1use crate::{
2 error::{binding_env_var, map_cloud_client_error, Error, ErrorData},
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::Context;
8use async_trait::async_trait;
9use std::collections::HashMap;
10
11use alien_azure_clients::{
12 container_apps::{AzureContainerAppsClient, ContainerAppsApi},
13 long_running_operation::OperationResult,
14 models::jobs::{
15 Container as JobContainer, ContainerResources as JobContainerResources,
16 EnvironmentVar as JobEnvironmentVar, Job, JobConfiguration,
17 JobConfigurationManualTriggerConfig, JobConfigurationTriggerType, JobProperties,
18 JobTemplate, ManagedServiceIdentity, ManagedServiceIdentityType, Parallelism,
19 ReplicaCompletionCount, UserAssignedIdentities, UserAssignedIdentity,
20 },
21 AzureClientConfig, AzureTokenCache,
22};
23use alien_client_core::ErrorData as CloudClientErrorData;
24
25#[derive(Debug)]
27pub struct AcaBuild {
28 client: AzureContainerAppsClient,
29 binding_name: String,
30 resource_prefix: String,
31 #[allow(dead_code)]
32 subscription_id: String,
33 resource_group_name: String,
34 managed_environment_id: String,
35 managed_identity_id: Option<String>,
36 build_env_vars: HashMap<String, String>,
37 region: String,
38 monitoring: Option<alien_core::MonitoringConfig>,
39}
40
41impl AcaBuild {
42 pub async fn new(
44 binding_name: String,
45 binding: BuildBinding,
46 azure_config: &AzureClientConfig,
47 ) -> Result<Self, Error> {
48 let client = AzureContainerAppsClient::new(
49 crate::http_client::create_http_client(),
50 AzureTokenCache::new(azure_config.clone()),
51 );
52
53 let config = match binding {
55 BuildBinding::Aca(config) => config,
56 _ => {
57 return Err(Error::new(ErrorData::BindingConfigInvalid {
58 env_var: binding_env_var(&binding_name),
59 binding_name: binding_name.clone(),
60 reason: "Expected ACA binding, got different service type".to_string(),
61 }));
62 }
63 };
64
65 let managed_environment_id = config
66 .managed_environment_id
67 .into_value(&binding_name, "managed_environment_id")
68 .context(ErrorData::BindingConfigInvalid {
69 env_var: binding_env_var(&binding_name),
70 binding_name: binding_name.clone(),
71 reason: "Failed to extract managed_environment_id from binding".to_string(),
72 })?;
73
74 let resource_group_name = config
75 .resource_group_name
76 .into_value(&binding_name, "resource_group_name")
77 .context(ErrorData::BindingConfigInvalid {
78 env_var: binding_env_var(&binding_name),
79 binding_name: binding_name.clone(),
80 reason: "Failed to extract resource_group_name from binding".to_string(),
81 })?;
82
83 let build_env_vars = config
84 .build_env_vars
85 .into_value(&binding_name, "build_env_vars")
86 .context(ErrorData::BindingConfigInvalid {
87 env_var: binding_env_var(&binding_name),
88 binding_name: binding_name.clone(),
89 reason: "Failed to extract build_env_vars from binding".to_string(),
90 })?;
91
92 let managed_identity_id = config
93 .managed_identity_id
94 .into_value(&binding_name, "managed_identity_id")
95 .context(ErrorData::BindingConfigInvalid {
96 env_var: binding_env_var(&binding_name),
97 binding_name: binding_name.clone(),
98 reason: "Failed to extract managed_identity_id from binding".to_string(),
99 })?;
100
101 let resource_prefix = config
102 .resource_prefix
103 .into_value(&binding_name, "resource_prefix")
104 .context(ErrorData::BindingConfigInvalid {
105 env_var: binding_env_var(&binding_name),
106 binding_name: binding_name.clone(),
107 reason: "Failed to extract resource_prefix from binding".to_string(),
108 })?;
109
110 let monitoring = config
111 .monitoring
112 .into_value(&binding_name, "monitoring")
113 .context(ErrorData::BindingConfigInvalid {
114 env_var: binding_env_var(&binding_name),
115 binding_name: binding_name.clone(),
116 reason: "Failed to extract monitoring from binding".to_string(),
117 })?;
118
119 let subscription_id = azure_config.subscription_id.clone();
121
122 let binding_name_clone = binding_name.clone();
123
124 Ok(Self {
125 client,
126 binding_name,
127 resource_prefix,
128 subscription_id,
129 resource_group_name,
130 managed_environment_id,
131 managed_identity_id,
132 build_env_vars,
133 region: azure_config.region.clone().ok_or_else(|| {
134 Error::new(ErrorData::BindingConfigInvalid {
135 env_var: binding_env_var(&binding_name_clone),
136 binding_name: binding_name_clone,
137 reason: "Azure region must be specified in config".to_string(),
138 })
139 })?,
140 monitoring,
141 })
142 }
143
144 fn map_compute_resources(compute_type: &ComputeType) -> JobContainerResources {
146 match compute_type {
147 ComputeType::Small => JobContainerResources {
148 cpu: Some(0.25),
149 memory: Some("0.5Gi".to_string()),
150 ephemeral_storage: None,
151 },
152 ComputeType::Medium => JobContainerResources {
153 cpu: Some(0.5),
154 memory: Some("1Gi".to_string()),
155 ephemeral_storage: None,
156 },
157 ComputeType::Large => JobContainerResources {
158 cpu: Some(1.0),
159 memory: Some("2Gi".to_string()),
160 ephemeral_storage: None,
161 },
162 ComputeType::XLarge => JobContainerResources {
163 cpu: Some(2.0),
164 memory: Some("4Gi".to_string()),
165 ephemeral_storage: None,
166 },
167 }
168 }
169
170 fn map_build_status(status: Option<&str>) -> BuildStatus {
172 match status {
173 Some("Succeeded") => BuildStatus::Succeeded,
174 Some("Failed") => BuildStatus::Failed,
175 Some("Cancelled") => BuildStatus::Cancelled,
176 Some("Running") => BuildStatus::Running,
177 Some("Pending") => BuildStatus::Queued,
178 _ => BuildStatus::Queued,
179 }
180 }
181
182 fn generate_job_name(&self) -> String {
189 let timestamp = chrono::Utc::now().timestamp_millis();
190 let short_name = self
192 .resource_prefix
193 .chars()
194 .take(8)
195 .collect::<String>()
196 .replace('_', "");
197 let short_timestamp = (timestamp % 1000000).to_string(); let job_name = format!("build-{}-{}", short_name, short_timestamp);
199
200 job_name
202 .to_lowercase()
203 .chars()
204 .filter(|c| c.is_alphanumeric() || *c == '-')
205 .take(32)
206 .collect()
207 }
208}
209
210#[async_trait]
211impl Build for AcaBuild {
212 async fn start_build(&self, config: BuildConfig) -> Result<BuildExecution, Error> {
213 let job_name = self.generate_job_name();
214
215 let mut merged_environment = self.build_env_vars.clone();
218 merged_environment.extend(config.environment);
219
220 let monitoring = config.monitoring.or_else(|| self.monitoring.clone());
222
223 let azure_env_vars: Vec<JobEnvironmentVar> = merged_environment
225 .iter()
226 .map(|(key, value)| JobEnvironmentVar {
227 name: Some(key.clone()),
228 value: Some(value.clone()),
229 secret_ref: None,
230 })
231 .collect();
232
233 let container_script = create_build_wrapper_script(&config.script, monitoring.as_ref());
235
236 let job_container = JobContainer {
237 name: Some("build-container".to_string()),
238 image: Some(config.image),
239 command: vec!["bash".to_string()],
240 args: vec!["-c".to_string(), container_script],
241 env: azure_env_vars,
242 resources: Some(Self::map_compute_resources(&config.compute_type)),
243 probes: vec![],
244 volume_mounts: vec![],
245 };
246
247 let job_template = JobTemplate {
249 containers: vec![job_container],
250 init_containers: vec![],
251 volumes: vec![],
252 };
253
254 let job_configuration = JobConfiguration {
256 trigger_type: JobConfigurationTriggerType::Manual,
257 replica_timeout: config.timeout_seconds as i32,
258 replica_retry_limit: Some(1),
259 manual_trigger_config: Some(JobConfigurationManualTriggerConfig {
260 parallelism: Some(Parallelism(1)),
261 replica_completion_count: Some(ReplicaCompletionCount(1)),
262 }),
263 registries: vec![],
264 secrets: vec![],
265 event_trigger_config: None,
266 schedule_trigger_config: None,
267 identity_settings: vec![],
268 };
269
270 let job_properties = JobProperties {
272 environment_id: Some(self.managed_environment_id.clone()),
273 configuration: Some(job_configuration),
274 template: Some(job_template),
275 workload_profile_name: None,
276 provisioning_state: None,
277 event_stream_endpoint: None,
278 outbound_ip_addresses: vec![],
279 };
280
281 let identity =
283 self.managed_identity_id
284 .as_ref()
285 .map(|identity_id| ManagedServiceIdentity {
286 type_: ManagedServiceIdentityType::UserAssigned,
287 user_assigned_identities: Some(UserAssignedIdentities(
288 std::collections::HashMap::from([(
289 identity_id.clone(),
290 UserAssignedIdentity::default(),
291 )]),
292 )),
293 principal_id: None,
294 tenant_id: None,
295 });
296
297 let job = Job {
299 location: self.region.clone(),
300 properties: Some(job_properties),
301 identity,
302 tags: [
303 ("alien-resource-type".to_string(), "build".to_string()),
304 ("alien-binding-name".to_string(), self.binding_name.clone()),
305 ]
306 .iter()
307 .cloned()
308 .collect(),
309 id: None,
310 name: None,
311 type_: None,
312 system_data: None,
313 };
314
315 let operation_result = self
316 .client
317 .create_or_update_job(&self.resource_group_name, &job_name, &job)
318 .await
319 .map_err(|e| {
320 map_cloud_client_error(
321 e,
322 format!("Failed to create Azure Container Apps job '{}'", job_name),
323 None,
324 )
325 })?;
326
327 let build_id = match operation_result {
328 OperationResult::Completed(created_job) => {
329 created_job.id.unwrap_or_else(|| job_name.clone())
330 }
331 OperationResult::LongRunning(_) => {
332 job_name.clone()
334 }
335 };
336
337 Ok(BuildExecution {
338 id: build_id,
339 status: BuildStatus::Queued,
340 start_time: Some(chrono::Utc::now().to_rfc3339()),
341 end_time: None,
342 })
343 }
344
345 async fn get_build_status(&self, build_id: &str) -> Result<BuildExecution, Error> {
346 let job_name = if build_id.contains("/") {
348 build_id.split('/').last().unwrap_or(build_id)
349 } else {
350 build_id
351 };
352
353 let job_result = self
354 .client
355 .get_job(&self.resource_group_name, job_name)
356 .await;
357
358 match job_result {
359 Ok(job) => {
360 let status = job
361 .properties
362 .as_ref()
363 .and_then(|props| props.provisioning_state.as_ref())
364 .map(|ps| Self::map_build_status(Some(&format!("{:?}", ps))))
365 .unwrap_or(BuildStatus::Queued);
366
367 let end_time = if matches!(
368 status,
369 BuildStatus::Succeeded | BuildStatus::Failed | BuildStatus::Cancelled
370 ) {
371 Some(chrono::Utc::now().to_rfc3339())
372 } else {
373 None
374 };
375
376 Ok(BuildExecution {
377 id: build_id.to_string(),
378 status,
379 start_time: Some(chrono::Utc::now().to_rfc3339()),
380 end_time,
381 })
382 }
383 Err(err) => {
384 if let Some(CloudClientErrorData::RemoteResourceNotFound { .. }) = &err.error {
386 Ok(BuildExecution {
388 id: build_id.to_string(),
389 status: BuildStatus::Cancelled,
390 start_time: Some(chrono::Utc::now().to_rfc3339()),
391 end_time: Some(chrono::Utc::now().to_rfc3339()),
392 })
393 } else {
394 Err(map_cloud_client_error(
396 err,
397 format!(
398 "Failed to get Azure Container Apps job status for '{}'",
399 job_name
400 ),
401 Some(build_id.to_string()),
402 ))
403 }
404 }
405 }
406 }
407
408 async fn stop_build(&self, build_id: &str) -> Result<(), Error> {
409 let job_name = if build_id.contains("/") {
411 build_id.split('/').last().unwrap_or(build_id)
412 } else {
413 build_id
414 };
415
416 self.client
418 .delete_job(&self.resource_group_name, job_name)
419 .await
420 .map_err(|e| {
421 map_cloud_client_error(
422 e,
423 format!("Failed to stop Azure Container Apps job '{}'", job_name),
424 Some(build_id.to_string()),
425 )
426 })?;
427
428 Ok(())
429 }
430}
431
432impl Binding for AcaBuild {}