]>
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
});
330 my $dest = parse_target
($param->{dest
}) if $param->{dest
};
332 $job->{name
} = !$param->{name
} ?
"default" : $param->{name
};
333 $job->{dest
} = $param->{dest
} if $param->{dest
};
334 $job->{method} = "local" if !$dest->{ip
} && !$source->{ip
};
335 $job->{method} = "ssh" if !$job->{method};
336 $job->{limit
} = $param->{limit
};
337 $job->{maxsnap
} = $param->{maxsnap
} if $param->{maxsnap
};
338 $job->{source
} = $param->{source
};
339 $job->{source_user
} = $param->{source_user
};
340 $job->{dest_user
} = $param->{dest_user
};
341 $job->{prepend_storage_id
} = !!$param->{prepend_storage_id
};
342 $job->{properties
} = !!$param->{properties
};
343 $job->{dest_config_path
} = $param->{dest_config_path
} if $param->{dest_config_path
};
351 make_path
$CONFIG_PATH;
352 my $new_fh = IO
::File-
>new("> $STATE");
353 die "Could not create $STATE: $!\n" if !$new_fh;
359 my $text = read_file
($STATE, 1);
360 return decode_json
($text);
366 my $text = eval { read_file
($STATE, 1); };
368 my $out_fh = IO
::File-
>new("> $STATE.new");
369 die "Could not open file ${STATE}.new: $!\n" if !$out_fh;
374 $states = decode_json
($text);
375 $state = $states->{$job->{source
}}->{$job->{name
}};
378 if ($job->{state} ne "del") {
379 $state->{state} = $job->{state};
380 $state->{lsync
} = $job->{lsync
};
381 $state->{instance_id
} = $job->{instance_id
};
382 $state->{vm_type
} = $job->{vm_type
};
384 for (my $i = 0; $job->{"snap$i"} ; $i++) {
385 $state->{"snap$i"} = $job->{"snap$i"};
387 $states->{$job->{source
}}->{$job->{name
}} = $state;
390 delete $states->{$job->{source
}}->{$job->{name
}};
391 delete $states->{$job->{source
}} if !keys %{$states->{$job->{source
}}};
394 $text = encode_json
($states);
398 rename "$STATE.new", $STATE;
410 my $header = "SHELL=/bin/sh\n";
411 $header .= "PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin\n\n";
413 my $current = read_file
($CRONJOBS, 0);
415 foreach my $line (@{$current}) {
417 if ($line =~ m/source $job->{source} .*name $job->{name} /) {
419 next if $job->{state} eq "del";
420 $text .= format_job
($job, $line);
422 if (($line_no < 3) && ($line =~ /^(PATH|SHELL)/ )) {
431 $text = "$header$text";
435 $text .= format_job
($job);
437 my $new_fh = IO
::File-
>new("> ${CRONJOBS}.new");
438 die "Could not open file ${CRONJOBS}.new: $!\n" if !$new_fh;
440 die "can't write to $CRONJOBS.new\n" if !print($new_fh $text);
443 die "can't move $CRONJOBS.new: $!\n" if !rename "${CRONJOBS}.new", $CRONJOBS;
447 my ($job, $line) = @_;
450 if ($job->{state} eq "stopped") {
454 $line =~ /^#*\s*((?:\S+\s+){4}\S+)\s+root/;
457 $text .= "*/$INTERVAL * * * *";
460 $text .= " $PROGNAME sync --source $job->{source} --dest $job->{dest}";
461 $text .= " --name $job->{name} --maxsnap $job->{maxsnap}";
462 $text .= " --limit $job->{limit}" if $job->{limit
};
463 $text .= " --method $job->{method}";
464 $text .= " --verbose" if $job->{verbose
};
465 $text .= " --source-user $job->{source_user}";
466 $text .= " --dest-user $job->{dest_user}";
467 $text .= " --prepend-storage-id" if $job->{prepend_storage_id
};
468 $text .= " --properties" if $job->{properties
};
469 $text .= " --dest-config-path $job->{dest_config_path}" if $job->{dest_config_path
};
477 my $cfg = read_cron
();
479 my $list = sprintf("%-25s%-25s%-10s%-20s%-6s%-5s\n" , "SOURCE", "NAME", "STATE", "LAST SYNC", "TYPE", "CON");
481 my $states = read_state
();
482 foreach my $source (sort keys%{$cfg}) {
483 foreach my $name (sort keys%{$cfg->{$source}}) {
484 $list .= sprintf("%-25s", cut_target_width
($source, 25));
485 $list .= sprintf("%-25s", cut_target_width
($name, 25));
486 $list .= sprintf("%-10s", $states->{$source}->{$name}->{state});
487 $list .= sprintf("%-20s", $states->{$source}->{$name}->{lsync
});
488 $list .= sprintf("%-6s", defined($states->{$source}->{$name}->{vm_type
}) ?
$states->{$source}->{$name}->{vm_type
} : "undef");
489 $list .= sprintf("%-5s\n", $cfg->{$source}->{$name}->{method});
497 my ($target, $user) = @_;
499 return undef if !defined($target->{vmid
});
501 my $conf_fn = "$target->{vmid}.conf";
504 my @cmd = ('ssh', "$user\@$target->{ip}", '--', '/bin/ls');
505 return "qemu" if eval { run_cmd
([@cmd, "$QEMU_CONF/$conf_fn"]) };
506 return "lxc" if eval { run_cmd
([@cmd, "$LXC_CONF/$conf_fn"]) };
508 return "qemu" if -f
"$QEMU_CONF/$conf_fn";
509 return "lxc" if -f
"$LXC_CONF/$conf_fn";
518 locked
("$CONFIG_PATH/cron_and_state.lock", sub {
519 my $cfg = read_cron
();
521 my $job = param_to_job
($param);
523 $job->{state} = "ok";
526 my $source = parse_target
($param->{source
});
527 my $dest = parse_target
($param->{dest
});
529 if (my $ip = $dest->{ip
}) {
530 run_cmd
(['ssh-copy-id', '-i', '/root/.ssh/id_rsa.pub', "$param->{dest_user}\@$ip"]);
533 if (my $ip = $source->{ip
}) {
534 run_cmd
(['ssh-copy-id', '-i', '/root/.ssh/id_rsa.pub', "$param->{source_user}\@$ip"]);
537 die "Pool $dest->{all} does not exist\n"
538 if !check_dataset_exists
($dest->{all
}, $dest->{ip
}, $param->{dest_user
});
540 if (!defined($source->{vmid
})) {
541 die "Pool $source->{all} does not exist\n"
542 if !check_dataset_exists
($source->{all
}, $source->{ip
}, $param->{source_user
});
545 my $vm_type = vm_exists
($source, $param->{source_user
});
546 $job->{vm_type
} = $vm_type;
547 $source->{vm_type
} = $vm_type;
549 die "VM $source->{vmid} doesn't exist\n" if $source->{vmid
} && !$vm_type;
551 die "Config already exists\n" if $cfg->{$job->{source
}}->{$job->{name
}};
553 #check if vm has zfs disks if not die;
554 get_disks
($source, $param->{source_user
}) if $source->{vmid
};
558 }); #cron and state lock
560 return if $param->{skip
};
562 eval { sync
($param) };
572 my $cfg = read_cron
();
574 if (!$cfg->{$param->{source
}}->{$param->{name
}}) {
575 die "Job with source $param->{source} and name $param->{name} does not exist\n" ;
577 my $job = $cfg->{$param->{source
}}->{$param->{name
}};
578 $job->{name
} = $param->{name
};
579 $job->{source
} = $param->{source
};
580 $job = add_state_to_job
($job);
588 locked
("$CONFIG_PATH/cron_and_state.lock", sub {
589 my $job = get_job
($param);
590 $job->{state} = "del";
597 sub get_instance_id
{
600 my $stat = read_file
("/proc/$pid/stat", 1)
601 or die "unable to read process stats\n";
602 my $boot_id = read_file
("/proc/sys/kernel/random/boot_id", 1)
603 or die "unable to read boot ID\n";
605 my $stats = [ split(/\s+/, $stat) ];
606 my $starttime = $stats->[21];
609 return "${pid}:${starttime}:${boot_id}";
612 sub instance_exists
{
613 my ($instance_id) = @_;
615 if (defined($instance_id) && $instance_id =~ m/^([1-9][0-9]*):/) {
617 my $actual_id = eval { get_instance_id
($pid); };
618 return defined($actual_id) && $actual_id eq $instance_id;
629 locked
("$CONFIG_PATH/cron_and_state.lock", sub {
630 eval { $job = get_job
($param) };
633 my $state = $job->{state} // 'ok';
634 $state = 'ok' if !instance_exists
($job->{instance_id
});
636 if ($state eq "syncing" || $state eq "waiting") {
637 die "Job --source $param->{source} --name $param->{name} is already scheduled to sync\n";
640 $job->{state} = "waiting";
641 $job->{instance_id
} = $INSTANCE_ID;
647 locked
("$CONFIG_PATH/sync.lock", sub {
649 my $date = get_date
();
655 locked
("$CONFIG_PATH/cron_and_state.lock", sub {
656 #job might've changed while we waited for the sync lock, but we can be sure it's not syncing
657 eval { $job = get_job
($param); };
659 if ($job && defined($job->{state}) && $job->{state} eq "stopped") {
660 die "Job --source $param->{source} --name $param->{name} has been disabled\n";
663 $dest = parse_target
($param->{dest
});
664 $source = parse_target
($param->{source
});
666 $vm_type = vm_exists
($source, $param->{source_user
});
667 $source->{vm_type
} = $vm_type;
670 $job->{state} = "syncing";
671 $job->{vm_type
} = $vm_type if !$job->{vm_type
};
674 }); #cron and state lock
676 my $sync_path = sub {
677 my ($source, $dest, $job, $param, $date) = @_;
679 ($dest->{old_snap
}, $dest->{last_snap
}) = snapshot_get
($source, $dest, $param->{maxsnap
}, $param->{name
}, $param->{dest_user
});
681 prepare_prepended_target
($source, $dest, $param->{dest_user
}) if defined($dest->{prepend
});
683 snapshot_add
($source, $dest, $param->{name
}, $date, $param->{source_user
}, $param->{dest_user
});
685 send_image
($source, $dest, $param);
687 snapshot_destroy
($source, $dest, $param->{method}, $dest->{old_snap
}, $param->{source_user
}, $param->{dest_user
}) if ($source->{destroy
} && $dest->{old_snap
});
692 if ($source->{vmid
}) {
693 die "VM $source->{vmid} doesn't exist\n" if !$vm_type;
694 die "source-user has to be root for syncing VMs\n" if ($param->{source_user
} ne "root");
695 my $disks = get_disks
($source, $param->{source_user
});
697 foreach my $disk (sort keys %{$disks}) {
698 $source->{all
} = $disks->{$disk}->{all
};
699 $source->{pool
} = $disks->{$disk}->{pool
};
700 $source->{path
} = $disks->{$disk}->{path
} if $disks->{$disk}->{path
};
701 $source->{last_part
} = $disks->{$disk}->{last_part
};
703 $dest->{prepend
} = $disks->{$disk}->{storage_id
}
704 if $param->{prepend_storage_id
};
706 &$sync_path($source, $dest, $job, $param, $date);
708 if ($param->{method} eq "ssh" && ($source->{ip
} || $dest->{ip
})) {
709 send_config
($source, $dest,'ssh', $param->{source_user
}, $param->{dest_user
}, $param->{dest_config_path
});
711 send_config
($source, $dest,'local', $param->{source_user
}, $param->{dest_user
}, $param->{dest_config_path
});
714 &$sync_path($source, $dest, $job, $param, $date);
718 locked
("$CONFIG_PATH/cron_and_state.lock", sub {
719 eval { $job = get_job
($param); };
721 $job->{state} = "error";
722 delete $job->{instance_id
};
726 print "Job --source $param->{source} --name $param->{name} got an ERROR!!!\nERROR Message:\n";
730 locked
("$CONFIG_PATH/cron_and_state.lock", sub {
731 eval { $job = get_job
($param); };
733 if (defined($job->{state}) && $job->{state} eq "stopped") {
734 $job->{state} = "stopped";
736 $job->{state} = "ok";
738 $job->{lsync
} = $date;
739 delete $job->{instance_id
};
747 my ($source, $dest, $max_snap, $name, $dest_user) = @_;
750 push @$cmd, 'ssh', "$dest_user\@$dest->{ip}", '--', if $dest->{ip
};
751 push @$cmd, 'zfs', 'list', '-r', '-t', 'snapshot', '-Ho', 'name', '-S', 'creation';
753 my $path = target_dataset
($source, $dest);
757 eval {$raw = run_cmd
($cmd)};
758 if (my $erro =$@) { #this means the volume doesn't exist on dest yet
764 my $last_snap = undef;
767 while ($raw && $raw =~ s/^(.*?)(\n|$)//) {
769 if ($line =~ m/@(.*)$/) {
770 $last_snap = $1 if (!$last_snap);
772 if ($line =~ m/(rep_\Q${name}\E_\d{4}-\d{2}-\d{2}_\d{2}:\d{2}:\d{2})$/) {
775 if ($index == $max_snap) {
776 $source->{destroy
} = 1;
782 return ($old_snap, $last_snap) if $last_snap;
788 my ($source, $dest, $name, $date, $source_user, $dest_user) = @_;
790 my $snap_name = "rep_$name\_".$date;
792 $source->{new_snap
} = $snap_name;
794 my $path = "$source->{all}\@$snap_name";
797 push @$cmd, 'ssh', "$source_user\@$source->{ip}", '--', if $source->{ip
};
798 push @$cmd, 'zfs', 'snapshot', $path;
804 snapshot_destroy
($source, $dest, 'ssh', $snap_name, $source_user, $dest_user);
810 my ($target, $user) = @_;
813 push @$cmd, 'ssh', "$user\@$target->{ip}", '--', if $target->{ip
};
815 if ($target->{vm_type
} eq 'qemu') {
816 push @$cmd, 'qm', 'config', $target->{vmid
};
817 } elsif ($target->{vm_type
} eq 'lxc') {
818 push @$cmd, 'pct', 'config', $target->{vmid
};
820 die "VM Type unknown\n";
823 my $res = run_cmd
($cmd);
825 my $disks = parse_disks
($res, $target->{ip
}, $target->{vm_type
}, $user);
832 print "Start CMD\n" if $DEBUG;
833 print Dumper
$cmd if $DEBUG;
834 if (ref($cmd) eq 'ARRAY') {
835 $cmd = join(' ', map { ref($_) ?
$$_ : shell_quote
($_) } @$cmd);
837 my $output = `$cmd 2>&1`;
839 die "COMMAND:\n\t$cmd\nGET ERROR:\n\t$output" if 0 != $?;
842 print Dumper
$output if $DEBUG;
843 print "END CMD\n" if $DEBUG;
848 my ($text, $ip, $vm_type, $user) = @_;
853 while ($text && $text =~ s/^(.*?)(\n|$)//) {
856 next if $line =~ /media=cdrom/;
857 next if $line !~ m/$DISK_KEY_RE/;
859 #QEMU if backup is not set include in sync
860 next if $vm_type eq 'qemu' && ($line =~ m/backup=(?i:0|no|off|false)/);
862 #LXC if backup is not set do no in sync
863 next if $vm_type eq 'lxc' && ($line =~ m/^mp\d:/) && ($line !~ m/backup=(?i:1|yes|on|true)/);
867 if($line =~ m/$DISK_KEY_RE(.*)$/) {
868 my @parameter = split(/,/,$1);
870 foreach my $opt (@parameter) {
871 if ($opt =~ m/^(?:file=|volume=)?([^:]+):([A-Za-z0-9\-]+)$/){
878 if (!defined($disk) || !defined($stor)) {
879 print "Disk: \"$line\" has no valid zfs dataset format
and will be skipped
\n";
884 push @$cmd, 'ssh', "$user\@$ip", '--' if $ip;
885 push @$cmd, 'pvesm', 'path', "$stor:$disk";
886 my $path = run_cmd($cmd);
888 die "Get
no path from pvesm path
$stor:$disk\n" if !$path;
890 $disks->{$num}->{storage_id} = $stor;
892 if ($vm_type eq 'qemu' && $path =~ m/^\/dev\/zvol\/(\w+.*)(\/$disk)$/) {
894 my @array = split('/', $1);
895 $disks->{$num}->{pool} = shift(@array);
896 $disks->{$num}->{all} = $disks->{$num}->{pool};
898 $disks->{$num}->{path} = join('/', @array);
899 $disks->{$num}->{all} .= "\
/$disks->{$num}->{path
}";
901 $disks->{$num}->{last_part} = $disk;
902 $disks->{$num}->{all} .= "\
/$disk";
905 } elsif ($vm_type eq 'lxc' && $path =~ m/^\/(\w+.+)(\/(\w+.*))*(\/$disk)$/) {
907 $disks->{$num}->{pool} = $1;
908 $disks->{$num}->{all} = $disks->{$num}->{pool};
911 $disks->{$num}->{path} = $3;
912 $disks->{$num}->{all} .= "\
/$disks->{$num}->{path
}";
915 $disks->{$num}->{last_part} = $disk;
916 $disks->{$num}->{all} .= "\
/$disk";
921 die "ERROR
: in path
\n";
925 die "Vm include
no disk on zfs
.\n" if !$disks->{0};
929 # how the corresponding dataset is named on the target
931 my ($source, $dest) = @_;
933 my $target = "$dest->{all
}";
934 $target .= "/$dest->{prepend
}" if defined($dest->{prepend});
935 $target .= "/$source->{last_part
}" if $source->{last_part};
941 # create the parent dataset for the actual target
942 sub prepare_prepended_target {
943 my ($source, $dest, $dest_user) = @_;
945 die "internal error
- not a prepended target
\n" if !defined($dest->{prepend});
947 # The parent dataset shouldn't be the actual target.
948 die "internal error
- no last_part
for source
\n" if !$source->{last_part};
950 my $target = "$dest->{all
}/$dest->{prepend
}";
953 return if check_dataset_exists($target, $dest->{ip}, $dest_user);
955 create_file_system($target, $dest->{ip}, $dest_user);
958 sub snapshot_destroy {
959 my ($source, $dest, $method, $snap, $source_user, $dest_user) = @_;
961 my @zfscmd = ('zfs', 'destroy');
962 my $snapshot = "$source->{all
}\
@$snap";
965 if($source->{ip} && $method eq 'ssh'){
966 run_cmd(['ssh', "$source_user\@$source->{ip
}", '--', @zfscmd, $snapshot]);
968 run_cmd([@zfscmd, $snapshot]);
975 my @ssh = $dest->{ip} ? ('ssh', "$dest_user\@$dest->{ip
}", '--') : ();
977 my $path = target_dataset($source, $dest);
980 run_cmd([@ssh, @zfscmd, "$path\@$snap"]);
988 # check if snapshot for incremental sync exist on source side
990 my ($source , $dest, $method, $source_user) = @_;
993 push @$cmd, 'ssh', "$source_user\@$source->{ip
}", '--' if $source->{ip};
994 push @$cmd, 'zfs', 'list', '-rt', 'snapshot', '-Ho', 'name';
996 my $path = $source->{all};
997 $path .= "\
@$dest->{last_snap
}";
1001 eval {run_cmd($cmd)};
1010 my ($source, $dest, $param) = @_;
1014 push @$cmd, 'ssh', '-o', 'BatchMode=yes', "$param->{source_user
}\
@$source->{ip
}", '--' if $source->{ip};
1015 push @$cmd, 'zfs', 'send';
1016 push @$cmd, '-p', if $param->{properties};
1017 push @$cmd, '-v' if $param->{verbose};
1019 if($dest->{last_snap} && snapshot_exist($source , $dest, $param->{method}, $param->{source_user})) {
1020 push @$cmd, '-i', "$source->{all
}\
@$dest->{last_snap
}";
1022 push @$cmd, '--', "$source->{all
}\
@$source->{new_snap
}";
1024 if ($param->{limit}){
1025 my $bwl = $param->{limit}*1024;
1026 push @$cmd, \'|', 'cstream', '-t', $bwl;
1028 my $target = target_dataset($source, $dest);
1031 push @$cmd, 'ssh', '-o', 'BatchMode=yes', "$param->{dest_user
}\
@$dest->{ip
}", '--' if $dest->{ip};
1032 push @$cmd, 'zfs', 'recv', '-F', '--';
1033 push @$cmd, "$target";
1039 if (my $erro = $@) {
1040 snapshot_destroy($source, undef, $param->{method}, $source->{new_snap}, $param->{source_user}, $param->{dest_user});
1047 my ($source, $dest, $method, $source_user, $dest_user, $dest_config_path) = @_;
1049 my $source_target = $source->{vm_type} eq 'qemu' ? "$QEMU_CONF/$source->{vmid
}.conf
": "$LXC_CONF/$source->{vmid
}.conf
";
1050 my $dest_target_new ="$source->{vmid
}.conf
.$source->{vm_type
}.$source->{new_snap
}";
1052 my $config_dir = $dest_config_path // $CONFIG_PATH;
1053 $config_dir .= "/$dest->{last_part
}" if $dest->{last_part};
1055 $dest_target_new = $config_dir.'/'.$dest_target_new;
1057 if ($method eq 'ssh'){
1058 if ($dest->{ip} && $source->{ip}) {
1059 run_cmd(['ssh', "$dest_user\@$dest->{ip
}", '--', 'mkdir', '-p', '--', $config_dir]);
1060 run_cmd(['scp', '--', "$source_user\@[$source->{ip
}]:$source_target", "$dest_user\@[$dest->{ip
}]:$dest_target_new"]);
1061 } elsif ($dest->{ip}) {
1062 run_cmd(['ssh', "$dest_user\@$dest->{ip
}", '--', 'mkdir', '-p', '--', $config_dir]);
1063 run_cmd(['scp', '--', $source_target, "$dest_user\@[$dest->{ip
}]:$dest_target_new"]);
1064 } elsif ($source->{ip}) {
1065 run_cmd(['mkdir', '-p', '--', $config_dir]);
1066 run_cmd(['scp', '--', "$source_user\@[$source->{ip
}]:$source_target", $dest_target_new]);
1069 if ($source->{destroy}){
1070 my $dest_target_old ="${config_dir
}/$source->{vmid
}.conf
.$source->{vm_type
}.$dest->{old_snap
}";
1072 run_cmd(['ssh', "$dest_user\@$dest->{ip
}", '--', 'rm', '-f', '--', $dest_target_old]);
1074 run_cmd(['rm', '-f', '--', $dest_target_old]);
1077 } elsif ($method eq 'local') {
1078 run_cmd(['mkdir', '-p', '--', $config_dir]);
1079 run_cmd(['cp', $source_target, $dest_target_new]);
1084 my ($sec, $min, $hour, $mday, $mon, $year, $wday, $yday, $isdst) = localtime(time);
1085 my $datestamp = sprintf ("%04d-%02d-%02d_%02d:%02d:%02d", $year+1900, $mon+1, $mday, $hour, $min, $sec);
1091 my $cfg = read_cron();
1093 my $status_list = sprintf("%-25s
%-25s
%-10s
\n", "SOURCE
", "NAME
", "STATUS
");
1095 my $states = read_state();
1097 foreach my $source (sort keys%{$cfg}) {
1098 foreach my $sync_name (sort keys%{$cfg->{$source}}) {
1099 $status_list .= sprintf("%-25s
", cut_target_width($source, 25));
1100 $status_list .= sprintf("%-25s
", cut_target_width($sync_name, 25));
1101 $status_list .= "$states->{$source}->{$sync_name}->{state}\n";
1105 return $status_list;
1111 locked("$CONFIG_PATH/cron_and_state.lock", sub {
1112 my $job = get_job($param);
1113 $job->{state} = "ok
";
1122 locked("$CONFIG_PATH/cron_and_state.lock", sub {
1123 my $job = get_job($param);
1124 $job->{state} = "stopped
";
1132 $PROGNAME destroy -source <string> [OPTIONS]
1134 remove a sync Job from the scheduler
1138 name of the sync job, if not set it is default
1142 the source can be an <VMID> or [IP:]<ZFSPool>[/Path]
1145 $PROGNAME create -dest <string> -source <string> [OPTIONS]
1151 the destination target is like [IP]:<Pool>[/Path]
1155 name of the user on the destination target, root by default
1159 max sync speed in kBytes/s, default unlimited
1163 how much snapshots will be kept before get erased, default 1
1167 name of the sync job, if not set it is default
1171 If specified, prepend the storage ID to the destination's path(s).
1175 If specified, skip the first sync.
1179 the source can be an <VMID> or [IP:]<ZFSPool>[/Path]
1183 name of the user on the source target, root by default
1187 If specified, include the dataset's properties in the stream.
1189 -dest-config-path string
1191 specify a custom config path on the destination target. default is /var/lib/pve-zsync
1194 $PROGNAME sync -dest <string> -source <string> [OPTIONS]\n
1200 the destination target is like [IP:]<Pool>[/Path]
1204 name of the user on the destination target, root by default
1208 max sync speed in kBytes/s, default unlimited
1212 how much snapshots will be kept before get erased, default 1
1216 name of the sync job, if not set it is default.
1217 It is only necessary if scheduler allready contains this source.
1221 If specified, prepend the storage ID to the destination's path(s).
1225 the source can be an <VMID> or [IP:]<ZFSPool>[/Path]
1229 name of the user on the source target, root by default
1233 If specified, print out the sync progress.
1237 If specified, include the dataset's properties in the stream.
1239 -dest-config-path string
1241 specify a custom config path on the destination target. default is /var/lib/pve-zsync
1246 Get a List of all scheduled Sync Jobs
1251 Get the status of all scheduled Sync Jobs
1254 $PROGNAME help <cmd> [OPTIONS]
1256 Get help about specified command.
1264 Verbose output format.
1267 $PROGNAME enable -source <string> [OPTIONS]
1269 enable a syncjob and reset error
1273 name of the sync job, if not set it is default
1277 the source can be an <VMID> or [IP:]<ZFSPool>[/Path]
1280 $PROGNAME disable -source <string> [OPTIONS]
1286 name of the sync job, if not set it is default
1290 the source can be an <VMID> or [IP:]<ZFSPool>[/Path]
1292 printpod
=> 'internal command',
1298 } elsif (!$cmd_help->{$command}) {
1299 print "ERROR: unknown command '$command'";
1304 my $param = parse_argv
(@arg);
1308 die "$cmd_help->{$command}\n" if !$param->{$_};
1312 if ($command eq 'destroy') {
1313 check_params
(qw(source));
1315 check_target
($param->{source
});
1316 destroy_job
($param);
1318 } elsif ($command eq 'sync') {
1319 check_params
(qw(source dest));
1321 check_target
($param->{source
});
1322 check_target
($param->{dest
});
1325 } elsif ($command eq 'create') {
1326 check_params
(qw(source dest));
1328 check_target
($param->{source
});
1329 check_target
($param->{dest
});
1332 } elsif ($command eq 'status') {
1335 } elsif ($command eq 'list') {
1338 } elsif ($command eq 'help') {
1339 my $help_command = $ARGV[1];
1341 if ($help_command && $cmd_help->{$help_command}) {
1342 die "$cmd_help->{$help_command}\n";
1345 if ($param->{verbose
}) {
1346 exec("man $PROGNAME");
1353 } elsif ($command eq 'enable') {
1354 check_params
(qw(source));
1356 check_target
($param->{source
});
1359 } elsif ($command eq 'disable') {
1360 check_params
(qw(source));
1362 check_target
($param->{source
});
1363 disable_job
($param);
1365 } elsif ($command eq 'printpod') {
1372 print("ERROR:\tno command specified\n") if !$help;
1373 print("USAGE:\t$PROGNAME <COMMAND> [ARGS] [OPTIONS]\n");
1374 print("\t$PROGNAME help [<cmd>] [OPTIONS]\n\n");
1375 print("\t$PROGNAME create -dest <string> -source <string> [OPTIONS]\n");
1376 print("\t$PROGNAME destroy -source <string> [OPTIONS]\n");
1377 print("\t$PROGNAME disable -source <string> [OPTIONS]\n");
1378 print("\t$PROGNAME enable -source <string> [OPTIONS]\n");
1379 print("\t$PROGNAME list\n");
1380 print("\t$PROGNAME status\n");
1381 print("\t$PROGNAME sync -dest <string> -source <string> [OPTIONS]\n");
1386 parse_target
($target);
1391 my $synopsis = join("\n", sort values %$cmd_help);
1396 pve-zsync - PVE ZFS Replication Manager
1400 pve-zsync <COMMAND> [ARGS] [OPTIONS]
1406 This Tool helps you to sync your VM or directory which stored on ZFS between 2 servers.
1407 This tool also has the capability to add jobs to cron so the sync will be automatically done.
1408 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.
1409 To config cron see man crontab.
1411 =head2 PVE ZFS Storage sync Tool
1413 This Tool can get remote pool on other PVE or send Pool to others ZFS machines
1417 add sync job from local VM to remote ZFS Server
1418 pve-zsync create -source=100 -dest=192.168.1.2:zfspool
1420 =head1 IMPORTANT FILES
1422 Cron jobs and config are stored at /etc/cron.d/pve-zsync
1424 The VM config get copied on the destination machine to /var/lib/pve-zsync/
1426 =head1 COPYRIGHT AND DISCLAIMER
1428 Copyright (C) 2007-2021 Proxmox Server Solutions GmbH
1430 This program is free software: you can redistribute it and/or modify it
1431 under the terms of the GNU Affero General Public License as published
1432 by the Free Software Foundation, either version 3 of the License, or
1433 (at your option) any later version.
1435 This program is distributed in the hope that it will be useful, but
1436 WITHOUT ANY WARRANTY; without even the implied warranty of
1437 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
1438 Affero General Public License for more details.
1440 You should have received a copy of the GNU Affero General Public
1441 License along with this program. If not, see
1442 <http://www.gnu.org/licenses/>.