[pve-devel] [POC storage v3 15/34] WIP Borg plugin

Fabian Grünbichler f.gruenbichler at proxmox.com
Wed Nov 13 11:52:58 CET 2024


On November 7, 2024 5:51 pm, Fiona Ebner wrote:
> Archive names start with the guest type and ID and then the same
> timestamp format as PBS.
> 
> Container archives have the following structure:
> guest.config
> firewall.config
> filesystem/ # containing the whole filesystem structure
> 
> VM archives have the following structure
> guest.config
> firewall.config
> volumes/ # containing a raw file for each device
> 
> A bindmount (respectively symlinks) are used to achieve this
> structure, because Borg doesn't seem to support renaming on-the-fly.
> (Prefix stripping via the "slashdot hack" would have helped slightly,
> but is only in Borg >= 1.4
> https://github.com/borgbackup/borg/actions/runs/7967940995)
> 
> NOTE: Bandwidth limit is not yet honored and the task size is not
> calculated yet. Discard for VM backups would also be nice to have, but
> it's not entirely clear how (parsing progress and discarding according
> to that is one idea). There is no dirty bitmap support, not sure if
> that is feasible to add.
> 
> Signed-off-by: Fiona Ebner <f.ebner at proxmox.com>
> ---
> 
> Changes in v3:
> * make SSH work.
> * adapt to API changes, i.e. config as raw data and user namespace
>   execution context for containers.
> 
>  src/PVE/API2/Storage/Config.pm         |   2 +-
>  src/PVE/BackupProvider/Plugin/Borg.pm  | 439 ++++++++++++++++++
>  src/PVE/BackupProvider/Plugin/Makefile |   2 +-
>  src/PVE/Storage.pm                     |   2 +
>  src/PVE/Storage/BorgBackupPlugin.pm    | 595 +++++++++++++++++++++++++
>  src/PVE/Storage/Makefile               |   1 +
>  6 files changed, 1039 insertions(+), 2 deletions(-)
>  create mode 100644 src/PVE/BackupProvider/Plugin/Borg.pm
>  create mode 100644 src/PVE/Storage/BorgBackupPlugin.pm
> 
> diff --git a/src/PVE/API2/Storage/Config.pm b/src/PVE/API2/Storage/Config.pm
> index e04b6ab..1cbf09d 100755
> --- a/src/PVE/API2/Storage/Config.pm
> +++ b/src/PVE/API2/Storage/Config.pm
> @@ -190,7 +190,7 @@ __PACKAGE__->register_method ({
>  	return &$api_storage_config($cfg, $param->{storage});
>      }});
>  
> -my $sensitive_params = [qw(password encryption-key master-pubkey keyring)];
> +my $sensitive_params = [qw(password encryption-key master-pubkey keyring ssh-key)];
>  
>  __PACKAGE__->register_method ({
>      name => 'create',
> diff --git a/src/PVE/BackupProvider/Plugin/Borg.pm b/src/PVE/BackupProvider/Plugin/Borg.pm
> new file mode 100644
> index 0000000..7bb3ae3
> --- /dev/null
> +++ b/src/PVE/BackupProvider/Plugin/Borg.pm
> @@ -0,0 +1,439 @@
> +package PVE::BackupProvider::Plugin::Borg;
> +
> +use strict;
> +use warnings;
> +
> +use File::chdir;
> +use File::Basename qw(basename);
> +use File::Path qw(make_path remove_tree);
> +use Net::IP;
> +use POSIX qw(strftime);
> +
> +use PVE::Tools;
> +
> +# ($vmtype, $vmid, $time_string)
> +our $ARCHIVE_RE_3 = qr!^pve-(lxc|qemu)-([0-9]+)-([0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z)$!;
> +
> +sub archive_name {
> +    my ($vmtype, $vmid, $backup_time) = @_;
> +
> +    return "pve-${vmtype}-${vmid}-" . strftime("%FT%TZ", gmtime($backup_time));
> +}
> +
> +# remove_tree can be very verbose by default, do explicit error handling and limit to one message
> +my sub _remove_tree {
> +    my ($path) = @_;
> +
> +    remove_tree($path, { error => \my $err });
> +    if ($err && @$err) { # empty array if no error
> +	for my $diag (@$err) {
> +	    my ($file, $message) = %$diag;
> +	    die "cannot remove_tree '$path': $message\n" if $file eq '';
> +	    die "cannot remove_tree '$path': unlinking $file failed - $message\n";
> +	}
> +    }
> +}
> +
> +my sub prepare_run_dir {
> +    my ($archive, $operation) = @_;
> +
> +    my $run_dir = "/run/pve-storage-borg-plugin/${archive}.${operation}";
> +    _remove_tree($run_dir);
> +    make_path($run_dir);
> +    die "unable to create directory $run_dir\n" if !-d $run_dir;

this is used as part of restoring - what if I restore the same archive
in parallel into two different VMIDs?

> +
> +    return $run_dir;
> +}
> +
> +my sub log_info {
> +    my ($self, $message) = @_;
> +
> +    $self->{'log-function'}->('info', $message);
> +}
> +
> +my sub log_warning {
> +    my ($self, $message) = @_;
> +
> +    $self->{'log-function'}->('warn', $message);
> +}
> +
> +my sub log_error {
> +    my ($self, $message) = @_;
> +
> +    $self->{'log-function'}->('err', $message);
> +}
> +
> +my sub file_contents_from_archive {
> +    my ($self, $archive, $file) = @_;
> +
> +    my $run_dir = prepare_run_dir($archive, "file-contents");
> +
> +    my $raw;
> +
> +    eval {
> +	local $CWD = $run_dir;
> +
> +	$self->{'storage-plugin'}->borg_cmd_extract(
> +	    $self->{scfg},
> +	    $self->{storeid},
> +	    $archive,
> +	    [$file],
> +	);

borg extract has `--stdout`, which would save writing to the FS here
(since this is only used to extract config file, it should be okay)?

> +
> +	$raw = PVE::Tools::file_get_contents("${run_dir}/${file}");
> +    };
> +    my $err = $@;
> +    eval { _remove_tree($run_dir); };
> +    log_warning($self, $@) if $@;
> +    die $err if $err;
> +
> +    return $raw;
> +}
> +
> +# Plugin implementation
> +
> +sub new {
> +    my ($class, $storage_plugin, $scfg, $storeid, $log_function) = @_;
> +
> +    my $self = bless {
> +	scfg => $scfg,
> +	storeid => $storeid,
> +	'storage-plugin' => $storage_plugin,
> +	'log-function' => $log_function,
> +    }, $class;
> +
> +    return $self;
> +}
> +
> +sub provider_name {
> +    my ($self) = @_;
> +
> +    return "Borg";
> +}
> +
> +sub job_hook {
> +    my ($self, $phase, $info) = @_;
> +
> +    if ($phase eq 'start') {
> +	$self->{'job-id'} = $info->{'start-time'};
> +	$self->{password} = $self->{'storage-plugin'}->borg_get_password(
> +	    $self->{scfg}, $self->{storeid});
> +	$self->{'ssh-key-fh'} = $self->{'storage-plugin'}->borg_open_ssh_key(
> +	    $self->{scfg}, $self->{storeid});
> +    } else {
> +	delete $self->{password};

why do we delete this, but don't close the ssh-key-fh ?

> +    }
> +
> +    return;
> +}
> +
> +sub backup_hook {
> +    my ($self, $phase, $vmid, $vmtype, $info) = @_;
> +
> +    if ($phase eq 'start') {
> +	$self->{$vmid}->{'task-size'} = 0;
> +    } elsif ($phase eq 'prepare') {
> +	if ($vmtype eq 'lxc') {
> +	    my $archive = $self->{$vmid}->{archive};
> +	    my $run_dir = prepare_run_dir($archive, "backup-container");
> +	    $self->{$vmid}->{'run-dir'} = $run_dir;
> +
> +	    my $create_dir = sub {
> +		my $dir = shift;
> +		make_path($dir);
> +		die "unable to create directory $dir\n" if !-d $dir;
> +		chown($info->{'backup-user-id'}, -1, $dir)
> +		    or die "unable to change owner for $dir\n";
> +	    };
> +
> +	    $create_dir->("${run_dir}/backup/");
> +	    $create_dir->("${run_dir}/backup/filesystem");
> +	    $create_dir->("${run_dir}/ssh");
> +	    $create_dir->("${run_dir}/.config");
> +	    $create_dir->("${run_dir}/.cache");

so this is a bit tricky.. we need unpriv access (to do the backup), but
we store sensitive things here that we don't actually want to hand out
to everyone..

> +
> +	    for my $subdir ($info->{sources}->@*) {
> +		PVE::Tools::run_command([
> +		    'mount',
> +		    '-o', 'bind,ro',
> +		    "$info->{directory}/${subdir}",
> +		    "${run_dir}/backup/filesystem/${subdir}",
> +		]);
> +	    }
> +	}
> +    } elsif ($phase eq 'end' || $phase eq 'abort') {
> +	if ($vmtype eq 'lxc') {
> +	    my $run_dir = $self->{$vmid}->{'run-dir'};
> +	    eval {
> +		eval { PVE::Tools::run_command(['umount', "${run_dir}/ssh"]); };

this might warrant a comment ;) a tmpfs is mounted there in
backup_container..

> +		eval { PVE::Tools::run_command(['umount', '-R', "${run_dir}/backup/filesystem"]); };
> +		_remove_tree($run_dir);
> +	    };
> +	    die "unable to clean up $run_dir - $@" if $@;
> +	}
> +    }
> +
> +    return;
> +}
> +
> +sub backup_get_mechanism {
> +    my ($self, $vmid, $vmtype) = @_;
> +
> +    return ('block-device', undef) if $vmtype eq 'qemu';
> +    return ('directory', undef) if $vmtype eq 'lxc';
> +
> +    die "unsupported VM type '$vmtype'\n";
> +}
> +
> +sub backup_get_archive_name {
> +    my ($self, $vmid, $vmtype, $backup_time) = @_;
> +
> +    return $self->{$vmid}->{archive} = archive_name($vmtype, $vmid, $backup_time);
> +}
> +
> +sub backup_get_task_size {
> +    my ($self, $vmid) = @_;
> +
> +    return $self->{$vmid}->{'task-size'};
> +}
> +
> +sub backup_handle_log_file {
> +    my ($self, $vmid, $filename) = @_;
> +
> +    return; # don't upload, Proxmox VE keeps the task log too
> +}
> +
> +sub backup_vm {
> +    my ($self, $vmid, $guest_config, $volumes, $info) = @_;
> +
> +    # TODO honor bandwith limit
> +    # TODO discard?
> +
> +    my $archive = $self->{$vmid}->{archive};
> +
> +    my $run_dir = prepare_run_dir($archive, "backup-vm");
> +    my $volume_dir = "${run_dir}/volumes";
> +    make_path($volume_dir);
> +    die "unable to create directory $volume_dir\n" if !-d $volume_dir;
> +
> +    PVE::Tools::file_set_contents("${run_dir}/guest.config", $guest_config);

same here

> +    my $paths = ['./guest.config'];
> +
> +    if (my $firewall_config = $info->{'firewall-config'}) {
> +	PVE::Tools::file_set_contents("${run_dir}/firewall.config", $firewall_config);

and here - these paths are world-readable by default..

> +	push $paths->@*, './firewall.config';
> +    }
> +
> +    for my $devicename (sort keys $volumes->%*) {
> +	my $path = $volumes->{$devicename}->{path};
> +	my $link_name = "${volume_dir}/${devicename}.raw";
> +	symlink($path, $link_name) or die "could not create symlink $link_name -> $path\n";
> +	push $paths->@*, "./volumes/" . basename($link_name, ());
> +    }
> +
> +    # TODO --stats for size?
> +
> +    eval {
> +	local $CWD = $run_dir;
> +
> +	$self->{'storage-plugin'}->borg_cmd_create(
> +	    $self->{scfg},
> +	    $self->{storeid},
> +	    $self->{$vmid}->{archive},
> +	    $paths,
> +	    ['--read-special', '--progress'],
> +	);
> +    };
> +    my $err = $@;
> +    eval { _remove_tree($run_dir) };
> +    log_warning($self, $@) if $@;
> +    die $err if $err;
> +}
> +
> +sub backup_container {
> +    my ($self, $vmid, $guest_config, $exclude_patterns, $info) = @_;
> +
> +    # TODO honor bandwith limit
> +
> +    my $run_dir = $self->{$vmid}->{'run-dir'};
> +    my $backup_dir = "${run_dir}/backup";
> +
> +    my $archive = $self->{$vmid}->{archive};
> +
> +    PVE::Tools::run_command(['mount', '-t', 'tmpfs', '-o', 'size=1M', 'tmpfs', "${run_dir}/ssh"]);
> +
> +    if ($self->{'ssh-key-fh'}) {
> +	my $ssh_key =
> +	    PVE::Tools::safe_read_from($self->{'ssh-key-fh'}, 1024 * 1024, 0, "SSH key file");
> +	PVE::Tools::file_set_contents("${run_dir}/ssh/ssh.key", $ssh_key, 0600);

okay, so this should be fine..

> +    }
> +
> +    if (my $ssh_fingerprint = $self->{scfg}->{'ssh-fingerprint'}) {
> +	my ($server, $port) = $self->{scfg}->@{qw(server port)};
> +	$server = "[$server]" if Net::IP::ip_is_ipv6($server);
> +	$server = "${server}:${port}" if $port;
> +	my $fp_line = "$server $ssh_fingerprint\n";
> +	PVE::Tools::file_set_contents("${run_dir}/ssh/known_hosts", $fp_line, 0600);
> +    }
> +
> +    PVE::Tools::file_set_contents("${backup_dir}/guest.config", $guest_config);

but this

> +    my $paths = ['./guest.config'];
> +
> +    if (my $firewall_config = $info->{'firewall-config'}) {
> +	PVE::Tools::file_set_contents("${backup_dir}/firewall.config", $firewall_config);

and this should also be 0600? or we could chmod the dirs themselves when
creating, to avoid missing paths?

> +	push $paths->@*, './firewall.config';
> +    }
> +
> +    push $paths->@*, "./filesystem";
> +
> +    my $opts = ['--numeric-ids', '--sparse', '--progress'];
> +
> +    for my $pattern ($exclude_patterns->@*) {
> +	if ($pattern =~ m|^/|) {
> +	    push $opts->@*, '-e', "filesystem${pattern}";
> +	} else {
> +	    push $opts->@*, '-e', "filesystem/**${pattern}";
> +	}
> +    }
> +
> +    push $opts->@*, '-e', "filesystem/**lost+found" if $info->{'backup-user-id'} != 0;
> +
> +    # TODO --stats for size?
> +
> +    # Don't make it local to avoid permission denied error when changing back, because the method is
> +    # executed in a user namespace.
> +    $CWD = $backup_dir if $info->{'backup-user-id'} != 0;
> +    {
> +	local $CWD = $backup_dir;
> +	local $ENV{BORG_BASE_DIR} = ${run_dir};
> +	local $ENV{BORG_PASSPHRASE} = $self->{password};
> +
> +	local $ENV{BORG_RSH} =
> +	    "ssh -o \"UserKnownHostsFile ${run_dir}/ssh/known_hosts\" -i ${run_dir}/ssh/ssh.key";
> +
> +	$self->{'storage-plugin'}->borg_cmd_create(
> +	    $self->{scfg},
> +	    $self->{storeid},
> +	    $self->{$vmid}->{archive},
> +	    $paths,
> +	    $opts,
> +	);
> +    }
> +}
> +
> +sub restore_get_mechanism {
> +    my ($self, $volname, $storeid) = @_;
> +
> +    my (undef, $archive) = $self->{'storage-plugin'}->parse_volname($volname);
> +    my ($vmtype) = $archive =~ m!^pve-([^\s-]+)!
> +	or die "cannot parse guest type from archive name '$archive'\n";
> +
> +    return ('qemu-img', $vmtype) if $vmtype eq 'qemu';
> +    return ('directory', $vmtype) if $vmtype eq 'lxc';
> +
> +    die "unexpected guest type '$vmtype'\n";
> +}
> +
> +sub restore_get_guest_config {
> +    my ($self, $volname, $storeid) = @_;
> +
> +    my (undef, $archive) = $self->{'storage-plugin'}->parse_volname($volname);
> +    return file_contents_from_archive($self, $archive, 'guest.config');
> +}
> +
> +sub restore_get_firewall_config {
> +    my ($self, $volname, $storeid) = @_;
> +
> +    my (undef, $archive) = $self->{'storage-plugin'}->parse_volname($volname);
> +    my $config = eval {
> +	file_contents_from_archive($self, $archive, 'firewall.config');
> +    };
> +    if (my $err = $@) {
> +	return if $err =~ m!Include pattern 'firewall\.config' never matched\.!;
> +	die $err;
> +    }
> +    return $config;
> +}
> +
> +sub restore_vm_init {
> +    my ($self, $volname, $storeid) = @_;
> +
> +    my $res = {};
> +
> +    my (undef, $archive, $vmid) = $self->{'storage-plugin'}->parse_volname($volname);
> +    my $mount_point = prepare_run_dir($archive, "restore-vm");
> +
> +    $self->{'storage-plugin'}->borg_cmd_mount(
> +	$self->{scfg},
> +	$self->{storeid},
> +	$archive,
> +	$mount_point,
> +    );

haven't actually tested this code, but what are the permissions like for
this mounted backup archive contents? we don't want to expose guest
volumes as world-readable either..

> +
> +    my @backup_files = glob("$mount_point/volumes/*");
> +    for my $backup_file (@backup_files) {
> +	next if $backup_file !~ m!^(.*/(.*)\.raw)$!; # untaint
> +	($backup_file, my $devicename) = ($1, $2);
> +	# TODO avoid dependency on base plugin?
> +	$res->{$devicename}->{size} = PVE::Storage::Plugin::file_size_info($backup_file);
> +    }
> +
> +    $self->{$volname}->{'mount-point'} = $mount_point;
> +
> +    return $res;
> +}
> +
> +sub restore_vm_cleanup {
> +    my ($self, $volname, $storeid) = @_;
> +
> +    my $mount_point = $self->{$volname}->{'mount-point'} or return;
> +
> +    PVE::Tools::run_command(['umount', $mount_point]);
> +
> +    return;
> +}
> +
> +sub restore_vm_volume_init {
> +    my ($self, $volname, $storeid, $devicename, $info) = @_;
> +
> +    my $mount_point = $self->{$volname}->{'mount-point'}
> +	or die "expected mount point for archive not present\n";
> +
> +    return { 'qemu-img-path' => "${mount_point}/volumes/${devicename}.raw" };
> +}
> +
> +sub restore_vm_volume_cleanup {
> +    my ($self, $volname, $storeid, $devicename, $info) = @_;
> +
> +    return;
> +}
> +
> +sub restore_container_init {
> +    my ($self, $volname, $storeid, $info) = @_;
> +
> +    my (undef, $archive, $vmid) = $self->{'storage-plugin'}->parse_volname($volname);
> +    my $mount_point = prepare_run_dir($archive, "restore-container");
> +
> +    $self->{'storage-plugin'}->borg_cmd_mount(
> +	$self->{scfg},
> +	$self->{storeid},
> +	$archive,
> +	$mount_point,
> +    );

same question here..

> +
> +    $self->{$volname}->{'mount-point'} = $mount_point;
> +
> +    return { 'archive-directory' => "${mount_point}/filesystem" };
> +}
> +
> +sub restore_container_cleanup {
> +    my ($self, $volname, $storeid, $info) = @_;
> +
> +    my $mount_point = $self->{$volname}->{'mount-point'} or return;
> +
> +    PVE::Tools::run_command(['umount', $mount_point]);
> +
> +    return;
> +}
> +
> +1;
> diff --git a/src/PVE/BackupProvider/Plugin/Makefile b/src/PVE/BackupProvider/Plugin/Makefile
> index bedc26e..db08c2d 100644
> --- a/src/PVE/BackupProvider/Plugin/Makefile
> +++ b/src/PVE/BackupProvider/Plugin/Makefile
> @@ -1,4 +1,4 @@
> -SOURCES = Base.pm DirectoryExample.pm
> +SOURCES = Base.pm Borg.pm DirectoryExample.pm
>  
>  .PHONY: install
>  install:
> diff --git a/src/PVE/Storage.pm b/src/PVE/Storage.pm
> index 9f9a86b..f4bfc55 100755
> --- a/src/PVE/Storage.pm
> +++ b/src/PVE/Storage.pm
> @@ -40,6 +40,7 @@ use PVE::Storage::ZFSPlugin;
>  use PVE::Storage::PBSPlugin;
>  use PVE::Storage::BTRFSPlugin;
>  use PVE::Storage::ESXiPlugin;
> +use PVE::Storage::BorgBackupPlugin;
>  
>  # Storage API version. Increment it on changes in storage API interface.
>  use constant APIVER => 11;
> @@ -66,6 +67,7 @@ PVE::Storage::ZFSPlugin->register();
>  PVE::Storage::PBSPlugin->register();
>  PVE::Storage::BTRFSPlugin->register();
>  PVE::Storage::ESXiPlugin->register();
> +PVE::Storage::BorgBackupPlugin->register();
>  
>  # load third-party plugins
>  if ( -d '/usr/share/perl5/PVE/Storage/Custom' ) {
> diff --git a/src/PVE/Storage/BorgBackupPlugin.pm b/src/PVE/Storage/BorgBackupPlugin.pm
> new file mode 100644
> index 0000000..8f0e721
> --- /dev/null
> +++ b/src/PVE/Storage/BorgBackupPlugin.pm
> @@ -0,0 +1,595 @@
> +package PVE::Storage::BorgBackupPlugin;
> +
> +use strict;
> +use warnings;
> +
> +use Fcntl qw(F_GETFD F_SETFD FD_CLOEXEC);
> +use JSON qw(from_json);
> +use MIME::Base64 qw(decode_base64);
> +use Net::IP;
> +use POSIX;
> +
> +use PVE::BackupProvider::Plugin::Borg;
> +use PVE::Tools;
> +
> +use base qw(PVE::Storage::Plugin);
> +
> +my sub borg_repository_uri {
> +    my ($scfg, $storeid) = @_;
> +
> +    my $uri = '';
> +    my $server = $scfg->{server} or die "no server configured for $storeid\n";
> +    my $username = $scfg->{username} or die "no username configured for $storeid\n";
> +    my $prefix = "ssh://$username@";
> +    $server = "[$server]" if Net::IP::ip_is_ipv6($server);
> +    if (my $port = $scfg->{port}) {
> +	$uri = "${prefix}${server}:${port}";
> +    } else {
> +	$uri = "${prefix}${server}";
> +    }
> +    $uri .= $scfg->{'repository-path'};
> +
> +    return $uri;
> +}
> +
> +my sub borg_password_file_name {
> +    my ($scfg, $storeid) = @_;
> +
> +    return "/etc/pve/priv/storage/${storeid}.pw";
> +}
> +
> +my sub borg_set_password {
> +    my ($scfg, $storeid, $password) = @_;
> +
> +    my $pwfile = borg_password_file_name($scfg, $storeid);
> +    mkdir "/etc/pve/priv/storage";
> +
> +    PVE::Tools::file_set_contents($pwfile, "$password\n");
> +}
> +
> +my sub borg_delete_password {
> +    my ($scfg, $storeid) = @_;
> +
> +    my $pwfile = borg_password_file_name($scfg, $storeid);
> +
> +    unlink $pwfile;
> +}
> +
> +sub borg_get_password {
> +    my ($class, $scfg, $storeid) = @_;
> +
> +    my $pwfile = borg_password_file_name($scfg, $storeid);
> +
> +    return PVE::Tools::file_read_firstline($pwfile);
> +}
> +
> +sub borg_cmd_list {
> +    my ($class, $scfg, $storeid) = @_;
> +
> +    my $uri = borg_repository_uri($scfg, $storeid);
> +
> +    local $ENV{BORG_PASSPHRASE} = $class->borg_get_password($scfg, $storeid)
> +	if !$ENV{BORG_PASSPHRASE};
> +
> +    my $json = '';
> +    my $cmd = ['borg', 'list', '--json', $uri];
> +
> +    my $errfunc = sub { warn $_[0]; };
> +    my $outfunc = sub { $json .= $_[0]; };
> +
> +    PVE::Tools::run_command(
> +	$cmd, errmsg => "command @$cmd failed", outfunc => $outfunc, errfunc => $errfunc);
> +
> +    my $res = eval { from_json($json) };
> +    die "unable to parse 'borg list' output - $@\n" if $@;
> +    return $res;
> +}
> +
> +sub borg_cmd_create {
> +    my ($class, $scfg, $storeid, $archive, $paths, $opts) = @_;
> +
> +    my $uri = borg_repository_uri($scfg, $storeid);
> +
> +    local $ENV{BORG_PASSPHRASE} = $class->borg_get_password($scfg, $storeid)
> +	if !$ENV{BORG_PASSPHRASE};
> +
> +    my $cmd = ['borg', 'create', $opts->@*, "${uri}::${archive}", $paths->@*];
> +
> +    PVE::Tools::run_command($cmd, errmsg => "command @$cmd failed");
> +
> +    return;
> +}
> +
> +sub borg_cmd_extract {
> +    my ($class, $scfg, $storeid, $archive, $paths) = @_;
> +
> +    my $uri = borg_repository_uri($scfg, $storeid);
> +
> +    local $ENV{BORG_PASSPHRASE} = $class->borg_get_password($scfg, $storeid)
> +	if !$ENV{BORG_PASSPHRASE};
> +
> +    my $cmd = ['borg', 'extract', "${uri}::${archive}", $paths->@*];
> +
> +    PVE::Tools::run_command($cmd, errmsg => "command @$cmd failed");
> +
> +    return;
> +}
> +
> +sub borg_cmd_delete {
> +    my ($class, $scfg, $storeid, $archive) = @_;
> +
> +    my $uri = borg_repository_uri($scfg, $storeid);
> +
> +    local $ENV{BORG_PASSPHRASE} = $class->borg_get_password($scfg, $storeid)
> +	if !$ENV{BORG_PASSPHRASE};
> +
> +    my $cmd = ['borg', 'delete', "${uri}::${archive}"];
> +
> +    PVE::Tools::run_command($cmd, errmsg => "command @$cmd failed");
> +
> +    return;
> +}
> +
> +sub borg_cmd_info {
> +    my ($class, $scfg, $storeid, $archive, $timeout) = @_;
> +
> +    my $uri = borg_repository_uri($scfg, $storeid);
> +
> +    local $ENV{BORG_PASSPHRASE} = $class->borg_get_password($scfg, $storeid)
> +	if !$ENV{BORG_PASSPHRASE};
> +
> +    my $json = '';
> +    my $cmd = ['borg', 'info', '--json', "${uri}::${archive}"];
> +
> +    my $errfunc = sub { warn $_[0]; };
> +    my $outfunc = sub { $json .= $_[0]; };
> +
> +    PVE::Tools::run_command(
> +	$cmd,
> +	errmsg => "command @$cmd failed",
> +	timeout => $timeout,
> +	outfunc => $outfunc,
> +	errfunc => $errfunc,
> +    );
> +
> +    my $res = eval { from_json($json) };
> +    die "unable to parse 'borg info' output for archive '$archive' - $@\n" if $@;
> +    return $res;
> +}
> +
> +sub borg_cmd_mount {
> +    my ($class, $scfg, $storeid, $archive, $mount_point) = @_;
> +
> +    my $uri = borg_repository_uri($scfg, $storeid);
> +
> +    local $ENV{BORG_PASSPHRASE} = $class->borg_get_password($scfg, $storeid)
> +	if !$ENV{BORG_PASSPHRASE};
> +
> +    my $cmd = ['borg', 'mount', "${uri}::${archive}", $mount_point];
> +
> +    PVE::Tools::run_command($cmd, errmsg => "command @$cmd failed");
> +
> +    return;
> +}
> +
> +my sub parse_backup_time {
> +    my ($time_string) = @_;
> +
> +    my @tm = (POSIX::strptime($time_string, "%FT%TZ"));
> +    # expect sec, min, hour, mday, mon, year
> +    if (grep { !defined($_) } @tm[0..5]) {
> +	warn "error parsing time from string '$time_string'\n";
> +	return 0;
> +    } else {
> +	local $ENV{TZ} = 'UTC'; # time string is UTC
> +
> +	# Fill in isdst to avoid undef warning. No daylight saving time for UTC.
> +	$tm[8] //= 0;
> +
> +	if (my $since_epoch = mktime(@tm)) {
> +	    return int($since_epoch);
> +	} else {
> +	    warn "error parsing time from string '$time_string'\n";
> +	    return 0;
> +	}
> +    }
> +}
> +
> +# Helpers
> +
> +sub type {
> +    return 'borg';
> +}
> +
> +sub plugindata {
> +    return {
> +	content => [ { backup => 1, none => 1 }, { backup => 1 } ],
> +	features => { 'backup-provider' => 1 },
> +    };
> +}
> +
> +sub properties {
> +    return {
> +	'repository-path' => {
> +	    description => "Path to the backup repository",
> +	    type => 'string',
> +	},
> +	'ssh-key' => {
> +	    description => "FIXME", # FIXME
> +	    type => 'string',
> +	},
> +	'ssh-fingerprint' => {
> +	    description => "FIXME", # FIXME
> +	    type => 'string',
> +	},

these should probably get descriptions and formats, but this is titled
WIP :)

> +    };
> +}
> +
> +sub options {
> +    return {
> +	'repository-path' => { fixed => 1 },
> +	server => { fixed => 1 },
> +	port => { optional => 1 },
> +	username => { fixed => 1 },
> +	'ssh-key' => { optional => 1 },
> +	'ssh-fingerprint' => { optional => 1 },
> +	password => { optional => 1 },
> +	disable => { optional => 1 },
> +	nodes => { optional => 1 },
> +	'prune-backups' => { optional => 1 },
> +	'max-protected-backups' => { optional => 1 },
> +    };
> +}
> +
> +sub borg_ssh_key_file_name {
> +    my ($scfg, $storeid) = @_;
> +
> +    return "/etc/pve/priv/storage/${storeid}.ssh.key";
> +}
> +
> +sub borg_set_ssh_key {
> +    my ($scfg, $storeid, $key) = @_;
> +
> +    my $pwfile = borg_ssh_key_file_name($scfg, $storeid);

nit: variable name

> +    mkdir "/etc/pve/priv/storage";
> +
> +    PVE::Tools::file_set_contents($pwfile, "$key\n");
> +}
> +
> +sub borg_delete_ssh_key {
> +    my ($scfg, $storeid) = @_;
> +
> +    my $pwfile = borg_ssh_key_file_name($scfg, $storeid);

same

> +
> +    if (!unlink $pwfile) {
> +	return if $! == ENOENT;
> +	die "failed to delete SSH key! $!\n";
> +    }
> +    delete $scfg->{'ssh-key'};
> +}
> +
> +sub borg_get_ssh_key {
> +    my ($scfg, $storeid) = @_;
> +
> +    my $pwfile = borg_ssh_key_file_name($scfg, $storeid);

same

> +
> +    return PVE::Tools::file_get_contents($pwfile);
> +}
> +
> +# Returns a file handle with FD_CLOEXEC disabled if there is an SSH key , or `undef` if there is
> +# not. Dies on error.
> +sub borg_open_ssh_key {
> +    my ($self, $scfg, $storeid) = @_;
> +
> +    my $ssh_key_file = borg_ssh_key_file_name($scfg, $storeid);
> +
> +    my $keyfd;
> +    if (!open($keyfd, '<', $ssh_key_file)) {
> +	if ($! == ENOENT) {
> +	    die "SSH key configured but no key file found!\n" if $scfg->{'ssh-key'};
> +	    return undef;
> +	}
> +	die "failed to open SSH key: $ssh_key_file: $!\n";
> +    }
> +    my $flags = fcntl($keyfd, F_GETFD, 0)
> +	// die "failed to get file descriptor flags for SSH key FD: $!\n";
> +    fcntl($keyfd, F_SETFD, $flags & ~FD_CLOEXEC)
> +	or die "failed to remove FD_CLOEXEC from SSH key file descriptor\n";
> +
> +    return $keyfd;
> +}
> +
> +# Storage implementation
> +
> +sub on_add_hook {
> +    my ($class, $storeid, $scfg, %param) = @_;
> +
> +    if (defined(my $password = $param{password})) {
> +	borg_set_password($scfg, $storeid, $password);
> +    } else {
> +	borg_delete_password($scfg, $storeid);
> +    }
> +
> +    if (defined(my $ssh_key = delete $param{'ssh-key'})) {
> +	my $decoded = decode_base64($ssh_key);
> +	borg_set_ssh_key($scfg, $storeid, $decoded);
> +	$scfg->{'ssh-key'} = 1;
> +    } else {
> +	borg_delete_ssh_key($scfg, $storeid);
> +    }
> +
> +    return;
> +}
> +
> +sub on_update_hook {
> +    my ($class, $storeid, $scfg, %param) = @_;
> +
> +    if (exists($param{password})) {
> +	if (defined($param{password})) {
> +	    borg_set_password($scfg, $storeid, $param{password});
> +	} else {
> +	    borg_delete_password($scfg, $storeid);
> +	}
> +    }
> +
> +    if (exists($param{'ssh-key'})) {
> +	if (defined(my $ssh_key = delete($param{'ssh-key'}))) {
> +	    my $decoded = decode_base64($ssh_key);
> +
> +	    borg_set_ssh_key($scfg, $storeid, $decoded);
> +	    $scfg->{'ssh-key'} = 1;
> +	} else {
> +	    borg_delete_ssh_key($scfg, $storeid);
> +	}
> +    }
> +
> +    return;
> +}
> +
> +sub on_delete_hook {
> +    my ($class, $storeid, $scfg) = @_;
> +
> +    borg_delete_password($scfg, $storeid);
> +    borg_delete_ssh_key($scfg, $storeid);
> +
> +    return;
> +}
> +
> +sub prune_backups {
> +    my ($class, $scfg, $storeid, $keep, $vmid, $type, $dryrun, $logfunc) = @_;
> +
> +    # FIXME - is 'borg prune' compatible with ours?
> +    die "not implemented";
> +}
> +
> +sub parse_volname {
> +    my ($class, $volname) = @_;
> +
> +    if ($volname =~ m!^backup/(.*)$!) {
> +	my $archive = $1;
> +	if ($archive =~ $PVE::BackupProvider::Plugin::Borg::ARCHIVE_RE_3) {
> +	    return ('backup', $archive, $2);
> +	}
> +    }
> +
> +    die "unable to parse Borg volume name '$volname'\n";
> +}
> +
> +sub path {
> +    my ($class, $scfg, $volname, $storeid, $snapname) = @_;
> +
> +    die "volume snapshot is not possible on Borg volume" if $snapname;
> +
> +    my $uri = borg_repository_uri($scfg, $storeid);
> +    my (undef, $archive) = $class->parse_volname($volname);
> +
> +    return "${uri}::${archive}";
> +}
> +
> +sub create_base {
> +    my ($class, $storeid, $scfg, $volname) = @_;
> +
> +    die "cannot create base image in Borg storage\n";
> +}
> +
> +sub clone_image {
> +    my ($class, $scfg, $storeid, $volname, $vmid, $snap) = @_;
> +
> +    die "can't clone images in Borg storage\n";
> +}
> +
> +sub alloc_image {
> +    my ($class, $storeid, $scfg, $vmid, $fmt, $name, $size) = @_;
> +
> +    die "can't allocate space in Borg storage\n";
> +}
> +
> +sub free_image {
> +    my ($class, $storeid, $scfg, $volname, $isBase) = @_;
> +
> +    my (undef, $archive) = $class->parse_volname($volname);
> +
> +    borg_cmd_delete($class, $scfg, $storeid, $archive);
> +
> +    return;
> +}
> +
> +sub list_images {
> +    my ($class, $storeid, $scfg, $vmid, $vollist, $cache) = @_;
> +
> +    return []; # guest images are not supported, only backups
> +}
> +
> +sub list_volumes {
> +    my ($class, $storeid, $scfg, $vmid, $content_types) = @_;
> +
> +    my $res = [];
> +
> +    return $res if !grep { $_ eq 'backup' } $content_types->@*;
> +
> +    my $archives = $class->borg_cmd_list($scfg, $storeid)->{archives}
> +	or die "expected 'archives' key in 'borg list' JSON output missing\n";
> +
> +    for my $info ($archives->@*) {
> +	my $archive = $info->{archive};
> +	my ($vmtype, $backup_vmid, $time_string) =
> +	    $archive =~ $PVE::BackupProvider::Plugin::Borg::ARCHIVE_RE_3 or next;
> +
> +	next if defined($vmid) && $vmid != $backup_vmid;
> +
> +	push $res->@*, {
> +	    volid => "${storeid}:backup/${archive}",
> +	    size => 0, # FIXME how to cheaply get?
> +	    content => 'backup',
> +	    ctime => parse_backup_time($time_string),
> +	    vmid => $backup_vmid,
> +	    format => "borg-archive",
> +	    subtype => $vmtype,
> +	}
> +    }
> +
> +    return $res;
> +}
> +
> +sub status {
> +    my ($class, $storeid, $scfg, $cache) = @_;
> +
> +    my $uri = borg_repository_uri($scfg, $storeid);
> +
> +    my $res;
> +
> +    if ($uri =~ m!^ssh://!) {
> +	#FIXME ssh and df on target?

borg targets will often be locked down to only allow executing borg on
the other end though..

I am not sure what makes sense here tbh..

> +	return;
> +    } else { # $uri is a local path
> +	my $timeout = 2;
> +	$res = PVE::Tools::df($uri, $timeout);
> +
> +	return if !$res || !$res->{total};
> +    }
> +
> +
> +    return ($res->{total}, $res->{avail}, $res->{used}, 1);
> +}
> +
> +sub activate_storage {
> +    my ($class, $storeid, $scfg, $cache) = @_;
> +
> +    # TODO how to cheaply check? split ssh and non-ssh?
> +
> +    return 1;
> +}
> +
> +sub deactivate_storage {
> +    my ($class, $storeid, $scfg, $cache) = @_;
> +
> +    return 1;
> +}
> +
> +sub activate_volume {
> +    my ($class, $storeid, $scfg, $volname, $snapname, $cache) = @_;
> +
> +    die "volume snapshot is not possible on Borg volume" if $snapname;
> +
> +    return 1;
> +}
> +
> +sub deactivate_volume {
> +    my ($class, $storeid, $scfg, $volname, $snapname, $cache) = @_;
> +
> +    die "volume snapshot is not possible on Borg volume" if $snapname;
> +
> +    return 1;
> +}
> +
> +sub get_volume_attribute {
> +    my ($class, $scfg, $storeid, $volname, $attribute) = @_;
> +
> +    return;
> +}
> +
> +sub update_volume_attribute {
> +    my ($class, $scfg, $storeid, $volname, $attribute, $value) = @_;
> +
> +    # FIXME notes or protected possible?
> +
> +    die "attribute '$attribute' is not supported on Borg volume";
> +}
> +
> +sub volume_size_info {
> +    my ($class, $scfg, $storeid, $volname, $timeout) = @_;
> +
> +    my (undef, $archive) = $class->parse_volname($volname);
> +    my (undef, undef, $time_string) =
> +	$archive =~ $PVE::BackupProvider::Plugin::Borg::ARCHIVE_RE_3;
> +
> +    my $backup_time = 0;
> +    if ($time_string) {
> +	$backup_time = parse_backup_time($time_string)
> +    } else {
> +	warn "could not parse time from archive name '$archive'\n";
> +    }
> +
> +    my $archives = borg_cmd_info($class, $scfg, $storeid, $archive, $timeout)->{archives}
> +	or die "expected 'archives' key in 'borg info' JSON output missing\n";
> +
> +    my $stats = eval { $archives->[0]->{stats} }
> +	or die "expected entry in 'borg info' JSON output missing\n";
> +    my ($size, $used) = $stats->@{qw(original_size deduplicated_size)};
> +
> +    ($size) = ($size =~ /^(\d+)$/); # untaint
> +    die "size '$size' not an integer\n" if !defined($size);
> +    # coerce back from string
> +    $size = int($size);
> +    ($used) = ($used =~ /^(\d+)$/); # untaint
> +    die "used '$used' not an integer\n" if !defined($used);
> +    # coerce back from string
> +    $used = int($used);
> +
> +    return wantarray ? ($size, 'borg-archive', $used, undef, $backup_time) : $size;
> +}
> +
> +sub volume_resize {
> +    my ($class, $scfg, $storeid, $volname, $size, $running) = @_;
> +
> +    die "volume resize is not possible on Borg volume";
> +}
> +
> +sub volume_snapshot {
> +    my ($class, $scfg, $storeid, $volname, $snap) = @_;
> +
> +    die "volume snapshot is not possible on Borg volume";
> +}
> +
> +sub volume_snapshot_rollback {
> +    my ($class, $scfg, $storeid, $volname, $snap) = @_;
> +
> +    die "volume snapshot rollback is not possible on Borg volume";
> +}
> +
> +sub volume_snapshot_delete {
> +    my ($class, $scfg, $storeid, $volname, $snap) = @_;
> +
> +    die "volume snapshot delete is not possible on Borg volume";
> +}
> +
> +sub volume_has_feature {
> +    my ($class, $scfg, $feature, $storeid, $volname, $snapname, $running) = @_;
> +
> +    return 0;
> +}
> +
> +sub rename_volume {
> +    my ($class, $scfg, $storeid, $source_volname, $target_vmid, $target_volname) = @_;
> +
> +    die "volume rename is not implemented in Borg storage plugin\n";
> +}
> +
> +sub new_backup_provider {
> +    my ($class, $scfg, $storeid, $bandwidth_limit, $log_function) = @_;
> +
> +    return PVE::BackupProvider::Plugin::Borg->new(
> +	$class, $scfg, $storeid, $bandwidth_limit, $log_function);
> +}
> +
> +1;
> diff --git a/src/PVE/Storage/Makefile b/src/PVE/Storage/Makefile
> index acd37f4..9fe2c66 100644
> --- a/src/PVE/Storage/Makefile
> +++ b/src/PVE/Storage/Makefile
> @@ -14,6 +14,7 @@ SOURCES= \
>  	PBSPlugin.pm \
>  	BTRFSPlugin.pm \
>  	LvmThinPlugin.pm \
> +	BorgBackupPlugin.pm \

do we want this one here, while the other one is in Custom?

>  	ESXiPlugin.pm
>  
>  .PHONY: install
> -- 
> 2.39.5
> 
> 
> 
> _______________________________________________
> pve-devel mailing list
> pve-devel at lists.proxmox.com
> https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel
> 
> 
> 




More information about the pve-devel mailing list