Skip to main content

alien_bindings/providers/build/
cloudbuild.rs

1use crate::{
2    error::{map_cloud_client_error, ErrorData, Result},
3    providers::build::script::create_build_wrapper_script,
4    traits::{Binding, Build},
5};
6use alien_core::{bindings::BuildBinding, BuildConfig, BuildExecution, BuildStatus, ComputeType};
7use alien_error::{AlienError, Context, IntoAlienError};
8use async_trait::async_trait;
9use std::collections::HashMap;
10
11use regex;
12use serde_json;
13
14use alien_gcp_clients::{
15    cloudbuild::{
16        Build as CloudBuild, BuildOptions, BuildStatus as GcpBuildStatus, BuildStep, CloudBuildApi,
17        CloudBuildClient, LoggingMode, MachineType,
18    },
19    GcpClientConfig,
20};
21
22/// GCP implementation of the `Build` trait using Cloud Build.
23#[derive(Debug)]
24pub struct CloudbuildBuild {
25    client: CloudBuildClient,
26    binding_name: String,
27    project_id: String,
28    location: String,
29    build_env_vars: HashMap<String, String>,
30    service_account: String,
31    monitoring: Option<alien_core::MonitoringConfig>,
32}
33
34impl CloudbuildBuild {
35    /// Creates a new GCP Build instance from binding parameters.
36    pub async fn new(
37        binding_name: String,
38        binding: BuildBinding,
39        gcp_config: &GcpClientConfig,
40    ) -> Result<Self> {
41        let client =
42            CloudBuildClient::new(crate::http_client::create_http_client(), gcp_config.clone());
43
44        // Get project_id and location from GCP config instead of binding
45        let project_id = gcp_config.project_id.clone();
46        let location = gcp_config.region.clone();
47
48        // Extract values from binding
49        let config = match binding {
50            BuildBinding::Cloudbuild(config) => config,
51            _ => {
52                return Err(AlienError::new(ErrorData::BindingConfigInvalid {
53                    binding_name: binding_name.clone(),
54                    reason: "Expected CloudBuild binding, got different service type".to_string(),
55                }));
56            }
57        };
58
59        let build_env_vars = config
60            .build_env_vars
61            .into_value(&binding_name, "build_env_vars")
62            .context(ErrorData::BindingConfigInvalid {
63                binding_name: binding_name.clone(),
64                reason: "Failed to extract build_env_vars from binding".to_string(),
65            })?;
66
67        let service_account = config
68            .service_account
69            .into_value(&binding_name, "service_account")
70            .context(ErrorData::BindingConfigInvalid {
71                binding_name: binding_name.clone(),
72                reason: "Failed to extract service_account from binding".to_string(),
73            })?;
74
75        let monitoring = config
76            .monitoring
77            .into_value(&binding_name, "monitoring")
78            .context(ErrorData::BindingConfigInvalid {
79                binding_name: binding_name.clone(),
80                reason: "Failed to extract monitoring from binding".to_string(),
81            })?;
82
83        Ok(Self {
84            client,
85            binding_name,
86            project_id,
87            location,
88            build_env_vars,
89            service_account,
90            monitoring,
91        })
92    }
93
94    /// Convert alien ComputeType to GCP Cloud Build machine type
95    fn map_machine_type(compute_type: &ComputeType) -> MachineType {
96        match compute_type {
97            ComputeType::Small => MachineType::E2Medium,
98            ComputeType::Medium => MachineType::E2Medium,
99            ComputeType::Large => MachineType::E2Highcpu8,
100            ComputeType::XLarge => MachineType::E2Highcpu32,
101        }
102    }
103
104    /// Convert GCP Cloud Build status to alien BuildStatus
105    fn map_build_status(status: Option<&GcpBuildStatus>) -> BuildStatus {
106        match status {
107            Some(GcpBuildStatus::Success) => BuildStatus::Succeeded,
108            Some(GcpBuildStatus::Failure)
109            | Some(GcpBuildStatus::InternalError)
110            | Some(GcpBuildStatus::Timeout) => BuildStatus::Failed,
111            Some(GcpBuildStatus::Cancelled) => BuildStatus::Cancelled,
112            Some(GcpBuildStatus::Working) => BuildStatus::Running,
113            Some(GcpBuildStatus::Queued) => BuildStatus::Queued,
114            _ => BuildStatus::Queued,
115        }
116    }
117
118    /// Escape environment variable references in the script to prevent GCP Cloud Build substitutions.
119    /// Converts $VAR to $$VAR while preserving existing $$VAR sequences.
120    fn escape_env_refs(
121        script: &str,
122        env: &HashMap<String, String>,
123        binding_name: &str,
124    ) -> Result<String> {
125        let mut out = script.to_owned();
126
127        // Temporary sentinel so already-escaped $$VAR survive the second pass
128        const SENTINEL_PREFIX: &str = "__DOUBLE_DOLLAR_SENTINEL__";
129        out = out.replace("$$", SENTINEL_PREFIX);
130
131        for key in env.keys() {
132            // \$KEY\b → matches $KEY followed by a word boundary
133            let escaped_key = regex::escape(key);
134            let pat = format!("\\${}\\b", escaped_key);
135
136            let re = regex::Regex::new(&pat).into_alien_error().context(
137                ErrorData::BuildOperationFailed {
138                    binding_name: binding_name.to_string(),
139                    operation: format!("compile regex for {}", key),
140                },
141            )?;
142
143            let replacement = format!("$$$${}", key);
144            out = re.replace_all(&out, replacement.as_str()).to_string();
145        }
146
147        // Restore any original $$ sequences
148        Ok(out.replace(SENTINEL_PREFIX, "$$"))
149    }
150
151    /// Escapes shell dollar references so Cloud Build template parsing does not treat
152    /// shell variables (for example, `$TMP_BUILD_SCRIPT`) as substitutions.
153    fn escape_for_cloudbuild_template(script: &str) -> String {
154        // Preserve existing escaped $$ sequences to avoid over-escaping user intent.
155        const SENTINEL_PREFIX: &str = "__DOUBLE_DOLLAR_SENTINEL__";
156        let with_sentinel = script.replace("$$", SENTINEL_PREFIX);
157        let escaped = with_sentinel.replace('$', "$$");
158        escaped.replace(SENTINEL_PREFIX, "$$")
159    }
160}
161
162#[async_trait]
163impl Build for CloudbuildBuild {
164    async fn start_build(&self, config: BuildConfig) -> Result<BuildExecution> {
165        // Merge build config environment with binding environment variables
166        // Build config environment takes precedence over binding environment
167        let mut merged_environment = self.build_env_vars.clone();
168        merged_environment.extend(config.environment);
169
170        // Merge monitoring configuration - build config takes precedence over binding
171        let monitoring = config.monitoring.or_else(|| self.monitoring.clone());
172
173        // Note: Monitoring configuration is now handled directly in the Fluent Bit config
174        // rather than through environment variables, similar to AWS implementation
175
176        // Convert environment variables to GCP Cloud Build format
177        let env_vars: Vec<String> = merged_environment
178            .iter()
179            .map(|(key, value)| format!("{}={}", key, value))
180            .collect();
181
182        // Escape environment variables in the script to prevent GCP Cloud Build substitutions
183        let escaped_script =
184            Self::escape_env_refs(&config.script, &merged_environment, &self.binding_name)?;
185
186        // Create build step that runs the unified wrapper script.
187        // Cloud Build parses `$FOO` as substitutions at request-time, so escape the entire
188        // script after generation to protect wrapper-local shell variables as well.
189        let wrapper_script = Self::escape_for_cloudbuild_template(&create_build_wrapper_script(
190            &escaped_script,
191            monitoring.as_ref(),
192        ));
193
194        let build_step = BuildStep::builder()
195            .name(config.image)
196            .args(vec!["bash".to_string(), "-c".to_string(), wrapper_script])
197            .env(env_vars)
198            .timeout(format!("{}s", config.timeout_seconds))
199            .automap_substitutions(false)
200            .build();
201
202        // Create build options with appropriate machine type and disable substitutions entirely
203        let options = BuildOptions::builder()
204            .machine_type(Self::map_machine_type(&config.compute_type))
205            .automap_substitutions(false)
206            .logging(LoggingMode::CloudLoggingOnly)
207            .build();
208
209        // Get service account from binding and format it as a resource path
210        let service_account = if self.service_account.contains("@") {
211            // Convert email format to resource path format
212            format!(
213                "projects/{}/serviceAccounts/{}",
214                self.project_id, self.service_account
215            )
216        } else {
217            // Assume it's already in resource path format
218            self.service_account.clone()
219        };
220
221        // Create the Cloud Build configuration
222        let cloud_build = CloudBuild::builder()
223            .steps(vec![build_step])
224            .timeout(format!("{}s", config.timeout_seconds))
225            .options(options)
226            .service_account(service_account)
227            .build();
228
229        let operation = self
230            .client
231            .create_build(&self.location, cloud_build)
232            .await
233            .map_err(|e| {
234                map_cloud_client_error(e, "Failed to start GCP Cloud Build".to_string(), None)
235            })?;
236
237        // Extract build ID from operation metadata (available immediately)
238        let build_id = operation
239            .metadata
240            .as_ref()
241            .and_then(|metadata| metadata.get("build"))
242            .and_then(|build| build.get("id"))
243            .and_then(|id| id.as_str())
244            .map(|s| s.to_string())
245            .ok_or_else(|| {
246                let response_json = serde_json::to_string_pretty(&operation)
247                    .unwrap_or_else(|_| "Failed to serialize operation".to_string());
248
249                AlienError::new(ErrorData::UnexpectedResponseFormat {
250                    provider: "gcp".to_string(),
251                    binding_name: self.binding_name.clone(),
252                    field: "metadata.build.id".to_string(),
253                    response_json,
254                })
255            })?;
256
257        Ok(BuildExecution {
258            id: build_id,
259            status: BuildStatus::Queued,
260            start_time: Some(chrono::Utc::now().to_rfc3339()),
261            end_time: None,
262        })
263    }
264
265    async fn get_build_status(&self, build_id: &str) -> Result<BuildExecution> {
266        let build = self
267            .client
268            .get_build(&self.location, build_id)
269            .await
270            .map_err(|e| {
271                map_cloud_client_error(
272                    e,
273                    format!(
274                        "Failed to get GCP Cloud Build status for build '{}'",
275                        build_id
276                    ),
277                    Some(build_id.to_string()),
278                )
279            })?;
280
281        let status = Self::map_build_status(build.status.as_ref());
282        let start_time = build.start_time.clone();
283        let end_time = if matches!(
284            status,
285            BuildStatus::Succeeded | BuildStatus::Failed | BuildStatus::Cancelled
286        ) {
287            build.finish_time.clone()
288        } else {
289            None
290        };
291
292        Ok(BuildExecution {
293            id: build_id.to_string(),
294            status,
295            start_time,
296            end_time,
297        })
298    }
299
300    async fn stop_build(&self, build_id: &str) -> Result<()> {
301        self.client
302            .cancel_build(&self.location, build_id)
303            .await
304            .map_err(|e| {
305                map_cloud_client_error(
306                    e,
307                    format!("Failed to stop GCP Cloud Build '{}'", build_id),
308                    Some(build_id.to_string()),
309                )
310            })?;
311
312        Ok(())
313    }
314}
315
316impl Binding for CloudbuildBuild {}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321    use std::collections::HashMap;
322
323    #[test]
324    fn test_escape_env_refs() {
325        let mut env = HashMap::new();
326        env.insert("CUSTOM_VAR".to_string(), "custom_value".to_string());
327        env.insert("ANOTHER_VAR".to_string(), "another_value".to_string());
328
329        let script = r#"echo "CUSTOM_VAR=$CUSTOM_VAR"; echo "ANOTHER_VAR=$ANOTHER_VAR""#;
330        let expected = r#"echo "CUSTOM_VAR=$$CUSTOM_VAR"; echo "ANOTHER_VAR=$$ANOTHER_VAR""#;
331
332        let result = CloudbuildBuild::escape_env_refs(script, &env, "test-binding").unwrap();
333        assert_eq!(result, expected);
334    }
335
336    #[test]
337    fn test_escape_env_refs_preserves_existing_double_dollar() {
338        let mut env = HashMap::new();
339        env.insert("VAR1".to_string(), "value1".to_string());
340
341        let script = r#"echo "Already escaped: $$VAR1, needs escaping: $VAR1""#;
342        let expected = r#"echo "Already escaped: $$VAR1, needs escaping: $$VAR1""#;
343
344        let result = CloudbuildBuild::escape_env_refs(script, &env, "test-binding").unwrap();
345        assert_eq!(result, expected);
346    }
347
348    #[test]
349    fn test_escape_env_refs_word_boundary() {
350        let mut env = HashMap::new();
351        env.insert("VAR".to_string(), "value".to_string());
352
353        let script = r#"echo "$VAR $VARIABLE""#;
354        let expected = r#"echo "$$VAR $VARIABLE""#;
355
356        let result = CloudbuildBuild::escape_env_refs(script, &env, "test-binding").unwrap();
357        assert_eq!(result, expected);
358    }
359
360    #[test]
361    fn test_escape_for_cloudbuild_template_escapes_wrapper_vars() {
362        let script = r#"echo "$TMP_BUILD_SCRIPT" && echo ${PIPESTATUS[0]}"#;
363        let expected = r#"echo "$$TMP_BUILD_SCRIPT" && echo $${PIPESTATUS[0]}"#;
364
365        let result = CloudbuildBuild::escape_for_cloudbuild_template(script);
366        assert_eq!(result, expected);
367    }
368
369    #[test]
370    fn test_escape_for_cloudbuild_template_preserves_existing_double_dollar() {
371        let script = r#"echo "$$CUSTOM_VAR $TMP_BUILD_SCRIPT""#;
372        let expected = r#"echo "$$CUSTOM_VAR $$TMP_BUILD_SCRIPT""#;
373
374        let result = CloudbuildBuild::escape_for_cloudbuild_template(script);
375        assert_eq!(result, expected);
376    }
377
378    #[test]
379    fn test_service_account_env_var_format() {
380        // Test that the service account environment variable follows the expected format
381        let binding_name = "test-build-resource";
382        let expected_env_var = "TEST_BUILD_RESOURCE_SERVICE_ACCOUNT";
383        let actual_env_var = format!(
384            "{}_SERVICE_ACCOUNT",
385            binding_name.to_uppercase().replace("-", "_")
386        );
387        assert_eq!(actual_env_var, expected_env_var);
388    }
389
390    #[test]
391    fn test_service_account_format_conversion() {
392        // Test email format conversion to resource path
393        let project_id = "test-project";
394        let service_account_email = "test-service@test-project.iam.gserviceaccount.com";
395
396        let formatted = if service_account_email.contains("@") {
397            format!(
398                "projects/{}/serviceAccounts/{}",
399                project_id, service_account_email
400            )
401        } else {
402            service_account_email.to_string()
403        };
404
405        assert_eq!(formatted, "projects/test-project/serviceAccounts/test-service@test-project.iam.gserviceaccount.com");
406
407        // Test resource path format is preserved
408        let resource_path = "projects/test-project/serviceAccounts/test-service@test-project.iam.gserviceaccount.com";
409        let preserved = if resource_path.contains("@") && !resource_path.starts_with("projects/") {
410            format!("projects/{}/serviceAccounts/{}", project_id, resource_path)
411        } else {
412            resource_path.to_string()
413        };
414
415        assert_eq!(preserved, resource_path);
416    }
417}