]>
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
} if $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
1139 name of the sync job, if not set it is default
1143 the source can be an <VMID> or [IP:]<ZFSPool>[/Path]
1146 $PROGNAME create -dest <string> -source <string> [OPTIONS]
1152 the destination target is like [IP]:<Pool>[/Path]
1156 name of the user on the destination target, root by default
1160 max sync speed in kBytes/s, default unlimited
1164 how much snapshots will be kept before get erased, default 1
1168 name of the sync job, if not set it is default
1172 If specified, prepend the storage ID to the destination's path(s).
1176 If specified, skip the first sync.
1180 the source can be an <VMID> or [IP:]<ZFSPool>[/Path]
1184 name of the user on the source target, root by default
1188 If specified, include the dataset's properties in the stream.
1190 -dest-config-path string
1192 specify a custom config path on the destination target. default is /var/lib/pve-zsync
1195 $PROGNAME sync -dest <string> -source <string> [OPTIONS]\n
1201 the destination target is like [IP:]<Pool>[/Path]
1205 name of the user on the destination target, root by default
1209 max sync speed in kBytes/s, default unlimited
1213 how much snapshots will be kept before get erased, default 1
1217 name of the sync job, if not set it is default.
1218 It is only necessary if scheduler allready contains this source.
1222 If specified, prepend the storage ID to the destination's path(s).
1226 the source can be an <VMID> or [IP:]<ZFSPool>[/Path]
1230 name of the user on the source target, root by default
1234 If specified, print out the sync progress.
1238 If specified, include the dataset's properties in the stream.
1240 -dest-config-path string
1242 specify a custom config path on the destination target. default is /var/lib/pve-zsync
1247 Get a List of all scheduled Sync Jobs
1252 Get the status of all scheduled Sync Jobs
1255 $PROGNAME help <cmd> [OPTIONS]
1257 Get help about specified command.
1265 Verbose output format.
1268 $PROGNAME enable -source <string> [OPTIONS]
1270 enable a syncjob and reset error
1274 name of the sync job, if not set it is default
1278 the source can be an <VMID> or [IP:]<ZFSPool>[/Path]
1281 $PROGNAME disable -source <string> [OPTIONS]
1287 name of the sync job, if not set it is default
1291 the source can be an <VMID> or [IP:]<ZFSPool>[/Path]
1293 printpod
=> 'internal command',
1299 } elsif (!$cmd_help->{$command}) {
1300 print "ERROR: unknown command '$command'";
1305 my $param = parse_argv
(@arg);
1309 die "$cmd_help->{$command}\n" if !$param->{$_};
1313 if ($command eq 'destroy') {
1314 check_params
(qw(source));
1316 check_target
($param->{source
});
1317 destroy_job
($param);
1319 } elsif ($command eq 'sync') {
1320 check_params
(qw(source dest));
1322 check_target
($param->{source
});
1323 check_target
($param->{dest
});
1326 } elsif ($command eq 'create') {
1327 check_params
(qw(source dest));
1329 check_target
($param->{source
});
1330 check_target
($param->{dest
});
1333 } elsif ($command eq 'status') {
1336 } elsif ($command eq 'list') {
1339 } elsif ($command eq 'help') {
1340 my $help_command = $ARGV[1];
1342 if ($help_command && $cmd_help->{$help_command}) {
1343 die "$cmd_help->{$help_command}\n";
1346 if ($param->{verbose
}) {
1347 exec("man $PROGNAME");
1354 } elsif ($command eq 'enable') {
1355 check_params
(qw(source));
1357 check_target
($param->{source
});
1360 } elsif ($command eq 'disable') {
1361 check_params
(qw(source));
1363 check_target
($param->{source
});
1364 disable_job
($param);
1366 } elsif ($command eq 'printpod') {
1373 print("ERROR:\tno command specified\n") if !$help;
1374 print("USAGE:\t$PROGNAME <COMMAND> [ARGS] [OPTIONS]\n");
1375 print("\t$PROGNAME help [<cmd>] [OPTIONS]\n\n");
1376 print("\t$PROGNAME create -dest <string> -source <string> [OPTIONS]\n");
1377 print("\t$PROGNAME destroy -source <string> [OPTIONS]\n");
1378 print("\t$PROGNAME disable -source <string> [OPTIONS]\n");
1379 print("\t$PROGNAME enable -source <string> [OPTIONS]\n");
1380 print("\t$PROGNAME list\n");
1381 print("\t$PROGNAME status\n");
1382 print("\t$PROGNAME sync -dest <string> -source <string> [OPTIONS]\n");
1387 parse_target
($target);
1392 my $synopsis = join("\n", sort values %$cmd_help);
1397 pve-zsync - PVE ZFS Replication Manager
1401 pve-zsync <COMMAND> [ARGS] [OPTIONS]
1407 This Tool helps you to sync your VM or directory which stored on ZFS between 2 servers.
1408 This tool also has the capability to add jobs to cron so the sync will be automatically done.
1409 The default syncing interval is set to 15 min, if you want to change this value you can do this in /etc/cron.d/pve-zsync.
1410 To config cron see man crontab.
1412 =head2 PVE ZFS Storage sync Tool
1414 This Tool can get remote pool on other PVE or send Pool to others ZFS machines
1418 add sync job from local VM to remote ZFS Server
1419 pve-zsync create -source=100 -dest=192.168.1.2:zfspool
1421 =head1 IMPORTANT FILES
1423 Cron jobs and config are stored at /etc/cron.d/pve-zsync
1425 The VM config get copied on the destination machine to /var/lib/pve-zsync/
1427 =head1 COPYRIGHT AND DISCLAIMER
1429 Copyright (C) 2007-2021 Proxmox Server Solutions GmbH
1431 This program is free software: you can redistribute it and/or modify it
1432 under the terms of the GNU Affero General Public License as published
1433 by the Free Software Foundation, either version 3 of the License, or
1434 (at your option) any later version.
1436 This program is distributed in the hope that it will be useful, but
1437 WITHOUT ANY WARRANTY; without even the implied warranty of
1438 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
1439 Affero General Public License for more details.
1441 You should have received a copy of the GNU Affero General Public
1442 License along with this program. If not, see
1443 <http://www.gnu.org/licenses/>.