From b7181da7d9c3c0bd87e01d3ac42a0325c5b14d96 Mon Sep 17 00:00:00 2001 From: sergio Date: Wed, 20 May 2026 20:44:50 +0000 Subject: [PATCH] =?UTF-8?q?feat(matilda):=20detecci=C3=B3n=20de=20drift=20?= =?UTF-8?q?con=20docker=20inspect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit matilda-discover gana discover_inventory(): corre docker inspect en cada contenedor y compara contra el spec deseado — imagen, puertos, env y volúmenes declarados. Si el contenedor que corre se desvió, el plan emite un Update; si está al día, no hay acción. La comparación es por satisfacción (lo extra que trae la imagen se ignora). El CLI (--discover) y el shell (:matilda) ahora usan discover_inventory en vez del descubrimiento por nombre: detectan no sólo qué crear y eliminar, sino la deriva de configuración de lo que ya existe. container_drift es puro — 6 tests nuevos con JSON de docker inspect. Co-Authored-By: Claude Opus 4.7 --- Cargo.lock | 1 + crates/apps/matilda/src/main.rs | 4 +- crates/apps/shuma-shell/src/main.rs | 6 +- .../matilda/matilda-discover/Cargo.toml | 1 + .../matilda/matilda-discover/src/lib.rs | 196 ++++++++++++++++++ vamos.txt | 25 +++ 6 files changed, 228 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 76e9839..02fceaa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7113,6 +7113,7 @@ dependencies = [ "matilda-core", "matilda-plan", "serde", + "serde_json", ] [[package]] diff --git a/crates/apps/matilda/src/main.rs b/crates/apps/matilda/src/main.rs index a7f2554..80cd944 100644 --- a/crates/apps/matilda/src/main.rs +++ b/crates/apps/matilda/src/main.rs @@ -88,8 +88,8 @@ fn current_inventory( desired: &Inventory, ) -> Result { if discover { - let state = matilda_discover::discover_local(); - Ok(matilda_discover::observed_inventory(&state, desired)) + // Descubrimiento detallado: `docker inspect` detecta el drift. + Ok(matilda_discover::discover_inventory(desired)) } else { match current { Some(p) => load(p), diff --git a/crates/apps/shuma-shell/src/main.rs b/crates/apps/shuma-shell/src/main.rs index aa2361c..848f75f 100644 --- a/crates/apps/shuma-shell/src/main.rs +++ b/crates/apps/shuma-shell/src/main.rs @@ -737,9 +737,9 @@ impl Shell { return; } }; - // Reconcilia contra el estado observado de esta máquina. - let current = - matilda_discover::observed_inventory(&matilda_discover::discover_local(), &desired); + // Reconcilia contra el estado real de esta máquina — con + // detección de drift vía `docker inspect`. + let current = matilda_discover::discover_inventory(&desired); let p = matilda_plan::plan(¤t, &desired); match sub { diff --git a/crates/modules/matilda/matilda-discover/Cargo.toml b/crates/modules/matilda/matilda-discover/Cargo.toml index 0c5f3ca..f4e5297 100644 --- a/crates/modules/matilda/matilda-discover/Cargo.toml +++ b/crates/modules/matilda/matilda-discover/Cargo.toml @@ -10,6 +10,7 @@ description = "matilda — descubrimiento del estado actual de un servidor: qué [dependencies] matilda-core = { path = "../matilda-core" } serde = { workspace = true } +serde_json = { workspace = true } [dev-dependencies] matilda-plan = { path = "../matilda-plan" } diff --git a/crates/modules/matilda/matilda-discover/src/lib.rs b/crates/modules/matilda/matilda-discover/src/lib.rs index e5be0bd..4feb9d0 100644 --- a/crates/modules/matilda/matilda-discover/src/lib.rs +++ b/crates/modules/matilda/matilda-discover/src/lib.rs @@ -79,6 +79,145 @@ fn run_local(program: &str, args: &[&str]) -> Option { .then(|| String::from_utf8_lossy(&out.stdout).into_owned()) } +// --- Detección de drift por `docker inspect` ---------------------------- + +/// Subconjunto de la salida de `docker inspect` que importa para el drift. +#[derive(Debug, Deserialize)] +struct DockerInspect { + #[serde(rename = "Config")] + config: DockerConfig, + #[serde(rename = "HostConfig")] + host_config: DockerHostConfig, +} + +#[derive(Debug, Deserialize)] +struct DockerConfig { + #[serde(rename = "Image")] + image: String, + #[serde(default, rename = "Env")] + env: Vec, +} + +#[derive(Debug, Default, Deserialize)] +struct DockerHostConfig { + #[serde(default, rename = "Binds")] + binds: Option>, + #[serde(default, rename = "PortBindings")] + port_bindings: std::collections::HashMap>>, + #[serde(default, rename = "RestartPolicy")] + restart_policy: DockerRestart, +} + +#[derive(Debug, Default, Deserialize)] +struct DockerRestart { + #[serde(default, rename = "Name")] + name: String, +} + +#[derive(Debug, Deserialize)] +struct PortBinding { + #[serde(rename = "HostPort")] + host_port: String, +} + +/// `true` si el contenedor que está corriendo **se desvió** de lo que +/// declara `desired` — distinta imagen, puerto, env o volumen. +/// +/// La comparación es por *satisfacción*: lo que el spec declara debe +/// estar; lo extra que traiga la imagen (su `PATH`, etc.) se ignora. +/// Si el JSON no se puede leer, se asume que no hay drift (no se marca +/// un cambio espurio). +pub fn container_drift(desired: &Container, inspect_json: &str) -> bool { + let parsed: Vec = match serde_json::from_str(inspect_json) { + Ok(v) => v, + Err(_) => return false, + }; + let Some(d) = parsed.first() else { + return false; + }; + + // Imagen. + if d.config.image != desired.image { + return true; + } + // Política de reinicio (docker reporta "" cuando no hay → "no"). + let actual = if d.host_config.restart_policy.name.is_empty() { + "no" + } else { + d.host_config.restart_policy.name.as_str() + }; + if actual != desired.restart.docker_flag() { + return true; + } + // Cada puerto declarado debe estar publicado al host correcto. + for p in &desired.ports { + let key = format!("{}/tcp", p.container); + let published = d + .host_config + .port_bindings + .get(&key) + .and_then(|b| b.as_ref()) + .map(|bs| bs.iter().any(|b| b.host_port == p.host.to_string())) + .unwrap_or(false); + if !published { + return true; + } + } + // Cada variable de entorno declarada debe estar presente. + for (k, v) in &desired.env { + let want = format!("{k}={v}"); + if !d.config.env.iter().any(|e| e == &want) { + return true; + } + } + // Cada volumen declarado debe estar montado. + for (h, c) in &desired.volumes { + let want = format!("{h}:{c}"); + if !d.host_config.binds.iter().flatten().any(|b| b.starts_with(&want)) { + return true; + } + } + false +} + +/// Descubre el inventario actual **con detección de drift**: corre +/// `docker inspect` en cada contenedor y, si se desvió del spec deseado, +/// lo marca para que el `plan` emita un `Update`. Los contenedores al +/// día se copian del deseado (sin cambio); los huérfanos quedan marcados +/// para `Remove`. Los vhosts se descubren por nombre. +pub fn discover_inventory(desired: &Inventory) -> Inventory { + let mut inv = Inventory::new(); + let names = run_local("docker", &["ps", "-a", "--format", "{{.Names}}"]) + .map(|t| parse_docker_names(&t)) + .unwrap_or_default(); + for name in names { + match desired.container(&name) { + Some(d) => { + let drifted = run_local("docker", &["inspect", &name]) + .map(|json| container_drift(d, &json)) + .unwrap_or(false); + if drifted { + // Marcador distinto del deseado → el plan verá `Update`. + inv.add_container(Container::new(&name, "(desviado)")); + } else { + inv.add_container(d.clone()); + } + } + None => inv.add_container(Container::new(&name, "(huérfano)")), + } + } + for domain in run_local("ls", &["-1", "/etc/nginx/sites-enabled"]) + .map(|t| parse_nginx_sites(&t)) + .unwrap_or_default() + { + match desired.vhost(&domain) { + Some(v) => inv.add_vhost(v.clone()), + None => inv.add_vhost(VHost::to_address(&domain, "(huérfano)")), + } + } + inv +} + /// Observa el estado de *esta* máquina: `docker ps` + los sitios de /// nginx. Si docker no está o el directorio no existe, esa parte queda /// vacía (no es un error — quizá el servidor aún no tiene nada). @@ -150,4 +289,61 @@ mod tests { assert_eq!(p.count(Op::Create), 1); assert_eq!(p.count(Op::Remove), 1); } + + /// `docker inspect` de un `web` con nginx:1.27, 8080→80, un volumen, + /// la env TZ y reinicio `always`. + const INSPECT_WEB: &str = r#"[{ + "Config": { + "Image": "nginx:1.27", + "Env": ["PATH=/usr/local/sbin", "TZ=UTC"] + }, + "HostConfig": { + "Binds": ["/srv/web:/usr/share/nginx/html"], + "PortBindings": {"80/tcp": [{"HostPort": "8080"}]}, + "RestartPolicy": {"Name": "always"} + } + }]"#; + + fn web_spec() -> matilda_core::Container { + Container::new("web", "nginx:1.27") + .with_port(8080, 80) + .with_volume("/srv/web", "/usr/share/nginx/html") + .with_env("TZ", "UTC") + .with_restart(matilda_core::RestartPolicy::Always) + } + + #[test] + fn no_drift_when_running_matches_the_spec() { + assert!(!container_drift(&web_spec(), INSPECT_WEB)); + } + + #[test] + fn drift_when_image_changed() { + let mut spec = web_spec(); + spec.image = "nginx:1.25".into(); + assert!(container_drift(&spec, INSPECT_WEB)); + } + + #[test] + fn drift_when_a_declared_port_is_missing() { + let spec = web_spec().with_port(9000, 9000); + assert!(container_drift(&spec, INSPECT_WEB)); + } + + #[test] + fn drift_when_a_declared_env_is_missing() { + let spec = web_spec().with_env("DEBUG", "1"); + assert!(container_drift(&spec, INSPECT_WEB)); + } + + #[test] + fn drift_when_restart_policy_differs() { + let spec = web_spec().with_restart(matilda_core::RestartPolicy::No); + assert!(container_drift(&spec, INSPECT_WEB)); + } + + #[test] + fn unreadable_json_is_not_treated_as_drift() { + assert!(!container_drift(&web_spec(), "no es json")); + } } diff --git a/vamos.txt b/vamos.txt index 91933ed..1e31f34 100644 --- a/vamos.txt +++ b/vamos.txt @@ -961,3 +961,28 @@ + + 1 · El ejecutable + + apps/matilda ya es un ejecutable CLI completo: + cargo run -p matilda -- example > /tmp/inv.json + cargo run -p matilda -- plan /tmp/inv.json --discover + cargo run -p matilda -- apply /tmp/inv.json --host deploy@servidor + + 2 · Embebido en el shell — cargo run -p shuma-shell + + matilda ahora es una herramienta del shell, no una app aparte. Dentro de la ventana de shuma: + + :matilda plan /tmp/inv.json → tarjeta con el plan de reconciliación + :matilda script /tmp/inv.json → tarjeta con el script bash + :matilda apply /tmp/inv.json → ejecuta el script de verdad + + - Reconcilia contra el estado real de la máquina (lee tus contenedores docker + sitios nginx). + - plan/script aparecen como tarjetas sintéticas en el feed. + - apply corre el script como cualquier comando: salida en streaming, captura acotada, botón de matar, acordeón — todo el aparato del shell, + gratis. + - El panel [RUN] tiene ahora una sección [tools] con ⚙ matilda — un clic precarga :matilda plan en el input para que sólo nombres el archivo. + + + +