feat(clawdie): host installer/deployer crate (FreeBSD + Linux)

New crates/clawdie binary. Discovers a host's ZFS layout and provisions the
clawdie service, cross-platform via a Platform backend (FreeBSD rc.d + native
ZFS; Linux systemd + ZFS-on-Linux).

- discover: read-only OS + pool/dataset inspection
- plan: render the ZFS layout + service-install steps (dry-run)
- apply: executes the plan, and only with --yes (dry-run otherwise)

apply writes to disk only with --yes. Discovery + plan logic is unit-tested (7);
the disk-touching path must be validated on real hosts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Sam & Claude 2026-06-13 22:55:23 +02:00
parent 900874c847
commit c902f75813
8 changed files with 685 additions and 1 deletions

View file

@ -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"

15
crates/clawdie/Cargo.toml Normal file
View file

@ -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"

38
crates/clawdie/README.md Normal file
View file

@ -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
<pool>/clawdie (container, canmount=off)
<pool>/clawdie/db -> /var/db/clawdie
<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`.

View file

@ -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(())
}

130
crates/clawdie/src/main.rs Normal file
View file

@ -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<String>,
},
/// Provision: create the ZFS layout and install the clawdie service.
Apply {
#[arg(long)]
pool: Option<String>,
/// 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);
}
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::<Vec<_>>()
.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::<Vec<_>>()
.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
}
}
}

113
crates/clawdie/src/plan.rs Normal file
View file

@ -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)
}
}
/// `<pool>/clawdie` container + `db` (→ data_dir) + `log` (→ /var/log/clawdie).
fn dataset_steps(cfg: &DeployConfig) -> Vec<Step> {
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<Step> {
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"));
}
}

View file

@ -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<String>),
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<String>, argv: &[&str]) -> Self {
Step {
describe: describe.into(),
action: Action::Run(argv.iter().map(|s| s.to_string()).collect()),
}
}
pub fn write(
describe: impl Into<String>,
path: impl Into<String>,
mode: u32,
contents: impl Into<String>,
) -> 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<Step>;
}
pub struct FreeBsd;
impl Platform for FreeBsd {
fn service_kind(&self) -> &'static str {
"rc.d"
}
fn install_service_steps(&self, s: &ServiceSpec) -> Vec<Step> {
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<Step> {
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<dyn Platform> {
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");
}
}
}

129
crates/clawdie/src/zfs.rs Normal file
View file

@ -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<Pool> {
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<Dataset> {
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<Pool> {
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<Dataset> {
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");
}
}