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, + /// 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, + #[arg(long)] + discover: bool, }, /// Aplica el plan: local, en seco, o remoto por SSH. Apply { inventory: PathBuf, #[arg(long)] current: Option, + /// 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 { 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) -> Result { - 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, + desired: &Inventory, +) -> Result { + 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(¤t)?, &desired); + let p = plan(¤t_inventory(discover, ¤t, &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(¤t)?, &desired); + let p = plan(¤t_inventory(discover, ¤t, &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(¤t)?, &desired); + let p = plan(¤t_inventory(discover, ¤t, &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, + /// Dominios de los vhosts presentes. + pub vhosts: Vec, +} + +/// Parsea la salida de `docker ps -a --format '{{.Names}}'` — un nombre +/// por línea. +pub fn parse_docker_names(text: &str) -> Vec { + 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 { + 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 { + 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(¤t, &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(¤t, &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(¤t, &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); + } +}