From df8f92fbb0d4c9cfc377c810341794766db51094 Mon Sep 17 00:00:00 2001 From: sergio Date: Wed, 20 May 2026 20:27:28 +0000 Subject: [PATCH] =?UTF-8?q?feat(matilda):=20ghost=20+=20linker=20+=20CLI?= =?UTF-8?q?=20=E2=80=94=20el=20ciclo=20completo=20de=20aplicaci=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .vamos.txt.kate-swp | Bin 4988 -> 0 bytes Cargo.lock | 22 ++ Cargo.toml | 2 + crates/apps/matilda/Cargo.toml | 7 +- crates/apps/matilda/src/main.rs | 255 +++++++++++++----- crates/modules/matilda/SDD.md | 26 +- .../modules/matilda/matilda-ghost/Cargo.toml | 12 + .../modules/matilda/matilda-ghost/src/lib.rs | 222 +++++++++++++++ .../modules/matilda/matilda-linker/Cargo.toml | 13 + .../modules/matilda/matilda-linker/src/lib.rs | 138 ++++++++++ vamos.txt | 72 ++++- 11 files changed, 683 insertions(+), 86 deletions(-) delete mode 100644 .vamos.txt.kate-swp create mode 100644 crates/modules/matilda/matilda-ghost/Cargo.toml create mode 100644 crates/modules/matilda/matilda-ghost/src/lib.rs create mode 100644 crates/modules/matilda/matilda-linker/Cargo.toml create mode 100644 crates/modules/matilda/matilda-linker/src/lib.rs diff --git a/.vamos.txt.kate-swp b/.vamos.txt.kate-swp deleted file mode 100644 index 65b49be13550c50a58fa8ca8251abfcfd25815c0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4988 zcmZQzU=Z?7EJ;-eE>A2_aLdd|RWQ;sU|?VniM>(#@yrp+E%z+h?&_-_^i_QSZB1|x z0|Rpu0|YR+28V*UuECxR49t-X3=FOe3=E745aDRo;BW>8<_3^jkbEPE!N9;^prGKB zuaKOdS6ot5nwgTX@Tg&mf|)|@;ms+fIr+sp3WjD1$wi4Jsl^I;rKx54#UN8ansq^p zCXgv0;-T>=UjP=J6#rVSD@uqa8%&&|xsEX{?)NKtBD zN@|gAK~7?xjzV%`URh$XZhAp!rj9~lK>;`(jLkve4vLXSb6ORW^K%PwQcFAc_l@ux&=k~$*INp#b6cSCNH0n( z$bghppzw!dP(&D^L`h~!YF-IA8q!izQi~E(pwSD`0^fl)$0{6d>Rz z>4Ss}EJ~6RQ%XSr2rg(4QBsy!Qi%~M;3%;KsRps~4)03NNzGT#R!B+Bg_M0DDL95k z3CKRM#r+VAN23H5C7=KShs*>>$S|ZpBB3NPJF^lTjG$l!^K?OZ1uTR{fK(WnfCND- zSd^rsDwL$=7ad-ksE}J)oSB@M15$yg-$53FZJr3R84@Q`h-gl2Awmr$?H*j_;}WOD zE;PL}KxH~8Kq5elNuZDcHGw9B7z_*ysR{~)#zqQ|)-9xGp`%cepORm!P?lPhl3J{% zkepbQp0ALck(!*XpsQP+UzA;3keHkbkyl8~Q%DAtS;_gixtS$;3Wg@2rdV!fNwGpf zX>mqsVoH9o9>{8tjj15U6p+;*H%tXF7#J9=6chpyixL$IOTmU_rezkErWPxd9Nwj% zkyubrtl%CH>Iv=;<>f2nB!Uc1P00t@uL&{_WQ;k8F%4uM$l&Q91_J|wxdPPjMWuNP zx&;b|7N&xNvO;lYZfQcw4z2brElDg=C`vrMw(8Jsg_O+V0+1JT5*70EQW6zXQWcUj@(WAB0Rpli z0A$rns8t4NRuyNI<|gVEXQbxjC@3qW<|sgzDX9v^3RU@eiQvv4NV5UR<+DJhfQ*|B zVlXf;s46H#CW3+s9I}U3DpckxXgK?L>VOKyqSQQvq|DSja8QDDsDR9v1JVjIV=jon zz`&4=<|IU&2l9PbYF=tpX=+ZQ0;q>tl9^tbsF0`-S(=-eS(Iq4kd|4Lo2ZbMm{Xjn zP?VaSkyw?Ol4uQbQEEzNa!G!%LP36!LP37c;k6~1$%!Bbfb7izIba^t0WoL}K$P$x z2PBs!rlc0-D6uRxB{4;zI5qe1 z!aSIbAd8|Pz4HYiJ3*E&1Th#G82A+w!cvPe(=wA2lQR!*&I7p+B*zD0ECMM4sa_0X zFfcG^DJVFiCfn5H)Z)yNd~gZ}CE&8uL~#1H0_g{7QwQl^0@ZJc=2hfm3-T=fgbOmo z0%Y7$sBz(F#vvzMka3hGU636?Aj_6PEi*#13_0n7EyJF8LE4Q#fw3H94#>b2AO-^i zgOP%QOKNU@v4XP?qztG`1eXD2;P@yk1t({elnf43kam5LIV+*&grPYMIRS$lMj#27 zr552#s~{^vL3XWz+7*mu7jjw!*+ouj1qBYsd~j&5hFTPdW>IcpNoEeb0jaEznOByY zSCUwinXmAuZIVJtesXqdkuIpKoL`)(pskRXo|#vnpsfJvWrOleQEGC2UUFtmCL)=G ztcnJCZVl8<^nk2POwLGzRO|{M&*kNpq$cI(XIm-c7p0`;fs9SfN=+^WXB<$oDl Inventory { +#[derive(Parser)] +#[command(name = "matilda", about = "Administración declarativa de servidores")] +struct Cli { + #[command(subcommand)] + cmd: Cmd, +} + +#[derive(Subcommand)] +enum Cmd { + /// Imprime un inventario de ejemplo para editar. + Example, + /// Muestra el plan de reconciliación del inventario. + Plan { + inventory: PathBuf, + /// Estado actual del servidor (por defecto: vacío). + #[arg(long)] + current: Option, + }, + /// Emite el script de shell que aplicaría el plan. + Script { + inventory: PathBuf, + #[arg(long)] + current: Option, + }, + /// Aplica el plan: local, en seco, o remoto por SSH. + Apply { + inventory: PathBuf, + #[arg(long)] + current: Option, + /// Simula sin tocar nada. + #[arg(long)] + dry_run: bool, + /// Aplica en un host remoto, `usuario@host`. + #[arg(long)] + host: Option, + /// Contraseña SSH (si no se da, se usa la clave por defecto). + #[arg(long)] + password: Option, + }, +} + +/// Carga un inventario JSON desde un archivo. +fn load(path: &PathBuf) -> Result { + let text = std::fs::read_to_string(path) + .map_err(|e| format!("no se pudo leer {}: {e}", path.display()))?; + serde_json::from_str(&text).map_err(|e| format!("JSON inválido en {}: {e}", path.display())) +} + +/// Carga el inventario actual, o uno vacío si no se especificó. +fn load_current(current: &Option) -> Result { + match current { + Some(p) => load(p), + None => Ok(Inventory::new()), + } +} + +/// Construye un inventario de ejemplo. +fn example_inventory() -> Inventory { let mut inv = Inventory::new(); inv.add_host(Host::new("edge-1", "10.0.0.1").with_tag("prod")); - inv.add_container( Container::new("web", "nginx:1.27") .with_port(8080, 80) @@ -22,85 +90,130 @@ fn desired() -> Inventory { .with_restart(RestartPolicy::Always), ); inv.add_container( - Container::new("api", "ghcr.io/jls/api:2.4") + Container::new("api", "ghcr.io/ejemplo/api:1.0") .with_port(9000, 9000) .with_env("DATABASE_URL", "postgres://db/app") .with_restart(RestartPolicy::UnlessStopped), ); - inv.add_container( - Container::new("db", "postgres:16") - .with_env("POSTGRES_DB", "app") - .with_volume("/srv/pgdata", "/var/lib/postgresql/data") - .with_restart(RestartPolicy::Always), + inv.add_vhost( + VHost::to_container("sitio.com", "web", 80) + .with_alias("www.sitio.com") + .with_tls(), ); - - inv.add_vhost(VHost::to_container("jlsoltech.com", "web", 80).with_alias("www.jlsoltech.com").with_tls()); - inv.add_vhost(VHost::to_container("api.jlsoltech.com", "api", 9000).with_tls()); inv } -/// El estado en que está el servidor hoy: `web` con imagen vieja, sin -/// `api`, y un contenedor `legacy` que ya no se quiere. -fn current() -> Inventory { - let mut inv = Inventory::new(); - inv.add_host(Host::new("edge-1", "10.0.0.1").with_tag("prod")); - inv.add_container(Container::new("web", "nginx:1.25").with_port(8080, 80)); - inv.add_container(Container::new("db", "postgres:16") - .with_env("POSTGRES_DB", "app") - .with_volume("/srv/pgdata", "/var/lib/postgresql/data") - .with_restart(RestartPolicy::Always)); - inv.add_container(Container::new("legacy", "old/cgi:1")); - inv.add_vhost(VHost::to_container("jlsoltech.com", "web", 80)); - inv +/// Imprime un `ApplyReport` legible. +fn print_report(report: &ApplyReport) { + for r in &report.results { + println!("\n{} {}", if r.ok { "✔" } else { "✘" }, r.describe); + for l in &r.log { + println!(" {l}"); + } + } + println!( + "\n{} de {} pasos aplicados.", + report.applied(), + report.results.len() + ); + if !report.all_ok() { + println!("✘ se detuvo en el primer error."); + } } -fn rule(title: &str) { - println!("\n── {title} {}", "─".repeat(56usize.saturating_sub(title.len()))); +/// Aplica los pasos en un host remoto por SSH. +async fn apply_remote( + target: &str, + password: Option, + steps: &[ApplyStep], +) -> Result { + let (user, host) = target + .split_once('@') + .ok_or_else(|| format!("host inválido (esperaba usuario@host): {target}"))?; + let auth = match password { + Some(pw) => SshAuth::Password(pw), + None => { + let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into()); + SshAuth::Key { + path: PathBuf::from(format!("{home}/.ssh/id_ed25519")), + passphrase: None, + } + } + }; + let config = SshConfig::new(host, user, auth); + let linker = Linker::connect(&config) + .await + .map_err(|e| format!("conexión SSH: {e}"))?; + Ok(linker.apply(steps).await) } -fn main() { - let desired = desired(); +fn run() -> Result<(), String> { + match Cli::parse().cmd { + Cmd::Example => { + let json = serde_json::to_string_pretty(&example_inventory()) + .map_err(|e| e.to_string())?; + println!("{json}"); + } - rule("docker-compose.yml (deseado)"); - print!("{}", compose_file(&desired)); + Cmd::Plan { inventory, current } => { + let desired = load(&inventory)?; + let p = plan(&load_current(¤t)?, &desired); + if p.is_empty() { + println!("Sin cambios: el servidor ya está al día."); + } else { + for (i, action) in p.actions.iter().enumerate() { + println!("{:>2}. {}", i + 1, action.describe()); + } + println!( + "\n{} acciones — {} crear, {} actualizar, {} eliminar.", + p.len(), + p.count(Op::Create), + p.count(Op::Update), + p.count(Op::Remove), + ); + } + } - rule("nginx — sites (deseado)"); - print!("{}", nginx_sites(&desired)); + Cmd::Script { inventory, current } => { + let desired = load(&inventory)?; + let p = plan(&load_current(¤t)?, &desired); + print!("{}", steps_to_script(&plan_to_steps(&p, &desired))); + } - rule("plan de reconciliación (actual → deseado)"); - let current = current(); - let p = plan(¤t, &desired); - if p.is_empty() { - println!(" sin cambios: el servidor ya está al día."); - } else { - for (i, action) in p.actions.iter().enumerate() { - println!(" {:>2}. {}", i + 1, action.describe()); + Cmd::Apply { inventory, current, dry_run, host, password } => { + let desired = load(&inventory)?; + let p = plan(&load_current(¤t)?, &desired); + let steps = plan_to_steps(&p, &desired); + if steps.is_empty() { + println!("Sin cambios: nada que aplicar."); + return Ok(()); + } + let report = if dry_run { + println!("— simulación (no se toca nada) —"); + matilda_ghost::dry_run(&steps) + } else if let Some(target) = host { + println!("— aplicando en {target} por SSH —"); + let rt = tokio::runtime::Runtime::new().map_err(|e| e.to_string())?; + rt.block_on(apply_remote(&target, password, &steps))? + } else { + println!("— aplicando localmente —"); + matilda_ghost::apply(&steps) + }; + print_report(&report); + if !report.all_ok() { + return Err("la aplicación falló".into()); + } } - println!( - "\n {} acciones — {} crear, {} actualizar, {} eliminar.", - p.len(), - p.count(matilda_plan::Op::Create), - p.count(matilda_plan::Op::Update), - p.count(matilda_plan::Op::Remove), - ); } - - rule("script de aplicación (lo que correría en el servidor)"); - let steps = matilda_apply::plan_to_steps(&p, &desired); - if steps.is_empty() { - println!(" nada que aplicar."); - } else { - print!("{}", matilda_apply::steps_to_script(&steps)); - } + Ok(()) +} - let broken = desired.broken_vhosts(); - rule("consistencia"); - if broken.is_empty() { - println!(" todos los vhosts apuntan a contenedores existentes. ✔"); - } else { - for v in broken { - println!(" ✘ vhost «{}» apunta a un contenedor inexistente", v.domain); +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("error: {e}"); + ExitCode::FAILURE } } - println!(); } diff --git a/crates/modules/matilda/SDD.md b/crates/modules/matilda/SDD.md index 06bdaaa..2aee897 100644 --- a/crates/modules/matilda/SDD.md +++ b/crates/modules/matilda/SDD.md @@ -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. diff --git a/crates/modules/matilda/matilda-ghost/Cargo.toml b/crates/modules/matilda/matilda-ghost/Cargo.toml new file mode 100644 index 0000000..64fe4fa --- /dev/null +++ b/crates/modules/matilda/matilda-ghost/Cargo.toml @@ -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 } diff --git a/crates/modules/matilda/matilda-ghost/src/lib.rs b/crates/modules/matilda/matilda-ghost/src/lib.rs new file mode 100644 index 0000000..bc4d9db --- /dev/null +++ b/crates/modules/matilda/matilda-ghost/src/lib.rs @@ -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, +} + +/// 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, +} + +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)> { + 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, 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); + } +} diff --git a/crates/modules/matilda/matilda-linker/Cargo.toml b/crates/modules/matilda/matilda-linker/Cargo.toml new file mode 100644 index 0000000..9efd70f --- /dev/null +++ b/crates/modules/matilda/matilda-linker/Cargo.toml @@ -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" } diff --git a/crates/modules/matilda/matilda-linker/src/lib.rs b/crates/modules/matilda/matilda-linker/src/lib.rs new file mode 100644 index 0000000..7c22f2b --- /dev/null +++ b/crates/modules/matilda/matilda-linker/src/lib.rs @@ -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 { + 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. +} diff --git a/vamos.txt b/vamos.txt index 5dd87df..91933ed 100644 --- a/vamos.txt +++ b/vamos.txt @@ -855,10 +855,8 @@ -p sandokan-lifecycle -p arje-incarnate # Fase B — orquestador + discovery - cargo test -p sandokan-core -p sandokan-local -p sandokan-daemon -p - sandokan \ - -p brahman-dht -p brahman-card-discovery -p - brahman-ssh-multiplex + cargo test -p sandokan-core -p sandokan-local -p sandokan-daemon -p sandokan \ + -p brahman-dht -p brahman-card-discovery -p brahman-ssh-multiplex # Fase F — pineal (6 charts) cargo test -p pineal-render -p pineal-export -p pineal-heatmap \ @@ -897,3 +895,69 @@ + Lo construido — 6 módulos, 16 crates nuevos + + ┌───────────┬───────────────────────────────┬───────┬────────────────────────────────┐ + │ módulo │ crates │ tests │ estado │ + ├───────────┼───────────────────────────────┼───────┼────────────────────────────────┤ + │ dominium │ render-plan, canvas-gpui, app │ 37 │ ✅ completo │ + ├───────────┼───────────────────────────────┼───────┼────────────────────────────────┤ + │ verbo │ verbo-daemon │ 8 │ ✅ daemon entre-procesos │ + ├───────────┼───────────────────────────────┼───────┼────────────────────────────────┤ + │ fana │ render-plan, editor-gpui, app │ 12 │ ✅ editor DAG (prioridad alta) │ + ├───────────┼───────────────────────────────┼───────┼────────────────────────────────┤ + │ agorapura │ core, graph, app │ 22 │ ✅ identidad federada │ + ├───────────┼───────────────────────────────┼───────┼────────────────────────────────┤ + │ badu │ core, gravity, app │ 29 │ núcleo + demo │ + ├───────────┼───────────────────────────────┼───────┼────────────────────────────────┤ + │ takiy │ takiy-core │ 24 │ núcleo de teoría musical │ + └───────────┴───────────────────────────────┴───────┴────────────────────────────────┘ + + 132 tests nuevos, todos verdes. cargo check --workspace verde en cada commit. 14 commits pusheados. + + Para que verifiques tú (apps GPUI — no las puedo ver) + + cargo run -p dominium # simulador isométrico vivo + cargo run -p fana # editor DAG — «Mutar raíz» dispara la onda de choque + cargo run -p shuma-shell # el shell de 3 zonas + + Ya verifiqué yo (CLI, corren bien) + + cargo run -p agorapura # Venezuela atestigua a Yumaira; firma falsa rechazada; veredictos por política + cargo run -p badu # cuaderno: grafo de enlaces + clústeres por gravedad semántica + + + Verificación + + Apps GPUI — necesito que las veas tú: + cargo run -p dominium # simulador isométrico vivo + cargo run -p fana # editor DAG — «Mutar raíz» dispara la onda de choque + cargo run -p shuma-shell # el shell de 3 zonas + + Demos CLI — ya las verifiqué yo, corren bien: + cargo run -p agorapura # Venezuela atestigua a Yumaira; veredictos por política + cargo run -p badu # cuaderno: grafo de enlaces + clústeres por gravedad + cargo run -p matilda # inventario → docker-compose + nginx + plan de reconciliación + cargo run -p yachay # notebook: orden de ejecución + digest cambia al editar + + + + + matilda — el puente plan→ejecución + + Avancé el módulo de server admin con matilda-apply: traduce el Plan de reconciliación a pasos concretos —los archivos a escribir y los comandos a + correr en el servidor— y a un script bash listo para ejecutar. cargo run -p matilda ahora lo imprime: + + # crear contenedor «api» + docker run -d --name api --restart unless-stopped -p 9000:9000 ... + # actualizar contenedor «web» + docker rm -f web + docker run -d --name web ... + # crear vhost «api.jlsoltech.com» + cat > /etc/nginx/sites-enabled/api.jlsoltech.com.conf <<'MATILDA_EOF' + server { listen 443 ssl; ... } + MATILDA_EOF + nginx -t && nginx -s reload + + +