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::{
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/// Azure implementation of the `Build` trait using Container Apps Jobs.
29#[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    /// Creates a new Azure Build instance from binding parameters.
46    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        // Extract values from binding
57        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        // Get subscription_id from Azure config (this is a cloud credential)
116        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    /// Convert alien ComputeType to Azure Container Apps resource allocation
140    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    /// Convert Azure Container Apps Job status to alien BuildStatus
166    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    /// Generate a unique job name for the build
178    /// Azure Container Apps Jobs have strict naming requirements:
179    /// - 2-32 characters inclusive
180    /// - Lower case alphanumeric characters or '-'
181    /// - Start with alphabetic character, end with alphanumeric
182    /// - Cannot have '--'
183    fn generate_job_name(&self) -> String {
184        let timestamp = chrono::Utc::now().timestamp_millis();
185        // Use short hash of binding name + timestamp to stay within 32 char limit
186        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(); // Last 6 digits
193        let job_name = format!("build-{}-{}", short_name, short_timestamp);
194
195        // Ensure it meets Azure naming requirements
196        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        // Merge build config environment with binding environment variables
211        // Build config environment takes precedence over binding environment
212        let mut merged_environment = self.build_env_vars.clone();
213        merged_environment.extend(config.environment);
214
215        // Merge monitoring configuration - build config takes precedence over binding
216        let monitoring = config.monitoring.or_else(|| self.monitoring.clone());
217
218        // Convert environment variables to Azure format
219        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        // Create the job container with the unified wrapper script
229        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        // Create job template
243        let job_template = JobTemplate {
244            containers: vec![job_container],
245            init_containers: vec![],
246            volumes: vec![],
247        };
248
249        // Create job configuration with manual trigger
250        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        // Create job properties
266        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        // Create managed service identity if we have a managed identity ID
277        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        // Create the job
293        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                // For long-running operations, we'll use the job name as ID
328                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        // Extract job name from build ID (could be a full resource ID or just the name)
342        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                // Check if this is a "resource not found" error (job was deleted/stopped)
380                if let Some(CloudClientErrorData::RemoteResourceNotFound { .. }) = &err.error {
381                    // Job was deleted (stopped), return cancelled status
382                    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                    // For other errors, propagate them
390                    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        // Extract job name from build ID
405        let job_name = if build_id.contains("/") {
406            build_id.split('/').last().unwrap_or(build_id)
407        } else {
408            build_id
409        };
410
411        // For Azure Container Apps Jobs, stopping means deleting the job
412        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 {}