Skip to main content

alien_bindings/providers/artifact_registry/
acr.rs

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