]>
git.proxmox.com Git - pve-zsync.git/blob - pve-zsync
75d4d7ce33171456e8088ac357457efc17787984
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;
174 sub create_file_system
{
175 my ($file_system, $ip, $user) = @_;
180 push @$cmd, 'ssh', "$user\@$ip", '--';
182 push @$cmd, 'zfs', 'create', $file_system;
190 my $errstr = "$text : is not a valid input! Use [IP:]<VMID> or [IP:]<ZFSPool>[/Path]";
193 if ($text !~ $TARGETRE) {
197 $target->{ip
} = $1 if $1;
198 my @parts = split('/', $2);
200 $target->{ip
} =~ s/^\[(.*)\]$/$1/ if $target->{ip
};
202 my $pool = $target->{pool
} = shift(@parts);
203 die "$errstr\n" if !$pool;
205 if ($pool =~ m/^\d+$/) {
206 $target->{vmid
} = $pool;
207 delete $target->{pool
};
210 return $target if (@parts == 0);
211 $target->{last_part
} = pop(@parts);
217 $target->{path
} = join('/', @parts);
225 #This is for the first use to init file;
227 my $new_fh = IO
::File-
>new("> $CRONJOBS");
228 die "Could not create $CRONJOBS: $!\n" if !$new_fh;
233 my $text = read_file
($CRONJOBS, 0);
235 return encode_cron
(@{$text});
250 source_user
=> undef,
253 dest_config_path
=> undef,
256 my ($ret) = GetOptionsFromArray
(
258 'dest=s' => \
$param->{dest
},
259 'source=s' => \
$param->{source
},
260 'verbose' => \
$param->{verbose
},
261 'limit=i' => \
$param->{limit
},
262 'maxsnap=i' => \
$param->{maxsnap
},
263 'name=s' => \
$param->{name
},
264 'skip' => \
$param->{skip
},
265 'method=s' => \
$param->{method},
266 'source-user=s' => \
$param->{source_user
},
267 'dest-user=s' => \
$param->{dest_user
},
268 'properties' => \
$param->{properties
},
269 'dest-config-path=s' => \
$param->{dest_config_path
},
272 die "can't parse options\n" if $ret == 0;
274 $param->{name
} //= "default";
275 $param->{maxsnap
} //= 1;
276 $param->{method} //= "ssh";
277 $param->{source_user
} //= "root";
278 $param->{dest_user
} //= "root";
283 sub add_state_to_job
{
286 my $states = read_state
();
287 my $state = $states->{$job->{source
}}->{$job->{name
}};
289 $job->{state} = $state->{state};
290 $job->{lsync
} = $state->{lsync
};
291 $job->{vm_type
} = $state->{vm_type
};
292 $job->{instance_id
} = $state->{instance_id
};
294 for (my $i = 0; $state->{"snap$i"}; $i++) {
295 $job->{"snap$i"} = $state->{"snap$i"};
306 while (my $line = shift(@text)) {
308 my @arg = split('\s', $line);
309 my $param = parse_argv
(@arg);
311 if ($param->{source
} && $param->{dest
}) {
312 my $source = delete $param->{source
};
313 my $name = delete $param->{name
};
315 $cfg->{$source}->{$name} = $param;
327 my $source = parse_target
($param->{source
});
328 my $dest = parse_target
($param->{dest
}) if $param->{dest
};
330 $job->{name
} = !$param->{name
} ?
"default" : $param->{name
};
331 $job->{dest
} = $param->{dest
} if $param->{dest
};
332 $job->{method} = "local" if !$dest->{ip
} && !$source->{ip
};
333 $job->{method} = "ssh" if !$job->{method};
334 $job->{limit
} = $param->{limit
};
335 $job->{maxsnap
} = $param->{maxsnap
} if $param->{maxsnap
};
336 $job->{source
} = $param->{source
};
337 $job->{source_user
} = $param->{source_user
};
338 $job->{dest_user
} = $param->{dest_user
};
339 $job->{properties
} = !!$param->{properties
};
340 $job->{dest_config_path
} = $param->{dest_config_path
} if $param->{dest_config_path
};
348 make_path
$CONFIG_PATH;
349 my $new_fh = IO
::File-
>new("> $STATE");
350 die "Could not create $STATE: $!\n" if !$new_fh;
356 my $text = read_file
($STATE, 1);
357 return decode_json
($text);
363 my $text = eval { read_file
($STATE, 1); };
365 my $out_fh = IO
::File-
>new("> $STATE.new");
366 die "Could not open file ${STATE}.new: $!\n" if !$out_fh;
371 $states = decode_json
($text);
372 $state = $states->{$job->{source
}}->{$job->{name
}};
375 if ($job->{state} ne "del") {
376 $state->{state} = $job->{state};
377 $state->{lsync
} = $job->{lsync
};
378 $state->{instance_id
} = $job->{instance_id
};
379 $state->{vm_type
} = $job->{vm_type
};
381 for (my $i = 0; $job->{"snap$i"} ; $i++) {
382 $state->{"snap$i"} = $job->{"snap$i"};
384 $states->{$job->{source
}}->{$job->{name
}} = $state;
387 delete $states->{$job->{source
}}->{$job->{name
}};
388 delete $states->{$job->{source
}} if !keys %{$states->{$job->{source
}}};
391 $text = encode_json
($states);
395 rename "$STATE.new", $STATE;
407 my $header = "SHELL=/bin/sh\n";
408 $header .= "PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin\n\n";
410 my $current = read_file
($CRONJOBS, 0);
412 foreach my $line (@{$current}) {
414 if ($line =~ m/source $job->{source} .*name $job->{name} /) {
416 next if $job->{state} eq "del";
417 $text .= format_job
($job, $line);
419 if (($line_no < 3) && ($line =~ /^(PATH|SHELL)/ )) {
428 $text = "$header$text";
432 $text .= format_job
($job);
434 my $new_fh = IO
::File-
>new("> ${CRONJOBS}.new");
435 die "Could not open file ${CRONJOBS}.new: $!\n" if !$new_fh;
437 die "can't write to $CRONJOBS.new\n" if !print($new_fh $text);
440 die "can't move $CRONJOBS.new: $!\n" if !rename "${CRONJOBS}.new", $CRONJOBS;
444 my ($job, $line) = @_;
447 if ($job->{state} eq "stopped") {
451 $line =~ /^#*\s*((?:\S+\s+){4}\S+)\s+root/;
454 $text .= "*/$INTERVAL * * * *";
457 $text .= " $PROGNAME sync --source $job->{source} --dest $job->{dest}";
458 $text .= " --name $job->{name} --maxsnap $job->{maxsnap}";
459 $text .= " --limit $job->{limit}" if $job->{limit
};
460 $text .= " --method $job->{method}";
461 $text .= " --verbose" if $job->{verbose
};
462 $text .= " --source-user $job->{source_user}";
463 $text .= " --dest-user $job->{dest_user}";
464 $text .= " --properties" if $job->{properties
};
465 $text .= " --dest-config-path $job->{dest_config_path}" if $job->{dest_config_path
};
473 my $cfg = read_cron
();
475 my $list = sprintf("%-25s%-25s%-10s%-20s%-6s%-5s\n" , "SOURCE", "NAME", "STATE", "LAST SYNC", "TYPE", "CON");
477 my $states = read_state
();
478 foreach my $source (sort keys%{$cfg}) {
479 foreach my $name (sort keys%{$cfg->{$source}}) {
480 $list .= sprintf("%-25s", cut_target_width
($source, 25));
481 $list .= sprintf("%-25s", cut_target_width
($name, 25));
482 $list .= sprintf("%-10s", $states->{$source}->{$name}->{state});
483 $list .= sprintf("%-20s", $states->{$source}->{$name}->{lsync
});
484 $list .= sprintf("%-6s", defined($states->{$source}->{$name}->{vm_type
}) ?
$states->{$source}->{$name}->{vm_type
} : "undef");
485 $list .= sprintf("%-5s\n", $cfg->{$source}->{$name}->{method});
493 my ($target, $user) = @_;
495 return undef if !defined($target->{vmid
});
497 my $conf_fn = "$target->{vmid}.conf";
500 my @cmd = ('ssh', "$user\@$target->{ip}", '--', '/bin/ls');
501 return "qemu" if eval { run_cmd
([@cmd, "$QEMU_CONF/$conf_fn"]) };
502 return "lxc" if eval { run_cmd
([@cmd, "$LXC_CONF/$conf_fn"]) };
504 return "qemu" if -f
"$QEMU_CONF/$conf_fn";
505 return "lxc" if -f
"$LXC_CONF/$conf_fn";
514 locked
("$CONFIG_PATH/cron_and_state.lock", sub {
515 my $cfg = read_cron
();
517 my $job = param_to_job
($param);
519 $job->{state} = "ok";
522 my $source = parse_target
($param->{source
});
523 my $dest = parse_target
($param->{dest
});
525 if (my $ip = $dest->{ip
}) {
526 run_cmd
(['ssh-copy-id', '-i', '/root/.ssh/id_rsa.pub', "$param->{dest_user}\@$ip"]);
529 if (my $ip = $source->{ip
}) {
530 run_cmd
(['ssh-copy-id', '-i', '/root/.ssh/id_rsa.pub', "$param->{source_user}\@$ip"]);
533 die "Pool $dest->{all} does not exist\n"
534 if !check_dataset_exists
($dest->{all
}, $dest->{ip
}, $param->{dest_user
});
536 if (!defined($source->{vmid
})) {
537 die "Pool $source->{all} does not exist\n"
538 if !check_dataset_exists
($source->{all
}, $source->{ip
}, $param->{source_user
});
541 my $vm_type = vm_exists
($source, $param->{source_user
});
542 $job->{vm_type
} = $vm_type;
543 $source->{vm_type
} = $vm_type;
545 die "VM $source->{vmid} doesn't exist\n" if $source->{vmid
} && !$vm_type;
547 die "Config already exists\n" if $cfg->{$job->{source
}}->{$job->{name
}};
549 #check if vm has zfs disks if not die;
550 get_disks
($source, $param->{source_user
}) if $source->{vmid
};
554 }); #cron and state lock
556 return if $param->{skip
};
558 eval { sync
($param) };
568 my $cfg = read_cron
();
570 if (!$cfg->{$param->{source
}}->{$param->{name
}}) {
571 die "Job with source $param->{source} and name $param->{name} does not exist\n" ;
573 my $job = $cfg->{$param->{source
}}->{$param->{name
}};
574 $job->{name
} = $param->{name
};
575 $job->{source
} = $param->{source
};
576 $job = add_state_to_job
($job);
584 locked
("$CONFIG_PATH/cron_and_state.lock", sub {
585 my $job = get_job
($param);
586 $job->{state} = "del";
593 sub get_instance_id
{
596 my $stat = read_file
("/proc/$pid/stat", 1)
597 or die "unable to read process stats\n";
598 my $boot_id = read_file
("/proc/sys/kernel/random/boot_id", 1)
599 or die "unable to read boot ID\n";
601 my $stats = [ split(/\s+/, $stat) ];
602 my $starttime = $stats->[21];
605 return "${pid}:${starttime}:${boot_id}";
608 sub instance_exists
{
609 my ($instance_id) = @_;
611 if (defined($instance_id) && $instance_id =~ m/^([1-9][0-9]*):/) {
613 my $actual_id = eval { get_instance_id
($pid); };
614 return defined($actual_id) && $actual_id eq $instance_id;
625 locked
("$CONFIG_PATH/cron_and_state.lock", sub {
626 eval { $job = get_job
($param) };
629 my $state = $job->{state} // 'ok';
630 $state = 'ok' if !instance_exists
($job->{instance_id
});
632 if ($state eq "syncing" || $state eq "waiting") {
633 die "Job --source $param->{source} --name $param->{name} is already scheduled to sync\n";
636 $job->{state} = "waiting";
637 $job->{instance_id
} = $INSTANCE_ID;
643 locked
("$CONFIG_PATH/sync.lock", sub {
645 my $date = get_date
();
651 locked
("$CONFIG_PATH/cron_and_state.lock", sub {
652 #job might've changed while we waited for the sync lock, but we can be sure it's not syncing
653 eval { $job = get_job
($param); };
655 if ($job && defined($job->{state}) && $job->{state} eq "stopped") {
656 die "Job --source $param->{source} --name $param->{name} has been disabled\n";
659 $dest = parse_target
($param->{dest
});
660 $source = parse_target
($param->{source
});
662 $vm_type = vm_exists
($source, $param->{source_user
});
663 $source->{vm_type
} = $vm_type;
666 $job->{state} = "syncing";
667 $job->{vm_type
} = $vm_type if !$job->{vm_type
};
670 }); #cron and state lock
672 my $sync_path = sub {
673 my ($source, $dest, $job, $param, $date) = @_;
675 ($dest->{old_snap
}, $dest->{last_snap
}) = snapshot_get
($source, $dest, $param->{maxsnap
}, $param->{name
}, $param->{dest_user
});
677 snapshot_add
($source, $dest, $param->{name
}, $date, $param->{source_user
}, $param->{dest_user
});
679 send_image
($source, $dest, $param);
681 snapshot_destroy
($source, $dest, $param->{method}, $dest->{old_snap
}, $param->{source_user
}, $param->{dest_user
}) if ($source->{destroy
} && $dest->{old_snap
});
686 if ($source->{vmid
}) {
687 die "VM $source->{vmid} doesn't exist\n" if !$vm_type;
688 die "source-user has to be root for syncing VMs\n" if ($param->{source_user
} ne "root");
689 my $disks = get_disks
($source, $param->{source_user
});
691 foreach my $disk (sort keys %{$disks}) {
692 $source->{all
} = $disks->{$disk}->{all
};
693 $source->{pool
} = $disks->{$disk}->{pool
};
694 $source->{path
} = $disks->{$disk}->{path
} if $disks->{$disk}->{path
};
695 $source->{last_part
} = $disks->{$disk}->{last_part
};
696 &$sync_path($source, $dest, $job, $param, $date);
698 if ($param->{method} eq "ssh" && ($source->{ip
} || $dest->{ip
})) {
699 send_config
($source, $dest,'ssh', $param->{source_user
}, $param->{dest_user
}, $param->{dest_config_path
});
701 send_config
($source, $dest,'local', $param->{source_user
}, $param->{dest_user
}, $param->{dest_config_path
});
704 &$sync_path($source, $dest, $job, $param, $date);
708 locked
("$CONFIG_PATH/cron_and_state.lock", sub {
709 eval { $job = get_job
($param); };
711 $job->{state} = "error";
712 delete $job->{instance_id
};
716 print "Job --source $param->{source} --name $param->{name} got an ERROR!!!\nERROR Message:\n";
720 locked
("$CONFIG_PATH/cron_and_state.lock", sub {
721 eval { $job = get_job
($param); };
723 if (defined($job->{state}) && $job->{state} eq "stopped") {
724 $job->{state} = "stopped";
726 $job->{state} = "ok";
728 $job->{lsync
} = $date;
729 delete $job->{instance_id
};
737 my ($source, $dest, $max_snap, $name, $dest_user) = @_;
740 push @$cmd, 'ssh', "$dest_user\@$dest->{ip}", '--', if $dest->{ip
};
741 push @$cmd, 'zfs', 'list', '-r', '-t', 'snapshot', '-Ho', 'name', '-S', 'creation';
743 my $path = target_dataset
($source, $dest);
747 eval {$raw = run_cmd
($cmd)};
748 if (my $erro =$@) { #this means the volume doesn't exist on dest yet
754 my $last_snap = undef;
757 while ($raw && $raw =~ s/^(.*?)(\n|$)//) {
759 if ($line =~ m/@(.*)$/) {
760 $last_snap = $1 if (!$last_snap);
762 if ($line =~ m/(rep_\Q${name}\E_\d{4}-\d{2}-\d{2}_\d{2}:\d{2}:\d{2})$/) {
765 if ($index == $max_snap) {
766 $source->{destroy
} = 1;
772 return ($old_snap, $last_snap) if $last_snap;
778 my ($source, $dest, $name, $date, $source_user, $dest_user) = @_;
780 my $snap_name = "rep_$name\_".$date;
782 $source->{new_snap
} = $snap_name;
784 my $path = "$source->{all}\@$snap_name";
787 push @$cmd, 'ssh', "$source_user\@$source->{ip}", '--', if $source->{ip
};
788 push @$cmd, 'zfs', 'snapshot', $path;
794 snapshot_destroy
($source, $dest, 'ssh', $snap_name, $source_user, $dest_user);
800 my ($target, $user) = @_;
803 push @$cmd, 'ssh', "$user\@$target->{ip}", '--', if $target->{ip
};
805 if ($target->{vm_type
} eq 'qemu') {
806 push @$cmd, 'qm', 'config', $target->{vmid
};
807 } elsif ($target->{vm_type
} eq 'lxc') {
808 push @$cmd, 'pct', 'config', $target->{vmid
};
810 die "VM Type unknown\n";
813 my $res = run_cmd
($cmd);
815 my $disks = parse_disks
($res, $target->{ip
}, $target->{vm_type
}, $user);
822 print "Start CMD\n" if $DEBUG;
823 print Dumper
$cmd if $DEBUG;
824 if (ref($cmd) eq 'ARRAY') {
825 $cmd = join(' ', map { ref($_) ?
$$_ : shell_quote
($_) } @$cmd);
827 my $output = `$cmd 2>&1`;
829 die "COMMAND:\n\t$cmd\nGET ERROR:\n\t$output" if 0 != $?;
832 print Dumper
$output if $DEBUG;
833 print "END CMD\n" if $DEBUG;
838 my ($text, $ip, $vm_type, $user) = @_;
843 while ($text && $text =~ s/^(.*?)(\n|$)//) {
846 next if $line =~ /media=cdrom/;
847 next if $line !~ m/$DISK_KEY_RE/;
849 #QEMU if backup is not set include in sync
850 next if $vm_type eq 'qemu' && ($line =~ m/backup=(?i:0|no|off|false)/);
852 #LXC if backup is not set do no in sync
853 next if $vm_type eq 'lxc' && ($line =~ m/^mp\d:/) && ($line !~ m/backup=(?i:1|yes|on|true)/);
857 if($line =~ m/$DISK_KEY_RE(.*)$/) {
858 my @parameter = split(/,/,$1);
860 foreach my $opt (@parameter) {
861 if ($opt =~ m/^(?:file=|volume=)?([^:]+):([A-Za-z0-9\-]+)$/){
868 if (!defined($disk) || !defined($stor)) {
869 print "Disk: \"$line\" has no valid zfs dataset format
and will be skipped
\n";
874 push @$cmd, 'ssh', "$user\@$ip", '--' if $ip;
875 push @$cmd, 'pvesm', 'path', "$stor:$disk";
876 my $path = run_cmd($cmd);
878 die "Get
no path from pvesm path
$stor:$disk\n" if !$path;
880 $disks->{$num}->{storage_id} = $stor;
882 if ($vm_type eq 'qemu' && $path =~ m/^\/dev\/zvol\/(\w+.*)(\/$disk)$/) {
884 my @array = split('/', $1);
885 $disks->{$num}->{pool} = shift(@array);
886 $disks->{$num}->{all} = $disks->{$num}->{pool};
888 $disks->{$num}->{path} = join('/', @array);
889 $disks->{$num}->{all} .= "\
/$disks->{$num}->{path
}";
891 $disks->{$num}->{last_part} = $disk;
892 $disks->{$num}->{all} .= "\
/$disk";
895 } elsif ($vm_type eq 'lxc' && $path =~ m/^\/(\w+.+)(\/(\w+.*))*(\/$disk)$/) {
897 $disks->{$num}->{pool} = $1;
898 $disks->{$num}->{all} = $disks->{$num}->{pool};
901 $disks->{$num}->{path} = $3;
902 $disks->{$num}->{all} .= "\
/$disks->{$num}->{path
}";
905 $disks->{$num}->{last_part} = $disk;
906 $disks->{$num}->{all} .= "\
/$disk";
911 die "ERROR
: in path
\n";
915 die "Vm include
no disk on zfs
.\n" if !$disks->{0};
919 # how the corresponding dataset is named on the target
921 my ($source, $dest) = @_;
923 my $target = "$dest->{all
}";
924 $target .= "/$source->{last_part
}" if $source->{last_part};
930 sub snapshot_destroy {
931 my ($source, $dest, $method, $snap, $source_user, $dest_user) = @_;
933 my @zfscmd = ('zfs', 'destroy');
934 my $snapshot = "$source->{all
}\
@$snap";
937 if($source->{ip} && $method eq 'ssh'){
938 run_cmd(['ssh', "$source_user\@$source->{ip
}", '--', @zfscmd, $snapshot]);
940 run_cmd([@zfscmd, $snapshot]);
947 my @ssh = $dest->{ip} ? ('ssh', "$dest_user\@$dest->{ip
}", '--') : ();
949 my $path = target_dataset($source, $dest);
952 run_cmd([@ssh, @zfscmd, "$path\@$snap"]);
960 # check if snapshot for incremental sync exist on source side
962 my ($source , $dest, $method, $source_user) = @_;
965 push @$cmd, 'ssh', "$source_user\@$source->{ip
}", '--' if $source->{ip};
966 push @$cmd, 'zfs', 'list', '-rt', 'snapshot', '-Ho', 'name';
968 my $path = $source->{all};
969 $path .= "\
@$dest->{last_snap
}";
973 eval {run_cmd($cmd)};
982 my ($source, $dest, $param) = @_;
986 push @$cmd, 'ssh', '-o', 'BatchMode=yes', "$param->{source_user
}\
@$source->{ip
}", '--' if $source->{ip};
987 push @$cmd, 'zfs', 'send';
988 push @$cmd, '-p', if $param->{properties};
989 push @$cmd, '-v' if $param->{verbose};
991 if($dest->{last_snap} && snapshot_exist($source , $dest, $param->{method}, $param->{source_user})) {
992 push @$cmd, '-i', "$source->{all
}\
@$dest->{last_snap
}";
994 push @$cmd, '--', "$source->{all
}\
@$source->{new_snap
}";
996 if ($param->{limit}){
997 my $bwl = $param->{limit}*1024;
998 push @$cmd, \'|', 'cstream', '-t', $bwl;
1000 my $target = target_dataset($source, $dest);
1003 push @$cmd, 'ssh', '-o', 'BatchMode=yes', "$param->{dest_user
}\
@$dest->{ip
}", '--' if $dest->{ip};
1004 push @$cmd, 'zfs', 'recv', '-F', '--';
1005 push @$cmd, "$target";
1011 if (my $erro = $@) {
1012 snapshot_destroy($source, undef, $param->{method}, $source->{new_snap}, $param->{source_user}, $param->{dest_user});
1019 my ($source, $dest, $method, $source_user, $dest_user, $dest_config_path) = @_;
1021 my $source_target = $source->{vm_type} eq 'qemu' ? "$QEMU_CONF/$source->{vmid
}.conf
": "$LXC_CONF/$source->{vmid
}.conf
";
1022 my $dest_target_new ="$source->{vmid
}.conf
.$source->{vm_type
}.$source->{new_snap
}";
1024 my $config_dir = $dest_config_path // $CONFIG_PATH;
1025 $config_dir .= "/$dest->{last_part
}" if $dest->{last_part};
1027 $dest_target_new = $config_dir.'/'.$dest_target_new;
1029 if ($method eq 'ssh'){
1030 if ($dest->{ip} && $source->{ip}) {
1031 run_cmd(['ssh', "$dest_user\@$dest->{ip
}", '--', 'mkdir', '-p', '--', $config_dir]);
1032 run_cmd(['scp', '--', "$source_user\@[$source->{ip
}]:$source_target", "$dest_user\@[$dest->{ip
}]:$dest_target_new"]);
1033 } elsif ($dest->{ip}) {
1034 run_cmd(['ssh', "$dest_user\@$dest->{ip
}", '--', 'mkdir', '-p', '--', $config_dir]);
1035 run_cmd(['scp', '--', $source_target, "$dest_user\@[$dest->{ip
}]:$dest_target_new"]);
1036 } elsif ($source->{ip}) {
1037 run_cmd(['mkdir', '-p', '--', $config_dir]);
1038 run_cmd(['scp', '--', "$source_user\@[$source->{ip
}]:$source_target", $dest_target_new]);
1041 if ($source->{destroy}){
1042 my $dest_target_old ="${config_dir
}/$source->{vmid
}.conf
.$source->{vm_type
}.$dest->{old_snap
}";
1044 run_cmd(['ssh', "$dest_user\@$dest->{ip
}", '--', 'rm', '-f', '--', $dest_target_old]);
1046 run_cmd(['rm', '-f', '--', $dest_target_old]);
1049 } elsif ($method eq 'local') {
1050 run_cmd(['mkdir', '-p', '--', $config_dir]);
1051 run_cmd(['cp', $source_target, $dest_target_new]);
1056 my ($sec, $min, $hour, $mday, $mon, $year, $wday, $yday, $isdst) = localtime(time);
1057 my $datestamp = sprintf ("%04d-%02d-%02d_%02d:%02d:%02d", $year+1900, $mon+1, $mday, $hour, $min, $sec);
1063 my $cfg = read_cron();
1065 my $status_list = sprintf("%-25s
%-25s
%-10s
\n", "SOURCE
", "NAME
", "STATUS
");
1067 my $states = read_state();
1069 foreach my $source (sort keys%{$cfg}) {
1070 foreach my $sync_name (sort keys%{$cfg->{$source}}) {
1071 $status_list .= sprintf("%-25s
", cut_target_width($source, 25));
1072 $status_list .= sprintf("%-25s
", cut_target_width($sync_name, 25));
1073 $status_list .= "$states->{$source}->{$sync_name}->{state}\n";
1077 return $status_list;
1083 locked("$CONFIG_PATH/cron_and_state.lock", sub {
1084 my $job = get_job($param);
1085 $job->{state} = "ok
";
1094 locked("$CONFIG_PATH/cron_and_state.lock", sub {
1095 my $job = get_job($param);
1096 $job->{state} = "stopped
";
1104 $PROGNAME destroy -source <string> [OPTIONS]
1106 remove a sync Job from the scheduler
1110 name of the sync job, if not set it is default
1114 the source can be an <VMID> or [IP:]<ZFSPool>[/Path]
1117 $PROGNAME create -dest <string> -source <string> [OPTIONS]
1123 the destination target is like [IP]:<Pool>[/Path]
1127 name of the user on the destination target, root by default
1131 max sync speed in kBytes/s, default unlimited
1135 how much snapshots will be kept before get erased, default 1
1139 name of the sync job, if not set it is default
1143 If specified, skip the first sync.
1147 the source can be an <VMID> or [IP:]<ZFSPool>[/Path]
1151 name of the user on the source target, root by default
1155 If specified, include the dataset's properties in the stream.
1157 -dest-config-path string
1159 specify a custom config path on the destination target. default is /var/lib/pve-zsync
1162 $PROGNAME sync -dest <string> -source <string> [OPTIONS]\n
1168 the destination target is like [IP:]<Pool>[/Path]
1172 name of the user on the destination target, root by default
1176 max sync speed in kBytes/s, default unlimited
1180 how much snapshots will be kept before get erased, default 1
1184 name of the sync job, if not set it is default.
1185 It is only necessary if scheduler allready contains this source.
1189 the source can be an <VMID> or [IP:]<ZFSPool>[/Path]
1193 name of the user on the source target, root by default
1197 If specified, print out the sync progress.
1201 If specified, include the dataset's properties in the stream.
1203 -dest-config-path string
1205 specify a custom config path on the destination target. default is /var/lib/pve-zsync
1210 Get a List of all scheduled Sync Jobs
1215 Get the status of all scheduled Sync Jobs
1218 $PROGNAME help <cmd> [OPTIONS]
1220 Get help about specified command.
1228 Verbose output format.
1231 $PROGNAME enable -source <string> [OPTIONS]
1233 enable a syncjob and reset error
1237 name of the sync job, if not set it is default
1241 the source can be an <VMID> or [IP:]<ZFSPool>[/Path]
1244 $PROGNAME disable -source <string> [OPTIONS]
1250 name of the sync job, if not set it is default
1254 the source can be an <VMID> or [IP:]<ZFSPool>[/Path]
1256 printpod
=> 'internal command',
1262 } elsif (!$cmd_help->{$command}) {
1263 print "ERROR: unknown command '$command'";
1268 my $param = parse_argv
(@arg);
1272 die "$cmd_help->{$command}\n" if !$param->{$_};
1276 if ($command eq 'destroy') {
1277 check_params
(qw(source));
1279 check_target
($param->{source
});
1280 destroy_job
($param);
1282 } elsif ($command eq 'sync') {
1283 check_params
(qw(source dest));
1285 check_target
($param->{source
});
1286 check_target
($param->{dest
});
1289 } elsif ($command eq 'create') {
1290 check_params
(qw(source dest));
1292 check_target
($param->{source
});
1293 check_target
($param->{dest
});
1296 } elsif ($command eq 'status') {
1299 } elsif ($command eq 'list') {
1302 } elsif ($command eq 'help') {
1303 my $help_command = $ARGV[1];
1305 if ($help_command && $cmd_help->{$help_command}) {
1306 die "$cmd_help->{$help_command}\n";
1309 if ($param->{verbose
}) {
1310 exec("man $PROGNAME");
1317 } elsif ($command eq 'enable') {
1318 check_params
(qw(source));
1320 check_target
($param->{source
});
1323 } elsif ($command eq 'disable') {
1324 check_params
(qw(source));
1326 check_target
($param->{source
});
1327 disable_job
($param);
1329 } elsif ($command eq 'printpod') {
1336 print("ERROR:\tno command specified\n") if !$help;
1337 print("USAGE:\t$PROGNAME <COMMAND> [ARGS] [OPTIONS]\n");
1338 print("\t$PROGNAME help [<cmd>] [OPTIONS]\n\n");
1339 print("\t$PROGNAME create -dest <string> -source <string> [OPTIONS]\n");
1340 print("\t$PROGNAME destroy -source <string> [OPTIONS]\n");
1341 print("\t$PROGNAME disable -source <string> [OPTIONS]\n");
1342 print("\t$PROGNAME enable -source <string> [OPTIONS]\n");
1343 print("\t$PROGNAME list\n");
1344 print("\t$PROGNAME status\n");
1345 print("\t$PROGNAME sync -dest <string> -source <string> [OPTIONS]\n");
1350 parse_target
($target);
1355 my $synopsis = join("\n", sort values %$cmd_help);
1360 pve-zsync - PVE ZFS Replication Manager
1364 pve-zsync <COMMAND> [ARGS] [OPTIONS]
1370 This Tool helps you to sync your VM or directory which stored on ZFS between 2 servers.
1371 This tool also has the capability to add jobs to cron so the sync will be automatically done.
1372 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.
1373 To config cron see man crontab.
1375 =head2 PVE ZFS Storage sync Tool
1377 This Tool can get remote pool on other PVE or send Pool to others ZFS machines
1381 add sync job from local VM to remote ZFS Server
1382 pve-zsync create -source=100 -dest=192.168.1.2:zfspool
1384 =head1 IMPORTANT FILES
1386 Cron jobs and config are stored at /etc/cron.d/pve-zsync
1388 The VM config get copied on the destination machine to /var/lib/pve-zsync/
1390 =head1 COPYRIGHT AND DISCLAIMER
1392 Copyright (C) 2007-2021 Proxmox Server Solutions GmbH
1394 This program is free software: you can redistribute it and/or modify it
1395 under the terms of the GNU Affero General Public License as published
1396 by the Free Software Foundation, either version 3 of the License, or
1397 (at your option) any later version.
1399 This program is distributed in the hope that it will be useful, but
1400 WITHOUT ANY WARRANTY; without even the implied warranty of
1401 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
1402 Affero General Public License for more details.
1404 You should have received a copy of the GNU Affero General Public
1405 License along with this program. If not, see
1406 <http://www.gnu.org/licenses/>.