From 5b9d8107fc41efc09344a70a7f0280ebc64b1e19 Mon Sep 17 00:00:00 2001 From: sergio Date: Wed, 20 May 2026 20:18:20 +0000 Subject: [PATCH] =?UTF-8?q?feat(matilda):=20matilda-apply=20=E2=80=94=20pu?= =?UTF-8?q?ente=20del=20plan=20a=20la=20ejecuci=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Traduce un Plan de reconciliación a ApplySteps concretos: por cada acción, los archivos a escribir en el servidor y los comandos a correr. Contenedores → docker rm/run; vhosts → archivo nginx + reload; hosts → sin pasos (son destino de conexión, no algo a aplicar). steps_to_script() emite un script bash único con heredocs. Sigue agnóstico de transporte — ejecutar los pasos (local, SSH o vía matilda-ghost) es la capa de I/O. La demo CLI ahora imprime el script. 6 tests; matilda llega de la declaración al script ejecutable. Co-Authored-By: Claude Opus 4.7 --- Cargo.lock | 11 + Cargo.toml | 1 + crates/apps/matilda/Cargo.toml | 1 + crates/apps/matilda/src/main.rs | 8 + crates/modules/matilda/SDD.md | 10 +- .../modules/matilda/matilda-apply/Cargo.toml | 14 ++ .../modules/matilda/matilda-apply/src/lib.rs | 208 ++++++++++++++++++ 7 files changed, 249 insertions(+), 4 deletions(-) create mode 100644 crates/modules/matilda/matilda-apply/Cargo.toml create mode 100644 crates/modules/matilda/matilda-apply/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index c0b9e30..45a595d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7070,11 +7070,22 @@ checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" name = "matilda" version = "0.1.0" dependencies = [ + "matilda-apply", "matilda-config", "matilda-core", "matilda-plan", ] +[[package]] +name = "matilda-apply" +version = "0.1.0" +dependencies = [ + "matilda-config", + "matilda-core", + "matilda-plan", + "serde", +] + [[package]] name = "matilda-config" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 62c0b69..299be98 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -142,6 +142,7 @@ members = [ "crates/modules/matilda/matilda-core", "crates/modules/matilda/matilda-config", "crates/modules/matilda/matilda-plan", + "crates/modules/matilda/matilda-apply", # ============================================================ # modules/yachay/ — Notebooks computacionales reproducibles diff --git a/crates/apps/matilda/Cargo.toml b/crates/apps/matilda/Cargo.toml index 3f68da1..3164966 100644 --- a/crates/apps/matilda/Cargo.toml +++ b/crates/apps/matilda/Cargo.toml @@ -16,3 +16,4 @@ path = "src/main.rs" 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" } diff --git a/crates/apps/matilda/src/main.rs b/crates/apps/matilda/src/main.rs index f4a0543..8d0547e 100644 --- a/crates/apps/matilda/src/main.rs +++ b/crates/apps/matilda/src/main.rs @@ -85,6 +85,14 @@ fn main() { ); } + 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)); + } + let broken = desired.broken_vhosts(); rule("consistencia"); if broken.is_empty() { diff --git a/crates/modules/matilda/SDD.md b/crates/modules/matilda/SDD.md index d5f3168..06bdaaa 100644 --- a/crates/modules/matilda/SDD.md +++ b/crates/modules/matilda/SDD.md @@ -12,6 +12,7 @@ reconcilia el estado actual con el deseado. | `matilda-core` | lib | Modelo: `Host`, `Container`, `VHost`, `Inventory` | | `matilda-config` | lib | Renderizado: `Container` → docker-compose / `docker run`; `VHost` → nginx | | `matilda-plan` | lib | Reconciliación: `plan(actual, deseado)` → lista ordenada de `Action`s | +| `matilda-apply` | lib | Puente plan→ejecución: `Action`s → `ApplyStep`s (archivos + comandos) y script de shell | App: `apps/matilda` — demo CLI (`cargo run -p matilda`). @@ -36,13 +37,14 @@ App: `apps/matilda` — demo CLI (`cargo run -p matilda`). ## Estado -`core` + `config` + `plan` implementados y verdes (29 tests) + demo CLI. +`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. -**Pendiente** (la capa de I/O, ~7 sub-crates del plan original): +**Pendiente** (la capa de I/O): | crate pendiente | rol | | ----------------- | ------------------------------------------------ | | `matilda-linker` | transporte SSH (sobre `transport-ssh-multiplex`) | -| `matilda-ghost` | agente remoto que aplica el plan en el servidor | -| `matilda-docker` | ejecución real de Docker vía Linker/Ghost | +| `matilda-ghost` | agente remoto que ejecuta los `ApplyStep`s | | `matilda-app` | frontend GPUI | diff --git a/crates/modules/matilda/matilda-apply/Cargo.toml b/crates/modules/matilda/matilda-apply/Cargo.toml new file mode 100644 index 0000000..b1f903d --- /dev/null +++ b/crates/modules/matilda/matilda-apply/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "matilda-apply" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "matilda — puente plan→ejecución: traduce un Plan de reconciliación a pasos concretos (archivos a escribir + comandos a correr) listos para aplicar en el servidor." + +[dependencies] +matilda-core = { path = "../matilda-core" } +matilda-plan = { path = "../matilda-plan" } +matilda-config = { path = "../matilda-config" } +serde = { workspace = true } diff --git a/crates/modules/matilda/matilda-apply/src/lib.rs b/crates/modules/matilda/matilda-apply/src/lib.rs new file mode 100644 index 0000000..353c08f --- /dev/null +++ b/crates/modules/matilda/matilda-apply/src/lib.rs @@ -0,0 +1,208 @@ +//! `matilda-apply` — el puente entre el plan y la ejecución real. +//! +//! `matilda-plan` dice *qué* cambiar (una lista ordenada de `Action`s). +//! Este crate dice *cómo*: traduce cada acción a un [`ApplyStep`] +//! concreto — los archivos a escribir en el servidor y los comandos a +//! correr, en orden. +//! +//! Sigue siendo **agnóstico de transporte**: no abre conexiones ni +//! ejecuta nada. Aplicar los pasos —localmente, por SSH o vía el agente +//! `matilda-ghost`— es trabajo de la capa de I/O. Aquí todo es una +//! función pura y testeable. + +#![forbid(unsafe_code)] + +use matilda_config::{docker_run_command, nginx_server_block}; +use matilda_core::Inventory; +use matilda_plan::{Op, Plan, Resource}; +use serde::{Deserialize, Serialize}; + +/// Directorio donde matilda deja los `server` de nginx. +const NGINX_SITES: &str = "/etc/nginx/sites-enabled"; + +/// Un archivo a escribir en el servidor. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FileWrite { + pub path: String, + pub content: String, +} + +/// Un paso de aplicación: la traducción concreta de una acción del plan. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ApplyStep { + /// Descripción legible de la acción de origen. + pub describe: String, + /// Archivos a escribir en el servidor (antes de los comandos). + pub files: Vec, + /// Comandos de shell a ejecutar, en orden. + pub commands: Vec, +} + +/// Ruta del archivo `server` de un dominio. +fn vhost_path(domain: &str) -> String { + format!("{NGINX_SITES}/{domain}.conf") +} + +/// Traduce un plan a pasos concretos de aplicación. +/// +/// Necesita el inventario **deseado** para conocer los detalles de cada +/// recurso (imagen del contenedor, upstream del vhost). Las acciones +/// sobre *hosts* no producen pasos: un host es a qué servidor conectarse, +/// no algo que se "aplique" en él. +pub fn plan_to_steps(plan: &Plan, desired: &Inventory) -> Vec { + let mut steps = Vec::new(); + for action in &plan.actions { + let describe = action.describe(); + let step = match (action.op, action.resource) { + // --- Contenedores --- + (Op::Create, Resource::Container) => desired + .container(&action.name) + .map(|c| ApplyStep { + describe, + files: Vec::new(), + commands: vec![docker_run_command(c)], + }), + (Op::Update, Resource::Container) => desired.container(&action.name).map(|c| { + ApplyStep { + describe, + files: Vec::new(), + // Recrear: quitar el viejo, lanzar el nuevo. + commands: vec![ + format!("docker rm -f {}", action.name), + docker_run_command(c), + ], + } + }), + (Op::Remove, Resource::Container) => Some(ApplyStep { + describe, + files: Vec::new(), + commands: vec![format!("docker rm -f {}", action.name)], + }), + + // --- VHosts --- + (Op::Create | Op::Update, Resource::VHost) => { + desired.vhost(&action.name).map(|v| ApplyStep { + describe, + files: vec![FileWrite { + path: vhost_path(&action.name), + content: nginx_server_block(v), + }], + commands: vec!["nginx -t && nginx -s reload".to_string()], + }) + } + (Op::Remove, Resource::VHost) => Some(ApplyStep { + describe, + files: Vec::new(), + commands: vec![ + format!("rm -f {}", vhost_path(&action.name)), + "nginx -t && nginx -s reload".to_string(), + ], + }), + + // --- Hosts: no se "aplican" (son destino de conexión) --- + (_, Resource::Host) => None, + }; + if let Some(step) = step { + steps.push(step); + } + } + steps +} + +/// Vuelca los pasos a un script de shell único — útil para revisarlo, o +/// para ejecutarlo de un tirón en el servidor. Los archivos se emiten +/// como heredocs. +pub fn steps_to_script(steps: &[ApplyStep]) -> String { + let mut out = String::from("#!/usr/bin/env bash\nset -euo pipefail\n"); + for step in steps { + out.push_str(&format!("\n# {}\n", step.describe)); + for f in &step.files { + out.push_str(&format!("cat > {} <<'MATILDA_EOF'\n", f.path)); + out.push_str(&f.content); + if !f.content.ends_with('\n') { + out.push('\n'); + } + out.push_str("MATILDA_EOF\n"); + } + for cmd in &step.commands { + out.push_str(cmd); + out.push('\n'); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use matilda_core::{Container, VHost}; + + fn desired() -> Inventory { + let mut inv = Inventory::new(); + inv.add_container(Container::new("web", "nginx:1.27").with_port(8080, 80)); + inv.add_vhost(VHost::to_container("site.com", "web", 8080)); + inv + } + + #[test] + fn fresh_inventory_produces_create_steps() { + let steps = plan_to_steps(&matilda_plan::plan(&Inventory::new(), &desired()), &desired()); + assert_eq!(steps.len(), 2); // un contenedor + un vhost + // El contenedor se crea con `docker run`. + assert!(steps[0].commands[0].starts_with("docker run -d --name web")); + // El vhost escribe su archivo y recarga nginx. + assert_eq!(steps[1].files.len(), 1); + assert!(steps[1].files[0].path.ends_with("site.com.conf")); + assert!(steps[1].commands[0].contains("nginx -s reload")); + } + + #[test] + fn update_recreates_the_container() { + let mut current = Inventory::new(); + current.add_container(Container::new("web", "nginx:1.25")); + let steps = plan_to_steps(&matilda_plan::plan(¤t, &desired()), &desired()); + let cont = steps.iter().find(|s| s.describe.contains("contenedor")).unwrap(); + assert_eq!(cont.commands[0], "docker rm -f web"); + assert!(cont.commands[1].starts_with("docker run")); + } + + #[test] + fn removal_steps_clean_up() { + let mut current = Inventory::new(); + current.add_container(Container::new("viejo", "img")); + current.add_vhost(VHost::to_address("viejo.com", "1.2.3.4:80")); + let steps = plan_to_steps(&matilda_plan::plan(¤t, &Inventory::new()), &Inventory::new()); + let cmds: Vec<&str> = steps + .iter() + .flat_map(|s| s.commands.iter()) + .map(|s| s.as_str()) + .collect(); + assert!(cmds.iter().any(|c| c.contains("docker rm -f viejo"))); + assert!(cmds.iter().any(|c| c.contains("rm -f") && c.contains("viejo.com"))); + } + + #[test] + fn host_actions_produce_no_steps() { + let mut desired = Inventory::new(); + desired.add_host(matilda_core::Host::new("edge", "10.0.0.1")); + let steps = plan_to_steps(&matilda_plan::plan(&Inventory::new(), &desired), &desired); + assert!(steps.is_empty()); + } + + #[test] + fn empty_plan_yields_no_steps() { + let inv = desired(); + let steps = plan_to_steps(&matilda_plan::plan(&inv, &inv.clone()), &inv); + assert!(steps.is_empty()); + } + + #[test] + fn script_emits_heredocs_and_commands() { + let steps = plan_to_steps(&matilda_plan::plan(&Inventory::new(), &desired()), &desired()); + let script = steps_to_script(&steps); + assert!(script.starts_with("#!/usr/bin/env bash")); + assert!(script.contains("docker run -d --name web")); + assert!(script.contains("cat > /etc/nginx/sites-enabled/site.com.conf <<'MATILDA_EOF'")); + assert!(script.contains("MATILDA_EOF")); + } +}