//! Media changer implementation (SCSI media changer) mod email; pub use email::*; pub mod sg_pt_changer; pub mod mtx; mod online_status_map; pub use online_status_map::*; use std::collections::HashSet; use std::path::PathBuf; use anyhow::{bail, Error}; use serde::{Serialize, Deserialize}; use serde_json::Value; use proxmox::{ api::schema::parse_property_string, tools::fs::{ CreateOptions, replace_file, file_read_optional_string, }, }; use crate::api2::types::{ SLOT_ARRAY_SCHEMA, ScsiTapeChanger, LinuxTapeDrive, }; /// Changer element status. /// /// Drive and slots may be `Empty`, or contain some media, either /// with knwon volume tag `VolumeTag(String)`, or without (`Full`). #[derive(Serialize, Deserialize, Debug)] pub enum ElementStatus { Empty, Full, VolumeTag(String), } /// Changer drive status. #[derive(Serialize, Deserialize)] pub struct DriveStatus { /// The slot the element was loaded from (if known). pub loaded_slot: Option, /// The status. pub status: ElementStatus, /// Drive Identifier (Serial number) pub drive_serial_number: Option, /// Drive Vendor pub vendor: Option, /// Drive Model pub model: Option, /// Element Address pub element_address: u16, } /// Storage element status. #[derive(Serialize, Deserialize)] pub struct StorageElementStatus { /// Flag for Import/Export slots pub import_export: bool, /// The status. pub status: ElementStatus, /// Element Address pub element_address: u16, } /// Transport element status. #[derive(Serialize, Deserialize)] pub struct TransportElementStatus { /// The status. pub status: ElementStatus, /// Element Address pub element_address: u16, } /// Changer status - show drive/slot usage #[derive(Serialize, Deserialize)] pub struct MtxStatus { /// List of known drives pub drives: Vec, /// List of known storage slots pub slots: Vec, /// Tranport elements /// /// Note: Some libraries do not report transport elements. pub transports: Vec, } impl MtxStatus { pub fn slot_address(&self, slot: u64) -> Result { if slot == 0 { bail!("invalid slot number '{}' (slots numbers starts at 1)", slot); } if slot > (self.slots.len() as u64) { bail!("invalid slot number '{}' (max {} slots)", slot, self.slots.len()); } Ok(self.slots[(slot -1) as usize].element_address) } pub fn drive_address(&self, drivenum: u64) -> Result { if drivenum >= (self.drives.len() as u64) { bail!("invalid drive number '{}'", drivenum); } Ok(self.drives[drivenum as usize].element_address) } pub fn transport_address(&self) -> u16 { // simply use first transport // (are there changers exposing more than one?) // defaults to 0 for changer that do not report transports self .transports .get(0) .map(|t| t.element_address) .unwrap_or(0u16) } pub fn find_free_slot(&self, import_export: bool) -> Option { let mut free_slot = None; for (i, slot_info) in self.slots.iter().enumerate() { if slot_info.import_export != import_export { continue; // skip slots of wrong type } if let ElementStatus::Empty = slot_info.status { free_slot = Some((i+1) as u64); break; } } free_slot } pub fn mark_import_export_slots(&mut self, config: &ScsiTapeChanger) -> Result<(), Error>{ let mut export_slots: HashSet = HashSet::new(); if let Some(slots) = &config.export_slots { let slots: Value = parse_property_string(&slots, &SLOT_ARRAY_SCHEMA)?; export_slots = slots .as_array() .unwrap() .iter() .filter_map(|v| v.as_u64()) .collect(); } for (i, entry) in self.slots.iter_mut().enumerate() { let slot = i as u64 + 1; if export_slots.contains(&slot) { entry.import_export = true; // mark as IMPORT/EXPORT } } Ok(()) } } /// Interface to SCSI changer devices pub trait ScsiMediaChange { fn status(&mut self, use_cache: bool) -> Result; fn load_slot(&mut self, from_slot: u64, drivenum: u64) -> Result<(), Error>; fn unload(&mut self, to_slot: u64, drivenum: u64) -> Result<(), Error>; fn transfer(&mut self, from_slot: u64, to_slot: u64) -> Result<(), Error>; } /// Interface to the media changer device for a single drive pub trait MediaChange { /// Drive number inside changer fn drive_number(&self) -> u64; /// Drive name (used for debug messages) fn drive_name(&self) -> &str; /// Returns the changer status fn status(&mut self) -> Result; /// Transfer media from on slot to another (storage or import export slots) /// /// Target slot needs to be empty fn transfer_media(&mut self, from: u64, to: u64) -> Result<(), Error>; /// Load media from storage slot into drive fn load_media_from_slot(&mut self, slot: u64) -> Result<(), Error>; /// Load media by label-text into drive /// /// This unloads first if the drive is already loaded with another media. /// /// Note: This refuses to load media inside import/export /// slots. Also, you cannot load cleaning units with this /// interface. fn load_media(&mut self, label_text: &str) -> Result<(), Error> { if label_text.starts_with("CLN") { bail!("unable to load media '{}' (seems to be a cleaning unit)", label_text); } let mut status = self.status()?; let mut unload_drive = false; // already loaded? for (i, drive_status) in status.drives.iter().enumerate() { if let ElementStatus::VolumeTag(ref tag) = drive_status.status { if *tag == label_text { if i as u64 != self.drive_number() { bail!("unable to load media '{}' - media in wrong drive ({} != {})", label_text, i, self.drive_number()); } return Ok(()) // already loaded } } if i as u64 == self.drive_number() { match drive_status.status { ElementStatus::Empty => { /* OK */ }, _ => unload_drive = true, } } } if unload_drive { self.unload_to_free_slot(status)?; status = self.status()?; } let mut slot = None; for (i, slot_info) in status.slots.iter().enumerate() { if let ElementStatus::VolumeTag(ref tag) = slot_info.status { if tag == label_text { if slot_info.import_export { bail!("unable to load media '{}' - inside import/export slot", label_text); } slot = Some(i+1); break; } } } let slot = match slot { None => bail!("unable to find media '{}' (offline?)", label_text), Some(slot) => slot, }; self.load_media_from_slot(slot as u64) } /// Unload media from drive (eject media if necessary) fn unload_media(&mut self, target_slot: Option) -> Result<(), Error>; /// List online media labels (label_text/barcodes) /// /// List acessible (online) label texts. This does not include /// media inside import-export slots or cleaning media. fn online_media_label_texts(&mut self) -> Result, Error> { let status = self.status()?; let mut list = Vec::new(); for drive_status in status.drives.iter() { if let ElementStatus::VolumeTag(ref tag) = drive_status.status { list.push(tag.clone()); } } for slot_info in status.slots.iter() { if slot_info.import_export { continue; } if let ElementStatus::VolumeTag(ref tag) = slot_info.status { if tag.starts_with("CLN") { continue; } list.push(tag.clone()); } } Ok(list) } /// Load/Unload cleaning cartridge /// /// This fail if there is no cleaning cartridge online. Any media /// inside the drive is automatically unloaded. fn clean_drive(&mut self) -> Result<(), Error> { let status = self.status()?; let mut cleaning_cartridge_slot = None; for (i, slot_info) in status.slots.iter().enumerate() { if slot_info.import_export { continue; } if let ElementStatus::VolumeTag(ref tag) = slot_info.status { if tag.starts_with("CLN") { cleaning_cartridge_slot = Some(i + 1); break; } } } let cleaning_cartridge_slot = match cleaning_cartridge_slot { None => bail!("clean failed - unable to find cleaning cartridge"), Some(cleaning_cartridge_slot) => cleaning_cartridge_slot as u64, }; if let Some(drive_status) = status.drives.get(self.drive_number() as usize) { match drive_status.status { ElementStatus::Empty => { /* OK */ }, _ => self.unload_to_free_slot(status)?, } } self.load_media_from_slot(cleaning_cartridge_slot)?; self.unload_media(Some(cleaning_cartridge_slot))?; Ok(()) } /// Export media /// /// By moving the media to an empty import-export slot. Returns /// Some(slot) if the media was exported. Returns None if the media is /// not online (already exported). fn export_media(&mut self, label_text: &str) -> Result, Error> { let status = self.status()?; let mut unload_from_drive = false; if let Some(drive_status) = status.drives.get(self.drive_number() as usize) { if let ElementStatus::VolumeTag(ref tag) = drive_status.status { if tag == label_text { unload_from_drive = true; } } } let mut from = None; let mut to = None; for (i, slot_info) in status.slots.iter().enumerate() { if slot_info.import_export { if to.is_some() { continue; } if let ElementStatus::Empty = slot_info.status { to = Some(i as u64 + 1); } } else if let ElementStatus::VolumeTag(ref tag) = slot_info.status { if tag == label_text { from = Some(i as u64 + 1); } } } if unload_from_drive { match to { Some(to) => { self.unload_media(Some(to))?; Ok(Some(to)) } None => bail!("unable to find free export slot"), } } else { match (from, to) { (Some(from), Some(to)) => { self.transfer_media(from, to)?; Ok(Some(to)) } (Some(_from), None) => bail!("unable to find free export slot"), (None, _) => Ok(None), // not online } } } /// Unload media to a free storage slot /// /// If posible to the slot it was previously loaded from. /// /// Note: This method consumes status - so please read again afterward. fn unload_to_free_slot(&mut self, status: MtxStatus) -> Result<(), Error> { let drive_status = &status.drives[self.drive_number() as usize]; if let Some(slot) = drive_status.loaded_slot { // check if original slot is empty/usable if let Some(info) = status.slots.get(slot as usize - 1) { if let ElementStatus::Empty = info.status { return self.unload_media(Some(slot)); } } } if let Some(slot) = status.find_free_slot(false) { self.unload_media(Some(slot)) } else { bail!("drive '{}' unload failure - no free slot", self.drive_name()); } } } const USE_MTX: bool = false; impl ScsiMediaChange for ScsiTapeChanger { fn status(&mut self, use_cache: bool) -> Result { if use_cache { if let Some(state) = load_changer_state_cache(&self.name)? { return Ok(state); } } let status = if USE_MTX { mtx::mtx_status(&self) } else { sg_pt_changer::status(&self) }; match &status { Ok(status) => { save_changer_state_cache(&self.name, status)?; } Err(_) => { delete_changer_state_cache(&self.name); } } status } fn load_slot(&mut self, from_slot: u64, drivenum: u64) -> Result<(), Error> { if USE_MTX { mtx::mtx_load(&self.path, from_slot, drivenum) } else { let mut file = sg_pt_changer::open(&self.path)?; sg_pt_changer::load_slot(&mut file, from_slot, drivenum) } } fn unload(&mut self, to_slot: u64, drivenum: u64) -> Result<(), Error> { if USE_MTX { mtx::mtx_unload(&self.path, to_slot, drivenum) } else { let mut file = sg_pt_changer::open(&self.path)?; sg_pt_changer::unload(&mut file, to_slot, drivenum) } } fn transfer(&mut self, from_slot: u64, to_slot: u64) -> Result<(), Error> { if USE_MTX { mtx::mtx_transfer(&self.path, from_slot, to_slot) } else { let mut file = sg_pt_changer::open(&self.path)?; sg_pt_changer::transfer_medium(&mut file, from_slot, to_slot) } } } fn save_changer_state_cache( changer: &str, state: &MtxStatus, ) -> Result<(), Error> { let mut path = PathBuf::from("/run/proxmox-backup/changer-state"); std::fs::create_dir_all(&path)?; path.push(changer); let backup_user = crate::backup::backup_user()?; let mode = nix::sys::stat::Mode::from_bits_truncate(0o0644); let options = CreateOptions::new() .perm(mode) .owner(backup_user.uid) .group(backup_user.gid); let state = serde_json::to_string_pretty(state)?; replace_file(path, state.as_bytes(), options) } fn delete_changer_state_cache(changer: &str) { let mut path = PathBuf::from("/run/proxmox-backup/changer-state"); path.push(changer); let _ = std::fs::remove_file(&path); // ignore errors } fn load_changer_state_cache(changer: &str) -> Result, Error> { let mut path = PathBuf::from("/run/proxmox-backup/changer-state"); path.push(changer); let data = match file_read_optional_string(&path)? { None => return Ok(None), Some(data) => data, }; let state = serde_json::from_str(&data)?; Ok(Some(state)) } /// Implements MediaChange using 'mtx' linux cli tool pub struct MtxMediaChanger { drive_name: String, // used for error messages drive_number: u64, config: ScsiTapeChanger, } impl MtxMediaChanger { pub fn with_drive_config(drive_config: &LinuxTapeDrive) -> Result { let (config, _digest) = crate::config::drive::config()?; let changer_config: ScsiTapeChanger = match drive_config.changer { Some(ref changer) => config.lookup("changer", changer)?, None => bail!("drive '{}' has no associated changer", drive_config.name), }; Ok(Self { drive_name: drive_config.name.clone(), drive_number: drive_config.changer_drivenum.unwrap_or(0), config: changer_config, }) } } impl MediaChange for MtxMediaChanger { fn drive_number(&self) -> u64 { self.drive_number } fn drive_name(&self) -> &str { &self.drive_name } fn status(&mut self) -> Result { self.config.status(false) } fn transfer_media(&mut self, from: u64, to: u64) -> Result<(), Error> { self.config.transfer(from, to) } fn load_media_from_slot(&mut self, slot: u64) -> Result<(), Error> { self.config.load_slot(slot, self.drive_number) } fn unload_media(&mut self, target_slot: Option) -> Result<(), Error> { if let Some(target_slot) = target_slot { self.config.unload(target_slot, self.drive_number) } else { let status = self.status()?; self.unload_to_free_slot(status) } } }