]>
git.proxmox.com Git - pve-zsync.git/blob - pve-zsync
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_dataset_exists
{
156 my ($dataset, $ip, $user) = @_;
161 push @$cmd, 'ssh', "$user\@$ip", '--';
163 push @$cmd, 'zfs', 'list', '-H', '--', $dataset;
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 exist\n"
521 if !check_dataset_exists
($dest->{all
}, $dest->{ip
}, $param->{dest_user
});
523 if (!defined($source->{vmid
})) {
524 die "Pool $source->{all} does not exist\n"
525 if !check_dataset_exists
($source->{all
}, $source->{ip
}, $param->{source_user
});
528 my $vm_type = vm_exists
($source, $param->{source_user
});
529 $job->{vm_type
} = $vm_type;
530 $source->{vm_type
} = $vm_type;
532 die "VM $source->{vmid} doesn't exist\n" if $source->{vmid
} && !$vm_type;
534 die "Config already exists\n" if $cfg->{$job->{source
}}->{$job->{name
}};
536 #check if vm has zfs disks if not die;
537 get_disks
($source, $param->{source_user
}) if $source->{vmid
};
541 }); #cron and state lock
543 return if $param->{skip
};
545 eval { sync
($param) };
555 my $cfg = read_cron
();
557 if (!$cfg->{$param->{source
}}->{$param->{name
}}) {
558 die "Job with source $param->{source} and name $param->{name} does not exist\n" ;
560 my $job = $cfg->{$param->{source
}}->{$param->{name
}};
561 $job->{name
} = $param->{name
};
562 $job->{source
} = $param->{source
};
563 $job = add_state_to_job
($job);
571 locked
("$CONFIG_PATH/cron_and_state.lock", sub {
572 my $job = get_job
($param);
573 $job->{state} = "del";
580 sub get_instance_id
{
583 my $stat = read_file
("/proc/$pid/stat", 1)
584 or die "unable to read process stats\n";
585 my $boot_id = read_file
("/proc/sys/kernel/random/boot_id", 1)
586 or die "unable to read boot ID\n";
588 my $stats = [ split(/\s+/, $stat) ];
589 my $starttime = $stats->[21];
592 return "${pid}:${starttime}:${boot_id}";
595 sub instance_exists
{
596 my ($instance_id) = @_;
598 if (defined($instance_id) && $instance_id =~ m/^([1-9][0-9]*):/) {
600 my $actual_id = eval { get_instance_id
($pid); };
601 return defined($actual_id) && $actual_id eq $instance_id;
612 locked
("$CONFIG_PATH/cron_and_state.lock", sub {
613 eval { $job = get_job
($param) };
616 my $state = $job->{state} // 'ok';
617 $state = 'ok' if !instance_exists
($job->{instance_id
});
619 if ($state eq "syncing" || $state eq "waiting") {
620 die "Job --source $param->{source} --name $param->{name} is already scheduled to sync\n";
623 $job->{state} = "waiting";
624 $job->{instance_id
} = $INSTANCE_ID;
630 locked
("$CONFIG_PATH/sync.lock", sub {
632 my $date = get_date
();
638 locked
("$CONFIG_PATH/cron_and_state.lock", sub {
639 #job might've changed while we waited for the sync lock, but we can be sure it's not syncing
640 eval { $job = get_job
($param); };
642 if ($job && defined($job->{state}) && $job->{state} eq "stopped") {
643 die "Job --source $param->{source} --name $param->{name} has been disabled\n";
646 $dest = parse_target
($param->{dest
});
647 $source = parse_target
($param->{source
});
649 $vm_type = vm_exists
($source, $param->{source_user
});
650 $source->{vm_type
} = $vm_type;
653 $job->{state} = "syncing";
654 $job->{vm_type
} = $vm_type if !$job->{vm_type
};
657 }); #cron and state lock
659 my $sync_path = sub {
660 my ($source, $dest, $job, $param, $date) = @_;
662 ($dest->{old_snap
}, $dest->{last_snap
}) = snapshot_get
($source, $dest, $param->{maxsnap
}, $param->{name
}, $param->{dest_user
});
664 snapshot_add
($source, $dest, $param->{name
}, $date, $param->{source_user
}, $param->{dest_user
});
666 send_image
($source, $dest, $param);
668 snapshot_destroy
($source, $dest, $param->{method}, $dest->{old_snap
}, $param->{source_user
}, $param->{dest_user
}) if ($source->{destroy
} && $dest->{old_snap
});
673 if ($source->{vmid
}) {
674 die "VM $source->{vmid} doesn't exist\n" if !$vm_type;
675 die "source-user has to be root for syncing VMs\n" if ($param->{source_user
} ne "root");
676 my $disks = get_disks
($source, $param->{source_user
});
678 foreach my $disk (sort keys %{$disks}) {
679 $source->{all
} = $disks->{$disk}->{all
};
680 $source->{pool
} = $disks->{$disk}->{pool
};
681 $source->{path
} = $disks->{$disk}->{path
} if $disks->{$disk}->{path
};
682 $source->{last_part
} = $disks->{$disk}->{last_part
};
683 &$sync_path($source, $dest, $job, $param, $date);
685 if ($param->{method} eq "ssh" && ($source->{ip
} || $dest->{ip
})) {
686 send_config
($source, $dest,'ssh', $param->{source_user
}, $param->{dest_user
}, $param->{dest_config_path
});
688 send_config
($source, $dest,'local', $param->{source_user
}, $param->{dest_user
}, $param->{dest_config_path
});
691 &$sync_path($source, $dest, $job, $param, $date);
695 locked
("$CONFIG_PATH/cron_and_state.lock", sub {
696 eval { $job = get_job
($param); };
698 $job->{state} = "error";
699 delete $job->{instance_id
};
703 print "Job --source $param->{source} --name $param->{name} got an ERROR!!!\nERROR Message:\n";
707 locked
("$CONFIG_PATH/cron_and_state.lock", sub {
708 eval { $job = get_job
($param); };
710 if (defined($job->{state}) && $job->{state} eq "stopped") {
711 $job->{state} = "stopped";
713 $job->{state} = "ok";
715 $job->{lsync
} = $date;
716 delete $job->{instance_id
};
724 my ($source, $dest, $max_snap, $name, $dest_user) = @_;
727 push @$cmd, 'ssh', "$dest_user\@$dest->{ip}", '--', if $dest->{ip
};
728 push @$cmd, 'zfs', 'list', '-r', '-t', 'snapshot', '-Ho', 'name', '-S', 'creation';
730 my $path = target_dataset
($source, $dest);
734 eval {$raw = run_cmd
($cmd)};
735 if (my $erro =$@) { #this means the volume doesn't exist on dest yet
741 my $last_snap = undef;
744 while ($raw && $raw =~ s/^(.*?)(\n|$)//) {
746 if ($line =~ m/@(.*)$/) {
747 $last_snap = $1 if (!$last_snap);
749 if ($line =~ m/(rep_\Q${name}\E_\d{4}-\d{2}-\d{2}_\d{2}:\d{2}:\d{2})$/) {
752 if ($index == $max_snap) {
753 $source->{destroy
} = 1;
759 return ($old_snap, $last_snap) if $last_snap;
765 my ($source, $dest, $name, $date, $source_user, $dest_user) = @_;
767 my $snap_name = "rep_$name\_".$date;
769 $source->{new_snap
} = $snap_name;
771 my $path = "$source->{all}\@$snap_name";
774 push @$cmd, 'ssh', "$source_user\@$source->{ip}", '--', if $source->{ip
};
775 push @$cmd, 'zfs', 'snapshot', $path;
781 snapshot_destroy
($source, $dest, 'ssh', $snap_name, $source_user, $dest_user);
787 my ($target, $user) = @_;
790 push @$cmd, 'ssh', "$user\@$target->{ip}", '--', if $target->{ip
};
792 if ($target->{vm_type
} eq 'qemu') {
793 push @$cmd, 'qm', 'config', $target->{vmid
};
794 } elsif ($target->{vm_type
} eq 'lxc') {
795 push @$cmd, 'pct', 'config', $target->{vmid
};
797 die "VM Type unknown\n";
800 my $res = run_cmd
($cmd);
802 my $disks = parse_disks
($res, $target->{ip
}, $target->{vm_type
}, $user);
809 print "Start CMD\n" if $DEBUG;
810 print Dumper
$cmd if $DEBUG;
811 if (ref($cmd) eq 'ARRAY') {
812 $cmd = join(' ', map { ref($_) ?
$$_ : shell_quote
($_) } @$cmd);
814 my $output = `$cmd 2>&1`;
816 die "COMMAND:\n\t$cmd\nGET ERROR:\n\t$output" if 0 != $?;
819 print Dumper
$output if $DEBUG;
820 print "END CMD\n" if $DEBUG;
825 my ($text, $ip, $vm_type, $user) = @_;
830 while ($text && $text =~ s/^(.*?)(\n|$)//) {
833 next if $line =~ /media=cdrom/;
834 next if $line !~ m/$DISK_KEY_RE/;
836 #QEMU if backup is not set include in sync
837 next if $vm_type eq 'qemu' && ($line =~ m/backup=(?i:0|no|off|false)/);
839 #LXC if backup is not set do no in sync
840 next if $vm_type eq 'lxc' && ($line =~ m/^mp\d:/) && ($line !~ m/backup=(?i:1|yes|on|true)/);
844 if($line =~ m/$DISK_KEY_RE(.*)$/) {
845 my @parameter = split(/,/,$1);
847 foreach my $opt (@parameter) {
848 if ($opt =~ m/^(?:file=|volume=)?([^:]+:)([A-Za-z0-9\-]+)$/){
855 if (!defined($disk) || !defined($stor)) {
856 print "Disk: \"$line\" has no valid zfs dataset format
and will be skipped
\n";
861 push @$cmd, 'ssh', "$user\@$ip", '--' if $ip;
862 push @$cmd, 'pvesm', 'path', "$stor$disk";
863 my $path = run_cmd($cmd);
865 die "Get
no path from pvesm path
$stor$disk\n" if !$path;
867 if ($vm_type eq 'qemu' && $path =~ m/^\/dev\/zvol\/(\w+.*)(\/$disk)$/) {
869 my @array = split('/', $1);
870 $disks->{$num}->{pool} = shift(@array);
871 $disks->{$num}->{all} = $disks->{$num}->{pool};
873 $disks->{$num}->{path} = join('/', @array);
874 $disks->{$num}->{all} .= "\
/$disks->{$num}->{path
}";
876 $disks->{$num}->{last_part} = $disk;
877 $disks->{$num}->{all} .= "\
/$disk";
880 } elsif ($vm_type eq 'lxc' && $path =~ m/^\/(\w+.+)(\/(\w+.*))*(\/$disk)$/) {
882 $disks->{$num}->{pool} = $1;
883 $disks->{$num}->{all} = $disks->{$num}->{pool};
886 $disks->{$num}->{path} = $3;
887 $disks->{$num}->{all} .= "\
/$disks->{$num}->{path
}";
890 $disks->{$num}->{last_part} = $disk;
891 $disks->{$num}->{all} .= "\
/$disk";
896 die "ERROR
: in path
\n";
900 die "Vm include
no disk on zfs
.\n" if !$disks->{0};
904 # how the corresponding dataset is named on the target
906 my ($source, $dest) = @_;
908 my $target = "$dest->{all
}";
909 $target .= "/$source->{last_part
}" if $source->{last_part};
915 sub snapshot_destroy {
916 my ($source, $dest, $method, $snap, $source_user, $dest_user) = @_;
918 my @zfscmd = ('zfs', 'destroy');
919 my $snapshot = "$source->{all
}\
@$snap";
922 if($source->{ip} && $method eq 'ssh'){
923 run_cmd(['ssh', "$source_user\@$source->{ip
}", '--', @zfscmd, $snapshot]);
925 run_cmd([@zfscmd, $snapshot]);
932 my @ssh = $dest->{ip} ? ('ssh', "$dest_user\@$dest->{ip
}", '--') : ();
934 my $path = target_dataset($source, $dest);
937 run_cmd([@ssh, @zfscmd, "$path\@$snap"]);
945 # check if snapshot for incremental sync exist on source side
947 my ($source , $dest, $method, $source_user) = @_;
950 push @$cmd, 'ssh', "$source_user\@$source->{ip
}", '--' if $source->{ip};
951 push @$cmd, 'zfs', 'list', '-rt', 'snapshot', '-Ho', 'name';
953 my $path = $source->{all};
954 $path .= "\
@$dest->{last_snap
}";
958 eval {run_cmd($cmd)};
967 my ($source, $dest, $param) = @_;
971 push @$cmd, 'ssh', '-o', 'BatchMode=yes', "$param->{source_user
}\
@$source->{ip
}", '--' if $source->{ip};
972 push @$cmd, 'zfs', 'send';
973 push @$cmd, '-p', if $param->{properties};
974 push @$cmd, '-v' if $param->{verbose};
976 if($dest->{last_snap} && snapshot_exist($source , $dest, $param->{method}, $param->{source_user})) {
977 push @$cmd, '-i', "$source->{all
}\
@$dest->{last_snap
}";
979 push @$cmd, '--', "$source->{all
}\
@$source->{new_snap
}";
981 if ($param->{limit}){
982 my $bwl = $param->{limit}*1024;
983 push @$cmd, \'|', 'cstream', '-t', $bwl;
985 my $target = target_dataset($source, $dest);
988 push @$cmd, 'ssh', '-o', 'BatchMode=yes', "$param->{dest_user
}\
@$dest->{ip
}", '--' if $dest->{ip};
989 push @$cmd, 'zfs', 'recv', '-F', '--';
990 push @$cmd, "$target";
997 snapshot_destroy($source, undef, $param->{method}, $source->{new_snap}, $param->{source_user}, $param->{dest_user});
1004 my ($source, $dest, $method, $source_user, $dest_user, $dest_config_path) = @_;
1006 my $source_target = $source->{vm_type} eq 'qemu' ? "$QEMU_CONF/$source->{vmid
}.conf
": "$LXC_CONF/$source->{vmid
}.conf
";
1007 my $dest_target_new ="$source->{vmid
}.conf
.$source->{vm_type
}.$source->{new_snap
}";
1009 my $config_dir = $dest_config_path // $CONFIG_PATH;
1010 $config_dir .= "/$dest->{last_part
}" if $dest->{last_part};
1012 $dest_target_new = $config_dir.'/'.$dest_target_new;
1014 if ($method eq 'ssh'){
1015 if ($dest->{ip} && $source->{ip}) {
1016 run_cmd(['ssh', "$dest_user\@$dest->{ip
}", '--', 'mkdir', '-p', '--', $config_dir]);
1017 run_cmd(['scp', '--', "$source_user\@[$source->{ip
}]:$source_target", "$dest_user\@[$dest->{ip
}]:$dest_target_new"]);
1018 } elsif ($dest->{ip}) {
1019 run_cmd(['ssh', "$dest_user\@$dest->{ip
}", '--', 'mkdir', '-p', '--', $config_dir]);
1020 run_cmd(['scp', '--', $source_target, "$dest_user\@[$dest->{ip
}]:$dest_target_new"]);
1021 } elsif ($source->{ip}) {
1022 run_cmd(['mkdir', '-p', '--', $config_dir]);
1023 run_cmd(['scp', '--', "$source_user\@[$source->{ip
}]:$source_target", $dest_target_new]);
1026 if ($source->{destroy}){
1027 my $dest_target_old ="${config_dir
}/$source->{vmid
}.conf
.$source->{vm_type
}.$dest->{old_snap
}";
1029 run_cmd(['ssh', "$dest_user\@$dest->{ip
}", '--', 'rm', '-f', '--', $dest_target_old]);
1031 run_cmd(['rm', '-f', '--', $dest_target_old]);
1034 } elsif ($method eq 'local') {
1035 run_cmd(['mkdir', '-p', '--', $config_dir]);
1036 run_cmd(['cp', $source_target, $dest_target_new]);
1041 my ($sec, $min, $hour, $mday, $mon, $year, $wday, $yday, $isdst) = localtime(time);
1042 my $datestamp = sprintf ("%04d-%02d-%02d_%02d:%02d:%02d", $year+1900, $mon+1, $mday, $hour, $min, $sec);
1048 my $cfg = read_cron();
1050 my $status_list = sprintf("%-25s
%-25s
%-10s
\n", "SOURCE
", "NAME
", "STATUS
");
1052 my $states = read_state();
1054 foreach my $source (sort keys%{$cfg}) {
1055 foreach my $sync_name (sort keys%{$cfg->{$source}}) {
1056 $status_list .= sprintf("%-25s
", cut_target_width($source, 25));
1057 $status_list .= sprintf("%-25s
", cut_target_width($sync_name, 25));
1058 $status_list .= "$states->{$source}->{$sync_name}->{state}\n";
1062 return $status_list;
1068 locked("$CONFIG_PATH/cron_and_state.lock", sub {
1069 my $job = get_job($param);
1070 $job->{state} = "ok
";
1079 locked("$CONFIG_PATH/cron_and_state.lock", sub {
1080 my $job = get_job($param);
1081 $job->{state} = "stopped
";
1089 $PROGNAME destroy -source <string> [OPTIONS]
1091 remove a sync Job from the scheduler
1095 name of the sync job, if not set it is default
1099 the source can be an <VMID> or [IP:]<ZFSPool>[/Path]
1102 $PROGNAME create -dest <string> -source <string> [OPTIONS]
1108 the destination target is like [IP]:<Pool>[/Path]
1112 name of the user on the destination target, root by default
1116 max sync speed in kBytes/s, default unlimited
1120 how much snapshots will be kept before get erased, default 1
1124 name of the sync job, if not set it is default
1128 If specified, skip the first sync.
1132 the source can be an <VMID> or [IP:]<ZFSPool>[/Path]
1136 name of the user on the source target, root by default
1140 If specified, include the dataset's properties in the stream.
1142 -dest-config-path string
1144 specify a custom config path on the destination target. default is /var/lib/pve-zsync
1147 $PROGNAME sync -dest <string> -source <string> [OPTIONS]\n
1153 the destination target is like [IP:]<Pool>[/Path]
1157 name of the user on the destination target, root by default
1161 max sync speed in kBytes/s, default unlimited
1165 how much snapshots will be kept before get erased, default 1
1169 name of the sync job, if not set it is default.
1170 It is only necessary if scheduler allready contains this source.
1174 the source can be an <VMID> or [IP:]<ZFSPool>[/Path]
1178 name of the user on the source target, root by default
1182 If specified, print out the sync progress.
1186 If specified, include the dataset's properties in the stream.
1188 -dest-config-path string
1190 specify a custom config path on the destination target. default is /var/lib/pve-zsync
1195 Get a List of all scheduled Sync Jobs
1200 Get the status of all scheduled Sync Jobs
1203 $PROGNAME help <cmd> [OPTIONS]
1205 Get help about specified command.
1213 Verbose output format.
1216 $PROGNAME enable -source <string> [OPTIONS]
1218 enable a syncjob and reset error
1222 name of the sync job, if not set it is default
1226 the source can be an <VMID> or [IP:]<ZFSPool>[/Path]
1229 $PROGNAME disable -source <string> [OPTIONS]
1235 name of the sync job, if not set it is default
1239 the source can be an <VMID> or [IP:]<ZFSPool>[/Path]
1241 printpod
=> 'internal command',
1247 } elsif (!$cmd_help->{$command}) {
1248 print "ERROR: unknown command '$command'";
1253 my $param = parse_argv
(@arg);
1257 die "$cmd_help->{$command}\n" if !$param->{$_};
1261 if ($command eq 'destroy') {
1262 check_params
(qw(source));
1264 check_target
($param->{source
});
1265 destroy_job
($param);
1267 } elsif ($command eq 'sync') {
1268 check_params
(qw(source dest));
1270 check_target
($param->{source
});
1271 check_target
($param->{dest
});
1274 } elsif ($command eq 'create') {
1275 check_params
(qw(source dest));
1277 check_target
($param->{source
});
1278 check_target
($param->{dest
});
1281 } elsif ($command eq 'status') {
1284 } elsif ($command eq 'list') {
1287 } elsif ($command eq 'help') {
1288 my $help_command = $ARGV[1];
1290 if ($help_command && $cmd_help->{$help_command}) {
1291 die "$cmd_help->{$help_command}\n";
1294 if ($param->{verbose
}) {
1295 exec("man $PROGNAME");
1302 } elsif ($command eq 'enable') {
1303 check_params
(qw(source));
1305 check_target
($param->{source
});
1308 } elsif ($command eq 'disable') {
1309 check_params
(qw(source));
1311 check_target
($param->{source
});
1312 disable_job
($param);
1314 } elsif ($command eq 'printpod') {
1321 print("ERROR:\tno command specified\n") if !$help;
1322 print("USAGE:\t$PROGNAME <COMMAND> [ARGS] [OPTIONS]\n");
1323 print("\t$PROGNAME help [<cmd>] [OPTIONS]\n\n");
1324 print("\t$PROGNAME create -dest <string> -source <string> [OPTIONS]\n");
1325 print("\t$PROGNAME destroy -source <string> [OPTIONS]\n");
1326 print("\t$PROGNAME disable -source <string> [OPTIONS]\n");
1327 print("\t$PROGNAME enable -source <string> [OPTIONS]\n");
1328 print("\t$PROGNAME list\n");
1329 print("\t$PROGNAME status\n");
1330 print("\t$PROGNAME sync -dest <string> -source <string> [OPTIONS]\n");
1335 parse_target
($target);
1340 my $synopsis = join("\n", sort values %$cmd_help);
1345 pve-zsync - PVE ZFS Replication Manager
1349 pve-zsync <COMMAND> [ARGS] [OPTIONS]
1355 This Tool helps you to sync your VM or directory which stored on ZFS between 2 servers.
1356 This tool also has the capability to add jobs to cron so the sync will be automatically done.
1357 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.
1358 To config cron see man crontab.
1360 =head2 PVE ZFS Storage sync Tool
1362 This Tool can get remote pool on other PVE or send Pool to others ZFS machines
1366 add sync job from local VM to remote ZFS Server
1367 pve-zsync create -source=100 -dest=192.168.1.2:zfspool
1369 =head1 IMPORTANT FILES
1371 Cron jobs and config are stored at /etc/cron.d/pve-zsync
1373 The VM config get copied on the destination machine to /var/lib/pve-zsync/
1375 =head1 COPYRIGHT AND DISCLAIMER
1377 Copyright (C) 2007-2021 Proxmox Server Solutions GmbH
1379 This program is free software: you can redistribute it and/or modify it
1380 under the terms of the GNU Affero General Public License as published
1381 by the Free Software Foundation, either version 3 of the License, or
1382 (at your option) any later version.
1384 This program is distributed in the hope that it will be useful, but
1385 WITHOUT ANY WARRANTY; without even the implied warranty of
1386 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
1387 Affero General Public License for more details.
1389 You should have received a copy of the GNU Affero General Public
1390 License along with this program. If not, see
1391 <http://www.gnu.org/licenses/>.