#!/usr/local/bin/php . */ define(CONFIG_FILE, "/usr/local/etc/backup.conf"); // This is the name of the zfs filesystem to snapshot // This should move to the config file define(BACKUP_ZFS_NAME, "tank/backup"); // This is the mountpoint for the above zfs dataset // This should move to the config file define(BACKUP_DIR, "/backup/"); define(VERSION, '1.0'); $config = array(); $thread_id_list = array(); $debug = FALSE; $compression = FALSE; $opt_debug = $opt_thread = $opt_host = $opt_path = NULL; function display_status($step) { $dots = ".............................................................................."; echo substr($step . $dots, 0, 65) . " "; } function display_result($resultcode, $resulttext = NULL)/*{{{*/ { if ($resultcode) { echo ($resulttext === NULL ? "done" : $resulttext) . "\n"; } else { echo ($resulttext === NULL ? "failed" : $resulttext) . "\n"; } } function status_line($line) { echo date("H:i:s") . " $line\n"; } // Remove any double slashes from the path function normalize_path($path) { while (preg_match('%//%', $path)) { $path = preg_replace('%//%', '/', $path); } return($path); } // Read in the configuration file and populate the global $config array function read_config($config_file) { global $config, $thread_id_list; if (! file_exists($config_file)) { echo "error: config file not found at $config_file\n"; echo "edit this script and set the location of CONFIG_FILE at the top\n"; exit(1); } // normalize the path and remove any trailing slashes $config['backup-dir'] = preg_replace('%/$%', '', normalize_path(BACKUP_DIR)); $cfglines = file($config_file); $inhostblock = FALSE; $linectr = 0; for ($ctr = 0; $ctr < count($cfglines); $ctr++) { $l = $cfglines[$ctr]; $linectr++; // skip blank lines and comments if (trim($l) == "") { continue; } if (preg_match('/^\s*#.*$/', $l)) { continue; } // make sure we're in the global context and this line isn't a // hostblock definition if (!$inhostblock && ! preg_match('/^\s*host\s+([^{]+){\s+$/', $l, $regs)) { // parse a global config option if (preg_match('/^\s*(\S+)\s+([^#]+)/', $l, $regs)) { $option = trim($regs[1]); $value = trim($regs[2]); switch ($option) { case 'default-protocol': $config['default-protocol'] = $value; break; case 'keep-daily': $config['keep-daily'] = $value; break; case 'keep-weekly': $config['keep-weekly'] = $value; break; case 'weekly-backup-day': $config['weekly-backup-day'] = $value; break; case 'show-start-line': $config['show-start-line'] = $value; break; case 'find-path': $config['find-path'] = $value; break; case 'rsync-path': $config['rsync-path'] = $value; break; case 'compression': if ($value != 'yes' && $value != 'no') { display_result(FALSE); echo "error: valid options for compression are 'yes' and 'no' (line $linectr)\n"; exit(1); } $config['compression'] = $value; $GLOBALS['compression'] = TRUE; break; case 'debug': $config['debug'] = 'yes'; $GLOBALS['debug'] = TRUE; break; case 'protocol-path': if (! preg_match('/(.+)=(.+)/', $value, $regs)) { display_result(FALSE); echo "error: bad protocol-path format: name=path (ie. rsh=/usr/bin/rsh) (line $linectr)\n"; exit(1); } $config['protocols'][$regs[1]]['path'] = trim($regs[2]); break; default: display_result(FALSE); echo "error: unknown global option '" . trim($regs[2]) . "' on line $linectr\n"; exit(1); break; } } else { display_result(FALSE); echo "error: expected host block start\n"; echo "error parsing line $linectr: $l"; exit(1); } } else if ($inhostblock && preg_match('/^\s*host\s+([^{]+){\s+$/', $l, $regs)) { // not able to have a host def inside of another host // definition. Probably forgot a closing brace. display_result(FALSE); echo "error: previous hostblock not ended at line $linectr: $l"; exit(1); } else if (preg_match('/^\s*host\s+([^{]+){\s+$/', $l, $regs)) { // start a new host block $inhostblock = TRUE; $hostname = trim($regs[1]); } else if (preg_match('/^\s*}\s+/', $l)) { // end a new host block $inhostblock = FALSE; $hostname = ''; } else { // we're inside of a hostblock, make sure the syntax is correct // and set host options if (preg_match('/^\s*(\S+)\s+([^#]+)/', $l, $regs)) { $option = trim($regs[1]); $value = trim($regs[2]); switch ($option) { case 'thread-id': if ($config['hosts'][$hostname]['thread-id'] != "") { display_result(FALSE); echo "error: attempting to reset thread-id to '$value' on line $linectr\n"; exit(0); } $config['hosts'][$hostname]['thread-id'] = $value; if (! array_key_exists($value, $thread_id_list)) { $thread_id_list[$value] = 0; } break; case 'protocol': if ($config['hosts'][$hostname]['protocol'] != "") { display_result(FALSE); echo "error: attempting to reset protocol to '$value' on line $linectr\n"; exit(0); } $config['hosts'][$hostname]['protocol'] = $value; break; case 'force-checksum': if ($config['hosts'][$hostname]['force-checksum'] != "") { display_result(FALSE); echo "error: attempting to specify force-checksum twice on line $linectr\n"; exit(0); } if ($value != "yes" && $value != "no") { display_result(FALSE); echo "error: invalid value for force-checksum on line $linectr\n"; exit(0); } $config['hosts'][$hostname]['force-checksum'] = $value; break; case 'path': if (is_array($config['hosts'][$hostname]['path']) && in_array($value, $config['hosts'][$hostname]['path'])) { display_result(FALSE); echo "error: path '$value' listed twice on line $linectr\n"; exit(0); } $config['hosts'][$hostname]['path'][] = $value; break; case 'exclude': if (is_array($config['hosts'][$hostname]['exclude']) && in_array($value, $config['hosts'][$hostname]['exclude'])) { display_result(FALSE); echo "error: exclude path '$value' listed twice on line $linectr\n"; exit(0); } $config['hosts'][$hostname]['exclude'][] = $value; break; case 'pre-script': $config['hosts'][$hostname]['pre-script'][] = $value; break; case 'post-script': $config['hosts'][$hostname]['post-script'][] = $value; break; default: display_result(FALSE); echo "error: unknown option on $linectr: $l"; exit(1); break; } } else { display_result(FALSE); echo "error: cannot parse line $linectr: $l"; exit(1); break; } } } } // this function goes through the config array and makes sure all of the // required global variables are set in the array, performs a sanity check // on the host configuration and globs any paths specified in path // configuration statements function check_config() { global $config; if (! isset($config['keep-daily'])) { echo "warning: keep-daily not defined in the configuration file\n"; echo "defaulting keep-daily to 7\n"; $config['keep-daily'] = 7; } if (! isset($config['keep-weekly'])) { echo "warning: keep-weekly not defined in the configuration file\n"; echo "defaulting keep-weekly to 42\n"; $config['keep-weekly'] = 42; } if (! isset($config['weekly-backup-day'])) { echo "warning: weekly-backup-day not defined in the configuration file\n"; echo "defaulting weekly-backup-day to Sun\n"; $config['weekly-backup-day'] = 'Sun'; } if (! isset($config['find-path'])) { echo "warning: find-path not defined in the configuration file\n"; echo "defaulting find-path to /usr/local/bin/find\n"; $config['find-path'] = '/usr/bin/find'; } if (! isset($config['rsync-path'])) { echo "warning: rsync-path not defined in the configuration file\n"; echo "defaulting rsync-path to /usr/local/bin/rsync\n"; $config['rsync-path'] = '/usr/local/bin/rsync'; } if (! isset($config['default-protocol'])) { display_result(FALSE); echo "error: default-protocol not defined in the configuration file\n"; exit(1); } foreach ($config['hosts'] as $hostname => $host) { if (! isset($host['protocol'])) { $config['hosts'][$hostname]['protocol'] = $config['default-protocol']; } if (! is_array($host['path'])) { display_result(FALSE); echo "error: $hostname has no backup paths defined\n"; exit(1); } if (! is_array($config['protocols'][$config['hosts'][$hostname]['protocol']])) { display_result(FALSE); echo "error: $hostname has unknown protocol '" . $host['protocol'] . "'\n"; echo "All protocols must be defined with the global protocol-path statement\n"; exit(1); } foreach ($host['path'] as $i => $path) { if ($path[strlen($path)-1] == '*') { $command = $config['protocols'][$config['hosts'][$hostname]['protocol']]['path'] . " " . $hostname . " '" . $config['find-path'] . " $path -type d -maxdepth 0'"; $output = array(); exec($command, $output, $ret); if ($ret != 0) { display_result(FALSE); echo "error: could not glob path '$path' for host $hostname\n"; exit(1); } $path[strlen($path)-1] = ''; foreach ($output as $subdir) { $config['hosts'][$hostname]['path'][] = $subdir; } unset($config['hosts'][$hostname]['path'][$i]); } } } } // This function actually performs the backup for a specific thread. This // is called by a child after is forked off to handle the thread. function run_thread($id) { global $config, $debug, $opt_host, $opt_path; foreach ($config['hosts'] as $hostname => $host) { if ($host['thread-id'] != $id) { // skip hosts not in the specified thread continue; } if ($opt_host && $opt_host != $hostname) { continue; } if ($host['force-checksum'] == 'yes') { $checksum = TRUE; } else { $checksum = FALSE; } // loop through each path, build an rsync command, and execute foreach ($host['path'] as $path) { if (preg_match('/^(.*)=(.*)$/', $path, $regs)) { $remotepath = $regs[1]; $localpath = $regs[2]; } else { $remotepath = $path; $localpath = $path; } if ($opt_path && $opt_path != $remotepath) { continue; } if ( $config['show-start-line'] == 'yes' || $config['show-start-line'] == 'y' || $config['show-start-line'] == 'on') { status_line("[$id] Starting backup of $hostname:$remotepath"); } $rsync_path = $config['rsync-path']; $protocol_path = $config['protocols'][$host['protocol']]['path']; $excludeparams=''; // create a --exclude param for each excluded path if (is_array($host['exclude'])) { foreach ($host['exclude'] as $ex) { $excludeparams="$excludeparams --exclude=$ex"; } } // if the path we're backing up to does exist, create it // locally (and recursively) if (! file_exists("/" . $config['backup-dir'] . "/$hostname/$localpath")) { mkdir("/" . $config['backup-dir'] . "/$hostname/$localpath", 0755, TRUE); } $starttime = time(); // if there's a pre-script for the host, run it if (is_array($host['pre-script'])) { $scriptfailed = FALSE; status_line("[$id] Executing pre-scripts on $hostname"); foreach ($host['pre-script'] as $scriptname) { $command = $config['protocols'][$config['hosts'][$hostname]['protocol']]['path'] . " " . $hostname . " '$scriptname'"; $output = array(); if ($debug) { echo $command . "\n"; } else { exec("truss -f -o /tmp/e " . $command, $output, $ret); } if ($ret != 0) { echo "error: pre-script failed execution -- skipping backup\n"; echo "---------[ Error Output ]--------------------------------------\n"; echo implode("\n", $output) . "\n\n"; $scriptfailed = TRUE; break; } } if ($scriptfailed) { continue; } } // put our actual rsync command together $command = "$rsync_path -av " . ($checksum ? " -c " : "") . " " . ($config['compression'] ? " -z " : "") . " --rsync-path=$rsync_path --rsh=$protocol_path --exclude=/lost+found $excludeparams --delete $hostname:$remotepath/ /" . $config['backup-dir'] . "/$hostname/$localpath/ 2>&1"; if ($debug) { echo $command . "\n"; } else { $output=''; exec($command, $output, $ret); $endtime = time(); $runtime = sprintf("%2.2lf min", ($endtime - $starttime) / 60); $speedline = $output[count($output)-2]; $sizeline = $output[count($output)-1]; // when the command finishes, parse the output of rsync and // build a string for the status line if (preg_match('/^.*sent\s+(\d+)\s+bytes\s+received\s+(\d+)\s+bytes\s+(\d+).*$/', $speedline, $regs)) { $xmitbytes = $regs[2]; if ($xmitbytes > 1024*1024*1024) { $xmitbytes = sprintf("%2.2lfGB", ($xmitbytes/1024/1024/1024)); } else if ($xmitbytes > 1024*1024) { $xmitbytes = sprintf("%2.2lfMB", ($xmitbytes/1024/1024)); } else if ($xmitbytes > 1024) { $xmitbytes = sprintf("%2.2lfKB", ($xmitbytes/1024)); } else { $xmitbytes = sprintf("%dB", ($xmitbytes)); } $speed = sprintf("%2.2lfKbps", $regs[3] / 1024 * 8); } else { $speed = "unknown"; $xmitbytes = "unknown"; } if (preg_match('/^.*total size is (\d+)\s+speedup.*$/', $sizeline, $regs)) { $sizebytes = $regs[1]; if ($sizebytes > 1024*1024*1024) { $size = sprintf("%2.2lfGB", ($sizebytes/1024/1024/1024)); } else if ($sizebytes > 1024*1024) { $size = sprintf("%2.2lfMB", ($sizebytes/1024/1024)); } else if ($sizebytes > 1024) { $size = sprintf("%2.2lfKB", ($sizebytes/1024)); } else { $size = $sizebytes; } } else { $size = "unknown"; } if ($ret != 0) { // if the rsync failed, just dump all of the output status_line("[$id] $hostname:$remotepath failed:"); echo "---------[ Error Output ]--------------------------------------\n"; echo implode("\n", $output) . "\n\n"; } else { // touch all of the paths down the line so we can see // when something was last backed up in ls output $e = 0; $rpath = $config['backup-dir'] . "/$hostname/$localpath/"; do { touch($rpath); } while (($rpath = dirname($rpath)) != $config['backup-dir']); status_line("[$id] $hostname:$remotepath: $size, xfer: $xmitbytes, $speed, $runtime"); // run any post backup scripts on the remote host if (is_array($host['post-script'])) { status_line("[$id] Executing post-scripts on $hostname"); foreach ($host['post-script'] as $scriptname) { $command = $config['protocols'][$config['hosts'][$hostname]['protocol']]['path'] . " " . $hostname . " '$scriptname'"; $output = array(); if ($debug) { echo $command . "\n"; } else { exec($command, $output, $ret); } if ($ret != 0) { echo "error: post-script failed execution\n"; echo "---------[ Error Output ]--------------------------------------\n"; echo implode("\n", $output) . "\n\n"; } } } } } } } } // remove zfs snapshots older than the specified limits function zfs_cleanup() { global $config; $bnpath = basename(BACKUP_DIR); exec('/usr/sbin/zfs list | grep ' . $bnpath . '@ | awk \'{ print $1 }\'', $snaplist, $ret); if (! is_array($snaplist)) { echo "warning: no snapshots exist\n\n"; return(0); } foreach ($snaplist as $snap) { if (!preg_match('/^.*@(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})$/', $snap, $regs)) { echo "warning: Ignoring unknown snapshot format on $snap\n"; } $snaptime = mktime($regs[4], $regs[5], $regs[6], $regs[2], $regs[3], $regs[1]); $snapage = time() - $snaptime; $snapday = date("D", $snaptime); if ($snapday == $config['weekly-backup-day']) { if ($snapage >= $config['keep-weekly'] * 86400) { status_line("$snap: removing - older than " . $config['keep-weekly'] . ' days'); `/usr/sbin/zfs destroy $snap`; } else { status_line("$snap: keeping - newer than " . $config['keep-weekly'] . ' days'); } } else { if ($snapage >= $config['keep-daily'] * 86400) { status_line("$snap: removing - older than " . $config['keep-daily'] . ' days'); `/usr/sbin/zfs destroy $snap`; } else { status_line("$snap: keeping - newer than " . $config['keep-daily'] . ' days'); } } } } // create a zfs snapshot with a timestamp function zfs_snapshot() { global $config, $debug; $snapname = BACKUP_ZFS_NAME . '@' . date("Ymd-His"); $output = array(); $command = '/usr/sbin/zfs snapshot ' . $snapname . " 2>&1"; if ($debug) { echo $command . "\n"; } else { exec($command, $output, $ret); } if ($ret != 0) { echo "error: couldn't snapshot $snapname:\n"; echo implode("\n", $output) . "\n\n"; } status_line("Created snapshot: $snapname"); } // display the zfs filesystems and snapshots function zfs_info() { global $config; $output = array(); exec('/usr/sbin/zfs list -r ' . BACKUP_ZFS_NAME, $output, $ret); echo implode("\n", $output); echo "\n"; } // parse command line options function parse_options() { global $config, $debug, $opt_thread, $opt_host, $opt_path; $longopts = array( 'thread:', 'host:', 'path:', 'debug', 'help', 'version', ); if (version_compare(phpversion(), "5.3.0") >= 0) { $optlist = getopt("dvt:h:p:", $longopts); } else { $optlist = getopt("dvt:h:p:"); } foreach ($optlist as $optind => $optarg) { switch ($optind) { case 'debug': case 'd': $config['debug'] = TRUE; $debug = TRUE; break; case 'thread': case 't': $opt_thread = $optarg; break; case 'host': case 'h': $opt_host = $optarg; break; case 'version': case 'v': case 'help': echo "ypass-backup v" . VERSION . " Copyright (C) 2009 Eric Kilfoil \n" . "\n" . "This program comes with ABSOLUTELY NO WARRANTY; This is free software, and you\n" . "are welcome to redistribute it under certain conditions.\n" . "For more information, visit http://www.ypass.net/solaris/zfsbackup/license.html\n" . "\n" . "Valid options:\n" . "-t, --thread Specify a thread id to back up\n" . "-h, --host Specify a hostname from the config file\n" . "-p, --path Specify a pathname from the config file. If you\n" . " specify a pathname, you must specify a host as well.\n" . "-d, --debug Enable debugging\n"; exit(0); case 'path': case 'p': $opt_path = $optarg; break; default: echo "error: unknown option '$optind'\n"; exit(1); break; } } if (strlen($opt_host) && strlen($opt_thread)) { echo "error: you cannot specify a host and a thread at the same time\n"; exit(1); } if (strlen($opt_path) && ! strlen($opt_host)) { echo "error: you cannot specify a path without selecting a host\n"; exit(1); } } // main code execution start parse_options(); echo "---------[ Configuration File Processing ]---------------------------\n"; display_status("Reading configuration file"); read_config(CONFIG_FILE); display_result(TRUE); display_status("Validating configuration"); check_config(); display_result(TRUE); echo "\n---------[ Filesystem Cleanup ]------------------------------------\n"; zfs_cleanup(); echo "\n---------[ Initializing Threads ]------------------------------------\n"; display_status("Checking number of threads"); if ($opt_thread) { $thread_id_list = array($opt_thread => 0); } if ($opt_host) { $thread_id_list = array($config['hosts'][$opt_host]['thread-id'] => 0); } display_result(TRUE, count($thread_id_list)); display_status("Checking for pcntl extension"); if (! extension_loaded('pcntl')) { if (! dl('pcntl.so')) { display_result(FALSE); echo "error: could not load pcntl.so"; exit(1); } display_result(TRUE, 'loaded'); } else{ display_result(TRUE, 'done'); } echo "\n---------[ Starting Backups ]------------------------------------\n"; // fork a child for every thread-id and start run running foreach ($thread_id_list as $id => $pid) { $pid = pcntl_fork(); if ($pid == -1 ) { echo "error: could not fork -- aborting\n"; exit(1); } else if ($pid) { // parent (master) process $thread_id_list[$id] = $pid; // sleep for a second or the children clutter up the output continue; } else { // child display_status("Starting thread-id $id"); display_result(TRUE, "pid " . posix_getpid()); run_thread($id); exit(0); } } // master process comes here after creating children. children never get // to this code. // wait on each child and print a status line when each child finishes for ($ctr = count($thread_id_list); $ctr > 0; $ctr--) { $pid = pcntl_waitpid(0, $status); $id = array_search($pid, $thread_id_list); status_line(" Thread [$id] completed - " . ($ctr-1) . " still running"); } echo "\n---------[ Creating Snapshot ]------------------------------------\n"; // only create a snapshot if we did a full backup if (! $opt_host && ! $opt_path && ! $opt_thread) { zfs_snapshot(); } else { status_line("not creating snapshot for partial backup"); } echo "\n---------[ Filesystem Info ]------------------------------------\n"; zfs_info(); ?>