From: "Fabian Grünbichler" <f.gruenbichler@proxmox.com>
To: Proxmox VE development discussion <pve-devel@lists.proxmox.com>
Subject: Re: [pve-devel] [POC storage v3 15/34] WIP Borg plugin
Date: Wed, 13 Nov 2024 11:52:58 +0100 [thread overview]
Message-ID: <1731489757.shva7ho8xr.astroid@yuna.none> (raw)
In-Reply-To: <20241107165146.125935-16-f.ebner@proxmox.com>
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@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@lists.proxmox.com
> https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel
>
>
>
_______________________________________________
pve-devel mailing list
pve-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel
next prev parent reply other threads:[~2024-11-13 10:53 UTC|newest]
Thread overview: 63+ messages / expand[flat|nested] mbox.gz Atom feed top
2024-11-07 16:51 [pve-devel] [RFC qemu/common/storage/qemu-server/container/manager v3 00/34] backup provider API Fiona Ebner
2024-11-07 16:51 ` [pve-devel] [PATCH qemu v3 01/34] block/reqlist: allow adding overlapping requests Fiona Ebner
2024-11-07 16:51 ` [pve-devel] [PATCH qemu v3 02/34] PVE backup: fixup error handling for fleecing Fiona Ebner
2024-11-07 16:51 ` [pve-devel] [PATCH qemu v3 03/34] PVE backup: factor out setting up snapshot access " Fiona Ebner
2024-11-07 16:51 ` [pve-devel] [PATCH qemu v3 04/34] PVE backup: save device name in device info structure Fiona Ebner
2024-11-07 16:51 ` [pve-devel] [PATCH qemu v3 05/34] PVE backup: include device name in error when setting up snapshot access fails Fiona Ebner
2024-11-07 16:51 ` [pve-devel] [RFC qemu v3 06/34] PVE backup: add target ID in backup state Fiona Ebner
2024-11-12 16:46 ` Fabian Grünbichler
2024-11-13 9:22 ` Fiona Ebner
2024-11-13 9:33 ` Fiona Ebner
2024-11-13 11:16 ` Fabian Grünbichler
2024-11-13 11:40 ` Fiona Ebner
2024-11-13 12:03 ` Fabian Grünbichler
2024-11-07 16:51 ` [pve-devel] [RFC qemu v3 07/34] PVE backup: get device info: allow caller to specify filter for which devices use fleecing Fiona Ebner
2024-11-07 16:51 ` [pve-devel] [RFC qemu v3 08/34] PVE backup: implement backup access setup and teardown API for external providers Fiona Ebner
2024-11-07 16:51 ` [pve-devel] [RFC qemu v3 09/34] PVE backup: implement bitmap support for external backup access Fiona Ebner
2024-11-07 16:51 ` [pve-devel] [RFC common v3 10/34] env: add module with helpers to run a Perl subroutine in a user namespace Fiona Ebner
2024-11-11 18:33 ` Thomas Lamprecht
2024-11-12 10:19 ` Fiona Ebner
2024-11-12 14:20 ` Fabian Grünbichler
2024-11-13 10:08 ` Fiona Ebner
2024-11-13 11:15 ` Fabian Grünbichler
2024-11-07 16:51 ` [pve-devel] [RFC storage v3 11/34] add storage_has_feature() helper function Fiona Ebner
2024-11-07 16:51 ` [pve-devel] [RFC storage v3 12/34] plugin: introduce new_backup_provider() method Fiona Ebner
2024-11-07 16:51 ` [pve-devel] [RFC storage v3 13/34] extract backup config: delegate to backup provider for storages that support it Fiona Ebner
2024-11-07 16:51 ` [pve-devel] [POC storage v3 14/34] add backup provider example Fiona Ebner
2024-11-13 10:52 ` Fabian Grünbichler
2024-11-07 16:51 ` [pve-devel] [POC storage v3 15/34] WIP Borg plugin Fiona Ebner
2024-11-13 10:52 ` Fabian Grünbichler [this message]
2024-11-07 16:51 ` [pve-devel] [PATCH qemu-server v3 16/34] move nbd_stop helper to QMPHelpers module Fiona Ebner
2024-11-11 13:55 ` [pve-devel] applied: " Fabian Grünbichler
2024-11-07 16:51 ` [pve-devel] [PATCH qemu-server v3 17/34] backup: move cleanup of fleecing images to cleanup method Fiona Ebner
2024-11-12 9:26 ` [pve-devel] applied: " Fabian Grünbichler
2024-11-07 16:51 ` [pve-devel] [PATCH qemu-server v3 18/34] backup: cleanup: check if VM is running before issuing QMP commands Fiona Ebner
2024-11-12 9:26 ` [pve-devel] applied: " Fabian Grünbichler
2024-11-07 16:51 ` [pve-devel] [PATCH qemu-server v3 19/34] backup: keep track of block-node size for fleecing Fiona Ebner
2024-11-11 14:22 ` Fabian Grünbichler
2024-11-12 9:50 ` Fiona Ebner
2024-11-07 16:51 ` [pve-devel] [RFC qemu-server v3 20/34] backup: allow adding fleecing images also for EFI and TPM Fiona Ebner
2024-11-12 9:26 ` Fabian Grünbichler
2024-11-07 16:51 ` [pve-devel] [RFC qemu-server v3 21/34] backup: implement backup for external providers Fiona Ebner
2024-11-12 12:27 ` Fabian Grünbichler
2024-11-12 14:35 ` Fiona Ebner
2024-11-12 15:17 ` Fabian Grünbichler
2024-11-07 16:51 ` [pve-devel] [PATCH qemu-server v3 22/34] restore: die early when there is no size for a device Fiona Ebner
2024-11-12 9:28 ` [pve-devel] applied: " Fabian Grünbichler
2024-11-07 16:51 ` [pve-devel] [RFC qemu-server v3 23/34] backup: implement restore for external providers Fiona Ebner
2024-11-07 16:51 ` [pve-devel] [RFC qemu-server v3 24/34] backup restore: external: hardening check for untrusted source image Fiona Ebner
2024-11-07 16:51 ` [pve-devel] [PATCH container v3 25/34] create: add missing include of PVE::Storage::Plugin Fiona Ebner
2024-11-12 15:22 ` [pve-devel] applied: " Fabian Grünbichler
2024-11-07 16:51 ` [pve-devel] [RFC container v3 26/34] backup: implement backup for external providers Fiona Ebner
2024-11-07 16:51 ` [pve-devel] [RFC container v3 27/34] create: factor out tar restore command helper Fiona Ebner
2024-11-12 16:28 ` Fabian Grünbichler
2024-11-12 17:08 ` [pve-devel] applied: " Thomas Lamprecht
2024-11-07 16:51 ` [pve-devel] [RFC container v3 28/34] backup: implement restore for external providers Fiona Ebner
2024-11-12 16:27 ` Fabian Grünbichler
2024-11-07 16:51 ` [pve-devel] [RFC container v3 29/34] external restore: don't use 'one-file-system' tar flag when restoring from a directory Fiona Ebner
2024-11-07 16:51 ` [pve-devel] [RFC container v3 30/34] create: factor out compression option helper Fiona Ebner
2024-11-07 16:51 ` [pve-devel] [RFC container v3 31/34] restore tar archive: check potentially untrusted archive Fiona Ebner
2024-11-07 16:51 ` [pve-devel] [RFC container v3 32/34] api: add early check against restoring privileged container from external source Fiona Ebner
2024-11-07 16:51 ` [pve-devel] [PATCH manager v3 33/34] ui: backup: also check for backup subtype to classify archive Fiona Ebner
2024-11-07 16:51 ` [pve-devel] [RFC manager v3 34/34] backup: implement backup for external providers Fiona Ebner
2024-11-12 15:50 ` [pve-devel] partially-applied: [RFC qemu/common/storage/qemu-server/container/manager v3 00/34] backup provider API Thomas Lamprecht
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=1731489757.shva7ho8xr.astroid@yuna.none \
--to=f.gruenbichler@proxmox.com \
--cc=pve-devel@lists.proxmox.com \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox