feat(swm): init_rule se materializa a /etc/hammer/init.d/{service}.rule
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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
0f42c38106
commit
8a5e75344a
@@ -145,10 +145,17 @@ impl<T: IntentTranslator> Orchestrator<T> {
|
||||
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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<T> = std::result::Result<T, ApplyError>;
|
||||
@@ -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<Self, String> {
|
||||
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<PathBuf> {
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user