use std::path::{Path, PathBuf};
use boxlite_shared::errors::BoxliteResult;
use serde::{Deserialize, Serialize};
use crate::db::base_disk::BaseDiskStore;
use crate::runtime::id::{BaseDiskID, BaseDiskIDMint};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BaseDiskKind {
Snapshot,
CloneBase,
Rootfs,
}
impl BaseDiskKind {
pub(crate) fn as_str(&self) -> &'static str {
match self {
Self::Snapshot => "snapshot",
Self::CloneBase => "clone_base",
Self::Rootfs => "rootfs",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BaseDisk {
pub id: BaseDiskID,
pub source_box_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
pub kind: BaseDiskKind,
#[serde(flatten)]
pub disk_info: super::DiskInfo,
pub created_at: i64,
}
use crate::disk::constants::filenames as disk_filenames;
#[derive(Clone)]
pub(crate) struct BaseDiskManager {
bases_dir: PathBuf,
store: BaseDiskStore,
}
impl BaseDiskManager {
pub(crate) fn new(bases_dir: PathBuf, store: BaseDiskStore) -> Self {
let bases_dir = bases_dir
.canonicalize()
.unwrap_or_else(|_| bases_dir.clone());
Self { bases_dir, store }
}
pub(crate) fn store(&self) -> &BaseDiskStore {
&self.store
}
#[allow(dead_code)] pub(crate) fn bases_dir(&self) -> &Path {
&self.bases_dir
}
pub(crate) fn create_base_disk(
&self,
source_disks_dir: &Path,
kind: BaseDiskKind,
name: Option<&str>,
source_box_id: &str,
) -> BoxliteResult<BaseDisk> {
let base_disk_id = BaseDiskIDMint::mint();
let container = source_disks_dir.join(disk_filenames::CONTAINER_DISK);
let base_file = self.bases_dir.join(format!("{}.qcow2", base_disk_id));
let forked = super::fork_qcow2(&container, &base_file)?;
let disk_info = super::DiskInfo::from(&forked);
let now = chrono::Utc::now().timestamp();
let disk = BaseDisk {
id: base_disk_id,
source_box_id: source_box_id.to_string(),
name: name.map(|s| s.to_string()),
kind,
disk_info,
created_at: now,
};
self.store.insert(&disk)?;
self.store.add_ref(&disk.id, source_box_id)?;
Ok(disk)
}
pub(crate) fn try_gc_base(&self, base_disk_id: &BaseDiskID) {
let record = match self.store.find_by_id(base_disk_id) {
Ok(Some(r)) => r,
_ => return,
};
if record.kind() != BaseDiskKind::CloneBase {
return;
}
if self.store.has_dependents(base_disk_id).unwrap_or(true) {
return;
}
let base_file = record.disk_info().to_path_buf();
let parent = self.find_parent_base(&base_file);
let _ = self.store.delete(record.id());
let _ = std::fs::remove_file(record.disk_info().as_path());
tracing::info!(
base_disk_id = %record.id(),
"Garbage-collected clone base (no dependents)"
);
if let Some(parent_path) = parent
&& let Ok(Some(parent_record)) =
self.store.find_by_base_path(&parent_path.to_string_lossy())
{
self.try_gc_base(parent_record.id());
}
}
pub(crate) fn find_parent_base(&self, qcow2_path: &Path) -> Option<PathBuf> {
let chain = super::read_backing_chain(qcow2_path);
let bases_dir_str = self.bases_dir.to_string_lossy();
for backing in chain {
let backing_str = backing.to_string_lossy();
if backing_str.starts_with(bases_dir_str.as_ref()) {
return Some(backing);
}
}
None
}
#[allow(dead_code)] pub(crate) fn identify_layer(&self, box_disks_dir: &Path) -> Option<String> {
let container = box_disks_dir.join(disk_filenames::CONTAINER_DISK);
if !container.exists() {
return None;
}
match super::read_backing_file_path(&container) {
Ok(Some(backing_path)) => {
let bases_dir_str = self.bases_dir.to_string_lossy();
if backing_path.starts_with(bases_dir_str.as_ref()) {
Some(backing_path)
} else {
None
}
}
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::Database;
use crate::disk::DiskInfo;
use tempfile::TempDir;
fn base_id(id: &str) -> BaseDiskID {
BaseDiskID::parse(id).expect("test ID must be valid Base62 length-8")
}
fn setup() -> (TempDir, BaseDiskManager) {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("db").join("test.db");
let db = Database::open(&db_path).unwrap();
let bases_dir = dir.path().join("bases");
std::fs::create_dir_all(&bases_dir).unwrap();
let store = BaseDiskStore::new(db);
let mgr = BaseDiskManager::new(bases_dir, store);
(dir, mgr)
}
fn write_qcow2_with_backing(path: &Path, backing: Option<&str>) {
use std::io::Write;
let mut buf = vec![0u8; 1024];
buf[0..4].copy_from_slice(&0x514649fbu32.to_be_bytes());
buf[4..8].copy_from_slice(&3u32.to_be_bytes());
buf[24..32].copy_from_slice(&(1024u64 * 1024 * 1024).to_be_bytes());
if let Some(backing_str) = backing {
let backing_bytes = backing_str.as_bytes();
buf[8..16].copy_from_slice(&512u64.to_be_bytes());
buf[16..20].copy_from_slice(&(backing_bytes.len() as u32).to_be_bytes());
buf[512..512 + backing_bytes.len()].copy_from_slice(backing_bytes);
}
let mut file = std::fs::File::create(path).unwrap();
file.write_all(&buf).unwrap();
}
#[test]
fn test_create_base_disk_moves_disk() {
let (dir, mgr) = setup();
let box_disks = dir.path().join("boxes").join("box-1").join("disks");
std::fs::create_dir_all(&box_disks).unwrap();
write_qcow2_with_backing(&box_disks.join(disk_filenames::CONTAINER_DISK), None);
let disk = mgr
.create_base_disk(&box_disks, BaseDiskKind::Snapshot, Some("snap-1"), "box-1")
.unwrap();
assert!(box_disks.join(disk_filenames::CONTAINER_DISK).exists());
let base_file = disk.disk_info.to_path_buf();
assert!(base_file.exists());
assert!(base_file.extension().is_some_and(|ext| ext == "qcow2"));
assert_eq!(base_file.parent().unwrap(), mgr.bases_dir());
assert!(
disk.disk_info
.base_path
.ends_with(&format!("{}.qcow2", disk.id))
);
let found = mgr.store().find_by_id(&disk.id).unwrap().unwrap();
assert_eq!(found.kind(), BaseDiskKind::Snapshot);
assert_eq!(found.name(), Some("snap-1"));
}
#[test]
fn test_create_base_disk_adds_ref() {
let (dir, mgr) = setup();
let box_disks = dir.path().join("boxes").join("box-1").join("disks");
std::fs::create_dir_all(&box_disks).unwrap();
write_qcow2_with_backing(&box_disks.join(disk_filenames::CONTAINER_DISK), None);
let disk = mgr
.create_base_disk(&box_disks, BaseDiskKind::CloneBase, None, "box-1")
.unwrap();
assert!(mgr.store().has_dependents(&disk.id).unwrap());
let deps = mgr.store().dependent_boxes(&disk.id).unwrap();
assert_eq!(deps, vec!["box-1"]);
}
#[test]
fn test_create_base_disk_uses_base_disk_id() {
let (dir, mgr) = setup();
let box_disks = dir.path().join("boxes").join("box-1").join("disks");
std::fs::create_dir_all(&box_disks).unwrap();
write_qcow2_with_backing(&box_disks.join(disk_filenames::CONTAINER_DISK), None);
let disk = mgr
.create_base_disk(&box_disks, BaseDiskKind::Snapshot, Some("snap-1"), "box-1")
.unwrap();
assert_eq!(disk.id.as_str().len(), BaseDiskID::FULL_LENGTH);
assert!(
disk.disk_info
.base_path
.ends_with(&format!("{}.qcow2", disk.id))
);
}
#[test]
fn test_create_base_disk_tracks_ancestry() {
let (dir, mgr) = setup();
let box_disks = dir.path().join("boxes").join("box-1").join("disks");
std::fs::create_dir_all(&box_disks).unwrap();
write_qcow2_with_backing(&box_disks.join(disk_filenames::CONTAINER_DISK), None);
let bd1 = mgr
.create_base_disk(&box_disks, BaseDiskKind::CloneBase, None, "box-1")
.unwrap();
let bd2 = mgr
.create_base_disk(&box_disks, BaseDiskKind::CloneBase, None, "box-1")
.unwrap();
let bd2_file = bd2.disk_info.to_path_buf();
let parent = mgr.find_parent_base(&bd2_file);
assert_eq!(
parent.as_ref().map(|p| p.to_string_lossy().to_string()),
Some(bd1.disk_info.base_path.clone()),
"bd2 should have bd1 as parent in its backing chain"
);
assert!(mgr.store().find_by_id(&bd1.id).unwrap().is_some());
assert!(mgr.store().find_by_id(&bd2.id).unwrap().is_some());
}
#[test]
fn test_find_parent_base() {
let (dir, mgr) = setup();
let base_file = mgr.bases_dir.join("layer-1.qcow2");
write_qcow2_with_backing(&base_file, None);
let test_qcow2 = dir.path().join("test.qcow2");
write_qcow2_with_backing(&test_qcow2, Some(&base_file.to_string_lossy()));
let parent = mgr.find_parent_base(&test_qcow2);
assert_eq!(parent, Some(base_file));
let standalone = dir.path().join("standalone.qcow2");
write_qcow2_with_backing(&standalone, None);
assert!(mgr.find_parent_base(&standalone).is_none());
}
#[test]
fn test_identify_layer() {
let (dir, mgr) = setup();
let base_file = mgr.bases_dir.join("layer-42.qcow2");
write_qcow2_with_backing(&base_file, None);
let box_disks = dir.path().join("boxes").join("box-1").join("disks");
std::fs::create_dir_all(&box_disks).unwrap();
write_qcow2_with_backing(
&box_disks.join(disk_filenames::CONTAINER_DISK),
Some(&base_file.to_string_lossy()),
);
let identified = mgr.identify_layer(&box_disks);
assert_eq!(
identified.as_deref(),
Some(base_file.to_string_lossy().as_ref())
);
let box_disks2 = dir.path().join("boxes").join("box-2").join("disks");
std::fs::create_dir_all(&box_disks2).unwrap();
write_qcow2_with_backing(&box_disks2.join(disk_filenames::CONTAINER_DISK), None);
assert!(mgr.identify_layer(&box_disks2).is_none());
}
#[test]
fn test_try_gc_base_single_level() {
let (_dir, mgr) = setup();
let base_file = mgr.bases_dir.join("base0001.qcow2");
write_qcow2_with_backing(&base_file, None);
let disk = BaseDisk {
id: base_id("base0001"),
source_box_id: "src".to_string(),
name: None,
kind: BaseDiskKind::CloneBase,
disk_info: DiskInfo {
base_path: base_file.to_string_lossy().to_string(),
container_disk_bytes: 0,
size_bytes: 0,
},
created_at: 0,
};
mgr.store().insert(&disk).unwrap();
mgr.store()
.add_ref(&base_id("base0001"), "clone-1")
.unwrap();
mgr.store().remove_all_refs_for_box("clone-1").unwrap();
mgr.try_gc_base(&base_id("base0001"));
assert!(
mgr.store()
.find_by_id(&base_id("base0001"))
.unwrap()
.is_none()
);
assert!(!base_file.exists());
}
#[test]
fn test_try_gc_base_cascade_nested() {
let (_dir, mgr) = setup();
let base1_file = mgr.bases_dir.join("base0001.qcow2");
write_qcow2_with_backing(&base1_file, None);
let bd1 = BaseDisk {
id: base_id("base0001"),
source_box_id: "src".to_string(),
name: None,
kind: BaseDiskKind::CloneBase,
disk_info: DiskInfo {
base_path: base1_file.to_string_lossy().to_string(),
container_disk_bytes: 0,
size_bytes: 0,
},
created_at: 0,
};
mgr.store().insert(&bd1).unwrap();
let base2_file = mgr.bases_dir.join("base0002.qcow2");
write_qcow2_with_backing(&base2_file, Some(&base1_file.to_string_lossy()));
let bd2 = BaseDisk {
id: base_id("base0002"),
source_box_id: "clone-of-src".to_string(),
name: None,
kind: BaseDiskKind::CloneBase,
disk_info: DiskInfo {
base_path: base2_file.to_string_lossy().to_string(),
container_disk_bytes: 0,
size_bytes: 0,
},
created_at: 0,
};
mgr.store().insert(&bd2).unwrap();
mgr.try_gc_base(&base_id("base0002"));
assert!(
mgr.store()
.find_by_id(&base_id("base0002"))
.unwrap()
.is_none(),
"base-2 should be deleted"
);
assert!(!base2_file.exists(), "base-2 file should be removed");
assert!(
mgr.store()
.find_by_id(&base_id("base0001"))
.unwrap()
.is_none(),
"base-1 should cascade-delete"
);
assert!(!base1_file.exists(), "base-1 file should be removed");
}
#[test]
fn test_try_gc_base_skips_snapshots() {
let (_dir, mgr) = setup();
let base_file = mgr.bases_dir.join("snap0001.qcow2");
write_qcow2_with_backing(&base_file, None);
let disk = BaseDisk {
id: base_id("snap0001"),
source_box_id: "box-1".to_string(),
name: Some("my-snapshot".to_string()),
kind: BaseDiskKind::Snapshot,
disk_info: DiskInfo {
base_path: base_file.to_string_lossy().to_string(),
container_disk_bytes: 0,
size_bytes: 0,
},
created_at: 0,
};
mgr.store().insert(&disk).unwrap();
mgr.try_gc_base(&base_id("snap0001"));
assert!(
mgr.store()
.find_by_id(&base_id("snap0001"))
.unwrap()
.is_some(),
"Snapshot should NOT be auto-deleted"
);
assert!(base_file.exists(), "Snapshot file should still exist");
}
#[test]
fn test_try_gc_base_preserves_with_dependents() {
let (_dir, mgr) = setup();
let base_file = mgr.bases_dir.join("shared01.qcow2");
write_qcow2_with_backing(&base_file, None);
let disk = BaseDisk {
id: base_id("shared01"),
source_box_id: "src".to_string(),
name: None,
kind: BaseDiskKind::CloneBase,
disk_info: DiskInfo {
base_path: base_file.to_string_lossy().to_string(),
container_disk_bytes: 0,
size_bytes: 0,
},
created_at: 0,
};
mgr.store().insert(&disk).unwrap();
mgr.store()
.add_ref(&base_id("shared01"), "clone-1")
.unwrap();
mgr.store()
.add_ref(&base_id("shared01"), "clone-2")
.unwrap();
mgr.store().remove_all_refs_for_box("clone-1").unwrap();
mgr.try_gc_base(&base_id("shared01"));
assert!(
mgr.store()
.find_by_id(&base_id("shared01"))
.unwrap()
.is_some(),
"Base should survive (clone-2 still depends on it)"
);
assert!(base_file.exists());
mgr.store().remove_all_refs_for_box("clone-2").unwrap();
mgr.try_gc_base(&base_id("shared01"));
assert!(
mgr.store()
.find_by_id(&base_id("shared01"))
.unwrap()
.is_none(),
"Base should be deleted (no more dependents)"
);
assert!(!base_file.exists());
}
}