Skip to main content

alien_bindings/providers/artifact_registry/
acr.rs

1use crate::{
2    error::{map_cloud_client_error, ErrorData, Result},
3    traits::{
4        ArtifactRegistry, ArtifactRegistryCredentials, ArtifactRegistryPermissions, Binding,
5        CrossAccountAccess, CrossAccountPermissions, RepositoryResponse,
6    },
7};
8use alien_azure_clients::long_running_operation::{LongRunningOperationClient, OperationResult};
9use alien_azure_clients::models::containerregistry::ScopeMapProperties;
10use alien_azure_clients::{
11    containerregistry::{AzureContainerRegistryClient, ContainerRegistryApi},
12    AzureClientConfig, AzureTokenCache,
13};
14use alien_core::bindings::ArtifactRegistryBinding;
15use alien_error::{AlienError, Context, IntoAlienError};
16use async_trait::async_trait;
17use tracing::{info, warn};
18
19/// Azure Container Registry implementation of the ArtifactRegistry binding.
20#[derive(Debug)]
21pub struct AcrArtifactRegistry {
22    acr_client: AzureContainerRegistryClient,
23    lro_client: LongRunningOperationClient,
24    binding_name: String,
25    registry_name: String,
26    registry_endpoint: String,
27    resource_group_name: String,
28    repository_prefix: String,
29    /// Azure credentials for direct registry access (AAD token exchange).
30    azure_token_cache: AzureTokenCache,
31    http_client: reqwest::Client,
32}
33
34impl AcrArtifactRegistry {
35    /// Creates a new Azure Container Registry artifact registry binding from binding parameters.
36    ///
37    /// # Arguments
38    /// * `binding_name` - The name of this binding
39    /// * `binding` - The parsed binding parameters
40    pub async fn new(
41        binding_name: String,
42        binding: ArtifactRegistryBinding,
43        azure_config: &AzureClientConfig,
44    ) -> Result<Self> {
45        info!(
46            binding_name = %binding_name,
47            "Initializing Azure Container Registry"
48        );
49
50        // Extract values from binding
51        let config = match binding {
52            ArtifactRegistryBinding::Acr(config) => config,
53            _ => {
54                return Err(AlienError::new(ErrorData::BindingConfigInvalid {
55                    binding_name: binding_name.clone(),
56                    reason: "Expected ACR binding, got different service type".to_string(),
57                }));
58            }
59        };
60
61        let registry_name = config
62            .registry_name
63            .into_value(&binding_name, "registry_name")
64            .context(ErrorData::BindingConfigInvalid {
65                binding_name: binding_name.clone(),
66                reason: "Failed to extract registry_name from binding".to_string(),
67            })?;
68
69        let resource_group_name = config
70            .resource_group_name
71            .into_value(&binding_name, "resource_group_name")
72            .context(ErrorData::BindingConfigInvalid {
73                binding_name: binding_name.clone(),
74                reason: "Failed to extract resource_group_name from binding".to_string(),
75            })?;
76
77        // Derive registry endpoint from registry name
78        let registry_endpoint = format!("{}.azurecr.io", registry_name);
79        let client = crate::http_client::create_http_client();
80        let token_cache_1 = AzureTokenCache::new(azure_config.clone());
81        let token_cache_2 = AzureTokenCache::new(azure_config.clone());
82        let token_cache_3 = AzureTokenCache::new(azure_config.clone());
83        let acr_client = AzureContainerRegistryClient::new(client.clone(), token_cache_1);
84        let lro_client = LongRunningOperationClient::new(client.clone(), token_cache_2);
85
86        let repository_prefix = match config.repository_prefix {
87            Some(bv) => bv
88                .into_value(&binding_name, "repository_prefix")
89                .unwrap_or_default(),
90            None => String::new(),
91        };
92
93        Ok(Self {
94            acr_client,
95            lro_client,
96            binding_name,
97            registry_name,
98            registry_endpoint,
99            resource_group_name,
100            repository_prefix,
101            azure_token_cache: token_cache_3,
102            http_client: client,
103        })
104    }
105
106    /// Creates a valid Azure resource name from a repository name.
107    /// Azure resource names must:
108    /// - Be less than 50 characters
109    /// - Start with a letter
110    /// - Only contain alphanumeric characters and hyphens
111    fn make_azure_resource_name(&self, repo_name: &str, suffix: &str) -> String {
112        use std::collections::hash_map::DefaultHasher;
113        use std::hash::{Hash, Hasher};
114
115        let max_length = 49;
116        let combined = format!("{}-{}", repo_name, suffix);
117
118        // Azure ACR resource names must: start with a letter, contain only
119        // alphanumeric + single hyphens, no underscores, no consecutive hyphens,
120        // length 5-50.
121        let is_valid = combined.len() >= 5
122            && combined.len() <= max_length
123            && combined
124                .chars()
125                .next()
126                .map_or(false, |c| c.is_ascii_alphabetic())
127            && combined
128                .chars()
129                .all(|c| c.is_ascii_alphanumeric() || c == '-')
130            && !combined.contains("--");
131
132        if is_valid {
133            combined
134        } else {
135            // Create a hash-based name that fits within Azure's constraints
136            let mut hasher = DefaultHasher::new();
137            repo_name.hash(&mut hasher);
138            suffix.hash(&mut hasher);
139            let hash = hasher.finish();
140
141            // Create a name that starts with a letter and includes the hash
142            format!("r{:x}-{}", hash, suffix)
143        }
144    }
145}
146
147impl Binding for AcrArtifactRegistry {}
148
149#[async_trait]
150impl ArtifactRegistry for AcrArtifactRegistry {
151    fn registry_endpoint(&self) -> String {
152        format!("https://{}", self.registry_endpoint)
153    }
154
155    fn upstream_repository_prefix(&self) -> String {
156        self.repository_prefix.clone()
157    }
158
159    async fn create_repository(&self, repo_name: &str) -> Result<RepositoryResponse> {
160        info!(
161            repo_name = %repo_name,
162            registry_name = %self.registry_name,
163            "Creating Azure Container Registry repository (via scope map)"
164        );
165
166        // In ACR, repositories are created implicitly on first push
167        // However, we can create a scope map to control access to the repository
168        let scope_map_name = self.make_azure_resource_name(repo_name, "scope");
169        let actions = vec![
170            format!("repositories/{}/content/read", repo_name),
171            format!("repositories/{}/content/write", repo_name),
172        ];
173
174        let scope_map_properties = ScopeMapProperties {
175            description: Some(format!("Scope map for repository {}", repo_name)),
176            actions,
177            creation_date: None,
178            provisioning_state: None,
179            type_: None,
180        };
181
182        match self
183            .acr_client
184            .create_scope_map(
185                &self.resource_group_name,
186                &self.registry_name,
187                &scope_map_name,
188                &scope_map_properties,
189            )
190            .await
191        {
192            Ok(operation_result) => {
193                match operation_result {
194                    OperationResult::Completed(_) => {
195                        info!(
196                            repo_name = %repo_name,
197                            "Azure Container Registry repository scope map created successfully"
198                        );
199
200                        // Construct the repository URI for Azure Container Registry
201                        let repository_uri = format!("{}/{}", self.registry_endpoint, repo_name);
202
203                        Ok(RepositoryResponse {
204                            name: repo_name.to_string(),
205                            uri: Some(repository_uri),
206                            created_at: None, // ACR doesn't provide creation time in this response
207                        })
208                    }
209                    OperationResult::LongRunning(_) => {
210                        info!(
211                            repo_name = %repo_name,
212                            "Azure Container Registry repository scope map creation is in progress"
213                        );
214
215                        Ok(RepositoryResponse {
216                            name: repo_name.to_string(),
217                            uri: None, // Will be available once creation completes
218                            created_at: None,
219                        })
220                    }
221                }
222            }
223            Err(e) => {
224                warn!(
225                    repo_name = %repo_name,
226                    error = %e,
227                    "Failed to create Azure Container Registry repository scope map"
228                );
229
230                Err(map_cloud_client_error(
231                    e,
232                    format!(
233                        "Failed to create Azure Container Registry repository '{}'",
234                        repo_name
235                    ),
236                    Some(repo_name.to_string()),
237                ))
238            }
239        }
240    }
241
242    async fn get_repository(&self, repo_id: &str) -> Result<RepositoryResponse> {
243        let repo_name = repo_id;
244        let scope_map_name = self.make_azure_resource_name(repo_name, "scope");
245
246        info!(
247            repo_name = %repo_name,
248            registry_name = %self.registry_name,
249            "Getting Azure Container Registry repository details"
250        );
251
252        let scope_map = self
253            .acr_client
254            .get_scope_map(
255                &self.resource_group_name,
256                &self.registry_name,
257                &scope_map_name,
258            )
259            .await
260            .map_err(|_e| {
261                warn!(
262                    repo_name = %repo_name,
263                    "Azure Container Registry repository not found"
264                );
265
266                AlienError::new(ErrorData::ResourceNotFound {
267                    resource_id: repo_name.to_string(),
268                })
269            })?;
270
271        // Construct the repository URI for Azure Container Registry
272        let repository_uri = format!("{}/{}", self.registry_endpoint, repo_name);
273
274        // Azure scope maps don't directly provide creation time
275        let created_at = scope_map.properties.and_then(|props| props.creation_date);
276
277        info!(
278            repo_name = %repo_name,
279            repo_uri = %repository_uri,
280            "Azure Container Registry repository details retrieved"
281        );
282
283        Ok(RepositoryResponse {
284            name: repo_name.to_string(),
285            uri: Some(repository_uri),
286            created_at,
287        })
288    }
289
290    async fn add_cross_account_access(
291        &self,
292        repo_id: &str,
293        _access: CrossAccountAccess,
294    ) -> Result<()> {
295        let repo_name = repo_id;
296
297        info!(
298            repo_name = %repo_name,
299            registry_name = %self.registry_name,
300            "Azure Container Registry cross-account access not supported"
301        );
302
303        Err(AlienError::new(ErrorData::OperationNotSupported {
304            operation: "add_cross_account_access".to_string(),
305            reason: "Azure Container Registry uses token-based access via generate_credentials - cross-account permissions are not supported".to_string(),
306        }))
307    }
308
309    async fn remove_cross_account_access(
310        &self,
311        repo_id: &str,
312        _access: CrossAccountAccess,
313    ) -> Result<()> {
314        let repo_name = repo_id;
315
316        info!(
317            repo_name = %repo_name,
318            registry_name = %self.registry_name,
319            "Azure Container Registry cross-account access not supported"
320        );
321
322        Err(AlienError::new(ErrorData::OperationNotSupported {
323            operation: "remove_cross_account_access".to_string(),
324            reason: "Azure Container Registry uses token-based access via generate_credentials - cross-account permissions are not supported".to_string(),
325        }))
326    }
327
328    async fn get_cross_account_access(&self, repo_id: &str) -> Result<CrossAccountPermissions> {
329        let repo_name = repo_id;
330
331        info!(
332            repo_name = %repo_name,
333            registry_name = %self.registry_name,
334            "Azure Container Registry cross-account access not supported"
335        );
336
337        Err(AlienError::new(ErrorData::OperationNotSupported {
338            operation: "get_cross_account_access".to_string(),
339            reason: "Azure Container Registry uses token-based access via generate_credentials - cross-account permissions are not supported".to_string(),
340        }))
341    }
342
343    async fn generate_credentials(
344        &self,
345        repo_id: &str,
346        permissions: ArtifactRegistryPermissions,
347        _ttl_seconds: Option<u32>,
348    ) -> Result<ArtifactRegistryCredentials> {
349        info!(
350            registry = %self.registry_endpoint,
351            repo_id = %repo_id,
352            permissions = ?permissions,
353            "Generating ACR credentials via AAD → refresh → access token flow"
354        );
355
356        // Step 1: Get an AAD access token for the management API.
357        let aad_token = self
358            .azure_token_cache
359            .get_bearer_token_with_scope("https://management.azure.com/.default")
360            .await
361            .map_err(|e| {
362                map_cloud_client_error(e, "Failed to get AAD token for ACR".to_string(), None)
363            })?;
364
365        // Step 2: Exchange AAD token for an ACR refresh token.
366        // See: https://github.com/Azure/acr/blob/main/docs/AAD-OAuth.md
367        let exchange_url = format!("https://{}/oauth2/exchange", self.registry_endpoint);
368        let exchange_resp = self
369            .http_client
370            .post(&exchange_url)
371            .form(&[
372                ("grant_type", "access_token"),
373                ("service", &self.registry_endpoint),
374                ("access_token", &aad_token),
375            ])
376            .send()
377            .await
378            .into_alien_error()
379            .context(ErrorData::Other {
380                message: "ACR OAuth2 exchange request failed".to_string(),
381            })?;
382
383        if !exchange_resp.status().is_success() {
384            let status = exchange_resp.status();
385            let body = exchange_resp.text().await.unwrap_or_default();
386            return Err(AlienError::new(ErrorData::Other {
387                message: format!("ACR OAuth2 exchange failed with {}: {}", status, body),
388            }));
389        }
390
391        #[derive(serde::Deserialize)]
392        struct ExchangeResponse {
393            refresh_token: String,
394        }
395        let refresh_token = exchange_resp
396            .json::<ExchangeResponse>()
397            .await
398            .into_alien_error()
399            .context(ErrorData::Other {
400                message: "Failed to parse ACR exchange response".to_string(),
401            })?
402            .refresh_token;
403
404        // Step 3: Exchange refresh token for a scoped access token.
405        // The access token is what ACR's /v2/ API accepts as Bearer auth.
406        // Scope: "repository:{repo}:pull,push" or "repository:{repo}:pull"
407        let scope = if repo_id.is_empty() {
408            // No specific repo — request registry-wide catalog access
409            "registry:catalog:*".to_string()
410        } else {
411            let actions = match permissions {
412                ArtifactRegistryPermissions::Pull => "pull",
413                ArtifactRegistryPermissions::PushPull => "pull,push",
414            };
415            format!("repository:{}:{}", repo_id, actions)
416        };
417
418        let token_url = format!("https://{}/oauth2/token", self.registry_endpoint);
419        let token_resp = self
420            .http_client
421            .post(&token_url)
422            .form(&[
423                ("grant_type", "refresh_token"),
424                ("service", &self.registry_endpoint),
425                ("scope", &scope),
426                ("refresh_token", &refresh_token),
427            ])
428            .send()
429            .await
430            .into_alien_error()
431            .context(ErrorData::Other {
432                message: "ACR OAuth2 token request failed".to_string(),
433            })?;
434
435        if !token_resp.status().is_success() {
436            let status = token_resp.status();
437            let body = token_resp.text().await.unwrap_or_default();
438            return Err(AlienError::new(ErrorData::Other {
439                message: format!("ACR OAuth2 token failed with {}: {}", status, body),
440            }));
441        }
442
443        #[derive(serde::Deserialize)]
444        struct TokenResponse {
445            access_token: String,
446        }
447        let access_token = token_resp
448            .json::<TokenResponse>()
449            .await
450            .into_alien_error()
451            .context(ErrorData::Other {
452                message: "Failed to parse ACR token response".to_string(),
453            })?
454            .access_token;
455
456        info!(
457            registry = %self.registry_endpoint,
458            scope = %scope,
459            "ACR access token generated"
460        );
461
462        // Return the access token with empty username to signal Bearer auth.
463        // The proxy checks: if username is empty, use Bearer instead of Basic.
464        Ok(ArtifactRegistryCredentials {
465            username: String::new(),
466            password: access_token,
467            expires_at: None,
468        })
469    }
470
471    async fn cleanup_credentials(&self, repo_id: &str) -> Result<()> {
472        // Same namespaced parsing as generate_credentials
473        let (naming_key, repo_name) = if let Some((_prefix, repo)) = repo_id.split_once("--") {
474            (repo_id, repo)
475        } else {
476            (repo_id, repo_id)
477        };
478
479        let scope_map_name = self.make_azure_resource_name(naming_key, "pull-scope");
480        let token_name = self.make_azure_resource_name(naming_key, "pull-token");
481
482        info!(
483            repo_name = %repo_name,
484            scope_map = %scope_map_name,
485            token = %token_name,
486            registry_name = %self.registry_name,
487            "Cleaning up Azure ACR credentials: deleting token and scope map"
488        );
489
490        // Delete token first (it references the scope map)
491        if let Err(e) = self
492            .acr_client
493            .delete_token(&self.resource_group_name, &self.registry_name, &token_name)
494            .await
495        {
496            warn!(token = %token_name, error = %e, "Failed to delete ACR token (may not exist)");
497        }
498
499        // Delete scope map
500        if let Err(e) = self
501            .acr_client
502            .delete_scope_map(
503                &self.resource_group_name,
504                &self.registry_name,
505                &scope_map_name,
506            )
507            .await
508        {
509            warn!(scope_map = %scope_map_name, error = %e, "Failed to delete ACR scope map (may not exist)");
510        }
511
512        Ok(())
513    }
514
515    async fn delete_repository(&self, repo_id: &str) -> Result<()> {
516        let repo_name = repo_id;
517        let scope_map_name = self.make_azure_resource_name(repo_name, "scope");
518
519        info!(
520            repo_name = %repo_name,
521            registry_name = %self.registry_name,
522            "Deleting Azure Container Registry repository scope map"
523        );
524
525        // Delete the scope map associated with the repository
526        match self
527            .acr_client
528            .delete_scope_map(
529                &self.resource_group_name,
530                &self.registry_name,
531                &scope_map_name,
532            )
533            .await
534        {
535            Ok(_) => {
536                info!(
537                    repo_name = %repo_name,
538                    "Azure Container Registry repository scope map deleted successfully"
539                );
540
541                // Also clean up credentials-related resources (from generate_credentials)
542                let pull_scope_name = self.make_azure_resource_name(repo_name, "pull-scope");
543                let pull_token_name = self.make_azure_resource_name(repo_name, "pull-token");
544
545                // Delete token first (it references the scope map)
546                let _ = self
547                    .acr_client
548                    .delete_token(
549                        &self.resource_group_name,
550                        &self.registry_name,
551                        &pull_token_name,
552                    )
553                    .await;
554
555                let _ = self
556                    .acr_client
557                    .delete_scope_map(
558                        &self.resource_group_name,
559                        &self.registry_name,
560                        &pull_scope_name,
561                    )
562                    .await;
563
564                Ok(())
565            }
566            Err(e) => {
567                warn!(
568                    repo_name = %repo_name,
569                    error = %e,
570                    "Failed to delete Azure Container Registry repository scope map"
571                );
572
573                Err(map_cloud_client_error(
574                    e,
575                    format!(
576                        "Failed to delete Azure Container Registry repository '{}'",
577                        repo_name
578                    ),
579                    Some(repo_name.to_string()),
580                ))
581            }
582        }
583    }
584}