From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [212.224.123.68]) by lore.proxmox.com (Postfix) with ESMTPS id 879E41FF189 for ; Fri, 24 Apr 2026 14:24:31 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id BD6F418019; Fri, 24 Apr 2026 14:00:52 +0200 (CEST) From: Hannes Laimer To: pbs-devel@lists.proxmox.com Subject: [PATCH proxmox-backup RFC] cli: reorganize namespace and group commands Date: Fri, 24 Apr 2026 14:00:41 +0200 Message-ID: <20260424120041.221676-1-h.laimer@proxmox.com> X-Mailer: git-send-email 2.47.3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1777031955183 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.080 Adjusted score from AWL reputation of From: address BAYES_00 -1.9 Bayes spam probability is 0 to 1% DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Message-ID-Hash: LBWPXOSYZ4GLWVFTR5KQ33ZYILPS6BX5 X-Message-ID-Hash: LBWPXOSYZ4GLWVFTR5KQ33ZYILPS6BX5 X-MailFrom: h.laimer@proxmox.com X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header X-Mailman-Version: 3.3.10 Precedence: list List-Id: Proxmox Backup Server development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: Move 'datastore move-group' on the manager to 'group move' on the client. Replace 'datastore move-namespace' on the manager with a 'datastore namespace' command group (list/create/delete/move), and add a 'move' subcommand to the client's 'namespace' group. new CLI layout: client: group forget [+] group move namespace list namespace create namespace delete [+] namespace move manager: [+] datastore namespace list [+] datastore namespace create [+] datastore namespace delete [+] datastore namespace move (moved from `datastore move-namespace`) Suggested-by: Fabian Grünbichler Signed-off-by: Hannes Laimer --- would be on top of [1] [1] https://lore.proxmox.com/pbs-devel/20260424112001.206897-1-h.laimer@proxmox.com/T/#u docs/storage.rst | 18 +- proxmox-backup-client/src/group.rs | 86 ++++++-- proxmox-backup-client/src/namespace.rs | 73 ++++++- src/bin/proxmox_backup_manager/datastore.rs | 115 +---------- src/bin/proxmox_backup_manager/mod.rs | 2 + src/bin/proxmox_backup_manager/namespace.rs | 205 ++++++++++++++++++++ 6 files changed, 363 insertions(+), 136 deletions(-) create mode 100644 src/bin/proxmox_backup_manager/namespace.rs diff --git a/docs/storage.rst b/docs/storage.rst index c2405194..2d901b15 100644 --- a/docs/storage.rst +++ b/docs/storage.rst @@ -542,14 +542,14 @@ 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 +A single group can be moved with ``group move``. To relocate an entire namespace subtree (including all child namespaces and their groups), use -``move-namespace``. +``namespace move``. .. code-block:: console - # proxmox-backup-manager datastore move-group --ns --target-ns --backup-type --backup-id - # proxmox-backup-manager datastore move-namespace --ns --target-ns + # proxmox-backup-client group move / --ns --target-ns --repository + # proxmox-backup-client namespace move --target-ns --repository 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 @@ -561,16 +561,16 @@ group provided: 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``. +``group move``. .. note:: - With defaults, ``move-namespace`` merges into existing target groups + With defaults, ``namespace move`` merges into existing target groups (``merge-groups=true``) and removes source namespaces once they are empty (``delete-source=true``). Pass ``--merge-groups false`` or ``--delete-source false`` to opt out. -Optional parameters for ``move-namespace``: +Optional parameters for ``namespace move``: ``merge-groups`` Allow merging snapshots into groups that already exist in the target @@ -587,10 +587,10 @@ Optional parameters for ``move-namespace``: Required privileges: -- ``move-group``: ``DATASTORE_PRUNE`` on the source namespace and +- ``group move``: ``DATASTORE_PRUNE`` on the source namespace and ``DATASTORE_BACKUP`` on the target namespace, plus ownership of the backup group; or ``DATASTORE_MODIFY`` on both. -- ``move-namespace``: ``DATASTORE_MODIFY`` on the parent of both the +- ``namespace move``: ``DATASTORE_MODIFY`` on the parent of both the source and the target namespace. diff --git a/proxmox-backup-client/src/group.rs b/proxmox-backup-client/src/group.rs index 42cb7ab7..a4291aa6 100644 --- a/proxmox-backup-client/src/group.rs +++ b/proxmox-backup-client/src/group.rs @@ -1,25 +1,38 @@ use anyhow::{bail, Error}; use serde_json::Value; -use proxmox_router::cli::{CliCommand, CliCommandMap, Confirmation}; +use proxmox_router::cli::{ + extract_output_format, CliCommand, CliCommandMap, Confirmation, OUTPUT_FORMAT, +}; use proxmox_schema::api; use crate::{ complete_backup_group, complete_namespace, complete_repository, merge_group_into, - BackupTargetArgs, + optional_ns_param, record_repository, BackupTargetArgs, }; -use pbs_api_types::BackupGroup; +use pbs_api_types::{BackupGroup, BackupNamespace}; use pbs_client::tools::{connect, remove_repository_from_value}; +use pbs_client::view_task_result; pub fn group_mgmt_cli() -> CliCommandMap { - CliCommandMap::new().insert( - "forget", - CliCommand::new(&API_METHOD_FORGET_GROUP) - .arg_param(&["group"]) - .completion_cb("ns", complete_namespace) - .completion_cb("repository", complete_repository) - .completion_cb("group", complete_backup_group), - ) + CliCommandMap::new() + .insert( + "forget", + CliCommand::new(&API_METHOD_FORGET_GROUP) + .arg_param(&["group"]) + .completion_cb("ns", complete_namespace) + .completion_cb("repository", complete_repository) + .completion_cb("group", complete_backup_group), + ) + .insert( + "move", + CliCommand::new(&API_METHOD_MOVE_GROUP) + .arg_param(&["group"]) + .completion_cb("ns", complete_namespace) + .completion_cb("target-ns", complete_namespace) + .completion_cb("repository", complete_repository) + .completion_cb("group", complete_backup_group), + ) } #[api( @@ -78,3 +91,54 @@ async fn forget_group(group: String, mut param: Value) -> Result<(), Error> { Ok(()) } + +#[api( + input: { + properties: { + group: { + type: String, + description: "Backup group.", + }, + repo: { + type: BackupTargetArgs, + 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.", + }, + "output-format": { + schema: OUTPUT_FORMAT, + optional: true, + }, + } + } +)] +/// Move a backup group to a different namespace within the same datastore. +async fn move_group(group: String, mut param: Value) -> Result<(), Error> { + let output_format = extract_output_format(&mut param); + let backup_group: BackupGroup = group.parse()?; + let repo = remove_repository_from_value(&mut param)?; + let ns = optional_ns_param(¶m)?; + if !ns.is_root() { + param["ns"] = serde_json::to_value(&ns)?; + } + merge_group_into(param.as_object_mut().unwrap(), backup_group); + + let client = connect(&repo)?; + let path = format!("api2/json/admin/datastore/{}/move-group", repo.store()); + let result = client.post(&path, Some(param)).await?; + + record_repository(&repo); + + view_task_result(&client, result, &output_format).await?; + + Ok(()) +} diff --git a/proxmox-backup-client/src/namespace.rs b/proxmox-backup-client/src/namespace.rs index 15ec085a..26c5cfb5 100644 --- a/proxmox-backup-client/src/namespace.rs +++ b/proxmox-backup-client/src/namespace.rs @@ -1,16 +1,18 @@ use anyhow::{bail, Error}; use serde_json::{json, Value}; -use pbs_client::BackupTargetArgs; +use pbs_api_types::{BackupNamespace, NS_MAX_DEPTH_SCHEMA}; +use pbs_client::{view_task_result, BackupTargetArgs}; use proxmox_router::cli::{ - format_and_print_result, get_output_format, CliCommand, CliCommandMap, OUTPUT_FORMAT, + extract_output_format, format_and_print_result, get_output_format, CliCommand, CliCommandMap, + OUTPUT_FORMAT, }; use proxmox_schema::api; use crate::{ complete_namespace, connect, extract_repository_from_value, optional_ns_param, - record_repository, + record_repository, remove_repository_from_value, }; #[api( @@ -151,6 +153,64 @@ async fn delete_namespace(param: Value, delete_groups: Option) -> Result<( Ok(()) } +#[api( + input: { + properties: { + repo: { + type: BackupTargetArgs, + flatten: true, + }, + "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.", + }, + "output-format": { + schema: OUTPUT_FORMAT, + optional: true, + }, + } + }, +)] +/// Move a backup namespace to a new location within the same datastore. +async fn move_namespace(mut param: Value) -> Result<(), Error> { + let output_format = extract_output_format(&mut param); + let source_ns = optional_ns_param(¶m)?; + if source_ns.is_root() { + bail!("root namespace cannot be moved"); + } + let repo = remove_repository_from_value(&mut param)?; + // Forward the source ns even if it only came from PBS_NAMESPACE. + param["ns"] = serde_json::to_value(&source_ns)?; + + let client = connect(&repo)?; + let path = format!("api2/json/admin/datastore/{}/move-namespace", repo.store()); + let result = client.post(&path, Some(param)).await?; + + record_repository(&repo); + + view_task_result(&client, result, &output_format).await?; + + Ok(()) +} + pub fn cli_map() -> CliCommandMap { CliCommandMap::new() .insert( @@ -171,4 +231,11 @@ pub fn cli_map() -> CliCommandMap { .arg_param(&["ns"]) .completion_cb("ns", complete_namespace), ) + .insert( + "move", + CliCommand::new(&API_METHOD_MOVE_NAMESPACE) + .arg_param(&["ns"]) + .completion_cb("ns", complete_namespace) + .completion_cb("target-ns", complete_namespace), + ) } diff --git a/src/bin/proxmox_backup_manager/datastore.rs b/src/bin/proxmox_backup_manager/datastore.rs index 738d0fa5..b547cfac 100644 --- a/src/bin/proxmox_backup_manager/datastore.rs +++ b/src/bin/proxmox_backup_manager/datastore.rs @@ -1,6 +1,5 @@ use pbs_api_types::{ - BackupNamespace, DataStoreConfig, DataStoreConfigUpdater, DATASTORE_SCHEMA, - NS_MAX_DEPTH_SCHEMA, PROXMOX_CONFIG_DIGEST_SCHEMA, + DataStoreConfig, DataStoreConfigUpdater, DATASTORE_SCHEMA, PROXMOX_CONFIG_DIGEST_SCHEMA, }; use pbs_client::view_task_result; use proxmox_router::{cli::*, ApiHandler, RpcEnvironment}; @@ -324,101 +323,6 @@ async fn uuid_mount(mut param: Value, _rpcenv: &mut dyn RpcEnvironment) -> Resul Ok(Value::Null) } -#[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.", - }, - "output-format": { - schema: OUTPUT_FORMAT, - optional: true, - }, - }, - }, -)] -/// Move a backup namespace to a new location within the same datastore. -async fn cli_move_namespace(store: String, mut param: Value) -> Result<(), Error> { - let output_format = extract_output_format(&mut param); - - let client = connect_to_localhost()?; - let path = format!("api2/json/admin/datastore/{store}/move-namespace"); - let result = client.post(&path, Some(param)).await?; - - view_task_result(&client, result, &output_format).await?; - - Ok(()) -} - -#[api( - 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.", - }, - "output-format": { - schema: OUTPUT_FORMAT, - optional: true, - }, - }, - }, -)] -/// Move a backup group to a different namespace within the same datastore. -async fn cli_move_group(store: String, mut param: Value) -> Result<(), Error> { - let output_format = extract_output_format(&mut param); - - let client = connect_to_localhost()?; - let path = format!("api2/json/admin/datastore/{store}/move-group"); - let result = client.post(&path, Some(param)).await?; - - view_task_result(&client, result, &output_format).await?; - - Ok(()) -} - #[api( protected: true, input: { @@ -446,6 +350,7 @@ async fn s3_refresh(mut param: Value, rpcenv: &mut dyn RpcEnvironment) -> Result pub fn datastore_commands() -> CommandLineInterface { let cmd_def = CliCommandMap::new() .insert("list", CliCommand::new(&API_METHOD_LIST_DATASTORES)) + .insert("namespace", super::namespace_commands()) .insert( "mount", CliCommand::new(&API_METHOD_MOUNT_DATASTORE) @@ -503,22 +408,6 @@ 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) - .completion_cb("ns", crate::complete_sync_local_datastore_namespace) - .completion_cb("target-ns", crate::complete_sync_local_datastore_namespace), - ) - .insert( - "move-group", - CliCommand::new(&API_METHOD_CLI_MOVE_GROUP) - .arg_param(&["store"]) - .completion_cb("store", pbs_config::datastore::complete_datastore_name) - .completion_cb("ns", crate::complete_sync_local_datastore_namespace) - .completion_cb("target-ns", crate::complete_sync_local_datastore_namespace), ); cmd_def.into() diff --git a/src/bin/proxmox_backup_manager/mod.rs b/src/bin/proxmox_backup_manager/mod.rs index a9b02604..8204e843 100644 --- a/src/bin/proxmox_backup_manager/mod.rs +++ b/src/bin/proxmox_backup_manager/mod.rs @@ -15,6 +15,8 @@ pub use dns::*; mod ldap; pub use ldap::*; pub mod migrate_config; +mod namespace; +pub use namespace::*; mod network; pub use network::*; mod node; diff --git a/src/bin/proxmox_backup_manager/namespace.rs b/src/bin/proxmox_backup_manager/namespace.rs new file mode 100644 index 00000000..63542514 --- /dev/null +++ b/src/bin/proxmox_backup_manager/namespace.rs @@ -0,0 +1,205 @@ +use anyhow::{bail, Error}; +use serde_json::{json, Value}; + +use pbs_api_types::{BackupNamespace, DATASTORE_SCHEMA, NS_MAX_DEPTH_SCHEMA}; +use pbs_client::view_task_result; + +use proxmox_router::cli::*; +use proxmox_schema::api; + +use proxmox_backup::api2; +use proxmox_backup::client_helpers::connect_to_localhost; + +#[api( + input: { + properties: { + store: { schema: DATASTORE_SCHEMA }, + parent: { + type: BackupNamespace, + optional: true, + }, + "max-depth": { + schema: NS_MAX_DEPTH_SCHEMA, + optional: true, + }, + "output-format": { + schema: OUTPUT_FORMAT, + optional: true, + }, + } + } +)] +/// List the namespaces of a datastore. +async fn list_namespaces( + store: String, + parent: Option, + max_depth: Option, + param: Value, +) -> Result { + let output_format = get_output_format(¶m); + + let client = connect_to_localhost()?; + let path = format!("api2/json/admin/datastore/{store}/namespace"); + + let mut args = json!({}); + if let Some(parent) = parent { + args["parent"] = serde_json::to_value(parent)?; + } + if let Some(max_depth) = max_depth { + args["max-depth"] = max_depth.into(); + } + + let mut result = client.get(&path, Some(args)).await?; + let mut data = result["data"].take(); + let return_type = &api2::admin::namespace::API_METHOD_LIST_NAMESPACES.returns; + + let options = default_table_format_options() + .column(ColumnConfig::new("ns")) + .column(ColumnConfig::new("comment")); + + format_and_print_result_full(&mut data, return_type, &output_format, &options); + + Ok(Value::Null) +} + +#[api( + input: { + properties: { + store: { schema: DATASTORE_SCHEMA }, + ns: { type: BackupNamespace }, + } + } +)] +/// Create a new datastore namespace. +async fn create_namespace(store: String, mut ns: BackupNamespace) -> Result<(), Error> { + let name = match ns.pop() { + Some(name) => name, + None => bail!("root namespace is always present"), + }; + + let client = connect_to_localhost()?; + let path = format!("api2/json/admin/datastore/{store}/namespace"); + let args = json!({ "parent": ns, "name": name }); + + let _result = client.post(&path, Some(args)).await?; + + Ok(()) +} + +#[api( + input: { + properties: { + store: { schema: DATASTORE_SCHEMA }, + ns: { type: BackupNamespace }, + "delete-groups": { + type: bool, + optional: true, + default: false, + description: "If set, destroy all groups in the hierarchy below and \ + including `ns`. If not set, only empty namespaces will be pruned.", + }, + } + } +)] +/// Delete a backup namespace, optionally including all snapshots. +async fn delete_namespace( + store: String, + ns: BackupNamespace, + delete_groups: Option, +) -> Result<(), Error> { + if ns.is_root() { + bail!("root namespace cannot be deleted"); + } + + let client = connect_to_localhost()?; + let path = format!("api2/json/admin/datastore/{store}/namespace"); + + let mut args = json!({ "ns": ns }); + if let Some(delete_groups) = delete_groups { + args["delete-groups"] = delete_groups.into(); + } + + let _result = client.delete(&path, Some(args)).await?; + + Ok(()) +} + +#[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.", + }, + "output-format": { + schema: OUTPUT_FORMAT, + optional: true, + }, + } + } +)] +/// Move a backup namespace to a new location within the same datastore. +async fn move_namespace(store: String, mut param: Value) -> Result<(), Error> { + let output_format = extract_output_format(&mut param); + + let client = connect_to_localhost()?; + let path = format!("api2/json/admin/datastore/{store}/move-namespace"); + let result = client.post(&path, Some(param)).await?; + + view_task_result(&client, result, &output_format).await?; + + Ok(()) +} + +pub fn namespace_commands() -> CommandLineInterface { + let cmd_def = CliCommandMap::new() + .insert( + "list", + CliCommand::new(&API_METHOD_LIST_NAMESPACES) + .arg_param(&["store"]) + .completion_cb("store", pbs_config::datastore::complete_datastore_name) + .completion_cb("parent", crate::complete_sync_local_datastore_namespace), + ) + .insert( + "create", + CliCommand::new(&API_METHOD_CREATE_NAMESPACE) + .arg_param(&["store", "ns"]) + .completion_cb("store", pbs_config::datastore::complete_datastore_name) + .completion_cb("ns", crate::complete_sync_local_datastore_namespace), + ) + .insert( + "delete", + CliCommand::new(&API_METHOD_DELETE_NAMESPACE) + .arg_param(&["store", "ns"]) + .completion_cb("store", pbs_config::datastore::complete_datastore_name) + .completion_cb("ns", crate::complete_sync_local_datastore_namespace), + ) + .insert( + "move", + CliCommand::new(&API_METHOD_MOVE_NAMESPACE) + .arg_param(&["store", "ns", "target-ns"]) + .completion_cb("store", pbs_config::datastore::complete_datastore_name) + .completion_cb("ns", crate::complete_sync_local_datastore_namespace) + .completion_cb("target-ns", crate::complete_sync_local_datastore_namespace), + ); + + cmd_def.into() +} -- 2.47.3