]>
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 ($dest->{old_snap
}, $dest->{last_snap
}) = snapshot_get
($source, $dest, $param->{maxsnap
}, $param->{name
}, $param->{dest_user
});
682 prepare_prepended_target
($source, $dest, $param->{dest_user
}) if defined($dest->{prepend
});
684 snapshot_add
($source, $dest, $param->{name
}, $date, $param->{source_user
}, $param->{dest_user
});
686 send_image
($source, $dest, $param);
688 snapshot_destroy
($source, $dest, $param->{method}, $dest->{old_snap
}, $param->{source_user
}, $param->{dest_user
}) if ($source->{destroy
} && $dest->{old_snap
});
693 if ($source->{vmid
}) {
694 die "VM $source->{vmid} doesn't exist\n" if !$vm_type;
695 die "source-user has to be root for syncing VMs\n" if ($param->{source_user
} ne "root");
696 my $disks = get_disks
($source, $param->{source_user
});
698 foreach my $disk (sort keys %{$disks}) {
699 $source->{all
} = $disks->{$disk}->{all
};
700 $source->{pool
} = $disks->{$disk}->{pool
};
701 $source->{path
} = $disks->{$disk}->{path
} if $disks->{$disk}->{path
};
702 $source->{last_part
} = $disks->{$disk}->{last_part
};
704 $dest->{prepend
} = $disks->{$disk}->{storage_id
}
705 if $param->{prepend_storage_id
};
707 &$sync_path($source, $dest, $job, $param, $date);
709 if ($param->{method} eq "ssh" && ($source->{ip
} || $dest->{ip
})) {
710 send_config
($source, $dest,'ssh', $param->{source_user
}, $param->{dest_user
}, $param->{dest_config_path
});
712 send_config
($source, $dest,'local', $param->{source_user
}, $param->{dest_user
}, $param->{dest_config_path
});
715 &$sync_path($source, $dest, $job, $param, $date);
719 locked
("$CONFIG_PATH/cron_and_state.lock", sub {
720 eval { $job = get_job
($param); };
722 $job->{state} = "error";
723 delete $job->{instance_id
};
727 print "Job --source $param->{source} --name $param->{name} got an ERROR!!!\nERROR Message:\n";
731 locked
("$CONFIG_PATH/cron_and_state.lock", sub {
732 eval { $job = get_job
($param); };
734 if (defined($job->{state}) && $job->{state} eq "stopped") {
735 $job->{state} = "stopped";
737 $job->{state} = "ok";
739 $job->{lsync
} = $date;
740 delete $job->{instance_id
};
748 my ($source, $dest, $max_snap, $name, $dest_user) = @_;
751 push @$cmd, 'ssh', "$dest_user\@$dest->{ip}", '--', if $dest->{ip
};
752 push @$cmd, 'zfs', 'list', '-r', '-t', 'snapshot', '-Ho', 'name', '-S', 'creation';
754 my $path = target_dataset
($source, $dest);
758 eval {$raw = run_cmd
($cmd)};
759 if (my $erro =$@) { #this means the volume doesn't exist on dest yet
765 my $last_snap = undef;
768 while ($raw && $raw =~ s/^(.*?)(\n|$)//) {
770 if ($line =~ m/@(.*)$/) {
771 $last_snap = $1 if (!$last_snap);
773 if ($line =~ m/(rep_\Q${name}\E_\d{4}-\d{2}-\d{2}_\d{2}:\d{2}:\d{2})$/) {
776 if ($index == $max_snap) {
777 $source->{destroy
} = 1;
783 return ($old_snap, $last_snap) if $last_snap;
789 my ($source, $dest, $name, $date, $source_user, $dest_user) = @_;
791 my $snap_name = "rep_$name\_".$date;
793 $source->{new_snap
} = $snap_name;
795 my $path = "$source->{all}\@$snap_name";
798 push @$cmd, 'ssh', "$source_user\@$source->{ip}", '--', if $source->{ip
};
799 push @$cmd, 'zfs', 'snapshot', $path;
805 snapshot_destroy
($source, $dest, 'ssh', $snap_name, $source_user, $dest_user);
811 my ($target, $user) = @_;
814 push @$cmd, 'ssh', "$user\@$target->{ip}", '--', if $target->{ip
};
816 if ($target->{vm_type
} eq 'qemu') {
817 push @$cmd, 'qm', 'config', $target->{vmid
};
818 } elsif ($target->{vm_type
} eq 'lxc') {
819 push @$cmd, 'pct', 'config', $target->{vmid
};
821 die "VM Type unknown\n";
824 my $res = run_cmd
($cmd);
826 my $disks = parse_disks
($res, $target->{ip
}, $target->{vm_type
}, $user);
833 print "Start CMD\n" if $DEBUG;
834 print Dumper
$cmd if $DEBUG;
835 if (ref($cmd) eq 'ARRAY') {
836 $cmd = join(' ', map { ref($_) ?
$$_ : shell_quote
($_) } @$cmd);
838 my $output = `$cmd 2>&1`;
840 die "COMMAND:\n\t$cmd\nGET ERROR:\n\t$output" if 0 != $?;
843 print Dumper
$output if $DEBUG;
844 print "END CMD\n" if $DEBUG;
849 my ($text, $ip, $vm_type, $user) = @_;
854 while ($text && $text =~ s/^(.*?)(\n|$)//) {
857 next if $line =~ /media=cdrom/;
858 next if $line !~ m/$DISK_KEY_RE/;
860 #QEMU if backup is not set include in sync
861 next if $vm_type eq 'qemu' && ($line =~ m/backup=(?i:0|no|off|false)/);
863 #LXC if backup is not set do no in sync
864 next if $vm_type eq 'lxc' && ($line =~ m/^mp\d:/) && ($line !~ m/backup=(?i:1|yes|on|true)/);
868 if($line =~ m/$DISK_KEY_RE(.*)$/) {
869 my @parameter = split(/,/,$1);
871 foreach my $opt (@parameter) {
872 if ($opt =~ m/^(?:file=|volume=)?([^:]+):([A-Za-z0-9\-]+)$/){
879 if (!defined($disk) || !defined($stor)) {
880 print "Disk: \"$line\" has no valid zfs dataset format
and will be skipped
\n";
885 push @$cmd, 'ssh', "$user\@$ip", '--' if $ip;
886 push @$cmd, 'pvesm', 'path', "$stor:$disk";
887 my $path = run_cmd($cmd);
889 die "Get
no path from pvesm path
$stor:$disk\n" if !$path;
891 $disks->{$num}->{storage_id} = $stor;
893 if ($vm_type eq 'qemu' && $path =~ m/^\/dev\/zvol\/(\w+.*)(\/$disk)$/) {
895 my @array = split('/', $1);
896 $disks->{$num}->{pool} = shift(@array);
897 $disks->{$num}->{all} = $disks->{$num}->{pool};
899 $disks->{$num}->{path} = join('/', @array);
900 $disks->{$num}->{all} .= "\
/$disks->{$num}->{path
}";
902 $disks->{$num}->{last_part} = $disk;
903 $disks->{$num}->{all} .= "\
/$disk";
906 } elsif ($vm_type eq 'lxc' && $path =~ m/^\/(\w+.+)(\/(\w+.*))*(\/$disk)$/) {
908 $disks->{$num}->{pool} = $1;
909 $disks->{$num}->{all} = $disks->{$num}->{pool};
912 $disks->{$num}->{path} = $3;
913 $disks->{$num}->{all} .= "\
/$disks->{$num}->{path
}";
916 $disks->{$num}->{last_part} = $disk;
917 $disks->{$num}->{all} .= "\
/$disk";
922 die "ERROR
: in path
\n";
926 die "Vm include
no disk on zfs
.\n" if !$disks->{0};
930 # how the corresponding dataset is named on the target
932 my ($source, $dest) = @_;
934 my $target = "$dest->{all
}";
935 $target .= "/$dest->{prepend
}" if defined($dest->{prepend});
936 $target .= "/$source->{last_part
}" if $source->{last_part};
942 # create the parent dataset for the actual target
943 sub prepare_prepended_target {
944 my ($source, $dest, $dest_user) = @_;
946 die "internal error
- not a prepended target
\n" if !defined($dest->{prepend});
948 # The parent dataset shouldn't be the actual target.
949 die "internal error
- no last_part
for source
\n" if !$source->{last_part};
951 my $target = "$dest->{all
}/$dest->{prepend
}";
954 return if check_dataset_exists($target, $dest->{ip}, $dest_user);
956 create_file_system($target, $dest->{ip}, $dest_user);
959 sub snapshot_destroy {
960 my ($source, $dest, $method, $snap, $source_user, $dest_user) = @_;
962 my @zfscmd = ('zfs', 'destroy');
963 my $snapshot = "$source->{all
}\
@$snap";
966 if($source->{ip} && $method eq 'ssh'){
967 run_cmd(['ssh', "$source_user\@$source->{ip
}", '--', @zfscmd, $snapshot]);
969 run_cmd([@zfscmd, $snapshot]);
976 my @ssh = $dest->{ip} ? ('ssh', "$dest_user\@$dest->{ip
}", '--') : ();
978 my $path = target_dataset($source, $dest);
981 run_cmd([@ssh, @zfscmd, "$path\@$snap"]);
989 # check if snapshot for incremental sync exist on source side
991 my ($source , $dest, $method, $source_user) = @_;
994 push @$cmd, 'ssh', "$source_user\@$source->{ip
}", '--' if $source->{ip};
995 push @$cmd, 'zfs', 'list', '-rt', 'snapshot', '-Ho', 'name';
997 my $path = $source->{all};
998 $path .= "\
@$dest->{last_snap
}";
1002 eval {run_cmd($cmd)};
1011 my ($source, $dest, $param) = @_;
1015 push @$cmd, 'ssh', '-o', 'BatchMode=yes', "$param->{source_user
}\
@$source->{ip
}", '--' if $source->{ip};
1016 push @$cmd, 'zfs', 'send';
1017 push @$cmd, '-p', if $param->{properties};
1018 push @$cmd, '-v' if $param->{verbose};
1020 if($dest->{last_snap} && snapshot_exist($source , $dest, $param->{method}, $param->{source_user})) {
1021 push @$cmd, '-i', "$source->{all
}\
@$dest->{last_snap
}";
1023 push @$cmd, '--', "$source->{all
}\
@$source->{new_snap
}";
1025 if ($param->{limit}){
1026 my $bwl = $param->{limit}*1024;
1027 push @$cmd, \'|', 'cstream', '-t', $bwl;
1029 my $target = target_dataset($source, $dest);
1032 push @$cmd, 'ssh', '-o', 'BatchMode=yes', "$param->{dest_user
}\
@$dest->{ip
}", '--' if $dest->{ip};
1033 push @$cmd, 'zfs', 'recv', '-F', '--';
1034 push @$cmd, "$target";
1040 if (my $erro = $@) {
1041 snapshot_destroy($source, undef, $param->{method}, $source->{new_snap}, $param->{source_user}, $param->{dest_user});
1048 my ($source, $dest, $method, $source_user, $dest_user, $dest_config_path) = @_;
1050 my $source_target = $source->{vm_type} eq 'qemu' ? "$QEMU_CONF/$source->{vmid
}.conf
": "$LXC_CONF/$source->{vmid
}.conf
";
1051 my $dest_target_new ="$source->{vmid
}.conf
.$source->{vm_type
}.$source->{new_snap
}";
1053 my $config_dir = $dest_config_path // $CONFIG_PATH;
1054 $config_dir .= "/$dest->{last_part
}" if $dest->{last_part};
1056 $dest_target_new = $config_dir.'/'.$dest_target_new;
1058 if ($method eq 'ssh'){
1059 if ($dest->{ip} && $source->{ip}) {
1060 run_cmd(['ssh', "$dest_user\@$dest->{ip
}", '--', 'mkdir', '-p', '--', $config_dir]);
1061 run_cmd(['scp', '--', "$source_user\@[$source->{ip
}]:$source_target", "$dest_user\@[$dest->{ip
}]:$dest_target_new"]);
1062 } elsif ($dest->{ip}) {
1063 run_cmd(['ssh', "$dest_user\@$dest->{ip
}", '--', 'mkdir', '-p', '--', $config_dir]);
1064 run_cmd(['scp', '--', $source_target, "$dest_user\@[$dest->{ip
}]:$dest_target_new"]);
1065 } elsif ($source->{ip}) {
1066 run_cmd(['mkdir', '-p', '--', $config_dir]);
1067 run_cmd(['scp', '--', "$source_user\@[$source->{ip
}]:$source_target", $dest_target_new]);
1070 if ($source->{destroy}){
1071 my $dest_target_old ="${config_dir
}/$source->{vmid
}.conf
.$source->{vm_type
}.$dest->{old_snap
}";
1073 run_cmd(['ssh', "$dest_user\@$dest->{ip
}", '--', 'rm', '-f', '--', $dest_target_old]);
1075 run_cmd(['rm', '-f', '--', $dest_target_old]);
1078 } elsif ($method eq 'local') {
1079 run_cmd(['mkdir', '-p', '--', $config_dir]);
1080 run_cmd(['cp', $source_target, $dest_target_new]);
1085 my ($sec, $min, $hour, $mday, $mon, $year, $wday, $yday, $isdst) = localtime(time);
1086 my $datestamp = sprintf ("%04d-%02d-%02d_%02d:%02d:%02d", $year+1900, $mon+1, $mday, $hour, $min, $sec);
1092 my $cfg = read_cron();
1094 my $status_list = sprintf("%-25s
%-25s
%-10s
\n", "SOURCE
", "NAME
", "STATUS
");
1096 my $states = read_state();
1098 foreach my $source (sort keys%{$cfg}) {
1099 foreach my $sync_name (sort keys%{$cfg->{$source}}) {
1100 $status_list .= sprintf("%-25s
", cut_target_width($source, 25));
1101 $status_list .= sprintf("%-25s
", cut_target_width($sync_name, 25));
1102 $status_list .= "$states->{$source}->{$sync_name}->{state}\n";
1106 return $status_list;
1112 locked("$CONFIG_PATH/cron_and_state.lock", sub {
1113 my $job = get_job($param);
1114 $job->{state} = "ok
";
1123 locked("$CONFIG_PATH/cron_and_state.lock", sub {
1124 my $job = get_job($param);
1125 $job->{state} = "stopped
";
1133 $PROGNAME destroy --source <string> [OPTIONS]
1135 Remove a sync Job from the scheduler
1138 The name of the sync job, if not set 'default' is used.
1141 The source can be an <VMID> or [IP:]<ZFSPool>[/Path]
1144 $PROGNAME create --dest <string> --source <string> [OPTIONS]
1146 Create a new sync-job
1149 The destination target is like [IP]:<Pool>[/Path]
1152 The name of the user on the destination target, root by default
1155 Maximal sync speed in kBytes/s, default is unlimited
1158 The number of snapshots to keep until older ones are erased.
1159 The default is 1, use 0 for unlimited.
1162 The name of the sync job, if not set it is default
1164 --prepend-storage-id
1165 If specified, prepend the storage ID to the destination's path(s).
1168 If specified, skip the first sync.
1171 The source can be an <VMID> or [IP:]<ZFSPool>[/Path]
1173 --source-user string
1174 The (ssh) user-name on the source target, root by default
1177 If specified, include the dataset's properties in the stream.
1179 --dest-config-path string
1180 Specifies a custom config path on the destination target.
1181 The default is /var/lib/pve-zsync
1184 $PROGNAME sync --dest <string> --source <string> [OPTIONS]\n
1189 The destination target is like [IP:]<Pool>[/Path]
1192 The (ssh) user-name on the destination target, root by default
1195 The maximal sync speed in kBytes/s, default is unlimited
1198 The number of snapshots to keep until older ones are erased.
1199 The default is 1, use 0 for unlimited.
1202 The name of the sync job, if not set it is 'default'.
1203 It is only necessary if scheduler allready contains this source.
1205 --prepend-storage-id
1206 If specified, prepend the storage ID to the destination's path(s).
1209 The source can either be an <VMID> or [IP:]<ZFSPool>[/Path]
1211 --source-user string
1212 The name of the user on the source target, root by default
1215 If specified, print out the sync progress.
1218 If specified, include the dataset's properties in the stream.
1220 --dest-config-path string
1221 Specifies a custom config path on the destination target.
1222 The default is /var/lib/pve-zsync
1227 Get a List of all scheduled Sync Jobs
1232 Get the status of all scheduled Sync Jobs
1235 $PROGNAME help <cmd> [OPTIONS]
1237 Get help about specified command.
1240 Command name to get help about.
1243 Verbose output format.
1246 $PROGNAME enable --source <string> [OPTIONS]
1248 Enable a sync-job and reset all job-errors, if any.
1251 name of the sync job, if not set it is default
1254 the source can be an <VMID> or [IP:]<ZFSPool>[/Path]
1257 $PROGNAME disable --source <string> [OPTIONS]
1259 Disables (pauses) a sync-job
1262 name of the sync-job, if not set it is default
1265 the source can be an <VMID> or [IP:]<ZFSPool>[/Path]
1267 printpod
=> "$PROGNAME printpod\n\n\tinternal command",
1273 } elsif (!$cmd_help->{$command}) {
1274 print "ERROR: unknown command '$command'";
1279 my $param = parse_argv
(@arg);
1283 die "$cmd_help->{$command}\n" if !$param->{$_};
1287 if ($command eq 'destroy') {
1288 check_params
(qw(source));
1290 check_target
($param->{source
});
1291 destroy_job
($param);
1293 } elsif ($command eq 'sync') {
1294 check_params
(qw(source dest));
1296 check_target
($param->{source
});
1297 check_target
($param->{dest
});
1300 } elsif ($command eq 'create') {
1301 check_params
(qw(source dest));
1303 check_target
($param->{source
});
1304 check_target
($param->{dest
});
1307 } elsif ($command eq 'status') {
1310 } elsif ($command eq 'list') {
1313 } elsif ($command eq 'help') {
1314 my $help_command = $ARGV[1];
1316 if ($help_command && $cmd_help->{$help_command}) {
1317 die "$cmd_help->{$help_command}\n";
1320 if ($param->{verbose
}) {
1321 exec("man $PROGNAME");
1328 } elsif ($command eq 'enable') {
1329 check_params
(qw(source));
1331 check_target
($param->{source
});
1334 } elsif ($command eq 'disable') {
1335 check_params
(qw(source));
1337 check_target
($param->{source
});
1338 disable_job
($param);
1340 } elsif ($command eq 'printpod') {
1347 print("ERROR:\tno command specified\n") if !$help;
1348 print("USAGE:\t$PROGNAME <COMMAND> [ARGS] [OPTIONS]\n");
1349 print("\t$PROGNAME help [<cmd>] [OPTIONS]\n\n");
1350 print("\t$PROGNAME create --dest <string> --source <string> [OPTIONS]\n");
1351 print("\t$PROGNAME destroy --source <string> [OPTIONS]\n");
1352 print("\t$PROGNAME disable --source <string> [OPTIONS]\n");
1353 print("\t$PROGNAME enable --source <string> [OPTIONS]\n");
1354 print("\t$PROGNAME list\n");
1355 print("\t$PROGNAME status\n");
1356 print("\t$PROGNAME sync --dest <string> --source <string> [OPTIONS]\n");
1361 parse_target
($target);
1366 my $synopsis = join("\n", sort values %$cmd_help);
1367 my $commands = join(", ", sort keys %$cmd_help);
1372 pve-zsync - PVE ZFS Storage Sync Tool
1376 pve-zsync <COMMAND> [ARGS] [OPTIONS]
1378 Where <COMMAND> can be one of: $commands
1382 The pve-zsync tool can help you to sync your VMs or directories stored on ZFS
1383 between multiple servers.
1385 pve-zsync is able to automatically configure CRON jobs, so that a periodic sync
1386 will be automatically triggered.
1387 The default sync interval is 15 min, if you want to change this value you can
1388 do this in F</etc/cron.d/pve-zsync>. If you need help to configure CRON tabs, see
1391 =head1 COMMANDS AND OPTIONS
1397 Adds a job for syncing the local VM 100 to a remote server's ZFS pool named "tank":
1398 pve-zsync create --source=100 -dest=192.168.1.2:tank
1400 =head1 IMPORTANT FILES
1402 Cron jobs and config are stored in F</etc/cron.d/pve-zsync>
1404 The VM configuration itself gets copied to the destination machines
1405 F</var/lib/pve-zsync/> path.
1407 =head1 COPYRIGHT AND DISCLAIMER
1409 Copyright (C) 2007-2021 Proxmox Server Solutions GmbH
1411 This program is free software: you can redistribute it and/or modify it under
1412 the terms of the GNU Affero General Public License as published by the Free
1413 Software Foundation, either version 3 of the License, or (at your option) any
1416 This program is distributed in the hope that it will be useful, but WITHOUT ANY
1417 WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
1418 PARTICULAR PURPOSE. See the GNU Affero General Public License for more
1421 You should have received a copy of the GNU Affero General Public License along
1422 with this program. If not, see <http://www.gnu.org/licenses/>.