btrbk: added support for ssh targets (identity file only, no password support yet)

pull/30/head
Axel Burri 2015-01-14 14:10:41 +01:00
parent 92ee8b0454
commit 17266d90aa
1 changed files with 105 additions and 60 deletions

165
btrbk
View File

@ -10,9 +10,9 @@ btrbk - backup btrfs volumes at file-system level
=head1 DESCRIPTION =head1 DESCRIPTION
Backup tool for btrfs (sub-)volumes, taking advantage of btrfs Backup tool for btrfs subvolumes, taking advantage of btrfs specific
specific send-receive mechanism, allowing incremental backups at send-receive mechanism, allowing incremental backups at file-system
file-system level. level.
The full btrbk documentation is available at L<http://www.digint.ch/btrbk>. The full btrbk documentation is available at L<http://www.digint.ch/btrbk>.
@ -72,6 +72,8 @@ my %config_options = (
target_preserve_weekly => { default => 0, accept => [ "all" ], accept_numeric => 1 }, target_preserve_weekly => { default => 0, accept => [ "all" ], accept_numeric => 1 },
target_preserve_monthly => { default => "all", accept => [ "all" ], accept_numeric => 1 }, target_preserve_monthly => { default => "all", accept => [ "all" ], accept_numeric => 1 },
btrfs_commit_delete => { default => undef, accept => [ "after", "each", "no" ] }, btrfs_commit_delete => { default => undef, accept => [ "after", "each", "no" ] },
ssh_identity => { default => undef, accept_file => "absolute" },
ssh_user => { default => "root", accept_regexp => qr/^[a-z_][a-z0-9_-]*$/ },
); );
my @config_target_types = qw(send-receive); my @config_target_types = qw(send-receive);
@ -125,19 +127,26 @@ sub ERROR { my $t = shift; print STDOUT "ERROR: $t\n"; }
sub run_cmd($;$) sub run_cmd($;$)
{ {
my $cmd = shift; my $cmd = shift || die;
my $non_destructive = shift; my $non_destructive = shift;
my $ret = ""; my $ret = "";
DEBUG "### $cmd" unless($non_destructive); $cmd =~ s/^\s+//;
$cmd =~ s/\s+$//;
if($non_destructive || (not $dryrun)) { if($non_destructive || (not $dryrun)) {
TRACE "### $cmd"; DEBUG "### $cmd";
$ret = `$cmd`; $ret = `$cmd`;
chomp($ret); chomp($ret);
TRACE "command output:\n$ret"; TRACE "Command output:\n$ret";
if($?) { if($?) {
WARN "command execution failed (exitcode=$?): \"$cmd\""; WARN "Command execution failed (exitcode=$?): \"$cmd\"";
return undef; return undef;
} }
else {
DEBUG "Command execution successful";
}
}
else {
DEBUG "### (dryrun) $cmd";
} }
return $ret; return $ret;
} }
@ -145,8 +154,8 @@ sub run_cmd($;$)
sub subvol($$) sub subvol($$)
{ {
my $root = shift; my $root = shift || die;
my $vol = shift; my $vol = shift || die;
if($vol_info{$root} && $vol_info{$root}->{$vol}) { if($vol_info{$root} && $vol_info{$root}->{$vol}) {
return $vol_info{$root}->{$vol}; return $vol_info{$root}->{$vol};
} }
@ -156,9 +165,9 @@ sub subvol($$)
sub create_snapdir($$$) sub create_snapdir($$$)
{ {
my $root = shift; my $root = shift || die;
my $vol = shift; my $vol = shift || die;
my $snapdir = shift; my $snapdir = shift || die;
if($snapdir && (not $dryrun)) if($snapdir && (not $dryrun))
{ {
my $dir = "$root/$snapdir"; my $dir = "$root/$snapdir";
@ -170,17 +179,32 @@ sub create_snapdir($$$)
} }
sub btr_subvolume_detail($) sub get_rsh($$)
{ {
my $vol = shift; my $vol = shift || die;
my $ret = run_cmd("/sbin/btrfs subvolume show $vol 2>/dev/null", 1); my $config = shift;
if($config && ($vol =~ /^ssh:\/\/(\S+?)(\/\S+)$/)) {
my ($ssh_host, $real_vol) = ($1, $2);
my $rsh = "/usr/bin/ssh -i " . config_key($config, "ssh_identity") . ' ' . config_key($config, "ssh_user") . '@' . $ssh_host;
return ($rsh, $real_vol);
}
return ("", $vol);
}
sub btr_subvolume_detail($;$)
{
my $vol = shift || die;
my $config = shift;
my ($rsh, $real_vol) = get_rsh($vol, $config);
my $ret = run_cmd("$rsh /sbin/btrfs subvolume show $real_vol 2>/dev/null", 1);
if($ret) if($ret)
{ {
if($ret eq "$vol is btrfs root") { if($ret eq "$real_vol is btrfs root") {
TRACE "btr_detail: found btrfs root: $vol"; DEBUG "found btrfs root: $vol";
return { id => 5, is_root => 1 }; return { id => 5, is_root => 1 };
} }
elsif($ret =~ /^$vol/) { elsif($ret =~ /^$real_vol/) {
TRACE "btr_detail: found btrfs subvolume: $vol"; TRACE "btr_detail: found btrfs subvolume: $vol";
my %trans = ( my %trans = (
name => "Name", name => "Name",
@ -202,6 +226,7 @@ sub btr_subvolume_detail($)
WARN "Failed to parse subvolume detail \"$trans{$_}\": $ret"; WARN "Failed to parse subvolume detail \"$trans{$_}\": $ret";
} }
} }
DEBUG "parsed " . scalar(keys %detail) . " subvolume detail items: $vol";
TRACE "btr_detail for $vol: " . Dumper \%detail; TRACE "btr_detail for $vol: " . Dumper \%detail;
return \%detail; return \%detail;
} }
@ -213,8 +238,8 @@ sub btr_subvolume_detail($)
sub config_key($$) sub config_key($$)
{ {
my $node = shift; my $node = shift || die;
my $key = shift; my $key = shift || die;
TRACE "config_key: context=$node->{CONTEXT}, key=$key"; TRACE "config_key: context=$node->{CONTEXT}, key=$key";
while(not exists($node->{$key})) { while(not exists($node->{$key})) {
return undef unless($node->{PARENT}); return undef unless($node->{PARENT});
@ -227,7 +252,7 @@ sub config_key($$)
sub parse_config($) sub parse_config($)
{ {
my $file = shift; my $file = shift || die;
my $root = { CONTEXT => "root" }; my $root = { CONTEXT => "root" };
my $cur = $root; my $cur = $root;
# set defaults # set defaults
@ -355,6 +380,16 @@ sub parse_config($)
$value .= '/'; $value .= '/';
} }
} }
elsif($config_options{$key}->{accept_regexp}) {
my $match = $config_options{$key}->{accept_regexp};
if($value =~ m/$match/) {
TRACE "option \"$key=$value\" is numeric, accepted";
}
else {
ERROR "Value \"$value\" failed input validation for option \"$key\" in \"$file\" line $.";
return undef;
}
}
else else
{ {
ERROR "Unsupported value \"$value\" for option \"$key\" in \"$file\" line $."; ERROR "Unsupported value \"$value\" for option \"$key\" in \"$file\" line $.";
@ -384,14 +419,16 @@ sub parse_config($)
} }
sub btr_subvolume_list($;@) sub btr_subvolume_list($;$@)
{ {
my $vol = shift; my $vol = shift || die;
my $config = shift;
my %opts = @_; my %opts = @_;
my $filter_option = "-a"; my $filter_option = "-a";
$filter_option = "-o" if($opts{subvol_only}); $filter_option = "-o" if($opts{subvol_only});
my $ret = run_cmd("/sbin/btrfs subvolume list $filter_option -c -u -q -R $vol", 1); my ($rsh, $real_vol) = get_rsh($vol, $config);
unless($ret) { my $ret = run_cmd("$rsh /sbin/btrfs subvolume list $filter_option -c -u -q -R $real_vol", 1);
unless(defined($ret)) {
WARN "Failed to fetch btrfs subvolume list for: $vol"; WARN "Failed to fetch btrfs subvolume list for: $vol";
return undef; return undef;
} }
@ -416,14 +453,18 @@ sub btr_subvolume_list($;@)
}; };
# $node{parent_uuid} = undef if($node{parent_uuid} eq '-'); # $node{parent_uuid} = undef if($node{parent_uuid} eq '-');
} }
DEBUG "found " . scalar(@nodes) . " subvolumes in: $vol";
return \@nodes; return \@nodes;
} }
sub btr_subvolume_find_new($$)
sub btr_subvolume_find_new($$;$)
{ {
my $vol = shift; my $vol = shift || die;
my $lastgen = shift; my $lastgen = shift // die;
my $ret = run_cmd("/sbin/btrfs subvolume find-new $vol $lastgen"); my $config = shift;
my ($rsh, $real_vol) = get_rsh($vol, $config);
my $ret = run_cmd("$rsh /sbin/btrfs subvolume find-new $real_vol $lastgen");
unless(defined($ret)) { unless(defined($ret)) {
ERROR "Failed to fetch modified files for: $vol"; ERROR "Failed to fetch modified files for: $vol";
return undef; return undef;
@ -474,12 +515,13 @@ sub btr_subvolume_find_new($$)
} }
sub btr_tree($) sub btr_tree($;$)
{ {
my $vol = shift; my $vol = shift || die;
my $config = shift;
my %tree; my %tree;
my %id; my %id;
my $subvol_list = btr_subvolume_list($vol, subvol_only => 0); my $subvol_list = btr_subvolume_list($vol, $config, subvol_only => 0);
return undef unless(ref($subvol_list) eq "ARRAY"); return undef unless(ref($subvol_list) eq "ARRAY");
foreach my $node (@$subvol_list) foreach my $node (@$subvol_list)
{ {
@ -513,10 +555,11 @@ sub btr_tree($)
} }
sub btr_subtree($) sub btr_subtree($;$)
{ {
my $vol = shift; my $vol = shift || die;
my $detail = btr_subvolume_detail($vol); my $config = shift;
my $detail = btr_subvolume_detail($vol, $config);
unless($detail) { unless($detail) {
WARN "Failed to build btrfs subtree for volume: $vol"; WARN "Failed to build btrfs subtree for volume: $vol";
return undef; return undef;
@ -524,7 +567,7 @@ sub btr_subtree($)
my $volname = $detail->{name} || ""; my $volname = $detail->{name} || "";
my %tree; my %tree;
my $subvol_list = btr_subvolume_list($vol, subvol_only => 1); my $subvol_list = btr_subvolume_list($vol, $config, subvol_only => 1);
return undef unless(ref($subvol_list) eq "ARRAY"); return undef unless(ref($subvol_list) eq "ARRAY");
foreach my $node (@$subvol_list) foreach my $node (@$subvol_list)
{ {
@ -559,8 +602,8 @@ sub btr_subtree($)
# returns $target, or undef on error # returns $target, or undef on error
sub btrfs_snapshot($$) sub btrfs_snapshot($$)
{ {
my $src = shift; my $src = shift || die;
my $target = shift; my $target = shift || die;
DEBUG "[btrfs] snapshot (ro):"; DEBUG "[btrfs] snapshot (ro):";
DEBUG "[btrfs] source: $src"; DEBUG "[btrfs] source: $src";
DEBUG "[btrfs] target: $target"; DEBUG "[btrfs] target: $target";
@ -573,9 +616,10 @@ sub btrfs_snapshot($$)
sub btrfs_subvolume_delete($@) sub btrfs_subvolume_delete($@)
{ {
my $commit_delete = shift; my $config = shift;
my @targets = @_; my @targets = @_;
return 0 unless(scalar(@targets)); return 0 unless(scalar(@targets));
my $commit_delete = config_key($config, "btrfs_commit_delete");
DEBUG "[btrfs] delete:"; DEBUG "[btrfs] delete:";
DEBUG "[btrfs] commit-delete: " . ($commit_delete ? $commit_delete : "no"); DEBUG "[btrfs] commit-delete: " . ($commit_delete ? $commit_delete : "no");
DEBUG "[btrfs] subvolume: $_" foreach(@targets); DEBUG "[btrfs] subvolume: $_" foreach(@targets);
@ -588,12 +632,14 @@ sub btrfs_subvolume_delete($@)
} }
sub btrfs_send_receive($$;$$) sub btrfs_send_receive($$$$;$)
{ {
my $src = shift; my $src = shift || die;
my $target = shift; my $target = shift || die;
my $parent = shift // ""; my $parent = shift // "";
my $changelog = shift // ""; my $changelog = shift // "";
my $config = shift;
my ($rsh, $real_target) = get_rsh($target, $config);
my $now = localtime; my $now = localtime;
my $src_name = $src; my $src_name = $src;
@ -612,7 +658,7 @@ sub btrfs_send_receive($$;$$)
my $receive_option = ""; my $receive_option = "";
$receive_option = "-v" if($changelog || ($loglevel >= 2)); $receive_option = "-v" if($changelog || ($loglevel >= 2));
$receive_option = "-v -v" if($parent && $changelog); $receive_option = "-v -v" if($parent && $changelog);
my $cmd = "/sbin/btrfs send $parent_option $src | /sbin/btrfs receive $receive_option $target/ 2>&1"; my $cmd = "/sbin/btrfs send $parent_option $src | $rsh /sbin/btrfs receive $receive_option $real_target/ 2>&1";
my $ret = run_cmd($cmd); my $ret = run_cmd($cmd);
unless(defined($ret)) { unless(defined($ret)) {
ERROR "Failed to send/receive btrfs subvolume: $src " . ($parent ? "[$parent]" : "") . " -> $target"; ERROR "Failed to send/receive btrfs subvolume: $src " . ($parent ? "[$parent]" : "") . " -> $target";
@ -639,8 +685,8 @@ sub btrfs_send_receive($$;$$)
sub get_children($$) sub get_children($$)
{ {
my $sroot = shift; my $sroot = shift || die;
my $svol = shift; my $svol = shift || die;
my $svol_href = subvol($sroot, $svol); my $svol_href = subvol($sroot, $svol);
die("subvolume info not present: $sroot/$svol") unless($svol_href); die("subvolume info not present: $sroot/$svol") unless($svol_href);
DEBUG "Getting snapshot children of: $sroot/$svol"; DEBUG "Getting snapshot children of: $sroot/$svol";
@ -656,8 +702,8 @@ sub get_children($$)
sub get_receive_targets_by_uuid($$) sub get_receive_targets_by_uuid($$)
{ {
my $droot = shift; my $droot = shift || die;
my $uuid = shift; my $uuid = shift || die;
die("root subvolume info not present: $droot") unless($vol_info{$droot}); die("root subvolume info not present: $droot") unless($vol_info{$droot});
die("subvolume info not present: $uuid") unless($uuid_info{$uuid}); die("subvolume info not present: $uuid") unless($uuid_info{$uuid});
DEBUG "Getting receive targets in \"$droot/\" for: $uuid_info{$uuid}->{FS_PATH}"; DEBUG "Getting receive targets in \"$droot/\" for: $uuid_info{$uuid}->{FS_PATH}";
@ -673,9 +719,9 @@ sub get_receive_targets_by_uuid($$)
sub get_latest_common($$$) sub get_latest_common($$$)
{ {
my $sroot = shift; my $sroot = shift || die;
my $svol = shift; my $svol = shift || die;
my $droot = shift; my $droot = shift || die;
die("source subvolume info not present: $sroot") unless($vol_info{$sroot}); die("source subvolume info not present: $sroot") unless($vol_info{$sroot});
die("target subvolume info not present: $droot") unless($vol_info{$droot}); die("target subvolume info not present: $droot") unless($vol_info{$droot});
@ -930,7 +976,7 @@ MAIN:
foreach my $config_vol (@{$config->{VOLUME}}) foreach my $config_vol (@{$config->{VOLUME}})
{ {
my $sroot = $config_vol->{sroot} || die; my $sroot = $config_vol->{sroot} || die;
$vol_info{$sroot} //= btr_subtree($sroot); $vol_info{$sroot} //= btr_subtree($sroot, $config_vol);
foreach my $config_subvol (@{$config_vol->{SUBVOLUME}}) foreach my $config_subvol (@{$config_vol->{SUBVOLUME}})
{ {
my $svol = $config_subvol->{svol} || die; my $svol = $config_subvol->{svol} || die;
@ -942,7 +988,7 @@ MAIN:
foreach my $config_target (@{$config_subvol->{TARGET}}) foreach my $config_target (@{$config_subvol->{TARGET}})
{ {
my $droot = $config_target->{droot} || die; my $droot = $config_target->{droot} || die;
$vol_info{$droot} //= btr_subtree($droot); $vol_info{$droot} //= btr_subtree($droot, $config_target);
unless($vol_info{$droot}) { unless($vol_info{$droot}) {
$config_target->{ABORTED} = "Failed to read btrfs subvolume list for \"$droot\""; $config_target->{ABORTED} = "Failed to read btrfs subvolume list for \"$droot\"";
WARN "Skipping target: $config_target->{ABORTED}"; WARN "Skipping target: $config_target->{ABORTED}";
@ -1115,11 +1161,11 @@ MAIN:
if($latest_common_src && $latest_common_target) { if($latest_common_src && $latest_common_target) {
my $parent_snap = $latest_common_src->{FS_PATH}; my $parent_snap = $latest_common_src->{FS_PATH};
INFO "Incremental from parent snapshot: $parent_snap"; INFO "Incremental from parent snapshot: $parent_snap";
$success = btrfs_send_receive($snapshot, $droot, $parent_snap, $receive_log); $success = btrfs_send_receive($snapshot, $droot, $parent_snap, $receive_log, $config_target);
} }
elsif($incremental ne "strict") { elsif($incremental ne "strict") {
INFO "No common parent subvolume present, creating full backup"; INFO "No common parent subvolume present, creating full backup";
$success = btrfs_send_receive($snapshot, $droot, undef, $receive_log); $success = btrfs_send_receive($snapshot, $droot, undef, $receive_log, $config_target);
} }
else { else {
WARN "Backup to $droot failed: no common parent subvolume found, and option \"incremental\" is set to \"strict\""; WARN "Backup to $droot failed: no common parent subvolume found, and option \"incremental\" is set to \"strict\"";
@ -1127,7 +1173,7 @@ MAIN:
} }
else { else {
INFO "Creating full backup (option \"incremental\" is not set)"; INFO "Creating full backup (option \"incremental\" is not set)";
$success = btrfs_send_receive($snapshot, $droot, undef, $receive_log); $success = btrfs_send_receive($snapshot, $droot, undef, $receive_log, $config_target);
} }
} }
else { else {
@ -1179,8 +1225,7 @@ MAIN:
preserve_weekly => config_key($config_target, "target_preserve_weekly"), preserve_weekly => config_key($config_target, "target_preserve_weekly"),
preserve_monthly => config_key($config_target, "target_preserve_monthly"), preserve_monthly => config_key($config_target, "target_preserve_monthly"),
); );
my $commit_delete = config_key($config_target, "btrfs_commit_delete"); my $ret = btrfs_subvolume_delete($config_target, @delete);
my $ret = btrfs_subvolume_delete($commit_delete, @delete);
if(defined($ret)) { if(defined($ret)) {
INFO "Deleted $ret subvolumes in: $droot/$svol.*"; INFO "Deleted $ret subvolumes in: $droot/$svol.*";
$config_target->{subvol_deleted} = \@delete; $config_target->{subvol_deleted} = \@delete;
@ -1194,6 +1239,7 @@ MAIN:
# #
# delete snapshots # delete snapshots
# #
# TODO: don't delete any snapshots if on of the above failed
INFO "Cleaning snapshots: $sroot/$snapdir$svol.*"; INFO "Cleaning snapshots: $sroot/$snapdir$svol.*";
my @schedule; my @schedule;
foreach my $vol (keys %{$vol_info{$sroot}}) { foreach my $vol (keys %{$vol_info{$sroot}}) {
@ -1209,8 +1255,7 @@ MAIN:
preserve_weekly => config_key($config_subvol, "snapshot_preserve_weekly"), preserve_weekly => config_key($config_subvol, "snapshot_preserve_weekly"),
preserve_monthly => config_key($config_subvol, "snapshot_preserve_monthly"), preserve_monthly => config_key($config_subvol, "snapshot_preserve_monthly"),
); );
my $commit_delete = config_key($config_subvol, "btrfs_commit_delete"); my $ret = btrfs_subvolume_delete($config_subvol, @delete);
my $ret = btrfs_subvolume_delete($commit_delete, @delete);
if(defined($ret)) { if(defined($ret)) {
INFO "Deleted $ret subvolumes in: $sroot/$snapdir$svol.*"; INFO "Deleted $ret subvolumes in: $sroot/$snapdir$svol.*";
$config_subvol->{subvol_deleted} = \@delete; $config_subvol->{subvol_deleted} = \@delete;