[pbs-devel] [PATCH proxmox-backup 1/1] Automatically select a drive (if part of a changer) when loading tapes

Laurențiu Leahu-Vlăducu l.leahu-vladucu at proxmox.com
Thu Jan 16 12:51:09 CET 2025


Signed-off-by: Laurențiu Leahu-Vlăducu <l.leahu-vladucu at proxmox.com>
---
 Cargo.toml                        |   2 +
 pbs-api-types/src/tape/changer.rs |  14 +++-
 src/api2/tape/changer.rs          | 117 +++++++++++++++++++++++++++++-
 src/bin/proxmox-backup-api.rs     |   2 +
 src/bin/proxmox-backup-proxy.rs   |   2 +
 src/bin/proxmox-tape.rs           |  50 +++++++++++--
 src/tape/changer/mod.rs           |  19 ++++-
 src/tape/drive/virtual_tape.rs    |   8 +-
 src/tape/drive_info.rs            |  51 +++++++++++++
 src/tape/mod.rs                   |   1 +
 src/tools/mod.rs                  |   7 ++
 www/tape/ChangerStatus.js         |  16 +++-
 12 files changed, 274 insertions(+), 15 deletions(-)
 create mode 100644 src/tape/drive_info.rs

diff --git a/Cargo.toml b/Cargo.toml
index adeeb6ef..348d1cea 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -72,6 +72,7 @@ proxmox-ldap = "0.2.1"
 proxmox-metrics = "0.3.1"
 proxmox-notify = "0.5.1"
 proxmox-openid = "0.10.0"
+proxmox-product-config = "0.2.2"
 proxmox-rest-server = { version = "0.8.5", features = [ "templates" ] }
 # some use "cli", some use "cli" and "server", pbs-config uses nothing
 proxmox-router = { version = "3.0.0", default-features = false }
@@ -221,6 +222,7 @@ proxmox-ldap.workspace = true
 proxmox-metrics.workspace = true
 proxmox-notify = { workspace = true, features = [ "pbs-context" ] }
 proxmox-openid.workspace = true
+proxmox-product-config.workspace = true
 proxmox-rest-server = { workspace = true, features = [ "rate-limited-stream" ] }
 proxmox-router = { workspace = true, features = [ "cli", "server"] }
 proxmox-schema = { workspace = true, features = [ "api-macro" ] }
diff --git a/pbs-api-types/src/tape/changer.rs b/pbs-api-types/src/tape/changer.rs
index df3823cf..52ee08db 100644
--- a/pbs-api-types/src/tape/changer.rs
+++ b/pbs-api-types/src/tape/changer.rs
@@ -8,10 +8,20 @@ use proxmox_schema::{
 
 use crate::{OptionalDeviceIdentification, PROXMOX_SAFE_ID_FORMAT};
 
+const TAPE_CHANGER_MIN_LENGTH: usize = 3;
+const TAPE_CHANGER_MAX_LENGTH: usize = 32;
+
 pub const CHANGER_NAME_SCHEMA: Schema = StringSchema::new("Tape Changer Identifier.")
     .format(&PROXMOX_SAFE_ID_FORMAT)
-    .min_length(3)
-    .max_length(32)
+    .min_length(TAPE_CHANGER_MIN_LENGTH)
+    .max_length(TAPE_CHANGER_MAX_LENGTH)
+    .schema();
+
+pub const CHANGER_NAME_SCHEMA_AUTOMATIC_DRIVE_ASIGNMENT: Schema =
+    StringSchema::new("Tape Changer Identifier to be used for automatic tape drive assignment.")
+    .format(&PROXMOX_SAFE_ID_FORMAT)
+    .min_length(TAPE_CHANGER_MIN_LENGTH)
+    .max_length(TAPE_CHANGER_MAX_LENGTH)
     .schema();
 
 pub const SCSI_CHANGER_PATH_SCHEMA: Schema =
diff --git a/src/api2/tape/changer.rs b/src/api2/tape/changer.rs
index 7ecf7bff..b73d6832 100644
--- a/src/api2/tape/changer.rs
+++ b/src/api2/tape/changer.rs
@@ -1,6 +1,6 @@
 use std::collections::HashMap;
 
-use anyhow::Error;
+use anyhow::{bail, Error};
 use serde_json::Value;
 
 use proxmox_router::{list_subdirs_api_method, Permission, Router, RpcEnvironment, SubdirMap};
@@ -8,7 +8,8 @@ use proxmox_schema::api;
 
 use pbs_api_types::{
     Authid, ChangerListEntry, LtoTapeDrive, MtxEntryKind, MtxStatusEntry, ScsiTapeChanger,
-    CHANGER_NAME_SCHEMA, PRIV_TAPE_AUDIT, PRIV_TAPE_READ,
+    CHANGER_NAME_SCHEMA, PRIV_TAPE_AUDIT, PRIV_TAPE_READ, MEDIA_LABEL_SCHEMA, UPID_SCHEMA,
+    DriveListEntry, DeviceActivity
 };
 use pbs_config::CachedUserInfo;
 use pbs_tape::{
@@ -199,7 +200,119 @@ pub fn list_changers(
     Ok(list)
 }
 
+#[api(
+    input: {
+        properties: {
+            name: {
+                schema: CHANGER_NAME_SCHEMA,
+            },
+            "label-text": {
+                schema: MEDIA_LABEL_SCHEMA,
+            },
+        },
+    },
+    returns: {
+        schema: UPID_SCHEMA,
+    },
+    access: {
+        permission: &Permission::Privilege(&["tape", "device", "{name}"], PRIV_TAPE_READ, false),
+    },
+)]
+/// Load media with specified label
+///
+/// Issue a media load request to the associated changer device.
+pub fn load_media(
+    name: String,
+    label_text: String,
+    rpcenv: &mut dyn RpcEnvironment,
+) -> Result<Value, Error> {
+    let drive = choose_drive(&name, rpcenv);
+    super::drive::load_media(drive?, label_text, rpcenv)
+}
+
+#[api(
+    input: {
+        properties: {
+            name: {
+                schema: CHANGER_NAME_SCHEMA,
+            },
+            "source-slot": {
+                description: "Source slot number.",
+                minimum: 1,
+            },
+        },
+    },
+    access: {
+        permission: &Permission::Privilege(&["tape", "device", "{name}"], PRIV_TAPE_READ, false),
+    },
+)]
+/// Load media from the specified slot
+///
+/// Issue a media load request to the associated changer device.
+pub async fn load_slot(
+    name: String,
+    source_slot: u64,
+    rpcenv: &mut dyn RpcEnvironment,) -> Result<(), Error> {
+    let drive = choose_drive(&name, rpcenv);
+    super::drive::load_slot(drive?, source_slot).await
+}
+
+fn choose_drive(
+    changer: &str,
+    rpcenv: &mut dyn RpcEnvironment) -> Result<String, Error> {
+    // We have to automatically select a drive from the specified changer. For that purpose, we take all drives that are part of the changer
+    // and search for the one that has not been used for the longest time (to ensure similar wear between the drives), or use one that has never been used.
+    let drives = super::drive::list_drives(Option::from(changer.to_string()), true, Value::Null, rpcenv);
+
+    match drives {
+        Ok(entries) => {
+            let idle_drives: Vec<DriveListEntry> = entries.into_iter().filter(|entry| matches!(entry.activity, None | Some(DeviceActivity::NoActivity))).collect();
+            let drives_info = crate::tape::drive_info::get_drives_info();
+
+            match drives_info {
+                Ok(drives_info) => {
+                    let mut index_oldest : Option<usize> = Option::default();
+                    let mut oldest_time: Option<i64> = Option::default();
+
+                    for index in 0..idle_drives.len() {
+                        let existing_drive = drives_info.drives.get(&idle_drives[index].config.name);
+
+                        match existing_drive {
+                            Some(existing_drive) => {
+                                if oldest_time.is_none() || oldest_time.is_some_and(|oldest_time| existing_drive.last_usage < oldest_time) {
+                                    oldest_time = Option::from(existing_drive.last_usage);
+                                    index_oldest = Option::from(index);
+                                }
+                            },
+                            None => {
+                                // Drive has never been used, so let's use this one!
+                                index_oldest = Option::from(index);
+                                break;
+                            }
+                        }
+                    }
+
+                    match index_oldest {
+                        Some(index_oldest) => Ok(idle_drives.get(index_oldest).unwrap().config.name.clone()),
+                        None => bail!("there are no idle drives to choose for automatic drive assignment")
+                    }
+                },
+                Err(_error) => {
+                    // Simply use the first drive, if possible.
+                    match idle_drives.first() {
+                        Some(idle_drive) => Ok(idle_drive.config.name.clone()),
+                        None => bail!("there are no idle drives to choose for automatic drive assignment")
+                    }
+                }
+            }
+        }
+        Err(error) => bail!("cannot query drives: {}", error)
+    }
+}
+
 const SUBDIRS: SubdirMap = &[
+    ("load-media", &Router::new().post(&API_METHOD_LOAD_MEDIA)),
+    ("load-slot", &Router::new().post(&API_METHOD_LOAD_SLOT)),
     ("status", &Router::new().get(&API_METHOD_GET_STATUS)),
     ("transfer", &Router::new().post(&API_METHOD_TRANSFER)),
 ];
diff --git a/src/bin/proxmox-backup-api.rs b/src/bin/proxmox-backup-api.rs
index 7a72d49a..c6b2d532 100644
--- a/src/bin/proxmox-backup-api.rs
+++ b/src/bin/proxmox-backup-api.rs
@@ -43,6 +43,8 @@ fn get_index() -> Pin<Box<dyn Future<Output = Response<Body>> + Send>> {
 async fn run() -> Result<(), Error> {
     init_logger("PBS_LOG", LevelFilter::INFO)?;
 
+    let _ = proxmox_backup::tools::init_product_config();
+
     config::create_configdir()?;
 
     config::update_self_signed_cert(false)?;
diff --git a/src/bin/proxmox-backup-proxy.rs b/src/bin/proxmox-backup-proxy.rs
index ce1be1c0..eb7da0e4 100644
--- a/src/bin/proxmox-backup-proxy.rs
+++ b/src/bin/proxmox-backup-proxy.rs
@@ -181,6 +181,8 @@ async fn get_index_future(env: RestEnvironment, parts: Parts) -> Response<Body>
 async fn run() -> Result<(), Error> {
     init_logger("PBS_LOG", LevelFilter::INFO)?;
 
+    let _ = proxmox_backup::tools::init_product_config();
+
     proxmox_backup::auth_helpers::setup_auth_context(false);
     proxmox_backup::server::notifications::init()?;
     metric_collection::init()?;
diff --git a/src/bin/proxmox-tape.rs b/src/bin/proxmox-tape.rs
index 8e8584b3..8de94af0 100644
--- a/src/bin/proxmox-tape.rs
+++ b/src/bin/proxmox-tape.rs
@@ -1,6 +1,8 @@
 use std::collections::HashMap;
 
 use anyhow::{bail, format_err, Error};
+use pbs_api_types::CHANGER_NAME_SCHEMA_AUTOMATIC_DRIVE_ASIGNMENT;
+use pbs_config::drive::complete_changer_name;
 use serde_json::{json, Value};
 
 use proxmox_human_byte::HumanByte;
@@ -214,6 +216,10 @@ async fn eject_media(mut param: Value) -> Result<(), Error> {
                 schema: DRIVE_NAME_SCHEMA,
                 optional: true,
             },
+            changer: {
+                schema: CHANGER_NAME_SCHEMA_AUTOMATIC_DRIVE_ASIGNMENT,
+                optional: true,
+            },
             "label-text": {
                 schema: MEDIA_LABEL_SCHEMA,
             },
@@ -230,11 +236,25 @@ async fn load_media(mut param: Value) -> Result<(), Error> {
 
     let (config, _digest) = pbs_config::drive::config()?;
 
-    let drive = extract_drive_name(&mut param, &config)?;
+    let drive = extract_drive_name(&mut param, &config);
+    let changer = param["changer"].as_str();
+
+    let path = match changer {
+        Some(changer) => {
+            format!("api2/json/tape/changer/{}/load-media", changer)
+        }
+        None => {
+            let drive = drive?;
+            format!("api2/json/tape/drive/{}/load-media", drive)
+        }
+    };
+
+    if let Some(param) = param.as_object_mut() {
+        param.remove("changer");
+    }
 
     let client = connect_to_localhost()?;
 
-    let path = format!("api2/json/tape/drive/{}/load-media", drive);
     let result = client.post(&path, Some(param)).await?;
 
     view_task_result(&client, result, &output_format).await?;
@@ -276,6 +296,10 @@ async fn export_media(mut param: Value) -> Result<(), Error> {
                 schema: DRIVE_NAME_SCHEMA,
                 optional: true,
             },
+            changer: {
+                schema: CHANGER_NAME_SCHEMA_AUTOMATIC_DRIVE_ASIGNMENT,
+                optional: true,
+            },
             "source-slot": {
                 description: "Source slot number.",
                 type: u64,
@@ -288,11 +312,25 @@ async fn export_media(mut param: Value) -> Result<(), Error> {
 async fn load_media_from_slot(mut param: Value) -> Result<(), Error> {
     let (config, _digest) = pbs_config::drive::config()?;
 
-    let drive = extract_drive_name(&mut param, &config)?;
+    let drive = extract_drive_name(&mut param, &config);
+    let changer = param["changer"].as_str();
+
+    let path = match changer {
+        Some(changer) => {
+            format!("api2/json/tape/changer/{}/load-slot", changer)
+        }
+        None => {
+            let drive = drive?;
+            format!("api2/json/tape/drive/{}/load-slot", drive)
+        }
+    };
+
+    if let Some(param) = param.as_object_mut() {
+        param.remove("changer");
+    }
 
     let client = connect_to_localhost()?;
 
-    let path = format!("api2/json/tape/drive/{}/load-slot", drive);
     client.post(&path, Some(param)).await?;
 
     Ok(())
@@ -1091,13 +1129,15 @@ fn main() {
             CliCommand::new(&API_METHOD_LOAD_MEDIA)
                 .arg_param(&["label-text"])
                 .completion_cb("drive", complete_drive_name)
+                .completion_cb("changer", complete_changer_name)
                 .completion_cb("label-text", complete_media_label_text),
         )
         .insert(
             "load-media-from-slot",
             CliCommand::new(&API_METHOD_LOAD_MEDIA_FROM_SLOT)
                 .arg_param(&["source-slot"])
-                .completion_cb("drive", complete_drive_name),
+                .completion_cb("drive", complete_drive_name)
+                .completion_cb("changer", complete_changer_name),
         )
         .insert(
             "unload",
diff --git a/src/tape/changer/mod.rs b/src/tape/changer/mod.rs
index 18ea0f46..8e3ff0f7 100644
--- a/src/tape/changer/mod.rs
+++ b/src/tape/changer/mod.rs
@@ -273,6 +273,17 @@ pub trait MediaChange {
     }
 }
 
+/// Updates the drive's last usage time to now.
+pub(super) fn update_drive_usage(drive: &str) -> Result<(), Error> {
+    let _lock = crate::tape::drive_info::lock()?;
+
+    let mut drives_info = crate::tape::drive_info::get_drives_info()?;
+
+    let now = proxmox_time::epoch_i64();
+    drives_info.drives.entry(drive.into()).or_default().last_usage = now;
+    crate::tape::drive_info::save_config(&drives_info)
+}
+
 const USE_MTX: bool = false;
 
 impl ScsiMediaChange for ScsiTapeChanger {
@@ -423,7 +434,13 @@ impl MediaChange for MtxMediaChanger {
     }
 
     fn load_media_from_slot(&mut self, slot: u64) -> Result<MtxStatus, Error> {
-        self.config.load_slot(slot, self.drive_number())
+        let status = self.config.load_slot(slot, self.drive_number())?;
+
+        if let Err(err) = update_drive_usage(self.drive_name()) {
+            log::warn!("could not update drive usage: {err}");
+        }
+
+        Ok(status)
     }
 
     fn unload_media(&mut self, target_slot: Option<u64>) -> Result<MtxStatus, Error> {
diff --git a/src/tape/drive/virtual_tape.rs b/src/tape/drive/virtual_tape.rs
index 866e4d32..213f17fe 100644
--- a/src/tape/drive/virtual_tape.rs
+++ b/src/tape/drive/virtual_tape.rs
@@ -567,7 +567,13 @@ impl MediaChange for VirtualTapeHandle {
         };
         self.store_status(&status)?;
 
-        self.status()
+        let status = self.status()?;
+
+        if let Err(err) = crate::tape::changer::update_drive_usage(self.drive_name()) {
+            log::warn!("could not update drive usage: {err}");
+        }
+
+        Ok(status)
     }
 
     fn unload_media(&mut self, _target_slot: Option<u64>) -> Result<MtxStatus, Error> {
diff --git a/src/tape/drive_info.rs b/src/tape/drive_info.rs
new file mode 100644
index 00000000..a0c2f836
--- /dev/null
+++ b/src/tape/drive_info.rs
@@ -0,0 +1,51 @@
+//! Serialize/deserialize tpae drive info (e.g. useful for statistics)
+//!
+//! This can be used to store a state over a longer period of time (e.g. last tape drive usage).
+
+use serde::{Serialize, Deserialize};
+use std::collections::HashMap;
+use anyhow::Error;
+use proxmox_product_config::ApiLockGuard;
+
+/// Drive info file name
+pub const DRIVE_INFO_FILENAME: &str = concat!(pbs_buildcfg::PROXMOX_BACKUP_STATE_DIR_M!(), "/tape_drive_info.json");
+/// Lock file name (used to prevent concurrent access)
+pub const DRIVE_INFO_LOCKFILE: &str = concat!(pbs_buildcfg::PROXMOX_BACKUP_STATE_DIR_M!(), "/.tape_drive_info.json.lock");
+
+#[derive(Serialize, Deserialize, Default)]
+pub struct SingleDriveInfo {
+    #[serde(with = "proxmox_serde::epoch_as_rfc3339")]
+    pub last_usage: i64,
+}
+
+#[derive(Serialize, Deserialize, Default)]
+pub struct DrivesInfo {
+    pub drives: HashMap<String, SingleDriveInfo>,
+}
+
+/// Get exclusive lock
+pub fn lock() -> Result<ApiLockGuard, Error> {
+    proxmox_product_config::open_api_lockfile(DRIVE_INFO_LOCKFILE, Option::None, true)
+}
+
+/// Read and parse the drive info file
+pub fn get_drives_info() -> Result<DrivesInfo, Error> {
+    let content =
+        proxmox_sys::fs::file_read_optional_string(DRIVE_INFO_FILENAME)?;
+
+    match content {
+        Some(content) => {
+            let result = serde_json::from_str::<DrivesInfo>(&content)?;
+            Ok(result)
+        },
+        None => {
+            Ok(DrivesInfo::default())
+        }
+    }
+}
+
+/// Save the configuration file
+pub fn save_config(data: &DrivesInfo) -> Result<(), Error> {
+    let json = serde_json::to_string(data)?;
+    proxmox_product_config::replace_config(DRIVE_INFO_FILENAME, json.as_bytes())
+}
diff --git a/src/tape/mod.rs b/src/tape/mod.rs
index f276f948..8b87152d 100644
--- a/src/tape/mod.rs
+++ b/src/tape/mod.rs
@@ -20,6 +20,7 @@ pub use inventory::*;
 
 pub mod changer;
 pub mod drive;
+pub mod drive_info;
 pub mod encryption_keys;
 
 mod media_pool;
diff --git a/src/tools/mod.rs b/src/tools/mod.rs
index 322894dd..11fb2821 100644
--- a/src/tools/mod.rs
+++ b/src/tools/mod.rs
@@ -61,3 +61,10 @@ pub fn setup_safe_path_env() {
         std::env::remove_var(name);
     }
 }
+
+pub fn init_product_config() -> Result<(), Error> {
+    let backup_user = pbs_config::backup_user()?;
+    let root_user = nix::unistd::User::from_uid(nix::unistd::ROOT)?.expect("could not find root user");
+    proxmox_product_config::init(backup_user, root_user);
+    Ok(())
+}
diff --git a/www/tape/ChangerStatus.js b/www/tape/ChangerStatus.js
index e18af90e..0a875779 100644
--- a/www/tape/ChangerStatus.js
+++ b/www/tape/ChangerStatus.js
@@ -222,12 +222,17 @@ Ext.define('PBS.TapeManagement.ChangerStatus', {
 		    autoShow: true,
 		    submitText: gettext('OK'),
 		    title: gettext('Load Media into Drive'),
-		    url: `/api2/extjs/tape/drive`,
+		    url: `/api2/extjs/tape`,
 		    method: 'POST',
 		    submitUrl: function(url, values) {
-			let drive = values.drive;
-			delete values.drive;
-			return `${url}/${encodeURIComponent(drive)}/${apiCall}`;
+			    let drive = values.drive;
+			    delete values.drive;
+			    
+			    if (drive) {
+				    return `${url}/drive/${encodeURIComponent(drive)}/${apiCall}`;
+			    } else {
+				    return `${url}/changer/${encodeURIComponent(changer)}/${apiCall}`;
+			    }
 		    },
 		    items: [
 			label !== "" ? {
@@ -248,6 +253,9 @@ Ext.define('PBS.TapeManagement.ChangerStatus', {
 			    fieldLabel: gettext('Drive'),
 			    changer: changer,
 			    name: 'drive',
+			    emptyText: gettext('Choose Automatically'),
+			    allowBlank: true,
+			    autoSelect: false,
 			},
 		    ],
 		    listeners: {
-- 
2.39.5





More information about the pbs-devel mailing list