feat(clawdie): ZFS-aware storage strategy + optional pool creation
Some checks failed
CI / rust (pull_request) Has been cancelled
CI / markdown (pull_request) Has been cancelled

clawdie chooses storage per host:
- FreeBSD: ZFS required (datasets under the pool)
- Linux with ZFS + a pool: datasets under the pool
- Linux without ZFS: plain-dir fallback, reporting ZFS benefits + spare disks
- --create-pool /dev/DEV runs `zpool create` (needs --pool NAME)

Pool creation is destructive and guarded: refused unless the disk is detected
empty (no partitions/filesystem/mount, not the root disk) or --force is given,
and only with --yes. `discover` lists block devices with candidacy. New
disk-candidacy parser + storage resolver are unit-tested (13 tests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Sam & Claude 2026-06-14 00:32:21 +02:00
parent 4f03f24a34
commit 325951be5c
4 changed files with 508 additions and 94 deletions

View file

@ -11,22 +11,25 @@ via a `Platform` backend:
## Commands
```sh
clawdie discover # read-only: OS, ZFS pools, datasets
clawdie discover # read-only: OS, ZFS pools, datasets, spare disks
clawdie plan [--pool NAME] # show the deploy plan (dry-run, no writes)
clawdie apply [--pool NAME] # dry-run unless --yes is given
clawdie apply --yes # provision: create ZFS layout + install service
clawdie apply --yes # provision: storage layout + install service
```
## Safety
## Storage strategy
`apply` is a **dry-run by default**; it writes to disk only with `--yes`. It
creates ZFS datasets and installs a service, so run it on the target host as root.
The disk-touching path (`deploy.rs`) is validated on real FreeBSD/Linux hosts; the
discovery and plan logic is unit-tested.
- **FreeBSD** — ZFS is required; the plan creates datasets under the chosen pool.
- **Linux with ZFS + a pool** — same: datasets under the pool.
- **Linux without ZFS/pool** — falls back to plain directories, and reports the
ZFS benefits (snapshots/rollback, per-service quotas, send/recv) plus any spare
disks that could host a pool.
- **Create a pool**`apply --pool NAME --create-pool /dev/DEV --yes` runs
`zpool create` on `DEV`. **This destroys the disk**, so it is refused unless the
disk is detected as empty (no partitions/filesystem/mount, not the root disk) or
`--force` is given.
## What it provisions
ZFS layout under the chosen pool:
ZFS layout under the pool:
```text
<pool>/clawdie (container, canmount=off)
@ -34,5 +37,14 @@ ZFS layout under the chosen pool:
<pool>/clawdie/log -> /var/log/clawdie
```
…then a `clawdie` service user and the rc.d script (FreeBSD) or systemd unit
(Linux), enabled to run `/usr/local/bin/colibri-daemon`.
Plain-dirs fallback creates the same mountpoints as ordinary directories owned by
the `clawdie` user. Either way, a `clawdie` service user and the rc.d script
(FreeBSD) or systemd unit (Linux) are installed and enabled to run
`/usr/local/bin/colibri-daemon`.
## Safety
`apply` is a **dry-run by default**; it writes only with `--yes`. Disk-touching
steps (`zfs`/`zpool create`, service install) run as root on the target host and
are validated on real FreeBSD/Linux hosts; discovery, plan, and disk-candidacy
logic are unit-tested.

166
crates/clawdie/src/disk.rs Normal file
View file

@ -0,0 +1,166 @@
//! Block-device discovery for ZFS pool creation (Linux).
//!
//! Read-only. Creating a pool on a disk DESTROYS its contents, so candidacy is
//! deliberately conservative: a disk qualifies only if it has no filesystem, no
//! mountpoint, and no partitions, and is not the disk backing `/`.
use std::process::Command;
use serde::Serialize;
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Disk {
pub name: String, // /dev/sdb
pub size_bytes: u64,
pub candidate: bool,
/// Why it is not a candidate (empty when it is one).
pub reason: String,
}
pub fn human_size(b: u64) -> String {
const U: [&str; 5] = ["B", "K", "M", "G", "T"];
let mut s = b as f64;
let mut i = 0;
while s >= 1024.0 && i < U.len() - 1 {
s /= 1024.0;
i += 1;
}
format!("{s:.0}{}", U[i])
}
/// Parse `lsblk -J -b -o NAME,SIZE,TYPE,FSTYPE,MOUNTPOINT`. `root_source` is the
/// basename of the device backing `/` (e.g. "sda2"); the disk owning it (itself
/// or via a child partition) is never a candidate.
pub fn parse_candidates(lsblk_json: &str, root_source: Option<&str>) -> Vec<Disk> {
let Ok(v) = serde_json::from_str::<serde_json::Value>(lsblk_json) else {
return Vec::new();
};
let devs = v
.get("blockdevices")
.and_then(|d| d.as_array())
.cloned()
.unwrap_or_default();
let mut out = Vec::new();
for d in devs {
if d.get("type").and_then(|t| t.as_str()) != Some("disk") {
continue;
}
let name = d.get("name").and_then(|n| n.as_str()).unwrap_or("");
if name.is_empty() {
continue;
}
let size = d.get("size").and_then(|s| s.as_u64()).unwrap_or(0);
let fstype = d
.get("fstype")
.and_then(|f| f.as_str())
.filter(|s| !s.is_empty());
let mountpoint = d
.get("mountpoint")
.and_then(|m| m.as_str())
.filter(|s| !s.is_empty());
let children = d.get("children").and_then(|c| c.as_array());
let owns_root = root_source == Some(name)
|| children.is_some_and(|ch| {
ch.iter().any(|c| {
c.get("name").and_then(|n| n.as_str()) == root_source && root_source.is_some()
})
});
let reason = if owns_root {
"root disk".to_string()
} else if children.is_some_and(|c| !c.is_empty()) {
"has partitions".to_string()
} else if let Some(fs) = fstype {
format!("has filesystem ({fs})")
} else if let Some(mp) = mountpoint {
format!("mounted at {mp}")
} else {
String::new()
};
out.push(Disk {
name: format!("/dev/{name}"),
size_bytes: size,
candidate: reason.is_empty(),
reason,
});
}
out
}
/// Basename of the device backing `/` (e.g. "sda2"), if detectable.
pub fn root_source() -> Option<String> {
let out = Command::new("findmnt")
.args(["-n", "-o", "SOURCE", "/"])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
Some(s.strip_prefix("/dev/").unwrap_or(&s).to_string())
}
/// All disks with candidacy + reason (read-only; empty if `lsblk` is absent).
pub fn list_disks() -> Vec<Disk> {
let Ok(out) = Command::new("lsblk")
.args(["-J", "-b", "-o", "NAME,SIZE,TYPE,FSTYPE,MOUNTPOINT"])
.output()
else {
return Vec::new();
};
if !out.status.success() {
return Vec::new();
}
parse_candidates(
&String::from_utf8_lossy(&out.stdout),
root_source().as_deref(),
)
}
pub fn candidates() -> Vec<Disk> {
list_disks().into_iter().filter(|d| d.candidate).collect()
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE: &str = r#"{"blockdevices":[
{"name":"sda","size":500107862016,"type":"disk","fstype":null,"mountpoint":null,
"children":[{"name":"sda1","type":"part","fstype":"ext4","mountpoint":"/"}]},
{"name":"sdb","size":1000204886016,"type":"disk","fstype":null,"mountpoint":null},
{"name":"sdc","size":256060514304,"type":"disk","fstype":"ext4","mountpoint":null}
]}"#;
#[test]
fn candidate_detection() {
let disks = parse_candidates(SAMPLE, Some("sda1"));
let by = |n: &str| disks.iter().find(|d| d.name == n).unwrap().clone();
assert_eq!(by("/dev/sda").reason, "root disk"); // owns the / partition
assert!(by("/dev/sdb").candidate); // empty
assert_eq!(by("/dev/sdc").reason, "has filesystem (ext4)");
assert_eq!(candidates_of(&disks), vec!["/dev/sdb".to_string()]);
}
fn candidates_of(d: &[Disk]) -> Vec<String> {
d.iter()
.filter(|x| x.candidate)
.map(|x| x.name.clone())
.collect()
}
#[test]
fn disk_with_partitions_excluded_even_if_not_root() {
let j = r#"{"blockdevices":[{"name":"sdd","size":100,"type":"disk","fstype":null,"mountpoint":null,"children":[{"name":"sdd1","type":"part"}]}]}"#;
assert_eq!(parse_candidates(j, None)[0].reason, "has partitions");
}
#[test]
fn human_size_works() {
assert_eq!(human_size(1024), "1K");
assert_eq!(human_size(1_000_204_886_016), "932G"); // 931.5 GiB rounds up
}
}

View file

@ -1,10 +1,13 @@
//! clawdie — host installer/deployer for the `clawdie` service.
//!
//! Discovers a host's ZFS layout and provisions the `clawdie` service on FreeBSD
//! (rc.d) or Linux (systemd). Provisioning is guarded: `plan` and a bare `apply`
//! are dry-runs; only `apply --yes` writes to disk.
//! (rc.d) or Linux (systemd). ZFS is required on FreeBSD; on Linux it is used
//! when present, can be created on a named spare disk, or falls back to plain
//! directories. Provisioning is guarded: `plan` and a bare `apply` are dry-runs;
//! only `apply --yes` writes to disk.
mod deploy;
mod disk;
mod plan;
mod platform;
mod zfs;
@ -13,13 +16,13 @@ use std::process::ExitCode;
use clap::{Parser, Subcommand};
use plan::DeployConfig;
use plan::Storage;
use platform::{detect_os, ServiceSpec};
#[derive(Parser)]
#[command(
name = "clawdie",
about = "Clawdie host installer/deployer (FreeBSD + Linux). Not an agent."
about = "Clawdie host installer/deployer (FreeBSD + Linux)."
)]
struct Cli {
#[command(subcommand)]
@ -28,32 +31,41 @@ struct Cli {
#[derive(Subcommand)]
enum Cmd {
/// Read-only: show OS, ZFS pools, and datasets.
/// Read-only: show OS, ZFS pools, datasets, and spare disks.
Discover,
/// Show the deploy plan (dry-run; writes nothing).
Plan {
/// Target pool (auto-detected if there is exactly one).
#[arg(long)]
pool: Option<String>,
/// Create a ZFS pool on this device (e.g. /dev/sdb) — DESTRUCTIVE.
#[arg(long)]
create_pool: Option<String>,
},
/// Provision: create the ZFS layout and install the clawdie service.
/// Provision: storage layout + install the clawdie service.
Apply {
#[arg(long)]
pool: Option<String>,
/// Create a ZFS pool on this device — DESTROYS it. Requires --pool NAME.
#[arg(long)]
create_pool: Option<String>,
/// Allow --create-pool on a disk that isn't detected as empty.
#[arg(long)]
force: bool,
/// Actually write to disk. Without this, apply is a dry-run.
#[arg(long)]
yes: bool,
},
}
fn pick_pool(explicit: Option<String>) -> Result<String, String> {
if let Some(p) = explicit {
return Ok(p);
/// Resolve the pool: explicit wins; one pool auto-selects; many is ambiguous.
fn pick_pool(explicit: Option<String>) -> Result<Option<String>, String> {
if explicit.is_some() {
return Ok(explicit);
}
let pools = zfs::list_pools();
match pools.as_slice() {
[] => Err("no ZFS pool found; pass --pool NAME".into()),
[only] => Ok(only.name.clone()),
[] => Ok(None),
[only] => Ok(Some(only.name.clone())),
many => Err(format!(
"multiple pools ({}); pass --pool NAME",
many.iter()
@ -64,6 +76,36 @@ fn pick_pool(explicit: Option<String>) -> Result<String, String> {
}
}
/// Refuse to create a pool on a disk that isn't clearly empty, unless --force.
fn validate_create_device(device: &str, force: bool) -> Result<(), String> {
if force {
return Ok(());
}
match disk::list_disks().into_iter().find(|d| d.name == device) {
Some(d) if d.candidate => Ok(()),
Some(d) => Err(format!(
"{device} is not empty ({}); refusing. Re-run with --force only if you are certain.",
d.reason
)),
None => Err(format!(
"{device} was not found among block devices; refusing. Check the path or use --force."
)),
}
}
fn deploy_steps(
storage: &Storage,
) -> (
Box<dyn platform::Platform>,
ServiceSpec,
Vec<platform::Step>,
) {
let svc = ServiceSpec::default();
let pf = platform::backend();
let steps = plan::build(pf.as_ref(), storage, &svc);
(pf, svc, steps)
}
fn run() -> Result<(), String> {
match Cli::parse().cmd {
Cmd::Discover => {
@ -82,43 +124,88 @@ fn run() -> Result<(), String> {
.join(", ")
}
);
let ds = zfs::list_datasets();
println!("datasets: {}", ds.len());
for d in ds.iter().filter(|d| d.name.contains("clawdie")) {
println!(" {} -> {}", d.name, d.mountpoint);
for d in zfs::list_datasets()
.iter()
.filter(|d| d.name.contains("clawdie"))
{
println!(" dataset {} -> {}", d.name, d.mountpoint);
}
let disks = disk::list_disks();
if !disks.is_empty() {
println!("disks:");
for d in &disks {
let tag = if d.candidate {
"spare (usable for --create-pool)".to_string()
} else {
d.reason.clone()
};
println!(" {} {}{}", d.name, disk::human_size(d.size_bytes), tag);
}
}
Ok(())
}
Cmd::Plan { pool } => {
let cfg = DeployConfig {
pool: pick_pool(pool)?,
service: ServiceSpec::default(),
};
let pf = platform::backend();
let steps = plan::build(pf.as_ref(), &cfg);
print!("{}", plan::render(pf.as_ref(), &cfg, &steps));
Cmd::Plan { pool, create_pool } => {
let storage = plan::resolve(
detect_os(),
zfs::zfs_available(),
pick_pool(pool)?,
create_pool,
)?;
let (pf, svc, steps) = deploy_steps(&storage);
print!("{}", plan::render(pf.as_ref(), &storage, &svc, &steps));
offer_zfs_if_plain(&storage);
Ok(())
}
Cmd::Apply { pool, yes } => {
let cfg = DeployConfig {
pool: pick_pool(pool)?,
service: ServiceSpec::default(),
};
let pf = platform::backend();
let steps = plan::build(pf.as_ref(), &cfg);
print!("{}", plan::render(pf.as_ref(), &cfg, &steps));
Cmd::Apply {
pool,
create_pool,
force,
yes,
} => {
let storage = plan::resolve(
detect_os(),
zfs::zfs_available(),
pick_pool(pool)?,
create_pool,
)?;
if let Storage::ZfsCreate { device, .. } = &storage {
validate_create_device(device, force)?;
}
let (pf, svc, steps) = deploy_steps(&storage);
print!("{}", plan::render(pf.as_ref(), &storage, &svc, &steps));
if !yes {
offer_zfs_if_plain(&storage);
eprintln!("\nDRY-RUN — nothing written. Re-run with --yes to apply.");
return Ok(());
}
eprintln!("\nApplying ({} steps)...", steps.len());
deploy::apply(&steps)?;
eprintln!("Done. `clawdie` service provisioned on pool {}.", cfg.pool);
eprintln!("Done. clawdie service provisioned.");
Ok(())
}
}
}
/// On the plain-dirs fallback, surface spare disks the operator could turn into
/// a ZFS pool.
fn offer_zfs_if_plain(storage: &Storage) {
if !matches!(storage, Storage::PlainDirs) {
return;
}
let spares = disk::candidates();
if spares.is_empty() {
return;
}
eprintln!("\nSpare disks available for ZFS (snapshots/rollback/quotas):");
for d in &spares {
eprintln!(" {} ({})", d.name, disk::human_size(d.size_bytes));
}
eprintln!(
" enable: clawdie apply --pool NAME --create-pool {} --yes",
spares[0].name
);
}
fn main() -> ExitCode {
match run() {
Ok(()) => ExitCode::SUCCESS,

View file

@ -1,24 +1,61 @@
//! The deploy plan: target ZFS layout + service install, as ordered steps.
//! Storage strategy + deploy plan: ZFS layout (or plain dirs) + service install.
use crate::platform::{Action, Platform, ServiceSpec, Step};
use crate::platform::{Action, Os, Platform, ServiceSpec, Step};
pub struct DeployConfig {
pub pool: String,
pub service: ServiceSpec,
/// How clawdie state is stored on this host.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Storage {
/// Datasets under an existing pool: `<pool>/clawdie/{db,log}`.
ZfsExisting { pool: String },
/// Create `pool` on `device` (DESTRUCTIVE), then the datasets.
ZfsCreate { pool: String, device: String },
/// No ZFS: plain directories (Linux fallback).
PlainDirs,
}
impl DeployConfig {
pub fn root_dataset(&self) -> String {
format!("{}/clawdie", self.pool)
/// Decide the storage strategy. FreeBSD requires ZFS; Linux prefers it but can
/// fall back to plain dirs, or create a pool when a device is named.
pub fn resolve(
os: Os,
zfs_available: bool,
pool: Option<String>,
create_pool_device: Option<String>,
) -> Result<Storage, String> {
match os {
Os::FreeBsd => {
if !zfs_available {
return Err("ZFS is required on FreeBSD but no ZFS userland was found".into());
}
let pool = pool.ok_or("no ZFS pool found; pass --pool NAME")?;
Ok(Storage::ZfsExisting { pool })
}
Os::Linux | Os::Other => {
if let Some(device) = create_pool_device {
if !zfs_available {
return Err(
"--create-pool needs the ZFS userland; install zfsutils-linux first".into(),
);
}
let pool =
pool.ok_or("--create-pool requires --pool NAME (the new pool's name)")?;
Ok(Storage::ZfsCreate { pool, device })
} else if zfs_available {
match pool {
Some(pool) => Ok(Storage::ZfsExisting { pool }),
None => Ok(Storage::PlainDirs),
}
} else {
Ok(Storage::PlainDirs)
}
}
}
}
/// `<pool>/clawdie` container + `db` (→ data_dir) + `log` (→ /var/log/clawdie).
fn dataset_steps(cfg: &DeployConfig) -> Vec<Step> {
let root = cfg.root_dataset();
fn zfs_dataset_steps(pool: &str, svc: &ServiceSpec) -> Vec<Step> {
let root = format!("{pool}/clawdie");
let db = format!("{root}/db");
let log = format!("{root}/log");
let db_mp = format!("mountpoint={}", cfg.service.data_dir);
let db_mp = format!("mountpoint={}", svc.data_dir);
let log_mp = "mountpoint=/var/log/clawdie".to_string();
vec![
Step::run(
@ -34,7 +71,7 @@ fn dataset_steps(cfg: &DeployConfig) -> Vec<Step> {
],
),
Step::run(
format!("create {db} -> {}", cfg.service.data_dir),
format!("create {db} -> {}", svc.data_dir),
&["zfs", "create", "-o", db_mp.as_str(), db.as_str()],
),
Step::run(
@ -44,24 +81,80 @@ fn dataset_steps(cfg: &DeployConfig) -> Vec<Step> {
]
}
/// Full ordered plan: ZFS datasets first, then service install for this platform.
pub fn build(platform: &dyn Platform, cfg: &DeployConfig) -> Vec<Step> {
let mut steps = dataset_steps(cfg);
steps.extend(platform.install_service_steps(&cfg.service));
fn plain_dir_steps(svc: &ServiceSpec) -> Vec<Step> {
let owner = format!("{}:{}", svc.user, svc.user);
vec![
Step::run(
format!("create {}", svc.data_dir),
&["mkdir", "-p", svc.data_dir.as_str()],
),
Step::run(
"create /var/log/clawdie",
&["mkdir", "-p", "/var/log/clawdie"],
),
Step::run(
"own clawdie directories",
&[
"chown",
"-R",
owner.as_str(),
svc.data_dir.as_str(),
"/var/log/clawdie",
],
),
]
}
/// Full ordered plan: storage first, then service install for this platform.
pub fn build(platform: &dyn Platform, storage: &Storage, svc: &ServiceSpec) -> Vec<Step> {
let mut steps = match storage {
Storage::ZfsExisting { pool } => zfs_dataset_steps(pool, svc),
Storage::ZfsCreate { pool, device } => {
let mut s = vec![Step::run(
format!("create ZFS pool {pool} on {device} — DESTROYS all data on {device}"),
&[
"zpool",
"create",
"-o",
"autotrim=on",
pool.as_str(),
device.as_str(),
],
)];
s.extend(zfs_dataset_steps(pool, svc));
s
}
Storage::PlainDirs => plain_dir_steps(svc),
};
steps.extend(platform.install_service_steps(svc));
steps
}
/// Human-readable dry-run rendering.
pub fn render(platform: &dyn Platform, cfg: &DeployConfig, steps: &[Step]) -> String {
let mut out = String::new();
out.push_str(&format!(
"Clawdie deploy plan\n pool: {}\n root: {}\n service: {} ({})\n exec: {}\n\nSteps:\n",
cfg.pool,
cfg.root_dataset(),
cfg.service.name,
pub fn render(
platform: &dyn Platform,
storage: &Storage,
svc: &ServiceSpec,
steps: &[Step],
) -> String {
let storage_line = match storage {
Storage::ZfsExisting { pool } => format!("ZFS on existing pool `{pool}`"),
Storage::ZfsCreate { pool, device } => {
format!("CREATE ZFS pool `{pool}` on `{device}` (destructive)")
}
Storage::PlainDirs => "plain directories (ZFS not in use)".to_string(),
};
let mut out = format!(
"Clawdie deploy plan\n storage: {storage_line}\n service: {} ({})\n exec: {}\n",
svc.name,
platform.service_kind(),
cfg.service.exec,
));
svc.exec,
);
if matches!(storage, Storage::PlainDirs) {
out.push_str(
" note: ZFS would add snapshots/rollback, per-service quotas, and send/recv\n backup. Install zfsutils-linux + a pool, or use --create-pool DEVICE.\n",
);
}
out.push_str("\nSteps:\n");
for (i, step) in steps.iter().enumerate() {
let detail = match &step.action {
Action::Run(argv) => format!("$ {}", argv.join(" ")),
@ -80,34 +173,90 @@ pub fn render(platform: &dyn Platform, cfg: &DeployConfig, steps: &[Step]) -> St
#[cfg(test)]
mod tests {
use super::*;
use crate::platform::FreeBsd;
use crate::platform::{FreeBsd, Linux};
fn cfg() -> DeployConfig {
DeployConfig {
pool: "zroot".into(),
service: ServiceSpec::default(),
}
#[test]
fn freebsd_requires_zfs() {
assert!(resolve(Os::FreeBsd, false, Some("z".into()), None).is_err());
assert_eq!(
resolve(Os::FreeBsd, true, Some("zroot".into()), None).unwrap(),
Storage::ZfsExisting {
pool: "zroot".into()
}
);
}
#[test]
fn plan_has_datasets_then_service() {
let steps = build(&FreeBsd, &cfg());
// 3 dataset steps + 3 freebsd service steps
assert_eq!(steps.len(), 6);
if let Action::Run(argv) = &steps[0].action {
assert_eq!(argv[0], "zfs");
assert!(argv.contains(&"zroot/clawdie".to_string()));
} else {
panic!("first step should be a zfs create");
}
fn linux_falls_back_to_plain_dirs() {
assert_eq!(
resolve(Os::Linux, false, None, None).unwrap(),
Storage::PlainDirs
);
assert_eq!(
resolve(Os::Linux, true, None, None).unwrap(),
Storage::PlainDirs
);
assert_eq!(
resolve(Os::Linux, true, Some("tank".into()), None).unwrap(),
Storage::ZfsExisting {
pool: "tank".into()
}
);
}
#[test]
fn render_mentions_pool_and_steps() {
let steps = build(&FreeBsd, &cfg());
let r = render(&FreeBsd, &cfg(), &steps);
assert!(r.contains("pool: zroot"));
assert!(r.contains("zfs create"));
assert!(r.contains("rc.d"));
fn linux_create_pool_needs_zfs_and_name() {
assert!(resolve(
Os::Linux,
false,
Some("tank".into()),
Some("/dev/sdb".into())
)
.is_err());
assert!(resolve(Os::Linux, true, None, Some("/dev/sdb".into())).is_err());
assert_eq!(
resolve(
Os::Linux,
true,
Some("tank".into()),
Some("/dev/sdb".into())
)
.unwrap(),
Storage::ZfsCreate {
pool: "tank".into(),
device: "/dev/sdb".into()
}
);
}
#[test]
fn plan_shapes() {
let svc = ServiceSpec::default();
// plain dirs: 3 dir steps + 4 linux service steps
let s = build(&Linux, &Storage::PlainDirs, &svc);
assert_eq!(s.len(), 7);
assert!(matches!(&s[0].action, Action::Run(a) if a[0] == "mkdir"));
// create pool: 1 zpool + 3 datasets + 3 freebsd service steps
let c = build(
&FreeBsd,
&Storage::ZfsCreate {
pool: "tank".into(),
device: "/dev/sdb".into(),
},
&svc,
);
assert!(
matches!(&c[0].action, Action::Run(a) if a[0] == "zpool" && a.contains(&"/dev/sdb".to_string()))
);
assert_eq!(c.len(), 7);
}
#[test]
fn render_notes_zfs_benefits_on_plain() {
let svc = ServiceSpec::default();
let s = build(&Linux, &Storage::PlainDirs, &svc);
let r = render(&Linux, &Storage::PlainDirs, &svc, &s);
assert!(r.contains("plain directories"));
assert!(r.contains("snapshots/rollback"));
}
}