Files
proxmox-git-pve-container/src/PVE/VZDump/LXC.pm
T
dualfroz 94c60cf6cd fix #7183: vzdump: handle mount points with a relative path
The path of a mount point does not need to start with a slash, for
example a mount point configured with

  mp0: tank:subvol-999-disk-0,mp=data,size=1G,backup=1

gets mounted at /data in the container when it starts. The backup code
however builds its paths by appending the path of the mount point to
the root directory, which only works with a leading slash. So such a
mount point broke all three backup modes, as it ended up as
'/mnt/vzsnap0/.data' instead of '/mnt/vzsnap0/./data' in snapshot and
stop mode, and as '/proc/<PID>/root//.data' in suspend mode.

Add the missing leading slash when collecting the mount points for the
backup, so that all paths built from it are correct. This also anchors
the exclude pattern of such a mount point if it is excluded from the
backup, which otherwise matched every file or directory with the same
name in suspend mode.

Signed-off-by: Michal Fox <me@dualfroz.com>
2026-10-02 16:38:17 +02:00

604 lines
19 KiB
Perl

package PVE::VZDump::LXC;
use strict;
use warnings;
use File::Basename;
use File::Path;
use POSIX qw(strftime);
use PVE::Cluster qw(cfs_read_file);
use PVE::GuestHelpers;
use PVE::INotify;
use PVE::LXC::Config;
use PVE::LXC::Namespaces;
use PVE::LXC;
use PVE::Storage;
use PVE::Tools;
use PVE::VZDump;
use base qw (PVE::VZDump::Plugin);
my $default_mount_point = "/mnt/vzsnap0";
my $rsync_vm = sub {
my ($self, $task, $to, $text) = @_;
my $disks = $task->{disks};
my $from = $disks->[0]->{dir};
$self->loginfo("starting $text sync $from to $to");
my $opts = $self->{vzdump}->{opts};
my @xattr = $task->{no_xattrs} ? () : ('-X', '-A');
my $rsync = [
'rsync',
'--stats',
'-h',
@xattr,
'--numeric-ids',
'-aH',
'--delete',
'--no-whole-file',
'--sparse',
'--one-file-system',
'--relative',
];
push @$rsync, "--bwlimit=$opts->{bwlimit}" if $opts->{bwlimit};
push @$rsync, map { "--exclude=$_" } @{ $self->{vzdump}->{findexcl} };
push @$rsync, map { "--exclude=$_" } @{ $task->{exclude_dirs} };
# See the rsync(1) manpage for --relative in conjunction with /./ in paths.
# This is the only way to have exclude-dirs work together with the
# --one-file-system option.
# This way we can pass multiple source paths and tell rsync which directory
# they're supposed to be relative to.
# Otherwise with eg. using multiple rsync commands means the --exclude
# directives need to be modified for every command as they are meant to be
# relative to the rootdir, while rsync treats them as relative to the
# source dir.
foreach my $disk (@$disks) {
push @$rsync, "$from/.$disk->{mp}";
}
my $transferred = '';
my $outfunc = sub {
return if $_[0] !~ /^Total transferred file size: (.+)$/;
$transferred = $1;
};
my $errfunc = sub { $self->logerr($_[0]); };
my $starttime = time();
PVE::Tools::run_command([@$rsync, $to], outfunc => $outfunc, errfunc => $errfunc);
my $delay = time() - $starttime;
$self->loginfo("$text sync finished - transferred $transferred in ${delay}s");
};
sub new {
my ($class, $vzdump) = @_;
PVE::VZDump::check_bin('lxc-stop');
PVE::VZDump::check_bin('lxc-start');
my $self = bless {}, $class;
$self->{vzdump} = $vzdump;
$self->{storecfg} = PVE::Storage::config();
$self->{vmlist} = PVE::LXC::config_list();
return $self;
}
sub type {
return 'lxc';
}
sub vm_status {
my ($self, $vmid) = @_;
my $running = PVE::LXC::check_running($vmid) ? 1 : 0;
return wantarray ? ($running, $running ? 'running' : 'stopped') : $running;
}
my $check_mountpoint_empty = sub {
my ($mountpoint) = @_;
die "mount point '$mountpoint' is not a directory\n" if !-d $mountpoint;
PVE::Tools::dir_glob_foreach(
$mountpoint,
qr/.*/,
sub {
my $entry = shift;
return if $entry eq '.' || $entry eq '..';
die "mount point '$mountpoint' not empty\n";
},
);
};
sub prepare {
my ($self, $task, $vmid, $mode) = @_;
my $conf = $self->{vmlist}->{$vmid} = PVE::LXC::Config->load_config($vmid);
my $storage_cfg = $self->{storecfg};
$self->loginfo("CT Name: $conf->{hostname}")
if defined($conf->{hostname});
my $running = PVE::LXC::check_running($vmid);
my $disks = $task->{disks} = [];
my $exclude_dirs = $task->{exclude_dirs} = [];
$task->{hostname} = $conf->{'hostname'} || "CT$vmid";
my ($id_map, $root_uid, $root_gid) = PVE::LXC::parse_id_maps($conf);
$task->{userns_cmd} = PVE::LXC::userns_command($id_map);
$task->{id_map} = $id_map;
$task->{root_uid} = $root_uid;
$task->{root_gid} = $root_gid;
my $volids = $task->{volids} = [];
my $backup_volumes = PVE::LXC::Config->get_backup_volumes($conf);
for my $volume (@{$backup_volumes}) {
my $name = $volume->{key};
my $included = $volume->{included};
my $volume_config = $volume->{volume_config};
my $volid = $volume_config->{volume};
my $mount = $volume_config->{mp};
next if !$volid || !$mount;
# the path of a mount point is always relative to the root of the container, but it
# does not need to start with a slash, which all the paths built below rely on
$mount = $volume_config->{mp} = "/$mount" if $mount !~ m!^/!;
if (!$included) {
my $type = $volume_config->{type};
push @$exclude_dirs, $mount;
$self->loginfo(
"excluding $type mount point $name ('$mount') from backup ($volume->{reason})");
next;
}
$volume_config->{name} = $name;
# immutable raw base images need RO mount
if ($conf->{template} && !defined($volume_config->{ro})) {
$volume_config->{ro} = 1;
}
$self->loginfo("including mount point $name ('$mount') in backup");
push @$disks, $volume_config;
push @$volids, $volid if $included;
}
if ($mode eq 'snapshot') {
if (!PVE::LXC::Config->has_feature('snapshot', $conf, $storage_cfg, undef, undef, 1)) {
die "mode failure - some volumes do not support snapshots\n";
}
if ($conf->{snapshots} && $conf->{snapshots}->{vzdump}) {
$self->loginfo("found old vzdump snapshot (force removal)");
PVE::LXC::Config->lock_config(
$vmid,
sub {
$self->unlock_vm($vmid);
PVE::LXC::Config->snapshot_delete($vmid, 'vzdump', 1);
$self->lock_vm($vmid);
},
);
}
my $rootdir = $default_mount_point;
mkpath $rootdir;
&$check_mountpoint_empty($rootdir);
# set snapshot_count (freezes CT if snapshot_count > 1)
$task->{snapshot_count} = scalar(@$volids);
} elsif ($mode eq 'stop') {
my $rootdir = $default_mount_point;
mkpath $rootdir;
&$check_mountpoint_empty($rootdir);
} elsif ($mode eq 'suspend') {
my $pid = PVE::LXC::find_lxc_pid($vmid);
foreach my $disk (@$disks) {
$disk->{dir} = "/proc/$pid/root$disk->{mp}";
}
$task->{snapdir} = $task->{tmpdir};
} else {
unlock_vm($self, $vmid);
die "unknown mode '$mode'\n"; # should not happen
}
if ($mode ne 'suspend') {
# If we perform mount operations, let's unshare the mount namespace
# to not influence the running host.
PVE::Tools::unshare(PVE::Tools::CLONE_NEWNS);
PVE::Tools::run_command(['mount', '--make-rslave', '/']);
}
}
sub lock_vm {
my ($self, $vmid) = @_;
PVE::LXC::Config->set_lock($vmid, 'backup');
}
sub unlock_vm {
my ($self, $vmid) = @_;
PVE::LXC::Config->remove_lock($vmid, 'backup');
}
sub snapshot {
my ($self, $task, $vmid) = @_;
$self->loginfo("create storage snapshot 'vzdump'");
# todo: freeze/unfreeze if we have more than one volid
PVE::LXC::Config->lock_config(
$vmid,
sub {
$self->unlock_vm($vmid);
PVE::LXC::Config->snapshot_create($vmid, 'vzdump', 0, "vzdump backup snapshot");
$self->lock_vm($vmid);
},
);
$task->{cleanup}->{remove_snapshot} = 1;
# reload config
my $conf = $self->{vmlist}->{$vmid} = PVE::LXC::Config->load_config($vmid);
die "unable to read vzdump snapshot config - internal error"
if !($conf->{snapshots} && $conf->{snapshots}->{vzdump});
my $disks = $task->{disks};
my $volids = $task->{volids};
my $rootdir = $default_mount_point;
my $storage_cfg = $self->{storecfg};
PVE::Storage::activate_volumes($storage_cfg, $volids, 'vzdump');
foreach my $disk (@$disks) {
$disk->{dir} = "${rootdir}$disk->{mp}";
PVE::LXC::mountpoint_mount(
$disk, $rootdir, $storage_cfg, 'vzdump', $task->{root_uid}, $task->{root_gid},
);
}
$task->{snapdir} = $rootdir;
}
sub copy_data_phase1 {
my ($self, $task) = @_;
if (my $mntinfo = PVE::VZDump::get_mount_info($task->{snapdir})) {
if ($mntinfo->{fstype} =~ /^nfs4?/) {
$self->loginfo("temporary directory is on NFS, disabling xattr and acl"
. " support, consider configuring a local tmpdir via"
. " /etc/vzdump.conf\n");
$task->{no_xattrs} = 1;
}
}
$self->$rsync_vm($task, $task->{snapdir}, "first");
}
sub copy_data_phase2 {
my ($self, $task) = @_;
$self->$rsync_vm($task, $task->{snapdir}, "final");
}
sub stop_vm {
my ($self, $task, $vmid) = @_;
my $opts = $self->{vzdump}->{opts};
my $timeout = $opts->{stopwait} * 60;
PVE::LXC::vm_stop($vmid, 0, $timeout);
}
sub start_vm {
my ($self, $task, $vmid) = @_;
my $conf = PVE::LXC::Config->load_config($vmid);
PVE::LXC::vm_start($vmid, $conf);
}
sub suspend_vm {
my ($self, $task, $vmid) = @_;
PVE::LXC::freeze($vmid);
}
sub resume_vm {
my ($self, $task, $vmid) = @_;
PVE::LXC::thaw($vmid);
}
sub assemble {
my ($self, $task, $vmid) = @_;
my $opts = $self->{vzdump}->{opts};
my $conf = PVE::LXC::Config->load_config($vmid);
delete $conf->{lock};
delete $conf->{snapshots};
delete $conf->{parent};
delete $conf->{pending};
my $tmpdir = $task->{tmpdir};
mkpath "$tmpdir/etc/vzdump/";
PVE::Tools::file_set_contents(
"$tmpdir/etc/vzdump/pct.conf",
PVE::LXC::Config::write_pct_config("/lxc/$vmid.conf", $conf),
);
my $firewall = "/etc/pve/firewall/$vmid.fw";
my $fwconftmp = "$tmpdir/etc/vzdump/pct.fw";
if ($self->{vzdump}->{opts}->{pbs}) {
# fixme: do not store pct.conf and fw.conf into $tmpdir
if (-e $firewall) {
PVE::Tools::file_copy($firewall, $fwconftmp);
}
} else {
if (-e $firewall) {
PVE::Tools::file_copy($firewall, $fwconftmp);
} else {
PVE::Tools::file_set_contents($fwconftmp, '');
}
$task->{fw} = 1;
}
}
sub archive {
my ($self, $task, $vmid, $filename, $comp) = @_;
my $disks = $task->{disks};
my @sources;
if ($task->{mode} eq 'stop') {
my $storage_cfg = $self->{storecfg};
PVE::Storage::activate_volumes($storage_cfg, $task->{volids});
my $rootdir = $default_mount_point;
foreach my $disk (@$disks) {
$disk->{dir} = "${rootdir}$disk->{mp}";
PVE::LXC::mountpoint_mount(
$disk, $rootdir, $storage_cfg, undef, $task->{root_uid}, $task->{root_gid},
);
# add every enabled mountpoint (since we use --one-file-system)
# mp already starts with a / so we only need to add the dot
push @sources, ".$disk->{mp}";
}
$task->{snapdir} = $rootdir;
} elsif ($task->{mode} eq 'snapshot') {
# mounting the vzdump snapshots and setting $snapdir is already done,
# but we need to include all mountpoints here!
foreach my $disk (@$disks) {
push @sources, ".$disk->{mp}";
}
} else {
# the data was rsynced to a temporary location, only use '.' to avoid
# having mountpoints duplicated
push @sources, '.';
}
my $opts = $self->{vzdump}->{opts};
my $snapdir = $task->{snapdir};
my $tmpdir = $task->{tmpdir};
my $userns_cmd = $task->{userns_cmd};
my $findexcl = $self->{vzdump}->{findexcl};
if (my $backup_provider = $self->{vzdump}->{'backup-provider'}) {
$self->loginfo("starting external backup via " . $backup_provider->provider_name());
if (!scalar($task->{id_map}->@*) || $task->{root_uid} == 0 || $task->{root_gid} == 0) {
$self->log(
"warn",
"external backup of privileged container can only be restored as"
. " unprivileged which might not work in all cases",
);
}
my $mechanism = $backup_provider->backup_get_mechanism($vmid, 'lxc');
die "mechanism '$mechanism' requested by backup provider is not supported for containers\n"
if $mechanism ne 'directory';
$self->loginfo("using backup mechanism '$mechanism'");
my $guest_config = PVE::Tools::file_get_contents("$tmpdir/etc/vzdump/pct.conf");
my $firewall_file = "$tmpdir/etc/vzdump/pct.fw";
my $info = {
directory => $snapdir,
sources => [@sources],
'backup-user-id' => $task->{root_uid},
};
$info->{'firewall-config'} = PVE::Tools::file_get_contents($firewall_file)
if -e $firewall_file;
$info->{'bandwidth-limit'} = $opts->{bwlimit} * 1024 if $opts->{bwlimit};
$backup_provider->backup_container_prepare($vmid, $info);
if (scalar($task->{id_map}->@*)) {
PVE::LXC::Namespaces::run_in_userns(
sub {
$backup_provider->backup_container($vmid, $guest_config, $findexcl, $info);
},
$task->{id_map},
);
} else {
$backup_provider->backup_container($vmid, $guest_config, $findexcl, $info);
}
} elsif ($self->{vzdump}->{opts}->{pbs}) {
my $param = [];
push @$param, "pct.conf:$tmpdir/etc/vzdump/pct.conf";
my $fw_conf = "$tmpdir/etc/vzdump/pct.fw";
if (-f $fw_conf) {
push @$param, "fw.conf:$fw_conf";
}
my $rootdir = $snapdir;
push @$param, "root.pxar:$rootdir";
foreach my $disk (@sources) {
push @$param, '--include-dev', "$snapdir/$disk";
}
push @$param, '--skip-lost-and-found' if $userns_cmd;
push @$param, map { "--exclude=$_" } @$findexcl;
push @$param, '--backup-type', 'ct';
push @$param, '--backup-id', $vmid;
push @$param, '--backup-time', $task->{backup_time};
push @$param, '--change-detection-mode', $opts->{"pbs-change-detection-mode"}
if $opts->{"pbs-change-detection-mode"};
if (my $entries_max = $opts->{"performance"}->{"pbs-entries-max"}) {
push $param->@*, '--entries-max', $entries_max;
$self->loginfo(
"set max number of entries in memory for file-based backups to $entries_max");
}
my @storage = ($opts->{scfg}, $opts->{storage});
my $logfunc = sub { my $line = shift; $self->loginfo($line) };
PVE::Storage::PBSPlugin::run_raw_client_cmd(
@storage, 'backup', $param,
logfunc => $logfunc,
userns_cmd => $userns_cmd,
);
# FIXME: move to a pve-common helper, storage has a similar one
my $time_iso8601 = strftime('%FT%TZ', gmtime($task->{backup_time}));
my $snapshot_id = "ct/$vmid/$time_iso8601";
my $res =
eval { PVE::Storage::PBSPlugin::run_client_cmd(@storage, 'files', [$snapshot_id]) }
// [];
$task->{size} = 0;
$task->{size} += $_->{size} for @$res;
} else {
my $tar = [
@$userns_cmd,
'tar',
'cpf',
'-',
'--totals',
@PVE::Storage::Plugin::COMMON_TAR_FLAGS,
'--one-file-system',
'--warning=no-file-ignored',
];
# note: --remove-files does not work because we do not
# backup all files (filters). tar complains:
# Cannot rmdir: Directory not empty
# we disable this optimization for now
#if ($snapdir eq $task->{tmpdir} && $snapdir =~ m|^$opts->{dumpdir}/|) {
# push @$tar, "--remove-files"; # try to save space
#}
# The directory parameter can give an alternative directory as source.
# the second parameter gives the structure in the tar.
push @$tar, "--directory=$tmpdir", './etc/vzdump/pct.conf';
push @$tar, "./etc/vzdump/pct.fw" if $task->{fw};
push @$tar, "--directory=$snapdir";
my @findexcl_no_anchored = ();
my @findexcl_anchored = ();
foreach my $pattern (@{$findexcl}) {
if ($pattern !~ m|^/|) {
push @findexcl_no_anchored, $pattern;
} else {
push @findexcl_anchored, $pattern;
}
# NOTE rsync and proxmox-backup-client will match directories, but not files when there
# is a trailing slash, tar won't match either. For suspend mode, rsync already did the
# exclusion, so no need to warn.
# TODO PVE 9 - consider matching "$pattern*" and "$pattern.*" in this case, which will
# only include the empty directory to more closely align the behavior between different
# modes. Don't forget to update the docs!
$self->log("warn", "tar does not match exclusion with a trailing slash '$pattern'")
if $pattern =~ m|/$| && $task->{mode} ne 'suspend';
}
push @$tar, '--no-anchored';
push @$tar, '--exclude=lost+found' if $userns_cmd;
push @$tar, map { "--exclude=$_" } @findexcl_no_anchored;
push @$tar, '--anchored';
push @$tar, map { "--exclude=.$_" } @findexcl_anchored;
push @$tar, '--', @sources;
my $cmd = [$tar];
my $bwl = $opts->{bwlimit} * 1024; # bandwidth limit for cstream
push @$cmd, ['cstream', '-t', $bwl] if $opts->{bwlimit};
push @$cmd, [split(/\s+/, $comp)] if $comp;
if ($opts->{stdout}) {
$self->cmd($cmd, output => ">&" . fileno($opts->{stdout}));
} else {
push @{ $cmd->[-1] }, \(">" . PVE::Tools::shellquote($filename));
$self->cmd($cmd);
}
}
}
sub cleanup {
my ($self, $task, $vmid) = @_;
my $conf = PVE::LXC::Config->load_config($vmid);
if ($task->{mode} ne 'suspend') {
my $disks = $task->{disks};
foreach my $disk (reverse @$disks) {
PVE::Tools::run_command(['umount', '-l', '-d', $disk->{dir}]) if $disk->{dir};
}
}
if ($task->{cleanup}->{remove_snapshot}) {
my $lock_obtained;
my $do_delete = sub {
$lock_obtained = 1;
$self->loginfo("cleanup temporary 'vzdump' snapshot");
PVE::LXC::Config->snapshot_delete($vmid, 'vzdump', 0);
};
eval {
eval { PVE::GuestHelpers::guest_migration_lock($vmid, 1, $do_delete); };
if (my $err = $@) {
die $err if $lock_obtained;
$self->loginfo("waiting for active replication or other operation..");
PVE::GuestHelpers::guest_migration_lock($vmid, 600, $do_delete);
}
};
# will be cleaned up by next backup run anyway
warn "snapshot 'vzdump' was not (fully) removed - $@" if $@;
}
}
1;