* [PATCH proxmox-backup v7 0/9] fixes #6195: add support for moving groups and namespaces
@ 2026-04-16 17:18 Hannes Laimer
2026-04-16 17:18 ` [PATCH proxmox-backup v7 1/9] ui: show empty groups Hannes Laimer
` (8 more replies)
0 siblings, 9 replies; 10+ messages in thread
From: Hannes Laimer @ 2026-04-16 17:18 UTC (permalink / raw)
To: pbs-devel
Add support for moving backup groups and entire namespace subtrees to
a different location within the same datastore.
Groups are moved with exclusive per-group and per-snapshot locking.
For S3, objects are copied to the target prefix before deleting the
source. Namespace moves process groups individually, deferring and
retrying lock conflicts once, so partially failed moves can be
completed with move-group.
v7, thanks @Fabian and @Arthur!:
- allow moving into existing target namespaces
- merge groups with the same name when owner matches and source
snapshots are strictly newer than target snapshots
- lock and move snapshots in batches(512) to avoid FD exhaustion
- assert new-style locking upfront
- pre-create target group dir with source owner
- use remove_dir for source cleanup, log errors instead of swallowing
- rename 'new-ns' to 'target-ns', use dedicated POST endpoints
/move-group and /move-namespace
- relax move-group permissions: DATASTORE_PRUNE + ownership on
source, DATASTORE_BACKUP + ownership on target
- add max-depth, delete-source, and merge-group/merge-groups flags
- add docs section and online help link for move dialogs
- make ns and target-ns required in CLI, no need to keep the
empty -> root thing since this is new
v6, thanks @Fabian and @Dominik!:
- drop ns locks, lock everything directly, like we do for delete
- ui: disable prune for empty groups
- ui: dont render verification status for empty groups
v5, thanks @Chris!:
- lock dir instead of .ns-lock file
- improve cleanup of partially failed s3 moves
- ui: show empty groups, re-order actions, add context menu
- add cli commands for both ns and group moves
Hannes Laimer (9):
ui: show empty groups
datastore: add move-group
datastore: add move-namespace
docs: add section on moving namespaces and groups
api: add POST endpoint for move-group
api: add POST endpoint for move-namespace
ui: add move group action
ui: add move namespace action
cli: add move-namespace and move-group commands
docs/storage.rst | 45 ++-
pbs-datastore/src/backup_info.rs | 165 +++++++-
pbs-datastore/src/datastore.rs | 422 +++++++++++++++++++-
src/api2/admin/datastore.rs | 109 +++++
src/api2/admin/namespace.rs | 99 ++++-
src/bin/proxmox_backup_manager/datastore.rs | 109 ++++-
www/Makefile | 2 +
www/OnlineHelpInfo.js | 4 +
www/datastore/Content.js | 161 ++++++--
www/form/NamespaceSelector.js | 11 +
www/window/GroupMove.js | 56 +++
www/window/NamespaceMove.js | 126 ++++++
12 files changed, 1274 insertions(+), 35 deletions(-)
create mode 100644 www/window/GroupMove.js
create mode 100644 www/window/NamespaceMove.js
--
2.47.3
^ permalink raw reply [flat|nested] 10+ messages in thread
* [PATCH proxmox-backup v7 1/9] ui: show empty groups
2026-04-16 17:18 [PATCH proxmox-backup v7 0/9] fixes #6195: add support for moving groups and namespaces Hannes Laimer
@ 2026-04-16 17:18 ` Hannes Laimer
2026-04-16 17:18 ` [PATCH proxmox-backup v7 2/9] datastore: add move-group Hannes Laimer
` (7 subsequent siblings)
8 siblings, 0 replies; 10+ messages in thread
From: Hannes Laimer @ 2026-04-16 17:18 UTC (permalink / raw)
To: pbs-devel
Display groups that have no snapshots. Currently, deleting the last
snapshot also removes the parent group, which causes metadata like
notes to be lost.
Showing empty groups is also needed for cleaning up partially failed
moves on an S3-backed datastore. Without them, the only way to delete
leftover groups (and their orphaned S3 objects) would be through the
API.
Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
www/datastore/Content.js | 89 +++++++++++++++++++++++++++-------------
1 file changed, 61 insertions(+), 28 deletions(-)
diff --git a/www/datastore/Content.js b/www/datastore/Content.js
index a2aa1949..dfb7787c 100644
--- a/www/datastore/Content.js
+++ b/www/datastore/Content.js
@@ -139,6 +139,22 @@ Ext.define('PBS.DataStoreContent', {
});
},
+ makeGroupEntry: function (btype, backupId) {
+ let cls = PBS.Utils.get_type_icon_cls(btype);
+ if (cls === '') {
+ return null;
+ }
+ return {
+ text: btype + '/' + backupId,
+ leaf: false,
+ iconCls: 'fa ' + cls,
+ expanded: false,
+ backup_type: btype,
+ backup_id: backupId,
+ children: [],
+ };
+ },
+
getRecordGroups: function (records) {
let groups = {};
@@ -150,27 +166,20 @@ Ext.define('PBS.DataStoreContent', {
continue;
}
- let cls = PBS.Utils.get_type_icon_cls(btype);
- if (cls === '') {
+ let entry = this.makeGroupEntry(btype, item.data['backup-id']);
+ if (entry === null) {
console.warn(`got unknown backup-type '${btype}'`);
continue; // FIXME: auto render? what do?
}
- groups[group] = {
- text: group,
- leaf: false,
- iconCls: 'fa ' + cls,
- expanded: false,
- backup_type: item.data['backup-type'],
- backup_id: item.data['backup-id'],
- children: [],
- };
+ groups[group] = entry;
}
return groups;
},
- updateGroupNotes: async function (view) {
+ loadGroups: async function () {
+ let view = this.getView();
try {
let url = `/api2/extjs/admin/datastore/${view.datastore}/groups`;
if (view.namespace && view.namespace !== '') {
@@ -179,19 +188,24 @@ Ext.define('PBS.DataStoreContent', {
let {
result: { data: groups },
} = await Proxmox.Async.api2({ url });
- let map = {};
- for (const group of groups) {
- map[`${group['backup-type']}/${group['backup-id']}`] = group.comment;
- }
- view.getRootNode().cascade((node) => {
- if (node.data.ty === 'group') {
- let group = `${node.data.backup_type}/${node.data.backup_id}`;
- node.set('comment', map[group], { dirty: false });
- }
- });
+ return groups;
} catch (err) {
console.debug(err);
}
+ return [];
+ },
+
+ updateGroupNotes: function (view, groupList) {
+ let map = {};
+ for (const group of groupList) {
+ map[`${group['backup-type']}/${group['backup-id']}`] = group.comment;
+ }
+ view.getRootNode().cascade((node) => {
+ if (node.data.ty === 'group') {
+ let group = `${node.data.backup_type}/${node.data.backup_id}`;
+ node.set('comment', map[group], { dirty: false });
+ }
+ });
},
loadNamespaceFromSameLevel: async function () {
@@ -215,7 +229,10 @@ Ext.define('PBS.DataStoreContent', {
let me = this;
let view = this.getView();
- let namespaces = await me.loadNamespaceFromSameLevel();
+ let [namespaces, groupList] = await Promise.all([
+ me.loadNamespaceFromSameLevel(),
+ me.loadGroups(),
+ ]);
if (!success) {
// TODO also check error code for != 403 ?
@@ -232,6 +249,22 @@ Ext.define('PBS.DataStoreContent', {
let groups = this.getRecordGroups(records);
+ for (const item of groupList) {
+ let btype = item['backup-type'];
+ let group = btype + '/' + item['backup-id'];
+ if (groups[group] !== undefined) {
+ continue;
+ }
+ let entry = me.makeGroupEntry(btype, item['backup-id']);
+ if (entry === null) {
+ continue;
+ }
+ entry.leaf = true;
+ entry.comment = item.comment;
+ entry.owner = item.owner;
+ groups[group] = entry;
+ }
+
let selected;
let expanded = {};
@@ -399,7 +432,7 @@ Ext.define('PBS.DataStoreContent', {
);
}
- this.updateGroupNotes(view);
+ this.updateGroupNotes(view, groupList);
if (selected !== undefined) {
let selection = view.getRootNode().findChildBy(
@@ -985,7 +1018,7 @@ Ext.define('PBS.DataStoreContent', {
flex: 1,
renderer: (v, meta, record) => {
let data = record.data;
- if (!data || data.leaf || data.root) {
+ if (!data || (data.leaf && data.ty !== 'group') || data.root) {
return '';
}
@@ -1029,7 +1062,7 @@ Ext.define('PBS.DataStoreContent', {
},
dblclick: function (tree, el, row, col, ev, rec) {
let data = rec.data || {};
- if (data.leaf || data.root) {
+ if ((data.leaf && data.ty !== 'group') || data.root) {
return;
}
let view = tree.up();
@@ -1065,7 +1098,7 @@ Ext.define('PBS.DataStoreContent', {
getTip: (v, m, rec) => Ext.String.format(gettext("Prune '{0}'"), v),
getClass: (v, m, { data }) =>
data.ty === 'group' ? 'fa fa-scissors' : 'pmx-hidden',
- isActionDisabled: (v, r, c, i, { data }) => data.ty !== 'group',
+ isActionDisabled: (v, r, c, i, { data }) => data.ty !== 'group' || !!data.leaf,
},
{
handler: 'onProtectionChange',
@@ -1230,7 +1263,7 @@ Ext.define('PBS.DataStoreContent', {
return ''; // TODO: accumulate verify of all groups into root NS node?
}
let i = (cls, txt) => `<i class="fa fa-fw fa-${cls}"></i> ${txt}`;
- if (v === undefined || v === null) {
+ if (v === undefined || v === null || record.data.count === 0) {
return record.data.leaf ? '' : i('question-circle-o warning', gettext('None'));
}
let tip, iconCls, txt;
--
2.47.3
^ permalink raw reply [flat|nested] 10+ messages in thread
* [PATCH proxmox-backup v7 2/9] datastore: add move-group
2026-04-16 17:18 [PATCH proxmox-backup v7 0/9] fixes #6195: add support for moving groups and namespaces Hannes Laimer
2026-04-16 17:18 ` [PATCH proxmox-backup v7 1/9] ui: show empty groups Hannes Laimer
@ 2026-04-16 17:18 ` Hannes Laimer
2026-04-16 17:18 ` [PATCH proxmox-backup v7 3/9] datastore: add move-namespace Hannes Laimer
` (6 subsequent siblings)
8 siblings, 0 replies; 10+ messages in thread
From: Hannes Laimer @ 2026-04-16 17:18 UTC (permalink / raw)
To: pbs-devel
Add support for moving a single backup group to a different namespace
within the same datastore.
For the filesystem backend each snapshot directory is renamed
individually. For S3 all objects are copied to the target prefix
before deleting the source, per snapshot.
Exclusive locks on the group and all its snapshots are acquired
before the move to ensure no concurrent operations are active.
Snapshots are locked and moved in batches to avoid exhausting file
descriptors on groups with many snapshots.
If the target group already exists and merging is enabled, the
individual snapshots are moved into the existing target group
provided both groups have the same owner and the source snapshots
are strictly newer than the target snapshots.
Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
pbs-datastore/src/backup_info.rs | 165 +++++++++++++++++++++++++++-
pbs-datastore/src/datastore.rs | 177 +++++++++++++++++++++++++++++++
2 files changed, 340 insertions(+), 2 deletions(-)
diff --git a/pbs-datastore/src/backup_info.rs b/pbs-datastore/src/backup_info.rs
index c33eb307..46dd9af1 100644
--- a/pbs-datastore/src/backup_info.rs
+++ b/pbs-datastore/src/backup_info.rs
@@ -9,7 +9,7 @@ use std::time::Duration;
use anyhow::{bail, format_err, Context, Error};
use const_format::concatcp;
-use proxmox_s3_client::S3PathPrefix;
+use proxmox_s3_client::{S3ObjectKey, S3PathPrefix};
use proxmox_sys::fs::{lock_dir_noblock, lock_dir_noblock_shared, replace_file, CreateOptions};
use proxmox_systemd::escape_unit;
@@ -273,8 +273,169 @@ impl BackupGroup {
Ok(delete_stats)
}
+ /// Check merge invariants for moving this group's snapshots into an existing
+ /// target group. Returns an error if ownership differs or snapshot times overlap.
+ pub(crate) fn check_merge_invariants(&self, target_ns: &BackupNamespace) -> Result<(), Error> {
+ let target_group = BackupGroup {
+ store: self.store.clone(),
+ ns: target_ns.clone(),
+ group: self.group.clone(),
+ };
+
+ let src_owner = self.get_owner()?;
+ let tgt_owner = target_group.get_owner()?;
+ if src_owner != tgt_owner {
+ bail!(
+ "cannot merge group '{}/{}': owner mismatch (source: {src_owner}, \
+ target: {tgt_owner})",
+ self.group.ty,
+ self.group.id,
+ );
+ }
+
+ let src_oldest = self
+ .iter_snapshots()?
+ .collect::<Result<Vec<_>, _>>()?
+ .iter()
+ .map(|s| s.backup_time())
+ .min();
+ let tgt_newest = target_group
+ .iter_snapshots()?
+ .collect::<Result<Vec<_>, _>>()?
+ .iter()
+ .map(|s| s.backup_time())
+ .max();
+ if let (Some(src_oldest), Some(tgt_newest)) = (src_oldest, tgt_newest) {
+ if src_oldest <= tgt_newest {
+ bail!(
+ "cannot merge group '{}/{}': snapshot time overlap \
+ (oldest source: {src_oldest}, newest target: {tgt_newest})",
+ self.group.ty,
+ self.group.id,
+ );
+ }
+ }
+
+ Ok(())
+ }
+
+ /// Move a single snapshot to a target group path.
+ ///
+ /// For the filesystem backend, renames the snapshot directory. For S3, copies all
+ /// objects under the snapshot prefix to the target, renames the local cache directory,
+ /// then deletes the source objects. A copy failure returns an error with the snapshot
+ /// intact at source. A delete failure is logged as a warning.
+ ///
+ /// The caller must hold an exclusive lock on the snapshot being moved.
+ pub(crate) fn move_snapshot(
+ &self,
+ snap: &BackupDir,
+ target_group_path: &Path,
+ backend: &DatastoreBackend,
+ ) -> Result<(), Error> {
+ let src_snap_path = snap.full_path();
+ let dst_snap_path = target_group_path.join(snap.backup_time_string());
+
+ match backend {
+ DatastoreBackend::Filesystem => {
+ std::fs::rename(&src_snap_path, &dst_snap_path).with_context(|| {
+ format!("failed to move snapshot {src_snap_path:?} to {dst_snap_path:?}")
+ })?;
+ }
+ DatastoreBackend::S3(s3_client) => {
+ let src_rel = snap.relative_path();
+ let src_rel_str = src_rel
+ .to_str()
+ .ok_or_else(|| format_err!("invalid source snapshot path"))?;
+ let src_prefix_str = format!("{S3_CONTENT_PREFIX}/{src_rel_str}/");
+
+ let dst_snap_rel = dst_snap_path
+ .strip_prefix(self.store.base_path())
+ .with_context(|| {
+ format!(
+ "target snapshot path {dst_snap_path:?} does not start with \
+ datastore base path {:?}",
+ self.store.base_path(),
+ )
+ })?;
+ let dst_rel_str = dst_snap_rel
+ .to_str()
+ .ok_or_else(|| format_err!("invalid target snapshot path"))?;
+ let dst_prefix_str = format!("{S3_CONTENT_PREFIX}/{dst_rel_str}/");
+
+ let store_prefix = format!("{}/", self.store.name());
+
+ // Copy all objects for this snapshot to the target prefix. On
+ // failure the source snapshot remains intact and any partial
+ // target copies stay as leftovers (visible via the API for cleanup).
+ let prefix = S3PathPrefix::Some(src_prefix_str.clone());
+ let mut token: Option<String> = None;
+ let mut src_keys = Vec::new();
+
+ loop {
+ let result = proxmox_async::runtime::block_on(
+ s3_client.list_objects_v2(&prefix, token.as_deref()),
+ )
+ .context("failed to list snapshot objects on S3 backend")?;
+
+ for item in result.contents {
+ let full_key_str: &str = &item.key;
+ let rel_key =
+ full_key_str.strip_prefix(&store_prefix).ok_or_else(|| {
+ format_err!("unexpected key prefix in '{full_key_str}'")
+ })?;
+ let src_key = S3ObjectKey::try_from(rel_key)?;
+
+ let suffix = rel_key
+ .strip_prefix(&src_prefix_str)
+ .ok_or_else(|| format_err!("unexpected key format '{rel_key}'"))?;
+ let dst_key_str = format!("{dst_prefix_str}{suffix}");
+ let dst_key = S3ObjectKey::try_from(dst_key_str.as_str())?;
+
+ proxmox_async::runtime::block_on(
+ s3_client.copy_object(src_key.clone(), dst_key),
+ )
+ .with_context(|| format!("failed to copy S3 object '{rel_key}'"))?;
+ src_keys.push(src_key);
+ }
+
+ if result.is_truncated {
+ token = result.next_continuation_token;
+ } else {
+ break;
+ }
+ }
+
+ // Rename local cache directory.
+ std::fs::rename(&src_snap_path, &dst_snap_path).with_context(|| {
+ format!("failed to move snapshot cache {src_snap_path:?} to {dst_snap_path:?}")
+ })?;
+
+ // Delete source S3 objects. Treat failures as warnings since the
+ // snapshot is already at the target.
+ for src_key in src_keys {
+ if let Err(err) =
+ proxmox_async::runtime::block_on(s3_client.delete_object(src_key.clone()))
+ {
+ log::warn!(
+ "S3 move: failed to delete source object '{src_key:?}' \
+ (snapshot already at target, orphaned object requires manual removal): \
+ {err:#}"
+ );
+ }
+ }
+ }
+ }
+
+ // Clean up stale source lock files under /run for this snapshot.
+ let _ = std::fs::remove_file(snap.manifest_lock_path());
+ let _ = std::fs::remove_file(snap.lock_path());
+
+ Ok(())
+ }
+
/// Helper function, assumes that no more snapshots are present in the group.
- fn remove_group_dir(&self) -> Result<(), Error> {
+ pub(crate) fn remove_group_dir(&self) -> Result<(), Error> {
let note_path = self.store.group_notes_path(&self.ns, &self.group);
if let Err(err) = std::fs::remove_file(¬e_path) {
if err.kind() != std::io::ErrorKind::NotFound {
diff --git a/pbs-datastore/src/datastore.rs b/pbs-datastore/src/datastore.rs
index f1475d77..f88c356e 100644
--- a/pbs-datastore/src/datastore.rs
+++ b/pbs-datastore/src/datastore.rs
@@ -88,6 +88,15 @@ const CHUNK_LOCK_TIMEOUT: Duration = Duration::from_secs(3 * 60 * 60);
const S3_DELETE_BATCH_LIMIT: usize = 100;
// max defer time for s3 batch deletions
const S3_DELETE_DEFER_LIMIT_SECONDS: Duration = Duration::from_secs(60 * 5);
+// upper bound on concurrent snapshot locks during move operations to avoid exhausting FDs
+const MAX_SNAPSHOT_LOCKS: usize = 512;
+
+/// Error from a per-group move attempt. Soft errors (e.g. lock conflicts) are
+/// retryable, hard errors are not.
+pub(crate) enum MoveGroupError {
+ Soft(Error),
+ Hard(Error),
+}
/// checks if auth_id is owner, or, if owner is a token, if
/// auth_id is the user of the token
@@ -1129,6 +1138,174 @@ impl DataStore {
backup_group.destroy(&self.backend()?)
}
+ /// Move a single backup group to a different namespace within the same datastore.
+ pub fn move_group(
+ self: &Arc<Self>,
+ source_ns: &BackupNamespace,
+ group: &pbs_api_types::BackupGroup,
+ target_ns: &BackupNamespace,
+ merge_group: bool,
+ ) -> Result<(), Error> {
+ if *OLD_LOCKING {
+ bail!("move operations require new-style file-based locking");
+ }
+ if source_ns == target_ns {
+ bail!("source and target namespace must be different");
+ }
+
+ let source_group = self.backup_group(source_ns.clone(), group.clone());
+
+ // Pre-lock existence checks to avoid unnecessary locking overhead.
+ if !self.namespace_exists(target_ns) {
+ bail!("target namespace '{target_ns}' does not exist");
+ }
+ if !source_group.exists() {
+ bail!("group '{group}' does not exist in namespace '{source_ns}'");
+ }
+
+ let backend = self.backend()?;
+
+ self.lock_and_move_group(&source_group, source_ns, target_ns, &backend, merge_group)
+ .map_err(|err| match err {
+ MoveGroupError::Soft(err) | MoveGroupError::Hard(err) => err,
+ })
+ }
+
+ /// Lock and move a single group. Acquires exclusive locks on both source and target
+ /// group paths, then moves snapshots in batches - each batch is locked, moved, and
+ /// released before the next batch starts. This ensures no concurrent readers (e.g.
+ /// verify) can operate on snapshots being moved, without exhausting file descriptors.
+ ///
+ /// Returns a `MoveGroupError` on failure, indicating whether the failure was a
+ /// transient lock conflict (retryable) or a hard error.
+ pub(crate) fn lock_and_move_group(
+ self: &Arc<Self>,
+ group: &BackupGroup,
+ source_ns: &BackupNamespace,
+ target_ns: &BackupNamespace,
+ backend: &DatastoreBackend,
+ merge_groups: bool,
+ ) -> Result<(), MoveGroupError> {
+ let target_group_ns = group
+ .backup_ns()
+ .map_prefix(source_ns, target_ns)
+ .map_err(MoveGroupError::Hard)?;
+
+ let target_group = self.backup_group(target_group_ns.clone(), group.group().clone());
+
+ // Acquire exclusive group locks - source prevents new snapshot additions/removals,
+ // target prevents concurrent creation at the destination path.
+ let _group_lock = group.lock().map_err(MoveGroupError::Soft)?;
+ let _target_group_lock = target_group.lock().map_err(MoveGroupError::Soft)?;
+
+ let merge = target_group.exists();
+
+ if merge && !merge_groups {
+ return Err(MoveGroupError::Hard(format_err!(
+ "group '{}' already exists in target namespace '{target_group_ns}' \
+ and merging is disabled",
+ group.group(),
+ )));
+ }
+
+ if merge {
+ group
+ .check_merge_invariants(&target_group_ns)
+ .map_err(MoveGroupError::Hard)?;
+ }
+
+ let mut snapshots: Vec<_> = group
+ .iter_snapshots()
+ .map_err(MoveGroupError::Hard)?
+ .collect::<Result<Vec<_>, _>>()
+ .map_err(MoveGroupError::Hard)?;
+ // Sort by time so that a partial failure (e.g. batch N succeeds but N+1
+ // fails) leaves a clean time-ordered split: all moved snapshots are older
+ // than all remaining ones. This allows a merge retry to succeed since the
+ // invariant (source oldest > target newest) still holds.
+ snapshots.sort_by_key(|s| s.backup_time());
+
+ // Ensure the target type and group directories exist.
+ std::fs::create_dir_all(self.type_path(&target_group_ns, group.group().ty))
+ .map_err(|err| MoveGroupError::Hard(err.into()))?;
+ let target_group_path = self.group_path(&target_group_ns, group.group());
+ if !merge {
+ std::fs::create_dir_all(&target_group_path)
+ .map_err(|err| MoveGroupError::Hard(err.into()))?;
+ // Copy owner so the group is visible in the UI during the move (especially
+ // relevant for S3 where the copy can take a while).
+ if let Ok(owner) = group.get_owner() {
+ if let Err(err) = target_group.set_owner(&owner, true) {
+ warn!(
+ "failed to set owner for target group '{}' in '{target_group_ns}': {err:#}",
+ group.group(),
+ );
+ }
+ }
+ }
+
+ log::info!(
+ "{} group '{}/{}' from '{}' to '{target_group_ns}'",
+ if merge { "merging" } else { "moving" },
+ group.group().ty,
+ group.group().id,
+ group.backup_ns(),
+ );
+
+ // Lock and move snapshots in batches. Each batch is locked, moved, and released
+ // before the next batch starts. This ensures no concurrent reader (e.g. verify,
+ // which only takes a snapshot-level shared lock) can operate on a snapshot while
+ // it is being moved.
+ for chunk in snapshots.chunks(MAX_SNAPSHOT_LOCKS) {
+ let _batch_locks: Vec<_> = chunk
+ .iter()
+ .map(|snap| snap.lock().map_err(MoveGroupError::Soft))
+ .collect::<Result<Vec<_>, _>>()?;
+
+ for snap in chunk {
+ group
+ .move_snapshot(snap, &target_group_path, backend)
+ .map_err(MoveGroupError::Hard)?;
+ }
+ // batch locks released here
+ }
+
+ // Copy group notes to the target (unless merging into a group that already has notes).
+ let src_notes_path = self.group_notes_path(group.backup_ns(), group.group());
+ let dst_notes_path = self.group_notes_path(&target_group_ns, group.group());
+ if src_notes_path.exists() && !dst_notes_path.exists() {
+ if let Err(err) = std::fs::copy(&src_notes_path, &dst_notes_path) {
+ warn!(
+ "failed to copy group notes from {src_notes_path:?} to {dst_notes_path:?}: {err}"
+ );
+ }
+ }
+
+ // Remove the now-empty source group directory (owner, notes, dir, group lock).
+ group.remove_group_dir().map_err(MoveGroupError::Hard)?;
+
+ // For S3: delete orphaned source group-level objects (owner, notes).
+ if let DatastoreBackend::S3(s3_client) = backend {
+ let src_group_rel = group.relative_group_path();
+ let src_group_rel_str = src_group_rel
+ .to_str()
+ .ok_or_else(|| MoveGroupError::Hard(format_err!("invalid source group path")))?;
+ for filename in [GROUP_OWNER_FILE_NAME, GROUP_NOTES_FILE_NAME] {
+ let key_str = format!("{S3_CONTENT_PREFIX}/{src_group_rel_str}/{filename}");
+ if let Ok(key) = S3ObjectKey::try_from(key_str.as_str()) {
+ if let Err(err) = proxmox_async::runtime::block_on(s3_client.delete_object(key))
+ {
+ log::warn!(
+ "S3 move: failed to delete source group object '{key_str}': {err:#}"
+ );
+ }
+ }
+ }
+ }
+
+ Ok(())
+ }
+
/// Remove a backup directory including all content
pub fn remove_backup_dir(
self: &Arc<Self>,
--
2.47.3
^ permalink raw reply [flat|nested] 10+ messages in thread
* [PATCH proxmox-backup v7 3/9] datastore: add move-namespace
2026-04-16 17:18 [PATCH proxmox-backup v7 0/9] fixes #6195: add support for moving groups and namespaces Hannes Laimer
2026-04-16 17:18 ` [PATCH proxmox-backup v7 1/9] ui: show empty groups Hannes Laimer
2026-04-16 17:18 ` [PATCH proxmox-backup v7 2/9] datastore: add move-group Hannes Laimer
@ 2026-04-16 17:18 ` Hannes Laimer
2026-04-16 17:18 ` [PATCH proxmox-backup v7 4/9] docs: add section on moving namespaces and groups Hannes Laimer
` (5 subsequent siblings)
8 siblings, 0 replies; 10+ messages in thread
From: Hannes Laimer @ 2026-04-16 17:18 UTC (permalink / raw)
To: pbs-devel
Relocate an entire namespace subtree (the given namespace, all child
namespaces, and their groups) to a new location within the same
datastore.
Groups that cannot be locked because another task is running are
deferred and retried once after all other groups have been processed.
Groups that still fail are reported and remain at the source so they
can be retried individually.
When merging is enabled and a source group already exists in the
target, the snapshots are merged provided ownership matches and
source snapshots are strictly newer.
An optional max-depth parameter limits how many levels of child
namespaces are included. When delete-source is true (the default),
empty source namespaces are removed after the move.
Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
pbs-datastore/src/datastore.rs | 245 ++++++++++++++++++++++++++++++++-
1 file changed, 244 insertions(+), 1 deletion(-)
diff --git a/pbs-datastore/src/datastore.rs b/pbs-datastore/src/datastore.rs
index f88c356e..536824c9 100644
--- a/pbs-datastore/src/datastore.rs
+++ b/pbs-datastore/src/datastore.rs
@@ -34,7 +34,7 @@ use pbs_api_types::{
ArchiveType, Authid, BackupGroupDeleteStats, BackupNamespace, BackupType, ChunkOrder,
DataStoreConfig, DatastoreBackendConfig, DatastoreBackendType, DatastoreFSyncLevel,
DatastoreTuning, GarbageCollectionCacheStats, GarbageCollectionStatus, MaintenanceMode,
- MaintenanceType, Operation, S3Statistics, UPID,
+ MaintenanceType, Operation, S3Statistics, MAX_NAMESPACE_DEPTH, UPID,
};
use pbs_config::s3::S3_CFG_TYPE_ID;
use pbs_config::{BackupLockGuard, ConfigVersionCache};
@@ -1124,6 +1124,249 @@ impl DataStore {
Ok((removed_all_requested, stats))
}
+ /// Move a backup namespace (including all child namespaces and groups) to a new location.
+ ///
+ /// Groups are moved one at a time. For each group, exclusive locks on the group and all
+ /// its snapshots (locked and moved in batches) ensure no concurrent operations are active. Groups
+ /// that cannot be locked (because a backup, verify, or other task is running) are deferred
+ /// and retried once, then reported in the error.
+ ///
+ /// If the target namespace already exists, groups are moved into it. Groups that fail
+ /// (lock conflict, merge invariant violation, or I/O error) are skipped and reported at
+ /// the end - they remain at the source and can be retried individually.
+ ///
+ /// `max_depth` limits how many levels of child namespaces below `source_ns` are included.
+ /// `None` means unlimited (move the entire subtree). `Some(0)` moves only the groups
+ /// directly in `source_ns` (no child namespaces).
+ ///
+ /// When `delete_source` is true the source namespace directories are removed after all
+ /// groups have been moved. When false the (now empty) source namespaces are kept.
+ ///
+ /// When `merge_groups` is true and a source group already exists in the target namespace,
+ /// the snapshots are merged into the existing target group (provided ownership matches
+ /// and source snapshots are strictly newer). When false the operation fails for that
+ /// group.
+ pub fn move_namespace(
+ self: &Arc<Self>,
+ source_ns: &BackupNamespace,
+ target_ns: &BackupNamespace,
+ max_depth: Option<usize>,
+ delete_source: bool,
+ merge_groups: bool,
+ ) -> Result<(), Error> {
+ if *OLD_LOCKING {
+ bail!("move operations require new-style file-based locking");
+ }
+ if source_ns.is_root() {
+ bail!("cannot move root namespace");
+ }
+ if source_ns == target_ns {
+ bail!("source and target namespace must be different");
+ }
+
+ if !self.namespace_exists(source_ns) {
+ bail!("source namespace '{source_ns}' does not exist");
+ }
+ let target_parent = target_ns.parent();
+ if !target_ns.is_root() && !self.namespace_exists(&target_parent) {
+ bail!("target namespace parent '{target_parent}' does not exist");
+ }
+ if source_ns.contains(target_ns).is_some() {
+ bail!(
+ "cannot move namespace '{source_ns}' into its own subtree (target: '{target_ns}')"
+ );
+ }
+
+ let all_source_ns: Vec<BackupNamespace> = if let Some(depth) = max_depth {
+ ListNamespacesRecursive::new_max_depth(Arc::clone(self), source_ns.clone(), depth)?
+ .collect::<Result<Vec<_>, Error>>()?
+ } else {
+ self.recursive_iter_backup_ns(source_ns.clone())?
+ .collect::<Result<Vec<_>, Error>>()?
+ };
+
+ let all_source_groups: Vec<BackupGroup> = all_source_ns
+ .iter()
+ .map(|ns| self.iter_backup_groups(ns.clone()))
+ .collect::<Result<Vec<_>, Error>>()?
+ .into_iter()
+ .flatten()
+ .collect::<Result<Vec<_>, Error>>()?;
+
+ let subtree_depth = all_source_ns
+ .iter()
+ .map(BackupNamespace::depth)
+ .max()
+ .map_or(0, |d| d - source_ns.depth());
+ if subtree_depth + target_ns.depth() > MAX_NAMESPACE_DEPTH {
+ bail!(
+ "move would exceed maximum namespace depth \
+ ({subtree_depth}+{} > {MAX_NAMESPACE_DEPTH})",
+ target_ns.depth(),
+ );
+ }
+
+ let backend = self.backend()?;
+
+ log::info!(
+ "moving namespace '{source_ns}' -> '{target_ns}': {} namespaces, {} groups",
+ all_source_ns.len(),
+ all_source_groups.len(),
+ );
+
+ // Create target local namespace directories upfront (covers empty namespaces).
+ for ns in &all_source_ns {
+ let target_child = ns.map_prefix(source_ns, target_ns)?;
+ std::fs::create_dir_all(self.namespace_path(&target_child)).with_context(|| {
+ format!("failed to create local dir for target namespace '{target_child}'")
+ })?;
+ }
+
+ // For S3: create namespace markers for all target namespaces.
+ if let DatastoreBackend::S3(s3_client) = &backend {
+ for ns in &all_source_ns {
+ let target_child = ns.map_prefix(source_ns, target_ns)?;
+ let object_key = crate::s3::object_key_from_path(
+ &target_child.path(),
+ NAMESPACE_MARKER_FILENAME,
+ )
+ .context("invalid namespace marker object key")?;
+ log::debug!("creating S3 namespace marker for '{target_child}': {object_key:?}");
+ proxmox_async::runtime::block_on(
+ s3_client.upload_no_replace_with_retry(object_key, Bytes::from("")),
+ )
+ .context("failed to create namespace marker on S3 backend")?;
+ }
+ }
+
+ // Move each group with per-group and per-snapshot locking. Groups that cannot be
+ // locked are deferred and retried once after all other groups have been processed.
+ let mut failed_groups: Vec<(BackupNamespace, String)> = Vec::new();
+ let mut failed_ns: HashSet<BackupNamespace> = HashSet::new();
+ let mut deferred: Vec<&BackupGroup> = Vec::new();
+
+ for group in &all_source_groups {
+ if let Err(err) =
+ self.lock_and_move_group(group, source_ns, target_ns, &backend, merge_groups)
+ {
+ match err {
+ MoveGroupError::Soft(err) => {
+ log::info!(
+ "deferring group '{}' in '{}' - lock conflict, will retry: {err:#}",
+ group.group(),
+ group.backup_ns(),
+ );
+ deferred.push(group);
+ }
+ MoveGroupError::Hard(err) => {
+ warn!(
+ "failed to move group '{}' in '{}': {err:#}",
+ group.group(),
+ group.backup_ns(),
+ );
+ failed_groups.push((group.backup_ns().clone(), group.group().to_string()));
+ failed_ns.insert(group.backup_ns().clone());
+ }
+ }
+ }
+ }
+
+ // Retry deferred groups once.
+ if !deferred.is_empty() {
+ log::info!("retrying {} deferred group(s)...", deferred.len());
+ for group in deferred {
+ if let Err(err) =
+ self.lock_and_move_group(group, source_ns, target_ns, &backend, merge_groups)
+ {
+ let err = match err {
+ MoveGroupError::Soft(err) | MoveGroupError::Hard(err) => err,
+ };
+ warn!(
+ "failed to move group '{}' in '{}' on retry: {err:#}",
+ group.group(),
+ group.backup_ns(),
+ );
+ failed_groups.push((group.backup_ns().clone(), group.group().to_string()));
+ failed_ns.insert(group.backup_ns().clone());
+ }
+ }
+ }
+
+ // Clean up source namespaces when requested. Process deepest-first so child
+ // directories are removed before their parents.
+ if delete_source {
+ let moved_ns: HashSet<&BackupNamespace> = all_source_ns.iter().collect();
+
+ for ns in all_source_ns.iter().rev() {
+ // Skip namespaces that still have groups from failed moves or child
+ // namespaces that were excluded by the depth limit.
+ if failed_ns
+ .iter()
+ .any(|fns| fns == ns || ns.contains(fns).is_some())
+ {
+ continue;
+ }
+ let has_excluded_children = self
+ .iter_backup_ns(ns.clone())
+ .ok()
+ .into_iter()
+ .flatten()
+ .filter_map(|child| child.ok())
+ .any(|child| !moved_ns.contains(&child));
+ if has_excluded_children {
+ continue;
+ }
+
+ // Remove type subdirectories first (should be empty after per-group renames),
+ // then the namespace directory itself. Uses remove_dir which fails on
+ // non-empty directories, handling concurrent group creation gracefully.
+ let ns_path = self.namespace_path(ns);
+ if let Ok(entries) = std::fs::read_dir(&ns_path) {
+ for entry in entries.flatten() {
+ if let Err(err) = std::fs::remove_dir(entry.path()) {
+ warn!(
+ "failed to remove source directory {:?}: {err}",
+ entry.path(),
+ );
+ }
+ }
+ }
+ if let Err(err) = std::fs::remove_dir(&ns_path) {
+ warn!("failed to remove source namespace directory {ns_path:?}: {err}");
+ continue;
+ }
+
+ // Local directory removed - delete the S3 namespace marker too.
+ if let DatastoreBackend::S3(s3_client) = &backend {
+ let object_key =
+ crate::s3::object_key_from_path(&ns.path(), NAMESPACE_MARKER_FILENAME)
+ .context("invalid namespace marker object key")?;
+ log::debug!("deleting source S3 namespace marker for '{ns}': {object_key:?}");
+ if let Err(err) =
+ proxmox_async::runtime::block_on(s3_client.delete_object(object_key))
+ {
+ warn!("failed to delete source S3 namespace marker for '{ns}': {err:#}");
+ }
+ }
+ }
+ }
+
+ if !failed_groups.is_empty() {
+ let group_list: Vec<String> = failed_groups
+ .iter()
+ .map(|(ns, group)| format!("'{group}' in '{ns}'"))
+ .collect();
+ bail!(
+ "namespace move partially completed; {} group(s) could not be moved \
+ and remain at source: {}",
+ failed_groups.len(),
+ group_list.join(", "),
+ );
+ }
+
+ Ok(())
+ }
+
/// Remove a complete backup group including all snapshots.
///
/// Returns `BackupGroupDeleteStats`, containing the number of deleted snapshots
--
2.47.3
^ permalink raw reply [flat|nested] 10+ messages in thread
* [PATCH proxmox-backup v7 4/9] docs: add section on moving namespaces and groups
2026-04-16 17:18 [PATCH proxmox-backup v7 0/9] fixes #6195: add support for moving groups and namespaces Hannes Laimer
` (2 preceding siblings ...)
2026-04-16 17:18 ` [PATCH proxmox-backup v7 3/9] datastore: add move-namespace Hannes Laimer
@ 2026-04-16 17:18 ` Hannes Laimer
2026-04-16 17:18 ` [PATCH proxmox-backup v7 5/9] api: add POST endpoint for move-group Hannes Laimer
` (4 subsequent siblings)
8 siblings, 0 replies; 10+ messages in thread
From: Hannes Laimer @ 2026-04-16 17:18 UTC (permalink / raw)
To: pbs-devel
Document the move-group and move-namespace operations including merge
behavior, max-depth and delete-source parameters. Add an online help
anchor so the UI move dialogs can link directly to this section.
Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
docs/storage.rst | 45 ++++++++++++++++++++++++++++++++++++++++++-
www/OnlineHelpInfo.js | 4 ++++
2 files changed, 48 insertions(+), 1 deletion(-)
diff --git a/docs/storage.rst b/docs/storage.rst
index 672091f8..ad507fd4 100644
--- a/docs/storage.rst
+++ b/docs/storage.rst
@@ -527,7 +527,50 @@ For backup groups, the existing privilege rules still apply. You either need a
privileged enough permission or to be the owner of the backup group; nothing
changed here.
-.. todo:: continue
+.. _storage_move_namespaces_groups:
+
+Moving Namespaces and Groups
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Backup groups can be moved between namespaces within the same datastore.
+This is useful for reorganizing backup hierarchies without having to
+re-run backups.
+
+A single group can be moved with ``move-group``. To relocate an entire
+namespace subtree (including all child namespaces and their groups), use
+``move-namespace``.
+
+.. code-block:: console
+
+ # proxmox-backup-manager datastore move-group <store> --ns <source> --target-ns <target> --backup-type <type> --backup-id <id>
+ # proxmox-backup-manager datastore move-namespace <store> --ns <source> --target-ns <target>
+
+If the target namespace already exists, groups are moved into it. When a
+group with the same type and ID already exists in the target and
+``merge-groups`` is enabled, the snapshots are merged into the existing
+group provided:
+
+- both groups have the same owner
+- the oldest source snapshot is newer than the newest target snapshot
+
+Groups that cannot be merged or locked are skipped and reported in the
+task log. They remain at the source and can be retried individually with
+``move-group``.
+
+Optional parameters for ``move-namespace``:
+
+``merge-groups``
+ Allow merging snapshots into groups that already exist in the target
+ namespace with the same type and ID. Defaults to false.
+
+``max-depth``
+ Limits how many levels of child namespaces below the source are
+ included. When not set, the entire subtree is moved.
+
+``delete-source``
+ Controls whether the source namespace directories are removed after
+ all groups have been moved out. Defaults to true. Set to false to
+ keep the (now empty) source namespace structure.
Options
diff --git a/www/OnlineHelpInfo.js b/www/OnlineHelpInfo.js
index 89650cfb..e118b0ad 100644
--- a/www/OnlineHelpInfo.js
+++ b/www/OnlineHelpInfo.js
@@ -299,6 +299,10 @@ const proxmoxOnlineHelpInfo = {
"link": "/docs/storage.html#storage-namespaces",
"title": "Backup Namespaces"
},
+ "storage-move-namespaces-groups": {
+ "link": "/docs/storage.html#storage-move-namespaces-groups",
+ "title": "Moving Namespaces and Groups"
+ },
"datastore-tuning-options": {
"link": "/docs/storage.html#datastore-tuning-options",
"title": "Tuning"
--
2.47.3
^ permalink raw reply [flat|nested] 10+ messages in thread
* [PATCH proxmox-backup v7 5/9] api: add POST endpoint for move-group
2026-04-16 17:18 [PATCH proxmox-backup v7 0/9] fixes #6195: add support for moving groups and namespaces Hannes Laimer
` (3 preceding siblings ...)
2026-04-16 17:18 ` [PATCH proxmox-backup v7 4/9] docs: add section on moving namespaces and groups Hannes Laimer
@ 2026-04-16 17:18 ` Hannes Laimer
2026-04-16 17:18 ` [PATCH proxmox-backup v7 6/9] api: add POST endpoint for move-namespace Hannes Laimer
` (3 subsequent siblings)
8 siblings, 0 replies; 10+ messages in thread
From: Hannes Laimer @ 2026-04-16 17:18 UTC (permalink / raw)
To: pbs-devel
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
^ permalink raw reply [flat|nested] 10+ messages in thread
* [PATCH proxmox-backup v7 6/9] api: add POST endpoint for move-namespace
2026-04-16 17:18 [PATCH proxmox-backup v7 0/9] fixes #6195: add support for moving groups and namespaces Hannes Laimer
` (4 preceding siblings ...)
2026-04-16 17:18 ` [PATCH proxmox-backup v7 5/9] api: add POST endpoint for move-group Hannes Laimer
@ 2026-04-16 17:18 ` Hannes Laimer
2026-04-16 17:18 ` [PATCH proxmox-backup v7 7/9] ui: add move group action Hannes Laimer
` (2 subsequent siblings)
8 siblings, 0 replies; 10+ messages in thread
From: Hannes Laimer @ 2026-04-16 17:18 UTC (permalink / raw)
To: pbs-devel
Add a dedicated /move-namespace endpoint for moving namespaces to a
new location within the same datastore. Exposes max-depth,
delete-source, and merge-groups as optional parameters.
Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
src/api2/admin/datastore.rs | 4 ++
src/api2/admin/namespace.rs | 99 ++++++++++++++++++++++++++++++++++++-
2 files changed, 101 insertions(+), 2 deletions(-)
diff --git a/src/api2/admin/datastore.rs b/src/api2/admin/datastore.rs
index 54895f2b..a91d48dc 100644
--- a/src/api2/admin/datastore.rs
+++ b/src/api2/admin/datastore.rs
@@ -2957,6 +2957,10 @@ const DATASTORE_INFO_SUBDIRS: SubdirMap = &[
),
("mount", &Router::new().post(&API_METHOD_MOUNT)),
("move-group", &Router::new().post(&API_METHOD_MOVE_GROUP)),
+ (
+ "move-namespace",
+ &Router::new().post(&crate::api2::admin::namespace::API_METHOD_MOVE_NAMESPACE),
+ ),
(
"namespace",
// FIXME: move into datastore:: sub-module?!
diff --git a/src/api2/admin/namespace.rs b/src/api2/admin/namespace.rs
index c885ab54..abfaf8ff 100644
--- a/src/api2/admin/namespace.rs
+++ b/src/api2/admin/namespace.rs
@@ -1,12 +1,16 @@
use anyhow::{bail, Error};
use pbs_config::CachedUserInfo;
-use proxmox_router::{http_bail, ApiMethod, Permission, Router, RpcEnvironment};
+use proxmox_rest_server::WorkerTask;
+use proxmox_router::{
+ http_bail, ApiMethod, Permission, Router, RpcEnvironment, RpcEnvironmentType,
+};
use proxmox_schema::*;
+use serde_json::{json, Value};
use pbs_api_types::{
Authid, BackupGroupDeleteStats, BackupNamespace, NamespaceListItem, Operation,
- DATASTORE_SCHEMA, NS_MAX_DEPTH_SCHEMA, PROXMOX_SAFE_ID_FORMAT,
+ DATASTORE_SCHEMA, NS_MAX_DEPTH_SCHEMA, PROXMOX_SAFE_ID_FORMAT, UPID_SCHEMA,
};
use pbs_datastore::DataStore;
@@ -192,6 +196,97 @@ pub fn delete_namespace(
Ok(stats)
}
+#[api(
+ input: {
+ properties: {
+ store: { schema: DATASTORE_SCHEMA },
+ ns: {
+ type: BackupNamespace,
+ },
+ "target-ns": {
+ type: BackupNamespace,
+ },
+ "max-depth": {
+ schema: NS_MAX_DEPTH_SCHEMA,
+ optional: true,
+ },
+ "delete-source": {
+ type: bool,
+ optional: true,
+ default: true,
+ description: "Remove the source namespace after moving all contents. \
+ Defaults to true.",
+ },
+ "merge-groups": {
+ type: bool,
+ optional: true,
+ default: true,
+ description: "If a group with the same name 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 on the parent of 'ns' and on the parent of 'target-ns'.",
+ },
+)]
+/// Move a backup namespace (including all child namespaces and groups) to a new location.
+pub fn move_namespace(
+ store: String,
+ ns: BackupNamespace,
+ target_ns: BackupNamespace,
+ max_depth: Option<usize>,
+ delete_source: bool,
+ merge_groups: bool,
+ rpcenv: &mut dyn RpcEnvironment,
+) -> Result<Value, Error> {
+ let auth_id: Authid = rpcenv.get_auth_id().unwrap().parse()?;
+
+ check_ns_modification_privs(&store, &ns, &auth_id)?;
+ check_ns_modification_privs(&store, &target_ns, &auth_id)?;
+
+ let datastore =
+ DataStore::lookup_datastore(crate::tools::lookup_with(&store, Operation::Write))?;
+
+ // Best-effort pre-checks for a fast synchronous error before spawning a worker.
+ if ns.is_root() {
+ bail!("cannot move root namespace");
+ }
+ if ns == target_ns {
+ bail!("source and target namespace must be different");
+ }
+ if !datastore.namespace_exists(&ns) {
+ bail!("source namespace '{ns}' does not exist");
+ }
+ let target_parent = target_ns.parent();
+ if !target_ns.is_root() && !datastore.namespace_exists(&target_parent) {
+ bail!("target parent namespace '{target_parent}' does not exist");
+ }
+ if ns.contains(&target_ns).is_some() {
+ bail!("cannot move namespace '{ns}' into its own subtree (target: '{target_ns}')");
+ }
+
+ let worker_id = format!("{store}:{ns}:{target_ns}");
+ let to_stdout = rpcenv.env_type() == RpcEnvironmentType::CLI;
+
+ let upid_str = WorkerTask::new_thread(
+ "move-namespace",
+ Some(worker_id),
+ auth_id.to_string(),
+ to_stdout,
+ move |_worker| {
+ datastore.move_namespace(&ns, &target_ns, max_depth, delete_source, merge_groups)
+ },
+ )?;
+
+ Ok(json!(upid_str))
+}
+
pub const ROUTER: Router = Router::new()
.get(&API_METHOD_LIST_NAMESPACES)
.post(&API_METHOD_CREATE_NAMESPACE)
--
2.47.3
^ permalink raw reply [flat|nested] 10+ messages in thread
* [PATCH proxmox-backup v7 7/9] ui: add move group action
2026-04-16 17:18 [PATCH proxmox-backup v7 0/9] fixes #6195: add support for moving groups and namespaces Hannes Laimer
` (5 preceding siblings ...)
2026-04-16 17:18 ` [PATCH proxmox-backup v7 6/9] api: add POST endpoint for move-namespace Hannes Laimer
@ 2026-04-16 17:18 ` Hannes Laimer
2026-04-16 17:18 ` [PATCH proxmox-backup v7 8/9] ui: add move namespace action Hannes Laimer
2026-04-16 17:18 ` [PATCH proxmox-backup v7 9/9] cli: add move-namespace and move-group commands Hannes Laimer
8 siblings, 0 replies; 10+ messages in thread
From: Hannes Laimer @ 2026-04-16 17:18 UTC (permalink / raw)
To: pbs-devel
Add a "Move" action to the backup group context menu and action
column. Opens a dialog where the user selects a target namespace,
then submits a POST to the move-group API endpoint.
Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
www/Makefile | 1 +
www/datastore/Content.js | 47 +++++++++++++++++++++++++++++++++
www/window/GroupMove.js | 56 ++++++++++++++++++++++++++++++++++++++++
3 files changed, 104 insertions(+)
create mode 100644 www/window/GroupMove.js
diff --git a/www/Makefile b/www/Makefile
index 5a60e47e..06441c02 100644
--- a/www/Makefile
+++ b/www/Makefile
@@ -76,6 +76,7 @@ JSSRC= \
config/PruneAndGC.js \
window/ACLEdit.js \
window/BackupGroupChangeOwner.js \
+ window/GroupMove.js \
window/CreateDirectory.js \
window/DataStoreEdit.js \
window/NamespaceEdit.js \
diff --git a/www/datastore/Content.js b/www/datastore/Content.js
index dfb7787c..585a1a2d 100644
--- a/www/datastore/Content.js
+++ b/www/datastore/Content.js
@@ -668,6 +668,27 @@ Ext.define('PBS.DataStoreContent', {
});
},
+ moveGroup: function (data) {
+ let me = this;
+ let view = me.getView();
+ let group = `${data.backup_type}/${data.backup_id}`;
+ Ext.create('PBS.window.GroupMove', {
+ datastore: view.datastore,
+ namespace: view.namespace,
+ backupType: data.backup_type,
+ backupId: data.backup_id,
+ group,
+ taskDone: () => me.reload(),
+ }).show();
+ },
+
+ onMove: function (table, rI, cI, item, e, { data }) {
+ let me = this;
+ if (data.ty === 'group') {
+ me.moveGroup(data);
+ }
+ },
+
forgetGroup: function (data) {
let me = this;
let view = me.getView();
@@ -972,6 +993,7 @@ Ext.define('PBS.DataStoreContent', {
onVerify: createControllerCallback('onVerify'),
onChangeOwner: createControllerCallback('onChangeOwner'),
onPrune: createControllerCallback('onPrune'),
+ onMove: createControllerCallback('onMove'),
onForget: createControllerCallback('onForget'),
});
} else if (record.data.ty === 'dir') {
@@ -1086,6 +1108,20 @@ Ext.define('PBS.DataStoreContent', {
: 'pmx-hidden',
isActionDisabled: (v, r, c, i, rec) => !!rec.data.leaf,
},
+ {
+ handler: 'onMove',
+ getTip: (v, m, { data }) => {
+ if (data.ty === 'group') {
+ return Ext.String.format(gettext("Move group '{0}'"), v);
+ }
+ return '';
+ },
+ getClass: (v, m, { data }) => {
+ if (data.ty === 'group') { return 'fa fa-arrows'; }
+ return 'pmx-hidden';
+ },
+ isActionDisabled: (v, r, c, i, { data }) => false,
+ },
{
handler: 'onChangeOwner',
getClass: (v, m, { data }) =>
@@ -1438,6 +1474,7 @@ Ext.define('PBS.datastore.GroupCmdMenu', {
onVerify: undefined,
onChangeOwner: undefined,
onPrune: undefined,
+ onMove: undefined,
onForget: undefined,
items: [
@@ -1461,6 +1498,16 @@ Ext.define('PBS.datastore.GroupCmdMenu', {
hidden: '{!onVerify}',
},
},
+ {
+ text: gettext('Move'),
+ iconCls: 'fa fa-arrows',
+ handler: function () {
+ this.up('menu').onMove();
+ },
+ cbind: {
+ hidden: '{!onMove}',
+ },
+ },
{
text: gettext('Change owner'),
iconCls: 'fa fa-user',
diff --git a/www/window/GroupMove.js b/www/window/GroupMove.js
new file mode 100644
index 00000000..6bfb0bca
--- /dev/null
+++ b/www/window/GroupMove.js
@@ -0,0 +1,56 @@
+Ext.define('PBS.window.GroupMove', {
+ extend: 'Proxmox.window.Edit',
+ alias: 'widget.pbsGroupMove',
+ mixins: ['Proxmox.Mixin.CBind'],
+
+ onlineHelp: 'storage-move-namespaces-groups',
+
+ isCreate: true,
+ submitText: gettext('Move'),
+ showTaskViewer: true,
+
+ cbind: {
+ url: '/api2/extjs/admin/datastore/{datastore}/move-group',
+ title: (get) => Ext.String.format(gettext("Move Backup Group '{0}'"), get('group')),
+ },
+ method: 'POST',
+
+ width: 400,
+ fieldDefaults: {
+ labelWidth: 120,
+ },
+
+ items: {
+ xtype: 'inputpanel',
+ onGetValues: function (values) {
+ let win = this.up('window');
+ let result = {
+ 'backup-type': win.backupType,
+ 'backup-id': win.backupId,
+ 'target-ns': values['target-ns'] || '',
+ };
+ if (win.namespace && win.namespace !== '') {
+ result.ns = win.namespace;
+ }
+ return result;
+ },
+ items: [
+ {
+ xtype: 'displayfield',
+ fieldLabel: gettext('Group'),
+ cbind: {
+ value: '{group}',
+ },
+ },
+ {
+ xtype: 'pbsNamespaceSelector',
+ name: 'target-ns',
+ fieldLabel: gettext('Target Namespace'),
+ allowBlank: true,
+ cbind: {
+ datastore: '{datastore}',
+ },
+ },
+ ],
+ },
+});
--
2.47.3
^ permalink raw reply [flat|nested] 10+ messages in thread
* [PATCH proxmox-backup v7 8/9] ui: add move namespace action
2026-04-16 17:18 [PATCH proxmox-backup v7 0/9] fixes #6195: add support for moving groups and namespaces Hannes Laimer
` (6 preceding siblings ...)
2026-04-16 17:18 ` [PATCH proxmox-backup v7 7/9] ui: add move group action Hannes Laimer
@ 2026-04-16 17:18 ` Hannes Laimer
2026-04-16 17:18 ` [PATCH proxmox-backup v7 9/9] cli: add move-namespace and move-group commands Hannes Laimer
8 siblings, 0 replies; 10+ messages in thread
From: Hannes Laimer @ 2026-04-16 17:18 UTC (permalink / raw)
To: pbs-devel
Add a "Move" action to the namespace action column. Opens a dialog
where the user selects a new parent namespace and name, then submits
a POST to the move-namespace API endpoint.
The source namespace and its descendants are excluded from the parent
selector to prevent cycles. An advanced section exposes the max-depth
and delete-source options.
Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
www/Makefile | 1 +
www/datastore/Content.js | 27 +++++++-
www/form/NamespaceSelector.js | 11 +++
www/window/NamespaceMove.js | 126 ++++++++++++++++++++++++++++++++++
4 files changed, 164 insertions(+), 1 deletion(-)
create mode 100644 www/window/NamespaceMove.js
diff --git a/www/Makefile b/www/Makefile
index 06441c02..bad243cf 100644
--- a/www/Makefile
+++ b/www/Makefile
@@ -80,6 +80,7 @@ JSSRC= \
window/CreateDirectory.js \
window/DataStoreEdit.js \
window/NamespaceEdit.js \
+ window/NamespaceMove.js \
window/MaintenanceOptions.js \
window/NotesEdit.js \
window/NotificationThresholds.js \
diff --git a/www/datastore/Content.js b/www/datastore/Content.js
index 585a1a2d..b53f9b67 100644
--- a/www/datastore/Content.js
+++ b/www/datastore/Content.js
@@ -668,6 +668,26 @@ Ext.define('PBS.DataStoreContent', {
});
},
+ moveNS: function () {
+ let me = this;
+ let view = me.getView();
+ if (!view.namespace || view.namespace === '') {
+ return;
+ }
+ let win = Ext.create('PBS.window.NamespaceMove', {
+ datastore: view.datastore,
+ namespace: view.namespace,
+ taskDone: (success) => {
+ if (success) {
+ let newNs = win.getNewNamespace();
+ view.down('pbsNamespaceSelector').store?.load();
+ me.nsChange(null, newNs);
+ }
+ },
+ });
+ win.show();
+ },
+
moveGroup: function (data) {
let me = this;
let view = me.getView();
@@ -686,6 +706,8 @@ Ext.define('PBS.DataStoreContent', {
let me = this;
if (data.ty === 'group') {
me.moveGroup(data);
+ } else if (data.ty === 'ns') {
+ me.moveNS();
}
},
@@ -1114,10 +1136,13 @@ Ext.define('PBS.DataStoreContent', {
if (data.ty === 'group') {
return Ext.String.format(gettext("Move group '{0}'"), v);
}
- return '';
+ return Ext.String.format(gettext("Move namespace '{0}'"), v);
},
getClass: (v, m, { data }) => {
if (data.ty === 'group') { return 'fa fa-arrows'; }
+ if (data.ty === 'ns' && !data.isRootNS && data.ns === undefined) {
+ return 'fa fa-arrows';
+ }
return 'pmx-hidden';
},
isActionDisabled: (v, r, c, i, { data }) => false,
diff --git a/www/form/NamespaceSelector.js b/www/form/NamespaceSelector.js
index ddf68254..d349b568 100644
--- a/www/form/NamespaceSelector.js
+++ b/www/form/NamespaceSelector.js
@@ -90,6 +90,17 @@ Ext.define('PBS.form.NamespaceSelector', {
},
});
+ if (me.excludeNs) {
+ me.store.addFilter(
+ new Ext.util.Filter({
+ filterFn: (rec) => {
+ let ns = rec.data.ns;
+ return ns !== me.excludeNs && !ns.startsWith(`${me.excludeNs}/`);
+ },
+ }),
+ );
+ }
+
me.callParent();
},
});
diff --git a/www/window/NamespaceMove.js b/www/window/NamespaceMove.js
new file mode 100644
index 00000000..59dd6d45
--- /dev/null
+++ b/www/window/NamespaceMove.js
@@ -0,0 +1,126 @@
+Ext.define('PBS.window.NamespaceMove', {
+ extend: 'Proxmox.window.Edit',
+ alias: 'widget.pbsNamespaceMove',
+ mixins: ['Proxmox.Mixin.CBind'],
+
+ onlineHelp: 'storage-move-namespaces-groups',
+
+ submitText: gettext('Move'),
+ isCreate: true,
+ showTaskViewer: true,
+
+ cbind: {
+ url: '/api2/extjs/admin/datastore/{datastore}/move-namespace',
+ title: (get) => Ext.String.format(gettext("Move Namespace '{0}'"), get('namespace')),
+ },
+ method: 'POST',
+
+ width: 450,
+ fieldDefaults: {
+ labelWidth: 120,
+ },
+
+ cbindData: function (initialConfig) {
+ let ns = initialConfig.namespace ?? '';
+ let parts = ns.split('/');
+ return { nsName: parts[parts.length - 1] };
+ },
+
+ // Returns the target-ns path that was submitted, for use by the caller after success.
+ getNewNamespace: function () {
+ let me = this;
+ let parent = me.down('[name=parent]').getValue() || '';
+ let name = me.down('[name=name]').getValue();
+ return parent ? `${parent}/${name}` : name;
+ },
+
+ items: {
+ xtype: 'inputpanel',
+ onGetValues: function (values) {
+ let parent = values.parent || '';
+ let newNs = parent ? `${parent}/${values.name}` : values.name;
+ let result = {
+ ns: this.up('window').namespace,
+ 'target-ns': newNs,
+ };
+ if (values['delete-source'] !== undefined) {
+ result['delete-source'] = values['delete-source'] ? 1 : 0;
+ }
+ if (values['merge-groups'] !== undefined) {
+ result['merge-groups'] = values['merge-groups'] ? 1 : 0;
+ }
+ if (values['max-depth'] !== undefined && values['max-depth'] !== '') {
+ result['max-depth'] = values['max-depth'];
+ }
+ return result;
+ },
+ items: [
+ {
+ xtype: 'displayfield',
+ fieldLabel: gettext('Namespace'),
+ cbind: {
+ value: '{namespace}',
+ },
+ },
+ {
+ xtype: 'pbsNamespaceSelector',
+ name: 'parent',
+ fieldLabel: gettext('New Parent'),
+ allowBlank: true,
+ cbind: {
+ datastore: '{datastore}',
+ excludeNs: '{namespace}',
+ },
+ },
+ {
+ xtype: 'proxmoxtextfield',
+ name: 'name',
+ fieldLabel: gettext('New Name'),
+ allowBlank: false,
+ maxLength: 31,
+ regex: PBS.Utils.SAFE_ID_RE,
+ regexText: gettext("Only alpha numerical, '_' and '-' (if not at start) allowed"),
+ cbind: {
+ value: '{nsName}',
+ },
+ },
+ ],
+ advancedItems: [
+ {
+ xtype: 'proxmoxintegerfield',
+ name: 'max-depth',
+ fieldLabel: gettext('Max Depth'),
+ allowBlank: true,
+ emptyText: gettext('Unlimited'),
+ minValue: 0,
+ maxValue: 8,
+ autoEl: {
+ tag: 'div',
+ 'data-qtip': gettext('Limit how many levels of child namespaces to include. Leave empty to move the entire subtree.'),
+ },
+ },
+ {
+ xtype: 'proxmoxcheckbox',
+ name: 'merge-groups',
+ fieldLabel: gettext('Merge Groups'),
+ checked: true,
+ uncheckedValue: 0,
+ autoEl: {
+ tag: 'div',
+ 'data-qtip': gettext('Merge snapshots into existing groups with the same name in the target namespace. Requires matching ownership and non-overlapping snapshot times.'),
+ },
+ },
+ {
+ xtype: 'proxmoxcheckbox',
+ name: 'delete-source',
+ fieldLabel: gettext('Delete Source'),
+ checked: true,
+ uncheckedValue: 0,
+ autoEl: {
+ tag: 'div',
+ 'data-qtip': gettext('Remove the empty source namespace directories after moving all groups. Uncheck to keep the namespace structure.'),
+ },
+ },
+ ],
+ },
+});
--
2.47.3
^ permalink raw reply [flat|nested] 10+ messages in thread
* [PATCH proxmox-backup v7 9/9] cli: add move-namespace and move-group commands
2026-04-16 17:18 [PATCH proxmox-backup v7 0/9] fixes #6195: add support for moving groups and namespaces Hannes Laimer
` (7 preceding siblings ...)
2026-04-16 17:18 ` [PATCH proxmox-backup v7 8/9] ui: add move namespace action Hannes Laimer
@ 2026-04-16 17:18 ` Hannes Laimer
8 siblings, 0 replies; 10+ messages in thread
From: Hannes Laimer @ 2026-04-16 17:18 UTC (permalink / raw)
To: pbs-devel
Add 'move-namespace' and 'move-group' subcommands to
proxmox-backup-manager datastore. Both call the corresponding API
handler and wait for the worker task to complete.
move-namespace accepts optional --max-depth and --delete-source
flags matching the API parameters.
Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
src/bin/proxmox_backup_manager/datastore.rs | 109 +++++++++++++++++++-
1 file changed, 108 insertions(+), 1 deletion(-)
diff --git a/src/bin/proxmox_backup_manager/datastore.rs b/src/bin/proxmox_backup_manager/datastore.rs
index 5c65c5ec..efae4cc3 100644
--- a/src/bin/proxmox_backup_manager/datastore.rs
+++ b/src/bin/proxmox_backup_manager/datastore.rs
@@ -1,5 +1,6 @@
use pbs_api_types::{
- DataStoreConfig, DataStoreConfigUpdater, DATASTORE_SCHEMA, PROXMOX_CONFIG_DIGEST_SCHEMA,
+ BackupNamespace, DataStoreConfig, DataStoreConfigUpdater, DATASTORE_SCHEMA,
+ NS_MAX_DEPTH_SCHEMA, PROXMOX_CONFIG_DIGEST_SCHEMA,
};
use pbs_client::view_task_result;
use proxmox_router::{cli::*, ApiHandler, RpcEnvironment};
@@ -323,6 +324,100 @@ async fn uuid_mount(mut param: Value, _rpcenv: &mut dyn RpcEnvironment) -> Resul
Ok(Value::Null)
}
+#[api(
+ protected: true,
+ input: {
+ properties: {
+ store: {
+ schema: DATASTORE_SCHEMA,
+ },
+ ns: {
+ type: BackupNamespace,
+ },
+ "target-ns": {
+ type: BackupNamespace,
+ },
+ "max-depth": {
+ schema: NS_MAX_DEPTH_SCHEMA,
+ optional: true,
+ },
+ "delete-source": {
+ type: bool,
+ optional: true,
+ default: true,
+ description: "Remove the source namespace after moving all contents. \
+ Defaults to true.",
+ },
+ "merge-groups": {
+ type: bool,
+ optional: true,
+ default: true,
+ description: "If a group with the same name already exists in the target \
+ namespace, merge snapshots into it. Requires matching ownership and \
+ non-overlapping snapshot times.",
+ },
+ },
+ },
+)]
+/// Move a backup namespace to a new location within the same datastore.
+async fn cli_move_namespace(
+ mut param: Value,
+ rpcenv: &mut dyn RpcEnvironment,
+) -> Result<(), Error> {
+ param["node"] = "localhost".into();
+
+ let info = &api2::admin::namespace::API_METHOD_MOVE_NAMESPACE;
+ let result = match info.handler {
+ ApiHandler::Sync(handler) => (handler)(param, info, rpcenv)?,
+ _ => unreachable!(),
+ };
+
+ crate::wait_for_local_worker(result.as_str().unwrap()).await?;
+ Ok(())
+}
+
+#[api(
+ protected: true,
+ input: {
+ properties: {
+ store: {
+ schema: DATASTORE_SCHEMA,
+ },
+ ns: {
+ type: BackupNamespace,
+ },
+ group: {
+ type: pbs_api_types::BackupGroup,
+ flatten: true,
+ },
+ "target-ns": {
+ type: BackupNamespace,
+ },
+ "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.",
+ },
+ },
+ },
+)]
+/// Move a backup group to a different namespace within the same datastore.
+async fn cli_move_group(mut param: Value, rpcenv: &mut dyn RpcEnvironment) -> Result<(), Error> {
+ param["node"] = "localhost".into();
+
+ let info = &api2::admin::datastore::API_METHOD_MOVE_GROUP;
+ let result = match info.handler {
+ ApiHandler::Sync(handler) => (handler)(param, info, rpcenv)?,
+ _ => unreachable!(),
+ };
+
+ crate::wait_for_local_worker(result.as_str().unwrap()).await?;
+ Ok(())
+}
+
#[api(
protected: true,
input: {
@@ -407,6 +502,18 @@ pub fn datastore_commands() -> CommandLineInterface {
CliCommand::new(&API_METHOD_DELETE_DATASTORE)
.arg_param(&["name"])
.completion_cb("name", pbs_config::datastore::complete_datastore_name),
+ )
+ .insert(
+ "move-namespace",
+ CliCommand::new(&API_METHOD_CLI_MOVE_NAMESPACE)
+ .arg_param(&["store"])
+ .completion_cb("store", pbs_config::datastore::complete_datastore_name),
+ )
+ .insert(
+ "move-group",
+ CliCommand::new(&API_METHOD_CLI_MOVE_GROUP)
+ .arg_param(&["store"])
+ .completion_cb("store", pbs_config::datastore::complete_datastore_name),
);
cmd_def.into()
--
2.47.3
^ permalink raw reply [flat|nested] 10+ messages in thread
end of thread, other threads:[~2026-04-16 17:19 UTC | newest]
Thread overview: 10+ messages (download: mbox.gz / follow: Atom feed)
-- links below jump to the message on this page --
2026-04-16 17:18 [PATCH proxmox-backup v7 0/9] fixes #6195: add support for moving groups and namespaces Hannes Laimer
2026-04-16 17:18 ` [PATCH proxmox-backup v7 1/9] ui: show empty groups Hannes Laimer
2026-04-16 17:18 ` [PATCH proxmox-backup v7 2/9] datastore: add move-group Hannes Laimer
2026-04-16 17:18 ` [PATCH proxmox-backup v7 3/9] datastore: add move-namespace Hannes Laimer
2026-04-16 17:18 ` [PATCH proxmox-backup v7 4/9] docs: add section on moving namespaces and groups Hannes Laimer
2026-04-16 17:18 ` [PATCH proxmox-backup v7 5/9] api: add POST endpoint for move-group Hannes Laimer
2026-04-16 17:18 ` [PATCH proxmox-backup v7 6/9] api: add POST endpoint for move-namespace Hannes Laimer
2026-04-16 17:18 ` [PATCH proxmox-backup v7 7/9] ui: add move group action Hannes Laimer
2026-04-16 17:18 ` [PATCH proxmox-backup v7 8/9] ui: add move namespace action Hannes Laimer
2026-04-16 17:18 ` [PATCH proxmox-backup v7 9/9] cli: add move-namespace and move-group commands Hannes Laimer
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox