Skip to main content

propel_cloud/
executor.rs

1use crate::gcloud::GcloudError;
2
3/// Abstraction over gcloud CLI execution for testability.
4///
5/// Production code uses [`RealExecutor`], tests use mockall-generated mocks.
6// Trait is consumed internally + tests only; Send bound on the trait itself suffices.
7#[allow(async_fn_in_trait)]
8pub trait GcloudExecutor: Send + Sync {
9    /// Execute a gcloud command and capture stdout.
10    async fn exec(&self, args: &[String]) -> Result<String, GcloudError>;
11
12    /// Execute a gcloud command, streaming output to the terminal.
13    async fn exec_streaming(&self, args: &[String]) -> Result<(), GcloudError>;
14
15    /// Execute a gcloud command with data piped to stdin.
16    async fn exec_with_stdin(
17        &self,
18        args: &[String],
19        stdin_data: &[u8],
20    ) -> Result<String, GcloudError>;
21}
22
23/// Real gcloud CLI executor.
24pub struct RealExecutor;
25
26impl GcloudExecutor for RealExecutor {
27    async fn exec(&self, args: &[String]) -> Result<String, GcloudError> {
28        use std::process::Stdio;
29
30        tracing::debug!(cmd = %format!("gcloud {}", args.join(" ")), "exec");
31
32        let output = tokio::process::Command::new("gcloud")
33            .args(args)
34            .stdout(Stdio::piped())
35            .stderr(Stdio::piped())
36            .output()
37            .await
38            .map_err(|e| GcloudError::NotFound { source: e })?;
39
40        if output.status.success() {
41            String::from_utf8(output.stdout).map_err(|e| GcloudError::InvalidUtf8 { source: e })
42        } else {
43            let stderr = String::from_utf8_lossy(&output.stderr).to_string();
44            tracing::warn!(cmd = %format!("gcloud {}", args.join(" ")), %stderr, "command failed");
45            Err(GcloudError::CommandFailed {
46                args: args.to_vec(),
47                stderr,
48            })
49        }
50    }
51
52    async fn exec_streaming(&self, args: &[String]) -> Result<(), GcloudError> {
53        use std::process::Stdio;
54
55        tracing::debug!(cmd = %format!("gcloud {}", args.join(" ")), "exec_streaming");
56
57        let status = tokio::process::Command::new("gcloud")
58            .args(args)
59            .stdout(Stdio::inherit())
60            .stderr(Stdio::inherit())
61            .status()
62            .await
63            .map_err(|e| GcloudError::NotFound { source: e })?;
64
65        if status.success() {
66            Ok(())
67        } else {
68            tracing::warn!(cmd = %format!("gcloud {}", args.join(" ")), %status, "streaming command failed");
69            Err(GcloudError::CommandFailed {
70                args: args.to_vec(),
71                stderr: format!("exit code: {status}"),
72            })
73        }
74    }
75
76    async fn exec_with_stdin(
77        &self,
78        args: &[String],
79        stdin_data: &[u8],
80    ) -> Result<String, GcloudError> {
81        use std::process::Stdio;
82        use tokio::io::AsyncWriteExt;
83
84        tracing::debug!(
85            cmd = %format!("gcloud {}", args.join(" ")),
86            stdin_bytes = stdin_data.len(),
87            "exec_with_stdin"
88        );
89
90        let mut child = tokio::process::Command::new("gcloud")
91            .args(args)
92            .stdin(Stdio::piped())
93            .stdout(Stdio::piped())
94            .stderr(Stdio::piped())
95            .spawn()
96            .map_err(|e| GcloudError::NotFound { source: e })?;
97
98        if let Some(mut stdin) = child.stdin.take() {
99            stdin
100                .write_all(stdin_data)
101                .await
102                .map_err(|e| GcloudError::StdinWrite { source: e })?;
103            stdin
104                .shutdown()
105                .await
106                .map_err(|e| GcloudError::StdinWrite { source: e })?;
107        }
108
109        let output = child
110            .wait_with_output()
111            .await
112            .map_err(|e| GcloudError::NotFound { source: e })?;
113
114        if output.status.success() {
115            String::from_utf8(output.stdout).map_err(|e| GcloudError::InvalidUtf8 { source: e })
116        } else {
117            let stderr = String::from_utf8_lossy(&output.stderr).to_string();
118            tracing::warn!(cmd = %format!("gcloud {}", args.join(" ")), %stderr, "command failed");
119            Err(GcloudError::CommandFailed {
120                args: args.to_vec(),
121                stderr,
122            })
123        }
124    }
125}