⎇ 5b9d8107fc41efc09344a70a7f0280ebc64b1e19
crates/modules/matilda/matilda-apply/src/lib.rs
Historia
Quién tocó qué
crudo
feat(matilda): matilda-apply — puente del plan a la ejecución
1//! `matilda-apply` — el puente entre el plan y la ejecución real.
2//!
3//! `matilda-plan` dice *qué* cambiar (una lista ordenada de `Action`s).
4//! Este crate dice *cómo*: traduce cada acción a un [`ApplyStep`]
5//! concreto — los archivos a escribir en el servidor y los comandos a
6//! correr, en orden.
7//!
8//! Sigue siendo **agnóstico de transporte**: no abre conexiones ni
9//! ejecuta nada. Aplicar los pasos —localmente, por SSH o vía el agente
10//! `matilda-ghost`— es trabajo de la capa de I/O. Aquí todo es una
11//! función pura y testeable.
12
13#![forbid(unsafe_code)]
14
15use matilda_config::{docker_run_command, nginx_server_block};
16use matilda_core::Inventory;
17use matilda_plan::{Op, Plan, Resource};
18use serde::{Deserialize, Serialize};
19
20/// Directorio donde matilda deja los `server` de nginx.
21const NGINX_SITES: &str = "/etc/nginx/sites-enabled";
22
23/// Un archivo a escribir en el servidor.
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25pub struct FileWrite {
26 pub path: String,
27 pub content: String,
28}
29
30/// Un paso de aplicación: la traducción concreta de una acción del plan.
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32pub struct ApplyStep {
33 /// Descripción legible de la acción de origen.
34 pub describe: String,
35 /// Archivos a escribir en el servidor (antes de los comandos).
36 pub files: Vec<FileWrite>,
37 /// Comandos de shell a ejecutar, en orden.
38 pub commands: Vec<String>,
39}
40
41/// Ruta del archivo `server` de un dominio.
42fn vhost_path(domain: &str) -> String {
43 format!("{NGINX_SITES}/{domain}.conf")
44}
45
46/// Traduce un plan a pasos concretos de aplicación.
47///
48/// Necesita el inventario **deseado** para conocer los detalles de cada
49/// recurso (imagen del contenedor, upstream del vhost). Las acciones
50/// sobre *hosts* no producen pasos: un host es a qué servidor conectarse,
51/// no algo que se "aplique" en él.
52pub fn plan_to_steps(plan: &Plan, desired: &Inventory) -> Vec<ApplyStep> {
53 let mut steps = Vec::new();
54 for action in &plan.actions {
55 let describe = action.describe();
56 let step = match (action.op, action.resource) {
57 // --- Contenedores ---
58 (Op::Create, Resource::Container) => desired
59 .container(&action.name)
60 .map(|c| ApplyStep {
61 describe,
62 files: Vec::new(),
63 commands: vec![docker_run_command(c)],
64 }),
65 (Op::Update, Resource::Container) => desired.container(&action.name).map(|c| {
66 ApplyStep {
67 describe,
68 files: Vec::new(),
69 // Recrear: quitar el viejo, lanzar el nuevo.
70 commands: vec![
71 format!("docker rm -f {}", action.name),
72 docker_run_command(c),
73 ],
74 }
75 }),
76 (Op::Remove, Resource::Container) => Some(ApplyStep {
77 describe,
78 files: Vec::new(),
79 commands: vec![format!("docker rm -f {}", action.name)],
80 }),
81
82 // --- VHosts ---
83 (Op::Create | Op::Update, Resource::VHost) => {
84 desired.vhost(&action.name).map(|v| ApplyStep {
85 describe,
86 files: vec![FileWrite {
87 path: vhost_path(&action.name),
88 content: nginx_server_block(v),
89 }],
90 commands: vec!["nginx -t && nginx -s reload".to_string()],
91 })
92 }
93 (Op::Remove, Resource::VHost) => Some(ApplyStep {
94 describe,
95 files: Vec::new(),
96 commands: vec![
97 format!("rm -f {}", vhost_path(&action.name)),
98 "nginx -t && nginx -s reload".to_string(),
99 ],
100 }),
101
102 // --- Hosts: no se "aplican" (son destino de conexión) ---
103 (_, Resource::Host) => None,
104 };
105 if let Some(step) = step {
106 steps.push(step);
107 }
108 }
109 steps
110}
111
112/// Vuelca los pasos a un script de shell único — útil para revisarlo, o
113/// para ejecutarlo de un tirón en el servidor. Los archivos se emiten
114/// como heredocs.
115pub fn steps_to_script(steps: &[ApplyStep]) -> String {
116 let mut out = String::from("#!/usr/bin/env bash\nset -euo pipefail\n");
117 for step in steps {
118 out.push_str(&format!("\n# {}\n", step.describe));
119 for f in &step.files {
120 out.push_str(&format!("cat > {} <<'MATILDA_EOF'\n", f.path));
121 out.push_str(&f.content);
122 if !f.content.ends_with('\n') {
123 out.push('\n');
124 }
125 out.push_str("MATILDA_EOF\n");
126 }
127 for cmd in &step.commands {
128 out.push_str(cmd);
129 out.push('\n');
130 }
131 }
132 out
133}
134
135#[cfg(test)]
136mod tests {
137 use super::*;
138 use matilda_core::{Container, VHost};
139
140 fn desired() -> Inventory {
141 let mut inv = Inventory::new();
142 inv.add_container(Container::new("web", "nginx:1.27").with_port(8080, 80));
143 inv.add_vhost(VHost::to_container("site.com", "web", 8080));
144 inv
145 }
146
147 #[test]
148 fn fresh_inventory_produces_create_steps() {
149 let steps = plan_to_steps(&matilda_plan::plan(&Inventory::new(), &desired()), &desired());
150 assert_eq!(steps.len(), 2); // un contenedor + un vhost
151 // El contenedor se crea con `docker run`.
152 assert!(steps[0].commands[0].starts_with("docker run -d --name web"));
153 // El vhost escribe su archivo y recarga nginx.
154 assert_eq!(steps[1].files.len(), 1);
155 assert!(steps[1].files[0].path.ends_with("site.com.conf"));
156 assert!(steps[1].commands[0].contains("nginx -s reload"));
157 }
158
159 #[test]
160 fn update_recreates_the_container() {
161 let mut current = Inventory::new();
162 current.add_container(Container::new("web", "nginx:1.25"));
163 let steps = plan_to_steps(&matilda_plan::plan(¤t, &desired()), &desired());
164 let cont = steps.iter().find(|s| s.describe.contains("contenedor")).unwrap();
165 assert_eq!(cont.commands[0], "docker rm -f web");
166 assert!(cont.commands[1].starts_with("docker run"));
167 }
168
169 #[test]
170 fn removal_steps_clean_up() {
171 let mut current = Inventory::new();
172 current.add_container(Container::new("viejo", "img"));
173 current.add_vhost(VHost::to_address("viejo.com", "1.2.3.4:80"));
174 let steps = plan_to_steps(&matilda_plan::plan(¤t, &Inventory::new()), &Inventory::new());
175 let cmds: Vec<&str> = steps
176 .iter()
177 .flat_map(|s| s.commands.iter())
178 .map(|s| s.as_str())
179 .collect();
180 assert!(cmds.iter().any(|c| c.contains("docker rm -f viejo")));
181 assert!(cmds.iter().any(|c| c.contains("rm -f") && c.contains("viejo.com")));
182 }
183
184 #[test]
185 fn host_actions_produce_no_steps() {
186 let mut desired = Inventory::new();
187 desired.add_host(matilda_core::Host::new("edge", "10.0.0.1"));
188 let steps = plan_to_steps(&matilda_plan::plan(&Inventory::new(), &desired), &desired);
189 assert!(steps.is_empty());
190 }
191
192 #[test]
193 fn empty_plan_yields_no_steps() {
194 let inv = desired();
195 let steps = plan_to_steps(&matilda_plan::plan(&inv, &inv.clone()), &inv);
196 assert!(steps.is_empty());
197 }
198
199 #[test]
200 fn script_emits_heredocs_and_commands() {
201 let steps = plan_to_steps(&matilda_plan::plan(&Inventory::new(), &desired()), &desired());
202 let script = steps_to_script(&steps);
203 assert!(script.starts_with("#!/usr/bin/env bash"));
204 assert!(script.contains("docker run -d --name web"));
205 assert!(script.contains("cat > /etc/nginx/sites-enabled/site.com.conf <<'MATILDA_EOF'"));
206 assert!(script.contains("MATILDA_EOF"));
207 }
208}