⎇ b7181da7d9c3c0bd87e01d3ac42a0325c5b14d96
crates/modules/matilda/matilda-discover/src/lib.rs
History
Who touched what
raw
feat(matilda): detección de drift con docker inspect
1//! `matilda-discover` — qué hay realmente en el servidor.
2//!
3//! Para reconciliar de verdad hace falta saber el estado *actual*: qué
4//! contenedores y vhosts existen. Este crate lo observa y lo reconstruye
5//! como un [`Inventory`] que `matilda-plan` puede diferenciar contra el
6//! deseado.
7//!
8//! Alcance v1: descubre por **nombre**. Detecta correctamente lo que hay
9//! que **crear** y lo que hay que **eliminar** (huérfanos). No detecta
10//! cambios de configuración de un recurso existente — eso necesita
11//! inspección detallada (`docker inspect`), aún no implementada; un
12//! recurso presente y deseado se asume sin cambios.
13//!
14//! El parseo es puro y testeable; sólo [`discover_local`] toca el sistema.
15
16#![forbid(unsafe_code)]
17
18use matilda_core::{Container, Inventory, VHost};
19use serde::{Deserialize, Serialize};
20
21/// El estado observado de un servidor — los nombres de lo que existe.
22#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
23pub struct ServerState {
24 /// Nombres de los contenedores presentes.
25 pub containers: Vec<String>,
26 /// Dominios de los vhosts presentes.
27 pub vhosts: Vec<String>,
28}
29
30/// Parsea la salida de `docker ps -a --format '{{.Names}}'` — un nombre
31/// por línea.
32pub fn parse_docker_names(text: &str) -> Vec<String> {
33 text.lines()
34 .map(str::trim)
35 .filter(|l| !l.is_empty())
36 .map(str::to_string)
37 .collect()
38}
39
40/// Parsea un listado de `/etc/nginx/sites-enabled` — un archivo por
41/// línea; el sufijo `.conf` se quita para quedarse con el dominio.
42pub fn parse_nginx_sites(text: &str) -> Vec<String> {
43 text.lines()
44 .map(str::trim)
45 .filter(|l| !l.is_empty())
46 .map(|l| l.strip_suffix(".conf").unwrap_or(l).to_string())
47 .collect()
48}
49
50/// Reconstruye el inventario "actual" a partir de los nombres observados.
51///
52/// Un recurso presente que también está en `desired` se copia de ahí —
53/// así el `plan` no marca cambios espurios (la detección real de drift
54/// necesita inspección detallada). Un recurso presente que **no** está
55/// en `desired` entra como un marcador, y el `plan` lo verá como un
56/// `Remove`.
57pub fn observed_inventory(state: &ServerState, desired: &Inventory) -> Inventory {
58 let mut inv = Inventory::new();
59 for name in &state.containers {
60 match desired.container(name) {
61 Some(c) => inv.add_container(c.clone()),
62 None => inv.add_container(Container::new(name, "(desconocido)")),
63 }
64 }
65 for domain in &state.vhosts {
66 match desired.vhost(domain) {
67 Some(v) => inv.add_vhost(v.clone()),
68 None => inv.add_vhost(VHost::to_address(domain, "(desconocido)")),
69 }
70 }
71 inv
72}
73
74/// Ejecuta un comando local y devuelve su stdout, o `None` si falla.
75fn run_local(program: &str, args: &[&str]) -> Option<String> {
76 let out = std::process::Command::new(program).args(args).output().ok()?;
77 out.status
78 .success()
79 .then(|| String::from_utf8_lossy(&out.stdout).into_owned())
80}
81
82// --- Detección de drift por `docker inspect` ----------------------------
83
84/// Subconjunto de la salida de `docker inspect` que importa para el drift.
85#[derive(Debug, Deserialize)]
86struct DockerInspect {
87 #[serde(rename = "Config")]
88 config: DockerConfig,
89 #[serde(rename = "HostConfig")]
90 host_config: DockerHostConfig,
91}
92
93#[derive(Debug, Deserialize)]
94struct DockerConfig {
95 #[serde(rename = "Image")]
96 image: String,
97 #[serde(default, rename = "Env")]
98 env: Vec<String>,
99}
100
101#[derive(Debug, Default, Deserialize)]
102struct DockerHostConfig {
103 #[serde(default, rename = "Binds")]
104 binds: Option<Vec<String>>,
105 #[serde(default, rename = "PortBindings")]
106 port_bindings: std::collections::HashMap<String, Option<Vec<PortBinding>>>,
107 #[serde(default, rename = "RestartPolicy")]
108 restart_policy: DockerRestart,
109}
110
111#[derive(Debug, Default, Deserialize)]
112struct DockerRestart {
113 #[serde(default, rename = "Name")]
114 name: String,
115}
116
117#[derive(Debug, Deserialize)]
118struct PortBinding {
119 #[serde(rename = "HostPort")]
120 host_port: String,
121}
122
123/// `true` si el contenedor que está corriendo **se desvió** de lo que
124/// declara `desired` — distinta imagen, puerto, env o volumen.
125///
126/// La comparación es por *satisfacción*: lo que el spec declara debe
127/// estar; lo extra que traiga la imagen (su `PATH`, etc.) se ignora.
128/// Si el JSON no se puede leer, se asume que no hay drift (no se marca
129/// un cambio espurio).
130pub fn container_drift(desired: &Container, inspect_json: &str) -> bool {
131 let parsed: Vec<DockerInspect> = match serde_json::from_str(inspect_json) {
132 Ok(v) => v,
133 Err(_) => return false,
134 };
135 let Some(d) = parsed.first() else {
136 return false;
137 };
138
139 // Imagen.
140 if d.config.image != desired.image {
141 return true;
142 }
143 // Política de reinicio (docker reporta "" cuando no hay → "no").
144 let actual = if d.host_config.restart_policy.name.is_empty() {
145 "no"
146 } else {
147 d.host_config.restart_policy.name.as_str()
148 };
149 if actual != desired.restart.docker_flag() {
150 return true;
151 }
152 // Cada puerto declarado debe estar publicado al host correcto.
153 for p in &desired.ports {
154 let key = format!("{}/tcp", p.container);
155 let published = d
156 .host_config
157 .port_bindings
158 .get(&key)
159 .and_then(|b| b.as_ref())
160 .map(|bs| bs.iter().any(|b| b.host_port == p.host.to_string()))
161 .unwrap_or(false);
162 if !published {
163 return true;
164 }
165 }
166 // Cada variable de entorno declarada debe estar presente.
167 for (k, v) in &desired.env {
168 let want = format!("{k}={v}");
169 if !d.config.env.iter().any(|e| e == &want) {
170 return true;
171 }
172 }
173 // Cada volumen declarado debe estar montado.
174 for (h, c) in &desired.volumes {
175 let want = format!("{h}:{c}");
176 if !d.host_config.binds.iter().flatten().any(|b| b.starts_with(&want)) {
177 return true;
178 }
179 }
180 false
181}
182
183/// Descubre el inventario actual **con detección de drift**: corre
184/// `docker inspect` en cada contenedor y, si se desvió del spec deseado,
185/// lo marca para que el `plan` emita un `Update`. Los contenedores al
186/// día se copian del deseado (sin cambio); los huérfanos quedan marcados
187/// para `Remove`. Los vhosts se descubren por nombre.
188pub fn discover_inventory(desired: &Inventory) -> Inventory {
189 let mut inv = Inventory::new();
190 let names = run_local("docker", &["ps", "-a", "--format", "{{.Names}}"])
191 .map(|t| parse_docker_names(&t))
192 .unwrap_or_default();
193 for name in names {
194 match desired.container(&name) {
195 Some(d) => {
196 let drifted = run_local("docker", &["inspect", &name])
197 .map(|json| container_drift(d, &json))
198 .unwrap_or(false);
199 if drifted {
200 // Marcador distinto del deseado → el plan verá `Update`.
201 inv.add_container(Container::new(&name, "(desviado)"));
202 } else {
203 inv.add_container(d.clone());
204 }
205 }
206 None => inv.add_container(Container::new(&name, "(huérfano)")),
207 }
208 }
209 for domain in run_local("ls", &["-1", "/etc/nginx/sites-enabled"])
210 .map(|t| parse_nginx_sites(&t))
211 .unwrap_or_default()
212 {
213 match desired.vhost(&domain) {
214 Some(v) => inv.add_vhost(v.clone()),
215 None => inv.add_vhost(VHost::to_address(&domain, "(huérfano)")),
216 }
217 }
218 inv
219}
220
221/// Observa el estado de *esta* máquina: `docker ps` + los sitios de
222/// nginx. Si docker no está o el directorio no existe, esa parte queda
223/// vacía (no es un error — quizá el servidor aún no tiene nada).
224pub fn discover_local() -> ServerState {
225 let containers = run_local("docker", &["ps", "-a", "--format", "{{.Names}}"])
226 .map(|t| parse_docker_names(&t))
227 .unwrap_or_default();
228 let vhosts = run_local("ls", &["-1", "/etc/nginx/sites-enabled"])
229 .map(|t| parse_nginx_sites(&t))
230 .unwrap_or_default();
231 ServerState { containers, vhosts }
232}
233
234#[cfg(test)]
235mod tests {
236 use super::*;
237 use matilda_plan::{plan, Op};
238
239 #[test]
240 fn parses_docker_names() {
241 let names = parse_docker_names("web\napi\n\n db \n");
242 assert_eq!(names, vec!["web", "api", "db"]);
243 }
244
245 #[test]
246 fn parses_nginx_sites_stripping_conf() {
247 let sites = parse_nginx_sites("sitio.com.conf\napi.sitio.com.conf\n");
248 assert_eq!(sites, vec!["sitio.com", "api.sitio.com"]);
249 }
250
251 #[test]
252 fn observed_present_and_desired_diffs_clean() {
253 // Un contenedor presente que también se desea → sin cambios.
254 let mut desired = Inventory::new();
255 desired.add_container(Container::new("web", "nginx:1.27"));
256 let state = ServerState { containers: vec!["web".into()], vhosts: vec![] };
257 let current = observed_inventory(&state, &desired);
258 let p = plan(¤t, &desired);
259 assert!(p.is_empty(), "presente y deseado → sin acciones");
260 }
261
262 #[test]
263 fn observed_orphan_becomes_a_removal() {
264 // Un contenedor presente que NO se desea → se elimina.
265 let desired = Inventory::new();
266 let state = ServerState { containers: vec!["viejo".into()], vhosts: vec![] };
267 let current = observed_inventory(&state, &desired);
268 let p = plan(¤t, &desired);
269 assert_eq!(p.count(Op::Remove), 1);
270 assert_eq!(p.actions[0].name, "viejo");
271 }
272
273 #[test]
274 fn missing_desired_resource_becomes_a_creation() {
275 let mut desired = Inventory::new();
276 desired.add_container(Container::new("nuevo", "img:1"));
277 // El servidor no tiene nada.
278 let current = observed_inventory(&ServerState::default(), &desired);
279 let p = plan(¤t, &desired);
280 assert_eq!(p.count(Op::Create), 1);
281 }
282
283 #[test]
284 fn create_and_remove_together() {
285 let mut desired = Inventory::new();
286 desired.add_container(Container::new("nuevo", "img:1"));
287 let state = ServerState { containers: vec!["viejo".into()], vhosts: vec![] };
288 let p = plan(&observed_inventory(&state, &desired), &desired);
289 assert_eq!(p.count(Op::Create), 1);
290 assert_eq!(p.count(Op::Remove), 1);
291 }
292
293 /// `docker inspect` de un `web` con nginx:1.27, 8080→80, un volumen,
294 /// la env TZ y reinicio `always`.
295 const INSPECT_WEB: &str = r#"[{
296 "Config": {
297 "Image": "nginx:1.27",
298 "Env": ["PATH=/usr/local/sbin", "TZ=UTC"]
299 },
300 "HostConfig": {
301 "Binds": ["/srv/web:/usr/share/nginx/html"],
302 "PortBindings": {"80/tcp": [{"HostPort": "8080"}]},
303 "RestartPolicy": {"Name": "always"}
304 }
305 }]"#;
306
307 fn web_spec() -> matilda_core::Container {
308 Container::new("web", "nginx:1.27")
309 .with_port(8080, 80)
310 .with_volume("/srv/web", "/usr/share/nginx/html")
311 .with_env("TZ", "UTC")
312 .with_restart(matilda_core::RestartPolicy::Always)
313 }
314
315 #[test]
316 fn no_drift_when_running_matches_the_spec() {
317 assert!(!container_drift(&web_spec(), INSPECT_WEB));
318 }
319
320 #[test]
321 fn drift_when_image_changed() {
322 let mut spec = web_spec();
323 spec.image = "nginx:1.25".into();
324 assert!(container_drift(&spec, INSPECT_WEB));
325 }
326
327 #[test]
328 fn drift_when_a_declared_port_is_missing() {
329 let spec = web_spec().with_port(9000, 9000);
330 assert!(container_drift(&spec, INSPECT_WEB));
331 }
332
333 #[test]
334 fn drift_when_a_declared_env_is_missing() {
335 let spec = web_spec().with_env("DEBUG", "1");
336 assert!(container_drift(&spec, INSPECT_WEB));
337 }
338
339 #[test]
340 fn drift_when_restart_policy_differs() {
341 let spec = web_spec().with_restart(matilda_core::RestartPolicy::No);
342 assert!(container_drift(&spec, INSPECT_WEB));
343 }
344
345 #[test]
346 fn unreadable_json_is_not_treated_as_drift() {
347 assert!(!container_drift(&web_spec(), "no es json"));
348 }
349}