tawasuyu

tawasuyu/brahman

Rust 582TOML 222Markdown 73JSON 198635 Definitions

⎇ 93e003be0d06732c5eab3bd3467363a593803ba1 crates/apps/matilda/src/main.rs Rust · 238 lines · 7.8 KB History Who touched what raw

feat(matilda): matilda-discover — estado actual del servidor 93e003be0 · 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        /// 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(&current_inventory(discover, &current, &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(&current_inventory(discover, &current, &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(&current_inventory(discover, &current, &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}