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::{
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#[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 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 let project_id = gcp_config.project_id.clone();
49 let location = gcp_config.region.clone();
50
51 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 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 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 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 const SENTINEL_PREFIX: &str = "__DOUBLE_DOLLAR_SENTINEL__";
132 out = out.replace("$$", SENTINEL_PREFIX);
133
134 for key in env.keys() {
135 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 Ok(out.replace(SENTINEL_PREFIX, "$$"))
152 }
153
154 fn escape_for_cloudbuild_template(script: &str) -> String {
157 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 let mut merged_environment = self.build_env_vars.clone();
171 merged_environment.extend(config.environment);
172
173 let monitoring = config.monitoring.or_else(|| self.monitoring.clone());
175
176 let env_vars: Vec<String> = merged_environment
181 .iter()
182 .map(|(key, value)| format!("{}={}", key, value))
183 .collect();
184
185 let escaped_script =
187 Self::escape_env_refs(&config.script, &merged_environment, &self.binding_name)?;
188
189 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 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 let service_account = if self.service_account.contains("@") {
214 format!(
216 "projects/{}/serviceAccounts/{}",
217 self.project_id, self.service_account
218 )
219 } else {
220 self.service_account.clone()
222 };
223
224 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 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 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 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 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}