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, RegistryAuthMethod, RepositoryResponse,
6    },
7};
8use alien_azure_clients::{AzureClientConfig, AzureTokenCache};
9use alien_core::bindings::ArtifactRegistryBinding;
10use alien_error::{AlienError, Context, IntoAlienError};
11use async_trait::async_trait;
12use tracing::info;
13
14/// Azure Container Registry implementation of the ArtifactRegistry binding.
15#[derive(Debug)]
16pub struct AcrArtifactRegistry {
17    registry_name: String,
18    registry_endpoint: String,
19    repository_prefix: String,
20    /// Azure credentials for direct registry access (AAD token exchange).
21    azure_token_cache: AzureTokenCache,
22    http_client: reqwest::Client,
23}
24
25impl AcrArtifactRegistry {
26    /// Creates a new Azure Container Registry artifact registry binding from binding parameters.
27    ///
28    /// # Arguments
29    /// * `binding_name` - The name of this binding
30    /// * `binding` - The parsed binding parameters
31    pub async fn new(
32        binding_name: String,
33        binding: ArtifactRegistryBinding,
34        azure_config: &AzureClientConfig,
35    ) -> Result<Self> {
36        info!(
37            binding_name = %binding_name,
38            "Initializing Azure Container Registry"
39        );
40
41        // Extract values from binding
42        let config = match binding {
43            ArtifactRegistryBinding::Acr(config) => config,
44            _ => {
45                return Err(AlienError::new(ErrorData::BindingConfigInvalid {
46                    binding_name: binding_name.clone(),
47                    reason: "Expected ACR binding, got different service type".to_string(),
48                }));
49            }
50        };
51
52        let registry_name = config
53            .registry_name
54            .into_value(&binding_name, "registry_name")
55            .context(ErrorData::BindingConfigInvalid {
56                binding_name: binding_name.clone(),
57                reason: "Failed to extract registry_name from binding".to_string(),
58            })?;
59
60        config
61            .resource_group_name
62            .into_value(&binding_name, "resource_group_name")
63            .context(ErrorData::BindingConfigInvalid {
64                binding_name: binding_name.clone(),
65                reason: "Failed to extract resource_group_name from binding".to_string(),
66            })?;
67
68        // Derive registry endpoint from registry name
69        let registry_endpoint = format!("{}.azurecr.io", registry_name);
70        let client = crate::http_client::create_http_client();
71        let azure_token_cache = AzureTokenCache::new(azure_config.clone());
72
73        let repository_prefix = match config.repository_prefix {
74            Some(bv) => bv.into_value(&binding_name, "repository_prefix").context(
75                ErrorData::BindingConfigInvalid {
76                    binding_name: binding_name.clone(),
77                    reason: "Failed to extract repository_prefix from binding".to_string(),
78                },
79            )?,
80            None => String::new(),
81        };
82
83        Ok(Self {
84            registry_name,
85            registry_endpoint,
86            repository_prefix,
87            azure_token_cache,
88            http_client: client,
89        })
90    }
91
92    fn routable_repository_name(&self, repo_name: &str) -> String {
93        if self.repository_prefix.is_empty() {
94            repo_name.to_string()
95        } else {
96            format!("{}/{}", self.repository_prefix, repo_name)
97        }
98    }
99}
100
101impl Binding for AcrArtifactRegistry {}
102
103#[async_trait]
104impl ArtifactRegistry for AcrArtifactRegistry {
105    fn registry_endpoint(&self) -> String {
106        format!("https://{}", self.registry_endpoint)
107    }
108
109    fn upstream_repository_prefix(&self) -> String {
110        self.repository_prefix.clone()
111    }
112
113    async fn create_repository(&self, repo_name: &str) -> Result<RepositoryResponse> {
114        // ACR repositories are created implicitly on first push.
115        // The ACR resource itself is provisioned by alien-infra.
116        let routable_name = self.routable_repository_name(repo_name);
117        let repository_uri = format!("{}/{}", self.registry_endpoint, routable_name);
118
119        Ok(RepositoryResponse {
120            name: routable_name,
121            uri: Some(repository_uri),
122            created_at: None,
123        })
124    }
125
126    async fn get_repository(&self, repo_id: &str) -> Result<RepositoryResponse> {
127        // ACR repositories are implicit — return the routable name and URI.
128        let repository_uri = format!("{}/{}", self.registry_endpoint, repo_id);
129
130        Ok(RepositoryResponse {
131            name: repo_id.to_string(),
132            uri: Some(repository_uri),
133            created_at: None,
134        })
135    }
136
137    async fn add_cross_account_access(
138        &self,
139        repo_id: &str,
140        _access: CrossAccountAccess,
141    ) -> Result<()> {
142        let repo_name = repo_id;
143
144        info!(
145            repo_name = %repo_name,
146            registry_name = %self.registry_name,
147            "Azure Container Registry cross-account access not supported"
148        );
149
150        Err(AlienError::new(ErrorData::OperationNotSupported {
151            operation: "add_cross_account_access".to_string(),
152            reason: "Azure Container Registry uses token-based access via generate_credentials - cross-account permissions are not supported".to_string(),
153        }))
154    }
155
156    async fn remove_cross_account_access(
157        &self,
158        repo_id: &str,
159        _access: CrossAccountAccess,
160    ) -> Result<()> {
161        let repo_name = repo_id;
162
163        info!(
164            repo_name = %repo_name,
165            registry_name = %self.registry_name,
166            "Azure Container Registry cross-account access not supported"
167        );
168
169        Err(AlienError::new(ErrorData::OperationNotSupported {
170            operation: "remove_cross_account_access".to_string(),
171            reason: "Azure Container Registry uses token-based access via generate_credentials - cross-account permissions are not supported".to_string(),
172        }))
173    }
174
175    async fn get_cross_account_access(&self, repo_id: &str) -> Result<CrossAccountPermissions> {
176        let repo_name = repo_id;
177
178        info!(
179            repo_name = %repo_name,
180            registry_name = %self.registry_name,
181            "Azure Container Registry cross-account access not supported"
182        );
183
184        Err(AlienError::new(ErrorData::OperationNotSupported {
185            operation: "get_cross_account_access".to_string(),
186            reason: "Azure Container Registry uses token-based access via generate_credentials - cross-account permissions are not supported".to_string(),
187        }))
188    }
189
190    async fn generate_credentials(
191        &self,
192        repo_id: &str,
193        permissions: ArtifactRegistryPermissions,
194        _ttl_seconds: Option<u32>,
195    ) -> Result<ArtifactRegistryCredentials> {
196        info!(
197            registry = %self.registry_endpoint,
198            repo_id = %repo_id,
199            permissions = ?permissions,
200            "Generating ACR credentials via AAD → refresh → access token flow"
201        );
202
203        // Step 1: Get an AAD access token for the management API.
204        let aad_token = self
205            .azure_token_cache
206            .get_bearer_token_with_scope("https://management.azure.com/.default")
207            .await
208            .map_err(|e| {
209                map_cloud_client_error(e, "Failed to get AAD token for ACR".to_string(), None)
210            })?;
211
212        // Step 2: Exchange AAD token for an ACR refresh token.
213        // See: https://github.com/Azure/acr/blob/main/docs/AAD-OAuth.md
214        let exchange_url = format!("https://{}/oauth2/exchange", self.registry_endpoint);
215        let exchange_resp = self
216            .http_client
217            .post(&exchange_url)
218            .form(&[
219                ("grant_type", "access_token"),
220                ("service", &self.registry_endpoint),
221                ("access_token", &aad_token),
222            ])
223            .send()
224            .await
225            .into_alien_error()
226            .context(ErrorData::Other {
227                message: "ACR OAuth2 exchange request failed".to_string(),
228            })?;
229
230        if !exchange_resp.status().is_success() {
231            let status = exchange_resp.status();
232            let body = exchange_resp.text().await.unwrap_or_default();
233            return Err(AlienError::new(ErrorData::Other {
234                message: format!("ACR OAuth2 exchange failed with {}: {}", status, body),
235            }));
236        }
237
238        #[derive(serde::Deserialize)]
239        struct ExchangeResponse {
240            refresh_token: String,
241        }
242        let refresh_token = exchange_resp
243            .json::<ExchangeResponse>()
244            .await
245            .into_alien_error()
246            .context(ErrorData::Other {
247                message: "Failed to parse ACR exchange response".to_string(),
248            })?
249            .refresh_token;
250
251        // Step 3: Exchange refresh token for a scoped access token.
252        // The access token is what ACR's /v2/ API accepts as Bearer auth.
253        // Scope: "repository:{repo}:pull,push" or "repository:{repo}:pull"
254        let scope = if repo_id.is_empty() {
255            // No specific repo — request registry-wide catalog access
256            "registry:catalog:*".to_string()
257        } else {
258            let actions = match permissions {
259                ArtifactRegistryPermissions::Pull => "pull",
260                ArtifactRegistryPermissions::PushPull => "pull,push",
261            };
262            format!("repository:{}:{}", repo_id, actions)
263        };
264
265        let token_url = format!("https://{}/oauth2/token", self.registry_endpoint);
266        let token_resp = self
267            .http_client
268            .post(&token_url)
269            .form(&[
270                ("grant_type", "refresh_token"),
271                ("service", &self.registry_endpoint),
272                ("scope", &scope),
273                ("refresh_token", &refresh_token),
274            ])
275            .send()
276            .await
277            .into_alien_error()
278            .context(ErrorData::Other {
279                message: "ACR OAuth2 token request failed".to_string(),
280            })?;
281
282        if !token_resp.status().is_success() {
283            let status = token_resp.status();
284            let body = token_resp.text().await.unwrap_or_default();
285            return Err(AlienError::new(ErrorData::Other {
286                message: format!("ACR OAuth2 token failed with {}: {}", status, body),
287            }));
288        }
289
290        #[derive(serde::Deserialize)]
291        struct TokenResponse {
292            access_token: String,
293        }
294        let access_token = token_resp
295            .json::<TokenResponse>()
296            .await
297            .into_alien_error()
298            .context(ErrorData::Other {
299                message: "Failed to parse ACR token response".to_string(),
300            })?
301            .access_token;
302
303        info!(
304            registry = %self.registry_endpoint,
305            scope = %scope,
306            "ACR access token generated"
307        );
308
309        // ACR OAuth2 access tokens expire in ~5 minutes
310        let expires_at = Some((chrono::Utc::now() + chrono::Duration::seconds(300)).to_rfc3339());
311
312        Ok(ArtifactRegistryCredentials {
313            auth_method: RegistryAuthMethod::Bearer,
314            username: String::new(),
315            password: access_token,
316            expires_at,
317        })
318    }
319
320    // No-op: generate_credentials() uses the stateless AAD → refresh → access token
321    // OAuth2 flow. No persistent resources (scope maps, tokens) are created, so
322    // there is nothing to clean up.
323
324    async fn delete_repository(&self, _repo_id: &str) -> Result<()> {
325        // ACR repositories are implicit (created on push). Nothing to delete.
326        Ok(())
327    }
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333    use alien_core::bindings::{AcrArtifactRegistryBinding, ArtifactRegistryBinding, BindingValue};
334    use alien_core::AzureCredentials;
335
336    fn test_config() -> AzureClientConfig {
337        AzureClientConfig {
338            subscription_id: "sub-test".to_string(),
339            tenant_id: "tenant-test".to_string(),
340            region: Some("eastus".to_string()),
341            credentials: AzureCredentials::AccessToken {
342                token: "token-test".to_string(),
343            },
344            service_overrides: None,
345        }
346    }
347
348    #[tokio::test]
349    async fn new_fails_when_configured_repository_prefix_cannot_be_resolved() {
350        let binding = ArtifactRegistryBinding::Acr(AcrArtifactRegistryBinding {
351            registry_name: BindingValue::Value("registrytest".to_string()),
352            resource_group_name: BindingValue::Value("rg-test".to_string()),
353            repository_prefix: Some(BindingValue::expression(serde_json::json!({
354                "ref": "repositoryPrefix"
355            }))),
356        });
357
358        let result =
359            AcrArtifactRegistry::new("artifact-registry".to_string(), binding, &test_config())
360                .await;
361        let Err(error) = result else {
362            panic!("configured repository_prefix resolution failure should fail initialization");
363        };
364
365        assert!(error
366            .to_string()
367            .contains("Failed to extract repository_prefix from binding"));
368    }
369
370    #[tokio::test]
371    async fn new_uses_empty_repository_prefix_when_repository_prefix_is_omitted() {
372        let binding = ArtifactRegistryBinding::Acr(AcrArtifactRegistryBinding {
373            registry_name: BindingValue::Value("registrytest".to_string()),
374            resource_group_name: BindingValue::Value("rg-test".to_string()),
375            repository_prefix: None,
376        });
377
378        let registry =
379            AcrArtifactRegistry::new("artifact-registry".to_string(), binding, &test_config())
380                .await
381                .expect("omitted repository_prefix should initialize");
382
383        assert_eq!(registry.upstream_repository_prefix(), "");
384    }
385
386    #[tokio::test]
387    async fn new_uses_configured_repository_prefix_for_routable_names() {
388        let binding = ArtifactRegistryBinding::Acr(AcrArtifactRegistryBinding {
389            registry_name: BindingValue::Value("registrytest".to_string()),
390            resource_group_name: BindingValue::Value("rg-test".to_string()),
391            repository_prefix: Some(BindingValue::Value("team-a".to_string())),
392        });
393
394        let registry =
395            AcrArtifactRegistry::new("artifact-registry".to_string(), binding, &test_config())
396                .await
397                .expect("configured repository_prefix should initialize");
398        let repository = registry
399            .create_repository("worker")
400            .await
401            .expect("ACR repository response should be implicit");
402
403        assert_eq!(registry.upstream_repository_prefix(), "team-a");
404        assert_eq!(repository.name, "team-a/worker");
405        assert_eq!(
406            repository.uri,
407            Some("registrytest.azurecr.io/team-a/worker".to_string())
408        );
409    }
410}