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#[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_token_cache: AzureTokenCache,
31 http_client: reqwest::Client,
32}
33
34impl AcrArtifactRegistry {
35 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 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 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 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 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 let mut hasher = DefaultHasher::new();
137 repo_name.hash(&mut hasher);
138 suffix.hash(&mut hasher);
139 let hash = hasher.finish();
140
141 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 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 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, })
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, 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 let repository_uri = format!("{}/{}", self.registry_endpoint, repo_name);
273
274 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 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 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 let scope = if repo_id.is_empty() {
408 "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 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 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 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 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 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 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 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}