//! Backup media Inventory //! //! The Inventory persistently stores the list of known backup //! media. A backup media is identified by its 'MediaId', which is the //! MediaLabel/MediaSetLabel combination. use std::collections::{HashMap, BTreeMap}; use std::path::{Path, PathBuf}; use std::os::unix::io::AsRawFd; use anyhow::{bail, Error}; use serde::{Serialize, Deserialize}; use serde_json::json; use proxmox::tools::{ Uuid, fs::{ open_file_locked, replace_file, fchown, file_get_json, CreateOptions, }, }; use crate::{ tools::systemd::time::compute_next_event, api2::types::{ MediaSetPolicy, RetentionPolicy, MediaStatus, MediaLocation, }, tape::{ TAPE_STATUS_DIR, MediaSet, file_formats::{ MediaLabel, MediaSetLabel, }, changer::OnlineStatusMap, }, }; /// Unique Media Identifier /// /// This combines the label and media set label. #[derive(Debug,Serialize,Deserialize,Clone)] pub struct MediaId { pub label: MediaLabel, #[serde(skip_serializing_if="Option::is_none")] pub media_set_label: Option, } #[derive(Serialize,Deserialize)] struct MediaStateEntry { id: MediaId, #[serde(skip_serializing_if="Option::is_none")] location: Option, #[serde(skip_serializing_if="Option::is_none")] status: Option, } /// Media Inventory pub struct Inventory { map: BTreeMap, inventory_path: PathBuf, lockfile_path: PathBuf, // helpers media_set_start_times: HashMap } impl Inventory { pub const MEDIA_INVENTORY_FILENAME: &'static str = "inventory.json"; pub const MEDIA_INVENTORY_LOCKFILE: &'static str = ".inventory.lck"; fn new(base_path: &Path) -> Self { let mut inventory_path = base_path.to_owned(); inventory_path.push(Self::MEDIA_INVENTORY_FILENAME); let mut lockfile_path = base_path.to_owned(); lockfile_path.push(Self::MEDIA_INVENTORY_LOCKFILE); Self { map: BTreeMap::new(), media_set_start_times: HashMap::new(), inventory_path, lockfile_path, } } pub fn load(base_path: &Path) -> Result { let mut me = Self::new(base_path); me.reload()?; Ok(me) } /// Reload the database pub fn reload(&mut self) -> Result<(), Error> { self.map = Self::load_media_db(&self.inventory_path)?; self.update_helpers(); Ok(()) } fn update_helpers(&mut self) { // recompute media_set_start_times let mut set_start_times = HashMap::new(); for entry in self.map.values() { let set = match &entry.id.media_set_label { None => continue, Some(set) => set, }; if set.seq_nr == 0 { set_start_times.insert(set.uuid.clone(), set.ctime); } } self.media_set_start_times = set_start_times; } /// Lock the database pub fn lock(&self) -> Result { let file = open_file_locked(&self.lockfile_path, std::time::Duration::new(10, 0), true)?; if cfg!(test) { // We cannot use chown inside test environment (no permissions) return Ok(file); } let backup_user = crate::backup::backup_user()?; fchown(file.as_raw_fd(), Some(backup_user.uid), Some(backup_user.gid))?; Ok(file) } fn load_media_db(path: &Path) -> Result, Error> { let data = file_get_json(path, Some(json!([])))?; let media_list: Vec = serde_json::from_value(data)?; let mut map = BTreeMap::new(); for entry in media_list.into_iter() { map.insert(entry.id.label.uuid.clone(), entry); } Ok(map) } fn replace_file(&self) -> Result<(), Error> { let list: Vec<&MediaStateEntry> = self.map.values().collect(); let raw = serde_json::to_string_pretty(&serde_json::to_value(list)?)?; let mode = nix::sys::stat::Mode::from_bits_truncate(0o0640); let options = if cfg!(test) { // We cannot use chown inside test environment (no permissions) CreateOptions::new().perm(mode) } else { let backup_user = crate::backup::backup_user()?; CreateOptions::new() .perm(mode) .owner(backup_user.uid) .group(backup_user.gid) }; replace_file(&self.inventory_path, raw.as_bytes(), options)?; Ok(()) } /// Stores a single MediaID persistently pub fn store( &mut self, mut media_id: MediaId, clear_media_status: bool, ) -> Result<(), Error> { let _lock = self.lock()?; self.map = Self::load_media_db(&self.inventory_path)?; let uuid = media_id.label.uuid.clone(); if let Some(previous) = self.map.remove(&media_id.label.uuid) { // do not overwrite unsaved pool assignments if media_id.media_set_label.is_none() { if let Some(ref set) = previous.id.media_set_label { if set.uuid.as_ref() == [0u8;16] { media_id.media_set_label = Some(set.clone()); } } } let entry = MediaStateEntry { id: media_id, location: previous.location, status: if clear_media_status { None } else { previous.status }, }; self.map.insert(uuid, entry); } else { let entry = MediaStateEntry { id: media_id, location: None, status: None }; self.map.insert(uuid, entry); } self.update_helpers(); self.replace_file()?; Ok(()) } /// Remove a single media persistently pub fn remove_media(&mut self, uuid: &Uuid) -> Result<(), Error> { let _lock = self.lock()?; self.map = Self::load_media_db(&self.inventory_path)?; self.map.remove(uuid); self.update_helpers(); self.replace_file()?; Ok(()) } /// Lookup media pub fn lookup_media(&self, uuid: &Uuid) -> Option<&MediaId> { self.map.get(uuid).map(|entry| &entry.id) } /// List all media Uuids pub fn media_list(&self) -> Vec<&Uuid> { self.map.keys().collect() } /// find media by label_text pub fn find_media_by_label_text(&self, label_text: &str) -> Option<&MediaId> { self.map.values().find_map(|entry| { if entry.id.label.label_text == label_text { Some(&entry.id) } else { None } }) } /// Lookup media pool /// /// Returns (pool, is_empty) pub fn lookup_media_pool(&self, uuid: &Uuid) -> Option<(&str, bool)> { match self.map.get(uuid) { None => None, Some(entry) => { match entry.id.media_set_label { None => None, // not assigned to any pool Some(ref set) => { let is_empty = set.uuid.as_ref() == [0u8;16]; Some((&set.pool, is_empty)) } } } } } /// List all media assigned to the pool pub fn list_pool_media(&self, pool: &str) -> Vec { let mut list = Vec::new(); for entry in self.map.values() { match entry.id.media_set_label { None => continue, // not assigned to any pool Some(ref set) => { if set.pool != pool { continue; // belong to another pool } if set.uuid.as_ref() == [0u8;16] { list.push(MediaId { label: entry.id.label.clone(), media_set_label: None, }) } else { list.push(entry.id.clone()); } } } } list } /// List all used media pub fn list_used_media(&self) -> Vec { let mut list = Vec::new(); for entry in self.map.values() { match entry.id.media_set_label { None => continue, // not assigned to any pool Some(ref set) => { if set.uuid.as_ref() != [0u8;16] { list.push(entry.id.clone()); } } } } list } /// List media not assigned to any pool pub fn list_unassigned_media(&self) -> Vec { self.map.values().filter_map(|entry| if entry.id.media_set_label.is_none() { Some(entry.id.clone()) } else { None } ).collect() } pub fn media_set_start_time(&self, media_set_uuid: &Uuid) -> Option { self.media_set_start_times.get(media_set_uuid).copied() } /// Lookup media set pool pub fn lookup_media_set_pool(&self, media_set_uuid: &Uuid) -> Result { let mut last_pool = None; for entry in self.map.values() { match entry.id.media_set_label { None => continue, Some(MediaSetLabel { ref uuid, .. }) => { if uuid != media_set_uuid { continue; } if let Some((pool, _)) = self.lookup_media_pool(&entry.id.label.uuid) { if let Some(last_pool) = last_pool { if last_pool != pool { bail!("detected media set with inconsistent pool assignment - internal error"); } } else { last_pool = Some(pool); } } } } } match last_pool { Some(pool) => Ok(pool.to_string()), None => bail!("media set {} is incomplete - unable to lookup pool", media_set_uuid), } } /// Compute a single media sets pub fn compute_media_set_members(&self, media_set_uuid: &Uuid) -> Result { let mut set = MediaSet::with_data(media_set_uuid.clone(), Vec::new()); for entry in self.map.values() { match entry.id.media_set_label { None => continue, Some(MediaSetLabel { seq_nr, ref uuid, .. }) => { if uuid != media_set_uuid { continue; } set.insert_media(entry.id.label.uuid.clone(), seq_nr)?; } } } Ok(set) } /// Compute all media sets pub fn compute_media_set_list(&self) -> Result, Error> { let mut set_map: HashMap = HashMap::new(); for entry in self.map.values() { match entry.id.media_set_label { None => continue, Some(MediaSetLabel { seq_nr, ref uuid, .. }) => { let set = set_map.entry(uuid.clone()).or_insert_with(|| { MediaSet::with_data(uuid.clone(), Vec::new()) }); set.insert_media(entry.id.label.uuid.clone(), seq_nr)?; } } } Ok(set_map) } /// Returns the latest media set for a pool pub fn latest_media_set(&self, pool: &str) -> Option { let mut last_set: Option<(Uuid, i64)> = None; let set_list = self.map.values() .filter_map(|entry| entry.id.media_set_label.as_ref()) .filter(|set| set.pool == pool && set.uuid.as_ref() != [0u8;16]); for set in set_list { match last_set { None => { last_set = Some((set.uuid.clone(), set.ctime)); } Some((_, last_ctime)) => { if set.ctime > last_ctime { last_set = Some((set.uuid.clone(), set.ctime)); } } } } let (uuid, ctime) = match last_set { None => return None, Some((uuid, ctime)) => (uuid, ctime), }; // consistency check - must be the only set with that ctime let set_list = self.map.values() .filter_map(|entry| entry.id.media_set_label.as_ref()) .filter(|set| set.pool == pool && set.uuid.as_ref() != [0u8;16]); for set in set_list { if set.uuid != uuid && set.ctime >= ctime { // should not happen eprintln!("latest_media_set: found set with equal ctime ({}, {})", set.uuid, uuid); return None; } } Some(uuid) } // Test if there is a media set (in the same pool) newer than this one. // Return the ctime of the nearest media set fn media_set_next_start_time(&self, media_set_uuid: &Uuid) -> Option { let (pool, ctime) = match self.map.values() .filter_map(|entry| entry.id.media_set_label.as_ref()) .find_map(|set| { if &set.uuid == media_set_uuid { Some((set.pool.clone(), set.ctime)) } else { None } }) { Some((pool, ctime)) => (pool, ctime), None => return None, }; let set_list = self.map.values() .filter_map(|entry| entry.id.media_set_label.as_ref()) .filter(|set| (&set.uuid != media_set_uuid) && (set.pool == pool)); let mut next_ctime = None; for set in set_list { if set.ctime > ctime { match next_ctime { None => { next_ctime = Some(set.ctime); } Some(last_next_ctime) => { if set.ctime < last_next_ctime { next_ctime = Some(set.ctime); } } } } } next_ctime } pub fn media_expire_time( &self, media: &MediaId, media_set_policy: &MediaSetPolicy, retention_policy: &RetentionPolicy, ) -> i64 { if let RetentionPolicy::KeepForever = retention_policy { return i64::MAX; } let set = match media.media_set_label { None => return i64::MAX, Some(ref set) => set, }; let set_start_time = match self.media_set_start_time(&set.uuid) { None => { // missing information, use ctime from this // set (always greater than ctime from seq_nr 0) set.ctime } Some(time) => time, }; let max_use_time = match self.media_set_next_start_time(&set.uuid) { Some(next_start_time) => { match media_set_policy { MediaSetPolicy::AlwaysCreate => set_start_time, _ => next_start_time, } } None => { match media_set_policy { MediaSetPolicy::ContinueCurrent => { return i64::MAX; } MediaSetPolicy::AlwaysCreate => { set_start_time } MediaSetPolicy::CreateAt(ref event) => { match compute_next_event(event, set_start_time, false) { Ok(Some(next)) => next, Ok(None) | Err(_) => return i64::MAX, } } } } }; match retention_policy { RetentionPolicy::KeepForever => i64::MAX, RetentionPolicy::OverwriteAlways => max_use_time, RetentionPolicy::ProtectFor(time_span) => { let seconds = f64::from(time_span.clone()) as i64; max_use_time + seconds } } } /// Generate a human readable name for the media set /// /// The template can include strftime time format specifications. pub fn generate_media_set_name( &self, media_set_uuid: &Uuid, template: Option, ) -> Result { if let Some(ctime) = self.media_set_start_time(media_set_uuid) { let mut template = template.unwrap_or_else(|| String::from("%c")); template = template.replace("%id%", &media_set_uuid.to_string()); proxmox::tools::time::strftime_local(&template, ctime) } else { // We don't know the set start time, so we cannot use the template Ok(media_set_uuid.to_string()) } } // Helpers to simplify testing /// Genreate and insert a new free tape (test helper) pub fn generate_free_tape(&mut self, label_text: &str, ctime: i64) -> Uuid { let label = MediaLabel { label_text: label_text.to_string(), uuid: Uuid::generate(), ctime, }; let uuid = label.uuid.clone(); self.store(MediaId { label, media_set_label: None }, false).unwrap(); uuid } /// Genreate and insert a new tape assigned to a specific pool /// (test helper) pub fn generate_assigned_tape( &mut self, label_text: &str, pool: &str, ctime: i64, ) -> Uuid { let label = MediaLabel { label_text: label_text.to_string(), uuid: Uuid::generate(), ctime, }; let uuid = label.uuid.clone(); let set = MediaSetLabel::with_data(pool, [0u8; 16].into(), 0, ctime, None); self.store(MediaId { label, media_set_label: Some(set) }, false).unwrap(); uuid } /// Genreate and insert a used tape (test helper) pub fn generate_used_tape( &mut self, label_text: &str, set: MediaSetLabel, ctime: i64, ) -> Uuid { let label = MediaLabel { label_text: label_text.to_string(), uuid: Uuid::generate(), ctime, }; let uuid = label.uuid.clone(); self.store(MediaId { label, media_set_label: Some(set) }, false).unwrap(); uuid } } // Status/location handling impl Inventory { /// Returns status and location with reasonable defaults. /// /// Default status is 'MediaStatus::Unknown'. /// Default location is 'MediaLocation::Offline'. pub fn status_and_location(&self, uuid: &Uuid) -> (MediaStatus, MediaLocation) { match self.map.get(uuid) { None => { // no info stored - assume media is writable/offline (MediaStatus::Unknown, MediaLocation::Offline) } Some(entry) => { let location = entry.location.clone().unwrap_or(MediaLocation::Offline); let status = entry.status.unwrap_or(MediaStatus::Unknown); (status, location) } } } // Lock database, reload database, set status, store database fn set_media_status(&mut self, uuid: &Uuid, status: Option) -> Result<(), Error> { let _lock = self.lock()?; self.map = Self::load_media_db(&self.inventory_path)?; if let Some(entry) = self.map.get_mut(uuid) { entry.status = status; self.update_helpers(); self.replace_file()?; Ok(()) } else { bail!("no such media '{}'", uuid); } } /// Lock database, reload database, set status to Full, store database pub fn set_media_status_full(&mut self, uuid: &Uuid) -> Result<(), Error> { self.set_media_status(uuid, Some(MediaStatus::Full)) } /// Lock database, reload database, set status to Damaged, store database pub fn set_media_status_damaged(&mut self, uuid: &Uuid) -> Result<(), Error> { self.set_media_status(uuid, Some(MediaStatus::Damaged)) } /// Lock database, reload database, set status to Retired, store database pub fn set_media_status_retired(&mut self, uuid: &Uuid) -> Result<(), Error> { self.set_media_status(uuid, Some(MediaStatus::Retired)) } /// Lock database, reload database, set status to None, store database pub fn clear_media_status(&mut self, uuid: &Uuid) -> Result<(), Error> { self.set_media_status(uuid, None) } // Lock database, reload database, set location, store database fn set_media_location(&mut self, uuid: &Uuid, location: Option) -> Result<(), Error> { let _lock = self.lock()?; self.map = Self::load_media_db(&self.inventory_path)?; if let Some(entry) = self.map.get_mut(uuid) { entry.location = location; self.update_helpers(); self.replace_file()?; Ok(()) } else { bail!("no such media '{}'", uuid); } } /// Lock database, reload database, set location to vault, store database pub fn set_media_location_vault(&mut self, uuid: &Uuid, vault: &str) -> Result<(), Error> { self.set_media_location(uuid, Some(MediaLocation::Vault(vault.to_string()))) } /// Lock database, reload database, set location to offline, store database pub fn set_media_location_offline(&mut self, uuid: &Uuid) -> Result<(), Error> { self.set_media_location(uuid, Some(MediaLocation::Offline)) } /// Update online status pub fn update_online_status(&mut self, online_map: &OnlineStatusMap) -> Result<(), Error> { let _lock = self.lock()?; self.map = Self::load_media_db(&self.inventory_path)?; for (uuid, entry) in self.map.iter_mut() { if let Some(changer_name) = online_map.lookup_changer(uuid) { entry.location = Some(MediaLocation::Online(changer_name.to_string())); } else if let Some(MediaLocation::Online(ref changer_name)) = entry.location { match online_map.online_map(changer_name) { None => { // no such changer device entry.location = Some(MediaLocation::Offline); } Some(None) => { // got no info - do nothing } Some(Some(_)) => { // media changer changed entry.location = Some(MediaLocation::Offline); } } } } self.update_helpers(); self.replace_file()?; Ok(()) } } // shell completion helper /// List of known media uuids pub fn complete_media_uuid( _arg: &str, _param: &HashMap, ) -> Vec { let inventory = match Inventory::load(Path::new(TAPE_STATUS_DIR)) { Ok(inventory) => inventory, Err(_) => return Vec::new(), }; inventory.map.keys().map(|uuid| uuid.to_string()).collect() } /// List of known media sets pub fn complete_media_set_uuid( _arg: &str, _param: &HashMap, ) -> Vec { let inventory = match Inventory::load(Path::new(TAPE_STATUS_DIR)) { Ok(inventory) => inventory, Err(_) => return Vec::new(), }; inventory.map.values() .filter_map(|entry| entry.id.media_set_label.as_ref()) .map(|set| set.uuid.to_string()).collect() } /// List of known media labels (barcodes) pub fn complete_media_label_text( _arg: &str, _param: &HashMap, ) -> Vec { let inventory = match Inventory::load(Path::new(TAPE_STATUS_DIR)) { Ok(inventory) => inventory, Err(_) => return Vec::new(), }; inventory.map.values().map(|entry| entry.id.label.label_text.clone()).collect() }