* [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
` (9 more replies)
0 siblings, 10 replies; 15+ 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] 15+ 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
` (8 subsequent siblings)
9 siblings, 0 replies; 15+ 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] 15+ 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-20 14:49 ` Fabian Grünbichler
2026-04-16 17:18 ` [PATCH proxmox-backup v7 3/9] datastore: add move-namespace Hannes Laimer
` (7 subsequent siblings)
9 siblings, 1 reply; 15+ 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] 15+ 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-20 14:49 ` Fabian Grünbichler
2026-04-16 17:18 ` [PATCH proxmox-backup v7 4/9] docs: add section on moving namespaces and groups Hannes Laimer
` (6 subsequent siblings)
9 siblings, 1 reply; 15+ 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] 15+ 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
` (5 subsequent siblings)
9 siblings, 0 replies; 15+ 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] 15+ 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-20 14:49 ` Fabian Grünbichler
2026-04-16 17:18 ` [PATCH proxmox-backup v7 6/9] api: add POST endpoint for move-namespace Hannes Laimer
` (4 subsequent siblings)
9 siblings, 1 reply; 15+ 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] 15+ 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
` (3 subsequent siblings)
9 siblings, 0 replies; 15+ 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] 15+ 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
` (2 subsequent siblings)
9 siblings, 0 replies; 15+ 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] 15+ 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
2026-04-20 15:02 ` [PATCH proxmox-backup v7 0/9] fixes #6195: add support for moving groups and namespaces Fabian Grünbichler
9 siblings, 0 replies; 15+ 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] 15+ 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
2026-04-20 15:02 ` [PATCH proxmox-backup v7 0/9] fixes #6195: add support for moving groups and namespaces Fabian Grünbichler
9 siblings, 0 replies; 15+ 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] 15+ messages in thread
* Re: [PATCH proxmox-backup v7 3/9] datastore: add move-namespace
2026-04-16 17:18 ` [PATCH proxmox-backup v7 3/9] datastore: add move-namespace Hannes Laimer
@ 2026-04-20 14:49 ` Fabian Grünbichler
0 siblings, 0 replies; 15+ messages in thread
From: Fabian Grünbichler @ 2026-04-20 14:49 UTC (permalink / raw)
To: Hannes Laimer, pbs-devel
On April 16, 2026 7:18 pm, Hannes Laimer wrote:
> 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}')"
> + );
> + }
all of these are already done right before calling this, so should
either be moved into a small helper to avoid deviating, or not done
twice IMHO. we don't do anything inbetween other than forking a worker
after all..
> +
> + 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>>()?
> + };
this could just be ListNamespacesRecrusive::new_max_depth with the depth
unwrapped to the max value if not set?
> +
> + 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>>()?;
the only benefit of this is a single log line below, and the chances of
conflicts might be lower if we iterate each namespace on its own,
instead of all of them up front?
> +
> + 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(),
> + );
> + }
this is basically crate::sync::check_namespace_depth_limit, should it
move to BackupNamespace or some other place?
> +
> + 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}'")
> + })?;
this could use Datastore::create_namespace , which does some extra
checks and
> + }
> +
> + // 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")?;
also does this for us
> + }
> + }
> +
> + // 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();
this is confusingly named, these are the namespaces we *tried to* move,
not necessarily the ones we actually *moved*.
> +
> + 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())
nit: fns is a bit opaque, maybe `failed` or `failed_ns`?
should this log a warning that this NS was not cleaned up, and why?
> + {
> + continue;
> + }
> + let has_excluded_children = self
> + .iter_backup_ns(ns.clone())
> + .ok()
> + .into_iter()
> + .flatten()
> + .filter_map(|child| child.ok())
this could use iter_backup_ns_ok instead?
> + .any(|child| !moved_ns.contains(&child));
this does a lot of nested iteration now.. what we actually want to check
is if there are any namespaces left below, because if there are, we
can't remove, either because
- we didn't try to move all children in the first place (depth check)
- we didn't manage to move all children we wanted to move (failed_ns,
handled above)
- new children were created in the meantime
this check here handles the first and third case?
> + 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:#}");
> + }
> + }
we do have remove_namespace_recursive already, which is what is used by
the delete_namespace API endpoint, and do not offer a way to just delete
a single namespace with protection against removing empty child
namespaces.. should we just use that here? maybe extending it with a
delete_child_namespaces parameter, and setting that to false here and in
pull, but true in the API endpoint?
> + }
> + }
> +
> + 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] 15+ messages in thread
* Re: [PATCH proxmox-backup v7 2/9] datastore: add move-group
2026-04-16 17:18 ` [PATCH proxmox-backup v7 2/9] datastore: add move-group Hannes Laimer
@ 2026-04-20 14:49 ` Fabian Grünbichler
2026-04-21 10:43 ` Hannes Laimer
0 siblings, 1 reply; 15+ messages in thread
From: Fabian Grünbichler @ 2026-04-20 14:49 UTC (permalink / raw)
To: Hannes Laimer, pbs-devel
On April 16, 2026 7:18 pm, Hannes Laimer wrote:
> 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> {
this could take the other group right away? there's only a single call
site (below) that already has the instance of the target group
available.. and it would make it possible to re-use this logic for
inter-datastore group merge checks in the future as well ;)
> + 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,
> + );
this error is lacking important context - we are attempting to merge two
groups, so we should mention both of them (or both namespaces)?
> + }
> +
> + let src_oldest = self
> + .iter_snapshots()?
> + .collect::<Result<Vec<_>, _>>()?
this collect is not needed (and should probably be avoided if there are
a lot of snapshots ;)).
let iter = self.iter_snapshots()?;
let oldest = iter
.filter_map(|s| s.ok())
.map(|s| s.backup_time())
.min();
> + .iter()
> + .map(|s| s.backup_time())
> + .min();
> + let tgt_newest = target_group
> + .iter_snapshots()?
> + .collect::<Result<Vec<_>, _>>()?
> + .iter()
> + .map(|s| s.backup_time())
> + .max();
same here, though instead of max we could short-circuit and check for
the first snapshot that is >= src_oldest?
> + 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(
should this be renamed to `move` and implemented on `BackupDir`? &self
is only used for accessing the store path/name, and that is available in
exactly the same fashion there..
> + &self,
> + snap: &BackupDir,
> + target_group_path: &Path,
shouldn't this be a BackupGroup? or even a BackupDir?
> + backend: &DatastoreBackend,
> + ) -> Result<(), Error> {
> + let src_snap_path = snap.full_path();
> + let dst_snap_path = target_group_path.join(snap.backup_time_string());
should we assert here that source and target are in the same store?
> +
> + 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())
then we could use relative_path here as well?
> + .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(
this can be private
> + self: &Arc<Self>,
> + group: &BackupGroup,
> + source_ns: &BackupNamespace,
> + target_ns: &BackupNamespace,
the order of parameters is different than above..
> + 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)?;
this (continued below)
> + 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());
if we need the full list of sorted backups, why not use
group.list_backups() and BackupInfo::sort_list ?
> +
> + // 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(),
> + );
> + }
> + }
> + }
(continued from above) and this could be replaced with a call to
`create_locked_backup_group`. downside - differentiating between
"currently locked" and "other error" is harder, but the upside is that
that code already handles a lot of the logic for us, and is how backup,
tape restore and pull jobs create new groups or add snapshots to
existing groups..
maybe it would be worth it to refactor that implementation, and a add
new non-pub variant/wrapper that
- returns whether it created the group or not
- allows us to differentiate locking from other errors?
> +
> + 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<_>, _>>()?;
this is funky, but I guess it does the job ;) I'd feel a bit more
comfortable if the Vec<_> were explicit though..
> +
> + for snap in chunk {
> + group
> + .move_snapshot(snap, &target_group_path, backend)
> + .map_err(MoveGroupError::Hard)?;
see above, I think we can leverage the type system a bit more here..
> + }
> + // 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) {
should this be a rename? should we warn if merging and the notes have
diverged? what if this is on S3?
> + warn!(
> + "failed to copy group notes from {src_notes_path:?} to {dst_notes_path:?}: {err}"
> + );
> + }
> + }
> +
the part starting here is basically group.destroy(), but we already have
the lock.. since it's pbs-datastore internal, we could change it to
consume the lock, and call it here? it only has two call sites, and we
don't risk the logic below diverging if new additional files are added
in the future..
> + // 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] 15+ messages in thread
* Re: [PATCH proxmox-backup v7 5/9] api: add POST endpoint for move-group
2026-04-16 17:18 ` [PATCH proxmox-backup v7 5/9] api: add POST endpoint for move-group Hannes Laimer
@ 2026-04-20 14:49 ` Fabian Grünbichler
0 siblings, 0 replies; 15+ messages in thread
From: Fabian Grünbichler @ 2026-04-20 14:49 UTC (permalink / raw)
To: Hannes Laimer, pbs-devel
On April 16, 2026 7:18 pm, Hannes Laimer wrote:
> 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");
> + }
this check
> + if !datastore.namespace_exists(&target_ns) {
> + bail!("target namespace '{target_ns}' does not exist");
> + }
this check
> + let source_group = datastore.backup_group(ns.clone(), group.clone());
> + if !source_group.exists() {
> + bail!("group '{group}' does not exist in namespace '{ns}'");
> + }
and this check are all done right away again after forking the worker
(before locking), but not again after locking.. should we maybe have a
helper fn?
> + 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"
> + );
> + }
this one is checked again after locking only..
> +
> + 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] 15+ messages in thread
* Re: [PATCH proxmox-backup v7 0/9] fixes #6195: add support for moving groups and namespaces
2026-04-16 17:18 [PATCH proxmox-backup v7 0/9] fixes #6195: add support for moving groups and namespaces Hannes Laimer
` (8 preceding siblings ...)
2026-04-16 17:18 ` [PATCH proxmox-backup v7 9/9] cli: add move-namespace and move-group commands Hannes Laimer
@ 2026-04-20 15:02 ` Fabian Grünbichler
9 siblings, 0 replies; 15+ messages in thread
From: Fabian Grünbichler @ 2026-04-20 15:02 UTC (permalink / raw)
To: Hannes Laimer, pbs-devel
On April 16, 2026 7:18 pm, Hannes Laimer wrote:
> 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.
I think the basic approach looks workable now, but there is still some
code duplication/refactoring that would make the whole thing nicer and
especially easier to maintain down the line..
I haven't taken a close look at the UI side of things yet though
> 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] 15+ messages in thread
* Re: [PATCH proxmox-backup v7 2/9] datastore: add move-group
2026-04-20 14:49 ` Fabian Grünbichler
@ 2026-04-21 10:43 ` Hannes Laimer
0 siblings, 0 replies; 15+ messages in thread
From: Hannes Laimer @ 2026-04-21 10:43 UTC (permalink / raw)
To: Fabian Grünbichler, pbs-devel
On 2026-04-20 16:48, Fabian Grünbichler wrote:
> On April 16, 2026 7:18 pm, Hannes Laimer wrote:
>> 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>
>> ---
[..]
>> + /// 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(
>
> this can be private
>
>> + self: &Arc<Self>,
>> + group: &BackupGroup,
>> + source_ns: &BackupNamespace,
>> + target_ns: &BackupNamespace,
>
> the order of parameters is different than above..
>
>> + 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)?;
>
> this (continued below)
>
>> + 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());
>
> if we need the full list of sorted backups, why not use
> group.list_backups() and BackupInfo::sort_list ?
>
list_backups() does do extra IO (`list_backup_files()`), without us
actually needing it
thanks for taking a look! will address the rest in a v8 :)
>> +
>> + // 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(),
>> + );
>> + }
>> + }
>> + }
>
> (continued from above) and this could be replaced with a call to
> `create_locked_backup_group`. downside - differentiating between
> "currently locked" and "other error" is harder, but the upside is that
> that code already handles a lot of the logic for us, and is how backup,
> tape restore and pull jobs create new groups or add snapshots to
> existing groups..
>
> maybe it would be worth it to refactor that implementation, and a add
> new non-pub variant/wrapper that
> - returns whether it created the group or not
> - allows us to differentiate locking from other errors?
>
>> +
>> + 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<_>, _>>()?;
>
> this is funky, but I guess it does the job ;) I'd feel a bit more
> comfortable if the Vec<_> were explicit though..
>
>> +
>> + for snap in chunk {
>> + group
>> + .move_snapshot(snap, &target_group_path, backend)
>> + .map_err(MoveGroupError::Hard)?;
>
> see above, I think we can leverage the type system a bit more here..
>
>> + }
>> + // 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) {
>
> should this be a rename? should we warn if merging and the notes have
> diverged? what if this is on S3?
>
>> + warn!(
>> + "failed to copy group notes from {src_notes_path:?} to {dst_notes_path:?}: {err}"
>> + );
>> + }
>> + }
>> +
[..]
^ permalink raw reply [flat|nested] 15+ messages in thread
end of thread, other threads:[~2026-04-21 10:44 UTC | newest]
Thread overview: 15+ 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-20 14:49 ` Fabian Grünbichler
2026-04-21 10:43 ` Hannes Laimer
2026-04-16 17:18 ` [PATCH proxmox-backup v7 3/9] datastore: add move-namespace Hannes Laimer
2026-04-20 14:49 ` Fabian Grünbichler
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-20 14:49 ` Fabian Grünbichler
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
2026-04-20 15:02 ` [PATCH proxmox-backup v7 0/9] fixes #6195: add support for moving groups and namespaces Fabian Grünbichler
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox