[PATCH proxmox-backup v7 5/9] api: add POST endpoint for move-group

Hannes Laimer
2026-04-16T17:19:20Z

Add a dedicated /move-group endpoint for moving backup groups between
namespaces within the same datastore.

The permission model allows users with DATASTORE_PRUNE on the source
and DATASTORE_BACKUP on the target namespace to move groups they own,
without requiring full DATASTORE_MODIFY on both sides.

Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
src/api2/admin/datastore.rs | 105 ++++++++++++++++++++++++++++++++++++
1 file changed, 105 insertions(+)

diff --git a/src/api2/admin/datastore.rs b/src/api2/admin/datastore.rs
index fcb81ec5..54895f2b 100644
--- a/src/api2/admin/datastore.rs
+++ b/src/api2/admin/datastore.rs
@@ -280,6 +280,110 @@ pub async fn delete_group(
.await?
}

+#[api(
+ input: {
+ properties: {
+ store: { schema: DATASTORE_SCHEMA },
+ ns: {
+ type: BackupNamespace,
+ optional: true,
+ },
+ group: {
+ type: pbs_api_types::BackupGroup,
+ flatten: true,
+ },
+ "target-ns": {
+ type: BackupNamespace,
+ optional: true,
+ },
+ "merge-group": {
+ type: bool,
+ optional: true,
+ default: true,
+ description: "If the group already exists in the target namespace, merge \
+ snapshots into it. Requires matching ownership and non-overlapping \
+ snapshot times.",
+ },
+ },
+ },
+ returns: {
+ schema: UPID_SCHEMA,
+ },
+ access: {
+ permission: &Permission::Anybody,
+ description: "Requires DATASTORE_MODIFY or DATASTORE_PRUNE (+ group ownership) on the \
+ source namespace and DATASTORE_MODIFY or DATASTORE_BACKUP (+ group ownership) on \
+ the target namespace.",
+ },
+)]
+/// Move a backup group to a different namespace within the same datastore.
+pub fn move_group(
+ store: String,
+ ns: Option<BackupNamespace>,
+ group: pbs_api_types::BackupGroup,
+ target_ns: Option<BackupNamespace>,
+ merge_group: bool,
+ rpcenv: &mut dyn RpcEnvironment,
+) -> Result<Value, Error> {
+ let auth_id: Authid = rpcenv.get_auth_id().unwrap().parse()?;
+ let ns = ns.unwrap_or_default();
+ let target_ns = target_ns.unwrap_or_default();
+
+ let source_limited = check_ns_privs_full(
+ &store,
+ &ns,
+ &auth_id,
+ PRIV_DATASTORE_MODIFY,
+ PRIV_DATASTORE_PRUNE,
+ )?;
+ let target_limited = check_ns_privs_full(
+ &store,
+ &target_ns,
+ &auth_id,
+ PRIV_DATASTORE_MODIFY,
+ PRIV_DATASTORE_BACKUP,
+ )?;
+
+ let datastore = DataStore::lookup_datastore(lookup_with(&store, Operation::Write))?;
+
+ if source_limited || target_limited {
+ let owner = datastore.get_owner(&ns, &group)?;
+ check_backup_owner(&owner, &auth_id)?;
+ }
+
+ // Best-effort pre-checks for a fast synchronous error before spawning a worker.
+ if ns == target_ns {
+ bail!("source and target namespace must be different");
+ }
+ if !datastore.namespace_exists(&target_ns) {
+ bail!("target namespace '{target_ns}' does not exist");
+ }
+ let source_group = datastore.backup_group(ns.clone(), group.clone());
+ if !source_group.exists() {
+ bail!("group '{group}' does not exist in namespace '{ns}'");
+ }
+ let target_group = datastore.backup_group(target_ns.clone(), group.clone());
+ if target_group.exists() && !merge_group {
+ bail!(
+ "group '{group}' already exists in target namespace '{target_ns}' \
+ and merge-group is disabled"
+ );
+ }
+
+ let worker_id = format!("{store}:{ns}/{group}:{target_ns}");
+ let to_stdout = rpcenv.env_type() == RpcEnvironmentType::CLI;
+
+ let upid_str = WorkerTask::new_thread(
+ "move-group",
+ Some(worker_id),
+ auth_id.to_string(),
+ to_stdout,
+ move |_worker| datastore.move_group(&ns, &group, &target_ns, merge_group),
+ )?;
+
+ Ok(json!(upid_str))
+}
+
#[api(
input: {
properties: {
@@ -2852,6 +2956,7 @@ const DATASTORE_INFO_SUBDIRS: SubdirMap = &[
.delete(&API_METHOD_DELETE_GROUP),
),
("mount", &Router::new().post(&API_METHOD_MOUNT)),
+ ("move-group", &Router::new().post(&API_METHOD_MOVE_GROUP)),
(
"namespace",
// FIXME: move into datastore:: sub-module?!
--
2.47.3