diff --git a/btrbk b/btrbk index f036bc8..24a24b5 100755 --- a/btrbk +++ b/btrbk @@ -61,6 +61,20 @@ my %uuid_info; my $dryrun; my $loglevel = 1; +my %config_options = ( + # NOTE: the parser always maps "no" to undef + snapshot_dir => { default => "_btrbk_snap", accept_file => "relative" }, + incremental => { default => "yes", accept => [ "yes", "no", "strict" ] }, + receive_log => { default => undef, accept => [ "sidecar", "no" ], accept_file => "absolute" }, + snapshot_create_always => { default => undef, accept => [ "yes", "no" ] }, + snapshot_preserve_days => { default => "all", accept => [ "no", "all" ], accept_number => 1 }, + snapshot_preserve_weekly => { default => 0, accept => [ "no" ], accept_number => 1 }, + target_preserve_days => { default => "all", accept => [ "no", "all" ], accept_number => 1 }, + target_preserve_weekly => { default => 0, accept => [ "no" ], accept_number => 1 }, + ); + +my @config_target_types = qw(send-receive); + sub VERSION_MESSAGE { @@ -184,70 +198,172 @@ sub btr_subvolume_detail($) } +sub config_key($$) +{ + my $node = shift; + my $key = shift; + TRACE "config_key: $node->{CONTEXT}"; + while(not exists($node->{$key})) { + return undef unless($node->{PARENT}); + $node = $node->{PARENT}; + } + TRACE "config_key: returning " . ($node->{$key} // ""); + return $node->{$key}; +} + + sub parse_config($) { my $file = shift; - my @jobs; + my $root = { CONTEXT => "root" }; + my $cur = $root; + # set defaults + foreach (keys %config_options) { + $root->{$_} = $config_options{$_}->{default}; + } + unless(-r "$file") { WARN "Configuration file not found: $file"; return undef; } - TRACE "parsing config file: $file"; + DEBUG "config: parsing file: $file"; open(FILE, '<', $file) or die $!; while () { chomp; next if /^\s*#/; # ignore comments next if /^\s*$/; # ignore empty lines - TRACE "parse_config: parsing line: $_"; - if(/^\s*(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s*$/) + TRACE "config: parsing line $. with context=$cur->{CONTEXT}: \"$_\""; + if(/^(\s*)([a-zA-Z_]+)\s+(.*)$/) { - my %job = ( type => "subvol_backup", - sroot => $1, - svol => $2, - droot => $3, - ); - my @options = split(/,/, $4); + my ($indent, $key, $value) = (length($1), lc($2), $3); + $value =~ s/\s*$//; + # NOTE: we do not perform checks on indentation! - $job{sroot} =~ s/\/+$//; # remove trailing slash - $job{sroot} =~ s/^\/+/\//; # sanitize leading slash - $job{svol} =~ s/\/+$//; # remove trailing slash - $job{svol} =~ s/^\/+//; # remove leading slash - if($job{svol} =~ /\//) { - ERROR "src_subvol contains slashes: $job{svol}"; + if($key eq "volume") + { + $cur = $root; + DEBUG "config: context forced to: $cur->{CONTEXT}"; + DEBUG "config: adding volume \"$value\" to root context"; + $value =~ s/\/+$//; # remove trailing slash + $value =~ s/^\/+/\//; # sanitize leading slash + my $volume = { CONTEXT => "volume", + PARENT => $cur, + sroot => $value, + }; + $cur->{VOLUME} //= []; + push(@{$cur->{VOLUME}}, $volume); + $cur = $volume; + } + elsif($key eq "subvolume") + { + while($cur->{CONTEXT} ne "volume") { + if(($cur->{CONTEXT} eq "root") || (not $cur->{PARENT})) { + ERROR "subvolume keyword outside volume context, in \"$file\" line $."; + return undef; + } + $cur = $cur->{PARENT} || die; + DEBUG "config: context changed to: $cur->{CONTEXT}"; + } + $value =~ s/\/+$//; # remove trailing slash + $value =~ s/^\/+//; # remove leading slash + if($value =~ /\//) { + ERROR "subvolume contains slashes: \"$value\" in \"$file\" line $."; + return undef; + } + + DEBUG "config: adding subvolume \"$value\" to volume context: $cur->{sroot}"; + my $subvolume = { CONTEXT => "subvolume", + PARENT => $cur, + svol => $value, + }; + $cur->{SUBVOLUME} //= []; + push(@{$cur->{SUBVOLUME}}, $subvolume); + $cur = $subvolume; + } + elsif($key eq "target") + { + if($cur->{CONTEXT} eq "target") { + $cur = $cur->{PARENT} || die; + DEBUG "config: context changed to: $cur->{CONTEXT}"; + } + if($cur->{CONTEXT} ne "subvolume") { + ERROR "target keyword outside subvolume context, in \"$file\" line $."; + return undef; + } + if($value =~ /^(\S+)\s+(\S+)$/) + { + my ($target_type, $droot) = ($1, $2); + unless(grep(/^$target_type$/, @config_target_types)) { + ERROR "unknown target type \"$target_type\" in \"$file\" line $."; + return undef; + } + $droot =~ s/\/+$//; # remove trailing slash + $droot =~ s/^\/+/\//; # sanitize leading slash + DEBUG "config: adding target \"$droot\" (type=$target_type) to subvolume context: $cur->{PARENT}->{sroot}/$cur->{svol}"; + my $target = { CONTEXT => "target", + PARENT => $cur, + target_type => $target_type, + droot => $droot, + }; + $cur->{TARGET} //= []; + push(@{$cur->{TARGET}}, $target); + $cur = $target; + } + else + { + ERROR "Ambiguous target configuration, in \"$file\" line $."; + return undef; + } + } + elsif(grep(/^$key$/, keys %config_options)) # accept only keys listed in %config_options + { + if(grep(/^$value$/, @{$config_options{$key}->{accept}})) { + TRACE "option \"$key=$value\" found in accept list"; + } + elsif($config_options{$key}->{accept_number} && ($value =~ /^[0-9]+$/)) { + TRACE "option \"$key=$value\" is a number, accepted"; + } + elsif($config_options{$key}->{accept_file}) + { + if(($config_options{$key}->{accept_file} eq "relative") && ($value =~ /^\//)) { + ERROR "Only relative files allowed for option \"$key\" in \"$file\" line $."; + return undef; + } + elsif(($config_options{$key}->{accept_file} eq "absolute") && ($value !~ /^\//)) { + ERROR "Only absolute files allowed for option \"$key\" in \"$file\" line $."; + return undef; + } + TRACE "option \"$key=$value\" is a file, accepted"; + $value =~ s/\/+$//; # remove trailing slash + $value =~ s/^\/+/\//; # sanitize leading slash + } + else + { + ERROR "Unsupported value \"$value\" for option \"$key\" in \"$file\" line $."; + return undef; + } + DEBUG "config: adding option \"$key=$value\" to $cur->{CONTEXT} context"; + $value = undef if($value eq "no"); # we don't want to check for "no" all the time + $cur->{$key} = $value; + } + else + { + ERROR "Unknown option \"$key\" in \"$file\" line $."; return undef; } - $job{droot} =~ s/\/+$//; # remove trailing slash - $job{droot} =~ s/^\/+/\//; # sanitize leading slash - - $job{mountpoint} = $job{sroot}; # TODO: honor this, automount - - foreach(@options) { - if ($_ eq "incremental") { $job{options}->{incremental} = 1; } - elsif($_ eq "init") { $job{options}->{init} = 1; } - elsif($_ eq "create") { $job{options}->{create} = 1; } - elsif($_ eq "log") { $job{options}->{log} = 1; } - elsif($_ =~ /^log=(\S+)$/) { $job{options}->{log} = 1; $job{options}->{logfile} = $1; } - elsif($_ =~ /^preserve=[dD]([0-9]+)[wW]([0-9]+)$/) { $job{options}->{preserve} = {daily => $1, weekly => $2}; } - else { - ERROR "Ambiguous option=\"$_\": $file line $."; - return undef; # be very strict here - } - } - - TRACE "parse_config: adding job \"$job{type}\": $job{sroot}/$job{svol} -> $job{droot}/"; - push @jobs, \%job; + TRACE "line processed: new context=$cur->{CONTEXT}"; } else { - ERROR "Ambiguous configuration: $file line $."; - return undef; # be very strict here + ERROR "Parse error in \"$file\" line $."; + return undef; } } - close FILE; - TRACE "jobs: " . Dumper(\@jobs); - return \@jobs; + + TRACE(Data::Dumper->Dump([$root], ["config_root"])); + return $root; } @@ -561,39 +677,50 @@ sub check_backup_scheme(@) my %args = @_; my $vol_date = $args{vol_date} || die; my $today = $args{today} || die; - my $keep_days = $args{keep_days} || die; - my $weekly_threshold = $args{weekly_threshold} || die; - my $keep_info = $args{keep_info} || die; - my $keep = 0; + my $week_start = $args{week_start} || die; + my $preserve_days = $args{preserve_days} // die; + my $preserve_weekly = $args{preserve_weekly} // die; + my $preserve_info = $args{preserve_info} || die; + my $preserve = 0; my ($vol_y, $vol_m, $vol_d) = @$vol_date; - my $dd = Delta_Days(@$vol_date, @$today); - if($dd <= $keep_days) - { - $keep = "less than $keep_days days old (age=$dd days)"; - DEBUG "$keep"; - } + # calculate weekly_threshold + my @weekly_threshold = Add_Delta_Days(@$week_start, (-7 * $preserve_weekly)); + TRACE "weekly_threshold for preserve_weekly=$preserve_weekly: " . join('-', @weekly_threshold); - if(Delta_Days(@$vol_date, @$weekly_threshold) < 0) - { - DEBUG "not older than " . join('-', @$weekly_threshold); - my ($vol_wnr, $vol_wy) = Week_of_Year(@$vol_date); - unless($keep_info->{week}->{"$vol_wy-$vol_wnr"}) + if($preserve_days eq "all") { + $preserve = "preserve_days is set to \"all\""; + DEBUG "$preserve"; + } + else { + my $dd = Delta_Days(@$vol_date, @$today); + if($dd <= $preserve_days) { - $keep_info->{week}->{"$vol_wy-$vol_wnr"} = 1; - $keep = "last in week $vol_wy-$vol_wnr"; - DEBUG "$keep"; + $preserve = "less than $preserve_days days old (age: $dd days)"; + DEBUG "$preserve"; } } - unless($keep_info->{month}->{"$vol_y-$vol_m"}) + if(Delta_Days(@$vol_date, @weekly_threshold) < 0) { - $keep_info->{month}->{"$vol_y-$vol_m"} = 1; - $keep = "last in month $vol_y-$vol_m"; - DEBUG "$keep"; + DEBUG "not older than " . join('-', @weekly_threshold); + my ($vol_wnr, $vol_wy) = Week_of_Year(@$vol_date); + unless($preserve_info->{week}->{"$vol_wy-$vol_wnr"}) + { + $preserve_info->{week}->{"$vol_wy-$vol_wnr"} = 1; + $preserve = "last in week #$vol_wnr, $vol_wy"; + DEBUG "$preserve"; + } } - return $keep; + unless($preserve_info->{month}->{"$vol_y-$vol_m"}) + { + $preserve_info->{month}->{"$vol_y-$vol_m"} = 1; + $preserve = "last in month $vol_y-$vol_m"; + DEBUG "$preserve"; + } + + return $preserve; } @@ -605,7 +732,11 @@ MAIN: my @today = Today(); my %opts; - getopts('s:t:c:vl:p', \%opts); + unless(getopts('s:t:c:vl:p', \%opts)) { + VERSION_MESSAGE(); + HELP_MESSAGE(0); + exit 1; + } my $command = shift @ARGV; # assign command line options @@ -618,7 +749,7 @@ MAIN: else { $loglevel = $opts{v} ? 2 : 0; } - my $config = $opts{c} || $default_config; + my $config_file = $opts{c} || $default_config; my $snapdir = $opts{s} || $default_snapdir; $snapdir =~ s/\/+$//; # remove trailing slash $snapdir =~ s/^\/+//; # remove leading slash @@ -751,24 +882,39 @@ MAIN: # - # check jobs, fill vol_info hash + # fill vol_info hash, basic checks on configuration # - my $jobs = parse_config($config); - unless($jobs) { + my $config = parse_config($config_file); + unless($config) { ERROR "Failed to parse configuration file"; exit 1; } - foreach my $job (@$jobs) + unless(ref($config->{VOLUME}) eq "ARRAY") { + ERROR "No volumes defined in configuration file"; + exit 1; + } + foreach my $config_vol (@{$config->{VOLUME}}) { - my $sroot = $job->{sroot} || die; - my $droot = $job->{droot} || die; - my $svol = $job->{svol} || die; + my $sroot = $config_vol->{sroot} || die; $vol_info{$sroot} //= btr_subtree($sroot); - $vol_info{$droot} //= btr_subtree($droot); - unless(subvol($sroot, $svol) && $vol_info{$droot}) { - ERROR "Failed to read btrfs subvolume information, aborting job"; - $job->{ABORTED} = 1; - next; + foreach my $config_subvol (@{$config_vol->{SUBVOLUME}}) + { + my $svol = $config_subvol->{svol} || die; + unless(subvol($sroot, $svol)) { + WARN "Subvolume \"$svol\" not present in btrfs subvolume list for \"$sroot\", skipping section"; + $config_subvol->{ABORTED} = 1; + next; + } + foreach my $config_target (@{$config_subvol->{TARGET}}) + { + my $droot = $config_target->{droot} || die; + $vol_info{$droot} //= btr_subtree($droot); + unless($vol_info{$droot}) { + WARN "Failed to read btrfs subvolume list for \"$droot\", skipping target"; + $config_target->{ABORTED} = 1; + next; + } + } } } TRACE(Data::Dumper->Dump([\%vol_info], ["vol_info"])); @@ -779,19 +925,14 @@ MAIN: # # print snapshot tree # - my %info; - foreach my $job (@$jobs) + foreach my $config_vol (@{$config->{VOLUME}}) { - $info{$job->{sroot}}->{$job->{svol}} = $job; - } - foreach my $root (sort keys %info) - { - print "$root\n"; - foreach my $job (sort { $a->{svol} cmp $b->{svol} } (values %{$info{$root}})) + my $sroot = $config_vol->{sroot} || die; + print "$sroot\n"; + next unless $vol_info{$sroot}; + foreach my $config_subvol (@{$config_vol->{SUBVOLUME}}) { - my $sroot = $job->{sroot} || die; - my $svol = $job->{svol} || die; - next unless $vol_info{$job->{sroot}}; + my $svol = $config_subvol->{svol} || die; print "|-- $svol\n"; my $sroot_uuid; foreach (values $vol_info{$sroot}) { @@ -801,17 +942,19 @@ MAIN: } } die unless $sroot_uuid; - foreach (sort { $a->{SUBVOL_PATH} cmp $b->{SUBVOL_PATH} } (values $vol_info{$sroot})) { + foreach (sort { $a->{SUBVOL_PATH} cmp $b->{SUBVOL_PATH} } (values $vol_info{$sroot})) + { next unless($_->{parent_uuid} eq $sroot_uuid); # next unless($_->{SUBVOL_PATH} =~ /^$snapdir/); # don't print non-btrbk snapshots print "| ^-- $_->{SUBVOL_PATH}\n"; my $snapshot = $_->{FS_PATH}; $snapshot =~ s/^.*\///; - foreach (sort { $a->{droot} cmp $b->{droot} } @$jobs) { - next unless $vol_info{$_->{droot}}; - next unless(($_->{sroot} eq $sroot) && ($_->{svol} eq $svol)); - my $match = "$_->{droot}/$snapshot"; - foreach (sort { $a->{FS_PATH} cmp $b->{FS_PATH} } (values $vol_info{$_->{droot}})) { + foreach my $config_target (@{$config_subvol->{TARGET}}) + { + my $droot = $config_target->{droot} || die; + next unless $vol_info{$droot}; + my $match = "$droot/$snapshot"; + foreach (sort { $a->{FS_PATH} cmp $b->{FS_PATH} } (values $vol_info{$droot})) { print "| | |== $_->{FS_PATH}\n" if($_->{FS_PATH} eq $match); } } @@ -829,113 +972,134 @@ MAIN: # my $timestamp = sprintf("%04d%02d%02d", @today); my %snapshot_cache; - foreach my $job (@$jobs) + foreach my $config_vol (@{$config->{VOLUME}}) { - my $sroot = $job->{sroot} || die; - my $svol = $job->{svol} || die; - my $droot = $job->{droot} || die; - my $type = $job->{type} || die; - - unless(subvol($sroot, $svol)) { - WARN "Source subvolume not found, aborting job: $sroot/$svol"; - $job->{ABORTED} = 1; - next; - } - - my $snapshot; - my $snapshot_name; - if($snapshot_cache{"$sroot/$svol"}) + next if($config_vol->{ABORTED}); + my $sroot = $config_vol->{sroot} || die; + foreach my $config_subvol (@{$config_vol->{SUBVOLUME}}) { - $snapshot = $snapshot_cache{"$sroot/$svol"}->{file}; - $snapshot_name = $snapshot_cache{"$sroot/$svol"}->{name}; - } - else - { - # find new snapshot name - my $postfix_counter = -1; - my $postfix; - do { - $postfix_counter++; - $postfix = '.' . $timestamp . ($postfix_counter ? "_$postfix_counter" : ""); - TRACE "Testing source snapshot name: $snapdir$svol$postfix"; - } while(subvol($sroot, "$snapdir$svol$postfix")); # NOTE: $snapdir always has trailing slash! - $snapshot = "$sroot/$snapdir$svol$postfix"; - $snapshot_name = "$svol$postfix"; - } - - if(subvol($droot, $snapshot_name)) { - # TODO: this seems not right here: maybe just skip this check, and panic later - WARN "Snapshot already exists at destination, aborting job: $droot/$snapshot_name"; - $job->{ABORTED} = 1; - next; - } - create_snapdir($sroot, $svol, $snapdir); - - # make snapshot of svol, if not already created by another job - unless($snapshot_cache{"$sroot/$svol"}) - { - DEBUG "***"; - DEBUG "*** snapshot"; - DEBUG "*** source: $sroot/$svol"; - DEBUG "*** dest : $snapshot"; - DEBUG "***"; - INFO "Creating subvolume snapshot for: $sroot/$svol"; - - unless(btrfs_snapshot("$sroot/$svol", $snapshot)) { - WARN "Failed to create snapshot, aborting job: $sroot/$svol"; - $job->{ABORTED} = 1; + next if($config_subvol->{ABORTED}); + my $svol = $config_subvol->{svol} || die; + my $snapshot; + my $snapshot_name; + if($snapshot_cache{"$sroot/$svol"}) + { + $snapshot = $snapshot_cache{"$sroot/$svol"}->{file}; + $snapshot_name = $snapshot_cache{"$sroot/$svol"}->{name}; } - $snapshot_cache{"$sroot/$svol"} = { name => $snapshot_name, - file => $snapshot }; + else + { + # find new snapshot name + my $postfix_counter = -1; + my $postfix; + do { + $postfix_counter++; + $postfix = '.' . $timestamp . ($postfix_counter ? "_$postfix_counter" : ""); + TRACE "Testing source snapshot name: $snapdir$svol$postfix"; + } while(subvol($sroot, "$snapdir$svol$postfix")); # NOTE: $snapdir always has trailing slash! + $snapshot = "$sroot/$snapdir$svol$postfix"; + $snapshot_name = "$svol$postfix"; + } + + my $create_snapshot = config_key($config_subvol, "snapshot_create_always"); + foreach my $config_target (@{$config_subvol->{TARGET}}) + { + next if($config_target->{ABORTED}); + my $droot = $config_target->{droot} || die; + if(subvol($droot, $snapshot_name)) { + # TODO: this seems not right here: maybe just skip this check, and panic later + WARN "Snapshot already exists at destination, skipping target: $droot/$snapshot_name"; + $config_target->{ABORTED} = 1; + next; + } + if($config_target->{target_type} eq "send-receive") { + $create_snapshot = 1; + } + } + unless($create_snapshot) { + WARN "No snapshots to be created, skipping subvolume: $sroot/$svol"; + $config_subvol->{ABORTED} = 1; + next; + } + + create_snapdir($sroot, $svol, $snapdir); + + # make snapshot of svol, if not already created by another job + unless($snapshot_cache{"$sroot/$svol"}) + { + INFO "Creating subvolume snapshot for: $sroot/$svol"; + + unless(btrfs_snapshot("$sroot/$svol", $snapshot)) { + WARN "Failed to create snapshot, skipping subvolume: $sroot/$svol"; + $config_subvol->{ABORTED} = 1; + } + $snapshot_cache{"$sroot/$svol"} = { name => $snapshot_name, + file => $snapshot }; + } + $config_subvol->{snapshot} = $snapshot; + $config_subvol->{snapshot_name} = $snapshot_name; } - $job->{snapshot} = $snapshot; - $job->{snapshot_name} = $snapshot_name; } # # create backups # - foreach my $job (@$jobs) + foreach my $config_vol (@{$config->{VOLUME}}) { - next if($job->{ABORTED}); + next if($config_vol->{ABORTED}); + my $sroot = $config_vol->{sroot} || die; + foreach my $config_subvol (@{$config_vol->{SUBVOLUME}}) + { + next if($config_subvol->{ABORTED}); + my $svol = $config_subvol->{svol} || die; + my $snapshot = $config_subvol->{snapshot} || die; + my $snapshot_name = $config_subvol->{snapshot_name} || die; - my $sroot = $job->{sroot} || die; - my $svol = $job->{svol} || die; - my $droot = $job->{droot} || die; - my $type = $job->{type} || die; - my $snapshot = $job->{snapshot} || die; - my $snapshot_name = $job->{snapshot_name} || die; - my $job_opts = $job->{options} || die; + foreach my $config_target (@{$config_subvol->{TARGET}}) + { + next if($config_target->{ABORTED}); + my $droot = $config_target->{droot} || die; + my $target_type = $config_target->{target_type} || die; - DEBUG "***"; - DEBUG "*** $type\[" . join(',', map { "$_=$job_opts->{$_}" } keys(%$job_opts)) . "]"; - DEBUG "*** source: $sroot/$svol"; - DEBUG "*** dest : $droot/"; - DEBUG "***"; - INFO "Creating subvolume backup for: $sroot/$svol"; + my $success = 0; + if($target_type eq "send-receive") + { + INFO "Creating subvolume backup ($target_type) for: $sroot/$svol"; + INFO "Using previously created snapshot: $snapshot"; - my $changelog = ""; - if ($job_opts->{log}) { - # log defaults to sidecar of destination snapshot - $changelog = $job_opts->{logfile} || "$droot/$snapshot_name.btrbk.log"; - } - if ($job_opts->{incremental}) { - INFO "Using previously created snapshot: $snapshot"; - # INFO "Attempting incremantal backup (option=incremental)"; - my ($latest_common_src, $latest_common_dst) = get_latest_common($sroot, $svol, $droot); - if ($latest_common_src && $latest_common_dst) { - my $parent_snap = $latest_common_src->{FS_PATH}; - INFO "Using parent snapshot: $parent_snap"; - btrfs_send_receive($snapshot, $droot, $parent_snap, $changelog); - } elsif ($job_opts->{init}) { - INFO "No common parent snapshots found, creating initial backup (option=init)"; - btrfs_send_receive($snapshot, $droot, undef, $changelog); - } else { - WARN "Backup to $droot failed: no common parent subvolume found, and job option \"create\" is not set"; + my $receive_log = config_key($config_target, "receive_log"); + if($receive_log && ($receive_log eq "sidecar")) { + # log to sidecar of destination snapshot + $receive_log = "$droot/$snapshot_name.btrbk.log"; + } + + my $incremental = config_key($config_target, "incremental"); + if($incremental) + { + my ($latest_common_src, $latest_common_dst) = get_latest_common($sroot, $svol, $droot); + if($latest_common_src && $latest_common_dst) { + my $parent_snap = $latest_common_src->{FS_PATH}; + INFO "Incremental from parent snapshot: $parent_snap"; + $success = btrfs_send_receive($snapshot, $droot, $parent_snap, $receive_log); + } + elsif($incremental ne "strict") { + INFO "No common parent subvolume present, creating full backup"; + $success = btrfs_send_receive($snapshot, $droot, undef, $receive_log); + } + else { + WARN "Backup to $droot failed: no common parent subvolume found, and option \"incremental\" is set to \"strict\""; + } + } + else { + INFO "Creating full backup (option \"incremental\" is not set)"; + $success = btrfs_send_receive($snapshot, $droot, undef, $receive_log); + } + } + else { + ERROR "Unknown target type \"$target_type\", skipping: $sroot/$svol"; + } + $config_target->{ABORTED} = 1 unless($success); } - } elsif ($job_opts->{create}) { - INFO "Creating new snapshot copy (option=create))"; - btrfs_send_receive($snapshot, $droot, undef, $changelog); } } } @@ -945,98 +1109,95 @@ MAIN: { $dryrun = 1; # TODO: gather all information first, then delete all backups/snapshots at the end - # TODO: always keep first/last + # TODO: always preserve first/last + + my @last_sunday; + if(Day_of_Week(@today) == 7) { # today is sunday + @last_sunday = @today; + } + else { + @last_sunday = Add_Delta_Days(Monday_of_Week(Week_of_Year(@today)), -1); + } + DEBUG "last sunday: " . join('-', @last_sunday); # - # remove backups following a keep_days/keep_weekly scheme + # remove backups following a preserve_days/preserve_weekly scheme # - foreach my $job (@$jobs) + foreach my $config_vol (@{$config->{VOLUME}}) { - next if($job->{ABORTED}); - - my $sroot = $job->{sroot} || die; - my $svol = $job->{svol} || die; - my $droot = $job->{droot} || die; - my $job_opts = $job->{options} || die; - - unless(ref($job_opts->{preserve})) { - INFO "Skip cleaning of subvolume backups (option preserve is not set): $sroot/$svol"; - next; - } - - INFO "Cleaning subvolume backups of job: $sroot/$svol"; - my $keep_days = $job_opts->{preserve}->{daily}; - my $keep_weekly = $job_opts->{preserve}->{weekly}; - - # calculate weekly_threshold - my @last_sunday; - if(Day_of_Week(@today) == 7) { # today is sunday - @last_sunday = @today; - } - else { - @last_sunday = Add_Delta_Days(Monday_of_Week(Week_of_Year(@today)), -1); - } - my @weekly_threshold = Add_Delta_Days(@last_sunday, (-7 * $keep_weekly)); - DEBUG "last sunday: " . join('-', @last_sunday); - DEBUG "weekly_threshold for keep_weekly=$keep_weekly: " . join('-', @weekly_threshold); - - # - # delete backups - # - my $keep_info = {}; - my @delete_backups; - INFO "Cleaning backups: $droot/$svol.*"; - foreach my $vol (sort { $b cmp $a } keys %{$vol_info{$droot}}) + next if($config_vol->{ABORTED}); + my $sroot = $config_vol->{sroot} || die; + foreach my $config_subvol (@{$config_vol->{SUBVOLUME}}) { - next unless($vol =~ /^$svol\.([0-9]{4})([0-9]{2})([0-9]{2})/); - my @vol_date = ($1, $2, $3); + next if($config_subvol->{ABORTED}); + my $svol = $config_subvol->{svol} || die; + INFO "Cleaning subvolume backups for: $sroot/$svol"; + foreach my $config_target (@{$config_subvol->{TARGET}}) + { + next if($config_target->{ABORTED}); + my $droot = $config_target->{droot} || die; - DEBUG "Checking: $vol"; - my $keep = check_backup_scheme( - vol_date => \@vol_date, - today => \@today, - weekly_threshold => \@weekly_threshold, - keep_days => $keep_days, - keep_info => $keep_info, - ); - if($keep) { - INFO "$vol: preserved: $keep"; + # + # delete backups + # + my $preserve_info = {}; + my @delete_backups; + INFO "Cleaning backups: $droot/$svol.*"; + foreach my $vol (sort { $b cmp $a } keys %{$vol_info{$droot}}) + { + next unless($vol =~ /^$svol\.([0-9]{4})([0-9]{2})([0-9]{2})/); + my @vol_date = ($1, $2, $3); + + DEBUG "Checking: $vol"; + my $preserve = check_backup_scheme( + vol_date => \@vol_date, + today => \@today, + week_start => \@last_sunday, # TODO: configurable + preserve_days => config_key($config_target, "target_preserve_days"), + preserve_weekly => config_key($config_target, "target_preserve_weekly"), + preserve_info => $preserve_info, + ); + if($preserve) { + INFO "$vol: preserved: $preserve"; + } + else { + INFO "$vol: DELETE"; + push @delete_backups, "$droot/$vol"; + } + } + btrfs_subvolume_delete(@delete_backups); } - else { - INFO "$vol: DELETE"; - push @delete_backups, "$droot/$vol"; + + # + # delete snapshots + # + my $preserve_info = {}; + my @delete_snapshots; + INFO "Cleaning snapshots: $sroot/$snapdir$svol.*"; + foreach my $vol (sort { $b cmp $a } keys %{$vol_info{$sroot}}) + { + next unless($vol =~ /^$snapdir$svol\.([0-9]{4})([0-9]{2})([0-9]{2})/); + my @vol_date = ($1, $2, $3); + + DEBUG "Checking: $vol"; + my $preserve = check_backup_scheme( + vol_date => \@vol_date, + today => \@today, + week_start => \@last_sunday, # TODO: configurable + preserve_days => config_key($config_subvol, "snapshot_preserve_days"), + preserve_weekly => config_key($config_subvol, "snapshot_preserve_weekly"), + preserve_info => $preserve_info, + ); + if($preserve) { + INFO "$vol: preserved: $preserve"; + } + else { + INFO "$vol: DELETE"; + push @delete_snapshots, "$sroot/$vol"; + } } + btrfs_subvolume_delete(@delete_snapshots); } - btrfs_subvolume_delete(@delete_backups); - - # - # delete snapshots - # - $keep_info = {}; - my @delete_snapshots; - INFO "Cleaning snapshots: $sroot/$snapdir$svol.*"; - foreach my $vol (sort { $b cmp $a } keys %{$vol_info{$sroot}}) - { - next unless($vol =~ /^$snapdir$svol\.([0-9]{4})([0-9]{2})([0-9]{2})/); - my @vol_date = ($1, $2, $3); - - DEBUG "Checking: $vol"; - my $keep = check_backup_scheme( - vol_date => \@vol_date, - today => \@today, - weekly_threshold => \@weekly_threshold, - keep_days => $keep_days, - keep_info => $keep_info, - ); - if($keep) { - INFO "$vol: preserved: $keep"; - } - else { - INFO "$vol: DELETE"; - push @delete_snapshots, "$sroot/$vol"; - } - } - btrfs_subvolume_delete(@delete_snapshots); } } } diff --git a/btrbk.conf b/btrbk.conf index c2611f6..ef9ac3b 100644 --- a/btrbk.conf +++ b/btrbk.conf @@ -16,24 +16,53 @@ # log= append log to specified logfile # +# old: +# /mnt/btr_system root_gentoo /mnt/btr_ext/_btrbk incremental,init,preserve=d14w10 -/mnt/btr_system root_gentoo /mnt/btr_ext/_btrbk incremental,init,preserve=d14w10 -/mnt/btr_system root_gentoo /mnt/btr_backup/_btrbk incremental,init,log -/mnt/btr_system kvm /mnt/btr_ext/_btrbk incremental,init -/mnt/btr_system kvm /mnt/btr_backup/_btrbk incremental,init,log +snapshot_dir _btrbk_snap +snapshot_create_always yes -/mnt/btr_data home /mnt/btr_backup/_btrbk incremental,init,log -/mnt/btr_data sdms.data /mnt/btr_backup/_btrbk incremental,init,log +# TODO: incremental = {yes|no|strict} +incremental strict -/mnt/btr_ext data /mnt/btr_backup/_btrbk incremental,init,log -# TODO: these monthly -#/mnt/btr_ext video /mnt/btr_backup/_btrbk incremental,init,log -#/mnt/btr_ext audio /mnt/btr_backup/_btrbk incremental,init,log +snapshot_preserve_days 14 +snapshot_preserve_weekly 0 -# TODO: these monthly -#/mnt/btr_boot boot /mnt/btr_ext/_btrbk incremental,init,log -#/mnt/btr_boot boot /mnt/btr_backup/_btrbk incremental +target_preserve_days 28 +target_preserve_weekly 10 -# non-incremental, create a new snapshot at every invocation! -##/mnt/btr_boot boot /mnt/btr_backup/_btrbk create +volume /mnt/btr_system + subvolume root_gentoo + target send-receive /mnt/btr_ext/_btrbk + target send-receive /mnt/btr_backup/_btrbk + receive_log sidecar + + subvolume kvm + target_preserve_days 7 + target_preserve_weekly 4 + + target send-receive /mnt/btr_ext/_btrbk + target_preserve_weekly 0 + + target send-receive /mnt/btr_backup/_btrbk + receive_log sidecar + + +volume /mnt/btr_data + subvolume home + target send-receive /mnt/btr_backup/_btrbk + + +volume /mnt/btr_ext + subvolume data + target send-receive /mnt/btr_backup/_btrbk + + +volume /mnt/btr_boot +# schedule weekly + incremental yes + + subvolume boot + target send-receive /mnt/btr_ext/_btrbk + target send-receive /mnt/btr_backup/_btrbk