tawasuyu

tawasuyu/brahman

Rust 582TOML 222Markdown 83JSON 198635 Definitions

⎇ df8f92fbb0d4c9cfc377c810341794766db51094 crates/modules/matilda/matilda-linker/src/lib.rs Rust · 138 lines · 4.8 KB History Who touched what raw

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

1//! `matilda-linker` — el enlace SSH que aplica un plan en un servidor.
2//!
3//! El [`Linker`] conecta a un host vía `brahman-ssh-multiplex` y aplica
4//! los [`ApplyStep`]s **remotamente**: escribe los archivos (con un
5//! heredoc) y corre los comandos, cada uno sobre la conexión SSH
6//! multiplexada. Produce el mismo [`ApplyReport`] que `matilda-ghost`,
7//! así el consumidor no distingue aplicación local de remota.
8//!
9//! La prueba real necesita un servidor SSH — se hace fuera del unit
10//! test. Lo puro y testeable es la construcción del comando de escritura.
11
12#![forbid(unsafe_code)]
13
14use matilda_apply::{ApplyStep, FileWrite};
15use matilda_ghost::{ApplyReport, StepResult};
16
17pub use brahman_ssh_multiplex::{SshAuth, SshConfig, SshError};
18use brahman_ssh_multiplex::SshSession;
19
20/// Marcador de heredoc para escribir archivos remotos.
21const HEREDOC: &str = "MATILDA_LINKER_EOF";
22
23/// Comando de shell que escribe `f.content` en `f.path` del host remoto.
24fn file_write_command(f: &FileWrite) -> String {
25    format!(
26        "cat > '{}' <<'{HEREDOC}'\n{}\n{HEREDOC}",
27        f.path, f.content
28    )
29}
30
31/// Enlace activo a un servidor: una sesión SSH multiplexada.
32pub struct Linker {
33    session: SshSession,
34}
35
36impl Linker {
37    /// Conecta y autentica contra el host descrito por `config`.
38    pub async fn connect(config: &SshConfig) -> Result<Linker, SshError> {
39        Ok(Linker { session: SshSession::connect(config).await? })
40    }
41
42    /// Aplica un paso en el host remoto: escribe sus archivos, corre sus
43    /// comandos. Se detiene en el primer error.
44    async fn apply_step(&self, step: &ApplyStep) -> StepResult {
45        let mut log = Vec::new();
46        let mut ok = true;
47
48        for f in &step.files {
49            match self.session.exec(&file_write_command(f)).await {
50                Ok(out) if out.exit_code == 0 => log.push(format!("✔ escrito {}", f.path)),
51                Ok(out) => {
52                    log.push(format!(
53                        "✘ escribir {}: {}",
54                        f.path,
55                        String::from_utf8_lossy(&out.stderr).trim()
56                    ));
57                    ok = false;
58                    break;
59                }
60                Err(e) => {
61                    log.push(format!("✘ {e}"));
62                    ok = false;
63                    break;
64                }
65            }
66        }
67
68        if ok {
69            for cmd in &step.commands {
70                log.push(format!("$ {cmd}"));
71                match self.session.exec(cmd).await {
72                    Ok(out) => {
73                        for l in String::from_utf8_lossy(&out.stdout).lines() {
74                            log.push(format!("  {l}"));
75                        }
76                        for l in String::from_utf8_lossy(&out.stderr).lines() {
77                            log.push(format!("  {l}"));
78                        }
79                        if out.exit_code != 0 {
80                            log.push(format!("✘ el comando salió con código {}", out.exit_code));
81                            ok = false;
82                            break;
83                        }
84                    }
85                    Err(e) => {
86                        log.push(format!("✘ no se pudo ejecutar: {e}"));
87                        ok = false;
88                        break;
89                    }
90                }
91            }
92        }
93
94        StepResult { describe: step.describe.clone(), ok, log }
95    }
96
97    /// Aplica los pasos en orden sobre el host remoto. Se detiene en el
98    /// primero que falle (semántica `set -e`).
99    pub async fn apply(&self, steps: &[ApplyStep]) -> ApplyReport {
100        let mut results = Vec::new();
101        for step in steps {
102            let result = self.apply_step(step).await;
103            let failed = !result.ok;
104            results.push(result);
105            if failed {
106                break;
107            }
108        }
109        ApplyReport { results }
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    #[test]
118    fn file_write_command_uses_a_heredoc() {
119        let f = FileWrite {
120            path: "/etc/nginx/sites-enabled/site.conf".into(),
121            content: "server { listen 80; }".into(),
122        };
123        let cmd = file_write_command(&f);
124        assert!(cmd.starts_with("cat > '/etc/nginx/sites-enabled/site.conf' <<'"));
125        assert!(cmd.contains("server { listen 80; }"));
126        assert!(cmd.ends_with(HEREDOC));
127    }
128
129    #[test]
130    fn ssh_config_is_re_exported() {
131        // El consumidor arma la conexión sin depender de ssh-multiplex.
132        let c = SshConfig::new("srv.example", "deploy", SshAuth::Password("x".into()));
133        assert_eq!(c.host, "srv.example");
134    }
135
136    // La aplicación remota real (`Linker::connect` + `apply`) necesita un
137    // servidor SSH — se prueba fuera del unit test.
138}