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:
Binary file not shown.
Generated
+22
@@ -7070,10 +7070,15 @@ checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
|
|||||||
name = "matilda"
|
name = "matilda"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"clap",
|
||||||
"matilda-apply",
|
"matilda-apply",
|
||||||
"matilda-config",
|
"matilda-config",
|
||||||
"matilda-core",
|
"matilda-core",
|
||||||
|
"matilda-ghost",
|
||||||
|
"matilda-linker",
|
||||||
"matilda-plan",
|
"matilda-plan",
|
||||||
|
"serde_json",
|
||||||
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7100,6 +7105,23 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "matilda-ghost"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"matilda-apply",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "matilda-linker"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"brahman-ssh-multiplex",
|
||||||
|
"matilda-apply",
|
||||||
|
"matilda-ghost",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "matilda-plan"
|
name = "matilda-plan"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|||||||
@@ -143,6 +143,8 @@ members = [
|
|||||||
"crates/modules/matilda/matilda-config",
|
"crates/modules/matilda/matilda-config",
|
||||||
"crates/modules/matilda/matilda-plan",
|
"crates/modules/matilda/matilda-plan",
|
||||||
"crates/modules/matilda/matilda-apply",
|
"crates/modules/matilda/matilda-apply",
|
||||||
|
"crates/modules/matilda/matilda-ghost",
|
||||||
|
"crates/modules/matilda/matilda-linker",
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# modules/yachay/ — Notebooks computacionales reproducibles
|
# modules/yachay/ — Notebooks computacionales reproducibles
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ rust-version.workspace = true
|
|||||||
license.workspace = true
|
license.workspace = true
|
||||||
authors.workspace = true
|
authors.workspace = true
|
||||||
publish.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]]
|
[[bin]]
|
||||||
name = "matilda"
|
name = "matilda"
|
||||||
@@ -17,3 +17,8 @@ matilda-core = { path = "../../modules/matilda/matilda-core" }
|
|||||||
matilda-config = { path = "../../modules/matilda/matilda-config" }
|
matilda-config = { path = "../../modules/matilda/matilda-config" }
|
||||||
matilda-plan = { path = "../../modules/matilda/matilda-plan" }
|
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-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
|
//! Carga un inventario declarativo (JSON), lo reconcilia contra el
|
||||||
//! su configuración nginx, y luego calcula el *plan* que lleva un
|
//! estado actual y aplica los cambios — localmente, en seco, o en un
|
||||||
//! servidor desde un estado actual distinto hasta el deseado.
|
//! 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_core::{Container, Host, Inventory, RestartPolicy, VHost};
|
||||||
use matilda_config::{compose_file, nginx_sites};
|
use matilda_ghost::ApplyReport;
|
||||||
use matilda_plan::plan;
|
use matilda_linker::{Linker, SshAuth, SshConfig};
|
||||||
|
use matilda_plan::{plan, Op};
|
||||||
|
|
||||||
/// El inventario que queremos tener en el servidor.
|
#[derive(Parser)]
|
||||||
fn desired() -> Inventory {
|
#[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();
|
let mut inv = Inventory::new();
|
||||||
inv.add_host(Host::new("edge-1", "10.0.0.1").with_tag("prod"));
|
inv.add_host(Host::new("edge-1", "10.0.0.1").with_tag("prod"));
|
||||||
|
|
||||||
inv.add_container(
|
inv.add_container(
|
||||||
Container::new("web", "nginx:1.27")
|
Container::new("web", "nginx:1.27")
|
||||||
.with_port(8080, 80)
|
.with_port(8080, 80)
|
||||||
@@ -22,85 +90,130 @@ fn desired() -> Inventory {
|
|||||||
.with_restart(RestartPolicy::Always),
|
.with_restart(RestartPolicy::Always),
|
||||||
);
|
);
|
||||||
inv.add_container(
|
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_port(9000, 9000)
|
||||||
.with_env("DATABASE_URL", "postgres://db/app")
|
.with_env("DATABASE_URL", "postgres://db/app")
|
||||||
.with_restart(RestartPolicy::UnlessStopped),
|
.with_restart(RestartPolicy::UnlessStopped),
|
||||||
);
|
);
|
||||||
inv.add_container(
|
inv.add_vhost(
|
||||||
Container::new("db", "postgres:16")
|
VHost::to_container("sitio.com", "web", 80)
|
||||||
.with_env("POSTGRES_DB", "app")
|
.with_alias("www.sitio.com")
|
||||||
.with_volume("/srv/pgdata", "/var/lib/postgresql/data")
|
.with_tls(),
|
||||||
.with_restart(RestartPolicy::Always),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
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
|
inv
|
||||||
}
|
}
|
||||||
|
|
||||||
/// El estado en que está el servidor hoy: `web` con imagen vieja, sin
|
/// Imprime un `ApplyReport` legible.
|
||||||
/// `api`, y un contenedor `legacy` que ya no se quiere.
|
fn print_report(report: &ApplyReport) {
|
||||||
fn current() -> Inventory {
|
for r in &report.results {
|
||||||
let mut inv = Inventory::new();
|
println!("\n{} {}", if r.ok { "✔" } else { "✘" }, r.describe);
|
||||||
inv.add_host(Host::new("edge-1", "10.0.0.1").with_tag("prod"));
|
for l in &r.log {
|
||||||
inv.add_container(Container::new("web", "nginx:1.25").with_port(8080, 80));
|
println!(" {l}");
|
||||||
inv.add_container(Container::new("db", "postgres:16")
|
}
|
||||||
.with_env("POSTGRES_DB", "app")
|
}
|
||||||
.with_volume("/srv/pgdata", "/var/lib/postgresql/data")
|
println!(
|
||||||
.with_restart(RestartPolicy::Always));
|
"\n{} de {} pasos aplicados.",
|
||||||
inv.add_container(Container::new("legacy", "old/cgi:1"));
|
report.applied(),
|
||||||
inv.add_vhost(VHost::to_container("jlsoltech.com", "web", 80));
|
report.results.len()
|
||||||
inv
|
);
|
||||||
|
if !report.all_ok() {
|
||||||
|
println!("✘ se detuvo en el primer error.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn rule(title: &str) {
|
/// Aplica los pasos en un host remoto por SSH.
|
||||||
println!("\n── {title} {}", "─".repeat(56usize.saturating_sub(title.len())));
|
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() {
|
fn run() -> Result<(), String> {
|
||||||
let desired = desired();
|
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)");
|
Cmd::Plan { inventory, current } => {
|
||||||
print!("{}", compose_file(&desired));
|
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)");
|
Cmd::Script { inventory, current } => {
|
||||||
print!("{}", nginx_sites(&desired));
|
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)");
|
Cmd::Apply { inventory, current, dry_run, host, password } => {
|
||||||
let current = current();
|
let desired = load(&inventory)?;
|
||||||
let p = plan(¤t, &desired);
|
let p = plan(&load_current(¤t)?, &desired);
|
||||||
if p.is_empty() {
|
let steps = plan_to_steps(&p, &desired);
|
||||||
println!(" sin cambios: el servidor ya está al día.");
|
if steps.is_empty() {
|
||||||
} else {
|
println!("Sin cambios: nada que aplicar.");
|
||||||
for (i, action) in p.actions.iter().enumerate() {
|
return Ok(());
|
||||||
println!(" {:>2}. {}", i + 1, action.describe());
|
}
|
||||||
|
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),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
Ok(())
|
||||||
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));
|
|
||||||
}
|
|
||||||
|
|
||||||
let broken = desired.broken_vhosts();
|
fn main() -> ExitCode {
|
||||||
rule("consistencia");
|
match run() {
|
||||||
if broken.is_empty() {
|
Ok(()) => ExitCode::SUCCESS,
|
||||||
println!(" todos los vhosts apuntan a contenedores existentes. ✔");
|
Err(e) => {
|
||||||
} else {
|
eprintln!("error: {e}");
|
||||||
for v in broken {
|
ExitCode::FAILURE
|
||||||
println!(" ✘ vhost «{}» apunta a un contenedor inexistente", v.domain);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
println!();
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,16 +35,22 @@ App: `apps/matilda` — demo CLI (`cargo run -p matilda`).
|
|||||||
- `config` y `plan` ← `matilda-core`. Todos `#![forbid(unsafe_code)]`.
|
- `config` y `plan` ← `matilda-core`. Todos `#![forbid(unsafe_code)]`.
|
||||||
- Cero Docker, cero SSH, cero disco — sólo modelos y strings.
|
- Cero Docker, cero SSH, cero disco — sólo modelos y strings.
|
||||||
|
|
||||||
|
## Crates de ejecución
|
||||||
|
|
||||||
|
| crate | tipo | rol |
|
||||||
|
| ---------------- | ---- | ------------------------------------------------------------ |
|
||||||
|
| `matilda-ghost` | lib | Ejecuta los `ApplyStep`s en la máquina destino (escribe archivos, corre comandos) + `dry_run`; reporta paso a paso |
|
||||||
|
| `matilda-linker` | lib | Aplica los pasos en un host **remoto** por SSH (`brahman-ssh-multiplex`); mismo `ApplyReport` |
|
||||||
|
|
||||||
|
CLI: `apps/matilda` — `example` / `plan` / `script` / `apply`
|
||||||
|
(local · `--dry-run` · `--host usuario@host` por SSH).
|
||||||
|
|
||||||
## Estado
|
## Estado
|
||||||
|
|
||||||
`core` + `config` + `plan` + `apply` implementados y verdes (35 tests) +
|
`core` + `config` + `plan` + `apply` + `ghost` + `linker` implementados
|
||||||
demo CLI. La cadena pura ya llega de la declaración al script de shell
|
y verdes (~42 tests) + CLI. La cadena va de la declaración (inventario
|
||||||
concreto listo para correr en el servidor.
|
JSON) al plan, al script y a la aplicación —local, en seco o remota—.
|
||||||
|
|
||||||
**Pendiente** (la capa de I/O):
|
**Pendiente**: `matilda-discover` (leer el estado actual del servidor —
|
||||||
|
`docker inspect`, sitios de nginx— para un diff real en vez de partir
|
||||||
| crate pendiente | rol |
|
de un inventario vacío) y `matilda-app`, el frontend GPUI.
|
||||||
| ----------------- | ------------------------------------------------ |
|
|
||||||
| `matilda-linker` | transporte SSH (sobre `transport-ssh-multiplex`) |
|
|
||||||
| `matilda-ghost` | agente remoto que ejecuta los `ApplyStep`s |
|
|
||||||
| `matilda-app` | frontend GPUI |
|
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
[package]
|
||||||
|
name = "matilda-ghost"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
description = "matilda — el agente que ejecuta los ApplySteps en la máquina destino: escribe los archivos, corre los comandos y reporta el resultado paso a paso."
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
matilda-apply = { path = "../matilda-apply" }
|
||||||
|
serde = { workspace = true }
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
//! `matilda-ghost` — el agente que aplica los pasos en la máquina destino.
|
||||||
|
//!
|
||||||
|
//! El «Ghost» es quien realmente ejecuta: recibe los [`ApplyStep`]s que
|
||||||
|
//! tradujo `matilda-apply` y, en orden, escribe los archivos y corre los
|
||||||
|
//! comandos en *esta* máquina (la del servidor). Reporta paso a paso en
|
||||||
|
//! un [`ApplyReport`].
|
||||||
|
//!
|
||||||
|
//! Semántica `set -e`: si un paso falla, se detiene — no se aplican los
|
||||||
|
//! siguientes. [`dry_run`] muestra lo que haría sin tocar nada.
|
||||||
|
//!
|
||||||
|
//! La aplicación *remota* (por SSH) la hace `matilda-linker`, que produce
|
||||||
|
//! el mismo [`ApplyReport`] reusando estos tipos.
|
||||||
|
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
use matilda_apply::ApplyStep;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Resultado de un paso de aplicación.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct StepResult {
|
||||||
|
/// Descripción de la acción aplicada.
|
||||||
|
pub describe: String,
|
||||||
|
/// `true` si el paso completó sin errores.
|
||||||
|
pub ok: bool,
|
||||||
|
/// Bitácora legible: archivos escritos, comandos y su salida.
|
||||||
|
pub log: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// El reporte de aplicar un plan: un resultado por paso ejecutado.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||||
|
pub struct ApplyReport {
|
||||||
|
pub results: Vec<StepResult>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ApplyReport {
|
||||||
|
/// `true` si todos los pasos ejecutados salieron bien.
|
||||||
|
pub fn all_ok(&self) -> bool {
|
||||||
|
self.results.iter().all(|r| r.ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cantidad de pasos que salieron bien.
|
||||||
|
pub fn applied(&self) -> usize {
|
||||||
|
self.results.iter().filter(|r| r.ok).count()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// El primer paso que falló, si lo hubo.
|
||||||
|
pub fn failed(&self) -> Option<&StepResult> {
|
||||||
|
self.results.iter().find(|r| !r.ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Corre un comando de shell, juntando su salida (stdout + stderr).
|
||||||
|
/// Los comandos de matilda llevan `&&`, redirecciones… → van por `sh -c`.
|
||||||
|
fn run_command(cmd: &str) -> std::io::Result<(i32, Vec<String>)> {
|
||||||
|
let out = std::process::Command::new("sh").arg("-c").arg(cmd).output()?;
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
for chunk in [&out.stdout, &out.stderr] {
|
||||||
|
for l in String::from_utf8_lossy(chunk).lines() {
|
||||||
|
lines.push(l.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok((out.status.code().unwrap_or(-1), lines))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Aplica un paso en esta máquina: escribe sus archivos y corre sus
|
||||||
|
/// comandos. Devuelve el resultado; se detiene en el primer error.
|
||||||
|
fn apply_step(step: &ApplyStep) -> StepResult {
|
||||||
|
let mut log = Vec::new();
|
||||||
|
let mut ok = true;
|
||||||
|
|
||||||
|
for f in &step.files {
|
||||||
|
match std::fs::write(&f.path, &f.content) {
|
||||||
|
Ok(()) => log.push(format!("✔ escrito {}", f.path)),
|
||||||
|
Err(e) => {
|
||||||
|
log.push(format!("✘ no se pudo escribir {}: {e}", f.path));
|
||||||
|
ok = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ok {
|
||||||
|
for cmd in &step.commands {
|
||||||
|
log.push(format!("$ {cmd}"));
|
||||||
|
match run_command(cmd) {
|
||||||
|
Ok((0, out)) => {
|
||||||
|
log.extend(out.into_iter().map(|l| format!(" {l}")));
|
||||||
|
}
|
||||||
|
Ok((code, out)) => {
|
||||||
|
log.extend(out.into_iter().map(|l| format!(" {l}")));
|
||||||
|
log.push(format!("✘ el comando salió con código {code}"));
|
||||||
|
ok = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log.push(format!("✘ no se pudo ejecutar: {e}"));
|
||||||
|
ok = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
StepResult { describe: step.describe.clone(), ok, log }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Aplica los pasos en orden. Se detiene en el primero que falle
|
||||||
|
/// (semántica `set -e`): los posteriores no se ejecutan.
|
||||||
|
pub fn apply(steps: &[ApplyStep]) -> ApplyReport {
|
||||||
|
let mut results = Vec::new();
|
||||||
|
for step in steps {
|
||||||
|
let result = apply_step(step);
|
||||||
|
let failed = !result.ok;
|
||||||
|
results.push(result);
|
||||||
|
if failed {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ApplyReport { results }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Simula la aplicación: reporta qué archivos y comandos se ejecutarían,
|
||||||
|
/// sin tocar nada. Seguro para previsualizar.
|
||||||
|
pub fn dry_run(steps: &[ApplyStep]) -> ApplyReport {
|
||||||
|
let results = steps
|
||||||
|
.iter()
|
||||||
|
.map(|s| {
|
||||||
|
let mut log = Vec::new();
|
||||||
|
for f in &s.files {
|
||||||
|
log.push(format!("escribiría {} ({} bytes)", f.path, f.content.len()));
|
||||||
|
}
|
||||||
|
for c in &s.commands {
|
||||||
|
log.push(format!("$ {c}"));
|
||||||
|
}
|
||||||
|
StepResult { describe: s.describe.clone(), ok: true, log }
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
ApplyReport { results }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use matilda_apply::FileWrite;
|
||||||
|
|
||||||
|
/// Paso que escribe un archivo temporal y corre un comando.
|
||||||
|
fn step(describe: &str, file: Option<FileWrite>, cmds: &[&str]) -> ApplyStep {
|
||||||
|
ApplyStep {
|
||||||
|
describe: describe.into(),
|
||||||
|
files: file.into_iter().collect(),
|
||||||
|
commands: cmds.iter().map(|s| s.to_string()).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn temp(name: &str) -> String {
|
||||||
|
std::env::temp_dir()
|
||||||
|
.join(format!("matilda-ghost-{}-{name}", std::process::id()))
|
||||||
|
.to_string_lossy()
|
||||||
|
.into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dry_run_touches_nothing() {
|
||||||
|
let path = temp("dry");
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
let steps = vec![step(
|
||||||
|
"crear x",
|
||||||
|
Some(FileWrite { path: path.clone(), content: "hola".into() }),
|
||||||
|
&["echo hecho"],
|
||||||
|
)];
|
||||||
|
let report = dry_run(&steps);
|
||||||
|
assert!(report.all_ok());
|
||||||
|
assert_eq!(report.results.len(), 1);
|
||||||
|
// dry_run no escribió el archivo.
|
||||||
|
assert!(!std::path::Path::new(&path).exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apply_writes_files_and_runs_commands() {
|
||||||
|
let path = temp("apply");
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
let steps = vec![step(
|
||||||
|
"crear config",
|
||||||
|
Some(FileWrite { path: path.clone(), content: "contenido".into() }),
|
||||||
|
&["echo aplicado"],
|
||||||
|
)];
|
||||||
|
let report = apply(&steps);
|
||||||
|
assert!(report.all_ok());
|
||||||
|
assert_eq!(std::fs::read_to_string(&path).unwrap(), "contenido");
|
||||||
|
assert!(report.results[0].log.iter().any(|l| l.contains("aplicado")));
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apply_stops_at_the_first_failure() {
|
||||||
|
let steps = vec![
|
||||||
|
step("ok", None, &["true"]),
|
||||||
|
step("falla", None, &["exit 7"]),
|
||||||
|
step("nunca", None, &["echo no-deberia-correr"]),
|
||||||
|
];
|
||||||
|
let report = apply(&steps);
|
||||||
|
// El tercer paso no se ejecutó.
|
||||||
|
assert_eq!(report.results.len(), 2);
|
||||||
|
assert!(!report.all_ok());
|
||||||
|
assert_eq!(report.applied(), 1);
|
||||||
|
assert!(report.failed().unwrap().describe.contains("falla"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nonzero_exit_marks_the_step_failed() {
|
||||||
|
let report = apply(&[step("test", None, &["false"])]);
|
||||||
|
assert!(!report.results[0].ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_plan_applies_cleanly() {
|
||||||
|
let report = apply(&[]);
|
||||||
|
assert!(report.all_ok());
|
||||||
|
assert_eq!(report.applied(), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
[package]
|
||||||
|
name = "matilda-linker"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
description = "matilda — el enlace SSH: conecta a un servidor y aplica los ApplySteps remotamente, escribiendo archivos y corriendo comandos sobre la conexión multiplexada."
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
matilda-apply = { path = "../matilda-apply" }
|
||||||
|
matilda-ghost = { path = "../matilda-ghost" }
|
||||||
|
brahman-ssh-multiplex = { path = "../../../protocol/brahman-ssh-multiplex" }
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
//! `matilda-linker` — el enlace SSH que aplica un plan en un servidor.
|
||||||
|
//!
|
||||||
|
//! El [`Linker`] conecta a un host vía `brahman-ssh-multiplex` y aplica
|
||||||
|
//! los [`ApplyStep`]s **remotamente**: escribe los archivos (con un
|
||||||
|
//! heredoc) y corre los comandos, cada uno sobre la conexión SSH
|
||||||
|
//! multiplexada. Produce el mismo [`ApplyReport`] que `matilda-ghost`,
|
||||||
|
//! así el consumidor no distingue aplicación local de remota.
|
||||||
|
//!
|
||||||
|
//! La prueba real necesita un servidor SSH — se hace fuera del unit
|
||||||
|
//! test. Lo puro y testeable es la construcción del comando de escritura.
|
||||||
|
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
use matilda_apply::{ApplyStep, FileWrite};
|
||||||
|
use matilda_ghost::{ApplyReport, StepResult};
|
||||||
|
|
||||||
|
pub use brahman_ssh_multiplex::{SshAuth, SshConfig, SshError};
|
||||||
|
use brahman_ssh_multiplex::SshSession;
|
||||||
|
|
||||||
|
/// Marcador de heredoc para escribir archivos remotos.
|
||||||
|
const HEREDOC: &str = "MATILDA_LINKER_EOF";
|
||||||
|
|
||||||
|
/// Comando de shell que escribe `f.content` en `f.path` del host remoto.
|
||||||
|
fn file_write_command(f: &FileWrite) -> String {
|
||||||
|
format!(
|
||||||
|
"cat > '{}' <<'{HEREDOC}'\n{}\n{HEREDOC}",
|
||||||
|
f.path, f.content
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enlace activo a un servidor: una sesión SSH multiplexada.
|
||||||
|
pub struct Linker {
|
||||||
|
session: SshSession,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Linker {
|
||||||
|
/// Conecta y autentica contra el host descrito por `config`.
|
||||||
|
pub async fn connect(config: &SshConfig) -> Result<Linker, SshError> {
|
||||||
|
Ok(Linker { session: SshSession::connect(config).await? })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Aplica un paso en el host remoto: escribe sus archivos, corre sus
|
||||||
|
/// comandos. Se detiene en el primer error.
|
||||||
|
async fn apply_step(&self, step: &ApplyStep) -> StepResult {
|
||||||
|
let mut log = Vec::new();
|
||||||
|
let mut ok = true;
|
||||||
|
|
||||||
|
for f in &step.files {
|
||||||
|
match self.session.exec(&file_write_command(f)).await {
|
||||||
|
Ok(out) if out.exit_code == 0 => log.push(format!("✔ escrito {}", f.path)),
|
||||||
|
Ok(out) => {
|
||||||
|
log.push(format!(
|
||||||
|
"✘ escribir {}: {}",
|
||||||
|
f.path,
|
||||||
|
String::from_utf8_lossy(&out.stderr).trim()
|
||||||
|
));
|
||||||
|
ok = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log.push(format!("✘ {e}"));
|
||||||
|
ok = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ok {
|
||||||
|
for cmd in &step.commands {
|
||||||
|
log.push(format!("$ {cmd}"));
|
||||||
|
match self.session.exec(cmd).await {
|
||||||
|
Ok(out) => {
|
||||||
|
for l in String::from_utf8_lossy(&out.stdout).lines() {
|
||||||
|
log.push(format!(" {l}"));
|
||||||
|
}
|
||||||
|
for l in String::from_utf8_lossy(&out.stderr).lines() {
|
||||||
|
log.push(format!(" {l}"));
|
||||||
|
}
|
||||||
|
if out.exit_code != 0 {
|
||||||
|
log.push(format!("✘ el comando salió con código {}", out.exit_code));
|
||||||
|
ok = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log.push(format!("✘ no se pudo ejecutar: {e}"));
|
||||||
|
ok = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
StepResult { describe: step.describe.clone(), ok, log }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Aplica los pasos en orden sobre el host remoto. Se detiene en el
|
||||||
|
/// primero que falle (semántica `set -e`).
|
||||||
|
pub async fn apply(&self, steps: &[ApplyStep]) -> ApplyReport {
|
||||||
|
let mut results = Vec::new();
|
||||||
|
for step in steps {
|
||||||
|
let result = self.apply_step(step).await;
|
||||||
|
let failed = !result.ok;
|
||||||
|
results.push(result);
|
||||||
|
if failed {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ApplyReport { results }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn file_write_command_uses_a_heredoc() {
|
||||||
|
let f = FileWrite {
|
||||||
|
path: "/etc/nginx/sites-enabled/site.conf".into(),
|
||||||
|
content: "server { listen 80; }".into(),
|
||||||
|
};
|
||||||
|
let cmd = file_write_command(&f);
|
||||||
|
assert!(cmd.starts_with("cat > '/etc/nginx/sites-enabled/site.conf' <<'"));
|
||||||
|
assert!(cmd.contains("server { listen 80; }"));
|
||||||
|
assert!(cmd.ends_with(HEREDOC));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ssh_config_is_re_exported() {
|
||||||
|
// El consumidor arma la conexión sin depender de ssh-multiplex.
|
||||||
|
let c = SshConfig::new("srv.example", "deploy", SshAuth::Password("x".into()));
|
||||||
|
assert_eq!(c.host, "srv.example");
|
||||||
|
}
|
||||||
|
|
||||||
|
// La aplicación remota real (`Linker::connect` + `apply`) necesita un
|
||||||
|
// servidor SSH — se prueba fuera del unit test.
|
||||||
|
}
|
||||||
@@ -855,10 +855,8 @@
|
|||||||
-p sandokan-lifecycle -p arje-incarnate
|
-p sandokan-lifecycle -p arje-incarnate
|
||||||
|
|
||||||
# Fase B — orquestador + discovery
|
# Fase B — orquestador + discovery
|
||||||
cargo test -p sandokan-core -p sandokan-local -p sandokan-daemon -p
|
cargo test -p sandokan-core -p sandokan-local -p sandokan-daemon -p sandokan \
|
||||||
sandokan \
|
-p brahman-dht -p brahman-card-discovery -p brahman-ssh-multiplex
|
||||||
-p brahman-dht -p brahman-card-discovery -p
|
|
||||||
brahman-ssh-multiplex
|
|
||||||
|
|
||||||
# Fase F — pineal (6 charts)
|
# Fase F — pineal (6 charts)
|
||||||
cargo test -p pineal-render -p pineal-export -p pineal-heatmap \
|
cargo test -p pineal-render -p pineal-export -p pineal-heatmap \
|
||||||
@@ -897,3 +895,69 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Lo construido — 6 módulos, 16 crates nuevos
|
||||||
|
|
||||||
|
┌───────────┬───────────────────────────────┬───────┬────────────────────────────────┐
|
||||||
|
│ módulo │ crates │ tests │ estado │
|
||||||
|
├───────────┼───────────────────────────────┼───────┼────────────────────────────────┤
|
||||||
|
│ dominium │ render-plan, canvas-gpui, app │ 37 │ ✅ completo │
|
||||||
|
├───────────┼───────────────────────────────┼───────┼────────────────────────────────┤
|
||||||
|
│ verbo │ verbo-daemon │ 8 │ ✅ daemon entre-procesos │
|
||||||
|
├───────────┼───────────────────────────────┼───────┼────────────────────────────────┤
|
||||||
|
│ fana │ render-plan, editor-gpui, app │ 12 │ ✅ editor DAG (prioridad alta) │
|
||||||
|
├───────────┼───────────────────────────────┼───────┼────────────────────────────────┤
|
||||||
|
│ agorapura │ core, graph, app │ 22 │ ✅ identidad federada │
|
||||||
|
├───────────┼───────────────────────────────┼───────┼────────────────────────────────┤
|
||||||
|
│ badu │ core, gravity, app │ 29 │ núcleo + demo │
|
||||||
|
├───────────┼───────────────────────────────┼───────┼────────────────────────────────┤
|
||||||
|
│ takiy │ takiy-core │ 24 │ núcleo de teoría musical │
|
||||||
|
└───────────┴───────────────────────────────┴───────┴────────────────────────────────┘
|
||||||
|
|
||||||
|
132 tests nuevos, todos verdes. cargo check --workspace verde en cada commit. 14 commits pusheados.
|
||||||
|
|
||||||
|
Para que verifiques tú (apps GPUI — no las puedo ver)
|
||||||
|
|
||||||
|
cargo run -p dominium # simulador isométrico vivo
|
||||||
|
cargo run -p fana # editor DAG — «Mutar raíz» dispara la onda de choque
|
||||||
|
cargo run -p shuma-shell # el shell de 3 zonas
|
||||||
|
|
||||||
|
Ya verifiqué yo (CLI, corren bien)
|
||||||
|
|
||||||
|
cargo run -p agorapura # Venezuela atestigua a Yumaira; firma falsa rechazada; veredictos por política
|
||||||
|
cargo run -p badu # cuaderno: grafo de enlaces + clústeres por gravedad semántica
|
||||||
|
|
||||||
|
|
||||||
|
Verificación
|
||||||
|
|
||||||
|
Apps GPUI — necesito que las veas tú:
|
||||||
|
cargo run -p dominium # simulador isométrico vivo
|
||||||
|
cargo run -p fana # editor DAG — «Mutar raíz» dispara la onda de choque
|
||||||
|
cargo run -p shuma-shell # el shell de 3 zonas
|
||||||
|
|
||||||
|
Demos CLI — ya las verifiqué yo, corren bien:
|
||||||
|
cargo run -p agorapura # Venezuela atestigua a Yumaira; veredictos por política
|
||||||
|
cargo run -p badu # cuaderno: grafo de enlaces + clústeres por gravedad
|
||||||
|
cargo run -p matilda # inventario → docker-compose + nginx + plan de reconciliación
|
||||||
|
cargo run -p yachay # notebook: orden de ejecución + digest cambia al editar
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
matilda — el puente plan→ejecución
|
||||||
|
|
||||||
|
Avancé el módulo de server admin con matilda-apply: traduce el Plan de reconciliación a pasos concretos —los archivos a escribir y los comandos a
|
||||||
|
correr en el servidor— y a un script bash listo para ejecutar. cargo run -p matilda ahora lo imprime:
|
||||||
|
|
||||||
|
# crear contenedor «api»
|
||||||
|
docker run -d --name api --restart unless-stopped -p 9000:9000 ...
|
||||||
|
# actualizar contenedor «web»
|
||||||
|
docker rm -f web
|
||||||
|
docker run -d --name web ...
|
||||||
|
# crear vhost «api.jlsoltech.com»
|
||||||
|
cat > /etc/nginx/sites-enabled/api.jlsoltech.com.conf <<'MATILDA_EOF'
|
||||||
|
server { listen 443 ssl; ... }
|
||||||
|
MATILDA_EOF
|
||||||
|
nginx -t && nginx -s reload
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user