* [PATCH cluster 01/21] pmxcfs: status: fix formatting of parameters in checked_mkdir()
2026-08-28 13:30 [RFC cluster/common/container/docs/installer/manager 00/21] add rudimentary host backup mechanism Shannon Sterz
@ 2026-08-28 13:30 ` Shannon Sterz
2026-08-28 13:30 ` [PATCH cluster 02/21] pmxcfs: correctly log message when directory can't be created Shannon Sterz
` (19 subsequent siblings)
20 siblings, 0 replies; 22+ messages in thread
From: Shannon Sterz @ 2026-08-28 13:30 UTC (permalink / raw)
To: pve-devel
and remove a stray `;`
Signed-off-by: Shannon Sterz <s.sterz@proxmox.com>
---
src/pmxcfs/status.c | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/pmxcfs/status.c b/src/pmxcfs/status.c
index 12a6c46..4aeeaee 100644
--- a/src/pmxcfs/status.c
+++ b/src/pmxcfs/status.c
@@ -1231,10 +1231,10 @@ static inline const char *rrd_skip_data(const char *data, int count, char separa
return data;
}
-static inline void checked_mkdir(char* path, int mode) {
+static inline void checked_mkdir(char *path, int mode) {
if (!mkdir(path, mode) && errno != EEXIST) {
cfs_message("could not create directory %s: %s", path, strerror(errno));
- };
+ }
}
// The key and subdirectory format used up until PVE8 is 'pve{version}-{type}/{id}' with version
--
2.47.3
^ permalink raw reply related [flat|nested] 22+ messages in thread* [PATCH cluster 02/21] pmxcfs: correctly log message when directory can't be created
2026-08-28 13:30 [RFC cluster/common/container/docs/installer/manager 00/21] add rudimentary host backup mechanism Shannon Sterz
2026-08-28 13:30 ` [PATCH cluster 01/21] pmxcfs: status: fix formatting of parameters in checked_mkdir() Shannon Sterz
@ 2026-08-28 13:30 ` Shannon Sterz
2026-08-28 13:30 ` [PATCH cluster 03/21] pmxcfs: add live backup capability Shannon Sterz
` (18 subsequent siblings)
20 siblings, 0 replies; 22+ messages in thread
From: Shannon Sterz @ 2026-08-28 13:30 UTC (permalink / raw)
To: pve-devel
`mkdir(2)` returns 0 on success and -1 on error. so the check that -1
is returned instead of inverting the return value. since errno would
not be set on success, this either never triggered or acted on stale
errnos only.
Signed-off-by: Shannon Sterz <s.sterz@proxmox.com>
---
Notes:
feel free to merge this into the previous commit. mainly sending this
to separate changes that affect behavior from those that only affect
formatting
src/pmxcfs/status.c | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/pmxcfs/status.c b/src/pmxcfs/status.c
index 4aeeaee..9581e2a 100644
--- a/src/pmxcfs/status.c
+++ b/src/pmxcfs/status.c
@@ -1232,7 +1232,7 @@ static inline const char *rrd_skip_data(const char *data, int count, char separa
}
static inline void checked_mkdir(char *path, int mode) {
- if (!mkdir(path, mode) && errno != EEXIST) {
+ if (mkdir(path, mode) == -1 && errno != EEXIST) {
cfs_message("could not create directory %s: %s", path, strerror(errno));
}
}
--
2.47.3
^ permalink raw reply related [flat|nested] 22+ messages in thread* [PATCH cluster 03/21] pmxcfs: add live backup capability
2026-08-28 13:30 [RFC cluster/common/container/docs/installer/manager 00/21] add rudimentary host backup mechanism Shannon Sterz
2026-08-28 13:30 ` [PATCH cluster 01/21] pmxcfs: status: fix formatting of parameters in checked_mkdir() Shannon Sterz
2026-08-28 13:30 ` [PATCH cluster 02/21] pmxcfs: correctly log message when directory can't be created Shannon Sterz
@ 2026-08-28 13:30 ` Shannon Sterz
2026-08-28 13:30 ` [PATCH cluster 04/21] pmxcfs: add ability to query backup progress Shannon Sterz
` (17 subsequent siblings)
20 siblings, 0 replies; 22+ messages in thread
From: Shannon Sterz @ 2026-08-28 13:30 UTC (permalink / raw)
To: pve-devel
Signed-off-by: Shannon Sterz <s.sterz@proxmox.com>
---
src/PVE/Cluster.pm | 49 ++++++++++++++-
src/pmxcfs/cfs-ipc-ops.h | 2 +
src/pmxcfs/cfs-utils.h | 1 +
src/pmxcfs/database.c | 128 +++++++++++++++++++++++++++++++++++++++
src/pmxcfs/memdb.c | 30 +++++++++
src/pmxcfs/memdb.h | 94 ++++++++++++++++++++++++++++
src/pmxcfs/server.c | 94 ++++++++++++++++++++++++++++
7 files changed, 395 insertions(+), 3 deletions(-)
diff --git a/src/PVE/Cluster.pm b/src/PVE/Cluster.pm
index 034b78c..31c06e8 100644
--- a/src/PVE/Cluster.pm
+++ b/src/PVE/Cluster.pm
@@ -42,6 +42,9 @@ my $lockdir = "/etc/pve/priv/lock";
my $dbfile = "/var/lib/pve-cluster/config.db";
my $dbbackupdir = "/var/lib/pve-cluster/backup";
+# maximum number of files to keep for each db backup type (live, cluster join)
+my $db_backup_maxfiles = 10;
+
# this is just a readonly copy, the relevant one is in status.c from pmxcfs
# observed files are the one we can get directly through IPCC, they are cached
# using a computed version and only those can be used by the cfs_*_file methods
@@ -236,6 +239,14 @@ my $ipcc_verify_token = sub {
die "$!\n";
};
+my $ipcc_init_backup = sub {
+ my ($filename) = @_;
+
+ my $bindata = pack "Z*", $filename;
+ PVE::IPCC::ipcc_send_rec(CFS_IPC_INIT_BACKUP, $bindata);
+ warn $! if $! != 0;
+};
+
my $ccache = {};
sub cfs_update {
@@ -906,11 +917,10 @@ sub cfs_backup_database {
my $cmd = [['sqlite3', $dbfile, '.dump'], ['gzip', '-', \ ">${backup_fn}"]];
run_command($cmd, 'errmsg' => "cannot backup old database\n");
- my $maxfiles = 10; # purge older backup
my $backups = [sort { $b cmp $a } <$dbbackupdir/config-*.sql.gz>];
- if ((my $count = scalar(@$backups)) > $maxfiles) {
- foreach my $f (@$backups[$maxfiles .. $count - 1]) {
+ if ((my $count = scalar(@$backups)) > $db_backup_maxfiles) {
+ foreach my $f (@$backups[$db_backup_maxfiles .. $count - 1]) {
next if $f !~ m/^(\S+)$/; # untaint
print "delete old backup '$1'\n";
unlink $1;
@@ -929,4 +939,37 @@ sub cfs_rename_db_unsafe {
rename $dbfile, "$dbfile.$ctime.bak" or warn "failed to rename old config database - $!\n";
}
+=head3 cfs_live_backup_database()
+
+Creates a new live backup of the database backing the cluster file system.
+
+Returns a file path to the created backup.
+
+=cut
+
+sub cfs_live_backup_database {
+ mkdir $dbbackupdir or $!{EEXIST} or die "failed to create backup dir - $!\n";
+ chmod 0700, $dbbackupdir or die "failed to change mode for backup dir - $!\n";
+
+ my $backups = [sort { $b cmp $a } glob("$dbbackupdir/config-backup-*.db")];
+
+ if ((my $count = scalar(@$backups)) > $db_backup_maxfiles) {
+ foreach my $f (@$backups[$db_backup_maxfiles .. $count - 1]) {
+ next if $f !~ m/^(\S+)$/; # untaint
+ print "cleaning up old backup '$1'\n";
+ unlink $1;
+ }
+ }
+
+ my $ctime = time();
+ my $backup_file = "config-backup-$ctime.db";
+ print "starting backup of current database to \"$dbbackupdir/$backup_file\"\n";
+
+ eval { &$ipcc_init_backup($backup_file); };
+
+ warn $@ if $@;
+
+ return $backup_file;
+}
+
1;
diff --git a/src/pmxcfs/cfs-ipc-ops.h b/src/pmxcfs/cfs-ipc-ops.h
index 249308d..054ebb3 100644
--- a/src/pmxcfs/cfs-ipc-ops.h
+++ b/src/pmxcfs/cfs-ipc-ops.h
@@ -45,4 +45,6 @@
#define CFS_IPC_GET_GUEST_CONFIG_PROPERTIES 13
+#define CFS_IPC_INIT_BACKUP 14
+
#endif
diff --git a/src/pmxcfs/cfs-utils.h b/src/pmxcfs/cfs-utils.h
index ed67264..19e3aab 100644
--- a/src/pmxcfs/cfs-utils.h
+++ b/src/pmxcfs/cfs-utils.h
@@ -32,6 +32,7 @@
#define HOST_CLUSTER_CONF_FN "/etc/corosync/corosync.conf"
#define CFS_PID_FN "/var/run/pve-cluster.pid"
#define VARLIBDIR "/var/lib/pve-cluster"
+#define BACKUPDIR VARLIBDIR "/backup"
#define RUNDIR "/run/pve-cluster"
#define CFS_MAX(a, b) (((a) > (b)) ? (a) : (b))
diff --git a/src/pmxcfs/database.c b/src/pmxcfs/database.c
index 4ee2389..07b3664 100644
--- a/src/pmxcfs/database.c
+++ b/src/pmxcfs/database.c
@@ -49,6 +49,8 @@ struct db_backend {
sqlite3_stmt *stmt_commit;
sqlite3_stmt *stmt_rollback;
sqlite3_stmt *stmt_load_all;
+ sqlite3_backup *current_backup;
+ sqlite3 *backup_db;
};
#define VERSIONFILENAME "__version__"
@@ -650,6 +652,10 @@ void bdb_backend_close(db_backend_t *bdb) {
sqlite3_finalize(bdb->stmt_rollback);
sqlite3_finalize(bdb->stmt_load_all);
+ // calling bdb_finish_or_abort_backup() has no effect if no backup is happening, so calling it
+ // unconditionally here is ok
+ (void)bdb_finish_or_abort_backup(bdb, TRUE);
+
int rc;
if ((rc = sqlite3_close(bdb->db)) != SQLITE_OK) {
cfs_critical("sqlite3_close failed: %d\n", rc);
@@ -738,3 +744,125 @@ fail:
return NULL;
}
+
+int bdb_init_backup(db_backend_t *bdb, const char *backup_filename) {
+ g_return_val_if_fail(bdb != NULL, BACKUP_RETURN_EPAR);
+ g_return_val_if_fail(bdb->db != NULL, BACKUP_RETURN_EPAR);
+ g_return_val_if_fail(backup_filename != NULL, BACKUP_RETURN_EPAR);
+
+ if (bdb->current_backup != NULL || bdb->backup_db != NULL) {
+ cfs_debug("trying to start a new backup while one is already running, aborting...");
+ return BACKUP_RETURN_EGEN;
+ }
+
+ int rc;
+ g_autofree gchar *canonical_filename = g_canonicalize_filename(backup_filename, BACKUPDIR);
+ cfs_message("starting live backup '%s'...", canonical_filename);
+
+ int flags = SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE;
+
+ if ((rc = sqlite3_open_v2(canonical_filename, &bdb->backup_db, flags, NULL)) != SQLITE_OK) {
+ cfs_critical("sqlite error while opening backup file (%d): %s", rc, sqlite3_errstr(rc));
+ (void)sqlite3_close_v2(bdb->backup_db);
+ bdb->backup_db = NULL;
+ return BACKUP_RETURN_EGEN;
+ }
+
+ if (chmod(canonical_filename, 0600) == -1) {
+ cfs_critical("chmod failed for backup db: %s", strerror(errno));
+ (void)sqlite3_close_v2(bdb->backup_db);
+ bdb->backup_db = NULL;
+ return BACKUP_RETURN_EGEN;
+ }
+
+ bdb->current_backup = sqlite3_backup_init(bdb->backup_db, "main", bdb->db, "main");
+
+ if (bdb->current_backup == NULL) {
+ rc = sqlite3_errcode(bdb->backup_db);
+ cfs_critical("sqlite error while initializing backup (%d): %s", rc, sqlite3_errstr(rc));
+ (void)sqlite3_close_v2(bdb->backup_db);
+ bdb->backup_db = NULL;
+ return BACKUP_RETURN_EGEN;
+ }
+
+ return BACKUP_RETURN_CONT;
+}
+
+int bdb_finish_or_abort_backup(db_backend_t *bdb, gboolean abort) {
+ g_return_val_if_fail(bdb != NULL, BACKUP_RETURN_EPAR);
+
+ int rc;
+ int to_return_code = BACKUP_RETURN_DONE;
+ GString *db_filename = NULL;
+
+ if (bdb->backup_db != NULL) {
+ sqlite3_filename fn;
+ if ((fn = sqlite3_db_filename(bdb->backup_db, "main")) != NULL) {
+ // the filename is managed by the sqlite3 object, so copy it into its own string to
+ // preserve it across the `sqlite3_close_v2()` call.
+ db_filename = g_string_new(fn);
+ };
+ }
+
+ if ((rc = sqlite3_backup_finish(bdb->current_backup)) != SQLITE_OK) {
+ cfs_critical("sqlite error when ending backup (%d): %s", rc, sqlite3_errstr(rc));
+ to_return_code = BACKUP_RETURN_EGEN;
+ }
+
+ bdb->current_backup = NULL;
+
+ if ((rc = sqlite3_close_v2(bdb->backup_db)) != SQLITE_OK) {
+ cfs_critical("sqlite error when ending backup (%d): %s", rc, sqlite3_errstr(rc));
+ to_return_code = BACKUP_RETURN_EGEN;
+ }
+
+ bdb->backup_db = NULL;
+
+ // if abort is true or an error was encountered, we need to clean up the database file here
+ if ((abort || to_return_code != BACKUP_RETURN_DONE) && db_filename != NULL) {
+ (void)unlink(db_filename->str);
+ }
+
+ if (db_filename != NULL) {
+ g_string_free(db_filename, TRUE);
+ }
+
+ return to_return_code;
+}
+
+int bdb_handle_backup(db_backend_t *bdb) {
+ g_return_val_if_fail(bdb != NULL, BACKUP_RETURN_EPAR);
+ g_return_val_if_fail(bdb->db != NULL, BACKUP_RETURN_EPAR);
+
+ if (bdb->backup_db == NULL || bdb->current_backup == NULL) {
+ cfs_debug("no backup in progress, please initialize one first...");
+ return BACKUP_RETURN_EGEN;
+ }
+
+ // `backup_db` and `current_backup` are initialized successfully, do a back up step
+ int rc = sqlite3_backup_step(bdb->current_backup, 10);
+
+ if (rc == SQLITE_OK || rc == SQLITE_BUSY || rc == SQLITE_LOCKED) {
+ // backup step succeeded or encountered a non-permanent error. backup is not done.
+ // -> signal that backup step is done and another one should be scheduled
+ return BACKUP_RETURN_CONT;
+ }
+
+ int to_return_code = BACKUP_RETURN_DONE;
+
+ if (rc != SQLITE_DONE) {
+ cfs_critical("sqlite error during backup step (%d): %s", rc, sqlite3_errstr(rc));
+ to_return_code = BACKUP_RETURN_EGEN;
+ } else {
+ cfs_debug("backup completed, cleaning up...");
+ }
+
+ // abort the backup if we encountered an error
+ rc = bdb_finish_or_abort_backup(bdb, to_return_code != BACKUP_RETURN_DONE);
+
+ if (rc != BACKUP_RETURN_DONE) {
+ to_return_code = BACKUP_RETURN_EGEN;
+ }
+
+ return to_return_code;
+}
diff --git a/src/pmxcfs/memdb.c b/src/pmxcfs/memdb.c
index 77d7652..b7372f9 100644
--- a/src/pmxcfs/memdb.c
+++ b/src/pmxcfs/memdb.c
@@ -1520,3 +1520,33 @@ memdb_index_t *memdb_encode_index(GHashTable *index, memdb_tree_entry_t *root) {
return idx;
}
+
+int memdb_init_backup(memdb_t *memdb, const char *backup_filename) {
+ g_return_val_if_fail(memdb != NULL, BACKUP_RETURN_EPAR);
+
+ g_mutex_lock(&memdb->mutex);
+ int to_return = bdb_init_backup(memdb->bdb, backup_filename);
+ g_mutex_unlock(&memdb->mutex);
+
+ return to_return;
+}
+
+int memdb_finish_or_abort_backup(memdb_t *memdb, gboolean abort) {
+ g_return_val_if_fail(memdb != NULL, BACKUP_RETURN_EPAR);
+
+ g_mutex_lock(&memdb->mutex);
+ int to_return = bdb_finish_or_abort_backup(memdb->bdb, abort);
+ g_mutex_unlock(&memdb->mutex);
+
+ return to_return;
+}
+
+int memdb_handle_backup(memdb_t *memdb) {
+ g_return_val_if_fail(memdb != NULL, BACKUP_RETURN_EPAR);
+
+ g_mutex_lock(&memdb->mutex);
+ int to_return = bdb_handle_backup(memdb->bdb);
+ g_mutex_unlock(&memdb->mutex);
+
+ return to_return;
+}
diff --git a/src/pmxcfs/memdb.h b/src/pmxcfs/memdb.h
index bca8967..2c27959 100644
--- a/src/pmxcfs/memdb.h
+++ b/src/pmxcfs/memdb.h
@@ -34,6 +34,11 @@
#define MEMDB_BLOCKSIZE 4096
#define MEMDB_BLOCKS ((MEMDB_MAX_FSSIZE + MEMDB_BLOCKSIZE - 1) / MEMDB_BLOCKSIZE)
+#define BACKUP_RETURN_CONT 0 // backup step completed, schedule next one
+#define BACKUP_RETURN_DONE 1 // backup done
+#define BACKUP_RETURN_EGEN 2 // encountered error while trying to back up
+#define BACKUP_RETURN_EPAR 3 // a parameter passed to the backup helper functions was invalid
+
typedef struct memdb_tree_entry memdb_tree_entry_t;
struct memdb_tree_entry {
guint64 parent;
@@ -151,6 +156,50 @@ memdb_index_t *memdb_index_copy(memdb_index_t *idx);
gboolean memdb_tree_entry_csum(memdb_tree_entry_t *te, guchar csum[32]);
+/**
+ * memdb_init_backup:
+ * @memdb: a memdb object that has an initialized db to back up.
+ * @backup_filename: the name for the backup file.
+ *
+ * Initializes a backup of the configuration database.
+ *
+ * Return: an integer representing the status of the backup. Either `BACKUP_RETURN_CONT` if the
+ * backup was initialized correctly or an error such as `BACKUP_RETURN_EGEN` or
+ * `BACKUP_RETURN_EPAR`.
+ */
+int memdb_init_backup(memdb_t *memdb, const char *backup_filename);
+
+/**
+ * memdb_finish_or_abort_backup:
+ * @memdb: a memdb object that is currently being backed up.
+ * @abort: if true, this will be considered an abort operation and the backup database file will
+ * be removed.
+ *
+ * Will finish a backup no matter whether all backup steps have completed successfully. If they
+ * haven't, the backup is invalid and all write operations should be rolled back by sqlite. If an
+ * error occurs while finishing the backup or if abort is set, the database file will be removed.
+ *
+ * Calling this function while no backup is in progress is harmless (similar to how NULL can
+ * be passed to sqlite3_backup_finish()).
+ *
+ * Return: an integer representing the result of the operation. Either `BACKUP_RETURN_DONE` if the
+ * close operation was successful or `BACKUP_RETURN_EGEN` if finishing the backup encountered an
+ * error. If no backup is in progress, `BACKUP_RETURN_EPAR` will be returned.
+ */
+int memdb_finish_or_abort_backup(memdb_t *memdb, gboolean abort);
+
+/**
+ * memdb_handle_backup:
+ * @memdb: a memdb object that has an initialized db to back up.
+ *
+ * Handles a single backup step and finalizes the backup if it is done.
+ *
+ * Return: an integer representing the status of the backup. Either `BACKUP_RETURN_CONT` if the
+ * backup was initialized correctly, `BACKUP_RETURN_DONE` if the backup is complete or an error such
+ * as `BACKUP_RETURN_EGEN` or `BACKUP_RETURN_EPAR`.
+ */
+int memdb_handle_backup(memdb_t *memdb);
+
db_backend_t *bdb_backend_open(const char *filename, memdb_tree_entry_t *root, GHashTable *index);
void bdb_backend_close(db_backend_t *bdb);
@@ -173,4 +222,49 @@ gboolean bdb_backend_commit_update(
memdb_t *memdb, memdb_index_t *master, memdb_index_t *slave, GList *inodes
);
+/**
+ * bdb_init_backup:
+ * @bdb: a database backend that has an initialized db to back up.
+ * @backup_filename: the name for the backup file.
+ *
+ * Initializes a backup of the configuration database.
+ *
+ * Return: an integer representing the status of the backup. Either `BACKUP_RETURN_CONT` if the
+ * backup was initialized correctly or an error such as `BACKUP_RETURN_EGEN` or
+ * `BACKUP_RETURN_EPAR`.
+ */
+int bdb_init_backup(db_backend_t *bdb, const char *backup_filename);
+
+/**
+ * bdb_finish_or_abort_backup:
+ * @bdb: a database backend that is currently being backed up.
+ * @abort: if true, this will be considered an abort operation and the backup database file will
+ * be removed.
+ *
+ * Will finish a backup no matter whether all backup steps have completed successfully. If they
+ * haven't, the backup is invalid and all write operations should be rolled back by sqlite. If an
+ * error occurs while finishing the backup or if abort is set, the database file will be removed.
+ *
+ * Calling this function while no backup is in progress is harmless (similar to how NULL can
+ * be passed to sqlite3_backup_finish()).
+
+ *
+ * Return: an integer representing the result of the operation. Either `BACKUP_RETURN_DONE` if the
+ * close operation was successful or `BACKUP_RETURN_EGEN` if finishing the backup encountered an
+ * error. If no backup is in progress, `BACKUP_RETURN_EPAR` will be returned.
+ */
+int bdb_finish_or_abort_backup(db_backend_t *bdb, gboolean abort);
+
+/**
+ * bdb_handle_backup:
+ * @bdb: a database backend that has an initialized db to back up.
+ *
+ * Handles a single backup step and finalizes the backup if it is done.
+ *
+ * Return: an integer representing the status of the backup. Either `BACKUP_RETURN_CONT` if the
+ * backup was initialized correctly, `BACKUP_RETURN_DONE` if the backup is complete or an error such
+ * as `BACKUP_RETURN_EGEN` or `BACKUP_RETURN_EPAR`.
+ */
+int bdb_handle_backup(db_backend_t *bdb);
+
#endif /* _PVE_MEMDB_H_ */
diff --git a/src/pmxcfs/server.c b/src/pmxcfs/server.c
index 278e2c9..8149fd3 100644
--- a/src/pmxcfs/server.c
+++ b/src/pmxcfs/server.c
@@ -48,6 +48,7 @@ static qb_loop_t *loop;
static qb_ipcs_service_t *s1;
static GString *outbuf;
static memdb_t *memdb;
+static GRegex *filename_regex;
static int server_started = 0; /* protect with server_started_mutex */
static int terminate_server = 0; /* protect with server_started_mutex */
@@ -100,6 +101,11 @@ typedef struct {
char token[];
} cfs_verify_token_request_header_t;
+typedef struct {
+ struct qb_ipc_request_header req_header;
+ char filename[];
+} cfs_init_backup_request_header_t;
+
struct s1_context {
int32_t client_pid;
uid_t uid;
@@ -107,6 +113,37 @@ struct s1_context {
gboolean read_only;
};
+static void backup_job(void *data) {
+ cfs_debug("handling backup step");
+
+ switch (memdb_handle_backup(memdb)) {
+ case BACKUP_RETURN_CONT:
+ cfs_debug("backup iteration done, scheduling next one");
+
+ int rc = qb_loop_job_add(loop, QB_LOOP_LOW, NULL, backup_job);
+
+ if (rc != 0) {
+ // Abort the backup as we can't finish it without scheduling a job.
+ cfs_critical("could not schedule next backup job (error %d), aborting backup...", rc);
+ (void)memdb_finish_or_abort_backup(memdb, TRUE);
+ }
+
+ break;
+ case BACKUP_RETURN_DONE:
+ cfs_message("live backup complete.");
+ break;
+ case BACKUP_RETURN_EGEN:
+ cfs_debug("encountered an error while backing up.");
+ break;
+ case BACKUP_RETURN_EPAR:
+ cfs_debug("a parameter passed to one of the backup functions was invalid.");
+ break;
+ default:
+ cfs_debug("encountered an unknown return code while backing up.");
+ break;
+ }
+}
+
static int32_t s1_connection_accept_fn(qb_ipcs_connection_t *c, uid_t uid, gid_t gid) {
if ((uid == 0 && gid == 0) || (gid == cfs.gid)) {
cfs_debug("authenticated connection %d/%d", uid, gid);
@@ -446,6 +483,45 @@ static int32_t s1_msg_process_fn(qb_ipcs_connection_t *c, void *data, size_t siz
result = -ENOENT;
}
}
+ } else if (request_id == CFS_IPC_INIT_BACKUP) {
+ cfs_init_backup_request_header_t *rh = (cfs_init_backup_request_header_t *)data;
+ int filename_len =
+ request_size - G_STRUCT_OFFSET(cfs_init_backup_request_header_t, filename) - 1;
+
+ if (ctx->read_only) {
+ result = -EPERM;
+ } else if (filename_len <= 0) {
+ cfs_debug("backup filename_len <= 0, %d", filename_len);
+ result = -EINVAL;
+ } else if (rh->filename[filename_len] != '\0') {
+ cfs_debug("backup file not NULL-terminated");
+ result = -EINVAL;
+ } else if (strnlen(rh->filename, filename_len) != filename_len) {
+ cfs_debug("backup file contains NULL-byte");
+ result = -EINVAL;
+ } else {
+ if (filename_regex != NULL &&
+ g_regex_match(filename_regex, rh->filename, G_REGEX_MATCH_DEFAULT, NULL)) {
+
+ if (memdb_init_backup(memdb, rh->filename) != BACKUP_RETURN_CONT) {
+ cfs_debug("could not initialize a backup");
+ result = -EIO;
+ } else {
+ result = qb_loop_job_add(loop, QB_LOOP_LOW, NULL, backup_job);
+ if (result != 0) {
+ cfs_critical("couldn't add first backup step (error %d), abort...", result);
+ (void)memdb_finish_or_abort_backup(memdb, TRUE);
+ }
+ }
+
+ if (result != 0) {
+ cfs_debug("could not start backup");
+ }
+ } else {
+ cfs_debug("could not check backup file name or it contains forbidden characters");
+ result = -EINVAL;
+ }
+ }
}
cfs_debug("process result %d", result);
@@ -577,6 +653,18 @@ gboolean server_start(memdb_t *db) {
outbuf = g_string_sized_new(8192 * 8);
+ // set up filename regex on start
+ filename_regex = g_regex_new(
+ "^[A-Za-z0-9\\-_][A-Za-z0-9\\-_\\.]*$", G_REGEX_DEFAULT, G_REGEX_MATCH_DEFAULT, NULL
+ );
+
+ if (filename_regex == NULL) {
+ // Shouldn't happen since we compile a static pattern. If it does, don't abort. pmxcfs
+ // will not allow live backups in this case, but should function fine otherwise. This is
+ // preferable over not allowing users to run pmxcfs at all in that scenario.
+ cfs_message("could not compile filename regex. live backups are not supported.");
+ }
+
if (!(loop = qb_loop_create())) {
cfs_critical("cant create event loop");
return FALSE;
@@ -625,6 +713,12 @@ void server_stop(void) {
loop = NULL;
}
+ // tear down regex on server stop
+ if (filename_regex != NULL) {
+ g_regex_unref(filename_regex);
+ filename_regex = NULL;
+ }
+
if (outbuf) {
g_string_free(outbuf, TRUE);
outbuf = NULL;
--
2.47.3
^ permalink raw reply related [flat|nested] 22+ messages in thread* [PATCH cluster 04/21] pmxcfs: add ability to query backup progress
2026-08-28 13:30 [RFC cluster/common/container/docs/installer/manager 00/21] add rudimentary host backup mechanism Shannon Sterz
` (2 preceding siblings ...)
2026-08-28 13:30 ` [PATCH cluster 03/21] pmxcfs: add live backup capability Shannon Sterz
@ 2026-08-28 13:30 ` Shannon Sterz
2026-08-28 13:30 ` [PATCH common 05/21] systemd: move parse_os_release() helper to PVE::Systemd Shannon Sterz
` (16 subsequent siblings)
20 siblings, 0 replies; 22+ messages in thread
From: Shannon Sterz @ 2026-08-28 13:30 UTC (permalink / raw)
To: pve-devel
cfs_live_backup_database() will now also return the progress of the
backup instead of just the filename.
Signed-off-by: Shannon Sterz <s.sterz@proxmox.com>
---
src/PVE/Cluster.pm | 43 +++++++++++++++++++++++++++++++++++-----
src/pmxcfs/cfs-ipc-ops.h | 2 ++
src/pmxcfs/database.c | 18 +++++++++++++++++
src/pmxcfs/memdb.c | 12 +++++++++++
src/pmxcfs/memdb.h | 28 ++++++++++++++++++++++++++
src/pmxcfs/server.c | 11 ++++++++++
src/pmxcfs/status.c | 7 +++++++
src/pmxcfs/status.h | 2 ++
8 files changed, 118 insertions(+), 5 deletions(-)
diff --git a/src/PVE/Cluster.pm b/src/PVE/Cluster.pm
index 31c06e8..b907f2e 100644
--- a/src/PVE/Cluster.pm
+++ b/src/PVE/Cluster.pm
@@ -243,8 +243,14 @@ my $ipcc_init_backup = sub {
my ($filename) = @_;
my $bindata = pack "Z*", $filename;
- PVE::IPCC::ipcc_send_rec(CFS_IPC_INIT_BACKUP, $bindata);
- warn $! if $! != 0;
+ my $result = &$ipcc_send_rec_json(CFS_IPC_INIT_BACKUP, $bindata);
+ die $! if $! != 0;
+
+ return $result;
+};
+
+my $ipcc_backup_progress = sub {
+ return &$ipcc_send_rec_json(CFS_IPC_BACKUP_PROGRESS);
};
my $ccache = {};
@@ -943,7 +949,11 @@ sub cfs_rename_db_unsafe {
Creates a new live backup of the database backing the cluster file system.
-Returns a file path to the created backup.
+Returns a hash with two members: C<file> is the filename of the new backups and
+C<progress> which is also a hash. C<progress> has three members: C<in-progress>,
+a boolean indicating whether the backup is finished or still on-going,
+C<remaining>, an integer showing the pages that are not yet backed up, and
+C<total> showing the total page count.
=cut
@@ -964,12 +974,35 @@ sub cfs_live_backup_database {
my $ctime = time();
my $backup_file = "config-backup-$ctime.db";
print "starting backup of current database to \"$dbbackupdir/$backup_file\"\n";
+ my $result = {};
- eval { &$ipcc_init_backup($backup_file); };
+ eval { $result = &$ipcc_init_backup($backup_file); };
+ die $@ if $@;
+ return {
+ 'file' => $backup_file,
+ 'progress' => $result,
+ };
+}
+
+=head3 cfs_live_backup_progress()
+
+Returns a hash that describes the current live backup status of the database
+backing the cluster file system.
+
+It contains three members: C<in-progress>, a boolean indicating whether the
+backup is finished or still on-going, C<remaining>, an integer showing the pages
+that are not yet backed up, and C<total> showing the total page count.
+
+=cut
+
+sub cfs_live_backup_progress {
+ my $result = {};
+
+ eval { $result = &$ipcc_backup_progress(); };
warn $@ if $@;
- return $backup_file;
+ return $result;
}
1;
diff --git a/src/pmxcfs/cfs-ipc-ops.h b/src/pmxcfs/cfs-ipc-ops.h
index 054ebb3..e8f7541 100644
--- a/src/pmxcfs/cfs-ipc-ops.h
+++ b/src/pmxcfs/cfs-ipc-ops.h
@@ -47,4 +47,6 @@
#define CFS_IPC_INIT_BACKUP 14
+#define CFS_IPC_BACKUP_PROGRESS 15
+
#endif
diff --git a/src/pmxcfs/database.c b/src/pmxcfs/database.c
index 07b3664..39bdf61 100644
--- a/src/pmxcfs/database.c
+++ b/src/pmxcfs/database.c
@@ -866,3 +866,21 @@ int bdb_handle_backup(db_backend_t *bdb) {
return to_return_code;
}
+
+memdb_backup_progress_t bdb_backup_progress(db_backend_t *bdb) {
+ memdb_backup_progress_t to_return = {
+ .in_progress = FALSE,
+ .remaining = 0,
+ .total = 0,
+ };
+
+ if (bdb == NULL || bdb->current_backup == NULL) {
+ return to_return;
+ }
+
+ to_return.in_progress = TRUE;
+ to_return.remaining = sqlite3_backup_remaining(bdb->current_backup);
+ to_return.total = sqlite3_backup_pagecount(bdb->current_backup);
+
+ return to_return;
+}
diff --git a/src/pmxcfs/memdb.c b/src/pmxcfs/memdb.c
index b7372f9..255c03f 100644
--- a/src/pmxcfs/memdb.c
+++ b/src/pmxcfs/memdb.c
@@ -1550,3 +1550,15 @@ int memdb_handle_backup(memdb_t *memdb) {
return to_return;
}
+
+memdb_backup_progress_t memdb_backup_progress(memdb_t *memdb) {
+ memdb_backup_progress_t to_return = {
+ .in_progress = FALSE,
+ .remaining = 0,
+ .total = 0,
+ };
+
+ g_return_val_if_fail(memdb != NULL, to_return);
+
+ return bdb_backup_progress(memdb->bdb);
+}
diff --git a/src/pmxcfs/memdb.h b/src/pmxcfs/memdb.h
index 2c27959..3943328 100644
--- a/src/pmxcfs/memdb.h
+++ b/src/pmxcfs/memdb.h
@@ -88,6 +88,12 @@ typedef struct {
db_backend_t *bdb;
} memdb_t;
+typedef struct {
+ gboolean in_progress; // whether a backup is currently in progress
+ int remaining; // how many pages are still remaining to back up
+ int total; // how many pages there are to back up in total after the last backup step
+} memdb_backup_progress_t;
+
memdb_t *memdb_open(const char *dbfilename);
void memdb_close(memdb_t *memdb);
@@ -200,6 +206,17 @@ int memdb_finish_or_abort_backup(memdb_t *memdb, gboolean abort);
*/
int memdb_handle_backup(memdb_t *memdb);
+/**
+ * memdb_backup_progress:
+ * @memdb: a memdb object that has an initialized db to back up.
+ *
+ * Queries whether a backup is in progress and if so how far it has progressed.
+ *
+ * Return: the returned `memdb_backup_progress_t` struct provides information on whether a back up
+ * is in progress and if so how far it has progressed.
+ */
+memdb_backup_progress_t memdb_backup_progress(memdb_t *memdb);
+
db_backend_t *bdb_backend_open(const char *filename, memdb_tree_entry_t *root, GHashTable *index);
void bdb_backend_close(db_backend_t *bdb);
@@ -267,4 +284,15 @@ int bdb_finish_or_abort_backup(db_backend_t *bdb, gboolean abort);
*/
int bdb_handle_backup(db_backend_t *bdb);
+/**
+ * bdb_backup_progress:
+ * @bdb: a db backend that has an initialized db to back up.
+ *
+ * Queries whether a backup is in progress and if so how far it has progressed.
+ *
+ * Return: the returned `memdb_backup_progress_t` struct provides information on whether a back up
+ * is in progress and if so how far it has progressed.
+ */
+memdb_backup_progress_t bdb_backup_progress(db_backend_t *bdb);
+
#endif /* _PVE_MEMDB_H_ */
diff --git a/src/pmxcfs/server.c b/src/pmxcfs/server.c
index 8149fd3..5f9ab0a 100644
--- a/src/pmxcfs/server.c
+++ b/src/pmxcfs/server.c
@@ -511,6 +511,9 @@ static int32_t s1_msg_process_fn(qb_ipcs_connection_t *c, void *data, size_t siz
if (result != 0) {
cfs_critical("couldn't add first backup step (error %d), abort...", result);
(void)memdb_finish_or_abort_backup(memdb, TRUE);
+ } else {
+ memdb_backup_progress_t progress = memdb_backup_progress(memdb);
+ cfs_create_backup_progress_msg(outbuf, &progress);
}
}
@@ -522,6 +525,14 @@ static int32_t s1_msg_process_fn(qb_ipcs_connection_t *c, void *data, size_t siz
result = -EINVAL;
}
}
+ } else if (request_id == CFS_IPC_BACKUP_PROGRESS) {
+ if (request_size != sizeof(struct qb_ipc_request_header)) {
+ result = -EINVAL;
+ } else {
+ memdb_backup_progress_t progress = memdb_backup_progress(memdb);
+ cfs_create_backup_progress_msg(outbuf, &progress);
+ result = 0;
+ }
}
cfs_debug("process result %d", result);
diff --git a/src/pmxcfs/status.c b/src/pmxcfs/status.c
index 9581e2a..41591d5 100644
--- a/src/pmxcfs/status.c
+++ b/src/pmxcfs/status.c
@@ -1974,3 +1974,10 @@ void cfs_set_quorate(uint32_t quorate, gboolean quiet) {
g_mutex_unlock(&mutex);
}
+
+void cfs_create_backup_progress_msg(GString *str, memdb_backup_progress_t *progress) {
+ g_string_append_printf(
+ str, "{\n\t\"in-progress\": %s,\n\t\"remaining\": %d,\n\t\"total\": %d\n}",
+ progress->in_progress ? "true" : "false", progress->remaining, progress->total
+ );
+}
diff --git a/src/pmxcfs/status.h b/src/pmxcfs/status.h
index 6a6b0a7..0790c88 100644
--- a/src/pmxcfs/status.h
+++ b/src/pmxcfs/status.h
@@ -103,4 +103,6 @@ int cfs_create_guest_conf_properties_msg(
GString *str, memdb_t *memdb, const char **props, uint8_t num_props, uint32_t vmid
);
+void cfs_create_backup_progress_msg(GString *str, memdb_backup_progress_t *progress);
+
#endif /* _PVE_STATUS_H_ */
--
2.47.3
^ permalink raw reply related [flat|nested] 22+ messages in thread* [PATCH common 05/21] systemd: move parse_os_release() helper to PVE::Systemd
2026-08-28 13:30 [RFC cluster/common/container/docs/installer/manager 00/21] add rudimentary host backup mechanism Shannon Sterz
` (3 preceding siblings ...)
2026-08-28 13:30 ` [PATCH cluster 04/21] pmxcfs: add ability to query backup progress Shannon Sterz
@ 2026-08-28 13:30 ` Shannon Sterz
2026-08-28 13:30 ` [PATCH container 06/21] setup: use parse_os_release from PVE::Systemd Shannon Sterz
` (15 subsequent siblings)
20 siblings, 0 replies; 22+ messages in thread
From: Shannon Sterz @ 2026-08-28 13:30 UTC (permalink / raw)
To: pve-devel
to avoid implementing such a parser multiple times.
Signed-off-by: Shannon Sterz <s.sterz@proxmox.com>
---
src/PVE/Systemd.pm | 38 ++++++++++++++++++++++++++++++++++++++
1 file changed, 38 insertions(+)
diff --git a/src/PVE/Systemd.pm b/src/PVE/Systemd.pm
index 1ed5271..8a75ad3 100644
--- a/src/PVE/Systemd.pm
+++ b/src/PVE/Systemd.pm
@@ -365,4 +365,42 @@ sub notify {
return;
}
+=head3 parse_os_release()
+
+Allows parsing an C<os-release> file as defined by C<man 5 os-release>.
+
+=cut
+
+# os-release(5):
+# (...) a newline-separated list of environment-like shell-compatible
+# variable assignments. (...) beyond mere variable assignments, no shell
+# features are supported (this means variable expansion is explicitly not
+# supported) (...). Variable assignment values must be enclosed in double or
+# single quotes *if* they include spaces, semicolons or other special
+# characters outside of A-Z, a-z, 0-9. Shell special characters ("$", quotes,
+# backslash, backtick) must be escaped with backslashes (...). All strings
+# should be in UTF-8 format, and non-printable characters should not be used.
+# It is not supported to concatenate multiple individually quoted strings.
+# Lines beginning with "#" shall be ignored as comments.
+sub parse_os_release {
+ my ($data) = @_;
+ my $variables = {};
+ while (defined($data) && $data =~ /^(.+)$/gm) {
+ next if $1 !~ /^\s*([a-zA-Z_][a-zA-Z0-9_]*)=(.*)$/;
+ my ($var, $content) = ($1, $2);
+ chomp $content;
+
+ if ($content =~ /^'([^']*)'/) {
+ $variables->{$var} = $1;
+ } elsif ($content =~ /^"((?:[^"\\]|\\.)*)"/) {
+ my $s = $1;
+ $s =~ s/(\\["'`nt\$\\])/"\"$1\""/eeg;
+ $variables->{$var} = $s;
+ } elsif ($content =~ /^([A-Za-z0-9]*)/) {
+ $variables->{$var} = $1;
+ }
+ }
+ return $variables;
+}
+
1;
--
2.47.3
^ permalink raw reply related [flat|nested] 22+ messages in thread* [PATCH container 06/21] setup: use parse_os_release from PVE::Systemd
2026-08-28 13:30 [RFC cluster/common/container/docs/installer/manager 00/21] add rudimentary host backup mechanism Shannon Sterz
` (4 preceding siblings ...)
2026-08-28 13:30 ` [PATCH common 05/21] systemd: move parse_os_release() helper to PVE::Systemd Shannon Sterz
@ 2026-08-28 13:30 ` Shannon Sterz
2026-08-28 13:30 ` [PATCH manager 07/21] jobs/api: add basic host backup job logic Shannon Sterz
` (14 subsequent siblings)
20 siblings, 0 replies; 22+ messages in thread
From: Shannon Sterz @ 2026-08-28 13:30 UTC (permalink / raw)
To: pve-devel
this was moved to PVE::Systemd to make the parse more re-usable and
avoid multiple implementations.
Signed-off-by: Shannon Sterz <s.sterz@proxmox.com>
---
src/PVE/LXC/Setup.pm | 35 ++---------------------------------
1 file changed, 2 insertions(+), 33 deletions(-)
diff --git a/src/PVE/LXC/Setup.pm b/src/PVE/LXC/Setup.pm
index d936af2..c8c036d 100644
--- a/src/PVE/LXC/Setup.pm
+++ b/src/PVE/LXC/Setup.pm
@@ -7,6 +7,7 @@ use POSIX;
use Cwd 'abs_path';
use PVE::RESTEnvironment;
+use PVE::Systemd;
use PVE::Tools;
use PVE::LXC::Setup::Alpine;
@@ -335,38 +336,6 @@ sub unified_cgroupv2_support {
return $self->{plugin}->unified_cgroupv2_support($self->get_ct_init_path());
}
-# os-release(5):
-# (...) a newline-separated list of environment-like shell-compatible
-# variable assignments. (...) beyond mere variable assignments, no shell
-# features are supported (this means variable expansion is explicitly not
-# supported) (...). Variable assignment values must be enclosed in double or
-# single quotes *if* they include spaces, semicolons or other special
-# characters outside of A-Z, a-z, 0-9. Shell special characters ("$", quotes,
-# backslash, backtick) must be escaped with backslashes (...). All strings
-# should be in UTF-8 format, and non-printable characters should not be used.
-# It is not supported to concatenate multiple individually quoted strings.
-# Lines beginning with "#" shall be ignored as comments.
-my $parse_os_release = sub {
- my ($data) = @_;
- my $variables = {};
- while (defined($data) && $data =~ /^(.+)$/gm) {
- next if $1 !~ /^\s*([a-zA-Z_][a-zA-Z0-9_]*)=(.*)$/;
- my ($var, $content) = ($1, $2);
- chomp $content;
-
- if ($content =~ /^'([^']*)'/) {
- $variables->{$var} = $1;
- } elsif ($content =~ /^"((?:[^"\\]|\\.)*)"/) {
- my $s = $1;
- $s =~ s/(\\["'`nt\$\\])/"\"$1\""/eeg;
- $variables->{$var} = $s;
- } elsif ($content =~ /^([A-Za-z0-9]*)/) {
- $variables->{$var} = $1;
- }
- }
- return $variables;
-};
-
sub get_ct_os_release {
my ($self) = @_;
@@ -379,7 +348,7 @@ sub get_ct_os_release {
return undef;
});
- return &$parse_os_release($data);
+ return PVE::Systemd::parse_os_release($data);
}
# Checks whether /sbin/init is a symlink, and if it is, resolves it to the actual binary
--
2.47.3
^ permalink raw reply related [flat|nested] 22+ messages in thread* [PATCH manager 07/21] jobs/api: add basic host backup job logic
2026-08-28 13:30 [RFC cluster/common/container/docs/installer/manager 00/21] add rudimentary host backup mechanism Shannon Sterz
` (5 preceding siblings ...)
2026-08-28 13:30 ` [PATCH container 06/21] setup: use parse_os_release from PVE::Systemd Shannon Sterz
@ 2026-08-28 13:30 ` Shannon Sterz
2026-08-28 13:30 ` [PATCH manager 08/21] api: cluster: add endpoints for manage host backup jobs Shannon Sterz
` (13 subsequent siblings)
20 siblings, 0 replies; 22+ messages in thread
From: Shannon Sterz @ 2026-08-28 13:30 UTC (permalink / raw)
To: pve-devel
adds a new job type "host-backup" that will back up certain host files
that are of particular concern to proxmox ve, this includes:
- everything below `/etc`
- interface pins: `/usr/local/lib/systemd/network/50-{pve,pmx}-*.link`
- pmxcfs database backup
to achieve a certain degree of consistency, the live backup feature of
pmxcfs is used to back up the database. for the rest of the files
included in a backup, snapshots are leveraged if the root file system
is detected to be either zfs or btrfs. for lvm-based systems, this
currently does not include any additional consistency measures.
users can add custom files and directories to a back-up via a
parameter. hooks can also be used to make the backup more versatile
and allow for improved consistency depending on the needs of users.
also, an api endpoint is added to trigger one-off host backups:
* POST /nodes/{node}/host-backup
Signed-off-by: Shannon Sterz <s.sterz@proxmox.com>
---
PVE/API2/HostBackup.pm | 388 +++++++++++++++++++++++++++++++++++++++++
PVE/API2/Makefile | 1 +
PVE/API2/Nodes.pm | 7 +
PVE/Jobs.pm | 2 +
PVE/Jobs/HostBackup.pm | 122 +++++++++++++
PVE/Jobs/Makefile | 5 +-
6 files changed, 523 insertions(+), 2 deletions(-)
create mode 100644 PVE/API2/HostBackup.pm
create mode 100644 PVE/Jobs/HostBackup.pm
diff --git a/PVE/API2/HostBackup.pm b/PVE/API2/HostBackup.pm
new file mode 100644
index 000000000..9a0b0dc1d
--- /dev/null
+++ b/PVE/API2/HostBackup.pm
@@ -0,0 +1,388 @@
+package PVE::API2::HostBackup;
+
+use v5.36;
+
+use Carp;
+use Cwd qw(abs_path);
+use Encode qw(decode);
+use File::Path qw(make_path remove_tree);
+use JSON qw(decode_json encode_json);
+use Time::HiRes qw(usleep);
+
+use PVE::Cluster;
+use PVE::Cmd qw(run);
+use PVE::Exception qw(raise_param_exc);
+use PVE::INotify qw(nodename);
+use PVE::JSONSchema qw(get_standard_option);
+use PVE::PBSClient;
+use PVE::Storage::PBSPlugin;
+use PVE::Storage;
+use PVE::Systemd;
+use PVE::VZDump;
+use PVE::pvecfg;
+
+use base qw(PVE::RESTHandler);
+
+my $host_backup_lock = "/run/pve/host-backup.lock";
+
+
+my sub prepare_backup($backup_name) {
+ my $snap_cmd = undef;
+ my $base_path = "/";
+
+ if (my $mntinfo = PVE::VZDump::get_mount_info("/")) {
+ if ($mntinfo->{fstype} eq "zfs") {
+ $snap_cmd = ['zfs', 'snapshot', $mntinfo->{device} . '@' . $backup_name];
+
+ # snapshots are mounted under `/.zfs/snapshots` unless snapdir is set to "disabled".
+ # the default is "hidden".
+ # https://openzfs.github.io/openzfs-docs/man/v2.4/7/zfsprops.7.html#snapdir
+ $base_path = "/.zfs/snapshot/$backup_name/";
+ } elsif ($mntinfo->{fstype} eq "btrfs") {
+ # check if snapshots directory exists, if not create one
+ mkdir "/.snapshots"
+ or $!{EEXIST}
+ or die "could not create snapshot folder - $!\n"
+ if !-d "/.snapshots";
+ $base_path = '/.snapshots/root@' . $backup_name . '/';
+ $snap_cmd = [
+ 'btrfs', '-q', 'subvolume', 'snapshot', '-r', '--', '/', $base_path,
+ ];
+ }
+ }
+
+ my $result = PVE::Cluster::cfs_live_backup_database();
+ my $progress = $result->{'progress'};
+
+ while ($progress->{'in-progress'}) {
+ print("Backing up pmxcfs $progress->{remaining} of $progress->{total} pages remain...\n");
+ usleep(500_000); # Sleep for half a second to avoid spamming pmxcfs which could pin a CPU.
+ $progress = PVE::Cluster::cfs_live_backup_progress();
+ }
+
+ my $db_file = "var/lib/pve-cluster/backup/" . $result->{'file'};
+
+ die "no pmxcfs backup was created, aborting backup...\n" if !-f "/$db_file";
+
+ if (defined($snap_cmd)) {
+ eval { run($snap_cmd, errmsg => "could not create root file system snapshot"); };
+ die $@ if $@;
+ }
+
+ return ($base_path, $db_file);
+}
+
+my sub do_backup : prototype($$\@$$) ($pbs, $base_path, $files, $backup_name, $backup_target) {
+ croak "backup directory has not been created yet, aborting...\n" if !-d $backup_target;
+
+ my $errors = "";
+ my $rsync_cmd = ['rsync', '-aqXA', '--relative', '--one-file-system'];
+
+ my $err_func = sub {
+ my ($line) = @_;
+ $errors .= decode('UTF-8', $line);
+ };
+
+ foreach my $to_backup (@{$files}) {
+ # skip files that don't exist without displaying an error
+ next if !-e "/$to_backup";
+ # add a `./` here to only copy necessary relative paths
+ push @$rsync_cmd, $base_path . './' . $to_backup;
+ }
+
+ push @$rsync_cmd, $backup_target;
+ run($rsync_cmd, errfunc => $err_func);
+
+ my @auto_packages = ();
+ run(
+ ['apt-mark', 'showauto'],
+ errmsg => "could not get automatically installed packages",
+ outfunc => sub {
+ my ($line) = @_;
+ push @auto_packages, decode('UTF-8', $line);
+ },
+ );
+
+ my @manual_packages = ();
+ run(
+ ['apt-mark', 'showmanual'],
+ errmsg => "could not get manually installed packages",
+ outfunc => sub {
+ my ($line) = @_;
+ push @manual_packages, decode('UTF-8', $line);
+ },
+ );
+
+ my $index = {
+ 'backup-name' => $backup_name,
+ 'version' => {
+ 'proxmox-ve' => PVE::pvecfg::version_info(),
+ },
+ 'package-information' => {
+ 'automatically-installed' => \@auto_packages,
+ 'manually-installed' => \@manual_packages,
+ },
+ };
+
+ my $os_release = undef;
+
+ if (-f '/etc/os-release') {
+ # On our current Debian Trixie releases this is a symlink to
+ # `/usr/lib/os-release`, but `man 5 os-release` specifies that
+ # `/etc/os-release` takes precedence, so adhere to that.
+ $os_release = PVE::Tools::file_get_contents('/etc/os-release');
+ } elsif (-f '/usr/lib/os-release') {
+ $os_release = PVE::Tools::file_get_contents('/usr/lib/os-release');
+ }
+
+ $index->{'version'}->{'os-release'} = PVE::Systemd::parse_os_release($os_release)
+ if defined($os_release);
+
+ if ($errors ne "") {
+ $index->{'errors'} = $errors;
+ }
+
+ my $nodename = nodename();
+ PVE::Tools::file_set_contents($backup_target . '/backup-index.json', encode_json($index));
+ $pbs->backup_fs_tree($backup_target, $nodename, 'pve-backup');
+}
+
+my sub cleanup_backup($backup_name, $backup_target) {
+ eval { remove_tree($backup_target, { safe => 1 }) if -d $backup_target; };
+ warn $@ if $@;
+
+ my $snap_cmd = undef;
+
+ if (my $mntinfo = PVE::VZDump::get_mount_info("/")) {
+ if ($mntinfo->{fstype} eq "zfs") {
+ $snap_cmd = ['zfs', 'destroy', $mntinfo->{device} . '@' . $backup_name];
+ } elsif ($mntinfo->{fstype} eq "btrfs") {
+ $snap_cmd = [
+ 'btrfs', '-q', 'subvolume', 'delete', '--', '/.snapshots/root@' . $backup_name,
+ ];
+ }
+ }
+
+ if (defined($snap_cmd)) {
+ run(
+ $snap_cmd, errmsg => "could not clean up root file system snapshot",
+ );
+ }
+}
+
+my sub run_hook_script : prototype($$$;\%) ($hooks, $phase, $storage_cfg, $payload = undef) {
+ return if !defined($hooks);
+
+ my ($path, undef, $type) = PVE::Storage::path($storage_cfg, $hooks);
+
+ croak "Error: The hook script '$hooks' is not a snippet.\n" if $type ne "snippets";
+ croak "Error: The hook script '$hooks' does not exist or is not a file.\n" if !-f $path;
+ croak "Error: The hook script '$hooks' is not executable.\n" if !-x $path;
+
+ my $json = '';
+ my $json_payload = '';
+ my $cmd = undef;
+
+ if (defined($payload)) {
+ $json_payload = encode_json($payload);
+ }
+
+ run(
+ [$path, $phase],
+ errmsg => "error while executing hook script '$hooks' in phase '$phase'",
+ input => $json_payload,
+ outfunc => sub {
+ my ($line) = @_;
+ $json .= decode('UTF-8', $line) . "\n";
+ },
+ );
+
+ return if !$json;
+ return decode_json($json);
+}
+
+my sub exec_host_backup : prototype(\%) ($conf) {
+ my $ctime = time();
+
+ my $backup_name = "host-backup-$ctime";
+ my $base_path = undef;
+ my $db_backup_file = undef;
+
+ croak "no storage defined, can't carry out host backup\n" if !defined($conf->{storage});
+
+ my $storeid = $conf->{storage};
+ my $cfg = PVE::Storage::config();
+ my $scfg = PVE::Storage::storage_check_enabled($cfg, $storeid, undef, 1);
+
+ # croak here since providing a viable storage is the responsibility of the caller
+ croak "storage is not enabled or not available on this host\n" if !defined($scfg);
+ croak "currently only proxmox backup server is supported for host backups\n"
+ if $scfg->{type} ne PVE::Storage::PBSPlugin::type();
+
+ my $pbs = PVE::PBSClient->new($scfg, $storeid);
+
+ PVE::Tools::lock_file(
+ $host_backup_lock,
+ undef,
+ sub {
+ eval {
+ ($base_path, $db_backup_file) = prepare_backup($backup_name);
+
+ if (my $result = run_hook_script($conf->{"hooks"}, "job-start", $cfg)) {
+ if (
+ defined($result->{"base-path"})
+ && -d $result->{"base-path"}
+ ) {
+ print "Using custom base path from hook script: "
+ . $result->{"base-path"} . "\n";
+ $base_path = $result->{"base-path"};
+ }
+ }
+ };
+
+ my $err = $@;
+ my $backup_target = "/run/pve/host-backup/$backup_name";
+
+ if (!$err) {
+ my $backup_info = {
+ "backup-name" => $backup_name,
+ "backup-target" => $backup_target,
+ };
+
+ eval {
+ make_path($backup_target, { mode => 0700 });
+
+ run_hook_script($conf->{"hooks"}, "backup-start", $cfg, %$backup_info);
+
+ my @files = ("etc/", $db_backup_file);
+
+ my @link_files =
+ glob("$base_path/usr/local/lib/systemd/network/50-{pve,pmx}-*.link");
+
+ foreach my $link (@link_files) {
+ # strip the base path, `do_backup` expects relative paths.
+ # this should also un-taint the string
+ if ($link =~ m/^\Q$base_path\E\/(.*)$/) {
+ push @files, $1;
+ }
+ }
+
+ if (defined($conf->{"additional-files"})) {
+ my @additional_files =
+ PVE::Tools::split_list($conf->{"additional-files"});
+
+ foreach my $additional (@additional_files) {
+ my $abs_path = abs_path($additional);
+ # untaint and strip first `/`
+ if ($abs_path =~ m/\/(.*)/) {
+ push @files, $1;
+ }
+ }
+ }
+
+ do_backup($pbs, $base_path, @files, $backup_name, $backup_target);
+ run_hook_script($conf->{"hooks"}, "backup-end", $cfg, %$backup_info);
+ };
+ warn "error while creating host backup: $@\n" if $@;
+ $err ||= $@;
+ }
+
+ eval { cleanup_backup($backup_name, $backup_target) };
+ warn "error while cleaning up backup: $@\n" if $@;
+ $err ||= $@;
+
+ eval { run_hook_script($conf->{"hooks"}, "job-end", $cfg); };
+ warn "could not run 'job-end' hook: $@\n" if $@;
+ $err ||= $@;
+
+ die "host backup failed: $err\n" if $err;
+ },
+ );
+ die $@ if $@;
+}
+
+sub complete_proxmox_backup_storage(@) {
+ my $cfg = PVE::Storage::config();
+ my $nodename = PVE::INotify::nodename();
+ my $ids = $cfg->{ids};
+ my $res = [];
+
+ foreach my $storageid (keys %$ids) {
+ my $scfg = PVE::Storage::storage_check_enabled($cfg, $storageid, $nodename, 1);
+ next if $scfg->{type} ne PVE::Storage::PBSPlugin::type();
+ push @$res, $storageid;
+ }
+
+ return $res;
+}
+
+__PACKAGE__->register_method({
+ name => 'create_backup',
+ path => '',
+ method => 'POST',
+ proxyto => 'node',
+ protected => 1,
+ permissions => {
+ description =>
+ "The user needs 'Sys.Console' permissions on '/' and 'Datastore.AllocateSpace' "
+ . "permissions on the target storage. The parameters 'hooks' and 'additional-files' are"
+ . "further restricted to 'root\@pam'.",
+ check => [
+ 'and',
+ ['perm', '/', ['Sys.Console']],
+ ['perm', '/storage/{storage}', ['Datastore.AllocateSpace']],
+ ],
+
+ },
+ description => "Create a new host backup.",
+ parameters => {
+ additionalProperties => 0,
+ properties => {
+ node => get_standard_option('pve-node'),
+ storage => get_standard_option(
+ 'pve-storage-id',
+ {
+ description => "Store resulting file to this storage.",
+ completion => \&complete_proxmox_backup_storage,
+ optional => 0,
+ },
+ ),
+ hooks => {
+ type => 'string',
+ description => "Use specified hook script.",
+ optional => 1,
+ },
+ 'additional-files' => {
+ description =>
+ "Additional files or directories that should be included in the backup."
+ . " Separated by a comma.",
+ type => 'string',
+ optional => 1,
+ },
+ },
+ },
+ returns => { type => 'string' },
+ code => sub($param) {
+ my $rpcenv = PVE::RPCEnvironment::get();
+ my $user = $rpcenv->get_user();
+
+ if ($user ne "root\@pam") {
+ if (defined($param->{hooks})) {
+ raise_param_exc({ hooks => "Only root may set the hooks option." });
+ }
+
+ if (defined($param->{"additional-files"})) {
+ raise_param_exc({
+ "additional-files" => "Only root may set the additional-files option." });
+ }
+ }
+
+ my $worker = sub {
+ exec_host_backup(%$param);
+ };
+
+ return $rpcenv->fork_worker('host-backup', $param->{node}, $user, $worker);
+ },
+});
+
+1;
diff --git a/PVE/API2/Makefile b/PVE/API2/Makefile
index 97f1cc202..1cd72a594 100644
--- a/PVE/API2/Makefile
+++ b/PVE/API2/Makefile
@@ -14,6 +14,7 @@ PERLSOURCE = \
Cluster.pm \
HAConfig.pm \
Hardware.pm \
+ HostBackup.pm \
Network.pm \
NodeConfig.pm \
Nodes.pm \
diff --git a/PVE/API2/Nodes.pm b/PVE/API2/Nodes.pm
index 2ca3244da..0879443fc 100644
--- a/PVE/API2/Nodes.pm
+++ b/PVE/API2/Nodes.pm
@@ -49,6 +49,7 @@ use PVE::API2::Certificates;
use PVE::API2::Disks;
use PVE::API2::Firewall::Host;
use PVE::API2::Hardware;
+use PVE::API2::HostBackup;
use PVE::API2::LXC::Status;
use PVE::API2::LXC;
use PVE::API2::Network;
@@ -204,6 +205,11 @@ __PACKAGE__->register_method({
path => 'sdn',
});
+__PACKAGE__->register_method({
+ subclass => "PVE::API2::HostBackup",
+ path => 'host-backup',
+});
+
__PACKAGE__->register_method({
name => 'index',
path => '',
@@ -269,6 +275,7 @@ __PACKAGE__->register_method({
{ name => 'vncshell' },
{ name => 'vzdump' },
{ name => 'wakeonlan' },
+ { name => 'host-backup' },
];
return $result;
diff --git a/PVE/Jobs.pm b/PVE/Jobs.pm
index 40caf257d..097cee5c6 100644
--- a/PVE/Jobs.pm
+++ b/PVE/Jobs.pm
@@ -9,9 +9,11 @@ use PVE::Job::Registry;
use PVE::Jobs::VZDump;
use PVE::Jobs::RealmSync;
use PVE::Tools;
+use PVE::Jobs::HostBackup;
PVE::Jobs::VZDump->register();
PVE::Jobs::RealmSync->register();
+PVE::Jobs::HostBackup->register();
PVE::Job::Registry->init();
cfs_register_file(
diff --git a/PVE/Jobs/HostBackup.pm b/PVE/Jobs/HostBackup.pm
new file mode 100644
index 000000000..787b19547
--- /dev/null
+++ b/PVE/Jobs/HostBackup.pm
@@ -0,0 +1,122 @@
+package PVE::Jobs::HostBackup;
+
+use v5.36;
+
+use PVE::API2::HostBackup;
+use PVE::Cluster;
+use PVE::INotify qw(nodename);
+use PVE::JSONSchema qw(get_standard_option);
+use PVE::SafeSyslog;
+
+use parent qw(PVE::Job::Registry);
+
+sub type($) {
+ return 'host-backup';
+}
+
+my $props = {
+ nodes => get_standard_option(
+ 'pve-node-list',
+ {
+ description => "List of nodes for which the storage configuration applies.",
+ optional => 1,
+ },
+ ),
+ hooks => {
+ description => "Use specified hook script.",
+ type => 'string',
+ optional => 1,
+ },
+ 'additional-files' => {
+ description => "Additional files or directories that should be included in the backup."
+ . " Separated by a comma.",
+ type => 'string',
+ optional => 1,
+ },
+};
+
+sub properties($) {
+ return $props;
+}
+
+sub options($) {
+ my $options = {
+ comment => { optional => 1 },
+ enabled => { optional => 1 },
+ 'repeat-missed' => { optional => 1 },
+ schedule => {},
+ storage => {},
+ };
+
+ foreach my $opt (keys %$props) {
+ if ($props->{$opt}->{optional}) {
+ $options->{$opt} = { optional => 1 };
+ } else {
+ $options->{$opt} = {};
+ }
+ }
+
+ return $options;
+}
+
+sub decode_value($class, $type, $key, $value) {
+ return $value;
+}
+
+sub encode_value($class, $type, $key, $value) {
+ return $value;
+}
+
+# Returns the create Schema of a host backup job.
+#
+# Override the Registry's base implementation as SectionConfig would otherwise return any option
+# registered for any job. This is due to all jobs sharing a base class that maintains globally all
+# fields for the jobs.cfg section config. However, when creating a host backup job, many options from
+# either VZDump nor the realm sync jobs apply. So filter them out here and only return properties
+# relevant for the host backup.
+sub createSchema($class) {
+ my $to_return = {
+ additionalProperties => 0,
+ properties => {},
+ };
+
+ my $opts = $class->options();
+ my $super_schema = $class->SUPER::createSchema();
+
+ foreach my $opt (keys %$opts) {
+ $to_return->{properties}->{$opt} = $super_schema->{properties}->{$opt};
+ }
+
+ return $to_return;
+}
+
+sub run($class, $conf, $job_id, $schedule) {
+ my $nodename = nodename();
+
+ # the jobs framework allows specifying whether a job should run on a single
+ # node. in the context of host backups, jobs are assigned to a subset of
+ # nodes. simply report 'OK' if this node is not part of the job to mark the
+ # job as "done".
+ if (defined($conf->{nodes})) {
+ my @nodes = PVE::Tools::split_list($conf->{nodes});
+ if (!grep(/^$nodename$/, @nodes)) {
+ return 'OK';
+ }
+ }
+
+ my $job_conf = {
+ node => $nodename,
+ storage => $conf->{'storage'},
+ };
+
+ foreach my $opt (keys %$conf) {
+ $job_conf->{$opt} = $conf->{$opt} if defined($props->{$opt});
+ }
+
+ # this is a scheduling-only parameter, so remove it here as the api won't allow it.
+ delete $job_conf->{nodes} if $job_conf->{nodes};
+
+ return PVE::API2::HostBackup->create_backup($job_conf);
+}
+
+1;
diff --git a/PVE/Jobs/Makefile b/PVE/Jobs/Makefile
index 11aed0d19..9c4b702b0 100644
--- a/PVE/Jobs/Makefile
+++ b/PVE/Jobs/Makefile
@@ -1,7 +1,8 @@
include ../../defines.mk
-PERLSOURCE = \
- VZDump.pm \
+PERLSOURCE = \
+ HostBackup.pm \
+ VZDump.pm \
all:
--
2.47.3
^ permalink raw reply related [flat|nested] 22+ messages in thread* [PATCH manager 08/21] api: cluster: add endpoints for manage host backup jobs
2026-08-28 13:30 [RFC cluster/common/container/docs/installer/manager 00/21] add rudimentary host backup mechanism Shannon Sterz
` (6 preceding siblings ...)
2026-08-28 13:30 ` [PATCH manager 07/21] jobs/api: add basic host backup job logic Shannon Sterz
@ 2026-08-28 13:30 ` Shannon Sterz
2026-08-28 13:30 ` [PATCH manager 09/21] api: node: add endpoints for listing backups for a node Shannon Sterz
` (12 subsequent siblings)
20 siblings, 0 replies; 22+ messages in thread
From: Shannon Sterz @ 2026-08-28 13:30 UTC (permalink / raw)
To: pve-devel
includes the follow api endpoints:
* GET /cluster/jobs/host-backup: list configured host backup jobs
* POST /cluster/jobs/host-backup: create a new host backup job
* GET /cluster/jobs/host-backup/{id}: get configuration for job {id}
* PUT /cluster/jobs/host-backup/{id}: update host backup job {id}
* DELETE /cluster/jobs/host-backup/{id}: remove host backup job {id}
Signed-off-by: Shannon Sterz <s.sterz@proxmox.com>
---
PVE/API2/Cluster/HostBackup.pm | 355 +++++++++++++++++++++++++++++++++
PVE/API2/Cluster/Jobs.pm | 10 +-
PVE/API2/Cluster/Makefile | 1 +
3 files changed, 365 insertions(+), 1 deletion(-)
create mode 100644 PVE/API2/Cluster/HostBackup.pm
diff --git a/PVE/API2/Cluster/HostBackup.pm b/PVE/API2/Cluster/HostBackup.pm
new file mode 100644
index 000000000..97c3553d7
--- /dev/null
+++ b/PVE/API2/Cluster/HostBackup.pm
@@ -0,0 +1,355 @@
+package PVE::API2::Cluster::HostBackup;
+
+use v5.36;
+
+use UUID qw(uuid);
+
+use PVE::API2::HostBackup;
+use PVE::Cluster qw(cfs_lock_file cfs_read_file cfs_write_file);
+use PVE::Exception qw(raise_param_exc);
+use PVE::GuestHelpers;
+use PVE::JSONSchema qw(get_standard_option);
+use PVE::Jobs::HostBackup;
+use PVE::Jobs;
+use PVE::RPCEnvironment;
+use PVE::Storage::PBSPlugin;
+use PVE::Storage;
+use PVE::Tools qw(extract_param split_list);
+
+use Proxmox::RS::CalendarEvent;
+
+use base qw(PVE::RESTHandler);
+
+# Helper to override or add certain parameters.
+my sub host_backup_properties : prototype(%) ($prop) {
+ my $create_schema = PVE::Jobs::HostBackup->createSchema();
+
+ foreach my $opt (keys %$prop) {
+ $create_schema->{properties}->{$opt} = $prop->{$opt};
+ }
+
+ return $create_schema;
+}
+
+__PACKAGE__->register_method({
+ name => 'list_jobs',
+ path => '',
+ method => 'GET',
+ description => "List host backup jobs.",
+ permissions => {
+ check => ['perm', '/', ['Sys.Audit']],
+ },
+ parameters => {
+ additionalProperties => 0,
+ properties => {},
+ },
+ returns => {
+ type => 'array',
+ items => host_backup_properties({
+ id => get_standard_option('pve-backup-jobid'),
+ 'next-run' => {
+ description => "UNIX timestamp when this host backup job will be executed next",
+ type => 'integer',
+ optional => 1,
+ },
+ }),
+ },
+ code => sub($) {
+ my $jobs_data = cfs_read_file('jobs.cfg');
+ my $order = $jobs_data->{order};
+ my $jobs = $jobs_data->{ids};
+ my $result = [];
+
+ foreach my $jobid (sort { $order->{$a} <=> $order->{$b} } keys %$jobs) {
+ my $job = $jobs->{$jobid};
+ $job->{id} = $jobid;
+ next if $job->{type} ne 'host-backup';
+
+ if (my $schedule = $job->{schedule}) {
+ my $last_run = time();
+ my $calspec = Proxmox::RS::CalendarEvent->new($schedule);
+ my $next_run = $calspec->compute_next_event($last_run);
+ $job->{'next-run'} = $next_run if defined($next_run);
+ }
+
+ push @$result, $job;
+ }
+
+ return $result;
+ },
+});
+
+__PACKAGE__->register_method({
+ name => 'create_backup_job',
+ path => '',
+ method => 'POST',
+ protected => 1,
+ permissions => {
+ check => [
+ 'and',
+ ['perm', '/', ['Sys.Console']],
+ ['perm', '/storage/{storage}', ['Datastore.AllocateSpace']],
+ ],
+ description =>
+ "The user needs to have 'Datastore.AllocateSpace' permissions on the storage that the "
+ . "backups are intended to be saved and 'Sys.Console' on '/'. The 'hooks' and "
+ . "'additional-files' parameters are further restricted to 'root\@pam'.",
+ },
+ description => "Create a new host backup job.",
+ parameters => host_backup_properties({
+ 'id' => get_standard_option(
+ 'pve-backup-jobid',
+ {
+ description => 'The job ID, will be auto-generated.',
+ optional => 1,
+ },
+ ),
+ }),
+ returns => { type => 'null' },
+ code => sub($param) {
+ my $rpcenv = PVE::RPCEnvironment::get();
+ my $user = $rpcenv->get_user();
+ my $cfg = PVE::Storage::config();
+
+ if (defined($param->{hooks})) {
+ raise_param_exc({ hooks => "Only root may set the hooks option." })
+ if $user ne "root\@pam";
+
+ eval { PVE::GuestHelpers::check_hookscript($param->{hooks}, $cfg); };
+ raise_param_exc({ hooks => $@ }) if $@;
+ }
+
+ if (defined($param->{"additional-files"}) && $user ne "root\@pam") {
+ raise_param_exc({
+ "additional-files" =>
+ "Only root may set or remove the additional-files option.",
+ });
+ }
+
+ # check that storage exists and is a pbs storage
+ my $storeid = $param->{storage};
+ my $scfg = PVE::Storage::storage_config($cfg, $storeid);
+ die "currently only proxmox backup server is supported for host backups\n"
+ if $scfg->{type} ne PVE::Storage::PBSPlugin::type();
+
+ my $id = extract_param($param, 'id') // UUID::uuid();
+
+ cfs_lock_file(
+ 'jobs.cfg',
+ undef,
+ sub {
+ my $data = cfs_read_file('jobs.cfg');
+
+ die "Job '$id' already exists\n" if $data->{ids}->{$id};
+
+ my $opts = PVE::Jobs::HostBackup->check_config($id, $param, 1, 1);
+
+ $data->{ids}->{$id} = $opts;
+
+ PVE::Jobs::create_job($id, 'host-backup', $opts);
+ cfs_write_file('jobs.cfg', $data);
+ },
+ );
+
+ die "could not add host backup job: $@\n" if $@;
+ return;
+ },
+});
+
+__PACKAGE__->register_method({
+ name => 'read_job',
+ path => '{id}',
+ method => 'GET',
+ description => "Get the configuration of a host backup job.",
+ permissions => {
+ check => ['perm', '/', ['Sys.Audit']],
+ },
+ parameters => {
+ additionalProperties => 0,
+ properties => {
+ id => get_standard_option('pve-backup-jobid'),
+ },
+ },
+ returns => host_backup_properties({
+ id => get_standard_option('pve-backup-jobid'),
+ }),
+ code => sub($param) {
+ my $jobs_data = cfs_read_file('jobs.cfg');
+ my $id = extract_param($param, 'id');
+ my $job = $jobs_data->{ids}->{$id};
+
+ if ($job && $job->{type} eq 'host-backup') {
+ $job->{id} = $id;
+ return $job;
+ }
+
+ raise_param_exc({ id => "No such job '$id'" });
+ },
+});
+
+__PACKAGE__->register_method({
+ name => 'update_job',
+ path => '{id}',
+ method => 'PUT',
+ protected => 1,
+ description => "Update host backup job configuration.",
+ permissions => {
+ check => [
+ 'and',
+ ['perm', '/', ['Sys.Console']],
+ ['perm', '/storage/{storage}', ['Datastore.AllocateSpace']],
+ ],
+ description =>
+ "The user needs to have 'Datastore.AllocateSpace' permissions on the storage that the "
+ . "backups are intended to be saved and 'Sys.Console' on '/'. The 'hooks' and "
+ . "'additional-files' parameters are further restricted to 'root\@pam'.",
+ },
+ parameters => host_backup_properties({
+ id => get_standard_option('pve-backup-jobid'),
+ schedule => {
+ description =>
+ "Backup schedule. The format is a subset of `systemd` calendar events.",
+ type => 'string',
+ format => 'pve-calendar-event',
+ maxLength => 128,
+ optional => 1,
+ },
+ storage => get_standard_option(
+ 'pve-storage-id',
+ {
+ description =>
+ "The storage that will store the backups. Currently, only Proxmox Backup Server is "
+ . "supported.",
+ completion => \&PVE::API2::HostBackup::complete_proxmox_backup_storage,
+ optional => 1,
+ },
+ ),
+ delete => {
+ type => 'string',
+ format => 'pve-configid-list',
+ description => "A list of settings you want to delete.",
+ optional => 1,
+ },
+ }),
+ returns => { type => 'null' },
+ code => sub($param) {
+ my $cfg = PVE::Storage::config();
+ my $rpcenv = PVE::RPCEnvironment::get();
+ my $user = $rpcenv->get_user();
+ my $delete = extract_param($param, 'delete');
+ $delete = { map { $_ => 1 } PVE::Tools::split_list($delete) } if $delete;
+
+ if ($user ne "root\@pam") {
+ if ((defined($param->{hooks}) || (defined($delete) && defined($delete->{hooks})))) {
+ raise_param_exc({ hooks => "Only root may set or remove the hooks option." });
+ }
+
+ if (
+ defined($param->{"additional-files"})
+ || (defined($delete) && defined($delete->{"additional-files"}))
+ ) {
+ raise_param_exc({
+ "additional-files" =>
+ "Only root may set or remove the additional-files option.",
+ });
+ }
+ }
+
+ if (defined($param->{hooks})) {
+ eval { PVE::GuestHelpers::check_hookscript($param->{hooks}, $cfg); };
+ raise_param_exc({ hooks => $@ }) if $@;
+ }
+
+ # check that storage exists if it is being modified
+ if (my $storeid = $param->{storage}) {
+ # check that storage exists and is a pbs storage
+ my $scfg = PVE::Storage::storage_config($cfg, $storeid);
+ die "currently only proxmox backup server is supported for host backups\n"
+ if $scfg->{type} ne PVE::Storage::PBSPlugin::type();
+ }
+
+ cfs_lock_file(
+ 'jobs.cfg',
+ undef,
+ sub {
+ my $id = extract_param($param, 'id');
+ my $jobs_data = cfs_read_file('jobs.cfg');
+ my $job = $jobs_data->{ids}->{$id};
+
+ raise_param_exc({ id => "No host backup job with ID '$id' exists." })
+ if !$job || $job->{type} ne 'host-backup';
+
+ my $deletable = {
+ 'additional-files' => 1,
+ 'repeat-missed' => 1,
+ comment => 1,
+ hooks => 1,
+ };
+
+ if (defined($delete)) {
+ for my $prop (keys $delete->%*) {
+ raise_param_exc({ delete => "unknown option '$prop'" })
+ if !$deletable->{$prop};
+
+ delete $job->{$prop};
+ }
+ }
+
+ foreach my $prop (keys %$param) {
+ $job->{$prop} = $param->{$prop};
+ }
+
+ cfs_write_file('jobs.cfg', $jobs_data);
+ PVE::Jobs::detect_changed_runtime_props($id, 'host-backup', $job);
+ return;
+
+ },
+ );
+ die "$@" if $@;
+ },
+});
+
+__PACKAGE__->register_method({
+ name => 'delete_job',
+ path => '{id}',
+ method => 'DELETE',
+ description => "Delete host backup job.",
+ permissions => {
+ check => ['perm', '/', ['Sys.Console']],
+ },
+ protected => 1,
+ parameters => {
+ additionalProperties => 0,
+ properties => {
+ id => get_standard_option('pve-backup-jobid'),
+ },
+ },
+ returns => { type => 'null' },
+ code => sub($param) {
+ my $id = extract_param($param, 'id');
+
+ cfs_lock_file(
+ 'jobs.cfg',
+ undef,
+ sub {
+ my $jobs_data = cfs_read_file('jobs.cfg');
+
+ if (!defined($jobs_data->{ids}->{$id})) {
+ raise_param_exc({ id => "No such job '$id'" });
+ }
+
+ raise_param_exc({ id => "Not a host-backup job." })
+ if $jobs_data->{ids}->{$id}->{type} ne 'host-backup';
+
+ delete $jobs_data->{ids}->{$id};
+ PVE::Jobs::remove_job($id, 'host-backup');
+ cfs_write_file('jobs.cfg', $jobs_data);
+ },
+ );
+
+ die "$@" if $@;
+ return;
+ },
+});
+
+1;
diff --git a/PVE/API2/Cluster/Jobs.pm b/PVE/API2/Cluster/Jobs.pm
index e02eed9e0..c1e992848 100644
--- a/PVE/API2/Cluster/Jobs.pm
+++ b/PVE/API2/Cluster/Jobs.pm
@@ -6,6 +6,7 @@ use warnings;
use PVE::RESTHandler;
use PVE::CalendarEvent;
+use PVE::API2::Cluster::HostBackup;
use PVE::API2::Jobs::RealmSync;
use base qw(PVE::RESTHandler);
@@ -15,6 +16,11 @@ __PACKAGE__->register_method({
path => 'realm-sync',
});
+__PACKAGE__->register_method({
+ subclass => "PVE::API2::Cluster::HostBackup",
+ path => 'host-backup',
+});
+
__PACKAGE__->register_method({
name => 'index',
path => '',
@@ -41,7 +47,9 @@ __PACKAGE__->register_method({
},
code => sub {
return [
- { subdir => 'schedule-analyze' }, { subdir => 'realm-sync' },
+ { subdir => 'schedule-analyze' },
+ { subdir => 'realm-sync' },
+ { subdir => 'host-backup' },
];
},
});
diff --git a/PVE/API2/Cluster/Makefile b/PVE/API2/Cluster/Makefile
index d3a56830c..da42cf81a 100644
--- a/PVE/API2/Cluster/Makefile
+++ b/PVE/API2/Cluster/Makefile
@@ -10,6 +10,7 @@ PERLSOURCE= \
BackupInfo.pm \
BulkAction.pm \
Ceph.pm \
+ HostBackup.pm \
Jobs.pm \
Mapping.pm \
MetricServer.pm \
--
2.47.3
^ permalink raw reply related [flat|nested] 22+ messages in thread* [PATCH manager 09/21] api: node: add endpoints for listing backups for a node
2026-08-28 13:30 [RFC cluster/common/container/docs/installer/manager 00/21] add rudimentary host backup mechanism Shannon Sterz
` (7 preceding siblings ...)
2026-08-28 13:30 ` [PATCH manager 08/21] api: cluster: add endpoints for manage host backup jobs Shannon Sterz
@ 2026-08-28 13:30 ` Shannon Sterz
2026-08-28 13:30 ` [PATCH manager 10/21] api: host backup: include global, disk and network options for restore Shannon Sterz
` (11 subsequent siblings)
20 siblings, 0 replies; 22+ messages in thread
From: Shannon Sterz @ 2026-08-28 13:30 UTC (permalink / raw)
To: pve-devel
GET /nodes/{node}/host-backup now lists backups of a host
Signed-off-by: Shannon Sterz <s.sterz@proxmox.com>
---
PVE/API2/HostBackup.pm | 140 +++++++++++++++++++++++++++++++++++++++++
1 file changed, 140 insertions(+)
diff --git a/PVE/API2/HostBackup.pm b/PVE/API2/HostBackup.pm
index 9a0b0dc1d..fe1263a85 100644
--- a/PVE/API2/HostBackup.pm
+++ b/PVE/API2/HostBackup.pm
@@ -18,6 +18,7 @@ use PVE::PBSClient;
use PVE::Storage::PBSPlugin;
use PVE::Storage;
use PVE::Systemd;
+use PVE::Tools qw(extract_param);
use PVE::VZDump;
use PVE::pvecfg;
@@ -316,6 +317,145 @@ sub complete_proxmox_backup_storage(@) {
return $res;
}
+__PACKAGE__->register_method({
+ name => 'index',
+ path => '',
+ method => 'GET',
+ proxyto => 'node',
+ protected => 1,
+ description => "List backups for specified node.",
+ permissions => {
+ check => [
+ 'and',
+ ['perm', '/', ['Sys.Audit']],
+ [
+ 'perm',
+ '/storage/{storage}',
+ ['Datastore.Audit', 'Datastore.AllocateSpace'],
+ any => 1,
+ ],
+ ],
+
+ },
+ parameters => {
+ additionalProperties => 0,
+ properties => {
+ node => get_standard_option('pve-node'),
+ storage => get_standard_option(
+ 'pve-storage-id',
+ {
+ description =>
+ "The Proxmox Backup Server storage to scan for host backups.",
+ completion => \&complete_proxmox_backup_storage,
+ optional => 0,
+ },
+ ),
+ },
+ },
+ returns => {
+ type => 'array',
+ items => {
+ type => 'object',
+ properties => {
+ volid => {
+ description => "Volume identifier.",
+ type => 'string',
+ },
+ 'format' => {
+ description => "Always 'pbs-host'.",
+ type => 'string',
+ },
+ size => {
+ description =>
+ "Volume size in bytes. 0 if the size couldn't be determined.",
+ type => 'integer',
+ renderer => 'bytes',
+ },
+ ctime => {
+ description => "Creation time (seconds since the UNIX Epoch).",
+ type => 'integer',
+ minimum => 0,
+ optional => 1,
+ },
+ notes => {
+ description =>
+ "Optional notes. If they are multiple lines long, only the first line will "
+ . "be returned.",
+ type => 'string',
+ optional => 1,
+ },
+ encrypted => {
+ description => "Fingerprint of the encrypted backup if it is encrypted.",
+ type => 'string',
+ optional => 1,
+ },
+ verification => {
+ description => "Last backup verification result.",
+ type => 'object',
+ properties => {
+ state => {
+ description => "Last backup verification state.",
+ type => 'string',
+ },
+ upid => {
+ description => "Last backup verification UPID.",
+ type => 'string',
+ },
+ },
+ optional => 1,
+ },
+ protected => {
+ description => "Protection status.",
+ type => 'boolean',
+ optional => 1,
+ },
+ },
+ },
+ links => [{ rel => 'child', href => "{backup-time}" }],
+ },
+ code => sub($param) {
+ my $storeid = extract_param($param, 'storage');
+ my $cfg = PVE::Storage::config();
+ my $scfg = PVE::Storage::storage_check_enabled($cfg, $storeid, undef, 1);
+
+ croak "storage is not enabled or not available on this host\n" if !defined($scfg);
+ croak "currently only proxmox backup server is supported for host backups\n"
+ if $scfg->{type} ne PVE::Storage::PBSPlugin::type();
+
+ my $pbs = PVE::PBSClient->new($scfg, $storeid);
+
+ my $nodename = extract_param($param, 'node');
+ my $snapshots = $pbs->get_snapshots("host/$nodename");
+
+ my $res = [];
+
+ foreach my $item (@$snapshots) {
+ my ($type, $id, $time) = $item->@{qw(backup-type backup-id backup-time)};
+ next if $type ne 'host';
+
+ my @pxar = grep { $_->{filename} eq 'pve-backup.pxar.didx' } @{ $item->{files} };
+ next if (scalar(@pxar) != 1);
+
+ my $info = {
+ volid => PVE::Storage::PBSPlugin::print_volid($storeid, $type, $id, $time),
+ format => "pbs-$type",
+ size => int($item->{size}),
+ content => 'backup',
+ ctime => $time,
+ subtype => 'host',
+ };
+
+ $info->{verification} = $item->{verification} if defined($item->{verification});
+ $info->{notes} = $item->{comment} if defined($item->{comment});
+ $info->{protected} = 1 if $item->{protected};
+
+ push @$res, $info;
+ }
+
+ return $res;
+ },
+});
+
__PACKAGE__->register_method({
name => 'create_backup',
path => '',
--
2.47.3
^ permalink raw reply related [flat|nested] 22+ messages in thread* [PATCH manager 10/21] api: host backup: include global, disk and network options for restore
2026-08-28 13:30 [RFC cluster/common/container/docs/installer/manager 00/21] add rudimentary host backup mechanism Shannon Sterz
` (8 preceding siblings ...)
2026-08-28 13:30 ` [PATCH manager 09/21] api: node: add endpoints for listing backups for a node Shannon Sterz
@ 2026-08-28 13:30 ` Shannon Sterz
2026-08-28 13:30 ` [PATCH manager 11/21] api: host backup: add warnings in case zfs snapdir is disabled Shannon Sterz
` (10 subsequent siblings)
20 siblings, 0 replies; 22+ messages in thread
From: Shannon Sterz @ 2026-08-28 13:30 UTC (permalink / raw)
To: pve-devel
this information is useful when trying to restore a backup via the
installer.
Signed-off-by: Shannon Sterz <s.sterz@proxmox.com>
---
Notes:
the restore could probably benefit from leveraging filters here for
network devices.
the bridge vmbr0 can be modified by users after install to depend on a
vlan or bond. the installer currently cannot handle that and guessing
which actual hardware device should be used in this situation is
difficult (e.g. which interface that makes up a bond should be used if
only one can be used for the bridge?).
such users are probably better off simply restoring the entire network
config at once or starting fresh. configuring only vmbr0 through the
installer and recovering all other settings via the restore mechanism
is likely to just cause confusion.
PVE/API2/HostBackup.pm | 15 ++
PVE/HostBackupTools.pm | 441 +++++++++++++++++++++++++++++++++++++++++
PVE/Makefile | 1 +
3 files changed, 457 insertions(+)
create mode 100644 PVE/HostBackupTools.pm
diff --git a/PVE/API2/HostBackup.pm b/PVE/API2/HostBackup.pm
index fe1263a85..8e8a87e10 100644
--- a/PVE/API2/HostBackup.pm
+++ b/PVE/API2/HostBackup.pm
@@ -12,6 +12,7 @@ use Time::HiRes qw(usleep);
use PVE::Cluster;
use PVE::Cmd qw(run);
use PVE::Exception qw(raise_param_exc);
+use PVE::HostBackupTools;
use PVE::INotify qw(nodename);
use PVE::JSONSchema qw(get_standard_option);
use PVE::PBSClient;
@@ -26,6 +27,19 @@ use base qw(PVE::RESTHandler);
my $host_backup_lock = "/run/pve/host-backup.lock";
+my sub collect_installer_info() {
+ my $disk_setup = PVE::HostBackupTools::get_root_fs_setup();
+
+ warn "could not get disk setup for the installer. restoring this backup "
+ . "requires manually specifying a disk setup.\n"
+ if !defined($disk_setup);
+
+ return {
+ 'global' => PVE::HostBackupTools::get_global_options(),
+ 'network' => PVE::HostBackupTools::get_network_config(),
+ 'disk-setup' => $disk_setup,
+ };
+}
my sub prepare_backup($backup_name) {
my $snap_cmd = undef;
@@ -123,6 +137,7 @@ my sub do_backup : prototype($$\@$$) ($pbs, $base_path, $files, $backup_name, $b
'automatically-installed' => \@auto_packages,
'manually-installed' => \@manual_packages,
},
+ 'installer-info' => collect_installer_info(),
};
my $os_release = undef;
diff --git a/PVE/HostBackupTools.pm b/PVE/HostBackupTools.pm
new file mode 100644
index 000000000..c51c78d9b
--- /dev/null
+++ b/PVE/HostBackupTools.pm
@@ -0,0 +1,441 @@
+package PVE::HostBackupTools;
+
+use v5.36;
+
+use Encode qw(decode);
+use JSON qw(decode_json);
+
+use PVE::Cluster qw (cfs_read_file);
+use PVE::DataCenterConfig; # so we can cfs-read datacenter.cfg
+use PVE::INotify;
+use PVE::Systemd;
+use PVE::Tools;
+use PVE::Cmd qw(run);
+use PVE::VZDump;
+
+my sub get_json_from_command($command) {
+
+ my $json = '';
+
+ run(
+ $command,
+ errmsg => "could not gather json information from command.",
+ outfunc => sub {
+ my ($line) = @_;
+ $json .= decode('UTF-8', $line) . "\n";
+ },
+ );
+
+ return decode_json($json);
+}
+
+my sub get_debconf_setting($package, $option) {
+ my $to_return = undef;
+
+ run(
+ ['debconf-show', $package],
+ errmsg => "error while trying to get debconf setting $package/$option",
+ outfunc => sub {
+ my ($line) = @_;
+ if ($line =~ m/^(?:\*|\s)\s$package\/$option: (.*)$/) {
+ $to_return = $1;
+ }
+ },
+ );
+
+ return $to_return;
+}
+
+my sub retrieve_zfs_config() {
+ # this only supports setups created through the pve installer.
+ # so hard coding pool names here is fine.
+ my $res = get_json_from_command(['zpool', 'list', '-v', '-j', '-o', 'name,ashift', 'rpool']);
+ my $rpool = $res->{pools}->{rpool};
+
+ my $raid_level = undef;
+ my @disks = qw();
+
+ for my $vdev_name (keys $rpool->{vdevs}->%*) {
+ my $vdev = $rpool->{vdevs}->{$vdev_name};
+
+ if ($vdev->{vdev_type} eq "disk") {
+ if (defined($raid_level) and $raid_level ne "raid0") {
+ # if we assigned a different raid type already, this layout is unsupported
+ return undef;
+ }
+
+ $raid_level = "raid0"; # single or stripped
+ push @disks, $vdev->{path};
+ } elsif ($vdev->{vdev_type} eq "mirror") {
+ if (!defined($raid_level)) {
+ $raid_level = "raid1";
+ } elsif ($raid_level eq "raid1") {
+ # raid10 has two top level mirrors
+ $raid_level = "raid10";
+ } else {
+ # more than two top level mirrors, not supported by the installer
+ return undef;
+ }
+
+ for my $disk (keys $vdev->{vdevs}->%*) {
+ my $disk = $vdev->{vdevs}->{$disk};
+ if ($disk->{vdev_type} eq "disk") {
+ push @disks, $disk->{path};
+ }
+ }
+ } elsif ($vdev->{vdev_type} eq "raidz" and $vdev_name =~ m/^raidz([123])-0$/) {
+ $raid_level = "raidz-$1";
+
+ for my $disk (keys $vdev->{vdevs}->%*) {
+ my $disk = $vdev->{vdevs}->{$disk};
+ if ($disk->{vdev_type} eq "disk") {
+ push @disks, $disk->{path};
+ }
+ }
+
+ # raidz only has one top level entry, so exit here
+ last;
+ }
+
+ # ignore other vdev types (draid, spares etc.), none of them can be
+ # created by the installer, so they don't matter for now. don't return
+ # either, users could have added hot-spares or similar and we don't want
+ # to report such setups as "unsupported"
+ }
+
+ # could not determine zfs layout, so it can't be re-created either
+ return undef if !defined($raid_level);
+
+ # check sufficient disks
+ if ($raid_level eq "raid0") {
+ return undef if scalar(@disks) < 1;
+ } elsif ($raid_level eq "raid1") {
+ return undef if scalar(@disks) < 2;
+ } elsif ($raid_level eq "raid10") {
+ return undef if scalar(@disks) < 4;
+ } elsif ($raid_level eq "raidz-1") {
+ return undef if scalar(@disks) < 3;
+ } elsif ($raid_level eq "raidz-2") {
+ return undef if scalar(@disks) < 4;
+ } elsif ($raid_level eq "raidz-3") {
+ return undef if scalar(@disks) < 5;
+ } else {
+ # unknown raid level
+ return undef;
+ }
+
+ # get remaining options only if we can use them with the installer
+ my $ashift = $rpool->{properties}->{ashift}->{value} + 0;
+
+ my $props = get_json_from_command([
+ 'zfs', 'get', 'copies,compression,checksum', 'rpool/ROOT/pve-1', '-j',
+ ]);
+ $props = $props->{datasets}->{"rpool/ROOT/pve-1"}->{properties};
+
+ my $compression = undef;
+ $compression = $props->{compression}->{value}
+ if grep(m/^$props->{compression}->{value}$/, qw(on off lzjb lz4 zle gzip zstd));
+
+ my $checksum = undef;
+ $checksum = $props->{checksum}->{value}
+ if grep(m/^$props->{checksum}->{value}$/, qw(on fletcher4 sha256));
+
+ my $copies = undef;
+ my $num_copies = int($props->{copies}->{value});
+ $copies = $num_copies if $num_copies >= 0 && $num_copies <= 3;
+
+ my $arc_tunables = {};
+
+ run(
+ ['zarcsummary', '-s', 'tunables', '-a'],
+ errmsg => "could not get arc tunables",
+ outfunc => sub {
+ my ($line) = @_;
+ if ($line =~ m/^\s+([^=\s]+)=(.*)$/) {
+ $arc_tunables->{$1} = $2;
+ }
+ },
+ );
+
+ return {
+ raid => $raid_level,
+ disks => \@disks,
+ ashift => $ashift,
+ 'arc-max' => int($arc_tunables->{zfs_arc_max} / (1024**2)), # arc-max is in MiB
+ checksum => $checksum,
+ compress => $compression,
+ copies => $copies,
+ };
+}
+
+my sub retrieve_btrfs_config() {
+ my $raid_level = undef;
+ my @disks = qw();
+
+ run(
+ ['btrfs', 'filesystem', 'usage', '/', '-T'],
+ errmsg => "could not get btrfs filesystem parameters",
+ outfunc => sub {
+ my ($line) = @_;
+ # parses a line like the below, the third column is the data raid level
+ # Id Path RAID10 RAID10 RAID10 Unallocated Total Slack
+ if ($line =~ m/^Id\s+Path\s+(\S+)\s+(?:\S+\s+){5}$/) {
+ $raid_level = $1;
+ }
+
+ # parses a line like the below, the second column is the device path
+ # 1 /dev/sda3 3.00GiB 768.00MiB 8.00MiB 27.74GiB 31.50GiB 3.50KiB
+ if ($line =~ m/^\s*[0-9]+\s(\S+)\s+(?:\S+\s*){6}$/) {
+ push @disks, $1;
+ }
+ },
+ );
+
+ # check that we got an appropriate amount of disks for the raid level
+ if ($raid_level eq "RAID0") {
+ return undef if scalar(@disks) < 1;
+ } elsif ($raid_level eq "RAID1") {
+ return undef if scalar(@disks) < 2;
+ } elsif ($raid_level eq "RAID10") {
+ return undef if scalar(@disks) < 4;
+ } else {
+ return undef;
+ }
+
+ my $content = PVE::Tools::file_get_contents('/etc/fstab');
+ my @lines = split(/\n/, $content);
+ my $compression = undef;
+
+ # parses the compression option for the root fs fstab line for btrfs
+ # the line looks like this:
+ # UUID=30551228-2953-4702-be88-fe045e98b15b / btrfs defaults,compress=zstd 0 1
+ for my $line (@lines) {
+ my @mnt_point = split(m/\s/, $line);
+
+ if (
+ $mnt_point[1] eq '/'
+ && $mnt_point[2] eq 'btrfs'
+ && $mnt_point[3] =~ m/^\S*compress(?:=([^\s,]+))?\S*$/
+ ) {
+ if (defined($1) && grep(m/^$1$/, qw(zlib lzo zstd))) {
+ $compression = $1;
+ } else {
+ $compression = 'on';
+ }
+
+ last;
+ }
+ }
+
+ return {
+ raid => lc($raid_level),
+ disks => \@disks,
+ compress => $compression // 'off',
+ };
+}
+
+my sub retrieve_lvm_config() {
+ my @disks = qw();
+ my $pvs = get_json_from_command(['pvs', '--reportformat', 'json_std']);
+
+ for my $pv (@{ $pvs->{report}->[0]->{pv} }) {
+ if ($pv->{vg_name} eq "pve") {
+ if ($pv->{pv_name} =~ m/(.*)/) { # untaint
+ push @disks, $1;
+ }
+ }
+ }
+
+ # for lvm setups the installer needs exactly one disk, so we couldn't find
+ # one or found too many, we can't recreate the current setup
+ return undef if scalar(@disks) != 1;
+
+ my $lvs = get_json_from_command(['lvs', 'pve', '--unit', 'G', '--reportformat', 'json_std']);
+ my $maxroot = undef;
+ my $swapsize = undef;
+ my $maxvz = undef;
+
+ for my $lv (@{ $lvs->{report}->[0]->{lv} }) {
+ if ($lv->{lv_name} eq 'root') {
+ $maxroot = substr($lv->{lv_size}, 0, -1) + 0;
+ } elsif ($lv->{lv_name} eq 'swap') {
+ $swapsize = substr($lv->{lv_size}, 0, -1) + 0;
+ } elsif ($lv->{lv_name} eq 'data') {
+ $maxvz = substr($lv->{lv_size}, 0, -1) + 0;
+ }
+ }
+
+ # could not find a root lv, not an expected pve layout
+ return undef if !defined($maxroot);
+
+ return {
+ disks => \@disks,
+ maxroot => $maxroot,
+ swapsize => $swapsize,
+ maxvz => $maxvz,
+ };
+}
+
+=head3 get_global_options()
+
+Returns options compatible with the C<global> section of the installer (country,
+fqdn, keyboard etc.).
+
+=cut
+
+sub get_global_options() {
+ my $config = {};
+
+ if (my $country = get_debconf_setting("pve-manager", "country")) {
+ $config->{country} = lc($country);
+ }
+
+ $config->{fqdn} = PVE::Tools::get_fqdn(PVE::INotify::nodename());
+
+ my $dc_conf = PVE::Cluster::cfs_read_file('datacenter.cfg');
+
+ if (my $keyboard = $dc_conf->{keyboard}) {
+ my $kb = lc($keyboard);
+
+ # some keymaps are stored differently in the datacenter config from how
+ # the installer expects them, normalize them.
+ my $normalize_keymap = {
+ "da" => "dk", # danish
+ "ja" => "jp", # japanese
+ "sv" => "se", # swedish
+ "sl" => "si", # slovenian
+ };
+
+ $config->{keyboard} = $normalize_keymap->{$kb} // $kb;
+ }
+
+ my $usercfg = cfs_read_file("user.cfg");
+
+ if (my $mailto = $usercfg->{users}->{'root@pam'}->{email}) {
+ $config->{mailto} = $mailto;
+ }
+
+ $config->{timezone} = PVE::Systemd::get_timezone();
+
+ return $config;
+}
+
+=head get_network_config()
+
+Returns the configuration for C<vmbr0> if we can get one that is compatible with
+the installer. Otherwise, C<undef> is returned.
+
+=cut
+
+sub get_network_config() {
+ my $data = PVE::INotify::read_file('interfaces');
+ my $vmbr0_conf = $data->{ifaces}->{vmbr0};
+
+ return undef if !defined($vmbr0_conf);
+
+ if ($vmbr0_conf->{method} eq 'static') {
+ my $resolve_conf = PVE::INotify::read_file('resolvconf');
+
+ return {
+ source => "from-answer",
+ cidr => $vmbr0_conf->{cidr},
+ dns => $resolve_conf->{dns1},
+ gateway => $vmbr0_conf->{gateway},
+ };
+ } elsif ($vmbr0_conf->{method} eq 'dhcp') {
+ return {
+ source => "from-dhcp",
+ };
+ }
+
+ return undef;
+}
+
+=head3 get_root_fs_setup()
+
+Tries to guess the root disk setup by querying the system via various commands.
+Only setups created through the Proxmox Installer are supported. The returned
+format is compatible with the installer and can be used to re-create the same
+layout.
+
+Returns C<undef> if the root disk layout could not be determined.
+
+=cut
+
+sub get_root_fs_setup() {
+ my $fs = PVE::VZDump::get_mount_info("/");
+ my $fs_type = $fs->{fstype};
+ my $config_key = undef;
+ my $config = undef;
+
+ if ($fs_type eq "zfs") {
+ $config_key = "zfs";
+ $config = retrieve_zfs_config();
+ } elsif ($fs_type eq "ext4" or $fs_type eq "xfs") {
+ $config_key = "lvm";
+ $config = retrieve_lvm_config();
+ } elsif ($fs_type eq "btrfs") {
+ $config_key = "btrfs";
+ $config = retrieve_btrfs_config();
+ }
+
+ return undef if !defined($config);
+
+ # disks are partitions at this point, they will now be normalized
+ # toward their parent disk. hdsize is then calculate by finding the minimum
+ # of the sum of the first three partitions amongst all disks.
+
+ my $hdsize = undef;
+ my @normalized_disks = qw();
+
+ for my $disk (@{ $config->{disks} }) {
+ my $devices = get_json_from_command(['lsblk', $disk, '-o', 'PKNAME', '-J']);
+ my $parent = $devices->{blockdevices}->[0]->{pkname};
+
+ $devices = get_json_from_command([
+ 'lsblk',
+ '-Q',
+ 'NAME =~ "' . $parent . '"',
+ '-o',
+ 'SIZE,PARTN,ID-LINK',
+ '-J',
+ '--bytes',
+ ]);
+
+ my $current_hdsize = 0;
+ my @devs = @{ $devices->{blockdevices} };
+
+ for my $device (@devs) {
+ # use the id link of a disk to identify it
+ if (!defined($device->{partn})) {
+ push @normalized_disks, "/dev/disk/by-id/$device->{'id-link'}";
+ }
+
+ # special case: only three partitions (+1 for the actual disk) are
+ # returned, use whole disk. otherwise, potential padding could
+ # prevent reproducing the desired layout.
+ if (!defined($device->{partn}) and scalar(@devs) == 4) {
+ $current_hdsize = $device->{size};
+ last;
+ } elsif (defined($device->{partn}) and $device->{partn} <= 3) {
+ $current_hdsize += $device->{size};
+ }
+ }
+
+ if (!defined($hdsize) || $current_hdsize < $hdsize) {
+ $hdsize = $current_hdsize;
+ }
+ }
+
+ # hdsize is a float of the size of disk space to use in GiB
+ $config->{hdsize} = $hdsize / (1024**3) if defined($hdsize);
+ delete $config->{disks};
+
+ return {
+ filesystem => $fs_type,
+ 'disk-list' => \@normalized_disks,
+ $config_key => $config,
+ };
+}
+
+1;
diff --git a/PVE/Makefile b/PVE/Makefile
index efcb250d0..25c1a38f3 100644
--- a/PVE/Makefile
+++ b/PVE/Makefile
@@ -10,6 +10,7 @@ PERLSOURCE = \
CertCache.pm \
CertHelpers.pm \
ExtMetric.pm \
+ HostBackupTools.pm \
HTTPServer.pm \
Jobs.pm \
NodeConfig.pm \
--
2.47.3
^ permalink raw reply related [flat|nested] 22+ messages in thread* [PATCH manager 11/21] api: host backup: add warnings in case zfs snapdir is disabled
2026-08-28 13:30 [RFC cluster/common/container/docs/installer/manager 00/21] add rudimentary host backup mechanism Shannon Sterz
` (9 preceding siblings ...)
2026-08-28 13:30 ` [PATCH manager 10/21] api: host backup: include global, disk and network options for restore Shannon Sterz
@ 2026-08-28 13:30 ` Shannon Sterz
2026-08-28 13:30 ` [PATCH manager 12/21] ui: node: add panel to manage backups of a host Shannon Sterz
` (9 subsequent siblings)
20 siblings, 0 replies; 22+ messages in thread
From: Shannon Sterz @ 2026-08-28 13:30 UTC (permalink / raw)
To: pve-devel
queries the zfs property `snapdir` and adds a warning to the logs that
backup consistency can be improved if users enable it. note that this
is mostly an additional measure, the default is 'hidden' which means
that the snapshot directory is enabled but hidden.
Signed-off-by: Shannon Sterz <s.sterz@proxmox.com>
---
PVE/API2/HostBackup.pm | 24 ++++++++++++++++++++----
PVE/HostBackupTools.pm | 13 ++++++++++++-
2 files changed, 32 insertions(+), 5 deletions(-)
diff --git a/PVE/API2/HostBackup.pm b/PVE/API2/HostBackup.pm
index 8e8a87e10..60b07773b 100644
--- a/PVE/API2/HostBackup.pm
+++ b/PVE/API2/HostBackup.pm
@@ -47,12 +47,27 @@ my sub prepare_backup($backup_name) {
if (my $mntinfo = PVE::VZDump::get_mount_info("/")) {
if ($mntinfo->{fstype} eq "zfs") {
- $snap_cmd = ['zfs', 'snapshot', $mntinfo->{device} . '@' . $backup_name];
-
# snapshots are mounted under `/.zfs/snapshots` unless snapdir is set to "disabled".
# the default is "hidden".
# https://openzfs.github.io/openzfs-docs/man/v2.4/7/zfsprops.7.html#snapdir
- $base_path = "/.zfs/snapshot/$backup_name/";
+ my $output = PVE::HostBackupTools::get_json_from_command([
+ "zfs", "get", "snapdir", "rpool/ROOT/pve-1", "-j",
+ ]);
+
+ my $snapdir_opt =
+ $output->{datasets}->{"rpool/ROOT/pve-1"}->{properties}->{snapdir}->{value};
+
+ warn "could not determine if zfs supports mounted root fs snapshots, continuing on a"
+ . " best effort basis.\n"
+ if !defined($snapdir_opt);
+
+ if ($snapdir_opt eq "disabled") {
+ warn "zfs property 'snapdir' is disabled. please enable it or set it to 'hidden'"
+ . " to allow for more consistent host backups!\n";
+ } elsif (-d "/.zfs/snapshot/") {
+ $snap_cmd = ['zfs', 'snapshot', $mntinfo->{device} . '@' . $backup_name];
+ $base_path = "/.zfs/snapshot/$backup_name/";
+ }
} elsif ($mntinfo->{fstype} eq "btrfs") {
# check if snapshots directory exists, if not create one
mkdir "/.snapshots"
@@ -171,7 +186,8 @@ my sub cleanup_backup($backup_name, $backup_target) {
if (my $mntinfo = PVE::VZDump::get_mount_info("/")) {
if ($mntinfo->{fstype} eq "zfs") {
- $snap_cmd = ['zfs', 'destroy', $mntinfo->{device} . '@' . $backup_name];
+ $snap_cmd = ['zfs', 'destroy', $mntinfo->{device} . '@' . $backup_name]
+ if -d "/.zfs/snapshot/";
} elsif ($mntinfo->{fstype} eq "btrfs") {
$snap_cmd = [
'btrfs', '-q', 'subvolume', 'delete', '--', '/.snapshots/root@' . $backup_name,
diff --git a/PVE/HostBackupTools.pm b/PVE/HostBackupTools.pm
index c51c78d9b..9ac143f8b 100644
--- a/PVE/HostBackupTools.pm
+++ b/PVE/HostBackupTools.pm
@@ -13,8 +13,19 @@ use PVE::Tools;
use PVE::Cmd qw(run);
use PVE::VZDump;
-my sub get_json_from_command($command) {
+=head3 get_json_from_command($command)
+Executes the provided C<command> using C<PVE::Cmd::run> and assumes that the
+output is valid JSON in its entirety. Output can be split across lines, but may
+not be interrupted by other text or contain more than one valid JSON array
+or object. The caller is required to make sure that the command's output conforms
+to these restrictions.
+
+Returns a parsed hash or array of the JSON output of the command.
+
+=cut
+
+sub get_json_from_command($command) {
my $json = '';
run(
--
2.47.3
^ permalink raw reply related [flat|nested] 22+ messages in thread* [PATCH manager 12/21] ui: node: add panel to manage backups of a host
2026-08-28 13:30 [RFC cluster/common/container/docs/installer/manager 00/21] add rudimentary host backup mechanism Shannon Sterz
` (10 preceding siblings ...)
2026-08-28 13:30 ` [PATCH manager 11/21] api: host backup: add warnings in case zfs snapdir is disabled Shannon Sterz
@ 2026-08-28 13:30 ` Shannon Sterz
2026-08-28 13:30 ` [PATCH manager 13/21] ui: dc: add panel for managing host backup jobs Shannon Sterz
` (8 subsequent siblings)
20 siblings, 0 replies; 22+ messages in thread
From: Shannon Sterz @ 2026-08-28 13:30 UTC (permalink / raw)
To: pve-devel
allows creating, editing the notes of, changing the protection status
of, file restoring and removing backups of a single pve node.
Signed-off-by: Shannon Sterz <s.sterz@proxmox.com>
---
www/manager6/Makefile | 1 +
www/manager6/node/Config.js | 7 +
www/manager6/node/HostBackup.js | 292 ++++++++++++++++++++++++++++++++
3 files changed, 300 insertions(+)
create mode 100644 www/manager6/node/HostBackup.js
diff --git a/www/manager6/Makefile b/www/manager6/Makefile
index d2ea786be..37f29747d 100644
--- a/www/manager6/Makefile
+++ b/www/manager6/Makefile
@@ -244,6 +244,7 @@ JSSRC= \
node/CmdMenu.js \
node/Config.js \
node/Directory.js \
+ node/HostBackup.js \
node/LVM.js \
node/LVMThin.js \
node/StatusView.js \
diff --git a/www/manager6/node/Config.js b/www/manager6/node/Config.js
index 217ee284c..385b9c6fd 100644
--- a/www/manager6/node/Config.js
+++ b/www/manager6/node/Config.js
@@ -169,6 +169,13 @@ Ext.define('PVE.node.Config', {
if (caps.nodes['Sys.Audit']) {
me.items.push(
+ {
+ xtype: 'pveHostBackup',
+ title: gettext('Host Backup'),
+ iconCls: 'fa fa-floppy-o',
+ itemId: 'nodebackup',
+ nodename: nodename,
+ },
{
xtype: 'proxmoxNodeServiceView',
title: gettext('System'),
diff --git a/www/manager6/node/HostBackup.js b/www/manager6/node/HostBackup.js
new file mode 100644
index 000000000..dcecd873d
--- /dev/null
+++ b/www/manager6/node/HostBackup.js
@@ -0,0 +1,292 @@
+Ext.define('PVE.node.AddBackupPanel', {
+ extend: 'Proxmox.window.Edit',
+ mixins: ['Proxmox.Mixin.CBind'],
+
+ subject: gettext('Host Backup'),
+
+ method: 'POST',
+ showTaskViewer: true,
+ nodename: undefined,
+
+ config: {
+ entry: null,
+ },
+
+ items: [
+ {
+ fieldLabel: gettext('Storage'),
+ name: 'storage',
+ xtype: 'pveStorageSelector',
+ reference: 'backupStorageSelector',
+ storageContent: 'backup',
+ allowBlank: false,
+ cbind: {
+ nodename: '{nodename}',
+ },
+ },
+ ],
+
+ initComponent: function () {
+ let me = this;
+ me.url = `/nodes/${me.nodename}/host-backup`;
+ me.callParent();
+
+ me.lookup('backupStorageSelector')
+ .getStore()
+ .addFilter({ filterFn: (item) => item.data.type === 'pbs' });
+
+ me.setValues(me.getEntry());
+ },
+});
+
+Ext.define('PVE.node.HostBackup', {
+ extend: 'Ext.grid.Panel',
+ alias: ['widget.pveHostBackup'],
+ mixins: ['Proxmox.Mixin.CBind'],
+
+ // onlineHelp: 'chapter_vzdump',
+
+ nodename: undefined,
+ stateful: true,
+ stateId: 'grid-node-backup',
+
+ store: {
+ model: 'pve-storage-content',
+ proxy: {
+ type: 'proxmox',
+ },
+ sorters: [
+ {
+ property: 'vdate',
+ direction: 'DESC',
+ },
+ ],
+ },
+
+ tbar: [
+ {
+ text: gettext('Backup Now'),
+ xtype: 'button',
+ handler: 'createBackupHandler',
+ },
+ '-',
+ {
+ text: gettext('File Restore'),
+ xtype: 'proxmoxButton',
+ handler: 'fileRestoreHandler',
+ disabled: true,
+ },
+ {
+ text: gettext('Edit Notes'),
+ xtype: 'proxmoxButton',
+ handler: 'editNotesHandler',
+ disabled: true,
+ },
+ {
+ text: gettext('Change Protection'),
+ xtype: 'proxmoxButton',
+ handler: 'changeProtectionHandler',
+ disabled: true,
+ },
+ '-',
+ {
+ text: gettext('Remove'),
+ xtype: 'proxmoxStdRemoveButton',
+ dangerous: true,
+ customConfirmationMessage: gettext(
+ 'Are you sure you want to remove {0}. This will permanently erase it.',
+ ),
+ getRecordName: (rec) => rec.data.volid,
+ callback: function () {
+ this.up('grid').getStore().load();
+ },
+ getUrl: function (rec) {
+ let grid = this.up('grid');
+ let nodename = grid.nodename;
+ let storage = grid.lookup('backupStorageSelector').getValue();
+ let volid = rec.data.volid;
+ return `/nodes/${nodename}/storage/${storage}/content/${volid}`;
+ },
+ },
+ '->',
+ {
+ fieldLabel: gettext('Storage'),
+ xtype: 'pveStorageSelector',
+ reference: 'backupStorageSelector',
+ labelAlign: 'right',
+ storageContent: 'backup',
+ allowBlank: false,
+ listeners: {
+ change: 'changeStorageHandler',
+ },
+ cbind: {
+ nodename: '{nodename}',
+ },
+ },
+ ],
+
+ listeners: {
+ itemdblclick: 'dblclickHandler',
+ },
+
+ columns: [
+ {
+ header: gettext('Name'),
+ flex: 2,
+ sortable: true,
+ renderer: PVE.Utils.render_storage_content,
+ dataIndex: 'volid',
+ },
+ {
+ header: gettext('Notes'),
+ dataIndex: 'notes',
+ flex: 1,
+ renderer: Ext.htmlEncode,
+ },
+ {
+ header: `<i class="fa fa-shield"></i>`,
+ tooltip: gettext('Protected'),
+ width: 30,
+ renderer: (v) =>
+ v ? `<i data-qtip="${gettext('Protected')}" class="fa fa-shield"></i>` : '',
+ sorter: (a, b) => (b.data.protected || 0) - (a.data.protected || 0),
+ dataIndex: 'protected',
+ },
+ {
+ header: gettext('Date'),
+ width: 150,
+ dataIndex: 'vdate',
+ },
+ {
+ header: gettext('Format'),
+ width: 100,
+ dataIndex: 'format',
+ },
+ {
+ header: gettext('Size'),
+ width: 100,
+ renderer: Proxmox.Utils.format_size,
+ dataIndex: 'size',
+ },
+ {
+ header: gettext('Encrypted'),
+ dataIndex: 'encrypted',
+ renderer: PVE.Utils.render_backup_encryption,
+ },
+ {
+ // TRANSLATORS: The state of the verification task
+ header: gettext('Verify State'),
+ dataIndex: 'verification',
+ renderer: PVE.Utils.render_backup_verification,
+ },
+ ],
+
+ controller: {
+ dblclickHandler: (element, rec) => {
+ let storage = element.up('grid').lookup('backupStorageSelector').getValue();
+
+ Ext.create('Proxmox.window.FileBrowser', {
+ title: gettext('File Restore') + ' - ' + rec.data.text,
+ autoShow: true,
+ listURL: `/api2/json/nodes/localhost/storage/${storage}/file-restore/list`,
+ downloadURL: `/api2/json/nodes/localhost/storage/${storage}/file-restore/download`,
+ extraParams: {
+ volume: rec.data.volid,
+ },
+ });
+ },
+ createBackupHandler: (button, _event) => {
+ let grid = button.up('grid');
+
+ Ext.create('PVE.node.AddBackupPanel', {
+ autoShow: true,
+ isCreate: true,
+ nodename: grid.nodename,
+ listeners: {
+ close: () => grid.getStore().load(),
+ },
+ });
+ },
+ fileRestoreHandler: function (button, _event, rec) {
+ if (!rec) {
+ console.warn('No host backup selected!');
+ } else {
+ this.dblclickHandler(button, rec);
+ }
+ },
+ editNotesHandler: (button, _event, rec) => {
+ if (!rec) {
+ console.warn('No host backup selected!');
+ return;
+ }
+
+ let grid = button.up('grid');
+ let nodename = grid.nodename;
+ let storage = grid.lookup('backupStorageSelector').getValue();
+ let volid = rec.data.volid;
+
+ Ext.create('Proxmox.window.Edit', {
+ title: gettext('Notes'),
+ autoShow: true,
+ width: 600,
+ height: 400,
+ layout: 'fit',
+ autoLoad: true,
+ resizable: true,
+ url: `/api2/extjs/nodes/${nodename}/storage/${storage}/content/${volid}`,
+ items: [
+ {
+ xtype: 'textarea',
+ layout: 'fit',
+ name: 'notes',
+ height: '100%',
+ },
+ ],
+ listeners: {
+ close: () => grid.getStore().load(),
+ },
+ });
+ },
+ changeProtectionHandler: (button, _event, rec) => {
+ if (!rec) {
+ console.warn('No host backup selected!');
+ return;
+ }
+
+ let grid = button.up('grid');
+ let nodename = grid.nodename;
+ let storage = grid.lookup('backupStorageSelector').getValue();
+ let volid = rec.data.volid;
+
+ Proxmox.Utils.API2Request({
+ url: `/api2/extjs/nodes/${nodename}/storage/${storage}/content/${volid}`,
+ method: 'PUT',
+ waitMsgTarget: grid,
+ params: {
+ protected: rec.data.protected ? 0 : 1,
+ },
+ failure: (response) => Ext.Msg.alert('Error', response.htmlStatus),
+ success: () => grid.getStore().load(),
+ });
+ },
+ changeStorageHandler: (selector, value) => {
+ let store = selector.up('grid').getStore();
+ let url = `/api2/json/nodes/${selector.nodename}/host-backup?storage=${value}`;
+
+ store.getProxy().setUrl(url);
+ store.load();
+ },
+ },
+
+ initComponent: function () {
+ let me = this;
+ me.callParent();
+ Proxmox.Utils.monStoreErrors(me.view, me.store, true);
+
+ // filter out non-pbs storages; the store only exist after
+ // `initComponent` of the selector
+ me.lookup('backupStorageSelector')
+ .getStore()
+ .addFilter({ filterFn: (item) => item.data.type === 'pbs' });
+ },
+});
--
2.47.3
^ permalink raw reply related [flat|nested] 22+ messages in thread* [PATCH manager 13/21] ui: dc: add panel for managing host backup jobs
2026-08-28 13:30 [RFC cluster/common/container/docs/installer/manager 00/21] add rudimentary host backup mechanism Shannon Sterz
` (11 preceding siblings ...)
2026-08-28 13:30 ` [PATCH manager 12/21] ui: node: add panel to manage backups of a host Shannon Sterz
@ 2026-08-28 13:30 ` Shannon Sterz
2026-08-28 13:30 ` [PATCH installer 14/21] bump proxmox-installer-types to 0.2 Shannon Sterz
` (7 subsequent siblings)
20 siblings, 0 replies; 22+ messages in thread
From: Shannon Sterz @ 2026-08-28 13:30 UTC (permalink / raw)
To: pve-devel
host backup jobs can be created, edited, removed and run. currently
the parameters "additional-files" and "hooks" are not exposed through
the ui.
also adds task description for host backup jobs.
Signed-off-by: Shannon Sterz <s.sterz@proxmox.com>
---
www/manager6/Makefile | 1 +
www/manager6/Utils.js | 1 +
www/manager6/dc/Config.js | 6 +
www/manager6/dc/HostBackupJobs.js | 360 ++++++++++++++++++++++++++++++
4 files changed, 368 insertions(+)
create mode 100644 www/manager6/dc/HostBackupJobs.js
diff --git a/www/manager6/Makefile b/www/manager6/Makefile
index 37f29747d..fd41c0e81 100644
--- a/www/manager6/Makefile
+++ b/www/manager6/Makefile
@@ -183,6 +183,7 @@ JSSRC= \
dc/GroupView.js \
dc/Guests.js \
dc/Health.js \
+ dc/HostBackupJobs.js \
dc/Log.js \
dc/NodeView.js \
dc/OptionView.js \
diff --git a/www/manager6/Utils.js b/www/manager6/Utils.js
index c86a00c5e..46dbb9257 100644
--- a/www/manager6/Utils.js
+++ b/www/manager6/Utils.js
@@ -2176,6 +2176,7 @@ Ext.define('PVE.Utils', {
hashutdown: ['HA', gettext('Shutdown')],
hastart: ['HA', gettext('Start')],
hastop: ['HA', gettext('Stop')],
+ 'host-backup': ['', gettext('Host Backup')],
imgcopy: ['', gettext('Copy data')],
imgdel: ['', gettext('Erase data')],
lvmcreate: [gettext('LVM Storage'), gettext('Create')],
diff --git a/www/manager6/dc/Config.js b/www/manager6/dc/Config.js
index 83c48c3d6..d40256632 100644
--- a/www/manager6/dc/Config.js
+++ b/www/manager6/dc/Config.js
@@ -140,6 +140,12 @@ Ext.define('PVE.dc.Config', {
title: gettext('Backup'),
itemId: 'backup',
},
+ {
+ xtype: 'pveDcHostBackup',
+ iconCls: 'fa fa-clock-o',
+ title: gettext('Host Backup Jobs'),
+ itemId: 'hostBackup',
+ },
{
xtype: 'pveReplicaView',
iconCls: 'fa fa-retweet',
diff --git a/www/manager6/dc/HostBackupJobs.js b/www/manager6/dc/HostBackupJobs.js
new file mode 100644
index 000000000..2576c9e9e
--- /dev/null
+++ b/www/manager6/dc/HostBackupJobs.js
@@ -0,0 +1,360 @@
+Ext.define('PVE.dc.AddHostBackupJob', {
+ extend: 'Proxmox.window.Edit',
+ mixins: ['Proxmox.Mixin.CBind'],
+
+ subject: gettext('Host Backup Job'),
+
+ showTaskViewer: true,
+ jobid: undefined,
+
+ url: '/api2/extjs/cluster/jobs/host-backup',
+ method: 'POST',
+ isCreate: true,
+
+ cbindData: function () {
+ let me = this;
+
+ if (me.jobid) {
+ me.isCreate = false;
+ me.method = 'PUT';
+ me.url += `/${me.jobid}`;
+ }
+
+ return {};
+ },
+
+ items: [
+ {
+ xtype: 'inputpanel',
+ column1: [
+ {
+ xtype: 'pveCalendarEvent',
+ fieldLabel: gettext('Schedule'),
+ allowBlank: false,
+ name: 'schedule',
+ },
+ {
+ xtype: 'pveNodeSelector',
+ name: 'nodes',
+ fieldLabel: gettext('Nodes'),
+ multiSelect: true,
+ autoSelect: false,
+ emptyText: `-- ${gettext('All')} --`,
+ listeners: {
+ change: 'nodeChange',
+ },
+ },
+ {
+ xtype: 'proxmoxcheckbox',
+ fieldLabel: gettext('Enable'),
+ name: 'enabled',
+ uncheckedValue: 0,
+ defaultValue: 1,
+ checked: true,
+ },
+ ],
+ column2: [
+ {
+ xtype: 'proxmoxcheckbox',
+ fieldLabel: gettext('Repeat Missed'),
+ name: 'repeat-missed',
+ uncheckedValue: 0,
+ defaultValue: 0,
+ checked: false,
+ },
+ {
+ fieldLabel: gettext('Storage'),
+ name: 'storage',
+ xtype: 'pveStorageSelector',
+ reference: 'backupStorageSelector',
+ clusterView: true,
+ storageContent: 'backup',
+ allowBlank: false,
+ autoSelect: false,
+ },
+ ],
+ columnB: [
+ {
+ xtype: 'proxmoxtextfield',
+ name: 'comment',
+ fieldLabel: gettext('Job Comment'),
+ cbind: {
+ deleteEmpty: '{!isCreate}',
+ },
+ autoEl: {
+ tag: 'div',
+ 'data-qtip': gettext('Description of the job.'),
+ },
+ },
+ // TODO: add custom files
+ // TODO: maybe hook scripts?
+ ],
+ },
+ ],
+
+ controller: {
+ nodeChange: (element, value) => {
+ let view = element.up('window');
+ let store = view.lookup('backupStorageSelector').getStore();
+ let id = 'nodeFilter';
+ store.removeFilter(id);
+
+ if (value.length !== 0) {
+ store.addFilter({
+ id,
+ filterFn: (item) => {
+ if (!item.data.nodes) {
+ // the storage is valid for all nodes
+ return true;
+ }
+
+ let storageNodes = item.data.nodes.split(',');
+ return value.every((n) => storageNodes.includes(n));
+ },
+ });
+ } else {
+ store.addFilter({
+ id,
+ // if no nodes are selected the job runs on all nodes, so
+ // the storage also needs to be available on all nodes. a
+ // storage is available on all nodes if the `nodes`
+ // property is not set.
+ filterFn: (item) => !item.data.nodes,
+ });
+ }
+ },
+ init: function (view) {
+ let me = this;
+
+ let store = me.lookup('backupStorageSelector').getStore();
+ store.addFilter({ filterFn: (item) => item.data.type === 'pbs' });
+ store.addFilter({ id: 'nodeFilter', filterFn: (item) => !item.data.nodes });
+
+ if (!view.isCreate) {
+ view.load({
+ success: function (response, _options) {
+ view.setValues(response.result.data);
+ },
+ });
+ }
+ },
+ },
+});
+
+Ext.define('PVE.dc.HostBackup', {
+ extend: 'Ext.grid.GridPanel',
+ alias: ['widget.pveDcHostBackup'],
+ mixins: ['Proxmox.Mixin.CBind'],
+
+ //onlineHelp: 'chapter_vzdump',
+
+ stateful: true,
+ stateId: 'grid-dc-node-backup',
+
+ store: {
+ model: '',
+ proxy: {
+ type: 'proxmox',
+ url: '/api2/json/cluster/jobs/host-backup',
+ },
+ },
+
+ tbar: [
+ {
+ text: gettext('Add'),
+ xtype: 'button',
+ handler: 'createBackupJobHandler',
+ },
+ '-',
+ {
+ text: gettext('Remove'),
+ xtype: 'proxmoxStdRemoveButton',
+ dangerous: true,
+ customConfirmationMessage: gettext(
+ 'Are you sure you want to remove the job {0}. This will remove it.',
+ ),
+ getRecordName: (rec) => rec.data.id,
+ callback: function () {
+ this.up('grid').getStore().load();
+ },
+ getUrl: (rec) => `/cluster/jobs/host-backup/${rec.data.id}`,
+ },
+ {
+ text: gettext('Edit'),
+ xtype: 'proxmoxButton',
+ handler: 'editNotesHandler',
+ disabled: true,
+ },
+ '-',
+ {
+ text: gettext('Run now'),
+ xtype: 'proxmoxButton',
+ handler: 'runNowHandler',
+ disabled: true,
+ },
+ ],
+
+ listeners: {
+ itemdblclick: 'dblclickHandler',
+ },
+
+ columns: [
+ {
+ header: gettext('Enabled'),
+ width: 80,
+ dataIndex: 'enabled',
+ align: 'center',
+ renderer: Proxmox.Utils.renderEnabledIcon,
+ sortable: true,
+ },
+ {
+ header: gettext('ID'),
+ dataIndex: 'id',
+ hidden: true,
+ },
+ {
+ header: gettext('Nodes'),
+ width: 100,
+ sortable: true,
+ dataIndex: 'nodes',
+ renderer: (value) => value ?? `-- ${gettext('All')} --`,
+ },
+ {
+ header: gettext('Schedule'),
+ width: 150,
+ dataIndex: 'schedule',
+ },
+ {
+ text: gettext('Next Run'),
+ dataIndex: 'next-run',
+ width: 150,
+ renderer: PVE.Utils.render_next_event,
+ },
+ {
+ header: gettext('Storage'),
+ width: 100,
+ sortable: true,
+ dataIndex: 'storage',
+ },
+ {
+ header: gettext('Comment'),
+ dataIndex: 'comment',
+ renderer: Ext.htmlEncode,
+ sorter: (a, b) => (a.data.comment || '').localeCompare(b.data.comment || ''),
+ flex: 1,
+ },
+ ],
+
+ controller: {
+ createBackupJobHandler: (button, _event) => {
+ let grid = button.up('grid');
+ Ext.create('PVE.dc.AddHostBackupJob', {
+ isCreate: true,
+ autoShow: true,
+ listeners: { close: () => grid.getStore().load() },
+ });
+ },
+ editNotesHandler: function (button, _event, rec) {
+ if (!rec) {
+ console.warn('No host backup selected!');
+ } else {
+ this.dblclickHandler(button, rec);
+ }
+ },
+ runNowHandler: (button, _event, rec) => {
+ Ext.Msg.show({
+ title: gettext('Confirm'),
+ icon: Ext.Msg.QUESTION,
+ msg: gettext('Start the selected backup job now?'),
+ buttons: Ext.Msg.YESNO,
+ callback: function (btn) {
+ if (btn !== 'yes') {
+ return;
+ }
+
+ let params = Ext.clone(rec.data);
+ let paramNodes = params.nodes?.split(',') ?? false;
+
+ delete params.comment;
+ delete params.enabled;
+ delete params.id;
+ delete params.nodes;
+ delete params.schedule;
+ delete params.type;
+ delete params['next-run'];
+ delete params['repeat-missed'];
+
+ let errors = [];
+ let allNodes = PVE.data.ResourceStore.getNodes();
+
+ let nodes = allNodes
+ .filter((n) => !paramNodes || paramNodes.includes(n.node))
+ .filter((n) => {
+ if (n.status === 'online') {
+ return true;
+ }
+
+ errors.push(`${n.node}: ${gettext('Node is offline')}`);
+ return false;
+ })
+ .map((n) => n.node);
+
+ Ext.Msg.show({
+ title: gettext('Please wait...'),
+ closable: false,
+ progress: true,
+ progressText: '0/' + nodes.length,
+ });
+
+ let started = 0;
+
+ let postRequest = function () {
+ started++;
+
+ Ext.Msg.updateProgress(
+ started / nodes.length,
+ `${started}/${nodes.length}`,
+ );
+
+ if (started === nodes.length) {
+ Ext.Msg.hide();
+ if (errors.length > 0) {
+ Ext.Msg.alert(
+ 'Error',
+ `${gettext('Some errors occurred:')}<br /> ${errors.join('<br />')}`,
+ );
+ }
+ }
+ };
+
+ nodes.forEach((node) => {
+ Proxmox.Utils.API2Request({
+ url: `/nodes/${node}/host-backup`,
+ method: 'POST',
+ params,
+ failure: (response, _opts) => {
+ errors.push(`${node}: ${response.htmlStatus}`);
+ postRequest();
+ },
+ success: postRequest,
+ });
+ });
+ },
+ });
+ },
+ dblclickHandler: (element, rec) => {
+ Ext.create('PVE.dc.AddHostBackupJob', {
+ autoShow: true,
+ jobid: rec.data.id,
+ listeners: { close: () => element.up('grid').getStore().load() },
+ });
+ },
+ },
+
+ initComponent: function () {
+ let me = this;
+ me.callParent();
+ Proxmox.Utils.monStoreErrors(me.view, me.store, true);
+ me.store.load();
+ },
+});
--
2.47.3
^ permalink raw reply related [flat|nested] 22+ messages in thread* [PATCH installer 14/21] bump proxmox-installer-types to 0.2
2026-08-28 13:30 [RFC cluster/common/container/docs/installer/manager 00/21] add rudimentary host backup mechanism Shannon Sterz
` (12 preceding siblings ...)
2026-08-28 13:30 ` [PATCH manager 13/21] ui: dc: add panel for managing host backup jobs Shannon Sterz
@ 2026-08-28 13:30 ` Shannon Sterz
2026-08-28 13:30 ` [PATCH installer 15/21] make tidy and clean up whitespace in unconfigured.sh Shannon Sterz
` (6 subsequent siblings)
20 siblings, 0 replies; 22+ messages in thread
From: Shannon Sterz @ 2026-08-28 13:30 UTC (permalink / raw)
To: pve-devel
and adapt to breaking changes
Signed-off-by: Shannon Sterz <s.sterz@proxmox.com>
---
Notes:
mainly a stop gap so this can be applied an build, the proper fix
would be to implement `AsRef` on the types here, see:
https://lore.proxmox.com/pve-devel/20260817125907.878237-1-c.heiss@proxmox.com/
Cargo.toml | 2 +-
debian/control | 2 +-
proxmox-auto-installer/src/utils.rs | 2 +-
proxmox-post-hook/src/main.rs | 2 +-
4 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/Cargo.toml b/Cargo.toml
index c33219c..335fb58 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -28,7 +28,7 @@ toml = "0.8"
proxmox-auto-installer.path = "./proxmox-auto-installer"
proxmox-installer-common.path = "./proxmox-installer-common"
proxmox-network-types = "1.1"
-proxmox-installer-types = { version = "0.1.1", features = ["legacy"] }
+proxmox-installer-types = { version = "0.2", features = ["legacy"] }
# Local path overrides
# NOTE: You must run `cargo update` after changing this for it to take effect!
diff --git a/debian/control b/debian/control
index 7565c2a..3512404 100644
--- a/debian/control
+++ b/debian/control
@@ -19,7 +19,7 @@ Build-Depends: cargo:native,
librust-native-tls-dev,
librust-pico-args-0.5-dev,
librust-pretty-assertions-1.4-dev,
- librust-proxmox-installer-types-0.1+legacy-dev (>= 0.1.1-~~),
+ librust-proxmox-installer-types-0.2+legacy-dev (>= 0.2-~~),
librust-proxmox-network-types-1-dev (>= 1.1-~~),
librust-proxmox-sys+crypt-dev,
librust-regex-1+default-dev (>= 1.7~~),
diff --git a/proxmox-auto-installer/src/utils.rs b/proxmox-auto-installer/src/utils.rs
index 710af21..db47c60 100644
--- a/proxmox-auto-installer/src/utils.rs
+++ b/proxmox-auto-installer/src/utils.rs
@@ -40,7 +40,7 @@ fn get_network_settings(
.interface_name_pinning()
.map(|answer| answer.into());
- let mut network_options = match &answer.global.fqdn {
+ let mut network_options = match &answer.global.fqdn.clone().into() {
// If the user set a static FQDN in the answer file, override it
FqdnConfig::Simple(name) => {
let mut opts = NetworkOptions::defaults_from(
diff --git a/proxmox-post-hook/src/main.rs b/proxmox-post-hook/src/main.rs
index ec9ab74..5f760b7 100644
--- a/proxmox-post-hook/src/main.rs
+++ b/proxmox-post-hook/src/main.rs
@@ -98,7 +98,7 @@ mod detail {
.and_then(|r| Ok(String::from_utf8(r.stdout)?))
};
- let fqdn = match &answer.global.fqdn {
+ let fqdn = match &answer.global.fqdn.clone().into() {
FqdnConfig::Simple(name) => name.to_string(),
FqdnConfig::FromDhcp(FqdnFromDhcpConfig {
source: FqdnSourceMode::FromDhcp,
--
2.47.3
^ permalink raw reply related [flat|nested] 22+ messages in thread* [PATCH installer 15/21] make tidy and clean up whitespace in unconfigured.sh
2026-08-28 13:30 [RFC cluster/common/container/docs/installer/manager 00/21] add rudimentary host backup mechanism Shannon Sterz
` (13 preceding siblings ...)
2026-08-28 13:30 ` [PATCH installer 14/21] bump proxmox-installer-types to 0.2 Shannon Sterz
@ 2026-08-28 13:30 ` Shannon Sterz
2026-08-28 13:30 ` [PATCH installer 16/21] installer-common: add option to verify TLS connections via callback Shannon Sterz
` (5 subsequent siblings)
20 siblings, 0 replies; 22+ messages in thread
From: Shannon Sterz @ 2026-08-28 13:30 UTC (permalink / raw)
To: pve-devel
Signed-off-by: Shannon Sterz <s.sterz@proxmox.com>
---
Proxmox/Install.pm | 3 ++-
unconfigured.sh | 2 +-
2 files changed, 3 insertions(+), 2 deletions(-)
diff --git a/Proxmox/Install.pm b/Proxmox/Install.pm
index 6298cff..7bb8e29 100644
--- a/Proxmox/Install.pm
+++ b/Proxmox/Install.pm
@@ -673,7 +673,8 @@ sub prepare_grub_efi_boot_esp {
# without a shim package installed (no signed shim exists for arm64 yet) grub-install
# deploys only the plain grub image, so use that as the removable-media default loader
$shim_src = $run_env->{arch} eq 'arm64' ? 'grubaa64.efi' : 'grubx64.efi';
- warn "no shim binary found, using '$shim_src' as removable-media default boot loader\n";
+ warn
+ "no shim binary found, using '$shim_src' as removable-media default boot loader\n";
}
syscmd(
"mv $targetdir/boot/efi/EFI/BOOT/$shim_src $targetdir/boot/efi/EFI/BOOT/$boot_dst")
diff --git a/unconfigured.sh b/unconfigured.sh
index 8b1ee51..6af29e8 100755
--- a/unconfigured.sh
+++ b/unconfigured.sh
@@ -398,7 +398,7 @@ fi
# just to be sure everything is on disk
sync
-if [ $proxdebug -ne 0 ]; then
+if [ $proxdebug -ne 0 ]; then
printf "\nDebug shell after installation exited (type exit or CTRL-D to reboot)\n"
debugsh || true
fi
--
2.47.3
^ permalink raw reply related [flat|nested] 22+ messages in thread* [PATCH installer 16/21] installer-common: add option to verify TLS connections via callback
2026-08-28 13:30 [RFC cluster/common/container/docs/installer/manager 00/21] add rudimentary host backup mechanism Shannon Sterz
` (14 preceding siblings ...)
2026-08-28 13:30 ` [PATCH installer 15/21] make tidy and clean up whitespace in unconfigured.sh Shannon Sterz
@ 2026-08-28 13:30 ` Shannon Sterz
2026-08-28 13:30 ` [PATCH installer 17/21] low-level-installer: add support for restoring backups Shannon Sterz
` (4 subsequent siblings)
20 siblings, 0 replies; 22+ messages in thread
From: Shannon Sterz @ 2026-08-28 13:30 UTC (permalink / raw)
To: pve-devel
this allows more flexibility and can be useful when, for example,
implementing an interactive check whether a fingerprint is correct.
Signed-off-by: Shannon Sterz <s.sterz@proxmox.com>
---
.../src/bin/proxmox-auto-installer.rs | 2 +-
.../src/fetch_plugins/http.rs | 8 +-
proxmox-installer-common/src/http.rs | 274 +++++++++++++-----
proxmox-post-hook/src/main.rs | 2 +-
4 files changed, 216 insertions(+), 70 deletions(-)
diff --git a/proxmox-auto-installer/src/bin/proxmox-auto-installer.rs b/proxmox-auto-installer/src/bin/proxmox-auto-installer.rs
index 0ced7d4..54b7050 100644
--- a/proxmox-auto-installer/src/bin/proxmox-auto-installer.rs
+++ b/proxmox-auto-installer/src/bin/proxmox-auto-installer.rs
@@ -37,7 +37,7 @@ fn setup_first_boot_executable(first_boot: &FirstBootHookInfo) -> Result<()> {
info!("Fetching first-boot hook from {url} ..");
Some(http::get_as_bytes(
url,
- first_boot.cert_fingerprint.as_deref(),
+ first_boot.cert_fingerprint.as_deref().try_into()?,
FIRST_BOOT_EXEC_MAX_SIZE,
)?)
} else {
diff --git a/proxmox-fetch-answer/src/fetch_plugins/http.rs b/proxmox-fetch-answer/src/fetch_plugins/http.rs
index 1251da6..ce5f648 100644
--- a/proxmox-fetch-answer/src/fetch_plugins/http.rs
+++ b/proxmox-fetch-answer/src/fetch_plugins/http.rs
@@ -103,8 +103,12 @@ impl FetchFromHTTP {
);
}
- let http::Response { body, content_type } =
- http::post(&answer_url, fingerprint.as_deref(), headers, payload)?;
+ let http::Response { body, content_type } = http::post(
+ &answer_url,
+ fingerprint.as_deref().try_into()?,
+ headers,
+ payload,
+ )?;
if let Some(ct) = content_type
&& ct == http::ContentType::Json
diff --git a/proxmox-installer-common/src/http.rs b/proxmox-installer-common/src/http.rs
index ca64a73..2fcc4f1 100644
--- a/proxmox-installer-common/src/http.rs
+++ b/proxmox-installer-common/src/http.rs
@@ -1,8 +1,10 @@
use anyhow::{Result, bail};
use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
-use rustls::{ClientConfig, ClientConnection, StreamOwned};
+use rustls::server::ParsedCertificate;
+use rustls::{ClientConfig, ClientConnection, RootCertStore, StreamOwned};
use sha2::{Digest, Sha256};
use std::fmt;
+use std::fmt::Debug;
use std::io::{Read, Write};
use std::str::FromStr;
use std::sync::Arc;
@@ -18,45 +20,24 @@ use ureq::unversioned::transport::{
// Re-export for conviencence when using post()
pub use ureq::http::header;
-/// Builds an [`Agent`] with TLS suitable set up, depending whether a custom fingerprint was
-/// supplied or not. If a fingerprint was supplied, only matching certificates will be accepted.
-/// Otherwise, the system certificate store is loaded.
+/// Builds an [Agent] with a suitable TLS setup, depending on the verification option that was
+/// provided. Verification can either be done via a fingerprint, that needs to match the server
+/// certificate's fingerprint, a callback, that can implement custom verification logic, or can be
+/// delegated to the system's trust store.
///
/// To gather the sha256 fingerprint you can use the following command:
+///
/// ```no_compile
/// openssl s_client -connect <host>:443 < /dev/null 2>/dev/null | openssl x509 -fingerprint -sha256 -noout -in /dev/stdin
/// ```
///
/// # Arguments
-/// * `fingerprint` - SHA256 cert fingerprint if certificate pinning should be used. Optional.
-fn build_agent(fingerprint: Option<&str>) -> Result<Agent> {
+/// * `verification_option` - Defines how the connection is verified.
+fn build_agent(verification_option: VerificationOption) -> Result<Agent> {
const GLOBAL_TIMEOUT: Duration = Duration::from_secs(60);
- if let Some(fingerprint) = fingerprint {
- // If the user specified a custom TLS fingerprint, we must use a custom
- // `rustls::ClientConfig`, which in turns means to use a custom
- // `Connector`.
- let crypto_provider = rustls::crypto::CryptoProvider::get_default()
- .cloned()
- .unwrap_or_else(|| Arc::new(rustls::crypto::ring::default_provider()));
-
- let tls_config = ClientConfig::builder_with_provider(crypto_provider)
- .with_protocol_versions(rustls::ALL_VERSIONS)?
- .dangerous()
- .with_custom_certificate_verifier(VerifyCertFingerprint::new(fingerprint)?)
- .with_no_client_auth();
-
- let connector = UreqRustlsConnector::new(Arc::new(tls_config));
-
- Ok(Agent::with_parts(
- ureq::config::Config::builder()
- .timeout_global(Some(GLOBAL_TIMEOUT))
- .build(),
- TcpConnector::default().chain(connector),
- DefaultResolver::default(),
- ))
- } else {
- Ok(Agent::config_builder()
+ let agent = match verification_option {
+ VerificationOption::Verify => Agent::config_builder()
.timeout_global(Some(GLOBAL_TIMEOUT))
.tls_config(
ureq::tls::TlsConfig::builder()
@@ -64,13 +45,38 @@ fn build_agent(fingerprint: Option<&str>) -> Result<Agent> {
.build(),
)
.build()
- .into())
- }
+ .into(),
+ opt => {
+ // If the user specified a custom TLS fingerprint or verification callback, we must use
+ // a custom `rustls::ClientConfig`, which in turn means to use a custom `Connector`.
+ let crypto_provider = rustls::crypto::CryptoProvider::get_default()
+ .cloned()
+ .unwrap_or_else(|| Arc::new(rustls::crypto::ring::default_provider()));
+
+ let tls_config = ClientConfig::builder_with_provider(crypto_provider)
+ .with_protocol_versions(rustls::ALL_VERSIONS)?
+ .dangerous()
+ .with_custom_certificate_verifier(VerifyCertHelper::new(opt)?)
+ .with_no_client_auth();
+
+ let connector = UreqRustlsConnector::new(Arc::new(tls_config));
+
+ Agent::with_parts(
+ ureq::config::Config::builder()
+ .timeout_global(Some(GLOBAL_TIMEOUT))
+ .build(),
+ TcpConnector::default().chain(connector),
+ DefaultResolver::default(),
+ )
+ }
+ };
+
+ Ok(agent)
}
-/// Issues a GET request to the specified URL and fetches the response. Optionally a SHA256
-/// fingerprint can be used to check the certificate against it, instead of the regular certificate
-/// validation.
+/// Issues a GET request to the specified URL and fetches the response. TLS verification can either
+/// be done via a fingerprint, that needs to match the server certificate's fingerprint, a callback,
+/// that can implement custom verification logic, or can be delegated to the system's trust store.
///
/// To gather the sha256 fingerprint you can use the following command:
/// ```no_compile
@@ -79,12 +85,19 @@ fn build_agent(fingerprint: Option<&str>) -> Result<Agent> {
///
/// # Arguments
/// * `url` - URL to fetch
-/// * `fingerprint` - SHA256 cert fingerprint if certificate pinning should be used. Optional.
+/// * `verification_option` - Defines how the connection is verified.
/// * `max_size` - Maximum amount of bytes that will be read.
-pub fn get_as_bytes(url: &str, fingerprint: Option<&str>, max_size: usize) -> Result<Vec<u8>> {
+pub fn get_as_bytes(
+ url: &str,
+ verification_option: VerificationOption,
+ max_size: usize,
+) -> Result<Vec<u8>> {
let mut result: Vec<u8> = Vec::new();
- let (_, body) = build_agent(fingerprint)?.get(url).call()?.into_parts();
+ let (_, body) = build_agent(verification_option)?
+ .get(url)
+ .call()?
+ .into_parts();
body.into_reader()
.take(max_size as u64)
@@ -126,8 +139,10 @@ pub struct Response {
pub content_type: Option<ContentType>,
}
-/// Issues a POST request with the payload (JSON). Optionally a SHA256 fingerprint can be used to
-/// check the cert against it, instead of the regular cert validation.
+/// Issues a POST request with the payload (JSON). TLS verification can either be done via a
+/// fingerprint, that needs to match the server certificate's fingerprint, a callback, that can
+/// implement custom verification logic, or can be delegated to the system's trust store.
+///
/// To gather the sha256 fingerprint you can use the following command:
/// ```no_compile
/// openssl s_client -connect <host>:443 < /dev/null 2>/dev/null | openssl x509 -fingerprint -sha256 -noout -in /dev/stdin
@@ -137,7 +152,7 @@ pub struct Response {
///
/// # Arguments
/// * `url` - URL to call
-/// * `fingerprint` - SHA256 cert fingerprint if certificate pinning should be used. Optional.
+/// * `verification_option` - Defines how the connection is verified.
/// * `headers` - Additional headers to add to the request.
/// * `payload` - The payload to send to the server. Expected to be a JSON formatted string.
///
@@ -147,13 +162,13 @@ pub struct Response {
/// contents and the `Content-Type` header, if present.
pub fn post(
url: &str,
- fingerprint: Option<&str>,
+ verification_option: VerificationOption,
headers: header::HeaderMap,
payload: String,
) -> Result<Response> {
// TODO: read_to_string limits the size to 10 MB, should be increase that?
- let mut request = build_agent(fingerprint)?
+ let mut request = build_agent(verification_option)?
.post(url)
.header("Content-Type", "application/json; charset=utf-8")
.config()
@@ -182,40 +197,148 @@ pub fn post(
}
}
-#[derive(Debug)]
-struct VerifyCertFingerprint {
- cert_fingerprint: Vec<u8>,
+
+/// A callback used to validate a TLS connection via rustls. See
+/// [rustls::client::danger::ServerCertVerifier::verify_server_cert] for an explanation of the
+/// arguments. The last argument is `true` if the system's trust store contains a root certificate
+/// that validates the certificate and the server name matches the certificate.
+///
+/// If `true` is returned, no further checks are done and the connection is accepted. This can be
+/// dangerous.
+pub type RustlsCallback = dyn Fn(&CertificateDer, &[CertificateDer], &ServerName, &[u8], UnixTime, bool) -> bool
+ + Send
+ + Sync
+ + 'static;
+
+/// How TLS connections are verified.
+#[derive(Default)]
+pub enum VerificationOption {
+ /// Default TLS verification.
+ #[default]
+ Verify,
+
+ /// Expect a specific fingerprint, can be used for certificate pinning.
+ Fingerprint(Vec<u8>),
+
+ /// Use a custom callback to verify the connection, if it returns `true` the connection is
+ /// accepted. No further checks are carried out, this can be dangerous.
+ DangerousCallback(Box<RustlsCallback>),
}
-impl VerifyCertFingerprint {
- fn new<S: AsRef<str>>(cert_fingerprint: S) -> Result<std::sync::Arc<Self>> {
- let cert_fingerprint = cert_fingerprint.as_ref();
- let sanitized = cert_fingerprint.replace(':', "");
+impl TryFrom<&str> for VerificationOption {
+ type Error = anyhow::Error;
+
+ fn try_from(value: &str) -> std::result::Result<Self, Self::Error> {
+ let sanitized = value.replace(':', "");
let decoded = hex::decode(sanitized)?;
- Ok(std::sync::Arc::new(Self {
- cert_fingerprint: decoded,
- }))
+ Ok(VerificationOption::Fingerprint(decoded))
}
}
-impl rustls::client::danger::ServerCertVerifier for VerifyCertFingerprint {
+impl TryFrom<Option<&str>> for VerificationOption {
+ type Error = anyhow::Error;
+
+ fn try_from(value: Option<&str>) -> std::result::Result<Self, Self::Error> {
+ match value {
+ Some(v) => v.try_into(),
+ None => Ok(VerificationOption::Verify),
+ }
+ }
+}
+
+impl Debug for VerificationOption {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ VerificationOption::Verify => write!(f, "Verify"),
+ VerificationOption::Fingerprint(v) => write!(f, "Fingerprint({:?})", v),
+ VerificationOption::DangerousCallback(_) => {
+ write!(f, "DangerousCallback(Box<RustlsCallback>)")
+ }
+ }
+ }
+}
+
+#[derive(Debug)]
+struct VerifyCertHelper {
+ option: VerificationOption,
+ store: RootCertStore,
+}
+
+impl VerifyCertHelper {
+ fn new(option: VerificationOption) -> Result<Arc<Self>> {
+ let res = rustls_native_certs::load_native_certs()?;
+ let mut store = RootCertStore::empty();
+
+ // with rustls_native_certs 0.7 [1], a `Result<Vec<CertificateDer>, Error>` is returned
+ // right away so the below switches to:
+ //
+ // ```
+ // let _ = store.add_parsable_certificates(res);
+ // ```
+ //
+ // rustls_native_certs 0.8 [2] again changes the API to return a `CertificateResult` so
+ // loading the certificates becomes
+ //
+ // ```
+ // let res = rustls_native_certs::load_native_certs();
+ // let mut store = RootCertStore::empty();
+ // let _ = store.add_parsable_certificates(res.certs);
+ // ```
+ //
+ // [1]: https://github.com/rustls/rustls-native-certs/blob/v/0.7.0/src/lib.rs#L57
+ // [2]: https://github.com/rustls/rustls-native-certs/blob/v/0.8.0/src/lib.rs#L120
+ let _ =
+ store.add_parsable_certificates(res.iter().map(|c| CertificateDer::from(c.as_ref())));
+
+ Ok(Arc::new(Self { option, store }))
+ }
+}
+
+impl rustls::client::danger::ServerCertVerifier for VerifyCertHelper {
fn verify_server_cert(
&self,
end_entity: &CertificateDer,
- _intermediates: &[CertificateDer],
- _server_name: &ServerName,
- _ocsp_response: &[u8],
- _now: UnixTime,
+ intermediates: &[CertificateDer],
+ server_name: &ServerName,
+ ocsp_response: &[u8],
+ now: UnixTime,
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
- let mut hasher = Sha256::new();
- hasher.update(end_entity);
- let result = hasher.finalize();
+ match &self.option {
+ VerificationOption::Fingerprint(fp) => {
+ let mut hasher = Sha256::new();
+ hasher.update(end_entity);
+ let result = hasher.finalize();
- if result.as_slice() == self.cert_fingerprint {
- Ok(rustls::client::danger::ServerCertVerified::assertion())
- } else {
- Err(rustls::Error::General("Fingerprint did not match!".into()))
+ if fp == result.as_slice() {
+ return Ok(rustls::client::danger::ServerCertVerified::assertion());
+ } else {
+ return Err(rustls::Error::General("Fingerprint did not match!".into()));
+ }
+ }
+ VerificationOption::DangerousCallback(cb) => {
+ let pre_ok =
+ verify_server_cert(end_entity, &self.store, intermediates, now, server_name)
+ .is_ok();
+
+ if cb(
+ end_entity,
+ intermediates,
+ server_name,
+ ocsp_response,
+ now,
+ pre_ok,
+ ) {
+ return Ok(rustls::client::danger::ServerCertVerified::assertion());
+ }
+ }
+ // `VerificationOption::Verify` does not require this verifier, so if we encounter it
+ // here, something went wrong.
+ _ => {}
}
+
+ Err(rustls::Error::General(
+ "Could not verify server certificate.".into(),
+ ))
}
fn verify_tls12_signature(
@@ -356,3 +479,22 @@ impl fmt::Debug for UreqRustlsTransport {
.finish()
}
}
+
+fn verify_server_cert(
+ cert: &CertificateDer,
+ store: &RootCertStore,
+ intermediates: &[CertificateDer],
+ now: UnixTime,
+ server_name: &ServerName,
+) -> Result<(), rustls::Error> {
+ use rustls::client::{verify_server_cert_signed_by_trust_anchor, verify_server_name};
+
+ let supported_algs = rustls::crypto::ring::default_provider()
+ .signature_verification_algorithms
+ .all;
+
+ let cert = ParsedCertificate::try_from(cert)?;
+
+ verify_server_cert_signed_by_trust_anchor(&cert, store, intermediates, now, supported_algs)?;
+ verify_server_name(&cert, server_name)
+}
diff --git a/proxmox-post-hook/src/main.rs b/proxmox-post-hook/src/main.rs
index 5f760b7..704af71 100644
--- a/proxmox-post-hook/src/main.rs
+++ b/proxmox-post-hook/src/main.rs
@@ -944,7 +944,7 @@ fn do_main() -> Result<()> {
http::post(
url,
- cert_fingerprint.as_deref(),
+ cert_fingerprint.as_deref().try_into()?,
HeaderMap::new(),
serde_json::to_string(&body)?,
)?;
--
2.47.3
^ permalink raw reply related [flat|nested] 22+ messages in thread* [PATCH installer 17/21] low-level-installer: add support for restoring backups
2026-08-28 13:30 [RFC cluster/common/container/docs/installer/manager 00/21] add rudimentary host backup mechanism Shannon Sterz
` (15 preceding siblings ...)
2026-08-28 13:30 ` [PATCH installer 16/21] installer-common: add option to verify TLS connections via callback Shannon Sterz
@ 2026-08-28 13:30 ` Shannon Sterz
2026-08-28 13:30 ` [PATCH installer 18/21] installer-common/tui-installer: implement restore tui Shannon Sterz
` (3 subsequent siblings)
20 siblings, 0 replies; 22+ messages in thread
From: Shannon Sterz @ 2026-08-28 13:30 UTC (permalink / raw)
To: pve-devel
and integrate it with the tui restoring installer.
Signed-off-by: Shannon Sterz <s.sterz@proxmox.com>
---
Proxmox/Install.pm | 63 +++++++++++++++++++++++++++++++++++++--
Proxmox/Install/Config.pm | 6 ++++
2 files changed, 66 insertions(+), 3 deletions(-)
diff --git a/Proxmox/Install.pm b/Proxmox/Install.pm
index 7bb8e29..1d4f56b 100644
--- a/Proxmox/Install.pm
+++ b/Proxmox/Install.pm
@@ -849,6 +849,9 @@ sub extract_data {
my $bootloader_err;
my $diskcount = 0;
+ my $backup_mp = Proxmox::Install::Config::get_restore_mount_point();
+ my $kapi;
+
eval {
my $maxper = 0.25;
@@ -1488,7 +1491,7 @@ _EOD
my $ask_for_patience = "";
$ask_for_patience = " (multiple disks detected, please be patient)" if $diskcount > 3;
- update_progress(0.8, 0.95, 1, "make system bootable$ask_for_patience");
+ update_progress(0.5, 0.95, 1, "make system bootable$ask_for_patience");
setup_proxmox_first_boot_service($targetdir);
stage_subscription_key($targetdir);
@@ -1538,7 +1541,6 @@ _EOD
diversion_remove($targetdir, "/usr/sbin/update-grub");
diversion_remove($targetdir, "/usr/sbin/update-initramfs");
- my $kapi;
foreach my $fn (<$targetdir/lib/modules/*>) {
if ($fn =~ m!/(\d+\.\d+\.\d+-\d+(?:-\w+)?-pve)$!) {
die "found multiple kernels\n" if defined($kapi);
@@ -1610,6 +1612,10 @@ _EOD
diversion_remove($targetdir, "/sbin/start-stop-daemon");
+ # return early, the next setup steps are overwritten by the backup and might fail (for
+ # example, setting a root password).
+ return if defined($backup_mp);
+
setup_root_password($targetdir);
# set root ssh keys
@@ -1697,7 +1703,9 @@ _EOD
my $err = $@;
- update_progress(1, 0, 1, "installation finished");
+ if ($err || !defined($kapi) || !defined($backup_mp)) {
+ update_progress(1, 0, 1, "installation finished");
+ }
print STDERR $err if $err && $err ne "\n";
@@ -1709,6 +1717,55 @@ _EOD
"chroot $targetdir /usr/bin/dpkg-query -W --showformat='\${package}\n'> final.pkglist");
}
+ if (!$err && defined($kapi) && defined($backup_mp)) {
+ eval {
+ update_progress(0.8, 0.95, 1, "restoring backup, this may take a little while...");
+ my @backup_files = glob("$backup_mp/*");
+
+ foreach my $file (@backup_files) {
+ if ($file !~ m/\/(dev|run|tmp|sys|proc|mnt)\/?$/) {
+ syscmd(["cp", "--archive", "--recursive", "--force", $file, $targetdir]) ==
+ 0
+ || die("could not restore backup - $!\n");
+ }
+ }
+
+ # Copy the latest pmxcfs backup into the "live" position.
+ my @backed_up_dbs =
+ sort(glob($backup_mp . "/var/lib/pve-cluster/backup/config-backup*.db"));
+ my $db = $backed_up_dbs[-1];
+
+ die "could not restore live pmxcfs db.\n" if !defined($db);
+
+ syscmd([
+ "cp", "--archive", "--force", $db, "$targetdir/var/lib/pve-cluster/config.db",
+ ]) == 0
+ || die("could not restore live pmxcfs backup - $!\n");
+
+ # unmount backup location as we are done restoring. ignore failures,
+ # nothing bad should happen if we reboot here while it's still
+ # mounted.
+ syscmd(["umount", "$backup_mp"]);
+
+ if (!is_test_mode()) {
+ syscmd("mount -n --bind /dev $targetdir/dev"); # re-bind mount dev
+
+ syscmd("chroot $targetdir /usr/sbin/update-initramfs -c -k $kapi") == 0
+ || die "unable to install initramfs on restore\n";
+
+ syscmd("chroot $targetdir /usr/sbin/update-grub") == 0
+ || die "unable to update boot loader config on restore\n";
+ }
+ };
+
+ $err = $@;
+ update_progress(1, 0, 1, "installation finished");
+
+ if (!is_test_mode()) {
+ syscmd("umount $targetdir/dev"); # unmount again
+ }
+ }
+
syscmd("umount $targetdir/run");
syscmd("umount $targetdir/mnt/hostrun");
syscmd("umount $targetdir/tmp/pkg");
diff --git a/Proxmox/Install/Config.pm b/Proxmox/Install/Config.pm
index dba3692..00b713f 100644
--- a/Proxmox/Install/Config.pm
+++ b/Proxmox/Install/Config.pm
@@ -121,6 +121,9 @@ my sub init_cfg {
# and the service files in the proxmox-first-boot package
ordering_target => 'multi-user', # one of `network-pre`, `network-online` or `multi-user`
},
+
+ # directory to restore contents from
+ restore_mount_point => undef,
};
$initial = parse_kernel_cmdline($initial);
@@ -310,4 +313,7 @@ sub get_first_boot_opt {
return defined($k) ? $opts->{$k} : $opts;
}
+sub get_restore_mount_point { return get('restore_mount_point'); }
+sub set_restore_mount_point { set_key('restore_mount_point', $_[0]); }
+
1;
--
2.47.3
^ permalink raw reply related [flat|nested] 22+ messages in thread* [PATCH installer 18/21] installer-common/tui-installer: implement restore tui
2026-08-28 13:30 [RFC cluster/common/container/docs/installer/manager 00/21] add rudimentary host backup mechanism Shannon Sterz
` (16 preceding siblings ...)
2026-08-28 13:30 ` [PATCH installer 17/21] low-level-installer: add support for restoring backups Shannon Sterz
@ 2026-08-28 13:30 ` Shannon Sterz
2026-08-28 13:30 ` [PATCH installer 19/21] unconfigured: add restore mode to unconfigured.sh Shannon Sterz
` (2 subsequent siblings)
20 siblings, 0 replies; 22+ messages in thread
From: Shannon Sterz @ 2026-08-28 13:30 UTC (permalink / raw)
To: pve-devel
to allow restoring a backed up host, gather information in the tui
installer, check it and then load the appropriate information into the
installer's state.
the tui installer is chosen for restoration as it is more likely to be
properly supported regardless of hardware. for example, this will
allow restoring even when only a serial interface is available.
Signed-off-by: Shannon Sterz <s.sterz@proxmox.com>
---
Cargo.toml | 2 +
debian/control | 4 +
proxmox-auto-installer/src/utils.rs | 3 +
proxmox-installer-common/Cargo.toml | 3 +-
proxmox-installer-common/src/http.rs | 51 ++
proxmox-installer-common/src/lib.rs | 1 +
proxmox-installer-common/src/options.rs | 132 ++++-
proxmox-installer-common/src/restore.rs | 119 +++++
proxmox-installer-common/src/setup.rs | 3 +
proxmox-tui-installer/Cargo.toml | 6 +-
proxmox-tui-installer/src/main.rs | 112 ++++-
proxmox-tui-installer/src/options.rs | 3 +
proxmox-tui-installer/src/setup.rs | 13 +-
proxmox-tui-installer/src/views/bootdisk.rs | 31 +-
proxmox-tui-installer/src/views/mod.rs | 3 +
proxmox-tui-installer/src/views/restore.rs | 522 ++++++++++++++++++++
16 files changed, 967 insertions(+), 41 deletions(-)
create mode 100644 proxmox-installer-common/src/restore.rs
create mode 100644 proxmox-tui-installer/src/views/restore.rs
diff --git a/Cargo.toml b/Cargo.toml
index 335fb58..7c5a7a6 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -24,7 +24,9 @@ regex = "1.7"
serde = "1.0"
serde_json = "1.0"
serde_plain = "1.0"
+sha2 = { version = "0.10" }
toml = "0.8"
+proxmox-auth-api = { version = "1.0", features = ["api-types"] }
proxmox-auto-installer.path = "./proxmox-auto-installer"
proxmox-installer-common.path = "./proxmox-installer-common"
proxmox-network-types = "1.1"
diff --git a/debian/control b/debian/control
index 3512404..7995e9f 100644
--- a/debian/control
+++ b/debian/control
@@ -19,9 +19,11 @@ Build-Depends: cargo:native,
librust-native-tls-dev,
librust-pico-args-0.5-dev,
librust-pretty-assertions-1.4-dev,
+ librust-proxmox-auth-api-1+api-types-dev (>= 1.0-~~),
librust-proxmox-installer-types-0.2+legacy-dev (>= 0.2-~~),
librust-proxmox-network-types-1-dev (>= 1.1-~~),
librust-proxmox-sys+crypt-dev,
+ librust-proxmox-time-2+default-dev,
librust-regex-1+default-dev (>= 1.7~~),
librust-rustls-0.23-dev,
librust-rustls-native-certs-dev,
@@ -32,6 +34,7 @@ Build-Depends: cargo:native,
librust-tempfile-3-dev,
librust-toml-0.8-dev,
librust-ureq-3-dev,
+ librust-url-2+default-dev (>= 2.1-~~),
librust-zstd-0.13-dev,
libtest-mockmodule-perl,
patchelf,
@@ -52,6 +55,7 @@ Depends: chrony,
libgtk3-webkit2-perl,
libjson-perl,
ndisc6,
+ proxmox-backup-client,
proxmox-kernel-helper,
rdnssd,
squashfs-tools,
diff --git a/proxmox-auto-installer/src/utils.rs b/proxmox-auto-installer/src/utils.rs
index db47c60..ec9048c 100644
--- a/proxmox-auto-installer/src/utils.rs
+++ b/proxmox-auto-installer/src/utils.rs
@@ -564,6 +564,9 @@ pub fn parse_answer(
dns: network_settings.dns_server,
first_boot: InstallFirstBootSetup::default(),
+
+ // TODO: add support for restoring backups via auto installer
+ restore_mount_point: None,
};
set_disks(answer, udev_info, runtime_info, &mut config)?;
diff --git a/proxmox-installer-common/Cargo.toml b/proxmox-installer-common/Cargo.toml
index 7682680..6cc354f 100644
--- a/proxmox-installer-common/Cargo.toml
+++ b/proxmox-installer-common/Cargo.toml
@@ -13,6 +13,7 @@ regex.workspace = true
serde = { workspace = true, features = [ "derive" ] }
serde_json.workspace = true
serde_plain.workspace = true
+proxmox-auth-api.workspace = true
proxmox-network-types.workspace = true
proxmox-installer-types.workspace = true
@@ -21,7 +22,7 @@ hex = { version = "0.4", optional = true }
native-tls = { version = "0.2", optional = true }
rustls = { version = "0.23", optional = true }
rustls-native-certs = { version = "0.6", optional = true }
-sha2 = { version = "0.10", optional = true }
+sha2 = { workspace = true, optional = true }
ureq = { version = "3", features = [ "platform-verifier" ], optional = true }
# `cli` feature
diff --git a/proxmox-installer-common/src/http.rs b/proxmox-installer-common/src/http.rs
index 2fcc4f1..9b4204d 100644
--- a/proxmox-installer-common/src/http.rs
+++ b/proxmox-installer-common/src/http.rs
@@ -197,6 +197,57 @@ pub fn post(
}
}
+/// Issues a GET request. TLS verification can either be done via a fingerprint, that needs to match
+/// the server certificate's fingerprint, a callback, that can implement custom verification logic,
+/// or can be delegated to the system's trust store.
+///
+/// To gather the sha256 fingerprint you can use the following command:
+/// ```no_compile
+/// openssl s_client -connect <host>:443 < /dev/null 2>/dev/null | openssl x509 -fingerprint -sha256 -noout -in /dev/stdin
+/// ```
+///
+/// # Arguments
+/// * `url` - URL to call
+/// * `verification_option` - Defines how the connection is verified.
+/// * `headers` - Additional headers to add to the request.
+///
+/// # Returns
+///
+/// If the request was successful, a [Response] holding the body contents and the `Content-Type`
+/// header, if present.
+pub fn get(
+ url: &str,
+ verification_option: VerificationOption,
+ headers: header::HeaderMap,
+) -> Result<Response> {
+ let mut request = build_agent(verification_option)?
+ .get(url)
+ .config()
+ // don't treat 4xx and 5xx statuses as error, so we can extract the
+ // error message from the body
+ .http_status_as_error(false)
+ .build();
+
+ for (name, value) in headers.iter() {
+ request = request.header(name, value);
+ }
+
+ let mut response = request.call()?;
+
+ let body = response.body_mut().read_to_string()?;
+ let content_type = response
+ .headers()
+ .get(header::CONTENT_TYPE)
+ .and_then(|h| h.to_str().ok())
+ .map(ContentType::from_str)
+ .transpose()?;
+
+ if response.status().is_success() {
+ Ok(Response { body, content_type })
+ } else {
+ bail!("http error: {}: {body}", response.status())
+ }
+}
/// A callback used to validate a TLS connection via rustls. See
/// [rustls::client::danger::ServerCertVerifier::verify_server_cert] for an explanation of the
diff --git a/proxmox-installer-common/src/lib.rs b/proxmox-installer-common/src/lib.rs
index ee34096..5f81e29 100644
--- a/proxmox-installer-common/src/lib.rs
+++ b/proxmox-installer-common/src/lib.rs
@@ -1,6 +1,7 @@
pub mod disk_checks;
pub mod dmi;
pub mod options;
+pub mod restore;
pub mod setup;
#[cfg(feature = "http")]
diff --git a/proxmox-installer-common/src/options.rs b/proxmox-installer-common/src/options.rs
index c7f0baf..76f1fe2 100644
--- a/proxmox-installer-common/src/options.rs
+++ b/proxmox-installer-common/src/options.rs
@@ -1,13 +1,14 @@
+use std::cmp;
+use std::collections::{HashMap, HashSet};
+use std::fmt;
+use std::net::{IpAddr, Ipv4Addr};
+use std::sync::OnceLock;
+
use anyhow::{Result, bail};
use regex::{Regex, RegexBuilder};
use serde::Deserialize;
-use std::{
- cmp,
- collections::HashMap,
- fmt,
- net::{IpAddr, Ipv4Addr},
- sync::OnceLock,
-};
+
+use proxmox_installer_types::answer::BtrfsOptions;
use crate::disk_checks::check_raid_min_disks;
use crate::net::{MAX_IFNAME_LEN, MIN_IFNAME_LEN};
@@ -15,8 +16,9 @@ use crate::setup::{LocaleInfo, NetworkInfo, RuntimeInfo, SetupInfo};
use proxmox_installer_types::{
EMAIL_DEFAULT_PLACEHOLDER,
answer::{
- BtrfsCompressOption, BtrfsRaidLevel, FilesystemType, NetworkInterfacePinningOptionsAnswer,
- ZfsChecksumOption, ZfsCompressOption, ZfsRaidLevel,
+ BtrfsCompressOption, BtrfsRaidLevel, FilesystemOptions, FilesystemType, LvmOptions,
+ NetworkInterfacePinningOptionsAnswer, ZfsChecksumOption, ZfsCompressOption, ZfsOptions,
+ ZfsRaidLevel,
},
};
use proxmox_network_types::{fqdn::Fqdn, ip_address::Cidr};
@@ -127,6 +129,16 @@ impl LvmBootdiskOptions {
min_lvm_free: None,
}
}
+
+ pub fn from_disk_and_options(disk: &Disk, opt: LvmOptions) -> LvmBootdiskOptions {
+ LvmBootdiskOptions {
+ total_size: opt.hdsize.map(|e| e.min(disk.size)).unwrap_or(disk.size),
+ swap_size: opt.swapsize,
+ max_root_size: opt.maxroot,
+ max_data_size: opt.maxvz,
+ min_lvm_free: opt.minfree,
+ }
+ }
}
pub trait FilesystemDiskInfo {
@@ -162,6 +174,23 @@ impl BtrfsBootdiskOptions {
compress: BtrfsCompressOption::default(),
}
}
+
+ pub fn from_runtime_info_disks_and_options(
+ runinfo: &RuntimeInfo,
+ selected_disks: Vec<usize>,
+ opt: BtrfsOptions,
+ ) -> Self {
+ let min_size = selected_disks
+ .iter()
+ .filter_map(|i| runinfo.disks.get(*i).map(|d| d.size))
+ .fold(f64::INFINITY, |a, b| a.min(b));
+
+ Self {
+ disk_size: opt.hdsize.map(|s| s.min(min_size)).unwrap_or(min_size),
+ selected_disks,
+ compress: opt.compress.unwrap_or_default(),
+ }
+ }
}
#[derive(Clone, Debug)]
@@ -189,6 +218,30 @@ impl ZfsBootdiskOptions {
selected_disks: (0..runinfo.disks.len()).collect(),
}
}
+
+ pub fn from_runtime_info_disks_and_options(
+ runinfo: &RuntimeInfo,
+ selected_disks: Vec<usize>,
+ opt: ZfsOptions,
+ ) -> Self {
+ let min_size = selected_disks
+ .iter()
+ .filter_map(|i| runinfo.disks.get(*i).map(|d| d.size))
+ .fold(f64::INFINITY, |a, b| a.min(b));
+
+ Self {
+ ashift: opt.ashift.unwrap_or(12) as usize,
+ compress: opt.compress.unwrap_or_default(),
+ checksum: opt.checksum.unwrap_or_default(),
+ copies: opt.copies.unwrap_or(1) as usize,
+ arc_max: opt
+ .arc_max
+ .map(|a| a as usize)
+ .unwrap_or(runinfo.default_zfs_arc_max),
+ disk_size: opt.hdsize.map(|s| s.min(min_size)).unwrap_or(min_size),
+ selected_disks,
+ }
+ }
}
#[derive(Clone, Debug)]
@@ -268,6 +321,67 @@ impl BootdiskOptions {
advanced: AdvancedBootdiskOptions::Lvm(LvmBootdiskOptions::defaults_from(disk)),
}
}
+
+ pub fn from_runtime_info_disks_and_options(
+ runinfo: &RuntimeInfo,
+ disks: Vec<Disk>,
+ options: FilesystemOptions,
+ ) -> std::result::Result<Self, anyhow::Error> {
+ let fs_type = options.to_type();
+ let selected_indices: HashSet<&String> = disks.iter().map(|i| &i.index).collect();
+ let selected_disks: Vec<usize> = runinfo
+ .disks
+ .iter()
+ .enumerate()
+ .filter_map(|(i, d)| selected_indices.contains(&d.index).then_some(i))
+ .collect();
+
+ match options {
+ FilesystemOptions::Ext4(opt) | FilesystemOptions::Xfs(opt) if disks.len() == 1 => {
+ let lvm_opt = LvmBootdiskOptions::from_disk_and_options(
+ &runinfo.disks[selected_disks[0]],
+ opt,
+ );
+ Ok(BootdiskOptions {
+ disks,
+ fstype: options.to_type(),
+ advanced: AdvancedBootdiskOptions::Lvm(lvm_opt),
+ })
+ }
+ FilesystemOptions::Zfs(opt)
+ if disks.len() >= opt.raid.unwrap_or_default().min_disks() =>
+ {
+ let zfs_opt = ZfsBootdiskOptions::from_runtime_info_disks_and_options(
+ runinfo,
+ selected_disks,
+ opt,
+ );
+ Ok(BootdiskOptions {
+ disks,
+ fstype: options.to_type(),
+ advanced: AdvancedBootdiskOptions::Zfs(zfs_opt),
+ })
+ }
+ FilesystemOptions::Btrfs(opt)
+ if disks.len() >= opt.raid.unwrap_or_default().min_disks() =>
+ {
+ let btrfs_opt = BtrfsBootdiskOptions::from_runtime_info_disks_and_options(
+ runinfo,
+ selected_disks,
+ opt,
+ );
+ Ok(BootdiskOptions {
+ disks,
+ fstype: options.to_type(),
+ advanced: AdvancedBootdiskOptions::Btrfs(btrfs_opt),
+ })
+ }
+ _ => bail!(
+ "File system unknown or wrong amount of disks {fs_type} {}.",
+ disks.len()
+ ),
+ }
+ }
}
#[derive(Clone, Debug)]
diff --git a/proxmox-installer-common/src/restore.rs b/proxmox-installer-common/src/restore.rs
new file mode 100644
index 0000000..3a7a3aa
--- /dev/null
+++ b/proxmox-installer-common/src/restore.rs
@@ -0,0 +1,119 @@
+use std::collections::HashMap;
+
+use serde::Deserialize;
+
+use proxmox_auth_api::types::Authid;
+use proxmox_installer_types::answer::AutoInstallerConfig;
+
+#[derive(Clone)]
+pub struct RestoreInfo {
+ pub authid: Authid,
+ pub secret: String,
+ pub fingerprint: Option<String>,
+ pub server: String,
+ pub datastore: String,
+ pub namespace: Option<String>,
+ pub backup_id: String,
+}
+
+impl RestoreInfo {
+ pub fn repository(&self) -> String {
+ format!("{}@{}:{}", self.authid, self.server, self.datastore)
+ }
+}
+
+impl Default for RestoreInfo {
+ fn default() -> Self {
+ RestoreInfo {
+ authid: "root@pam!pve-backup".parse().unwrap(),
+ secret: String::default(),
+ fingerprint: Option::default(),
+ server: String::default(),
+ datastore: String::default(),
+ namespace: Option::default(),
+ backup_id: String::default(),
+ }
+ }
+}
+
+#[derive(Clone, Debug, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+/// A Proxmox VE backup index, which can be used to restore a Proxmox VE host.
+pub struct PveBackupIndex {
+ /// The name of this backup.
+ pub backup_name: String,
+ /// Information used by the installer to reconstruct the original installation settings.
+ pub installer_info: AutoInstallerConfig,
+ /// Version information of the original Proxmox VE.
+ pub version: BackupVersionInformation,
+ /// Information on manually and automatically installed packages.
+ pub package_information: BackupPackageInformation,
+}
+
+#[derive(Clone, Debug, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+/// Version information of a Proxmox VE host. Including os-release and Proxmox VE specific
+/// information.
+pub struct BackupVersionInformation {
+ /// Information provided via the os-release file (see `man 5 os-release`).
+ pub os_release: OsRelease,
+ /// Version information retrieved via the Proxmox VE API.
+ pub proxmox_ve: PveVersionInformation,
+}
+
+#[derive(Clone, Debug, Deserialize)]
+#[serde(rename_all = "UPPERCASE")]
+/// Information parsed from an os release file (see `man 5 os-release`). Note that in the case of
+/// Proxmox VE this file is filled as if it was running its base Debian release only. The exact keys
+/// present depend on the os vendor. This struct only covers fields commonly available in
+/// Debian-based Proxmox products, all other fields can be accessed via the `additional` field.
+pub struct OsRelease {
+ /// The name of this OS.
+ pub name: String,
+ /// The OS' ID.
+ pub id: String,
+ /// The pretty name of this OS.
+ pub pretty_name: String,
+
+ /// The version of the OS.
+ pub version: Option<String>,
+ /// The OS' version ID.
+ pub version_id: Option<String>,
+ /// The codename of this version.
+ pub version_codename: Option<String>,
+ /// The full Debian version, Debian specific.
+ pub debian_version_full: Option<String>,
+
+ /// The home URL.
+ pub home_url: Option<String>,
+ /// The support URL.
+ pub support_url: Option<String>,
+ /// The bug report URL.
+ pub bug_report_url: Option<String>,
+
+ /// Additional fields, os-release files can have any number of vendor specific fields.
+ #[serde(skip_serializing_if = "Option::is_none", flatten)]
+ pub additional: Option<HashMap<String, String>>,
+}
+
+#[derive(Clone, Debug, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+/// Version information provided by the Proxmox VE API.
+pub struct PveVersionInformation {
+ /// The current installed pve-manager package version.
+ pub version: String,
+ /// The short git commit hash ID from which this version was build.
+ pub repoid: String,
+ /// The current installed Proxmox VE Release.
+ pub release: String,
+}
+
+#[derive(Clone, Debug, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+/// Packages installed through dpkg, marked as manually or automatically installed.
+pub struct BackupPackageInformation {
+ /// Automatically installed packages.
+ pub automatically_installed: Vec<String>,
+ /// Manually installed packages.
+ pub manually_installed: Vec<String>,
+}
diff --git a/proxmox-installer-common/src/setup.rs b/proxmox-installer-common/src/setup.rs
index b8af4e3..2abac6c 100644
--- a/proxmox-installer-common/src/setup.rs
+++ b/proxmox-installer-common/src/setup.rs
@@ -546,6 +546,9 @@ pub struct InstallConfig {
pub dns: IpAddr,
pub first_boot: InstallFirstBootSetup,
+
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub restore_mount_point: Option<PathBuf>,
}
#[derive(Clone, Debug, Deserialize, PartialEq)]
diff --git a/proxmox-tui-installer/Cargo.toml b/proxmox-tui-installer/Cargo.toml
index 56395a4..433c76d 100644
--- a/proxmox-tui-installer/Cargo.toml
+++ b/proxmox-tui-installer/Cargo.toml
@@ -8,10 +8,14 @@ exclude = [ "build", "debian" ]
homepage = "https://www.proxmox.com"
[dependencies]
-proxmox-installer-common.workspace = true
+proxmox-auth-api.workspace = true
+proxmox-installer-common = { workspace = true, features = ["http"] }
proxmox-network-types.workspace = true
proxmox-installer-types.workspace = true
+proxmox-time = "2"
anyhow.workspace = true
serde_json.workspace = true
+sha2.workspace = true
+url = "2.1"
cursive = { version = "0.21", default-features = false, features = ["crossterm-backend"] }
diff --git a/proxmox-tui-installer/src/main.rs b/proxmox-tui-installer/src/main.rs
index e9f47a9..1f3e8fe 100644
--- a/proxmox-tui-installer/src/main.rs
+++ b/proxmox-tui-installer/src/main.rs
@@ -13,13 +13,14 @@ use cursive::{
},
};
+use proxmox_installer_common::restore::RestoreInfo;
+use proxmox_installer_common::setup::{LocaleInfo, RuntimeInfo, SetupInfo, installer_setup};
use proxmox_installer_common::{
ROOT_PASSWORD_MIN_LENGTH,
options::{
BootdiskOptions, NetworkInterfacePinningOptions, NetworkOptions, TimezoneOptions,
email_validate,
},
- setup::{LocaleInfo, RuntimeInfo, SetupInfo, installer_setup},
};
use proxmox_installer_types::ProxmoxProduct;
@@ -32,10 +33,12 @@ mod system;
mod views;
use views::{
- BootdiskOptionsView, FormView, InstallProgressView, NetworkOptionsView, TableView,
- TableViewItem, TimezoneOptionsView,
+ BootdiskOptionsView, FormView, InstallProgressView, NetworkOptionsView, RestoreOptionsView,
+ TableView, TableViewItem, TimezoneOptionsView,
};
+use crate::views::{check_connection_cb, restore_installer_state};
+
// TextView::center() seems to garble the first two lines, so fix it manually here.
const PROXMOX_LOGO: &str = r"
____
@@ -131,6 +134,7 @@ impl ViewWrapper for InstallerBackgroundView {
#[derive(Clone, Eq, Hash, PartialEq)]
enum InstallerStep {
Licence,
+ Restore,
Bootdisk,
Timezone,
Password,
@@ -152,12 +156,17 @@ struct InstallerState {
fn main() {
let mut siv = cursive::crossterm();
- let in_test_mode = match env::args().nth(1).as_deref() {
- Some("-t") => true,
+ // Always force the test directory in debug builds
+ let mut in_test_mode = cfg!(debug_assertions);
+ let mut restore = false;
- // Always force the test directory in debug builds
- _ => cfg!(debug_assertions),
- };
+ for arg in env::args() {
+ match arg.as_str() {
+ "-t" => in_test_mode = true,
+ "-r" => restore = true,
+ _ => {}
+ };
+ }
let (setup_info, locales, runtime_info) = match installer_setup(in_test_mode) {
Ok(result) => result,
@@ -167,6 +176,13 @@ fn main() {
siv.clear_global_callbacks(Event::CtrlChar('c'));
siv.set_on_pre_event(Event::CtrlChar('c'), trigger_abort_install_dialog);
+ if restore && setup_info.config.product != ProxmoxProduct::Pve {
+ initial_setup_error(
+ &mut siv,
+ "Only Proxmox VE installers currently support restore mode.",
+ );
+ }
+
siv.set_user_data(InstallerState {
options: InstallerOptions {
bootdisk: BootdiskOptions::defaults_from(&runtime_info.disks[0]),
@@ -180,6 +196,8 @@ fn main() {
Some(&NetworkInterfacePinningOptions::default()),
),
autoreboot: true,
+ restore: restore.then_some(RestoreInfo::default()),
+ restore_mounted: false,
},
setup_info,
runtime_info,
@@ -254,15 +272,21 @@ fn switch_to_next_screen(
let state = siv.user_data::<InstallerState>().cloned().unwrap();
let is_first_screen = state.steps.is_empty();
+ let restore = siv
+ .with_user_data(|state: &mut InstallerState| state.options.restore.is_some())
+ .unwrap_or_default();
+
// Check if the screen already exists; if yes, then simply switch to it.
if let Some(screen_id) = state.steps.get(&step) {
siv.set_screen(*screen_id);
- // The summary view cannot be cached (otherwise it would display stale values). Thus
- // replace it if the screen is switched to.
+ // Caching views does not work if the contents of the view depend on the selection in
+ // previous views. This always applies to the summary view and, when in restore mode, the
+ // boot disk view. So don't cache them here.
+ //
// TODO: Could be done by e.g. having all the main dialog views implement some sort of
// .refresh(), which can be called if the view is switched to.
- if step == InstallerStep::Summary {
+ if step == InstallerStep::Summary || (restore && step == InstallerStep::Bootdisk) {
let view = constructor(siv);
siv.screen_mut().pop_layer();
siv.screen_mut().add_layer(view);
@@ -351,12 +375,19 @@ fn get_eula(setup: &SetupInfo) -> String {
fn license_dialog(siv: &mut Cursive) -> InstallerView {
let state = siv.user_data::<InstallerState>().unwrap();
+ let restore = state.options.restore.is_some();
let mut bbar = LinearLayout::horizontal()
.child(abort_install_button())
.child(DummyView.full_width())
- .child(Button::new("I agree", |siv| {
- switch_to_next_screen(siv, InstallerStep::Bootdisk, &bootdisk_dialog)
+ .child(Button::new("I agree", move |siv| {
+ if restore {
+ // if in restore mode, move to timezone dialog first. it will set the keyboard
+ // layout, which will make entering restore information easier.
+ switch_to_next_screen(siv, InstallerStep::Timezone, &timezone_dialog)
+ } else {
+ switch_to_next_screen(siv, InstallerStep::Bootdisk, &bootdisk_dialog)
+ }
}));
let _ = bbar.set_focus_index(2); // ignore errors
@@ -378,14 +409,52 @@ fn license_dialog(siv: &mut Cursive) -> InstallerView {
InstallerView::with_raw(state, inner)
}
+fn restore_dialog(siv: &mut Cursive) -> InstallerView {
+ let state = siv.user_data::<InstallerState>().cloned().unwrap();
+
+ InstallerView::new(
+ &state,
+ RestoreOptionsView::new(siv, &state).with_name("restore-options"),
+ Box::new(|siv| {
+ check_connection_cb(
+ siv,
+ Box::new(|siv| {
+ let Some(Ok(opts)) =
+ siv.call_on_name("restore-options", RestoreOptionsView::get_values)
+ else {
+ display_setup_warning(siv, "Could not get restore options.");
+ return;
+ };
+
+ let res = siv.with_user_data(|state: &mut InstallerState| {
+ state.options.restore = Some(opts);
+ restore_installer_state(state)
+ });
+
+ if let Some(Err(e)) = res {
+ display_setup_warning(
+ siv,
+ &format!("Could not get information from backup - {e}"),
+ );
+ } else {
+ switch_to_next_screen(siv, InstallerStep::Bootdisk, &bootdisk_dialog);
+ }
+ }),
+ )
+ }),
+ true,
+ )
+}
+
fn bootdisk_dialog(siv: &mut Cursive) -> InstallerView {
let state = siv.user_data::<InstallerState>().cloned().unwrap();
+ let restore = state.options.restore.is_some();
InstallerView::new(
&state,
BootdiskOptionsView::new(siv, &state.runtime_info, &state.options.bootdisk)
.with_name("bootdisk-options"),
- Box::new(|siv| {
+ Box::new(move |siv| {
let options = siv.call_on_name("bootdisk-options", BootdiskOptionsView::get_values);
match options {
@@ -394,7 +463,11 @@ fn bootdisk_dialog(siv: &mut Cursive) -> InstallerView {
state.options.bootdisk = options;
});
- switch_to_next_screen(siv, InstallerStep::Timezone, &timezone_dialog);
+ if restore {
+ switch_to_next_screen(siv, InstallerStep::Summary, &summary_dialog);
+ } else {
+ switch_to_next_screen(siv, InstallerStep::Timezone, &timezone_dialog);
+ }
}
Some(Err(err)) => siv.add_layer(Dialog::info(format!("Invalid values: {err}"))),
@@ -408,11 +481,12 @@ fn bootdisk_dialog(siv: &mut Cursive) -> InstallerView {
fn timezone_dialog(siv: &mut Cursive) -> InstallerView {
let state = siv.user_data::<InstallerState>().unwrap();
let options = &state.options.timezone;
+ let restore = state.options.restore.is_some();
InstallerView::new(
state,
TimezoneOptionsView::new(&state.locales, options).with_name("timezone-options"),
- Box::new(|siv| {
+ Box::new(move |siv| {
let options = siv.call_on_name("timezone-options", TimezoneOptionsView::get_values);
match options {
@@ -421,7 +495,11 @@ fn timezone_dialog(siv: &mut Cursive) -> InstallerView {
state.options.timezone = options;
});
- switch_to_next_screen(siv, InstallerStep::Password, &password_dialog);
+ if restore {
+ switch_to_next_screen(siv, InstallerStep::Restore, &restore_dialog);
+ } else {
+ switch_to_next_screen(siv, InstallerStep::Password, &password_dialog);
+ }
}
Some(Err(err)) => siv.add_layer(Dialog::info(format!("Invalid values: {err}"))),
_ => siv.add_layer(Dialog::info("Invalid values")),
diff --git a/proxmox-tui-installer/src/options.rs b/proxmox-tui-installer/src/options.rs
index 2c156e8..9b22115 100644
--- a/proxmox-tui-installer/src/options.rs
+++ b/proxmox-tui-installer/src/options.rs
@@ -1,5 +1,6 @@
use crate::SummaryOption;
+use proxmox_installer_common::restore::RestoreInfo;
use proxmox_installer_common::{
options::{BootdiskOptions, NetworkOptions, TimezoneOptions},
setup::LocaleInfo,
@@ -28,6 +29,8 @@ pub struct InstallerOptions {
pub password: PasswordOptions,
pub network: NetworkOptions,
pub autoreboot: bool,
+ pub restore: Option<RestoreInfo>,
+ pub restore_mounted: bool,
}
impl InstallerOptions {
diff --git a/proxmox-tui-installer/src/setup.rs b/proxmox-tui-installer/src/setup.rs
index ae4b717..31a2396 100644
--- a/proxmox-tui-installer/src/setup.rs
+++ b/proxmox-tui-installer/src/setup.rs
@@ -1,4 +1,5 @@
-use std::collections::BTreeMap;
+use std::collections::{BTreeMap, HashMap};
+use std::path::PathBuf;
use crate::options::InstallerOptions;
use proxmox_installer_common::{
@@ -44,8 +45,18 @@ impl From<InstallerOptions> for InstallConfig {
dns: options.network.dns_server,
first_boot: InstallFirstBootSetup::default(),
+ restore_mount_point: options
+ .restore_mounted
+ .then_some(PathBuf::from("/run/proxmox-installer/restore-mp")),
};
+ // If we are restoring, do not pin any interfaces. Either they have been pinned by the
+ // backup already or they shouldn't be pinned to avoid breaking compatibility with the
+ // backed up `/etc/network/interfaces` file.
+ if options.restore_mounted {
+ config.network_interface_pin_map = HashMap::new();
+ }
+
match &options.bootdisk.advanced {
AdvancedBootdiskOptions::Lvm(lvm) => {
config.hdsize = lvm.total_size;
diff --git a/proxmox-tui-installer/src/views/bootdisk.rs b/proxmox-tui-installer/src/views/bootdisk.rs
index a0267f1..c6ed619 100644
--- a/proxmox-tui-installer/src/views/bootdisk.rs
+++ b/proxmox-tui-installer/src/views/bootdisk.rs
@@ -53,18 +53,25 @@ impl BootdiskOptionsView {
pub fn new(siv: &mut Cursive, runinfo: &RuntimeInfo, options: &BootdiskOptions) -> Self {
let advanced_options = Arc::new(Mutex::new(options.clone()));
- let bootdisk_form = FormView::<()>::new()
- .child(
- "Target harddisk",
- target_bootdisk_selectview(
- &runinfo.disks,
- advanced_options.clone(),
- // At least one disk must always exist to even get to this point,
- // see proxmox_installer_common::setup::installer_setup()
- &options.disks[0],
- ),
- )
- .with_name("bootdisk-options-target-disk");
+ let mut bootdisk_form = FormView::<()>::new();
+
+ match options.fstype {
+ FilesystemType::Ext4 | FilesystemType::Xfs => {
+ bootdisk_form.add_child(
+ "Target harddisk",
+ target_bootdisk_selectview(
+ &runinfo.disks,
+ advanced_options.clone(),
+ // At least one disk must always exist to even get to this point,
+ // see proxmox_installer_common::setup::installer_setup()
+ &options.disks[0],
+ ),
+ )
+ }
+ other => bootdisk_form.add_child("Target harddisk", TextView::new(other.to_string())),
+ }
+
+ let bootdisk_form = bootdisk_form.with_name("bootdisk-options-target-disk");
let product_conf = siv
.user_data::<InstallerState>()
diff --git a/proxmox-tui-installer/src/views/mod.rs b/proxmox-tui-installer/src/views/mod.rs
index a343e60..9b57911 100644
--- a/proxmox-tui-installer/src/views/mod.rs
+++ b/proxmox-tui-installer/src/views/mod.rs
@@ -8,6 +8,9 @@ use cursive::{
views::{EditView, LinearLayout, NamedView, ResizedView, SelectView, TextView},
};
+mod restore;
+pub use restore::*;
+
mod bootdisk;
pub use bootdisk::*;
diff --git a/proxmox-tui-installer/src/views/restore.rs b/proxmox-tui-installer/src/views/restore.rs
new file mode 100644
index 0000000..7c9036c
--- /dev/null
+++ b/proxmox-tui-installer/src/views/restore.rs
@@ -0,0 +1,522 @@
+use std::fmt::Write;
+use std::net::Ipv6Addr;
+use std::path::PathBuf;
+use std::process::Command;
+use std::sync::{Arc, Mutex};
+use std::thread;
+
+use cursive::Cursive;
+use cursive::view::ViewWrapper;
+use cursive::views::{Button, Dialog, DummyView, EditView, LinearLayout};
+use serde_json::Value;
+
+use proxmox_auth_api::types::Authid;
+use proxmox_installer_common::http;
+use proxmox_installer_common::options::{BootdiskOptions, Disk};
+use proxmox_installer_common::restore::{PveBackupIndex, RestoreInfo};
+use proxmox_installer_common::setup::read_json;
+use proxmox_installer_types::UdevInfo;
+use proxmox_installer_types::answer::Filesystem;
+
+use crate::InstallerState;
+use crate::views::FormView;
+use crate::{display_setup_warning, prompt_dialog};
+
+pub struct RestoreOptionsView {
+ view: LinearLayout,
+}
+
+impl RestoreOptionsView {
+ pub fn new(_siv: &mut Cursive, state: &InstallerState) -> Self {
+ let restore_info = state.options.restore.as_ref().unwrap();
+
+ let form: FormView = FormView::<()>::new()
+ .child("Server", EditView::new().content(&restore_info.server))
+ .child(
+ "Token",
+ EditView::new().content(restore_info.authid.to_string()),
+ )
+ .child(
+ "Secret",
+ EditView::new().content(&restore_info.secret).secret(),
+ )
+ .child(
+ "Fingerprint [Optional]",
+ EditView::new().content(restore_info.fingerprint.as_deref().unwrap_or("")),
+ )
+ .child(
+ "Datastore",
+ EditView::new().content(&restore_info.datastore),
+ )
+ .child(
+ "Namespace [Optional]",
+ EditView::new().content(restore_info.namespace.as_deref().unwrap_or("")),
+ )
+ .child(
+ "Backup ID",
+ EditView::new().content(&restore_info.backup_id),
+ );
+
+ let view = LinearLayout::vertical()
+ .child(form)
+ .child(DummyView::new())
+ .child(Button::new(
+ "Test Settings",
+ Box::new(|siv: &mut Cursive| {
+ check_connection_cb(
+ siv,
+ Box::new(|siv| {
+ siv.add_layer(
+ Dialog::info("Restore settings are OK.").title("Restore Settings"),
+ )
+ }),
+ );
+ }),
+ ));
+
+ Self { view }
+ }
+
+ pub fn set_fingerprint(&mut self, fingerprint: &str) -> Result<(), String> {
+ let form: &mut FormView = self
+ .view
+ .get_child_mut(0)
+ .and_then(|f| f.downcast_mut::<FormView>())
+ .ok_or("Could not get form view.")?;
+
+ form.get_child_mut::<EditView>(3)
+ .ok_or("Could not get fingerprint field.")?
+ .set_content(fingerprint);
+
+ Ok(())
+ }
+
+ pub fn get_values(&mut self) -> Result<RestoreInfo, String> {
+ let form = self
+ .view
+ .get_child(0)
+ .and_then(|f| f.downcast_ref::<FormView>())
+ .ok_or("Could not get restore information form.")?;
+
+ let server = form
+ .get_value::<EditView, _>(0)
+ .ok_or("Failed to retrieve backup server address.")?;
+
+ let authid = form
+ .get_value::<EditView, _>(1)
+ .ok_or("Failed to retrieve authid for restore.")?
+ .parse::<Authid>()
+ .map_err(|err| format!("Authid is not valid - {err:#}"))?;
+
+ if !authid.is_token() {
+ return Err("Currently only tokens are supported for restoring backups!".to_owned());
+ }
+
+ let secret = form
+ .get_value::<EditView, _>(2)
+ .ok_or("Failed to retrieve secret for restore.")?;
+
+ let fingerprint = form
+ .get_value::<EditView, _>(3)
+ .ok_or("Failed to retrieve fingerprint for restore.")
+ .map(|fp| if fp.is_empty() { None } else { Some(fp) })?;
+
+ let datastore = form
+ .get_value::<EditView, _>(4)
+ .ok_or("Failed to retrieve datastore for restore.")?;
+
+ let namespace = form
+ .get_value::<EditView, _>(5)
+ .ok_or("Failed to retrieve namespace for restore.")
+ .map(|ns| if ns.is_empty() { None } else { Some(ns) })?;
+
+ let backup_id = form
+ .get_value::<EditView, _>(6)
+ .ok_or("Failed to retrieve backup_id for restore.")?;
+
+ Ok(RestoreInfo {
+ authid,
+ secret,
+ fingerprint,
+ server,
+ datastore,
+ namespace,
+ backup_id,
+ })
+ }
+}
+
+impl ViewWrapper for RestoreOptionsView {
+ cursive::wrap_impl!(self.view: LinearLayout);
+}
+
+fn list_snapshot_file_url(options: &RestoreInfo) -> Result<String, String> {
+ let backup_parts: Vec<&str> = options.backup_id.splitn(3, '/').collect();
+
+ let (id, time) = match backup_parts.len() {
+ 2 => (backup_parts[0], backup_parts[1]),
+ 3 if backup_parts[0] == "host" => (backup_parts[1], backup_parts[2]),
+ _ => return Err(
+ "could not parse backup id, invalid format ([host/]<backup-id>/<rfc3339-timestamp>)"
+ .to_owned(),
+ ),
+ };
+
+ // backup ids can contain characters that should be url encoded
+ let id: String = url::form_urlencoded::byte_serialize(id.as_bytes()).collect();
+ let time = proxmox_time::parse_rfc3339(time)
+ .map_err(|e| format!("could not parse backup time stamp - {e}"))?;
+ let mut params = format!("?backup-type=host&backup-id={id}&backup-time={time}");
+
+ if let Some(namespace) = options.namespace.as_ref() {
+ let namespace: String =
+ url::form_urlencoded::byte_serialize(namespace.as_bytes()).collect();
+ params = format!("{params}&ns={namespace}");
+ }
+
+ let store: String =
+ url::form_urlencoded::byte_serialize(options.datastore.as_bytes()).collect();
+
+ let (host, has_port) = if options.server.parse::<Ipv6Addr>().is_ok() {
+ (format!("[{}]", options.server), false)
+ } else if let Some(rest) = options.server.strip_prefix('[') {
+ let has_port = rest
+ .rsplit_once(']')
+ .is_some_and(|(_, tail)| tail.starts_with(':'));
+ (options.server.clone(), has_port)
+ } else {
+ (options.server.clone(), options.server.contains(':'))
+ };
+
+ let port = if has_port { "" } else { ":8007" };
+
+ Ok(format!(
+ "https://{host}{port}/api2/json/admin/datastore/{store}/files{params}",
+ ))
+}
+
+fn get_major_version(release: &str) -> Option<u32> {
+ release.split('.').next().and_then(|r| r.parse().ok())
+}
+
+fn check_information(options: RestoreInfo) -> Result<Option<String>, String> {
+ let mut headers = http::header::HeaderMap::new();
+
+ headers.insert(
+ http::header::ACCEPT,
+ http::header::HeaderValue::from_str("application/json")
+ .map_err(|e| format!("Could not construct header value - {e}"))?,
+ );
+
+ headers.insert(
+ http::header::AUTHORIZATION,
+ http::header::HeaderValue::from_str(&format!(
+ "PBSAPIToken={}:{}",
+ options.authid, options.secret
+ ))
+ .map_err(|e| format!("Could not construct AUTHORIZATION header - {e}"))?,
+ );
+
+ let get_url = list_snapshot_file_url(&options)?;
+ let fp: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(Option::default()));
+
+ let verification_option = if let Some(fp) = options.fingerprint.as_ref() {
+ fp.as_str()
+ .try_into()
+ .map_err(|e| format!("Could not parse PBS fingerprint - {e:#}"))?
+ } else {
+ let inner_fp = fp.clone();
+
+ http::VerificationOption::DangerousCallback(Box::new(move |cert, _, _, _, _, valid| {
+ if valid {
+ // The server uses a valid certificate, we don't need to query its fingerprint.
+ return true;
+ }
+
+ use sha2::{Digest, Sha256};
+ let mut hasher = Sha256::new();
+ hasher.update(cert);
+ let result = hasher.finalize();
+
+ // 256 bits in bytes -> 256/8;
+ // each byte needs 2 chars of encoding + ":"-> *3;
+ // no ":" at the end of the last byte -> -1
+ let mut remote_fp = String::with_capacity((256 / 8) * 3 - 1);
+
+ let _ = write!(remote_fp, "{:02x}", result[0]);
+ for byte in &result[1..] {
+ let _ = write!(remote_fp, ":{byte:02x}");
+ }
+
+ if let Ok(mut inner_fp) = inner_fp.lock() {
+ *inner_fp = Some(remote_fp);
+ }
+
+ false
+ }))
+ };
+
+ let res = http::get(&get_url, verification_option, headers)
+ .map_err(|e| format!("Could not get backup files - {e}"));
+
+ let locked_fp = fp
+ .lock()
+ .map_err(|e| format!("Could not lock fingerprint mutex - {e}"))?
+ .take();
+
+ if locked_fp.is_some() && options.fingerprint.is_none() {
+ return Ok(locked_fp);
+ }
+
+ let files: Value = serde_json::from_str(&res?.body)
+ .map_err(|e| format!("Could not parse pbs response - {e}"))?;
+
+ for file in files["data"]
+ .as_array()
+ .ok_or("could not get data".to_owned())?
+ {
+ if file["filename"]
+ .as_str()
+ .ok_or("could not get filename")?
+ .starts_with("pve-backup.pxar")
+ {
+ return Ok(locked_fp);
+ }
+ }
+
+ Err("Not a PVE host backup.".to_owned())
+}
+
+/// Connects to a PBS with the restore information from the installer state and checks if the
+/// specified backup is a PVE backup.
+pub(crate) fn check_connection_cb(
+ siv: &mut Cursive,
+ success_cb: Box<dyn FnOnce(&mut Cursive) + Send + 'static>,
+) {
+ let Some(Ok(opts)) = siv.call_on_name("restore-options", RestoreOptionsView::get_values) else {
+ display_setup_warning(siv, "Could not get restore options.");
+ return;
+ };
+
+ // set loading mask
+ siv.add_layer(Dialog::text("Checking restore information...").title("Restore Settings"));
+
+ let sink = siv.cb_sink().clone();
+
+ // Actual check needs to happen on separate thread to avoid blocking Cursive's rendering loop;
+ // otherwise the loading mask above will not be shown properly.
+ thread::spawn(move || {
+ let res = check_information(opts);
+
+ // remove loading mask
+ let _ = sink.send(Box::new(|s| {
+ let _ = s.pop_layer();
+ }));
+
+ let _ = match res {
+ Err(e) => sink.send(Box::new(move |siv| display_setup_warning(siv, &e))),
+ Ok(fp) => {
+ if let Some(got_fp) = fp.as_ref() {
+ let new_fp = got_fp.clone();
+ sink.send(Box::new(move |siv| {
+ prompt_dialog(
+ siv,
+ "Confirm Fingerprint",
+ &format!(
+ "Got the fingerprint:\n\n{new_fp}\n\nIs this the correct TLS \
+ fingerprint for the provided PBS server?"
+ ),
+ "Yes",
+ Box::new(move |s| {
+ s.call_on_name("restore-options", |v: &mut RestoreOptionsView| {
+ v.set_fingerprint(&new_fp)
+ });
+ }),
+ "No",
+ // nothing to do
+ Box::new(|_| {}),
+ )
+ }))
+ } else {
+ sink.send(success_cb)
+ }
+ }
+ };
+ });
+}
+
+/// Mounts a backup and loads data from its index into the installer state.
+pub(crate) fn restore_installer_state(state: &mut InstallerState) -> Result<(), String> {
+ let Some(opts) = state.options.restore.clone() else {
+ return Ok(()); // Not a restoring installer, nothing to do.
+ };
+
+ let mut mount_dir = if state.in_test_mode {
+ PathBuf::from("./testdir")
+ } else {
+ PathBuf::from("/")
+ };
+
+ mount_dir.push("run");
+ mount_dir.push("proxmox-installer");
+ mount_dir.push("restore-mp");
+
+ if let Err(e) = std::fs::create_dir_all(&mount_dir)
+ && e.kind() != std::io::ErrorKind::AlreadyExists
+ {
+ return Err(format!(
+ "Mount directory for restore could not be created. - {e}"
+ ));
+ }
+
+ ensure_no_mounted_backup(state)?;
+
+ let mut cmd = Command::new("proxmox-backup-client");
+ cmd.env_clear()
+ .env("PBS_PASSWORD", &opts.secret)
+ .arg("mount")
+ .arg(&opts.backup_id)
+ .arg("pve-backup.pxar")
+ .arg(&mount_dir)
+ .args(["--repository", &opts.repository()]);
+
+ if let Some(fingerprint) = opts.fingerprint.as_ref() {
+ cmd.env("PBS_FINGERPRINT", fingerprint);
+ }
+
+ if let Some(namespace) = opts.namespace.as_ref() {
+ cmd.args(["--ns", namespace]);
+ }
+
+ let output = cmd
+ .output()
+ .map_err(|e| format!("Could not spawn proxmox-backup-client - {e}"))?;
+
+ if !output.status.success() {
+ return Err("Could not mount backup for restore.".to_owned());
+ }
+
+ // proxmox-backup-client doesn't always return 1 on error here it seems
+ let stdout = String::from_utf8_lossy(&output.stdout);
+ let stderr = String::from_utf8_lossy(&output.stderr);
+
+ if stdout.to_lowercase().contains("error") || stderr.to_lowercase().contains("error") {
+ return Err("error occurred when trying to mount backup for restore".to_owned());
+ }
+
+ state.options.restore_mounted = true;
+
+ let index: PveBackupIndex = read_json(mount_dir.join("backup-index.json"))
+ .map_err(|e| format!("could not read backup index - {e}"))?;
+
+ let opts = index
+ .installer_info
+ .disks
+ .filesystem_details()
+ .map_err(|e| format!("could not get valid filesystem details from backup - {e:#}"))?;
+
+ let backup_major_version = get_major_version(&index.version.proxmox_ve.release);
+ let installer_major_version = get_major_version(&state.setup_info.iso_info.release);
+
+ if let Some(backup_version) = backup_major_version
+ && let Some(installer_version) = installer_major_version
+ {
+ // An installer is only able to restore backups from versions in the range
+ // `[installer_version-1, installer_version]`. Otherwise, incompatibilities are too likely
+ // to ensure consistency. If an older backup needs to be restored, it should be restored
+ // with an older installer and then upgraded via the usual upgrade procedure.
+ //
+ // `contains()` excludes the maximum of a range so +1 here.
+ if !(installer_version - 1..installer_version + 1).contains(&backup_version) {
+ return Err(format!(
+ "Installer version ({}) is not compatible with backup version ({}).",
+ index.version.proxmox_ve.release, state.setup_info.iso_info.release
+ ));
+ }
+ } else {
+ return Err(format!(
+ "Could not parse release versions to check compatibility.\n\n\
+ Backup Proxmox VE version: {}\n\
+ Installer version: {}",
+ index.version.proxmox_ve.release, state.setup_info.iso_info.release
+ ));
+ }
+
+ // pop-off "restore-mp"
+ mount_dir.pop();
+
+ // Manually filter disks via the device links provided as disks in a backup. This should provide
+ // better stability than the kernel assigned device name.
+ //
+ // TODO: Switch to a more general approach once #5493 is implemented.
+ let udev_info: UdevInfo = read_json(mount_dir.join("run-env-udev.json"))
+ .map_err(|err| format!("Failed to retrieve udev info details: {err:#}"))?;
+
+ let dev_links = &index.installer_info.disks.disk_list;
+ let disk_setup = &index.installer_info.disks;
+ let hdsize = match disk_setup.filesystem {
+ Filesystem::Ext4 | Filesystem::Xfs => disk_setup.lvm.and_then(|l| l.hdsize),
+ Filesystem::Zfs => disk_setup.zfs.and_then(|z| z.hdsize),
+ Filesystem::Btrfs => disk_setup.btrfs.and_then(|b| b.hdsize),
+ };
+
+ let disks: Vec<Disk> = state
+ .runtime_info
+ .disks
+ .iter()
+ .filter(|d| {
+ for i in dev_links {
+ if udev_info
+ .disks
+ .get(&d.index)
+ .and_then(|u| u.get("DEVLINKS").map(|c| c.split(" ").any(|l| l == i)))
+ .unwrap_or_default()
+ {
+ return true;
+ }
+ }
+
+ false
+ })
+ .cloned()
+ .collect();
+
+ let disks = if disks.len() == dev_links.len() {
+ disks
+ } else {
+ // Couldn't find the exact disks that the original system used; fall back to the first n
+ // disks that are at least `hdsize` big (or just the first n disks if no `hdsize` was given).
+ state
+ .runtime_info
+ .disks
+ .iter()
+ .filter(|d| hdsize.map(|hd| hd < d.size).unwrap_or(true))
+ .take(dev_links.len())
+ .cloned()
+ .collect()
+ };
+
+ state.options.bootdisk =
+ BootdiskOptions::from_runtime_info_disks_and_options(&state.runtime_info, disks, opts)
+ .map_err(|e| format!("could not get boot disk information from backup - {e:#}"))?;
+
+ Ok(())
+}
+
+/// Ensure that no backup is currently mounted.
+pub(crate) fn ensure_no_mounted_backup(state: &mut InstallerState) -> Result<(), String> {
+ if state.options.restore_mounted {
+ let output = Command::new("umount")
+ .arg("/run/proxmox-installer/restore-mp")
+ .output()
+ .map_err(|e| format!("Could not run unmount command - {e}"))?;
+
+ if !output.status.success() {
+ return Err("Could not unmount backup!".to_owned());
+ }
+ }
+
+ state.options.restore_mounted = false;
+
+ Ok(())
+}
--
2.47.3
^ permalink raw reply related [flat|nested] 22+ messages in thread* [PATCH installer 19/21] unconfigured: add restore mode to unconfigured.sh
2026-08-28 13:30 [RFC cluster/common/container/docs/installer/manager 00/21] add rudimentary host backup mechanism Shannon Sterz
` (17 preceding siblings ...)
2026-08-28 13:30 ` [PATCH installer 18/21] installer-common/tui-installer: implement restore tui Shannon Sterz
@ 2026-08-28 13:30 ` Shannon Sterz
2026-08-28 13:30 ` [PATCH installer 20/21] tui-installer: unmount a potentially mounted backup on abort Shannon Sterz
2026-08-28 13:30 ` [PATCH docs 21/21] examples: add example hook script for host backup jobs Shannon Sterz
20 siblings, 0 replies; 22+ messages in thread
From: Shannon Sterz @ 2026-08-28 13:30 UTC (permalink / raw)
To: pve-devel
Signed-off-by: Shannon Sterz <s.sterz@proxmox.com>
---
Proxmox/Install/Config.pm | 2 +-
unconfigured.sh | 18 +++++++++++++++---
2 files changed, 16 insertions(+), 4 deletions(-)
diff --git a/Proxmox/Install/Config.pm b/Proxmox/Install/Config.pm
index 00b713f..299bab1 100644
--- a/Proxmox/Install/Config.pm
+++ b/Proxmox/Install/Config.pm
@@ -46,7 +46,7 @@ sub parse_kernel_cmdline {
my @filtered = grep {
$_ !~ m/^(BOOT_IMAGE|root|ramdisk_size|splash|vga)=\S+$/
&& $_ !~ m/^(ro|rw|quiet)$/
- && $_ !~ m/^(prox(debug|tui|auto)|proxmox-\S+)$/
+ && $_ !~ m/^(prox(debug|tui|auto|restore)|proxmox-\S+)$/
} split(/\s+/, $cmdline);
$cfg->{target_cmdline} = join(' ', @filtered);
diff --git a/unconfigured.sh b/unconfigured.sh
index 6af29e8..8e4300d 100755
--- a/unconfigured.sh
+++ b/unconfigured.sh
@@ -12,6 +12,7 @@ parse_cmdline() {
proxdebug=0
proxtui=0
serial=0
+ proxrestore=0
# shellcheck disable=SC2013 # per word splitting is wanted here
for par in $(cat /proc/cmdline); do
case $par in
@@ -27,6 +28,9 @@ parse_cmdline() {
console=ttyS*|console=ttyAMA*)
serial=1
;;
+ proxrestore|proxmox-restore-mode)
+ proxrestore=1
+ ;;
esac
done;
}
@@ -349,9 +353,17 @@ sysctl -w kernel.printk='4 4 1 7'
/usr/bin/proxmox-low-level-installer dump-env
-if [ $proxtui -ne 0 ]; then
- echo "Starting the TUI installer"
- /usr/bin/proxmox-tui-installer 2>/dev/tty2
+if [ $proxtui -ne 0 ] || [ $proxrestore -ne 0 ]; then
+ if [ $proxrestore -ne 0 ]; then
+ echo "Caching device info from udev"
+ /usr/bin/proxmox-low-level-installer dump-udev
+
+ echo "Starting the TUI installer in restore mode"
+ /usr/bin/proxmox-tui-installer -r 2>/dev/tty2
+ else
+ echo "Starting the TUI installer"
+ /usr/bin/proxmox-tui-installer 2>/dev/tty2
+ fi
elif [ $start_auto_installer -ne 0 ]; then
echo "Caching device info from udev"
/usr/bin/proxmox-low-level-installer dump-udev
--
2.47.3
^ permalink raw reply related [flat|nested] 22+ messages in thread* [PATCH installer 20/21] tui-installer: unmount a potentially mounted backup on abort
2026-08-28 13:30 [RFC cluster/common/container/docs/installer/manager 00/21] add rudimentary host backup mechanism Shannon Sterz
` (18 preceding siblings ...)
2026-08-28 13:30 ` [PATCH installer 19/21] unconfigured: add restore mode to unconfigured.sh Shannon Sterz
@ 2026-08-28 13:30 ` Shannon Sterz
2026-08-28 13:30 ` [PATCH docs 21/21] examples: add example hook script for host backup jobs Shannon Sterz
20 siblings, 0 replies; 22+ messages in thread
From: Shannon Sterz @ 2026-08-28 13:30 UTC (permalink / raw)
To: pve-devel
Signed-off-by: Shannon Sterz <s.sterz@proxmox.com>
---
proxmox-tui-installer/src/main.rs | 8 +++++++-
1 file changed, 7 insertions(+), 1 deletion(-)
diff --git a/proxmox-tui-installer/src/main.rs b/proxmox-tui-installer/src/main.rs
index 1f3e8fe..c23be46 100644
--- a/proxmox-tui-installer/src/main.rs
+++ b/proxmox-tui-installer/src/main.rs
@@ -346,6 +346,8 @@ fn prompt_dialog(
}
fn trigger_abort_install_dialog(siv: &mut Cursive) {
+ #[cfg(debug_assertions)]
+ let _ = siv.with_user_data(|state| crate::views::ensure_no_mounted_backup(state));
#[cfg(debug_assertions)]
siv.quit();
@@ -355,7 +357,11 @@ fn trigger_abort_install_dialog(siv: &mut Cursive) {
"Abort installation?",
"Are you sure you want to abort the installation?",
"Yes",
- Box::new(Cursive::quit),
+ Box::new(|siv| {
+ // Ignore unmounting errors to ensure aborting finishes.
+ let _ = siv.with_user_data(|state| crate::views::ensure_no_mounted_backup(state));
+ Cursive::quit(siv);
+ }),
"No",
Box::new(|_| {}),
)
--
2.47.3
^ permalink raw reply related [flat|nested] 22+ messages in thread* [PATCH docs 21/21] examples: add example hook script for host backup jobs
2026-08-28 13:30 [RFC cluster/common/container/docs/installer/manager 00/21] add rudimentary host backup mechanism Shannon Sterz
` (19 preceding siblings ...)
2026-08-28 13:30 ` [PATCH installer 20/21] tui-installer: unmount a potentially mounted backup on abort Shannon Sterz
@ 2026-08-28 13:30 ` Shannon Sterz
20 siblings, 0 replies; 22+ messages in thread
From: Shannon Sterz @ 2026-08-28 13:30 UTC (permalink / raw)
To: pve-devel
Signed-off-by: Shannon Sterz <s.sterz@proxmox.com>
---
Makefile | 1 +
examples/host-backup-example-hookscript.pl | 90 ++++++++++++++++++++++
2 files changed, 91 insertions(+)
create mode 100755 examples/host-backup-example-hookscript.pl
diff --git a/Makefile b/Makefile
index db07f2e..13ddb73 100644
--- a/Makefile
+++ b/Makefile
@@ -218,6 +218,7 @@ doc-install: index.html $(WIKI_IMPORTS) $(API_VIEWER_SOURCES) verify-images exam
install -dm755 $(DESTDIR)/usr/share/doc/$(DOC_PACKAGE)
install -dm755 $(DESTDIR)/usr/share/$(DOC_PACKAGE)/examples/
install -m 755 examples/guest-example-hookscript.pl $(DESTDIR)/usr/share/$(DOC_PACKAGE)/examples/
+ install -m 755 examples/host-backup-example-hookscript.pl $(DESTDIR)/usr/share/$(DOC_PACKAGE)/examples/
install -m 0644 index.html $(INDEX_INCLUDES) $(DESTDIR)/usr/share/$(DOC_PACKAGE)
install -m 0644 $(WIKI_IMPORTS) $(DESTDIR)/usr/share/$(DOC_PACKAGE)
# install images
diff --git a/examples/host-backup-example-hookscript.pl b/examples/host-backup-example-hookscript.pl
new file mode 100755
index 0000000..9791814
--- /dev/null
+++ b/examples/host-backup-example-hookscript.pl
@@ -0,0 +1,90 @@
+#!/usr/bin/perl
+
+# Example host backup hook script. Currently these need to be added
+# manually to a host backup job (or as an API parameter to a one off
+# host backup).
+
+use v5.36;
+
+# First argument is the backup phase.
+
+my $phase = shift;
+
+# A payload is provided via `stdin` as JSON encoded. This will only
+# exist for certain stages. Parameters may be added to the payload
+# over time. Make sure a hook script can handle additional fields,
+# for better compatibility.
+
+my @input = <STDIN>;
+my $payload = join('\n', @input);
+
+if ($phase eq 'job-start') {
+
+ # A backup was started. Other than validating the parameters of
+ # the job, nothing has been done yet.
+
+ # This stage has no payload:
+ die "Unknown payload: '$payload'\n" if $payload ne '';
+
+ # If the script returns only a valid JSON string that contains an
+ # object with a single string member called `base-path`, that
+ # string will be used as the base path of the backup. It needs to
+ # point to a directory.
+ #
+ # The default depends on the root file system. On XFS and ext-4
+ # it is simply `/`. On ZFS and BTRFS it will be a path to a
+ # mounted snapshot of the root file system.
+ #
+ # This can be useful if the root file system supports extra
+ # consistency methods (such as, snapshot support), but the
+ # default backup mechanism cannot use it. For example, because
+ # the file system is not supported by Proxmox VE by default or
+ # taking snapshots comes with an overhead that needs to be
+ # evaluated by the operator.
+ print '{ "base-path": "/path/to/base/path" }';
+
+} elsif ($phase eq 'backup-start') {
+
+ # A backup has been prepared, but nothing has been backed up yet.
+
+ # This stage has a payload that contains the name of the backup
+ # and the backup target. The target is a directory that should
+ # contain all files that should be included in the backup. It
+ # should be created but empty in this phase.
+ #
+ # The payload should be structured as following:
+ # ```js
+ # {
+ # "backup-name": "host-backup-1234567",
+ # "backup-target": "/path/to/tmp/backup/dir"
+ # }
+ # ```
+ die "Paylod was undefined.\n" if !defined($payload);
+
+ # Nothing can be communicated back to the job at this stage.
+
+} elsif ($phase eq 'backup-end') {
+
+ # The backup has been completed and uploaded at this stage.
+
+ # A payload should be provided, it will be identical to the
+ # previous stage's payload.
+ die "Paylod was undefined.\n" if !defined($payload);
+
+ # Nothing can be communicated back to the job at this stage.
+
+} elsif ($phase eq 'job-end') {
+
+ # The back up job has completed including cleaning up any
+ # snapshots or temporary files.
+
+ # This stage receives no payload.
+ die "Unknown payload: '$payload'\n" if $payload ne '';
+
+ # Nothing can be communicated back to the job at this stage.
+
+} else {
+ die "Got unknown phase: '$phase'\n";
+}
+
+exit(0);
--
2.47.3
^ permalink raw reply related [flat|nested] 22+ messages in thread