#!/usr/bin/perl -w
#
#    Copyright (C) 2007 Proxmox Server Solutions GmbH
#
#    Copyright: vzdump is under GNU GPL, the GNU General Public License.
#
#    This program is free software; you can redistribute it and/or modify
#    it under the terms of the GNU General Public License as published by
#    the Free Software Foundation; version 2 dated June, 1991.
#
#    This program is distributed in the hope that it will be useful,
#    but WITHOUT ANY WARRANTY; without even the implied warranty of
#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#    GNU General Public License for more details.
#
#    You should have received a copy of the GNU General Public License
#    along with this program; if not, write to the Free Software
#    Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA
#    02111-1307, USA.
#
#

use strict;
use Getopt::Long;
use File::Path;
use IO::File;
use IO::Select;
use IPC::Open3;
use POSIX ":sys_wait_h";
use POSIX ':signal_h';

my $vzdir = '/etc/vz';

my $vpslist;

my $opt_all;
my $opt_debug = 1;
my $opt_dumpdir;
my $opt_compress = 0;
my $opt_restore;
my $opt_mailto;
my $opt_xdelta;
my $opt_stop;
my $opt_suspend;
my $opt_snap;
    
my $vzctl = 'vzctl';
my $rsync = 'rsync';
my $xdelta = 'xdelta';
my $lvcreate = 'lvcreate';
my $lvscan = 'lvscan';
my $lvremove = 'lvremove';

my @script_ext = qw (start stop mount umount);

sub run_command {
    my ($logfd, $cmdstr, $input, $timeout) = @_;

    my $reader = IO::File->new();
    my $writer = IO::File->new();
    my $error  = IO::File->new();

    my $pid = open3 ($writer, $reader, $error, ($cmdstr)) || die $!;

    print $writer $input if defined $input;
    close $writer;

    my $select = new IO::Select;
    $select->add ($reader);
    $select->add ($error);

    my ($ostream, $estream) = ('', '');

    while ($select->count) {
	my @handles = $select->can_read ($timeout);

	if (defined ($timeout) && (scalar (@handles) == 0)) {
	    die "command '$cmdstr' failed: timeout";
	}

	foreach my $h (@handles) {
	    my $buf = '';
	    my $count = sysread ($h, $buf, 4096);
	    if (!defined ($count)) {
		waitpid ($pid, 0);
		die "command '$cmdstr' failed: $!";
	    }
	    $select->remove ($h) if !$count;
	    if ($h eq $reader) {
		print STDOUT $buf;
		print $logfd $buf if $logfd;
		$ostream .= $buf;
	    } elsif ($h eq $error) {
		print STDERR $buf;
		print $logfd $buf if $logfd;
	    }
	}
    }

    waitpid ($pid, 0);
    my $ec = ($? >> 8);

    return $ostream if $ec == 24 && ($cmdstr =~ m/^$rsync/);

    return $ostream if $ec == 1 && ($cmdstr =~ m/^$xdelta/);

    die "command '$cmdstr' failed with exit code $ec" if $ec;

    return $ostream;
}

sub print_usage {
    my $msg = shift;

    print STDERR "ERROR: $msg\n\n" if $msg;

    print STDERR "usage: $0 OPTIONS [--all | VPSID]\n\n";
    print STDERR "\t--compress\t\tcompress dump file (gzip)\n";
    print STDERR "\t--dumpdir DIR\t\tstore resulting files in DIR\n";
    print STDERR "\t--xdelta\t\tcreate differential backup using xdelta\n";

    print STDERR "\t--mailto EMAIL\t\tsend notification mail to EMAIL\n";
    print STDERR "\t--stop\t\t\tstop/start VPS if running\n";
    print STDERR "\t--suspend\t\tsuspend/resume VPS when running\n";
    print STDERR "\t--snapshot\t\tuse LVM snapshot when running\n";
    print STDERR "\t--restore FILENAME\trestore FILENAME\n";
    print STDERR "\n";

}

my $devmapper;

sub get_device {
    my $dir = shift;

    open (TMP, "df '$dir'|");
    <TMP>; #skip first line
    my $out = <TMP>;
    close (TMP);

    my @res = split (/\s+/, $out);

    my $dev = $res[0];
    my $mp = $res[5];
    my ($vg, $lv);

    ($vg, $lv) = @{$devmapper->{$dev}} if defined $devmapper->{$dev};

    return wantarray ? ($dev, $mp, $vg, $lv) : $dev;
}

sub debugmsg {
    my ($msg, $logfd) = @_;

    print STDERR "$msg\n" if $opt_debug;
    print $logfd "$msg\n" if $logfd;
}

if (!GetOptions ('all' => \$opt_all,
		 'compress' => \$opt_compress,
		 'restore=s' => \$opt_restore,
		 'mailto=s' => \$opt_mailto,
		 'xdelta' => \$opt_xdelta,
		 'stop' =>\$opt_stop,
		 'suspend' =>\$opt_suspend,
		 'snapshot' =>\$opt_snap,
		 'dumpdir=s' => \$opt_dumpdir)) {
    print_usage ();
    exit (-1);
}

if ($opt_all && ($#ARGV >= 0 || $opt_restore)) {
    print_usage ();
    exit (-1);
} 


if (!$opt_all && $#ARGV != 0) {
    print_usage ();
    exit (-1);
}

if ($opt_restore && ! -f $opt_restore) {
    print_usage ("unable to access file '${opt_restore}'");
    exit (-1);
}

my $opt_vpsid;

if (!$opt_all) {
    $opt_vpsid = $ARGV[0];
    if ($opt_vpsid !~ m/^\d\d\d+$/) {
	print_usage ("strange VPS ID '${opt_vpsid}'");
	exit (-1); 
    }
}

sub check_bin {
    my ($bin, $msg)  = @_;

    my $v = $$bin;

    my $path = "/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin";

    foreach my $p (split (/:/, $path)) {
	my $fn = "$p/$v";
	if (-x $fn) {
	    $$bin = $fn;
	    return;
	}
    }

    die "unable to find '$v' - $msg\n";
}

check_bin (\$vzctl, "OpenVZ not installed?");
check_bin (\$rsync, "rsync not installed?") if $opt_suspend;
check_bin (\$xdelta, "xdelta not installed?") if $opt_xdelta;
check_bin (\$lvcreate, "lvm2 not installed?") if $opt_snap;
check_bin (\$lvscan, "lvm2 not installed?") if $opt_snap;
check_bin (\$lvremove, "lvm2 not installed?") if $opt_snap;

if ($opt_snap) {
    open (TMP, "$lvscan|") || die "unable to exec $lvscan\n";
    while (my $line = <TMP>) {
	if ($line =~ m|^\s+ACTIVE\s+\'/dev/([^/]+)/([^\']+)\'\s|) {
	    my $vg = $1;
	    my $lv = $2;
	    $devmapper->{"/dev/$vg/$lv"} = [$vg, $lv];
	    $devmapper->{"/dev/mapper/$vg-$lv"} = [$vg, $lv];
	}
    }
    close (TMP);
}


my $stopve;
$stopve = 'stop' if $opt_stop;
$stopve = 'suspend' if $opt_suspend;

my $vzconf = {
    vzdir =>  $vzdir,
    confdir => "$vzdir/conf",
    conffile => "$vzdir/vz.conf",
    rootdir => '/vz/root',
    privatedir => '/vz/private',
    dumpdir => $opt_dumpdir || '/vz/dump',
};

$SIG{INT} = $SIG{TERM} = $SIG{QUIT} = $SIG{HUP} = sub {
    die "interrupted by signal\n";
};

# read global vz.conf
sub read_global_config {
 
    local $/;
    
    my $filename = $vzconf->{conffile};

    die "unable to open file '$filename' - $!\n" if ! -f $filename;

    open (TMP, "<$filename");
    my $data = <TMP> || '';
    close (TMP);

    if ($data =~ m/VE_PRIVATE=\"?(.*)\"?/) {
	my $dir = $1;
	$dir =~ s|/\$VEID$||;
	$vzconf->{privatedir} = $dir;
    }
    if ($data =~ m/VE_ROOT=\"?(.*)\"?/) {
	my $dir = $1;
	$dir =~ s|/\$VEID$||;
	$vzconf->{rootdir} = $dir;
    }
    if ($data =~ m/DUMPDIR=\"?(.*)\"?/) {
	my $dir = $1;
	$dir =~ s|/\$VEID$||;
	$vzconf->{dumpdir} = $opt_dumpdir || $dir;
    }
}

read_global_config ();

my $cfgdir = $vzconf->{confdir};
foreach my $conf (<$cfgdir/*.conf>) {
    if ($conf =~ m|/(\d\d\d+)\.conf$|) {
	my $vpsid = $1;
	local $/;

	open (TMP, "<$conf");
	my $data = <TMP>;
	close (TMP);

	$vpslist->{$vpsid}->{conffile} = $conf;
	$vpslist->{$vpsid}->{conf} = $data;

	if ($data =~ m/VE_PRIVATE=\"(.*)\"/) {
	    my $dir = $1;
	    $dir =~ s/\$VEID/$vpsid/;
	    $vpslist->{$vpsid}->{dir} = $dir;
	}
	if ($data =~ m/VE_ROOT=\"(.*)\"/) {
	    my $dir = $1;
	    $dir =~ s/\$VEID/$vpsid/;
	    $vpslist->{$vpsid}->{root} = $dir;
	}
    }
}

if ($opt_restore) {

    my $conffile = "$vzconf->{confdir}/${opt_vpsid}.conf";

    if (-f $conffile) {
	print_usage ("unable to restore VPS '${opt_vpsid}' - VPS already exists");
	exit (-1);
    }

    my $private = "$vzconf->{privatedir}/${opt_vpsid}";
    my $root = "$vzconf->{rootdir}/${opt_vpsid}";

    if (-d $private) {
	print_usage ("unable to restore VPS '${opt_vpsid}' - directory '$private' already exists");
	exit (-1);
    }
    if (-d $root) {
	print_usage ("unable to restore VPS '${opt_vpsid}' - directory '$root' already exists");
	exit (-1);
    }

    eval {
	mkpath $private || die "unable to create private dir '$private'";
	mkpath $root || die "unable to create private dir '$private'";

	my $cmd = "zcat -f ${opt_restore}| tar xpf - --totals -C $private";

	debugmsg ("extracting archive '${opt_restore}'");

	system ($cmd) == 0 || die "unable to extract archive";

	debugmsg ("extracting configuration to '$conffile'");

	my $qroot = $root;
	$qroot =~ s|/|\\\/|g;
	$qroot =~ s|/${opt_vpsid}$|/\$VEID|;
	my $qprivate = $private;
	$qprivate =~ s|/|\\\/|g;
	$qprivate =~ s|/${opt_vpsid}$|/\$VEID|;

	my $scmd = "sed -e 's/VE_ROOT=.*/VE_ROOT=\\\"$qroot\\\"/' -e 's/VE_PRIVATE=.*/VE_PRIVATE=\\\"$qprivate\\\"/'  <'$private/etc/vzdump/vps.conf' >'$conffile'";

	run_command (undef, $scmd);

	foreach my $s (@script_ext) {
	    my $cfgdir = $vzconf->{confdir};
	    my $tfn = "$cfgdir/${opt_vpsid}.$s";
	    my $sfn = "$private/etc/vzdump/vps.$s";
	    if (-f $sfn) {
		run_command (undef, "cp '$sfn' '$tfn'");
	    }
	}

	rmtree "$private/etc/vzdump";

	debugmsg ("restore successful");

    };

    my $err = $@;

    if ($err) {
	system ("rm -rf '$private' '$root' '$conffile'");
	die $err;
    }

    exit (0);
}

if ($opt_vpsid && !exists ($vpslist->{$opt_vpsid})) {
    print_usage ("unable to find VPS '${opt_vpsid}'");
    exit (-1);
}

foreach my $vpsid (keys %$vpslist) {

    next if $opt_vpsid && $vpsid ne $opt_vpsid;

    my $backuptime = time ();

    my $targetdir = $vzconf->{dumpdir};

    my $private = $vpslist->{$vpsid}->{dir};
    my $root = $vpslist->{$vpsid}->{root};

    my $basename = "vzdump-${vpsid}";

    my $tarfile = "$targetdir/$basename". ($opt_compress ? '.tgz' : '.tar');
    my $logfile = "$targetdir/$basename.log";
    my $deltafile = "$targetdir/$basename.xdelta";

    my $oldtarfile;

    if ($opt_xdelta) {
	if (! -f $tarfile) {
	    $opt_xdelta = undef;
	} else {
	    $oldtarfile = $tarfile;
	    $tarfile = "$tarfile.$$";
	}
    }

    unlink $tarfile;
    unlink $logfile;

    unlink $deltafile if !$opt_xdelta;

    my $dir = $private;
    my $tmpdir = "$targetdir/tmp$$";

    open (LOG, ">$logfile");

    my $lt = localtime;

    my ($lvmpath, $lvmvg, $lvmlv);
    if ($opt_snap) {
	my ($dev, $mp, $vg, $lv) = get_device ($dir);
	$lvmpath = $mp;
	$lvmvg = $vg;
	$lvmlv = $lv;
    }

    my $snapdev;

    my $status = `$vzctl status $vpsid`;
    my $running = 0;
    $running = 1 if $status =~ m/running/;

    eval {

	debugmsg ("starting backup for VPS $vpsid ($dir) $lt", \*LOG);

	if ($running) {
	    if ($opt_snap) {

		my $targetdev = get_device ($targetdir);
		my $srcdev = get_device ($dir);

		die "unable to dump into snapshot (use option --dumpdir)" 
		    if ($targetdev eq $srcdev);

		mkdir "/vzsnap"; # create mount point for lvm snapshot

		$snapdev = "/dev/$lvmvg/vzsnap";
		debugmsg ("creating lvm snapshot of $srcdev ('$snapdev')", \*LOG);
		run_command (\*LOG, "$lvcreate --size 500m --snapshot --name vzsnap /dev/$lvmvg/$lvmlv");

		debugmsg ("mounting lvm snapshot", \*LOG);

		run_command (\*LOG, "mount $snapdev /vzsnap");

		$dir =~ s|/?$lvmpath/?|/vzsnap/|;

		die "wrong lvm mount point '$lvmpath'" if ($dir !~ m|/vzsnap|) || (! -d $dir);

	    } elsif ($stopve) {

		system ("rm -rf $tmpdir");

		mkdir $tmpdir || die "unable to create tmpdir";

		my $synccmd = "$rsync -aH --delete $dir $tmpdir";
		debugmsg ("starting first sync $dir to $tmpdir", \*LOG);
		run_command (\*LOG, $synccmd);

		my $starttime = time();

		if ($stopve eq 'stop') {
		    debugmsg ("stopping vps", \*LOG);
		    run_command (\*LOG, "$vzctl stop $vpsid");
		} elsif ($stopve eq 'suspend') {
		    debugmsg ("suspend vps", \*LOG);
		    run_command (\*LOG, "$vzctl chkpnt $vpsid --suspend");
		} else {
		    die "unknown mode '$stopve'";
		}

		debugmsg ("final sync $dir to $tmpdir", \*LOG);
		run_command (\*LOG, $synccmd);

		if ($stopve eq 'stop') {
		    debugmsg ("restarting vps", \*LOG);
		    run_command (\*LOG, "$vzctl start $vpsid");
		} elsif ($stopve eq 'suspend') {
		    debugmsg ("resume vps", \*LOG);
		    run_command (\*LOG, "$vzctl chkpnt $vpsid --resume");
		}

		my $delay = time () - $starttime;

		debugmsg ("vps is online again after $delay seconds", \*LOG);
	    
		$dir = "$tmpdir/$vpsid";
	    } else {
		debugmsg ("WARNING: online backup without suspend/snapshot", \*LOG);
		debugmsg ("WARNING: this can lead to inconsistent data", \*LOG);
	    }
	}

	debugmsg ("Creating archive '$tarfile' ($dir)", \*LOG);

	mkpath "$dir/etc/vzdump/";
	my $srcconf = $vpslist->{$vpsid}->{conffile};
	run_command (\*LOG, "cp $srcconf $dir/etc/vzdump/vps.conf");

	foreach my $s (@script_ext) {
	    my $cfgdir = $vzconf->{confdir};
	    my $fn = "$cfgdir/$vpsid.$s";
	    if (-f $fn) {
		run_command (\*LOG, "cp $fn $dir/etc/vzdump/vps.$s");
	    }
	}

	my $excludes = "--exclude='var/log/*log'";
	$excludes .= " --exclude='var/log/*crit'";
	$excludes .= " --exclude='var/log/*notice'";
	$excludes .= " --exclude='var/log/*err'";
	$excludes .= " --exclude='var/log/*info'";
	$excludes .= " --exclude='tmp/*'";
	$excludes .= " --exclude='var/tmp/*'";
	    
	my $zflag = $opt_compress ? 'z' : '';
	my $cmd = "tar c${zflag}pf $tarfile --totals --numeric-owner -C $dir $excludes .";
	run_command (\*LOG, $cmd);

	rmtree "$dir/etc/vzdump";

	system ("rm -rf $tmpdir"); # no longer needed
	
	if ($opt_xdelta) {
	    debugmsg ("creating delta", \*LOG);
	    run_command (\*LOG, "$xdelta delta $oldtarfile $tarfile $deltafile");
	    unlink $tarfile;
	}

    };

    my $err = $@;

    system ("rm -rf $tmpdir");

    if ($running && $opt_snap && $snapdev) {
	eval { run_command (\*LOG, "umount /vzsnap"); };

	rmdir "/vzsnap";

	eval { run_command (\*LOG, "$lvremove -f $snapdev"); };
    }

    $backuptime = (time () - $backuptime) / 60.0;
 
    if ($err) {
	unlink $tarfile;
	unlink $deltafile;
	debugmsg (sprintf ("creating backup for  VPS %s failed (%.2f minutes): %s", 
			   $vpsid, $backuptime, $err), \*LOG);
    } else {
	debugmsg (sprintf ("backup for VPS %s finished successful  (%.2f minutes)", 
			   $vpsid, $backuptime), \*LOG);
    }
 
    close (LOG);

    if ($opt_mailto) {

	my $tmpfile = "/tmp/.bkmail-$$.txt";
	eval {
	    open (TMP, ">$tmpfile");
	    my $stat;

	    if ($err) {
		$stat = 'backup failed';
		print TMP "ERRORS during backup - backup FAILED!\n\n";
	    } else {
		$stat = 'backup successful';
		print TMP "Backup successful!\n\n";
	    }
	    close TMP;
	    
	    system ("cat '$logfile' >> '$tmpfile';" . 
		    "mail -s 'VPS Backup Status for $vpsid : $stat' '$opt_mailto' <'$tmpfile'");
	};

	unlink $tmpfile;
    }

    last if $err =~ m/interrupted by signal/;
}

exit 0;

__END__

=head1 NAME
                                          
vzdump - openvz backup and restore utility

=head1 SYNOPSIS

vzdump OPTIONS [--all | <VEID>]

--compress              compress dump file (gzip)

--dumpdir DIR           store resulting files in DIR

--xdelta                create a differential backup using xdelta

--mailto EMAIL          send notification mail to EMAIL

--stop                  stop/start VPS if running

--suspend               suspend/resume VPS when running

--snapshot              use LVM snapshot when running

--restore FILENAME      restore FILENAME

=head1 DESCRIPTION

vzdump is an utility to make consistent snapshots of running OpenVZ VEs. It basically creates a tar archive of the VE private area, which also includes the VE configuration files.

There are several ways to provide consistency:

- stop the VE during backup (very long downtime)

- use rsync and suspend/resume (minimal downtime).

- use LVM2 (no downtime)


=head1 EXAMPLES

Simply dump VE 777 - no snapshot, just archive the VE private area and configuration files to the default dump directory (usually /vz/dump/).

> vzdump 777

Use rsync and suspend/resume to create an snapshot (minimal downtime).

> vzdump --suspend 777

Backup all VEs and send notification mails to root.

> vzdump --suspend --all --mailto root

Use LVM2 to create snapshots (no downtime).

> vzdump --dumpdir /space/backup --snapshot 777

Restore above backup to VE 600

> vzdump --restore /space/backup/vzdump-777.tar 600
