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