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