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 = '2017-07-31'; # DATE
4             our $VERSION = '0.35'; # VERSION
5              
6 2     2   177452 use 5.010001;
  2         7  
7 2     2   13 use strict;
  2         6  
  2         50  
8 2     2   12 use warnings;
  2         5  
  2         68  
9 2     2   2906 use Log::ger;
  2         193  
  2         10  
10              
11 2     2   696 use File::chdir;
  2         2418  
  2         4812  
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   2722 require Cwd;
21              
22 25         89 my ($path) = @_;
23 25         185 $path =~ s!/+$!!;
24 25 50       159 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         48 raw=>$path, remote=>1, host=>$2,
32             user=>$1, proto=>"ssh", path=>$3,
33             };
34             } else {
35             return {
36 20         702 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         33 my $all_local = 1;
48 12         40 for (@$sources) {
49 15 100       68 if ($_->{remote}) { $all_local = 0; last }
  3         7  
  3         8  
50             }
51              
52 12         26 my $all_remote = 1;
53 12         40 for (@$sources) {
54 14 100       55 if (!$_->{remote}) { $all_remote = 0; last }
  10         28  
  10         25  
55             }
56              
57 12 100 100     94 return [400, "Sources must be all local or all remote"]
58             unless $all_remote || $all_local;
59              
60 11 100       41 if ($all_remote) {
61 2         4 my $host;
62 2         5 for (@$sources) {
63 4   66     20 $host //= $_->{host};
64             return [400, "Remote sources must all be from the same machine"]
65 4 100       15 if $host ne $_->{host};
66             }
67             }
68 10         61 [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 4059758 require File::Flock::Retry;
183 8         1435 require File::Path;
184 8         61 require File::Which;
185              
186 8         127 my %args = @_;
187              
188             # XXX schema
189 8 50       73 my $source = $args{source} or return [400, "Please specify source"];
190 8 100       66 my @sources = ref($source) eq 'ARRAY' ? @$source : ($source);
191 8         82 for (@sources) { $_ = _parse_path($_) }
  9         70  
192 8         50 my $res = _check_sources(\@sources);
193 8 50       85 return $res unless $res->[0] == 200;
194 8 50       32 my $target = $args{target} or return [400, "Please specify target"];
195 8         28 $target = _parse_path($target);
196             $target->{remote} and
197 8 50       48 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       37 ref($histories) eq 'ARRAY' or return [400, "histories must be array"];
200 8   50     69 my $backup = $args{backup} // 1;
201 8   50     56 my $rotate = $args{rotate} // 1;
202 8   100     65 my $extra_dir = $args{extra_dir} || (@sources > 1);
203              
204             # sanity
205 8 50       140 my $rsync_path = File::Which::which("rsync")
206             or return [500, "Can't find rsync in PATH"];
207              
208 8 100       1257 unless (-d $target->{abs_path}) {
209 4         41 log_debug("Creating target directory %s ...", $target->{abs_path});
210             File::Path::make_path($target->{abs_path})
211 4 50       1042 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       1776 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       180 if ($rotate) {
227 8         58 _rotate($target->{abs_path}, $histories);
228             }
229              
230 8         422 [200, "OK"];
231             }
232              
233             sub _backup {
234 8     8   551 require POSIX;
235 8         8229 require String::ShellQuote; String::ShellQuote->import;
  8         551  
236              
237 8         38 my ($sources, $target, $opts) = @_;
238             log_info("Starting backup %s ==> %s ...",
239 8         45 [map {$_->{raw}} @$sources], $target);
  9         82  
240 8         44 my $cmd;
241             $cmd = join(
242             "",
243             "nice -n19 rsync ",
244 2         69 ($opts->{extra_rsync_opts} ? map { shell_quote($_), " " }
245 1         8 @{$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       53 map({ shell_quote($_->{raw}), ($opts->{extra_dir} ? "" : "/"), " " }
  9 50       766  
    100          
    100          
252             @$sources),
253             shell_quote("$target->{abs_path}/.tmp/"),
254             );
255 8         773 log_debug("Running rsync ...");
256 8         48 log_trace("system(): $cmd");
257 8         541091 system $cmd;
258 8 50       210 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       287 if (-e "$target->{abs_path}/current") {
264 4         63 my $tspath = "$target->{abs_path}/.current.timestamp";
265 4         131 my @st = stat($tspath);
266 4   66     364 my $tstamp = POSIX::strftime(
267             "%Y-%m-%d\@%H:%M:%S+00",
268             gmtime( $st[9] || time() )); # timestamp might not exist yet
269 4         108 log_debug("rename $target->{abs_path}/current ==> ".
270             "hist.$tstamp ...");
271 4 100       298 unless (rename "$target->{abs_path}/current",
272             "$target->{abs_path}/hist.$tstamp") {
273 1         59 log_warn("Failed renaming $target->{abs_path}/current ==> ".
274             "hist.$tstamp: $!");
275             }
276 4         40 log_debug("touch $tspath ...");
277 4         63 system "touch ".shell_quote($tspath);
278             }
279              
280 8         26334 log_debug("rename $target->{abs_path}/.tmp ==> current ...");
281 8 100       679 unless (rename "$target->{abs_path}/.tmp",
282             "$target->{abs_path}/current") {
283 1         26 log_warn("Failed renaming $target->{abs_path}/.tmp ==> current: $!");
284             }
285              
286 8         56 log_info("Finished backup %s ==> %s", $sources, $target);
287             }
288              
289             sub _rotate {
290 8     8   142 require String::ShellQuote; String::ShellQuote->import;
  8         1292  
291 8         781 require Time::Local;
292              
293 8         2104 my ($target, $histories) = @_;
294 8         50 log_info("Rotating backup histories in %s (%s) ...",
295             $target, $histories);
296              
297 8         178 local $CWD = $target; # throws exception when failed
298              
299 8         766 my $now = time();
300 8         57 for my $level (1 .. @$histories) {
301 16         51 my $is_highest_level = $level == @$histories;
302 16 100       110 my $prefix = "hist" . ($level == 1 ? '' : $level);
303 16         61 my $prefix_next_level = "hist" . ($level + 1);
304 16         53 my $n = $histories->[$level - 1];
305 16         31 my $moved = 0;
306              
307 16 50       46 if ($n > 0) {
308 16         109 log_debug("Only keeping $n level-$level histories ...");
309 16         1603 my @f = reverse sort grep { !/\.tmp$/ } glob "$prefix.*";
  8         90  
310             #untaint for @f;
311 16 50       87 my $any_tagged = (grep {/t$/} @f) ? 1 : 0;
  8         121  
312 16         132 for my $f (@f[ $n .. @f - 1 ]) {
313 1         22 my ($st, $tagged) = $f =~ /[^.]+\.(.+?)(t)?$/;
314 1         11 my $f2 = "$prefix_next_level.$st";
315 1 50 33     34 if (!$is_highest_level &&
      33        
      33        
316             !$moved && ($tagged || !$any_tagged)) {
317 1         12 log_debug("Moving history level: $f -> $f2");
318 1         48 rename $f, $f2;
319 1         6 $moved++;
320 1 50       9 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__