tawasuyu

tawasuyu/brahman

Rust 492TOML 194Markdown 53JSON 176939 Definiciones

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>

df8f92fbb0d4c9cfc377c810341794766db51094 · por sergio · (hace 5 meses) · padre 5b9d8107f · ver el árbol en este commit

Delta semántico · 38

crates/apps/matilda/src/main.rs

crates/modules/matilda/matilda-ghost/src/lib.rs

crates/modules/matilda/matilda-linker/src/lib.rs

11 archivos · +683 −86

diff --git a/.vamos.txt.kate-swp b/.vamos.txt.kate-swp
deleted file mode 100644
index 65b49be..0000000
Binary files a/.vamos.txt.kate-swp and /dev/null differ
diff --git a/Cargo.lock b/Cargo.lock
index 45a595d..b5fb7f3 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -7070,10 +7070,15 @@ checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
 name = "matilda"
 version = "0.1.0"
 dependencies = [
+ "clap",
  "matilda-apply",
  "matilda-config",
  "matilda-core",
+ "matilda-ghost",
+ "matilda-linker",
  "matilda-plan",
+ "serde_json",
+ "tokio",
 ]
 
 [[package]]
@@ -7100,6 +7105,23 @@ dependencies = [
  "serde",
 ]
 
+[[package]]
+name = "matilda-ghost"
+version = "0.1.0"
+dependencies = [
+ "matilda-apply",
+ "serde",
+]
+
+[[package]]
+name = "matilda-linker"
+version = "0.1.0"
+dependencies = [
+ "brahman-ssh-multiplex",
+ "matilda-apply",
+ "matilda-ghost",
+]
+
 [[package]]
 name = "matilda-plan"
 version = "0.1.0"
diff --git a/Cargo.toml b/Cargo.toml
index 299be98..7a97481 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -143,6 +143,8 @@ members = [
     "crates/modules/matilda/matilda-config",
     "crates/modules/matilda/matilda-plan",
     "crates/modules/matilda/matilda-apply",
+    "crates/modules/matilda/matilda-ghost",
+    "crates/modules/matilda/matilda-linker",
 
     # ============================================================
     # modules/yachay/ — Notebooks computacionales reproducibles
diff --git a/crates/apps/matilda/Cargo.toml b/crates/apps/matilda/Cargo.toml
index 3164966..7839c8f 100644
--- a/crates/apps/matilda/Cargo.toml
+++ b/crates/apps/matilda/Cargo.toml
@@ -6,7 +6,7 @@ rust-version.workspace = true
 license.workspace = true
 authors.workspace = true
 publish.workspace = true
-description = "matilda — demostración de administración de servidores: inventario declarativo, renderizado de docker-compose y nginx, y plan de reconciliación."
+description = "matilda — CLI de administración de servidores: carga un inventario, muestra el plan, emite el script y lo aplica (local, remoto por SSH, o en seco)."
 
 [[bin]]
 name = "matilda"
@@ -17,3 +17,8 @@ matilda-core = { path = "../../modules/matilda/matilda-core" }
 matilda-config = { path = "../../modules/matilda/matilda-config" }
 matilda-plan = { path = "../../modules/matilda/matilda-plan" }
 matilda-apply = { path = "../../modules/matilda/matilda-apply" }
+matilda-ghost = { path = "../../modules/matilda/matilda-ghost" }
+matilda-linker = { path = "../../modules/matilda/matilda-linker" }
+clap = { workspace = true }
+serde_json = { workspace = true }
+tokio = { workspace = true }
diff --git a/crates/apps/matilda/src/main.rs b/crates/apps/matilda/src/main.rs
index 8d0547e..1a512f0 100644
--- a/crates/apps/matilda/src/main.rs
+++ b/crates/apps/matilda/src/main.rs
@@ -1,20 +1,88 @@
-//! `matilda` — demostración de administración de servidores.
+//! `matilda` — CLI de administración de servidores.
 //!
-//! Declara un inventario *deseado*, renderiza su `docker-compose.yml` y
-//! su configuración nginx, y luego calcula el *plan* que lleva un
-//! servidor desde un estado actual distinto hasta el deseado.
+//! Carga un inventario declarativo (JSON), lo reconcilia contra el
+//! estado actual y aplica los cambios — localmente, en seco, o en un
+//! servidor remoto por SSH:
 //!
-//! Smoke test legible del módulo: `cargo run -p matilda`.
+//! ```text
+//!   matilda example                 imprime un inventario de ejemplo
+//!   matilda plan    inv.json        muestra el plan de reconciliación
+//!   matilda script  inv.json        emite el script de aplicación
+//!   matilda apply   inv.json        aplica localmente
+//!   matilda apply   inv.json --dry-run            simula
+//!   matilda apply   inv.json --host deploy@srv    aplica por SSH
+//! ```
 
+use std::path::PathBuf;
+use std::process::ExitCode;
+
+use clap::{Parser, Subcommand};
+use matilda_apply::{plan_to_steps, steps_to_script, ApplyStep};
 use matilda_core::{Container, Host, Inventory, RestartPolicy, VHost};
-use matilda_config::{compose_file, nginx_sites};
-use matilda_plan::plan;
+use matilda_ghost::ApplyReport;
+use matilda_linker::{Linker, SshAuth, SshConfig};
+use matilda_plan::{plan, Op};
+
+#[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<PathBuf>,
+    },
+    /// Emite el script de shell que aplicaría el plan.
+    Script {
+        inventory: PathBuf,
+        #[arg(long)]
+        current: Option<PathBuf>,
+    },
+    /// Aplica el plan: local, en seco, o remoto por SSH.
+    Apply {
+        inventory: PathBuf,
+        #[arg(long)]
+        current: Option<PathBuf>,
+        /// Simula sin tocar nada.
+        #[arg(long)]
+        dry_run: bool,
+        /// Aplica en un host remoto, `usuario@host`.
+        #[arg(long)]
+        host: Option<String>,
+        /// Contraseña SSH (si no se da, se usa la clave por defecto).
+        #[arg(long)]
+        password: Option<String>,
+    },
+}
+
+/// Carga un inventario JSON desde un archivo.
+fn load(path: &PathBuf) -> Result<Inventory, String> {
+    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()))
+}
 
-/// El inventario que queremos tener en el servidor.
-fn desired() -> Inventory {
+/// Carga el inventario actual, o uno vacío si no se especificó.
+fn load_current(current: &Option<PathBuf>) -> Result<Inventory, String> {
+    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<String>,
+    steps: &[ApplyStep],
+) -> Result<ApplyReport, String> {
+    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();
-
-    rule("docker-compose.yml (deseado)");
-    print!("{}", compose_file(&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("nginx — sites (deseado)");
-    print!("{}", nginx_sites(&desired));
+        Cmd::Plan { inventory, current } => {
+            let desired = load(&inventory)?;
+            let p = plan(&load_current(&current)?, &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("plan de reconciliación (actual → deseado)");
-    let current = current();
-    let p = plan(&current, &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::Script { inventory, current } => {
+            let desired = load(&inventory)?;
+            let p = plan(&load_current(&current)?, &desired);
+            print!("{}", steps_to_script(&plan_to_steps(&p, &desired)));
         }
-        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));
+        Cmd::Apply { inventory, current, dry_run, host, password } => {
+            let desired = load(&inventory)?;
+            let p = plan(&load_current(&current)?, &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());
+            }
+        }
     }
+    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.
 
-## Estado
+## 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).
 
-`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.
+## Estado
 
-**Pendiente** (la capa de I/O):
+`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—.
 
-| 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<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);
+    }
+}
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<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.
+}
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
+
+
+