Skip to main content

alien_bindings/providers/build/
cloudbuild.rs

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