gioser

tawasuyu/brahman

Rust 326TOML 123JSON 14Markdown 114554 Sutichaykuna

feat(matilda): matilda-discover — estado actual del servidor

Observa qué contenedores y vhosts existen (docker ps + sitios de
nginx) y reconstruye un Inventory "actual" que matilda-plan diferencia
contra el deseado: detecta correctamente qué crear y qué eliminar
(huérfanos). Parseo puro y testeable; sólo discover_local toca el
sistema. 6 tests.

La CLI gana el flag --discover en plan/script/apply: reconcilia
contra el estado real de la máquina en vez de partir de vacío.

matilda: 7 crates + CLI, ~48 tests. Pendiente: matilda-app (GPUI) y
la inspección detallada (docker inspect) para detectar drift de
configuración, no sólo presencia.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

93e003be0d06732c5eab3bd3467363a593803ba1 · ruwaq sergio · (5 killa ñawpaq) · tayta df8f92fbb · kay commitpi sach'ata qhaway

Semántico sayk'uy · 17

crates/apps/matilda/src/main.rs

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

6 qillqakuna · +210 −11

diff --git a/Cargo.lock b/Cargo.lock
index b5fb7f3..8d0d41d 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -7074,6 +7074,7 @@ dependencies = [
  "matilda-apply",
  "matilda-config",
  "matilda-core",
+ "matilda-discover",
  "matilda-ghost",
  "matilda-linker",
  "matilda-plan",
@@ -7105,6 +7106,15 @@ dependencies = [
  "serde",
 ]
 
+[[package]]
+name = "matilda-discover"
+version = "0.1.0"
+dependencies = [
+ "matilda-core",
+ "matilda-plan",
+ "serde",
+]
+
 [[package]]
 name = "matilda-ghost"
 version = "0.1.0"
diff --git a/Cargo.toml b/Cargo.toml
index 7a97481..12d1ea8 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -145,6 +145,7 @@ members = [
     "crates/modules/matilda/matilda-apply",
     "crates/modules/matilda/matilda-ghost",
     "crates/modules/matilda/matilda-linker",
+    "crates/modules/matilda/matilda-discover",
 
     # ============================================================
     # modules/yachay/ — Notebooks computacionales reproducibles
diff --git a/crates/apps/matilda/Cargo.toml b/crates/apps/matilda/Cargo.toml
index 7839c8f..18cdf44 100644
--- a/crates/apps/matilda/Cargo.toml
+++ b/crates/apps/matilda/Cargo.toml
@@ -19,6 +19,7 @@ matilda-plan = { path = "../../modules/matilda/matilda-plan" }
 matilda-apply = { path = "../../modules/matilda/matilda-apply" }
 matilda-ghost = { path = "../../modules/matilda/matilda-ghost" }
 matilda-linker = { path = "../../modules/matilda/matilda-linker" }
+matilda-discover = { path = "../../modules/matilda/matilda-discover" }
 clap = { workspace = true }
 serde_json = { workspace = true }
 tokio = { workspace = true }
diff --git a/crates/apps/matilda/src/main.rs b/crates/apps/matilda/src/main.rs
index 1a512f0..a7f2554 100644
--- a/crates/apps/matilda/src/main.rs
+++ b/crates/apps/matilda/src/main.rs
@@ -40,18 +40,26 @@ enum Cmd {
         /// Estado actual del servidor (por defecto: vacío).
         #[arg(long)]
         current: Option<PathBuf>,
+        /// Descubre el estado actual de esta máquina (docker + nginx).
+        #[arg(long)]
+        discover: bool,
     },
     /// Emite el script de shell que aplicaría el plan.
     Script {
         inventory: PathBuf,
         #[arg(long)]
         current: Option<PathBuf>,
+        #[arg(long)]
+        discover: bool,
     },
     /// Aplica el plan: local, en seco, o remoto por SSH.
     Apply {
         inventory: PathBuf,
         #[arg(long)]
         current: Option<PathBuf>,
+        /// Descubre el estado actual de esta máquina antes de reconciliar.
+        #[arg(long)]
+        discover: bool,
         /// Simula sin tocar nada.
         #[arg(long)]
         dry_run: bool,
@@ -71,11 +79,22 @@ fn load(path: &PathBuf) -> Result<Inventory, String> {
     serde_json::from_str(&text).map_err(|e| format!("JSON inválido en {}: {e}", path.display()))
 }
 
-/// Carga el inventario actual, o uno vacío si no se especificó.
-fn load_current(current: &Option<PathBuf>) -> Result<Inventory, String> {
-    match current {
-        Some(p) => load(p),
-        None => Ok(Inventory::new()),
+/// Resuelve el inventario "actual" contra el que reconciliar:
+/// `--discover` observa esta máquina; `--current` lee un archivo; si no,
+/// se parte de un inventario vacío (todo es creación).
+fn current_inventory(
+    discover: bool,
+    current: &Option<PathBuf>,
+    desired: &Inventory,
+) -> Result<Inventory, String> {
+    if discover {
+        let state = matilda_discover::discover_local();
+        Ok(matilda_discover::observed_inventory(&state, desired))
+    } else {
+        match current {
+            Some(p) => load(p),
+            None => Ok(Inventory::new()),
+        }
     }
 }
 
@@ -155,9 +174,9 @@ fn run() -> Result<(), String> {
             println!("{json}");
         }
 
-        Cmd::Plan { inventory, current } => {
+        Cmd::Plan { inventory, current, discover } => {
             let desired = load(&inventory)?;
-            let p = plan(&load_current(&current)?, &desired);
+            let p = plan(&current_inventory(discover, &current, &desired)?, &desired);
             if p.is_empty() {
                 println!("Sin cambios: el servidor ya está al día.");
             } else {
@@ -174,15 +193,15 @@ fn run() -> Result<(), String> {
             }
         }
 
-        Cmd::Script { inventory, current } => {
+        Cmd::Script { inventory, current, discover } => {
             let desired = load(&inventory)?;
-            let p = plan(&load_current(&current)?, &desired);
+            let p = plan(&current_inventory(discover, &current, &desired)?, &desired);
             print!("{}", steps_to_script(&plan_to_steps(&p, &desired)));
         }
 
-        Cmd::Apply { inventory, current, dry_run, host, password } => {
+        Cmd::Apply { inventory, current, discover, dry_run, host, password } => {
             let desired = load(&inventory)?;
-            let p = plan(&load_current(&current)?, &desired);
+            let p = plan(&current_inventory(discover, &current, &desired)?, &desired);
             let steps = plan_to_steps(&p, &desired);
             if steps.is_empty() {
                 println!("Sin cambios: nada que aplicar.");
diff --git a/crates/modules/matilda/matilda-discover/Cargo.toml b/crates/modules/matilda/matilda-discover/Cargo.toml
new file mode 100644
index 0000000..0c5f3ca
--- /dev/null
+++ b/crates/modules/matilda/matilda-discover/Cargo.toml
@@ -0,0 +1,15 @@
+[package]
+name = "matilda-discover"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "matilda — descubrimiento del estado actual de un servidor: qué contenedores y vhosts existen, para reconciliar contra el inventario deseado."
+
+[dependencies]
+matilda-core = { path = "../matilda-core" }
+serde = { 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
new file mode 100644
index 0000000..e5be0bd
--- /dev/null
+++ b/crates/modules/matilda/matilda-discover/src/lib.rs
@@ -0,0 +1,153 @@
+//! `matilda-discover` — qué hay realmente en el servidor.
+//!
+//! Para reconciliar de verdad hace falta saber el estado *actual*: qué
+//! contenedores y vhosts existen. Este crate lo observa y lo reconstruye
+//! como un [`Inventory`] que `matilda-plan` puede diferenciar contra el
+//! deseado.
+//!
+//! Alcance v1: descubre por **nombre**. Detecta correctamente lo que hay
+//! que **crear** y lo que hay que **eliminar** (huérfanos). No detecta
+//! cambios de configuración de un recurso existente — eso necesita
+//! inspección detallada (`docker inspect`), aún no implementada; un
+//! recurso presente y deseado se asume sin cambios.
+//!
+//! El parseo es puro y testeable; sólo [`discover_local`] toca el sistema.
+
+#![forbid(unsafe_code)]
+
+use matilda_core::{Container, Inventory, VHost};
+use serde::{Deserialize, Serialize};
+
+/// El estado observado de un servidor — los nombres de lo que existe.
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
+pub struct ServerState {
+    /// Nombres de los contenedores presentes.
+    pub containers: Vec<String>,
+    /// Dominios de los vhosts presentes.
+    pub vhosts: Vec<String>,
+}
+
+/// Parsea la salida de `docker ps -a --format '{{.Names}}'` — un nombre
+/// por línea.
+pub fn parse_docker_names(text: &str) -> Vec<String> {
+    text.lines()
+        .map(str::trim)
+        .filter(|l| !l.is_empty())
+        .map(str::to_string)
+        .collect()
+}
+
+/// Parsea un listado de `/etc/nginx/sites-enabled` — un archivo por
+/// línea; el sufijo `.conf` se quita para quedarse con el dominio.
+pub fn parse_nginx_sites(text: &str) -> Vec<String> {
+    text.lines()
+        .map(str::trim)
+        .filter(|l| !l.is_empty())
+        .map(|l| l.strip_suffix(".conf").unwrap_or(l).to_string())
+        .collect()
+}
+
+/// Reconstruye el inventario "actual" a partir de los nombres observados.
+///
+/// Un recurso presente que también está en `desired` se copia de ahí —
+/// así el `plan` no marca cambios espurios (la detección real de drift
+/// necesita inspección detallada). Un recurso presente que **no** está
+/// en `desired` entra como un marcador, y el `plan` lo verá como un
+/// `Remove`.
+pub fn observed_inventory(state: &ServerState, desired: &Inventory) -> Inventory {
+    let mut inv = Inventory::new();
+    for name in &state.containers {
+        match desired.container(name) {
+            Some(c) => inv.add_container(c.clone()),
+            None => inv.add_container(Container::new(name, "(desconocido)")),
+        }
+    }
+    for domain in &state.vhosts {
+        match desired.vhost(domain) {
+            Some(v) => inv.add_vhost(v.clone()),
+            None => inv.add_vhost(VHost::to_address(domain, "(desconocido)")),
+        }
+    }
+    inv
+}
+
+/// Ejecuta un comando local y devuelve su stdout, o `None` si falla.
+fn run_local(program: &str, args: &[&str]) -> Option<String> {
+    let out = std::process::Command::new(program).args(args).output().ok()?;
+    out.status
+        .success()
+        .then(|| String::from_utf8_lossy(&out.stdout).into_owned())
+}
+
+/// 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).
+pub fn discover_local() -> ServerState {
+    let containers = run_local("docker", &["ps", "-a", "--format", "{{.Names}}"])
+        .map(|t| parse_docker_names(&t))
+        .unwrap_or_default();
+    let vhosts = run_local("ls", &["-1", "/etc/nginx/sites-enabled"])
+        .map(|t| parse_nginx_sites(&t))
+        .unwrap_or_default();
+    ServerState { containers, vhosts }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use matilda_plan::{plan, Op};
+
+    #[test]
+    fn parses_docker_names() {
+        let names = parse_docker_names("web\napi\n\n  db  \n");
+        assert_eq!(names, vec!["web", "api", "db"]);
+    }
+
+    #[test]
+    fn parses_nginx_sites_stripping_conf() {
+        let sites = parse_nginx_sites("sitio.com.conf\napi.sitio.com.conf\n");
+        assert_eq!(sites, vec!["sitio.com", "api.sitio.com"]);
+    }
+
+    #[test]
+    fn observed_present_and_desired_diffs_clean() {
+        // Un contenedor presente que también se desea → sin cambios.
+        let mut desired = Inventory::new();
+        desired.add_container(Container::new("web", "nginx:1.27"));
+        let state = ServerState { containers: vec!["web".into()], vhosts: vec![] };
+        let current = observed_inventory(&state, &desired);
+        let p = plan(&current, &desired);
+        assert!(p.is_empty(), "presente y deseado → sin acciones");
+    }
+
+    #[test]
+    fn observed_orphan_becomes_a_removal() {
+        // Un contenedor presente que NO se desea → se elimina.
+        let desired = Inventory::new();
+        let state = ServerState { containers: vec!["viejo".into()], vhosts: vec![] };
+        let current = observed_inventory(&state, &desired);
+        let p = plan(&current, &desired);
+        assert_eq!(p.count(Op::Remove), 1);
+        assert_eq!(p.actions[0].name, "viejo");
+    }
+
+    #[test]
+    fn missing_desired_resource_becomes_a_creation() {
+        let mut desired = Inventory::new();
+        desired.add_container(Container::new("nuevo", "img:1"));
+        // El servidor no tiene nada.
+        let current = observed_inventory(&ServerState::default(), &desired);
+        let p = plan(&current, &desired);
+        assert_eq!(p.count(Op::Create), 1);
+    }
+
+    #[test]
+    fn create_and_remove_together() {
+        let mut desired = Inventory::new();
+        desired.add_container(Container::new("nuevo", "img:1"));
+        let state = ServerState { containers: vec!["viejo".into()], vhosts: vec![] };
+        let p = plan(&observed_inventory(&state, &desired), &desired);
+        assert_eq!(p.count(Op::Create), 1);
+        assert_eq!(p.count(Op::Remove), 1);
+    }
+}