]> git.proxmox.com Git - pve-zsync.git/blob - pve-zsync
close #1824: add 'compressed' option enabling -c and -L for send
[pve-zsync.git] / pve-zsync
1 #!/usr/bin/perl
2
3 use strict;
4 use warnings;
5
6 use Fcntl qw(:flock SEEK_END);
7 use Getopt::Long qw(GetOptionsFromArray);
8 use File::Path qw(make_path);
9 use JSON;
10 use IO::File;
11 use String::ShellQuote 'shell_quote';
12 use Text::ParseWords;
13
14 my $PROGNAME = "pve-zsync";
15 my $CONFIG_PATH = "/var/lib/${PROGNAME}";
16 my $STATE = "${CONFIG_PATH}/sync_state";
17 my $CRONJOBS = "/etc/cron.d/$PROGNAME";
18 my $PATH = "/usr/sbin";
19 my $PVE_DIR = "/etc/pve/local";
20 my $QEMU_CONF = "${PVE_DIR}/qemu-server";
21 my $LXC_CONF = "${PVE_DIR}/lxc";
22 my $PROG_PATH = "$PATH/${PROGNAME}";
23 my $INTERVAL = 15;
24 my $DEBUG;
25
26 BEGIN {
27 $DEBUG = 0; # change default here. not above on declaration!
28 $DEBUG ||= $ENV{ZSYNC_DEBUG};
29 if ($DEBUG) {
30 require Data::Dumper;
31 Data::Dumper->import();
32 }
33 }
34
35 my $IPV4OCTET = "(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])";
36 my $IPV4RE = "(?:(?:$IPV4OCTET\\.){3}$IPV4OCTET)";
37 my $IPV6H16 = "(?:[0-9a-fA-F]{1,4})";
38 my $IPV6LS32 = "(?:(?:$IPV4RE|$IPV6H16:$IPV6H16))";
39
40 my $IPV6RE = "(?:" .
41 "(?:(?:" . "(?:$IPV6H16:){6})$IPV6LS32)|" .
42 "(?:(?:" . "::(?:$IPV6H16:){5})$IPV6LS32)|" .
43 "(?:(?:(?:" . "$IPV6H16)?::(?:$IPV6H16:){4})$IPV6LS32)|" .
44 "(?:(?:(?:(?:$IPV6H16:){0,1}$IPV6H16)?::(?:$IPV6H16:){3})$IPV6LS32)|" .
45 "(?:(?:(?:(?:$IPV6H16:){0,2}$IPV6H16)?::(?:$IPV6H16:){2})$IPV6LS32)|" .
46 "(?:(?:(?:(?:$IPV6H16:){0,3}$IPV6H16)?::(?:$IPV6H16:){1})$IPV6LS32)|" .
47 "(?:(?:(?:(?:$IPV6H16:){0,4}$IPV6H16)?::" . ")$IPV6LS32)|" .
48 "(?:(?:(?:(?:$IPV6H16:){0,5}$IPV6H16)?::" . ")$IPV6H16)|" .
49 "(?:(?:(?:(?:$IPV6H16:){0,6}$IPV6H16)?::" . ")))";
50
51 my $HOSTv4RE0 = "(?:[\\w\\.\\-_]+|$IPV4RE)"; # hostname or ipv4 address
52 my $HOSTv4RE1 = "(?:$HOSTv4RE0|\\[$HOSTv4RE0\\])"; # these may be in brackets, too
53 my $HOSTRE = "(?:$HOSTv4RE1|\\[$IPV6RE\\])"; # ipv6 must always be in brackets
54 # targets are either a VMID, or a 'host:zpool/path' with 'host:' being optional
55 my $TARGETRE = qr!^(?:($HOSTRE):)?(\d+|(?:[\w\-_]+)(/.+)?)$!;
56
57 my $DISK_KEY_RE = qr/^(?:(?:(?:virtio|ide|scsi|sata|efidisk|tpmstate|mp)\d+)|rootfs): /;
58
59 my $INSTANCE_ID = get_instance_id($$);
60
61 my $command = $ARGV[0];
62
63 if (defined($command) && $command ne 'help' && $command ne 'printpod') {
64 check_bin ('cstream');
65 check_bin ('zfs');
66 check_bin ('ssh');
67 check_bin ('scp');
68 }
69
70 $SIG{TERM} = $SIG{QUIT} = $SIG{PIPE} = $SIG{HUP} = $SIG{KILL} = $SIG{INT} = sub {
71 die "Signaled, aborting sync: $!\n";
72 };
73
74 sub check_bin {
75 my ($bin) = @_;
76
77 foreach my $p (split (/:/, $ENV{PATH})) {
78 my $fn = "$p/$bin";
79 if (-x $fn) {
80 return $fn;
81 }
82 }
83
84 die "unable to find command '$bin'\n";
85 }
86
87 sub read_file {
88 my ($filename, $one_line_only) = @_;
89
90 my $fh = IO::File->new($filename, "r")
91 or die "Could not open file ${filename}: $!\n";
92
93 my $text = $one_line_only ? <$fh> : [ <$fh> ];
94
95 close($fh);
96
97 return $text;
98 }
99
100 sub cut_target_width {
101 my ($path, $maxlen) = @_;
102 $path =~ s@/+@/@g;
103
104 return $path if length($path) <= $maxlen;
105
106 return '..'.substr($path, -$maxlen+2) if $path !~ m@/@;
107
108 $path =~ s@/([^/]+/?)$@@;
109 my $tail = $1;
110
111 if (length($tail)+3 == $maxlen) {
112 return "../$tail";
113 } elsif (length($tail)+2 >= $maxlen) {
114 return '..'.substr($tail, -$maxlen+2)
115 }
116
117 $path =~ s@(/[^/]+)(?:/|$)@@;
118 my $head = $1;
119 my $both = length($head) + length($tail);
120 my $remaining = $maxlen-$both-4; # -4 for "/../"
121
122 if ($remaining < 0) {
123 return substr($head, 0, $maxlen - length($tail) - 3) . "../$tail"; # -3 for "../"
124 }
125
126 substr($path, ($remaining/2), (length($path)-$remaining), '..');
127 return "$head/" . $path . "/$tail";
128 }
129
130 sub locked {
131 my ($lock_fn, $code) = @_;
132
133 my $lock_fh = IO::File->new("> $lock_fn");
134
135 flock($lock_fh, LOCK_EX) || die "Couldn't acquire lock - $!\n";
136 my $res = eval { $code->() };
137 my $err = $@;
138
139 flock($lock_fh, LOCK_UN) || warn "Error unlocking - $!\n";
140 die "$err" if $err;
141
142 close($lock_fh);
143 return $res;
144 }
145
146 sub get_status {
147 my ($source, $name, $status) = @_;
148
149 if ($status->{$source->{all}}->{$name}->{status}) {
150 return $status;
151 }
152
153 return undef;
154 }
155
156 sub check_dataset_exists {
157 my ($dataset, $ip, $user) = @_;
158
159 my $cmd = [];
160
161 if ($ip) {
162 push @$cmd, 'ssh', "$user\@$ip", '--';
163 }
164 push @$cmd, 'zfs', 'list', '-H', '--', $dataset;
165 eval {
166 run_cmd($cmd);
167 };
168
169 if ($@) {
170 return 0;
171 }
172 return 1;
173 }
174
175 sub create_file_system {
176 my ($file_system, $ip, $user) = @_;
177
178 my $cmd = [];
179
180 if ($ip) {
181 push @$cmd, 'ssh', "$user\@$ip", '--';
182 }
183 push @$cmd, 'zfs', 'create', $file_system;
184
185 run_cmd($cmd);
186 }
187
188 sub parse_target {
189 my ($text) = @_;
190
191 my $errstr = "$text : is not a valid input! Use [IP:]<VMID> or [IP:]<ZFSPool>[/Path]";
192 my $target = {};
193
194 if ($text !~ $TARGETRE) {
195 die "$errstr\n";
196 }
197 $target->{all} = $2;
198 $target->{ip} = $1 if $1;
199 my @parts = split('/', $2);
200
201 $target->{ip} =~ s/^\[(.*)\]$/$1/ if $target->{ip};
202
203 my $pool = $target->{pool} = shift(@parts);
204 die "$errstr\n" if !$pool;
205
206 if ($pool =~ m/^\d+$/) {
207 $target->{vmid} = $pool;
208 delete $target->{pool};
209 }
210
211 return $target if (@parts == 0);
212 $target->{last_part} = pop(@parts);
213
214 if ($target->{ip}) {
215 pop(@parts);
216 }
217 if (@parts > 0) {
218 $target->{path} = join('/', @parts);
219 }
220
221 return $target;
222 }
223
224 sub read_cron {
225
226 #This is for the first use to init file;
227 if (!-e $CRONJOBS) {
228 my $new_fh = IO::File->new("> $CRONJOBS");
229 die "Could not create $CRONJOBS: $!\n" if !$new_fh;
230 close($new_fh);
231 return undef;
232 }
233
234 my $text = read_file($CRONJOBS, 0);
235
236 return parse_cron(@{$text});
237 }
238
239 sub parse_argv {
240 my (@arg) = @_;
241
242 my $param = {
243 dest => undef,
244 source => undef,
245 verbose => undef,
246 limit => undef,
247 maxsnap => undef,
248 dest_maxsnap => undef,
249 name => undef,
250 skip => undef,
251 method => undef,
252 source_user => undef,
253 dest_user => undef,
254 prepend_storage_id => undef,
255 compressed => undef,
256 properties => undef,
257 dest_config_path => undef,
258 };
259
260 my ($ret) = GetOptionsFromArray(
261 \@arg,
262 'dest=s' => \$param->{dest},
263 'source=s' => \$param->{source},
264 'verbose' => \$param->{verbose},
265 'limit=i' => \$param->{limit},
266 'maxsnap=i' => \$param->{maxsnap},
267 'dest-maxsnap=i' => \$param->{dest_maxsnap},
268 'name=s' => \$param->{name},
269 'skip' => \$param->{skip},
270 'method=s' => \$param->{method},
271 'source-user=s' => \$param->{source_user},
272 'dest-user=s' => \$param->{dest_user},
273 'prepend-storage-id' => \$param->{prepend_storage_id},
274 'compressed' => \$param->{compressed},
275 'properties' => \$param->{properties},
276 'dest-config-path=s' => \$param->{dest_config_path},
277 );
278
279 die "can't parse options\n" if $ret == 0;
280
281 $param->{name} //= "default";
282 $param->{maxsnap} //= 1;
283 $param->{method} //= "ssh";
284 $param->{source_user} //= "root";
285 $param->{dest_user} //= "root";
286
287 return $param;
288 }
289
290 sub add_state_to_job {
291 my ($job) = @_;
292
293 my $states = read_state();
294 my $state = $states->{$job->{source}}->{$job->{name}};
295
296 $job->{state} = $state->{state};
297 $job->{lsync} = $state->{lsync};
298 $job->{vm_type} = $state->{vm_type};
299 $job->{instance_id} = $state->{instance_id};
300
301 for (my $i = 0; $state->{"snap$i"}; $i++) {
302 $job->{"snap$i"} = $state->{"snap$i"};
303 }
304
305 return $job;
306 }
307
308 sub parse_cron {
309 my (@text) = @_;
310
311 my $cfg = {};
312
313 while (my $line = shift(@text)) {
314 my @arg = Text::ParseWords::shellwords($line);
315 my $param = parse_argv(@arg);
316
317 if ($param->{source} && $param->{dest}) {
318 my $source = delete $param->{source};
319 my $name = delete $param->{name};
320
321 $cfg->{$source}->{$name} = $param;
322 }
323 }
324
325 return $cfg;
326 }
327
328 sub param_to_job {
329 my ($param) = @_;
330
331 my $job = {};
332
333 my $source = parse_target($param->{source});
334 my $dest;
335 $dest = parse_target($param->{dest}) if $param->{dest};
336
337 $job->{name} = !$param->{name} ? "default" : $param->{name};
338 $job->{dest} = $param->{dest} if $param->{dest};
339 $job->{method} = "local" if !$dest->{ip} && !$source->{ip};
340 $job->{method} = "ssh" if !$job->{method};
341 $job->{limit} = $param->{limit};
342 $job->{maxsnap} = $param->{maxsnap};
343 $job->{dest_maxsnap} = $param->{dest_maxsnap};
344 $job->{source} = $param->{source};
345 $job->{source_user} = $param->{source_user};
346 $job->{dest_user} = $param->{dest_user};
347 $job->{prepend_storage_id} = !!$param->{prepend_storage_id};
348 $job->{compressed} = !!$param->{compressed};
349 $job->{properties} = !!$param->{properties};
350 $job->{dest_config_path} = $param->{dest_config_path} if $param->{dest_config_path};
351
352 return $job;
353 }
354
355 sub read_state {
356
357 if (!-e $STATE) {
358 make_path $CONFIG_PATH;
359 my $new_fh = IO::File->new("> $STATE");
360 die "Could not create $STATE: $!\n" if !$new_fh;
361 print $new_fh "{}";
362 close($new_fh);
363 return undef;
364 }
365
366 my $text = read_file($STATE, 1);
367 return decode_json($text);
368 }
369
370 sub update_state {
371 my ($job) = @_;
372
373 my $text = eval { read_file($STATE, 1); };
374
375 my $out_fh = IO::File->new("> $STATE.new");
376 die "Could not open file ${STATE}.new: $!\n" if !$out_fh;
377
378 my $states = {};
379 my $state = {};
380 if ($text){
381 $states = decode_json($text);
382 $state = $states->{$job->{source}}->{$job->{name}};
383 }
384
385 if ($job->{state} ne "del") {
386 $state->{state} = $job->{state};
387 $state->{lsync} = $job->{lsync};
388 $state->{instance_id} = $job->{instance_id};
389 $state->{vm_type} = $job->{vm_type};
390
391 for (my $i = 0; $job->{"snap$i"} ; $i++) {
392 $state->{"snap$i"} = $job->{"snap$i"};
393 }
394 $states->{$job->{source}}->{$job->{name}} = $state;
395 } else {
396
397 delete $states->{$job->{source}}->{$job->{name}};
398 delete $states->{$job->{source}} if !keys %{$states->{$job->{source}}};
399 }
400
401 $text = encode_json($states);
402 print $out_fh $text;
403
404 close($out_fh);
405 rename "$STATE.new", $STATE;
406
407 return $states;
408 }
409
410 sub update_cron {
411 my ($job) = @_;
412
413 my $updated;
414 my $has_header;
415 my $line_no = 0;
416 my $text = "";
417 my $header = "SHELL=/bin/sh\n";
418 $header .= "PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin\n\n";
419
420 my $current = read_file($CRONJOBS, 0);
421
422 foreach my $line (@{$current}) {
423 chomp($line);
424 if ($line =~ m/source $job->{source} .*name $job->{name} /) {
425 $updated = 1;
426 next if $job->{state} eq "del";
427 $text .= format_job($job, $line);
428 } else {
429 if (($line_no < 3) && ($line =~ /^(PATH|SHELL)/ )) {
430 $has_header = 1;
431 }
432 $text .= "$line\n";
433 }
434 $line_no++;
435 }
436
437 if (!$has_header) {
438 $text = "$header$text";
439 }
440
441 if (!$updated) {
442 $text .= format_job($job);
443 }
444 my $new_fh = IO::File->new("> ${CRONJOBS}.new");
445 die "Could not open file ${CRONJOBS}.new: $!\n" if !$new_fh;
446
447 print $new_fh $text or die "can't write to $CRONJOBS.new: $!\n";
448 close ($new_fh);
449
450 rename "${CRONJOBS}.new", $CRONJOBS or die "can't move $CRONJOBS.new: $!\n";
451 }
452
453 sub format_job {
454 my ($job, $line) = @_;
455 my $text = "";
456
457 if ($job->{state} eq "stopped") {
458 $text = "#";
459 }
460 if ($line) {
461 $line =~ /^#*\s*((?:\S+\s+){4}\S+)\s+root/;
462 $text .= $1;
463 } else {
464 $text .= "*/$INTERVAL * * * *";
465 }
466 $text .= " root";
467 $text .= " $PROGNAME sync --source $job->{source} --dest $job->{dest}";
468 $text .= " --name $job->{name} --maxsnap $job->{maxsnap}";
469 $text .= " --dest-maxsnap $job->{dest_maxsnap}" if defined($job->{dest_maxsnap});
470 $text .= " --limit $job->{limit}" if $job->{limit};
471 $text .= " --method $job->{method}";
472 $text .= " --verbose" if $job->{verbose};
473 $text .= " --source-user $job->{source_user}";
474 $text .= " --dest-user $job->{dest_user}";
475 $text .= " --prepend-storage-id" if $job->{prepend_storage_id};
476 $text .= " --compressed" if $job->{compressed};
477 $text .= " --properties" if $job->{properties};
478 $text .= " --dest-config-path $job->{dest_config_path}" if $job->{dest_config_path};
479 $text .= "\n";
480
481 return $text;
482 }
483
484 sub list {
485
486 my $cfg = read_cron();
487
488 my $list = sprintf("%-25s%-25s%-10s%-20s%-6s%-5s\n" , "SOURCE", "NAME", "STATE", "LAST SYNC", "TYPE", "CON");
489
490 my $states = read_state();
491 foreach my $source (sort keys%{$cfg}) {
492 foreach my $name (sort keys%{$cfg->{$source}}) {
493 $list .= sprintf("%-25s", cut_target_width($source, 25));
494 $list .= sprintf("%-25s", cut_target_width($name, 25));
495 $list .= sprintf("%-10s", $states->{$source}->{$name}->{state});
496 $list .= sprintf("%-20s", $states->{$source}->{$name}->{lsync});
497 $list .= sprintf("%-6s", defined($states->{$source}->{$name}->{vm_type}) ? $states->{$source}->{$name}->{vm_type} : "undef");
498 $list .= sprintf("%-5s\n", $cfg->{$source}->{$name}->{method});
499 }
500 }
501
502 return $list;
503 }
504
505 sub vm_exists {
506 my ($target, $user) = @_;
507
508 return undef if !defined($target->{vmid});
509
510 my $conf_fn = "$target->{vmid}.conf";
511
512 if ($target->{ip}) {
513 my @cmd = ('ssh', "$user\@$target->{ip}", '--', '/bin/ls');
514 return "qemu" if eval { run_cmd([@cmd, "$QEMU_CONF/$conf_fn"]) };
515 return "lxc" if eval { run_cmd([@cmd, "$LXC_CONF/$conf_fn"]) };
516 } else {
517 return "qemu" if -f "$QEMU_CONF/$conf_fn";
518 return "lxc" if -f "$LXC_CONF/$conf_fn";
519 }
520
521 return undef;
522 }
523
524 sub init {
525 my ($param) = @_;
526
527 locked("$CONFIG_PATH/cron_and_state.lock", sub {
528 my $cfg = read_cron();
529
530 my $job = param_to_job($param);
531
532 $job->{state} = "ok";
533 $job->{lsync} = 0;
534
535 my $source = parse_target($param->{source});
536 my $dest = parse_target($param->{dest});
537
538 if (my $ip = $dest->{ip}) {
539 run_cmd(['ssh-copy-id', '-i', '/root/.ssh/id_rsa.pub', "$param->{dest_user}\@$ip"]);
540 }
541
542 if (my $ip = $source->{ip}) {
543 run_cmd(['ssh-copy-id', '-i', '/root/.ssh/id_rsa.pub', "$param->{source_user}\@$ip"]);
544 }
545
546 die "Pool $dest->{all} does not exist\n"
547 if !check_dataset_exists($dest->{all}, $dest->{ip}, $param->{dest_user});
548
549 if (!defined($source->{vmid})) {
550 die "Pool $source->{all} does not exist\n"
551 if !check_dataset_exists($source->{all}, $source->{ip}, $param->{source_user});
552 }
553
554 my $vm_type = vm_exists($source, $param->{source_user});
555 $job->{vm_type} = $vm_type;
556 $source->{vm_type} = $vm_type;
557
558 die "VM $source->{vmid} doesn't exist\n" if $source->{vmid} && !$vm_type;
559
560 die "Config already exists\n" if $cfg->{$job->{source}}->{$job->{name}};
561
562 #check if vm has zfs disks if not die;
563 get_disks($source, $param->{source_user}) if $source->{vmid};
564
565 update_cron($job);
566 update_state($job);
567 }); #cron and state lock
568
569 return if $param->{skip};
570
571 eval { sync($param) };
572 if (my $err = $@) {
573 destroy_job($param);
574 print $err;
575 }
576 }
577
578 sub get_job {
579 my ($param) = @_;
580
581 my $cfg = read_cron();
582
583 if (!$cfg->{$param->{source}}->{$param->{name}}) {
584 die "Job with source $param->{source} and name $param->{name} does not exist\n" ;
585 }
586 my $job = $cfg->{$param->{source}}->{$param->{name}};
587 $job->{name} = $param->{name};
588 $job->{source} = $param->{source};
589 $job = add_state_to_job($job);
590
591 return $job;
592 }
593
594 sub destroy_job {
595 my ($param) = @_;
596
597 locked("$CONFIG_PATH/cron_and_state.lock", sub {
598 my $job = get_job($param);
599 $job->{state} = "del";
600
601 update_cron($job);
602 update_state($job);
603 });
604 }
605
606 sub get_instance_id {
607 my ($pid) = @_;
608
609 my $stat = read_file("/proc/$pid/stat", 1)
610 or die "unable to read process stats\n";
611 my $boot_id = read_file("/proc/sys/kernel/random/boot_id", 1)
612 or die "unable to read boot ID\n";
613
614 my $stats = [ split(/\s+/, $stat) ];
615 my $starttime = $stats->[21];
616 chomp($boot_id);
617
618 return "${pid}:${starttime}:${boot_id}";
619 }
620
621 sub instance_exists {
622 my ($instance_id) = @_;
623
624 if (defined($instance_id) && $instance_id =~ m/^([1-9][0-9]*):/) {
625 my $pid = $1;
626 my $actual_id = eval { get_instance_id($pid); };
627 return defined($actual_id) && $actual_id eq $instance_id;
628 }
629
630 return 0;
631 }
632
633 sub sync {
634 my ($param) = @_;
635
636 my $job;
637
638 locked("$CONFIG_PATH/cron_and_state.lock", sub {
639 eval { $job = get_job($param) };
640
641 if ($job) {
642 my $state = $job->{state} // 'ok';
643 $state = 'ok' if !instance_exists($job->{instance_id});
644
645 if ($state eq "syncing" || $state eq "waiting") {
646 die "Job --source $param->{source} --name $param->{name} is already scheduled to sync\n";
647 }
648
649 $job->{state} = "waiting";
650 $job->{instance_id} = $INSTANCE_ID;
651
652 update_state($job);
653 }
654 });
655
656 locked("$CONFIG_PATH/sync.lock", sub {
657
658 my $date = get_date();
659
660 my $dest;
661 my $source;
662 my $vm_type;
663
664 locked("$CONFIG_PATH/cron_and_state.lock", sub {
665 #job might've changed while we waited for the sync lock, but we can be sure it's not syncing
666 eval { $job = get_job($param); };
667
668 if ($job && defined($job->{state}) && $job->{state} eq "stopped") {
669 die "Job --source $param->{source} --name $param->{name} has been disabled\n";
670 }
671
672 $dest = parse_target($param->{dest});
673 $source = parse_target($param->{source});
674
675 $vm_type = vm_exists($source, $param->{source_user});
676 $source->{vm_type} = $vm_type;
677
678 if ($job) {
679 $job->{state} = "syncing";
680 $job->{vm_type} = $vm_type if !$job->{vm_type};
681 update_state($job);
682 }
683 }); #cron and state lock
684
685 my $sync_path = sub {
686 my ($source, $dest, $job, $param, $date) = @_;
687
688 my $dest_dataset = target_dataset($source, $dest);
689
690 ($dest->{old_snap}, $dest->{last_snap}) = snapshot_get(
691 $dest_dataset,
692 $param->{dest_maxsnap} // $param->{maxsnap},
693 $param->{name},
694 $dest->{ip},
695 $param->{dest_user},
696 );
697
698 ($source->{old_snap}) = snapshot_get(
699 $source->{all},
700 $param->{maxsnap},
701 $param->{name},
702 $source->{ip},
703 $param->{source_user},
704 );
705
706 prepare_prepended_target($source, $dest, $param->{dest_user}) if defined($dest->{prepend});
707
708 snapshot_add($source, $dest, $param->{name}, $date, $param->{source_user}, $param->{dest_user});
709
710 send_image($source, $dest, $param);
711
712 for my $old_snap (@{$source->{old_snap}}) {
713 snapshot_destroy($source->{all}, $old_snap, $source->{ip}, $param->{source_user});
714 }
715
716 for my $old_snap (@{$dest->{old_snap}}) {
717 snapshot_destroy($dest_dataset, $old_snap, $dest->{ip}, $param->{dest_user});
718 }
719 };
720
721 eval{
722 if ($source->{vmid}) {
723 die "VM $source->{vmid} doesn't exist\n" if !$vm_type;
724 die "source-user has to be root for syncing VMs\n" if ($param->{source_user} ne "root");
725 my $disks = get_disks($source, $param->{source_user});
726
727 foreach my $disk (sort keys %{$disks}) {
728 $source->{all} = $disks->{$disk}->{all};
729 $source->{pool} = $disks->{$disk}->{pool};
730 $source->{path} = $disks->{$disk}->{path} if $disks->{$disk}->{path};
731 $source->{last_part} = $disks->{$disk}->{last_part};
732
733 $dest->{prepend} = $disks->{$disk}->{storage_id}
734 if $param->{prepend_storage_id};
735
736 &$sync_path($source, $dest, $job, $param, $date);
737 }
738 if ($param->{method} eq "ssh" && ($source->{ip} || $dest->{ip})) {
739 send_config($source, $dest,'ssh', $param->{source_user}, $param->{dest_user}, $param->{dest_config_path});
740 } else {
741 send_config($source, $dest,'local', $param->{source_user}, $param->{dest_user}, $param->{dest_config_path});
742 }
743 } else {
744 &$sync_path($source, $dest, $job, $param, $date);
745 }
746 };
747 if (my $err = $@) {
748 locked("$CONFIG_PATH/cron_and_state.lock", sub {
749 eval { $job = get_job($param); };
750 if ($job) {
751 $job->{state} = "error";
752 delete $job->{instance_id};
753 update_state($job);
754 }
755 });
756 print "Job --source $param->{source} --name $param->{name} got an ERROR!!!\nERROR Message:\n";
757 die "$err\n";
758 }
759
760 locked("$CONFIG_PATH/cron_and_state.lock", sub {
761 eval { $job = get_job($param); };
762 if ($job) {
763 if (defined($job->{state}) && $job->{state} eq "stopped") {
764 $job->{state} = "stopped";
765 } else {
766 $job->{state} = "ok";
767 }
768 $job->{lsync} = $date;
769 delete $job->{instance_id};
770 update_state($job);
771 }
772 });
773 }); #sync lock
774 }
775
776 sub snapshot_get{
777 my ($dataset, $max_snap, $name, $ip, $user) = @_;
778
779 my $cmd = [];
780 push @$cmd, 'ssh', "$user\@$ip", '--', if $ip;
781 push @$cmd, 'zfs', 'list', '-r', '-t', 'snapshot', '-Ho', 'name', '-S', 'creation';
782 push @$cmd, $dataset;
783
784 my $raw;
785 eval {$raw = run_cmd($cmd)};
786 if (my $erro =$@) { #this means the volume doesn't exist on dest yet
787 return undef;
788 }
789
790 my $index = 0;
791 my $line = "";
792 my $last_snap = undef;
793 my $old_snap = [];
794
795 while ($raw && $raw =~ s/^(.*?)(\n|$)//) {
796 $line = $1;
797 if ($line =~ m/@(.*)$/) {
798 $last_snap = $1 if (!$last_snap);
799 }
800 if ($line =~ m/(rep_\Q${name}\E_\d{4}-\d{2}-\d{2}_\d{2}:\d{2}:\d{2})$/) {
801 # interpreted as infinity
802 last if $max_snap <= 0;
803
804 my $snap = $1;
805 $index++;
806
807 if ($index >= $max_snap) {
808 push @{$old_snap}, $snap;
809 }
810 }
811 }
812
813 return ($old_snap, $last_snap) if $last_snap;
814
815 return undef;
816 }
817
818 sub snapshot_add {
819 my ($source, $dest, $name, $date, $source_user, $dest_user) = @_;
820
821 my $snap_name = "rep_$name\_".$date;
822
823 $source->{new_snap} = $snap_name;
824
825 my $path = "$source->{all}\@$snap_name";
826
827 my $cmd = [];
828 push @$cmd, 'ssh', "$source_user\@$source->{ip}", '--', if $source->{ip};
829 push @$cmd, 'zfs', 'snapshot', $path;
830 eval{
831 run_cmd($cmd);
832 };
833
834 if (my $err = $@) {
835 snapshot_destroy($source->{all}, $snap_name, $source->{ip}, $source_user);
836 die "$err\n";
837 }
838 }
839
840 sub get_disks {
841 my ($target, $user) = @_;
842
843 my $cmd = [];
844 push @$cmd, 'ssh', "$user\@$target->{ip}", '--', if $target->{ip};
845
846 if ($target->{vm_type} eq 'qemu') {
847 push @$cmd, 'qm', 'config', $target->{vmid};
848 } elsif ($target->{vm_type} eq 'lxc') {
849 push @$cmd, 'pct', 'config', $target->{vmid};
850 } else {
851 die "VM Type unknown\n";
852 }
853
854 my $res = run_cmd($cmd);
855
856 my $disks = parse_disks($res, $target->{ip}, $target->{vm_type}, $user);
857
858 return $disks;
859 }
860
861 sub run_cmd {
862 my ($cmd) = @_;
863 print "Start CMD\n" if $DEBUG;
864 print Dumper $cmd if $DEBUG;
865 if (ref($cmd) eq 'ARRAY') {
866 $cmd = join(' ', map { ref($_) ? $$_ : shell_quote($_) } @$cmd);
867 }
868 my $output = `$cmd 2>&1`;
869
870 die "COMMAND:\n\t$cmd\nGET ERROR:\n\t$output" if 0 != $?;
871
872 chomp($output);
873 print Dumper $output if $DEBUG;
874 print "END CMD\n" if $DEBUG;
875 return $output;
876 }
877
878 sub parse_disks {
879 my ($text, $ip, $vm_type, $user) = @_;
880
881 my $disks;
882
883 my $num = 0;
884 while ($text && $text =~ s/^(.*?)(\n|$)//) {
885 my $line = $1;
886
887 next if $line =~ /media=cdrom/;
888 next if $line !~ m/$DISK_KEY_RE/;
889
890 #QEMU if backup is not set include in sync
891 next if $vm_type eq 'qemu' && ($line =~ m/backup=(?i:0|no|off|false)/);
892
893 #LXC if backup is not set do no in sync
894 next if $vm_type eq 'lxc' && ($line =~ m/^mp\d:/) && ($line !~ m/backup=(?i:1|yes|on|true)/);
895
896 my $disk = undef;
897 my $stor = undef;
898 if($line =~ m/$DISK_KEY_RE(.*)$/) {
899 my @parameter = split(/,/,$1);
900
901 foreach my $opt (@parameter) {
902 if ($opt =~ m/^(?:file=|volume=)?([^:]+):([A-Za-z0-9\-]+)$/){
903 $disk = $2;
904 $stor = $1;
905 last;
906 }
907 }
908 }
909 if (!defined($disk) || !defined($stor)) {
910 print "Disk: \"$line\" has no valid zfs dataset format and will be skipped\n";
911 next;
912 }
913
914 my $cmd = [];
915 push @$cmd, 'ssh', "$user\@$ip", '--' if $ip;
916 push @$cmd, 'pvesm', 'path', "$stor:$disk";
917 my $path = run_cmd($cmd);
918
919 die "Get no path from pvesm path $stor:$disk\n" if !$path;
920
921 $disks->{$num}->{storage_id} = $stor;
922
923 if ($vm_type eq 'qemu' && $path =~ m/^\/dev\/zvol\/(\w+.*)(\/$disk)$/) {
924
925 my @array = split('/', $1);
926 $disks->{$num}->{pool} = shift(@array);
927 $disks->{$num}->{all} = $disks->{$num}->{pool};
928 if (0 < @array) {
929 $disks->{$num}->{path} = join('/', @array);
930 $disks->{$num}->{all} .= "\/$disks->{$num}->{path}";
931 }
932 $disks->{$num}->{last_part} = $disk;
933 $disks->{$num}->{all} .= "\/$disk";
934
935 $num++;
936 } elsif ($vm_type eq 'lxc' && $path =~ m/^\/(\w+.+)(\/(\w+.*))*(\/$disk)$/) {
937
938 $disks->{$num}->{pool} = $1;
939 $disks->{$num}->{all} = $disks->{$num}->{pool};
940
941 if ($2) {
942 $disks->{$num}->{path} = $3;
943 $disks->{$num}->{all} .= "\/$disks->{$num}->{path}";
944 }
945
946 $disks->{$num}->{last_part} = $disk;
947 $disks->{$num}->{all} .= "\/$disk";
948
949 $num++;
950
951 } else {
952 die "ERROR: in path\n";
953 }
954 }
955
956 die "Vm include no disk on zfs.\n" if !$disks->{0};
957 return $disks;
958 }
959
960 # how the corresponding dataset is named on the target
961 sub target_dataset {
962 my ($source, $dest) = @_;
963
964 my $target = "$dest->{all}";
965 $target .= "/$dest->{prepend}" if defined($dest->{prepend});
966 $target .= "/$source->{last_part}" if $source->{last_part};
967 $target =~ s!/+!/!g;
968
969 return $target;
970 }
971
972 # create the parent dataset for the actual target
973 sub prepare_prepended_target {
974 my ($source, $dest, $dest_user) = @_;
975
976 die "internal error - not a prepended target\n" if !defined($dest->{prepend});
977
978 # The parent dataset shouldn't be the actual target.
979 die "internal error - no last_part for source\n" if !$source->{last_part};
980
981 my $target = "$dest->{all}/$dest->{prepend}";
982 $target =~ s!/+!/!g;
983
984 return if check_dataset_exists($target, $dest->{ip}, $dest_user);
985
986 create_file_system($target, $dest->{ip}, $dest_user);
987 }
988
989 sub snapshot_destroy {
990 my ($dataset, $snap, $ip, $user) = @_;
991
992 my @zfscmd = ('zfs', 'destroy');
993 my $snapshot = "$dataset\@$snap";
994
995 eval {
996 if ($ip) {
997 run_cmd(['ssh', "$user\@$ip", '--', @zfscmd, $snapshot]);
998 } else {
999 run_cmd([@zfscmd, $snapshot]);
1000 }
1001 };
1002 if (my $erro = $@) {
1003 warn "WARN: $erro";
1004 }
1005 }
1006
1007 # check if snapshot for incremental sync exist on source side
1008 sub snapshot_exist {
1009 my ($source , $dest, $method, $source_user) = @_;
1010
1011 my $cmd = [];
1012 push @$cmd, 'ssh', "$source_user\@$source->{ip}", '--' if $source->{ip};
1013 push @$cmd, 'zfs', 'list', '-rt', 'snapshot', '-Ho', 'name';
1014
1015 my $path = $source->{all};
1016 $path .= "\@$dest->{last_snap}";
1017
1018 push @$cmd, $path;
1019
1020 eval {run_cmd($cmd)};
1021 if (my $erro =$@) {
1022 warn "WARN: $erro";
1023 return undef;
1024 }
1025 return 1;
1026 }
1027
1028 sub send_image {
1029 my ($source, $dest, $param) = @_;
1030
1031 my $cmd = [];
1032
1033 push @$cmd, 'ssh', '-o', 'BatchMode=yes', "$param->{source_user}\@$source->{ip}", '--' if $source->{ip};
1034 push @$cmd, 'zfs', 'send';
1035 push @$cmd, '-L', if $param->{compressed}; # no effect if dataset never had large recordsize
1036 push @$cmd, '-c', if $param->{compressed};
1037 push @$cmd, '-p', if $param->{properties};
1038 push @$cmd, '-v' if $param->{verbose};
1039
1040 if($dest->{last_snap} && snapshot_exist($source , $dest, $param->{method}, $param->{source_user})) {
1041 push @$cmd, '-i', "$source->{all}\@$dest->{last_snap}";
1042 }
1043 push @$cmd, '--', "$source->{all}\@$source->{new_snap}";
1044
1045 if ($param->{limit}){
1046 my $bwl = $param->{limit}*1024;
1047 push @$cmd, \'|', 'cstream', '-t', $bwl;
1048 }
1049 my $target = target_dataset($source, $dest);
1050
1051 push @$cmd, \'|';
1052 push @$cmd, 'ssh', '-o', 'BatchMode=yes', "$param->{dest_user}\@$dest->{ip}", '--' if $dest->{ip};
1053 push @$cmd, 'zfs', 'recv', '-F', '--';
1054 push @$cmd, "$target";
1055
1056 eval {
1057 run_cmd($cmd)
1058 };
1059
1060 if (my $erro = $@) {
1061 snapshot_destroy($source->{all}, $source->{new_snap}, $source->{ip}, $param->{source_user});
1062 die $erro;
1063 };
1064 }
1065
1066
1067 sub send_config{
1068 my ($source, $dest, $method, $source_user, $dest_user, $dest_config_path) = @_;
1069
1070 my $source_target = $source->{vm_type} eq 'qemu' ? "$QEMU_CONF/$source->{vmid}.conf": "$LXC_CONF/$source->{vmid}.conf";
1071 my $dest_target_new ="$source->{vmid}.conf.$source->{vm_type}.$source->{new_snap}";
1072
1073 my $config_dir = $dest_config_path // $CONFIG_PATH;
1074 $config_dir .= "/$dest->{last_part}" if $dest->{last_part};
1075
1076 $dest_target_new = $config_dir.'/'.$dest_target_new;
1077
1078 if ($method eq 'ssh'){
1079 if ($dest->{ip} && $source->{ip}) {
1080 run_cmd(['ssh', "$dest_user\@$dest->{ip}", '--', 'mkdir', '-p', '--', $config_dir]);
1081 run_cmd(['scp', '--', "$source_user\@[$source->{ip}]:$source_target", "$dest_user\@[$dest->{ip}]:$dest_target_new"]);
1082 } elsif ($dest->{ip}) {
1083 run_cmd(['ssh', "$dest_user\@$dest->{ip}", '--', 'mkdir', '-p', '--', $config_dir]);
1084 run_cmd(['scp', '--', $source_target, "$dest_user\@[$dest->{ip}]:$dest_target_new"]);
1085 } elsif ($source->{ip}) {
1086 run_cmd(['mkdir', '-p', '--', $config_dir]);
1087 run_cmd(['scp', '--', "$source_user\@[$source->{ip}]:$source_target", $dest_target_new]);
1088 }
1089
1090 for my $old_snap (@{$dest->{old_snap}}) {
1091 my $dest_target_old ="${config_dir}/$source->{vmid}.conf.$source->{vm_type}.${old_snap}";
1092 if($dest->{ip}){
1093 run_cmd(['ssh', "$dest_user\@$dest->{ip}", '--', 'rm', '-f', '--', $dest_target_old]);
1094 } else {
1095 run_cmd(['rm', '-f', '--', $dest_target_old]);
1096 }
1097 }
1098 } elsif ($method eq 'local') {
1099 run_cmd(['mkdir', '-p', '--', $config_dir]);
1100 run_cmd(['cp', $source_target, $dest_target_new]);
1101 }
1102 }
1103
1104 sub get_date {
1105 my ($sec, $min, $hour, $mday, $mon, $year, $wday, $yday, $isdst) = localtime(time);
1106 my $datestamp = sprintf ("%04d-%02d-%02d_%02d:%02d:%02d", $year+1900, $mon+1, $mday, $hour, $min, $sec);
1107
1108 return $datestamp;
1109 }
1110
1111 sub status {
1112 my $cfg = read_cron();
1113
1114 my $status_list = sprintf("%-25s%-25s%-10s\n", "SOURCE", "NAME", "STATUS");
1115
1116 my $states = read_state();
1117
1118 foreach my $source (sort keys%{$cfg}) {
1119 foreach my $sync_name (sort keys%{$cfg->{$source}}) {
1120 $status_list .= sprintf("%-25s", cut_target_width($source, 25));
1121 $status_list .= sprintf("%-25s", cut_target_width($sync_name, 25));
1122 $status_list .= "$states->{$source}->{$sync_name}->{state}\n";
1123 }
1124 }
1125
1126 return $status_list;
1127 }
1128
1129 sub enable_job {
1130 my ($param) = @_;
1131
1132 locked("$CONFIG_PATH/cron_and_state.lock", sub {
1133 my $job = get_job($param);
1134 $job->{state} = "ok";
1135 update_state($job);
1136 update_cron($job);
1137 });
1138 }
1139
1140 sub disable_job {
1141 my ($param) = @_;
1142
1143 locked("$CONFIG_PATH/cron_and_state.lock", sub {
1144 my $job = get_job($param);
1145 $job->{state} = "stopped";
1146 update_state($job);
1147 update_cron($job);
1148 });
1149 }
1150
1151 my $cmd_help = {
1152 destroy => qq{
1153 $PROGNAME destroy --source <string> [OPTIONS]
1154
1155 Remove a sync Job from the scheduler
1156
1157 --name string
1158 The name of the sync job, if not set 'default' is used.
1159
1160 --source string
1161 The source can be an <VMID> or [IP:]<ZFSPool>[/Path]
1162 },
1163 create => qq{
1164 $PROGNAME create --dest <string> --source <string> [OPTIONS]
1165
1166 Create a new sync-job
1167
1168 --dest string
1169 The destination target is like [IP]:<Pool>[/Path]
1170
1171 --dest-user string
1172 The name of the user on the destination target, root by default
1173
1174 --limit integer
1175 Maximal sync speed in kBytes/s, default is unlimited
1176
1177 --maxsnap integer
1178 The number of snapshots to keep until older ones are erased.
1179 The default is 1, use 0 for unlimited.
1180
1181 --dest-maxsnap integer
1182 Override maxsnap for the destination dataset.
1183
1184 --name string
1185 The name of the sync job, if not set it is default
1186
1187 --prepend-storage-id
1188 If specified, prepend the storage ID to the destination's path(s).
1189
1190 --skip
1191 If specified, skip the first sync.
1192
1193 --source string
1194 The source can be an <VMID> or [IP:]<ZFSPool>[/Path]
1195
1196 --source-user string
1197 The (ssh) user-name on the source target, root by default
1198
1199 --compressed
1200 If specified, send data without decompressing first. If features lz4_compress,
1201 zstd_compress or large_blocks are in use by the source, they need to be enabled on
1202 the target as well.
1203
1204 --properties
1205 If specified, include the dataset's properties in the stream.
1206
1207 --dest-config-path string
1208 Specifies a custom config path on the destination target.
1209 The default is /var/lib/pve-zsync
1210 },
1211 sync => qq{
1212 $PROGNAME sync --dest <string> --source <string> [OPTIONS]\n
1213
1214 Trigger one sync.
1215
1216 --dest string
1217 The destination target is like [IP:]<Pool>[/Path]
1218
1219 --dest-user string
1220 The (ssh) user-name on the destination target, root by default
1221
1222 --limit integer
1223 The maximal sync speed in kBytes/s, default is unlimited
1224
1225 --maxsnap integer
1226 The number of snapshots to keep until older ones are erased.
1227 The default is 1, use 0 for unlimited.
1228
1229 --dest-maxsnap integer
1230 Override maxsnap for the destination dataset.
1231
1232 --name string
1233 The name of the sync job, if not set it is 'default'.
1234 It is only necessary if scheduler allready contains this source.
1235
1236 --prepend-storage-id
1237 If specified, prepend the storage ID to the destination's path(s).
1238
1239 --source string
1240 The source can either be an <VMID> or [IP:]<ZFSPool>[/Path]
1241
1242 --source-user string
1243 The name of the user on the source target, root by default
1244
1245 --verbose
1246 If specified, print out the sync progress.
1247
1248 --compressed
1249 If specified, send data without decompressing first. If features lz4_compress,
1250 zstd_compress or large_blocks are in use by the source, they need to be enabled on
1251 the target as well.
1252
1253 --properties
1254 If specified, include the dataset's properties in the stream.
1255
1256 --dest-config-path string
1257 Specifies a custom config path on the destination target.
1258 The default is /var/lib/pve-zsync
1259 },
1260 list => qq{
1261 $PROGNAME list
1262
1263 Get a List of all scheduled Sync Jobs
1264 },
1265 status => qq{
1266 $PROGNAME status
1267
1268 Get the status of all scheduled Sync Jobs
1269 },
1270 help => qq{
1271 $PROGNAME help <cmd> [OPTIONS]
1272
1273 Get help about specified command.
1274
1275 <cmd> string
1276 Command name to get help about.
1277
1278 --verbose
1279 Verbose output format.
1280 },
1281 enable => qq{
1282 $PROGNAME enable --source <string> [OPTIONS]
1283
1284 Enable a sync-job and reset all job-errors, if any.
1285
1286 --name string
1287 name of the sync job, if not set it is default
1288
1289 --source string
1290 the source can be an <VMID> or [IP:]<ZFSPool>[/Path]
1291 },
1292 disable => qq{
1293 $PROGNAME disable --source <string> [OPTIONS]
1294
1295 Disables (pauses) a sync-job
1296
1297 --name string
1298 name of the sync-job, if not set it is default
1299
1300 --source string
1301 the source can be an <VMID> or [IP:]<ZFSPool>[/Path]
1302 },
1303 printpod => "$PROGNAME printpod\n\n\tinternal command",
1304
1305 };
1306
1307 if (!$command) {
1308 usage(); die "\n";
1309 } elsif (!$cmd_help->{$command}) {
1310 print "ERROR: unknown command '$command'";
1311 usage(1); die "\n";
1312 }
1313
1314 my @arg = @ARGV;
1315 my $param = parse_argv(@arg);
1316
1317 sub check_params {
1318 for (@_) {
1319 die "$cmd_help->{$command}\n" if !$param->{$_};
1320 }
1321 }
1322
1323 if ($command eq 'destroy') {
1324 check_params(qw(source));
1325
1326 check_target($param->{source});
1327 destroy_job($param);
1328
1329 } elsif ($command eq 'sync') {
1330 check_params(qw(source dest));
1331
1332 check_target($param->{source});
1333 check_target($param->{dest});
1334 sync($param);
1335
1336 } elsif ($command eq 'create') {
1337 check_params(qw(source dest));
1338
1339 check_target($param->{source});
1340 check_target($param->{dest});
1341 init($param);
1342
1343 } elsif ($command eq 'status') {
1344 print status();
1345
1346 } elsif ($command eq 'list') {
1347 print list();
1348
1349 } elsif ($command eq 'help') {
1350 my $help_command = $ARGV[1];
1351
1352 if ($help_command && $cmd_help->{$help_command}) {
1353 die "$cmd_help->{$help_command}\n";
1354
1355 }
1356 if ($param->{verbose}) {
1357 exec("man $PROGNAME");
1358
1359 } else {
1360 usage(1);
1361
1362 }
1363
1364 } elsif ($command eq 'enable') {
1365 check_params(qw(source));
1366
1367 check_target($param->{source});
1368 enable_job($param);
1369
1370 } elsif ($command eq 'disable') {
1371 check_params(qw(source));
1372
1373 check_target($param->{source});
1374 disable_job($param);
1375
1376 } elsif ($command eq 'printpod') {
1377 print_pod();
1378 }
1379
1380 sub usage {
1381 my ($help) = @_;
1382
1383 print("ERROR:\tno command specified\n") if !$help;
1384 print("USAGE:\t$PROGNAME <COMMAND> [ARGS] [OPTIONS]\n");
1385 print("\t$PROGNAME help [<cmd>] [OPTIONS]\n\n");
1386 print("\t$PROGNAME create --dest <string> --source <string> [OPTIONS]\n");
1387 print("\t$PROGNAME destroy --source <string> [OPTIONS]\n");
1388 print("\t$PROGNAME disable --source <string> [OPTIONS]\n");
1389 print("\t$PROGNAME enable --source <string> [OPTIONS]\n");
1390 print("\t$PROGNAME list\n");
1391 print("\t$PROGNAME status\n");
1392 print("\t$PROGNAME sync --dest <string> --source <string> [OPTIONS]\n");
1393 }
1394
1395 sub check_target {
1396 my ($target) = @_;
1397 parse_target($target);
1398 }
1399
1400 sub print_pod {
1401
1402 my $synopsis = join("\n", sort values %$cmd_help);
1403 my $commands = join(", ", sort keys %$cmd_help);
1404
1405 print <<EOF;
1406 =head1 NAME
1407
1408 pve-zsync - PVE ZFS Storage Sync Tool
1409
1410 =head1 SYNOPSIS
1411
1412 pve-zsync <COMMAND> [ARGS] [OPTIONS]
1413
1414 Where <COMMAND> can be one of: $commands
1415
1416 =head1 DESCRIPTION
1417
1418 The pve-zsync tool can help you to sync your VMs or directories stored on ZFS
1419 between multiple servers.
1420
1421 pve-zsync is able to automatically configure CRON jobs, so that a periodic sync
1422 will be automatically triggered.
1423 The default sync interval is 15 min, if you want to change this value you can
1424 do this in F</etc/cron.d/pve-zsync>. If you need help to configure CRON tabs, see
1425 man crontab.
1426
1427 =head1 COMMANDS AND OPTIONS
1428
1429 $synopsis
1430
1431 =head1 EXAMPLES
1432
1433 Adds a job for syncing the local VM 100 to a remote server's ZFS pool named "tank":
1434 pve-zsync create --source=100 -dest=192.168.1.2:tank
1435
1436 =head1 IMPORTANT FILES
1437
1438 Cron jobs and config are stored in F</etc/cron.d/pve-zsync>
1439
1440 The VM configuration itself gets copied to the destination machines
1441 F</var/lib/pve-zsync/> path.
1442
1443 =head1 COPYRIGHT AND DISCLAIMER
1444
1445 Copyright (C) 2007-2021 Proxmox Server Solutions GmbH
1446
1447 This program is free software: you can redistribute it and/or modify it under
1448 the terms of the GNU Affero General Public License as published by the Free
1449 Software Foundation, either version 3 of the License, or (at your option) any
1450 later version.
1451
1452 This program is distributed in the hope that it will be useful, but WITHOUT ANY
1453 WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
1454 PARTICULAR PURPOSE. See the GNU Affero General Public License for more
1455 details.
1456
1457 You should have received a copy of the GNU Affero General Public License along
1458 with this program. If not, see <http://www.gnu.org/licenses/>.
1459
1460 EOF
1461 }