feat(clawdie): ZFS-aware storage strategy + optional pool creation
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:
parent
4f03f24a34
commit
325951be5c
4 changed files with 508 additions and 94 deletions
|
|
@ -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
166
crates/clawdie/src/disk.rs
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue