public inbox for pve-devel@lists.proxmox.com
 help / color / mirror / Atom feed
* [pve-devel] [PATCH pve-storage-examples] initial commit
@ 2025-04-07 12:04 Fiona Ebner
  0 siblings, 0 replies; only message in thread
From: Fiona Ebner @ 2025-04-07 12:04 UTC (permalink / raw)
  To: pve-devel

Includes two backup provider example plugins: one for a directory
storage and one for Borg.

Signed-off-by: Fiona Ebner <f.ebner@proxmox.com>
---
 Makefile                                      |  47 +
 debian/changelog                              |   6 +
 debian/control                                |  19 +
 debian/copyright                              |  16 +
 debian/docs                                   |   1 +
 debian/rules                                  |   8 +
 debian/triggers                               |   1 +
 src/Makefile                                  |  14 +
 src/PVE/BackupProvider/Makefile               |   3 +
 src/PVE/BackupProvider/Plugin/Borg.pm         | 466 ++++++++++
 .../BackupProvider/Plugin/DirectoryExample.pm | 809 ++++++++++++++++++
 src/PVE/BackupProvider/Plugin/Makefile        |   5 +
 src/PVE/Makefile                              |   6 +
 .../Custom/BackupProviderDirExamplePlugin.pm  | 308 +++++++
 src/PVE/Storage/Custom/BorgBackupPlugin.pm    | 689 +++++++++++++++
 src/PVE/Storage/Custom/Makefile               |   6 +
 src/PVE/Storage/Makefile                      |   3 +
 17 files changed, 2407 insertions(+)
 create mode 100644 Makefile
 create mode 100644 debian/changelog
 create mode 100644 debian/control
 create mode 100644 debian/copyright
 create mode 100644 debian/docs
 create mode 100755 debian/rules
 create mode 100644 debian/triggers
 create mode 100644 src/Makefile
 create mode 100644 src/PVE/BackupProvider/Makefile
 create mode 100644 src/PVE/BackupProvider/Plugin/Borg.pm
 create mode 100644 src/PVE/BackupProvider/Plugin/DirectoryExample.pm
 create mode 100644 src/PVE/BackupProvider/Plugin/Makefile
 create mode 100644 src/PVE/Makefile
 create mode 100644 src/PVE/Storage/Custom/BackupProviderDirExamplePlugin.pm
 create mode 100644 src/PVE/Storage/Custom/BorgBackupPlugin.pm
 create mode 100644 src/PVE/Storage/Custom/Makefile
 create mode 100644 src/PVE/Storage/Makefile

diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..1846daa
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,47 @@
+include /usr/share/dpkg/default.mk
+
+PACKAGE=libpve-storage-examples-perl
+
+GITVERSION:=$(shell git rev-parse HEAD)
+
+BUILDDIR ?= $(PACKAGE)-$(DEB_VERSION)
+DSC=$(PACKAGE)_$(DEB_VERSION).dsc
+
+DEB=$(PACKAGE)_$(DEB_VERSION_UPSTREAM_REVISION)_all.deb
+
+all: $(DEB)
+
+$(BUILDDIR): debian
+	rm -rf $@ $@.tmp
+	cp -a src $@.tmp
+	cp -a debian/ $@.tmp/
+	echo "git clone git://git.proxmox.com/git/pve-storage-examples.git\\ngit checkout $(GITVERSION)" > $@.tmp/debian/SOURCE
+	mv $@.tmp $@
+
+.PHONY: deb
+deb: $(DEB)
+$(DEB): $(BUILDDIR)
+	cd $(BUILDDIR); dpkg-buildpackage -b -us -uc
+	lintian $(DEB)
+
+.PHONY: dsc
+dsc: $(DSC)
+$(DSC): $(BUILDDIR)
+	cd $(BUILDDIR); dpkg-buildpackage -S -us -uc -d
+	lintian $(DSC)
+
+.PHONY: sbuild
+sbuild: $(DSC)
+	sbuild $(DSC)
+
+.PHONY: upload
+upload: UPLOAD_DIST ?= $(DEB_DISTRIBUTION)
+upload: $(DEB)
+	tar cf - $(DEB)|ssh repoman@repo.proxmox.com -- upload --product pve --dist $(UPLOAD_DIST)
+
+.PHONY: distclean
+distclean: clean
+
+.PHONY: clean
+clean:
+	rm -rf $(PACKAGE)-[0-9]*/ $(PACKAGE)*.tar.* *.deb *.dsc *.changes *.build *.buildinfo
diff --git a/debian/changelog b/debian/changelog
new file mode 100644
index 0000000..941e101
--- /dev/null
+++ b/debian/changelog
@@ -0,0 +1,6 @@
+libpve-storage-examples-perl (0.1.0) bookworm; urgency=medium
+
+  * Initial release. Includes two backup provider plugin examples: one for a
+    directory storage and one for Borg.
+
+ -- Proxmox Support Team <support@proxmox.com>  Mon, 07 Apr 2025 13:49:40 +0200
diff --git a/debian/control b/debian/control
new file mode 100644
index 0000000..5f73b8c
--- /dev/null
+++ b/debian/control
@@ -0,0 +1,19 @@
+Source: libpve-storage-examples-perl
+Section: perl
+Priority: optional
+Maintainer: Proxmox Support Team <support@proxmox.com>
+Build-Depends: debhelper-compat (= 13),
+               lintian,
+               perl,
+Standards-Version: 4.6.2
+Homepage: https://www.proxmox.com
+
+Package: libpve-storage-examples-perl
+Architecture: all
+Depends: libpve-storage-perl (>= 8.3.5),
+         ${misc:Depends},
+         ${perl:Depends},
+Recommends: libnbd-bin,
+Description: Example plugins for Proxmox VE storage management library
+ This package contains example plugins for the storage management library used
+ by Proxmox VE.
diff --git a/debian/copyright b/debian/copyright
new file mode 100644
index 0000000..1517c49
--- /dev/null
+++ b/debian/copyright
@@ -0,0 +1,16 @@
+Copyright (C) 2025 Proxmox Server Solutions GmbH
+
+This software is written by Proxmox Server Solutions GmbH <support@proxmox.com>
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
diff --git a/debian/docs b/debian/docs
new file mode 100644
index 0000000..8696672
--- /dev/null
+++ b/debian/docs
@@ -0,0 +1 @@
+debian/SOURCE
diff --git a/debian/rules b/debian/rules
new file mode 100755
index 0000000..218df65
--- /dev/null
+++ b/debian/rules
@@ -0,0 +1,8 @@
+#!/usr/bin/make -f
+# -*- makefile -*-
+
+# Uncomment this to turn on verbose mode.
+#export DH_VERBOSE=1
+
+%:
+	dh $@
diff --git a/debian/triggers b/debian/triggers
new file mode 100644
index 0000000..59dd688
--- /dev/null
+++ b/debian/triggers
@@ -0,0 +1 @@
+activate-noawait pve-api-updates
diff --git a/src/Makefile b/src/Makefile
new file mode 100644
index 0000000..2bd6667
--- /dev/null
+++ b/src/Makefile
@@ -0,0 +1,14 @@
+DESTDIR=
+PREFIX=/usr
+
+export PERLDIR=$(PREFIX)/share/perl5
+
+all:
+
+.PHONY: install
+install: PVE
+	$(MAKE) -C PVE install
+
+.PHONY: clean
+clean:
+	$(MAKE) -C PVE clean
diff --git a/src/PVE/BackupProvider/Makefile b/src/PVE/BackupProvider/Makefile
new file mode 100644
index 0000000..f018cef
--- /dev/null
+++ b/src/PVE/BackupProvider/Makefile
@@ -0,0 +1,3 @@
+.PHONY: install
+install:
+	make -C Plugin install
diff --git a/src/PVE/BackupProvider/Plugin/Borg.pm b/src/PVE/BackupProvider/Plugin/Borg.pm
new file mode 100644
index 0000000..decc78a
--- /dev/null
+++ b/src/PVE/BackupProvider/Plugin/Borg.pm
@@ -0,0 +1,466 @@
+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 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 ($storeid, $archive, $operation, $uid) = @_;
+
+    my $run_dir = "/run/pve-storage/borg-plugin/${storeid}.${archive}.${operation}.$$";
+    _remove_tree($run_dir);
+    make_path($run_dir) or die "unable to create directory $run_dir\n";
+    chmod(0700, $run_dir) or die "unable to chmod directory $run_dir - $!\n";
+    if ($uid) {
+	chown($uid, -1, $run_dir) or die "unable to change owner for $run_dir\n";
+    }
+
+    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) = @_;
+
+    return $self->{'storage-plugin'}->borg_cmd_extract_contents(
+	$self->{scfg},
+	$self->{storeid},
+	$archive,
+	[$file],
+    );
+}
+
+# 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_init {
+    my ($self, $start_time) = @_;
+
+    $self->{'job-id'} = $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});
+}
+
+sub job_cleanup {
+    my ($self) = @_;
+
+    delete($self->{password});
+    close($self->{'ssh-key-fh'});
+
+    return;
+}
+
+sub backup_init {
+    my ($self, $vmid, $vmtype, $start_time) = @_;
+
+    $self->{$vmid}->{archive} = archive_name($vmtype, $vmid, $start_time);
+
+    return { 'archive-name' => $self->{$vmid}->{archive} };
+}
+
+sub backup_cleanup {
+    my ($self, $vmid, $vmtype, $success, $info) = @_;
+
+    if (defined($vmtype) && $vmtype eq 'lxc') {
+	if (my $run_dir = $self->{$vmid}->{'run-dir'}) {
+	    eval {
+		# tmpfs for temporary SSH files gets mounted there in backup_container()
+		eval { PVE::Tools::run_command(['umount', "${run_dir}/ssh"]); };
+		eval { PVE::Tools::run_command(['umount', '-R', "${run_dir}/backup/filesystem"]); };
+		_remove_tree($run_dir);
+	    };
+	    die "unable to clean up $run_dir - $@" if $@;
+	}
+    }
+    return { stats => { 'archive-size' => 0 } }; # TODO get size
+}
+
+sub backup_container_prepare {
+    my ($self, $vmid, $info) = @_;
+
+    my $archive = $self->{$vmid}->{archive};
+    my $run_dir = prepare_run_dir(
+	$self->{storeid}, $archive, "backup-container", $info->{'backup-user-id'});
+    $self->{$vmid}->{'run-dir'} = $run_dir;
+
+    my $create_dir = sub {
+	my $dir = shift;
+	make_path($dir) or die "unable to create directory $dir\n";
+	chmod(0700, $dir) or die "unable to chmod directory $dir\n";
+	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");
+
+    for my $subdir ($info->{sources}->@*) {
+	PVE::Tools::run_command([
+	    'mount',
+	    '-o', 'bind,ro',
+	    "$info->{directory}/${subdir}",
+	    "${run_dir}/backup/filesystem/${subdir}",
+	]);
+    }
+}
+
+sub backup_get_mechanism {
+    my ($self, $vmid, $vmtype) = @_;
+
+    return 'file-handle' if $vmtype eq 'qemu';
+    return 'directory' if $vmtype eq 'lxc';
+
+    die "unsupported VM type '$vmtype'\n";
+}
+
+sub backup_handle_log_file {
+    my ($self, $vmid, $filename) = @_;
+
+    return; # don't upload, Proxmox VE keeps the task log too
+}
+
+sub backup_vm_query_incremental {
+    my ($self, $vmid, $volumes) = @_;
+
+    return; # no support currently
+}
+
+my sub backup_vm_setup_loopdev {
+    my ($file) = @_;
+
+    my $device;
+    my $parser = sub {
+	my $line = shift;
+	if ($line =~ m@^(/dev/loop\d+)$@) {
+	    $device = $1;
+	}
+    };
+    my $losetup_cmd = [
+	'losetup',
+	'--show',
+	'-r',
+	'-f',
+	$file,
+    ];
+    PVE::Tools::run_command($losetup_cmd, outfunc => $parser);
+    return $device;
+}
+
+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($self->{storeid}, $archive, "backup-vm");
+    my $volume_dir = "${run_dir}/volumes";
+    make_path($volume_dir) or die "unable to create directory $volume_dir\n";
+
+    PVE::Tools::file_set_contents("${run_dir}/guest.config", $guest_config);
+    my $paths = ['./guest.config'];
+
+    if (my $firewall_config = $info->{'firewall-config'}) {
+	PVE::Tools::file_set_contents("${run_dir}/firewall.config", $firewall_config);
+	push $paths->@*, './firewall.config';
+    }
+
+    my @blockdevs = ();
+
+    # TODO --stats for size?
+
+    eval {
+	for my $device_name (sort keys $volumes->%*) {
+	    # FIXME there is no option to follow symlinks except in combination with special files,
+	    # so loop devices are set up here for this purpose. Newer versions of Borg (since 1.4)
+	    # could use the slashdot hack instead:
+	    # https://github.com/borgbackup/borg/commit/e7bd18d7f38ddf9e58a4587ae4a2ad8a24d67374
+	    my $path = "/proc/$$/fd/" . fileno($volumes->{$device_name}->{'file-handle'});
+	    my $blockdev = backup_vm_setup_loopdev($path);
+	    push @blockdevs, $blockdev;
+
+	    my $link_name = "${volume_dir}/${device_name}.raw";
+	    symlink($blockdev, $link_name) or die "could not create symlink $link_name -> $blockdev\n";
+	    push $paths->@*, "./volumes/" . basename($link_name, ());
+	}
+
+	local $CWD = $run_dir;
+
+	$self->{'storage-plugin'}->borg_cmd_create(
+	    $self->{scfg},
+	    $self->{storeid},
+	    $self->{$vmid}->{archive},
+	    $paths,
+	    ['--read-special', '--progress'],
+	);
+    };
+    my $err = $@;
+    for my $blockdev (@blockdevs) {
+	eval { PVE::Tools::run_command(['losetup', '-d', $blockdev]); };
+	log_warning($self, "cannot cleanup loop device - $@") if $@;
+    }
+    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};
+
+    my $ssh_key;
+    if ($self->{'ssh-key-fh'}) {
+	$ssh_key =
+	    PVE::Tools::safe_read_from($self->{'ssh-key-fh'}, 1024 * 1024, 0, "SSH key file");
+    }
+
+    my (undef, $ssh_options) =
+	$self->{'storage-plugin'}->borg_setup_ssh_dir($self->{scfg}, "${run_dir}/ssh", $ssh_key);
+
+    PVE::Tools::file_set_contents("${backup_dir}/guest.config", $guest_config);
+    my $paths = ['./guest.config'];
+
+    if (my $firewall_config = $info->{'firewall-config'}) {
+	PVE::Tools::file_set_contents("${backup_dir}/firewall.config", $firewall_config);
+	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 " . join(" ", $ssh_options->@*);
+
+	my $uri = $self->{'storage-plugin'}->borg_repository_uri($self->{scfg}, $self->{storeid});
+	my $archive = $self->{$vmid}->{archive};
+
+	my $cmd = ['borg', 'create', $opts->@*, "${uri}::${archive}", $paths->@*];
+
+	PVE::Tools::run_command($cmd, errmsg => "command @$cmd failed");
+    }
+}
+
+sub restore_get_mechanism {
+    my ($self, $volname) = @_;
+
+    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 archive_get_guest_config {
+    my ($self, $volname) = @_;
+
+    my (undef, $archive) = $self->{'storage-plugin'}->parse_volname($volname);
+    return file_contents_from_archive($self, $archive, 'guest.config');
+}
+
+sub archive_get_firewall_config {
+    my ($self, $volname) = @_;
+
+    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) = @_;
+
+    my $res = {};
+
+    my (undef, $archive, $vmid) = $self->{'storage-plugin'}->parse_volname($volname);
+
+    my $run_dir = prepare_run_dir($self->{storeid}, $archive, "restore-vm");
+    $self->{$volname}->{'run-dir'} = $run_dir;
+
+    my $mount_point = "${run_dir}/mount";
+    make_path($mount_point) or die "unable to create directory $mount_point\n";
+    $self->{$volname}->{'mount-point'} = $mount_point;
+
+    $self->{'storage-plugin'}->borg_cmd_mount(
+	$self->{scfg},
+	$self->{storeid},
+	$archive,
+	$mount_point,
+    );
+
+    my @backup_files = glob("$mount_point/volumes/*");
+    for my $backup_file (@backup_files) {
+	next if $backup_file !~ m!^(.*/(.*)\.raw)$!; # untaint
+	($backup_file, my $device_name) = ($1, $2);
+	# TODO avoid dependency on base plugin?
+	$res->{$device_name}->{size} =
+	    PVE::Storage::Plugin::file_size_info($backup_file, undef, 'raw');
+    }
+
+    return $res;
+}
+
+sub restore_vm_cleanup {
+    my ($self, $volname) = @_;
+
+    my $run_dir = $self->{$volname}->{'run-dir'} or return;
+    my $mount_point = $self->{$volname}->{'mount-point'};
+
+    eval { PVE::Tools::run_command(['umount', $mount_point]) };
+    eval { _remove_tree($run_dir); };
+    die "unable to clean up $run_dir - $@" if $@;
+
+    return;
+}
+
+sub restore_vm_volume_init {
+    my ($self, $volname, $device_name, $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/${device_name}.raw" };
+}
+
+sub restore_vm_volume_cleanup {
+    my ($self, $volname, $device_name, $info) = @_;
+
+    return;
+}
+
+sub restore_container_init {
+    my ($self, $volname, $info) = @_;
+
+    my (undef, $archive, $vmid) = $self->{'storage-plugin'}->parse_volname($volname);
+    my $run_dir = prepare_run_dir($self->{storeid}, $archive, "restore-container");
+    $self->{$volname}->{'run-dir'} = $run_dir;
+
+    my $mount_point = "${run_dir}/mount";
+    make_path($mount_point) or die "unable to create directory $mount_point\n";
+    $self->{$volname}->{'mount-point'} = $mount_point;
+
+    $self->{'storage-plugin'}->borg_cmd_mount(
+	$self->{scfg},
+	$self->{storeid},
+	$archive,
+	$mount_point,
+    );
+
+    return { 'archive-directory' => "${mount_point}/filesystem" };
+}
+
+sub restore_container_cleanup {
+    my ($self, $volname, $info) = @_;
+
+    my $run_dir = $self->{$volname}->{'run-dir'} or return;
+    my $mount_point = $self->{$volname}->{'mount-point'};
+
+    eval { PVE::Tools::run_command(['umount', $mount_point]) };
+    eval { _remove_tree($run_dir); };
+    die "unable to clean up $run_dir - $@" if $@;
+}
+
+1;
diff --git a/src/PVE/BackupProvider/Plugin/DirectoryExample.pm b/src/PVE/BackupProvider/Plugin/DirectoryExample.pm
new file mode 100644
index 0000000..f375f4f
--- /dev/null
+++ b/src/PVE/BackupProvider/Plugin/DirectoryExample.pm
@@ -0,0 +1,809 @@
+package PVE::BackupProvider::Plugin::DirectoryExample;
+
+use strict;
+use warnings;
+
+use Fcntl qw(SEEK_SET);
+use File::Path qw(make_path remove_tree);
+use IO::File;
+use IPC::Open3;
+use JSON qw(from_json to_json);
+
+use PVE::Storage::Common;
+use PVE::Storage::Plugin;
+use PVE::Tools qw(file_get_contents file_read_firstline file_set_contents run_command);
+
+use base qw(PVE::BackupProvider::Plugin::Base);
+
+# Private helpers
+
+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);
+}
+
+# NOTE: This is just for proof-of-concept testing! A backup provider plugin should either use the
+# 'nbd' backup mechansim and use the NBD protocol or use the 'file-handle' mechanism. There should
+# be no need to use /dev/nbdX nodes for proper plugins.
+my sub bind_next_free_dev_nbd_node {
+    my ($options) = @_;
+
+    # /dev/nbdX devices are reserved in a file. Those reservations expires after $expiretime.
+    # This avoids race conditions between allocation and use.
+
+    die "file '/sys/module/nbd' does not exist - 'nbd' kernel module not loaded?"
+	if !-e "/sys/module/nbd";
+
+    my $line = PVE::Tools::file_read_firstline("/sys/module/nbd/parameters/nbds_max")
+	or die "could not read 'nbds_max' parameter file for 'nbd' kernel module\n";
+    my ($nbds_max) = ($line =~ m/(\d+)/)
+	or die "could not determine 'nbds_max' parameter for 'nbd' kernel module\n";
+
+    my $filename = "/run/qemu-server/reserved-dev-nbd-nodes";
+
+    my $code = sub {
+	my $expiretime = 60;
+	my $ctime = time();
+
+	my $used = {};
+	my $latest = [0, 0];
+
+	if (my $fh = IO::File->new ($filename, "r")) {
+	    while (my $line = <$fh>) {
+		if ($line =~ m/^(\d+)\s(\d+)$/) {
+		    my ($n, $timestamp) = ($1, $2);
+
+		    $latest = [$n, $timestamp] if $latest->[1] <= $timestamp;
+
+		    if (($timestamp + $expiretime) > $ctime) {
+			$used->{$n} = $timestamp; # not expired
+		    }
+		}
+	    }
+	}
+
+	my $new_n;
+	for (my $count = 0; $count < $nbds_max; $count++) {
+	    my $n = ($latest->[0] + $count) % $nbds_max;
+	    my $block_device = "/dev/nbd${n}";
+	    next if $used->{$n}; # reserved
+	    next if !-e $block_device;
+
+	    my $st = File::stat::stat("/run/lock/qemu-nbd-nbd${n}");
+	    next if defined($st) && S_ISSOCK($st->mode) && $st->uid == 0; # in use
+
+	    # Used to avoid looping if there are other issues then the NBD node being in use
+	    my $socket_error = 0;
+	    eval {
+		my $errfunc = sub {
+		    my ($line) = @_;
+		    $socket_error = 1 if $line =~ m/^qemu-nbd: Failed to set NBD socket$/;
+		    log_warn($line);
+		};
+		run_command(["qemu-nbd", "-c", $block_device, $options->@*], errfunc => $errfunc);
+	    };
+	    if (my $err = $@) {
+		die $err if !$socket_error;
+		log_warn("unable to bind $block_device - trying next one");
+		next;
+	    }
+	    $used->{$n} = $ctime;
+	    $new_n = $n;
+	    last;
+	}
+
+	my $data = "";
+	$data .= "$_ $used->{$_}\n" for keys $used->%*;
+
+	PVE::Tools::file_set_contents($filename, $data);
+
+	return defined($new_n) ? "/dev/nbd${new_n}" : undef;
+    };
+
+    my $block_device =
+	PVE::Tools::lock_file('/run/lock/qemu-server/reserved-dev-nbd-nodes.lock', 10, $code);
+    die $@ if $@;
+
+    die "unable to find free /dev/nbdX block device node\n" if !$block_device;
+
+    return $block_device;
+}
+
+# Backup Provider API
+
+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 'dir provider example';
+}
+
+# Hooks
+
+sub job_init {
+    my ($self, $start_time) = @_;
+
+    log_info($self, "job init called");
+
+    if (!-e '/sys/module/nbd/parameters') {
+	die "required 'nbd' kernel module not loaded - use 'modprobe nbd nbds_max=128' to load it"
+	    ." manually\n";
+    }
+
+    log_info($self, "backup provider initialized successfully for new job $start_time");
+
+    return;
+}
+
+sub job_cleanup {
+    my ($self) = @_;
+
+    log_info($self, "job cleanup called");
+
+    return;
+}
+
+sub backup_init {
+    my ($self, $vmid, $vmtype, $backup_time) = @_;
+
+    my $archive_subdir = "${vmtype}-${backup_time}";
+    my $archive = "${vmid}/${archive_subdir}";
+
+    log_info($self, "backup start hook called");
+
+    my $backup_dir = $self->{scfg}->{path} . "/" . $archive;
+
+    make_path($backup_dir);
+    die "unable to create directory $backup_dir\n" if !-d $backup_dir;
+
+    $self->{$vmid}->{'backup-time'} = $backup_time;
+    $self->{$vmid}->{'backup-dir'} = $backup_dir;
+
+    $self->{$vmid}->{'archive-subdir'} = $archive_subdir;
+    $self->{$vmid}->{archive} = $archive;
+    return { 'archive-name' => $archive };
+}
+
+my sub get_previous_info_tainted {
+    my ($self, $vmid) = @_;
+
+    my $previous_info_file = "$self->{scfg}->{path}/$vmid/previous-info";
+
+    return eval { from_json(file_get_contents($previous_info_file)) } // {};
+}
+
+my sub update_previous_info {
+    my ($self, $vmid) = @_;
+
+    my $previous_info_file = "$self->{scfg}->{path}/$vmid/previous-info";
+
+    if (defined(my $info = $self->{$vmid}->{previous})) {
+	file_set_contents($previous_info_file, to_json($info));
+    } else {
+	unlink($previous_info_file);
+    }
+}
+
+
+sub backup_cleanup {
+    my ($self, $vmid, $vmtype, $success, $info) = @_;
+
+    if ($success) {
+	log_info($self, "backup cleanup called - success");
+	eval {
+	    update_previous_info($self, $vmid, $self->{$vmid}->{previous});
+	};
+	if (my $err = $@) {
+	    log_error($self, "failed to update previous-info file: $err");
+	}
+	my $size = 0;
+	my $backup_dir = $self->{$vmid}->{'backup-dir'};
+	my @backup_files = glob("$backup_dir/*");
+	$size += -s $_ for @backup_files;
+	my $stats = { 'archive-size' => $size };
+	return { 'stats' => $stats };
+    } else {
+	log_info($self, "backup cleanup called - failure");
+
+	$self->{$vmid}->{failed} = 1;
+
+	if (my $dir = $self->{$vmid}->{'backup-dir'}) {
+	    eval { remove_tree($dir) };
+	    log_warning($self, "unable to clean up $dir - $@") if $@;
+	}
+
+	# Restore old previous-info so next attempt can re-use bitmap again
+	if (my $info = $self->{$vmid}->{'old-previous-info'}) {
+	    my $previous_info_dir = "$self->{scfg}->{path}/$vmid/";
+	    my $previous_info_file = "$previous_info_dir/previous-info";
+	    file_set_contents($previous_info_file, $info);
+	}
+    }
+}
+
+sub backup_container_prepare {
+    my ($self, $vmid, $info) = @_;
+
+    my $dir = $self->{$vmid}->{'backup-dir'};
+    chown($info->{'backup-user-id'}, -1, $dir) or die "unable to change owner for $dir\n";
+
+    return;
+}
+
+sub backup_vm_query_incremental {
+    my ($self, $vmid, $volumes) = @_;
+
+    # Try to use the last backup's disks for incremental backup if the storage
+    # is configured for incremental VM backup. Need to start fresh if there is
+    # no previous backup or the associated backup doesn't exist.
+
+    return if $self->{'storage-plugin'}->get_vm_backup_mode($self->{scfg}) ne 'incremental';
+
+    my $vmtype = 'qemu';
+
+    my $out = {};
+
+    my $info = get_previous_info_tainted($self, $vmid);
+    for my $device_name (keys $volumes->%*) {
+	$out->{$device_name} = 'new';
+
+	my $prev_file = $info->{$device_name};
+	next if !defined $prev_file;
+	# it's type-time/disk.qcow2
+	next if $prev_file !~ m!^([^/]+/[^/]+\.qcow2)$!;
+	$prev_file = $1; # untaint
+
+	my $full_path = "$self->{scfg}->{path}/$vmid/$prev_file";
+
+	if (-e $full_path) {
+	    $self->{$vmid}->{previous}->{$device_name} = $prev_file;
+	    $out->{$device_name} = 'use';
+	}
+    }
+
+    return $out;
+}
+
+sub backup_get_mechanism {
+    my ($self, $vmid, $vmtype) = @_;
+
+    return 'directory' if $vmtype eq 'lxc';
+    return $self->{'storage-plugin'}->get_vm_backup_mechanism($self->{scfg}) if $vmtype eq 'qemu';
+
+    die "unsupported guest type '$vmtype'\n";
+}
+
+sub backup_handle_log_file {
+    my ($self, $vmid, $filename) = @_;
+
+    my $log_dir = $self->{$vmid}->{'backup-dir'};
+    if ($self->{$vmid}->{failed}) {
+	$log_dir .= ".failed";
+    }
+    make_path($log_dir);
+    die "unable to create directory $log_dir\n" if !-d $log_dir;
+
+    my $data = file_get_contents($filename);
+    my $target = "${log_dir}/backup.log";
+    file_set_contents($target, $data);
+}
+
+my sub backup_file {
+    my ($self, $vmid, $device_name, $size, $in_fh, $bitmap_mode, $next_dirty_region, $bandwidth_limit) = @_;
+
+    # TODO honor bandwidth_limit
+
+    my $target = "$self->{$vmid}->{'backup-dir'}/${device_name}.qcow2";
+
+    my $create_cmd = ["qemu-img", "create", "-f", "qcow2", $target, $size];
+    if (my $previous_file = $self->{$vmid}->{previous}->{$device_name}) {
+	my $target_base = "../$previous_file";
+	push $create_cmd->@*, "-b", $target_base, "-F", "qcow2";
+    }
+    run_command($create_cmd);
+
+    my $nbd_node;
+    eval {
+	# allows to easily write to qcow2 target
+	$nbd_node = bind_next_free_dev_nbd_node([$target, '--format=qcow2']);
+	# FIXME use nbdfuse like in qemu-server rather than qemu-nbd. Seems like there is a race and
+	# sysseek() can fail with "Invalid argument" if done too early...
+	sleep 1;
+
+	my $block_size = 4 * 1024 * 1024; # 4 MiB
+
+	my $out_fh = IO::File->new($nbd_node, "r+")
+	    or die "unable to open NBD backup target - $!\n";
+
+	my $buffer = '';
+	my $skip_discard;
+
+	while (scalar((my $region_offset, my $region_length) = $next_dirty_region->())) {
+	    sysseek($in_fh, $region_offset, SEEK_SET)
+		// die "unable to seek '$region_offset' in NBD backup source - $!\n";
+	    sysseek($out_fh, $region_offset, SEEK_SET)
+		// die "unable to seek '$region_offset' in NBD backup target - $!\n";
+
+	    my $local_offset = 0; # within the region
+	    while ($local_offset < $region_length) {
+		my $remaining = $region_length - $local_offset;
+		my $request_size = $remaining < $block_size ? $remaining : $block_size;
+		my $offset = $region_offset + $local_offset;
+
+		my $read = sysread($in_fh, $buffer, $request_size);
+		die "failed to read from backup source - $!\n" if !defined($read);
+		die "premature EOF while reading backup source\n" if $read == 0;
+
+		my $written = 0;
+		while ($written < $read) {
+		    my $res = syswrite($out_fh, $buffer, $request_size - $written, $written);
+		    die "failed to write to backup target - $!\n" if !defined($res);
+		    die "unable to progress writing to backup target\n" if $res == 0;
+		    $written += $res;
+		}
+
+		if (!$skip_discard) {
+		    eval { PVE::Storage::Common::deallocate($in_fh, $offset, $request_size); };
+		    if (my $err = $@) {
+			# Just assume that if one request didn't work, others won't either.
+			log_warning(
+			    $self, "discard source failed (skipping further discards) - $err");
+			$skip_discard = 1;
+		     }
+		 }
+
+		$local_offset += $request_size;
+	    }
+	}
+	$out_fh->sync();
+    };
+    my $err = $@;
+
+    $self->{$vmid}->{previous}->{$device_name} = "$self->{$vmid}->{'archive-subdir'}/${device_name}.qcow2";
+
+    eval { run_command(['qemu-nbd', '-d', $nbd_node ]); };
+    log_warning($self, "unable to disconnect NBD backup target - $@") if $@;
+
+    die $err if $err;
+}
+
+my sub backup_nbd {
+    my ($self, $vmid, $device_name, $size, $nbd_path, $bitmap_mode, $bitmap_name, $bandwidth_limit) = @_;
+
+    # TODO honor bandwidth_limit
+
+    die "need 'nbdinfo' binary from package libnbd-bin\n" if !-e "/usr/bin/nbdinfo";
+
+    my $nbd_info_uri = "nbd+unix:///${device_name}?socket=${nbd_path}";
+    my $qemu_nbd_uri = "nbd:unix:${nbd_path}:exportname=${device_name}";
+
+    my $cpid;
+    my $error_fh;
+    my $next_dirty_region;
+
+    # If there is no dirty bitmap, it can be treated as if there's a full dirty one. The output of
+    # nbdinfo is a list of tuples with offset, length, type, description. The first bit of 'type' is
+    # set when the bitmap is dirty, see QEMU's docs/interop/nbd.txt
+    my $dirty_bitmap = [];
+    if ($bitmap_mode ne 'none') {
+	my $input = IO::File->new();
+	my $info = IO::File->new();
+	$error_fh = IO::File->new();
+	my $nbdinfo_cmd = ["nbdinfo", $nbd_info_uri, "--map=qemu:dirty-bitmap:${bitmap_name}"];
+	$cpid = open3($input, $info, $error_fh, $nbdinfo_cmd->@*)
+	    or die "failed to spawn nbdinfo child - $!\n";
+
+	$next_dirty_region = sub {
+	    my ($offset, $length, $type);
+	    do {
+		my $line = <$info>;
+		return if !$line;
+		die "unexpected output from nbdinfo - $line\n"
+		    if $line !~ m/^\s*(\d+)\s*(\d+)\s*(\d+)/; # also untaints
+		($offset, $length, $type) = ($1, $2, $3);
+	    } while (($type & 0x1) == 0); # not dirty
+	    return ($offset, $length);
+	};
+    } else {
+	my $done = 0;
+	$next_dirty_region = sub {
+	    return if $done;
+	    $done = 1;
+	    return (0, $size);
+	};
+    }
+
+    my $nbd_node;
+    eval {
+	$nbd_node = bind_next_free_dev_nbd_node([$qemu_nbd_uri, "--format=raw", "--discard=on"]);
+
+	my $in_fh = IO::File->new($nbd_node, 'r+')
+	    or die "unable to open NBD backup source '$nbd_node' - $!\n";
+
+	backup_file(
+	    $self,
+	    $vmid,
+	    $device_name,
+	    $size,
+	    $in_fh,
+	    $bitmap_mode,
+	    $next_dirty_region,
+	    $bandwidth_limit,
+	);
+    };
+    my $err = $@;
+
+    eval { run_command(["qemu-nbd", "-d", $nbd_node ]); };
+    log_warning($self, "unable to disconnect NBD backup source - $@") if $@;
+
+    if ($cpid) {
+	my $waited;
+	my $wait_limit = 5;
+	for ($waited = 0; $waited < $wait_limit && waitpid($cpid, POSIX::WNOHANG) == 0; $waited++) {
+	    kill 15, $cpid if $waited == 0;
+	    sleep 1;
+	}
+	if ($waited == $wait_limit) {
+	    kill 9, $cpid;
+	    sleep 1;
+	    log_warning($self, "unable to collect nbdinfo child process")
+		if waitpid($cpid, POSIX::WNOHANG) == 0;
+	}
+    }
+
+    die $err if $err;
+}
+
+my sub backup_vm_volume {
+    my ($self, $vmid, $device_name, $info, $bandwidth_limit) = @_;
+
+    my $backup_mechanism = $self->{'storage-plugin'}->get_vm_backup_mechanism($self->{scfg});
+
+    if ($backup_mechanism eq 'nbd') {
+	backup_nbd(
+	    $self,
+	    $vmid,
+	    $device_name,
+	    $info->{size},
+	    $info->{'nbd-path'},
+	    $info->{'bitmap-mode'},
+	    $info->{'bitmap-name'},
+	    $bandwidth_limit,
+	);
+    } elsif ($backup_mechanism eq 'file-handle') {
+	backup_file(
+	    $self,
+	    $vmid,
+	    $device_name,
+	    $info->{size},
+	    $info->{'file-handle'},
+	    $info->{'bitmap-mode'},
+	    $info->{'next-dirty-region'},
+	    $bandwidth_limit,
+	);
+    } else {
+	die "internal error - unknown VM backup mechansim '$backup_mechanism'\n";
+    }
+}
+
+sub backup_vm {
+    my ($self, $vmid, $guest_config, $volumes, $info) = @_;
+
+    my $target = "$self->{$vmid}->{'backup-dir'}/guest.conf";
+    file_set_contents($target, $guest_config);
+
+    if (my $firewall_config = $info->{'firewall-config'}) {
+	$target = "$self->{$vmid}->{'backup-dir'}/firewall.conf";
+	file_set_contents($target, $firewall_config);
+    }
+
+    for my $device_name (sort keys $volumes->%*) {
+	backup_vm_volume(
+	    $self, $vmid, $device_name, $volumes->{$device_name}, $info->{'bandwidth-limit'});
+    }
+}
+
+my sub backup_directory_tar {
+    my ($self, $vmid, $directory, $exclude_patterns, $sources, $bandwidth_limit) = @_;
+
+    # essentially copied from PVE/VZDump/LXC.pm' archive()
+
+    # copied from PVE::Storage::Plugin::COMMON_TAR_FLAGS
+    my @tar_flags = qw(
+	--one-file-system
+	-p --sparse --numeric-owner --acls
+	--xattrs --xattrs-include=user.* --xattrs-include=security.capability
+	--warning=no-file-ignored --warning=no-xattr-write
+    );
+
+    my $tar = ['tar', 'cpf', '-', '--totals', @tar_flags];
+
+    push @$tar, "--directory=$directory";
+
+    my @exclude_no_anchored = ();
+    my @exclude_anchored = ();
+    for my $pattern ($exclude_patterns->@*) {
+	if ($pattern !~ m|^/|) {
+	    push @exclude_no_anchored, $pattern;
+	} else {
+	    push @exclude_anchored, $pattern;
+	}
+    }
+
+    push @$tar, '--no-anchored';
+    push @$tar, '--exclude=lost+found';
+    push @$tar, map { "--exclude=$_" } @exclude_no_anchored;
+
+    push @$tar, '--anchored';
+    push @$tar, map { "--exclude=.$_" } @exclude_anchored;
+
+    push @$tar, $sources->@*;
+
+    my $cmd = [ $tar ];
+
+    push @$cmd, [ 'cstream', '-t', $bandwidth_limit * 1024 ] if $bandwidth_limit;
+
+    my $target = "$self->{$vmid}->{'backup-dir'}/archive.tar";
+    push @{$cmd->[-1]}, \(">" . PVE::Tools::shellquote($target));
+
+    my $logfunc = sub {
+	my $line = shift;
+	log_info($self, "tar: $line");
+    };
+
+    PVE::Tools::run_command($cmd, logfunc => $logfunc);
+
+    return;
+};
+
+# NOTE This only serves as an example to illustrate the 'directory' restore mechanism. It is not
+# fleshed out properly, e.g. I didn't check if exclusion is compatible with
+# proxmox-backup-client/rsync or xattrs/ACL/etc. work as expected!
+my sub backup_directory_squashfs {
+    my ($self, $vmid, $directory, $exclude_patterns, $bandwidth_limit) = @_;
+
+    my $target = "$self->{$vmid}->{'backup-dir'}/archive.sqfs";
+
+    my $mksquashfs = ['mksquashfs', $directory, $target, '-quiet', '-no-progress'];
+
+    push $mksquashfs->@*, '-wildcards';
+
+    for my $pattern ($exclude_patterns->@*) {
+	if ($pattern !~ m|^/|) { # non-anchored
+	    push $mksquashfs->@*, '-e', "... $pattern";
+	} else { # anchored
+	    push $mksquashfs->@*, '-e', substr($pattern, 1); # need to strip leading slash
+	}
+    }
+
+    my $cmd = [ $mksquashfs ];
+
+    push @$cmd, [ 'cstream', '-t', $bandwidth_limit * 1024 ] if $bandwidth_limit;
+
+    my $logfunc = sub {
+	my $line = shift;
+	log_info($self, "mksquashfs: $line");
+    };
+
+    PVE::Tools::run_command($cmd, logfunc => $logfunc);
+
+    return;
+};
+
+sub backup_container {
+    my ($self, $vmid, $guest_config, $exclude_patterns, $info) = @_;
+
+    my $target = "$self->{$vmid}->{'backup-dir'}/guest.conf";
+    file_set_contents($target, $guest_config);
+
+    if (my $firewall_config = $info->{'firewall-config'}) {
+	$target = "$self->{$vmid}->{'backup-dir'}/firewall.conf";
+	file_set_contents($target, $firewall_config);
+    }
+
+    my $backup_mode = $self->{'storage-plugin'}->get_lxc_backup_mode($self->{scfg});
+    if ($backup_mode eq 'tar') {
+	backup_directory_tar(
+	    $self,
+	    $vmid,
+	    $info->{directory},
+	    $exclude_patterns,
+	    $info->{sources},
+	    $info->{'bandwidth-limit'},
+	);
+    } elsif ($backup_mode eq 'squashfs') {
+	backup_directory_squashfs(
+	    $self,
+	    $vmid,
+	    $info->{directory},
+	    $exclude_patterns,
+	    $info->{'bandwidth-limit'},
+	);
+    } else {
+	die "got unexpected backup mode '$backup_mode' from storage plugin\n";
+    }
+}
+
+# Restore API
+
+sub restore_get_mechanism {
+    my ($self, $volname) = @_;
+
+    my (undef, $relative_backup_dir) = $self->{'storage-plugin'}->parse_volname($volname);
+    my ($vmtype) = $relative_backup_dir =~ m!^\d+/([a-z]+)-!;
+
+    return ('qemu-img', $vmtype) if $vmtype eq 'qemu';
+
+    if ($vmtype eq 'lxc') {
+	my (undef, $relative_backup_dir) = $self->{'storage-plugin'}->parse_volname($volname);
+
+	if (-e "$self->{scfg}->{path}/${relative_backup_dir}/archive.tar") {
+	    $self->{'restore-mechanisms'}->{$volname} = 'tar';
+	    return ('tar', $vmtype);
+	}
+
+	if (-e "$self->{scfg}->{path}/${relative_backup_dir}/archive.sqfs") {
+	    $self->{'restore-mechanisms'}->{$volname} = 'directory';
+	    return ('directory', $vmtype)
+	}
+
+	die "unable to find archive '$volname'\n";
+    }
+
+    die "cannot restore unexpected guest type '$vmtype'\n";
+}
+
+sub archive_get_guest_config {
+    my ($self, $volname) = @_;
+
+    my (undef, $relative_backup_dir) = $self->{'storage-plugin'}->parse_volname($volname);
+    my $filename = "$self->{scfg}->{path}/${relative_backup_dir}/guest.conf";
+
+    return file_get_contents($filename);
+}
+
+sub archive_get_firewall_config {
+    my ($self, $volname) = @_;
+
+    my (undef, $relative_backup_dir) = $self->{'storage-plugin'}->parse_volname($volname);
+    my $filename = "$self->{scfg}->{path}/${relative_backup_dir}/firewall.conf";
+
+    return if !-e $filename;
+
+    return file_get_contents($filename);
+}
+
+sub restore_vm_init {
+    my ($self, $volname) = @_;
+
+    my $res = {};
+
+    my (undef, $relative_backup_dir) = $self->{'storage-plugin'}->parse_volname($volname);
+    my $backup_dir = "$self->{scfg}->{path}/${relative_backup_dir}";
+
+    my @backup_files = glob("$backup_dir/*");
+    for my $backup_file (@backup_files) {
+	next if $backup_file !~ m!^(.*/(.*)\.qcow2)$!;
+	$backup_file = $1; # untaint
+	$res->{$2}->{size} = PVE::Storage::Plugin::file_size_info($backup_file, undef, 'qcow2');
+    }
+
+    return $res;
+}
+
+sub restore_vm_cleanup {
+    my ($self, $volname) = @_;
+
+    return; # nothing to do
+}
+
+sub restore_vm_volume_init {
+    my ($self, $volname, $device_name, $info) = @_;
+
+    my (undef, $relative_backup_dir) = $self->{'storage-plugin'}->parse_volname($volname);
+    my $image = "$self->{scfg}->{path}/${relative_backup_dir}/${device_name}.qcow2";
+    # NOTE Backing files are not allowed by Proxmox VE when restoring. The reason is that an
+    # untrusted qcow2 image can specify an arbitrary backing file and thus leak data from the host.
+    # For the sake of the directory example plugin, an NBD export is created, but this side-steps
+    # the check and would allow the attack again. An actual implementation should check that the
+    # backing file (or rather, the whole backing chain) is safe first!
+    my $nbd_node = bind_next_free_dev_nbd_node([$image]);
+    $self->{"${volname}/${device_name}"}->{'nbd-node'} = $nbd_node;
+    return {
+	'qemu-img-path' => $nbd_node,
+    };
+}
+
+sub restore_vm_volume_cleanup {
+    my ($self, $volname, $device_name, $info) = @_;
+
+    if (my $nbd_node = delete($self->{"${volname}/${device_name}"}->{'nbd-node'})) {
+	PVE::Tools::run_command(['qemu-nbd', '-d', $nbd_node]);
+    }
+
+    return;
+}
+
+my sub restore_tar_init {
+    my ($self, $volname) = @_;
+
+    my (undef, $relative_backup_dir) = $self->{'storage-plugin'}->parse_volname($volname);
+    return { 'tar-path' => "$self->{scfg}->{path}/${relative_backup_dir}/archive.tar" };
+}
+
+my sub restore_directory_init {
+    my ($self, $volname) = @_;
+
+    my (undef, $relative_backup_dir, $vmid) = $self->{'storage-plugin'}->parse_volname($volname);
+    my $archive = "$self->{scfg}->{path}/${relative_backup_dir}/archive.sqfs";
+
+    my $mount_point = "/run/backup-provider-example/${vmid}.mount";
+    make_path($mount_point);
+    die "unable to create directory $mount_point\n" if !-d $mount_point;
+
+    run_command(['mount', '-o', 'ro', $archive, $mount_point]);
+
+    return { 'archive-directory' => $mount_point };
+}
+
+my sub restore_directory_cleanup {
+    my ($self, $volname) = @_;
+
+    my (undef, undef, $vmid) = $self->{'storage-plugin'}->parse_volname($volname);
+    my $mount_point = "/run/backup-provider-example/${vmid}.mount";
+
+    run_command(['umount', $mount_point]);
+
+    return;
+}
+
+sub restore_container_init {
+    my ($self, $volname, $info) = @_;
+
+    if ($self->{'restore-mechanisms'}->{$volname} eq 'tar') {
+	return restore_tar_init($self, $volname);
+    } elsif ($self->{'restore-mechanisms'}->{$volname} eq 'directory') {
+	return restore_directory_init($self, $volname);
+    } else {
+	die "no restore mechanism set for '$volname'\n";
+    }
+}
+
+sub restore_container_cleanup {
+    my ($self, $volname, $info) = @_;
+
+    if ($self->{'restore-mechanisms'}->{$volname} eq 'tar') {
+	return; # nothing to do
+    } elsif ($self->{'restore-mechanisms'}->{$volname} eq 'directory') {
+	return restore_directory_cleanup($self, $volname);
+    } else {
+	die "no restore mechanism set for '$volname'\n";
+    }
+}
+
+1;
diff --git a/src/PVE/BackupProvider/Plugin/Makefile b/src/PVE/BackupProvider/Plugin/Makefile
new file mode 100644
index 0000000..936885e
--- /dev/null
+++ b/src/PVE/BackupProvider/Plugin/Makefile
@@ -0,0 +1,5 @@
+SOURCES = Borg.pm DirectoryExample.pm
+
+.PHONY: install
+install:
+	for i in ${SOURCES}; do install -D -m 0644 $$i ${DESTDIR}${PERLDIR}/PVE/BackupProvider/Plugin/$$i; done
diff --git a/src/PVE/Makefile b/src/PVE/Makefile
new file mode 100644
index 0000000..4ea12dd
--- /dev/null
+++ b/src/PVE/Makefile
@@ -0,0 +1,6 @@
+.PHONY: install
+install:
+	make -C Storage install
+	make -C BackupProvider install
+
+clean:
diff --git a/src/PVE/Storage/Custom/BackupProviderDirExamplePlugin.pm b/src/PVE/Storage/Custom/BackupProviderDirExamplePlugin.pm
new file mode 100644
index 0000000..a57e126
--- /dev/null
+++ b/src/PVE/Storage/Custom/BackupProviderDirExamplePlugin.pm
@@ -0,0 +1,308 @@
+package PVE::Storage::Custom::BackupProviderDirExamplePlugin;
+
+use strict;
+use warnings;
+
+use File::Basename qw(basename);
+
+use PVE::BackupProvider::Plugin::DirectoryExample;
+use PVE::Tools;
+
+use base qw(PVE::Storage::Plugin);
+
+# Helpers
+
+sub get_vm_backup_mechanism {
+    my ($class, $scfg) = @_;
+
+    return $scfg->{'vm-backup-mechanism'} // properties()->{'vm-backup-mechanism'}->{'default'};
+}
+
+sub get_vm_backup_mode {
+    my ($class, $scfg) = @_;
+
+    return $scfg->{'vm-backup-mode'} // properties()->{'vm-backup-mode'}->{'default'};
+}
+
+sub get_lxc_backup_mode {
+    my ($class, $scfg) = @_;
+
+    return $scfg->{'lxc-backup-mode'} // properties()->{'lxc-backup-mode'}->{'default'};
+}
+
+# Configuration
+
+sub api {
+    return 11;
+}
+
+sub type {
+    return 'backup-provider-dir-example';
+}
+
+sub plugindata {
+    return {
+	content => [ { backup => 1, none => 1 }, { backup => 1 } ],
+	features => { 'backup-provider' => 1 },
+	'sensitive-properties' => {},
+    };
+}
+
+sub properties {
+    return {
+	'lxc-backup-mode' => {
+	    description => "How to create LXC backups. tar - create a tar archive."
+		." squashfs - create a squashfs image. Requires squashfs-tools to be installed.",
+	    type => 'string',
+	    enum => [qw(tar squashfs)],
+	    default => 'tar',
+	},
+	'vm-backup-mechanism' => {
+	    description => "Which mechanism to use for creating VM backups. nbd - access data via "
+		." NBD export. file-handle - access data via file handle.",
+	    type => 'string',
+	    enum => [qw(nbd file-handle)],
+	    default => 'file-handle',
+	},
+	'vm-backup-mode' => {
+	    description => "How to create VM backups. full - always create full backups."
+		." incremental - create incremental backups when possible, fallback to full when"
+		." necessary, e.g. VM disk's bitmap is invalid.",
+	    type => 'string',
+	    enum => [qw(full incremental)],
+	    default => 'full',
+	},
+    };
+}
+
+sub options {
+    return {
+	path => { fixed => 1 },
+	'lxc-backup-mode' => { optional => 1 },
+	'vm-backup-mechanism' => { optional => 1 },
+	'vm-backup-mode' => { optional => 1 },
+	disable => { optional => 1 },
+	nodes => { optional => 1 },
+	'prune-backups' => { optional => 1 },
+	'max-protected-backups' => { optional => 1 },
+    };
+}
+
+# Storage implementation
+
+# NOTE a proper backup storage should implement this
+sub prune_backups {
+    my ($class, $scfg, $storeid, $keep, $vmid, $type, $dryrun, $logfunc) = @_;
+
+    die "not implemented";
+}
+
+sub parse_volname {
+    my ($class, $volname) = @_;
+
+    if ($volname =~ m!^backup/((\d+)/[a-z]+-\d+)$!) {
+	my ($filename, $vmid) = ($1, $2);
+	return ('backup', $filename, $vmid);
+    }
+
+    die "unable to parse volume name '$volname'\n";
+}
+
+sub path {
+    my ($class, $scfg, $volname, $storeid, $snapname) = @_;
+
+    die "volume snapshot is not possible on backup-provider-dir-example volume" if $snapname;
+
+    my ($type, $filename, $vmid) = $class->parse_volname($volname);
+
+    return ("$scfg->{path}/${filename}", $vmid, $type);
+}
+
+sub create_base {
+    my ($class, $storeid, $scfg, $volname) = @_;
+
+    die "cannot create base image in backup-provider-dir-example storage\n";
+}
+
+sub clone_image {
+    my ($class, $scfg, $storeid, $volname, $vmid, $snap) = @_;
+
+    die "can't clone images in backup-provider-dir-example storage\n";
+}
+
+sub alloc_image {
+    my ($class, $storeid, $scfg, $vmid, $fmt, $name, $size) = @_;
+
+    die "can't allocate space in backup-provider-dir-example storage\n";
+}
+
+# NOTE a proper backup storage should implement this
+sub free_image {
+    my ($class, $storeid, $scfg, $volname, $isBase) = @_;
+
+    # if it's a backing file, it would need to be merged into the upper image first.
+
+    die "not implemented";
+}
+
+sub list_images {
+    my ($class, $storeid, $scfg, $vmid, $vollist, $cache) = @_;
+
+    my $res = [];
+
+    return $res;
+}
+
+sub list_volumes {
+    my ($class, $storeid, $scfg, $vmid, $content_types) = @_;
+
+    my $path = $scfg->{path};
+
+    my $res = [];
+    for my $type ($content_types->@*) {
+	next if $type ne 'backup';
+
+	my @guest_dirs = glob("$path/*");
+	for my $guest_dir (@guest_dirs) {
+	    next if !-d $guest_dir || $guest_dir !~ m!/(\d+)$!;
+
+	    my $backup_vmid = basename($guest_dir);
+
+	    next if defined($vmid) && $backup_vmid != $vmid;
+
+	    my @backup_dirs = glob("$guest_dir/*");
+	    for my $backup_dir (@backup_dirs) {
+		next if !-d $backup_dir || $backup_dir !~ m!/(lxc|qemu)-(\d+)$!;
+		my ($subtype, $backup_id) = ($1, $2);
+
+		my $size = 0;
+		my @backup_files = glob("$backup_dir/*");
+		$size += -s $_ for @backup_files;
+
+		push $res->@*, {
+		    volid => "$storeid:backup/${backup_vmid}/${subtype}-${backup_id}",
+		    vmid => $backup_vmid,
+		    format => "directory",
+		    ctime => $backup_id,
+		    size => $size,
+		    subtype => $subtype,
+		    content => $type,
+		    # TODO parent for incremental
+		};
+	    }
+	}
+    }
+
+    return $res;
+}
+
+sub activate_storage {
+    my ($class, $storeid, $scfg, $cache) = @_;
+
+    my $path = $scfg->{path};
+
+    my $timeout = 2;
+    if (!PVE::Tools::run_fork_with_timeout($timeout, sub {-d $path})) {
+	die "unable to activate storage '$storeid' - directory '$path' does not exist or is"
+	    ." unreachable\n";
+    }
+
+    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 backup-provider-dir-example volume" if $snapname;
+
+    return 1;
+}
+
+sub deactivate_volume {
+    my ($class, $storeid, $scfg, $volname, $snapname, $cache) = @_;
+
+    die "volume snapshot is not possible on backup-provider-dir-example volume" if $snapname;
+
+    return 1;
+}
+
+sub get_volume_attribute {
+    my ($class, $scfg, $storeid, $volname, $attribute) = @_;
+
+    return;
+}
+
+# NOTE a proper backup storage should implement this to support backup notes and
+# setting protected status.
+sub update_volume_attribute {
+    my ($class, $scfg, $storeid, $volname, $attribute, $value) = @_;
+
+    die "attribute '$attribute' is not supported on backup-provider-dir-example volume";
+}
+
+sub volume_size_info {
+    my ($class, $scfg, $storeid, $volname, $timeout) = @_;
+
+    my (undef, $relative_backup_dir) = $class->parse_volname($volname);
+    my ($ctime) = $relative_backup_dir =~ m/-(\d+)$/;
+    my $backup_dir = "$scfg->{path}/${relative_backup_dir}";
+
+    my $size = 0;
+    my @backup_files = glob("$backup_dir/*");
+    for my $backup_file (@backup_files) {
+	if ($backup_file =~ m!\.qcow2$!) {
+	    $size += PVE::Storage::Plugin::file_size_info($backup_file, undef, 'qcow2');
+	} else {
+	    $size += -s $backup_file;
+	}
+    }
+
+    my $parent; # TODO for incremental
+
+    return wantarray ? ($size, 'directory', $size, $parent, $ctime) : $size;
+}
+
+sub volume_resize {
+    my ($class, $scfg, $storeid, $volname, $size, $running) = @_;
+
+    die "volume resize is not possible on backup-provider-dir-example volume";
+}
+
+sub volume_snapshot {
+    my ($class, $scfg, $storeid, $volname, $snap) = @_;
+
+    die "volume snapshot is not possible on backup-provider-dir-example volume";
+}
+
+sub volume_snapshot_rollback {
+    my ($class, $scfg, $storeid, $volname, $snap) = @_;
+
+    die "volume snapshot rollback is not possible on backup-provider-dir-example volume";
+}
+
+sub volume_snapshot_delete {
+    my ($class, $scfg, $storeid, $volname, $snap) = @_;
+
+    die "volume snapshot delete is not possible on backup-provider-dir-example volume";
+}
+
+sub volume_has_feature {
+    my ($class, $scfg, $feature, $storeid, $volname, $snapname, $running) = @_;
+
+    return 0;
+}
+
+sub new_backup_provider {
+    my ($class, $scfg, $storeid, $bandwidth_limit, $log_function) = @_;
+
+    return PVE::BackupProvider::Plugin::DirectoryExample->new(
+	$class, $scfg, $storeid, $bandwidth_limit, $log_function);
+}
+
+1;
diff --git a/src/PVE/Storage/Custom/BorgBackupPlugin.pm b/src/PVE/Storage/Custom/BorgBackupPlugin.pm
new file mode 100644
index 0000000..84b12b9
--- /dev/null
+++ b/src/PVE/Storage/Custom/BorgBackupPlugin.pm
@@ -0,0 +1,689 @@
+package PVE::Storage::Custom::BorgBackupPlugin;
+
+use strict;
+use warnings;
+
+use Fcntl qw(F_GETFD F_SETFD FD_CLOEXEC);
+use File::Path qw(make_path remove_tree);
+use JSON qw(from_json);
+use MIME::Base64 qw(decode_base64 encode_base64);
+use Net::IP;
+use POSIX;
+
+use PVE::BackupProvider::Plugin::Borg;
+use PVE::Tools;
+
+use base qw(PVE::Storage::Plugin);
+
+sub api {
+    return 11;
+}
+
+sub check_config {
+    my ($class, $sectionId, $config, $create, $skipSchemaCheck) = @_;
+
+    if (my $ssh_public_keys = $config->{'ssh-server-public-keys'}) {
+	if ($ssh_public_keys !~ m/^[A-Za-z0-9+\/]+={0,2}$/) {
+	    $config->{'ssh-server-public-keys'} = encode_base64($ssh_public_keys, '');
+	}
+    }
+
+    return $class->SUPER::check_config($sectionId, $config, $create, $skipSchemaCheck);
+}
+
+sub borg_repository_uri {
+    my ($class, $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_setup_ssh_dir {
+    my ($class, $scfg, $ssh_dir, $ssh_key) = @_;
+
+    my $dir_created;
+    my $ssh_opts = [];
+
+    my $ensure_dir_created = sub {
+	return if $dir_created;
+	# for container backup, it needs to be created while privileged and already exists
+	if (!-d $ssh_dir) {
+	    make_path($ssh_dir) or die "unable to create directory $ssh_dir\n";
+	    chmod(0700, $ssh_dir) or die "unable to chmod directory $ssh_dir\n";
+	}
+	PVE::Tools::run_command(
+	    ['mount', '-t', 'tmpfs', '-o', 'size=1M,mode=0700', 'tmpfs', $ssh_dir]);
+	$dir_created = 1;
+    };
+
+    if ($ssh_key) {
+	$ensure_dir_created->();
+	PVE::Tools::file_set_contents("${ssh_dir}/ssh.key", $ssh_key, 0600);
+	push $ssh_opts->@*, '-i', "${ssh_dir}/ssh.key";
+    }
+
+    if ($scfg->{'ssh-server-public-keys'}) {
+	$ensure_dir_created->();
+	my $raw = decode_base64($scfg->{'ssh-server-public-keys'});
+	PVE::Tools::file_set_contents("${ssh_dir}/known_hosts", $raw, 0600);
+	push $ssh_opts->@*, '-o', "UserKnownHostsFile=${ssh_dir}/known_hosts";
+	push $ssh_opts->@*, '-o', "GlobalKnownHostsFile=none";
+    }
+
+    return ($dir_created, $ssh_opts);
+}
+
+sub borg_cmd_env {
+    my ($class, $scfg, $storeid, $sub) = @_;
+
+    my $ssh_dir = "/run/pve-storage/borg-plugin/${storeid}.ssh.$$";
+    my $ssh_key = borg_get_ssh_key($scfg, $storeid);
+    my ($uses_ssh_dir, $ssh_options) = $class->borg_setup_ssh_dir($scfg, $ssh_dir, $ssh_key);
+    local $ENV{BORG_RSH} = "ssh " . join(" ", $ssh_options->@*);
+
+    local $ENV{BORG_PASSPHRASE} = $class->borg_get_password($scfg, $storeid);
+
+    my $res = eval {
+	my $uri = $class->borg_repository_uri($scfg, $storeid);
+	return $sub->($uri);
+    };
+    my $err = $@;
+
+    if ($uses_ssh_dir) {
+	eval { PVE::Tools::run_command(['umount', "$ssh_dir"]); };
+	warn "unable to unmount directory $ssh_dir - $@" if $@;
+	eval { remove_tree($ssh_dir); };
+	warn "unable to cleanup directory $ssh_dir - $@" if $@;
+    }
+
+    die $err if $err;
+
+    return $res;
+}
+
+sub borg_cmd_list {
+    my ($class, $scfg, $storeid) = @_;
+
+    return $class->borg_cmd_env($scfg, $storeid, sub {
+	my ($uri) = @_;
+
+	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) = @_;
+
+    return $class->borg_cmd_env($scfg, $storeid, sub {
+	my ($uri) = @_;
+
+	my $cmd = ['borg', 'create', $opts->@*, "${uri}::${archive}", $paths->@*];
+
+	PVE::Tools::run_command($cmd, errmsg => "command @$cmd failed");
+
+	return;
+    });
+}
+
+sub borg_cmd_extract_contents {
+    my ($class, $scfg, $storeid, $archive, $paths) = @_;
+
+    return $class->borg_cmd_env($scfg, $storeid, sub {
+	my ($uri) = @_;
+
+	my $output = '';
+	my $outfunc = sub {
+	    $output .= "$_[0]\n";
+	};
+
+	my $cmd = ['borg', 'extract', '--stdout', "${uri}::${archive}", $paths->@*];
+
+	PVE::Tools::run_command($cmd, errmsg => "command @$cmd failed", outfunc => $outfunc);
+
+	return $output;
+    });
+}
+
+sub borg_cmd_delete {
+    my ($class, $scfg, $storeid, $archive) = @_;
+
+    return $class->borg_cmd_env($scfg, $storeid, sub {
+	my ($uri) = @_;
+
+	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) = @_;
+
+    return $class->borg_cmd_env($scfg, $storeid, sub {
+	my ($uri) = @_;
+
+	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) = @_;
+
+    return $class->borg_cmd_env($scfg, $storeid, sub {
+	my ($uri) = @_;
+
+	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 },
+	'sensitive-properties' => {
+	    password => 1,
+	    'ssh-private-key' => 1,
+	},
+    };
+}
+
+sub properties {
+    return {
+	'repository-path' => {
+	    description => "Path to the backup repository",
+	    type => 'string',
+	},
+	'ssh-private-key' => {
+	    # Since 1 is written to the config when the key is present, the format is not checked.
+	    description => "SSH identity/private key for the client-side in PEM format.",
+	    type => 'string',
+	},
+	'ssh-server-public-keys' => {
+	    description => "SSH public key(s) for the server-side, (one key per line, OpenSSH"
+		." format).",
+	    type => 'string',
+	},
+    };
+}
+
+sub options {
+    return {
+	'repository-path' => { fixed => 1 },
+	server => { fixed => 1 },
+	port => { optional => 1 },
+	username => { fixed => 1 },
+	'ssh-private-key' => { optional => 1 },
+	'ssh-server-public-keys' => { 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 $keyfile = borg_ssh_key_file_name($scfg, $storeid);
+    mkdir "/etc/pve/priv/storage";
+
+    PVE::Tools::file_set_contents($keyfile, "$key\n");
+}
+
+sub borg_delete_ssh_key {
+    my ($scfg, $storeid) = @_;
+
+    my $keyfile = borg_ssh_key_file_name($scfg, $storeid);
+
+    if (!unlink $keyfile) {
+	return if $! == ENOENT;
+	die "failed to delete SSH key! $!\n";
+    }
+    delete $scfg->{'ssh-private-key'};
+}
+
+sub borg_get_ssh_key {
+    my ($scfg, $storeid) = @_;
+
+    my $keyfile = borg_ssh_key_file_name($scfg, $storeid);
+
+    return if !-f $keyfile;
+
+    return PVE::Tools::file_get_contents($keyfile);
+}
+
+# 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-private-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-private-key'})) {
+	borg_set_ssh_key($scfg, $storeid, $ssh_key);
+	$scfg->{'ssh-private-key'} = 1;
+    } else {
+	borg_delete_ssh_key($scfg, $storeid);
+    }
+
+    if ($scfg->{'ssh-server-public-keys'}) {
+	my $ssh_public_keys = decode_base64($scfg->{'ssh-server-public-keys'});
+	PVE::Tools::validate_ssh_public_keys($ssh_public_keys);
+    }
+
+    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-private-key'})) {
+	if (defined(my $ssh_key = delete($param{'ssh-private-key'}))) {
+	    borg_set_ssh_key($scfg, $storeid, $ssh_key);
+	    $scfg->{'ssh-private-key'} = 1;
+	} else {
+	    borg_delete_ssh_key($scfg, $storeid);
+	}
+    }
+
+    if ($scfg->{'ssh-server-public-keys'}) {
+	my $ssh_public_keys = decode_base64($scfg->{'ssh-server-public-keys'});
+	PVE::Tools::validate_ssh_public_keys($ssh_public_keys);
+    }
+
+    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 = $class->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 = $class->borg_repository_uri($scfg, $storeid);
+
+    my $res;
+
+    if ($uri =~ m!^ssh://!) {
+	#FIXME ssh and df on target?
+	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/Custom/Makefile b/src/PVE/Storage/Custom/Makefile
new file mode 100644
index 0000000..886442d
--- /dev/null
+++ b/src/PVE/Storage/Custom/Makefile
@@ -0,0 +1,6 @@
+SOURCES = BackupProviderDirExamplePlugin.pm \
+          BorgBackupPlugin.pm
+
+.PHONY: install
+install:
+	for i in ${SOURCES}; do install -D -m 0644 $$i ${DESTDIR}${PERLDIR}/PVE/Storage/Custom/$$i; done
diff --git a/src/PVE/Storage/Makefile b/src/PVE/Storage/Makefile
new file mode 100644
index 0000000..530ce54
--- /dev/null
+++ b/src/PVE/Storage/Makefile
@@ -0,0 +1,3 @@
+.PHONY: install
+install:
+	make -C Custom install
-- 
2.39.5



_______________________________________________
pve-devel mailing list
pve-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel


^ permalink raw reply	[flat|nested] only message in thread

only message in thread, other threads:[~2025-04-07 12:05 UTC | newest]

Thread overview: (only message) (download: mbox.gz / follow: Atom feed)
-- links below jump to the message on this page --
2025-04-07 12:04 [pve-devel] [PATCH pve-storage-examples] initial commit Fiona Ebner

This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal