feat(matilda): ghost + linker + CLI — el ciclo completo de aplicación
matilda-ghost: el agente que ejecuta los ApplySteps en la máquina destino — escribe archivos, corre comandos, reporta paso a paso; semántica set -e (se detiene en el primer error). dry_run previsualiza sin tocar nada. 5 tests. matilda-linker: aplica los pasos en un host remoto por SSH sobre brahman-ssh-multiplex; produce el mismo ApplyReport que el ghost local. apps/matilda: deja de ser una demo hardcoded — ahora es una CLI real: matilda example | plan | script | apply (local · --dry-run · --host) Carga el inventario de un JSON, reconcilia y aplica. matilda: 6 crates + CLI, ~42 tests. La cadena va de la declaración a la aplicación local/remota. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -6,7 +6,7 @@ rust-version.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
publish.workspace = true
|
||||
description = "matilda — demostración de administración de servidores: inventario declarativo, renderizado de docker-compose y nginx, y plan de reconciliación."
|
||||
description = "matilda — CLI de administración de servidores: carga un inventario, muestra el plan, emite el script y lo aplica (local, remoto por SSH, o en seco)."
|
||||
|
||||
[[bin]]
|
||||
name = "matilda"
|
||||
@@ -17,3 +17,8 @@ matilda-core = { path = "../../modules/matilda/matilda-core" }
|
||||
matilda-config = { path = "../../modules/matilda/matilda-config" }
|
||||
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" }
|
||||
clap = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
|
||||
+184
-71
@@ -1,20 +1,88 @@
|
||||
//! `matilda` — demostración de administración de servidores.
|
||||
//! `matilda` — CLI de administración de servidores.
|
||||
//!
|
||||
//! Declara un inventario *deseado*, renderiza su `docker-compose.yml` y
|
||||
//! su configuración nginx, y luego calcula el *plan* que lleva un
|
||||
//! servidor desde un estado actual distinto hasta el deseado.
|
||||
//! Carga un inventario declarativo (JSON), lo reconcilia contra el
|
||||
//! estado actual y aplica los cambios — localmente, en seco, o en un
|
||||
//! servidor remoto por SSH:
|
||||
//!
|
||||
//! Smoke test legible del módulo: `cargo run -p matilda`.
|
||||
//! ```text
|
||||
//! matilda example imprime un inventario de ejemplo
|
||||
//! matilda plan inv.json muestra el plan de reconciliación
|
||||
//! matilda script inv.json emite el script de aplicación
|
||||
//! matilda apply inv.json aplica localmente
|
||||
//! matilda apply inv.json --dry-run simula
|
||||
//! matilda apply inv.json --host deploy@srv aplica por SSH
|
||||
//! ```
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::process::ExitCode;
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
use matilda_apply::{plan_to_steps, steps_to_script, ApplyStep};
|
||||
use matilda_core::{Container, Host, Inventory, RestartPolicy, VHost};
|
||||
use matilda_config::{compose_file, nginx_sites};
|
||||
use matilda_plan::plan;
|
||||
use matilda_ghost::ApplyReport;
|
||||
use matilda_linker::{Linker, SshAuth, SshConfig};
|
||||
use matilda_plan::{plan, Op};
|
||||
|
||||
/// El inventario que queremos tener en el servidor.
|
||||
fn desired() -> Inventory {
|
||||
#[derive(Parser)]
|
||||
#[command(name = "matilda", about = "Administración declarativa de servidores")]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
cmd: Cmd,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Cmd {
|
||||
/// Imprime un inventario de ejemplo para editar.
|
||||
Example,
|
||||
/// Muestra el plan de reconciliación del inventario.
|
||||
Plan {
|
||||
inventory: PathBuf,
|
||||
/// Estado actual del servidor (por defecto: vacío).
|
||||
#[arg(long)]
|
||||
current: Option<PathBuf>,
|
||||
},
|
||||
/// Emite el script de shell que aplicaría el plan.
|
||||
Script {
|
||||
inventory: PathBuf,
|
||||
#[arg(long)]
|
||||
current: Option<PathBuf>,
|
||||
},
|
||||
/// Aplica el plan: local, en seco, o remoto por SSH.
|
||||
Apply {
|
||||
inventory: PathBuf,
|
||||
#[arg(long)]
|
||||
current: Option<PathBuf>,
|
||||
/// Simula sin tocar nada.
|
||||
#[arg(long)]
|
||||
dry_run: bool,
|
||||
/// Aplica en un host remoto, `usuario@host`.
|
||||
#[arg(long)]
|
||||
host: Option<String>,
|
||||
/// Contraseña SSH (si no se da, se usa la clave por defecto).
|
||||
#[arg(long)]
|
||||
password: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Carga un inventario JSON desde un archivo.
|
||||
fn load(path: &PathBuf) -> Result<Inventory, String> {
|
||||
let text = std::fs::read_to_string(path)
|
||||
.map_err(|e| format!("no se pudo leer {}: {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ó.
|
||||
fn load_current(current: &Option<PathBuf>) -> Result<Inventory, String> {
|
||||
match current {
|
||||
Some(p) => load(p),
|
||||
None => Ok(Inventory::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Construye un inventario de ejemplo.
|
||||
fn example_inventory() -> Inventory {
|
||||
let mut inv = Inventory::new();
|
||||
inv.add_host(Host::new("edge-1", "10.0.0.1").with_tag("prod"));
|
||||
|
||||
inv.add_container(
|
||||
Container::new("web", "nginx:1.27")
|
||||
.with_port(8080, 80)
|
||||
@@ -22,85 +90,130 @@ fn desired() -> Inventory {
|
||||
.with_restart(RestartPolicy::Always),
|
||||
);
|
||||
inv.add_container(
|
||||
Container::new("api", "ghcr.io/jls/api:2.4")
|
||||
Container::new("api", "ghcr.io/ejemplo/api:1.0")
|
||||
.with_port(9000, 9000)
|
||||
.with_env("DATABASE_URL", "postgres://db/app")
|
||||
.with_restart(RestartPolicy::UnlessStopped),
|
||||
);
|
||||
inv.add_container(
|
||||
Container::new("db", "postgres:16")
|
||||
.with_env("POSTGRES_DB", "app")
|
||||
.with_volume("/srv/pgdata", "/var/lib/postgresql/data")
|
||||
.with_restart(RestartPolicy::Always),
|
||||
inv.add_vhost(
|
||||
VHost::to_container("sitio.com", "web", 80)
|
||||
.with_alias("www.sitio.com")
|
||||
.with_tls(),
|
||||
);
|
||||
|
||||
inv.add_vhost(VHost::to_container("jlsoltech.com", "web", 80).with_alias("www.jlsoltech.com").with_tls());
|
||||
inv.add_vhost(VHost::to_container("api.jlsoltech.com", "api", 9000).with_tls());
|
||||
inv
|
||||
}
|
||||
|
||||
/// El estado en que está el servidor hoy: `web` con imagen vieja, sin
|
||||
/// `api`, y un contenedor `legacy` que ya no se quiere.
|
||||
fn current() -> Inventory {
|
||||
let mut inv = Inventory::new();
|
||||
inv.add_host(Host::new("edge-1", "10.0.0.1").with_tag("prod"));
|
||||
inv.add_container(Container::new("web", "nginx:1.25").with_port(8080, 80));
|
||||
inv.add_container(Container::new("db", "postgres:16")
|
||||
.with_env("POSTGRES_DB", "app")
|
||||
.with_volume("/srv/pgdata", "/var/lib/postgresql/data")
|
||||
.with_restart(RestartPolicy::Always));
|
||||
inv.add_container(Container::new("legacy", "old/cgi:1"));
|
||||
inv.add_vhost(VHost::to_container("jlsoltech.com", "web", 80));
|
||||
inv
|
||||
/// Imprime un `ApplyReport` legible.
|
||||
fn print_report(report: &ApplyReport) {
|
||||
for r in &report.results {
|
||||
println!("\n{} {}", if r.ok { "✔" } else { "✘" }, r.describe);
|
||||
for l in &r.log {
|
||||
println!(" {l}");
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"\n{} de {} pasos aplicados.",
|
||||
report.applied(),
|
||||
report.results.len()
|
||||
);
|
||||
if !report.all_ok() {
|
||||
println!("✘ se detuvo en el primer error.");
|
||||
}
|
||||
}
|
||||
|
||||
fn rule(title: &str) {
|
||||
println!("\n── {title} {}", "─".repeat(56usize.saturating_sub(title.len())));
|
||||
/// Aplica los pasos en un host remoto por SSH.
|
||||
async fn apply_remote(
|
||||
target: &str,
|
||||
password: Option<String>,
|
||||
steps: &[ApplyStep],
|
||||
) -> Result<ApplyReport, String> {
|
||||
let (user, host) = target
|
||||
.split_once('@')
|
||||
.ok_or_else(|| format!("host inválido (esperaba usuario@host): {target}"))?;
|
||||
let auth = match password {
|
||||
Some(pw) => SshAuth::Password(pw),
|
||||
None => {
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into());
|
||||
SshAuth::Key {
|
||||
path: PathBuf::from(format!("{home}/.ssh/id_ed25519")),
|
||||
passphrase: None,
|
||||
}
|
||||
}
|
||||
};
|
||||
let config = SshConfig::new(host, user, auth);
|
||||
let linker = Linker::connect(&config)
|
||||
.await
|
||||
.map_err(|e| format!("conexión SSH: {e}"))?;
|
||||
Ok(linker.apply(steps).await)
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let desired = desired();
|
||||
fn run() -> Result<(), String> {
|
||||
match Cli::parse().cmd {
|
||||
Cmd::Example => {
|
||||
let json = serde_json::to_string_pretty(&example_inventory())
|
||||
.map_err(|e| e.to_string())?;
|
||||
println!("{json}");
|
||||
}
|
||||
|
||||
rule("docker-compose.yml (deseado)");
|
||||
print!("{}", compose_file(&desired));
|
||||
Cmd::Plan { inventory, current } => {
|
||||
let desired = load(&inventory)?;
|
||||
let p = plan(&load_current(¤t)?, &desired);
|
||||
if p.is_empty() {
|
||||
println!("Sin cambios: el servidor ya está al día.");
|
||||
} else {
|
||||
for (i, action) in p.actions.iter().enumerate() {
|
||||
println!("{:>2}. {}", i + 1, action.describe());
|
||||
}
|
||||
println!(
|
||||
"\n{} acciones — {} crear, {} actualizar, {} eliminar.",
|
||||
p.len(),
|
||||
p.count(Op::Create),
|
||||
p.count(Op::Update),
|
||||
p.count(Op::Remove),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
rule("nginx — sites (deseado)");
|
||||
print!("{}", nginx_sites(&desired));
|
||||
Cmd::Script { inventory, current } => {
|
||||
let desired = load(&inventory)?;
|
||||
let p = plan(&load_current(¤t)?, &desired);
|
||||
print!("{}", steps_to_script(&plan_to_steps(&p, &desired)));
|
||||
}
|
||||
|
||||
rule("plan de reconciliación (actual → deseado)");
|
||||
let current = current();
|
||||
let p = plan(¤t, &desired);
|
||||
if p.is_empty() {
|
||||
println!(" sin cambios: el servidor ya está al día.");
|
||||
} else {
|
||||
for (i, action) in p.actions.iter().enumerate() {
|
||||
println!(" {:>2}. {}", i + 1, action.describe());
|
||||
Cmd::Apply { inventory, current, dry_run, host, password } => {
|
||||
let desired = load(&inventory)?;
|
||||
let p = plan(&load_current(¤t)?, &desired);
|
||||
let steps = plan_to_steps(&p, &desired);
|
||||
if steps.is_empty() {
|
||||
println!("Sin cambios: nada que aplicar.");
|
||||
return Ok(());
|
||||
}
|
||||
let report = if dry_run {
|
||||
println!("— simulación (no se toca nada) —");
|
||||
matilda_ghost::dry_run(&steps)
|
||||
} else if let Some(target) = host {
|
||||
println!("— aplicando en {target} por SSH —");
|
||||
let rt = tokio::runtime::Runtime::new().map_err(|e| e.to_string())?;
|
||||
rt.block_on(apply_remote(&target, password, &steps))?
|
||||
} else {
|
||||
println!("— aplicando localmente —");
|
||||
matilda_ghost::apply(&steps)
|
||||
};
|
||||
print_report(&report);
|
||||
if !report.all_ok() {
|
||||
return Err("la aplicación falló".into());
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"\n {} acciones — {} crear, {} actualizar, {} eliminar.",
|
||||
p.len(),
|
||||
p.count(matilda_plan::Op::Create),
|
||||
p.count(matilda_plan::Op::Update),
|
||||
p.count(matilda_plan::Op::Remove),
|
||||
);
|
||||
}
|
||||
|
||||
rule("script de aplicación (lo que correría en el servidor)");
|
||||
let steps = matilda_apply::plan_to_steps(&p, &desired);
|
||||
if steps.is_empty() {
|
||||
println!(" nada que aplicar.");
|
||||
} else {
|
||||
print!("{}", matilda_apply::steps_to_script(&steps));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
let broken = desired.broken_vhosts();
|
||||
rule("consistencia");
|
||||
if broken.is_empty() {
|
||||
println!(" todos los vhosts apuntan a contenedores existentes. ✔");
|
||||
} else {
|
||||
for v in broken {
|
||||
println!(" ✘ vhost «{}» apunta a un contenedor inexistente", v.domain);
|
||||
fn main() -> ExitCode {
|
||||
match run() {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(e) => {
|
||||
eprintln!("error: {e}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
println!();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user