//! `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")); } }