]>
git.proxmox.com Git - pve-zsync.git/blob - pve-zsync
6 use Fcntl
qw(:flock SEEK_END);
7 use Getopt
::Long
qw(GetOptionsFromArray);
8 use File
::Path
qw(make_path);
11 use String
::ShellQuote
'shell_quote';
13 my $PROGNAME = "pve-zsync";
14 my $CONFIG_PATH = "/var/lib/${PROGNAME}";
15 my $STATE = "${CONFIG_PATH}/sync_state";
16 my $CRONJOBS = "/etc/cron.d/$PROGNAME";
17 my $PATH = "/usr/sbin";
18 my $PVE_DIR = "/etc/pve/local";
19 my $QEMU_CONF = "${PVE_DIR}/qemu-server";
20 my $LXC_CONF = "${PVE_DIR}/lxc";
21 my $PROG_PATH = "$PATH/${PROGNAME}";
26 $DEBUG = 0; # change default here. not above on declaration!
27 $DEBUG ||= $ENV{ZSYNC_DEBUG
};
30 Data
::Dumper-
>import();
34 my $IPV4OCTET = "(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])";
35 my $IPV4RE = "(?:(?:$IPV4OCTET\\.){3}$IPV4OCTET)";
36 my $IPV6H16 = "(?:[0-9a-fA-F]{1,4})";
37 my $IPV6LS32 = "(?:(?:$IPV4RE|$IPV6H16:$IPV6H16))";
40 "(?:(?:" . "(?:$IPV6H16:){6})$IPV6LS32)|" .
41 "(?:(?:" . "::(?:$IPV6H16:){5})$IPV6LS32)|" .
42 "(?:(?:(?:" . "$IPV6H16)?::(?:$IPV6H16:){4})$IPV6LS32)|" .
43 "(?:(?:(?:(?:$IPV6H16:){0,1}$IPV6H16)?::(?:$IPV6H16:){3})$IPV6LS32)|" .
44 "(?:(?:(?:(?:$IPV6H16:){0,2}$IPV6H16)?::(?:$IPV6H16:){2})$IPV6LS32)|" .
45 "(?:(?:(?:(?:$IPV6H16:){0,3}$IPV6H16)?::(?:$IPV6H16:){1})$IPV6LS32)|" .
46 "(?:(?:(?:(?:$IPV6H16:){0,4}$IPV6H16)?::" . ")$IPV6LS32)|" .
47 "(?:(?:(?:(?:$IPV6H16:){0,5}$IPV6H16)?::" . ")$IPV6H16)|" .
48 "(?:(?:(?:(?:$IPV6H16:){0,6}$IPV6H16)?::" . ")))";
50 my $HOSTv4RE0 = "(?:[\\w\\.\\-_]+|$IPV4RE)"; # hostname or ipv4 address
51 my $HOSTv4RE1 = "(?:$HOSTv4RE0|\\[$HOSTv4RE0\\])"; # these may be in brackets, too
52 my $HOSTRE = "(?:$HOSTv4RE1|\\[$IPV6RE\\])"; # ipv6 must always be in brackets
53 # targets are either a VMID, or a 'host:zpool/path' with 'host:' being optional
54 my $TARGETRE = qr!^(?:($HOSTRE):)?(\d+|(?:[\w\-_]+)(/.+)?)$!;
56 my $DISK_KEY_RE = qr/^(?:(?:(?:virtio|ide|scsi|sata|efidisk|mp)\d+)|rootfs): /;
58 my $INSTANCE_ID = get_instance_id
($$);
60 my $command = $ARGV[0];
62 if (defined($command) && $command ne 'help' && $command ne 'printpod') {
63 check_bin
('cstream');
69 $SIG{TERM
} = $SIG{QUIT
} = $SIG{PIPE
} = $SIG{HUP
} = $SIG{KILL
} = $SIG{INT
} = sub {
70 die "Signaled, aborting sync: $!\n";
76 foreach my $p (split (/:/, $ENV{PATH
})) {
83 die "unable to find command '$bin'\n";
87 my ($filename, $one_line_only) = @_;
89 my $fh = IO
::File-
>new($filename, "r")
90 or die "Could not open file ${filename}: $!\n";
92 my $text = $one_line_only ?
<$fh> : [ <$fh> ];
99 sub cut_target_width
{
100 my ($path, $maxlen) = @_;
103 return $path if length($path) <= $maxlen;
105 return '..'.substr($path, -$maxlen+2) if $path !~ m
@/@;
107 $path =~ s
@/([^/]+/?
)$@@;
110 if (length($tail)+3 == $maxlen) {
112 } elsif (length($tail)+2 >= $maxlen) {
113 return '..'.substr($tail, -$maxlen+2)
116 $path =~ s
@(/[^/]+)(?
:/|$)@@;
118 my $both = length($head) + length($tail);
119 my $remaining = $maxlen-$both-4; # -4 for "/../"
121 if ($remaining < 0) {
122 return substr($head, 0, $maxlen - length($tail) - 3) . "../$tail"; # -3 for "../"
125 substr($path, ($remaining/2), (length($path)-$remaining), '..');
126 return "$head/" . $path . "/$tail";
130 my ($lock_fn, $code) = @_;
132 my $lock_fh = IO
::File-
>new("> $lock_fn");
134 flock($lock_fh, LOCK_EX
) || die "Couldn't acquire lock - $!\n";
135 my $res = eval { $code->() };
138 flock($lock_fh, LOCK_UN
) || warn "Error unlocking - $!\n";
146 my ($source, $name, $status) = @_;
148 if ($status->{$source->{all
}}->{$name}->{status
}) {
155 sub check_dataset_exists
{
156 my ($dataset, $ip, $user) = @_;
161 push @$cmd, 'ssh', "$user\@$ip", '--';
163 push @$cmd, 'zfs', 'list', '-H', '--', $dataset;
174 sub create_file_system
{
175 my ($file_system, $ip, $user) = @_;
180 push @$cmd, 'ssh', "$user\@$ip", '--';
182 push @$cmd, 'zfs', 'create', $file_system;
190 my $errstr = "$text : is not a valid input! Use [IP:]<VMID> or [IP:]<ZFSPool>[/Path]";
193 if ($text !~ $TARGETRE) {
197 $target->{ip
} = $1 if $1;
198 my @parts = split('/', $2);
200 $target->{ip
} =~ s/^\[(.*)\]$/$1/ if $target->{ip
};
202 my $pool = $target->{pool
} = shift(@parts);
203 die "$errstr\n" if !$pool;
205 if ($pool =~ m/^\d+$/) {
206 $target->{vmid
} = $pool;
207 delete $target->{pool
};
210 return $target if (@parts == 0);
211 $target->{last_part
} = pop(@parts);
217 $target->{path
} = join('/', @parts);
225 #This is for the first use to init file;
227 my $new_fh = IO
::File-
>new("> $CRONJOBS");
228 die "Could not create $CRONJOBS: $!\n" if !$new_fh;
233 my $text = read_file
($CRONJOBS, 0);
235 return encode_cron
(@{$text});
250 source_user
=> undef,
252 prepend_storage_id
=> undef,
254 dest_config_path
=> undef,
257 my ($ret) = GetOptionsFromArray
(
259 'dest=s' => \
$param->{dest
},
260 'source=s' => \
$param->{source
},
261 'verbose' => \
$param->{verbose
},
262 'limit=i' => \
$param->{limit
},
263 'maxsnap=i' => \
$param->{maxsnap
},
264 'name=s' => \
$param->{name
},
265 'skip' => \
$param->{skip
},
266 'method=s' => \
$param->{method},
267 'source-user=s' => \
$param->{source_user
},
268 'dest-user=s' => \
$param->{dest_user
},
269 'prepend-storage-id' => \
$param->{prepend_storage_id
},
270 'properties' => \
$param->{properties
},
271 'dest-config-path=s' => \
$param->{dest_config_path
},
274 die "can't parse options\n" if $ret == 0;
276 $param->{name
} //= "default";
277 $param->{maxsnap
} //= 1;
278 $param->{method} //= "ssh";
279 $param->{source_user
} //= "root";
280 $param->{dest_user
} //= "root";
285 sub add_state_to_job
{
288 my $states = read_state
();
289 my $state = $states->{$job->{source
}}->{$job->{name
}};
291 $job->{state} = $state->{state};
292 $job->{lsync
} = $state->{lsync
};
293 $job->{vm_type
} = $state->{vm_type
};
294 $job->{instance_id
} = $state->{instance_id
};
296 for (my $i = 0; $state->{"snap$i"}; $i++) {
297 $job->{"snap$i"} = $state->{"snap$i"};
308 while (my $line = shift(@text)) {
310 my @arg = split('\s', $line);
311 my $param = parse_argv
(@arg);
313 if ($param->{source
} && $param->{dest
}) {
314 my $source = delete $param->{source
};
315 my $name = delete $param->{name
};
317 $cfg->{$source}->{$name} = $param;
329 my $source = parse_target
($param->{source
});
331 $dest = parse_target
($param->{dest
}) if $param->{dest
};
333 $job->{name
} = !$param->{name
} ?
"default" : $param->{name
};
334 $job->{dest
} = $param->{dest
} if $param->{dest
};
335 $job->{method} = "local" if !$dest->{ip
} && !$source->{ip
};
336 $job->{method} = "ssh" if !$job->{method};
337 $job->{limit
} = $param->{limit
};
338 $job->{maxsnap
} = $param->{maxsnap
};
339 $job->{source
} = $param->{source
};
340 $job->{source_user
} = $param->{source_user
};
341 $job->{dest_user
} = $param->{dest_user
};
342 $job->{prepend_storage_id
} = !!$param->{prepend_storage_id
};
343 $job->{properties
} = !!$param->{properties
};
344 $job->{dest_config_path
} = $param->{dest_config_path
} if $param->{dest_config_path
};
352 make_path
$CONFIG_PATH;
353 my $new_fh = IO
::File-
>new("> $STATE");
354 die "Could not create $STATE: $!\n" if !$new_fh;
360 my $text = read_file
($STATE, 1);
361 return decode_json
($text);
367 my $text = eval { read_file
($STATE, 1); };
369 my $out_fh = IO
::File-
>new("> $STATE.new");
370 die "Could not open file ${STATE}.new: $!\n" if !$out_fh;
375 $states = decode_json
($text);
376 $state = $states->{$job->{source
}}->{$job->{name
}};
379 if ($job->{state} ne "del") {
380 $state->{state} = $job->{state};
381 $state->{lsync
} = $job->{lsync
};
382 $state->{instance_id
} = $job->{instance_id
};
383 $state->{vm_type
} = $job->{vm_type
};
385 for (my $i = 0; $job->{"snap$i"} ; $i++) {
386 $state->{"snap$i"} = $job->{"snap$i"};
388 $states->{$job->{source
}}->{$job->{name
}} = $state;
391 delete $states->{$job->{source
}}->{$job->{name
}};
392 delete $states->{$job->{source
}} if !keys %{$states->{$job->{source
}}};
395 $text = encode_json
($states);
399 rename "$STATE.new", $STATE;
411 my $header = "SHELL=/bin/sh\n";
412 $header .= "PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin\n\n";
414 my $current = read_file
($CRONJOBS, 0);
416 foreach my $line (@{$current}) {
418 if ($line =~ m/source $job->{source} .*name $job->{name} /) {
420 next if $job->{state} eq "del";
421 $text .= format_job
($job, $line);
423 if (($line_no < 3) && ($line =~ /^(PATH|SHELL)/ )) {
432 $text = "$header$text";
436 $text .= format_job
($job);
438 my $new_fh = IO
::File-
>new("> ${CRONJOBS}.new");
439 die "Could not open file ${CRONJOBS}.new: $!\n" if !$new_fh;
441 print $new_fh $text or die "can't write to $CRONJOBS.new: $!\n";
444 rename "${CRONJOBS}.new", $CRONJOBS or die "can't move $CRONJOBS.new: $!\n";
448 my ($job, $line) = @_;
451 if ($job->{state} eq "stopped") {
455 $line =~ /^#*\s*((?:\S+\s+){4}\S+)\s+root/;
458 $text .= "*/$INTERVAL * * * *";
461 $text .= " $PROGNAME sync --source $job->{source} --dest $job->{dest}";
462 $text .= " --name $job->{name} --maxsnap $job->{maxsnap}";
463 $text .= " --limit $job->{limit}" if $job->{limit
};
464 $text .= " --method $job->{method}";
465 $text .= " --verbose" if $job->{verbose
};
466 $text .= " --source-user $job->{source_user}";
467 $text .= " --dest-user $job->{dest_user}";
468 $text .= " --prepend-storage-id" if $job->{prepend_storage_id
};
469 $text .= " --properties" if $job->{properties
};
470 $text .= " --dest-config-path $job->{dest_config_path}" if $job->{dest_config_path
};
478 my $cfg = read_cron
();
480 my $list = sprintf("%-25s%-25s%-10s%-20s%-6s%-5s\n" , "SOURCE", "NAME", "STATE", "LAST SYNC", "TYPE", "CON");
482 my $states = read_state
();
483 foreach my $source (sort keys%{$cfg}) {
484 foreach my $name (sort keys%{$cfg->{$source}}) {
485 $list .= sprintf("%-25s", cut_target_width
($source, 25));
486 $list .= sprintf("%-25s", cut_target_width
($name, 25));
487 $list .= sprintf("%-10s", $states->{$source}->{$name}->{state});
488 $list .= sprintf("%-20s", $states->{$source}->{$name}->{lsync
});
489 $list .= sprintf("%-6s", defined($states->{$source}->{$name}->{vm_type
}) ?
$states->{$source}->{$name}->{vm_type
} : "undef");
490 $list .= sprintf("%-5s\n", $cfg->{$source}->{$name}->{method});
498 my ($target, $user) = @_;
500 return undef if !defined($target->{vmid
});
502 my $conf_fn = "$target->{vmid}.conf";
505 my @cmd = ('ssh', "$user\@$target->{ip}", '--', '/bin/ls');
506 return "qemu" if eval { run_cmd
([@cmd, "$QEMU_CONF/$conf_fn"]) };
507 return "lxc" if eval { run_cmd
([@cmd, "$LXC_CONF/$conf_fn"]) };
509 return "qemu" if -f
"$QEMU_CONF/$conf_fn";
510 return "lxc" if -f
"$LXC_CONF/$conf_fn";
519 locked
("$CONFIG_PATH/cron_and_state.lock", sub {
520 my $cfg = read_cron
();
522 my $job = param_to_job
($param);
524 $job->{state} = "ok";
527 my $source = parse_target
($param->{source
});
528 my $dest = parse_target
($param->{dest
});
530 if (my $ip = $dest->{ip
}) {
531 run_cmd
(['ssh-copy-id', '-i', '/root/.ssh/id_rsa.pub', "$param->{dest_user}\@$ip"]);
534 if (my $ip = $source->{ip
}) {
535 run_cmd
(['ssh-copy-id', '-i', '/root/.ssh/id_rsa.pub', "$param->{source_user}\@$ip"]);
538 die "Pool $dest->{all} does not exist\n"
539 if !check_dataset_exists
($dest->{all
}, $dest->{ip
}, $param->{dest_user
});
541 if (!defined($source->{vmid
})) {
542 die "Pool $source->{all} does not exist\n"
543 if !check_dataset_exists
($source->{all
}, $source->{ip
}, $param->{source_user
});
546 my $vm_type = vm_exists
($source, $param->{source_user
});
547 $job->{vm_type
} = $vm_type;
548 $source->{vm_type
} = $vm_type;
550 die "VM $source->{vmid} doesn't exist\n" if $source->{vmid
} && !$vm_type;
552 die "Config already exists\n" if $cfg->{$job->{source
}}->{$job->{name
}};
554 #check if vm has zfs disks if not die;
555 get_disks
($source, $param->{source_user
}) if $source->{vmid
};
559 }); #cron and state lock
561 return if $param->{skip
};
563 eval { sync
($param) };
573 my $cfg = read_cron
();
575 if (!$cfg->{$param->{source
}}->{$param->{name
}}) {
576 die "Job with source $param->{source} and name $param->{name} does not exist\n" ;
578 my $job = $cfg->{$param->{source
}}->{$param->{name
}};
579 $job->{name
} = $param->{name
};
580 $job->{source
} = $param->{source
};
581 $job = add_state_to_job
($job);
589 locked
("$CONFIG_PATH/cron_and_state.lock", sub {
590 my $job = get_job
($param);
591 $job->{state} = "del";
598 sub get_instance_id
{
601 my $stat = read_file
("/proc/$pid/stat", 1)
602 or die "unable to read process stats\n";
603 my $boot_id = read_file
("/proc/sys/kernel/random/boot_id", 1)
604 or die "unable to read boot ID\n";
606 my $stats = [ split(/\s+/, $stat) ];
607 my $starttime = $stats->[21];
610 return "${pid}:${starttime}:${boot_id}";
613 sub instance_exists
{
614 my ($instance_id) = @_;
616 if (defined($instance_id) && $instance_id =~ m/^([1-9][0-9]*):/) {
618 my $actual_id = eval { get_instance_id
($pid); };
619 return defined($actual_id) && $actual_id eq $instance_id;
630 locked
("$CONFIG_PATH/cron_and_state.lock", sub {
631 eval { $job = get_job
($param) };
634 my $state = $job->{state} // 'ok';
635 $state = 'ok' if !instance_exists
($job->{instance_id
});
637 if ($state eq "syncing" || $state eq "waiting") {
638 die "Job --source $param->{source} --name $param->{name} is already scheduled to sync\n";
641 $job->{state} = "waiting";
642 $job->{instance_id
} = $INSTANCE_ID;
648 locked
("$CONFIG_PATH/sync.lock", sub {
650 my $date = get_date
();
656 locked
("$CONFIG_PATH/cron_and_state.lock", sub {
657 #job might've changed while we waited for the sync lock, but we can be sure it's not syncing
658 eval { $job = get_job
($param); };
660 if ($job && defined($job->{state}) && $job->{state} eq "stopped") {
661 die "Job --source $param->{source} --name $param->{name} has been disabled\n";
664 $dest = parse_target
($param->{dest
});
665 $source = parse_target
($param->{source
});
667 $vm_type = vm_exists
($source, $param->{source_user
});
668 $source->{vm_type
} = $vm_type;
671 $job->{state} = "syncing";
672 $job->{vm_type
} = $vm_type if !$job->{vm_type
};
675 }); #cron and state lock
677 my $sync_path = sub {
678 my ($source, $dest, $job, $param, $date) = @_;
680 ($dest->{old_snap
}, $dest->{last_snap
}) = snapshot_get
($source, $dest, $param->{maxsnap
}, $param->{name
}, $param->{dest_user
});
682 prepare_prepended_target
($source, $dest, $param->{dest_user
}) if defined($dest->{prepend
});
684 snapshot_add
($source, $dest, $param->{name
}, $date, $param->{source_user
}, $param->{dest_user
});
686 send_image
($source, $dest, $param);
688 for my $old_snap (@{$dest->{old_snap
}}) {
689 snapshot_destroy
($source, $dest, $param->{method}, $old_snap, $param->{source_user
}, $param->{dest_user
});
694 if ($source->{vmid
}) {
695 die "VM $source->{vmid} doesn't exist\n" if !$vm_type;
696 die "source-user has to be root for syncing VMs\n" if ($param->{source_user
} ne "root");
697 my $disks = get_disks
($source, $param->{source_user
});
699 foreach my $disk (sort keys %{$disks}) {
700 $source->{all
} = $disks->{$disk}->{all
};
701 $source->{pool
} = $disks->{$disk}->{pool
};
702 $source->{path
} = $disks->{$disk}->{path
} if $disks->{$disk}->{path
};
703 $source->{last_part
} = $disks->{$disk}->{last_part
};
705 $dest->{prepend
} = $disks->{$disk}->{storage_id
}
706 if $param->{prepend_storage_id
};
708 &$sync_path($source, $dest, $job, $param, $date);
710 if ($param->{method} eq "ssh" && ($source->{ip
} || $dest->{ip
})) {
711 send_config
($source, $dest,'ssh', $param->{source_user
}, $param->{dest_user
}, $param->{dest_config_path
});
713 send_config
($source, $dest,'local', $param->{source_user
}, $param->{dest_user
}, $param->{dest_config_path
});
716 &$sync_path($source, $dest, $job, $param, $date);
720 locked
("$CONFIG_PATH/cron_and_state.lock", sub {
721 eval { $job = get_job
($param); };
723 $job->{state} = "error";
724 delete $job->{instance_id
};
728 print "Job --source $param->{source} --name $param->{name} got an ERROR!!!\nERROR Message:\n";
732 locked
("$CONFIG_PATH/cron_and_state.lock", sub {
733 eval { $job = get_job
($param); };
735 if (defined($job->{state}) && $job->{state} eq "stopped") {
736 $job->{state} = "stopped";
738 $job->{state} = "ok";
740 $job->{lsync
} = $date;
741 delete $job->{instance_id
};
749 my ($source, $dest, $max_snap, $name, $dest_user) = @_;
752 push @$cmd, 'ssh', "$dest_user\@$dest->{ip}", '--', if $dest->{ip
};
753 push @$cmd, 'zfs', 'list', '-r', '-t', 'snapshot', '-Ho', 'name', '-S', 'creation';
755 my $path = target_dataset
($source, $dest);
759 eval {$raw = run_cmd
($cmd)};
760 if (my $erro =$@) { #this means the volume doesn't exist on dest yet
766 my $last_snap = undef;
769 while ($raw && $raw =~ s/^(.*?)(\n|$)//) {
771 if ($line =~ m/@(.*)$/) {
772 $last_snap = $1 if (!$last_snap);
774 if ($line =~ m/(rep_\Q${name}\E_\d{4}-\d{2}-\d{2}_\d{2}:\d{2}:\d{2})$/) {
775 # interpreted as infinity
776 last if $max_snap <= 0;
781 if ($index >= $max_snap) {
782 push @{$old_snap}, $snap;
787 return ($old_snap, $last_snap) if $last_snap;
793 my ($source, $dest, $name, $date, $source_user, $dest_user) = @_;
795 my $snap_name = "rep_$name\_".$date;
797 $source->{new_snap
} = $snap_name;
799 my $path = "$source->{all}\@$snap_name";
802 push @$cmd, 'ssh', "$source_user\@$source->{ip}", '--', if $source->{ip
};
803 push @$cmd, 'zfs', 'snapshot', $path;
809 snapshot_destroy
($source, $dest, 'ssh', $snap_name, $source_user, $dest_user);
815 my ($target, $user) = @_;
818 push @$cmd, 'ssh', "$user\@$target->{ip}", '--', if $target->{ip
};
820 if ($target->{vm_type
} eq 'qemu') {
821 push @$cmd, 'qm', 'config', $target->{vmid
};
822 } elsif ($target->{vm_type
} eq 'lxc') {
823 push @$cmd, 'pct', 'config', $target->{vmid
};
825 die "VM Type unknown\n";
828 my $res = run_cmd
($cmd);
830 my $disks = parse_disks
($res, $target->{ip
}, $target->{vm_type
}, $user);
837 print "Start CMD\n" if $DEBUG;
838 print Dumper
$cmd if $DEBUG;
839 if (ref($cmd) eq 'ARRAY') {
840 $cmd = join(' ', map { ref($_) ?
$$_ : shell_quote
($_) } @$cmd);
842 my $output = `$cmd 2>&1`;
844 die "COMMAND:\n\t$cmd\nGET ERROR:\n\t$output" if 0 != $?;
847 print Dumper
$output if $DEBUG;
848 print "END CMD\n" if $DEBUG;
853 my ($text, $ip, $vm_type, $user) = @_;
858 while ($text && $text =~ s/^(.*?)(\n|$)//) {
861 next if $line =~ /media=cdrom/;
862 next if $line !~ m/$DISK_KEY_RE/;
864 #QEMU if backup is not set include in sync
865 next if $vm_type eq 'qemu' && ($line =~ m/backup=(?i:0|no|off|false)/);
867 #LXC if backup is not set do no in sync
868 next if $vm_type eq 'lxc' && ($line =~ m/^mp\d:/) && ($line !~ m/backup=(?i:1|yes|on|true)/);
872 if($line =~ m/$DISK_KEY_RE(.*)$/) {
873 my @parameter = split(/,/,$1);
875 foreach my $opt (@parameter) {
876 if ($opt =~ m/^(?:file=|volume=)?([^:]+):([A-Za-z0-9\-]+)$/){
883 if (!defined($disk) || !defined($stor)) {
884 print "Disk: \"$line\" has no valid zfs dataset format
and will be skipped
\n";
889 push @$cmd, 'ssh', "$user\@$ip", '--' if $ip;
890 push @$cmd, 'pvesm', 'path', "$stor:$disk";
891 my $path = run_cmd($cmd);
893 die "Get
no path from pvesm path
$stor:$disk\n" if !$path;
895 $disks->{$num}->{storage_id} = $stor;
897 if ($vm_type eq 'qemu' && $path =~ m/^\/dev\/zvol\/(\w+.*)(\/$disk)$/) {
899 my @array = split('/', $1);
900 $disks->{$num}->{pool} = shift(@array);
901 $disks->{$num}->{all} = $disks->{$num}->{pool};
903 $disks->{$num}->{path} = join('/', @array);
904 $disks->{$num}->{all} .= "\
/$disks->{$num}->{path
}";
906 $disks->{$num}->{last_part} = $disk;
907 $disks->{$num}->{all} .= "\
/$disk";
910 } elsif ($vm_type eq 'lxc' && $path =~ m/^\/(\w+.+)(\/(\w+.*))*(\/$disk)$/) {
912 $disks->{$num}->{pool} = $1;
913 $disks->{$num}->{all} = $disks->{$num}->{pool};
916 $disks->{$num}->{path} = $3;
917 $disks->{$num}->{all} .= "\
/$disks->{$num}->{path
}";
920 $disks->{$num}->{last_part} = $disk;
921 $disks->{$num}->{all} .= "\
/$disk";
926 die "ERROR
: in path
\n";
930 die "Vm include
no disk on zfs
.\n" if !$disks->{0};
934 # how the corresponding dataset is named on the target
936 my ($source, $dest) = @_;
938 my $target = "$dest->{all
}";
939 $target .= "/$dest->{prepend
}" if defined($dest->{prepend});
940 $target .= "/$source->{last_part
}" if $source->{last_part};
946 # create the parent dataset for the actual target
947 sub prepare_prepended_target {
948 my ($source, $dest, $dest_user) = @_;
950 die "internal error
- not a prepended target
\n" if !defined($dest->{prepend});
952 # The parent dataset shouldn't be the actual target.
953 die "internal error
- no last_part
for source
\n" if !$source->{last_part};
955 my $target = "$dest->{all
}/$dest->{prepend
}";
958 return if check_dataset_exists($target, $dest->{ip}, $dest_user);
960 create_file_system($target, $dest->{ip}, $dest_user);
963 sub snapshot_destroy {
964 my ($source, $dest, $method, $snap, $source_user, $dest_user) = @_;
966 my @zfscmd = ('zfs', 'destroy');
967 my $snapshot = "$source->{all
}\
@$snap";
970 if($source->{ip} && $method eq 'ssh'){
971 run_cmd(['ssh', "$source_user\@$source->{ip
}", '--', @zfscmd, $snapshot]);
973 run_cmd([@zfscmd, $snapshot]);
980 my @ssh = $dest->{ip} ? ('ssh', "$dest_user\@$dest->{ip
}", '--') : ();
982 my $path = target_dataset($source, $dest);
985 run_cmd([@ssh, @zfscmd, "$path\@$snap"]);
993 # check if snapshot for incremental sync exist on source side
995 my ($source , $dest, $method, $source_user) = @_;
998 push @$cmd, 'ssh', "$source_user\@$source->{ip
}", '--' if $source->{ip};
999 push @$cmd, 'zfs', 'list', '-rt', 'snapshot', '-Ho', 'name';
1001 my $path = $source->{all};
1002 $path .= "\
@$dest->{last_snap
}";
1006 eval {run_cmd($cmd)};
1015 my ($source, $dest, $param) = @_;
1019 push @$cmd, 'ssh', '-o', 'BatchMode=yes', "$param->{source_user
}\
@$source->{ip
}", '--' if $source->{ip};
1020 push @$cmd, 'zfs', 'send';
1021 push @$cmd, '-p', if $param->{properties};
1022 push @$cmd, '-v' if $param->{verbose};
1024 if($dest->{last_snap} && snapshot_exist($source , $dest, $param->{method}, $param->{source_user})) {
1025 push @$cmd, '-i', "$source->{all
}\
@$dest->{last_snap
}";
1027 push @$cmd, '--', "$source->{all
}\
@$source->{new_snap
}";
1029 if ($param->{limit}){
1030 my $bwl = $param->{limit}*1024;
1031 push @$cmd, \'|', 'cstream', '-t', $bwl;
1033 my $target = target_dataset($source, $dest);
1036 push @$cmd, 'ssh', '-o', 'BatchMode=yes', "$param->{dest_user
}\
@$dest->{ip
}", '--' if $dest->{ip};
1037 push @$cmd, 'zfs', 'recv', '-F', '--';
1038 push @$cmd, "$target";
1044 if (my $erro = $@) {
1045 snapshot_destroy($source, undef, $param->{method}, $source->{new_snap}, $param->{source_user}, $param->{dest_user});
1052 my ($source, $dest, $method, $source_user, $dest_user, $dest_config_path) = @_;
1054 my $source_target = $source->{vm_type} eq 'qemu' ? "$QEMU_CONF/$source->{vmid
}.conf
": "$LXC_CONF/$source->{vmid
}.conf
";
1055 my $dest_target_new ="$source->{vmid
}.conf
.$source->{vm_type
}.$source->{new_snap
}";
1057 my $config_dir = $dest_config_path // $CONFIG_PATH;
1058 $config_dir .= "/$dest->{last_part
}" if $dest->{last_part};
1060 $dest_target_new = $config_dir.'/'.$dest_target_new;
1062 if ($method eq 'ssh'){
1063 if ($dest->{ip} && $source->{ip}) {
1064 run_cmd(['ssh', "$dest_user\@$dest->{ip
}", '--', 'mkdir', '-p', '--', $config_dir]);
1065 run_cmd(['scp', '--', "$source_user\@[$source->{ip
}]:$source_target", "$dest_user\@[$dest->{ip
}]:$dest_target_new"]);
1066 } elsif ($dest->{ip}) {
1067 run_cmd(['ssh', "$dest_user\@$dest->{ip
}", '--', 'mkdir', '-p', '--', $config_dir]);
1068 run_cmd(['scp', '--', $source_target, "$dest_user\@[$dest->{ip
}]:$dest_target_new"]);
1069 } elsif ($source->{ip}) {
1070 run_cmd(['mkdir', '-p', '--', $config_dir]);
1071 run_cmd(['scp', '--', "$source_user\@[$source->{ip
}]:$source_target", $dest_target_new]);
1074 for my $old_snap (@{$dest->{old_snap}}) {
1075 my $dest_target_old ="${config_dir
}/$source->{vmid
}.conf
.$source->{vm_type
}.${old_snap
}";
1077 run_cmd(['ssh', "$dest_user\@$dest->{ip
}", '--', 'rm', '-f', '--', $dest_target_old]);
1079 run_cmd(['rm', '-f', '--', $dest_target_old]);
1082 } elsif ($method eq 'local') {
1083 run_cmd(['mkdir', '-p', '--', $config_dir]);
1084 run_cmd(['cp', $source_target, $dest_target_new]);
1089 my ($sec, $min, $hour, $mday, $mon, $year, $wday, $yday, $isdst) = localtime(time);
1090 my $datestamp = sprintf ("%04d-%02d-%02d_%02d:%02d:%02d", $year+1900, $mon+1, $mday, $hour, $min, $sec);
1096 my $cfg = read_cron();
1098 my $status_list = sprintf("%-25s
%-25s
%-10s
\n", "SOURCE
", "NAME
", "STATUS
");
1100 my $states = read_state();
1102 foreach my $source (sort keys%{$cfg}) {
1103 foreach my $sync_name (sort keys%{$cfg->{$source}}) {
1104 $status_list .= sprintf("%-25s
", cut_target_width($source, 25));
1105 $status_list .= sprintf("%-25s
", cut_target_width($sync_name, 25));
1106 $status_list .= "$states->{$source}->{$sync_name}->{state}\n";
1110 return $status_list;
1116 locked("$CONFIG_PATH/cron_and_state.lock", sub {
1117 my $job = get_job($param);
1118 $job->{state} = "ok
";
1127 locked("$CONFIG_PATH/cron_and_state.lock", sub {
1128 my $job = get_job($param);
1129 $job->{state} = "stopped
";
1137 $PROGNAME destroy --source <string> [OPTIONS]
1139 Remove a sync Job from the scheduler
1142 The name of the sync job, if not set 'default' is used.
1145 The source can be an <VMID> or [IP:]<ZFSPool>[/Path]
1148 $PROGNAME create --dest <string> --source <string> [OPTIONS]
1150 Create a new sync-job
1153 The destination target is like [IP]:<Pool>[/Path]
1156 The name of the user on the destination target, root by default
1159 Maximal sync speed in kBytes/s, default is unlimited
1162 The number of snapshots to keep until older ones are erased.
1163 The default is 1, use 0 for unlimited.
1166 The name of the sync job, if not set it is default
1168 --prepend-storage-id
1169 If specified, prepend the storage ID to the destination's path(s).
1172 If specified, skip the first sync.
1175 The source can be an <VMID> or [IP:]<ZFSPool>[/Path]
1177 --source-user string
1178 The (ssh) user-name on the source target, root by default
1181 If specified, include the dataset's properties in the stream.
1183 --dest-config-path string
1184 Specifies a custom config path on the destination target.
1185 The default is /var/lib/pve-zsync
1188 $PROGNAME sync --dest <string> --source <string> [OPTIONS]\n
1193 The destination target is like [IP:]<Pool>[/Path]
1196 The (ssh) user-name on the destination target, root by default
1199 The maximal sync speed in kBytes/s, default is unlimited
1202 The number of snapshots to keep until older ones are erased.
1203 The default is 1, use 0 for unlimited.
1206 The name of the sync job, if not set it is 'default'.
1207 It is only necessary if scheduler allready contains this source.
1209 --prepend-storage-id
1210 If specified, prepend the storage ID to the destination's path(s).
1213 The source can either be an <VMID> or [IP:]<ZFSPool>[/Path]
1215 --source-user string
1216 The name of the user on the source target, root by default
1219 If specified, print out the sync progress.
1222 If specified, include the dataset's properties in the stream.
1224 --dest-config-path string
1225 Specifies a custom config path on the destination target.
1226 The default is /var/lib/pve-zsync
1231 Get a List of all scheduled Sync Jobs
1236 Get the status of all scheduled Sync Jobs
1239 $PROGNAME help <cmd> [OPTIONS]
1241 Get help about specified command.
1244 Command name to get help about.
1247 Verbose output format.
1250 $PROGNAME enable --source <string> [OPTIONS]
1252 Enable a sync-job and reset all job-errors, if any.
1255 name of the sync job, if not set it is default
1258 the source can be an <VMID> or [IP:]<ZFSPool>[/Path]
1261 $PROGNAME disable --source <string> [OPTIONS]
1263 Disables (pauses) a sync-job
1266 name of the sync-job, if not set it is default
1269 the source can be an <VMID> or [IP:]<ZFSPool>[/Path]
1271 printpod
=> "$PROGNAME printpod\n\n\tinternal command",
1277 } elsif (!$cmd_help->{$command}) {
1278 print "ERROR: unknown command '$command'";
1283 my $param = parse_argv
(@arg);
1287 die "$cmd_help->{$command}\n" if !$param->{$_};
1291 if ($command eq 'destroy') {
1292 check_params
(qw(source));
1294 check_target
($param->{source
});
1295 destroy_job
($param);
1297 } elsif ($command eq 'sync') {
1298 check_params
(qw(source dest));
1300 check_target
($param->{source
});
1301 check_target
($param->{dest
});
1304 } elsif ($command eq 'create') {
1305 check_params
(qw(source dest));
1307 check_target
($param->{source
});
1308 check_target
($param->{dest
});
1311 } elsif ($command eq 'status') {
1314 } elsif ($command eq 'list') {
1317 } elsif ($command eq 'help') {
1318 my $help_command = $ARGV[1];
1320 if ($help_command && $cmd_help->{$help_command}) {
1321 die "$cmd_help->{$help_command}\n";
1324 if ($param->{verbose
}) {
1325 exec("man $PROGNAME");
1332 } elsif ($command eq 'enable') {
1333 check_params
(qw(source));
1335 check_target
($param->{source
});
1338 } elsif ($command eq 'disable') {
1339 check_params
(qw(source));
1341 check_target
($param->{source
});
1342 disable_job
($param);
1344 } elsif ($command eq 'printpod') {
1351 print("ERROR:\tno command specified\n") if !$help;
1352 print("USAGE:\t$PROGNAME <COMMAND> [ARGS] [OPTIONS]\n");
1353 print("\t$PROGNAME help [<cmd>] [OPTIONS]\n\n");
1354 print("\t$PROGNAME create --dest <string> --source <string> [OPTIONS]\n");
1355 print("\t$PROGNAME destroy --source <string> [OPTIONS]\n");
1356 print("\t$PROGNAME disable --source <string> [OPTIONS]\n");
1357 print("\t$PROGNAME enable --source <string> [OPTIONS]\n");
1358 print("\t$PROGNAME list\n");
1359 print("\t$PROGNAME status\n");
1360 print("\t$PROGNAME sync --dest <string> --source <string> [OPTIONS]\n");
1365 parse_target
($target);
1370 my $synopsis = join("\n", sort values %$cmd_help);
1371 my $commands = join(", ", sort keys %$cmd_help);
1376 pve-zsync - PVE ZFS Storage Sync Tool
1380 pve-zsync <COMMAND> [ARGS] [OPTIONS]
1382 Where <COMMAND> can be one of: $commands
1386 The pve-zsync tool can help you to sync your VMs or directories stored on ZFS
1387 between multiple servers.
1389 pve-zsync is able to automatically configure CRON jobs, so that a periodic sync
1390 will be automatically triggered.
1391 The default sync interval is 15 min, if you want to change this value you can
1392 do this in F</etc/cron.d/pve-zsync>. If you need help to configure CRON tabs, see
1395 =head1 COMMANDS AND OPTIONS
1401 Adds a job for syncing the local VM 100 to a remote server's ZFS pool named "tank":
1402 pve-zsync create --source=100 -dest=192.168.1.2:tank
1404 =head1 IMPORTANT FILES
1406 Cron jobs and config are stored in F</etc/cron.d/pve-zsync>
1408 The VM configuration itself gets copied to the destination machines
1409 F</var/lib/pve-zsync/> path.
1411 =head1 COPYRIGHT AND DISCLAIMER
1413 Copyright (C) 2007-2021 Proxmox Server Solutions GmbH
1415 This program is free software: you can redistribute it and/or modify it under
1416 the terms of the GNU Affero General Public License as published by the Free
1417 Software Foundation, either version 3 of the License, or (at your option) any
1420 This program is distributed in the hope that it will be useful, but WITHOUT ANY
1421 WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
1422 PARTICULAR PURPOSE. See the GNU Affero General Public License for more
1425 You should have received a copy of the GNU Affero General Public License along
1426 with this program. If not, see <http://www.gnu.org/licenses/>.