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>
This commit is contained in:
Generated
+10
@@ -7074,6 +7074,7 @@ dependencies = [
|
|||||||
"matilda-apply",
|
"matilda-apply",
|
||||||
"matilda-config",
|
"matilda-config",
|
||||||
"matilda-core",
|
"matilda-core",
|
||||||
|
"matilda-discover",
|
||||||
"matilda-ghost",
|
"matilda-ghost",
|
||||||
"matilda-linker",
|
"matilda-linker",
|
||||||
"matilda-plan",
|
"matilda-plan",
|
||||||
@@ -7105,6 +7106,15 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "matilda-discover"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"matilda-core",
|
||||||
|
"matilda-plan",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "matilda-ghost"
|
name = "matilda-ghost"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ members = [
|
|||||||
"crates/modules/matilda/matilda-apply",
|
"crates/modules/matilda/matilda-apply",
|
||||||
"crates/modules/matilda/matilda-ghost",
|
"crates/modules/matilda/matilda-ghost",
|
||||||
"crates/modules/matilda/matilda-linker",
|
"crates/modules/matilda/matilda-linker",
|
||||||
|
"crates/modules/matilda/matilda-discover",
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# modules/yachay/ — Notebooks computacionales reproducibles
|
# modules/yachay/ — Notebooks computacionales reproducibles
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ matilda-plan = { path = "../../modules/matilda/matilda-plan" }
|
|||||||
matilda-apply = { path = "../../modules/matilda/matilda-apply" }
|
matilda-apply = { path = "../../modules/matilda/matilda-apply" }
|
||||||
matilda-ghost = { path = "../../modules/matilda/matilda-ghost" }
|
matilda-ghost = { path = "../../modules/matilda/matilda-ghost" }
|
||||||
matilda-linker = { path = "../../modules/matilda/matilda-linker" }
|
matilda-linker = { path = "../../modules/matilda/matilda-linker" }
|
||||||
|
matilda-discover = { path = "../../modules/matilda/matilda-discover" }
|
||||||
clap = { workspace = true }
|
clap = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
|
|||||||
@@ -40,18 +40,26 @@ enum Cmd {
|
|||||||
/// Estado actual del servidor (por defecto: vacío).
|
/// Estado actual del servidor (por defecto: vacío).
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
current: Option<PathBuf>,
|
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.
|
/// Emite el script de shell que aplicaría el plan.
|
||||||
Script {
|
Script {
|
||||||
inventory: PathBuf,
|
inventory: PathBuf,
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
current: Option<PathBuf>,
|
current: Option<PathBuf>,
|
||||||
|
#[arg(long)]
|
||||||
|
discover: bool,
|
||||||
},
|
},
|
||||||
/// Aplica el plan: local, en seco, o remoto por SSH.
|
/// Aplica el plan: local, en seco, o remoto por SSH.
|
||||||
Apply {
|
Apply {
|
||||||
inventory: PathBuf,
|
inventory: PathBuf,
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
current: Option<PathBuf>,
|
current: Option<PathBuf>,
|
||||||
|
/// Descubre el estado actual de esta máquina antes de reconciliar.
|
||||||
|
#[arg(long)]
|
||||||
|
discover: bool,
|
||||||
/// Simula sin tocar nada.
|
/// Simula sin tocar nada.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
dry_run: bool,
|
dry_run: bool,
|
||||||
@@ -71,13 +79,24 @@ fn load(path: &PathBuf) -> Result<Inventory, String> {
|
|||||||
serde_json::from_str(&text).map_err(|e| format!("JSON inválido en {}: {e}", path.display()))
|
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ó.
|
/// Resuelve el inventario "actual" contra el que reconciliar:
|
||||||
fn load_current(current: &Option<PathBuf>) -> Result<Inventory, String> {
|
/// `--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 {
|
match current {
|
||||||
Some(p) => load(p),
|
Some(p) => load(p),
|
||||||
None => Ok(Inventory::new()),
|
None => Ok(Inventory::new()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Construye un inventario de ejemplo.
|
/// Construye un inventario de ejemplo.
|
||||||
fn example_inventory() -> Inventory {
|
fn example_inventory() -> Inventory {
|
||||||
@@ -155,9 +174,9 @@ fn run() -> Result<(), String> {
|
|||||||
println!("{json}");
|
println!("{json}");
|
||||||
}
|
}
|
||||||
|
|
||||||
Cmd::Plan { inventory, current } => {
|
Cmd::Plan { inventory, current, discover } => {
|
||||||
let desired = load(&inventory)?;
|
let desired = load(&inventory)?;
|
||||||
let p = plan(&load_current(¤t)?, &desired);
|
let p = plan(¤t_inventory(discover, ¤t, &desired)?, &desired);
|
||||||
if p.is_empty() {
|
if p.is_empty() {
|
||||||
println!("Sin cambios: el servidor ya está al día.");
|
println!("Sin cambios: el servidor ya está al día.");
|
||||||
} else {
|
} else {
|
||||||
@@ -174,15 +193,15 @@ fn run() -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Cmd::Script { inventory, current } => {
|
Cmd::Script { inventory, current, discover } => {
|
||||||
let desired = load(&inventory)?;
|
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)));
|
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 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);
|
let steps = plan_to_steps(&p, &desired);
|
||||||
if steps.is_empty() {
|
if steps.is_empty() {
|
||||||
println!("Sin cambios: nada que aplicar.");
|
println!("Sin cambios: nada que aplicar.");
|
||||||
|
|||||||
@@ -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" }
|
||||||
@@ -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(¤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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user