]>
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->{all
}, $old_snap, $source->{ip
}, $param->{source_user
});
698 snapshot_destroy
($dest_dataset, $old_snap, $dest->{ip
}, $param->{dest_user
});
703 if ($source->{vmid
}) {
704 die "VM $source->{vmid} doesn't exist\n" if !$vm_type;
705 die "source-user has to be root for syncing VMs\n" if ($param->{source_user
} ne "root");
706 my $disks = get_disks
($source, $param->{source_user
});
708 foreach my $disk (sort keys %{$disks}) {
709 $source->{all
} = $disks->{$disk}->{all
};
710 $source->{pool
} = $disks->{$disk}->{pool
};
711 $source->{path
} = $disks->{$disk}->{path
} if $disks->{$disk}->{path
};
712 $source->{last_part
} = $disks->{$disk}->{last_part
};
714 $dest->{prepend
} = $disks->{$disk}->{storage_id
}
715 if $param->{prepend_storage_id
};
717 &$sync_path($source, $dest, $job, $param, $date);
719 if ($param->{method} eq "ssh" && ($source->{ip
} || $dest->{ip
})) {
720 send_config
($source, $dest,'ssh', $param->{source_user
}, $param->{dest_user
}, $param->{dest_config_path
});
722 send_config
($source, $dest,'local', $param->{source_user
}, $param->{dest_user
}, $param->{dest_config_path
});
725 &$sync_path($source, $dest, $job, $param, $date);
729 locked
("$CONFIG_PATH/cron_and_state.lock", sub {
730 eval { $job = get_job
($param); };
732 $job->{state} = "error";
733 delete $job->{instance_id
};
737 print "Job --source $param->{source} --name $param->{name} got an ERROR!!!\nERROR Message:\n";
741 locked
("$CONFIG_PATH/cron_and_state.lock", sub {
742 eval { $job = get_job
($param); };
744 if (defined($job->{state}) && $job->{state} eq "stopped") {
745 $job->{state} = "stopped";
747 $job->{state} = "ok";
749 $job->{lsync
} = $date;
750 delete $job->{instance_id
};
758 my ($dataset, $max_snap, $name, $ip, $user) = @_;
761 push @$cmd, 'ssh', "$user\@$ip", '--', if $ip;
762 push @$cmd, 'zfs', 'list', '-r', '-t', 'snapshot', '-Ho', 'name', '-S', 'creation';
763 push @$cmd, $dataset;
766 eval {$raw = run_cmd
($cmd)};
767 if (my $erro =$@) { #this means the volume doesn't exist on dest yet
773 my $last_snap = undef;
776 while ($raw && $raw =~ s/^(.*?)(\n|$)//) {
778 if ($line =~ m/@(.*)$/) {
779 $last_snap = $1 if (!$last_snap);
781 if ($line =~ m/(rep_\Q${name}\E_\d{4}-\d{2}-\d{2}_\d{2}:\d{2}:\d{2})$/) {
782 # interpreted as infinity
783 last if $max_snap <= 0;
788 if ($index >= $max_snap) {
789 push @{$old_snap}, $snap;
794 return ($old_snap, $last_snap) if $last_snap;
800 my ($source, $dest, $name, $date, $source_user, $dest_user) = @_;
802 my $snap_name = "rep_$name\_".$date;
804 $source->{new_snap
} = $snap_name;
806 my $path = "$source->{all}\@$snap_name";
809 push @$cmd, 'ssh', "$source_user\@$source->{ip}", '--', if $source->{ip
};
810 push @$cmd, 'zfs', 'snapshot', $path;
816 snapshot_destroy
($source->{all
}, $snap_name, $source->{ip
}, $source_user);
822 my ($target, $user) = @_;
825 push @$cmd, 'ssh', "$user\@$target->{ip}", '--', if $target->{ip
};
827 if ($target->{vm_type
} eq 'qemu') {
828 push @$cmd, 'qm', 'config', $target->{vmid
};
829 } elsif ($target->{vm_type
} eq 'lxc') {
830 push @$cmd, 'pct', 'config', $target->{vmid
};
832 die "VM Type unknown\n";
835 my $res = run_cmd
($cmd);
837 my $disks = parse_disks
($res, $target->{ip
}, $target->{vm_type
}, $user);
844 print "Start CMD\n" if $DEBUG;
845 print Dumper
$cmd if $DEBUG;
846 if (ref($cmd) eq 'ARRAY') {
847 $cmd = join(' ', map { ref($_) ?
$$_ : shell_quote
($_) } @$cmd);
849 my $output = `$cmd 2>&1`;
851 die "COMMAND:\n\t$cmd\nGET ERROR:\n\t$output" if 0 != $?;
854 print Dumper
$output if $DEBUG;
855 print "END CMD\n" if $DEBUG;
860 my ($text, $ip, $vm_type, $user) = @_;
865 while ($text && $text =~ s/^(.*?)(\n|$)//) {
868 next if $line =~ /media=cdrom/;
869 next if $line !~ m/$DISK_KEY_RE/;
871 #QEMU if backup is not set include in sync
872 next if $vm_type eq 'qemu' && ($line =~ m/backup=(?i:0|no|off|false)/);
874 #LXC if backup is not set do no in sync
875 next if $vm_type eq 'lxc' && ($line =~ m/^mp\d:/) && ($line !~ m/backup=(?i:1|yes|on|true)/);
879 if($line =~ m/$DISK_KEY_RE(.*)$/) {
880 my @parameter = split(/,/,$1);
882 foreach my $opt (@parameter) {
883 if ($opt =~ m/^(?:file=|volume=)?([^:]+):([A-Za-z0-9\-]+)$/){
890 if (!defined($disk) || !defined($stor)) {
891 print "Disk: \"$line\" has no valid zfs dataset format
and will be skipped
\n";
896 push @$cmd, 'ssh', "$user\@$ip", '--' if $ip;
897 push @$cmd, 'pvesm', 'path', "$stor:$disk";
898 my $path = run_cmd($cmd);
900 die "Get
no path from pvesm path
$stor:$disk\n" if !$path;
902 $disks->{$num}->{storage_id} = $stor;
904 if ($vm_type eq 'qemu' && $path =~ m/^\/dev\/zvol\/(\w+.*)(\/$disk)$/) {
906 my @array = split('/', $1);
907 $disks->{$num}->{pool} = shift(@array);
908 $disks->{$num}->{all} = $disks->{$num}->{pool};
910 $disks->{$num}->{path} = join('/', @array);
911 $disks->{$num}->{all} .= "\
/$disks->{$num}->{path
}";
913 $disks->{$num}->{last_part} = $disk;
914 $disks->{$num}->{all} .= "\
/$disk";
917 } elsif ($vm_type eq 'lxc' && $path =~ m/^\/(\w+.+)(\/(\w+.*))*(\/$disk)$/) {
919 $disks->{$num}->{pool} = $1;
920 $disks->{$num}->{all} = $disks->{$num}->{pool};
923 $disks->{$num}->{path} = $3;
924 $disks->{$num}->{all} .= "\
/$disks->{$num}->{path
}";
927 $disks->{$num}->{last_part} = $disk;
928 $disks->{$num}->{all} .= "\
/$disk";
933 die "ERROR
: in path
\n";
937 die "Vm include
no disk on zfs
.\n" if !$disks->{0};
941 # how the corresponding dataset is named on the target
943 my ($source, $dest) = @_;
945 my $target = "$dest->{all
}";
946 $target .= "/$dest->{prepend
}" if defined($dest->{prepend});
947 $target .= "/$source->{last_part
}" if $source->{last_part};
953 # create the parent dataset for the actual target
954 sub prepare_prepended_target {
955 my ($source, $dest, $dest_user) = @_;
957 die "internal error
- not a prepended target
\n" if !defined($dest->{prepend});
959 # The parent dataset shouldn't be the actual target.
960 die "internal error
- no last_part
for source
\n" if !$source->{last_part};
962 my $target = "$dest->{all
}/$dest->{prepend
}";
965 return if check_dataset_exists($target, $dest->{ip}, $dest_user);
967 create_file_system($target, $dest->{ip}, $dest_user);
970 sub snapshot_destroy {
971 my ($dataset, $snap, $ip, $user) = @_;
973 my @zfscmd = ('zfs', 'destroy');
974 my $snapshot = "$dataset\@$snap";
978 run_cmd(['ssh', "$user\@$ip", '--', @zfscmd, $snapshot]);
980 run_cmd([@zfscmd, $snapshot]);
988 # check if snapshot for incremental sync exist on source side
990 my ($source , $dest, $method, $source_user) = @_;
993 push @$cmd, 'ssh', "$source_user\@$source->{ip
}", '--' if $source->{ip};
994 push @$cmd, 'zfs', 'list', '-rt', 'snapshot', '-Ho', 'name';
996 my $path = $source->{all};
997 $path .= "\
@$dest->{last_snap
}";
1001 eval {run_cmd($cmd)};
1010 my ($source, $dest, $param) = @_;
1014 push @$cmd, 'ssh', '-o', 'BatchMode=yes', "$param->{source_user
}\
@$source->{ip
}", '--' if $source->{ip};
1015 push @$cmd, 'zfs', 'send';
1016 push @$cmd, '-p', if $param->{properties};
1017 push @$cmd, '-v' if $param->{verbose};
1019 if($dest->{last_snap} && snapshot_exist($source , $dest, $param->{method}, $param->{source_user})) {
1020 push @$cmd, '-i', "$source->{all
}\
@$dest->{last_snap
}";
1022 push @$cmd, '--', "$source->{all
}\
@$source->{new_snap
}";
1024 if ($param->{limit}){
1025 my $bwl = $param->{limit}*1024;
1026 push @$cmd, \'|', 'cstream', '-t', $bwl;
1028 my $target = target_dataset($source, $dest);
1031 push @$cmd, 'ssh', '-o', 'BatchMode=yes', "$param->{dest_user
}\
@$dest->{ip
}", '--' if $dest->{ip};
1032 push @$cmd, 'zfs', 'recv', '-F', '--';
1033 push @$cmd, "$target";
1039 if (my $erro = $@) {
1040 snapshot_destroy($source->{all}, $source->{new_snap}, $source->{ip}, $param->{source_user});
1047 my ($source, $dest, $method, $source_user, $dest_user, $dest_config_path) = @_;
1049 my $source_target = $source->{vm_type} eq 'qemu' ? "$QEMU_CONF/$source->{vmid
}.conf
": "$LXC_CONF/$source->{vmid
}.conf
";
1050 my $dest_target_new ="$source->{vmid
}.conf
.$source->{vm_type
}.$source->{new_snap
}";
1052 my $config_dir = $dest_config_path // $CONFIG_PATH;
1053 $config_dir .= "/$dest->{last_part
}" if $dest->{last_part};
1055 $dest_target_new = $config_dir.'/'.$dest_target_new;
1057 if ($method eq 'ssh'){
1058 if ($dest->{ip} && $source->{ip}) {
1059 run_cmd(['ssh', "$dest_user\@$dest->{ip
}", '--', 'mkdir', '-p', '--', $config_dir]);
1060 run_cmd(['scp', '--', "$source_user\@[$source->{ip
}]:$source_target", "$dest_user\@[$dest->{ip
}]:$dest_target_new"]);
1061 } elsif ($dest->{ip}) {
1062 run_cmd(['ssh', "$dest_user\@$dest->{ip
}", '--', 'mkdir', '-p', '--', $config_dir]);
1063 run_cmd(['scp', '--', $source_target, "$dest_user\@[$dest->{ip
}]:$dest_target_new"]);
1064 } elsif ($source->{ip}) {
1065 run_cmd(['mkdir', '-p', '--', $config_dir]);
1066 run_cmd(['scp', '--', "$source_user\@[$source->{ip
}]:$source_target", $dest_target_new]);
1069 for my $old_snap (@{$dest->{old_snap}}) {
1070 my $dest_target_old ="${config_dir
}/$source->{vmid
}.conf
.$source->{vm_type
}.${old_snap
}";
1072 run_cmd(['ssh', "$dest_user\@$dest->{ip
}", '--', 'rm', '-f', '--', $dest_target_old]);
1074 run_cmd(['rm', '-f', '--', $dest_target_old]);
1077 } elsif ($method eq 'local') {
1078 run_cmd(['mkdir', '-p', '--', $config_dir]);
1079 run_cmd(['cp', $source_target, $dest_target_new]);
1084 my ($sec, $min, $hour, $mday, $mon, $year, $wday, $yday, $isdst) = localtime(time);
1085 my $datestamp = sprintf ("%04d-%02d-%02d_%02d:%02d:%02d", $year+1900, $mon+1, $mday, $hour, $min, $sec);
1091 my $cfg = read_cron();
1093 my $status_list = sprintf("%-25s
%-25s
%-10s
\n", "SOURCE
", "NAME
", "STATUS
");
1095 my $states = read_state();
1097 foreach my $source (sort keys%{$cfg}) {
1098 foreach my $sync_name (sort keys%{$cfg->{$source}}) {
1099 $status_list .= sprintf("%-25s
", cut_target_width($source, 25));
1100 $status_list .= sprintf("%-25s
", cut_target_width($sync_name, 25));
1101 $status_list .= "$states->{$source}->{$sync_name}->{state}\n";
1105 return $status_list;
1111 locked("$CONFIG_PATH/cron_and_state.lock", sub {
1112 my $job = get_job($param);
1113 $job->{state} = "ok
";
1122 locked("$CONFIG_PATH/cron_and_state.lock", sub {
1123 my $job = get_job($param);
1124 $job->{state} = "stopped
";
1132 $PROGNAME destroy --source <string> [OPTIONS]
1134 Remove a sync Job from the scheduler
1137 The name of the sync job, if not set 'default' is used.
1140 The source can be an <VMID> or [IP:]<ZFSPool>[/Path]
1143 $PROGNAME create --dest <string> --source <string> [OPTIONS]
1145 Create a new sync-job
1148 The destination target is like [IP]:<Pool>[/Path]
1151 The name of the user on the destination target, root by default
1154 Maximal sync speed in kBytes/s, default is unlimited
1157 The number of snapshots to keep until older ones are erased.
1158 The default is 1, use 0 for unlimited.
1161 The name of the sync job, if not set it is default
1163 --prepend-storage-id
1164 If specified, prepend the storage ID to the destination's path(s).
1167 If specified, skip the first sync.
1170 The source can be an <VMID> or [IP:]<ZFSPool>[/Path]
1172 --source-user string
1173 The (ssh) user-name on the source target, root by default
1176 If specified, include the dataset's properties in the stream.
1178 --dest-config-path string
1179 Specifies a custom config path on the destination target.
1180 The default is /var/lib/pve-zsync
1183 $PROGNAME sync --dest <string> --source <string> [OPTIONS]\n
1188 The destination target is like [IP:]<Pool>[/Path]
1191 The (ssh) user-name on the destination target, root by default
1194 The maximal sync speed in kBytes/s, default is unlimited
1197 The number of snapshots to keep until older ones are erased.
1198 The default is 1, use 0 for unlimited.
1201 The name of the sync job, if not set it is 'default'.
1202 It is only necessary if scheduler allready contains this source.
1204 --prepend-storage-id
1205 If specified, prepend the storage ID to the destination's path(s).
1208 The source can either be an <VMID> or [IP:]<ZFSPool>[/Path]
1210 --source-user string
1211 The name of the user on the source target, root by default
1214 If specified, print out the sync progress.
1217 If specified, include the dataset's properties in the stream.
1219 --dest-config-path string
1220 Specifies a custom config path on the destination target.
1221 The default is /var/lib/pve-zsync
1226 Get a List of all scheduled Sync Jobs
1231 Get the status of all scheduled Sync Jobs
1234 $PROGNAME help <cmd> [OPTIONS]
1236 Get help about specified command.
1239 Command name to get help about.
1242 Verbose output format.
1245 $PROGNAME enable --source <string> [OPTIONS]
1247 Enable a sync-job and reset all job-errors, if any.
1250 name of the sync job, if not set it is default
1253 the source can be an <VMID> or [IP:]<ZFSPool>[/Path]
1256 $PROGNAME disable --source <string> [OPTIONS]
1258 Disables (pauses) a sync-job
1261 name of the sync-job, if not set it is default
1264 the source can be an <VMID> or [IP:]<ZFSPool>[/Path]
1266 printpod
=> "$PROGNAME printpod\n\n\tinternal command",
1272 } elsif (!$cmd_help->{$command}) {
1273 print "ERROR: unknown command '$command'";
1278 my $param = parse_argv
(@arg);
1282 die "$cmd_help->{$command}\n" if !$param->{$_};
1286 if ($command eq 'destroy') {
1287 check_params
(qw(source));
1289 check_target
($param->{source
});
1290 destroy_job
($param);
1292 } elsif ($command eq 'sync') {
1293 check_params
(qw(source dest));
1295 check_target
($param->{source
});
1296 check_target
($param->{dest
});
1299 } elsif ($command eq 'create') {
1300 check_params
(qw(source dest));
1302 check_target
($param->{source
});
1303 check_target
($param->{dest
});
1306 } elsif ($command eq 'status') {
1309 } elsif ($command eq 'list') {
1312 } elsif ($command eq 'help') {
1313 my $help_command = $ARGV[1];
1315 if ($help_command && $cmd_help->{$help_command}) {
1316 die "$cmd_help->{$help_command}\n";
1319 if ($param->{verbose
}) {
1320 exec("man $PROGNAME");
1327 } elsif ($command eq 'enable') {
1328 check_params
(qw(source));
1330 check_target
($param->{source
});
1333 } elsif ($command eq 'disable') {
1334 check_params
(qw(source));
1336 check_target
($param->{source
});
1337 disable_job
($param);
1339 } elsif ($command eq 'printpod') {
1346 print("ERROR:\tno command specified\n") if !$help;
1347 print("USAGE:\t$PROGNAME <COMMAND> [ARGS] [OPTIONS]\n");
1348 print("\t$PROGNAME help [<cmd>] [OPTIONS]\n\n");
1349 print("\t$PROGNAME create --dest <string> --source <string> [OPTIONS]\n");
1350 print("\t$PROGNAME destroy --source <string> [OPTIONS]\n");
1351 print("\t$PROGNAME disable --source <string> [OPTIONS]\n");
1352 print("\t$PROGNAME enable --source <string> [OPTIONS]\n");
1353 print("\t$PROGNAME list\n");
1354 print("\t$PROGNAME status\n");
1355 print("\t$PROGNAME sync --dest <string> --source <string> [OPTIONS]\n");
1360 parse_target
($target);
1365 my $synopsis = join("\n", sort values %$cmd_help);
1366 my $commands = join(", ", sort keys %$cmd_help);
1371 pve-zsync - PVE ZFS Storage Sync Tool
1375 pve-zsync <COMMAND> [ARGS] [OPTIONS]
1377 Where <COMMAND> can be one of: $commands
1381 The pve-zsync tool can help you to sync your VMs or directories stored on ZFS
1382 between multiple servers.
1384 pve-zsync is able to automatically configure CRON jobs, so that a periodic sync
1385 will be automatically triggered.
1386 The default sync interval is 15 min, if you want to change this value you can
1387 do this in F</etc/cron.d/pve-zsync>. If you need help to configure CRON tabs, see
1390 =head1 COMMANDS AND OPTIONS
1396 Adds a job for syncing the local VM 100 to a remote server's ZFS pool named "tank":
1397 pve-zsync create --source=100 -dest=192.168.1.2:tank
1399 =head1 IMPORTANT FILES
1401 Cron jobs and config are stored in F</etc/cron.d/pve-zsync>
1403 The VM configuration itself gets copied to the destination machines
1404 F</var/lib/pve-zsync/> path.
1406 =head1 COPYRIGHT AND DISCLAIMER
1408 Copyright (C) 2007-2021 Proxmox Server Solutions GmbH
1410 This program is free software: you can redistribute it and/or modify it under
1411 the terms of the GNU Affero General Public License as published by the Free
1412 Software Foundation, either version 3 of the License, or (at your option) any
1415 This program is distributed in the hope that it will be useful, but WITHOUT ANY
1416 WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
1417 PARTICULAR PURPOSE. See the GNU Affero General Public License for more
1420 You should have received a copy of the GNU Affero General Public License along
1421 with this program. If not, see <http://www.gnu.org/licenses/>.