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