File Coverage

bin/sibs
Criterion Covered Total %
statement 140 191 73.3
branch 56 110 50.9
condition 10 25 40.0
subroutine 21 25 84.0
pod n/a
total 227 351 64.6


line stmt bran cond sub pod time code
1             #!/usr/bin/env perl
2 3     3   1388 use strict;
  3         4  
  3         95  
3 3     3   9 use warnings;
  3         3  
  3         94  
4 3     3   9 use Fcntl qw( O_CREAT O_EXCL );
  3         3  
  3         157  
5 3     3   1172 use POSIX ();
  3         13116  
  3         59  
6 3     3   1182 use URI;
  3         8038  
  3         4873  
7              
8             our @DEFAULT_EXCLUDE = qw( .cache .cpanm .gvfs Downloads Dropbox Trash );
9             our $SSH = 'ssh';
10             our $SSH_KEYGEN = 'ssh-keygen';
11             our $LOCK;
12              
13             $ENV{HOME} ||= 'ENVIRONMENT_HOME_IS_NOT_SET';
14              
15             END {
16 1 50   1   360 unlink $LOCK if $LOCK;
17             }
18              
19             sub run_rsync {
20 0     0   0 my $self = shift;
21 0         0 my $uri = $self->{destination};
22 0         0 my $lock = sprintf '%s.backup.lock', $self->{config};
23 0         0 my @options = qw( -az --delete-after --numeric-ids --relative );
24              
25 0 0       0 push @options, map {qq(--exclude=$_)} @{$self->{exclude} || []};
  0         0  
  0         0  
26 0 0       0 push @options, '--verbose' if $self->{verbose};
27 0         0 push @options, @{$self->{source}};
  0         0  
28              
29 0 0       0 if (my $remote_host = $self->remote_host) {
30 0         0 push @options, sprintf '%s@%s:%s/incoming', $uri->userinfo, $remote_host, $uri->path;
31             }
32             else {
33 0         0 push @options, sprintf '%s/incoming', $uri->path;
34             }
35              
36 0 0       0 IO::File->new->open($lock, O_CREAT | O_EXCL) or die "Already backing up. ($lock)\n";
37 0         0 $LOCK = $lock;
38 0         0 $self->_system(rsync => @options);
39             }
40              
41             sub create_sibs_config {
42 1     1   269 my $self = shift;
43 1         4 my $tmp = sprintf '%s.tmp', $self->{config};
44              
45 1 50       56 open my $CONFIG, '>', $tmp or die "Cannot write $tmp: $!\n";
46 1         2 local $_;
47 1   33     6 $self->_log($@ || "Creating '$self->{config}' from user input...");
48              
49 1         7 print $CONFIG "{\n";
50 1 50       3 printf $CONFIG " email => '%s',\n", $_ if $self->_read('email');
51 1 50       11 printf $CONFIG " exclude => [qw( %s )],\n", $_ if $self->_read('exclude');
52 1 50       5 printf $CONFIG " source => [qw( %s )],\n", $_ if $self->_read('source');
53 1 50       3 printf $CONFIG " destination => '%s',\n", $_ if $self->_read('destination');
54 1         4 print $CONFIG "}\n";
55              
56 1 50       32 close $CONFIG or die "Could not write '$tmp': $!\n";
57 1 50       47 rename $tmp, $self->{config} or die "Could not write '$self->{config}: $!'\n";
58             }
59              
60             sub add_backup_host_to_ssh_config {
61 2     2   2833 my $self = shift;
62 2         6 my $moniker = $self->remote_host;
63 2         8 my $file = $self->ssh_file('config');
64              
65 2 100       43 if (-r $file) {
66 1 50       20 open my $CONFIG, '<', $file or die "Could not open $file: $!";
67 1         12 while (<$CONFIG>) {
68 2 100       22 next unless /Host\s+$moniker/;
69 1         6 $self->_log("Host $moniker exists in $file.");
70 1         8 return 1;
71             }
72             }
73              
74 1         11 $self->_log("Adding $moniker to $file");
75 1 50       74 open my $CONFIG, '>>', $file or die "Cannot write to $file: $!";
76 1         4 printf $CONFIG "\nHost %s\n", $self->remote_host;
77 1         4 printf $CONFIG " Hostname %s\n", $self->{destination}->host;
78 1         16 printf $CONFIG " IdentityFile %s\n", $self->ssh_file('sibs_dsa');
79 1         47 close $CONFIG;
80             }
81              
82             sub create_identity_file {
83 1     1   6394 my $self = shift;
84 1         4 my $file = $self->ssh_file('sibs_dsa');
85 1         1 my $identity;
86              
87 1 50       13 if (-r $file) {
88 0         0 $self->_log("Identity file '$file' exists");
89             }
90             else {
91 1         5 $self->_log("Creating $file with empty password using ssh-keygen ...");
92 1         9 $self->_system($SSH_KEYGEN => qw( -q -b 4096 -t rsa ), -N => '', -f => $file);
93             }
94              
95 1         14 $self->_log("Copying pub key to remote host ...");
96 1 50       37 open my $IDENTITY, '<', "$file.pub" or die "Cannot read $file.pub: $!";
97 1     1   11 $self->run_sibs_remote(sub { readline $IDENTITY }, 'remote-init');
  1         15  
98             }
99              
100             sub remote_add_pub_key {
101 2     2   1597 my ($self, $key) = @_;
102 2         5 my $file = $self->ssh_file('authorized_keys');
103              
104 2 100       28 if (-r $file) {
105 1         3 my $match = quotemeta $key;
106 1 50       18 open my $AUTHORIZED_KEYS, '<', $file or die "Could not open $file: $!";
107 1         13 while (<$AUTHORIZED_KEYS>) {
108 1 50       18 next unless /$match/;
109 1         4 $self->_log("Remote host has pub key");
110 1         7 return 0;
111             }
112             }
113              
114 1 50       57 open my $AUTHORIZED_KEYS, '>>', $file or die "Could not append to $file: $!\n";
115 1         4 print $AUTHORIZED_KEYS $key;
116 1 50       13 print $AUTHORIZED_KEYS "\n" unless $key =~ /\n$/;
117 1         25 close $AUTHORIZED_KEYS;
118 1         5 $self->_log("Pub key added to remote authorized_keys.");
119 1         4 return 1;
120             }
121              
122             sub remote_host {
123 4     4   3 my $self = shift;
124 4 50       28 my $host = $self->{destination}->host or return '';
125 4         296 my $moniker = "sibs-$host";
126              
127 4         6 $moniker =~ s/\./-/g;
128 4         17 $moniker;
129             }
130              
131             sub load_config {
132 1     1   4 my $self = shift;
133 1         1 my $config;
134              
135 1 50       19 open my $CONFIG, '<', $self->{config} or die "Cannot read $self->{config}: $! Run '$0 setup'\n";
136 1         13 $config = join '', <$CONFIG>;
137 1     1   43 $config = eval <<" CONFIG";
  1     1   5  
  1     1   1  
  1         28  
  1         4  
  1         0  
  1         25  
  1         3  
  1         15  
  1         71  
138             use strict;
139             use warnings;
140             use File::Basename;
141             $config
142             CONFIG
143              
144 1 50       2 $config or die "Invalid config file: ($@)\n";
145 1   50     2 $config->{exclude} ||= [@DEFAULT_EXCLUDE];
146 1   50     4 $config->{source} ||= [$ENV{HOME}];
147 1   50     5 $config->{destination} = URI->new($config->{destination} || '');
148              
149 1         6180 @{$self}{keys %$config} = values %$config;
  1         9  
150              
151 1         2 for my $m (qw( scheme path )) {
152 2 50       141 next if $config->{destination}->$m;
153 0         0 die "[$self->{config}] Missing '$m' part for 'destination' URI\n";
154             }
155              
156 1 50       10 $config->{destination}->scheme eq 'rsync'
157             or die "[$self->{config}] Only rsync:// is supported for 'destination' URI\n";
158             }
159              
160             sub run_sibs_remote {
161 1     1   4 my ($self, @args) = @_;
162 1 50   0   6 my $stdin = ref $args[0] eq 'CODE' ? shift @args : sub {''};
  0         0  
163 1         4 my @cmd;
164              
165 1 50       5 if (my $remote_host = $self->remote_host) {
166 1         7 @cmd = ($SSH => '-l' => $self->{destination}->userinfo, $remote_host);
167             }
168              
169 1 50       46 unshift @args, '--silent' if $self->{silent};
170 1 50       4 unshift @args, '--verbose' if $self->{verbose};
171 1         4 push @cmd, qq(perl - @args);
172              
173 1 50       996 open my $SSH, '|-', @cmd or die "Cannot start 'sibs @args' remote: $!";
174 1 50       34 open my $SELF, '<', __FILE__ or die "Cannot read $0: $!";
175 1         244 print $SSH $_ while <$SELF>;
176 1         3 print $SSH "\n__DATA__\n";
177 1         5 print $SSH $self->$stdin;
178 1         915 close $SSH; # TODO: do i need to wait?
179             }
180              
181             sub ssh_file {
182 7     7   924 my ($self, $file) = @_;
183              
184 7 100       24 if (!$self->{ssh_dir}) {
185 2 50 50     156 mkdir "$ENV{HOME}/.ssh" or die "Could not mkdir $ENV{HOME}/.ssh: $!" unless -d "$ENV{HOME}/.ssh";
186 2         33 chmod 0700, "$ENV{HOME}/.ssh";
187 2         7 $self->{ssh_dir} = "$ENV{HOME}/.ssh";
188             }
189              
190 7 50       13 return $self->{ssh_dir} unless $file;
191 7         23 return join '/', $self->{ssh_dir}, $file;
192             }
193              
194             sub _backup_name {
195 0   0 0   0 POSIX::strftime($_[0]->{format} || '%d-%H', localtime);
196             }
197              
198             sub _log {
199 8     8   13 my $self = shift;
200 8         262 my $min = (localtime)[1];
201 8         58 my $hour = (localtime)[2];
202              
203 8 50       33 return if $self->{silent};
204 0         0 warn sprintf "[%02s:%02s] %s\n", $hour, $min, join ' ', @_;
205             }
206              
207             sub _read {
208 0     0   0 my ($self, $k) = @_;
209 0         0 my $v = $self->{$k};
210 0 0       0 $v = join ' ', @$v if ref $v eq 'ARRAY';
211 0         0 local $| = 1;
212 0         0 print $k;
213 0 0       0 printf " ($v)", if $v;
214 0         0 print ": ";
215 0         0 $_ = ;
216 0         0 chomp;
217 0   0     0 $_ ||= $v;
218             }
219              
220             sub _system {
221 1     1   3 my ($self, $program, @options) = @_;
222              
223 1         2 for my $path (qw( /bin /usr/bin /usr/local/bin )) {
224 3 50       29 next unless -x "$path/$program";
225 0         0 $program = "$path/$program";
226 0         0 last;
227             }
228              
229 1 100       4 $self->_log(join ' ', map { length $_ ? $_ : '""' } $program, @options);
  10         15  
230 1         2661 system $program => @options;
231             }
232              
233             sub run {
234 4     4   19 my ($self, @args) = @_;
235 4         2 my $action = 'help';
236 4         4 my $i = 0;
237              
238 4         12 while ($i < @args) {
239 6 100 50     35 $self->{config} = splice @args, $i, 1, () and next if -f $args[$i];
240 5 100 50     18 $self->{verbose} = splice @args, $i, 1, () and next if $args[$i] =~ /^--?v/;
241 3 100 50     15 $self->{silent} = splice @args, $i, 1, () and next if $args[$i] =~ /^--?s/;
242 1         3 $i++;
243             }
244              
245 4 100       5 $action = shift @args if @args;
246 4   66     15 $self->{config} ||= "$ENV{HOME}/.sibs.conf";
247              
248 4 50       19 if ($action eq 'setup') {
    50          
    50          
    50          
    50          
    50          
    50          
249 0         0 $self->create_sibs_config until eval { $self->load_config };
  0         0  
250 0         0 $self->_log("Created $self->{config}");
251 0         0 $self->add_backup_host_to_ssh_config;
252 0         0 $self->create_identity_file;
253             }
254             elsif ($action eq 'backup') {
255 0         0 $self->load_config;
256 0         0 $self->run_rsync;
257 0         0 $self->run_sibs_remote('remote-archive', $self->{destination}->path, $self->_backup_name);
258             }
259             elsif ($action eq 'man') {
260 0         0 exec perldoc => 'App::sibs';
261             }
262             elsif ($action eq 'remote-init') {
263 0         0 $self->remote_add_pub_key(eval 'do { local $/; }');
264             }
265             elsif ($action eq 'remote-archive') {
266 0         0 my ($dir, $name) = @args;
267 0 0       0 chdir $dir or die "Cannot chdir $dir: $!\n";
268 0 0       0 $self->_system(rm => -r => $name) if -d $name;
269 0         0 $self->_system(cp => "-al" => "incoming" => $name);
270 0         0 $self->_system(touch => $name);
271             }
272             elsif ($action eq 'version') {
273 0         0 require App::sibs;
274 0         0 print App::sibs->VERSION, "\n";
275             }
276             elsif (!$ENV{HARNESS_IS_VERBOSE}) {
277 0         0 print <<' HELP';
278             sibs man
279             sibs setup
280             sibs backup
281             sibs version
282             HELP
283             }
284              
285 4         11 return 0;
286             }
287              
288             exit +(bless {})->run(@ARGV) unless defined wantarray;
289             bless {};