Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ jobs:
- name: make clippy
run: make clippy

- name: cargo test
run: cargo test
- name: make test
run: make test

- name: cargo fmt -- --check
run: cargo fmt -- --check
Expand Down
20 changes: 17 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,20 @@

UNAME := $(shell uname -s)

# Which workspace members to check, lint, and test.
#
# `default-members = ["."]` in Cargo.toml means a bare `cargo test` only
# covers the root package, so the unit tests in ember-core and the
# platform crate never run. `--workspace` is not the fix: it selects the
# other platform's backend too, and that backend does not compile here
# (ember-macos needs `clonefile`, ember-linux's image tests need
# `mkfs.ext4`). Naming the packages is what actually works.
ifeq ($(UNAME),Darwin)
PACKAGES := -p ember -p ember-core -p ember-macos
else
PACKAGES := -p ember -p ember-core -p ember-linux
endif

.PHONY: build release clean fmt check clippy test udeps

build:
Expand Down Expand Up @@ -32,13 +46,13 @@ fmt:
cargo fmt

check:
cargo check
cargo check --all-targets $(PACKAGES)

clippy:
cargo clippy --all-targets -- -D warnings
cargo clippy --all-targets $(PACKAGES) -- -D warnings

test:
cargo test
cargo test $(PACKAGES)

udeps:
cargo machete
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,14 +216,17 @@ ember cp ./local-file.txt myvm:/tmp/
ember cp myvm:/var/log/syslog ./syslog.txt
```

## Storage efficiency
## Storage usage

Both platforms use copy-on-write storage, so VMs and forks 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, and every backend that compresses shrinks them further. `ember storage usage` reports what each VM and image actually occupies against what it was provisioned:

```bash
ember debug storage-efficiency
ember storage usage
ember storage usage --format json
```

`EXCLUSIVE` is what a volume holds on its own, `SHARED` is what it still has in common with the image or fork it came from. `ember vm list` carries the same exclusive figure in its `USED` column.

## Building a custom kernel

The stock kernel (auto-downloaded on first use) works for most use cases. However, it **lacks full Docker networking support** — the iptables `raw` table and nftables modules are missing, so Docker bridge networking doesn't work inside guest VMs.
Expand Down
206 changes: 206 additions & 0 deletions crates/ember-core/src/backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,11 @@
//! The active implementation is selected at compile time in the binary crate
//! and re-exported as type aliases (`Vm`, `Storage`, `Network`).

use std::collections::BTreeMap;
use std::path::{Path, PathBuf};

use serde::Serialize;

use crate::config::size::ByteSize;
use crate::config::{DmThinMode, GlobalConfig};
use crate::error::Result;
Expand Down Expand Up @@ -52,6 +55,113 @@ impl VolumeHandle {
}
}

/// Space accounting for a single volume, in bytes.
///
/// This is an occupancy model: it answers where space has gone, not
/// what a delete would give back. Those differ enough on ZFS to be
/// worth stating. A zvol is also charged for its refreservation and for
/// blocks held only by its snapshots, and neither appears in
/// `exclusive`. Backends whose forks are independent (dm-thin, APFS)
/// have no such gap.
///
/// Backends fill in what they can measure. `exclusive` is the only
/// field all of them produce.
#[derive(Clone, Copy, Debug, Serialize)]
pub struct VolumeUsage {
/// Virtual size presented to the guest.
pub provisioned: u64,
/// Physical bytes this volume holds that are not shared with an
/// origin. Always within `referenced` when that is known.
pub exclusive: u64,
/// Physical bytes reachable from this volume, including blocks
/// shared with an origin. `None` when the backend cannot tell
/// shared and exclusive blocks apart.
pub referenced: Option<u64>,
/// Uncompressed size of `referenced`. `None` when the backend does
/// not compress.
pub logical: Option<u64>,
}

impl VolumeUsage {
/// Bytes shared with an origin volume, when the backend can tell.
pub fn shared(&self) -> Option<u64> {
self.referenced.map(|r| r.saturating_sub(self.exclusive))
}

/// Compression ratio over the referenced blocks, when the backend
/// compresses. `None` for an empty volume, where the ratio would be
/// a division by zero rather than a meaningful 1.0.
pub fn compression_ratio(&self) -> Option<f64> {
ratio(self.logical, self.referenced)
}
}

/// Usage of a backend's dedicated metadata device, in bytes.
#[derive(Clone, Copy, Debug, Serialize)]
pub struct MetadataUsage {
pub capacity: u64,
pub used: u64,
}

/// Pool-wide capacity, in bytes.
#[derive(Clone, Copy, Debug, Serialize)]
pub struct PoolUsage {
pub capacity: u64,
pub allocated: u64,
/// Part of `allocated` that is reserved but holds no data, so it
/// compresses to nothing and must be kept out of the ratio. Zero
/// for backends without reservations.
pub reserved: u64,
/// Uncompressed size of the data within `allocated`. `None` when
/// the backend does not compress.
pub logical: Option<u64>,
/// Present only for backends that keep a separate metadata device.
pub metadata: Option<MetadataUsage>,
}

impl PoolUsage {
pub fn free(&self) -> u64 {
self.capacity.saturating_sub(self.allocated)
}

/// Bytes of `allocated` that actually hold data.
pub fn occupied(&self) -> u64 {
self.allocated.saturating_sub(self.reserved)
}

/// Compression ratio over occupied space, when the backend
/// compresses.
///
/// Measured against [`occupied`](Self::occupied) rather than
/// `allocated`: empty reservation is charged to the pool but has no
/// logical counterpart, so including it would understate the ratio.
pub fn compression_ratio(&self) -> Option<f64> {
ratio(self.logical, Some(self.occupied()))
}
}

/// Shared by the two `compression_ratio` accessors. A zero
/// denominator yields `None` rather than an infinity that would render
/// as `inf` in the CLI.
fn ratio(logical: Option<u64>, physical: Option<u64>) -> Option<f64> {
match (logical, physical) {
(Some(logical), Some(physical)) if physical > 0 => Some(logical as f64 / physical as f64),
_ => None,
}
}

/// Space accounting for a whole installation, produced in one pass.
#[derive(Clone, Debug, Serialize)]
pub struct StorageUsage {
pub pool: PoolUsage,
/// Keyed by [`VmMetadata::name`]. A missing key means the backend
/// could not account for that VM, which the CLI renders as `-`
/// rather than as zero.
pub vms: BTreeMap<String, VolumeUsage>,
/// Keyed by [`ImageEntry::local_name`], same missing-key rule.
pub images: BTreeMap<String, VolumeUsage>,
}

/// Configuration for storage backend initialization during `ember init`.
///
/// Carries the subset of init arguments that the storage backend needs.
Expand Down Expand Up @@ -256,6 +366,18 @@ pub trait StorageBackend {
/// destroyed. Empty for backends whose forks are independent.
fn storage_dependents(&self, vm: &VmMetadata) -> Result<Vec<String>>;

/// Measure actual space usage across the installation.
///
/// Takes the state records rather than discovering volumes itself,
/// because the name-to-volume mapping lives in state and not in the
/// backend. Returns the whole set in one value so that backends
/// which have to walk pool-wide metadata do that walk once instead
/// of once per volume.
///
/// Volumes the backend cannot account for are left out of the maps
/// instead of being reported as zero.
fn usage(&self, vms: &[VmMetadata], images: &[ImageEntry]) -> Result<StorageUsage>;

/// Mount a disk image and return the mount point path.
///
/// Linux: mounts the zvol block device.
Expand Down Expand Up @@ -484,3 +606,87 @@ pub trait Platform {
/// callers are expected to soft-fail rather than block on this.
fn host_ram_mib() -> anyhow::Result<u32>;
}

#[cfg(test)]
mod tests {
use super::*;

fn volume(exclusive: u64, referenced: Option<u64>, logical: Option<u64>) -> VolumeUsage {
VolumeUsage {
provisioned: 1024,
exclusive,
referenced,
logical,
}
}

#[test]
fn shared_is_the_gap_between_referenced_and_exclusive() {
assert_eq!(volume(80, Some(100), None).shared(), Some(20));
assert_eq!(volume(100, Some(100), None).shared(), Some(0));
}

/// Backends that cannot separate shared from exclusive report
/// nothing rather than claiming zero sharing.
#[test]
fn shared_is_unknown_without_referenced() {
assert_eq!(volume(80, None, None).shared(), None);
}

/// `exclusive` is contractually within `referenced`, but the
/// saturating subtraction keeps a backend bug from producing a
/// wrapped, astronomically large shared figure.
#[test]
fn shared_saturates_instead_of_wrapping() {
assert_eq!(volume(120, Some(100), None).shared(), Some(0));
}

#[test]
fn compression_ratio_divides_logical_by_referenced() {
let r = volume(80, Some(100), Some(200)).compression_ratio();
assert_eq!(r, Some(2.0));
}

/// An untouched volume would divide by zero. `None` renders as `-`
/// where an infinity would render as `inf`.
#[test]
fn compression_ratio_guards_empty_volume() {
assert_eq!(volume(0, Some(0), Some(0)).compression_ratio(), None);
assert_eq!(volume(0, None, Some(200)).compression_ratio(), None);
assert_eq!(volume(0, Some(100), None).compression_ratio(), None);
}

fn pool(allocated: u64, reserved: u64, logical: Option<u64>) -> PoolUsage {
PoolUsage {
capacity: 1000,
allocated,
reserved,
logical,
metadata: None,
}
}

#[test]
fn free_is_capacity_minus_allocated() {
assert_eq!(pool(400, 0, None).free(), 600);
// A pool reporting more allocated than capacity must not wrap.
assert_eq!(pool(1200, 0, None).free(), 0);
}

/// Empty reservation is charged to the pool but has no logical
/// counterpart, so leaving it in the denominator understates
/// compression.
#[test]
fn pool_ratio_excludes_reservation() {
let p = pool(300, 100, Some(400));
assert_eq!(p.occupied(), 200);
assert_eq!(p.compression_ratio(), Some(2.0));
}

#[test]
fn pool_ratio_guards_fully_reserved_pool() {
assert_eq!(pool(100, 100, Some(0)).compression_ratio(), None);
assert_eq!(pool(0, 0, Some(0)).compression_ratio(), None);
assert_eq!(pool(100, 0, None).compression_ratio(), None);
}
}
7 changes: 7 additions & 0 deletions crates/ember-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,13 @@ pub fn fnv1a_32(bytes: &[u8]) -> u32 {
}

impl GlobalConfig {
/// Root of the ZFS dataset tree ember owns (e.g. `ember/ember`),
/// the common parent of [`images_dataset`](Self::images_dataset)
/// and [`vms_dataset`](Self::vms_dataset).
pub fn base_dataset(&self) -> String {
format!("{}/{}", self.pool, self.dataset)
}

/// Full ZFS dataset path for images (e.g. `ember/ember/images`).
pub fn images_dataset(&self) -> String {
format!("{}/{}/images", self.pool, self.dataset)
Expand Down
32 changes: 32 additions & 0 deletions crates/ember-linux/src/dm_thin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,23 @@ pub fn is_already_exists(err: &ember_core::error::Error) -> bool {
)
}

/// Whether an [`Error`](ember_core::error::Error) reports a kernel `EBUSY`
/// from a `dmsetup message` operation.
///
/// The only place we act on this is `reserve_metadata_snap`, where the
/// kernel returns `-EBUSY` when the pool already holds a snapshot
/// (`__reserve_metadata_snap` in `drivers/md/dm-thin-metadata.c`).
/// Same stability argument as [`is_already_exists`]: `dmsetup` embeds
/// the libc strerror, and `strerror(EBUSY)` is `"Device or resource
/// busy"` on glibc and musl.
pub fn is_busy(err: &ember_core::error::Error) -> bool {
matches!(
err,
ember_core::error::Error::Command { stderr, .. }
if stderr.contains("Device or resource busy")
)
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -99,4 +116,19 @@ mod tests {
let err = Error::Vm("File exists somewhere else in the system".to_string());
assert!(!is_already_exists(&err));
}

/// Companion to [`matches_dmsetup_eexist_message`], pinning the
/// wording `reserve_metadata_snap` fails with when a snapshot is
/// already held.
#[test]
fn matches_dmsetup_ebusy_message() {
let err = Error::Command {
command: "dmsetup".to_string(),
exit_code: 1,
stderr: "device-mapper: message ioctl on ember-pool failed: Device or resource busy\n"
.to_string(),
};
assert!(is_busy(&err));
assert!(!is_already_exists(&err));
}
}
Loading
Loading