⎇ df8f92fbb0d4c9cfc377c810341794766db51094
crates/modules/matilda/matilda-ghost/src/lib.rs
History
Who touched what
raw
feat(matilda): ghost + linker + CLI — el ciclo completo de aplicación
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}