From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from gate001.proxmox.com (gate001.proxmox.com [IPv6:2a0f:8001:1:32::40]) by lore.proxmox.com (Postfix) with ESMTPS id C2EE51FF0AF for ; Fri, 06 Nov 2026 18:25:04 +0100 (CET) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id E4518218AA; Fri, 06 Nov 2026 18:23:49 +0100 (CET) From: Christian Ebner To: pbs-devel@lists.proxmox.com Subject: [PATCH proxmox-backup v2 41/43] client: add flags to set snapshot retention via timestamp/timespan Date: Fri, 6 Nov 2026 18:22:33 +0100 Message-ID: <20261106172235.890542-42-c.ebner@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20261106172235.890542-1-c.ebner@proxmox.com> References: <20261106172235.890542-1-c.ebner@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1793985781574 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.439 Adjusted score from AWL reputation of From: address DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment (newer systems) RCVD_IN_DNSWL_MED -2.3 Sender listed at https://www.dnswl.org/, medium trust 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: RGVVRYFQUP5E4KZ2HB2JDL34HPJ72UA2 X-Message-ID-Hash: RGVVRYFQUP5E4KZ2HB2JDL34HPJ72UA2 X-MailFrom: c.ebner@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: Add `retention-timespan` and `retain-until` options to the clients backup command. Since the client does not a-priori know potential limits and defaults configured on the target datastore, fetch this information on-demand to check already client side for incompatibility. Signed-off-by: Christian Ebner --- proxmox-backup-client/src/main.rs | 77 +++++++++++++++++++-- proxmox-backup-client/src/snapshot.rs | 98 ++++++++++++++++++++++++++- 2 files changed, 169 insertions(+), 6 deletions(-) diff --git a/proxmox-backup-client/src/main.rs b/proxmox-backup-client/src/main.rs index bfd26897d..ec83f0c81 100644 --- a/proxmox-backup-client/src/main.rs +++ b/proxmox-backup-client/src/main.rs @@ -28,9 +28,9 @@ use pxar::accessor::{MaybeReady, ReadAt, ReadAtOperation}; use pbs_api_types::{ ArchiveType, Authid, BACKUP_ID_SCHEMA, BACKUP_TIME_SCHEMA, BACKUP_TYPE_SCHEMA, BackupArchiveName, BackupDir, BackupGroup, BackupNamespace, BackupPart, BackupType, - CATALOG_NAME, ClientRateLimitConfig, CryptMode, ENCRYPTED_KEY_BLOB_NAME, Fingerprint, - GroupListItem, MANIFEST_BLOB_NAME, PathPattern, PruneJobOptions, PruneListItem, - RateLimitConfig, ServerIdentity, SnapshotListItem, StorageStatus, + CATALOG_NAME, ClientRateLimitConfig, CryptMode, DataStoreStatus, ENCRYPTED_KEY_BLOB_NAME, + Fingerprint, GroupListItem, MANIFEST_BLOB_NAME, PathPattern, PruneJobOptions, PruneListItem, + RateLimitConfig, RetentionTimespan, ServerIdentity, SnapshotListItem, StorageStatus, }; use pbs_client::catalog_shell::Shell; use pbs_client::pxar::{ErrorHandler as PxarErrorHandler, MetadataArchiveReader, PxarPrevRef}; @@ -794,6 +794,15 @@ fn spawn_catalog_upload( optional: true, default: false, }, + "retain-until": { + type: String, + description: "Retention timestamp in RFC3339 UTC format.", + optional: true, + }, + "retention-timespan": { + type: RetentionTimespan, + optional: true, + }, } } )] @@ -987,11 +996,65 @@ async fn create_backup( let backup_time = backup_time_opt.unwrap_or_else(epoch_i64); + let retain_until_param = match param["retain-until"].as_str() { + Some(s) => Some(proxmox_time::parse_rfc3339(s)?), + None => None, + }; + let retention_timespan_param: Option = + match param["retention-timespan"].as_str() { + Some(s) => Some(s.parse()?), + None => None, + }; + + let retain_until = match (retain_until_param, retention_timespan_param) { + (Some(_), Some(_)) => { + bail!("cannot specify both '--retain-until' and '--retention-timespan'") + } + (Some(timestamp), None) => Some(timestamp), + (None, Some(timespan)) => Some(timespan.to_timestamp_from_systemtime()?), + (None, None) => None, + }; + let detection_mode = change_detection_mode.unwrap_or_default(); let http_client = connect_rate_limited(&repo, rate_limit)?; record_repository(&repo); + // Check server retention limits and validate requested retention + let datastore_status = match http_client + .get( + &format!("api2/json/admin/datastore/{}/status", repo.store()), + None, + ) + .await + { + Ok(mut res) => serde_json::from_value::(res["data"].take()).ok(), + Err(_) => None, + }; + + if let Some(status) = &datastore_status { + if let Some(target_retention) = retain_until { + if let Some(max_retention) = &status.max_retention_timespan { + let max_timestamp = max_retention.to_timestamp_from_systemtime()?; + if target_retention > max_timestamp { + bail!( + "requested retention timestamp '{}' exceeds datastore limit of '{}' (max retention: {})", + proxmox_time::epoch_to_rfc3339_utc(target_retention)?, + proxmox_time::epoch_to_rfc3339_utc(max_timestamp)?, + max_retention, + ); + } + } else { + bail!( + "datastore '{}' has no maximum retention timespan configured; retention cannot be set", + repo.store() + ); + } + } else if let Some(default_retention) = &status.default_retention_timespan { + log::info!("Server datastore default retention: {default_retention}"); + } + } + let snapshot = BackupDir::from((backup_type, backup_id.to_owned(), backup_time)); if backup_ns.is_root() { log::info!("Starting backup: {snapshot}"); @@ -1007,6 +1070,12 @@ async fn create_backup( "Starting backup protocol: {}", strftime_local("%c", epoch_i64())? ); + if let Some(retain_until) = retain_until { + log::info!( + "Requesting retention until: {}", + proxmox_time::epoch_to_rfc3339_utc(retain_until)?, + ); + } let (crypt_config, rsa_encrypted_key) = match crypto.enc_key { None => (None, None), @@ -1054,7 +1123,7 @@ async fn create_backup( debug: true, benchmark: false, no_cache, - retain_until: None, + retain_until, }, ) .await?; diff --git a/proxmox-backup-client/src/snapshot.rs b/proxmox-backup-client/src/snapshot.rs index 438d03b5a..4a751c5da 100644 --- a/proxmox-backup-client/src/snapshot.rs +++ b/proxmox-backup-client/src/snapshot.rs @@ -1,13 +1,15 @@ use std::sync::Arc; -use anyhow::Error; +use anyhow::{Error, bail}; use serde_json::{Value, json}; use proxmox_router::cli::*; use proxmox_schema::api; use proxmox_sys::fs::file_get_contents; -use pbs_api_types::{BackupGroup, BackupNamespace, CryptMode, SnapshotListItem}; +use pbs_api_types::{ + BackupGroup, BackupNamespace, CryptMode, DataStoreStatus, RetentionTimespan, SnapshotListItem, +}; use pbs_client::tools::key_source::get_encryption_key_password; use pbs_datastore::DataBlob; use pbs_key_config::decrypt_key; @@ -479,10 +481,102 @@ fn notes_cli() -> CliCommandMap { ) } +#[api( + input: { + properties: { + repo: { + type: BackupTargetArgs, + flatten: true, + }, + snapshot: { + type: String, + description: "Snapshot path.", + }, + "retain-until": { + type: String, + description: "Retention timestamp in RFC3339 UTC format.", + optional: true, + }, + "retention-timespan": { + type: RetentionTimespan, + optional: true, + }, + } + } +)] +/// Set or extend the retention timestamp of a snapshot +async fn update_retention(param: Value) -> Result<(), Error> { + let repo = extract_repository_from_value(¶m)?; + let path = required_string_param(¶m, "snapshot")?; + + let retain_until_param = match param["retain-until"].as_str() { + Some(s) => Some(proxmox_time::parse_rfc3339(s)?), + None => None, + }; + + let retention_timespan_param: Option = + match param["retention-timespan"].as_str() { + Some(s) => Some(s.parse()?), + None => None, + }; + + let retain_until = match (retain_until_param, retention_timespan_param) { + (Some(_), Some(_)) => { + bail!("cannot specify both '--retain-until' and '--retention-timespan'") + } + (Some(timestamp), None) => timestamp, + (None, Some(timespan)) => timespan.to_timestamp_from_systemtime()?, + (None, None) => bail!("must specify either '--retain-until' or '--retention-timespan'"), + }; + + let backup_ns = optional_ns_param(¶m)?; + let snapshot: BackupDir = path.parse()?; + let client = connect(&repo)?; + let api_path = format!("api2/json/admin/datastore/{}/status", repo.store()); + + if let Ok(mut status_res) = client.get(&api_path, None).await { + if let Ok(status) = serde_json::from_value::(status_res["data"].take()) { + if let Some(max_retention) = &status.max_retention_timespan { + let max_timestamp = max_retention.to_timestamp_from_systemtime()?; + if retain_until > max_timestamp { + bail!( + "requested retention timestamp '{}' exceeds datastore limit of '{}' (max retention: {})", + proxmox_time::epoch_to_rfc3339_utc(retain_until)?, + proxmox_time::epoch_to_rfc3339_utc(max_timestamp)?, + max_retention, + ); + } + } + } + } + + let path = format!("api2/json/admin/datastore/{}/retention", repo.store()); + let mut args = snapshot_args(&backup_ns, &snapshot)?; + args["retain-until"] = Value::from(retain_until); + + client.put(&path, Some(args)).await?; + + let date_str = proxmox_time::epoch_to_rfc3339_utc(retain_until)?; + log::info!("successfully set retention until {date_str}"); + + Ok(()) +} + +fn retention_cli() -> CliCommandMap { + CliCommandMap::new().insert( + "update", + CliCommand::new(&API_METHOD_UPDATE_RETENTION) + .arg_param(&["snapshot"]) + .completion_cb("ns", complete_namespace) + .completion_cb("snapshot", complete_backup_snapshot), + ) +} + pub fn snapshot_mgtm_cli() -> CliCommandMap { CliCommandMap::new() .insert("notes", notes_cli()) .insert("protected", protected_cli()) + .insert("retention", retention_cli()) .insert( "list", CliCommand::new(&API_METHOD_LIST_SNAPSHOTS) -- 2.47.3