]>
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';
14 my $PROGNAME = "pve-zsync";
15 my $CONFIG_PATH = "/var/lib/${PROGNAME}";
16 my $STATE = "${CONFIG_PATH}/sync_state";
17 my $CRONJOBS = "/etc/cron.d/$PROGNAME";
18 my $PATH = "/usr/sbin";
19 my $PVE_DIR = "/etc/pve/local";
20 my $QEMU_CONF = "${PVE_DIR}/qemu-server";
21 my $LXC_CONF = "${PVE_DIR}/lxc";
22 my $PROG_PATH = "$PATH/${PROGNAME}";
27 $DEBUG = 0; # change default here. not above on declaration!
28 $DEBUG ||= $ENV{ZSYNC_DEBUG
};
31 Data
::Dumper-
>import();
35 my $IPV4OCTET = "(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])";
36 my $IPV4RE = "(?:(?:$IPV4OCTET\\.){3}$IPV4OCTET)";
37 my $IPV6H16 = "(?:[0-9a-fA-F]{1,4})";
38 my $IPV6LS32 = "(?:(?:$IPV4RE|$IPV6H16:$IPV6H16))";
41 "(?:(?:" . "(?:$IPV6H16:){6})$IPV6LS32)|" .
42 "(?:(?:" . "::(?:$IPV6H16:){5})$IPV6LS32)|" .
43 "(?:(?:(?:" . "$IPV6H16)?::(?:$IPV6H16:){4})$IPV6LS32)|" .
44 "(?:(?:(?:(?:$IPV6H16:){0,1}$IPV6H16)?::(?:$IPV6H16:){3})$IPV6LS32)|" .
45 "(?:(?:(?:(?:$IPV6H16:){0,2}$IPV6H16)?::(?:$IPV6H16:){2})$IPV6LS32)|" .
46 "(?:(?:(?:(?:$IPV6H16:){0,3}$IPV6H16)?::(?:$IPV6H16:){1})$IPV6LS32)|" .
47 "(?:(?:(?:(?:$IPV6H16:){0,4}$IPV6H16)?::" . ")$IPV6LS32)|" .
48 "(?:(?:(?:(?:$IPV6H16:){0,5}$IPV6H16)?::" . ")$IPV6H16)|" .
49 "(?:(?:(?:(?:$IPV6H16:){0,6}$IPV6H16)?::" . ")))";
51 my $HOSTv4RE0 = "(?:[\\w\\.\\-_]+|$IPV4RE)"; # hostname or ipv4 address
52 my $HOSTv4RE1 = "(?:$HOSTv4RE0|\\[$HOSTv4RE0\\])"; # these may be in brackets, too
53 my $HOSTRE = "(?:$HOSTv4RE1|\\[$IPV6RE\\])"; # ipv6 must always be in brackets
54 # targets are either a VMID, or a 'host:zpool/path' with 'host:' being optional
55 my $TARGETRE = qr!^(?:($HOSTRE):)?(\d+|(?:[\w\-_]+)(/.+)?)$!;
57 my $DISK_KEY_RE = qr/^(?:(?:(?:virtio|ide|scsi|sata|efidisk|tpmstate|mp)\d+)|rootfs): /;
59 my $INSTANCE_ID = get_instance_id
($$);
61 my $command = $ARGV[0];
63 if (defined($command) && $command ne 'help' && $command ne 'printpod') {
64 check_bin
('cstream');
70 $SIG{TERM
} = $SIG{QUIT
} = $SIG{PIPE
} = $SIG{HUP
} = $SIG{KILL
} = $SIG{INT
} = sub {
71 die "Signaled, aborting sync: $!\n";
77 foreach my $p (split (/:/, $ENV{PATH
})) {
84 die "unable to find command '$bin'\n";
88 my ($filename, $one_line_only) = @_;
90 my $fh = IO
::File-
>new($filename, "r")
91 or die "Could not open file ${filename}: $!\n";
93 my $text = $one_line_only ?
<$fh> : [ <$fh> ];
100 sub cut_target_width
{
101 my ($path, $maxlen) = @_;
104 return $path if length($path) <= $maxlen;
106 return '..'.substr($path, -$maxlen+2) if $path !~ m
@/@;
108 $path =~ s
@/([^/]+/?
)$@@;
111 if (length($tail)+3 == $maxlen) {
113 } elsif (length($tail)+2 >= $maxlen) {
114 return '..'.substr($tail, -$maxlen+2)
117 $path =~ s
@(/[^/]+)(?
:/|$)@@;
119 my $both = length($head) + length($tail);
120 my $remaining = $maxlen-$both-4; # -4 for "/../"
122 if ($remaining < 0) {
123 return substr($head, 0, $maxlen - length($tail) - 3) . "../$tail"; # -3 for "../"
126 substr($path, ($remaining/2), (length($path)-$remaining), '..');
127 return "$head/" . $path . "/$tail";
131 my ($lock_fn, $code) = @_;
133 my $lock_fh = IO
::File-
>new("> $lock_fn");
135 flock($lock_fh, LOCK_EX
) || die "Couldn't acquire lock - $!\n";
136 my $res = eval { $code->() };
139 flock($lock_fh, LOCK_UN
) || warn "Error unlocking - $!\n";
147 my ($source, $name, $status) = @_;
149 if ($status->{$source->{all
}}->{$name}->{status
}) {
156 sub check_dataset_exists
{
157 my ($dataset, $ip, $user) = @_;
162 push @$cmd, 'ssh', "$user\@$ip", '--';
164 push @$cmd, 'zfs', 'list', '-H', '--', $dataset;
175 sub create_file_system
{
176 my ($file_system, $ip, $user) = @_;
181 push @$cmd, 'ssh', "$user\@$ip", '--';
183 push @$cmd, 'zfs', 'create', $file_system;
191 my $errstr = "$text : is not a valid input! Use [IP:]<VMID> or [IP:]<ZFSPool>[/Path]";
194 if ($text !~ $TARGETRE) {
198 $target->{ip
} = $1 if $1;
199 my @parts = split('/', $2);
201 $target->{ip
} =~ s/^\[(.*)\]$/$1/ if $target->{ip
};
203 my $pool = $target->{pool
} = shift(@parts);
204 die "$errstr\n" if !$pool;
206 if ($pool =~ m/^\d+$/) {
207 $target->{vmid
} = $pool;
208 delete $target->{pool
};
211 return $target if (@parts == 0);
212 $target->{last_part
} = pop(@parts);
218 $target->{path
} = join('/', @parts);
226 #This is for the first use to init file;
228 my $new_fh = IO
::File-
>new("> $CRONJOBS");
229 die "Could not create $CRONJOBS: $!\n" if !$new_fh;
234 my $text = read_file
($CRONJOBS, 0);
236 return parse_cron
(@{$text});
248 dest_maxsnap
=> undef,
252 source_user
=> undef,
254 prepend_storage_id
=> undef,
256 dest_config_path
=> undef,
259 my ($ret) = GetOptionsFromArray
(
261 'dest=s' => \
$param->{dest
},
262 'source=s' => \
$param->{source
},
263 'verbose' => \
$param->{verbose
},
264 'limit=i' => \
$param->{limit
},
265 'maxsnap=i' => \
$param->{maxsnap
},
266 'dest-maxsnap=i' => \
$param->{dest_maxsnap
},
267 'name=s' => \
$param->{name
},
268 'skip' => \
$param->{skip
},
269 'method=s' => \
$param->{method},
270 'source-user=s' => \
$param->{source_user
},
271 'dest-user=s' => \
$param->{dest_user
},
272 'prepend-storage-id' => \
$param->{prepend_storage_id
},
273 'properties' => \
$param->{properties
},
274 'dest-config-path=s' => \
$param->{dest_config_path
},
277 die "can't parse options\n" if $ret == 0;
279 $param->{name
} //= "default";
280 $param->{maxsnap
} //= 1;
281 $param->{method} //= "ssh";
282 $param->{source_user
} //= "root";
283 $param->{dest_user
} //= "root";
288 sub add_state_to_job
{
291 my $states = read_state
();
292 my $state = $states->{$job->{source
}}->{$job->{name
}};
294 $job->{state} = $state->{state};
295 $job->{lsync
} = $state->{lsync
};
296 $job->{vm_type
} = $state->{vm_type
};
297 $job->{instance_id
} = $state->{instance_id
};
299 for (my $i = 0; $state->{"snap$i"}; $i++) {
300 $job->{"snap$i"} = $state->{"snap$i"};
311 while (my $line = shift(@text)) {
312 my @arg = Text
::ParseWords
::shellwords
($line);
313 my $param = parse_argv
(@arg);
315 if ($param->{source
} && $param->{dest
}) {
316 my $source = delete $param->{source
};
317 my $name = delete $param->{name
};
319 $cfg->{$source}->{$name} = $param;
331 my $source = parse_target
($param->{source
});
333 $dest = parse_target
($param->{dest
}) if $param->{dest
};
335 $job->{name
} = !$param->{name
} ?
"default" : $param->{name
};
336 $job->{dest
} = $param->{dest
} if $param->{dest
};
337 $job->{method} = "local" if !$dest->{ip
} && !$source->{ip
};
338 $job->{method} = "ssh" if !$job->{method};
339 $job->{limit
} = $param->{limit
};
340 $job->{maxsnap
} = $param->{maxsnap
};
341 $job->{dest_maxsnap
} = $param->{dest_maxsnap
};
342 $job->{source
} = $param->{source
};
343 $job->{source_user
} = $param->{source_user
};
344 $job->{dest_user
} = $param->{dest_user
};
345 $job->{prepend_storage_id
} = !!$param->{prepend_storage_id
};
346 $job->{properties
} = !!$param->{properties
};
347 $job->{dest_config_path
} = $param->{dest_config_path
} if $param->{dest_config_path
};
355 make_path
$CONFIG_PATH;
356 my $new_fh = IO
::File-
>new("> $STATE");
357 die "Could not create $STATE: $!\n" if !$new_fh;
363 my $text = read_file
($STATE, 1);
364 return decode_json
($text);
370 my $text = eval { read_file
($STATE, 1); };
372 my $out_fh = IO
::File-
>new("> $STATE.new");
373 die "Could not open file ${STATE}.new: $!\n" if !$out_fh;
378 $states = decode_json
($text);
379 $state = $states->{$job->{source
}}->{$job->{name
}};
382 if ($job->{state} ne "del") {
383 $state->{state} = $job->{state};
384 $state->{lsync
} = $job->{lsync
};
385 $state->{instance_id
} = $job->{instance_id
};
386 $state->{vm_type
} = $job->{vm_type
};
388 for (my $i = 0; $job->{"snap$i"} ; $i++) {
389 $state->{"snap$i"} = $job->{"snap$i"};
391 $states->{$job->{source
}}->{$job->{name
}} = $state;
394 delete $states->{$job->{source
}}->{$job->{name
}};
395 delete $states->{$job->{source
}} if !keys %{$states->{$job->{source
}}};
398 $text = encode_json
($states);
402 rename "$STATE.new", $STATE;
414 my $header = "SHELL=/bin/sh\n";
415 $header .= "PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin\n\n";
417 my $current = read_file
($CRONJOBS, 0);
419 foreach my $line (@{$current}) {
421 if ($line =~ m/source $job->{source} .*name $job->{name} /) {
423 next if $job->{state} eq "del";
424 $text .= format_job
($job, $line);
426 if (($line_no < 3) && ($line =~ /^(PATH|SHELL)/ )) {
435 $text = "$header$text";
439 $text .= format_job
($job);
441 my $new_fh = IO
::File-
>new("> ${CRONJOBS}.new");
442 die "Could not open file ${CRONJOBS}.new: $!\n" if !$new_fh;
444 print $new_fh $text or die "can't write to $CRONJOBS.new: $!\n";
447 rename "${CRONJOBS}.new", $CRONJOBS or die "can't move $CRONJOBS.new: $!\n";
451 my ($job, $line) = @_;
454 if ($job->{state} eq "stopped") {
458 $line =~ /^#*\s*((?:\S+\s+){4}\S+)\s+root/;
461 $text .= "*/$INTERVAL * * * *";
464 $text .= " $PROGNAME sync --source $job->{source} --dest $job->{dest}";
465 $text .= " --name $job->{name} --maxsnap $job->{maxsnap}";
466 $text .= " --dest-maxsnap $job->{dest_maxsnap}" if defined($job->{dest_maxsnap
});
467 $text .= " --limit $job->{limit}" if $job->{limit
};
468 $text .= " --method $job->{method}";
469 $text .= " --verbose" if $job->{verbose
};
470 $text .= " --source-user $job->{source_user}";
471 $text .= " --dest-user $job->{dest_user}";
472 $text .= " --prepend-storage-id" if $job->{prepend_storage_id
};
473 $text .= " --properties" if $job->{properties
};
474 $text .= " --dest-config-path $job->{dest_config_path}" if $job->{dest_config_path
};
482 my $cfg = read_cron
();
484 my $list = sprintf("%-25s%-25s%-10s%-20s%-6s%-5s\n" , "SOURCE", "NAME", "STATE", "LAST SYNC", "TYPE", "CON");
486 my $states = read_state
();
487 foreach my $source (sort keys%{$cfg}) {
488 foreach my $name (sort keys%{$cfg->{$source}}) {
489 $list .= sprintf("%-25s", cut_target_width
($source, 25));
490 $list .= sprintf("%-25s", cut_target_width
($name, 25));
491 $list .= sprintf("%-10s", $states->{$source}->{$name}->{state});
492 $list .= sprintf("%-20s", $states->{$source}->{$name}->{lsync
});
493 $list .= sprintf("%-6s", defined($states->{$source}->{$name}->{vm_type
}) ?
$states->{$source}->{$name}->{vm_type
} : "undef");
494 $list .= sprintf("%-5s\n", $cfg->{$source}->{$name}->{method});
502 my ($target, $user) = @_;
504 return undef if !defined($target->{vmid
});
506 my $conf_fn = "$target->{vmid}.conf";
509 my @cmd = ('ssh', "$user\@$target->{ip}", '--', '/bin/ls');
510 return "qemu" if eval { run_cmd
([@cmd, "$QEMU_CONF/$conf_fn"]) };
511 return "lxc" if eval { run_cmd
([@cmd, "$LXC_CONF/$conf_fn"]) };
513 return "qemu" if -f
"$QEMU_CONF/$conf_fn";
514 return "lxc" if -f
"$LXC_CONF/$conf_fn";
523 locked
("$CONFIG_PATH/cron_and_state.lock", sub {
524 my $cfg = read_cron
();
526 my $job = param_to_job
($param);
528 $job->{state} = "ok";
531 my $source = parse_target
($param->{source
});
532 my $dest = parse_target
($param->{dest
});
534 if (my $ip = $dest->{ip
}) {
535 run_cmd
(['ssh-copy-id', '-i', '/root/.ssh/id_rsa.pub', "$param->{dest_user}\@$ip"]);
538 if (my $ip = $source->{ip
}) {
539 run_cmd
(['ssh-copy-id', '-i', '/root/.ssh/id_rsa.pub', "$param->{source_user}\@$ip"]);
542 die "Pool $dest->{all} does not exist\n"
543 if !check_dataset_exists
($dest->{all
}, $dest->{ip
}, $param->{dest_user
});
545 if (!defined($source->{vmid
})) {
546 die "Pool $source->{all} does not exist\n"
547 if !check_dataset_exists
($source->{all
}, $source->{ip
}, $param->{source_user
});
550 my $vm_type = vm_exists
($source, $param->{source_user
});
551 $job->{vm_type
} = $vm_type;
552 $source->{vm_type
} = $vm_type;
554 die "VM $source->{vmid} doesn't exist\n" if $source->{vmid
} && !$vm_type;
556 die "Config already exists\n" if $cfg->{$job->{source
}}->{$job->{name
}};
558 #check if vm has zfs disks if not die;
559 get_disks
($source, $param->{source_user
}) if $source->{vmid
};
563 }); #cron and state lock
565 return if $param->{skip
};
567 eval { sync
($param) };
577 my $cfg = read_cron
();
579 if (!$cfg->{$param->{source
}}->{$param->{name
}}) {
580 die "Job with source $param->{source} and name $param->{name} does not exist\n" ;
582 my $job = $cfg->{$param->{source
}}->{$param->{name
}};
583 $job->{name
} = $param->{name
};
584 $job->{source
} = $param->{source
};
585 $job = add_state_to_job
($job);
593 locked
("$CONFIG_PATH/cron_and_state.lock", sub {
594 my $job = get_job
($param);
595 $job->{state} = "del";
602 sub get_instance_id
{
605 my $stat = read_file
("/proc/$pid/stat", 1)
606 or die "unable to read process stats\n";
607 my $boot_id = read_file
("/proc/sys/kernel/random/boot_id", 1)
608 or die "unable to read boot ID\n";
610 my $stats = [ split(/\s+/, $stat) ];
611 my $starttime = $stats->[21];
614 return "${pid}:${starttime}:${boot_id}";
617 sub instance_exists
{
618 my ($instance_id) = @_;
620 if (defined($instance_id) && $instance_id =~ m/^([1-9][0-9]*):/) {
622 my $actual_id = eval { get_instance_id
($pid); };
623 return defined($actual_id) && $actual_id eq $instance_id;
634 locked
("$CONFIG_PATH/cron_and_state.lock", sub {
635 eval { $job = get_job
($param) };
638 my $state = $job->{state} // 'ok';
639 $state = 'ok' if !instance_exists
($job->{instance_id
});
641 if ($state eq "syncing" || $state eq "waiting") {
642 die "Job --source $param->{source} --name $param->{name} is already scheduled to sync\n";
645 $job->{state} = "waiting";
646 $job->{instance_id
} = $INSTANCE_ID;
652 locked
("$CONFIG_PATH/sync.lock", sub {
654 my $date = get_date
();
660 locked
("$CONFIG_PATH/cron_and_state.lock", sub {
661 #job might've changed while we waited for the sync lock, but we can be sure it's not syncing
662 eval { $job = get_job
($param); };
664 if ($job && defined($job->{state}) && $job->{state} eq "stopped") {
665 die "Job --source $param->{source} --name $param->{name} has been disabled\n";
668 $dest = parse_target
($param->{dest
});
669 $source = parse_target
($param->{source
});
671 $vm_type = vm_exists
($source, $param->{source_user
});
672 $source->{vm_type
} = $vm_type;
675 $job->{state} = "syncing";
676 $job->{vm_type
} = $vm_type if !$job->{vm_type
};
679 }); #cron and state lock
681 my $sync_path = sub {
682 my ($source, $dest, $job, $param, $date) = @_;
684 my $dest_dataset = target_dataset
($source, $dest);
686 ($dest->{old_snap
}, $dest->{last_snap
}) = snapshot_get
(
688 $param->{dest_maxsnap
} // $param->{maxsnap
},
694 ($source->{old_snap
}) = snapshot_get
(
699 $param->{source_user
},
702 prepare_prepended_target
($source, $dest, $param->{dest_user
}) if defined($dest->{prepend
});
704 snapshot_add
($source, $dest, $param->{name
}, $date, $param->{source_user
}, $param->{dest_user
});
706 send_image
($source, $dest, $param);
708 for my $old_snap (@{$source->{old_snap
}}) {
709 snapshot_destroy
($source->{all
}, $old_snap, $source->{ip
}, $param->{source_user
});
712 for my $old_snap (@{$dest->{old_snap
}}) {
713 snapshot_destroy
($dest_dataset, $old_snap, $dest->{ip
}, $param->{dest_user
});
718 if ($source->{vmid
}) {
719 die "VM $source->{vmid} doesn't exist\n" if !$vm_type;
720 die "source-user has to be root for syncing VMs\n" if ($param->{source_user
} ne "root");
721 my $disks = get_disks
($source, $param->{source_user
});
723 foreach my $disk (sort keys %{$disks}) {
724 $source->{all
} = $disks->{$disk}->{all
};
725 $source->{pool
} = $disks->{$disk}->{pool
};
726 $source->{path
} = $disks->{$disk}->{path
} if $disks->{$disk}->{path
};
727 $source->{last_part
} = $disks->{$disk}->{last_part
};
729 $dest->{prepend
} = $disks->{$disk}->{storage_id
}
730 if $param->{prepend_storage_id
};
732 &$sync_path($source, $dest, $job, $param, $date);
734 if ($param->{method} eq "ssh" && ($source->{ip
} || $dest->{ip
})) {
735 send_config
($source, $dest,'ssh', $param->{source_user
}, $param->{dest_user
}, $param->{dest_config_path
});
737 send_config
($source, $dest,'local', $param->{source_user
}, $param->{dest_user
}, $param->{dest_config_path
});
740 &$sync_path($source, $dest, $job, $param, $date);
744 locked
("$CONFIG_PATH/cron_and_state.lock", sub {
745 eval { $job = get_job
($param); };
747 $job->{state} = "error";
748 delete $job->{instance_id
};
752 print "Job --source $param->{source} --name $param->{name} got an ERROR!!!\nERROR Message:\n";
756 locked
("$CONFIG_PATH/cron_and_state.lock", sub {
757 eval { $job = get_job
($param); };
759 if (defined($job->{state}) && $job->{state} eq "stopped") {
760 $job->{state} = "stopped";
762 $job->{state} = "ok";
764 $job->{lsync
} = $date;
765 delete $job->{instance_id
};
773 my ($dataset, $max_snap, $name, $ip, $user) = @_;
776 push @$cmd, 'ssh', "$user\@$ip", '--', if $ip;
777 push @$cmd, 'zfs', 'list', '-r', '-t', 'snapshot', '-Ho', 'name', '-S', 'creation';
778 push @$cmd, $dataset;
781 eval {$raw = run_cmd
($cmd)};
782 if (my $erro =$@) { #this means the volume doesn't exist on dest yet
788 my $last_snap = undef;
791 while ($raw && $raw =~ s/^(.*?)(\n|$)//) {
793 if ($line =~ m/@(.*)$/) {
794 $last_snap = $1 if (!$last_snap);
796 if ($line =~ m/(rep_\Q${name}\E_\d{4}-\d{2}-\d{2}_\d{2}:\d{2}:\d{2})$/) {
797 # interpreted as infinity
798 last if $max_snap <= 0;
803 if ($index >= $max_snap) {
804 push @{$old_snap}, $snap;
809 return ($old_snap, $last_snap) if $last_snap;
815 my ($source, $dest, $name, $date, $source_user, $dest_user) = @_;
817 my $snap_name = "rep_$name\_".$date;
819 $source->{new_snap
} = $snap_name;
821 my $path = "$source->{all}\@$snap_name";
824 push @$cmd, 'ssh', "$source_user\@$source->{ip}", '--', if $source->{ip
};
825 push @$cmd, 'zfs', 'snapshot', $path;
831 snapshot_destroy
($source->{all
}, $snap_name, $source->{ip
}, $source_user);
837 my ($target, $user) = @_;
840 push @$cmd, 'ssh', "$user\@$target->{ip}", '--', if $target->{ip
};
842 if ($target->{vm_type
} eq 'qemu') {
843 push @$cmd, 'qm', 'config', $target->{vmid
};
844 } elsif ($target->{vm_type
} eq 'lxc') {
845 push @$cmd, 'pct', 'config', $target->{vmid
};
847 die "VM Type unknown\n";
850 my $res = run_cmd
($cmd);
852 my $disks = parse_disks
($res, $target->{ip
}, $target->{vm_type
}, $user);
859 print "Start CMD\n" if $DEBUG;
860 print Dumper
$cmd if $DEBUG;
861 if (ref($cmd) eq 'ARRAY') {
862 $cmd = join(' ', map { ref($_) ?
$$_ : shell_quote
($_) } @$cmd);
864 my $output = `$cmd 2>&1`;
866 die "COMMAND:\n\t$cmd\nGET ERROR:\n\t$output" if 0 != $?;
869 print Dumper
$output if $DEBUG;
870 print "END CMD\n" if $DEBUG;
875 my ($text, $ip, $vm_type, $user) = @_;
880 while ($text && $text =~ s/^(.*?)(\n|$)//) {
883 next if $line =~ /media=cdrom/;
884 next if $line !~ m/$DISK_KEY_RE/;
886 #QEMU if backup is not set include in sync
887 next if $vm_type eq 'qemu' && ($line =~ m/backup=(?i:0|no|off|false)/);
889 #LXC if backup is not set do no in sync
890 next if $vm_type eq 'lxc' && ($line =~ m/^mp\d:/) && ($line !~ m/backup=(?i:1|yes|on|true)/);
894 if($line =~ m/$DISK_KEY_RE(.*)$/) {
895 my @parameter = split(/,/,$1);
897 foreach my $opt (@parameter) {
898 if ($opt =~ m/^(?:file=|volume=)?([^:]+):([A-Za-z0-9\-]+)$/){
905 if (!defined($disk) || !defined($stor)) {
906 print "Disk: \"$line\" has no valid zfs dataset format
and will be skipped
\n";
911 push @$cmd, 'ssh', "$user\@$ip", '--' if $ip;
912 push @$cmd, 'pvesm', 'path', "$stor:$disk";
913 my $path = run_cmd($cmd);
915 die "Get
no path from pvesm path
$stor:$disk\n" if !$path;
917 $disks->{$num}->{storage_id} = $stor;
919 if ($vm_type eq 'qemu' && $path =~ m/^\/dev\/zvol\/(\w+.*)(\/$disk)$/) {
921 my @array = split('/', $1);
922 $disks->{$num}->{pool} = shift(@array);
923 $disks->{$num}->{all} = $disks->{$num}->{pool};
925 $disks->{$num}->{path} = join('/', @array);
926 $disks->{$num}->{all} .= "\
/$disks->{$num}->{path
}";
928 $disks->{$num}->{last_part} = $disk;
929 $disks->{$num}->{all} .= "\
/$disk";
932 } elsif ($vm_type eq 'lxc' && $path =~ m/^\/(\w+.+)(\/(\w+.*))*(\/$disk)$/) {
934 $disks->{$num}->{pool} = $1;
935 $disks->{$num}->{all} = $disks->{$num}->{pool};
938 $disks->{$num}->{path} = $3;
939 $disks->{$num}->{all} .= "\
/$disks->{$num}->{path
}";
942 $disks->{$num}->{last_part} = $disk;
943 $disks->{$num}->{all} .= "\
/$disk";
948 die "ERROR
: in path
\n";
952 die "Vm include
no disk on zfs
.\n" if !$disks->{0};
956 # how the corresponding dataset is named on the target
958 my ($source, $dest) = @_;
960 my $target = "$dest->{all
}";
961 $target .= "/$dest->{prepend
}" if defined($dest->{prepend});
962 $target .= "/$source->{last_part
}" if $source->{last_part};
968 # create the parent dataset for the actual target
969 sub prepare_prepended_target {
970 my ($source, $dest, $dest_user) = @_;
972 die "internal error
- not a prepended target
\n" if !defined($dest->{prepend});
974 # The parent dataset shouldn't be the actual target.
975 die "internal error
- no last_part
for source
\n" if !$source->{last_part};
977 my $target = "$dest->{all
}/$dest->{prepend
}";
980 return if check_dataset_exists($target, $dest->{ip}, $dest_user);
982 create_file_system($target, $dest->{ip}, $dest_user);
985 sub snapshot_destroy {
986 my ($dataset, $snap, $ip, $user) = @_;
988 my @zfscmd = ('zfs', 'destroy');
989 my $snapshot = "$dataset\@$snap";
993 run_cmd(['ssh', "$user\@$ip", '--', @zfscmd, $snapshot]);
995 run_cmd([@zfscmd, $snapshot]);
1003 # check if snapshot for incremental sync exist on source side
1004 sub snapshot_exist {
1005 my ($source , $dest, $method, $source_user) = @_;
1008 push @$cmd, 'ssh', "$source_user\@$source->{ip
}", '--' if $source->{ip};
1009 push @$cmd, 'zfs', 'list', '-rt', 'snapshot', '-Ho', 'name';
1011 my $path = $source->{all};
1012 $path .= "\
@$dest->{last_snap
}";
1016 eval {run_cmd($cmd)};
1025 my ($source, $dest, $param) = @_;
1029 push @$cmd, 'ssh', '-o', 'BatchMode=yes', "$param->{source_user
}\
@$source->{ip
}", '--' if $source->{ip};
1030 push @$cmd, 'zfs', 'send';
1031 push @$cmd, '-p', if $param->{properties};
1032 push @$cmd, '-v' if $param->{verbose};
1034 if($dest->{last_snap} && snapshot_exist($source , $dest, $param->{method}, $param->{source_user})) {
1035 push @$cmd, '-i', "$source->{all
}\
@$dest->{last_snap
}";
1037 push @$cmd, '--', "$source->{all
}\
@$source->{new_snap
}";
1039 if ($param->{limit}){
1040 my $bwl = $param->{limit}*1024;
1041 push @$cmd, \'|', 'cstream', '-t', $bwl;
1043 my $target = target_dataset($source, $dest);
1046 push @$cmd, 'ssh', '-o', 'BatchMode=yes', "$param->{dest_user
}\
@$dest->{ip
}", '--' if $dest->{ip};
1047 push @$cmd, 'zfs', 'recv', '-F', '--';
1048 push @$cmd, "$target";
1054 if (my $erro = $@) {
1055 snapshot_destroy($source->{all}, $source->{new_snap}, $source->{ip}, $param->{source_user});
1062 my ($source, $dest, $method, $source_user, $dest_user, $dest_config_path) = @_;
1064 my $source_target = $source->{vm_type} eq 'qemu' ? "$QEMU_CONF/$source->{vmid
}.conf
": "$LXC_CONF/$source->{vmid
}.conf
";
1065 my $dest_target_new ="$source->{vmid
}.conf
.$source->{vm_type
}.$source->{new_snap
}";
1067 my $config_dir = $dest_config_path // $CONFIG_PATH;
1068 $config_dir .= "/$dest->{last_part
}" if $dest->{last_part};
1070 $dest_target_new = $config_dir.'/'.$dest_target_new;
1072 if ($method eq 'ssh'){
1073 if ($dest->{ip} && $source->{ip}) {
1074 run_cmd(['ssh', "$dest_user\@$dest->{ip
}", '--', 'mkdir', '-p', '--', $config_dir]);
1075 run_cmd(['scp', '--', "$source_user\@[$source->{ip
}]:$source_target", "$dest_user\@[$dest->{ip
}]:$dest_target_new"]);
1076 } elsif ($dest->{ip}) {
1077 run_cmd(['ssh', "$dest_user\@$dest->{ip
}", '--', 'mkdir', '-p', '--', $config_dir]);
1078 run_cmd(['scp', '--', $source_target, "$dest_user\@[$dest->{ip
}]:$dest_target_new"]);
1079 } elsif ($source->{ip}) {
1080 run_cmd(['mkdir', '-p', '--', $config_dir]);
1081 run_cmd(['scp', '--', "$source_user\@[$source->{ip
}]:$source_target", $dest_target_new]);
1084 for my $old_snap (@{$dest->{old_snap}}) {
1085 my $dest_target_old ="${config_dir
}/$source->{vmid
}.conf
.$source->{vm_type
}.${old_snap
}";
1087 run_cmd(['ssh', "$dest_user\@$dest->{ip
}", '--', 'rm', '-f', '--', $dest_target_old]);
1089 run_cmd(['rm', '-f', '--', $dest_target_old]);
1092 } elsif ($method eq 'local') {
1093 run_cmd(['mkdir', '-p', '--', $config_dir]);
1094 run_cmd(['cp', $source_target, $dest_target_new]);
1099 my ($sec, $min, $hour, $mday, $mon, $year, $wday, $yday, $isdst) = localtime(time);
1100 my $datestamp = sprintf ("%04d-%02d-%02d_%02d:%02d:%02d", $year+1900, $mon+1, $mday, $hour, $min, $sec);
1106 my $cfg = read_cron();
1108 my $status_list = sprintf("%-25s
%-25s
%-10s
\n", "SOURCE
", "NAME
", "STATUS
");
1110 my $states = read_state();
1112 foreach my $source (sort keys%{$cfg}) {
1113 foreach my $sync_name (sort keys%{$cfg->{$source}}) {
1114 $status_list .= sprintf("%-25s
", cut_target_width($source, 25));
1115 $status_list .= sprintf("%-25s
", cut_target_width($sync_name, 25));
1116 $status_list .= "$states->{$source}->{$sync_name}->{state}\n";
1120 return $status_list;
1126 locked("$CONFIG_PATH/cron_and_state.lock", sub {
1127 my $job = get_job($param);
1128 $job->{state} = "ok
";
1137 locked("$CONFIG_PATH/cron_and_state.lock", sub {
1138 my $job = get_job($param);
1139 $job->{state} = "stopped
";
1147 $PROGNAME destroy --source <string> [OPTIONS]
1149 Remove a sync Job from the scheduler
1152 The name of the sync job, if not set 'default' is used.
1155 The source can be an <VMID> or [IP:]<ZFSPool>[/Path]
1158 $PROGNAME create --dest <string> --source <string> [OPTIONS]
1160 Create a new sync-job
1163 The destination target is like [IP]:<Pool>[/Path]
1166 The name of the user on the destination target, root by default
1169 Maximal sync speed in kBytes/s, default is unlimited
1172 The number of snapshots to keep until older ones are erased.
1173 The default is 1, use 0 for unlimited.
1175 --dest-maxsnap integer
1176 Override maxsnap for the destination dataset.
1179 The name of the sync job, if not set it is default
1181 --prepend-storage-id
1182 If specified, prepend the storage ID to the destination's path(s).
1185 If specified, skip the first sync.
1188 The source can be an <VMID> or [IP:]<ZFSPool>[/Path]
1190 --source-user string
1191 The (ssh) user-name on the source target, root by default
1194 If specified, include the dataset's properties in the stream.
1196 --dest-config-path string
1197 Specifies a custom config path on the destination target.
1198 The default is /var/lib/pve-zsync
1201 $PROGNAME sync --dest <string> --source <string> [OPTIONS]\n
1206 The destination target is like [IP:]<Pool>[/Path]
1209 The (ssh) user-name on the destination target, root by default
1212 The maximal sync speed in kBytes/s, default is unlimited
1215 The number of snapshots to keep until older ones are erased.
1216 The default is 1, use 0 for unlimited.
1218 --dest-maxsnap integer
1219 Override maxsnap for the destination dataset.
1222 The name of the sync job, if not set it is 'default'.
1223 It is only necessary if scheduler allready contains this source.
1225 --prepend-storage-id
1226 If specified, prepend the storage ID to the destination's path(s).
1229 The source can either be an <VMID> or [IP:]<ZFSPool>[/Path]
1231 --source-user string
1232 The name of the user on the source target, root by default
1235 If specified, print out the sync progress.
1238 If specified, include the dataset's properties in the stream.
1240 --dest-config-path string
1241 Specifies a custom config path on the destination target.
1242 The 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.
1260 Command name to get help about.
1263 Verbose output format.
1266 $PROGNAME enable --source <string> [OPTIONS]
1268 Enable a sync-job and reset all job-errors, if any.
1271 name of the sync job, if not set it is default
1274 the source can be an <VMID> or [IP:]<ZFSPool>[/Path]
1277 $PROGNAME disable --source <string> [OPTIONS]
1279 Disables (pauses) a sync-job
1282 name of the sync-job, if not set it is default
1285 the source can be an <VMID> or [IP:]<ZFSPool>[/Path]
1287 printpod
=> "$PROGNAME printpod\n\n\tinternal command",
1293 } elsif (!$cmd_help->{$command}) {
1294 print "ERROR: unknown command '$command'";
1299 my $param = parse_argv
(@arg);
1303 die "$cmd_help->{$command}\n" if !$param->{$_};
1307 if ($command eq 'destroy') {
1308 check_params
(qw(source));
1310 check_target
($param->{source
});
1311 destroy_job
($param);
1313 } elsif ($command eq 'sync') {
1314 check_params
(qw(source dest));
1316 check_target
($param->{source
});
1317 check_target
($param->{dest
});
1320 } elsif ($command eq 'create') {
1321 check_params
(qw(source dest));
1323 check_target
($param->{source
});
1324 check_target
($param->{dest
});
1327 } elsif ($command eq 'status') {
1330 } elsif ($command eq 'list') {
1333 } elsif ($command eq 'help') {
1334 my $help_command = $ARGV[1];
1336 if ($help_command && $cmd_help->{$help_command}) {
1337 die "$cmd_help->{$help_command}\n";
1340 if ($param->{verbose
}) {
1341 exec("man $PROGNAME");
1348 } elsif ($command eq 'enable') {
1349 check_params
(qw(source));
1351 check_target
($param->{source
});
1354 } elsif ($command eq 'disable') {
1355 check_params
(qw(source));
1357 check_target
($param->{source
});
1358 disable_job
($param);
1360 } elsif ($command eq 'printpod') {
1367 print("ERROR:\tno command specified\n") if !$help;
1368 print("USAGE:\t$PROGNAME <COMMAND> [ARGS] [OPTIONS]\n");
1369 print("\t$PROGNAME help [<cmd>] [OPTIONS]\n\n");
1370 print("\t$PROGNAME create --dest <string> --source <string> [OPTIONS]\n");
1371 print("\t$PROGNAME destroy --source <string> [OPTIONS]\n");
1372 print("\t$PROGNAME disable --source <string> [OPTIONS]\n");
1373 print("\t$PROGNAME enable --source <string> [OPTIONS]\n");
1374 print("\t$PROGNAME list\n");
1375 print("\t$PROGNAME status\n");
1376 print("\t$PROGNAME sync --dest <string> --source <string> [OPTIONS]\n");
1381 parse_target
($target);
1386 my $synopsis = join("\n", sort values %$cmd_help);
1387 my $commands = join(", ", sort keys %$cmd_help);
1392 pve-zsync - PVE ZFS Storage Sync Tool
1396 pve-zsync <COMMAND> [ARGS] [OPTIONS]
1398 Where <COMMAND> can be one of: $commands
1402 The pve-zsync tool can help you to sync your VMs or directories stored on ZFS
1403 between multiple servers.
1405 pve-zsync is able to automatically configure CRON jobs, so that a periodic sync
1406 will be automatically triggered.
1407 The default sync interval is 15 min, if you want to change this value you can
1408 do this in F</etc/cron.d/pve-zsync>. If you need help to configure CRON tabs, see
1411 =head1 COMMANDS AND OPTIONS
1417 Adds a job for syncing the local VM 100 to a remote server's ZFS pool named "tank":
1418 pve-zsync create --source=100 -dest=192.168.1.2:tank
1420 =head1 IMPORTANT FILES
1422 Cron jobs and config are stored in F</etc/cron.d/pve-zsync>
1424 The VM configuration itself gets copied to the destination machines
1425 F</var/lib/pve-zsync/> path.
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 under
1432 the terms of the GNU Affero General Public License as published by the Free
1433 Software Foundation, either version 3 of the License, or (at your option) any
1436 This program is distributed in the hope that it will be useful, but WITHOUT ANY
1437 WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
1438 PARTICULAR PURPOSE. See the GNU Affero General Public License for more
1441 You should have received a copy of the GNU Affero General Public License along
1442 with this program. If not, see <http://www.gnu.org/licenses/>.