feat(clawdie): host installer/deployer crate (FreeBSD + Linux) #46
10 changed files with 689 additions and 3 deletions
|
|
@ -60,9 +60,10 @@ Target: `x86_64-unknown-freebsd` (Rust Tier-2). TLS is `rustls` to avoid
|
|||
| `colibri-store` | Embedded SQLite coordination (task board, agents, skills) |
|
||||
| `colibri-skills` | Skills catalog (read-only consumer of reviewed skill artifacts) |
|
||||
| `colibri-mcp` | MCP bridge for editor integration + external MCP host (jailed) |
|
||||
| `clawdie` | Host installer/deployer: ZFS layout + `clawdie` service (FreeBSD/Linux) |
|
||||
| `colibri` (root) | Workspace root + probe binaries |
|
||||
|
||||
The workspace currently has 11 crates (10 members + root).
|
||||
The workspace currently has 12 crates (11 members + root).
|
||||
|
||||
Gate status should be rechecked from source instead of relying on a fixed test
|
||||
count:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ ISO acceptance runbook: `docs/ISO-ACCEPTANCE-RUNBOOK.md`.
|
|||
Clawdie Studio/Zed proposal: `docs/CLAWDIE-STUDIO-PROPOSAL.md`.
|
||||
External MCP host prototype: `docs/COLIBRI-EXTERNAL-MCP-PROTOTYPE.md`.
|
||||
|
||||
## Workspace — 10 crates
|
||||
## Workspace — 11 crates
|
||||
|
||||
| Crate | Role |
|
||||
| ----------------------- | ----------------------------------------------------------------------- |
|
||||
|
|
@ -25,6 +25,7 @@ External MCP host prototype: `docs/COLIBRI-EXTERNAL-MCP-PROTOTYPE.md`.
|
|||
| `colibri-glasspane-tui` | ratatui live dashboard (FreeBSD-native) |
|
||||
| `colibri-store` | Embedded SQLite coordination (task board, agents, skills) |
|
||||
| `colibri-skills` | Skills catalog crate |
|
||||
| `clawdie` | Host installer/deployer: ZFS layout + `clawdie` service (FreeBSD/Linux) |
|
||||
|
||||
## Build
|
||||
|
||||
|
|
|
|||
15
crates/clawdie/Cargo.toml
Normal file
15
crates/clawdie/Cargo.toml
Normal 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
38
crates/clawdie/README.md
Normal 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`.
|
||||
49
crates/clawdie/src/deploy.rs
Normal file
49
crates/clawdie/src/deploy.rs
Normal 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
130
crates/clawdie/src/main.rs
Normal 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
113
crates/clawdie/src/plan.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
210
crates/clawdie/src/platform.rs
Normal file
210
crates/clawdie/src/platform.rs
Normal 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
129
crates/clawdie/src/zfs.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue