gioser

tawasuyu/brahman

Rust 463TOML 180Markdown 49JSON 176325 Definitions

⎇ df8f92fbb0d4c9cfc377c810341794766db51094 crates/modules/matilda/matilda-ghost/src/lib.rs Rust · 222 lines · 7.1 KB History Who touched what raw

feat(matilda): ghost + linker + CLI — el ciclo completo de aplicación df8f92fbb · sergio ·

1//! `matilda-ghost` — el agente que aplica los pasos en la máquina destino.
2//!
3//! El «Ghost» es quien realmente ejecuta: recibe los [`ApplyStep`]s que
4//! tradujo `matilda-apply` y, en orden, escribe los archivos y corre los
5//! comandos en *esta* máquina (la del servidor). Reporta paso a paso en
6//! un [`ApplyReport`].
7//!
8//! Semántica `set -e`: si un paso falla, se detiene — no se aplican los
9//! siguientes. [`dry_run`] muestra lo que haría sin tocar nada.
10//!
11//! La aplicación *remota* (por SSH) la hace `matilda-linker`, que produce
12//! el mismo [`ApplyReport`] reusando estos tipos.
13
14#![forbid(unsafe_code)]
15
16use matilda_apply::ApplyStep;
17use serde::{Deserialize, Serialize};
18
19/// Resultado de un paso de aplicación.
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21pub struct StepResult {
22    /// Descripción de la acción aplicada.
23    pub describe: String,
24    /// `true` si el paso completó sin errores.
25    pub ok: bool,
26    /// Bitácora legible: archivos escritos, comandos y su salida.
27    pub log: Vec<String>,
28}
29
30/// El reporte de aplicar un plan: un resultado por paso ejecutado.
31#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
32pub struct ApplyReport {
33    pub results: Vec<StepResult>,
34}
35
36impl ApplyReport {
37    /// `true` si todos los pasos ejecutados salieron bien.
38    pub fn all_ok(&self) -> bool {
39        self.results.iter().all(|r| r.ok)
40    }
41
42    /// Cantidad de pasos que salieron bien.
43    pub fn applied(&self) -> usize {
44        self.results.iter().filter(|r| r.ok).count()
45    }
46
47    /// El primer paso que falló, si lo hubo.
48    pub fn failed(&self) -> Option<&StepResult> {
49        self.results.iter().find(|r| !r.ok)
50    }
51}
52
53/// Corre un comando de shell, juntando su salida (stdout + stderr).
54/// Los comandos de matilda llevan `&&`, redirecciones… → van por `sh -c`.
55fn run_command(cmd: &str) -> std::io::Result<(i32, Vec<String>)> {
56    let out = std::process::Command::new("sh").arg("-c").arg(cmd).output()?;
57    let mut lines = Vec::new();
58    for chunk in [&out.stdout, &out.stderr] {
59        for l in String::from_utf8_lossy(chunk).lines() {
60            lines.push(l.to_string());
61        }
62    }
63    Ok((out.status.code().unwrap_or(-1), lines))
64}
65
66/// Aplica un paso en esta máquina: escribe sus archivos y corre sus
67/// comandos. Devuelve el resultado; se detiene en el primer error.
68fn apply_step(step: &ApplyStep) -> StepResult {
69    let mut log = Vec::new();
70    let mut ok = true;
71
72    for f in &step.files {
73        match std::fs::write(&f.path, &f.content) {
74            Ok(()) => log.push(format!("✔ escrito {}", f.path)),
75            Err(e) => {
76                log.push(format!("✘ no se pudo escribir {}: {e}", f.path));
77                ok = false;
78                break;
79            }
80        }
81    }
82
83    if ok {
84        for cmd in &step.commands {
85            log.push(format!("$ {cmd}"));
86            match run_command(cmd) {
87                Ok((0, out)) => {
88                    log.extend(out.into_iter().map(|l| format!("  {l}")));
89                }
90                Ok((code, out)) => {
91                    log.extend(out.into_iter().map(|l| format!("  {l}")));
92                    log.push(format!("✘ el comando salió con código {code}"));
93                    ok = false;
94                    break;
95                }
96                Err(e) => {
97                    log.push(format!("✘ no se pudo ejecutar: {e}"));
98                    ok = false;
99                    break;
100                }
101            }
102        }
103    }
104
105    StepResult { describe: step.describe.clone(), ok, log }
106}
107
108/// Aplica los pasos en orden. Se detiene en el primero que falle
109/// (semántica `set -e`): los posteriores no se ejecutan.
110pub fn apply(steps: &[ApplyStep]) -> ApplyReport {
111    let mut results = Vec::new();
112    for step in steps {
113        let result = apply_step(step);
114        let failed = !result.ok;
115        results.push(result);
116        if failed {
117            break;
118        }
119    }
120    ApplyReport { results }
121}
122
123/// Simula la aplicación: reporta qué archivos y comandos se ejecutarían,
124/// sin tocar nada. Seguro para previsualizar.
125pub fn dry_run(steps: &[ApplyStep]) -> ApplyReport {
126    let results = steps
127        .iter()
128        .map(|s| {
129            let mut log = Vec::new();
130            for f in &s.files {
131                log.push(format!("escribiría {} ({} bytes)", f.path, f.content.len()));
132            }
133            for c in &s.commands {
134                log.push(format!("$ {c}"));
135            }
136            StepResult { describe: s.describe.clone(), ok: true, log }
137        })
138        .collect();
139    ApplyReport { results }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use matilda_apply::FileWrite;
146
147    /// Paso que escribe un archivo temporal y corre un comando.
148    fn step(describe: &str, file: Option<FileWrite>, cmds: &[&str]) -> ApplyStep {
149        ApplyStep {
150            describe: describe.into(),
151            files: file.into_iter().collect(),
152            commands: cmds.iter().map(|s| s.to_string()).collect(),
153        }
154    }
155
156    fn temp(name: &str) -> String {
157        std::env::temp_dir()
158            .join(format!("matilda-ghost-{}-{name}", std::process::id()))
159            .to_string_lossy()
160            .into_owned()
161    }
162
163    #[test]
164    fn dry_run_touches_nothing() {
165        let path = temp("dry");
166        let _ = std::fs::remove_file(&path);
167        let steps = vec![step(
168            "crear x",
169            Some(FileWrite { path: path.clone(), content: "hola".into() }),
170            &["echo hecho"],
171        )];
172        let report = dry_run(&steps);
173        assert!(report.all_ok());
174        assert_eq!(report.results.len(), 1);
175        // dry_run no escribió el archivo.
176        assert!(!std::path::Path::new(&path).exists());
177    }
178
179    #[test]
180    fn apply_writes_files_and_runs_commands() {
181        let path = temp("apply");
182        let _ = std::fs::remove_file(&path);
183        let steps = vec![step(
184            "crear config",
185            Some(FileWrite { path: path.clone(), content: "contenido".into() }),
186            &["echo aplicado"],
187        )];
188        let report = apply(&steps);
189        assert!(report.all_ok());
190        assert_eq!(std::fs::read_to_string(&path).unwrap(), "contenido");
191        assert!(report.results[0].log.iter().any(|l| l.contains("aplicado")));
192        let _ = std::fs::remove_file(&path);
193    }
194
195    #[test]
196    fn apply_stops_at_the_first_failure() {
197        let steps = vec![
198            step("ok", None, &["true"]),
199            step("falla", None, &["exit 7"]),
200            step("nunca", None, &["echo no-deberia-correr"]),
201        ];
202        let report = apply(&steps);
203        // El tercer paso no se ejecutó.
204        assert_eq!(report.results.len(), 2);
205        assert!(!report.all_ok());
206        assert_eq!(report.applied(), 1);
207        assert!(report.failed().unwrap().describe.contains("falla"));
208    }
209
210    #[test]
211    fn nonzero_exit_marks_the_step_failed() {
212        let report = apply(&[step("test", None, &["false"])]);
213        assert!(!report.results[0].ok);
214    }
215
216    #[test]
217    fn empty_plan_applies_cleanly() {
218        let report = apply(&[]);
219        assert!(report.all_ok());
220        assert_eq!(report.applied(), 0);
221    }
222}