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
93impl Binding for AcrArtifactRegistry {}
94
95#[async_trait]
96impl ArtifactRegistry for AcrArtifactRegistry {
97    fn registry_endpoint(&self) -> String {
98        format!("https://{}", self.registry_endpoint)
99    }
100
101    fn upstream_repository_prefix(&self) -> String {
102        self.repository_prefix.clone()
103    }
104
105    async fn create_repository(&self, repo_name: &str) -> Result<RepositoryResponse> {
106        // ACR repositories are created implicitly on first push.
107        // The ACR resource itself is provisioned by alien-infra.
108        let repository_uri = format!("{}/{}", self.registry_endpoint, repo_name);
109
110        Ok(RepositoryResponse {
111            name: repo_name.to_string(),
112            uri: Some(repository_uri),
113            created_at: None,
114        })
115    }
116
117    async fn get_repository(&self, repo_id: &str) -> Result<RepositoryResponse> {
118        // ACR repositories are implicit — return the routable name and URI.
119        let repository_uri = format!("{}/{}", self.registry_endpoint, repo_id);
120
121        Ok(RepositoryResponse {
122            name: repo_id.to_string(),
123            uri: Some(repository_uri),
124            created_at: None,
125        })
126    }
127
128    async fn add_cross_account_access(
129        &self,
130        repo_id: &str,
131        _access: CrossAccountAccess,
132    ) -> Result<()> {
133        let repo_name = repo_id;
134
135        info!(
136            repo_name = %repo_name,
137            registry_name = %self.registry_name,
138            "Azure Container Registry cross-account access not supported"
139        );
140
141        Err(AlienError::new(ErrorData::OperationNotSupported {
142            operation: "add_cross_account_access".to_string(),
143            reason: "Azure Container Registry uses token-based access via generate_credentials - cross-account permissions are not supported".to_string(),
144        }))
145    }
146
147    async fn remove_cross_account_access(
148        &self,
149        repo_id: &str,
150        _access: CrossAccountAccess,
151    ) -> Result<()> {
152        let repo_name = repo_id;
153
154        info!(
155            repo_name = %repo_name,
156            registry_name = %self.registry_name,
157            "Azure Container Registry cross-account access not supported"
158        );
159
160        Err(AlienError::new(ErrorData::OperationNotSupported {
161            operation: "remove_cross_account_access".to_string(),
162            reason: "Azure Container Registry uses token-based access via generate_credentials - cross-account permissions are not supported".to_string(),
163        }))
164    }
165
166    async fn get_cross_account_access(&self, repo_id: &str) -> Result<CrossAccountPermissions> {
167        let repo_name = repo_id;
168
169        info!(
170            repo_name = %repo_name,
171            registry_name = %self.registry_name,
172            "Azure Container Registry cross-account access not supported"
173        );
174
175        Err(AlienError::new(ErrorData::OperationNotSupported {
176            operation: "get_cross_account_access".to_string(),
177            reason: "Azure Container Registry uses token-based access via generate_credentials - cross-account permissions are not supported".to_string(),
178        }))
179    }
180
181    async fn generate_credentials(
182        &self,
183        repo_id: &str,
184        permissions: ArtifactRegistryPermissions,
185        _ttl_seconds: Option<u32>,
186    ) -> Result<ArtifactRegistryCredentials> {
187        info!(
188            registry = %self.registry_endpoint,
189            repo_id = %repo_id,
190            permissions = ?permissions,
191            "Generating ACR credentials via AAD → refresh → access token flow"
192        );
193
194        // Step 1: Get an AAD access token for the management API.
195        let aad_token = self
196            .azure_token_cache
197            .get_bearer_token_with_scope("https://management.azure.com/.default")
198            .await
199            .map_err(|e| {
200                map_cloud_client_error(e, "Failed to get AAD token for ACR".to_string(), None)
201            })?;
202
203        // Step 2: Exchange AAD token for an ACR refresh token.
204        // See: https://github.com/Azure/acr/blob/main/docs/AAD-OAuth.md
205        let exchange_url = format!("https://{}/oauth2/exchange", self.registry_endpoint);
206        let exchange_resp = self
207            .http_client
208            .post(&exchange_url)
209            .form(&[
210                ("grant_type", "access_token"),
211                ("service", &self.registry_endpoint),
212                ("access_token", &aad_token),
213            ])
214            .send()
215            .await
216            .into_alien_error()
217            .context(ErrorData::Other {
218                message: "ACR OAuth2 exchange request failed".to_string(),
219            })?;
220
221        if !exchange_resp.status().is_success() {
222            let status = exchange_resp.status();
223            let body = exchange_resp.text().await.unwrap_or_default();
224            return Err(AlienError::new(ErrorData::Other {
225                message: format!("ACR OAuth2 exchange failed with {}: {}", status, body),
226            }));
227        }
228
229        #[derive(serde::Deserialize)]
230        struct ExchangeResponse {
231            refresh_token: String,
232        }
233        let refresh_token = exchange_resp
234            .json::<ExchangeResponse>()
235            .await
236            .into_alien_error()
237            .context(ErrorData::Other {
238                message: "Failed to parse ACR exchange response".to_string(),
239            })?
240            .refresh_token;
241
242        // Step 3: Exchange refresh token for a scoped access token.
243        // The access token is what ACR's /v2/ API accepts as Bearer auth.
244        // Scope: "repository:{repo}:pull,push" or "repository:{repo}:pull"
245        let scope = if repo_id.is_empty() {
246            // No specific repo — request registry-wide catalog access
247            "registry:catalog:*".to_string()
248        } else {
249            let actions = match permissions {
250                ArtifactRegistryPermissions::Pull => "pull",
251                ArtifactRegistryPermissions::PushPull => "pull,push",
252            };
253            format!("repository:{}:{}", repo_id, actions)
254        };
255
256        let token_url = format!("https://{}/oauth2/token", self.registry_endpoint);
257        let token_resp = self
258            .http_client
259            .post(&token_url)
260            .form(&[
261                ("grant_type", "refresh_token"),
262                ("service", &self.registry_endpoint),
263                ("scope", &scope),
264                ("refresh_token", &refresh_token),
265            ])
266            .send()
267            .await
268            .into_alien_error()
269            .context(ErrorData::Other {
270                message: "ACR OAuth2 token request failed".to_string(),
271            })?;
272
273        if !token_resp.status().is_success() {
274            let status = token_resp.status();
275            let body = token_resp.text().await.unwrap_or_default();
276            return Err(AlienError::new(ErrorData::Other {
277                message: format!("ACR OAuth2 token failed with {}: {}", status, body),
278            }));
279        }
280
281        #[derive(serde::Deserialize)]
282        struct TokenResponse {
283            access_token: String,
284        }
285        let access_token = token_resp
286            .json::<TokenResponse>()
287            .await
288            .into_alien_error()
289            .context(ErrorData::Other {
290                message: "Failed to parse ACR token response".to_string(),
291            })?
292            .access_token;
293
294        info!(
295            registry = %self.registry_endpoint,
296            scope = %scope,
297            "ACR access token generated"
298        );
299
300        // ACR OAuth2 access tokens expire in ~5 minutes
301        let expires_at = Some((chrono::Utc::now() + chrono::Duration::seconds(300)).to_rfc3339());
302
303        Ok(ArtifactRegistryCredentials {
304            auth_method: RegistryAuthMethod::Bearer,
305            username: String::new(),
306            password: access_token,
307            expires_at,
308        })
309    }
310
311    // No-op: generate_credentials() uses the stateless AAD → refresh → access token
312    // OAuth2 flow. No persistent resources (scope maps, tokens) are created, so
313    // there is nothing to clean up.
314
315    async fn delete_repository(&self, _repo_id: &str) -> Result<()> {
316        // ACR repositories are implicit (created on push). Nothing to delete.
317        Ok(())
318    }
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324    use alien_core::bindings::{AcrArtifactRegistryBinding, ArtifactRegistryBinding, BindingValue};
325    use alien_core::AzureCredentials;
326
327    fn test_config() -> AzureClientConfig {
328        AzureClientConfig {
329            subscription_id: "sub-test".to_string(),
330            tenant_id: "tenant-test".to_string(),
331            region: Some("eastus".to_string()),
332            credentials: AzureCredentials::AccessToken {
333                token: "token-test".to_string(),
334            },
335            service_overrides: None,
336        }
337    }
338
339    #[tokio::test]
340    async fn new_fails_when_configured_repository_prefix_cannot_be_resolved() {
341        let binding = ArtifactRegistryBinding::Acr(AcrArtifactRegistryBinding {
342            registry_name: BindingValue::Value("registrytest".to_string()),
343            resource_group_name: BindingValue::Value("rg-test".to_string()),
344            repository_prefix: Some(BindingValue::expression(serde_json::json!({
345                "ref": "repositoryPrefix"
346            }))),
347        });
348
349        let result =
350            AcrArtifactRegistry::new("artifact-registry".to_string(), binding, &test_config())
351                .await;
352        let Err(error) = result else {
353            panic!("configured repository_prefix resolution failure should fail initialization");
354        };
355
356        assert!(error
357            .to_string()
358            .contains("Failed to extract repository_prefix from binding"));
359    }
360
361    #[tokio::test]
362    async fn new_uses_empty_repository_prefix_when_repository_prefix_is_omitted() {
363        let binding = ArtifactRegistryBinding::Acr(AcrArtifactRegistryBinding {
364            registry_name: BindingValue::Value("registrytest".to_string()),
365            resource_group_name: BindingValue::Value("rg-test".to_string()),
366            repository_prefix: None,
367        });
368
369        let registry =
370            AcrArtifactRegistry::new("artifact-registry".to_string(), binding, &test_config())
371                .await
372                .expect("omitted repository_prefix should initialize");
373
374        assert_eq!(registry.upstream_repository_prefix(), "");
375    }
376}