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}