1 #! /usr/bin/perl  | 
         | 
     2   | 
         | 
     3 use 5.010;  | 
         | 
     4 use strict;  | 
         | 
     5 use warnings;  | 
         | 
     6   | 
         | 
     7 use File::Basename;  | 
         | 
     8 use Net::FTP;  | 
         | 
     9 use Perl6::Slurp;  | 
         | 
    10 use Getopt::Long;  | 
         | 
    11 use Sys::Hostname;  | 
         | 
    12 use Pod::Usage;  | 
         | 
    13 use POSIX qw(strftime);  | 
         | 
    14 use Date::Parse qw(str2time);  | 
         | 
    15 use Cwd qw(realpath);  | 
         | 
    16 use English qw(-no_match_vars);  | 
         | 
    17 use if $ENV{DEBUG} => qw(Smart::Comments); | 
         | 
    18   | 
         | 
    19 $ENV{LC_ALL} = "C"; | 
         | 
    20   | 
         | 
    21 my $ME = basename $0;  | 
         | 
    22 my $VERSION = '<VERSION>';  | 
         | 
    23   | 
         | 
    24 my @CONFIGS = ("/etc/$ME.conf", "$ENV{HOME}/.$ME.conf", "$ME.conf"); | 
         | 
    25   | 
         | 
    26 my $HOSTNAME = hostname;  | 
         | 
    27 my $NOW  = time();  | 
         | 
    28   | 
         | 
    29 my $opt_level   = undef;  | 
         | 
    30 my $opt_today   = strftime("%F", localtime $NOW); | 
         | 
    31 my @opt_debug   = ();  | 
         | 
    32 my $opt_verbose = 0;  | 
         | 
    33 my $opt_dry     = 0;  | 
         | 
    34 my $opt_force   = 0;  | 
         | 
    35 my $opt_label   = "daily";  | 
         | 
    36 my $opt_info    = 0;  | 
         | 
    37 my $opt_config  = "";  | 
         | 
    38 my $opt_dumpdates = "/var/lib/dumpdates";  | 
         | 
    39   | 
         | 
    40 sub get_configs(@);  | 
         | 
    41 sub get_candidates();  | 
         | 
    42 sub verbose(@);  | 
         | 
    43 sub update_devnames($$$);  | 
         | 
    44 sub get_history(@);  | 
         | 
    45 sub calculate_level($@);  | 
         | 
    46 sub real_device($);  | 
         | 
    47 sub get_estimate($$);  | 
         | 
    48 sub devno($);  | 
         | 
    49   | 
         | 
    50 our @AT_EXIT;  | 
         | 
    51 END { $_->() foreach @AT_EXIT } | 
         | 
    52 $SIG{INT} = sub { warn "Got signal INT\n"; exit 1 }; | 
         | 
    53   | 
         | 
    54 my %CONFIG = (  | 
         | 
    55     FTP_DIR     => "backup/<LABEL>/<HOSTNAME>",  | 
         | 
    56     FTP_PASSIVE => 1,  | 
         | 
    57     FULL_CYCLE  => 7,  | 
         | 
    58     COMPRESSION_LEVEL => 6,  | 
         | 
    59 );  | 
         | 
    60   | 
         | 
    61   | 
         | 
    62 MAIN: { | 
         | 
    63   | 
         | 
    64     Getopt::Long::Configure("bundling"); | 
         | 
    65     GetOptions(  | 
         | 
    66         "l|level=i" => \$opt_level,  | 
         | 
    67         "L|label=s" => \$opt_label,  | 
         | 
    68         "d|debug:s" => sub { push @opt_debug, split /,/, $_[1] }, | 
         | 
    69         "v|verbose" => \$opt_verbose,  | 
         | 
    70 	"i|info"    => \$opt_info,  | 
         | 
    71         "dry"       => sub { $opt_dry = 1; $opt_verbose = 1 }, | 
         | 
    72         #"f|force"   => \$opt_force,  | 
         | 
    73         "h|help"    => sub { pod2usage(-exit => 0, -verbose => 1) }, | 
         | 
    74         "m|man"     => sub { pod2usage(-exit => 0, -verbose => 3) }, | 
         | 
    75         "C|config=s" => sub { @CONFIGS = ($_[1]) }, | 
         | 
    76 	"V|version" => sub { print "$ME: $VERSION\n"; exit 0 }, | 
         | 
    77 	"D|dumpdates=s" => \$opt_dumpdates,  | 
         | 
    78     ) or pod2usage;  | 
         | 
    79   | 
         | 
    80     my %cf = (%CONFIG, get_configs(@CONFIGS));  | 
         | 
    81     $cf{FTP_DIR} =~ s/<HOSTNAME>/$HOSTNAME/g; | 
         | 
    82     $cf{FTP_DIR} =~ s/<LABEL>/$opt_label/g; | 
         | 
    83   | 
         | 
    84     # get the backup candiates -> all file systems from /etc/fstab  | 
         | 
    85     # with a dump frequence > 0  | 
         | 
    86     my @devs = get_candidates();  | 
         | 
    87   | 
         | 
    88     ### %cf  | 
         | 
    89     ### @devs  | 
         | 
    90   | 
         | 
    91   | 
         | 
    92     verbose +(map { "candidate: $_->{dev} as $_->{rdev}\n" } @devs), "\n"; | 
         | 
    93   | 
         | 
    94     my @errors = ();  | 
         | 
    95     push @errors, "Need FTP_HOST (see config)." if not defined $cf{FTP_HOST}; | 
         | 
    96     push @errors, "Need KEY (see config)."      if not defined $cf{KEY}; | 
         | 
    97     push @errors, "Command `dump' not found."   if system("command -v dump >/dev/null"); | 
         | 
    98     die "$ME: pre-flight check failed:\n\t",   | 
         | 
    99 	join("\n\t" => @errors), "\n" if @errors; | 
         | 
   100   | 
         | 
   101     my $ftp;  | 
         | 
   102   | 
         | 
   103     if (not "output" ~~ \@opt_debug) { | 
         | 
   104         $ftp = new FTP(  | 
         | 
   105             $cf{FTP_HOST}, | 
         | 
   106             Passive => $cf{FTP_PASSIVE}, | 
         | 
   107             Debug   => "ftp" ~~ \@opt_debug,  | 
         | 
   108         ) or die $@;  | 
         | 
   109         $ftp->login or die $ftp->message;  | 
         | 
   110         $ftp->home($ftp->try(pwd => ()));  | 
         | 
   111         $ftp->try(binary => ());  | 
         | 
   112         $ftp->try(mkpath => $cf{FTP_DIR}); | 
         | 
   113         $ftp->try(cwd    => $cf{FTP_DIR}); | 
         | 
   114     }  | 
         | 
   115   | 
         | 
   116     # get_history the situation - we rely on $opt_dumpdates  | 
         | 
   117     @devs = get_history(@devs);  | 
         | 
   118     @devs = calculate_level($cf{FULL_CYCLE}, @devs); | 
         | 
   119   | 
         | 
   120     ### @devs  | 
         | 
   121   | 
         | 
   122     if ($opt_info) { | 
         | 
   123 	my $lr = (reverse sort { $a <=> $b } map { length $_->{rdev} } @devs)[0]; | 
         | 
   124 	my $ld = (reverse sort { $a <=> $b } map { length $_->{dev} } @devs)[0]; | 
         | 
   125 	my $ln = (reverse sort { $a <=> $b } map { length $_->{devno} } @devs)[0]; | 
         | 
   126   | 
         | 
   127 	my %l;  | 
         | 
   128 	foreach my $dev (@devs) { | 
         | 
   129 	    $l{$dev} = sprintf "%*s (%*s %*s)", -$ld => $dev->{dev}, | 
         | 
   130 				       -$lr => $dev->{rdev}, | 
         | 
   131 				       -$ln => $dev->{devno}; | 
         | 
   132 	}  | 
         | 
   133   | 
         | 
   134 	say "\ncurrent situation\n",  | 
         | 
   135 	      "------------------";  | 
         | 
   136 	foreach my $dev (@devs) { | 
         | 
   137 	    if (!$dev->{last}) { say "$l{$dev}: never" }  | 
         | 
   138 	    else { | 
         | 
   139 		for (my $i = 0; $i < @{$dev->{last}}; $i++) { | 
         | 
   140 		    say "$l{$dev}: $i ", defined($dev->{last}[$i]) ? scalar localtime($dev->{last}[$i]) : "-"; | 
         | 
   141 		}  | 
         | 
   142 	    }  | 
         | 
   143 	}  | 
         | 
   144   | 
         | 
   145 	say "\nplan for next dump\n",   | 
         | 
   146 	      "------------------";  | 
         | 
   147 	foreach my $dev (@devs) { | 
         | 
   148 	    say "$l{$dev}: level $dev->{level}"; | 
         | 
   149 	}  | 
         | 
   150   | 
         | 
   151   | 
         | 
   152 	exit;  | 
         | 
   153     }  | 
         | 
   154   | 
         | 
   155     # and now we can start doing something with our filesystems  | 
         | 
   156   DEVICE: foreach my $dev (@devs) { | 
         | 
   157         my $dir = $dev->{mountpoint}; | 
         | 
   158         $dir =~ s/_/__/g;  | 
         | 
   159         $dir =~ s/\//_/g;  | 
         | 
   160         $dir = "$cf{FTP_DIR}/$dir"; | 
         | 
   161   | 
         | 
   162         my @last;  | 
         | 
   163         if ($ftp) { | 
         | 
   164             $ftp->home();  | 
         | 
   165             $ftp->try(mkpath => $dir);  | 
         | 
   166             $ftp->try(cwd    => $dir);  | 
         | 
   167   | 
         | 
   168             #verbose "Now in @{[$ftp->pwd]}.\n" if $ftp; | 
         | 
   169   | 
         | 
   170             # examine the situation and decide about the level  | 
         | 
   171             # FIXME: currently we simply run a full dump every FULL_CYCLE  | 
         | 
   172             # days, the intermediate dumps are level 1  | 
         | 
   173             foreach (reverse sort $ftp->ls) { | 
         | 
   174                 /^(?<date>.*)\.(?<level>\d+)$/ or next;  | 
         | 
   175                 $last[$+{level}] = $+{date}; | 
         | 
   176                 last if $+{level} == 0; | 
         | 
   177             }  | 
         | 
   178         }  | 
         | 
   179   | 
         | 
   180 	# for safety we check if there is really a full dump not older than xxx days  | 
         | 
   181         if ($dev->{level} > 0) { | 
         | 
   182 	    if (!@last) { | 
         | 
   183 		$dev->{level} = 0; | 
         | 
   184 		warn "adjusted backup level to 0, last full backup missing\n";  | 
         | 
   185 	    } elsif ($NOW - str2time($last[0]) > ($cf{FULL_CYCLE} * 86_400)) { | 
         | 
   186 		$dev->{level} = 0; | 
         | 
   187 		warn sprintf "adjusted backup level to 0, last full backup is %.1f days old\n",  | 
         | 
   188 		    ($NOW - str2time $last[0])/86_400;  | 
         | 
   189 	    }  | 
         | 
   190         }  | 
         | 
   191   | 
         | 
   192         my $file = strftime("%FT%R.$dev->{level}", localtime $NOW); | 
         | 
   193         my $label = basename($dev->{rdev}); | 
         | 
   194         verbose "> $dev->{dev} ($dev->{rdev}\@$dev->{mountpoint}) to @{[$ftp->pwd]}/$file\n"; | 
         | 
   195         next if $opt_dry;  | 
         | 
   196   | 
         | 
   197         # For LVM do a snapshot, for regular partitions  | 
         | 
   198         # do nothing. But anyway the device to dump is named in $dev->{dump} | 
         | 
   199         if ($dev->{lvm}) { | 
         | 
   200   | 
         | 
   201             # we can do a snapshot  | 
         | 
   202             # FIXME: check the snapshot name is not used already  | 
         | 
   203             my $snap = "$dev->{lvm}{path}-snap.0"; | 
         | 
   204   | 
         | 
   205             verbose "Creating snapshot $snap\n";  | 
         | 
   206             system($_ =  | 
         | 
   207                   "lvcreate -s -L 1G -n $snap $dev->{lvm}{path} >/dev/null"); | 
         | 
   208             die "failed system command: $_\n" if $?;  | 
         | 
   209   | 
         | 
   210             $dev->{cleanup} = sub { | 
         | 
   211                 system "lvdisplay $snap &>/dev/null"  | 
         | 
   212                   . " && lvremove -f $snap >/dev/null";  | 
         | 
   213             };  | 
         | 
   214             push @AT_EXIT, $dev->{cleanup}; | 
         | 
   215   | 
         | 
   216             (my $device) =  | 
         | 
   217               (grep /lv name/i, `lvdisplay $snap`)[0] =~ /(\S+)\s*$/;  | 
         | 
   218   | 
         | 
   219             for (my $retries = 3 ; $retries ; $retries--) { | 
         | 
   220                 system($_ =  | 
         | 
   221                       "fsck -f @{[$opt_verbose ? '-C0' : '']} -y $device"); | 
         | 
   222                 last if not $?;  | 
         | 
   223                 warn "fsck on $device (using: $_) failed"  | 
         | 
   224                   . ($retries > 1 ? ", retrying…\n" : "") . "\n";  | 
         | 
   225             }  | 
         | 
   226   | 
         | 
   227             ($dev->{dump}) = $device; | 
         | 
   228   | 
         | 
   229         }  | 
         | 
   230         else { | 
         | 
   231             $dev->{dump} = $dev->{rdev}; | 
         | 
   232         }  | 
         | 
   233   | 
         | 
   234         ### $dev  | 
         | 
   235   | 
         | 
   236         $ENV{key} = $cf{KEY}; | 
         | 
   237         my $dumper = open(my $dump, "-|") or do { | 
         | 
   238             print <<__HEAD;  | 
         | 
   239 #! /bin/bash  | 
         | 
   240 LC_ALL=C  | 
         | 
   241 if test -t 1; then  | 
         | 
   242     cat <<___  | 
         | 
   243 HOSTNAME   : $HOSTNAME  | 
         | 
   244 DATE       : $NOW @{[scalar localtime $NOW]} | 
         | 
   245 LEVEL      : $dev->{level} | 
         | 
   246 DEVICE     : $dev->{dev} | 
         | 
   247 REAL_DEVICE: $dev->{rdev} | 
         | 
   248 MOUNTPOINT : $dev->{mountpoint} | 
         | 
   249 FSTYPE     : $dev->{fstype} | 
         | 
   250 DEVICE_NO  : $dev->{devno} | 
         | 
   251   | 
         | 
   252 # For recovery pass everything following the first  | 
         | 
   253 # ^### START to "recover -rf -". Or do one of the following  | 
         | 
   254 # lines:  | 
         | 
   255 #   sh <THIS SCRIPT> | restore -rf -  | 
         | 
   256 #   sh <(ftpipe <URL>) -pass file:/dev/tty | restore -rf -  | 
         | 
   257 ___  | 
         | 
   258     exit 0  | 
         | 
   259 fi  | 
         | 
   260 while read; do  | 
         | 
   261     test "\$REPLY" = "### START" \\  | 
         | 
   262 	&& exec openssl enc -d -blowfish "\$@"  | 
         | 
   263 done <"\$0"  | 
         | 
   264   | 
         | 
   265 ### START  | 
         | 
   266 __HEAD  | 
         | 
   267   | 
         | 
   268   | 
         | 
   269 	    update_devnames($opt_dumpdates, $dev->{rdev} => $dev->{dump}) | 
         | 
   270 		    if $opt_dumpdates;  | 
         | 
   271   | 
         | 
   272             exec "dump -$dev->{level} -L $label -f- -u -z$cf{COMPRESSION_LEVEL} $dev->{dump}" | 
         | 
   273               . "| openssl enc -pass env:key -salt -blowfish";  | 
         | 
   274             die "Can't exec dumper\n";  | 
         | 
   275         };  | 
         | 
   276   | 
         | 
   277         if ($ftp) { | 
         | 
   278             $ftp->try(put => $dump, $file);  | 
         | 
   279         }  | 
         | 
   280         else { | 
         | 
   281             print while <$dump>;  | 
         | 
   282             warn "STOPPED after the first dump\n";  | 
         | 
   283             exit;  | 
         | 
   284         }  | 
         | 
   285         $dev->{cleanup}->() if $dev->{cleanup}; | 
         | 
   286         verbose "Done.\n";  | 
         | 
   287   | 
         | 
   288 	update_devnames($opt_dumpdates, $dev->{dump} => $dev->{rdev}) | 
         | 
   289 		if $opt_dumpdates;  | 
         | 
   290     }  | 
         | 
   291   | 
         | 
   292 }  | 
         | 
   293   | 
         | 
   294 sub verbose(@) { | 
         | 
   295     return if not $opt_verbose;  | 
         | 
   296     print STDERR @_;  | 
         | 
   297 }  | 
         | 
   298   | 
         | 
   299 sub get_candidates() { | 
         | 
   300   | 
         | 
   301     # return the list of backup candidates  | 
         | 
   302   | 
         | 
   303     my @devs;  | 
         | 
   304   | 
         | 
   305     # later we need the major of the device mapper  | 
         | 
   306     my $dev_mapper = (grep /device.mapper/, slurp("/proc/devices"))[0]; | 
         | 
   307     $dev_mapper = (split " " => $dev_mapper)[0] if defined $dev_mapper;  | 
         | 
   308   | 
         | 
   309     # find all non comment lines  | 
         | 
   310     foreach (grep !/^\s*#/, slurp("/etc/fstab")) { | 
         | 
   311         my ($dev, $mp, $fstype, $options, $dump, $check) = split;  | 
         | 
   312         next if not $dump;  | 
         | 
   313   | 
         | 
   314         # $dev does not have to contain the real device  | 
         | 
   315         my $rdev = real_device($dev);  | 
         | 
   316 	my ($major, $minor) = devno($rdev);  | 
         | 
   317   | 
         | 
   318         # if it's LVM we gather more information (to support snapshots)  | 
         | 
   319         my $lvm;  | 
         | 
   320         if ($_ = (grep { /:$major:$minor\s*$/ } `lvdisplay -c`)[0] | 
         | 
   321             and /\s*(?<path>\S+?):/)  | 
         | 
   322         { | 
         | 
   323             ($lvm->{path} = $+{path}) =~ s/^\/dev\///; | 
         | 
   324         }  | 
         | 
   325   | 
         | 
   326         push @devs,  | 
         | 
   327           { | 
         | 
   328             dev        => $dev,  | 
         | 
   329             rdev       => $rdev,  | 
         | 
   330             mountpoint => $mp,  | 
         | 
   331             fstype     => $fstype,  | 
         | 
   332             lvm        => $lvm,  | 
         | 
   333             devno      => "$major:$minor",  | 
         | 
   334           };  | 
         | 
   335     }  | 
         | 
   336   | 
         | 
   337     return @devs;  | 
         | 
   338 }  | 
         | 
   339   | 
         | 
   340 sub get_configs(@) { | 
         | 
   341     local $_;  | 
         | 
   342     my %r = ();  | 
         | 
   343     foreach (grep { -f } map { (-d) ? glob("$_/*") : $_ } @_) { | 
         | 
   344   | 
         | 
   345         # check permission and ownership  | 
         | 
   346         { | 
         | 
   347             my $p = (stat)[2] & 07777;  | 
         | 
   348             my $u = (stat _)[4];  | 
         | 
   349             die  | 
         | 
   350 "$ME: $_ has wrong permissions: found @{[sprintf '%04o', $p]}, need 0600\n" | 
         | 
   351               if $p != 0600;  | 
         | 
   352             die  | 
         | 
   353               "$ME: owner of $_ ($u) is not the EUID ($EUID) of this process\n"  | 
         | 
   354               if (stat _)[4] != $EUID;  | 
         | 
   355   | 
         | 
   356             # FIXME: should check the containing directories too!  | 
         | 
   357         };  | 
         | 
   358   | 
         | 
   359 	open(my $f, $_) or die "Can't open $_: $!\n";  | 
         | 
   360         my %h = map { split /\s*=\s*/, $_, 2 } grep { !/^\s*#/ and /=/ } <$f>; | 
         | 
   361         map { chomp } values %h; | 
         | 
   362         %r = (%r, %h);  | 
         | 
   363     }  | 
         | 
   364     return %r;  | 
         | 
   365 }  | 
         | 
   366   | 
         | 
   367 { | 
         | 
   368   | 
         | 
   369     package FTP;  | 
         | 
   370     use strict;  | 
         | 
   371     use warnings;  | 
         | 
   372     use base qw(Net::FTP);  | 
         | 
   373   | 
         | 
   374     my %data;  | 
         | 
   375   | 
         | 
   376     sub new { | 
         | 
   377         my $class = shift;  | 
         | 
   378         return bless Net::FTP->new(@_) => $class;  | 
         | 
   379     }  | 
         | 
   380   | 
         | 
   381     sub try { | 
         | 
   382         my $self = shift;  | 
         | 
   383         my $func = shift;  | 
         | 
   384         $self->$func(@_)  | 
         | 
   385           or die "FTP $func failed: " . $self->message . "\n";  | 
         | 
   386     }  | 
         | 
   387   | 
         | 
   388     sub mkpath { | 
         | 
   389         my $self    = shift;  | 
         | 
   390         my $current = $self->pwd();  | 
         | 
   391         foreach (split /\/+/, $_[0]) { | 
         | 
   392             next if $self->cwd($_);  | 
         | 
   393             return undef if not $self->message ~~ /no such .*dir/i;  | 
         | 
   394             return undef if not $self->SUPER::mkdir($_);  | 
         | 
   395             return undef if not $self->cwd($_);  | 
         | 
   396         }  | 
         | 
   397         $self->cwd($current);  | 
         | 
   398     }  | 
         | 
   399   | 
         | 
   400     sub home { | 
         | 
   401         my $self = shift;  | 
         | 
   402         return $data{ ref $self }{home} = shift if @_; | 
         | 
   403         $self->try(cwd => exists $data{ ref $self }{home} | 
         | 
   404             ? $data{ ref $self }{home} | 
         | 
   405             : "/");  | 
         | 
   406         return $self->pwd();  | 
         | 
   407     }  | 
         | 
   408   | 
         | 
   409     sub get_home { return $data{ ref shift }{home} } | 
         | 
   410 }  | 
         | 
   411   | 
         | 
   412 sub update_devnames($$$) { | 
         | 
   413 	my ($file, $from, $to) = @_;  | 
         | 
   414 	open(my $f, "+>>", $file) or die "Can't open $file: $!\n";  | 
         | 
   415 	seek($f, 0, 0);  | 
         | 
   416 	my $_ = join "", <$f>;  | 
         | 
   417 	s/^$from\s/$to /mg;  | 
         | 
   418 	truncate($f, 0);  | 
         | 
   419 	# fix the dumpdates  | 
         | 
   420 	print $f $_;		  | 
         | 
   421 	close($f);  | 
         | 
   422 }  | 
         | 
   423   | 
         | 
   424 sub real_device($) { | 
         | 
   425     my $dev = shift;  | 
         | 
   426   | 
         | 
   427     if ($dev ~~ /^(LABEL|UUID)=/) { | 
         | 
   428 	# NOTE: dump is able to handle LABEL=... too, but I think  | 
         | 
   429 	# it's more easy for recovery to know the real device  | 
         | 
   430 	chomp($dev = `blkid -c /dev/null -o device -t '$dev'`);  | 
         | 
   431     }  | 
         | 
   432     $dev = realpath($dev);  | 
         | 
   433 }  | 
         | 
   434   | 
         | 
   435 sub devno($) { | 
         | 
   436     my @mm = ((stat shift)[6] >> 8, (stat _)[6] & 0xff);  | 
         | 
   437     return wantarray ? @mm : "$mm[0]:$mm[1]";  | 
         | 
   438 }  | 
         | 
   439   | 
         | 
   440   | 
         | 
   441 # put the last dump information (level and date) into  | 
         | 
   442 # the device structure - information is obtained from $opt_dumpdates  | 
         | 
   443 sub get_history(@) { | 
         | 
   444     my @devs = @_;  | 
         | 
   445     my %dd;  | 
         | 
   446   | 
         | 
   447     open(my $dd, "+>>", $opt_dumpdates);  | 
         | 
   448     seek($dd, 0, 0);  | 
         | 
   449     while (<$dd>) { | 
         | 
   450 	my ($dev, $level, $date) = /^(\S+)\s+(\d+)\s+(.{30})/ | 
         | 
   451 	    or die "Can't parse $opt_dumpdates: `$_'\n";  | 
         | 
   452 	my $rdev = real_device($dev);  | 
         | 
   453 	my $devno = devno($rdev);  | 
         | 
   454   | 
         | 
   455 	push @{$dd{$rdev}} => { | 
         | 
   456 	    dev => $dev,  | 
         | 
   457 	    rdev => real_device($dev),  | 
         | 
   458 	    level => $level,  | 
         | 
   459 	    date => str2time($date),  | 
         | 
   460 	    devno => scalar(devno(real_device($dev))),  | 
         | 
   461 	}  | 
         | 
   462     }  | 
         | 
   463     close($dd);  | 
         | 
   464   | 
         | 
   465     foreach my $dev (@devs) { | 
         | 
   466 	my $dd = $dd{$dev->{rdev}}; | 
         | 
   467   | 
         | 
   468 	if (!$dd) { | 
         | 
   469 	    $dev->{last} = undef; | 
         | 
   470 	    next;  | 
         | 
   471 	}  | 
         | 
   472   | 
         | 
   473 	foreach my $dump (@$dd) { | 
         | 
   474 	    $dev->{last}[$dump->{level}] = $dump->{date}; | 
         | 
   475 	}  | 
         | 
   476     }  | 
         | 
   477   | 
         | 
   478     ### @devs  | 
         | 
   479     return @devs;  | 
         | 
   480 }  | 
         | 
   481   | 
         | 
   482 sub get_estimate($$) { | 
         | 
   483     my ($dev, $level) = @_;  | 
         | 
   484     warn "% estimating $dev->{rdev} at level $level\n"; | 
         | 
   485     chomp(my $_ = `dump -S -$level $dev->{rdev}`); | 
         | 
   486     return $_;  | 
         | 
   487 }  | 
         | 
   488   | 
         | 
   489 sub calculate_level($@) { | 
         | 
   490     my ($cycle, @devs) = @_;  | 
         | 
   491   | 
         | 
   492     foreach my $dev (@devs) { | 
         | 
   493 	if (defined $opt_level) { | 
         | 
   494 	    $dev->{level} = $opt_level; | 
         | 
   495 	}   | 
         | 
   496 	elsif (!$dev->{last} | 
         | 
   497 	    or not $dev->{last}[0] | 
         | 
   498 	    or $NOW - $dev->{last}[0] > ($cycle * 86_400)) { | 
         | 
   499 	    $dev->{level} = 0; | 
         | 
   500 	}   | 
         | 
   501 	else { $dev->{level} = 1 } | 
         | 
   502   | 
         | 
   503 	# now we'll see if the level really saves space compared  | 
         | 
   504 	# with the next lower level  | 
         | 
   505 	my @estimates;  | 
         | 
   506 	while (my $l = $dev->{level} > 0) { | 
         | 
   507 	    $estimates[$l] //= get_estimate($dev, $l);  | 
         | 
   508 	    $estimates[$l - 1] //= get_estimate($dev, $l - 1);  | 
         | 
   509   | 
         | 
   510 	    last if my $savings = ($estimates[$l-1] - $estimates[$l]) / $estimates[$l-1] >= 0.10;  | 
         | 
   511 	    warn "% savings for level $dev->{level} on $dev->{dev} are @{[int($savings * 100)]}% ", | 
         | 
   512 		 "will use level ", $dev->{level} - 1, "\n"; | 
         | 
   513 	    --$dev->{level}; | 
         | 
   514 	}  | 
         | 
   515     }  | 
         | 
   516   | 
         | 
   517     return @devs;  | 
         | 
   518 }  | 
         | 
   519   | 
         | 
   520 #/dev/vda1 0 Thu Apr 14 12:54:31 2011 +0200  | 
         | 
   521 #/dev/vda1 1 Thu Apr 14 12:54:16 2011 +0200  | 
         | 
   522   | 
         | 
   523 __END__  | 
         | 
   524   | 
         | 
   525 =head1 NAME  | 
         | 
   526   | 
         | 
   527 ftbackup - ftp backup tool  | 
         | 
   528   | 
         | 
   529 =head1 SYNOPSIS  | 
         | 
   530   | 
         | 
   531     ftbackup [--level <level>] [options]  | 
         | 
   532   | 
         | 
   533 =head1 DESCRIPTION  | 
         | 
   534   | 
         | 
   535 The B<ftbackup> tools saves the partitions (file systems) marked in  | 
         | 
   536 F</etc/fstab> to an FTP host. It uses dump(8) for generating the backup  | 
         | 
   537 and openssl(1) for encrypting the data stream (and thus the written  | 
         | 
   538 files).  | 
         | 
   539   | 
         | 
   540 =head1 OPTIONS  | 
         | 
   541   | 
         | 
   542 =over  | 
         | 
   543   | 
         | 
   544 =item B<-D>|B<--dumpdates> I<file>  | 
         | 
   545   | 
         | 
   546 Update the I<file> as dumpdates file. (default: /var/lib/dumpdates)  | 
         | 
   547   | 
         | 
   548 =item B<-d>|B<--debug> [I<item>]  | 
         | 
   549   | 
         | 
   550 Enables debugging for the specified items (comma separated).  | 
         | 
   551 If no item is specified, just some debugging is done.  | 
         | 
   552   | 
         | 
   553 Valid items are B<ftp>, B<output>, B<devices> and currently nothing else.  | 
         | 
   554   | 
         | 
   555 =over  | 
         | 
   556   | 
         | 
   557 =item B<ftp>  | 
         | 
   558   | 
         | 
   559 This switches on debugging of the used L<Net::FTP> module.  | 
         | 
   560   | 
         | 
   561 =item B<output>  | 
         | 
   562   | 
         | 
   563 The output is not sent via FTP but to stdout. Beware!  | 
         | 
   564   | 
         | 
   565 =back  | 
         | 
   566   | 
         | 
   567 Even more debugging is shown using the DEBUG=1 environment setting.  | 
         | 
   568   | 
         | 
   569 =item B<--clean> [I<days>]  | 
         | 
   570   | 
         | 
   571 Cleanup older backups we do not need (that is: incremental backups with  | 
         | 
   572 no previous full backup. If I<days> are given, then all full backups older than  | 
         | 
   573 the number of I<days> are removed (and all incremental backups based on these  | 
         | 
   574 full backups). (default: 0 and not implemented)  | 
         | 
   575   | 
         | 
   576 =item B<--dry>  | 
         | 
   577   | 
         | 
   578 Dry run, no real backup is done, this option implies B<--verbose>. (default: off)  | 
         | 
   579   | 
         | 
   580 =item B<-f>|B<--force>  | 
         | 
   581   | 
         | 
   582 Use more power (e.g. overwrite a previous level backup and remove all  | 
         | 
   583 invalidated other backups). (default: 0 and not implemented)  | 
         | 
   584   | 
         | 
   585 =item B<-i>|B<--info>  | 
         | 
   586   | 
         | 
   587 Just output information about the last backups and exit. (default: off)  | 
         | 
   588   | 
         | 
   589 =item B<-l>|B<--level> I<level>  | 
         | 
   590   | 
         | 
   591 The backup level. Level other than "0" needs a previous  | 
         | 
   592 level 0 (full) backup. If not specified, it is choosen automagically.  | 
         | 
   593 (default: undef)  | 
         | 
   594   | 
         | 
   595 =item B<-L>|B<--label> I<label>  | 
         | 
   596   | 
         | 
   597 The label for the backup. (default: daily)  | 
         | 
   598   | 
         | 
   599 =item B<-v>|B<--verbose>  | 
         | 
   600   | 
         | 
   601 Be verbose. (default: no)  | 
         | 
   602   | 
         | 
   603 =back  | 
         | 
   604   | 
         | 
   605 =head1 FILES  | 
         | 
   606   | 
         | 
   607 =head2 Configuration  | 
         | 
   608   | 
         | 
   609 The config files are searched in the following places:  | 
         | 
   610   | 
         | 
   611     /etc/ftbackup.conf  | 
         | 
   612     ~/.ftbackup.conf  | 
         | 
   613     ./ftbackup.conf  | 
         | 
   614   | 
         | 
   615 If the location is a directory, all (not hidden) files in this directory are  | 
         | 
   616 considered to be config, if the location a file itself, this is considered to  | 
         | 
   617 be a config file. The config files have to be mode 0600 and they have to be   | 
         | 
   618 owned by the EUID running the process.  | 
         | 
   619   | 
         | 
   620 The config file may contain the following items (listed with their built in defaults)  | 
         | 
   621   | 
         | 
   622     KEY		= <no default>  | 
         | 
   623     FTP_HOST	= <no default>  | 
         | 
   624     FTP_DIR	= "backup/<LABEL>/<HOSTNAME>"  | 
         | 
   625     FTP_PASSIVE = 1  | 
         | 
   626     FULL_CYCLE	= 7  | 
         | 
   627     COMPRESSION_LEVEL = 6  | 
         | 
   628   | 
         | 
   629 =head2 F<.netrc>  | 
         | 
   630   | 
         | 
   631 You may miss the login information for the FTP server. Currently we rely on a valid  | 
         | 
   632 F<~/.netrc> entry. An example line of the F<~/.netrc>:  | 
         | 
   633   | 
         | 
   634     machine ... login ... password ...  | 
         | 
   635   | 
         | 
   636 =cut  | 
         | 
   637   | 
         | 
   638 # vim:sts=4 sw=4 aw ai sm:  | 
         |