]> git.proxmox.com Git - pve-zsync.git/blob - pve-zsync
47e7b590f5a63e0ea7761fb80b7b2b853fa33387
[pve-zsync.git] / pve-zsync
1 #!/usr/bin/perl
2
3 my $PROGNAME = "pve-zsync";
4 my $CONFIG_PATH = '/var/lib/'.$PROGNAME.'/';
5 my $CONFIG = "$PROGNAME.cfg";
6 my $CRONJOBS = '/etc/cron.d/'.$PROGNAME;
7 my $VMCONFIG = '/var/lib/'.$PROGNAME.'/';
8 my $PATH = "/usr/sbin/";
9 my $QEMU_CONF = '/etc/pve/local/qemu-server/';
10 my $DEBUG = 0;
11
12 use strict;
13 use warnings;
14 use Data::Dumper qw(Dumper);
15 use Fcntl qw(:flock SEEK_END);
16 use Getopt::Long;
17 use Switch;
18
19 check_bin ('cstream');
20 check_bin ('zfs');
21 check_bin ('ssh');
22 check_bin ('scp');
23
24 sub check_bin {
25 my ($bin) = @_;
26
27 foreach my $p (split (/:/, $ENV{PATH})) {
28 my $fn = "$p/$bin";
29 if (-x $fn) {
30 return $fn;
31 }
32 }
33
34 warn "unable to find command '$bin'\n";
35 }
36
37 sub cut_to_width {
38 my ($text, $max) = @_;
39
40 return $text if (length($text) <= $max);
41 my @spl = split('/', $text);
42
43 my $count = length($spl[@spl-1]);
44 return "..\/".substr($spl[@spl-1],($count-$max)+3 ,$count) if $count > $max;
45
46 $count += length($spl[0]) if @spl > 1;
47 return substr($spl[0], 0, $max-4-length($spl[@spl-1]))."\/..\/".$spl[@spl-1] if $count > $max;
48
49 my $rest = 1 ;
50 $rest = $max-$count if ($max-$count > 0);
51
52 return "$spl[0]".substr($text, length($spl[0]), $rest)."..\/".$spl[@spl-1];
53 }
54
55 sub lock {
56 my ($fh) = @_;
57 flock($fh, LOCK_EX) or die "Cannot lock config - $!\n";
58
59 seek($fh, 0, SEEK_END) or die "Cannot seek - $!\n";
60 }
61
62 sub unlock {
63 my ($fh) = @_;
64 flock($fh, LOCK_UN) or die "Cannot unlock config- $!\n";
65 }
66
67 sub check_config {
68 my ($source, $name, $cfg) = @_;
69
70 if ($source->{vmid} && $cfg->{$source->{vmid}}->{$name}->{locked}){
71 return "active" if $cfg->{$source->{vmid}}->{$name}->{locked} eq 'yes';
72 return "exist" if $cfg->{$source->{vmid}}->{$name}->{locked} eq 'no';
73 } elsif ($cfg->{$source->{abs_path}}->{$name}->{locked}) {
74 return "active" if $cfg->{$source->{abs_path}}->{$name}->{locked} eq 'yes';
75 return "exist" if $cfg->{$source->{abs_path}}->{$name}->{locked} eq 'no';
76 }
77
78 return undef;
79 }
80
81
82 sub check_pool_exsits {
83 my ($pool, $ip) = @_;
84
85 my $cmd = '';
86 $cmd = "ssh root\@$ip " if $ip;
87 $cmd .= "zfs list $pool -H";
88 eval {
89 run_cmd($cmd);
90 };
91
92 if($@){
93 return 1;
94 }
95 return undef;
96 }
97
98 sub write_to_config {
99 my ($cfg) = @_;
100
101 open(my $fh, ">", "$CONFIG_PATH$CONFIG")
102 or die "cannot open >$CONFIG_PATH$CONFIG: $!\n";
103
104 my $text = decode_config($cfg);
105
106 print($fh $text);
107
108 close($fh);
109 }
110
111 sub read_from_config {
112
113 unless(-e "$CONFIG_PATH$CONFIG") {
114 return undef;
115 }
116
117 open(my $fh, "<", "$CONFIG_PATH$CONFIG")
118 or die "cannot open > $CONFIG_PATH$CONFIG: $!\n";
119
120 $/ = undef;
121
122 my $text = <$fh>;
123
124 unlock($fh);
125
126 close($fh);
127
128 my $cfg = encode_config($text);
129
130 return $cfg;
131 }
132
133 sub decode_config {
134 my ($cfg) = @_;
135 my $raw = '';
136 foreach my $source (sort keys%{$cfg}){
137 foreach my $sync_name (sort keys%{$cfg->{$source}}){
138 $raw .= "$source: $sync_name\n";
139 foreach my $parameter (sort keys%{$cfg->{$source}->{$sync_name}}){
140 $raw .= "\t$parameter: $cfg->{$source}->{$sync_name}->{$parameter}\n";
141 }
142 }
143 }
144 return $raw;
145 }
146
147 sub encode_config {
148 my ($raw) = @_;
149 my $cfg = {};
150 my $source;
151 my $check = 0;
152 my $sync_name;
153
154 while ($raw && $raw =~ s/^(.*?)(\n|$)//) {
155 my $line = $1;
156
157 next if $line =~ m/^\#/;
158 next if $line =~ m/^\s*$/;
159
160 if ($line =~ m/^(\t| )(\w+): (.+)/){
161 my $par = $2;
162 my $value = $3;
163
164 if ($par eq 'source_pool') {
165 $cfg->{$source}->{$sync_name}->{$par} = $value;
166 die "error in Config: SourcePool value doubled\n" if ($check & 1);
167 $check += 1;
168 } elsif ($par eq 'source_ip') {
169 $cfg->{$source}->{$sync_name}->{$par} = $value;
170 die "error in Config: SourceIP value doubled\n" if ($check & 2);
171 $check += 2;
172 } elsif ($par eq 'locked') {
173 $cfg->{$source}->{$sync_name}->{$par} = $value;
174 die "error in Config: Locked value doubled\n" if ($check & 4);
175 $check += 4;
176 } elsif ($par eq 'method') {
177 $cfg -> {$source}->{$sync_name}->{$par} = $value;
178 die "error in Config: Method value doubled\n" if ($check & 8);
179 $check += 8;
180 } elsif ($par eq 'interval') {
181 $cfg -> {$source}->{$sync_name}->{$par} = $value;
182 die "error in Config: Iterval value doubled\n" if ($check & 16);
183 $check += 16;
184 } elsif ($par eq 'limit') {
185 $cfg -> {$source}->{$sync_name}->{$par} = $value;
186 die "error in Config: Limit value doubled\n" if ($check & 32);
187 $check += 32;
188 } elsif ($par eq 'dest_pool') {
189 $cfg -> {$source}->{$sync_name}->{$par} = $value;
190 die "error in Config: DestPool value doubled\n" if ($check & 64);
191 $check += 64;
192 } elsif ($par eq 'dest_ip') {
193 $cfg -> {$source}->{$sync_name}->{$par} = $value;
194 die "error in Config: DestIp value doubled\n" if ($check & 128);
195 $check += 128;
196 } elsif ($par eq 'dest_path') {
197 $cfg -> {$source}->{$sync_name}->{$par} = $value;
198 die "error in Config: DestPath value doubled\n" if ($check & 256);
199 $check += 256;
200 } elsif ($par eq 'source_path') {
201 $cfg -> {$source}->{$sync_name}->{$par} = $value;
202 die "error in Config: SourcePath value doubled\n" if ($check & 512);
203 $check += 512;
204 } elsif ($par eq 'vmid') {
205 $cfg -> {$source}->{$sync_name}->{$par} = $value;
206 die "error in Config: Vmid value doubled\n" if ($check & 1024);
207 $check += 1024;
208 } elsif ($par =~ 'lsync') {
209 $cfg->{$source}->{$sync_name}->{$par} = $value;
210 die "error in Config: lsync value doubled\n" if ($check & 2048);
211 $check += 2048;
212 } elsif ($par =~ 'maxsnap') {
213 $cfg->{$source}->{$sync_name}->{$par} = $value;
214 die "error in Config: maxsnap value doubled\n" if ($check & 4096);
215 $check += 4096;
216 } else {
217 die "error in Config\n";
218 }
219 } elsif ($line =~ m/^((\d+.\d+.\d+.\d+):)?([\w\-\_\/]+): (.+){0,1}/){
220 $source = $3;
221 $sync_name = $4 ? $4 : 'default' ;
222 $cfg->{$source}->{$sync_name} = undef;
223 $cfg->{$source}->{$sync_name}->{source_ip} = $2 if $2;
224 $check = 0;
225 }
226 }
227 return $cfg;
228 }
229
230 sub parse_target {
231 my ($text) = @_;
232
233 if ($text =~ m/^((\d+.\d+.\d+.\d+):)?((\w+)\/?)([\w\/\-\_]*)?$/) {
234
235 die "Input not valid\n" if !$3;
236 my $tmp = $3;
237 my $target = {};
238
239 if ($2) {
240 $target->{ip} = $2 ;
241 }
242
243 if ($tmp =~ m/^(\d\d\d+)$/){
244 $target->{vmid} = $tmp;
245 } else {
246 $target->{pool} = $4;
247 my $abs_path = $4;
248 if ($5) {
249 $target->{path} = "\/$5";
250 $abs_path .= "\/$5";
251 }
252 $target->{abs_path} = $abs_path;
253 }
254
255 return $target;
256 }
257 die "Input not valid\n";
258 }
259
260 sub list {
261
262 my $cfg = read_from_config("$CONFIG_PATH$CONFIG");
263
264 my $list = sprintf("%-25s%-15s%-7s%-20s%-10s%-5s\n" , "SOURCE", "NAME", "ACTIVE", "LAST SYNC", "INTERVAL", "TYPE");
265
266 foreach my $source (sort keys%{$cfg}){
267 foreach my $sync_name (sort keys%{$cfg->{$source}}){
268 my $source_name = $source;
269 $source_name = $cfg->{$source}->{$sync_name}->{source_ip}.":".$source if $cfg->{$source}->{$sync_name}->{source_ip};
270 $list .= sprintf("%-25s%-15s", cut_to_width($source_name,25), cut_to_width($sync_name,15));
271 $list .= sprintf("%-7s",$cfg->{$source}->{$sync_name}->{locked});
272 $list .= sprintf("%-20s",$cfg->{$source}->{$sync_name}->{lsync});
273 $list .= sprintf("%-10s",$cfg->{$source}->{$sync_name}->{interval});
274 $list .= sprintf("%-5s\n",$cfg->{$source}->{$sync_name}->{method});
275 }
276 }
277
278 return $list;
279 }
280
281 sub vm_exists {
282 my ($target) = @_;
283
284 my $cmd = "";
285 $cmd = "ssh root\@$target->{ip} " if ($target->{ip});
286 $cmd .= "qm status $target->{vmid}";
287
288 my $res = run_cmd($cmd);
289
290 return 1 if ($res =~ m/^status.*$/);
291 return undef;
292 }
293
294 sub init {
295 my ($param) = @_;
296
297 my $cfg = read_from_config;
298
299 my $vm = {};
300
301 my $name = $param->{name} ? $param->{name} : "default";
302 my $interval = $param->{interval} ? $param->{interval} : 15;
303
304 my $source = parse_target($param->{source});
305 my $dest = parse_target($param->{dest});
306
307 $vm->{$name}->{dest_pool} = $dest->{pool};
308 $vm->{$name}->{dest_ip} = $dest->{ip} if $dest->{ip};
309 $vm->{$name}->{dest_path} = $dest->{path} if $dest->{path};
310
311 $param->{method} = "local" if !$dest->{ip} && !$source->{ip};
312 $vm->{$name}->{locked} = "no";
313 $vm->{$name}->{interval} = $interval;
314 $vm->{$name}->{method} = $param->{method} ? $param->{method} : "ssh";
315 $vm->{$name}->{limit} = $param->{limit} if $param->{limit};
316 $vm->{$name}->{maxsnap} = $param->{maxsnap} if $param->{maxsnap};
317
318 if ( my $ip = $vm->{$name}->{dest_ip} ) {
319 run_cmd("ssh-copy-id -i /root/.ssh/id_rsa.pub root\@$ip");
320 }
321
322 if ( my $ip = $source->{ip} ) {
323 run_cmd("ssh-copy-id -i /root/.ssh/id_rsa.pub root\@$ip");
324 }
325
326 die "Pool $dest->{abs_path} does not exists\n" if check_pool_exsits($dest->{abs_path}, $dest->{ip});
327
328 my $check = check_pool_exsits($source->{abs_path}, $source->{ip}) if !$source->{vmid} && $source->{abs_path};
329
330 die "Pool $source->{abs_path} does not exists\n" if undef($check);
331
332 my $add_job = sub {
333 my ($vm, $name) = @_;
334 my $source = "";
335
336 if ($vm->{$name}->{vmid}) {
337 $source = "$vm->{$name}->{source_ip}:" if $vm->{$name}->{source_ip};
338 $source .= $vm->{$name}->{vmid};
339 } else {
340 $source = $vm->{$name}->{source_pool};
341 $source .= $vm->{$name}->{source_path} if $vm->{$name}->{source_path};
342 }
343 die "Config already exists\n" if $cfg->{$source}->{$name};
344
345 cron_add($vm);
346
347 $cfg->{$source}->{$name} = $vm->{$name};
348
349 write_to_config($cfg);
350 };
351
352 if ($source->{vmid}) {
353 die "VM $source->{vmid} doesn't exist\n" if !vm_exists($source);
354 my $disks = get_disks($source);
355 $vm->{$name}->{vmid} = $source->{vmid};
356 $vm->{$name}->{lsync} = 0;
357 $vm->{$name}->{source_ip} = $source->{ip} if $source->{ip};
358
359 &$add_job($vm, $name);
360
361 } else {
362 $vm->{$name}->{source_pool} = $source->{pool};
363 $vm->{$name}->{source_ip} = $source->{ip} if $source->{ip};
364 $vm->{$name}->{source_path} = $source->{path} if $source->{path};
365 $vm->{$name}->{lsync} = 0;
366
367 &$add_job($vm, $name);
368 }
369
370 eval {sync($param) if !$param->{skip};};
371 if(my $err = $@) {
372 destroy($param);
373 print $err;
374 }
375 }
376
377 sub destroy {
378 my ($param) = @_;
379
380 my $cfg = read_from_config("$CONFIG_PATH$CONFIG");
381 my $name = $param->{name} ? $param->{name} : "default";
382
383 my $source = parse_target($param->{source});
384
385 my $delete_cron = sub {
386 my ($path, $name, $cfg) = @_;
387
388 die "Source does not exist!\n" unless $cfg->{$path} ;
389
390 die "Sync Name does not exist!\n" unless $cfg->{$path}->{$name};
391
392 delete $cfg->{$path}->{$name};
393
394 delete $cfg->{$path} if keys%{$cfg->{$path}} == 0;
395
396 write_to_config($cfg);
397
398 cron_del($path, $name);
399 };
400
401
402 if ($source->{vmid}) {
403 my $path = $source->{vmid};
404
405 &$delete_cron($path, $name, $cfg)
406
407 } else {
408
409 my $path = $source->{pool};
410 $path .= $source->{path} if $source->{path};
411
412 &$delete_cron($path, $name, $cfg);
413 }
414 }
415
416 sub sync {
417 my ($param) = @_;
418
419 my $cfg = read_from_config("$CONFIG_PATH$CONFIG");
420
421 my $name = $param->{name} ? $param->{name} : "default";
422 my $max_snap = $param->{maxsnap} ? $param->{maxsnap} : 1;
423 my $method = $param->{method} ? $param->{method} : "ssh";
424
425 my $dest = parse_target($param->{dest});
426 my $source = parse_target($param->{source});
427
428 my $sync_path = sub {
429 my ($source, $name, $cfg, $max_snap, $dest, $method) = @_;
430
431 ($source->{old_snap},$source->{last_snap}) = snapshot_get($source, $dest, $max_snap, $name);
432
433 my $job_status = check_config($source, $name, $cfg) if $cfg;
434 die "VM syncing at the moment!\n" if ($job_status && $job_status eq "active");
435
436 if ($job_status && $job_status eq "exist") {
437 my $conf_name = $source->{abs_path};
438 $conf_name = $source->{vmid} if $source->{vmid};
439 $cfg->{$conf_name}->{$name}->{locked} = "yes";
440 write_to_config($cfg);
441 }
442
443 my $date = snapshot_add($source, $dest, $name);
444
445 send_image($source, $dest, $method, $param->{verbose}, $param->{limit});
446
447 snapshot_destroy($source, $dest, $method, $source->{old_snap}) if ($source->{destroy} && $source->{old_snap});
448
449
450 if ($job_status && $job_status eq "exist") {
451 my $conf_name = $source->{abs_path};
452 $conf_name = $source->{vmid} if $source->{vmid};
453 $cfg->{$conf_name}->{$name}->{locked} = "no";
454 $cfg->{$conf_name}->{$name}->{lsync} = $date;
455 write_to_config($cfg);
456 }
457 };
458
459 $param->{method} = "ssh" if !$param->{method};
460
461 if ($source->{vmid}) {
462 die "VM $source->{vmid} doesn't exist\n" if !vm_exists($source);
463 my $disks = get_disks($source);
464
465 foreach my $disk (keys %{$disks}) {
466 $source->{abs_path} = $disks->{$disk}->{pool};
467 $source->{abs_path} .= "\/$disks->{$disk}->{path}" if $disks->{$disk}->{path};
468
469 $source->{pool} = $disks->{$disk}->{pool};
470 $source->{path} = "\/$disks->{$disk}->{path}";
471
472 &$sync_path($source, $name, $cfg, $max_snap, $dest, $method);
473 }
474 } else {
475 &$sync_path($source, $name, $cfg, $max_snap, $dest, $method);
476 }
477 }
478
479 sub snapshot_get{
480 my ($source, $dest, $max_snap, $name) = @_;
481
482 my $cmd = "zfs list -r -t snapshot -Ho name, -S creation ";
483
484 $cmd .= $source->{abs_path};
485 $cmd = "ssh root\@$source->{ip} ".$cmd if $source->{ip};
486
487 my $raw = run_cmd($cmd);
488 my $index = 1;
489 my $line = "";
490 my $last_snap = undef;
491
492 while ($raw && $raw =~ s/^(.*?)(\n|$)//) {
493 $line = $1;
494 $last_snap = $line if $index == 1;
495 if ($index == $max_snap) {
496 $source->{destroy} = 1;
497 last;
498 };
499 $index++;
500 }
501
502 $line =~ m/^(.+)\@(rep_$name\_.+)(\n|$)/;
503 return ($2, $last_snap) if $2;
504
505 return undef;
506 }
507
508 sub snapshot_add {
509 my ($source, $dest, $name) = @_;
510
511 my $date = get_date();
512
513 my $snap_name = "rep_$name\_".$date;
514
515 $source->{new_snap} = $snap_name;
516
517 my $path = $source->{abs_path}."\@".$snap_name;
518
519 my $cmd = "zfs snapshot $path";
520 $cmd = "ssh root\@$source->{ip} ".$cmd if $source->{ip};
521
522 eval{
523 run_cmd($cmd);
524 };
525
526 if (my $err = $@){
527 snapshot_destroy($source, $dest, 'ssh', $snap_name);
528 die "$err\n";
529 }
530 return $date;
531 }
532
533 sub cron_add {
534 my ($vm) = @_;
535
536 open(my $fh, '>>', "$CRONJOBS")
537 or die "Could not open file: $!\n";
538
539 foreach my $name (keys%{$vm}){
540 my $text = "*/$vm->{$name}->{interval} * * * * root ";
541 $text .= "$PATH$PROGNAME sync";
542 $text .= " -source ";
543 if ($vm->{$name}->{vmid}) {
544 $text .= "$vm->{$name}->{source_ip}:" if $vm->{$name}->{source_ip};
545 $text .= "$vm->{$name}->{vmid} ";
546 } else {
547 $text .= "$vm->{$name}->{source_ip}:" if $vm->{$name}->{source_ip};
548 $text .= "$vm->{$name}->{source_pool}";
549 $text .= "$vm->{$name}->{source_path}" if $vm->{$name}->{source_path};
550 }
551 $text .= " -dest ";
552 $text .= "$vm->{$name}->{dest_ip}:" if $vm->{$name}->{dest_ip};
553 $text .= "$vm->{$name}->{dest_pool}";
554 $text .= "$vm->{$name}->{dest_path}" if $vm->{$name}->{dest_path};
555 $text .= " -name $name ";
556 $text .= " -limit $vm->{$name}->{limit}" if $vm->{$name}->{limit};
557 $text .= " -maxsnap $vm->{$name}->{maxsnap}" if $vm->{$name}->{maxsnap};
558 $text .= "\n";
559 print($fh $text);
560 }
561 close($fh);
562 }
563
564 sub cron_del {
565 my ($source, $name) = @_;
566
567 open(my $fh, '<', "$CRONJOBS")
568 or die "Could not open file: $!\n";
569
570 $/ = undef;
571
572 my $text = <$fh>;
573 my $buffer = "";
574 close($fh);
575 while ($text && $text =~ s/^(.*?)(\n|$)//) {
576 my $line = $1.$2;
577 if ($line !~ m/.*$PROGNAME.*$source.*$name.*/){
578 $buffer .= $line;
579 }
580 }
581 open($fh, '>', "$CRONJOBS")
582 or die "Could not open file: $!\n";
583 print($fh $buffer);
584 close($fh);
585 }
586
587 sub get_disks {
588 my ($target) = @_;
589
590 my $cmd = "";
591 $cmd = "ssh root\@$target->{ip} " if $target->{ip};
592 $cmd .= "qm config $target->{vmid}";
593
594 my $res = run_cmd($cmd);
595
596 my $disks = parse_disks($res, $target->{ip});
597
598 return $disks;
599 }
600
601 sub run_cmd {
602 my ($cmd) = @_;
603 print "Start CMD\n" if $DEBUG;
604 print Dumper $cmd if $DEBUG;
605 my $output = `$cmd 2>&1`;
606
607 die $output if 0 != $?;
608
609 chomp($output);
610 print Dumper $output if $DEBUG;
611 print "END CMD\n" if $DEBUG;
612 return $output;
613 }
614
615 sub parse_disks {
616 my ($text, $ip) = @_;
617
618 my $disks;
619
620 my $num = 0;
621 my $cmd = "";
622 $cmd .= "ssh root\@$ip " if $ip;
623 $cmd .= "pvesm zfsscan";
624 my $zfs_pools = run_cmd($cmd);
625 while ($text && $text =~ s/^(.*?)(\n|$)//) {
626 my $line = $1;
627 my $disk = undef;
628 my $stor = undef;
629 if($line =~ m/^(virtio\d+: )(.+:)([A-Za-z0-9\-]+),(.*)$/) {
630 $disk = $3;
631 $stor = $2;
632 } elsif($line =~ m/^(ide\d+: )(.+:)([A-Za-z0-9\-]+),(.*)$/) {
633 $disk = $3;
634 $stor = $2;
635 } elsif($line =~ m/^(scsi\d+: )(.+:)([A-Za-z0-9\-]+),(.*)$/) {
636 $disk = $3;
637 $stor = $2;
638 } elsif($line =~ m/^(sata\d+: )(.+:)([A-Za-z0-9\-]+),(.*)$/) {
639 $disk = $3;
640 $stor = $2;
641 }
642
643 if($disk && $disk ne "none" && $disk !~ m/cdrom/ ) {
644 my $cmd = "";
645 $cmd .= "ssh root\@$ip " if $ip;
646 $cmd .= "pvesm path $stor$disk";
647 my $path = run_cmd($cmd);
648
649 if ($path =~ m/^\/dev\/zvol\/(\w+).*(\/$disk)$/){
650
651 $disks->{$num}->{pool} = $1;
652 $disks->{$num}->{path} = $disk;
653 $num++;
654
655 } else {
656 die "ERROR: in path\n";
657 }
658 }
659 }
660 die "disk is not on ZFS Storage\n" if $num == 0;
661 return $disks;
662 }
663
664 sub snapshot_destroy {
665 my ($source, $dest, $method, $snap) = @_;
666
667 my $zfscmd = "zfs destroy ";
668 my $name = "$source->{path}\@$snap";
669
670 eval {
671 if($source->{ip} && $method eq 'ssh'){
672 run_cmd("ssh root\@$source->{ip} $zfscmd $source->{pool}$name");
673 } else {
674 run_cmd("$zfscmd $source->{pool}$name");
675 }
676 };
677 if (my $erro = $@) {
678 warn "WARN: $erro";
679 }
680 if ($dest){
681 my $ssh = $dest->{ip} ? "ssh root\@$dest->{ip}" : "";
682
683 my $path = "";
684 $path ="$dest->{path}" if $dest->{path};
685
686 my @dir = split(/\//, $source->{path});
687 eval {
688 run_cmd("$ssh $zfscmd $dest->{pool}$path\/$dir[@dir-1]\@$snap ");
689 };
690 if (my $erro = $@) {
691 warn "WARN: $erro";
692 }
693 }
694 }
695
696 sub snapshot_exist {
697 my ($source ,$dest, $method) = @_;
698
699 my $cmd = "";
700 $cmd = "ssh root\@$dest->{ip} " if $dest->{ip};
701 $cmd .= "zfs list -rt snapshot -Ho name $dest->{pool}";
702 $cmd .= "$dest->{path}" if $dest->{path};
703 my @dir = split(/\//, $source->{path});
704 $cmd .= "\/$dir[@dir-1]\@$source->{old_snap}";
705
706 my $text = "";
707 eval {$text =run_cmd($cmd);};
708 if (my $erro = $@) {
709 warn "WARN: $erro";
710 return undef;
711 }
712
713 while ($text && $text =~ s/^(.*?)(\n|$)//) {
714 my $line = $1;
715 return 1 if $line =~ m/^.*$source->{old_snap}$/;
716 }
717 }
718
719 sub send_image {
720 my ($source, $dest, $method, $verbose, $limit) = @_;
721
722 my $cmd = "";
723
724 $cmd .= "ssh root\@$source->{ip} " if $source->{ip};
725 $cmd .= "zfs send ";
726 $cmd .= "-v " if $verbose;
727
728 if($source->{last_snap} && snapshot_exist($source ,$dest, $method)) {
729 $cmd .= "-i $source->{abs_path}\@$source->{old_snap} $source->{abs_path}\@$source->{new_snap} ";
730 } else {
731 $cmd .= "$source->{abs_path}\@$source->{new_snap} ";
732 }
733
734 if ($limit){
735 my $bwl = $limit*1024;
736 $cmd .= "| cstream -t $bwl";
737 }
738 $cmd .= "| ";
739 $cmd .= "ssh root\@$dest->{ip} " if $dest->{ip};
740 $cmd .= "zfs recv $dest->{pool}";
741 $cmd .= "$dest->{path}" if $dest->{path};
742
743 my @dir = split(/\//,$source->{path});
744 $cmd .= "\/$dir[@dir-1]\@$source->{new_snap}";
745
746 eval {
747 run_cmd($cmd)
748 };
749
750 if (my $erro = $@) {
751 snapshot_destroy($source, undef, $method, $source->{new_snap});
752 die $erro;
753 };
754
755 if ($source->{vmid}) {
756 if ($method eq "ssh") {
757 send_config($source, $dest,'ssh');
758 }
759 }
760 }
761
762
763 sub send_config{
764 my ($source, $dest, $method) = @_;
765
766 if ($method eq 'ssh'){
767 if ($dest->{ip} && $source->{ip}) {
768 run_cmd("ssh root\@$dest->{ip} mkdir $VMCONFIG -p");
769 run_cmd("scp root\@$source->{ip}:$QEMU_CONF$source->{vmid}.conf root\@$dest->{ip}:$VMCONFIG$source->{vmid}.conf.$source->{new_snap}");
770 } elsif ($dest->{ip}) {
771 run_cmd("ssh root\@$dest->{ip} mkdir $VMCONFIG -p");
772 run_cmd("scp $QEMU_CONF$source->{vmid}.conf root\@$dest->{ip}:$VMCONFIG$source->{vmid}.conf.$source->{new_snap}");
773 } elsif ($source->{ip}) {
774 run_cmd("mkdir $VMCONFIG -p");
775 run_cmd("scp root\@$source->{ip}:$QEMU_CONF$source->{vmid}.conf $VMCONFIG$source->{vmid}.conf.$source->{new_snap}");
776 }
777
778 if ($source->{destroy}){
779 if($dest->{ip}){
780 run_cmd("ssh root\@$dest->{ip} rm -f $VMCONFIG$source->{vmid}.conf.$source->{old_snap}");
781 } else {
782 run_cmd("rm -f $VMCONFIG$source->{vmid}.conf.$source->{old_snap}");
783 }
784 }
785 }
786 }
787
788 sub get_date {
789 my ($sec,$min,$hour,$mday,$mon,$year,$wday,$yday,$isdst) = localtime(time);
790 my $datestamp = sprintf ( "%04d-%02d-%02d_%02d:%02d:%02d",$year+1900,$mon+1,$mday,$hour,$min,$sec);
791
792 return $datestamp;
793 }
794
795 sub status {
796 my $cfg = read_from_config("$CONFIG_PATH$CONFIG");
797
798 my $status_list = sprintf("%-25s%-15s%-10s\n","SOURCE","NAME","STATUS");
799
800 foreach my $source (sort keys%{$cfg}){
801 foreach my $sync_name (sort keys%{$cfg->{$source}}){
802 my $status;
803
804 my $source_name = $source;
805
806 $source_name = $cfg->{$source}->{$sync_name}->{source_ip}.":".$source if $cfg->{$source}->{$sync_name}->{source_ip};
807
808 if ($cfg->{$source}->{$sync_name}->{locked} eq 'no'){
809 $status = sprintf("%-10s","OK");
810 } elsif ($cfg->{$source}->{$sync_name}->{locked} eq 'yes' &&
811 $cfg->{$source}->{$sync_name}->{failure}) {
812 $status = sprintf("%-10s","sync error");
813 } else {
814 $status = sprintf("%-10s","syncing");
815 }
816
817 $status_list .= sprintf("%-25s%-15s", cut_to_width($source_name,25), cut_to_width($sync_name,15));
818 $status_list .= "$status\n";
819 }
820 }
821
822 return $status_list;
823 }
824
825
826 my $command = $ARGV[0];
827
828 my $commands = {'destroy' => 1,
829 'create' => 1,
830 'sync' => 1,
831 'list' => 1,
832 'status' => 1,
833 'help' => 1};
834
835 if (!$command || !$commands->{$command}) {
836 usage();
837 die "\n";
838 }
839
840 my $dest = '';
841 my $source = '';
842 my $verbose = '';
843 my $interval = '';
844 my $limit = '';
845 my $maxsnap = '';
846 my $name = '';
847 my $skip = '';
848
849 my $help_sync = "zfs-zsync sync -dest <string> -source <string> [OPTIONS]\n
850 \twill sync one time\n
851 \t-dest\tstring\n
852 \t\tthe destination target is like [IP:]<Pool>[/Path]\n
853 \t-limit\tinteger\n
854 \t\tmax sync speed in kBytes/s, default unlimited\n
855 \t-maxsnap\tinteger\n
856 \t\thow much snapshots will be kept before get erased, default 1/n
857 \t-name\tstring\n
858 \t\tname of the sync job, if not set it is default.
859 \tIt is only necessary if scheduler allready contains this source.\n
860 \t-source\tstring\n
861 \t\tthe source can be an <VMID> or [IP:]<ZFSPool>[/Path]\n";
862
863 my $help_create = "zfs-zsync create -dest <string> -source <string> [OPTIONS]/n
864 \tCreate a sync Job\n
865 \t-dest\tstringn\n
866 \t\tthe destination target is like [IP]:<Pool>[/Path]\n
867 \t-interval\tinteger\n
868 \t\tthe interval in min in witch the zfs will sync,
869 \t\tdefault is 15 min\n
870 \t-limit\tinteger\n
871 \t\tmax sync speed, default unlimited\n
872 \t-maxsnap\tstring\n
873 \t\thow much snapshots will be kept before get erased, default 1\n
874 \t-name\tstring\n
875 \t\tname of the sync job, if not set it is default\n
876 \t-skip\tboolean\n
877 \t\tif this flag is set it will skip the first sync\n
878 \t-source\tstring\n
879 \t\tthe source can be an <VMID> or [IP:]<ZFSPool>[/Path]\n";
880
881 my $help_destroy = "zfs-zsync destroy -source <string> [OPTIONS]\n
882 \tremove a sync Job from the scheduler\n
883 \t-name\tstring\n
884 \t\tname of the sync job, if not set it is default\n
885 \t-source\tstring\n
886 \t\tthe source can be an <VMID> or [IP:]<ZFSPool>[/Path]\n";
887
888 my $help_help = "zfs-zsync help <cmd> [OPTIONS]\n
889 \tGet help about specified command.\n
890 \t<cmd>\tstring\n
891 \t\tCommand name\n
892 \t-verbose\tboolean\n
893 \t\tVerbose output format.\n";
894
895 my $help_list = "zfs-zsync list\n
896 \tGet a List of all scheduled Sync Jobs\n";
897
898 my $help_status = "zfs-zsync status\n
899 \tGet the status of all scheduled Sync Jobs\n";
900
901 sub help{
902 my ($command) = @_;
903
904 switch($command){
905 case 'help'
906 {
907 die "$help_help\n";
908 }
909 case 'sync'
910 {
911 die "$help_sync\n";
912 }
913 case 'destroy'
914 {
915 die "$help_destroy\n";
916 }
917 case 'create'
918 {
919 die "$help_create\n";
920 }
921 case 'list'
922 {
923 die "$help_list\n";
924 }
925 case 'status'
926 {
927 die "$help_status\n";
928 }
929 }
930
931 }
932
933 my $err = GetOptions ('dest=s' => \$dest,
934 'source=s' => \$source,
935 'verbose' => \$verbose,
936 'interval=i' => \$interval,
937 'limit=i' => \$limit,
938 'maxsnap=i' => \$maxsnap,
939 'name=s' => \$name,
940 'skip' => \$skip);
941
942 if ($err == 0) {
943 die "can't parse options\n";
944 }
945
946 my $param;
947 $param->{dest} = $dest;
948 $param->{source} = $source;
949 $param->{verbose} = $verbose;
950 $param->{interval} = $interval;
951 $param->{limit} = $limit;
952 $param->{maxsnap} = $maxsnap;
953 $param->{name} = $name;
954 $param->{skip} = $skip;
955
956 switch($command){
957 case "destroy"
958 {
959 die "$help_destroy\n" if !$source;
960 check_target($source);
961 destroy($param);
962 }
963 case "sync"
964 {
965 die "$help_sync\n" if !$source || !$dest;
966 check_target($source);
967 check_target($dest);
968 sync($param);
969 }
970 case "create"
971 {
972 die "$help_create\n" if !$source || !$dest;
973 check_target($source);
974 check_target($dest);
975 init($param);
976 }
977 case "status"
978 {
979 print status();
980 }
981 case "list"
982 {
983 print list();
984 }
985 case "help"
986 {
987 my $help_command = $ARGV[1];
988 if ($help_command && $commands->{$help_command}) {
989 print help($help_command);
990 }
991 if ($verbose){
992 exec("man $PROGNAME");
993 } else {
994 usage(1);
995 }
996 }
997 }
998
999 sub usage{
1000 my ($help) = @_;
1001
1002 print("ERROR:\tno command specified\n") if !$help;
1003 print("USAGE:\t$PROGNAME <COMMAND> [ARGS] [OPTIONS]\n");
1004 print("\tpve-zsync help [<cmd>] [OPTIONS]\n\n");
1005 print("\tpve-zsync create -dest <string> -source <string> [OPTIONS]\n");
1006 print("\tpve-zsync destroy -source <string> [OPTIONS]\n");
1007 print("\tpve-zsync list\n");
1008 print("\tpve-zsync status\n");
1009 print("\tpve-zsync sync -dest <string> -source <string> [OPTIONS]\n");
1010 }
1011
1012 sub check_target{
1013 my ($target) = @_;
1014
1015 chomp($target);
1016
1017 if($target !~ m/(\d+.\d+.\d+.\d+:)?([\w\-\_\/]+)(\/.+)?/){
1018 print("ERROR:\t$target is not valid.\n\tUse [IP:]<ZFSPool>[/Path]!\n");
1019 return 1;
1020 }
1021 return undef;
1022 }
1023
1024 __END__
1025
1026 =head1 NAME
1027
1028 pve-zsync - PVE ZFS Replication Manager
1029
1030 =head1 SYNOPSIS
1031
1032 zfs-zsync <COMMAND> [ARGS] [OPTIONS]
1033
1034 zfs-zsync help <cmd> [OPTIONS]
1035
1036 Get help about specified command.
1037
1038 <cmd> string
1039
1040 Command name
1041
1042 -verbose boolean
1043
1044 Verbose output format.
1045
1046 zfs-zsync create -dest <string> -source <string> [OPTIONS]
1047
1048 Create a sync Job
1049
1050 -dest string
1051
1052 the destination target is like [IP]:<Pool>[/Path]
1053
1054 -interval integer
1055
1056 the interval in min in witch the zfs will sync, default is 15 min
1057
1058 -limit integer
1059
1060 max sync speed, default unlimited
1061
1062 -maxsnap string
1063
1064 how much snapshots will be kept before get erased, default 1
1065
1066 -name string
1067
1068 name of the sync job, if not set it is default
1069
1070 -skip boolean
1071
1072 if this flag is set it will skip the first sync
1073
1074 -source string
1075
1076 the source can be an <VMID> or [IP:]<ZFSPool>[/Path]
1077
1078 zfs-zsync destroy -source <string> [OPTIONS]
1079
1080 remove a sync Job from the scheduler
1081
1082 -name string
1083
1084 name of the sync job, if not set it is default
1085
1086 -source string
1087
1088 the source can be an <VMID> or [IP:]<ZFSPool>[/Path]
1089
1090 zfs-zsync list
1091
1092 Get a List of all scheduled Sync Jobs
1093
1094 zfs-zsync status
1095
1096 Get the status of all scheduled Sync Jobs
1097
1098 zfs-zsync sync -dest <string> -source <string> [OPTIONS]
1099
1100 will sync one time
1101
1102 -dest string
1103
1104 the destination target is like [IP:]<Pool>[/Path]
1105
1106 -limit integer
1107
1108 max sync speed in kBytes/s, default unlimited
1109
1110 -maxsnap integer
1111
1112 how much snapshots will be kept before get erased, default 1
1113
1114 -name string
1115
1116 name of the sync job, if not set it is default.
1117 It is only necessary if scheduler allready contains this source.
1118
1119 -source string
1120
1121 the source can be an <VMID> or [IP:]<ZFSPool>[/Path]
1122
1123 =head1 DESCRIPTION
1124
1125 This Tool helps you to sync your VM or directory which stored on ZFS between 2 servers.
1126 This tool also has the capability to add jobs to cron so the sync will be automatically done.
1127
1128 =head2 PVE ZFS Storage sync Tool
1129
1130 This Tool can get remote pool on other PVE or send Pool to others ZFS machines
1131
1132 =head1 EXAMPLES
1133
1134 add sync job from local VM to remote ZFS Server
1135 zfs-zsync -source=100 -dest=192.168.1.2:zfspool
1136
1137 =head1 IMPORTANT FILES
1138
1139 Where the cron jobs are stored /etc/cron.d/pve-zsync
1140 Where the VM config get copied on the destination machine /var/pve-zsync
1141 Where the config is stored /var/pve-zsync
1142
1143 Copyright (C) 2007-2015 Proxmox Server Solutions GmbH
1144
1145 This program is free software: you can redistribute it and/or modify it
1146 under the terms of the GNU Affero General Public License as published
1147 by the Free Software Foundation, either version 3 of the License, or
1148 (at your option) any later version.
1149
1150 This program is distributed in the hope that it will be useful, but
1151 WITHOUT ANY WARRANTY; without even the implied warranty of
1152 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
1153 Affero General Public License for more details.
1154
1155 You should have received a copy of the GNU Affero General Public
1156 License along with this program. If not, see
1157 <http://www.gnu.org/licenses/>.