diff --git a/Cargo.toml b/Cargo.toml index 780d8a0..1b10102 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["crates/colibri-contracts", "crates/colibri-deepseek", "crates/colibri-runtime", "crates/colibri-glasspane", "crates/colibri-daemon", "crates/colibri-client", "crates/colibri-glasspane-tui", "crates/colibri-store", "crates/colibri-skills", "crates/colibri-mcp"] +members = ["crates/colibri-contracts", "crates/colibri-deepseek", "crates/colibri-runtime", "crates/colibri-glasspane", "crates/colibri-daemon", "crates/colibri-client", "crates/colibri-glasspane-tui", "crates/colibri-store", "crates/colibri-skills", "crates/colibri-mcp", "crates/clawdie"] [package] name = "colibri" diff --git a/crates/clawdie/Cargo.toml b/crates/clawdie/Cargo.toml new file mode 100644 index 0000000..eb908c6 --- /dev/null +++ b/crates/clawdie/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "clawdie" +version = "0.0.1" +edition = "2021" +license = "AGPL-3.0-only" +description = "Clawdie host installer/deployer — discovers ZFS layout and installs the clawdie service on FreeBSD and Linux." + +[[bin]] +name = "clawdie" +path = "src/main.rs" + +[dependencies] +clap = { version = "4", features = ["derive"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/crates/clawdie/README.md b/crates/clawdie/README.md new file mode 100644 index 0000000..7ae0e08 --- /dev/null +++ b/crates/clawdie/README.md @@ -0,0 +1,38 @@ +# clawdie + +Host **installer/deployer** for the `clawdie` service. + +Discovers a host's ZFS layout and provisions the `clawdie` service, cross-platform +via a `Platform` backend: + +- **FreeBSD** — native ZFS, rc.d service (`pw` / `sysrc`) +- **Linux** — ZFS-on-Linux, systemd unit (`useradd` / `systemctl`) + +## Commands + +```sh +clawdie discover # read-only: OS, ZFS pools, datasets +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 +``` + +## Safety + +`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. + +## What it provisions + +ZFS layout under the chosen pool: + +```text +/clawdie (container, canmount=off) +/clawdie/db -> /var/db/clawdie +/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`. diff --git a/crates/clawdie/src/deploy.rs b/crates/clawdie/src/deploy.rs new file mode 100644 index 0000000..e5344d3 --- /dev/null +++ b/crates/clawdie/src/deploy.rs @@ -0,0 +1,49 @@ +//! Execute a plan's steps. Only reached via `apply --yes`. +//! +//! Runs each step in order, stopping on the first failure. `Run` steps shell +//! out; `WriteFile` steps write the file and set its mode. This is the only +//! module that mutates the host. + +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::process::Command; + +use crate::platform::{Action, Step}; + +pub fn apply(steps: &[Step]) -> Result<(), String> { + let total = steps.len(); + for (i, step) in steps.iter().enumerate() { + eprintln!("[{}/{}] {}", i + 1, total, step.describe); + match &step.action { + Action::Run(argv) => { + let (cmd, rest) = argv.split_first().ok_or("empty command in step")?; + let status = Command::new(cmd) + .args(rest) + .status() + .map_err(|e| format!("failed to run `{}`: {e}", argv.join(" ")))?; + if !status.success() { + return Err(format!( + "step {} failed ({}): `{}`", + i + 1, + status, + argv.join(" ") + )); + } + } + Action::WriteFile { + path, + mode, + contents, + } => { + if let Some(parent) = std::path::Path::new(path).parent() { + fs::create_dir_all(parent) + .map_err(|e| format!("mkdir {}: {e}", parent.display()))?; + } + fs::write(path, contents).map_err(|e| format!("write {path}: {e}"))?; + fs::set_permissions(path, fs::Permissions::from_mode(*mode)) + .map_err(|e| format!("chmod {path}: {e}"))?; + } + } + } + Ok(()) +} diff --git a/crates/clawdie/src/main.rs b/crates/clawdie/src/main.rs new file mode 100644 index 0000000..35185e3 --- /dev/null +++ b/crates/clawdie/src/main.rs @@ -0,0 +1,130 @@ +//! 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. + +mod deploy; +mod plan; +mod platform; +mod zfs; + +use std::process::ExitCode; + +use clap::{Parser, Subcommand}; + +use plan::DeployConfig; +use platform::{detect_os, ServiceSpec}; + +#[derive(Parser)] +#[command( + name = "clawdie", + about = "Clawdie host installer/deployer (FreeBSD + Linux). Not an agent." +)] +struct Cli { + #[command(subcommand)] + cmd: Cmd, +} + +#[derive(Subcommand)] +enum Cmd { + /// Read-only: show OS, ZFS pools, and datasets. + Discover, + /// Show the deploy plan (dry-run; writes nothing). + Plan { + /// Target pool (auto-detected if there is exactly one). + #[arg(long)] + pool: Option, + }, + /// Provision: create the ZFS layout and install the clawdie service. + Apply { + #[arg(long)] + pool: Option, + /// 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); + } + let pools = zfs::list_pools(); + match pools.as_slice() { + [] => Err("no ZFS pool found; pass --pool NAME".into()), + [only] => Ok(only.name.clone()), + many => Err(format!( + "multiple pools ({}); pass --pool NAME", + many.iter() + .map(|p| p.name.as_str()) + .collect::>() + .join(", ") + )), + } +} + +fn run() -> Result<(), String> { + match Cli::parse().cmd { + Cmd::Discover => { + println!("os: {:?}", detect_os()); + println!("zfs available: {}", zfs::zfs_available()); + let pools = zfs::list_pools(); + println!( + "pools: {}", + if pools.is_empty() { + "(none)".into() + } else { + pools + .iter() + .map(|p| format!("{} [{}]", p.name, p.health)) + .collect::>() + .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); + } + 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)); + 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)); + if !yes { + 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); + Ok(()) + } + } +} + +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("error: {e}"); + ExitCode::FAILURE + } + } +} diff --git a/crates/clawdie/src/plan.rs b/crates/clawdie/src/plan.rs new file mode 100644 index 0000000..63fa643 --- /dev/null +++ b/crates/clawdie/src/plan.rs @@ -0,0 +1,113 @@ +//! The deploy plan: target ZFS layout + service install, as ordered steps. + +use crate::platform::{Action, Platform, ServiceSpec, Step}; + +pub struct DeployConfig { + pub pool: String, + pub service: ServiceSpec, +} + +impl DeployConfig { + pub fn root_dataset(&self) -> String { + format!("{}/clawdie", self.pool) + } +} + +/// `/clawdie` container + `db` (→ data_dir) + `log` (→ /var/log/clawdie). +fn dataset_steps(cfg: &DeployConfig) -> Vec { + let root = cfg.root_dataset(); + let db = format!("{root}/db"); + let log = format!("{root}/log"); + let db_mp = format!("mountpoint={}", cfg.service.data_dir); + let log_mp = "mountpoint=/var/log/clawdie".to_string(); + vec![ + Step::run( + format!("create container dataset {root}"), + &[ + "zfs", + "create", + "-o", + "canmount=off", + "-o", + "mountpoint=none", + root.as_str(), + ], + ), + Step::run( + format!("create {db} -> {}", cfg.service.data_dir), + &["zfs", "create", "-o", db_mp.as_str(), db.as_str()], + ), + Step::run( + format!("create {log} -> /var/log/clawdie"), + &["zfs", "create", "-o", log_mp.as_str(), log.as_str()], + ), + ] +} + +/// 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)); + 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, + platform.service_kind(), + cfg.service.exec, + )); + for (i, step) in steps.iter().enumerate() { + let detail = match &step.action { + Action::Run(argv) => format!("$ {}", argv.join(" ")), + Action::WriteFile { path, mode, .. } => format!("write {path} (mode {mode:o})"), + }; + out.push_str(&format!( + " {:>2}. {}\n {}\n", + i + 1, + step.describe, + detail + )); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::platform::FreeBsd; + + fn cfg() -> DeployConfig { + DeployConfig { + pool: "zroot".into(), + service: ServiceSpec::default(), + } + } + + #[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"); + } + } + + #[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")); + } +} diff --git a/crates/clawdie/src/platform.rs b/crates/clawdie/src/platform.rs new file mode 100644 index 0000000..baaa759 --- /dev/null +++ b/crates/clawdie/src/platform.rs @@ -0,0 +1,210 @@ +//! Platform backends. +//! +//! The same ZFS layout is provisioned everywhere; only service installation +//! differs: FreeBSD uses rc.d + `sysrc` + `pw`, Linux uses a systemd unit + +//! `systemctl` + `useradd`. A `Platform` turns a `ServiceSpec` into ordered +//! `Step`s that `plan` renders (dry-run) and `deploy` executes (`--apply`). + +use serde::Serialize; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum Os { + FreeBsd, + Linux, + Other, +} + +pub fn detect_os() -> Os { + match std::env::consts::OS { + "freebsd" => Os::FreeBsd, + "linux" => Os::Linux, + _ => Os::Other, + } +} + +/// What the `clawdie` service should run, and as whom. +#[derive(Debug, Clone)] +pub struct ServiceSpec { + pub name: String, + pub exec: String, + pub user: String, + pub data_dir: String, +} + +impl Default for ServiceSpec { + fn default() -> Self { + Self { + name: "clawdie".into(), + exec: "/usr/local/bin/colibri-daemon".into(), + user: "clawdie".into(), + data_dir: "/var/db/clawdie".into(), + } + } +} + +/// One provisioning action — either a command to run or a file to write. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Action { + Run(Vec), + WriteFile { + path: String, + mode: u32, + contents: String, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Step { + pub describe: String, + pub action: Action, +} + +impl Step { + pub fn run(describe: impl Into, argv: &[&str]) -> Self { + Step { + describe: describe.into(), + action: Action::Run(argv.iter().map(|s| s.to_string()).collect()), + } + } + pub fn write( + describe: impl Into, + path: impl Into, + mode: u32, + contents: impl Into, + ) -> Self { + Step { + describe: describe.into(), + action: Action::WriteFile { + path: path.into(), + mode, + contents: contents.into(), + }, + } + } +} + +pub trait Platform { + /// Human label for the service manager ("rc.d" | "systemd"). + fn service_kind(&self) -> &'static str; + /// Steps to create the service user, install the unit/script, and enable it. + fn install_service_steps(&self, spec: &ServiceSpec) -> Vec; +} + +pub struct FreeBsd; + +impl Platform for FreeBsd { + fn service_kind(&self) -> &'static str { + "rc.d" + } + fn install_service_steps(&self, s: &ServiceSpec) -> Vec { + let rc_path = format!("/usr/local/etc/rc.d/{}", s.name); + let rc = format!( + "#!/bin/sh\n# PROVIDE: {name}\n# REQUIRE: LOGIN\n# KEYWORD: shutdown\n. /etc/rc.subr\nname=\"{name}\"\nrcvar=\"{name}_enable\"\ncommand=\"/usr/sbin/daemon\"\ncommand_args=\"-f -o /var/log/clawdie/daemon.log {exec}\"\nload_rc_config $name\n: ${{{name}_enable:=NO}}\nrun_rc_command \"$1\"\n", + name = s.name, + exec = s.exec, + ); + vec![ + Step::run( + format!("create service user {}", s.user), + &[ + "pw", + "useradd", + &s.user, + "-d", + &s.data_dir, + "-s", + "/usr/sbin/nologin", + "-c", + "Clawdie host service", + ], + ), + Step::write(format!("install rc.d script {rc_path}"), rc_path, 0o555, rc), + Step::run( + format!("enable {} service", s.name), + &["sysrc", &format!("{}_enable=YES", s.name)], + ), + ] + } +} + +pub struct Linux; + +impl Platform for Linux { + fn service_kind(&self) -> &'static str { + "systemd" + } + fn install_service_steps(&self, s: &ServiceSpec) -> Vec { + let unit_path = format!("/etc/systemd/system/{}.service", s.name); + let unit = format!( + "[Unit]\nDescription=Clawdie host service\nAfter=network-online.target zfs.target\n\n[Service]\nType=simple\nUser={user}\nExecStart={exec}\nRestart=on-failure\n\n[Install]\nWantedBy=multi-user.target\n", + user = s.user, + exec = s.exec, + ); + vec![ + Step::run( + format!("create service user {}", s.user), + &[ + "useradd", + "--system", + "--home-dir", + &s.data_dir, + "--shell", + "/usr/sbin/nologin", + &s.user, + ], + ), + Step::write( + format!("install systemd unit {unit_path}"), + unit_path, + 0o644, + unit, + ), + Step::run("reload systemd", &["systemctl", "daemon-reload"]), + Step::run( + format!("enable + start {} service", s.name), + &["systemctl", "enable", "--now", &s.name], + ), + ] + } +} + +/// Backend for the host we're running on. `Other` falls back to Linux-style. +pub fn backend() -> Box { + match detect_os() { + Os::FreeBsd => Box::new(FreeBsd), + _ => Box::new(Linux), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn freebsd_steps_shape() { + let steps = FreeBsd.install_service_steps(&ServiceSpec::default()); + assert_eq!(FreeBsd.service_kind(), "rc.d"); + // user create, rc.d write, sysrc enable + assert_eq!(steps.len(), 3); + assert!(matches!(steps[1].action, Action::WriteFile { .. })); + if let Action::Run(argv) = &steps[2].action { + assert_eq!(argv[0], "sysrc"); + assert_eq!(argv[1], "clawdie_enable=YES"); + } else { + panic!("expected sysrc run step"); + } + } + + #[test] + fn linux_steps_shape() { + let steps = Linux.install_service_steps(&ServiceSpec::default()); + assert_eq!(Linux.service_kind(), "systemd"); + assert_eq!(steps.len(), 4); + if let Action::WriteFile { path, .. } = &steps[1].action { + assert_eq!(path, "/etc/systemd/system/clawdie.service"); + } else { + panic!("expected unit write step"); + } + } +} diff --git a/crates/clawdie/src/zfs.rs b/crates/clawdie/src/zfs.rs new file mode 100644 index 0000000..4a7ea52 --- /dev/null +++ b/crates/clawdie/src/zfs.rs @@ -0,0 +1,129 @@ +//! ZFS discovery — read-only inspection of pools and datasets. +//! +//! Discovery never writes; it shells out to `zpool`/`zfs` with `-H` (script +//! mode, tab-separated). Provisioning lives in `plan`/`deploy`. + +use std::process::Command; + +use serde::Serialize; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct Pool { + pub name: String, + pub size: String, + pub health: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct Dataset { + pub name: String, + pub used: String, + pub mountpoint: String, +} + +/// Is a ZFS userland available on this host? +pub fn zfs_available() -> bool { + Command::new("zpool") + .arg("-?") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +/// Parse `zpool list -H -o name,size,health` (tab-separated, one pool per line). +pub fn parse_pools(out: &str) -> Vec { + out.lines() + .filter(|l| !l.trim().is_empty()) + .filter_map(|l| { + let mut f = l.split('\t'); + Some(Pool { + name: f.next()?.to_string(), + size: f.next().unwrap_or("").to_string(), + health: f.next().unwrap_or("").to_string(), + }) + }) + .filter(|p| !p.name.is_empty()) + .collect() +} + +/// Parse `zfs list -H -o name,used,mountpoint` (tab-separated). +pub fn parse_datasets(out: &str) -> Vec { + out.lines() + .filter(|l| !l.trim().is_empty()) + .filter_map(|l| { + let mut f = l.split('\t'); + Some(Dataset { + name: f.next()?.to_string(), + used: f.next().unwrap_or("").to_string(), + mountpoint: f.next().unwrap_or("").to_string(), + }) + }) + .filter(|d| !d.name.is_empty()) + .collect() +} + +/// List pools (empty if ZFS is absent or there are none). +pub fn list_pools() -> Vec { + let Ok(out) = Command::new("zpool") + .args(["list", "-H", "-o", "name,size,health"]) + .output() + else { + return Vec::new(); + }; + if !out.status.success() { + return Vec::new(); + } + parse_pools(&String::from_utf8_lossy(&out.stdout)) +} + +/// List datasets (empty if ZFS is absent or there are none). +pub fn list_datasets() -> Vec { + let Ok(out) = Command::new("zfs") + .args(["list", "-H", "-o", "name,used,mountpoint"]) + .output() + else { + return Vec::new(); + }; + if !out.status.success() { + return Vec::new(); + } + parse_datasets(&String::from_utf8_lossy(&out.stdout)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_pools_basic() { + let out = "zroot\t464G\tONLINE\ntank\t7.2T\tDEGRADED\n"; + let p = parse_pools(out); + assert_eq!(p.len(), 2); + assert_eq!( + p[0], + Pool { + name: "zroot".into(), + size: "464G".into(), + health: "ONLINE".into() + } + ); + assert_eq!(p[1].health, "DEGRADED"); + } + + #[test] + fn parse_pools_ignores_blank_and_partial() { + assert!(parse_pools("\n \n").is_empty()); + // a name-only line still yields a pool with empty size/health + let p = parse_pools("zroot\n"); + assert_eq!(p.len(), 1); + assert_eq!(p[0].name, "zroot"); + } + + #[test] + fn parse_datasets_basic() { + let out = "zroot/clawdie\t96K\t/var/db/clawdie\n"; + let d = parse_datasets(out); + assert_eq!(d.len(), 1); + assert_eq!(d[0].mountpoint, "/var/db/clawdie"); + } +}