]>
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;
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,
252 prepend_storage_id
=> undef,
254 dest_config_path
=> undef,
257 my ($ret) = GetOptionsFromArray
(
259 'dest=s' => \
$param->{dest
},
260 'source=s' => \
$param->{source
},
261 'verbose' => \
$param->{verbose
},
262 'limit=i' => \
$param->{limit
},
263 'maxsnap=i' => \
$param->{maxsnap
},
264 'name=s' => \
$param->{name
},
265 'skip' => \
$param->{skip
},
266 'method=s' => \
$param->{method},
267 'source-user=s' => \
$param->{source_user
},
268 'dest-user=s' => \
$param->{dest_user
},
269 'prepend-storage-id' => \
$param->{prepend_storage_id
},
270 'properties' => \
$param->{properties
},
271 'dest-config-path=s' => \
$param->{dest_config_path
},
274 die "can't parse options\n" if $ret == 0;
276 $param->{name
} //= "default";
277 $param->{maxsnap
} //= 1;
278 $param->{method} //= "ssh";
279 $param->{source_user
} //= "root";
280 $param->{dest_user
} //= "root";
285 sub add_state_to_job
{
288 my $states = read_state
();
289 my $state = $states->{$job->{source
}}->{$job->{name
}};
291 $job->{state} = $state->{state};
292 $job->{lsync
} = $state->{lsync
};
293 $job->{vm_type
} = $state->{vm_type
};
294 $job->{instance_id
} = $state->{instance_id
};
296 for (my $i = 0; $state->{"snap$i"}; $i++) {
297 $job->{"snap$i"} = $state->{"snap$i"};
308 while (my $line = shift(@text)) {
310 my @arg = split('\s', $line);
311 my $param = parse_argv
(@arg);
313 if ($param->{source
} && $param->{dest
}) {
314 my $source = delete $param->{source
};
315 my $name = delete $param->{name
};
317 $cfg->{$source}->{$name} = $param;
329 my $source = parse_target
($param->{source
});
331 $dest = parse_target
($param->{dest
}) if $param->{dest
};
333 $job->{name
} = !$param->{name
} ?
"default" : $param->{name
};
334 $job->{dest
} = $param->{dest
} if $param->{dest
};
335 $job->{method} = "local" if !$dest->{ip
} && !$source->{ip
};
336 $job->{method} = "ssh" if !$job->{method};
337 $job->{limit
} = $param->{limit
};
338 $job->{maxsnap
} = $param->{maxsnap
};
339 $job->{source
} = $param->{source
};
340 $job->{source_user
} = $param->{source_user
};
341 $job->{dest_user
} = $param->{dest_user
};
342 $job->{prepend_storage_id
} = !!$param->{prepend_storage_id
};
343 $job->{properties
} = !!$param->{properties
};
344 $job->{dest_config_path
} = $param->{dest_config_path
} if $param->{dest_config_path
};
352 make_path
$CONFIG_PATH;
353 my $new_fh = IO
::File-
>new("> $STATE");
354 die "Could not create $STATE: $!\n" if !$new_fh;
360 my $text = read_file
($STATE, 1);
361 return decode_json
($text);
367 my $text = eval { read_file
($STATE, 1); };
369 my $out_fh = IO
::File-
>new("> $STATE.new");
370 die "Could not open file ${STATE}.new: $!\n" if !$out_fh;
375 $states = decode_json
($text);
376 $state = $states->{$job->{source
}}->{$job->{name
}};
379 if ($job->{state} ne "del") {
380 $state->{state} = $job->{state};
381 $state->{lsync
} = $job->{lsync
};
382 $state->{instance_id
} = $job->{instance_id
};
383 $state->{vm_type
} = $job->{vm_type
};
385 for (my $i = 0; $job->{"snap$i"} ; $i++) {
386 $state->{"snap$i"} = $job->{"snap$i"};
388 $states->{$job->{source
}}->{$job->{name
}} = $state;
391 delete $states->{$job->{source
}}->{$job->{name
}};
392 delete $states->{$job->{source
}} if !keys %{$states->{$job->{source
}}};
395 $text = encode_json
($states);
399 rename "$STATE.new", $STATE;
411 my $header = "SHELL=/bin/sh\n";
412 $header .= "PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin\n\n";
414 my $current = read_file
($CRONJOBS, 0);
416 foreach my $line (@{$current}) {
418 if ($line =~ m/source $job->{source} .*name $job->{name} /) {
420 next if $job->{state} eq "del";
421 $text .= format_job
($job, $line);
423 if (($line_no < 3) && ($line =~ /^(PATH|SHELL)/ )) {
432 $text = "$header$text";
436 $text .= format_job
($job);
438 my $new_fh = IO
::File-
>new("> ${CRONJOBS}.new");
439 die "Could not open file ${CRONJOBS}.new: $!\n" if !$new_fh;
441 print $new_fh $text or die "can't write to $CRONJOBS.new: $!\n";
444 rename "${CRONJOBS}.new", $CRONJOBS or die "can't move $CRONJOBS.new: $!\n";
448 my ($job, $line) = @_;
451 if ($job->{state} eq "stopped") {
455 $line =~ /^#*\s*((?:\S+\s+){4}\S+)\s+root/;
458 $text .= "*/$INTERVAL * * * *";
461 $text .= " $PROGNAME sync --source $job->{source} --dest $job->{dest}";
462 $text .= " --name $job->{name} --maxsnap $job->{maxsnap}";
463 $text .= " --limit $job->{limit}" if $job->{limit
};
464 $text .= " --method $job->{method}";
465 $text .= " --verbose" if $job->{verbose
};
466 $text .= " --source-user $job->{source_user}";
467 $text .= " --dest-user $job->{dest_user}";
468 $text .= " --prepend-storage-id" if $job->{prepend_storage_id
};
469 $text .= " --properties" if $job->{properties
};
470 $text .= " --dest-config-path $job->{dest_config_path}" if $job->{dest_config_path
};
478 my $cfg = read_cron
();
480 my $list = sprintf("%-25s%-25s%-10s%-20s%-6s%-5s\n" , "SOURCE", "NAME", "STATE", "LAST SYNC", "TYPE", "CON");
482 my $states = read_state
();
483 foreach my $source (sort keys%{$cfg}) {
484 foreach my $name (sort keys%{$cfg->{$source}}) {
485 $list .= sprintf("%-25s", cut_target_width
($source, 25));
486 $list .= sprintf("%-25s", cut_target_width
($name, 25));
487 $list .= sprintf("%-10s", $states->{$source}->{$name}->{state});
488 $list .= sprintf("%-20s", $states->{$source}->{$name}->{lsync
});
489 $list .= sprintf("%-6s", defined($states->{$source}->{$name}->{vm_type
}) ?
$states->{$source}->{$name}->{vm_type
} : "undef");
490 $list .= sprintf("%-5s\n", $cfg->{$source}->{$name}->{method});
498 my ($target, $user) = @_;
500 return undef if !defined($target->{vmid
});
502 my $conf_fn = "$target->{vmid}.conf";
505 my @cmd = ('ssh', "$user\@$target->{ip}", '--', '/bin/ls');
506 return "qemu" if eval { run_cmd
([@cmd, "$QEMU_CONF/$conf_fn"]) };
507 return "lxc" if eval { run_cmd
([@cmd, "$LXC_CONF/$conf_fn"]) };
509 return "qemu" if -f
"$QEMU_CONF/$conf_fn";
510 return "lxc" if -f
"$LXC_CONF/$conf_fn";
519 locked
("$CONFIG_PATH/cron_and_state.lock", sub {
520 my $cfg = read_cron
();
522 my $job = param_to_job
($param);
524 $job->{state} = "ok";
527 my $source = parse_target
($param->{source
});
528 my $dest = parse_target
($param->{dest
});
530 if (my $ip = $dest->{ip
}) {
531 run_cmd
(['ssh-copy-id', '-i', '/root/.ssh/id_rsa.pub', "$param->{dest_user}\@$ip"]);
534 if (my $ip = $source->{ip
}) {
535 run_cmd
(['ssh-copy-id', '-i', '/root/.ssh/id_rsa.pub', "$param->{source_user}\@$ip"]);
538 die "Pool $dest->{all} does not exist\n"
539 if !check_dataset_exists
($dest->{all
}, $dest->{ip
}, $param->{dest_user
});
541 if (!defined($source->{vmid
})) {
542 die "Pool $source->{all} does not exist\n"
543 if !check_dataset_exists
($source->{all
}, $source->{ip
}, $param->{source_user
});
546 my $vm_type = vm_exists
($source, $param->{source_user
});
547 $job->{vm_type
} = $vm_type;
548 $source->{vm_type
} = $vm_type;
550 die "VM $source->{vmid} doesn't exist\n" if $source->{vmid
} && !$vm_type;
552 die "Config already exists\n" if $cfg->{$job->{source
}}->{$job->{name
}};
554 #check if vm has zfs disks if not die;
555 get_disks
($source, $param->{source_user
}) if $source->{vmid
};
559 }); #cron and state lock
561 return if $param->{skip
};
563 eval { sync
($param) };
573 my $cfg = read_cron
();
575 if (!$cfg->{$param->{source
}}->{$param->{name
}}) {
576 die "Job with source $param->{source} and name $param->{name} does not exist\n" ;
578 my $job = $cfg->{$param->{source
}}->{$param->{name
}};
579 $job->{name
} = $param->{name
};
580 $job->{source
} = $param->{source
};
581 $job = add_state_to_job
($job);
589 locked
("$CONFIG_PATH/cron_and_state.lock", sub {
590 my $job = get_job
($param);
591 $job->{state} = "del";
598 sub get_instance_id
{
601 my $stat = read_file
("/proc/$pid/stat", 1)
602 or die "unable to read process stats\n";
603 my $boot_id = read_file
("/proc/sys/kernel/random/boot_id", 1)
604 or die "unable to read boot ID\n";
606 my $stats = [ split(/\s+/, $stat) ];
607 my $starttime = $stats->[21];
610 return "${pid}:${starttime}:${boot_id}";
613 sub instance_exists
{
614 my ($instance_id) = @_;
616 if (defined($instance_id) && $instance_id =~ m/^([1-9][0-9]*):/) {
618 my $actual_id = eval { get_instance_id
($pid); };
619 return defined($actual_id) && $actual_id eq $instance_id;
630 locked
("$CONFIG_PATH/cron_and_state.lock", sub {
631 eval { $job = get_job
($param) };
634 my $state = $job->{state} // 'ok';
635 $state = 'ok' if !instance_exists
($job->{instance_id
});
637 if ($state eq "syncing" || $state eq "waiting") {
638 die "Job --source $param->{source} --name $param->{name} is already scheduled to sync\n";
641 $job->{state} = "waiting";
642 $job->{instance_id
} = $INSTANCE_ID;
648 locked
("$CONFIG_PATH/sync.lock", sub {
650 my $date = get_date
();
656 locked
("$CONFIG_PATH/cron_and_state.lock", sub {
657 #job might've changed while we waited for the sync lock, but we can be sure it's not syncing
658 eval { $job = get_job
($param); };
660 if ($job && defined($job->{state}) && $job->{state} eq "stopped") {
661 die "Job --source $param->{source} --name $param->{name} has been disabled\n";
664 $dest = parse_target
($param->{dest
});
665 $source = parse_target
($param->{source
});
667 $vm_type = vm_exists
($source, $param->{source_user
});
668 $source->{vm_type
} = $vm_type;
671 $job->{state} = "syncing";
672 $job->{vm_type
} = $vm_type if !$job->{vm_type
};
675 }); #cron and state lock
677 my $sync_path = sub {
678 my ($source, $dest, $job, $param, $date) = @_;
680 my $dest_dataset = target_dataset
($source, $dest);
682 ($dest->{old_snap
}, $dest->{last_snap
}) = snapshot_get
(
690 prepare_prepended_target
($source, $dest, $param->{dest_user
}) if defined($dest->{prepend
});
692 snapshot_add
($source, $dest, $param->{name
}, $date, $param->{source_user
}, $param->{dest_user
});
694 send_image
($source, $dest, $param);
696 for my $old_snap (@{$dest->{old_snap
}}) {
697 snapshot_destroy
($source, $dest, $param->{method}, $old_snap, $param->{source_user
}, $param->{dest_user
});
702 if ($source->{vmid
}) {
703 die "VM $source->{vmid} doesn't exist\n" if !$vm_type;
704 die "source-user has to be root for syncing VMs\n" if ($param->{source_user
} ne "root");
705 my $disks = get_disks
($source, $param->{source_user
});
707 foreach my $disk (sort keys %{$disks}) {
708 $source->{all
} = $disks->{$disk}->{all
};
709 $source->{pool
} = $disks->{$disk}->{pool
};
710 $source->{path
} = $disks->{$disk}->{path
} if $disks->{$disk}->{path
};
711 $source->{last_part
} = $disks->{$disk}->{last_part
};
713 $dest->{prepend
} = $disks->{$disk}->{storage_id
}
714 if $param->{prepend_storage_id
};
716 &$sync_path($source, $dest, $job, $param, $date);
718 if ($param->{method} eq "ssh" && ($source->{ip
} || $dest->{ip
})) {
719 send_config
($source, $dest,'ssh', $param->{source_user
}, $param->{dest_user
}, $param->{dest_config_path
});
721 send_config
($source, $dest,'local', $param->{source_user
}, $param->{dest_user
}, $param->{dest_config_path
});
724 &$sync_path($source, $dest, $job, $param, $date);
728 locked
("$CONFIG_PATH/cron_and_state.lock", sub {
729 eval { $job = get_job
($param); };
731 $job->{state} = "error";
732 delete $job->{instance_id
};
736 print "Job --source $param->{source} --name $param->{name} got an ERROR!!!\nERROR Message:\n";
740 locked
("$CONFIG_PATH/cron_and_state.lock", sub {
741 eval { $job = get_job
($param); };
743 if (defined($job->{state}) && $job->{state} eq "stopped") {
744 $job->{state} = "stopped";
746 $job->{state} = "ok";
748 $job->{lsync
} = $date;
749 delete $job->{instance_id
};
757 my ($dataset, $max_snap, $name, $ip, $user) = @_;
760 push @$cmd, 'ssh', "$user\@$ip", '--', if $ip;
761 push @$cmd, 'zfs', 'list', '-r', '-t', 'snapshot', '-Ho', 'name', '-S', 'creation';
762 push @$cmd, $dataset;
765 eval {$raw = run_cmd
($cmd)};
766 if (my $erro =$@) { #this means the volume doesn't exist on dest yet
772 my $last_snap = undef;
775 while ($raw && $raw =~ s/^(.*?)(\n|$)//) {
777 if ($line =~ m/@(.*)$/) {
778 $last_snap = $1 if (!$last_snap);
780 if ($line =~ m/(rep_\Q${name}\E_\d{4}-\d{2}-\d{2}_\d{2}:\d{2}:\d{2})$/) {
781 # interpreted as infinity
782 last if $max_snap <= 0;
787 if ($index >= $max_snap) {
788 push @{$old_snap}, $snap;
793 return ($old_snap, $last_snap) if $last_snap;
799 my ($source, $dest, $name, $date, $source_user, $dest_user) = @_;
801 my $snap_name = "rep_$name\_".$date;
803 $source->{new_snap
} = $snap_name;
805 my $path = "$source->{all}\@$snap_name";
808 push @$cmd, 'ssh', "$source_user\@$source->{ip}", '--', if $source->{ip
};
809 push @$cmd, 'zfs', 'snapshot', $path;
815 snapshot_destroy
($source, $dest, 'ssh', $snap_name, $source_user, $dest_user);
821 my ($target, $user) = @_;
824 push @$cmd, 'ssh', "$user\@$target->{ip}", '--', if $target->{ip
};
826 if ($target->{vm_type
} eq 'qemu') {
827 push @$cmd, 'qm', 'config', $target->{vmid
};
828 } elsif ($target->{vm_type
} eq 'lxc') {
829 push @$cmd, 'pct', 'config', $target->{vmid
};
831 die "VM Type unknown\n";
834 my $res = run_cmd
($cmd);
836 my $disks = parse_disks
($res, $target->{ip
}, $target->{vm_type
}, $user);
843 print "Start CMD\n" if $DEBUG;
844 print Dumper
$cmd if $DEBUG;
845 if (ref($cmd) eq 'ARRAY') {
846 $cmd = join(' ', map { ref($_) ?
$$_ : shell_quote
($_) } @$cmd);
848 my $output = `$cmd 2>&1`;
850 die "COMMAND:\n\t$cmd\nGET ERROR:\n\t$output" if 0 != $?;
853 print Dumper
$output if $DEBUG;
854 print "END CMD\n" if $DEBUG;
859 my ($text, $ip, $vm_type, $user) = @_;
864 while ($text && $text =~ s/^(.*?)(\n|$)//) {
867 next if $line =~ /media=cdrom/;
868 next if $line !~ m/$DISK_KEY_RE/;
870 #QEMU if backup is not set include in sync
871 next if $vm_type eq 'qemu' && ($line =~ m/backup=(?i:0|no|off|false)/);
873 #LXC if backup is not set do no in sync
874 next if $vm_type eq 'lxc' && ($line =~ m/^mp\d:/) && ($line !~ m/backup=(?i:1|yes|on|true)/);
878 if($line =~ m/$DISK_KEY_RE(.*)$/) {
879 my @parameter = split(/,/,$1);
881 foreach my $opt (@parameter) {
882 if ($opt =~ m/^(?:file=|volume=)?([^:]+):([A-Za-z0-9\-]+)$/){
889 if (!defined($disk) || !defined($stor)) {
890 print "Disk: \"$line\" has no valid zfs dataset format
and will be skipped
\n";
895 push @$cmd, 'ssh', "$user\@$ip", '--' if $ip;
896 push @$cmd, 'pvesm', 'path', "$stor:$disk";
897 my $path = run_cmd($cmd);
899 die "Get
no path from pvesm path
$stor:$disk\n" if !$path;
901 $disks->{$num}->{storage_id} = $stor;
903 if ($vm_type eq 'qemu' && $path =~ m/^\/dev\/zvol\/(\w+.*)(\/$disk)$/) {
905 my @array = split('/', $1);
906 $disks->{$num}->{pool} = shift(@array);
907 $disks->{$num}->{all} = $disks->{$num}->{pool};
909 $disks->{$num}->{path} = join('/', @array);
910 $disks->{$num}->{all} .= "\
/$disks->{$num}->{path
}";
912 $disks->{$num}->{last_part} = $disk;
913 $disks->{$num}->{all} .= "\
/$disk";
916 } elsif ($vm_type eq 'lxc' && $path =~ m/^\/(\w+.+)(\/(\w+.*))*(\/$disk)$/) {
918 $disks->{$num}->{pool} = $1;
919 $disks->{$num}->{all} = $disks->{$num}->{pool};
922 $disks->{$num}->{path} = $3;
923 $disks->{$num}->{all} .= "\
/$disks->{$num}->{path
}";
926 $disks->{$num}->{last_part} = $disk;
927 $disks->{$num}->{all} .= "\
/$disk";
932 die "ERROR
: in path
\n";
936 die "Vm include
no disk on zfs
.\n" if !$disks->{0};
940 # how the corresponding dataset is named on the target
942 my ($source, $dest) = @_;
944 my $target = "$dest->{all
}";
945 $target .= "/$dest->{prepend
}" if defined($dest->{prepend});
946 $target .= "/$source->{last_part
}" if $source->{last_part};
952 # create the parent dataset for the actual target
953 sub prepare_prepended_target {
954 my ($source, $dest, $dest_user) = @_;
956 die "internal error
- not a prepended target
\n" if !defined($dest->{prepend});
958 # The parent dataset shouldn't be the actual target.
959 die "internal error
- no last_part
for source
\n" if !$source->{last_part};
961 my $target = "$dest->{all
}/$dest->{prepend
}";
964 return if check_dataset_exists($target, $dest->{ip}, $dest_user);
966 create_file_system($target, $dest->{ip}, $dest_user);
969 sub snapshot_destroy {
970 my ($source, $dest, $method, $snap, $source_user, $dest_user) = @_;
972 my @zfscmd = ('zfs', 'destroy');
973 my $snapshot = "$source->{all
}\
@$snap";
976 if($source->{ip} && $method eq 'ssh'){
977 run_cmd(['ssh', "$source_user\@$source->{ip
}", '--', @zfscmd, $snapshot]);
979 run_cmd([@zfscmd, $snapshot]);
986 my @ssh = $dest->{ip} ? ('ssh', "$dest_user\@$dest->{ip
}", '--') : ();
988 my $path = target_dataset($source, $dest);
991 run_cmd([@ssh, @zfscmd, "$path\@$snap"]);
999 # check if snapshot for incremental sync exist on source side
1000 sub snapshot_exist {
1001 my ($source , $dest, $method, $source_user) = @_;
1004 push @$cmd, 'ssh', "$source_user\@$source->{ip
}", '--' if $source->{ip};
1005 push @$cmd, 'zfs', 'list', '-rt', 'snapshot', '-Ho', 'name';
1007 my $path = $source->{all};
1008 $path .= "\
@$dest->{last_snap
}";
1012 eval {run_cmd($cmd)};
1021 my ($source, $dest, $param) = @_;
1025 push @$cmd, 'ssh', '-o', 'BatchMode=yes', "$param->{source_user
}\
@$source->{ip
}", '--' if $source->{ip};
1026 push @$cmd, 'zfs', 'send';
1027 push @$cmd, '-p', if $param->{properties};
1028 push @$cmd, '-v' if $param->{verbose};
1030 if($dest->{last_snap} && snapshot_exist($source , $dest, $param->{method}, $param->{source_user})) {
1031 push @$cmd, '-i', "$source->{all
}\
@$dest->{last_snap
}";
1033 push @$cmd, '--', "$source->{all
}\
@$source->{new_snap
}";
1035 if ($param->{limit}){
1036 my $bwl = $param->{limit}*1024;
1037 push @$cmd, \'|', 'cstream', '-t', $bwl;
1039 my $target = target_dataset($source, $dest);
1042 push @$cmd, 'ssh', '-o', 'BatchMode=yes', "$param->{dest_user
}\
@$dest->{ip
}", '--' if $dest->{ip};
1043 push @$cmd, 'zfs', 'recv', '-F', '--';
1044 push @$cmd, "$target";
1050 if (my $erro = $@) {
1051 snapshot_destroy($source, undef, $param->{method}, $source->{new_snap}, $param->{source_user}, $param->{dest_user});
1058 my ($source, $dest, $method, $source_user, $dest_user, $dest_config_path) = @_;
1060 my $source_target = $source->{vm_type} eq 'qemu' ? "$QEMU_CONF/$source->{vmid
}.conf
": "$LXC_CONF/$source->{vmid
}.conf
";
1061 my $dest_target_new ="$source->{vmid
}.conf
.$source->{vm_type
}.$source->{new_snap
}";
1063 my $config_dir = $dest_config_path // $CONFIG_PATH;
1064 $config_dir .= "/$dest->{last_part
}" if $dest->{last_part};
1066 $dest_target_new = $config_dir.'/'.$dest_target_new;
1068 if ($method eq 'ssh'){
1069 if ($dest->{ip} && $source->{ip}) {
1070 run_cmd(['ssh', "$dest_user\@$dest->{ip
}", '--', 'mkdir', '-p', '--', $config_dir]);
1071 run_cmd(['scp', '--', "$source_user\@[$source->{ip
}]:$source_target", "$dest_user\@[$dest->{ip
}]:$dest_target_new"]);
1072 } elsif ($dest->{ip}) {
1073 run_cmd(['ssh', "$dest_user\@$dest->{ip
}", '--', 'mkdir', '-p', '--', $config_dir]);
1074 run_cmd(['scp', '--', $source_target, "$dest_user\@[$dest->{ip
}]:$dest_target_new"]);
1075 } elsif ($source->{ip}) {
1076 run_cmd(['mkdir', '-p', '--', $config_dir]);
1077 run_cmd(['scp', '--', "$source_user\@[$source->{ip
}]:$source_target", $dest_target_new]);
1080 for my $old_snap (@{$dest->{old_snap}}) {
1081 my $dest_target_old ="${config_dir
}/$source->{vmid
}.conf
.$source->{vm_type
}.${old_snap
}";
1083 run_cmd(['ssh', "$dest_user\@$dest->{ip
}", '--', 'rm', '-f', '--', $dest_target_old]);
1085 run_cmd(['rm', '-f', '--', $dest_target_old]);
1088 } elsif ($method eq 'local') {
1089 run_cmd(['mkdir', '-p', '--', $config_dir]);
1090 run_cmd(['cp', $source_target, $dest_target_new]);
1095 my ($sec, $min, $hour, $mday, $mon, $year, $wday, $yday, $isdst) = localtime(time);
1096 my $datestamp = sprintf ("%04d-%02d-%02d_%02d:%02d:%02d", $year+1900, $mon+1, $mday, $hour, $min, $sec);
1102 my $cfg = read_cron();
1104 my $status_list = sprintf("%-25s
%-25s
%-10s
\n", "SOURCE
", "NAME
", "STATUS
");
1106 my $states = read_state();
1108 foreach my $source (sort keys%{$cfg}) {
1109 foreach my $sync_name (sort keys%{$cfg->{$source}}) {
1110 $status_list .= sprintf("%-25s
", cut_target_width($source, 25));
1111 $status_list .= sprintf("%-25s
", cut_target_width($sync_name, 25));
1112 $status_list .= "$states->{$source}->{$sync_name}->{state}\n";
1116 return $status_list;
1122 locked("$CONFIG_PATH/cron_and_state.lock", sub {
1123 my $job = get_job($param);
1124 $job->{state} = "ok
";
1133 locked("$CONFIG_PATH/cron_and_state.lock", sub {
1134 my $job = get_job($param);
1135 $job->{state} = "stopped
";
1143 $PROGNAME destroy --source <string> [OPTIONS]
1145 Remove a sync Job from the scheduler
1148 The name of the sync job, if not set 'default' is used.
1151 The source can be an <VMID> or [IP:]<ZFSPool>[/Path]
1154 $PROGNAME create --dest <string> --source <string> [OPTIONS]
1156 Create a new sync-job
1159 The destination target is like [IP]:<Pool>[/Path]
1162 The name of the user on the destination target, root by default
1165 Maximal sync speed in kBytes/s, default is unlimited
1168 The number of snapshots to keep until older ones are erased.
1169 The default is 1, use 0 for unlimited.
1172 The name of the sync job, if not set it is default
1174 --prepend-storage-id
1175 If specified, prepend the storage ID to the destination's path(s).
1178 If specified, skip the first sync.
1181 The source can be an <VMID> or [IP:]<ZFSPool>[/Path]
1183 --source-user string
1184 The (ssh) user-name on the source target, root by default
1187 If specified, include the dataset's properties in the stream.
1189 --dest-config-path string
1190 Specifies a custom config path on the destination target.
1191 The default is /var/lib/pve-zsync
1194 $PROGNAME sync --dest <string> --source <string> [OPTIONS]\n
1199 The destination target is like [IP:]<Pool>[/Path]
1202 The (ssh) user-name on the destination target, root by default
1205 The maximal sync speed in kBytes/s, default is unlimited
1208 The number of snapshots to keep until older ones are erased.
1209 The default is 1, use 0 for unlimited.
1212 The name of the sync job, if not set it is 'default'.
1213 It is only necessary if scheduler allready contains this source.
1215 --prepend-storage-id
1216 If specified, prepend the storage ID to the destination's path(s).
1219 The source can either be an <VMID> or [IP:]<ZFSPool>[/Path]
1221 --source-user string
1222 The name of the user on the source target, root by default
1225 If specified, print out the sync progress.
1228 If specified, include the dataset's properties in the stream.
1230 --dest-config-path string
1231 Specifies a custom config path on the destination target.
1232 The default is /var/lib/pve-zsync
1237 Get a List of all scheduled Sync Jobs
1242 Get the status of all scheduled Sync Jobs
1245 $PROGNAME help <cmd> [OPTIONS]
1247 Get help about specified command.
1250 Command name to get help about.
1253 Verbose output format.
1256 $PROGNAME enable --source <string> [OPTIONS]
1258 Enable a sync-job and reset all job-errors, if any.
1261 name of the sync job, if not set it is default
1264 the source can be an <VMID> or [IP:]<ZFSPool>[/Path]
1267 $PROGNAME disable --source <string> [OPTIONS]
1269 Disables (pauses) a sync-job
1272 name of the sync-job, if not set it is default
1275 the source can be an <VMID> or [IP:]<ZFSPool>[/Path]
1277 printpod
=> "$PROGNAME printpod\n\n\tinternal command",
1283 } elsif (!$cmd_help->{$command}) {
1284 print "ERROR: unknown command '$command'";
1289 my $param = parse_argv
(@arg);
1293 die "$cmd_help->{$command}\n" if !$param->{$_};
1297 if ($command eq 'destroy') {
1298 check_params
(qw(source));
1300 check_target
($param->{source
});
1301 destroy_job
($param);
1303 } elsif ($command eq 'sync') {
1304 check_params
(qw(source dest));
1306 check_target
($param->{source
});
1307 check_target
($param->{dest
});
1310 } elsif ($command eq 'create') {
1311 check_params
(qw(source dest));
1313 check_target
($param->{source
});
1314 check_target
($param->{dest
});
1317 } elsif ($command eq 'status') {
1320 } elsif ($command eq 'list') {
1323 } elsif ($command eq 'help') {
1324 my $help_command = $ARGV[1];
1326 if ($help_command && $cmd_help->{$help_command}) {
1327 die "$cmd_help->{$help_command}\n";
1330 if ($param->{verbose
}) {
1331 exec("man $PROGNAME");
1338 } elsif ($command eq 'enable') {
1339 check_params
(qw(source));
1341 check_target
($param->{source
});
1344 } elsif ($command eq 'disable') {
1345 check_params
(qw(source));
1347 check_target
($param->{source
});
1348 disable_job
($param);
1350 } elsif ($command eq 'printpod') {
1357 print("ERROR:\tno command specified\n") if !$help;
1358 print("USAGE:\t$PROGNAME <COMMAND> [ARGS] [OPTIONS]\n");
1359 print("\t$PROGNAME help [<cmd>] [OPTIONS]\n\n");
1360 print("\t$PROGNAME create --dest <string> --source <string> [OPTIONS]\n");
1361 print("\t$PROGNAME destroy --source <string> [OPTIONS]\n");
1362 print("\t$PROGNAME disable --source <string> [OPTIONS]\n");
1363 print("\t$PROGNAME enable --source <string> [OPTIONS]\n");
1364 print("\t$PROGNAME list\n");
1365 print("\t$PROGNAME status\n");
1366 print("\t$PROGNAME sync --dest <string> --source <string> [OPTIONS]\n");
1371 parse_target
($target);
1376 my $synopsis = join("\n", sort values %$cmd_help);
1377 my $commands = join(", ", sort keys %$cmd_help);
1382 pve-zsync - PVE ZFS Storage Sync Tool
1386 pve-zsync <COMMAND> [ARGS] [OPTIONS]
1388 Where <COMMAND> can be one of: $commands
1392 The pve-zsync tool can help you to sync your VMs or directories stored on ZFS
1393 between multiple servers.
1395 pve-zsync is able to automatically configure CRON jobs, so that a periodic sync
1396 will be automatically triggered.
1397 The default sync interval is 15 min, if you want to change this value you can
1398 do this in F</etc/cron.d/pve-zsync>. If you need help to configure CRON tabs, see
1401 =head1 COMMANDS AND OPTIONS
1407 Adds a job for syncing the local VM 100 to a remote server's ZFS pool named "tank":
1408 pve-zsync create --source=100 -dest=192.168.1.2:tank
1410 =head1 IMPORTANT FILES
1412 Cron jobs and config are stored in F</etc/cron.d/pve-zsync>
1414 The VM configuration itself gets copied to the destination machines
1415 F</var/lib/pve-zsync/> path.
1417 =head1 COPYRIGHT AND DISCLAIMER
1419 Copyright (C) 2007-2021 Proxmox Server Solutions GmbH
1421 This program is free software: you can redistribute it and/or modify it under
1422 the terms of the GNU Affero General Public License as published by the Free
1423 Software Foundation, either version 3 of the License, or (at your option) any
1426 This program is distributed in the hope that it will be useful, but WITHOUT ANY
1427 WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
1428 PARTICULAR PURPOSE. See the GNU Affero General Public License for more
1431 You should have received a copy of the GNU Affero General Public License along
1432 with this program. If not, see <http://www.gnu.org/licenses/>.