]> git.proxmox.com Git - proxmox-backup.git/blobdiff - src/tape/inventory.rs
tape/inventory: add completion helper for tape snapshots
[proxmox-backup.git] / src / tape / inventory.rs
index 3f97f777303c1b84f18478030f6e26cc6f005b3e..4bb6d4f8715a45208be440ae9725924ba51b9132 100644 (file)
@@ -3,9 +3,30 @@
 //! The Inventory persistently stores the list of known backup
 //! media. A backup media is identified by its 'MediaId', which is the
 //! MediaLabel/MediaSetLabel combination.
+//!
+//! Inventory Locking
+//!
+//! The inventory itself has several methods to update single entries,
+//! but all of them can be considered atomic.
+//!
+//! Pool Locking
+//!
+//! To add/modify media assigned to a pool, we always do
+//! lock_media_pool(). For unassigned media, we call
+//! lock_unassigned_media_pool().
+//!
+//! MediaSet Locking
+//!
+//! To add/remove media from a media set, or to modify catalogs we
+//! always do lock_media_set(). Also, we aquire this lock during
+//! restore, to make sure it is not reused for backups.
+//!
 
 use std::collections::{HashMap, BTreeMap};
 use std::path::{Path, PathBuf};
+use std::os::unix::io::AsRawFd;
+use std::fs::File;
+use std::time::Duration;
 
 use anyhow::{bail, Error};
 use serde::{Serialize, Deserialize};
@@ -16,6 +37,7 @@ use proxmox::tools::{
     fs::{
         open_file_locked,
         replace_file,
+        fchown,
         file_get_json,
         CreateOptions,
     },
@@ -26,13 +48,18 @@ use crate::{
     api2::types::{
         MediaSetPolicy,
         RetentionPolicy,
+        MediaStatus,
+        MediaLocation,
     },
     tape::{
         TAPE_STATUS_DIR,
+        MediaSet,
+        MediaCatalog,
         file_formats::{
             MediaLabel,
             MediaSetLabel,
         },
+        changer::OnlineStatusMap,
     },
 };
 
@@ -46,83 +73,19 @@ pub struct MediaId {
     pub media_set_label: Option<MediaSetLabel>,
 }
 
-/// Media Set
-///
-/// A List of backup media
-#[derive(Debug, Serialize, Deserialize)]
-pub struct MediaSet {
-    /// Unique media set ID
-    uuid: Uuid,
-    /// List of BackupMedia
-    media_list: Vec<Option<Uuid>>,
-}
-
-impl MediaSet {
 
-    pub const MEDIA_SET_MAX_SEQ_NR: u64 = 100;
-
-    pub fn new() -> Self {
-        let uuid = Uuid::generate();
-        Self {
-            uuid,
-            media_list: Vec::new(),
-        }
-    }
-
-    pub fn with_data(uuid: Uuid, media_list: Vec<Option<Uuid>>) -> Self {
-        Self { uuid, media_list }
-    }
-
-    pub fn uuid(&self) -> &Uuid {
-        &self.uuid
-    }
-
-    pub fn media_list(&self) -> &[Option<Uuid>] {
-        &self.media_list
-    }
-
-    pub fn add_media(&mut self, uuid: Uuid) {
-        self.media_list.push(Some(uuid));
-    }
-
-    pub fn insert_media(&mut self, uuid: Uuid, seq_nr: u64) -> Result<(), Error> {
-        if seq_nr > Self::MEDIA_SET_MAX_SEQ_NR {
-            bail!("media set sequence number to large in media set {} ({} > {})",
-                  self.uuid.to_string(), seq_nr, Self::MEDIA_SET_MAX_SEQ_NR);
-        }
-        let seq_nr = seq_nr as usize;
-        if self.media_list.len() > seq_nr {
-            if self.media_list[seq_nr].is_some() {
-                bail!("found duplicate squence number in media set '{}/{}'",
-                      self.uuid.to_string(), seq_nr);
-            }
-        } else {
-            self.media_list.resize(seq_nr + 1, None);
-        }
-        self.media_list[seq_nr] = Some(uuid);
-        Ok(())
-    }
-
-    pub fn last_media_uuid(&self) -> Option<&Uuid> {
-        match self.media_list.last() {
-            None => None,
-            Some(None) => None,
-            Some(Some(ref last_uuid)) => Some(last_uuid),
-        }
-    }
-
-    pub fn is_last_media(&self, uuid: &Uuid) -> bool {
-        match self.media_list.last() {
-            None => false,
-            Some(None) => false,
-            Some(Some(last_uuid)) => uuid == last_uuid,
-        }
-    }
+#[derive(Serialize,Deserialize)]
+struct MediaStateEntry {
+    id: MediaId,
+    #[serde(skip_serializing_if="Option::is_none")]
+    location: Option<MediaLocation>,
+    #[serde(skip_serializing_if="Option::is_none")]
+    status: Option<MediaStatus>,
 }
 
 /// Media Inventory
 pub struct Inventory {
-    map: BTreeMap<Uuid, MediaId>,
+    map: BTreeMap<Uuid, MediaStateEntry>,
 
     inventory_path: PathBuf,
     lockfile_path: PathBuf,
@@ -136,7 +99,8 @@ 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 {
+    /// Create empty instance, no data loaded
+    pub fn new(base_path: &Path) -> Self {
 
         let mut inventory_path = base_path.to_owned();
         inventory_path.push(Self::MEDIA_INVENTORY_FILENAME);
@@ -171,8 +135,8 @@ impl Inventory {
 
         let mut set_start_times = HashMap::new();
 
-        for media in self.map.values() {
-            let set = match &media.media_set_label {
+        for entry in self.map.values() {
+            let set = match &entry.id.media_set_label {
                 None => continue,
                 Some(set) => set,
             };
@@ -185,33 +149,48 @@ impl Inventory {
     }
 
     /// Lock the database
-    pub fn lock(&self) -> Result<std::fs::File, Error> {
-        open_file_locked(&self.lockfile_path, std::time::Duration::new(10, 0), true)
+    fn lock(&self) -> Result<std::fs::File, Error> {
+        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<BTreeMap<Uuid, MediaId>, Error> {
+    fn load_media_db(path: &Path) -> Result<BTreeMap<Uuid, MediaStateEntry>, Error> {
 
         let data = file_get_json(path, Some(json!([])))?;
-        let media_list: Vec<MediaId> = serde_json::from_value(data)?;
+        let media_list: Vec<MediaStateEntry> = serde_json::from_value(data)?;
 
         let mut map = BTreeMap::new();
-        for item in media_list.into_iter() {
-            map.insert(item.label.uuid.clone(), item);
+        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<&MediaId> = self.map.values().collect();
+        let list: Vec<&MediaStateEntry> = self.map.values().collect();
         let raw = serde_json::to_string_pretty(&serde_json::to_value(list)?)?;
 
-        let backup_user = crate::backup::backup_user()?;
         let mode = nix::sys::stat::Mode::from_bits_truncate(0o0640);
-        let options = CreateOptions::new()
-            .perm(mode)
-            .owner(backup_user.uid)
-            .group(backup_user.gid);
+
+        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)?;
 
@@ -219,22 +198,40 @@ impl Inventory {
     }
 
     /// Stores a single MediaID persistently
-    pub fn store(&mut self, mut media_id: MediaId) -> Result<(), Error> {
+    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)?;
 
-        // do not overwrite unsaved pool assignments
-        if media_id.media_set_label.is_none() {
-            if let Some(previous) = self.map.get(&media_id.label.uuid) {
-                if let Some(ref set) = previous.media_set_label {
+        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.map.insert(media_id.label.uuid.clone(), media_id);
         self.update_helpers();
         self.replace_file()?;
         Ok(())
@@ -252,17 +249,23 @@ impl Inventory {
 
     /// Lookup media
     pub fn lookup_media(&self, uuid: &Uuid) -> Option<&MediaId> {
-        self.map.get(uuid)
+        self.map.get(uuid).map(|entry| &entry.id)
     }
 
-    /// find media by changer_id
-    pub fn find_media_by_changer_id(&self, changer_id: &str) -> Option<&MediaId> {
-        for (_uuid, media_id) in &self.map {
-            if media_id.label.changer_id == changer_id {
-                return Some(media_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
             }
-        }
-        None
+        })
     }
 
     /// Lookup media pool
@@ -271,8 +274,8 @@ impl Inventory {
     pub fn lookup_media_pool(&self, uuid: &Uuid) -> Option<(&str, bool)> {
         match self.map.get(uuid) {
             None => None,
-            Some(media_id) => {
-                match media_id.media_set_label {
+            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];
@@ -287,25 +290,24 @@ impl Inventory {
     pub fn list_pool_media(&self, pool: &str) -> Vec<MediaId> {
         let mut list = Vec::new();
 
-        for (_uuid, media_id) in &self.map {
-            match media_id.media_set_label {
+        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] { // should we do this??
+                    if set.uuid.as_ref() == [0u8;16] {
                         list.push(MediaId {
-                            label: media_id.label.clone(),
+                            label: entry.id.label.clone(),
                             media_set_label: None,
                         })
                     } else {
-                        list.push(media_id.clone());
+                        list.push(entry.id.clone());
                     }
                 }
             }
-
         }
 
         list
@@ -315,12 +317,12 @@ impl Inventory {
     pub fn list_used_media(&self) -> Vec<MediaId> {
         let mut list = Vec::new();
 
-        for (_uuid, media_id) in &self.map {
-            match media_id.media_set_label {
+        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(media_id.clone());
+                        list.push(entry.id.clone());
                     }
                 }
             }
@@ -331,19 +333,17 @@ impl Inventory {
 
     /// List media not assigned to any pool
     pub fn list_unassigned_media(&self) -> Vec<MediaId> {
-        let mut list = Vec::new();
-
-        for (_uuid, media_id) in &self.map {
-            if media_id.media_set_label.is_none() {
-                list.push(media_id.clone());
+        self.map.values().filter_map(|entry|
+            if entry.id.media_set_label.is_none() {
+                Some(entry.id.clone())
+            } else {
+                None
             }
-        }
-
-        list
+        ).collect()
     }
 
     pub fn media_set_start_time(&self, media_set_uuid: &Uuid) -> Option<i64> {
-        self.media_set_start_times.get(media_set_uuid).map(|t| *t)
+        self.media_set_start_times.get(media_set_uuid).copied()
     }
 
     /// Lookup media set pool
@@ -351,14 +351,14 @@ impl Inventory {
 
         let mut last_pool = None;
 
-        for media in self.map.values() {
-            match media.media_set_label {
+        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(&media.label.uuid) {
+                    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");
@@ -373,7 +373,7 @@ impl Inventory {
 
         match last_pool {
             Some(pool) => Ok(pool.to_string()),
-            None => bail!("media set {} is incomplete - unable to lookup pool"),
+            None => bail!("media set {} is incomplete - unable to lookup pool", media_set_uuid),
         }
     }
 
@@ -382,14 +382,14 @@ impl Inventory {
 
         let mut set = MediaSet::with_data(media_set_uuid.clone(), Vec::new());
 
-        for media in self.map.values() {
-            match media.media_set_label {
+        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(media.label.uuid.clone(), seq_nr)?;
+                    set.insert_media(entry.id.label.uuid.clone(), seq_nr)?;
                 }
             }
         }
@@ -402,8 +402,8 @@ impl Inventory {
 
         let mut set_map: HashMap<Uuid, MediaSet> = HashMap::new();
 
-        for media in self.map.values() {
-            match media.media_set_label {
+        for entry in self.map.values() {
+            match entry.id.media_set_label {
                 None => continue,
                 Some(MediaSetLabel { seq_nr, ref uuid, .. }) => {
 
@@ -411,7 +411,7 @@ impl Inventory {
                         MediaSet::with_data(uuid.clone(), Vec::new())
                     });
 
-                    set.insert_media(media.label.uuid.clone(), seq_nr)?;
+                    set.insert_media(entry.id.label.uuid.clone(), seq_nr)?;
                 }
             }
         }
@@ -425,8 +425,8 @@ impl Inventory {
         let mut last_set: Option<(Uuid, i64)> = None;
 
         let set_list = self.map.values()
-            .filter_map(|media| media.media_set_label.as_ref())
-            .filter(|set| &set.pool == &pool && set.uuid.as_ref() != [0u8;16]);
+            .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 {
@@ -448,8 +448,8 @@ impl Inventory {
 
         // consistency check - must be the only set with that ctime
         let set_list = self.map.values()
-            .filter_map(|media| media.media_set_label.as_ref())
-            .filter(|set| &set.pool == &pool && set.uuid.as_ref() != [0u8;16]);
+            .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
@@ -466,7 +466,7 @@ impl Inventory {
     fn media_set_next_start_time(&self, media_set_uuid: &Uuid) -> Option<i64> {
 
         let (pool, ctime) = match self.map.values()
-            .filter_map(|media| media.media_set_label.as_ref())
+            .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))
@@ -479,8 +479,8 @@ impl Inventory {
             };
 
         let set_list = self.map.values()
-            .filter_map(|media| media.media_set_label.as_ref())
-            .filter(|set| (&set.uuid != media_set_uuid) && (&set.pool == &pool));
+            .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;
 
@@ -527,20 +527,27 @@ impl Inventory {
             Some(time) => time,
         };
 
-        let max_use_time = match media_set_policy {
-            MediaSetPolicy::ContinueCurrent => {
-                match self.media_set_next_start_time(&set.uuid) {
-                    Some(next_start_time) => next_start_time,
-                    None => return i64::MAX,
-                }
+        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,
+               }
             }
-            MediaSetPolicy::AlwaysCreate => {
-                set_start_time + 1
-            }
-            MediaSetPolicy::CreateAt(ref event) => {
-                match compute_next_event(event, set_start_time, false) {
-                    Ok(Some(next)) => next,
-                    Ok(None) | Err(_) => return i64::MAX,
+            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,
+                        }
+                    }
                 }
             }
         };
@@ -565,7 +572,7 @@ impl Inventory {
     ) -> Result<String, Error> {
 
         if let Some(ctime) = self.media_set_start_time(media_set_uuid) {
-            let mut template = template.unwrap_or(String::from("%id%"));
+            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 {
@@ -576,65 +583,229 @@ impl Inventory {
 
     // Helpers to simplify testing
 
-    /// Genreate and insert a new free tape (test helper)
-    pub fn generate_free_tape(&mut self, changer_id: &str, ctime: i64) -> Uuid {
+    /// Generate and insert a new free tape (test helper)
+    pub fn generate_free_tape(&mut self, label_text: &str, ctime: i64) -> Uuid {
 
         let label = MediaLabel {
-            changer_id: changer_id.to_string(),
+            label_text: label_text.to_string(),
             uuid: Uuid::generate(),
             ctime,
         };
         let uuid = label.uuid.clone();
 
-        self.store(MediaId { label, media_set_label: None }).unwrap();
+        self.store(MediaId { label, media_set_label: None }, false).unwrap();
 
         uuid
     }
 
-    /// Genreate and insert a new tape assigned to a specific pool
+    /// Generate and insert a new tape assigned to a specific pool
     /// (test helper)
     pub fn generate_assigned_tape(
         &mut self,
-        changer_id: &str,
+        label_text: &str,
         pool: &str,
         ctime: i64,
     ) -> Uuid {
 
         let label = MediaLabel {
-            changer_id: changer_id.to_string(),
+            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);
+        let set = MediaSetLabel::with_data(pool, [0u8; 16].into(), 0, ctime, None);
 
-        self.store(MediaId { label, media_set_label: Some(set) }).unwrap();
+        self.store(MediaId { label, media_set_label: Some(set) }, false).unwrap();
 
         uuid
     }
 
-    /// Genreate and insert a used tape (test helper)
+    /// Generate and insert a used tape (test helper)
     pub fn generate_used_tape(
         &mut self,
-        changer_id: &str,
+        label_text: &str,
         set: MediaSetLabel,
         ctime: i64,
     ) -> Uuid {
         let label = MediaLabel {
-            changer_id: changer_id.to_string(),
+            label_text: label_text.to_string(),
             uuid: Uuid::generate(),
             ctime,
         };
         let uuid = label.uuid.clone();
 
-        self.store(MediaId { label, media_set_label: Some(set) }).unwrap();
+        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<MediaStatus>) -> 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<MediaLocation>) -> 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(())
+    }
+
+}
+
+/// Lock a media pool
+pub fn lock_media_pool(base_path: &Path, name: &str) -> Result<File, Error> {
+    let mut path = base_path.to_owned();
+    path.push(format!(".pool-{}", name));
+    path.set_extension("lck");
+
+    let timeout = std::time::Duration::new(10, 0);
+    let lock = proxmox::tools::fs::open_file_locked(&path, timeout, true)?;
+
+    if cfg!(test) {
+        // We cannot use chown inside test environment (no permissions)
+        return Ok(lock);
+    }
+
+    let backup_user = crate::backup::backup_user()?;
+    fchown(lock.as_raw_fd(), Some(backup_user.uid), Some(backup_user.gid))?;
+
+    Ok(lock)
+}
+
+/// Lock for media not assigned to any pool
+pub fn lock_unassigned_media_pool(base_path: &Path)  -> Result<File, Error> {
+    // lock artificial "__UNASSIGNED__" pool to avoid races
+    lock_media_pool(base_path, "__UNASSIGNED__")
+}
+
+/// Lock a media set
+///
+/// Timeout is 10 seconds by default
+pub fn lock_media_set(
+    base_path: &Path,
+    media_set_uuid: &Uuid,
+    timeout: Option<Duration>,
+) -> Result<File, Error> {
+    let mut path = base_path.to_owned();
+    path.push(format!(".media-set-{}", media_set_uuid));
+    path.set_extension("lck");
+
+    let timeout = timeout.unwrap_or(Duration::new(10, 0));
+    let file = open_file_locked(&path, timeout, 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)
+}
+
 // shell completion helper
 
 /// List of known media uuids
@@ -663,12 +834,12 @@ pub fn complete_media_set_uuid(
     };
 
     inventory.map.values()
-        .filter_map(|media| media.media_set_label.as_ref())
+        .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_changer_id(
+pub fn complete_media_label_text(
     _arg: &str,
     _param: &HashMap<String, String>,
 ) -> Vec<String> {
@@ -678,5 +849,40 @@ pub fn complete_media_changer_id(
         Err(_) => return Vec::new(),
     };
 
-    inventory.map.values().map(|media| media.label.changer_id.clone()).collect()
+    inventory.map.values().map(|entry| entry.id.label.label_text.clone()).collect()
+}
+
+pub fn complete_media_set_snapshots(_arg: &str, param: &HashMap<String, String>) -> Vec<String> {
+    let media_set_uuid: Uuid = match param.get("media-set").and_then(|s| s.parse().ok()) {
+        Some(uuid) => uuid,
+        None => return Vec::new(),
+    };
+    let status_path = Path::new(TAPE_STATUS_DIR);
+    let inventory = match Inventory::load(&status_path) {
+        Ok(inventory) => inventory,
+        Err(_) => return Vec::new(),
+    };
+
+    let mut res = Vec::new();
+    let media_ids = inventory.list_used_media().into_iter().filter(|media| {
+        match &media.media_set_label {
+            Some(label) => label.uuid == media_set_uuid,
+            None => false,
+        }
+    });
+
+    for media_id in media_ids {
+        let catalog = match MediaCatalog::open(status_path, &media_id, false, false) {
+            Ok(catalog) => catalog,
+            Err(_) => continue,
+        };
+
+        for (store, content) in catalog.content() {
+            for snapshot in content.snapshot_index.keys() {
+                res.push(format!("{}:{}", store, snapshot));
+            }
+        }
+    }
+
+    res
 }