⎇ 93e003be0d06732c5eab3bd3467363a593803ba1
crates/apps/matilda/src/main.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
feat(matilda): matilda-discover — estado actual del servidor
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 /// Descubre el estado actual de esta máquina (docker + nginx).
44 #[arg(long)]
45 discover: bool,
46 },
47 /// Emite el script de shell que aplicaría el plan.
48 Script {
49 inventory: PathBuf,
50 #[arg(long)]
51 current: Option<PathBuf>,
52 #[arg(long)]
53 discover: bool,
54 },
55 /// Aplica el plan: local, en seco, o remoto por SSH.
56 Apply {
57 inventory: PathBuf,
58 #[arg(long)]
59 current: Option<PathBuf>,
60 /// Descubre el estado actual de esta máquina antes de reconciliar.
61 #[arg(long)]
62 discover: bool,
63 /// Simula sin tocar nada.
64 #[arg(long)]
65 dry_run: bool,
66 /// Aplica en un host remoto, `usuario@host`.
67 #[arg(long)]
68 host: Option<String>,
69 /// Contraseña SSH (si no se da, se usa la clave por defecto).
70 #[arg(long)]
71 password: Option<String>,
72 },
73}
74
75/// Carga un inventario JSON desde un archivo.
76fn load(path: &PathBuf) -> Result<Inventory, String> {
77 let text = std::fs::read_to_string(path)
78 .map_err(|e| format!("no se pudo leer {}: {e}", path.display()))?;
79 serde_json::from_str(&text).map_err(|e| format!("JSON inválido en {}: {e}", path.display()))
80}
81
82/// Resuelve el inventario "actual" contra el que reconciliar:
83/// `--discover` observa esta máquina; `--current` lee un archivo; si no,
84/// se parte de un inventario vacío (todo es creación).
85fn current_inventory(
86 discover: bool,
87 current: &Option<PathBuf>,
88 desired: &Inventory,
89) -> Result<Inventory, String> {
90 if discover {
91 let state = matilda_discover::discover_local();
92 Ok(matilda_discover::observed_inventory(&state, desired))
93 } else {
94 match current {
95 Some(p) => load(p),
96 None => Ok(Inventory::new()),
97 }
98 }
99}
100
101/// Construye un inventario de ejemplo.
102fn example_inventory() -> Inventory {
103 let mut inv = Inventory::new();
104 inv.add_host(Host::new("edge-1", "10.0.0.1").with_tag("prod"));
105 inv.add_container(
106 Container::new("web", "nginx:1.27")
107 .with_port(8080, 80)
108 .with_volume("/srv/site", "/usr/share/nginx/html")
109 .with_restart(RestartPolicy::Always),
110 );
111 inv.add_container(
112 Container::new("api", "ghcr.io/ejemplo/api:1.0")
113 .with_port(9000, 9000)
114 .with_env("DATABASE_URL", "postgres://db/app")
115 .with_restart(RestartPolicy::UnlessStopped),
116 );
117 inv.add_vhost(
118 VHost::to_container("sitio.com", "web", 80)
119 .with_alias("www.sitio.com")
120 .with_tls(),
121 );
122 inv
123}
124
125/// Imprime un `ApplyReport` legible.
126fn print_report(report: &ApplyReport) {
127 for r in &report.results {
128 println!("\n{} {}", if r.ok { "✔" } else { "✘" }, r.describe);
129 for l in &r.log {
130 println!(" {l}");
131 }
132 }
133 println!(
134 "\n{} de {} pasos aplicados.",
135 report.applied(),
136 report.results.len()
137 );
138 if !report.all_ok() {
139 println!("✘ se detuvo en el primer error.");
140 }
141}
142
143/// Aplica los pasos en un host remoto por SSH.
144async fn apply_remote(
145 target: &str,
146 password: Option<String>,
147 steps: &[ApplyStep],
148) -> Result<ApplyReport, String> {
149 let (user, host) = target
150 .split_once('@')
151 .ok_or_else(|| format!("host inválido (esperaba usuario@host): {target}"))?;
152 let auth = match password {
153 Some(pw) => SshAuth::Password(pw),
154 None => {
155 let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into());
156 SshAuth::Key {
157 path: PathBuf::from(format!("{home}/.ssh/id_ed25519")),
158 passphrase: None,
159 }
160 }
161 };
162 let config = SshConfig::new(host, user, auth);
163 let linker = Linker::connect(&config)
164 .await
165 .map_err(|e| format!("conexión SSH: {e}"))?;
166 Ok(linker.apply(steps).await)
167}
168
169fn run() -> Result<(), String> {
170 match Cli::parse().cmd {
171 Cmd::Example => {
172 let json = serde_json::to_string_pretty(&example_inventory())
173 .map_err(|e| e.to_string())?;
174 println!("{json}");
175 }
176
177 Cmd::Plan { inventory, current, discover } => {
178 let desired = load(&inventory)?;
179 let p = plan(¤t_inventory(discover, ¤t, &desired)?, &desired);
180 if p.is_empty() {
181 println!("Sin cambios: el servidor ya está al día.");
182 } else {
183 for (i, action) in p.actions.iter().enumerate() {
184 println!("{:>2}. {}", i + 1, action.describe());
185 }
186 println!(
187 "\n{} acciones — {} crear, {} actualizar, {} eliminar.",
188 p.len(),
189 p.count(Op::Create),
190 p.count(Op::Update),
191 p.count(Op::Remove),
192 );
193 }
194 }
195
196 Cmd::Script { inventory, current, discover } => {
197 let desired = load(&inventory)?;
198 let p = plan(¤t_inventory(discover, ¤t, &desired)?, &desired);
199 print!("{}", steps_to_script(&plan_to_steps(&p, &desired)));
200 }
201
202 Cmd::Apply { inventory, current, discover, dry_run, host, password } => {
203 let desired = load(&inventory)?;
204 let p = plan(¤t_inventory(discover, ¤t, &desired)?, &desired);
205 let steps = plan_to_steps(&p, &desired);
206 if steps.is_empty() {
207 println!("Sin cambios: nada que aplicar.");
208 return Ok(());
209 }
210 let report = if dry_run {
211 println!("— simulación (no se toca nada) —");
212 matilda_ghost::dry_run(&steps)
213 } else if let Some(target) = host {
214 println!("— aplicando en {target} por SSH —");
215 let rt = tokio::runtime::Runtime::new().map_err(|e| e.to_string())?;
216 rt.block_on(apply_remote(&target, password, &steps))?
217 } else {
218 println!("— aplicando localmente —");
219 matilda_ghost::apply(&steps)
220 };
221 print_report(&report);
222 if !report.all_ok() {
223 return Err("la aplicación falló".into());
224 }
225 }
226 }
227 Ok(())
228}
229
230fn main() -> ExitCode {
231 match run() {
232 Ok(()) => ExitCode::SUCCESS,
233 Err(e) => {
234 eprintln!("error: {e}");
235 ExitCode::FAILURE
236 }
237 }
238}