Skip to main content

torrust_tracker_deployer_lib/testing/e2e/
process_runner.rs

1//! External Process Execution
2//!
3//! Provides utilities for running the production application as an external
4//! process for black-box testing.
5
6use anyhow::{Context, Result};
7use std::path::{Path, PathBuf};
8use std::process::{Command, Output};
9
10/// Runs the production application as an external process
11///
12/// This struct provides methods for executing the application binary
13/// with different command-line arguments for black-box testing.
14///
15/// By default the runner falls back to `cargo run --` so it can be used
16/// from `src/bin/` programs that do not have a pre-built binary available.
17/// In integration tests (`tests/`), you should always call
18/// [`with_binary`](Self::with_binary) with
19/// `env!("CARGO_BIN_EXE_torrust-tracker-deployer")` so that Cargo's
20/// pre-built binary is used directly, eliminating ~13 s of `cargo run`
21/// startup overhead per invocation.
22pub struct ProcessRunner {
23    working_dir: Option<PathBuf>,
24    log_dir: Option<PathBuf>,
25    /// Path to the pre-built binary. When `None`, falls back to `cargo run`.
26    binary: Option<PathBuf>,
27}
28
29impl ProcessRunner {
30    /// Create a new process runner
31    ///
32    /// Falls back to `cargo run --` for executing the application. In
33    /// integration tests prefer [`with_binary`](Self::with_binary) to avoid
34    /// the ~13 s `cargo run` startup overhead.
35    #[must_use]
36    pub fn new() -> Self {
37        Self {
38            working_dir: None,
39            log_dir: None,
40            binary: None,
41        }
42    }
43
44    /// Set the pre-built binary to use instead of `cargo run`.
45    ///
46    /// In integration tests pass `env!("CARGO_BIN_EXE_torrust-tracker-deployer")`
47    /// here. Cargo automatically builds the binary before running the
48    /// integration test, so the binary is always up-to-date.
49    #[must_use]
50    pub fn with_binary<P: AsRef<Path>>(mut self, binary: P) -> Self {
51        self.binary = Some(binary.as_ref().to_path_buf());
52        self
53    }
54
55    /// Build the base [`Command`] for the application.
56    ///
57    /// When a binary path is set, returns `Command::new(binary)`.
58    /// Otherwise returns `Command::new("cargo")` pre-loaded with
59    /// `["run", "--"]` so callers only need to append sub-command args.
60    fn make_command(&self) -> Command {
61        if let Some(binary) = &self.binary {
62            Command::new(binary)
63        } else {
64            let mut cmd = Command::new("cargo");
65            cmd.args(["run", "--"]);
66            cmd
67        }
68    }
69
70    /// Set the working directory for the test process (not the app working dir)
71    ///
72    /// This is the directory where the test command will be executed from,
73    /// typically a temporary directory for test isolation.
74    #[must_use]
75    pub fn working_dir<P: AsRef<Path>>(mut self, dir: P) -> Self {
76        self.working_dir = Some(dir.as_ref().to_path_buf());
77        self
78    }
79
80    /// Set the log directory for the application
81    ///
82    /// This is passed as `--log-dir` to the application to control where
83    /// logs are written, enabling test isolation.
84    #[must_use]
85    pub fn log_dir<P: AsRef<Path>>(mut self, dir: P) -> Self {
86        self.log_dir = Some(dir.as_ref().to_path_buf());
87        self
88    }
89
90    /// Run the create command with the production binary
91    ///
92    /// This method runs `create environment --env-file <config_file>` with
93    /// optional working directory for the application itself via `--working-dir`.
94    ///
95    /// # Errors
96    ///
97    /// Returns an error if the command fails to execute.
98    ///
99    /// # Panics
100    ///
101    /// Panics if the working directory or config file path contains invalid UTF-8.
102    pub fn run_create_command(&self, config_file: &str) -> Result<ProcessResult> {
103        let mut cmd = self.make_command();
104        // If working directory is specified, we need to:
105        // 1. Make the config file path absolute (the binary runs from project root)
106        // 2. Pass --working-dir to tell the app where to store data
107        if let Some(working_dir) = &self.working_dir {
108            // Convert config file to absolute path
109            let absolute_config = if config_file.starts_with("./") {
110                working_dir.join(config_file.trim_start_matches("./"))
111            } else {
112                working_dir.join(config_file)
113            };
114
115            // Build command with absolute paths
116            cmd.args([
117                "create",
118                "environment",
119                "--env-file",
120                absolute_config.to_str().unwrap(),
121                "--working-dir",
122                working_dir.to_str().unwrap(),
123            ]);
124        } else {
125            // No working directory, use relative paths
126            cmd.args(["create", "environment", "--env-file", config_file]);
127        }
128
129        // Add log-dir if specified
130        if let Some(log_dir) = &self.log_dir {
131            cmd.arg("--log-dir");
132            cmd.arg(log_dir);
133        }
134
135        let output = cmd.output().context("Failed to execute create command")?;
136
137        Ok(ProcessResult::new(output))
138    }
139
140    /// Run the provision command with the production binary
141    ///
142    /// This method runs `provision <environment_name>` with
143    /// optional working directory for the application itself via `--working-dir`.
144    ///
145    /// # Errors
146    ///
147    /// Returns an error if the command fails to execute.
148    ///
149    /// # Panics
150    ///
151    /// Panics if the working directory path contains invalid UTF-8.
152    pub fn run_provision_command(&self, environment_name: &str) -> Result<ProcessResult> {
153        let mut cmd = self.make_command();
154
155        if let Some(working_dir) = &self.working_dir {
156            // Build command with working directory
157            cmd.args([
158                "provision",
159                environment_name,
160                "--working-dir",
161                working_dir.to_str().unwrap(),
162            ]);
163        } else {
164            cmd.args(["provision", environment_name]);
165        }
166
167        // Add log-dir if specified
168        if let Some(log_dir) = &self.log_dir {
169            cmd.arg("--log-dir");
170            cmd.arg(log_dir);
171        }
172
173        let output = cmd
174            .output()
175            .context("Failed to execute provision command")?;
176
177        Ok(ProcessResult::new(output))
178    }
179
180    /// Run the destroy command with the production binary
181    ///
182    /// This method runs `destroy <environment_name>` with
183    /// optional working directory for the application itself via `--working-dir`.
184    ///
185    /// # Errors
186    ///
187    /// Returns an error if the command fails to execute.
188    ///
189    /// # Panics
190    ///
191    /// Panics if the working directory path contains invalid UTF-8.
192    pub fn run_destroy_command(&self, environment_name: &str) -> Result<ProcessResult> {
193        let mut cmd = self.make_command();
194
195        if let Some(working_dir) = &self.working_dir {
196            cmd.args([
197                "destroy",
198                environment_name,
199                "--working-dir",
200                working_dir.to_str().unwrap(),
201            ]);
202        } else {
203            cmd.args(["destroy", environment_name]);
204        }
205
206        // Add log-dir if specified
207        if let Some(log_dir) = &self.log_dir {
208            cmd.arg("--log-dir");
209            cmd.arg(log_dir);
210        }
211
212        let output = cmd.output().context("Failed to execute destroy command")?;
213
214        Ok(ProcessResult::new(output))
215    }
216
217    /// Run the register command with the production binary
218    ///
219    /// This method runs `register <environment_name> --instance-ip <ip>` with
220    /// optional working directory for the application itself via `--working-dir`.
221    ///
222    /// # Errors
223    ///
224    /// Returns an error if the command fails to execute.
225    ///
226    /// # Panics
227    ///
228    /// Panics if the working directory path contains invalid UTF-8.
229    pub fn run_register_command(
230        &self,
231        environment_name: &str,
232        instance_ip: &str,
233        ssh_port: Option<u16>,
234    ) -> Result<ProcessResult> {
235        let mut cmd = self.make_command();
236        cmd.args(["register", environment_name, "--instance-ip", instance_ip]);
237
238        // Add optional SSH port
239        if let Some(port) = ssh_port {
240            cmd.args(["--ssh-port", &port.to_string()]);
241        }
242
243        // Add working-dir if specified
244        if let Some(working_dir) = &self.working_dir {
245            cmd.args(["--working-dir", working_dir.to_str().unwrap()]);
246        }
247
248        // Add log-dir if specified
249        if let Some(log_dir) = &self.log_dir {
250            cmd.arg("--log-dir");
251            cmd.arg(log_dir);
252        }
253
254        let output = cmd.output().context("Failed to execute register command")?;
255
256        Ok(ProcessResult::new(output))
257    }
258
259    /// Run the configure command with the production binary
260    ///
261    /// This method runs `configure <environment_name>` with
262    /// optional working directory for the application itself via `--working-dir`.
263    ///
264    /// # Errors
265    ///
266    /// Returns an error if the command fails to execute.
267    ///
268    /// # Panics
269    ///
270    /// Panics if the working directory path contains invalid UTF-8.
271    pub fn run_configure_command(&self, environment_name: &str) -> Result<ProcessResult> {
272        let mut cmd = self.make_command();
273
274        if let Some(working_dir) = &self.working_dir {
275            cmd.args([
276                "configure",
277                environment_name,
278                "--working-dir",
279                working_dir.to_str().unwrap(),
280            ]);
281        } else {
282            cmd.args(["configure", environment_name]);
283        }
284
285        // Add log-dir if specified
286        if let Some(log_dir) = &self.log_dir {
287            cmd.arg("--log-dir");
288            cmd.arg(log_dir);
289        }
290
291        let output = cmd
292            .output()
293            .context("Failed to execute configure command")?;
294
295        Ok(ProcessResult::new(output))
296    }
297
298    /// Run the test command with the production binary
299    ///
300    /// This method runs `test <environment_name>` with
301    /// optional working directory for the application itself via `--working-dir`.
302    ///
303    /// # Errors
304    ///
305    /// Returns an error if the command fails to execute.
306    ///
307    /// # Panics
308    ///
309    /// Panics if the working directory path contains invalid UTF-8.
310    pub fn run_test_command(&self, environment_name: &str) -> Result<ProcessResult> {
311        let mut cmd = self.make_command();
312
313        if let Some(working_dir) = &self.working_dir {
314            cmd.args([
315                "test",
316                environment_name,
317                "--working-dir",
318                working_dir.to_str().unwrap(),
319            ]);
320        } else {
321            cmd.args(["test", environment_name]);
322        }
323
324        // Add log-dir if specified
325        if let Some(log_dir) = &self.log_dir {
326            cmd.arg("--log-dir");
327            cmd.arg(log_dir);
328        }
329
330        let output = cmd.output().context("Failed to execute test command")?;
331
332        Ok(ProcessResult::new(output))
333    }
334
335    /// Run the release command with the production binary
336    ///
337    /// This method runs `release <environment_name>` with
338    /// optional working directory for the application itself via `--working-dir`.
339    ///
340    /// # Errors
341    ///
342    /// Returns an error if the command fails to execute.
343    ///
344    /// # Panics
345    ///
346    /// Panics if the working directory path contains invalid UTF-8.
347    pub fn run_release_command(&self, environment_name: &str) -> Result<ProcessResult> {
348        let mut cmd = self.make_command();
349
350        if let Some(working_dir) = &self.working_dir {
351            cmd.args([
352                "release",
353                environment_name,
354                "--working-dir",
355                working_dir.to_str().unwrap(),
356            ]);
357        } else {
358            cmd.args(["release", environment_name]);
359        }
360
361        // Add log-dir if specified
362        if let Some(log_dir) = &self.log_dir {
363            cmd.arg("--log-dir");
364            cmd.arg(log_dir);
365        }
366
367        let output = cmd.output().context("Failed to execute release command")?;
368
369        Ok(ProcessResult::new(output))
370    }
371
372    /// Run the run command with the production binary
373    ///
374    /// This method runs `run <environment_name>` with
375    /// optional working directory for the application itself via `--working-dir`.
376    ///
377    /// # Errors
378    ///
379    /// Returns an error if the command fails to execute.
380    ///
381    /// # Panics
382    ///
383    /// Panics if the working directory path contains invalid UTF-8.
384    pub fn run_run_command(&self, environment_name: &str) -> Result<ProcessResult> {
385        let mut cmd = self.make_command();
386
387        if let Some(working_dir) = &self.working_dir {
388            cmd.args([
389                "run",
390                environment_name,
391                "--working-dir",
392                working_dir.to_str().unwrap(),
393            ]);
394        } else {
395            cmd.args(["run", environment_name]);
396        }
397
398        // Add log-dir if specified
399        if let Some(log_dir) = &self.log_dir {
400            cmd.arg("--log-dir");
401            cmd.arg(log_dir);
402        }
403
404        let output = cmd.output().context("Failed to execute run command")?;
405
406        Ok(ProcessResult::new(output))
407    }
408
409    /// Run the list command with the production binary
410    ///
411    /// This method runs `list` with optional working directory
412    /// for the application itself via `--working-dir`.
413    ///
414    /// # Errors
415    ///
416    /// Returns an error if the command fails to execute.
417    ///
418    /// # Panics
419    ///
420    /// Panics if the working directory path contains invalid UTF-8.
421    pub fn run_list_command(&self) -> Result<ProcessResult> {
422        let mut cmd = self.make_command();
423
424        if let Some(working_dir) = &self.working_dir {
425            cmd.args(["list", "--working-dir", working_dir.to_str().unwrap()]);
426        } else {
427            cmd.arg("list");
428        }
429
430        // Add log-dir if specified
431        if let Some(log_dir) = &self.log_dir {
432            cmd.arg("--log-dir");
433            cmd.arg(log_dir);
434        }
435
436        let output = cmd.output().context("Failed to execute list command")?;
437
438        Ok(ProcessResult::new(output))
439    }
440
441    /// Run the exists command with the production binary
442    ///
443    /// This method runs `exists <environment_name>` with
444    /// optional working directory for the application itself via `--working-dir`.
445    ///
446    /// # Errors
447    ///
448    /// Returns an error if the command fails to execute.
449    ///
450    /// # Panics
451    ///
452    /// Panics if the working directory path contains invalid UTF-8.
453    pub fn run_exists_command(&self, environment_name: &str) -> Result<ProcessResult> {
454        let mut cmd = self.make_command();
455
456        if let Some(working_dir) = &self.working_dir {
457            cmd.args([
458                "exists",
459                environment_name,
460                "--working-dir",
461                working_dir.to_str().unwrap(),
462            ]);
463        } else {
464            cmd.args(["exists", environment_name]);
465        }
466
467        // Add log-dir if specified
468        if let Some(log_dir) = &self.log_dir {
469            cmd.arg("--log-dir");
470            cmd.arg(log_dir);
471        }
472
473        let output = cmd.output().context("Failed to execute exists command")?;
474
475        Ok(ProcessResult::new(output))
476    }
477
478    /// Run the show command with the production binary
479    ///
480    /// This method runs `show <environment_name>` with
481    /// optional working directory for the application itself via `--working-dir`.
482    ///
483    /// # Errors
484    ///
485    /// Returns an error if the command fails to execute.
486    ///
487    /// # Panics
488    ///
489    /// Panics if the working directory path contains invalid UTF-8.
490    pub fn run_show_command(&self, environment_name: &str) -> Result<ProcessResult> {
491        let mut cmd = self.make_command();
492
493        if let Some(working_dir) = &self.working_dir {
494            cmd.args([
495                "show",
496                environment_name,
497                "--working-dir",
498                working_dir.to_str().unwrap(),
499            ]);
500        } else {
501            cmd.args(["show", environment_name]);
502        }
503
504        // Add log-dir if specified
505        if let Some(log_dir) = &self.log_dir {
506            cmd.arg("--log-dir");
507            cmd.arg(log_dir);
508        }
509
510        let output = cmd.output().context("Failed to execute show command")?;
511
512        Ok(ProcessResult::new(output))
513    }
514
515    /// Run the validate command with the production binary
516    ///
517    /// This method runs `validate -f <config_file>` with
518    /// optional working directory for the application itself via `--working-dir`.
519    ///
520    /// # Errors
521    ///
522    /// Returns an error if the command fails to execute.
523    ///
524    /// # Panics
525    ///
526    /// Panics if the working directory or log directory path contains invalid UTF-8.
527    pub fn run_validate_command(&self, config_file: &str) -> Result<ProcessResult> {
528        let mut cmd = self.make_command();
529        cmd.args(["validate", "-f", config_file]);
530
531        // Add working-dir if specified
532        if let Some(working_dir) = &self.working_dir {
533            cmd.arg("--working-dir");
534            cmd.arg(working_dir);
535        }
536
537        // Add log-dir if specified
538        if let Some(log_dir) = &self.log_dir {
539            cmd.arg("--log-dir");
540            cmd.arg(log_dir);
541        }
542
543        let output = cmd.output().context("Failed to execute validate command")?;
544
545        Ok(ProcessResult::new(output))
546    }
547
548    /// Run the purge command with the production binary
549    ///
550    /// This method runs `cargo run -- purge <environment_name> --force` with
551    /// optional working directory for the application itself via `--working-dir`.
552    /// Always uses `--force` flag to skip interactive confirmation prompts in tests.
553    ///
554    /// # Errors
555    ///
556    /// Returns an error if the command fails to execute.
557    ///
558    /// # Panics
559    ///
560    /// Panics if the working directory path contains invalid UTF-8.
561    /// Run the render command with environment name input mode
562    ///
563    /// This method runs `render --env-name <name> --instance-ip <ip> --output-dir <dir>`
564    /// with optional working directory for the application itself via `--working-dir`.
565    ///
566    /// # Errors
567    ///
568    /// Returns an error if the command fails to execute.
569    ///
570    /// # Panics
571    ///
572    /// May panic if the working directory path is not valid UTF-8.
573    pub fn run_render_command_with_env_name(
574        &self,
575        environment_name: &str,
576        instance_ip: &str,
577        output_dir: &str,
578    ) -> Result<ProcessResult> {
579        let mut cmd = self.make_command();
580        cmd.args([
581            "render",
582            "--env-name",
583            environment_name,
584            "--instance-ip",
585            instance_ip,
586            "--output-dir",
587            output_dir,
588        ]);
589
590        if let Some(working_dir) = &self.working_dir {
591            cmd.args(["--working-dir", working_dir.to_str().unwrap()]);
592        }
593
594        // Add log-dir if specified
595        if let Some(log_dir) = &self.log_dir {
596            cmd.arg("--log-dir");
597            cmd.arg(log_dir);
598        }
599
600        let output = cmd
601            .output()
602            .context("Failed to execute render command with env-name")?;
603
604        Ok(ProcessResult::new(output))
605    }
606
607    /// Run the render command with config file input mode
608    ///
609    /// This method runs `render --env-file <path> --instance-ip <ip> --output-dir <dir>`
610    /// with optional working directory and log directory for test isolation.
611    ///
612    /// # Errors
613    ///
614    /// Returns an error if the command fails to execute.
615    ///
616    /// # Panics
617    ///
618    /// May panic if the working directory or log directory path is not valid UTF-8.
619    pub fn run_render_command_with_config_file(
620        &self,
621        config_file: &str,
622        instance_ip: &str,
623        output_dir: &str,
624    ) -> Result<ProcessResult> {
625        let mut cmd = self.make_command();
626        cmd.args([
627            "render",
628            "--env-file",
629            config_file,
630            "--instance-ip",
631            instance_ip,
632            "--output-dir",
633            output_dir,
634        ]);
635
636        // Add working-dir if specified
637        if let Some(working_dir) = &self.working_dir {
638            cmd.arg("--working-dir");
639            cmd.arg(working_dir);
640        }
641
642        // Add log-dir if specified
643        if let Some(log_dir) = &self.log_dir {
644            cmd.arg("--log-dir");
645            cmd.arg(log_dir);
646        }
647
648        let output = cmd
649            .output()
650            .context("Failed to execute render command with env-file")?;
651
652        Ok(ProcessResult::new(output))
653    }
654
655    /// Run the purge command with the production binary
656    ///
657    /// This method runs `purge <environment_name> --force`
658    /// with optional working directory for the application itself via `--working-dir`.
659    /// The `--force` flag is always used to skip interactive prompts.
660    ///
661    /// # Errors
662    ///
663    /// Returns an error if the command fails to execute.
664    ///
665    /// # Panics
666    ///
667    /// May panic if the working directory path is not valid UTF-8.
668    pub fn run_purge_command(&self, environment_name: &str) -> Result<ProcessResult> {
669        let mut cmd = self.make_command();
670
671        if let Some(working_dir) = &self.working_dir {
672            cmd.args([
673                "purge",
674                environment_name,
675                "--force",
676                "--working-dir",
677                working_dir.to_str().unwrap(),
678            ]);
679        } else {
680            cmd.args(["purge", environment_name, "--force"]);
681        }
682
683        // Add log-dir if specified
684        if let Some(log_dir) = &self.log_dir {
685            cmd.arg("--log-dir");
686            cmd.arg(log_dir);
687        }
688
689        let output = cmd.output().context("Failed to execute purge command")?;
690
691        Ok(ProcessResult::new(output))
692    }
693}
694
695impl Default for ProcessRunner {
696    fn default() -> Self {
697        Self::new()
698    }
699}
700
701/// Wrapper around process execution results
702///
703/// Provides convenient access to process output, exit status, and other
704/// execution results.
705pub struct ProcessResult {
706    output: Output,
707}
708
709impl ProcessResult {
710    fn new(output: Output) -> Self {
711        Self { output }
712    }
713
714    /// Check if the process completed successfully
715    #[must_use]
716    pub fn success(&self) -> bool {
717        self.output.status.success()
718    }
719
720    /// Get the process stdout as a string
721    #[must_use]
722    pub fn stdout(&self) -> String {
723        String::from_utf8_lossy(&self.output.stdout).to_string()
724    }
725
726    /// Get the process stderr as a string
727    #[must_use]
728    pub fn stderr(&self) -> String {
729        String::from_utf8_lossy(&self.output.stderr).to_string()
730    }
731
732    /// Get the process exit code
733    #[must_use]
734    pub fn exit_code(&self) -> Option<i32> {
735        self.output.status.code()
736    }
737}