]>
git.proxmox.com Git - pve-zsync.git/blob - pve-zsync
a21da4929b1add1d6a12948ae27bf34837d5cabd
6 use Fcntl
qw(:flock SEEK_END);
7 use Getopt
::Long
qw(GetOptionsFromArray);
8 use File
::Path
qw(make_path);
11 use String
::ShellQuote
'shell_quote';
13 my $PROGNAME = "pve-zsync";
14 my $CONFIG_PATH = "/var/lib/${PROGNAME}";
15 my $STATE = "${CONFIG_PATH}/sync_state";
16 my $CRONJOBS = "/etc/cron.d/$PROGNAME";
17 my $PATH = "/usr/sbin";
18 my $PVE_DIR = "/etc/pve/local";
19 my $QEMU_CONF = "${PVE_DIR}/qemu-server";
20 my $LXC_CONF = "${PVE_DIR}/lxc";
21 my $PROG_PATH = "$PATH/${PROGNAME}";
26 $DEBUG = 0; # change default here. not above on declaration!
27 $DEBUG ||= $ENV{ZSYNC_DEBUG
};
30 Data
::Dumper-
>import();
34 my $IPV4OCTET = "(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])";
35 my $IPV4RE = "(?:(?:$IPV4OCTET\\.){3}$IPV4OCTET)";
36 my $IPV6H16 = "(?:[0-9a-fA-F]{1,4})";
37 my $IPV6LS32 = "(?:(?:$IPV4RE|$IPV6H16:$IPV6H16))";
40 "(?:(?:" . "(?:$IPV6H16:){6})$IPV6LS32)|" .
41 "(?:(?:" . "::(?:$IPV6H16:){5})$IPV6LS32)|" .
42 "(?:(?:(?:" . "$IPV6H16)?::(?:$IPV6H16:){4})$IPV6LS32)|" .
43 "(?:(?:(?:(?:$IPV6H16:){0,1}$IPV6H16)?::(?:$IPV6H16:){3})$IPV6LS32)|" .
44 "(?:(?:(?:(?:$IPV6H16:){0,2}$IPV6H16)?::(?:$IPV6H16:){2})$IPV6LS32)|" .
45 "(?:(?:(?:(?:$IPV6H16:){0,3}$IPV6H16)?::(?:$IPV6H16:){1})$IPV6LS32)|" .
46 "(?:(?:(?:(?:$IPV6H16:){0,4}$IPV6H16)?::" . ")$IPV6LS32)|" .
47 "(?:(?:(?:(?:$IPV6H16:){0,5}$IPV6H16)?::" . ")$IPV6H16)|" .
48 "(?:(?:(?:(?:$IPV6H16:){0,6}$IPV6H16)?::" . ")))";
50 my $HOSTv4RE0 = "(?:[\\w\\.\\-_]+|$IPV4RE)"; # hostname or ipv4 address
51 my $HOSTv4RE1 = "(?:$HOSTv4RE0|\\[$HOSTv4RE0\\])"; # these may be in brackets, too
52 my $HOSTRE = "(?:$HOSTv4RE1|\\[$IPV6RE\\])"; # ipv6 must always be in brackets
53 # targets are either a VMID, or a 'host:zpool/path' with 'host:' being optional
54 my $TARGETRE = qr!^(?:($HOSTRE):)?(\d+|(?:[\w\-_]+)(/.+)?)$!;
56 my $DISK_KEY_RE = qr/^(?:(?:(?:virtio|ide|scsi|sata|efidisk|mp)\d+)|rootfs): /;
58 my $INSTANCE_ID = get_instance_id
($$);
60 my $command = $ARGV[0];
62 if (defined($command) && $command ne 'help' && $command ne 'printpod') {
63 check_bin
('cstream');
69 $SIG{TERM
} = $SIG{QUIT
} = $SIG{PIPE
} = $SIG{HUP
} = $SIG{KILL
} = $SIG{INT
} = sub {
70 die "Signaled, aborting sync: $!\n";
76 foreach my $p (split (/:/, $ENV{PATH
})) {
83 die "unable to find command '$bin'\n";
87 my ($filename, $one_line_only) = @_;
89 my $fh = IO
::File-
>new($filename, "r")
90 or die "Could not open file ${filename}: $!\n";
92 my $text = $one_line_only ?
<$fh> : [ <$fh> ];
99 sub cut_target_width
{
100 my ($path, $maxlen) = @_;
103 return $path if length($path) <= $maxlen;
105 return '..'.substr($path, -$maxlen+2) if $path !~ m
@/@;
107 $path =~ s
@/([^/]+/?
)$@@;
110 if (length($tail)+3 == $maxlen) {
112 } elsif (length($tail)+2 >= $maxlen) {
113 return '..'.substr($tail, -$maxlen+2)
116 $path =~ s
@(/[^/]+)(?
:/|$)@@;
118 my $both = length($head) + length($tail);
119 my $remaining = $maxlen-$both-4; # -4 for "/../"
121 if ($remaining < 0) {
122 return substr($head, 0, $maxlen - length($tail) - 3) . "../$tail"; # -3 for "../"
125 substr($path, ($remaining/2), (length($path)-$remaining), '..');
126 return "$head/" . $path . "/$tail";
130 my ($lock_fn, $code) = @_;
132 my $lock_fh = IO
::File-
>new("> $lock_fn");
134 flock($lock_fh, LOCK_EX
) || die "Couldn't acquire lock - $!\n";
135 my $res = eval { $code->() };
138 flock($lock_fh, LOCK_UN
) || warn "Error unlocking - $!\n";
146 my ($source, $name, $status) = @_;
148 if ($status->{$source->{all
}}->{$name}->{status
}) {
155 sub check_pool_exists
{
156 my ($target, $user) = @_;
161 push @$cmd, 'ssh', "$user\@$target->{ip}", '--';
163 push @$cmd, 'zfs', 'list', '-H', '--', $target->{all
};
177 my $errstr = "$text : is not a valid input! Use [IP:]<VMID> or [IP:]<ZFSPool>[/Path]";
180 if ($text !~ $TARGETRE) {
184 $target->{ip
} = $1 if $1;
185 my @parts = split('/', $2);
187 $target->{ip
} =~ s/^\[(.*)\]$/$1/ if $target->{ip
};
189 my $pool = $target->{pool
} = shift(@parts);
190 die "$errstr\n" if !$pool;
192 if ($pool =~ m/^\d+$/) {
193 $target->{vmid
} = $pool;
194 delete $target->{pool
};
197 return $target if (@parts == 0);
198 $target->{last_part
} = pop(@parts);
204 $target->{path
} = join('/', @parts);
212 #This is for the first use to init file;
214 my $new_fh = IO
::File-
>new("> $CRONJOBS");
215 die "Could not create $CRONJOBS: $!\n" if !$new_fh;
220 my $text = read_file
($CRONJOBS, 0);
222 return encode_cron
(@{$text});
237 source_user
=> undef,
240 dest_config_path
=> undef,
243 my ($ret) = GetOptionsFromArray
(
245 'dest=s' => \
$param->{dest
},
246 'source=s' => \
$param->{source
},
247 'verbose' => \
$param->{verbose
},
248 'limit=i' => \
$param->{limit
},
249 'maxsnap=i' => \
$param->{maxsnap
},
250 'name=s' => \
$param->{name
},
251 'skip' => \
$param->{skip
},
252 'method=s' => \
$param->{method},
253 'source-user=s' => \
$param->{source_user
},
254 'dest-user=s' => \
$param->{dest_user
},
255 'properties' => \
$param->{properties
},
256 'dest-config-path=s' => \
$param->{dest_config_path
},
259 die "can't parse options\n" if $ret == 0;
261 $param->{name
} //= "default";
262 $param->{maxsnap
} //= 1;
263 $param->{method} //= "ssh";
264 $param->{source_user
} //= "root";
265 $param->{dest_user
} //= "root";
270 sub add_state_to_job
{
273 my $states = read_state
();
274 my $state = $states->{$job->{source
}}->{$job->{name
}};
276 $job->{state} = $state->{state};
277 $job->{lsync
} = $state->{lsync
};
278 $job->{vm_type
} = $state->{vm_type
};
279 $job->{instance_id
} = $state->{instance_id
};
281 for (my $i = 0; $state->{"snap$i"}; $i++) {
282 $job->{"snap$i"} = $state->{"snap$i"};
293 while (my $line = shift(@text)) {
295 my @arg = split('\s', $line);
296 my $param = parse_argv
(@arg);
298 if ($param->{source
} && $param->{dest
}) {
299 my $source = delete $param->{source
};
300 my $name = delete $param->{name
};
302 $cfg->{$source}->{$name} = $param;
314 my $source = parse_target
($param->{source
});
315 my $dest = parse_target
($param->{dest
}) if $param->{dest
};
317 $job->{name
} = !$param->{name
} ?
"default" : $param->{name
};
318 $job->{dest
} = $param->{dest
} if $param->{dest
};
319 $job->{method} = "local" if !$dest->{ip
} && !$source->{ip
};
320 $job->{method} = "ssh" if !$job->{method};
321 $job->{limit
} = $param->{limit
};
322 $job->{maxsnap
} = $param->{maxsnap
} if $param->{maxsnap
};
323 $job->{source
} = $param->{source
};
324 $job->{source_user
} = $param->{source_user
};
325 $job->{dest_user
} = $param->{dest_user
};
326 $job->{properties
} = !!$param->{properties
};
327 $job->{dest_config_path
} = $param->{dest_config_path
} if $param->{dest_config_path
};
335 make_path
$CONFIG_PATH;
336 my $new_fh = IO
::File-
>new("> $STATE");
337 die "Could not create $STATE: $!\n" if !$new_fh;
343 my $text = read_file
($STATE, 1);
344 return decode_json
($text);
350 my $text = eval { read_file
($STATE, 1); };
352 my $out_fh = IO
::File-
>new("> $STATE.new");
353 die "Could not open file ${STATE}.new: $!\n" if !$out_fh;
358 $states = decode_json
($text);
359 $state = $states->{$job->{source
}}->{$job->{name
}};
362 if ($job->{state} ne "del") {
363 $state->{state} = $job->{state};
364 $state->{lsync
} = $job->{lsync
};
365 $state->{instance_id
} = $job->{instance_id
};
366 $state->{vm_type
} = $job->{vm_type
};
368 for (my $i = 0; $job->{"snap$i"} ; $i++) {
369 $state->{"snap$i"} = $job->{"snap$i"};
371 $states->{$job->{source
}}->{$job->{name
}} = $state;
374 delete $states->{$job->{source
}}->{$job->{name
}};
375 delete $states->{$job->{source
}} if !keys %{$states->{$job->{source
}}};
378 $text = encode_json
($states);
382 rename "$STATE.new", $STATE;
394 my $header = "SHELL=/bin/sh\n";
395 $header .= "PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin\n\n";
397 my $current = read_file
($CRONJOBS, 0);
399 foreach my $line (@{$current}) {
401 if ($line =~ m/source $job->{source} .*name $job->{name} /) {
403 next if $job->{state} eq "del";
404 $text .= format_job
($job, $line);
406 if (($line_no < 3) && ($line =~ /^(PATH|SHELL)/ )) {
415 $text = "$header$text";
419 $text .= format_job
($job);
421 my $new_fh = IO
::File-
>new("> ${CRONJOBS}.new");
422 die "Could not open file ${CRONJOBS}.new: $!\n" if !$new_fh;
424 die "can't write to $CRONJOBS.new\n" if !print($new_fh $text);
427 die "can't move $CRONJOBS.new: $!\n" if !rename "${CRONJOBS}.new", $CRONJOBS;
431 my ($job, $line) = @_;
434 if ($job->{state} eq "stopped") {
438 $line =~ /^#*\s*((?:\S+\s+){4}\S+)\s+root/;
441 $text .= "*/$INTERVAL * * * *";
444 $text .= " $PROGNAME sync --source $job->{source} --dest $job->{dest}";
445 $text .= " --name $job->{name} --maxsnap $job->{maxsnap}";
446 $text .= " --limit $job->{limit}" if $job->{limit
};
447 $text .= " --method $job->{method}";
448 $text .= " --verbose" if $job->{verbose
};
449 $text .= " --source-user $job->{source_user}";
450 $text .= " --dest-user $job->{dest_user}";
451 $text .= " --properties" if $job->{properties
};
452 $text .= " --dest-config-path $job->{dest_config_path}" if $job->{dest_config_path
};
460 my $cfg = read_cron
();
462 my $list = sprintf("%-25s%-25s%-10s%-20s%-6s%-5s\n" , "SOURCE", "NAME", "STATE", "LAST SYNC", "TYPE", "CON");
464 my $states = read_state
();
465 foreach my $source (sort keys%{$cfg}) {
466 foreach my $name (sort keys%{$cfg->{$source}}) {
467 $list .= sprintf("%-25s", cut_target_width
($source, 25));
468 $list .= sprintf("%-25s", cut_target_width
($name, 25));
469 $list .= sprintf("%-10s", $states->{$source}->{$name}->{state});
470 $list .= sprintf("%-20s", $states->{$source}->{$name}->{lsync
});
471 $list .= sprintf("%-6s", defined($states->{$source}->{$name}->{vm_type
}) ?
$states->{$source}->{$name}->{vm_type
} : "undef");
472 $list .= sprintf("%-5s\n", $cfg->{$source}->{$name}->{method});
480 my ($target, $user) = @_;
482 return undef if !defined($target->{vmid
});
484 my $conf_fn = "$target->{vmid}.conf";
487 my @cmd = ('ssh', "$user\@$target->{ip}", '--', '/bin/ls');
488 return "qemu" if eval { run_cmd
([@cmd, "$QEMU_CONF/$conf_fn"]) };
489 return "lxc" if eval { run_cmd
([@cmd, "$LXC_CONF/$conf_fn"]) };
491 return "qemu" if -f
"$QEMU_CONF/$conf_fn";
492 return "lxc" if -f
"$LXC_CONF/$conf_fn";
501 locked
("$CONFIG_PATH/cron_and_state.lock", sub {
502 my $cfg = read_cron
();
504 my $job = param_to_job
($param);
506 $job->{state} = "ok";
509 my $source = parse_target
($param->{source
});
510 my $dest = parse_target
($param->{dest
});
512 if (my $ip = $dest->{ip
}) {
513 run_cmd
(['ssh-copy-id', '-i', '/root/.ssh/id_rsa.pub', "$param->{dest_user}\@$ip"]);
516 if (my $ip = $source->{ip
}) {
517 run_cmd
(['ssh-copy-id', '-i', '/root/.ssh/id_rsa.pub', "$param->{source_user}\@$ip"]);
520 die "Pool $dest->{all} does not exists\n" if !check_pool_exists
($dest, $param->{dest_user
});
522 if (!defined($source->{vmid
})) {
523 die "Pool $source->{all} does not exists\n" if !check_pool_exists
($source, $param->{source_user
});
526 my $vm_type = vm_exists
($source, $param->{source_user
});
527 $job->{vm_type
} = $vm_type;
528 $source->{vm_type
} = $vm_type;
530 die "VM $source->{vmid} doesn't exist\n" if $source->{vmid
} && !$vm_type;
532 die "Config already exists\n" if $cfg->{$job->{source
}}->{$job->{name
}};
534 #check if vm has zfs disks if not die;
535 get_disks
($source, $param->{source_user
}) if $source->{vmid
};
539 }); #cron and state lock
541 return if $param->{skip
};
543 eval { sync
($param) };
553 my $cfg = read_cron
();
555 if (!$cfg->{$param->{source
}}->{$param->{name
}}) {
556 die "Job with source $param->{source} and name $param->{name} does not exist\n" ;
558 my $job = $cfg->{$param->{source
}}->{$param->{name
}};
559 $job->{name
} = $param->{name
};
560 $job->{source
} = $param->{source
};
561 $job = add_state_to_job
($job);
569 locked
("$CONFIG_PATH/cron_and_state.lock", sub {
570 my $job = get_job
($param);
571 $job->{state} = "del";
578 sub get_instance_id
{
581 my $stat = read_file
("/proc/$pid/stat", 1)
582 or die "unable to read process stats\n";
583 my $boot_id = read_file
("/proc/sys/kernel/random/boot_id", 1)
584 or die "unable to read boot ID\n";
586 my $stats = [ split(/\s+/, $stat) ];
587 my $starttime = $stats->[21];
590 return "${pid}:${starttime}:${boot_id}";
593 sub instance_exists
{
594 my ($instance_id) = @_;
596 if (defined($instance_id) && $instance_id =~ m/^([1-9][0-9]*):/) {
598 my $actual_id = eval { get_instance_id
($pid); };
599 return defined($actual_id) && $actual_id eq $instance_id;
610 locked
("$CONFIG_PATH/cron_and_state.lock", sub {
611 eval { $job = get_job
($param) };
614 my $state = $job->{state} // 'ok';
615 $state = 'ok' if !instance_exists
($job->{instance_id
});
617 if ($state eq "syncing" || $state eq "waiting") {
618 die "Job --source $param->{source} --name $param->{name} is already scheduled to sync\n";
621 $job->{state} = "waiting";
622 $job->{instance_id
} = $INSTANCE_ID;
628 locked
("$CONFIG_PATH/sync.lock", sub {
630 my $date = get_date
();
636 locked
("$CONFIG_PATH/cron_and_state.lock", sub {
637 #job might've changed while we waited for the sync lock, but we can be sure it's not syncing
638 eval { $job = get_job
($param); };
640 if ($job && defined($job->{state}) && $job->{state} eq "stopped") {
641 die "Job --source $param->{source} --name $param->{name} has been disabled\n";
644 $dest = parse_target
($param->{dest
});
645 $source = parse_target
($param->{source
});
647 $vm_type = vm_exists
($source, $param->{source_user
});
648 $source->{vm_type
} = $vm_type;
651 $job->{state} = "syncing";
652 $job->{vm_type
} = $vm_type if !$job->{vm_type
};
655 }); #cron and state lock
657 my $sync_path = sub {
658 my ($source, $dest, $job, $param, $date) = @_;
660 ($dest->{old_snap
}, $dest->{last_snap
}) = snapshot_get
($source, $dest, $param->{maxsnap
}, $param->{name
}, $param->{dest_user
});
662 snapshot_add
($source, $dest, $param->{name
}, $date, $param->{source_user
}, $param->{dest_user
});
664 send_image
($source, $dest, $param);
666 snapshot_destroy
($source, $dest, $param->{method}, $dest->{old_snap
}, $param->{source_user
}, $param->{dest_user
}) if ($source->{destroy
} && $dest->{old_snap
});
671 if ($source->{vmid
}) {
672 die "VM $source->{vmid} doesn't exist\n" if !$vm_type;
673 die "source-user has to be root for syncing VMs\n" if ($param->{source_user
} ne "root");
674 my $disks = get_disks
($source, $param->{source_user
});
676 foreach my $disk (sort keys %{$disks}) {
677 $source->{all
} = $disks->{$disk}->{all
};
678 $source->{pool
} = $disks->{$disk}->{pool
};
679 $source->{path
} = $disks->{$disk}->{path
} if $disks->{$disk}->{path
};
680 $source->{last_part
} = $disks->{$disk}->{last_part
};
681 &$sync_path($source, $dest, $job, $param, $date);
683 if ($param->{method} eq "ssh" && ($source->{ip
} || $dest->{ip
})) {
684 send_config
($source, $dest,'ssh', $param->{source_user
}, $param->{dest_user
}, $param->{dest_config_path
});
686 send_config
($source, $dest,'local', $param->{source_user
}, $param->{dest_user
}, $param->{dest_config_path
});
689 &$sync_path($source, $dest, $job, $param, $date);
693 locked
("$CONFIG_PATH/cron_and_state.lock", sub {
694 eval { $job = get_job
($param); };
696 $job->{state} = "error";
697 delete $job->{instance_id
};
701 print "Job --source $param->{source} --name $param->{name} got an ERROR!!!\nERROR Message:\n";
705 locked
("$CONFIG_PATH/cron_and_state.lock", sub {
706 eval { $job = get_job
($param); };
708 if (defined($job->{state}) && $job->{state} eq "stopped") {
709 $job->{state} = "stopped";
711 $job->{state} = "ok";
713 $job->{lsync
} = $date;
714 delete $job->{instance_id
};
722 my ($source, $dest, $max_snap, $name, $dest_user) = @_;
725 push @$cmd, 'ssh', "$dest_user\@$dest->{ip}", '--', if $dest->{ip
};
726 push @$cmd, 'zfs', 'list', '-r', '-t', 'snapshot', '-Ho', 'name', '-S', 'creation';
728 my $path = $dest->{all
};
729 $path .= "/$source->{last_part}" if $source->{last_part
};
733 eval {$raw = run_cmd
($cmd)};
734 if (my $erro =$@) { #this means the volume doesn't exist on dest yet
740 my $last_snap = undef;
743 while ($raw && $raw =~ s/^(.*?)(\n|$)//) {
745 if ($line =~ m/@(.*)$/) {
746 $last_snap = $1 if (!$last_snap);
748 if ($line =~ m/(rep_\Q${name}\E_\d{4}-\d{2}-\d{2}_\d{2}:\d{2}:\d{2})$/) {
751 if ($index == $max_snap) {
752 $source->{destroy
} = 1;
758 return ($old_snap, $last_snap) if $last_snap;
764 my ($source, $dest, $name, $date, $source_user, $dest_user) = @_;
766 my $snap_name = "rep_$name\_".$date;
768 $source->{new_snap
} = $snap_name;
770 my $path = "$source->{all}\@$snap_name";
773 push @$cmd, 'ssh', "$source_user\@$source->{ip}", '--', if $source->{ip
};
774 push @$cmd, 'zfs', 'snapshot', $path;
780 snapshot_destroy
($source, $dest, 'ssh', $snap_name, $source_user, $dest_user);
786 my ($target, $user) = @_;
789 push @$cmd, 'ssh', "$user\@$target->{ip}", '--', if $target->{ip
};
791 if ($target->{vm_type
} eq 'qemu') {
792 push @$cmd, 'qm', 'config', $target->{vmid
};
793 } elsif ($target->{vm_type
} eq 'lxc') {
794 push @$cmd, 'pct', 'config', $target->{vmid
};
796 die "VM Type unknown\n";
799 my $res = run_cmd
($cmd);
801 my $disks = parse_disks
($res, $target->{ip
}, $target->{vm_type
}, $user);
808 print "Start CMD\n" if $DEBUG;
809 print Dumper
$cmd if $DEBUG;
810 if (ref($cmd) eq 'ARRAY') {
811 $cmd = join(' ', map { ref($_) ?
$$_ : shell_quote
($_) } @$cmd);
813 my $output = `$cmd 2>&1`;
815 die "COMMAND:\n\t$cmd\nGET ERROR:\n\t$output" if 0 != $?;
818 print Dumper
$output if $DEBUG;
819 print "END CMD\n" if $DEBUG;
824 my ($text, $ip, $vm_type, $user) = @_;
829 while ($text && $text =~ s/^(.*?)(\n|$)//) {
832 next if $line =~ /media=cdrom/;
833 next if $line !~ m/$DISK_KEY_RE/;
835 #QEMU if backup is not set include in sync
836 next if $vm_type eq 'qemu' && ($line =~ m/backup=(?i:0|no|off|false)/);
838 #LXC if backup is not set do no in sync
839 next if $vm_type eq 'lxc' && ($line =~ m/^mp\d:/) && ($line !~ m/backup=(?i:1|yes|on|true)/);
843 if($line =~ m/$DISK_KEY_RE(.*)$/) {
844 my @parameter = split(/,/,$1);
846 foreach my $opt (@parameter) {
847 if ($opt =~ m/^(?:file=|volume=)?([^:]+:)([A-Za-z0-9\-]+)$/){
854 if (!defined($disk) || !defined($stor)) {
855 print "Disk: \"$line\" has no valid zfs dataset format
and will be skipped
\n";
860 push @$cmd, 'ssh', "$user\@$ip", '--' if $ip;
861 push @$cmd, 'pvesm', 'path', "$stor$disk";
862 my $path = run_cmd($cmd);
864 die "Get
no path from pvesm path
$stor$disk\n" if !$path;
866 if ($vm_type eq 'qemu' && $path =~ m/^\/dev\/zvol\/(\w+.*)(\/$disk)$/) {
868 my @array = split('/', $1);
869 $disks->{$num}->{pool} = shift(@array);
870 $disks->{$num}->{all} = $disks->{$num}->{pool};
872 $disks->{$num}->{path} = join('/', @array);
873 $disks->{$num}->{all} .= "\
/$disks->{$num}->{path
}";
875 $disks->{$num}->{last_part} = $disk;
876 $disks->{$num}->{all} .= "\
/$disk";
879 } elsif ($vm_type eq 'lxc' && $path =~ m/^\/(\w+.+)(\/(\w+.*))*(\/$disk)$/) {
881 $disks->{$num}->{pool} = $1;
882 $disks->{$num}->{all} = $disks->{$num}->{pool};
885 $disks->{$num}->{path} = $3;
886 $disks->{$num}->{all} .= "\
/$disks->{$num}->{path
}";
889 $disks->{$num}->{last_part} = $disk;
890 $disks->{$num}->{all} .= "\
/$disk";
895 die "ERROR
: in path
\n";
899 die "Vm include
no disk on zfs
.\n" if !$disks->{0};
903 sub snapshot_destroy {
904 my ($source, $dest, $method, $snap, $source_user, $dest_user) = @_;
906 my @zfscmd = ('zfs', 'destroy');
907 my $snapshot = "$source->{all
}\
@$snap";
910 if($source->{ip} && $method eq 'ssh'){
911 run_cmd(['ssh', "$source_user\@$source->{ip
}", '--', @zfscmd, $snapshot]);
913 run_cmd([@zfscmd, $snapshot]);
920 my @ssh = $dest->{ip} ? ('ssh', "$dest_user\@$dest->{ip
}", '--') : ();
922 my $path = "$dest->{all
}";
923 $path .= "/$source->{last_part
}" if $source->{last_part};
926 run_cmd([@ssh, @zfscmd, "$path\@$snap"]);
934 # check if snapshot for incremental sync exist on source side
936 my ($source , $dest, $method, $source_user) = @_;
939 push @$cmd, 'ssh', "$source_user\@$source->{ip
}", '--' if $source->{ip};
940 push @$cmd, 'zfs', 'list', '-rt', 'snapshot', '-Ho', 'name';
942 my $path = $source->{all};
943 $path .= "\
@$dest->{last_snap
}";
947 eval {run_cmd($cmd)};
956 my ($source, $dest, $param) = @_;
960 push @$cmd, 'ssh', '-o', 'BatchMode=yes', "$param->{source_user
}\
@$source->{ip
}", '--' if $source->{ip};
961 push @$cmd, 'zfs', 'send';
962 push @$cmd, '-p', if $param->{properties};
963 push @$cmd, '-v' if $param->{verbose};
965 if($dest->{last_snap} && snapshot_exist($source , $dest, $param->{method}, $param->{source_user})) {
966 push @$cmd, '-i', "$source->{all
}\
@$dest->{last_snap
}";
968 push @$cmd, '--', "$source->{all
}\
@$source->{new_snap
}";
970 if ($param->{limit}){
971 my $bwl = $param->{limit}*1024;
972 push @$cmd, \'|', 'cstream', '-t', $bwl;
974 my $target = "$dest->{all
}";
975 $target .= "/$source->{last_part
}" if $source->{last_part};
979 push @$cmd, 'ssh', '-o', 'BatchMode=yes', "$param->{dest_user
}\
@$dest->{ip
}", '--' if $dest->{ip};
980 push @$cmd, 'zfs', 'recv', '-F', '--';
981 push @$cmd, "$target";
988 snapshot_destroy($source, undef, $param->{method}, $source->{new_snap}, $param->{source_user}, $param->{dest_user});
995 my ($source, $dest, $method, $source_user, $dest_user, $dest_config_path) = @_;
997 my $source_target = $source->{vm_type} eq 'qemu' ? "$QEMU_CONF/$source->{vmid
}.conf
": "$LXC_CONF/$source->{vmid
}.conf
";
998 my $dest_target_new ="$source->{vmid
}.conf
.$source->{vm_type
}.$source->{new_snap
}";
1000 my $config_dir = $dest_config_path // $CONFIG_PATH;
1001 $config_dir .= "/$dest->{last_part
}" if $dest->{last_part};
1003 $dest_target_new = $config_dir.'/'.$dest_target_new;
1005 if ($method eq 'ssh'){
1006 if ($dest->{ip} && $source->{ip}) {
1007 run_cmd(['ssh', "$dest_user\@$dest->{ip
}", '--', 'mkdir', '-p', '--', $config_dir]);
1008 run_cmd(['scp', '--', "$source_user\@[$source->{ip
}]:$source_target", "$dest_user\@[$dest->{ip
}]:$dest_target_new"]);
1009 } elsif ($dest->{ip}) {
1010 run_cmd(['ssh', "$dest_user\@$dest->{ip
}", '--', 'mkdir', '-p', '--', $config_dir]);
1011 run_cmd(['scp', '--', $source_target, "$dest_user\@[$dest->{ip
}]:$dest_target_new"]);
1012 } elsif ($source->{ip}) {
1013 run_cmd(['mkdir', '-p', '--', $config_dir]);
1014 run_cmd(['scp', '--', "$source_user\@[$source->{ip
}]:$source_target", $dest_target_new]);
1017 if ($source->{destroy}){
1018 my $dest_target_old ="${config_dir
}/$source->{vmid
}.conf
.$source->{vm_type
}.$dest->{old_snap
}";
1020 run_cmd(['ssh', "$dest_user\@$dest->{ip
}", '--', 'rm', '-f', '--', $dest_target_old]);
1022 run_cmd(['rm', '-f', '--', $dest_target_old]);
1025 } elsif ($method eq 'local') {
1026 run_cmd(['mkdir', '-p', '--', $config_dir]);
1027 run_cmd(['cp', $source_target, $dest_target_new]);
1032 my ($sec, $min, $hour, $mday, $mon, $year, $wday, $yday, $isdst) = localtime(time);
1033 my $datestamp = sprintf ("%04d-%02d-%02d_%02d:%02d:%02d", $year+1900, $mon+1, $mday, $hour, $min, $sec);
1039 my $cfg = read_cron();
1041 my $status_list = sprintf("%-25s
%-25s
%-10s
\n", "SOURCE
", "NAME
", "STATUS
");
1043 my $states = read_state();
1045 foreach my $source (sort keys%{$cfg}) {
1046 foreach my $sync_name (sort keys%{$cfg->{$source}}) {
1047 $status_list .= sprintf("%-25s
", cut_target_width($source, 25));
1048 $status_list .= sprintf("%-25s
", cut_target_width($sync_name, 25));
1049 $status_list .= "$states->{$source}->{$sync_name}->{state}\n";
1053 return $status_list;
1059 locked("$CONFIG_PATH/cron_and_state.lock", sub {
1060 my $job = get_job($param);
1061 $job->{state} = "ok
";
1070 locked("$CONFIG_PATH/cron_and_state.lock", sub {
1071 my $job = get_job($param);
1072 $job->{state} = "stopped
";
1080 $PROGNAME destroy -source <string> [OPTIONS]
1082 remove a sync Job from the scheduler
1086 name of the sync job, if not set it is default
1090 the source can be an <VMID> or [IP:]<ZFSPool>[/Path]
1093 $PROGNAME create -dest <string> -source <string> [OPTIONS]
1099 the destination target is like [IP]:<Pool>[/Path]
1103 name of the user on the destination target, root by default
1107 max sync speed in kBytes/s, default unlimited
1111 how much snapshots will be kept before get erased, default 1
1115 name of the sync job, if not set it is default
1119 If specified, skip the first sync.
1123 the source can be an <VMID> or [IP:]<ZFSPool>[/Path]
1127 name of the user on the source target, root by default
1131 If specified, include the dataset's properties in the stream.
1133 -dest-config-path string
1135 specify a custom config path on the destination target. default is /var/lib/pve-zsync
1138 $PROGNAME sync -dest <string> -source <string> [OPTIONS]\n
1144 the destination target is like [IP:]<Pool>[/Path]
1148 name of the user on the destination target, root by default
1152 max sync speed in kBytes/s, default unlimited
1156 how much snapshots will be kept before get erased, default 1
1160 name of the sync job, if not set it is default.
1161 It is only necessary if scheduler allready contains this source.
1165 the source can be an <VMID> or [IP:]<ZFSPool>[/Path]
1169 name of the user on the source target, root by default
1173 If specified, print out the sync progress.
1177 If specified, include the dataset's properties in the stream.
1179 -dest-config-path string
1181 specify a custom config path on the destination target. default is /var/lib/pve-zsync
1186 Get a List of all scheduled Sync Jobs
1191 Get the status of all scheduled Sync Jobs
1194 $PROGNAME help <cmd> [OPTIONS]
1196 Get help about specified command.
1204 Verbose output format.
1207 $PROGNAME enable -source <string> [OPTIONS]
1209 enable a syncjob and reset error
1213 name of the sync job, if not set it is default
1217 the source can be an <VMID> or [IP:]<ZFSPool>[/Path]
1220 $PROGNAME disable -source <string> [OPTIONS]
1226 name of the sync job, if not set it is default
1230 the source can be an <VMID> or [IP:]<ZFSPool>[/Path]
1232 printpod
=> 'internal command',
1238 } elsif (!$cmd_help->{$command}) {
1239 print "ERROR: unknown command '$command'";
1244 my $param = parse_argv
(@arg);
1248 die "$cmd_help->{$command}\n" if !$param->{$_};
1252 if ($command eq 'destroy') {
1253 check_params
(qw(source));
1255 check_target
($param->{source
});
1256 destroy_job
($param);
1258 } elsif ($command eq 'sync') {
1259 check_params
(qw(source dest));
1261 check_target
($param->{source
});
1262 check_target
($param->{dest
});
1265 } elsif ($command eq 'create') {
1266 check_params
(qw(source dest));
1268 check_target
($param->{source
});
1269 check_target
($param->{dest
});
1272 } elsif ($command eq 'status') {
1275 } elsif ($command eq 'list') {
1278 } elsif ($command eq 'help') {
1279 my $help_command = $ARGV[1];
1281 if ($help_command && $cmd_help->{$help_command}) {
1282 die "$cmd_help->{$help_command}\n";
1285 if ($param->{verbose
}) {
1286 exec("man $PROGNAME");
1293 } elsif ($command eq 'enable') {
1294 check_params
(qw(source));
1296 check_target
($param->{source
});
1299 } elsif ($command eq 'disable') {
1300 check_params
(qw(source));
1302 check_target
($param->{source
});
1303 disable_job
($param);
1305 } elsif ($command eq 'printpod') {
1312 print("ERROR:\tno command specified\n") if !$help;
1313 print("USAGE:\t$PROGNAME <COMMAND> [ARGS] [OPTIONS]\n");
1314 print("\t$PROGNAME help [<cmd>] [OPTIONS]\n\n");
1315 print("\t$PROGNAME create -dest <string> -source <string> [OPTIONS]\n");
1316 print("\t$PROGNAME destroy -source <string> [OPTIONS]\n");
1317 print("\t$PROGNAME disable -source <string> [OPTIONS]\n");
1318 print("\t$PROGNAME enable -source <string> [OPTIONS]\n");
1319 print("\t$PROGNAME list\n");
1320 print("\t$PROGNAME status\n");
1321 print("\t$PROGNAME sync -dest <string> -source <string> [OPTIONS]\n");
1326 parse_target
($target);
1331 my $synopsis = join("\n", sort values %$cmd_help);
1336 pve-zsync - PVE ZFS Replication Manager
1340 pve-zsync <COMMAND> [ARGS] [OPTIONS]
1346 This Tool helps you to sync your VM or directory which stored on ZFS between 2 servers.
1347 This tool also has the capability to add jobs to cron so the sync will be automatically done.
1348 The default syncing interval is set to 15 min, if you want to change this value you can do this in /etc/cron.d/pve-zsync.
1349 To config cron see man crontab.
1351 =head2 PVE ZFS Storage sync Tool
1353 This Tool can get remote pool on other PVE or send Pool to others ZFS machines
1357 add sync job from local VM to remote ZFS Server
1358 pve-zsync create -source=100 -dest=192.168.1.2:zfspool
1360 =head1 IMPORTANT FILES
1362 Cron jobs and config are stored at /etc/cron.d/pve-zsync
1364 The VM config get copied on the destination machine to /var/lib/pve-zsync/
1366 =head1 COPYRIGHT AND DISCLAIMER
1368 Copyright (C) 2007-2021 Proxmox Server Solutions GmbH
1370 This program is free software: you can redistribute it and/or modify it
1371 under the terms of the GNU Affero General Public License as published
1372 by the Free Software Foundation, either version 3 of the License, or
1373 (at your option) any later version.
1375 This program is distributed in the hope that it will be useful, but
1376 WITHOUT ANY WARRANTY; without even the implied warranty of
1377 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
1378 Affero General Public License for more details.
1380 You should have received a copy of the GNU Affero General Public
1381 License along with this program. If not, see
1382 <http://www.gnu.org/licenses/>.