⎇ df8f92fbb0d4c9cfc377c810341794766db51094
crates/apps/matilda/src/main.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
feat(matilda): ghost + linker + CLI — el ciclo completo de aplicación
1//! `matilda` — CLI de administración de servidores.
2//!
3//! Carga un inventario declarativo (JSON), lo reconcilia contra el
4//! estado actual y aplica los cambios — localmente, en seco, o en un
5//! servidor remoto por SSH:
6//!
7//! ```text
8//! matilda example imprime un inventario de ejemplo
9//! matilda plan inv.json muestra el plan de reconciliación
10//! matilda script inv.json emite el script de aplicación
11//! matilda apply inv.json aplica localmente
12//! matilda apply inv.json --dry-run simula
13//! matilda apply inv.json --host deploy@srv aplica por SSH
14//! ```
15
16use std::path::PathBuf;
17use std::process::ExitCode;
18
19use clap::{Parser, Subcommand};
20use matilda_apply::{plan_to_steps, steps_to_script, ApplyStep};
21use matilda_core::{Container, Host, Inventory, RestartPolicy, VHost};
22use matilda_ghost::ApplyReport;
23use matilda_linker::{Linker, SshAuth, SshConfig};
24use matilda_plan::{plan, Op};
25
26#[derive(Parser)]
27#[command(name = "matilda", about = "Administración declarativa de servidores")]
28struct Cli {
29 #[command(subcommand)]
30 cmd: Cmd,
31}
32
33#[derive(Subcommand)]
34enum Cmd {
35 /// Imprime un inventario de ejemplo para editar.
36 Example,
37 /// Muestra el plan de reconciliación del inventario.
38 Plan {
39 inventory: PathBuf,
40 /// Estado actual del servidor (por defecto: vacío).
41 #[arg(long)]
42 current: Option<PathBuf>,
43 },
44 /// Emite el script de shell que aplicaría el plan.
45 Script {
46 inventory: PathBuf,
47 #[arg(long)]
48 current: Option<PathBuf>,
49 },
50 /// Aplica el plan: local, en seco, o remoto por SSH.
51 Apply {
52 inventory: PathBuf,
53 #[arg(long)]
54 current: Option<PathBuf>,
55 /// Simula sin tocar nada.
56 #[arg(long)]
57 dry_run: bool,
58 /// Aplica en un host remoto, `usuario@host`.
59 #[arg(long)]
60 host: Option<String>,
61 /// Contraseña SSH (si no se da, se usa la clave por defecto).
62 #[arg(long)]
63 password: Option<String>,
64 },
65}
66
67/// Carga un inventario JSON desde un archivo.
68fn load(path: &PathBuf) -> Result<Inventory, String> {
69 let text = std::fs::read_to_string(path)
70 .map_err(|e| format!("no se pudo leer {}: {e}", path.display()))?;
71 serde_json::from_str(&text).map_err(|e| format!("JSON inválido en {}: {e}", path.display()))
72}
73
74/// Carga el inventario actual, o uno vacío si no se especificó.
75fn load_current(current: &Option<PathBuf>) -> Result<Inventory, String> {
76 match current {
77 Some(p) => load(p),
78 None => Ok(Inventory::new()),
79 }
80}
81
82/// Construye un inventario de ejemplo.
83fn example_inventory() -> Inventory {
84 let mut inv = Inventory::new();
85 inv.add_host(Host::new("edge-1", "10.0.0.1").with_tag("prod"));
86 inv.add_container(
87 Container::new("web", "nginx:1.27")
88 .with_port(8080, 80)
89 .with_volume("/srv/site", "/usr/share/nginx/html")
90 .with_restart(RestartPolicy::Always),
91 );
92 inv.add_container(
93 Container::new("api", "ghcr.io/ejemplo/api:1.0")
94 .with_port(9000, 9000)
95 .with_env("DATABASE_URL", "postgres://db/app")
96 .with_restart(RestartPolicy::UnlessStopped),
97 );
98 inv.add_vhost(
99 VHost::to_container("sitio.com", "web", 80)
100 .with_alias("www.sitio.com")
101 .with_tls(),
102 );
103 inv
104}
105
106/// Imprime un `ApplyReport` legible.
107fn print_report(report: &ApplyReport) {
108 for r in &report.results {
109 println!("\n{} {}", if r.ok { "✔" } else { "✘" }, r.describe);
110 for l in &r.log {
111 println!(" {l}");
112 }
113 }
114 println!(
115 "\n{} de {} pasos aplicados.",
116 report.applied(),
117 report.results.len()
118 );
119 if !report.all_ok() {
120 println!("✘ se detuvo en el primer error.");
121 }
122}
123
124/// Aplica los pasos en un host remoto por SSH.
125async fn apply_remote(
126 target: &str,
127 password: Option<String>,
128 steps: &[ApplyStep],
129) -> Result<ApplyReport, String> {
130 let (user, host) = target
131 .split_once('@')
132 .ok_or_else(|| format!("host inválido (esperaba usuario@host): {target}"))?;
133 let auth = match password {
134 Some(pw) => SshAuth::Password(pw),
135 None => {
136 let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into());
137 SshAuth::Key {
138 path: PathBuf::from(format!("{home}/.ssh/id_ed25519")),
139 passphrase: None,
140 }
141 }
142 };
143 let config = SshConfig::new(host, user, auth);
144 let linker = Linker::connect(&config)
145 .await
146 .map_err(|e| format!("conexión SSH: {e}"))?;
147 Ok(linker.apply(steps).await)
148}
149
150fn run() -> Result<(), String> {
151 match Cli::parse().cmd {
152 Cmd::Example => {
153 let json = serde_json::to_string_pretty(&example_inventory())
154 .map_err(|e| e.to_string())?;
155 println!("{json}");
156 }
157
158 Cmd::Plan { inventory, current } => {
159 let desired = load(&inventory)?;
160 let p = plan(&load_current(¤t)?, &desired);
161 if p.is_empty() {
162 println!("Sin cambios: el servidor ya está al día.");
163 } else {
164 for (i, action) in p.actions.iter().enumerate() {
165 println!("{:>2}. {}", i + 1, action.describe());
166 }
167 println!(
168 "\n{} acciones — {} crear, {} actualizar, {} eliminar.",
169 p.len(),
170 p.count(Op::Create),
171 p.count(Op::Update),
172 p.count(Op::Remove),
173 );
174 }
175 }
176
177 Cmd::Script { inventory, current } => {
178 let desired = load(&inventory)?;
179 let p = plan(&load_current(¤t)?, &desired);
180 print!("{}", steps_to_script(&plan_to_steps(&p, &desired)));
181 }
182
183 Cmd::Apply { inventory, current, dry_run, host, password } => {
184 let desired = load(&inventory)?;
185 let p = plan(&load_current(¤t)?, &desired);
186 let steps = plan_to_steps(&p, &desired);
187 if steps.is_empty() {
188 println!("Sin cambios: nada que aplicar.");
189 return Ok(());
190 }
191 let report = if dry_run {
192 println!("— simulación (no se toca nada) —");
193 matilda_ghost::dry_run(&steps)
194 } else if let Some(target) = host {
195 println!("— aplicando en {target} por SSH —");
196 let rt = tokio::runtime::Runtime::new().map_err(|e| e.to_string())?;
197 rt.block_on(apply_remote(&target, password, &steps))?
198 } else {
199 println!("— aplicando localmente —");
200 matilda_ghost::apply(&steps)
201 };
202 print_report(&report);
203 if !report.all_ok() {
204 return Err("la aplicación falló".into());
205 }
206 }
207 }
208 Ok(())
209}
210
211fn main() -> ExitCode {
212 match run() {
213 Ok(()) => ExitCode::SUCCESS,
214 Err(e) => {
215 eprintln!("error: {e}");
216 ExitCode::FAILURE
217 }
218 }
219}