alien_bindings/providers/artifact_registry/
acr.rs1use 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#[derive(Debug)]
16pub struct AcrArtifactRegistry {
17 registry_name: String,
18 registry_endpoint: String,
19 repository_prefix: String,
20 azure_token_cache: AzureTokenCache,
22 http_client: reqwest::Client,
23}
24
25impl AcrArtifactRegistry {
26 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 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 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 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 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 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 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 let scope = if repo_id.is_empty() {
258 "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 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 async fn delete_repository(&self, _repo_id: &str) -> Result<()> {
328 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}