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 = '2019-03-11'; # DATE
4             our $VERSION = '0.361'; # VERSION
5              
6 2     2   158366 use 5.010001;
  2         23  
7 2     2   11 use strict;
  2         4  
  2         53  
8 2     2   12 use warnings;
  2         3  
  2         57  
9 2     2   2878 use Log::ger;
  2         99  
  2         9  
10              
11 2     2   852 use File::chdir;
  2         3054  
  2         4544  
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   2152 require Cwd;
21              
22 25         71 my ($path) = @_;
23 25         216 $path =~ s!/+$!!;
24 25 50       129 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         34 raw=>$path, remote=>1, host=>$2,
32             user=>$1, proto=>"ssh", path=>$3,
33             };
34             } else {
35             return {
36 20         910 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   62 my ($sources) = @_;
46              
47 12         30 my $all_local = 1;
48 12         41 for (@$sources) {
49 15 100       69 if ($_->{remote}) { $all_local = 0; last }
  3         6  
  3         5  
50             }
51              
52 12         26 my $all_remote = 1;
53 12         29 for (@$sources) {
54 14 100       39 if (!$_->{remote}) { $all_remote = 0; last }
  10         13  
  10         22  
55             }
56              
57 12 100 100     135 return [400, "Sources must be all local or all remote"]
58             unless $all_remote || $all_local;
59              
60 11 100       38 if ($all_remote) {
61 2         3 my $host;
62 2         5 for (@$sources) {
63 4   66     14 $host //= $_->{host};
64             return [400, "Remote sources must all be from the same machine"]
65 4 100       13 if $host ne $_->{host};
66             }
67             }
68 10         72 [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 4042288 require File::Flock::Retry;
183 8         1321 require File::Path;
184 8         98 require File::Which;
185              
186 8         86 my %args = @_;
187              
188             # XXX schema
189 8 50       82 my $source = $args{source} or return [400, "Please specify source"];
190 8 100       79 my @sources = ref($source) eq 'ARRAY' ? @$source : ($source);
191 8         54 for (@sources) { $_ = _parse_path($_) }
  9         65  
192 8         102 my $res = _check_sources(\@sources);
193 8 50       46 return $res unless $res->[0] == 200;
194 8 50       33 my $target = $args{target} or return [400, "Please specify target"];
195 8         27 $target = _parse_path($target);
196             $target->{remote} and
197 8 50       40 return [400, "Sorry, target can't be remote at the moment"];
198 8   50     43 my $histories = $args{histories} // [-7, 4, 3];
199 8 50       46 ref($histories) eq 'ARRAY' or return [400, "histories must be array"];
200 8   50     86 my $backup = $args{backup} // 1;
201 8   50     44 my $rotate = $args{rotate} // 1;
202 8   100     60 my $extra_dir = $args{extra_dir} || (@sources > 1);
203              
204             # sanity
205 8 50       119 my $rsync_path = File::Which::which("rsync")
206             or return [500, "Can't find rsync in PATH"];
207              
208 8 100       1447 unless (-d $target->{abs_path}) {
209 4         36 log_debug("Creating target directory %s ...", $target->{abs_path});
210             File::Path::make_path($target->{abs_path})
211 4 50       1029 or return [500, "Error: Can't create target directory ".
212             "$target->{abs_path}: $!"];
213             }
214              
215 8         142 my $lock = File::Flock::Retry->lock("$target->{abs_path}/.lock");
216              
217 8 50       1724 if ($backup) {
218             _backup(
219             \@sources, $target,
220             {
221             extra_dir => $extra_dir,
222             extra_rsync_opts => $args{extra_rsync_opts},
223 8         65 });
224             }
225              
226 8 50       292 if ($rotate) {
227 8         100 _rotate($target->{abs_path}, $histories);
228             }
229              
230 8         360 [200, "OK"];
231             }
232              
233             sub _backup {
234 8     8   640 require POSIX;
235 8         6870 require String::ShellQuote; String::ShellQuote->import;
  8         608  
236              
237 8         31 my ($sources, $target, $opts) = @_;
238             log_info("Starting backup %s ==> %s ...",
239 8         48 [map {$_->{raw}} @$sources], $target);
  9         97  
240 8         40 my $cmd;
241             $cmd = join(
242             "",
243             "nice -n19 rsync ",
244 2         100 ($opts->{extra_rsync_opts} ? map { (shell_quote($_), " ") }
245 1         13 @{$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       42 map({ (shell_quote($_->{raw}), ($opts->{extra_dir} ? "" : "/"), " ") }
  9 50       682  
    100          
    100          
252             @$sources),
253             shell_quote("$target->{abs_path}/.tmp/"),
254             );
255 8         632 log_debug("Running rsync ...");
256 8         45 log_trace("system(): $cmd");
257 8         368365 system $cmd;
258 8 50       294 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       505 if (-e "$target->{abs_path}/current") {
264 4         87 my $tspath = "$target->{abs_path}/.current.timestamp";
265 4         175 my @st = stat($tspath);
266 4   66     526 my $tstamp = POSIX::strftime(
267             "%Y-%m-%d\@%H:%M:%S+00",
268             gmtime( $st[9] || time() )); # timestamp might not exist yet
269 4         152 log_debug("rename $target->{abs_path}/current ==> ".
270             "hist.$tstamp ...");
271 4 100       270 unless (rename "$target->{abs_path}/current",
272             "$target->{abs_path}/hist.$tstamp") {
273 1         57 log_warn("Failed renaming $target->{abs_path}/current ==> ".
274             "hist.$tstamp: $!");
275             }
276 4         45 log_debug("touch $tspath ...");
277 4         101 system "touch ".shell_quote($tspath);
278             }
279              
280 8         13277 log_debug("rename $target->{abs_path}/.tmp ==> current ...");
281 8 100       576 unless (rename "$target->{abs_path}/.tmp",
282             "$target->{abs_path}/current") {
283 1         53 log_warn("Failed renaming $target->{abs_path}/.tmp ==> current: $!");
284             }
285              
286 8         110 log_info("Finished backup %s ==> %s", $sources, $target);
287             }
288              
289             sub _rotate {
290 8     8   183 require String::ShellQuote; String::ShellQuote->import;
  8         1229  
291 8         1000 require Time::Local;
292              
293 8         1969 my ($target, $histories) = @_;
294 8         44 log_info("Rotating backup histories in %s (%s) ...",
295             $target, $histories);
296              
297 8         351 local $CWD = $target; # throws exception when failed
298              
299 8         941 my $now = time();
300 8         78 for my $level (1 .. @$histories) {
301 16         53 my $is_highest_level = $level == @$histories;
302 16 100       131 my $prefix = "hist" . ($level == 1 ? '' : $level);
303 16         126 my $prefix_next_level = "hist" . ($level + 1);
304 16         59 my $n = $histories->[$level - 1];
305 16         28 my $moved = 0;
306              
307 16 50       47 if ($n > 0) {
308 16         139 log_debug("Only keeping $n level-$level histories ...");
309 16         1329 my @f = reverse sort grep { !/\.tmp$/ } glob "$prefix.*";
  8         107  
310             #untaint for @f;
311 16 50       73 my $any_tagged = (grep {/t$/} @f) ? 1 : 0;
  8         56  
312 16         130 for my $f (@f[ $n .. @f - 1 ]) {
313 1         24 my ($st, $tagged) = $f =~ /[^.]+\.(.+?)(t)?$/;
314 1         12 my $f2 = "$prefix_next_level.$st";
315 1 50 33     93 if (!$is_highest_level &&
      33        
      33        
316             !$moved && ($tagged || !$any_tagged)) {
317 1         18 log_debug("Moving history level: $f -> $f2");
318 1         54 rename $f, $f2;
319 1         14 $moved++;
320 1 50       10 if ($f ne $f[0]) {
321 1         37 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__