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 that prepends `RYRA_REGISTRY_DIR=<path>`
111    /// to every command, so `ryra` resolves the default registry to the
112    /// local checkout under test.
113    pub fn with_registry(registry_path: &Path) -> Self {
114        let mut env_prefix = String::new();
115        // Run the *same* ryra we're part of, not whatever (possibly stale)
116        // `ryra` is on the user's PATH. `ryra test` is a subcommand of the ryra
117        // binary, so the running executable IS the up-to-date ryra — put its
118        // directory first on PATH so `ryra add/remove/...` in tests resolve to
119        // it. Without this, `cargo run test` would drive an old installed ryra
120        // that doesn't honour RYRA_DATA_DIR/RYRA_CONFIG_DIR and the sandbox
121        // would silently diverge from where data actually lands.
122        if let Ok(exe) = std::env::current_exe()
123            && let Some(dir) = exe.parent()
124        {
125            env_prefix.push_str(&format!("export PATH=\"{}:$PATH\"; ", dir.display()));
126        }
127        env_prefix.push_str(&registry_env_prefix(&registry_path.display().to_string()));
128        Self { env_prefix }
129    }
130
131    /// Also export `RYRA_CONFIG_DIR=<path>` on every command, isolating
132    /// `preferences.toml` into a throwaway dir so host tests never read or
133    /// clobber the user's real SMTP/auth/backup credentials.
134    pub fn with_config_dir(mut self, config_dir: &Path) -> Self {
135        self.env_prefix.push_str(&format!(
136            "export {}={}; ",
137            ryra_core::CONFIG_DIR_ENV,
138            config_dir.display()
139        ));
140        self
141    }
142
143    /// Also export `RYRA_DATA_DIR=<path>` on every command, so test service
144    /// deployments (data, `.env`, configs, and the quadlet files ryra writes
145    /// into `service_home`) land in the sandbox instead of the user's real
146    /// `~/.local/share/services/`.
147    pub fn with_data_dir(mut self, data_dir: &Path) -> Self {
148        self.env_prefix.push_str(&format!(
149            "export {}={}; ",
150            ryra_core::DATA_DIR_ENV,
151            data_dir.display()
152        ));
153        self
154    }
155}
156
157impl Default for LocalExecutor {
158    /// LocalExecutor with no registry override — only useful when the
159    /// commands run don't touch `ryra`. Most callers want
160    /// [`LocalExecutor::with_registry`].
161    fn default() -> Self {
162        Self {
163            env_prefix: String::new(),
164        }
165    }
166}
167
168#[async_trait]
169impl Executor for LocalExecutor {
170    async fn exec(&self, cmd: &str) -> Result<ExecOutput> {
171        let wrapped = format!("{}{cmd}", self.env_prefix);
172        let output = tokio::process::Command::new("bash")
173            .args(["-c", &wrapped])
174            .output()
175            .await
176            .with_context(|| format!("failed to exec locally: {cmd}"))?;
177
178        let stdout = String::from_utf8_lossy(&output.stdout).to_string();
179        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
180
181        if !output.status.success() {
182            anyhow::bail!(
183                "command failed locally (exit {}): {cmd}\nstdout: {stdout}\nstderr: {stderr}",
184                output.status,
185            );
186        }
187
188        Ok(ExecOutput { stdout, stderr })
189    }
190
191    async fn exec_streaming(&self, cmd: &str, prefix: &str) -> Result<ExecOutput> {
192        use tokio::io::{AsyncBufReadExt, BufReader};
193
194        let wrapped = format!("{}{cmd}", self.env_prefix);
195        let mut child = tokio::process::Command::new("bash")
196            .args(["-c", &wrapped])
197            .stdout(std::process::Stdio::piped())
198            .stderr(std::process::Stdio::piped())
199            .spawn()
200            .with_context(|| format!("failed to exec locally: {cmd}"))?;
201
202        let stdout_pipe = child.stdout.take();
203        let stderr_pipe = child.stderr.take();
204
205        let prefix_out = prefix.to_string();
206        let prefix_err = prefix.to_string();
207
208        let stdout_handle = tokio::spawn(async move {
209            let mut lines = String::new();
210            if let Some(pipe) = stdout_pipe {
211                let mut reader = BufReader::new(pipe).lines();
212                while let Ok(Some(line)) = reader.next_line().await {
213                    if prefix_out.is_empty() {
214                        println!("    {line}");
215                    } else {
216                        println!("[{prefix_out}]     {line}");
217                    }
218                    lines.push_str(&line);
219                    lines.push('\n');
220                }
221            }
222            lines
223        });
224
225        let stderr_handle = tokio::spawn(async move {
226            let mut lines = String::new();
227            if let Some(pipe) = stderr_pipe {
228                let mut reader = BufReader::new(pipe).lines();
229                while let Ok(Some(line)) = reader.next_line().await {
230                    if prefix_err.is_empty() {
231                        eprintln!("    {line}");
232                    } else {
233                        eprintln!("[{prefix_err}]     {line}");
234                    }
235                    lines.push_str(&line);
236                    lines.push('\n');
237                }
238            }
239            lines
240        });
241
242        let status = child.wait().await?;
243        let stdout_buf = stdout_handle.await.context("stdout reader task panicked")?;
244        let stderr_buf = stderr_handle.await.context("stderr reader task panicked")?;
245
246        if !status.success() {
247            anyhow::bail!(
248                "command failed locally (exit {}): {cmd}\nstdout: {stdout_buf}\nstderr: {stderr_buf}",
249                status,
250            );
251        }
252
253        Ok(ExecOutput {
254            stdout: stdout_buf,
255            stderr: stderr_buf,
256        })
257    }
258
259    async fn wait_for_service(&self, unit: &str, timeout: Duration, prefix: &str) -> Result<()> {
260        // The container is named after the unit (ContainerName=<svc>).
261        let container = unit.trim_end_matches(".service");
262        let mut progress =
263            ryra_vm::progress::WaitProgress::new(unit, "systemctl + healthcheck", timeout)
264                .with_prefix(prefix);
265        loop {
266            // One round-trip: unit active/failed + an *active* health probe.
267            // When the container declares a healthcheck we run it immediately
268            // (`podman healthcheck run`) instead of reading the passively-
269            // scheduled status — otherwise we'd wait up to the health interval
270            // (often 30s) for the first check. No healthcheck → "none".
271            let cmd = format!(
272                "a=$(systemctl --user is-active {unit} 2>/dev/null || true); \
273                 f=$(systemctl --user is-failed {unit} 2>/dev/null || true); \
274                 if [ \"$(podman inspect --format '{HEALTH_DEFINED_FMT}' {container} 2>/dev/null)\" = yes ]; then \
275                   podman healthcheck run {container} >/dev/null 2>&1 && h=healthy || h=unhealthy; \
276                 else h=none; fi; \
277                 echo \"$a|$f|$h\""
278            );
279            let out = self.exec(&cmd).await?;
280            let line = out.stdout.trim();
281            let mut parts = line.split('|');
282            let active = parts.next().unwrap_or("");
283            let failed = parts.next().unwrap_or("");
284            let health = parts.next().unwrap_or("");
285
286            if failed == "failed" {
287                anyhow::bail!("service {unit} entered failed state");
288            }
289            if active == "active" {
290                // Health-aware readiness: when the container declares a
291                // HealthCmd, wait for `healthy` rather than just "unit
292                // started" — that's what makes the next step reliable on slow
293                // machines and catches services that start then die (the unit
294                // flips active for a beat before a fatal startup check). No
295                // healthcheck → unit-active is the best signal we have.
296                match health {
297                    "healthy" | "none" | "" => return Ok(()),
298                    // "starting" / "unhealthy" — keep polling until timeout.
299                    _ => {}
300                }
301            }
302            if progress.timed_out() {
303                anyhow::bail!(
304                    "service {unit} not ready after {}s (active={active}, health={health})",
305                    timeout.as_secs()
306                );
307            }
308            progress.tick();
309            tokio::time::sleep(Duration::from_secs(1)).await;
310        }
311    }
312
313    async fn fetch_dir(&self, _remote_path: &str, _local_path: &Path) -> Result<()> {
314        // No-op: in local mode the "remote" path is already on the host.
315        Ok(())
316    }
317
318    fn playwright_out_dir(&self, test_name: &str) -> String {
319        // Local mode: write straight to the host's canonical reports dir, so
320        // the subsequent no-op fetch finds it already in place.
321        crate::reports::reports_dir()
322            .map(|d| d.join(test_name).join("playwright").display().to_string())
323            .unwrap_or_else(|_| format!("/tmp/ryra-test-reports/{test_name}/playwright"))
324    }
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330
331    #[tokio::test]
332    async fn local_executor_runs_command() {
333        let exec = LocalExecutor::default();
334        let out = exec.exec("echo hello").await.unwrap();
335        assert_eq!(out.stdout.trim(), "hello");
336    }
337
338    #[tokio::test]
339    async fn local_executor_fails_on_bad_command() {
340        let exec = LocalExecutor::default();
341        let result = exec.exec("false").await;
342        assert!(result.is_err());
343    }
344
345    #[tokio::test]
346    async fn local_executor_streams_output() {
347        let exec = LocalExecutor::default();
348        let out = exec.exec_streaming("echo streamed", "test").await.unwrap();
349        assert_eq!(out.stdout.trim(), "streamed");
350    }
351
352    #[tokio::test]
353    async fn local_wait_for_nonexistent_service_times_out() {
354        let exec = LocalExecutor::default();
355        let result = exec
356            .wait_for_service(
357                "definitely-not-a-real-unit-xyz123.service",
358                Duration::from_secs(2),
359                "  ",
360            )
361            .await;
362        assert!(result.is_err());
363        // Should time out or report non-active, not hang
364    }
365}