]>
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,
257 dest_config_path
=> undef,
260 my ($ret) = GetOptionsFromArray
(
262 'dest=s' => \
$param->{dest
},
263 'source=s' => \
$param->{source
},
264 'verbose' => \
$param->{verbose
},
265 'limit=i' => \
$param->{limit
},
266 'maxsnap=i' => \
$param->{maxsnap
},
267 'dest-maxsnap=i' => \
$param->{dest_maxsnap
},
268 'name=s' => \
$param->{name
},
269 'skip' => \
$param->{skip
},
270 'method=s' => \
$param->{method},
271 'source-user=s' => \
$param->{source_user
},
272 'dest-user=s' => \
$param->{dest_user
},
273 'prepend-storage-id' => \
$param->{prepend_storage_id
},
274 'compressed' => \
$param->{compressed
},
275 'properties' => \
$param->{properties
},
276 'dest-config-path=s' => \
$param->{dest_config_path
},
279 die "can't parse options\n" if $ret == 0;
281 $param->{name
} //= "default";
282 $param->{maxsnap
} //= 1;
283 $param->{method} //= "ssh";
284 $param->{source_user
} //= "root";
285 $param->{dest_user
} //= "root";
290 sub add_state_to_job
{
293 my $states = read_state
();
294 my $state = $states->{$job->{source
}}->{$job->{name
}};
296 $job->{state} = $state->{state};
297 $job->{lsync
} = $state->{lsync
};
298 $job->{vm_type
} = $state->{vm_type
};
299 $job->{instance_id
} = $state->{instance_id
};
301 for (my $i = 0; $state->{"snap$i"}; $i++) {
302 $job->{"snap$i"} = $state->{"snap$i"};
313 while (my $line = shift(@text)) {
314 my @arg = Text
::ParseWords
::shellwords
($line);
315 my $param = parse_argv
(@arg);
317 if ($param->{source
} && $param->{dest
}) {
318 my $source = delete $param->{source
};
319 my $name = delete $param->{name
};
321 $cfg->{$source}->{$name} = $param;
333 my $source = parse_target
($param->{source
});
335 $dest = parse_target
($param->{dest
}) if $param->{dest
};
337 $job->{name
} = !$param->{name
} ?
"default" : $param->{name
};
338 $job->{dest
} = $param->{dest
} if $param->{dest
};
339 $job->{method} = "local" if !$dest->{ip
} && !$source->{ip
};
340 $job->{method} = "ssh" if !$job->{method};
341 $job->{limit
} = $param->{limit
};
342 $job->{maxsnap
} = $param->{maxsnap
};
343 $job->{dest_maxsnap
} = $param->{dest_maxsnap
};
344 $job->{source
} = $param->{source
};
345 $job->{source_user
} = $param->{source_user
};
346 $job->{dest_user
} = $param->{dest_user
};
347 $job->{prepend_storage_id
} = !!$param->{prepend_storage_id
};
348 $job->{compressed
} = !!$param->{compressed
};
349 $job->{properties
} = !!$param->{properties
};
350 $job->{dest_config_path
} = $param->{dest_config_path
} if $param->{dest_config_path
};
358 make_path
$CONFIG_PATH;
359 my $new_fh = IO
::File-
>new("> $STATE");
360 die "Could not create $STATE: $!\n" if !$new_fh;
366 my $text = read_file
($STATE, 1);
367 return decode_json
($text);
373 my $text = eval { read_file
($STATE, 1); };
375 my $out_fh = IO
::File-
>new("> $STATE.new");
376 die "Could not open file ${STATE}.new: $!\n" if !$out_fh;
381 $states = decode_json
($text);
382 $state = $states->{$job->{source
}}->{$job->{name
}};
385 if ($job->{state} ne "del") {
386 $state->{state} = $job->{state};
387 $state->{lsync
} = $job->{lsync
};
388 $state->{instance_id
} = $job->{instance_id
};
389 $state->{vm_type
} = $job->{vm_type
};
391 for (my $i = 0; $job->{"snap$i"} ; $i++) {
392 $state->{"snap$i"} = $job->{"snap$i"};
394 $states->{$job->{source
}}->{$job->{name
}} = $state;
397 delete $states->{$job->{source
}}->{$job->{name
}};
398 delete $states->{$job->{source
}} if !keys %{$states->{$job->{source
}}};
401 $text = encode_json
($states);
405 rename "$STATE.new", $STATE;
417 my $header = "SHELL=/bin/sh\n";
418 $header .= "PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin\n\n";
420 my $current = read_file
($CRONJOBS, 0);
422 foreach my $line (@{$current}) {
424 if ($line =~ m/source $job->{source} .*name $job->{name} /) {
426 next if $job->{state} eq "del";
427 $text .= format_job
($job, $line);
429 if (($line_no < 3) && ($line =~ /^(PATH|SHELL)/ )) {
438 $text = "$header$text";
442 $text .= format_job
($job);
444 my $new_fh = IO
::File-
>new("> ${CRONJOBS}.new");
445 die "Could not open file ${CRONJOBS}.new: $!\n" if !$new_fh;
447 print $new_fh $text or die "can't write to $CRONJOBS.new: $!\n";
450 rename "${CRONJOBS}.new", $CRONJOBS or die "can't move $CRONJOBS.new: $!\n";
454 my ($job, $line) = @_;
457 if ($job->{state} eq "stopped") {
461 $line =~ /^#*\s*((?:\S+\s+){4}\S+)\s+root/;
464 $text .= "*/$INTERVAL * * * *";
467 $text .= " $PROGNAME sync --source $job->{source} --dest $job->{dest}";
468 $text .= " --name $job->{name} --maxsnap $job->{maxsnap}";
469 $text .= " --dest-maxsnap $job->{dest_maxsnap}" if defined($job->{dest_maxsnap
});
470 $text .= " --limit $job->{limit}" if $job->{limit
};
471 $text .= " --method $job->{method}";
472 $text .= " --verbose" if $job->{verbose
};
473 $text .= " --source-user $job->{source_user}";
474 $text .= " --dest-user $job->{dest_user}";
475 $text .= " --prepend-storage-id" if $job->{prepend_storage_id
};
476 $text .= " --compressed" if $job->{compressed
};
477 $text .= " --properties" if $job->{properties
};
478 $text .= " --dest-config-path $job->{dest_config_path}" if $job->{dest_config_path
};
486 my $cfg = read_cron
();
488 my $list = sprintf("%-25s%-25s%-10s%-20s%-6s%-5s\n" , "SOURCE", "NAME", "STATE", "LAST SYNC", "TYPE", "CON");
490 my $states = read_state
();
491 foreach my $source (sort keys%{$cfg}) {
492 foreach my $name (sort keys%{$cfg->{$source}}) {
493 $list .= sprintf("%-25s", cut_target_width
($source, 25));
494 $list .= sprintf("%-25s", cut_target_width
($name, 25));
495 $list .= sprintf("%-10s", $states->{$source}->{$name}->{state});
496 $list .= sprintf("%-20s", $states->{$source}->{$name}->{lsync
});
497 $list .= sprintf("%-6s", defined($states->{$source}->{$name}->{vm_type
}) ?
$states->{$source}->{$name}->{vm_type
} : "undef");
498 $list .= sprintf("%-5s\n", $cfg->{$source}->{$name}->{method});
506 my ($target, $user) = @_;
508 return undef if !defined($target->{vmid
});
510 my $conf_fn = "$target->{vmid}.conf";
513 my @cmd = ('ssh', "$user\@$target->{ip}", '--', '/bin/ls');
514 return "qemu" if eval { run_cmd
([@cmd, "$QEMU_CONF/$conf_fn"]) };
515 return "lxc" if eval { run_cmd
([@cmd, "$LXC_CONF/$conf_fn"]) };
517 return "qemu" if -f
"$QEMU_CONF/$conf_fn";
518 return "lxc" if -f
"$LXC_CONF/$conf_fn";
527 locked
("$CONFIG_PATH/cron_and_state.lock", sub {
528 my $cfg = read_cron
();
530 my $job = param_to_job
($param);
532 $job->{state} = "ok";
535 my $source = parse_target
($param->{source
});
536 my $dest = parse_target
($param->{dest
});
538 if (my $ip = $dest->{ip
}) {
539 run_cmd
(['ssh-copy-id', '-i', '/root/.ssh/id_rsa.pub', "$param->{dest_user}\@$ip"]);
542 if (my $ip = $source->{ip
}) {
543 run_cmd
(['ssh-copy-id', '-i', '/root/.ssh/id_rsa.pub', "$param->{source_user}\@$ip"]);
546 die "Pool $dest->{all} does not exist\n"
547 if !check_dataset_exists
($dest->{all
}, $dest->{ip
}, $param->{dest_user
});
549 if (!defined($source->{vmid
})) {
550 die "Pool $source->{all} does not exist\n"
551 if !check_dataset_exists
($source->{all
}, $source->{ip
}, $param->{source_user
});
554 my $vm_type = vm_exists
($source, $param->{source_user
});
555 $job->{vm_type
} = $vm_type;
556 $source->{vm_type
} = $vm_type;
558 die "VM $source->{vmid} doesn't exist\n" if $source->{vmid
} && !$vm_type;
560 die "Config already exists\n" if $cfg->{$job->{source
}}->{$job->{name
}};
562 #check if vm has zfs disks if not die;
563 get_disks
($source, $param->{source_user
}) if $source->{vmid
};
567 }); #cron and state lock
569 return if $param->{skip
};
571 eval { sync
($param) };
581 my $cfg = read_cron
();
583 if (!$cfg->{$param->{source
}}->{$param->{name
}}) {
584 die "Job with source $param->{source} and name $param->{name} does not exist\n" ;
586 my $job = $cfg->{$param->{source
}}->{$param->{name
}};
587 $job->{name
} = $param->{name
};
588 $job->{source
} = $param->{source
};
589 $job = add_state_to_job
($job);
597 locked
("$CONFIG_PATH/cron_and_state.lock", sub {
598 my $job = get_job
($param);
599 $job->{state} = "del";
606 sub get_instance_id
{
609 my $stat = read_file
("/proc/$pid/stat", 1)
610 or die "unable to read process stats\n";
611 my $boot_id = read_file
("/proc/sys/kernel/random/boot_id", 1)
612 or die "unable to read boot ID\n";
614 my $stats = [ split(/\s+/, $stat) ];
615 my $starttime = $stats->[21];
618 return "${pid}:${starttime}:${boot_id}";
621 sub instance_exists
{
622 my ($instance_id) = @_;
624 if (defined($instance_id) && $instance_id =~ m/^([1-9][0-9]*):/) {
626 my $actual_id = eval { get_instance_id
($pid); };
627 return defined($actual_id) && $actual_id eq $instance_id;
638 locked
("$CONFIG_PATH/cron_and_state.lock", sub {
639 eval { $job = get_job
($param) };
642 my $state = $job->{state} // 'ok';
643 $state = 'ok' if !instance_exists
($job->{instance_id
});
645 if ($state eq "syncing" || $state eq "waiting") {
646 die "Job --source $param->{source} --name $param->{name} is already scheduled to sync\n";
649 $job->{state} = "waiting";
650 $job->{instance_id
} = $INSTANCE_ID;
656 locked
("$CONFIG_PATH/sync.lock", sub {
658 my $date = get_date
();
664 locked
("$CONFIG_PATH/cron_and_state.lock", sub {
665 #job might've changed while we waited for the sync lock, but we can be sure it's not syncing
666 eval { $job = get_job
($param); };
668 if ($job && defined($job->{state}) && $job->{state} eq "stopped") {
669 die "Job --source $param->{source} --name $param->{name} has been disabled\n";
672 $dest = parse_target
($param->{dest
});
673 $source = parse_target
($param->{source
});
675 $vm_type = vm_exists
($source, $param->{source_user
});
676 $source->{vm_type
} = $vm_type;
679 $job->{state} = "syncing";
680 $job->{vm_type
} = $vm_type if !$job->{vm_type
};
683 }); #cron and state lock
685 my $sync_path = sub {
686 my ($source, $dest, $job, $param, $date) = @_;
688 my $dest_dataset = target_dataset
($source, $dest);
690 ($dest->{old_snap
}, $dest->{last_snap
}) = snapshot_get
(
692 $param->{dest_maxsnap
} // $param->{maxsnap
},
698 ($source->{old_snap
}) = snapshot_get
(
703 $param->{source_user
},
706 prepare_prepended_target
($source, $dest, $param->{dest_user
}) if defined($dest->{prepend
});
708 snapshot_add
($source, $dest, $param->{name
}, $date, $param->{source_user
}, $param->{dest_user
});
710 send_image
($source, $dest, $param);
712 for my $old_snap (@{$source->{old_snap
}}) {
713 snapshot_destroy
($source->{all
}, $old_snap, $source->{ip
}, $param->{source_user
});
716 for my $old_snap (@{$dest->{old_snap
}}) {
717 snapshot_destroy
($dest_dataset, $old_snap, $dest->{ip
}, $param->{dest_user
});
722 if ($source->{vmid
}) {
723 die "VM $source->{vmid} doesn't exist\n" if !$vm_type;
724 die "source-user has to be root for syncing VMs\n" if ($param->{source_user
} ne "root");
725 my $disks = get_disks
($source, $param->{source_user
});
727 foreach my $disk (sort keys %{$disks}) {
728 $source->{all
} = $disks->{$disk}->{all
};
729 $source->{pool
} = $disks->{$disk}->{pool
};
730 $source->{path
} = $disks->{$disk}->{path
} if $disks->{$disk}->{path
};
731 $source->{last_part
} = $disks->{$disk}->{last_part
};
733 $dest->{prepend
} = $disks->{$disk}->{storage_id
}
734 if $param->{prepend_storage_id
};
736 &$sync_path($source, $dest, $job, $param, $date);
738 if ($param->{method} eq "ssh" && ($source->{ip
} || $dest->{ip
})) {
739 send_config
($source, $dest,'ssh', $param->{source_user
}, $param->{dest_user
}, $param->{dest_config_path
});
741 send_config
($source, $dest,'local', $param->{source_user
}, $param->{dest_user
}, $param->{dest_config_path
});
744 &$sync_path($source, $dest, $job, $param, $date);
748 locked
("$CONFIG_PATH/cron_and_state.lock", sub {
749 eval { $job = get_job
($param); };
751 $job->{state} = "error";
752 delete $job->{instance_id
};
756 print "Job --source $param->{source} --name $param->{name} got an ERROR!!!\nERROR Message:\n";
760 locked
("$CONFIG_PATH/cron_and_state.lock", sub {
761 eval { $job = get_job
($param); };
763 if (defined($job->{state}) && $job->{state} eq "stopped") {
764 $job->{state} = "stopped";
766 $job->{state} = "ok";
768 $job->{lsync
} = $date;
769 delete $job->{instance_id
};
777 my ($dataset, $max_snap, $name, $ip, $user) = @_;
780 push @$cmd, 'ssh', "$user\@$ip", '--', if $ip;
781 push @$cmd, 'zfs', 'list', '-r', '-t', 'snapshot', '-Ho', 'name', '-S', 'creation';
782 push @$cmd, $dataset;
785 eval {$raw = run_cmd
($cmd)};
786 if (my $erro =$@) { #this means the volume doesn't exist on dest yet
792 my $last_snap = undef;
795 while ($raw && $raw =~ s/^(.*?)(\n|$)//) {
797 if ($line =~ m/@(.*)$/) {
798 $last_snap = $1 if (!$last_snap);
800 if ($line =~ m/(rep_\Q${name}\E_\d{4}-\d{2}-\d{2}_\d{2}:\d{2}:\d{2})$/) {
801 # interpreted as infinity
802 last if $max_snap <= 0;
807 if ($index >= $max_snap) {
808 push @{$old_snap}, $snap;
813 return ($old_snap, $last_snap) if $last_snap;
819 my ($source, $dest, $name, $date, $source_user, $dest_user) = @_;
821 my $snap_name = "rep_$name\_".$date;
823 $source->{new_snap
} = $snap_name;
825 my $path = "$source->{all}\@$snap_name";
828 push @$cmd, 'ssh', "$source_user\@$source->{ip}", '--', if $source->{ip
};
829 push @$cmd, 'zfs', 'snapshot', $path;
835 snapshot_destroy
($source->{all
}, $snap_name, $source->{ip
}, $source_user);
841 my ($target, $user) = @_;
844 push @$cmd, 'ssh', "$user\@$target->{ip}", '--', if $target->{ip
};
846 if ($target->{vm_type
} eq 'qemu') {
847 push @$cmd, 'qm', 'config', $target->{vmid
};
848 } elsif ($target->{vm_type
} eq 'lxc') {
849 push @$cmd, 'pct', 'config', $target->{vmid
};
851 die "VM Type unknown\n";
854 my $res = run_cmd
($cmd);
856 my $disks = parse_disks
($res, $target->{ip
}, $target->{vm_type
}, $user);
863 print "Start CMD\n" if $DEBUG;
864 print Dumper
$cmd if $DEBUG;
865 if (ref($cmd) eq 'ARRAY') {
866 $cmd = join(' ', map { ref($_) ?
$$_ : shell_quote
($_) } @$cmd);
868 my $output = `$cmd 2>&1`;
870 die "COMMAND:\n\t$cmd\nGET ERROR:\n\t$output" if 0 != $?;
873 print Dumper
$output if $DEBUG;
874 print "END CMD\n" if $DEBUG;
879 my ($text, $ip, $vm_type, $user) = @_;
884 while ($text && $text =~ s/^(.*?)(\n|$)//) {
887 next if $line =~ /media=cdrom/;
888 next if $line !~ m/$DISK_KEY_RE/;
890 #QEMU if backup is not set include in sync
891 next if $vm_type eq 'qemu' && ($line =~ m/backup=(?i:0|no|off|false)/);
893 #LXC if backup is not set do no in sync
894 next if $vm_type eq 'lxc' && ($line =~ m/^mp\d:/) && ($line !~ m/backup=(?i:1|yes|on|true)/);
898 if($line =~ m/$DISK_KEY_RE(.*)$/) {
899 my @parameter = split(/,/,$1);
901 foreach my $opt (@parameter) {
902 if ($opt =~ m/^(?:file=|volume=)?([^:]+):([A-Za-z0-9\-]+)$/){
909 if (!defined($disk) || !defined($stor)) {
910 print "Disk: \"$line\" has no valid zfs dataset format
and will be skipped
\n";
915 push @$cmd, 'ssh', "$user\@$ip", '--' if $ip;
916 push @$cmd, 'pvesm', 'path', "$stor:$disk";
917 my $path = run_cmd($cmd);
919 die "Get
no path from pvesm path
$stor:$disk\n" if !$path;
921 $disks->{$num}->{storage_id} = $stor;
923 if ($vm_type eq 'qemu' && $path =~ m/^\/dev\/zvol\/(\w+.*)(\/$disk)$/) {
925 my @array = split('/', $1);
926 $disks->{$num}->{pool} = shift(@array);
927 $disks->{$num}->{all} = $disks->{$num}->{pool};
929 $disks->{$num}->{path} = join('/', @array);
930 $disks->{$num}->{all} .= "\
/$disks->{$num}->{path
}";
932 $disks->{$num}->{last_part} = $disk;
933 $disks->{$num}->{all} .= "\
/$disk";
936 } elsif ($vm_type eq 'lxc' && $path =~ m/^\/(\w+.+)(\/(\w+.*))*(\/$disk)$/) {
938 $disks->{$num}->{pool} = $1;
939 $disks->{$num}->{all} = $disks->{$num}->{pool};
942 $disks->{$num}->{path} = $3;
943 $disks->{$num}->{all} .= "\
/$disks->{$num}->{path
}";
946 $disks->{$num}->{last_part} = $disk;
947 $disks->{$num}->{all} .= "\
/$disk";
952 die "ERROR
: in path
\n";
956 die "Vm include
no disk on zfs
.\n" if !$disks->{0};
960 # how the corresponding dataset is named on the target
962 my ($source, $dest) = @_;
964 my $target = "$dest->{all
}";
965 $target .= "/$dest->{prepend
}" if defined($dest->{prepend});
966 $target .= "/$source->{last_part
}" if $source->{last_part};
972 # create the parent dataset for the actual target
973 sub prepare_prepended_target {
974 my ($source, $dest, $dest_user) = @_;
976 die "internal error
- not a prepended target
\n" if !defined($dest->{prepend});
978 # The parent dataset shouldn't be the actual target.
979 die "internal error
- no last_part
for source
\n" if !$source->{last_part};
981 my $target = "$dest->{all
}/$dest->{prepend
}";
984 return if check_dataset_exists($target, $dest->{ip}, $dest_user);
986 create_file_system($target, $dest->{ip}, $dest_user);
989 sub snapshot_destroy {
990 my ($dataset, $snap, $ip, $user) = @_;
992 my @zfscmd = ('zfs', 'destroy');
993 my $snapshot = "$dataset\@$snap";
997 run_cmd(['ssh', "$user\@$ip", '--', @zfscmd, $snapshot]);
999 run_cmd([@zfscmd, $snapshot]);
1002 if (my $erro = $@) {
1007 # check if snapshot for incremental sync exist on source side
1008 sub snapshot_exist {
1009 my ($source , $dest, $method, $source_user) = @_;
1012 push @$cmd, 'ssh', "$source_user\@$source->{ip
}", '--' if $source->{ip};
1013 push @$cmd, 'zfs', 'list', '-rt', 'snapshot', '-Ho', 'name';
1015 my $path = $source->{all};
1016 $path .= "\
@$dest->{last_snap
}";
1020 eval {run_cmd($cmd)};
1029 my ($source, $dest, $param) = @_;
1033 push @$cmd, 'ssh', '-o', 'BatchMode=yes', "$param->{source_user
}\
@$source->{ip
}", '--' if $source->{ip};
1034 push @$cmd, 'zfs', 'send';
1035 push @$cmd, '-L', if $param->{compressed}; # no effect if dataset never had large recordsize
1036 push @$cmd, '-c', if $param->{compressed};
1037 push @$cmd, '-p', if $param->{properties};
1038 push @$cmd, '-v' if $param->{verbose};
1040 if($dest->{last_snap} && snapshot_exist($source , $dest, $param->{method}, $param->{source_user})) {
1041 push @$cmd, '-i', "$source->{all
}\
@$dest->{last_snap
}";
1043 push @$cmd, '--', "$source->{all
}\
@$source->{new_snap
}";
1045 if ($param->{limit}){
1046 my $bwl = $param->{limit}*1024;
1047 push @$cmd, \'|', 'cstream', '-t', $bwl;
1049 my $target = target_dataset($source, $dest);
1052 push @$cmd, 'ssh', '-o', 'BatchMode=yes', "$param->{dest_user
}\
@$dest->{ip
}", '--' if $dest->{ip};
1053 push @$cmd, 'zfs', 'recv', '-F', '--';
1054 push @$cmd, "$target";
1060 if (my $erro = $@) {
1061 snapshot_destroy($source->{all}, $source->{new_snap}, $source->{ip}, $param->{source_user});
1068 my ($source, $dest, $method, $source_user, $dest_user, $dest_config_path) = @_;
1070 my $source_target = $source->{vm_type} eq 'qemu' ? "$QEMU_CONF/$source->{vmid
}.conf
": "$LXC_CONF/$source->{vmid
}.conf
";
1071 my $dest_target_new ="$source->{vmid
}.conf
.$source->{vm_type
}.$source->{new_snap
}";
1073 my $config_dir = $dest_config_path // $CONFIG_PATH;
1074 $config_dir .= "/$dest->{last_part
}" if $dest->{last_part};
1076 $dest_target_new = $config_dir.'/'.$dest_target_new;
1078 if ($method eq 'ssh'){
1079 if ($dest->{ip} && $source->{ip}) {
1080 run_cmd(['ssh', "$dest_user\@$dest->{ip
}", '--', 'mkdir', '-p', '--', $config_dir]);
1081 run_cmd(['scp', '--', "$source_user\@[$source->{ip
}]:$source_target", "$dest_user\@[$dest->{ip
}]:$dest_target_new"]);
1082 } elsif ($dest->{ip}) {
1083 run_cmd(['ssh', "$dest_user\@$dest->{ip
}", '--', 'mkdir', '-p', '--', $config_dir]);
1084 run_cmd(['scp', '--', $source_target, "$dest_user\@[$dest->{ip
}]:$dest_target_new"]);
1085 } elsif ($source->{ip}) {
1086 run_cmd(['mkdir', '-p', '--', $config_dir]);
1087 run_cmd(['scp', '--', "$source_user\@[$source->{ip
}]:$source_target", $dest_target_new]);
1090 for my $old_snap (@{$dest->{old_snap}}) {
1091 my $dest_target_old ="${config_dir
}/$source->{vmid
}.conf
.$source->{vm_type
}.${old_snap
}";
1093 run_cmd(['ssh', "$dest_user\@$dest->{ip
}", '--', 'rm', '-f', '--', $dest_target_old]);
1095 run_cmd(['rm', '-f', '--', $dest_target_old]);
1098 } elsif ($method eq 'local') {
1099 run_cmd(['mkdir', '-p', '--', $config_dir]);
1100 run_cmd(['cp', $source_target, $dest_target_new]);
1105 my ($sec, $min, $hour, $mday, $mon, $year, $wday, $yday, $isdst) = localtime(time);
1106 my $datestamp = sprintf ("%04d-%02d-%02d_%02d:%02d:%02d", $year+1900, $mon+1, $mday, $hour, $min, $sec);
1112 my $cfg = read_cron();
1114 my $status_list = sprintf("%-25s
%-25s
%-10s
\n", "SOURCE
", "NAME
", "STATUS
");
1116 my $states = read_state();
1118 foreach my $source (sort keys%{$cfg}) {
1119 foreach my $sync_name (sort keys%{$cfg->{$source}}) {
1120 $status_list .= sprintf("%-25s
", cut_target_width($source, 25));
1121 $status_list .= sprintf("%-25s
", cut_target_width($sync_name, 25));
1122 $status_list .= "$states->{$source}->{$sync_name}->{state}\n";
1126 return $status_list;
1132 locked("$CONFIG_PATH/cron_and_state.lock", sub {
1133 my $job = get_job($param);
1134 $job->{state} = "ok
";
1143 locked("$CONFIG_PATH/cron_and_state.lock", sub {
1144 my $job = get_job($param);
1145 $job->{state} = "stopped
";
1153 $PROGNAME destroy --source <string> [OPTIONS]
1155 Remove a sync Job from the scheduler
1158 The name of the sync job, if not set 'default' is used.
1161 The source can be an <VMID> or [IP:]<ZFSPool>[/Path]
1164 $PROGNAME create --dest <string> --source <string> [OPTIONS]
1166 Create a new sync-job
1169 The destination target is like [IP]:<Pool>[/Path]
1172 The name of the user on the destination target, root by default
1175 Maximal sync speed in kBytes/s, default is unlimited
1178 The number of snapshots to keep until older ones are erased.
1179 The default is 1, use 0 for unlimited.
1181 --dest-maxsnap integer
1182 Override maxsnap for the destination dataset.
1185 The name of the sync job, if not set it is default
1187 --prepend-storage-id
1188 If specified, prepend the storage ID to the destination's path(s).
1191 If specified, skip the first sync.
1194 The source can be an <VMID> or [IP:]<ZFSPool>[/Path]
1196 --source-user string
1197 The (ssh) user-name on the source target, root by default
1200 If specified, send data without decompressing first. If features lz4_compress,
1201 zstd_compress or large_blocks are in use by the source, they need to be enabled on
1205 If specified, include the dataset's properties in the stream.
1207 --dest-config-path string
1208 Specifies a custom config path on the destination target.
1209 The default is /var/lib/pve-zsync
1212 $PROGNAME sync --dest <string> --source <string> [OPTIONS]\n
1217 The destination target is like [IP:]<Pool>[/Path]
1220 The (ssh) user-name on the destination target, root by default
1223 The maximal sync speed in kBytes/s, default is unlimited
1226 The number of snapshots to keep until older ones are erased.
1227 The default is 1, use 0 for unlimited.
1229 --dest-maxsnap integer
1230 Override maxsnap for the destination dataset.
1233 The name of the sync job, if not set it is 'default'.
1234 It is only necessary if scheduler allready contains this source.
1236 --prepend-storage-id
1237 If specified, prepend the storage ID to the destination's path(s).
1240 The source can either be an <VMID> or [IP:]<ZFSPool>[/Path]
1242 --source-user string
1243 The name of the user on the source target, root by default
1246 If specified, print out the sync progress.
1249 If specified, send data without decompressing first. If features lz4_compress,
1250 zstd_compress or large_blocks are in use by the source, they need to be enabled on
1254 If specified, include the dataset's properties in the stream.
1256 --dest-config-path string
1257 Specifies a custom config path on the destination target.
1258 The default is /var/lib/pve-zsync
1263 Get a List of all scheduled Sync Jobs
1268 Get the status of all scheduled Sync Jobs
1271 $PROGNAME help <cmd> [OPTIONS]
1273 Get help about specified command.
1276 Command name to get help about.
1279 Verbose output format.
1282 $PROGNAME enable --source <string> [OPTIONS]
1284 Enable a sync-job and reset all job-errors, if any.
1287 name of the sync job, if not set it is default
1290 the source can be an <VMID> or [IP:]<ZFSPool>[/Path]
1293 $PROGNAME disable --source <string> [OPTIONS]
1295 Disables (pauses) a sync-job
1298 name of the sync-job, if not set it is default
1301 the source can be an <VMID> or [IP:]<ZFSPool>[/Path]
1303 printpod
=> "$PROGNAME printpod\n\n\tinternal command",
1309 } elsif (!$cmd_help->{$command}) {
1310 print "ERROR: unknown command '$command'";
1315 my $param = parse_argv
(@arg);
1319 die "$cmd_help->{$command}\n" if !$param->{$_};
1323 if ($command eq 'destroy') {
1324 check_params
(qw(source));
1326 check_target
($param->{source
});
1327 destroy_job
($param);
1329 } elsif ($command eq 'sync') {
1330 check_params
(qw(source dest));
1332 check_target
($param->{source
});
1333 check_target
($param->{dest
});
1336 } elsif ($command eq 'create') {
1337 check_params
(qw(source dest));
1339 check_target
($param->{source
});
1340 check_target
($param->{dest
});
1343 } elsif ($command eq 'status') {
1346 } elsif ($command eq 'list') {
1349 } elsif ($command eq 'help') {
1350 my $help_command = $ARGV[1];
1352 if ($help_command && $cmd_help->{$help_command}) {
1353 die "$cmd_help->{$help_command}\n";
1356 if ($param->{verbose
}) {
1357 exec("man $PROGNAME");
1364 } elsif ($command eq 'enable') {
1365 check_params
(qw(source));
1367 check_target
($param->{source
});
1370 } elsif ($command eq 'disable') {
1371 check_params
(qw(source));
1373 check_target
($param->{source
});
1374 disable_job
($param);
1376 } elsif ($command eq 'printpod') {
1383 print("ERROR:\tno command specified\n") if !$help;
1384 print("USAGE:\t$PROGNAME <COMMAND> [ARGS] [OPTIONS]\n");
1385 print("\t$PROGNAME help [<cmd>] [OPTIONS]\n\n");
1386 print("\t$PROGNAME create --dest <string> --source <string> [OPTIONS]\n");
1387 print("\t$PROGNAME destroy --source <string> [OPTIONS]\n");
1388 print("\t$PROGNAME disable --source <string> [OPTIONS]\n");
1389 print("\t$PROGNAME enable --source <string> [OPTIONS]\n");
1390 print("\t$PROGNAME list\n");
1391 print("\t$PROGNAME status\n");
1392 print("\t$PROGNAME sync --dest <string> --source <string> [OPTIONS]\n");
1397 parse_target
($target);
1402 my $synopsis = join("\n", sort values %$cmd_help);
1403 my $commands = join(", ", sort keys %$cmd_help);
1408 pve-zsync - PVE ZFS Storage Sync Tool
1412 pve-zsync <COMMAND> [ARGS] [OPTIONS]
1414 Where <COMMAND> can be one of: $commands
1418 The pve-zsync tool can help you to sync your VMs or directories stored on ZFS
1419 between multiple servers.
1421 pve-zsync is able to automatically configure CRON jobs, so that a periodic sync
1422 will be automatically triggered.
1423 The default sync interval is 15 min, if you want to change this value you can
1424 do this in F</etc/cron.d/pve-zsync>. If you need help to configure CRON tabs, see
1427 =head1 COMMANDS AND OPTIONS
1433 Adds a job for syncing the local VM 100 to a remote server's ZFS pool named "tank":
1434 pve-zsync create --source=100 -dest=192.168.1.2:tank
1436 =head1 IMPORTANT FILES
1438 Cron jobs and config are stored in F</etc/cron.d/pve-zsync>
1440 The VM configuration itself gets copied to the destination machines
1441 F</var/lib/pve-zsync/> path.
1443 =head1 COPYRIGHT AND DISCLAIMER
1445 Copyright (C) 2007-2021 Proxmox Server Solutions GmbH
1447 This program is free software: you can redistribute it and/or modify it under
1448 the terms of the GNU Affero General Public License as published by the Free
1449 Software Foundation, either version 3 of the License, or (at your option) any
1452 This program is distributed in the hope that it will be useful, but WITHOUT ANY
1453 WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
1454 PARTICULAR PURPOSE. See the GNU Affero General Public License for more
1457 You should have received a copy of the GNU Affero General Public License along
1458 with this program. If not, see <http://www.gnu.org/licenses/>.