File Coverage

blib/lib/File/RsyBak.pm
Criterion Covered Total %
statement 122 153 79.7
branch 46 76 60.5
condition 16 39 41.0
subroutine 10 10 100.0
pod 1 1 100.0
total 195 279 69.8


line stmt bran cond sub pod time code
1             package File::RsyBak;
2              
3             our $DATE = '2018-09-30'; # DATE
4             our $VERSION = '0.360'; # VERSION
5              
6 2     2   143230 use 5.010001;
  2         27  
7 2     2   11 use strict;
  2         2  
  2         47  
8 2     2   11 use warnings;
  2         329  
  2         59  
9 2     2   2650 use Log::ger;
  2         90  
  2         10  
10              
11 2     2   865 use File::chdir;
  2         3043  
  2         4153  
12              
13             require Exporter;
14             our @ISA = qw(Exporter);
15             our @EXPORT_OK = qw(backup);
16              
17             our %SPEC;
18              
19             sub _parse_path {
20 25     25   2269 require Cwd;
21              
22 25         75 my ($path) = @_;
23 25         200 $path =~ s!/+$!!;
24 25 50       135 if ($path =~ m!^(\S+)::([^/]+)/?(.*)$!) {
    100          
25             return {
26 0         0 raw=>$path, remote=>1, host=>$1,
27             proto=>"rsync", module=>$2, path=>$3,
28             };
29             } elsif ($path =~ m!([^@]+)?\@?(^\S+):(.*)$!) {
30             return {
31 5         37 raw=>$path, remote=>1, host=>$2,
32             user=>$1, proto=>"ssh", path=>$3,
33             };
34             } else {
35             return {
36 20         936 raw=>$path, remote=>0, path=>$path,
37             abs_path=>Cwd::abs_path($path)
38             };
39             }
40             }
41              
42             # rsync requires all source to be local, or remote (same host). check sources
43             # before we run rsync, so we can report the error and die earlier.
44             sub _check_sources {
45 12     12   57 my ($sources) = @_;
46              
47 12         27 my $all_local = 1;
48 12         28 for (@$sources) {
49 15 100       52 if ($_->{remote}) { $all_local = 0; last }
  3         4  
  3         6  
50             }
51              
52 12         24 my $all_remote = 1;
53 12         34 for (@$sources) {
54 14 100       46 if (!$_->{remote}) { $all_remote = 0; last }
  10         22  
  10         24  
55             }
56              
57 12 100 100     145 return [400, "Sources must be all local or all remote"]
58             unless $all_remote || $all_local;
59              
60 11 100       30 if ($all_remote) {
61 2         3 my $host;
62 2         4 for (@$sources) {
63 4   66     15 $host //= $_->{host};
64             return [400, "Remote sources must all be from the same machine"]
65 4 100       14 if $host ne $_->{host};
66             }
67             }
68 10         73 [200, "OK"];
69             }
70              
71             $SPEC{backup} = {
72             v => 1.1,
73             summary =>
74             'Backup files/directories with histories, using rsync',
75             args => {
76             source => {
77             summary => 'Director(y|ies) to backup',
78             #schema => ['any*' => {
79             # of => ['str*', ['array*' => {of=>'str*'}]]
80             #}],
81             schema => 'str*', # temp, because in pericmd when specifying as arg#0, there is a warning of JSON decoding failure
82             req => 1,
83             pos => 0,
84             },
85             target => {
86             summary => 'Backup destination',
87             schema => ['str*' => {}],
88             req => 1,
89             pos => 1,
90             },
91             histories => {
92             summary => 'Histories/history levels',
93             schema => ['array' => {
94             default => [-7, 4, 3],
95             of => 'int*',
96             }],
97             description => <<'_',
98              
99             Specifies number of backup histories to keep for level 1, 2, and so on. If
100             number is negative, specifies number of days to keep instead (regardless of
101             number of histories).
102              
103             _
104             },
105             extra_dir => {
106             summary =>
107             'Whether to force creation of source directory in target',
108             schema => ['bool' => {}],
109             description => <<'_',
110              
111             If set to 1, then backup(source => '/a', target => '/backup/a') will create
112             another 'a' directory in target, i.e. /backup/a/current/a. Otherwise, contents
113             of a/ will be directly copied under /backup/a/current/.
114              
115             Will always be set to 1 if source is more than one, but default to 0 if source
116             is a single directory. You can set this to 1 to so that behaviour when there is
117             a single source is the same as behaviour when there are several sources.
118              
119             _
120             },
121             backup => {
122             summary => 'Whether to do backup or not',
123             schema => [bool => {
124             default => 1,
125             }],
126             description => <<'_',
127              
128             If backup=1 and rotate=0 then will only create new backup without rotating
129             histories.
130              
131             _
132             },
133             rotate => {
134             summary => 'Whether to do rotate after backup or not',
135             schema => [bool => {
136             default => 1,
137             }],
138             description => <<'_',
139              
140             If backup=0 and rotate=1 then will only do history rotating.
141              
142             _
143             },
144             extra_rsync_opts => {
145             summary => 'Pass extra options to rsync command',
146             schema => [array => {
147             of => 'str*',
148             }],
149             description => <<'_',
150              
151             Extra options to pass to rsync command when doing backup. Note that the options
152             will be shell quoted, , so you should pass it unquoted, e.g. ['--exclude',
153             '/Program Files'].
154              
155             _
156             },
157             },
158              
159             examples => [
160             {
161             argv => ['/home/jajang/mydata','/backup/jajang/mydata'],
162             test => 0,
163             'x.doc.show_result' => 0,
164             description => <<'_',
165              
166             Backup /home/jajang/mydata to /backup/jajang/mydata using the default number of
167             histories ([-7, 4, 3]).
168              
169             _
170             },
171             ],
172              
173             deps => {
174             all => [
175             {prog => 'nice'},
176             {prog => 'rsync'}, # XXX not needed if backup=0
177             {prog => 'rm'}, # XXX not needed if rotate=0
178             ],
179             },
180             };
181             sub backup {
182 8     8 1 4044613 require File::Flock::Retry;
183 8         1058 require File::Path;
184 8         62 require File::Which;
185              
186 8         76 my %args = @_;
187              
188             # XXX schema
189 8 50       86 my $source = $args{source} or return [400, "Please specify source"];
190 8 100       73 my @sources = ref($source) eq 'ARRAY' ? @$source : ($source);
191 8         75 for (@sources) { $_ = _parse_path($_) }
  9         74  
192 8         92 my $res = _check_sources(\@sources);
193 8 50       32 return $res unless $res->[0] == 200;
194 8 50       50 my $target = $args{target} or return [400, "Please specify target"];
195 8         20 $target = _parse_path($target);
196             $target->{remote} and
197 8 50       51 return [400, "Sorry, target can't be remote at the moment"];
198 8   50     33 my $histories = $args{histories} // [-7, 4, 3];
199 8 50       44 ref($histories) eq 'ARRAY' or return [400, "histories must be array"];
200 8   50     82 my $backup = $args{backup} // 1;
201 8   50     42 my $rotate = $args{rotate} // 1;
202 8   100     47 my $extra_dir = $args{extra_dir} || (@sources > 1);
203              
204             # sanity
205 8 50       124 my $rsync_path = File::Which::which("rsync")
206             or return [500, "Can't find rsync in PATH"];
207              
208 8 100       1529 unless (-d $target->{abs_path}) {
209 4         42 log_debug("Creating target directory %s ...", $target->{abs_path});
210             File::Path::make_path($target->{abs_path})
211 4 50       1038 or return [500, "Error: Can't create target directory ".
212             "$target->{abs_path}: $!"];
213             }
214              
215 8         159 my $lock = File::Flock::Retry->lock("$target->{abs_path}/.lock");
216              
217 8 50       1659 if ($backup) {
218             _backup(
219             \@sources, $target,
220             {
221             extra_dir => $extra_dir,
222             extra_rsync_opts => $args{extra_rsync_opts},
223 8         67 });
224             }
225              
226 8 50       274 if ($rotate) {
227 8         112 _rotate($target->{abs_path}, $histories);
228             }
229              
230 8         362 [200, "OK"];
231             }
232              
233             sub _backup {
234 8     8   547 require POSIX;
235 8         5541 require String::ShellQuote; String::ShellQuote->import;
  8         632  
236              
237 8         29 my ($sources, $target, $opts) = @_;
238             log_info("Starting backup %s ==> %s ...",
239 8         36 [map {$_->{raw}} @$sources], $target);
  9         100  
240 8         42 my $cmd;
241             $cmd = join(
242             "",
243             "nice -n19 rsync ",
244 2         296 ($opts->{extra_rsync_opts} ? map { (shell_quote($_), " ") }
245 1         14 @{$opts->{extra_rsync_opts}} : ()),
246             "-a --del --force --ignore-errors --ignore-existing ",
247             (log_is_debug() ? "-v " : ""),
248             ((-e "$target->{abs_path}/current") ?
249             "--link-dest ".shell_quote("$target->{abs_path}/current")." "
250             : ""),
251 8 100       55 map({ (shell_quote($_->{raw}), ($opts->{extra_dir} ? "" : "/"), " ") }
  9 50       707  
    100          
    100          
252             @$sources),
253             shell_quote("$target->{abs_path}/.tmp/"),
254             );
255 8         612 log_debug("Running rsync ...");
256 8         37 log_trace("system(): $cmd");
257 8         350975 system $cmd;
258 8 50       370 log_warn("rsync didn't succeed ($?)".
259             ", please recheck") if $?;
260              
261             # but continue anyway, half backups are better than nothing
262              
263 8 100       502 if (-e "$target->{abs_path}/current") {
264 4         90 my $tspath = "$target->{abs_path}/.current.timestamp";
265 4         163 my @st = stat($tspath);
266 4   66     413 my $tstamp = POSIX::strftime(
267             "%Y-%m-%d\@%H:%M:%S+00",
268             gmtime( $st[9] || time() )); # timestamp might not exist yet
269 4         177 log_debug("rename $target->{abs_path}/current ==> ".
270             "hist.$tstamp ...");
271 4 100       252 unless (rename "$target->{abs_path}/current",
272             "$target->{abs_path}/hist.$tstamp") {
273 1         58 log_warn("Failed renaming $target->{abs_path}/current ==> ".
274             "hist.$tstamp: $!");
275             }
276 4         41 log_debug("touch $tspath ...");
277 4         88 system "touch ".shell_quote($tspath);
278             }
279              
280 8         11308 log_debug("rename $target->{abs_path}/.tmp ==> current ...");
281 8 100       641 unless (rename "$target->{abs_path}/.tmp",
282             "$target->{abs_path}/current") {
283 1         59 log_warn("Failed renaming $target->{abs_path}/.tmp ==> current: $!");
284             }
285              
286 8         106 log_info("Finished backup %s ==> %s", $sources, $target);
287             }
288              
289             sub _rotate {
290 8     8   260 require String::ShellQuote; String::ShellQuote->import;
  8         1585  
291 8         853 require Time::Local;
292              
293 8         1654 my ($target, $histories) = @_;
294 8         40 log_info("Rotating backup histories in %s (%s) ...",
295             $target, $histories);
296              
297 8         369 local $CWD = $target; # throws exception when failed
298              
299 8         960 my $now = time();
300 8         95 for my $level (1 .. @$histories) {
301 16         56 my $is_highest_level = $level == @$histories;
302 16 100       101 my $prefix = "hist" . ($level == 1 ? '' : $level);
303 16         145 my $prefix_next_level = "hist" . ($level + 1);
304 16         47 my $n = $histories->[$level - 1];
305 16         30 my $moved = 0;
306              
307 16 50       42 if ($n > 0) {
308 16         129 log_debug("Only keeping $n level-$level histories ...");
309 16         1309 my @f = reverse sort grep { !/\.tmp$/ } glob "$prefix.*";
  8         106  
310             #untaint for @f;
311 16 50       71 my $any_tagged = (grep {/t$/} @f) ? 1 : 0;
  8         54  
312 16         161 for my $f (@f[ $n .. @f - 1 ]) {
313 1         30 my ($st, $tagged) = $f =~ /[^.]+\.(.+?)(t)?$/;
314 1         11 my $f2 = "$prefix_next_level.$st";
315 1 50 33     64 if (!$is_highest_level &&
      33        
      33        
316             !$moved && ($tagged || !$any_tagged)) {
317 1         17 log_debug("Moving history level: $f -> $f2");
318 1         42 rename $f, $f2;
319 1         12 $moved++;
320 1 50       9 if ($f ne $f[0]) {
321 1         34 rename $f[0], "$f[0]t";
322             }
323             } else {
324 0           log_debug("Removing history: $f ...");
325 0           system "nice -n19 rm -rf " . shell_quote($f);
326             }
327             }
328             } else {
329 0           $n = -$n;
330 0           log_debug("Only keeping $n day(s) of level-$level histories ...");
331 0           my @f = reverse sort grep { !/\.tmp$/ } glob "$prefix.*";
  0            
332 0 0         my $any_tagged = ( grep {/t$/} @f ) ? 1 : 0;
  0            
333 0           for my $f (@f) {
334 0           my ($st, $tagged) = $f =~ /[^.]+\.(.+?)(t)?$/;
335 0           my $f2 = "$prefix_next_level.$st";
336 0           my $t;
337 0           $st =~ /(\d\d\d\d)-(\d\d)-(\d\d)\@(\d\d):(\d\d):(\d\d)\+00/;
338 0 0         $t = Time::Local::timegm($6, $5, $4, $3, $2 - 1, $1) if $1;
339 0 0 0       unless ($st && $t) {
340 0           log_warn("Wrong format of history, ignored: $f");
341 0           next;
342             }
343 0 0         if ($t > $now) {
344 0           log_warn("History in the future, ignored: $f");
345 0           next;
346             }
347 0           my $delta = ($now - $t) / 86400;
348 0 0         if ($delta > $n) {
349 0 0 0       if (!$is_highest_level &&
      0        
      0        
350             !$moved && ( $tagged || !$any_tagged)) {
351 0           log_debug("Moving history level: $f -> $f2");
352 0           rename $f, $f2;
353 0           $moved++;
354 0 0         if ($f ne $f[0]) {
355 0           rename $f[0], "$f[0]t";
356             }
357             } else {
358 0           log_debug("Removing history: $f ...");
359 0           system "nice -n19 rm -rf " . shell_quote($f);
360             }
361             }
362             }
363             }
364             }
365             }
366              
367             1;
368             # ABSTRACT: Backup files/directories with histories, using rsync
369              
370             __END__