feat(matilda): ghost + linker + CLI — el ciclo completo de aplicación
matilda-ghost: el agente que ejecuta los ApplySteps en la máquina destino — escribe archivos, corre comandos, reporta paso a paso; semántica set -e (se detiene en el primer error). dry_run previsualiza sin tocar nada. 5 tests. matilda-linker: aplica los pasos en un host remoto por SSH sobre brahman-ssh-multiplex; produce el mismo ApplyReport que el ghost local. apps/matilda: deja de ser una demo hardcoded — ahora es una CLI real: matilda example | plan | script | apply (local · --dry-run · --host) Carga el inventario de un JSON, reconcilia y aplica. matilda: 6 crates + CLI, ~42 tests. La cadena va de la declaración a la aplicación local/remota. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -35,16 +35,22 @@ App: `apps/matilda` — demo CLI (`cargo run -p matilda`).
|
||||
- `config` y `plan` ← `matilda-core`. Todos `#![forbid(unsafe_code)]`.
|
||||
- Cero Docker, cero SSH, cero disco — sólo modelos y strings.
|
||||
|
||||
## Crates de ejecución
|
||||
|
||||
| crate | tipo | rol |
|
||||
| ---------------- | ---- | ------------------------------------------------------------ |
|
||||
| `matilda-ghost` | lib | Ejecuta los `ApplyStep`s en la máquina destino (escribe archivos, corre comandos) + `dry_run`; reporta paso a paso |
|
||||
| `matilda-linker` | lib | Aplica los pasos en un host **remoto** por SSH (`brahman-ssh-multiplex`); mismo `ApplyReport` |
|
||||
|
||||
CLI: `apps/matilda` — `example` / `plan` / `script` / `apply`
|
||||
(local · `--dry-run` · `--host usuario@host` por SSH).
|
||||
|
||||
## Estado
|
||||
|
||||
`core` + `config` + `plan` + `apply` implementados y verdes (35 tests) +
|
||||
demo CLI. La cadena pura ya llega de la declaración al script de shell
|
||||
concreto listo para correr en el servidor.
|
||||
`core` + `config` + `plan` + `apply` + `ghost` + `linker` implementados
|
||||
y verdes (~42 tests) + CLI. La cadena va de la declaración (inventario
|
||||
JSON) al plan, al script y a la aplicación —local, en seco o remota—.
|
||||
|
||||
**Pendiente** (la capa de I/O):
|
||||
|
||||
| crate pendiente | rol |
|
||||
| ----------------- | ------------------------------------------------ |
|
||||
| `matilda-linker` | transporte SSH (sobre `transport-ssh-multiplex`) |
|
||||
| `matilda-ghost` | agente remoto que ejecuta los `ApplyStep`s |
|
||||
| `matilda-app` | frontend GPUI |
|
||||
**Pendiente**: `matilda-discover` (leer el estado actual del servidor —
|
||||
`docker inspect`, sitios de nginx— para un diff real en vez de partir
|
||||
de un inventario vacío) y `matilda-app`, el frontend GPUI.
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "matilda-ghost"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
publish.workspace = true
|
||||
description = "matilda — el agente que ejecuta los ApplySteps en la máquina destino: escribe los archivos, corre los comandos y reporta el resultado paso a paso."
|
||||
|
||||
[dependencies]
|
||||
matilda-apply = { path = "../matilda-apply" }
|
||||
serde = { workspace = true }
|
||||
@@ -0,0 +1,222 @@
|
||||
//! `matilda-ghost` — el agente que aplica los pasos en la máquina destino.
|
||||
//!
|
||||
//! El «Ghost» es quien realmente ejecuta: recibe los [`ApplyStep`]s que
|
||||
//! tradujo `matilda-apply` y, en orden, escribe los archivos y corre los
|
||||
//! comandos en *esta* máquina (la del servidor). Reporta paso a paso en
|
||||
//! un [`ApplyReport`].
|
||||
//!
|
||||
//! Semántica `set -e`: si un paso falla, se detiene — no se aplican los
|
||||
//! siguientes. [`dry_run`] muestra lo que haría sin tocar nada.
|
||||
//!
|
||||
//! La aplicación *remota* (por SSH) la hace `matilda-linker`, que produce
|
||||
//! el mismo [`ApplyReport`] reusando estos tipos.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use matilda_apply::ApplyStep;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Resultado de un paso de aplicación.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct StepResult {
|
||||
/// Descripción de la acción aplicada.
|
||||
pub describe: String,
|
||||
/// `true` si el paso completó sin errores.
|
||||
pub ok: bool,
|
||||
/// Bitácora legible: archivos escritos, comandos y su salida.
|
||||
pub log: Vec<String>,
|
||||
}
|
||||
|
||||
/// El reporte de aplicar un plan: un resultado por paso ejecutado.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub struct ApplyReport {
|
||||
pub results: Vec<StepResult>,
|
||||
}
|
||||
|
||||
impl ApplyReport {
|
||||
/// `true` si todos los pasos ejecutados salieron bien.
|
||||
pub fn all_ok(&self) -> bool {
|
||||
self.results.iter().all(|r| r.ok)
|
||||
}
|
||||
|
||||
/// Cantidad de pasos que salieron bien.
|
||||
pub fn applied(&self) -> usize {
|
||||
self.results.iter().filter(|r| r.ok).count()
|
||||
}
|
||||
|
||||
/// El primer paso que falló, si lo hubo.
|
||||
pub fn failed(&self) -> Option<&StepResult> {
|
||||
self.results.iter().find(|r| !r.ok)
|
||||
}
|
||||
}
|
||||
|
||||
/// Corre un comando de shell, juntando su salida (stdout + stderr).
|
||||
/// Los comandos de matilda llevan `&&`, redirecciones… → van por `sh -c`.
|
||||
fn run_command(cmd: &str) -> std::io::Result<(i32, Vec<String>)> {
|
||||
let out = std::process::Command::new("sh").arg("-c").arg(cmd).output()?;
|
||||
let mut lines = Vec::new();
|
||||
for chunk in [&out.stdout, &out.stderr] {
|
||||
for l in String::from_utf8_lossy(chunk).lines() {
|
||||
lines.push(l.to_string());
|
||||
}
|
||||
}
|
||||
Ok((out.status.code().unwrap_or(-1), lines))
|
||||
}
|
||||
|
||||
/// Aplica un paso en esta máquina: escribe sus archivos y corre sus
|
||||
/// comandos. Devuelve el resultado; se detiene en el primer error.
|
||||
fn apply_step(step: &ApplyStep) -> StepResult {
|
||||
let mut log = Vec::new();
|
||||
let mut ok = true;
|
||||
|
||||
for f in &step.files {
|
||||
match std::fs::write(&f.path, &f.content) {
|
||||
Ok(()) => log.push(format!("✔ escrito {}", f.path)),
|
||||
Err(e) => {
|
||||
log.push(format!("✘ no se pudo escribir {}: {e}", f.path));
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ok {
|
||||
for cmd in &step.commands {
|
||||
log.push(format!("$ {cmd}"));
|
||||
match run_command(cmd) {
|
||||
Ok((0, out)) => {
|
||||
log.extend(out.into_iter().map(|l| format!(" {l}")));
|
||||
}
|
||||
Ok((code, out)) => {
|
||||
log.extend(out.into_iter().map(|l| format!(" {l}")));
|
||||
log.push(format!("✘ el comando salió con código {code}"));
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
log.push(format!("✘ no se pudo ejecutar: {e}"));
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
StepResult { describe: step.describe.clone(), ok, log }
|
||||
}
|
||||
|
||||
/// Aplica los pasos en orden. Se detiene en el primero que falle
|
||||
/// (semántica `set -e`): los posteriores no se ejecutan.
|
||||
pub fn apply(steps: &[ApplyStep]) -> ApplyReport {
|
||||
let mut results = Vec::new();
|
||||
for step in steps {
|
||||
let result = apply_step(step);
|
||||
let failed = !result.ok;
|
||||
results.push(result);
|
||||
if failed {
|
||||
break;
|
||||
}
|
||||
}
|
||||
ApplyReport { results }
|
||||
}
|
||||
|
||||
/// Simula la aplicación: reporta qué archivos y comandos se ejecutarían,
|
||||
/// sin tocar nada. Seguro para previsualizar.
|
||||
pub fn dry_run(steps: &[ApplyStep]) -> ApplyReport {
|
||||
let results = steps
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let mut log = Vec::new();
|
||||
for f in &s.files {
|
||||
log.push(format!("escribiría {} ({} bytes)", f.path, f.content.len()));
|
||||
}
|
||||
for c in &s.commands {
|
||||
log.push(format!("$ {c}"));
|
||||
}
|
||||
StepResult { describe: s.describe.clone(), ok: true, log }
|
||||
})
|
||||
.collect();
|
||||
ApplyReport { results }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use matilda_apply::FileWrite;
|
||||
|
||||
/// Paso que escribe un archivo temporal y corre un comando.
|
||||
fn step(describe: &str, file: Option<FileWrite>, cmds: &[&str]) -> ApplyStep {
|
||||
ApplyStep {
|
||||
describe: describe.into(),
|
||||
files: file.into_iter().collect(),
|
||||
commands: cmds.iter().map(|s| s.to_string()).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn temp(name: &str) -> String {
|
||||
std::env::temp_dir()
|
||||
.join(format!("matilda-ghost-{}-{name}", std::process::id()))
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dry_run_touches_nothing() {
|
||||
let path = temp("dry");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let steps = vec![step(
|
||||
"crear x",
|
||||
Some(FileWrite { path: path.clone(), content: "hola".into() }),
|
||||
&["echo hecho"],
|
||||
)];
|
||||
let report = dry_run(&steps);
|
||||
assert!(report.all_ok());
|
||||
assert_eq!(report.results.len(), 1);
|
||||
// dry_run no escribió el archivo.
|
||||
assert!(!std::path::Path::new(&path).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_writes_files_and_runs_commands() {
|
||||
let path = temp("apply");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let steps = vec![step(
|
||||
"crear config",
|
||||
Some(FileWrite { path: path.clone(), content: "contenido".into() }),
|
||||
&["echo aplicado"],
|
||||
)];
|
||||
let report = apply(&steps);
|
||||
assert!(report.all_ok());
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), "contenido");
|
||||
assert!(report.results[0].log.iter().any(|l| l.contains("aplicado")));
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_stops_at_the_first_failure() {
|
||||
let steps = vec![
|
||||
step("ok", None, &["true"]),
|
||||
step("falla", None, &["exit 7"]),
|
||||
step("nunca", None, &["echo no-deberia-correr"]),
|
||||
];
|
||||
let report = apply(&steps);
|
||||
// El tercer paso no se ejecutó.
|
||||
assert_eq!(report.results.len(), 2);
|
||||
assert!(!report.all_ok());
|
||||
assert_eq!(report.applied(), 1);
|
||||
assert!(report.failed().unwrap().describe.contains("falla"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonzero_exit_marks_the_step_failed() {
|
||||
let report = apply(&[step("test", None, &["false"])]);
|
||||
assert!(!report.results[0].ok);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_plan_applies_cleanly() {
|
||||
let report = apply(&[]);
|
||||
assert!(report.all_ok());
|
||||
assert_eq!(report.applied(), 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "matilda-linker"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
publish.workspace = true
|
||||
description = "matilda — el enlace SSH: conecta a un servidor y aplica los ApplySteps remotamente, escribiendo archivos y corriendo comandos sobre la conexión multiplexada."
|
||||
|
||||
[dependencies]
|
||||
matilda-apply = { path = "../matilda-apply" }
|
||||
matilda-ghost = { path = "../matilda-ghost" }
|
||||
brahman-ssh-multiplex = { path = "../../../protocol/brahman-ssh-multiplex" }
|
||||
@@ -0,0 +1,138 @@
|
||||
//! `matilda-linker` — el enlace SSH que aplica un plan en un servidor.
|
||||
//!
|
||||
//! El [`Linker`] conecta a un host vía `brahman-ssh-multiplex` y aplica
|
||||
//! los [`ApplyStep`]s **remotamente**: escribe los archivos (con un
|
||||
//! heredoc) y corre los comandos, cada uno sobre la conexión SSH
|
||||
//! multiplexada. Produce el mismo [`ApplyReport`] que `matilda-ghost`,
|
||||
//! así el consumidor no distingue aplicación local de remota.
|
||||
//!
|
||||
//! La prueba real necesita un servidor SSH — se hace fuera del unit
|
||||
//! test. Lo puro y testeable es la construcción del comando de escritura.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use matilda_apply::{ApplyStep, FileWrite};
|
||||
use matilda_ghost::{ApplyReport, StepResult};
|
||||
|
||||
pub use brahman_ssh_multiplex::{SshAuth, SshConfig, SshError};
|
||||
use brahman_ssh_multiplex::SshSession;
|
||||
|
||||
/// Marcador de heredoc para escribir archivos remotos.
|
||||
const HEREDOC: &str = "MATILDA_LINKER_EOF";
|
||||
|
||||
/// Comando de shell que escribe `f.content` en `f.path` del host remoto.
|
||||
fn file_write_command(f: &FileWrite) -> String {
|
||||
format!(
|
||||
"cat > '{}' <<'{HEREDOC}'\n{}\n{HEREDOC}",
|
||||
f.path, f.content
|
||||
)
|
||||
}
|
||||
|
||||
/// Enlace activo a un servidor: una sesión SSH multiplexada.
|
||||
pub struct Linker {
|
||||
session: SshSession,
|
||||
}
|
||||
|
||||
impl Linker {
|
||||
/// Conecta y autentica contra el host descrito por `config`.
|
||||
pub async fn connect(config: &SshConfig) -> Result<Linker, SshError> {
|
||||
Ok(Linker { session: SshSession::connect(config).await? })
|
||||
}
|
||||
|
||||
/// Aplica un paso en el host remoto: escribe sus archivos, corre sus
|
||||
/// comandos. Se detiene en el primer error.
|
||||
async fn apply_step(&self, step: &ApplyStep) -> StepResult {
|
||||
let mut log = Vec::new();
|
||||
let mut ok = true;
|
||||
|
||||
for f in &step.files {
|
||||
match self.session.exec(&file_write_command(f)).await {
|
||||
Ok(out) if out.exit_code == 0 => log.push(format!("✔ escrito {}", f.path)),
|
||||
Ok(out) => {
|
||||
log.push(format!(
|
||||
"✘ escribir {}: {}",
|
||||
f.path,
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
));
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
log.push(format!("✘ {e}"));
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ok {
|
||||
for cmd in &step.commands {
|
||||
log.push(format!("$ {cmd}"));
|
||||
match self.session.exec(cmd).await {
|
||||
Ok(out) => {
|
||||
for l in String::from_utf8_lossy(&out.stdout).lines() {
|
||||
log.push(format!(" {l}"));
|
||||
}
|
||||
for l in String::from_utf8_lossy(&out.stderr).lines() {
|
||||
log.push(format!(" {l}"));
|
||||
}
|
||||
if out.exit_code != 0 {
|
||||
log.push(format!("✘ el comando salió con código {}", out.exit_code));
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log.push(format!("✘ no se pudo ejecutar: {e}"));
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
StepResult { describe: step.describe.clone(), ok, log }
|
||||
}
|
||||
|
||||
/// Aplica los pasos en orden sobre el host remoto. Se detiene en el
|
||||
/// primero que falle (semántica `set -e`).
|
||||
pub async fn apply(&self, steps: &[ApplyStep]) -> ApplyReport {
|
||||
let mut results = Vec::new();
|
||||
for step in steps {
|
||||
let result = self.apply_step(step).await;
|
||||
let failed = !result.ok;
|
||||
results.push(result);
|
||||
if failed {
|
||||
break;
|
||||
}
|
||||
}
|
||||
ApplyReport { results }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn file_write_command_uses_a_heredoc() {
|
||||
let f = FileWrite {
|
||||
path: "/etc/nginx/sites-enabled/site.conf".into(),
|
||||
content: "server { listen 80; }".into(),
|
||||
};
|
||||
let cmd = file_write_command(&f);
|
||||
assert!(cmd.starts_with("cat > '/etc/nginx/sites-enabled/site.conf' <<'"));
|
||||
assert!(cmd.contains("server { listen 80; }"));
|
||||
assert!(cmd.ends_with(HEREDOC));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssh_config_is_re_exported() {
|
||||
// El consumidor arma la conexión sin depender de ssh-multiplex.
|
||||
let c = SshConfig::new("srv.example", "deploy", SshAuth::Password("x".into()));
|
||||
assert_eq!(c.host, "srv.example");
|
||||
}
|
||||
|
||||
// La aplicación remota real (`Linker::connect` + `apply`) necesita un
|
||||
// servidor SSH — se prueba fuera del unit test.
|
||||
}
|
||||
Reference in New Issue
Block a user