Monday, May 23, 2011

Perl script to "copy" a Rackspace image


I recently found myself putting together a perl script that performs an rsync of one server in the Rackspace cloud to another server on another account.  Why?  See my next post about restrictions found with Rackspace for details.  Long story short, I needed a way to copy an image from one account to another and Rackspace recommends using rsync.

Since I will be needing to do this at least once a month for my testing, I thought it best to write up a script using perl that does the rsync.  The first thing was to figure out to successfully do this rsync before putting things together in a script.  I followed the instructions and when I rebooted my new server with the files rsync'ed over, it failed to accept ssh anymore.  On a whim, I decided to go through the basic exclude file from the instructions to see if those directories/files really lived where the file says they would be.

Ah-ha! That was my problem. I'm not sure which distro was used to create the exclude file, but some of the recommended files/directories to be excluded were located in different places.

#Suggested by Rackspace
/etc/hostname

#Actual location on Fedora 14 64bit
/bin/hostname
 

#Suggested by Rackspace
/etc/modules 

#Actual location on Fedora 14 64bit
/etc/sysconfig/modules

What I did was to do a
find / -name [sometext]

Where [sometext] I started from the end of the path and worked my way in to find the location. It was definitely trial and error.  Here is what I have for my final exclude file for Fedora 14 64bit:

#exclude.txt
/proc
/sys
/tmp
/dev
/root/copyImage.log
/root/.ssh/
/var/lock
/etc/fstab
/etc/mtab
/etc/resolv.conf
/usr/share/dbus-1/interfaces
/etc/networks
/etc/sysconfig/network
/etc/sysconfig/network-scripts
/etc/sysconfig/iptables-config
/lib/modules/
/usr/share/selinux/devel/include/kernel
/bin/hostname
/usr/lib64/gettext/hostname
/etc/hosts
/etc/modprobe*
/etc/selinux/targeted/modules
/etc/selinux/targeted/modules/active/modules
/etc/sysconfig/modules
/etc/selinux/targeted/modules
/etc/selinux/targeted/modules/active/modules
/usr/lib64/gio/modules
/lib/udev/devices/net
/usr/lib/ruby/1.8/net
/etc/init/

So now that I have a working exclude file, the next thing was to code up a perl script to do this for me.  In order to automate this through the script I needed to be able to:
  • Create an ssh tunnel from the "sending" server to the "receiving" server and be able to generate an ssh key on the "receiving" server.
  • Add the "sending" server's public key to the "receiving" server's authorized_key file
    • In my case, as part of the image, there is an authorized_key file with some public keys in it, which I want available to all servers generated with the image. I added the "sending" server's public key to this file and rsync'ed this file over first
  • Rsync the "sending" server data to the "receiving" server.
Two of the largest hurdle is:
  1. Open the ssh connection to the "receiving" server
  2. Know when the "receiving" server is asking for the password and supply it
There are many many perl modules available that do just this in various ways. I didn't think to make a note of which ones I did try, unfortunately.  I ended up using Net::OpenSSH, it had the best documentation and did everything I needed.  I used this in conjunction with Expect module.


Neither of these modules are included with the Perl package that comes with Fedora 14 64 bit.  In trying to add these modules, I learned some more things.  For example, I learned about CPAN.  This is a little piece of software that manages installation and dependency resolution for perl modules.  Other benefits I found are:
  • Access to modules that may not be in repos yet, i.e. Net::OpenSSH is not available via yum on Fedora 14
  • Type in instmodsh at the command prompt and you can see a list of all installed modules and get information about the modules
    • If module is installed via yum install , will not show up in instmodsh
Here is a link describing how to install CPAN.  After you follow the instructions at that link, you should also check to see if gcc is installed.  In my case, it was also not included in my default Fedora 14 64bit installation.  It is needed to compile some of the modules (in this case, one of the dependencies for Expect).  The error I got before I installed it is:

ERROR: cannot run the configured compiler 'gcc'
(see conf/compilerok.log). Suggestions:
1) The complier 'gcc' is not in your PATH. Add it
   to the PATH and try again. OR
2) The compiler isn't installed on your system. Install it. OR
3) You only have a different compiler installed (e.g. 'gcc').
   Either fix the compiler config in the perl Config.pm
   or install a perl that was built with the right compiler
   (you could build perl yourself with the available compiler).

Note: this is a system-administration issue, please ask your local
admin for help. Thank you.

Warning: No success on command[/usr/bin/perl Makefile.PL]
'YAML' not installed, will not store persistent state
  TODDR/IO-Tty-1.10.tar.gz
  /usr/bin/perl Makefile.PL -- NOT OK
Could not read metadata file. Falling back to other methods to determine prerequisites
Failed during this command:
 TODDR/IO-Tty-1.10.tar.gz                     : writemakefile NO '/usr/bin/perl Makefile.PL' returned status 6400

Finally, my last hurdle for putting together this script is trouble using the open function in perl.

#DOES *NOT* WORK
open(CAT_FILE,"cat /root/test.txt >>/root/test2.txt ") || die "Failed: $!\n";

#DOES WORK
open(CAT_FILE,"| cat /root/test.txt >>/root/test2.txt ") || die "Failed: $!\n";

Can you spot the difference?
So apparently that "|" is required ... oops...guess the documentation was right. *insert chagrin here*.

So without further ado, please find below my full script to do the rsync step of copying an image to another server on Rackspace:


#!/usr/bin/perl

use strict;
use Expect;
use Net::OpenSSH;
use POSIX qw(strftime);

# ---- start of main ---- #
# Input to script should be ipAddress pwd ipAddress pwd
# take arguments and put into a hash, {key=ipAddress, value=pwd}
my %args = @ARGV;
my @bad_args;
my $log_fname = "/root/copyImage.log";
my $user = "root";

open(LOG_FILE,">>".$log_fname);

# Do a simple check to make sure that the key of hash is
# IP-like (just checking it is ###.###.###.###) and the
# password is only alphanumeric
foreach my $key (keys(%args)) {
  if (not $key =~ m/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/) {
    print LOG_FILE "[".get_timestamp()."]\t";
    print LOG_FILE "Invalid IP address: ".$key."\n";
    push(@bad_args,"Invalid IP, ".$key);
  } 
  if (not $args{$key} =~ m/^[a-z0-9.-]+$/i) {
    print LOG_FILE "[".get_timestamp()."]\t";
    print LOG_FILE "Invalid password: ".$args{$key}."\n";
    push(@bad_args,"Invalid password, ".$args{$key});
  } 
}

if (scalar(@bad_args) > 0) {
  # at least one of the arguments is bad, safer to stop now
  # close log file and exit
  close(LOG_FILE);
  exit(1);
}

# Comment out below to disable debugging
$Net::OpenSSH::debug |= 16;

# add local host pub key to authorized_keys file, so local host pub key is
# transferred when transferring authorized_keys file to remote host
my $cat_out = `cat /root/.ssh/id_rsa.pub >>/root/.ssh/authorized_keys`;
print "Concatenate local pubId to authorized keys file. [".$cat_out."]\n";

# now loop through the hash and do the work
foreach my $key (keys(%args)) {
  # reassign key & value to make rest of code more readable
  my $host = $key;
  my $pwd = $args{$key};
  my $opts = "StrictHostKeyChecking=no";
  
  # set up the ssh connection to receiving host to do
  # ssh key generation
  my $ssh = Net::OpenSSH->new($host,user=>$user,password=>$pwd,
                               master_opts=>[-o=>$opts]);
  $ssh->error and die "Couldn't establish connection: ".$ssh->error;

  # send the ssh key generation down the pipe
  my ($rout,$pid) = $ssh->pipe_out("ssh-keygen -t rsa -f /root/.ssh/id_rsa -P \"\"") or
    die "Pipe out failed: ".$ssh->eror; 
  wait_pid_exit($rout, $pid, "Key generation");

  # use rsync_put method to send rsync command via ssh 
  # send over the authorized keys file
  my ($rout, $pid) = $ssh->rsync_put({stdout_fh => *STDOUT,   
                   quiet=>0,
                   progress=>1,             
                   archive=>1,
                   compress=>1,
                   partial=>1,
                   one_file_system=>1,
                   delete_after=>1}, 
                   "/root/.ssh/authorized_keys","/root/.ssh/authorized_keys");
  wait_pid_exit($rout, $pid, "Rsync: authorized_keys");

  # now rsync the rest of the files needed
  my ($rout, $pid) = $ssh->rsync_put({stdout_fh => *STDOUT,   
                   quiet=>0,
                   progress=>1,             
                   archive=>1,
                   compress=>1,
                   partial=>1,
                   one_file_system=>1,
                   delete_after=>1,
                   exclude_from=>"/root/rsyncExclude.txt"},
                   "/","/");
  wait_pid_exit($rout, $pid, "Rsync: rest of files");
  
  
  # send command to delete the last line of the authorized_keys file
  # down the pipe
  my ($rout,$pid) = $ssh->pipe_out("sed '\$d' < /root/.ssh/authorized_keys > /root/.ssh/temp && mv -f /root/.ssh/temp /root/.ssh/authorized_keys") or die "Pipe out failed: ".$ssh->eror; 
  wait_pid_exit($rout, $pid, "Editing authorized_keys file");
  
  my ($rout, $pid) = $ssh->pipe_out("exit") or
    die "Pipe out failed: ".$ssh->error;
  wait_pid_exit($rout, $pid, "Exiting ssh");
  print "Exiting ssh.\n";
}
# Clean local authorized_key file by removing self from it
my $cat_out = `sed '\$d' < /root/.ssh/authorized_keys > /root/.ssh/temp && mv -f /root/.ssh/temp /root/.ssh/authorized_keys`;

print $cat_out."\nC'est fin!\n";
close(LOG_FILE);
exit(0);
# ---- end of main ----#

# ----sub routines start here ----#
# This method uses Expect to spawn a command line process on the local/this host/server
# that communicates to a remote/receiving host/server (i.e. rsync, ssh, etc.).
# The command will result result in a prompt on the local host for the remote host's password.
# and the method will wait for the prompt and provide the given password.
#
# Takes 2 parameters:
# 1 - The command string to be executed on the remote/receiving host/server
# 2 - The password to use
sub spawn_expect {
  my ($command, $pwd, $ssh) = @_;
  my @cmd = $command.split(/ /);
  print "[".@cmd."]";
  
  #my $exp = new Expect;
  my ($pty, $pid) = ssh->open2pty(@cmd);
  my $exp = Expect->init($pty);

  #$exp->spawn($command);
  $exp->expect(10,[ qr/password:\s*\z/ => sub { my $exp = shift;
                                              $exp->send($pwd."\n"); } ] );
  $exp->soft_close();
  
  # wait for the process to complete
  print "Waiting for pid [".$pid."] to exit...";
  waitpid($pid, 0);
  print "Spawning done.";
}

# Returns a local formatted timestamp string
sub get_timestamp {
  return strftime "%a %b %e %Y %H:%M:%S", gmtime;
}

# waits for the given process id to exit
# uses given string to display helpful print out
sub wait_pid_exit {
  my ($rout, $pid, $str_out) = @_;
  while (<$rout>) { print } close $rout;  
  print "\nWaiting for pid [".$pid."] to exit...\n";
  waitpid($pid, 0);
  print $str_out." done.\n";
}
# ---- end of sub routines ---- #

No comments:

Post a Comment