]>
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
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 How much snapshots will be kept before get erased, default 1
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 Configure how many snapshots will be kept before get erased, default 1
1200 The name of the sync job, if not set it is 'default'.
1201 It is only necessary if scheduler allready contains this source.
1203 --prepend-storage-id
1204 If specified, prepend the storage ID to the destination's path(s).
1207 The source can either be an <VMID> or [IP:]<ZFSPool>[/Path]
1209 --source-user string
1210 The name of the user on the source target, root by default
1213 If specified, print out the sync progress.
1216 If specified, include the dataset's properties in the stream.
1218 --dest-config-path string
1219 Specifies a custom config path on the destination target.
1220 The default is /var/lib/pve-zsync
1225 Get a List of all scheduled Sync Jobs
1230 Get the status of all scheduled Sync Jobs
1233 $PROGNAME help <cmd> [OPTIONS]
1235 Get help about specified command.
1238 Command name to get help about.
1241 Verbose output format.
1244 $PROGNAME enable --source <string> [OPTIONS]
1246 Enable a sync-job and reset all job-errors, if any.
1249 name of the sync job, if not set it is default
1252 the source can be an <VMID> or [IP:]<ZFSPool>[/Path]
1255 $PROGNAME disable --source <string> [OPTIONS]
1257 Disables (pauses) a sync-job
1260 name of the sync-job, if not set it is default
1263 the source can be an <VMID> or [IP:]<ZFSPool>[/Path]
1265 printpod
=> "$PROGNAME printpod\n\n\tinternal command",
1271 } elsif (!$cmd_help->{$command}) {
1272 print "ERROR: unknown command '$command'";
1277 my $param = parse_argv
(@arg);
1281 die "$cmd_help->{$command}\n" if !$param->{$_};
1285 if ($command eq 'destroy') {
1286 check_params
(qw(source));
1288 check_target
($param->{source
});
1289 destroy_job
($param);
1291 } elsif ($command eq 'sync') {
1292 check_params
(qw(source dest));
1294 check_target
($param->{source
});
1295 check_target
($param->{dest
});
1298 } elsif ($command eq 'create') {
1299 check_params
(qw(source dest));
1301 check_target
($param->{source
});
1302 check_target
($param->{dest
});
1305 } elsif ($command eq 'status') {
1308 } elsif ($command eq 'list') {
1311 } elsif ($command eq 'help') {
1312 my $help_command = $ARGV[1];
1314 if ($help_command && $cmd_help->{$help_command}) {
1315 die "$cmd_help->{$help_command}\n";
1318 if ($param->{verbose
}) {
1319 exec("man $PROGNAME");
1326 } elsif ($command eq 'enable') {
1327 check_params
(qw(source));
1329 check_target
($param->{source
});
1332 } elsif ($command eq 'disable') {
1333 check_params
(qw(source));
1335 check_target
($param->{source
});
1336 disable_job
($param);
1338 } elsif ($command eq 'printpod') {
1345 print("ERROR:\tno command specified\n") if !$help;
1346 print("USAGE:\t$PROGNAME <COMMAND> [ARGS] [OPTIONS]\n");
1347 print("\t$PROGNAME help [<cmd>] [OPTIONS]\n\n");
1348 print("\t$PROGNAME create --dest <string> --source <string> [OPTIONS]\n");
1349 print("\t$PROGNAME destroy --source <string> [OPTIONS]\n");
1350 print("\t$PROGNAME disable --source <string> [OPTIONS]\n");
1351 print("\t$PROGNAME enable --source <string> [OPTIONS]\n");
1352 print("\t$PROGNAME list\n");
1353 print("\t$PROGNAME status\n");
1354 print("\t$PROGNAME sync --dest <string> --source <string> [OPTIONS]\n");
1359 parse_target
($target);
1364 my $synopsis = join("\n", sort values %$cmd_help);
1369 pve-zsync - PVE ZFS Replication Manager
1373 pve-zsync <COMMAND> [ARGS] [OPTIONS]
1379 This Tool helps you to sync your VM or directory which stored on ZFS between 2 servers.
1380 This tool also has the capability to add jobs to cron so the sync will be automatically done.
1381 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.
1382 To config cron see man crontab.
1384 =head2 PVE ZFS Storage sync Tool
1386 This Tool can get remote pool on other PVE or send Pool to others ZFS machines
1390 add sync job from local VM to remote ZFS Server
1391 pve-zsync create -source=100 -dest=192.168.1.2:zfspool
1393 =head1 IMPORTANT FILES
1395 Cron jobs and config are stored at /etc/cron.d/pve-zsync
1397 The VM config get copied on the destination machine to /var/lib/pve-zsync/
1399 =head1 COPYRIGHT AND DISCLAIMER
1401 Copyright (C) 2007-2021 Proxmox Server Solutions GmbH
1403 This program is free software: you can redistribute it and/or modify it
1404 under the terms of the GNU Affero General Public License as published
1405 by the Free Software Foundation, either version 3 of the License, or
1406 (at your option) any later version.
1408 This program is distributed in the hope that it will be useful, but
1409 WITHOUT ANY WARRANTY; without even the implied warranty of
1410 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
1411 Affero General Public License for more details.
1413 You should have received a copy of the GNU Affero General Public
1414 License along with this program. If not, see
1415 <http://www.gnu.org/licenses/>.