From 49ca87d97b55736806ebbc7f88717cd56507d3a5 Mon Sep 17 00:00:00 2001 From: Aljoscha Krettek Date: Thu, 30 Apr 2026 13:24:44 +0200 Subject: [PATCH] cli: drop snapshot/restore in favor of fork as the CoW primitive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes the user-facing `ember snapshot {create,restore,list,delete}` commands along with the corresponding StorageBackend trait methods, backend impls (ZFS, dm-thin, APFS), the SnapshotEntry/SnapshotInfo types, and the VmMetadata.snapshots field. Internal `@base` and `fork-` snapshots used by image cloning and fork stay in place. The "checkpoint before risky change → roll back" workflow becomes "fork before the change → delete the bad VM". Fork already exercises the same CoW machinery on every backend, so this is a strict simplification. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 23 +- crates/ember-core/src/backend.rs | 57 +-- crates/ember-core/src/state/vm.rs | 30 -- crates/ember-linux/src/dm_thin_storage.rs | 120 +----- crates/ember-linux/src/storage.rs | 43 +- crates/ember-linux/src/zfs/snapshot.rs | 2 +- crates/ember-macos/src/storage.rs | 196 +-------- docs/BTRFS-SPEC.md | 29 +- docs/DM-THIN-SPEC.md | 73 +--- docs/MACOS-SPEC.md | 44 +- docs/SPEC.md | 23 +- src/cli.rs | 5 - src/cli/debug.rs | 17 +- src/cli/snapshot.rs | 270 ------------- src/cli/vm.rs | 2 - src/main.rs | 1 - tests/macos_storage.rs | 25 +- tests/snapshot.rs | 470 ---------------------- 18 files changed, 57 insertions(+), 1373 deletions(-) delete mode 100644 src/cli/snapshot.rs delete mode 100644 tests/snapshot.rs diff --git a/README.md b/README.md index 500035f..f5fd7d9 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ ember vm create myvm --image ubuntu-dev ember ssh myvm ``` -No `sudo` needed. State is stored in `~/Library/Application Support/ember/`. Storage uses instant APFS copy-on-write clones — creating VMs and snapshots takes milliseconds regardless of disk size. +No `sudo` needed. State is stored in `~/Library/Application Support/ember/`. Storage uses instant APFS copy-on-write clones — creating and forking VMs takes milliseconds regardless of disk size. The kernel build requires Docker or Podman and enables Docker networking inside your VMs. It only needs to run once — the kernel is cached for all future VMs. If you don't need Docker inside VMs, skip the `kernel build` step and the stock kernel will be auto-downloaded on first use. @@ -201,23 +201,6 @@ Forks can grow the disk but not shrink it below the source size. Use `--no-start ember vm fork base template --no-start ``` -## Snapshots - -Snapshots capture point-in-time state of a VM's disk. Useful for checkpointing before risky changes. - -```bash -ember snapshot create myvm before-upgrade -ember snapshot list myvm - -# Something went wrong? Roll back (VM must be stopped): -ember vm stop myvm -ember snapshot restore myvm before-upgrade -ember vm start myvm - -# Clean up: -ember snapshot delete myvm before-upgrade -``` - ## Guest access SSH keys are auto-injected at image build and VM creation time. The SSH user is auto-detected (`ubuntu` if `/home/ubuntu` exists, otherwise `root`). @@ -237,7 +220,7 @@ ember cp myvm:/var/log/syslog ./syslog.txt ## Storage efficiency -Both platforms use copy-on-write storage, so VMs and snapshots share disk blocks with their parent image. Check actual disk usage: +Both platforms use copy-on-write storage, so VMs and forks share disk blocks with their parent image. Check actual disk usage: ```bash ember debug storage-efficiency @@ -306,7 +289,7 @@ The CLI is identical on both platforms. Under the hood: | | Linux | macOS | |---|---|---| | Hypervisor | Firecracker (KVM) | Apple Virtualization Framework | -| Storage | ZFS zvols + snapshots | APFS clones (`cp -c`) | +| Storage | ZFS zvols + clones | APFS clones (`cp -c`) | | Networking | TAP devices + iptables NAT | vmnet shared mode | | Root required | Yes | No | | State directory | `/var/lib/ember/` | `~/Library/Application Support/ember/` | diff --git a/crates/ember-core/src/backend.rs b/crates/ember-core/src/backend.rs index 8750973..9bd48a2 100644 --- a/crates/ember-core/src/backend.rs +++ b/crates/ember-core/src/backend.rs @@ -30,27 +30,8 @@ pub struct StartedVm { pub network: NetworkInfo, } -/// Platform-agnostic snapshot information. -/// -/// On Linux/ZFS this is backed by `zfs list -t snapshot`. -/// On macOS/APFS this is backed by APFS clone files in the VM's -/// `snapshots/` directory. On Linux/dm-thin this is backed by entries -/// stored on `VmMetadata::snapshots`. -pub struct SnapshotInfo { - /// Snapshot name (e.g., "snap1"). Does not include dataset path or directory prefix. - pub name: String, - /// Creation timestamp (Unix epoch seconds). - pub created_at: u64, - /// Size in bytes. - /// - /// - Linux/ZFS: `referenced` property (bytes the snapshot points to). - /// - macOS/APFS: logical file size via `stat`. - /// - Linux/dm-thin: virtual volume size at snapshot time. - pub size: u64, -} - /// A storage volume returned by the [`StorageBackend`] when a fresh -/// volume is created (image base, VM clone, fork, restore). +/// volume is created (image base, VM clone, fork). /// /// `disk_path` is what gets recorded on `VmMetadata::disk_path` / /// `ImageEntry::disk_path` and passed to Firecracker as @@ -151,10 +132,10 @@ pub trait VmBackend { fn is_running(pid: u32) -> bool; } -/// Storage backend: manages disk images, clones, and snapshots. +/// Storage backend: manages disk images, clones, and forks. /// -/// - **Linux/ZFS**: ZFS zvols with snapshots and `zfs clone`. -/// - **Linux/dm-thin**: device-mapper thin volumes with kernel snapshots. +/// - **Linux/ZFS**: ZFS zvols with `zfs clone`. +/// - **Linux/dm-thin**: device-mapper thin volumes with kernel `create_snap`. /// - **macOS/APFS**: raw `.img` files with APFS CoW clones (`cp -c`). /// /// Methods take `&VmMetadata` / `&ImageEntry` rather than bare names @@ -208,38 +189,12 @@ pub trait StorageBackend { /// macOS/APFS: `cp -c .img /rootfs.img`. fn clone_for_vm(&self, image: &ImageEntry, vm_name: &str) -> Result; - /// Create a named snapshot of a VM's current disk state. - /// - /// Returns `Some(SnapshotEntry)` when the backend persists snapshot - /// metadata in user-space state (dm-thin). Returns `None` when the - /// backend tracks snapshots itself (ZFS in the kernel, APFS as - /// files on disk). - fn snapshot( - &self, - vm: &VmMetadata, - snap_name: &str, - ) -> Result>; - - /// Restore a VM's disk to a previously created snapshot. - /// - /// Returns a fresh `VolumeHandle` because some backends generate a - /// new identifier on restore (dm-thin's `delete` + `create_snap` - /// produces a new `thin_id`). For backends that mutate the volume - /// in place (ZFS rollback) or replace the file atomically (APFS), - /// the handle's `thin_id` is `None`. - fn restore_snapshot(&self, vm: &VmMetadata, snap_name: &str) -> Result; - - /// Delete a snapshot. - fn delete_snapshot(&self, vm: &VmMetadata, snap_name: &str) -> Result<()>; - - /// List all snapshots for a VM. - fn list_snapshots(&self, vm: &VmMetadata) -> Result>; - /// Resize a VM's disk to `new_size`. Caller is responsible for /// stopping the VM first. fn resize(&self, vm: &VmMetadata, new_size: ByteSize) -> Result<()>; - /// Destroy all storage for a VM (disk image, snapshots). + /// Destroy all storage for a VM (disk image and any internal fork + /// snapshots beneath it). fn destroy_vm_storage(&self, vm: &VmMetadata) -> Result<()>; /// Destroy storage for a base image. diff --git a/crates/ember-core/src/state/vm.rs b/crates/ember-core/src/state/vm.rs index ebf6e38..706269a 100644 --- a/crates/ember-core/src/state/vm.rs +++ b/crates/ember-core/src/state/vm.rs @@ -63,28 +63,6 @@ pub struct NetworkInfo { pub wan_iface: Option, } -/// A snapshot tracked by a backend that doesn't have a native list -/// query. -/// -/// ZFS records snapshots in the kernel and lists them via `zfs list -t -/// snapshot`, so [`VmMetadata::snapshots`] stays empty for ZFS. dm-thin -/// addresses snapshots by numeric thin id with no name attached at the -/// kernel level, so it persists names + ids in `vm.json`. macOS APFS -/// uses on-disk filenames, so it also doesn't need this list. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct SnapshotEntry { - /// User-visible snapshot name. - pub name: String, - /// Backend-specific thin id. Only meaningful for the dm-thin backend. - pub thin_id: u64, - /// Creation time as Unix epoch seconds — same shape as - /// [`crate::backend::SnapshotInfo::created_at`] so the backend's - /// `list_snapshots` can copy this through without reparsing. - pub created_at: u64, - /// Volume size in 512-byte sectors. - pub size_sectors: u64, -} - /// SSH connection configuration for a VM. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct SshConfig { @@ -158,12 +136,6 @@ pub struct VmMetadata { /// volume identity in [`disk_path`](Self::disk_path) instead. #[serde(default, skip_serializing_if = "Option::is_none")] pub thin_id: Option, - /// Snapshots maintained by the storage backend in user-space state. - /// - /// dm-thin populates this; ZFS and macOS leave it empty and surface - /// snapshots through their native APIs. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub snapshots: Vec, } impl VmMetadata { @@ -195,7 +167,6 @@ impl VmMetadata { }, parent_vm: None, thin_id: None, - snapshots: Vec::new(), } } } @@ -394,7 +365,6 @@ mod tests { ssh: SshConfig::default(), parent_vm: None, thin_id: None, - snapshots: Vec::new(), } } diff --git a/crates/ember-linux/src/dm_thin_storage.rs b/crates/ember-linux/src/dm_thin_storage.rs index 1c8b326..51fa08e 100644 --- a/crates/ember-linux/src/dm_thin_storage.rs +++ b/crates/ember-linux/src/dm_thin_storage.rs @@ -12,12 +12,12 @@ use std::fs; use std::path::{Path, PathBuf}; use std::process::Command as ProcessCommand; -use ember_core::backend::{InitConfig, SnapshotInfo, StorageBackend, VolumeHandle}; +use ember_core::backend::{InitConfig, StorageBackend, VolumeHandle}; use ember_core::config::size::ByteSize; use ember_core::config::{DmThinMode, GlobalConfig}; use ember_core::error::{Error, Result}; use ember_core::image::registry::ImageEntry; -use ember_core::state::vm::{SnapshotEntry, VmMetadata}; +use ember_core::state::vm::VmMetadata; use crate::dm_thin::{dm_device_exists, loop_device, pool, thin, tools, SECTOR_SIZE}; use crate::zvol; @@ -42,9 +42,9 @@ const MAX_METADATA_SIZE_BYTES: u64 = 16 * 1024 * 1024 * 1024; /// dm-thin storage backend. /// /// Holds the configured backing path and pool block size; thin id state -/// lives on `VmMetadata`/`ImageEntry`/`SnapshotEntry`. Concurrent -/// invocations are race-free thanks to the kernel's atomic id rejection -/// in `create_thin`/`create_snap`. +/// lives on `VmMetadata`/`ImageEntry`. Concurrent invocations are +/// race-free thanks to the kernel's atomic id rejection in +/// `create_thin`/`create_snap`. #[derive(Clone)] pub struct DmThinStorage { /// Backing path. Either a directory holding `metadata.img` and @@ -429,109 +429,6 @@ impl StorageBackend for DmThinStorage { } } - fn snapshot(&self, vm: &VmMetadata, snap_name: &str) -> Result> { - self.ensure_pool_active()?; - self.assert_pool_healthy()?; - let vm_id = Self::require_vm_thin_id(vm)?; - let dm_name = thin::vm_dm_name(&vm.name); - let size_sectors = Self::vm_size_sectors(vm); - - // Suspend so create_snap sees a metadata-coherent volume. - // Some operations (e.g. snapshotting a never-activated volume) - // can run without an active device, but suspending an inactive - // device errors. Activate first if needed. - self.ensure_thin_active(&dm_name, vm_id, size_sectors)?; - - thin::suspend(&dm_name)?; - let snap_result = thin::allocate_snap(pool::POOL_NAME, vm_id); - let _ = thin::resume(&dm_name); - let snap_id = snap_result?; - - Ok(Some(SnapshotEntry { - name: snap_name.to_string(), - thin_id: snap_id, - created_at: ember_core::state::vm::now_epoch_secs(), - size_sectors, - })) - } - - fn restore_snapshot(&self, vm: &VmMetadata, snap_name: &str) -> Result { - self.ensure_pool_active()?; - self.assert_pool_healthy()?; - let vm_id = Self::require_vm_thin_id(vm)?; - let snap = vm - .snapshots - .iter() - .find(|s| s.name == snap_name) - .ok_or_else(|| { - Error::Vm(format!( - "snapshot '{snap_name}' not found on vm '{}'", - vm.name - )) - })?; - let snap_id = snap.thin_id; - - let dm_name = thin::vm_dm_name(&vm.name); - let size_sectors = Self::vm_size_sectors(vm); - - // Allocate the replacement thin id from the snapshot up-front so - // a failure here leaves `vm.thin_id` and the kernel pool - // unchanged. The old order (deactivate -> delete -> allocate) - // would orphan `vm.thin_id` on any allocate hiccup. - let new_id = thin::allocate_snap(pool::POOL_NAME, snap_id)?; - - // Once new_id exists, swap the dm-mapper slot over to it. Any - // failure from here on must release new_id so we don't leak - // kernel state. - let result = (|| -> Result { - if dm_device_exists(&dm_name)? { - thin::deactivate(&dm_name)?; - } - thin::delete(pool::POOL_NAME, vm_id)?; - thin::activate(&dm_name, pool::POOL_NAME, new_id, size_sectors) - })(); - - match result { - Ok(disk_path) => Ok(VolumeHandle { - disk_path, - thin_id: Some(new_id), - }), - Err(e) => { - let _ = thin::delete(pool::POOL_NAME, new_id); - Err(e) - } - } - } - - fn delete_snapshot(&self, vm: &VmMetadata, snap_name: &str) -> Result<()> { - self.ensure_pool_active()?; - let snap = vm - .snapshots - .iter() - .find(|s| s.name == snap_name) - .ok_or_else(|| { - Error::Vm(format!( - "snapshot '{snap_name}' not found on vm '{}'", - vm.name - )) - })?; - thin::delete(pool::POOL_NAME, snap.thin_id) - } - - fn list_snapshots(&self, vm: &VmMetadata) -> Result> { - // dm-thin tracks snapshots via the persisted `vm.snapshots` - // list; the kernel knows nothing about names. - Ok(vm - .snapshots - .iter() - .map(|s| SnapshotInfo { - name: s.name.clone(), - created_at: s.created_at, - size: s.size_sectors * SECTOR_SIZE, - }) - .collect()) - } - fn resize(&self, vm: &VmMetadata, new_size: ByteSize) -> Result<()> { self.ensure_pool_active()?; self.assert_pool_healthy()?; @@ -558,13 +455,6 @@ impl StorageBackend for DmThinStorage { if let Ok(true) = dm_device_exists(&dm_name) { let _ = thin::deactivate(&dm_name); } - // Snapshots only live in the kernel pool; the user-level - // handle is `vm.json`, which is about to disappear. Free their - // thin ids before the VM's own id, otherwise they'd remain - // pinned in pool metadata with no way for ember to reach them. - for snap in &vm.snapshots { - let _ = thin::delete(pool::POOL_NAME, snap.thin_id); - } if let Some(id) = vm.thin_id { let _ = thin::delete(pool::POOL_NAME, id); } diff --git a/crates/ember-linux/src/storage.rs b/crates/ember-linux/src/storage.rs index 391fae9..0270434 100644 --- a/crates/ember-linux/src/storage.rs +++ b/crates/ember-linux/src/storage.rs @@ -1,4 +1,4 @@ -//! Linux storage backend: ZFS zvols, snapshots, and clones. +//! Linux storage backend: ZFS zvols and clones. //! //! Wraps the `zfs::pool`, `zfs::dataset`, `zfs::volume`, and `zfs::snapshot` //! modules behind the [`StorageBackend`] trait. On Linux, each VM's rootfs @@ -11,12 +11,12 @@ use std::path::{Path, PathBuf}; use std::process::Command as ProcessCommand; use crate::zfs; -use ember_core::backend::{InitConfig, SnapshotInfo, StorageBackend, VolumeHandle}; +use ember_core::backend::{InitConfig, StorageBackend, VolumeHandle}; use ember_core::config::size::ByteSize; use ember_core::config::GlobalConfig; use ember_core::error::{Error, Result}; use ember_core::image::registry::ImageEntry; -use ember_core::state::vm::{SnapshotEntry, VmMetadata}; +use ember_core::state::vm::VmMetadata; /// Linux storage backend using ZFS zvols. #[derive(Clone)] @@ -133,41 +133,6 @@ impl StorageBackend for LinuxStorage { Ok(VolumeHandle::from_path(vm_zvol)) } - fn snapshot(&self, vm: &VmMetadata, snap_name: &str) -> Result> { - let zvol = self.vm_zvol(&vm.name); - zfs::snapshot::create(&zvol, snap_name)?; - // ZFS records snapshots in the kernel; nothing to add to vm.json. - Ok(None) - } - - fn restore_snapshot(&self, vm: &VmMetadata, snap_name: &str) -> Result { - let zvol = self.vm_zvol(&vm.name); - zfs::snapshot::rollback(&zvol, snap_name)?; - // Rollback mutates the volume in place; identity unchanged. - Ok(VolumeHandle::from_path(zvol)) - } - - fn delete_snapshot(&self, vm: &VmMetadata, snap_name: &str) -> Result<()> { - let zvol = self.vm_zvol(&vm.name); - zfs::snapshot::destroy(&zvol, snap_name) - } - - /// List snapshots, filtering out the reserved `@base` snapshot. - fn list_snapshots(&self, vm: &VmMetadata) -> Result> { - let zvol = self.vm_zvol(&vm.name); - let zfs_snaps = zfs::snapshot::list(&zvol)?; - - Ok(zfs_snaps - .into_iter() - .filter(|s| s.short_name != zfs::BASE_SNAPSHOT_NAME) - .map(|s| SnapshotInfo { - name: s.short_name, - created_at: s.creation, - size: s.referenced, - }) - .collect()) - } - /// Grow the zvol and expand the ext4 filesystem. fn resize(&self, vm: &VmMetadata, new_size: ByteSize) -> Result<()> { let zvol = self.vm_zvol(&vm.name); @@ -186,7 +151,7 @@ impl StorageBackend for LinuxStorage { Ok(()) } - /// Destroy the VM's zvol and all its snapshots. + /// Destroy the VM's zvol (and any internal fork snapshots beneath it). fn destroy_vm_storage(&self, vm: &VmMetadata) -> Result<()> { let zvol = self.vm_zvol(&vm.name); // Ignore errors — the zvol may already be gone. diff --git a/crates/ember-linux/src/zfs/snapshot.rs b/crates/ember-linux/src/zfs/snapshot.rs index 7dea216..75fdd68 100644 --- a/crates/ember-linux/src/zfs/snapshot.rs +++ b/crates/ember-linux/src/zfs/snapshot.rs @@ -3,7 +3,7 @@ //! Snapshots are point-in-time copies of datasets or zvols. Ember //! uses them for: //! - `@base` snapshots on image zvols (clone source for VMs) -//! - User-created snapshots on VM zvols (Phase 6) +//! - `fork-` snapshots on VM zvols (clone source for forks) use std::process::Command; diff --git a/crates/ember-macos/src/storage.rs b/crates/ember-macos/src/storage.rs index 7cf2d39..61eeb2f 100644 --- a/crates/ember-macos/src/storage.rs +++ b/crates/ember-macos/src/storage.rs @@ -1,16 +1,14 @@ //! macOS storage backend: APFS copy-on-write clones for disk images. //! //! Uses raw `.img` files (ext4) and `cp -c` (APFS CoW clones) for instant -//! VM cloning and snapshots. No ZFS, no root privileges required. +//! VM cloning. No ZFS, no root privileges required. //! //! Storage layout under the state directory: //! ```text //! ~/Library/Application Support/ember/ //! ├── images/data/-.img # Base ext4 disk images //! └── vms// -//! ├── rootfs.img # APFS clone of base image -//! └── snapshots/ -//! └── .img # APFS clone at snapshot time +//! └── rootfs.img # APFS clone of base image //! ``` use std::fs; @@ -18,16 +16,16 @@ use std::path::{Path, PathBuf}; use std::process::Command; use std::time::Instant; -use ember_core::backend::{InitConfig, SnapshotInfo, StorageBackend, VolumeHandle}; +use ember_core::backend::{InitConfig, StorageBackend, VolumeHandle}; use ember_core::config::size::ByteSize; use ember_core::error::{Error, Result}; use ember_core::image::registry::ImageEntry; -use ember_core::state::vm::{SnapshotEntry, VmMetadata}; +use ember_core::state::vm::VmMetadata; /// macOS storage backend using APFS copy-on-write clones. /// -/// Holds the state directory path, from which all image/VM/snapshot -/// paths are derived. +/// Holds the state directory path, from which all image/VM paths are +/// derived. #[derive(Clone)] pub struct MacosStorage { /// Root state directory (e.g., `~/Library/Application Support/ember`). @@ -64,11 +62,6 @@ impl MacosStorage { self.vm_dir(vm_name).join("rootfs.img") } - /// Path to a VM's snapshots directory. - fn vm_snapshots_dir(&self, vm_name: &str) -> PathBuf { - self.vm_dir(vm_name).join("snapshots") - } - /// Path to a base image file. fn image_path(&self, name: &str) -> PathBuf { self.images_dir().join(format!("{name}.img")) @@ -162,177 +155,12 @@ impl StorageBackend for MacosStorage { source: e, })?; - // Create snapshots directory for this VM. - let snap_dir = self.vm_snapshots_dir(vm_name); - fs::create_dir_all(&snap_dir).map_err(|e| Error::Io { - path: snap_dir, - source: e, - })?; - let dest = self.vm_rootfs(vm_name); apfs_clone(&src, &dest)?; Ok(VolumeHandle::from_path(dest)) } - /// Create a snapshot by APFS-cloning the VM's current rootfs. - /// - /// `cp -c vms//rootfs.img → vms//snapshots/.img` - /// This is instant (CoW) and costs no additional disk space until - /// the VM's rootfs diverges from the snapshot. - fn snapshot(&self, vm: &VmMetadata, snap_name: &str) -> Result> { - let vm_name = vm.name.as_str(); - let src = self.vm_rootfs(vm_name); - if !src.exists() { - return Err(Error::Image(format!( - "VM rootfs not found: {}", - src.display() - ))); - } - - let snap_dir = self.vm_snapshots_dir(vm_name); - fs::create_dir_all(&snap_dir).map_err(|e| Error::Io { - path: snap_dir.clone(), - source: e, - })?; - - let dest = snap_dir.join(format!("{snap_name}.img")); - if dest.exists() { - return Err(Error::Image(format!( - "snapshot '{snap_name}' already exists for VM '{vm_name}'" - ))); - } - - apfs_clone(&src, &dest)?; - // APFS tracks snapshots as files on disk; nothing to add to vm.json. - Ok(None) - } - - /// Restore a snapshot by replacing the VM's rootfs with an APFS clone - /// of the snapshot file. - /// - /// `cp -c vms//snapshots/.img → vms//rootfs.img` - /// The old rootfs is removed first, then replaced with a fresh CoW clone - /// of the snapshot. - fn restore_snapshot(&self, vm: &VmMetadata, snap_name: &str) -> Result { - let vm_name = vm.name.as_str(); - let snap_path = self - .vm_snapshots_dir(vm_name) - .join(format!("{snap_name}.img")); - if !snap_path.exists() { - return Err(Error::Image(format!( - "snapshot '{snap_name}' not found for VM '{vm_name}'" - ))); - } - - let rootfs = self.vm_rootfs(vm_name); - - // Clone to a temporary file first, then atomically rename. - // This prevents data loss if the clone fails — the original rootfs - // is only replaced once the new clone is fully written. - let tmp_rootfs = rootfs.with_extension("img.restoring"); - if tmp_rootfs.exists() { - fs::remove_file(&tmp_rootfs).map_err(|e| Error::Io { - path: tmp_rootfs.clone(), - source: e, - })?; - } - - apfs_clone(&snap_path, &tmp_rootfs)?; - - // Atomic rename replaces the old rootfs in one operation. - fs::rename(&tmp_rootfs, &rootfs).map_err(|e| Error::Io { - path: rootfs.clone(), - source: e, - })?; - Ok(VolumeHandle::from_path(rootfs)) - } - - /// Delete a snapshot by removing its image file. - /// - /// APFS reference-counts the underlying blocks — deleting a snapshot only - /// frees blocks that are not shared with other clones (rootfs or other - /// snapshots). - fn delete_snapshot(&self, vm: &VmMetadata, snap_name: &str) -> Result<()> { - let vm_name = vm.name.as_str(); - let snap_path = self - .vm_snapshots_dir(vm_name) - .join(format!("{snap_name}.img")); - if !snap_path.exists() { - return Err(Error::Image(format!( - "snapshot '{snap_name}' not found for VM '{vm_name}'" - ))); - } - - fs::remove_file(&snap_path).map_err(|e| Error::Io { - path: snap_path, - source: e, - })?; - Ok(()) - } - - /// List all snapshots for a VM by reading the `snapshots/` directory. - /// - /// Each `.img` file in the directory is a snapshot. Metadata (creation - /// time, size) comes from `fs::metadata` on each file. - fn list_snapshots(&self, vm: &VmMetadata) -> Result> { - let snap_dir = self.vm_snapshots_dir(&vm.name); - if !snap_dir.exists() { - return Ok(vec![]); - } - - let mut snapshots = Vec::new(); - let entries = fs::read_dir(&snap_dir).map_err(|e| Error::Io { - path: snap_dir.clone(), - source: e, - })?; - - for entry in entries { - let entry = entry.map_err(|e| Error::Io { - path: snap_dir.clone(), - source: e, - })?; - let path = entry.path(); - - // Only consider .img files as snapshots. - if path.extension().and_then(|e| e.to_str()) != Some("img") { - continue; - } - - let name = path - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or_default() - .to_string(); - - let meta = fs::metadata(&path).map_err(|e| Error::Io { - path: path.clone(), - source: e, - })?; - - // Use file modification time as creation timestamp. On macOS, - // the birth time (created) would be more accurate, but mtime - // is portable and close enough — the file is created once and - // never modified. - let created_at = meta - .modified() - .ok() - .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) - .map(|d| d.as_secs()) - .unwrap_or(0); - - snapshots.push(SnapshotInfo { - name, - created_at, - size: meta.len(), - }); - } - - // Sort by creation time (oldest first) for consistent output. - snapshots.sort_by_key(|s| s.created_at); - Ok(snapshots) - } - /// Resize a VM's rootfs image. /// /// 1. Grow the raw `.img` file with `truncate` to the new size. @@ -411,7 +239,7 @@ impl StorageBackend for MacosStorage { Ok(()) } - /// Destroy all storage for a VM: rootfs image, snapshots, and VM directory. + /// Destroy all storage for a VM: rootfs image and VM directory. /// /// Silently succeeds if the directory doesn't exist (idempotent delete). fn destroy_vm_storage(&self, vm: &VmMetadata) -> Result<()> { @@ -449,8 +277,8 @@ impl StorageBackend for MacosStorage { /// Clone a source VM's disk for forking via APFS copy-on-write. /// /// Directly clones the source VM's rootfs into the target VM's rootfs - /// using `cp -c`. No intermediate snapshot is created — APFS clones - /// are fully independent, so no cleanup or dependency tracking is needed. + /// using `cp -c`. APFS clones are fully independent, so no cleanup + /// or dependency tracking is needed. fn clone_vm_storage(&self, source: &VmMetadata, target_vm: &str) -> Result { let source_rootfs = self.vm_rootfs(&source.name); if !source_rootfs.exists() { @@ -460,17 +288,11 @@ impl StorageBackend for MacosStorage { ))); } - // Create target VM directory and snapshots subdirectory. let target_dir = self.vm_dir(target_vm); fs::create_dir_all(&target_dir).map_err(|e| Error::Io { path: target_dir.clone(), source: e, })?; - let snap_dir = self.vm_snapshots_dir(target_vm); - fs::create_dir_all(&snap_dir).map_err(|e| Error::Io { - path: snap_dir, - source: e, - })?; let target_rootfs = self.vm_rootfs(target_vm); apfs_clone(&source_rootfs, &target_rootfs)?; diff --git a/docs/BTRFS-SPEC.md b/docs/BTRFS-SPEC.md index a07a054..e725c0a 100644 --- a/docs/BTRFS-SPEC.md +++ b/docs/BTRFS-SPEC.md @@ -21,9 +21,7 @@ This document specifies how ember will support btrfs as an alternative to ZFS fo | `zfs create -V 10G pool/images/x` (zvol) | `cp image.img images/x.img` | Regular file replaces block device | | `zfs snapshot pool/images/x@base` | Not needed | The `.img` file itself is the base; no snapshot layer | | `zfs clone pool/images/x@base pool/vms/y` | `cp --reflink=always images/x.img vms/y/rootfs.img` | Instant CoW clone | -| `zfs snapshot pool/vms/y@snap` | `cp --reflink=always rootfs.img snapshots/snap.img` | Snapshot is a reflink copy | -| `zfs rollback pool/vms/y@snap` | `cp --reflink=always snap.img rootfs.img` | Replace rootfs with snapshot clone | -| `zfs destroy pool/vms/y@snap` | `rm snapshots/snap.img` | Just delete the file | +| `zfs clone pool/vms/a@fork-b pool/vms/b` | `cp --reflink=always vms/a/rootfs.img vms/b/rootfs.img` | Fork is an independent reflink clone | | `zfs set volsize=20G pool/vms/y` | `truncate -s 20G rootfs.img` | Grow the sparse file | | `zfs destroy -r pool/vms/y` | `rm -rf vms/y/` | Delete directory tree | | `/dev/zvol/pool/vms/y` | `/var/lib/ember/btrfs/vms/y/rootfs.img` | File path replaces block device path | @@ -329,31 +327,6 @@ ember vm resize myvm --disk-size 8G Both `e2fsck` and `resize2fs` operate directly on image files (no loop mount needed for resize). Shrinking is not supported. The approach is the same as `MacosStorage::resize` (truncate + e2fsck + resize2fs), though `e2fsck` uses `-f -p` to match the existing Linux convention. -## User Snapshots - -```bash -# Create: reflink clone current state -ember snapshot create myvm snap1 -→ cp --reflink=always vms/myvm/rootfs.img vms/myvm/snapshots/snap1.img - -# Restore: replace rootfs with snapshot clone (VM must be stopped) -ember snapshot restore myvm snap1 -→ cp --reflink=always vms/myvm/snapshots/snap1.img vms/myvm/rootfs.img.restoring -→ mv vms/myvm/rootfs.img.restoring vms/myvm/rootfs.img - -# List: read snapshot directory -ember snapshot list myvm -→ ls vms/myvm/snapshots/*.img (stat each for size and mtime) - -# Delete: remove snapshot file -ember snapshot delete myvm snap1 -→ rm vms/myvm/snapshots/snap1.img -``` - -### Atomic Restore - -Snapshot restore uses a two-step process for atomicity: reflink clone to a `.restoring` file, then `mv` (rename) to the final path. `mv` within the same filesystem is atomic — if interrupted, either the old or new file is present, never a partial copy. The macOS backend uses the same approach. - ## VM Fork (Instant Clone) ```bash diff --git a/docs/DM-THIN-SPEC.md b/docs/DM-THIN-SPEC.md index 6b75449..8010f28 100644 --- a/docs/DM-THIN-SPEC.md +++ b/docs/DM-THIN-SPEC.md @@ -14,7 +14,7 @@ The goal is the same as the btrfs spec: drop the ZFS kernel module dependency an * **Sparse-file backing by default**: `ember init` creates two sparse files (metadata + data) on the existing filesystem and assembles them into a thin pool via `losetup` + `dmsetup`. A raw block device may be used instead, but is not required. * **Kernel-builtin**: dm-thin is in-tree (`CONFIG_DM_THIN_PROVISIONING`), shipped by every mainstream distribution since ~2012. No DKMS, no out-of-tree module, no licensing friction with the kernel. * **No filesystem on the pool**: The pool itself is a block-device factory. Each thin volume is independently formatted with ext4 (the same ext4 image pipeline used today). The pool does not see file-level structure. -* **Thin volumes and snapshots are the same primitive**: In dm-thin, a snapshot is just another thin volume that shares blocks with its source. Image base, VM disk, user snapshot, and fork all use the same `create_snap` call. +* **Thin volumes and snapshots are the same primitive**: In dm-thin, a snapshot is just another thin volume that shares blocks with its source. Image base, VM disk, and fork all use the same `create_snap` call. * **Random 64-bit thin ids**: Unlike ZFS where datasets are addressed by name, dm-thin volumes are addressed by numeric ids. Ember picks a random `u64` per volume and retries on the rare collision. The id is stored on the existing `VmMetadata`/`ImageEntry` records; no separate allocator state. * **Root required**: Same as ZFS — `dmsetup`, `losetup`, `mount`, and Firecracker all need root. @@ -27,9 +27,7 @@ The goal is the same as the btrfs spec: drop the ZFS kernel module dependency an | `zfs create -V 10G pool/images/x` (zvol) | `dmsetup message ember-pool 0 "create_thin "` + `dmsetup create ember-img-x` | Thin volume replaces zvol | | `zfs snapshot pool/images/x@base` | `dmsetup message ember-pool 0 "create_snap "` | Snapshot is just another thin id | | `zfs clone pool/images/x@base pool/vms/y` | `create_snap ` + `dmsetup create ember-vm-y` | Same `create_snap`; activate as device | -| `zfs snapshot pool/vms/y@snap` | suspend vm + `create_snap ` + resume | Suspend ensures consistent on-disk state | -| `zfs rollback pool/vms/y@snap` | remove vm device + delete vm thin + `create_snap ` + recreate vm device | Restore replaces the live volume | -| `zfs destroy pool/vms/y@snap` | `dmsetup message ember-pool 0 "delete "` | Releases blocks back to the pool | +| `zfs clone pool/vms/a@fork-b pool/vms/b` | suspend vm + `create_snap ` + resume + activate | Fork is the same `create_snap` primitive | | `zfs set volsize=20G pool/vms/y` | `dmsetup suspend` + `dmsetup load` (new size) + `dmsetup resume` + `resize2fs` | Resize is a table reload | | `zfs destroy -r pool/vms/y` | `dmsetup remove ember-vm-y` + `delete ` | Two-step: deactivate then free | | `/dev/zvol/pool/vms/y` | `/dev/mapper/ember-vm-y` | Different path, same shape | @@ -227,7 +225,7 @@ Why this is safe: * No persistent counter, no allocator file, no flock around id generation. `create_snap` follows the same pattern (allocate id, retry on `EEXIST`). -The `id` is recorded on the relevant `VmMetadata`/`ImageEntry`/`SnapshotEntry` under whichever lock already protects that record; the kernel pool itself remains the source of truth for liveness, queryable via `thin_dump` for recovery. +The `id` is recorded on the relevant `VmMetadata`/`ImageEntry` under whichever lock already protects that record; the kernel pool itself remains the source of truth for liveness, queryable via `thin_dump` for recovery. The serialized type on those records stays `u64` so the on-disk format does not need to change if the kernel ever lifts the 24-bit cap. For now only the low 24 bits are populated. @@ -438,59 +436,6 @@ ember storage grow --size 100G Metadata cannot be resized in place. If `thin_metadata_size` for the new pool size exceeds the existing metadata device, ember refuses the grow and prints instructions for an offline metadata move using `pdata_tools` (out of scope for the initial implementation; doc only). -## User snapshots - -```bash -# Create -ember snapshot create myvm s1 -→ id_s1 = fresh_thin_id() -→ dmsetup suspend ember-vm-myvm -→ dmsetup message ember-pool 0 "create_snap " -→ dmsetup resume ember-vm-myvm - (id_s1 stays inactive — no /dev/mapper entry until restore) - -# Restore (VM must be stopped) -ember snapshot restore myvm s1 -→ dmsetup remove ember-vm-myvm -→ dmsetup message ember-pool 0 "delete " -→ id_vm_new = fresh_thin_id() -→ dmsetup message ember-pool 0 "create_snap " -→ dmsetup create ember-vm-myvm --table "0 thin /dev/mapper/ember-pool " - VmMetadata.thin_id = id_vm_new - -# List -ember snapshot list myvm -→ read snapshot records from VmMetadata (or a sidecar; see below) - -# Delete -ember snapshot delete myvm s1 -→ dmsetup message ember-pool 0 "delete " -``` - -### Snapshot consistency - -Suspending the VM volume during `create_snap` flushes outstanding I/O and forces a metadata commit before the snapshot is taken. -The kernel performs the equivalent of an fsync at the block layer. -A guest that has not fsynced its in-flight writes may still see an uncrashed-but-dirty filesystem on the snapshot, exactly as with ZFS zvol snapshots. -This matches existing behavior; no additional guarantees are introduced. - -### Snapshot metadata - -Snapshot records are stored alongside `VmMetadata`, since the existing ZFS backend reads them via `zfs::snapshot::list`. -For dm-thin, ember maintains a `snapshots: Vec` list in `vm.json`: - -```rust -pub struct SnapshotEntry { - pub name: String, - pub thin_id: u64, - pub created_at: String, - pub size_sectors: u64, -} -``` - -`list_snapshots` reads this list. -`size` reflects unique block usage and can be queried via `dmsetup status` or `thin_ls --metadata-snap` for accurate accounting; for the initial implementation, ember reports the volume's virtual size and defers exclusive-block accounting to a future enhancement. - ## VM fork ```bash @@ -552,7 +497,6 @@ pub struct VmMetadata { /// dm-thin volume id. None for ZFS/btrfs/APFS backends. #[serde(default, skip_serializing_if = "Option::is_none")] pub thin_id: Option, - pub snapshots: Vec, // NEW: dm-thin owns this list // ... } @@ -571,10 +515,6 @@ pub struct ImageEntry { The `#[serde(skip_serializing_if = "Option::is_none")]` keeps ZFS configs unchanged on disk. Existing `vm.json` and `registry.json` files are read without modification — the ZFS backend simply ignores `thin_id`. -For ZFS, the `snapshots` list remains empty in `vm.json` and `list_snapshots` continues to read live state from `zfs::snapshot::list`. -The dm-thin backend writes to it. -This split is acceptable but slightly asymmetric; an alternative is for ZFS to mirror its snapshots into `vm.json` too, which is out of scope here. - ## Image dependency tracking With dm-thin, the base thin id can technically be deleted while VMs cloned from it exist — block reference counting at the pool level prevents data loss. @@ -620,7 +560,7 @@ pub struct DmThinStorage { The struct holds no allocator state. `fresh_thin_id()` generates a random `u64` and returns it; collisions are handled by the kernel (`create_thin` returns `EEXIST`) and the caller retries. -The authoritative record of which ids are live lives in `ImageEntry`/`VmMetadata`/`SnapshotEntry`, which are already updated under the existing per-VM and registry locks — no new locking primitive is introduced. +The authoritative record of which ids are live lives in `ImageEntry`/`VmMetadata`, which are already updated under the existing per-VM and registry locks — no new locking primitive is introduced. ### Display and platform adaptations @@ -638,9 +578,6 @@ The authoritative record of which ids are live lives in `ImageEntry`/`VmMetadata | Init | `zpool create` + `zfs create` | `mkfs.btrfs` + `mount` + `mkdir` | `truncate` + `losetup` + `dmsetup create thin-pool` | `mkdir` | | Base image | zvol + `@base` snapshot | Raw `.img` file | Thin volume + snapshot id | Raw `.img` file | | VM clone | `zfs clone x@base y` | `cp --reflink=always x.img y.img` | `dmsetup message create_snap` + `dmsetup create` | `cp -c x.img y.img` | -| Snapshot | `zfs snapshot y@snap` | `cp --reflink=always` | suspend + `create_snap` + resume | `cp -c` | -| Restore | `zfs rollback y@snap` | `cp --reflink=always` + `mv` | remove + delete + `create_snap` + create | `cp -c` + `mv` | -| Delete snap | `zfs destroy y@snap` | `rm snap.img` | `dmsetup message delete` | `rm snap.img` | | Resize | `zfs set volsize` + `resize2fs` | `truncate` + `resize2fs` | `dmsetup load` + `resize2fs` | `truncate` + `resize2fs` | | Fork | `zfs clone` (creates dependency) | `cp --reflink=always` (independent) | `create_snap` (independent) | `cp -c` (independent) | | Drive path | `/dev/zvol/...` | `.../rootfs.img` (file) | `/dev/mapper/...` | `.../rootfs.img` (file) | @@ -671,7 +608,7 @@ The macOS `st_blocks` approach used by the btrfs and APFS backends does not appl * **Metadata exhaustion**: Less recoverable than data exhaustion. The metadata device must be sized generously at init. `ember storage info` should warn when metadata usage exceeds 80%. * **Block size is permanent**: Chosen at `dmsetup create`; cannot be changed without rebuilding the pool. The 64 KiB default is a balance; users with very large VM disks (~hundreds of GiB) may want 128–256 KiB blocks for lower metadata overhead. * **Loop device limits**: The default `max_loop=8` per kernel module load can be a constraint on systems with many loop-using services. Ember uses two loop devices total (metadata and data); the limit only matters when other software is competing. Documented as a troubleshooting hint, not a hard requirement. -* **Numeric id lifecycle**: Thin ids live on `VmMetadata`/`ImageEntry`/`SnapshotEntry`. Loss of the state directory therefore loses the name→id map even though the pool metadata is intact. Recovery is possible via `thin_dump` (lists all live thin ids) but requires manual reconstruction. No worse than the equivalent loss for ZFS or btrfs configs. +* **Numeric id lifecycle**: Thin ids live on `VmMetadata`/`ImageEntry`. Loss of the state directory therefore loses the name→id map even though the pool metadata is intact. Recovery is possible via `thin_dump` (lists all live thin ids) but requires manual reconstruction. No worse than the equivalent loss for ZFS or btrfs configs. * **Concurrent invocations**: Race-free by construction. The kernel rejects duplicate ids atomically; the random-pick-and-retry loop tolerates concurrent creators without coordination. Per-record state mutation (writing `thin_id` into `vm.json` etc.) is already serialized by the existing per-VM and registry locks. * **No data checksums**: Bit rot on the underlying block device goes undetected. Users who need this should layer dm-thin on top of LVM mirrors or hardware RAID, or stay on ZFS. * **No `send`/`receive` equivalent**: Backup and migration require `dd` of the activated device, or `thin_dump` + `thin_delta` for incremental sync. Out of scope for the initial implementation. diff --git a/docs/MACOS-SPEC.md b/docs/MACOS-SPEC.md index 3971772..2a5b9be 100644 --- a/docs/MACOS-SPEC.md +++ b/docs/MACOS-SPEC.md @@ -4,7 +4,7 @@ This document specifies how ember provides the same CLI experience on macOS by s ## Design Principles -- **Same CLI, different backends**: All `ember` commands (`init`, `vm create/start/stop`, `ssh`, `snapshot`, etc.) work identically on macOS. The platform difference is invisible to users. +- **Same CLI, different backends**: All `ember` commands (`init`, `vm create/start/stop`, `ssh`, `vm fork`, etc.) work identically on macOS. The platform difference is invisible to users. - **No root required**: Unlike Linux (where TAP devices, iptables, and ZFS all require root), the macOS backend runs entirely without `sudo`. - **Native tools**: Use Apple's own frameworks (Virtualization.framework, vmnet, APFS) rather than porting Linux tools. This matches ember's philosophy of shelling out to platform tools. - **Minimal external dependencies**: Only Homebrew packages that aren't avoidable (`e2fsprogs` for ext4, `skopeo` for OCI pulls). @@ -14,7 +14,7 @@ This document specifies how ember provides the same CLI experience on macOS by s | Linux | macOS | Notes | |-------|-------|-------| | Firecracker (KVM) | Apple Virtualization Framework (AVF) | Native hypervisor, macOS 13+ | -| ZFS zvols + snapshots | APFS clones (`cp -c`) + raw disk images | Zero-cost CoW clones | +| ZFS zvols + clones | APFS clones (`cp -c`) + raw disk images | Zero-cost CoW clones | | TAP devices (ioctl) | vmnet framework (shared mode) | Built-in NAT, static IP allocation | | iptables (NAT/masquerade) | vmnet (handles NAT internally) | No manual firewall rules | | `ip` command | Not needed | vmnet manages devices | @@ -131,10 +131,7 @@ AVF provides a virtio console device. `ember-vz` captures serial output to a log │ └── / │ ├── vm.json # VM metadata (includes PID when running) │ ├── rootfs.img # APFS clone of base image -│ ├── console.log # Serial console output -│ └── snapshots/ -│ ├── snap1.img # APFS clone at snapshot time -│ └── snap2.img +│ └── console.log # Serial console output └── network/ └── allocations.json # Not needed for vmnet shared mode, but kept for consistency ``` @@ -174,29 +171,14 @@ After cloning, per-VM SSH keys are injected using `debugfs -w` from Homebrew e2f 1. `debugfs -R 'stat /home/'` — detect SSH user and uid/gid 2. `debugfs -w -f ` — create `.ssh/` directory, write `authorized_keys`, fix permissions/ownership via `set_inode_field` -### Snapshots - -```bash -# Create: clone current state -cp -c vms//rootfs.img vms//snapshots/.img - -# Restore: replace current with snapshot clone -cp -c vms//snapshots/.img vms//rootfs.img - -# Delete: just remove the file -rm vms//snapshots/.img -``` - -APFS handles the CoW reference counting internally. Deleting a snapshot only frees blocks not referenced by other clones. - ### VM Fork ```bash -# Snapshot source, then clone +# Clone source disk into a new VM cp -c vms//rootfs.img vms//rootfs.img ``` -Same instant CoW semantics as ZFS clone. +Same instant CoW semantics as ZFS clone. APFS reference-counts blocks internally, so source and fork share storage until they diverge. ### VM Resize @@ -217,9 +199,6 @@ resize2fs vms//rootfs.img |-----------|-------------|--------------| | Base image | zvol + `@base` snapshot | Raw `.img` file | | VM clone | `zfs clone pool/images/x@base pool/vms/y` | `cp -c images/x.img vms/y/rootfs.img` | -| Snapshot | `zfs snapshot pool/vms/y@snap` | `cp -c vms/y/rootfs.img vms/y/snapshots/snap.img` | -| Restore | `zfs rollback pool/vms/y@snap` | `cp -c vms/y/snapshots/snap.img vms/y/rootfs.img` | -| Delete snap | `zfs destroy pool/vms/y@snap` | `rm vms/y/snapshots/snap.img` | | Resize | `zfs set volsize=XG` + `resize2fs` | `truncate -s XG` + `resize2fs` | | Fork | `zfs clone pool/vms/a@fork-b pool/vms/b` | `cp -c vms/a/rootfs.img vms/b/rootfs.img` | @@ -240,11 +219,10 @@ Storage Efficiency Report ───────────────────────── Images: 2 (3.2 GB logical) VMs: 8 (25.6 GB logical) -Snapshots: 12 (38.4 GB logical) ────────────────── -Total logical: 67.2 GB +Total logical: 28.8 GB Actual disk used: 4.1 GB (via df) -CoW efficiency: 16.4x space savings +CoW efficiency: 7.0x space savings ``` **How it works:** @@ -355,11 +333,7 @@ pub trait StorageBackend { fn init(config: &InitConfig) -> Result<()>; fn create_image_volume(name: &str, image_path: &Path) -> Result; fn clone_for_vm(image_name: &str, vm_name: &str) -> Result; - fn clone_from_snapshot(src_vm: &str, snap: &str, dst_vm: &str) -> Result; // For vm fork - fn snapshot(vm_name: &str, snap_name: &str) -> Result<()>; - fn restore_snapshot(vm_name: &str, snap_name: &str) -> Result<()>; - fn delete_snapshot(vm_name: &str, snap_name: &str) -> Result<()>; - fn list_snapshots(vm_name: &str) -> Result>; + fn clone_vm_storage(src_vm: &str, dst_vm: &str) -> Result; // For vm fork fn resize(vm_name: &str, new_size: ByteSize) -> Result<()>; fn destroy_vm_storage(vm_name: &str) -> Result<()>; fn destroy_image_storage(name: &str) -> Result<()>; @@ -382,7 +356,7 @@ src/ │ ├── linux/ │ │ ├── mod.rs │ │ ├── vm.rs # Firecracker process management + API -│ │ ├── storage.rs # ZFS zvol/snapshot/clone operations +│ │ ├── storage.rs # ZFS zvol/clone operations │ │ ├── network.rs # TAP + iptables + IP allocation │ │ └── image.rs # ext4 creation with loop mount │ └── macos/ diff --git a/docs/SPEC.md b/docs/SPEC.md index d58774f..731fbcf 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -7,7 +7,7 @@ A CLI tool for managing Firecracker microVMs with ZFS-backed storage. CLI-only ## Design Principles - **CLI-first**: All operations via command line. No background daemon. -- **ZFS-native**: ZFS zvols as block devices for VMs. Instant cloning from snapshots. User-facing snapshot operations. +- **ZFS-native**: ZFS zvols as block devices for VMs. Instant cloning from snapshots. Forks via CoW clone of a VM's disk. - **Minimal moving parts**: Shell out to `zfs`/`zpool`/`iptables` CLI tools rather than fragile library bindings. Thin custom Firecracker API client over Unix socket. - **Root required**: TAP devices, iptables, ZFS, loop mounting, and Firecracker all need root. Like Docker — run as root. @@ -39,12 +39,6 @@ ember │ ├── delete [--force] │ └── inspect [--format table|json] │ -├── snapshot -│ ├── create -│ ├── restore -│ ├── list [--format table|json] -│ └── delete -│ ├── ssh [-- ...] │ ├── exec [--user ] -- ... @@ -91,7 +85,6 @@ src/ │ ├── init.rs # ember init │ ├── vm.rs # ember vm * │ ├── image.rs # ember image * -│ ├── snapshot.rs # ember snapshot * │ ├── ssh.rs # ember ssh │ ├── exec.rs # ember exec │ └── cp.rs # ember cp @@ -170,8 +163,7 @@ src/ │ └── @base # snapshot, cloned per VM └── vms/ └── # zvol, cloned from image snapshot - ├── @snap1 # user snapshots - └── @snap2 + └── @fork- # snapshot, cloned per fork (one per child) ``` ### Image Pull Workflow @@ -256,15 +248,6 @@ ember vm resize myvm --disk-size 8G Shrinking is not supported — only growing. The command errors if the new size is smaller than or equal to the current size. -### User Snapshots - -``` -ember snapshot create myvm snap1 → zfs snapshot /vms/myvm@snap1 -ember snapshot restore myvm snap1 → zfs rollback /vms/myvm@snap1 (VM must be stopped) -ember snapshot list myvm → zfs list -t snapshot -r /vms/myvm -ember snapshot delete myvm snap1 → zfs destroy /vms/myvm@snap1 -``` - ### VM Fork (Instant Clone) ``` @@ -488,7 +471,7 @@ For example, during `vm start`: 2. Remove iptables rules 3. Delete TAP device 4. Release IP allocation -5. `zfs destroy` zvol (and snapshots under it) +5. `zfs destroy` zvol (and any internal fork snapshots beneath it) 6. Remove state directory Each step is idempotent — continues if resource already gone. diff --git a/src/cli.rs b/src/cli.rs index b7c4741..3d37c05 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -7,7 +7,6 @@ pub mod image; pub mod info; pub mod init; pub mod kernel; -pub mod snapshot; pub mod ssh; pub mod storage; pub mod vm; @@ -59,10 +58,6 @@ pub enum Command { #[command(subcommand)] Kernel(kernel::KernelCommand), - /// Manage VM snapshots - #[command(subcommand)] - Snapshot(snapshot::SnapshotCommand), - /// SSH into a VM Ssh(ssh::SshArgs), diff --git a/src/cli/debug.rs b/src/cli/debug.rs index c0e6f42..b3d6a2d 100644 --- a/src/cli/debug.rs +++ b/src/cli/debug.rs @@ -36,8 +36,6 @@ fn storage_efficiency(state_dir: &Path) -> anyhow::Result<()> { // Count VM rootfs files and their logical sizes. let mut vm_count: u64 = 0; let mut vm_bytes: u64 = 0; - let mut snap_count: u64 = 0; - let mut snap_bytes: u64 = 0; if vms_dir.exists() { if let Ok(entries) = std::fs::read_dir(&vms_dir) { @@ -53,19 +51,11 @@ fn storage_efficiency(state_dir: &Path) -> anyhow::Result<()> { vm_count += 1; vm_bytes += meta.len(); } - - // Count snapshot .img files. - let snap_dir = vm_dir.join("snapshots"); - if snap_dir.exists() { - let (sc, sb) = count_img_files(&snap_dir); - snap_count += sc; - snap_bytes += sb; - } } } } - let total_logical = image_bytes + vm_bytes + snap_bytes; + let total_logical = image_bytes + vm_bytes; // Get actual disk usage by summing allocated blocks for all .img files. // On APFS, cloned files only report their unique (non-shared) blocks, @@ -85,11 +75,6 @@ fn storage_efficiency(state_dir: &Path) -> anyhow::Result<()> { vm_count, format_bytes(vm_bytes) ); - println!( - "Snapshots: {:>3} ({} logical)", - snap_count, - format_bytes(snap_bytes) - ); println!(" {}", "─".repeat(22)); println!("Total logical: {}", format_bytes(total_logical)); diff --git a/src/cli/snapshot.rs b/src/cli/snapshot.rs deleted file mode 100644 index e1e2638..0000000 --- a/src/cli/snapshot.rs +++ /dev/null @@ -1,270 +0,0 @@ -use std::path::Path; - -use clap::{Args, Subcommand}; - -use crate::backend::create_storage; -use ember_core::config::GlobalConfig; -use ember_core::state::store::StateStore; -use ember_core::state::vm; - -use super::vm::OutputFormat; - -/// Reserved snapshot name used internally for image cloning (ZFS `@base`). -const RESERVED_SNAPSHOT_NAME: &str = "base"; - -#[derive(Subcommand)] -pub enum SnapshotCommand { - /// Create a snapshot of a VM - Create(CreateArgs), - - /// Restore a VM to a snapshot - Restore(RestoreArgs), - - /// List snapshots for a VM - List(ListArgs), - - /// Delete a VM snapshot - Delete(DeleteArgs), -} - -#[derive(Args)] -pub struct CreateArgs { - /// VM name - pub vm_name: String, - - /// Snapshot name - pub snapshot_name: String, -} - -#[derive(Args)] -pub struct RestoreArgs { - /// VM name - pub vm_name: String, - - /// Snapshot name - pub snapshot_name: String, -} - -#[derive(Args)] -pub struct ListArgs { - /// VM name - pub vm_name: String, - - /// Output format - #[arg(long, default_value = "table")] - pub format: OutputFormat, -} - -#[derive(Args)] -pub struct DeleteArgs { - /// VM name - pub vm_name: String, - - /// Snapshot name - pub snapshot_name: String, -} - -pub fn run(cmd: &SnapshotCommand, state_dir: &Path) -> anyhow::Result<()> { - match cmd { - SnapshotCommand::Create(args) => create(args, state_dir), - SnapshotCommand::Restore(args) => restore(args, state_dir), - SnapshotCommand::List(args) => list(args, state_dir), - SnapshotCommand::Delete(args) => delete(args, state_dir), - } -} - -/// Create a snapshot of a VM's disk. -/// -/// The snapshot name must not conflict with the reserved `base` name -/// used for image cloning, and must not already exist. -fn create(args: &CreateArgs, state_dir: &Path) -> anyhow::Result<()> { - let store = StateStore::new(state_dir.to_path_buf()); - let config: GlobalConfig = store.read(&store.config_path())?; - let storage = create_storage(&config); - let mut metadata = vm::load(&store, &args.vm_name)?; - - // Disallow the reserved snapshot name. - if args.snapshot_name == RESERVED_SNAPSHOT_NAME { - anyhow::bail!("snapshot name 'base' is reserved for image cloning"); - } - - // Check the snapshot doesn't already exist. - let existing = storage.list_snapshots(&metadata)?; - if existing.iter().any(|s| s.name == args.snapshot_name) { - anyhow::bail!( - "snapshot '{}' already exists on vm '{}'", - args.snapshot_name, - args.vm_name - ); - } - - if let Some(entry) = storage.snapshot(&metadata, &args.snapshot_name)? { - metadata.snapshots.push(entry); - vm::save(&store, &metadata)?; - } - - println!( - "Created snapshot '{}' of vm '{}'", - args.snapshot_name, args.vm_name - ); - Ok(()) -} - -/// List snapshots for a VM. -/// -/// Shows all user-created snapshots, excluding internal snapshots. -/// Supports table and JSON output formats. -fn list(args: &ListArgs, state_dir: &Path) -> anyhow::Result<()> { - let store = StateStore::new(state_dir.to_path_buf()); - let config: GlobalConfig = store.read(&store.config_path())?; - let storage = create_storage(&config); - let metadata = vm::load(&store, &args.vm_name)?; - - let snapshots = storage.list_snapshots(&metadata)?; - - match args.format { - OutputFormat::Json => { - // Build a JSON-serializable list matching the backend's SnapshotInfo. - let json_list: Vec<_> = snapshots - .iter() - .map(|s| { - serde_json::json!({ - "name": s.name, - "created_at": s.created_at, - "size": s.size, - }) - }) - .collect(); - println!("{}", serde_json::to_string_pretty(&json_list)?); - } - OutputFormat::Table => { - if snapshots.is_empty() { - println!( - "No snapshots for vm '{}'. Create one with: ember snapshot create {} ", - args.vm_name, args.vm_name - ); - return Ok(()); - } - - println!("{:<30} {:<24} {:>10}", "NAME", "CREATED", "SIZE"); - for snap in &snapshots { - println!( - "{:<30} {:<24} {:>10}", - snap.name, - format_epoch(snap.created_at), - format_bytes(snap.size), - ); - } - } - } - - Ok(()) -} - -/// Convert a Unix epoch timestamp to a human-readable UTC string. -/// -/// Uses the same civil date algorithm as `state::vm::now_iso8601()`. -fn format_epoch(epoch: u64) -> String { - let day_secs = epoch % 86400; - let hour = day_secs / 3600; - let min = (day_secs % 3600) / 60; - let sec = day_secs % 60; - - let days = epoch / 86400; - let z = days as i64 + 719468; - let era = z.div_euclid(146097); - let doe = z.rem_euclid(146097) as u64; - let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; - let y = yoe as i64 + era * 400; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let m = if mp < 10 { mp + 3 } else { mp - 9 }; - let y = if m <= 2 { y + 1 } else { y }; - - format!("{y:04}-{m:02}-{d:02} {hour:02}:{min:02}:{sec:02} UTC") -} - -use super::fmt::format_bytes_binary as format_bytes; - -/// Delete a snapshot from a VM. -/// -/// The reserved `base` snapshot cannot be deleted — it is used for image cloning. -fn delete(args: &DeleteArgs, state_dir: &Path) -> anyhow::Result<()> { - let store = StateStore::new(state_dir.to_path_buf()); - let config: GlobalConfig = store.read(&store.config_path())?; - let storage = create_storage(&config); - let mut metadata = vm::load(&store, &args.vm_name)?; - - // Disallow deleting the reserved snapshot. - if args.snapshot_name == RESERVED_SNAPSHOT_NAME { - anyhow::bail!("snapshot 'base' is reserved for image cloning and cannot be deleted"); - } - - // Verify the snapshot exists. - let existing = storage.list_snapshots(&metadata)?; - if !existing.iter().any(|s| s.name == args.snapshot_name) { - anyhow::bail!( - "snapshot '{}' does not exist on vm '{}'\n\ - Hint: list snapshots with: ember snapshot list {}", - args.snapshot_name, - args.vm_name, - args.vm_name - ); - } - - storage.delete_snapshot(&metadata, &args.snapshot_name)?; - - // For backends that track snapshots in vm.json (dm-thin), drop the - // entry. ZFS/APFS leave vm.snapshots empty; this is a no-op there. - let before = metadata.snapshots.len(); - metadata.snapshots.retain(|s| s.name != args.snapshot_name); - if metadata.snapshots.len() != before { - vm::save(&store, &metadata)?; - } - - println!( - "Deleted snapshot '{}' from vm '{}'", - args.snapshot_name, args.vm_name - ); - Ok(()) -} - -/// Restore a VM's disk to a previously created snapshot. -/// -/// The VM must be stopped — rolling back a disk that is in use by a running -/// hypervisor would corrupt it. -fn restore(args: &RestoreArgs, state_dir: &Path) -> anyhow::Result<()> { - let store = StateStore::new(state_dir.to_path_buf()); - let config: GlobalConfig = store.read(&store.config_path())?; - let storage = create_storage(&config); - let mut metadata = vm::require_stopped(&store, &args.vm_name, "restoring a snapshot")?; - - // Verify the snapshot exists. - let existing = storage.list_snapshots(&metadata)?; - if !existing.iter().any(|s| s.name == args.snapshot_name) { - anyhow::bail!( - "snapshot '{}' does not exist on vm '{}'\n\ - Hint: list snapshots with: ember snapshot list {}", - args.snapshot_name, - args.vm_name, - args.vm_name - ); - } - - let handle = storage.restore_snapshot(&metadata, &args.snapshot_name)?; - // Persist any backend-specific identity change (dm-thin replaces the - // thin_id on restore; ZFS/APFS keep the same identity). - let new_disk_path = handle.disk_path.to_string_lossy().to_string(); - if metadata.thin_id != handle.thin_id || metadata.disk_path != new_disk_path { - metadata.thin_id = handle.thin_id; - metadata.disk_path = new_disk_path; - vm::save(&store, &metadata)?; - } - - println!( - "Restored vm '{}' to snapshot '{}'", - args.vm_name, args.snapshot_name - ); - Ok(()) -} diff --git a/src/cli/vm.rs b/src/cli/vm.rs index d2484e3..19f4320 100644 --- a/src/cli/vm.rs +++ b/src/cli/vm.rs @@ -577,7 +577,6 @@ fn create_post_clone( }, parent_vm: None, thin_id: pending.thin_id, - snapshots: Vec::new(), }; vm::save(store, &metadata)?; @@ -698,7 +697,6 @@ fn fork(args: &ForkArgs, state_dir: &Path) -> anyhow::Result<()> { ssh: source.ssh.clone(), parent_vm: Some(args.source.clone()), thin_id: pending.thin_id, - snapshots: Vec::new(), }; vm::save(&store, &metadata)?; diff --git a/src/main.rs b/src/main.rs index b6b0eb0..42c6811 100644 --- a/src/main.rs +++ b/src/main.rs @@ -78,7 +78,6 @@ fn main() -> anyhow::Result<()> { Command::Vm(cmd) => cli::vm::run(cmd, &cli.state_dir), Command::Image(cmd) => cli::image::run(cmd, &cli.state_dir), Command::Kernel(cmd) => cli::kernel::run(cmd, &cli.state_dir), - Command::Snapshot(cmd) => cli::snapshot::run(cmd, &cli.state_dir), Command::Ssh(args) => cli::ssh::run(args, &cli.state_dir), Command::Exec(args) => cli::exec::run(args, &cli.state_dir), Command::Cp(args) => cli::cp::run(args, &cli.state_dir), diff --git a/tests/macos_storage.rs b/tests/macos_storage.rs index 57a9205..a1737f5 100644 --- a/tests/macos_storage.rs +++ b/tests/macos_storage.rs @@ -6,8 +6,7 @@ //! - VM delete removes storage //! - Non-APFS (HFS+) detection and warnings //! -//! Cross-platform snapshot and resize tests live in `snapshot.rs` and -//! `resize.rs` respectively. +//! Cross-platform resize tests live in `resize.rs`. //! //! Requirements: //! - macOS with APFS filesystem (default since 10.13) @@ -58,7 +57,7 @@ fn apfs_clone_does_not_reduce_free_space() { ); } -/// `ember debug storage-efficiency` should report images, VMs, and snapshots. +/// `ember debug storage-efficiency` should report images and VMs. #[test] #[ignore] fn storage_efficiency_shows_savings() { @@ -72,17 +71,20 @@ fn storage_efficiency_shows_savings() { let vm_name = format!("effvm{i}"); common::macos::create_test_vm_manual(&state_dir, &vm_name, "effimg-latest"); + // Fork each VM to exercise a second layer of CoW (image → VM → fork). + let fork_name = format!("efffork{i}"); let output = common::ember(&[ "--state-dir", state, - "snapshot", - "create", + "vm", + "fork", &vm_name, - "snap1", + &fork_name, + "--no-start", ]); assert!( output.status.success(), - "snapshot create failed: {}", + "vm fork failed: {}", String::from_utf8_lossy(&output.stderr) ); } @@ -100,17 +102,13 @@ fn storage_efficiency_shows_savings() { "expected 'Images:' in: {stdout}" ); assert!(stdout.contains("VMs:"), "expected 'VMs:' in: {stdout}"); - assert!( - stdout.contains("Snapshots:"), - "expected 'Snapshots:' in: {stdout}" - ); assert!( stdout.contains("Total logical:"), "expected 'Total logical:' in: {stdout}" ); } -/// VM delete should remove all storage (rootfs + snapshots directory). +/// VM delete should remove all storage (rootfs + VM directory). #[test] #[ignore] fn vm_delete_removes_storage() { @@ -118,9 +116,6 @@ fn vm_delete_removes_storage() { let state_dir = common::macos::setup_with_vm(tmp.path(), "deltest", "delvm"); let state = state_dir.to_str().unwrap(); - let output = common::ember(&["--state-dir", state, "snapshot", "create", "delvm", "snap1"]); - assert!(output.status.success()); - let vm_dir = state_dir.join("vms").join("delvm"); assert!(vm_dir.exists(), "VM dir should exist before delete"); diff --git a/tests/snapshot.rs b/tests/snapshot.rs deleted file mode 100644 index 73fa473..0000000 --- a/tests/snapshot.rs +++ /dev/null @@ -1,470 +0,0 @@ -//! Integration tests for `ember snapshot create`, `snapshot list`, -//! `snapshot restore`, and `snapshot delete`. -//! -//! Cross-platform tests use `TestEnv::with_vm()` to abstract platform setup. -//! Platform-specific storage checks (ZFS snapshots on Linux, .img files on -//! macOS) are gated with `#[cfg(target_os)]`. -//! -//! To run: -//! ./run-integration-tests.sh snapshot - -#[allow(dead_code)] -mod common; - -// --------------------------------------------------------------------------- -// Cross-platform tests -// --------------------------------------------------------------------------- - -/// Full snapshot lifecycle: create → list (table + JSON) → delete. -#[test] -#[ignore] -fn snapshot_create_list_delete() { - let env = common::TestEnv::with_vm("snapbasic", "snapvm1"); - let state = env.state(); - - // -- Create snapshot -- - let output = common::ember(&[ - "--state-dir", - state, - "snapshot", - "create", - "snapvm1", - "snap1", - ]); - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - output.status.success(), - "snapshot create failed.\nstdout: {stdout}\nstderr: {stderr}" - ); - - #[cfg(target_os = "linux")] - { - let vm_zvol = format!("{}/ember/vms/snapvm1", env.pool); - common::linux::assert_snapshot_exists(&format!("{vm_zvol}@snap1")); - } - - // -- Create a second snapshot -- - let output = common::ember(&[ - "--state-dir", - state, - "snapshot", - "create", - "snapvm1", - "snap2", - ]); - assert!( - output.status.success(), - "snapshot create snap2 failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - - #[cfg(target_os = "linux")] - { - let vm_zvol = format!("{}/ember/vms/snapvm1", env.pool); - common::linux::assert_snapshot_exists(&format!("{vm_zvol}@snap2")); - } - - // -- List snapshots (table) -- - let output = common::ember(&["--state-dir", state, "snapshot", "list", "snapvm1"]); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!(output.status.success()); - assert!( - stdout.contains("snap1"), - "expected 'snap1' in snapshot list: {stdout}" - ); - assert!( - stdout.contains("snap2"), - "expected 'snap2' in snapshot list: {stdout}" - ); - // The internal @base snapshot should NOT appear in user-facing list. - assert!( - !stdout.contains("base"), - "internal @base snapshot should be hidden from list: {stdout}" - ); - - // -- List snapshots (JSON) -- - let output = common::ember(&[ - "--state-dir", - state, - "snapshot", - "list", - "snapvm1", - "--format", - "json", - ]); - let json_stdout = String::from_utf8_lossy(&output.stdout); - assert!(output.status.success()); - let parsed: serde_json::Value = serde_json::from_str(&json_stdout) - .unwrap_or_else(|e| panic!("invalid JSON: {e}\noutput: {json_stdout}")); - let snapshots = parsed.as_array().expect("expected JSON array of snapshots"); - assert_eq!( - snapshots.len(), - 2, - "expected 2 snapshots in JSON list, got: {json_stdout}" - ); - - // -- Delete snap1 -- - let output = common::ember(&[ - "--state-dir", - state, - "snapshot", - "delete", - "snapvm1", - "snap1", - ]); - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - output.status.success(), - "snapshot delete failed.\nstdout: {stdout}\nstderr: {stderr}" - ); - - #[cfg(target_os = "linux")] - { - let vm_zvol = format!("{}/ember/vms/snapvm1", env.pool); - common::linux::assert_snapshot_absent(&format!("{vm_zvol}@snap1")); - common::linux::assert_snapshot_exists(&format!("{vm_zvol}@snap2")); - } - - // -- Delete snap2 -- - let output = common::ember(&[ - "--state-dir", - state, - "snapshot", - "delete", - "snapvm1", - "snap2", - ]); - assert!( - output.status.success(), - "snapshot delete snap2 failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - - #[cfg(target_os = "linux")] - { - let vm_zvol = format!("{}/ember/vms/snapvm1", env.pool); - common::linux::assert_snapshot_absent(&format!("{vm_zvol}@snap2")); - } -} - -/// Duplicate snapshot name should fail. -#[test] -#[ignore] -fn snapshot_create_duplicate_fails() { - let env = common::TestEnv::with_vm("snapdup", "dupvm"); - let state = env.state(); - - let output = common::ember(&[ - "--state-dir", - state, - "snapshot", - "create", - "dupvm", - "mysnap", - ]); - assert!( - output.status.success(), - "first snapshot create failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - - let output = common::ember(&[ - "--state-dir", - state, - "snapshot", - "create", - "dupvm", - "mysnap", - ]); - assert!( - !output.status.success(), - "expected duplicate snapshot create to fail" - ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("already exists"), - "expected 'already exists' error: {stderr}" - ); -} - -/// Cannot create a snapshot named "base" (reserved for internal use). -#[test] -#[ignore] -fn snapshot_create_base_name_rejected() { - let env = common::TestEnv::with_vm("snapbase", "basevm"); - let state = env.state(); - - let output = common::ember(&["--state-dir", state, "snapshot", "create", "basevm", "base"]); - assert!( - !output.status.success(), - "expected snapshot create 'base' to be rejected" - ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("reserved") || stderr.contains("base"), - "expected error about reserved name: {stderr}" - ); -} - -/// Restoring a non-existent snapshot should fail. -#[test] -#[ignore] -fn snapshot_restore_nonexistent_fails() { - let env = common::TestEnv::with_vm("snaprestnosnap", "novm"); - let state = env.state(); - - let output = common::ember(&[ - "--state-dir", - state, - "snapshot", - "restore", - "novm", - "nosuchsnap", - ]); - assert!( - !output.status.success(), - "expected restore of non-existent snapshot to fail" - ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("not found") || stderr.contains("does not exist"), - "expected error about missing snapshot: {stderr}" - ); -} - -/// Deleting a non-existent snapshot should fail. -#[test] -#[ignore] -fn snapshot_delete_nonexistent_fails() { - let env = common::TestEnv::with_vm("snapdelnosnap", "delnovm"); - let state = env.state(); - - let output = common::ember(&[ - "--state-dir", - state, - "snapshot", - "delete", - "delnovm", - "nosuchsnap", - ]); - assert!( - !output.status.success(), - "expected delete of non-existent snapshot to fail" - ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("not found") || stderr.contains("does not exist"), - "expected error about missing snapshot: {stderr}" - ); -} - -/// Snapshot list on a VM with no snapshots should show an empty result. -#[test] -#[ignore] -fn snapshot_list_empty() { - let env = common::TestEnv::with_vm("snapempty", "emptyvm"); - let state = env.state(); - - let output = common::ember(&["--state-dir", state, "snapshot", "list", "emptyvm"]); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!(output.status.success()); - assert!( - stdout.contains("No snapshots") || stdout.contains("no snapshots"), - "expected empty snapshot message: {stdout}" - ); -} - -// --------------------------------------------------------------------------- -// Linux-specific tests -// --------------------------------------------------------------------------- - -/// Snapshot → modify zvol → restore → verify original state. -/// -/// This is the core data-integrity test: it proves that ZFS rollback -/// actually reverts the VM's disk contents to the snapshot point. -#[cfg(target_os = "linux")] -#[test] -#[ignore] -fn snapshot_restore_reverts_changes() { - let tmp = tempfile::tempdir().unwrap(); - let (pool, state_dir, _cleanup) = - common::linux::setup_pool_and_vm("snaprestore", "restorevm", &tmp); - let state = state_dir.to_str().unwrap(); - let vm_zvol = format!("{pool}/ember/vms/restorevm"); - let zvol_device = format!("/dev/zvol/{vm_zvol}"); - - assert!( - common::linux::wait_for_zvol_device(&zvol_device), - "zvol device {zvol_device} did not appear within timeout" - ); - - // Write a marker file to the zvol BEFORE snapshotting. - common::linux::with_mounted_zvol(&zvol_device, |mount| { - std::fs::write(mount.join("before-snapshot.txt"), "original-content\n").unwrap(); - }); - - // -- Create snapshot -- - let output = common::ember(&[ - "--state-dir", - state, - "snapshot", - "create", - "restorevm", - "checkpoint", - ]); - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - output.status.success(), - "snapshot create failed.\nstdout: {stdout}\nstderr: {stderr}" - ); - common::linux::assert_snapshot_exists(&format!("{vm_zvol}@checkpoint")); - - // Modify the zvol AFTER snapshotting. - common::linux::with_mounted_zvol(&zvol_device, |mount| { - std::fs::write(mount.join("after-snapshot.txt"), "this should disappear\n").unwrap(); - std::fs::write(mount.join("before-snapshot.txt"), "modified-content\n").unwrap(); - }); - - // Verify the modifications are present before restore. - common::linux::with_mounted_zvol(&zvol_device, |mount| { - assert!(mount.join("after-snapshot.txt").exists()); - let content = std::fs::read_to_string(mount.join("before-snapshot.txt")).unwrap(); - assert_eq!(content, "modified-content\n"); - }); - - // -- Restore snapshot -- - let output = common::ember(&[ - "--state-dir", - state, - "snapshot", - "restore", - "restorevm", - "checkpoint", - ]); - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - output.status.success(), - "snapshot restore failed.\nstdout: {stdout}\nstderr: {stderr}" - ); - - // -- Verify original state is restored -- - common::linux::with_mounted_zvol(&zvol_device, |mount| { - assert!( - !mount.join("after-snapshot.txt").exists(), - "after-snapshot.txt should NOT exist after restore" - ); - let content = std::fs::read_to_string(mount.join("before-snapshot.txt")).unwrap(); - assert_eq!(content, "original-content\n"); - }); -} - -/// Cannot delete the internal @base snapshot. -#[cfg(target_os = "linux")] -#[test] -#[ignore] -fn snapshot_delete_base_rejected() { - let tmp = tempfile::tempdir().unwrap(); - let (_pool, state_dir, _cleanup) = - common::linux::setup_pool_and_vm("snapdelbase", "delbasevm", &tmp); - let state = state_dir.to_str().unwrap(); - - let output = common::ember(&[ - "--state-dir", - state, - "snapshot", - "delete", - "delbasevm", - "base", - ]); - assert!( - !output.status.success(), - "expected snapshot delete 'base' to be rejected" - ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("reserved") || stderr.contains("base"), - "expected error about reserved name: {stderr}" - ); -} - -/// Snapshot operations on a non-existent VM should fail. -#[cfg(target_os = "linux")] -#[test] -#[ignore] -fn snapshot_on_nonexistent_vm_fails() { - let tmp = tempfile::tempdir().unwrap(); - let pool = common::linux::test_pool("snapnovm"); - let state_dir = tmp.path().join("state"); - let (loop_dev, _img) = common::linux::create_loop_device(tmp.path()); - - let _cleanup = common::linux::PoolCleanup { - pool: pool.clone(), - dev: loop_dev.clone(), - }; - - let output = common::ember(&[ - "--state-dir", - state_dir.to_str().unwrap(), - "init", - "--pool", - &pool, - "--device", - &loop_dev, - ]); - assert!( - output.status.success(), - "init failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - - let state = state_dir.to_str().unwrap(); - - let output = common::ember(&[ - "--state-dir", - state, - "snapshot", - "create", - "nosuchvm", - "snap1", - ]); - assert!( - !output.status.success(), - "expected snapshot create on non-existent VM to fail" - ); - - let output = common::ember(&["--state-dir", state, "snapshot", "list", "nosuchvm"]); - assert!( - !output.status.success(), - "expected snapshot list on non-existent VM to fail" - ); - - let output = common::ember(&[ - "--state-dir", - state, - "snapshot", - "restore", - "nosuchvm", - "snap1", - ]); - assert!( - !output.status.success(), - "expected snapshot restore on non-existent VM to fail" - ); - - let output = common::ember(&[ - "--state-dir", - state, - "snapshot", - "delete", - "nosuchvm", - "snap1", - ]); - assert!( - !output.status.success(), - "expected snapshot delete on non-existent VM to fail" - ); -}