alien_bindings/providers/build/
cloudbuild.rs1use 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#[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 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 let project_id = gcp_config.project_id.clone();
46 let location = gcp_config.region.clone();
47
48 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 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 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 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 const SENTINEL_PREFIX: &str = "__DOUBLE_DOLLAR_SENTINEL__";
133 out = out.replace("$$", SENTINEL_PREFIX);
134
135 for key in env.keys() {
136 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 Ok(out.replace(SENTINEL_PREFIX, "$$"))
153 }
154
155 fn escape_for_cloudbuild_template(script: &str) -> String {
158 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 let mut merged_environment = self.build_env_vars.clone();
172 merged_environment.extend(config.environment);
173
174 let monitoring = config.monitoring.or_else(|| self.monitoring.clone());
176
177 let env_vars: Vec<String> = merged_environment
182 .iter()
183 .map(|(key, value)| format!("{}={}", key, value))
184 .collect();
185
186 let escaped_script =
188 Self::escape_env_refs(&config.script, &merged_environment, &self.binding_name)?;
189
190 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 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 let service_account = if self.service_account.contains("@") {
215 format!(
217 "projects/{}/serviceAccounts/{}",
218 self.project_id, self.service_account
219 )
220 } else {
221 self.service_account.clone()
223 };
224
225 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 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 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 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 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}