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>
This commit is contained in:
Generated
+1
@@ -7113,6 +7113,7 @@ dependencies = [
|
||||
"matilda-core",
|
||||
"matilda-plan",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user