From 46dcf7d7e77e1eff74d66745692007696f7853c6 Mon Sep 17 00:00:00 2001 From: Sam & Claude Date: Sun, 21 Jun 2026 15:07:56 +0200 Subject: [PATCH] fix(clawdie): rename service user to _clawdie + idempotent creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes to the clawdie deploy binary: 1. Service user renamed from 'clawdie' to '_clawdie' — follows FreeBSD daemon convention (underscore prefix). Avoids collision with the operator's interactive 'clawdie' user on existing hosts like OSA. 2. User creation is now idempotent — exit code 65 (pw: user already exists) is treated as success via the new allowed_exit_codes field on Action::Run. Deploy can safely re-run without failing. Full end-to-end test on OSA file-backed pool: all 7 steps (ZFS datasets, user, chown, rc.d write, sysrc enable) complete. --- crates/clawdie/src/deploy.rs | 20 ++++++++++++------- crates/clawdie/src/plan.rs | 10 +++++----- crates/clawdie/src/platform.rs | 36 +++++++++++++++++++++++++--------- 3 files changed, 45 insertions(+), 21 deletions(-) diff --git a/crates/clawdie/src/deploy.rs b/crates/clawdie/src/deploy.rs index e5344d3..333b463 100644 --- a/crates/clawdie/src/deploy.rs +++ b/crates/clawdie/src/deploy.rs @@ -15,18 +15,24 @@ pub fn apply(steps: &[Step]) -> Result<(), String> { for (i, step) in steps.iter().enumerate() { eprintln!("[{}/{}] {}", i + 1, total, step.describe); match &step.action { - Action::Run(argv) => { + Action::Run { + argv, + allowed_exit_codes, + } => { let (cmd, rest) = argv.split_first().ok_or("empty command in step")?; - let status = Command::new(cmd) + let output = Command::new(cmd) .args(rest) - .status() + .output() .map_err(|e| format!("failed to run `{}`: {e}", argv.join(" ")))?; - if !status.success() { + let code = output.status.code().unwrap_or(-1); + if !output.status.success() && !allowed_exit_codes.contains(&code) { + let stderr = String::from_utf8_lossy(&output.stderr); return Err(format!( - "step {} failed ({}): `{}`", + "step {} failed (exit {}): `{}`\n{}", i + 1, - status, - argv.join(" ") + code, + argv.join(" "), + stderr.trim() )); } } diff --git a/crates/clawdie/src/plan.rs b/crates/clawdie/src/plan.rs index c39003a..9fc1bd6 100644 --- a/crates/clawdie/src/plan.rs +++ b/crates/clawdie/src/plan.rs @@ -146,7 +146,7 @@ pub fn render( out.push_str("\nSteps:\n"); for (i, step) in steps.iter().enumerate() { let detail = match &step.action { - Action::Run(argv) => format!("$ {}", argv.join(" ")), + Action::Run { argv, .. } => format!("$ {}", argv.join(" ")), Action::WriteFile { path, mode, .. } => format!("write {path} (mode {mode:o})"), }; out.push_str(&format!( @@ -224,7 +224,7 @@ mod tests { // plain dirs: 2 dir steps + 5 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")); + assert!(matches!(&s[0].action, Action::Run { argv, .. } if argv[0] == "mkdir")); // create pool: 1 zpool + 3 dataset steps + 4 freebsd service steps let c = build( &FreeBsd, @@ -234,10 +234,10 @@ mod tests { }, &svc, ); - assert!( - matches!(&c[0].action, Action::Run(a) if a[0] == "zpool" && a.contains(&"/dev/sdb".to_string())) - ); assert_eq!(c.len(), 8); + assert!( + matches!(&c[0].action, Action::Run { argv, .. } if argv[0] == "zpool" && argv.contains(&"/dev/sdb".to_string())) + ); } #[test] diff --git a/crates/clawdie/src/platform.rs b/crates/clawdie/src/platform.rs index 0f7cc72..1ca0d14 100644 --- a/crates/clawdie/src/platform.rs +++ b/crates/clawdie/src/platform.rs @@ -37,7 +37,7 @@ impl Default for ServiceSpec { Self { name: "clawdie".into(), exec: "/usr/local/bin/colibri-daemon".into(), - user: "clawdie".into(), + user: "_clawdie".into(), data_dir: "/var/db/clawdie".into(), } } @@ -46,7 +46,11 @@ impl Default for ServiceSpec { /// One provisioning action — either a command to run or a file to write. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Action { - Run(Vec), + /// Run argv[0] with argv[1..]; non-zero exit fails unless listed in allowed_exit_codes. + Run { + argv: Vec, + allowed_exit_codes: Vec, + }, WriteFile { path: String, mode: u32, @@ -64,7 +68,20 @@ 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()), + action: Action::Run { + argv: argv.iter().map(|s| s.to_string()).collect(), + allowed_exit_codes: vec![], + }, + } + } + /// Like `run`, but treats the given exit codes as success (for idempotent steps). + pub fn run_idem(describe: impl Into, argv: &[&str], allowed: &[i32]) -> Self { + Step { + describe: describe.into(), + action: Action::Run { + argv: argv.iter().map(|s| s.to_string()).collect(), + allowed_exit_codes: allowed.to_vec(), + }, } } pub fn write( @@ -107,7 +124,7 @@ impl Platform for FreeBsd { ); let owner = format!("{}:{}", s.user, s.user); vec![ - Step::run( + Step::run_idem( format!("create service user {}", s.user), &[ "pw", @@ -120,6 +137,7 @@ impl Platform for FreeBsd { "-c", "Clawdie host service", ], + &[65], // already exists → skip ), Step::run( "own clawdie state directories", @@ -210,20 +228,20 @@ mod tests { assert_eq!(FreeBsd.service_kind(), "rc.d"); // user create, ownership, rc.d write, sysrc enable assert_eq!(steps.len(), 4); - if let Action::Run(argv) = &steps[1].action { + if let Action::Run { argv, .. } = &steps[1].action { assert_eq!(argv[0], "chown"); - assert!(argv.contains(&"clawdie:clawdie".to_string())); + assert!(argv.contains(&"_clawdie:_clawdie".to_string())); } else { panic!("expected chown run step"); } assert!(matches!(steps[2].action, Action::WriteFile { .. })); if let Action::WriteFile { contents, .. } = &steps[2].action { assert!(contents.contains("daemon\"")); - assert!(contents.contains("-u clawdie")); + assert!(contents.contains("-u _clawdie")); } else { panic!("expected rc.d write step"); } - if let Action::Run(argv) = &steps[3].action { + if let Action::Run { argv, .. } = &steps[3].action { assert_eq!(argv[0], "sysrc"); assert_eq!(argv[1], "clawdie_enable=YES"); } else { @@ -236,7 +254,7 @@ mod tests { let steps = Linux.install_service_steps(&ServiceSpec::default()); assert_eq!(Linux.service_kind(), "systemd"); assert_eq!(steps.len(), 5); - if let Action::Run(argv) = &steps[1].action { + if let Action::Run { argv, .. } = &steps[1].action { assert_eq!(argv[0], "chown"); } else { panic!("expected chown run step");