#!/usr/bin/perl -w # =head1 NAME cb - CasparBuster looks at the structure in your CMS environment and deploy it to the target systems as needed =head1 SYNOPSIS cb [options] Options: --debug |-d debug mode --help |-h brief help message --man full documentation --force |-f force copy of files, even if they exist --source |-s directory or files to copy from the CasparBuster tree (',' separated if many) to the target --plugin |-p plugin defining what to copy from the CasparBuster tree (',' separated if many) to the target --machine|-m machine to deploy on. =head1 OPTIONS =over 4 =item B<--debug> Enter debug mode. This will print what would be done. No commands are executed, so this is safe to use when testing. =item B<--help> Print a brief help message and exits. =item B<--man> Prints the manual page and exits. =item B<--machine> I Specify the machine to consider when dealing with the CasparBuster structure. The files will be pushed to this machine, and a subdirectory named after the machine will be used under the basedir to look at the directory structure to deploy When no machine is given, all machnes available are processed =item B<--source> I Specify the path of the source file or directory to deploy with CasparBuster. Multiple paths can be specified separated by ','. =item B<--plugin> I Specify the name of the plugin to deploy with CasparBuster. Multiple plugins can be specified separated by ','. A plugin defines a set of files (with their mode and owner), a set of directories (with their mode and owner) and a set of scripts to launch once the files are copied remotely. =back =head1 DESCRIPTION Deploy the standard CasparBuster structure created by I. It will reinstall all files and directory in the plugin, with correct owner, group and mode, and launch at the end the script to re-enable potentially the service using the updated files. =head1 EXAMPLES # this will deploy the appropriate CasparBuster environment for DHCP # from the base ~/prj/musique-ancienne.org directory (Cf cbbasedir in cb.conf) # containing the directory victoria2 for this machine # to which it will copy the required files cb -m victoria2 -p dhcpd =head1 AUTHOR =over 4 Bruno Cornec, http://brunocornec.wordpress.com =back =head1 LICENSE Copyright (C) 2012 Bruno Cornec Released under the GPLv2 or the Artistic license at your will. =cut use strict; use CasparBuster::Version; use CasparBuster::Env; use CasparBuster::Plugin; use CasparBuster::SSH; #use Cwd 'realpath'; use Carp qw/confess cluck/; use File::Find; use Archive::Tar; use Getopt::Long; use Pod::Usage; use Data::Dumper; use Time::Local; use Net::SSH2; use ProjectBuilder::Base; use ProjectBuilder::Conf; use ProjectBuilder::VCS; use DBI; use DBD::SQLite; # settings my $debug = 0; my $help = undef; my $man = undef; my $source = undef; my $machine = undef; my $plugin = undef; my $quiet = undef; my $force = undef; my $log = undef; my $LOG = undef; my $findtarget = undef; my ($cbver,$cbrev) = cb_version_init(); my $appname = "cb"; $ENV{'PBPROJ'} = $appname; # Initialize the syntax string pb_syntax_init("$appname (aka CasparBuster) Version $cbver-$cbrev\n"); # parse command-line options GetOptions( 'machine|m=s' => \$machine, 'debug|d+' => \$debug, 'help|h' => \$help, 'quiet|q' => \$quiet, 'force|f' => \$force, 'man' => \$man, 'logfile|l=s' => \$log, 'source|s=s' => \$source, 'plugin|p=s' => \$plugin, ) || pb_syntax(-1,0); if (defined $help) { pb_syntax(0,1); } if (defined $man) { pb_syntax(0,2); } if (defined $quiet) { $debug=-1; } if (defined $log) { open(LOG,"> $log") || die "Unable to log to $log: $!"; $LOG = \*LOG; $debug = 0 if ($debug == -1); } pb_log_init($debug, $LOG); pb_temp_init(); pb_log(1,"Starting cb\n"); # Get conf file in context pb_conf_init($appname); # The personal one if there is such pb_conf_add("$ENV{'HOME'}/.cbrc") if (-f "$ENV{'HOME'}/.cbrc"); # The system one pb_conf_add(cb_env_conffile()); # Get configuration parameters my %cb; my $cbp = (); my $cb = \%cb; ($cb->{'basedir'},$cb->{'cms'},$cb->{'database'}) = pb_conf_get("cbbasedir","cbcms","cbdatabase"); pb_log(2,"%cb: ",Dumper($cb)); my $basedir = $cb->{'basedir'}->{$appname}; eval { $basedir =~ s/(\$ENV.+\})/$1/eeg }; # Create basedir if it doesn't exist die "Unable to find base directory at $basedir" if (not -d $basedir); pb_log(1, "DEBUG MODE, not doing anything, just printing\nDEBUG: basedir = $basedir\n"); # Create database if not existing and give a handler my $db = "$basedir/$cb->{'database'}->{$appname}"; my $precmd = ""; if (! -f $db) { $precmd = "CREATE TABLE dates (id INTEGER PRIMARY KEY AUTOINCREMENT, date INTEGER, file VARCHAR[65535], machine VARCHAR[65535], mode VARCHAR[4], uid VARCHAR[5], gid VARCHAR[5])"; } my $dbh = DBI->connect("dbi:SQLite:dbname=$db","","", { RaiseError => 1, AutoCommit => 1 }) || die "Unable to connect to $db"; my $sth; if ($precmd ne "") { $sth = $dbh->prepare(qq{$precmd}) || die "Unable to create table into $db"; if ($debug) { pb_log(1,"DEBUG: Creating DB $db\n"); pb_log(1,"DEBUG: with command $precmd\n"); } else { $sth->execute(); } $sth->finish(); } # Define destination dir and populate with a VCS export my $dest = "$ENV{'PBTMP'}/vcs.$$"; my $scheme = $cb->{'cms'}->{$appname}; # Avoids too many permission changes umask(0022); pb_vcs_export(pb_vcs_get_uri($scheme,$basedir),$basedir,$dest); # Load all plugins plus the additional manually defined cb_plugin_load(); if (defined $plugin) { pb_conf_add("$basedir/plugins/$plugin") if (-f "$basedir/plugins/$plugin"); } # Now distribute to the right machines if (defined $machine) { cb_distribute($machine); } else { # Distribute to all # First dir level is the machine, then the content opendir(DIR,$dest) || die "Unable to open $dest: $!"; foreach my $m (readdir(DIR)) { next if ($m =~ /^\./); next if (! -d $m); # Machine name cb_distribute($m); closedir(DIR); } } # Cleanup $dbh->disconnect; pb_exit(); # End of Main # Distribute files to target machines sub cb_distribute { my $machine = shift; pb_log(2,"Entering into cb_distribute with machine $machine\n"); confess "No machine given to cb_distribute" if (not defined $machine); # Use potentially a remote account if defined my $remote = undef; my ($account) = pb_conf_get_if("cbaccount"); $remote = $account->{$machine} if ((defined $account) && (defined $account->{$machine})); pb_log(1, "DEBUG: remote account1 = $remote\n") if (defined $remote); $remote = getpwuid($<) if (not defined $remote); pb_log(1, "DEBUG: remote account2 = $remote\n"); # Now handle plugins if any if (defined $plugin) { foreach my $p (split(/,/,$plugin)) { pb_log(1,"Getting context for plugin $p\n"); $cbp = cb_plugin_get($p,$cbp); # Adds mtime info to the plugin structure foreach my $type ('files','dirs','dirsandfiles') { foreach my $f (keys %{$cbp->{$p}->{$type}}) { my $tdir = "$dest/$machine"; if (-r "$tdir/$f") { my ($dev,$ino,$mode,$nlink,$uid,$gid,$rdev,$size,$atime,$mtime,$ctime,$blksize,$blocks) = stat("$tdir/$f") || die "Unable to stat $tdir: $!"; $cbp->{$p}->{$type}->{$f}->{'mtime'} = $mtime; } else { pb_log(0,"WARNING: Unable to read $tdir/$f from plugin $p\n"); } } } } } # Handle this source if (defined $source) { my $fullsource = "$source"; $fullsource = "$machine/$source"; pb_log(2,"fullsource is $fullsource\n"); my $type = 'files'; if (-d $fullsource) { $type = 'dirsandfiles'; } die "ERROR: Only able to handle files or dirs with option --source\n" if ((! -d $fullsource) && (! -f $fullsource)); cb_fill_cbp("cb.source","$dest/$fullsource",$type,$source) } ($cb->{'commondir'},$cb->{'websrv'},$cb->{'webdir'}) = pb_conf_get_if("cbcommondir","cbwebsrv","cbwebdir"); if ((not defined $source) && (not defined $plugin)) { # Here we need to take all content under $dest considering that machine $findtarget = "$dest/$machine"; find(\&cb_add_to_cbp,($findtarget)); # And we also need all what is common, but not what is for the web side foreach my $c (keys $cb->{'commondir'}) { $findtarget = "$dest/$c"; opendir(DIR,"$findtarget") || die "Unable to open $dest/$c: $!"; foreach my $m (readdir(DIR)) { next if ($m =~ /^\./); next if ($m eq $cb->{'commondir'}->{$c}); find(\&cb_add_to_cbp,("$findtarget/$m")); } closedir(DIR); } } pb_log(1,"INFO: RAW cbp: ".Dumper(%$cbp)."\n"); # Clean up cbp structure by comparing with data stored in the DB # Only keep the more recent modified content # Allow for errors to occur at DBI level $dbh->{RaiseError} = 0; my $checkdb = 1; my $dbcmd = "SELECT id,date,file,machine FROM dates WHERE machine=\"$machine\""; if (! ($sth = $dbh->prepare(qq{$dbcmd}))) { pb_log(0,"Unable to prepare DB statement $dbcmd\n"); $checkdb = 0; } # DisAllow for errors to occur at DBI level $dbh->{RaiseError} = 1; my $dbid = (); if ($checkdb == 1) { $sth->execute(); # Check what in cbp is in the DB and deploy only if necessary or forced foreach my $k (keys %{$cbp}) { foreach my $type ('files','dirs','dirsandfiles') { foreach my $o (keys %{$cbp->{$k}->{$type}}) { # Compare with info from DB foreach my $row ($sth->fetch) { next if (not defined $row); my ($id, $date, $file, $mac1) = @$row; # If less recent than in the DB remove it $cbp->{$k}->{$type}->{$o}->{'deleted'} = "true" if ((defined $file) && ($file eq $o) && ($date > $cbp->{$k}->{$type}->{$o}->{'mtime'})); $dbid->{$o} = $id; } } } } $sth->finish(); } pb_log(2,"INFO: cleaned cbp: ".Dumper($cbp)."\n"); # Now create a tar containing all the relevant content # We need to loop separately to allow for DB to not exist in the previous loop ! my $tdir = undef; $tdir = "$dest/$machine"; chdir("$tdir") || die "ERROR: Unable to chdir to $tdir\n"; pb_log(2,"Working now under $tdir\n"); my $tar = Archive::Tar->new; my $curdate = time(); foreach my $k (keys %{$cbp}) { foreach my $type ('files','dirs','dirsandfiles') { # TODO: for dirs we may remove the files below ? foreach my $o ((keys %{$cbp->{$k}->{$type}})) { if ((defined $force) || (not defined $cbp->{$k}->{$type}->{$o}->{'deleted'})) { if ( -r "$tdir/$o" ) { pb_log(1,"INFO: Adding to the tar file $tdir/$o\n"); chdir($tdir); $tar->add_files("$o"); } else { # It's in the common place instead foreach my $c (keys $cb->{'commondir'}) { if (-r "$dest/$c/$o") { pb_log(1,"INFO: Adding to the tar file $dest/$c/$o\n"); chdir("$dest/$c"); $tar->add_files("$o"); } } } # Add an entry to the DB if (defined $dbid->{$o}) { # Modify an existing entry $dbcmd = "UPDATE dates SET date=\"$curdate\",file=\"$o\" WHERE id=\"$dbid->{$o}\""; if (not $debug) { $sth = $dbh->prepare(qq{$dbcmd}); $sth->execute(); } pb_log(0,"Executing in DB: $dbcmd with curdate=$curdate,file=$o,id=$dbid->{$o}\n"); } else { # Add an new entry $dbcmd = "INSERT INTO dates VALUES (NULL,?,?,\"$machine\")"; if (not $debug) { $sth = $dbh->prepare(qq{$dbcmd}); $sth->execute($curdate,$o); } pb_log(0,"Executing in DB: $dbcmd with curdate=$curdate,file=$o,machine=$machine\n"); } if (not $debug) { $sth->finish(); } } } } } my $tarfile = "$ENV{'PBTMP'}/cbcontent$$.tar"; $tar->write("$tarfile"); my $ssh2; my $chan; my $mach = $machine; if ((defined $cb->{'commondir'}) && (defined $cb->{'commondir'}->{$machine})) { confess "Please provide a cbwebsrv config parameter in order to use common delivery" if ((not defined $cb->{'websrv'}) && (not defined $cb->{'websrv'}->{$machine})); $mach = $cb->{'websrv'}->{$machine}; } $ssh2 = cb_ssh_init($remote,$mach,$debug); if (!($ssh2->scp_put($tarfile,$tarfile))) { my @error = $ssh2->error(); print "@error\n"; confess "Unable to copy tar file $tarfile to $mach\n"; } pb_log(0,"INFO: Copying content under $ENV{'PBTMP'} on $remote\@$mach\n"); my $path = "/"; my $tbextract = ""; if ((defined $cb->{'commondir'}) && (defined $cb->{'commondir'}->{$machine})) { $path = $cb->{'webdir'}->{$machine}; #$tbextract = $cb->{'commondir'}->{$machine}; } $chan = $ssh2->channel(); confess "Unable to launch remote shell through Net:SSH2 ($remote\@$mach)" if (not $chan->shell()); if (not $debug) { # Reminder: sudo should be configured for this account as Defaults !requiretty for this to work print $chan "sudo tar -C $path --no-overwrite-dir -x -f $tarfile $tbextract\n"; pb_log(0,"WARNING: $_\n") while (<$chan>); } else { print $chan "tar -C $path -t -f $tarfile $tbextract\n"; pb_log(2,"INFO: tar content: $_") while (<$chan>); } pb_log(0,"INFO: Extracting $tbextract (on $mach) $tarfile under $path\n"); foreach my $k (keys %{$cbp}) { foreach my $type ('files','dirs','dirsandfiles') { # TODO: do we act recursively for dirsandfiles at least for uid/gid ? foreach my $o ((keys %{$cbp->{$k}->{$type}})) { # Note that $path/$o is remote only if ((defined $force) || (not defined $cbp->{$k}->{$type}->{$o}->{'deleted'})) { if ($debug) { #pb_log(1,"INFO: Executing (on $mach) sudo chown $cbp->{$k}->{$type}->{$o}->{'uid'}:$cbp->{$k}->{$type}->{$o}->{'gid'} $path/$o\n"); #pb_log(1,"INFO: Executing (on $mach) sudo chmod $cbp->{$k}->{$type}->{$o}->{'mode'} $path/$o\n"); } else { # TODO: remove hardcoded commands #print $chan "sudo chown $cbp->{$k}->{$type}->{$o}->{'uid'}:$cbp->{$k}->{$type}->{$o}->{'gid'} $path/$o\n"; # TODO: get a correct mode before setting it up #print $chan "sudo chmod $cbp->{$k}->{$type}->{$o}->{'mode'} $path/$o\n"; } pb_log(0,"INFO: Delivering $path/$o on $mach\n"); } } } if (defined $cbp->{$k}->{'reloadscript'}) { if (not $debug) { print $chan "sudo $cbp->{$k}->{'reloadscript'}\n"; } pb_log(0,"INFO: Executing (on $mach) $cbp->{$k}->{'reloadscript'} as root\n"); } } pb_log(0,"INFO: Executing (on $mach) /usr/local/bin/mk$mach if present as root\n"); if (not $debug) { # Using Net::SSH2 here was not working (due to the shell ?) pb_system("ssh $remote\@$mach \"sudo /usr/local/bin/mk$mach\"","WAIT: Executing (on $mach) /usr/local/bin/mk$mach if present as root","verbose"); } # Remote cleanup if (not $debug) { print $chan "rm -rf $ENV{'PBTMP'}\n"; } else { pb_log(1,"INFO: Please remove remote directory $ENV{'PBTMP'} on $mach\n"); } $chan->close(); cb_ssh_close($ssh2); chdir("/"); pb_log(2,"Exiting cb_distribute\n"); } sub cb_add_to_cbp { pb_log(3,"Entering into cb_add_to_cbp\n"); my $type = 'files'; if (-d $File::Find::name) { $type = 'dirs'; } # Target name is without the $findtarget part my $targetname = $File::Find::name; $targetname =~ s|^$findtarget[/]*||; return if ($targetname eq ""); cb_fill_cbp("cb.full",$File::Find::name,$type,$targetname) } sub cb_fill_cbp { my $k = shift; my $f = shift; my $type = shift; my $targetname = shift; my ($dev,$ino,$mode,$nlink,$uid,$gid,$rdev,$size,$atime,$mtime,$ctime,$blksize,$blocks) = stat($f); ($dev,$ino,$mode,$nlink,$uid,$gid,$rdev,$size,$atime,$mtime,$ctime,$blksize,$blocks) = lstat($f) if (not defined $mode); die "Unable to stat $f" if (not defined $mode); # We should get uid/gid from elsewhere as they're probably wrong locally $cbp->{$k}->{$type}->{$targetname}->{'uid'} = $uid; $cbp->{$k}->{$type}->{$targetname}->{'gid'} = $gid; $cbp->{$k}->{$type}->{$targetname}->{'mode'} = sprintf("%04o",$mode & 07777); $cbp->{$k}->{$type}->{$targetname}->{'mtime'} = $mtime; pb_log(2,"Adding $f ($uid,$gid,$mode) to cbp\n"); }