alien_bindings/providers/build/
cloudbuild.rs1use 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#[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 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 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 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 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 const SENTINEL_PREFIX: &str = "__DOUBLE_DOLLAR_SENTINEL__";
129 out = out.replace("$$", SENTINEL_PREFIX);
130
131 for key in env.keys() {
132 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 Ok(out.replace(SENTINEL_PREFIX, "$$"))
149 }
150
151 fn escape_for_cloudbuild_template(script: &str) -> String {
154 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 let mut merged_environment = self.build_env_vars.clone();
168 merged_environment.extend(config.environment);
169
170 let monitoring = config.monitoring.or_else(|| self.monitoring.clone());
172
173 let env_vars: Vec<String> = merged_environment
178 .iter()
179 .map(|(key, value)| format!("{}={}", key, value))
180 .collect();
181
182 let escaped_script =
184 Self::escape_env_refs(&config.script, &merged_environment, &self.binding_name)?;
185
186 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 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 let service_account = if self.service_account.contains("@") {
211 format!(
213 "projects/{}/serviceAccounts/{}",
214 self.project_id, self.service_account
215 )
216 } else {
217 self.service_account.clone()
219 };
220
221 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 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 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 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 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}