tawasuyu

tawasuyu/brahman

Rust 582TOML 222Markdown 73JSON 198635 Definitions

⎇ 93e003be0d06732c5eab3bd3467363a593803ba1 crates/modules/matilda/matilda-discover/src/lib.rs Rust · 153 lines · 5.7 KB History Who touched what raw

feat(matilda): matilda-discover — estado actual del servidor 93e003be0 · 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/// Observa el estado de *esta* máquina: `docker ps` + los sitios de
83/// nginx. Si docker no está o el directorio no existe, esa parte queda
84/// vacía (no es un error — quizá el servidor aún no tiene nada).
85pub fn discover_local() -> ServerState {
86    let containers = run_local("docker", &["ps", "-a", "--format", "{{.Names}}"])
87        .map(|t| parse_docker_names(&t))
88        .unwrap_or_default();
89    let vhosts = run_local("ls", &["-1", "/etc/nginx/sites-enabled"])
90        .map(|t| parse_nginx_sites(&t))
91        .unwrap_or_default();
92    ServerState { containers, vhosts }
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98    use matilda_plan::{plan, Op};
99
100    #[test]
101    fn parses_docker_names() {
102        let names = parse_docker_names("web\napi\n\n  db  \n");
103        assert_eq!(names, vec!["web", "api", "db"]);
104    }
105
106    #[test]
107    fn parses_nginx_sites_stripping_conf() {
108        let sites = parse_nginx_sites("sitio.com.conf\napi.sitio.com.conf\n");
109        assert_eq!(sites, vec!["sitio.com", "api.sitio.com"]);
110    }
111
112    #[test]
113    fn observed_present_and_desired_diffs_clean() {
114        // Un contenedor presente que también se desea → sin cambios.
115        let mut desired = Inventory::new();
116        desired.add_container(Container::new("web", "nginx:1.27"));
117        let state = ServerState { containers: vec!["web".into()], vhosts: vec![] };
118        let current = observed_inventory(&state, &desired);
119        let p = plan(&current, &desired);
120        assert!(p.is_empty(), "presente y deseado → sin acciones");
121    }
122
123    #[test]
124    fn observed_orphan_becomes_a_removal() {
125        // Un contenedor presente que NO se desea → se elimina.
126        let desired = Inventory::new();
127        let state = ServerState { containers: vec!["viejo".into()], vhosts: vec![] };
128        let current = observed_inventory(&state, &desired);
129        let p = plan(&current, &desired);
130        assert_eq!(p.count(Op::Remove), 1);
131        assert_eq!(p.actions[0].name, "viejo");
132    }
133
134    #[test]
135    fn missing_desired_resource_becomes_a_creation() {
136        let mut desired = Inventory::new();
137        desired.add_container(Container::new("nuevo", "img:1"));
138        // El servidor no tiene nada.
139        let current = observed_inventory(&ServerState::default(), &desired);
140        let p = plan(&current, &desired);
141        assert_eq!(p.count(Op::Create), 1);
142    }
143
144    #[test]
145    fn create_and_remove_together() {
146        let mut desired = Inventory::new();
147        desired.add_container(Container::new("nuevo", "img:1"));
148        let state = ServerState { containers: vec!["viejo".into()], vhosts: vec![] };
149        let p = plan(&observed_inventory(&state, &desired), &desired);
150        assert_eq!(p.count(Op::Create), 1);
151        assert_eq!(p.count(Op::Remove), 1);
152    }
153}