gioser

tawasuyu/brahman

Rust 578TOML 219Markdown 68JSON 198491 Definitions

⎇ b7181da7d9c3c0bd87e01d3ac42a0325c5b14d96 crates/modules/matilda/matilda-discover/src/lib.rs Rust · 349 lines · 12.0 KB History Who touched what raw

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

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(&current, &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(&current, &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(&current, &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}