From 8a5e75344a0b5d9daff8948af71e28e0fe059cd9 Mon Sep 17 00:00:00 2001 From: Sergio Date: Sun, 14 Jun 2026 03:17:56 +0000 Subject: [PATCH] feat(swm): init_rule se materializa a /etc/hammer/init.d/{service}.rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deja de ser un no-op "pendiente fase 5": apply::apply_init_rule escribe una regla TOML por servicio (service/action/command); enable/start/restart la escriben, disable/stop la retiran (idempotente), con guardas de nombre y acción. CLI y orchestrator la aplican (rebaseando con prefix/overlay). Es el contrato on-disk que el init (arje) lee para supervisar. 3 tests + doc del formato. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/hammer-agent/src/orchestrator.rs | 13 ++- crates/hammer-cli/src/main.rs | 10 +- crates/hammer-core/src/apply.rs | 127 ++++++++++++++++++++++++ docs/06-swm-format.md | 4 +- 4 files changed, 144 insertions(+), 10 deletions(-) diff --git a/crates/hammer-agent/src/orchestrator.rs b/crates/hammer-agent/src/orchestrator.rs index de4be4ce..7ce8ec3f 100644 --- a/crates/hammer-agent/src/orchestrator.rs +++ b/crates/hammer-agent/src/orchestrator.rs @@ -145,10 +145,17 @@ impl Orchestrator { target.display() ))); } - Mutation::InitRule { action, service, .. } => { + Mutation::InitRule { action, service, command } => { + let rules_dir = + rebase_path(hammer_core::apply::INIT_RULES_DIR, prefix.as_deref()); + let p = hammer_core::apply::apply_init_rule( + &rules_dir, action, service, command, + ) + .map_err(|e| Error::Orchestrate(format!("init_rule '{action} {service}': {e}")))?; applied.init_rule += 1; - checks.push(VerifyCheck::warn(format!( - "init_rule '{action} {service}' anotada — pendiente Fase 5+ (supervisor)" + checks.push(VerifyCheck::pass(format!( + "init_rule '{action} {service}' → {}", + p.display() ))); } Mutation::SourcePatch { diff --git a/crates/hammer-cli/src/main.rs b/crates/hammer-cli/src/main.rs index 3a208779..de9c17e7 100644 --- a/crates/hammer-cli/src/main.rs +++ b/crates/hammer-cli/src/main.rs @@ -832,12 +832,12 @@ fn run_apply( hammer_core::apply::apply_config_edit(&target, inline_diff)?; eprintln!(" config_edit #{} → {}", i + 1, target.display()); } - hammer_core::Mutation::InitRule { action, service, .. } => { + hammer_core::Mutation::InitRule { action, service, command } => { counts.2 += 1; - eprintln!( - " [pendiente fase 5] init_rule #{}: {action} {service} — ignorando", - i + 1 - ); + let rules_dir = + hammer_core::apply::rebase_path(hammer_core::apply::INIT_RULES_DIR, prefix); + let p = hammer_core::apply::apply_init_rule(&rules_dir, action, service, command)?; + eprintln!(" init_rule #{}: {action} {service} → {}", i + 1, p.display()); } hammer_core::Mutation::FileDrop { path, content_hash, content_b64, content_url } => { counts.3 += 1; diff --git a/crates/hammer-core/src/apply.rs b/crates/hammer-core/src/apply.rs index b3d1b177..807a08ae 100644 --- a/crates/hammer-core/src/apply.rs +++ b/crates/hammer-core/src/apply.rs @@ -25,9 +25,15 @@ use std::path::{Path, PathBuf}; use base64::{engine::general_purpose::STANDARD, Engine as _}; +use serde::Serialize; use crate::hash::ArtifactHash; +/// Directorio canónico donde `init_rule` materializa una regla por servicio. El init +/// (arje, PID 1) lee este árbol para saber qué supervisar. Es el contrato on-disk entre +/// hammer (declara) y el init (ejecuta) — análogo a `/etc/systemd/system` pero nativo. +pub const INIT_RULES_DIR: &str = "/etc/hammer/init.d"; + #[derive(Debug, thiserror::Error)] pub enum ApplyError { #[error("io: {0}")] @@ -36,6 +42,8 @@ pub enum ApplyError { ConfigEdit(String), #[error("file_drop: {0}")] FileDrop(String), + #[error("init_rule: {0}")] + InitRule(String), } pub type ApplyResult = std::result::Result; @@ -97,6 +105,89 @@ pub fn apply_file_drop( Ok(()) } +/// Acción de un `init_rule`. Decide si la regla se **materializa** (el servicio debe +/// existir/correr) o se **retira** (no debe correr). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InitAction { + Enable, + Disable, + Start, + Stop, + Restart, +} + +impl InitAction { + pub fn parse(s: &str) -> Result { + match s { + "enable" => Ok(Self::Enable), + "disable" => Ok(Self::Disable), + "start" => Ok(Self::Start), + "stop" => Ok(Self::Stop), + "restart" => Ok(Self::Restart), + other => Err(format!( + "acción desconocida '{other}' (enable|disable|start|stop|restart)" + )), + } + } + + /// `true` si la acción implica que el servicio debe quedar declarado en disco. + /// `disable`/`stop` son las "negativas": retiran la regla. + fn materializes(self) -> bool { + !matches!(self, Self::Disable | Self::Stop) + } +} + +/// Forma serializada de una regla de init en disco (TOML). +#[derive(Serialize)] +struct InitRuleFile<'a> { + service: &'a str, + action: &'a str, + command: &'a str, +} + +/// Aplica un `init_rule`: materializa (o retira) `{service}.rule` bajo `rules_dir` (el +/// caller pasa [`INIT_RULES_DIR`] re-rooteado al overlay o a un prefix de test). Acciones +/// positivas (enable/start/restart) escriben la regla con su `command`; negativas +/// (disable/stop) la borran (idempotente). Devuelve el path de la regla afectada. +pub fn apply_init_rule( + rules_dir: &Path, + action: &str, + service: &str, + command: &str, +) -> ApplyResult { + let act = InitAction::parse(action).map_err(ApplyError::InitRule)?; + // El nombre del servicio se vuelve un nombre de archivo: no debe escapar del dir. + if service.is_empty() || service.contains('/') || service.contains("..") { + return Err(ApplyError::InitRule(format!( + "nombre de servicio inválido: '{service}'" + ))); + } + let rule_path = rules_dir.join(format!("{service}.rule")); + + if !act.materializes() { + // disable/stop: retiramos la regla si existe. No es error que falte. + match std::fs::remove_file(&rule_path) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(ApplyError::Io(e)), + } + return Ok(rule_path); + } + + if command.is_empty() { + return Err(ApplyError::InitRule(format!( + "init_rule '{action} {service}': command vacío" + ))); + } + let body = toml::to_string(&InitRuleFile { service, action, command }) + .map_err(|e| ApplyError::InitRule(format!("serializando regla: {e}")))?; + if let Some(parent) = rule_path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&rule_path, body)?; + Ok(rule_path) +} + /// Para el caller que ya quiere chequear el hash sin escribir nada (p. ej. `swm verify`). pub fn verify_content_hash(content_b64: &str, content_hash: &str) -> ApplyResult<()> { let bytes = STANDARD @@ -385,4 +476,40 @@ mod tests { let r = rebase_path("/etc/foo", None); assert_eq!(r, PathBuf::from("/etc/foo")); } + + #[test] + fn init_rule_enable_escribe_regla() { + let d = tempfile::tempdir().unwrap(); + let dir = d.path().join("etc/hammer/init.d"); + let p = apply_init_rule(&dir, "enable", "nginx", "/usr/sbin/nginx -g 'daemon off;'").unwrap(); + assert_eq!(p, dir.join("nginx.rule")); + let body = std::fs::read_to_string(&p).unwrap(); + assert!(body.contains("service = \"nginx\""), "{body}"); + assert!(body.contains("action = \"enable\""), "{body}"); + assert!(body.contains("daemon off"), "{body}"); + } + + #[test] + fn init_rule_disable_retira_la_regla() { + let d = tempfile::tempdir().unwrap(); + let dir = d.path().join("init.d"); + let p = apply_init_rule(&dir, "enable", "foo", "/bin/foo").unwrap(); + assert!(p.exists()); + // disable borra; y es idempotente (segunda vez no falla aunque ya no esté). + apply_init_rule(&dir, "disable", "foo", "/bin/foo").unwrap(); + assert!(!p.exists(), "disable debió borrar la regla"); + apply_init_rule(&dir, "stop", "foo", "/bin/foo").unwrap(); + } + + #[test] + fn init_rule_rechaza_accion_y_servicio_invalidos() { + let d = tempfile::tempdir().unwrap(); + let dir = d.path().join("init.d"); + let e1 = apply_init_rule(&dir, "frobnicate", "x", "/bin/x").unwrap_err().to_string(); + assert!(e1.contains("acción desconocida"), "{e1}"); + let e2 = apply_init_rule(&dir, "enable", "../escape", "/bin/x").unwrap_err().to_string(); + assert!(e2.contains("servicio inválido"), "{e2}"); + let e3 = apply_init_rule(&dir, "enable", "x", "").unwrap_err().to_string(); + assert!(e3.contains("command vacío"), "{e3}"); + } } diff --git a/docs/06-swm-format.md b/docs/06-swm-format.md index 48fd192d..aaa19129 100644 --- a/docs/06-swm-format.md +++ b/docs/06-swm-format.md @@ -55,9 +55,9 @@ signature: # opcional pero recomendado (ver SDD 09) | `type` | Qué describe | Cómo lo aplica el receptor | |---|---|---| -| `source_patch` | recompilar una herramienta desde fuente parcheada | clona repo@commit → aplica patch → `hammer build` → hidrata en overlay | +| `source_patch` | recompilar una herramienta desde fuente parcheada (git **o** tarball) | clona repo@commit / baja tarball+sha256 → aplica patch → `hammer build` → hidrata en overlay | | `config_edit` | edición de un archivo de config | aplica el `inline_diff` (3-way) sobre el archivo objetivo | -| `init_rule` | regla del bus de init | inyecta la regla vía `/run/init.control` / receta de servicio | +| `init_rule` | regla de supervisión de un servicio | materializa `/etc/hammer/init.d/{service}.rule` (TOML: service/action/command); `disable`/`stop` la retiran. El init (arje) lee ese árbol | | `file_drop` | depositar un archivo de datos no compilable | escribe el archivo (con su hash declarado) en la ruta | `file_drop` admite dos modos para el contenido, mutuamente excluyentes: