Skip to main content

ryra_test/
executor.rs

1use std::path::Path;
2use std::time::Duration;
3
4use anyhow::{Context, Result};
5use async_trait::async_trait;
6use ryra_vm::machine::{ExecOutput, Machine, scp_dir_from_vm};
7
8/// Abstraction over where commands run — VM (SSH) or local host.
9#[async_trait]
10pub trait Executor: Send + Sync {
11    /// Run a command and return its output. Fails if exit code != 0.
12    async fn exec(&self, cmd: &str) -> Result<ExecOutput>;
13
14    /// Run a command, streaming stdout/stderr to the terminal.
15    async fn exec_streaming(&self, cmd: &str, prefix: &str) -> Result<ExecOutput>;
16
17    /// Wait for a systemd user service to become active. `prefix` is the line
18    /// prefix for the wait's progress heartbeat, so it aligns with surrounding
19    /// test output (e.g. `"[my-test]     "`).
20    async fn wait_for_service(&self, unit: &str, timeout: Duration, prefix: &str) -> Result<()>;
21
22    /// Copy a directory from the execution environment to the host.
23    /// No-op for local execution (files are already on the host).
24    async fn fetch_dir(&self, remote_path: &str, local_path: &Path) -> Result<()>;
25
26    /// Directory, *in this executor's environment*, where a browser step's
27    /// Playwright HTML report should be written. The runner points Playwright
28    /// here and then [`fetch_dir`]s it into the host's canonical reports dir.
29    /// For local execution the two are the same path (so the fetch is a no-op);
30    /// for a VM it's a VM-internal staging path that gets copied out.
31    fn playwright_out_dir(&self, test_name: &str) -> String;
32}
33
34/// Path inside the VM where the test runner scps the registry fixtures.
35/// Set as `RYRA_REGISTRY_DIR` so every `ryra` invocation resolves the
36/// default registry to this local copy instead of cloning from GitHub.
37pub const VM_REGISTRY_PATH: &str = "/opt/ryra-test-registry";
38
39/// `podman inspect --format` template: prints `yes` when the container
40/// declares a healthcheck, `no` otherwise. The wait loop uses this to decide
41/// whether to actively probe health (`podman healthcheck run`, which forces an
42/// immediate check rather than waiting for the scheduled interval) or fall
43/// back to unit-active. Interpolated as a value, so its Go-template `{{…}}`
44/// braces aren't reprocessed by Rust's `format!`.
45const HEALTH_DEFINED_FMT: &str = "{{if .State.Health}}yes{{else}}no{{end}}";
46
47/// Build the env-var prefix the executors prepend to every command so
48/// `ryra` resolves the default registry against the test fixtures dir
49/// instead of cloning from the internet on each test.
50fn registry_env_prefix(path: &str) -> String {
51    format!("export {}={}; ", ryra_core::REGISTRY_DIR_ENV, path)
52}
53
54/// Executes commands inside a QEMU VM via SSH.
55pub struct VmExecutor<'a> {
56    pub vm: &'a Machine,
57    env_prefix: String,
58}
59
60impl<'a> VmExecutor<'a> {
61    pub fn new(vm: &'a Machine) -> Self {
62        Self {
63            vm,
64            env_prefix: registry_env_prefix(VM_REGISTRY_PATH),
65        }
66    }
67}
68
69#[async_trait]
70impl Executor for VmExecutor<'_> {
71    async fn exec(&self, cmd: &str) -> Result<ExecOutput> {
72        let wrapped = format!("{}{cmd}", self.env_prefix);
73        self.vm.exec(&wrapped).await
74    }
75
76    async fn exec_streaming(&self, cmd: &str, prefix: &str) -> Result<ExecOutput> {
77        let wrapped = format!("{}{cmd}", self.env_prefix);
78        self.vm.exec_streaming(&wrapped, prefix).await
79    }
80
81    async fn wait_for_service(&self, unit: &str, timeout: Duration, prefix: &str) -> Result<()> {
82        self.vm.wait_for_service(unit, timeout, prefix).await
83    }
84
85    async fn fetch_dir(&self, remote_path: &str, local_path: &Path) -> Result<()> {
86        // Ensure the local parent dir exists so scp writes into local_path/
87        if let Some(parent) = local_path.parent() {
88            tokio::fs::create_dir_all(parent).await.ok();
89        }
90        scp_dir_from_vm(self.vm, remote_path, local_path).await
91    }
92
93    fn playwright_out_dir(&self, test_name: &str) -> String {
94        // VM-internal staging path; the runner copies it to the host's
95        // reports dir afterwards. The VM user is always `ryra`.
96        format!("/home/ryra/.local/share/services-test/reports/{test_name}/playwright")
97    }
98}
99
100/// Executes commands directly on the host machine.
101///
102/// Carries an optional `RYRA_REGISTRY_DIR` override prepended to every
103/// command — bare-mode tests use this to point ryra at the in-repo
104/// `registry/` checkout instead of cloning from GitHub on each run.
105pub struct LocalExecutor {
106    env_prefix: String,
107}
108
109impl LocalExecutor {
110    /// Construct a LocalExecutor with no registry override. Used by commands
111    /// that run `ryra` but never resolve the registry (e.g. `ryra remove`
112    /// during `ryra test reset`); most callers want
113    /// [`LocalExecutor::with_registry`].
114    pub fn new() -> Self {
115        let mut env_prefix = String::new();
116        // Run the *same* ryra we're part of, not whatever (possibly stale)
117        // `ryra` is on the user's PATH. `ryra test` is a subcommand of the ryra
118        // binary, so the running executable IS the up-to-date ryra — put its
119        // directory first on PATH so `ryra add/remove/...` in tests resolve to
120        // it. Without this, `cargo run test` would drive an old installed ryra
121        // that doesn't honour RYRA_DATA_DIR/RYRA_CONFIG_DIR and the sandbox
122        // would silently diverge from where data actually lands.
123        if let Ok(exe) = std::env::current_exe()
124            && let Some(dir) = exe.parent()
125        {
126            env_prefix.push_str(&format!("export PATH=\"{}:$PATH\"; ", dir.display()));
127        }
128        Self { env_prefix }
129    }
130
131    /// Construct a LocalExecutor that prepends `RYRA_REGISTRY_DIR=<path>`
132    /// to every command, so `ryra` resolves the default registry to the
133    /// local checkout under test.
134    pub fn with_registry(registry_path: &Path) -> Self {
135        let mut s = Self::new();
136        s.env_prefix
137            .push_str(&registry_env_prefix(&registry_path.display().to_string()));
138        s
139    }
140
141    /// Also export `RYRA_CONFIG_DIR=<path>` on every command, isolating
142    /// `preferences.toml` into a throwaway dir so host tests never read or
143    /// clobber the user's real SMTP/auth/backup credentials.
144    pub fn with_config_dir(mut self, config_dir: &Path) -> Self {
145        self.env_prefix.push_str(&format!(
146            "export {}={}; ",
147            ryra_core::CONFIG_DIR_ENV,
148            config_dir.display()
149        ));
150        self
151    }
152
153    /// Also export `RYRA_DATA_DIR=<path>` on every command, so test service
154    /// deployments (data, `.env`, configs, and the quadlet files ryra writes
155    /// into `service_home`) land in the sandbox instead of the user's real
156    /// `~/.local/share/services/`.
157    pub fn with_data_dir(mut self, data_dir: &Path) -> Self {
158        self.env_prefix.push_str(&format!(
159            "export {}={}; ",
160            ryra_core::DATA_DIR_ENV,
161            data_dir.display()
162        ));
163        self
164    }
165}
166
167impl Default for LocalExecutor {
168    fn default() -> Self {
169        Self::new()
170    }
171}
172
173#[async_trait]
174impl Executor for LocalExecutor {
175    async fn exec(&self, cmd: &str) -> Result<ExecOutput> {
176        let wrapped = format!("{}{cmd}", self.env_prefix);
177        let output = tokio::process::Command::new("bash")
178            .args(["-c", &wrapped])
179            .output()
180            .await
181            .with_context(|| format!("failed to exec locally: {cmd}"))?;
182
183        let stdout = String::from_utf8_lossy(&output.stdout).to_string();
184        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
185
186        if !output.status.success() {
187            anyhow::bail!(
188                "command failed locally (exit {}): {cmd}\nstdout: {stdout}\nstderr: {stderr}",
189                output.status,
190            );
191        }
192
193        Ok(ExecOutput { stdout, stderr })
194    }
195
196    async fn exec_streaming(&self, cmd: &str, prefix: &str) -> Result<ExecOutput> {
197        use tokio::io::{AsyncBufReadExt, BufReader};
198
199        let wrapped = format!("{}{cmd}", self.env_prefix);
200        let mut child = tokio::process::Command::new("bash")
201            .args(["-c", &wrapped])
202            .stdout(std::process::Stdio::piped())
203            .stderr(std::process::Stdio::piped())
204            .spawn()
205            .with_context(|| format!("failed to exec locally: {cmd}"))?;
206
207        let stdout_pipe = child.stdout.take();
208        let stderr_pipe = child.stderr.take();
209
210        let prefix_out = prefix.to_string();
211        let prefix_err = prefix.to_string();
212
213        let stdout_handle = tokio::spawn(async move {
214            let mut lines = String::new();
215            if let Some(pipe) = stdout_pipe {
216                let mut reader = BufReader::new(pipe).lines();
217                while let Ok(Some(line)) = reader.next_line().await {
218                    if prefix_out.is_empty() {
219                        println!("    {line}");
220                    } else {
221                        println!("[{prefix_out}]     {line}");
222                    }
223                    lines.push_str(&line);
224                    lines.push('\n');
225                }
226            }
227            lines
228        });
229
230        let stderr_handle = tokio::spawn(async move {
231            let mut lines = String::new();
232            if let Some(pipe) = stderr_pipe {
233                let mut reader = BufReader::new(pipe).lines();
234                while let Ok(Some(line)) = reader.next_line().await {
235                    if prefix_err.is_empty() {
236                        eprintln!("    {line}");
237                    } else {
238                        eprintln!("[{prefix_err}]     {line}");
239                    }
240                    lines.push_str(&line);
241                    lines.push('\n');
242                }
243            }
244            lines
245        });
246
247        let status = child.wait().await?;
248        let stdout_buf = stdout_handle.await.context("stdout reader task panicked")?;
249        let stderr_buf = stderr_handle.await.context("stderr reader task panicked")?;
250
251        if !status.success() {
252            anyhow::bail!(
253                "command failed locally (exit {}): {cmd}\nstdout: {stdout_buf}\nstderr: {stderr_buf}",
254                status,
255            );
256        }
257
258        Ok(ExecOutput {
259            stdout: stdout_buf,
260            stderr: stderr_buf,
261        })
262    }
263
264    async fn wait_for_service(&self, unit: &str, timeout: Duration, prefix: &str) -> Result<()> {
265        // The container is named after the unit (ContainerName=<svc>).
266        let container = unit.trim_end_matches(".service");
267        let mut progress =
268            ryra_vm::progress::WaitProgress::new(unit, "systemctl + healthcheck", timeout)
269                .with_prefix(prefix);
270        loop {
271            // One round-trip: unit active/failed + an *active* health probe.
272            // When the container declares a healthcheck we run it immediately
273            // (`podman healthcheck run`) instead of reading the passively-
274            // scheduled status — otherwise we'd wait up to the health interval
275            // (often 30s) for the first check. No healthcheck → "none".
276            let cmd = format!(
277                "a=$(systemctl --user is-active {unit} 2>/dev/null || true); \
278                 f=$(systemctl --user is-failed {unit} 2>/dev/null || true); \
279                 if [ \"$(podman inspect --format '{HEALTH_DEFINED_FMT}' {container} 2>/dev/null)\" = yes ]; then \
280                   podman healthcheck run {container} >/dev/null 2>&1 && h=healthy || h=unhealthy; \
281                 else h=none; fi; \
282                 echo \"$a|$f|$h\""
283            );
284            let out = self.exec(&cmd).await?;
285            let line = out.stdout.trim();
286            let mut parts = line.split('|');
287            let active = parts.next().unwrap_or("");
288            let failed = parts.next().unwrap_or("");
289            let health = parts.next().unwrap_or("");
290
291            if failed == "failed" {
292                anyhow::bail!("service {unit} entered failed state");
293            }
294            if active == "active" {
295                // Health-aware readiness: when the container declares a
296                // HealthCmd, wait for `healthy` rather than just "unit
297                // started" — that's what makes the next step reliable on slow
298                // machines and catches services that start then die (the unit
299                // flips active for a beat before a fatal startup check). No
300                // healthcheck → unit-active is the best signal we have.
301                match health {
302                    "healthy" | "none" | "" => return Ok(()),
303                    // "starting" / "unhealthy" — keep polling until timeout.
304                    _ => {}
305                }
306            }
307            if progress.timed_out() {
308                anyhow::bail!(
309                    "service {unit} not ready after {}s (active={active}, health={health})",
310                    timeout.as_secs()
311                );
312            }
313            progress.tick();
314            tokio::time::sleep(Duration::from_secs(1)).await;
315        }
316    }
317
318    async fn fetch_dir(&self, _remote_path: &str, _local_path: &Path) -> Result<()> {
319        // No-op: in local mode the "remote" path is already on the host.
320        Ok(())
321    }
322
323    fn playwright_out_dir(&self, test_name: &str) -> String {
324        // Local mode: write straight to the host's canonical reports dir, so
325        // the subsequent no-op fetch finds it already in place.
326        crate::reports::reports_dir()
327            .map(|d| d.join(test_name).join("playwright").display().to_string())
328            .unwrap_or_else(|_| format!("/tmp/ryra-test-reports/{test_name}/playwright"))
329    }
330}
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335
336    #[tokio::test]
337    async fn local_executor_runs_command() {
338        let exec = LocalExecutor::default();
339        let out = exec.exec("echo hello").await.unwrap();
340        assert_eq!(out.stdout.trim(), "hello");
341    }
342
343    #[tokio::test]
344    async fn local_executor_fails_on_bad_command() {
345        let exec = LocalExecutor::default();
346        let result = exec.exec("false").await;
347        assert!(result.is_err());
348    }
349
350    #[tokio::test]
351    async fn local_executor_streams_output() {
352        let exec = LocalExecutor::default();
353        let out = exec.exec_streaming("echo streamed", "test").await.unwrap();
354        assert_eq!(out.stdout.trim(), "streamed");
355    }
356
357    #[tokio::test]
358    async fn local_wait_for_nonexistent_service_times_out() {
359        let exec = LocalExecutor::default();
360        let result = exec
361            .wait_for_service(
362                "definitely-not-a-real-unit-xyz123.service",
363                Duration::from_secs(2),
364                "  ",
365            )
366            .await;
367        assert!(result.is_err());
368        // Should time out or report non-active, not hang
369    }
370}