gioser

tawasuyu/brahman

Rust 321TOML 122JSON 14Markdown 114306 Definiciones

feat(matilda): detección de drift con docker inspect

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 <noreply@anthropic.com>

b7181da7d9c3c0bd87e01d3ac42a0325c5b14d96 · por sergio · (hace 5 meses) · padre fdf820edb · ver el árbol en este commit

Delta semántico · 17

crates/apps/matilda/src/main.rs

crates/apps/shuma-shell/src/main.rs

crates/modules/matilda/matilda-discover/src/lib.rs

6 archivos · +228 −5

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<Inventory, String> {
     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(&current, &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<String> {
         .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<String>,
+}
+
+#[derive(Debug, Default, Deserialize)]
+struct DockerHostConfig {
+    #[serde(default, rename = "Binds")]
+    binds: Option<Vec<String>>,
+    #[serde(default, rename = "PortBindings")]
+    port_bindings: std::collections::HashMap<String, Option<Vec<PortBinding>>>,
+    #[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<DockerInspect> = 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.
+
+
+
+