Skip to main content

alien_bindings/providers/build/
aca.rs

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/// 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                    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        // Get subscription_id from Azure config (this is a cloud credential)
113        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    /// Convert alien ComputeType to Azure Container Apps resource allocation
137    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    /// Convert Azure Container Apps Job status to alien BuildStatus
163    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    /// Generate a unique job name for the build
175    /// Azure Container Apps Jobs have strict naming requirements:
176    /// - 2-32 characters inclusive
177    /// - Lower case alphanumeric characters or '-'
178    /// - Start with alphabetic character, end with alphanumeric
179    /// - Cannot have '--'
180    fn generate_job_name(&self) -> String {
181        let timestamp = chrono::Utc::now().timestamp_millis();
182        // Use short hash of binding name + timestamp to stay within 32 char limit
183        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(); // Last 6 digits
190        let job_name = format!("build-{}-{}", short_name, short_timestamp);
191
192        // Ensure it meets Azure naming requirements
193        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        // Merge build config environment with binding environment variables
208        // Build config environment takes precedence over binding environment
209        let mut merged_environment = self.build_env_vars.clone();
210        merged_environment.extend(config.environment);
211
212        // Merge monitoring configuration - build config takes precedence over binding
213        let monitoring = config.monitoring.or_else(|| self.monitoring.clone());
214
215        // Convert environment variables to Azure format
216        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        // Create the job container with the unified wrapper script
226        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        // Create job template
240        let job_template = JobTemplate {
241            containers: vec![job_container],
242            init_containers: vec![],
243            volumes: vec![],
244        };
245
246        // Create job configuration with manual trigger
247        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        // Create job properties
263        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        // Create managed service identity if we have a managed identity ID
274        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        // Create the job
290        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                // For long-running operations, we'll use the job name as ID
325                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        // Extract job name from build ID (could be a full resource ID or just the name)
339        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                // Check if this is a "resource not found" error (job was deleted/stopped)
377                if let Some(CloudClientErrorData::RemoteResourceNotFound { .. }) = &err.error {
378                    // Job was deleted (stopped), return cancelled status
379                    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                    // For other errors, propagate them
387                    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        // Extract job name from build ID
402        let job_name = if build_id.contains("/") {
403            build_id.split('/').last().unwrap_or(build_id)
404        } else {
405            build_id
406        };
407
408        // For Azure Container Apps Jobs, stopping means deleting the job
409        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 {}