found and semi-manually replaced by using: codespell -L mut -L crate -i 3 -w Mostly in comments, but also email notification and two occurrences of misspelled 'reserved' struct member, which where not used and cargo build did not complain about the change, soo ... Signed-off-by: Thomas Lamprecht <t.lamprecht@proxmox.com>
575 lines
17 KiB
Rust
575 lines
17 KiB
Rust
//! 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 known 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<u64>,
|
|
/// The status.
|
|
pub status: ElementStatus,
|
|
/// Drive Identifier (Serial number)
|
|
pub drive_serial_number: Option<String>,
|
|
/// Drive Vendor
|
|
pub vendor: Option<String>,
|
|
/// Drive Model
|
|
pub model: Option<String>,
|
|
/// 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<DriveStatus>,
|
|
/// List of known storage slots
|
|
pub slots: Vec<StorageElementStatus>,
|
|
/// Transport elements
|
|
///
|
|
/// Note: Some libraries do not report transport elements.
|
|
pub transports: Vec<TransportElementStatus>,
|
|
}
|
|
|
|
impl MtxStatus {
|
|
|
|
pub fn slot_address(&self, slot: u64) -> Result<u16, Error> {
|
|
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<u16, Error> {
|
|
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<u64> {
|
|
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<u64> = 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<MtxStatus, Error>;
|
|
|
|
fn load_slot(&mut self, from_slot: u64, drivenum: u64) -> Result<MtxStatus, Error>;
|
|
|
|
fn unload(&mut self, to_slot: u64, drivenum: u64) -> Result<MtxStatus, Error>;
|
|
|
|
fn transfer(&mut self, from_slot: u64, to_slot: u64) -> Result<MtxStatus, 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<MtxStatus, Error>;
|
|
|
|
/// 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<MtxStatus, Error>;
|
|
|
|
/// Load media from storage slot into drive
|
|
fn load_media_from_slot(&mut self, slot: u64) -> Result<MtxStatus, 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<MtxStatus, 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(status) // already loaded
|
|
}
|
|
}
|
|
if i as u64 == self.drive_number() {
|
|
match drive_status.status {
|
|
ElementStatus::Empty => { /* OK */ },
|
|
_ => unload_drive = true,
|
|
}
|
|
}
|
|
}
|
|
|
|
if unload_drive {
|
|
status = self.unload_to_free_slot(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<u64>) -> Result<MtxStatus, Error>;
|
|
|
|
/// List online media labels (label_text/barcodes)
|
|
///
|
|
/// List accessible (online) label texts. This does not include
|
|
/// media inside import-export slots or cleaning media.
|
|
fn online_media_label_texts(&mut self) -> Result<Vec<String>, 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<MtxStatus, Error> {
|
|
let mut status = self.status()?;
|
|
|
|
// Unload drive first. Note: This also unloads a loaded cleaning tape
|
|
if let Some(drive_status) = status.drives.get(self.drive_number() as usize) {
|
|
match drive_status.status {
|
|
ElementStatus::Empty => { /* OK */ },
|
|
_ => { status = self.unload_to_free_slot(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,
|
|
};
|
|
|
|
|
|
self.load_media_from_slot(cleaning_cartridge_slot)?;
|
|
|
|
self.unload_media(Some(cleaning_cartridge_slot))
|
|
}
|
|
|
|
/// 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<Option<u64>, 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 possible to the slot it was previously loaded from.
|
|
///
|
|
/// Note: This method consumes status - so please use returned status afterward.
|
|
fn unload_to_free_slot(&mut self, status: MtxStatus) -> Result<MtxStatus, 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<MtxStatus, Error> {
|
|
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<MtxStatus, Error> {
|
|
let result = 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)
|
|
};
|
|
|
|
let status = self.status(false)?; // always update status
|
|
|
|
result?; // check load result
|
|
|
|
Ok(status)
|
|
}
|
|
|
|
fn unload(&mut self, to_slot: u64, drivenum: u64) -> Result<MtxStatus, Error> {
|
|
let result = 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)
|
|
};
|
|
|
|
let status = self.status(false)?; // always update status
|
|
|
|
result?; // check unload result
|
|
|
|
Ok(status)
|
|
}
|
|
|
|
fn transfer(&mut self, from_slot: u64, to_slot: u64) -> Result<MtxStatus, Error> {
|
|
let result = 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)
|
|
};
|
|
|
|
let status = self.status(false)?; // always update status
|
|
|
|
result?; // check unload result
|
|
|
|
Ok(status)
|
|
}
|
|
}
|
|
|
|
fn save_changer_state_cache(
|
|
changer: &str,
|
|
state: &MtxStatus,
|
|
) -> Result<(), Error> {
|
|
|
|
let mut path = PathBuf::from(crate::tape::CHANGER_STATE_DIR);
|
|
path.push(changer);
|
|
|
|
let state = serde_json::to_string_pretty(state)?;
|
|
|
|
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);
|
|
|
|
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<Option<MtxStatus>, 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<Self, Error> {
|
|
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<MtxStatus, Error> {
|
|
self.config.status(false)
|
|
}
|
|
|
|
fn transfer_media(&mut self, from: u64, to: u64) -> Result<MtxStatus, Error> {
|
|
self.config.transfer(from, to)
|
|
}
|
|
|
|
fn load_media_from_slot(&mut self, slot: u64) -> Result<MtxStatus, Error> {
|
|
self.config.load_slot(slot, self.drive_number)
|
|
}
|
|
|
|
fn unload_media(&mut self, target_slot: Option<u64>) -> Result<MtxStatus, 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)
|
|
}
|
|
}
|
|
}
|