gioser

tawasuyu/brahman

Rust 582TOML 222Markdown 73JSON 198629 Sutichaykuna

⎇ df8f92fbb0d4c9cfc377c810341794766db51094 crates/apps/matilda/src/main.rs Rust · 219 siq'ikuna · 7.1 KB Ñawpa willay Pi imata llamkarqan ch'uya

feat(matilda): ghost + linker + CLI — el ciclo completo de aplicación df8f92fbb · sergio ·

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(&current)?, &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(&current)?, &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(&current)?, &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}