Skip to main content

alien_bindings/providers/build/
aca.rs

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/// Azure implementation of the `Build` trait using Container Apps Jobs.
26#[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    /// Creates a new Azure Build instance from binding parameters.
43    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        // Extract values from binding
54        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        // Get subscription_id from Azure config (this is a cloud credential)
120        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    /// Convert alien ComputeType to Azure Container Apps resource allocation
145    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    /// Convert Azure Container Apps Job status to alien BuildStatus
171    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    /// Generate a unique job name for the build
183    /// Azure Container Apps Jobs have strict naming requirements:
184    /// - 2-32 characters inclusive
185    /// - Lower case alphanumeric characters or '-'
186    /// - Start with alphabetic character, end with alphanumeric
187    /// - Cannot have '--'
188    fn generate_job_name(&self) -> String {
189        let timestamp = chrono::Utc::now().timestamp_millis();
190        // Use short hash of binding name + timestamp to stay within 32 char limit
191        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(); // Last 6 digits
198        let job_name = format!("build-{}-{}", short_name, short_timestamp);
199
200        // Ensure it meets Azure naming requirements
201        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        // Merge build config environment with binding environment variables
216        // Build config environment takes precedence over binding environment
217        let mut merged_environment = self.build_env_vars.clone();
218        merged_environment.extend(config.environment);
219
220        // Merge monitoring configuration - build config takes precedence over binding
221        let monitoring = config.monitoring.or_else(|| self.monitoring.clone());
222
223        // Convert environment variables to Azure format
224        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        // Create the job container with the unified wrapper script
234        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        // Create job template
248        let job_template = JobTemplate {
249            containers: vec![job_container],
250            init_containers: vec![],
251            volumes: vec![],
252        };
253
254        // Create job configuration with manual trigger
255        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        // Create job properties
271        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        // Create managed service identity if we have a managed identity ID
282        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        // Create the job
298        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                // For long-running operations, we'll use the job name as ID
333                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        // Extract job name from build ID (could be a full resource ID or just the name)
347        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                // Check if this is a "resource not found" error (job was deleted/stopped)
385                if let Some(CloudClientErrorData::RemoteResourceNotFound { .. }) = &err.error {
386                    // Job was deleted (stopped), return cancelled status
387                    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                    // For other errors, propagate them
395                    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        // Extract job name from build ID
410        let job_name = if build_id.contains("/") {
411            build_id.split('/').last().unwrap_or(build_id)
412        } else {
413            build_id
414        };
415
416        // For Azure Container Apps Jobs, stopping means deleting the job
417        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 {}