diff --git a/crates/clawdie/README.md b/crates/clawdie/README.md index 7ae0e08..4a6fa7a 100644 --- a/crates/clawdie/README.md +++ b/crates/clawdie/README.md @@ -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 /clawdie (container, canmount=off) @@ -34,5 +37,14 @@ ZFS layout under the chosen 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. diff --git a/crates/clawdie/src/disk.rs b/crates/clawdie/src/disk.rs new file mode 100644 index 0000000..a219e2a --- /dev/null +++ b/crates/clawdie/src/disk.rs @@ -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 { + let Ok(v) = serde_json::from_str::(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 { + 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 { + 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 { + 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 { + 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 + } +} diff --git a/crates/clawdie/src/main.rs b/crates/clawdie/src/main.rs index 35185e3..f1a63b0 100644 --- a/crates/clawdie/src/main.rs +++ b/crates/clawdie/src/main.rs @@ -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, + /// Create a ZFS pool on this device (e.g. /dev/sdb) — DESTRUCTIVE. + #[arg(long)] + create_pool: Option, }, - /// Provision: create the ZFS layout and install the clawdie service. + /// Provision: storage layout + install the clawdie service. Apply { #[arg(long)] pool: Option, + /// Create a ZFS pool on this device — DESTROYS it. Requires --pool NAME. + #[arg(long)] + create_pool: Option, + /// 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) -> Result { - 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) -> Result, 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) -> Result { } } +/// 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, + ServiceSpec, + Vec, +) { + 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, diff --git a/crates/clawdie/src/plan.rs b/crates/clawdie/src/plan.rs index 63fa643..dc6b36b 100644 --- a/crates/clawdie/src/plan.rs +++ b/crates/clawdie/src/plan.rs @@ -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: `/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, + create_pool_device: Option, +) -> Result { + 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) + } + } } } -/// `/clawdie` container + `db` (→ data_dir) + `log` (→ /var/log/clawdie). -fn dataset_steps(cfg: &DeployConfig) -> Vec { - let root = cfg.root_dataset(); +fn zfs_dataset_steps(pool: &str, svc: &ServiceSpec) -> Vec { + 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::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 { ] } -/// Full ordered plan: ZFS datasets first, then service install for this platform. -pub fn build(platform: &dyn Platform, cfg: &DeployConfig) -> Vec { - let mut steps = dataset_steps(cfg); - steps.extend(platform.install_service_steps(&cfg.service)); +fn plain_dir_steps(svc: &ServiceSpec) -> Vec { + 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 { + 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")); } }