1use crate::{
2 error::{map_cloud_client_error, Error, ErrorData, Result},
3 traits::{
4 ArtifactRegistry, ArtifactRegistryCredentials, ArtifactRegistryPermissions,
5 AwsCrossAccountAccess, Binding, ComputeServiceType, CrossAccountAccess,
6 CrossAccountPermissions, RepositoryResponse,
7 },
8};
9use alien_aws_clients::{
10 ecr::{
11 CreateRepositoryRequest, DescribeRepositoriesRequest, EcrApi, EcrClient,
12 GetAuthorizationTokenRequest, GetDownloadUrlForLayerRequest, GetRepositoryPolicyRequest,
13 SetRepositoryPolicyRequest,
14 },
15 sts::{AssumeRoleRequest, StsApi, StsClient},
16 AwsClientConfigExt as _, AwsCredentialProvider,
17};
18use alien_core::bindings::{ArtifactRegistryBinding, EcrArtifactRegistryBinding};
19use alien_error::{AlienError, Context, ContextError, IntoAlienError};
20use async_trait::async_trait;
21use base64::engine::{general_purpose::STANDARD as BASE64, Engine as _};
22use chrono::{DateTime, Utc};
23use serde_json::{json, Value};
24use std::collections::HashMap;
25use tracing::{info, warn};
26
27#[derive(Debug)]
29pub struct EcrArtifactRegistry {
30 credentials: AwsCredentialProvider,
31 ecr_client: EcrClient,
32 binding_name: String,
33 repository_prefix: String,
34 pull_role_arn: Option<String>,
35 push_role_arn: Option<String>,
36}
37
38impl EcrArtifactRegistry {
39 pub async fn new(
41 binding_name: String,
42 binding: ArtifactRegistryBinding,
43 credentials: &AwsCredentialProvider,
44 ) -> Result<Self> {
45 info!(
46 binding_name = %binding_name,
47 "Initializing AWS ECR artifact registry"
48 );
49
50 let client = crate::http_client::create_http_client();
51 let ecr_client = EcrClient::new(client, credentials.clone());
52
53 let config = match binding {
55 ArtifactRegistryBinding::Ecr(config) => config,
56 _ => {
57 return Err(AlienError::new(ErrorData::BindingConfigInvalid {
58 binding_name: binding_name.clone(),
59 reason: "Expected ECR binding, got different service type".to_string(),
60 }));
61 }
62 };
63
64 let repository_prefix = config
65 .repository_prefix
66 .into_value(&binding_name, "repository_prefix")
67 .context(ErrorData::BindingConfigInvalid {
68 binding_name: binding_name.clone(),
69 reason: "Failed to extract repository_prefix from binding".to_string(),
70 })?;
71
72 let pull_role_arn = config
73 .pull_role_arn
74 .map(|v| {
75 v.into_value(&binding_name, "pull_role_arn").context(
76 ErrorData::BindingConfigInvalid {
77 binding_name: binding_name.clone(),
78 reason: "Failed to extract pull_role_arn from binding".to_string(),
79 },
80 )
81 })
82 .transpose()?;
83
84 let push_role_arn = config
85 .push_role_arn
86 .map(|v| {
87 v.into_value(&binding_name, "push_role_arn").context(
88 ErrorData::BindingConfigInvalid {
89 binding_name: binding_name.clone(),
90 reason: "Failed to extract push_role_arn from binding".to_string(),
91 },
92 )
93 })
94 .transpose()?;
95
96 Ok(Self {
97 credentials: credentials.clone(),
98 ecr_client,
99 binding_name,
100 repository_prefix,
101 pull_role_arn,
102 push_role_arn,
103 })
104 }
105
106 fn make_full_repo_name(&self, repo_name: &str) -> String {
109 if repo_name.is_empty() {
110 self.repository_prefix.clone()
111 } else if !self.repository_prefix.is_empty() {
112 format!("{}-{}", self.repository_prefix, repo_name)
113 } else {
114 repo_name.to_string()
115 }
116 }
117
118 async fn set_full_policy(
120 &self,
121 repo_name: &str,
122 aws_access: &AwsCrossAccountAccess,
123 ) -> Result<()> {
124 self.set_full_policy_with_client(&self.ecr_client, repo_name, aws_access)
125 .await
126 }
127
128 async fn set_full_policy_with_client(
129 &self,
130 ecr_client: &EcrClient,
131 repo_name: &str,
132 aws_access: &AwsCrossAccountAccess,
133 ) -> Result<()> {
134 let mut statements = Vec::new();
135
136 {
141 let mut principals: Vec<String> = aws_access
142 .account_ids
143 .iter()
144 .map(|id| format!("arn:aws:iam::{}:root", id))
145 .collect();
146 for arn in &aws_access.role_arns {
147 if !principals.contains(arn) {
148 principals.push(arn.clone());
149 }
150 }
151 if !principals.is_empty() {
152 statements.push(json!({
153 "Sid": "CrossAccountRolePermission",
154 "Effect": "Allow",
155 "Principal": {
156 "AWS": principals
157 },
158 "Action": [
159 "ecr:BatchCheckLayerAvailability",
160 "ecr:GetDownloadUrlForLayer",
161 "ecr:BatchGetImage",
162 "ecr:GetRepositoryPolicy",
167 "ecr:SetRepositoryPolicy"
168 ]
169 }));
170 }
171 }
172
173 for service_type in &aws_access.allowed_service_types {
175 match service_type {
176 ComputeServiceType::Function => {
177 if !aws_access.account_ids.is_empty() {
178 let source_arns: Vec<String> = aws_access
182 .account_ids
183 .iter()
184 .flat_map(|account_id| {
185 if aws_access.regions.is_empty() {
186 vec![format!("arn:aws:lambda:*:{}:function:*", account_id)]
187 } else {
188 aws_access
189 .regions
190 .iter()
191 .map(|region| {
192 format!(
193 "arn:aws:lambda:{}:{}:function:*",
194 region, account_id
195 )
196 })
197 .collect()
198 }
199 })
200 .collect();
201
202 statements.push(json!({
203 "Sid": "LambdaECRImageCrossAccountRetrievalPolicy",
204 "Effect": "Allow",
205 "Principal": {
206 "Service": "lambda.amazonaws.com"
207 },
208 "Action": [
209 "ecr:BatchGetImage",
210 "ecr:GetDownloadUrlForLayer"
211 ],
212 "Condition": {
213 "StringLike": {
214 "aws:sourceArn": source_arns
215 }
216 }
217 }));
218 }
219 }
220 }
221 }
222
223 let policy = json!({
225 "Version": "2012-10-17",
226 "Statement": statements
227 });
228
229 let request = SetRepositoryPolicyRequest::builder()
230 .repository_name(repo_name.to_string())
231 .policy_text(policy.to_string())
232 .build();
233
234 ecr_client
235 .set_repository_policy(request)
236 .await
237 .map_err(|e| {
238 map_cloud_client_error(
239 e,
240 format!(
241 "Failed to set cross-account access for ECR repository '{}'",
242 repo_name
243 ),
244 Some(repo_name.to_string()),
245 )
246 })?;
247
248 info!(
249 repo_name = %repo_name,
250 "ECR repository cross-account access policy updated successfully"
251 );
252 Ok(())
253 }
254}
255
256impl Binding for EcrArtifactRegistry {}
257
258#[async_trait]
259impl ArtifactRegistry for EcrArtifactRegistry {
260 fn registry_endpoint(&self) -> String {
261 format!(
262 "https://{}.dkr.ecr.{}.amazonaws.com",
263 self.credentials.account_id(),
264 self.credentials.region(),
265 )
266 }
267
268 fn upstream_repository_prefix(&self) -> String {
269 self.repository_prefix.clone()
270 }
271
272 async fn create_repository(&self, repo_name: &str) -> Result<RepositoryResponse> {
273 let full_repo_name = self.make_full_repo_name(repo_name);
274
275 info!(
276 repo_name = %repo_name,
277 full_repo_name = %full_repo_name,
278 "Creating ECR repository"
279 );
280
281 let ecr_config = if let Some(push_role_arn) = &self.push_role_arn {
283 self.credentials
284 .config()
285 .impersonate(alien_aws_clients::AwsImpersonationConfig {
286 role_arn: push_role_arn.clone(),
287 session_name: Some("alien-ecr-create".to_string()),
288 duration_seconds: None,
289 external_id: None,
290 target_region: None,
291 })
292 .await
293 .map_err(|e| {
294 map_cloud_client_error(
295 e,
296 "Failed to assume ECR push role".to_string(),
297 Some(repo_name.to_string()),
298 )
299 })?
300 } else {
301 self.credentials.config().clone()
302 };
303 let ecr_client = alien_aws_clients::ecr::EcrClient::new(
304 crate::http_client::create_http_client(),
305 AwsCredentialProvider::from_config(ecr_config)
306 .await
307 .context(ErrorData::BindingSetupFailed {
308 binding_type: "artifact_registry.ecr".to_string(),
309 reason: "Failed to create credential provider for ECR access".to_string(),
310 })?,
311 );
312
313 let request = CreateRepositoryRequest::builder()
314 .repository_name(full_repo_name.clone())
315 .build();
316
317 let response = ecr_client.create_repository(request).await.map_err(|e| {
318 map_cloud_client_error(
319 e,
320 format!("Failed to create ECR repository '{}'", full_repo_name),
321 Some(repo_name.to_string()),
322 )
323 })?;
324
325 info!(
326 repo_name = %repo_name,
327 full_repo_name = %full_repo_name,
328 "ECR repository created successfully"
329 );
330
331 let repository = &response.repository;
333 let created_at = if repository.created_at > 0.0 {
334 DateTime::from_timestamp(repository.created_at as i64, 0).map(|dt| dt.to_rfc3339())
335 } else {
336 None
337 };
338
339 Ok(RepositoryResponse {
340 name: repo_name.to_string(),
341 uri: Some(repository.repository_uri.clone()),
342 created_at,
343 })
344 }
345
346 async fn get_repository(&self, repo_id: &str) -> Result<RepositoryResponse> {
347 let full_repo_name = self.make_full_repo_name(repo_id);
348
349 info!(
350 repo_id = %repo_id,
351 full_repo_name = %full_repo_name,
352 "Getting ECR repository details"
353 );
354
355 let pull_role_arn = self.pull_role_arn.as_ref().ok_or_else(|| {
357 AlienError::new(ErrorData::BindingConfigInvalid {
358 binding_name: self.binding_name.clone(),
359 reason: "Pull role ARN not available".to_string(),
360 })
361 })?;
362 let impersonated = self
363 .credentials
364 .config()
365 .impersonate(alien_aws_clients::AwsImpersonationConfig {
366 role_arn: pull_role_arn.clone(),
367 session_name: Some("alien-ecr-describe".to_string()),
368 duration_seconds: None,
369 external_id: None,
370 target_region: None,
371 })
372 .await
373 .map_err(|e| {
374 map_cloud_client_error(
375 e,
376 "Failed to assume ECR pull role".to_string(),
377 Some(repo_id.to_string()),
378 )
379 })?;
380 let ecr_client = alien_aws_clients::ecr::EcrClient::new(
381 crate::http_client::create_http_client(),
382 AwsCredentialProvider::from_config(impersonated)
383 .await
384 .context(ErrorData::BindingSetupFailed {
385 binding_type: "artifact_registry.ecr".to_string(),
386 reason: "Failed to create credential provider for impersonated role"
387 .to_string(),
388 })?,
389 );
390
391 let request = DescribeRepositoriesRequest::builder()
392 .repository_names(vec![full_repo_name.clone()])
393 .build();
394
395 let response = ecr_client
396 .describe_repositories(request)
397 .await
398 .map_err(|e| {
399 map_cloud_client_error(
400 e,
401 format!(
402 "Failed to get ECR repository details for '{}'",
403 full_repo_name
404 ),
405 Some(repo_id.to_string()),
406 )
407 })?;
408
409 if response.repositories.is_empty() {
410 warn!(
411 repo_id = %repo_id,
412 full_repo_name = %full_repo_name,
413 "ECR repository not found"
414 );
415
416 return Err(AlienError::new(ErrorData::ResourceNotFound {
417 resource_id: repo_id.to_string(),
418 }));
419 }
420
421 let repository = &response.repositories[0];
422 let created_at = if repository.created_at > 0.0 {
423 DateTime::from_timestamp(repository.created_at as i64, 0).map(|dt| dt.to_rfc3339())
424 } else {
425 None
426 };
427
428 info!(
429 repo_id = %repo_id,
430 full_repo_name = %full_repo_name,
431 repo_uri = %repository.repository_uri,
432 "ECR repository details retrieved"
433 );
434
435 Ok(RepositoryResponse {
436 name: repository.repository_name.clone(),
437 uri: Some(repository.repository_uri.clone()),
438 created_at,
439 })
440 }
441
442 async fn add_cross_account_access(
443 &self,
444 repo_id: &str,
445 access: CrossAccountAccess,
446 ) -> Result<()> {
447 let full_repo_name = self.make_full_repo_name(repo_id);
448
449 let aws_access = match access {
450 CrossAccountAccess::Aws(aws_access) => aws_access,
451 _ => {
452 return Err(AlienError::new(ErrorData::BindingConfigInvalid {
453 binding_name: self.binding_name.clone(),
454 reason: "AWS artifact registry can only accept AWS cross-account access configuration".to_string(),
455 }));
456 }
457 };
458
459 info!(
460 repo_id = %repo_id,
461 full_repo_name = %full_repo_name,
462 account_ids = ?aws_access.account_ids,
463 allowed_service_types = ?aws_access.allowed_service_types,
464 role_arns = ?aws_access.role_arns,
465 "Adding ECR repository cross-account access"
466 );
467
468 let current_permissions = self.get_cross_account_access(repo_id).await?;
470 let current_aws_access = match current_permissions.access {
471 CrossAccountAccess::Aws(aws_access) => aws_access,
472 _ => AwsCrossAccountAccess {
473 account_ids: Vec::new(),
474 regions: Vec::new(),
475 allowed_service_types: Vec::new(),
476 role_arns: Vec::new(),
477 },
478 };
479
480 let mut merged_account_ids = current_aws_access.account_ids;
482 let mut merged_regions = current_aws_access.regions;
483 let mut merged_service_types = current_aws_access.allowed_service_types;
484 let mut merged_role_arns = current_aws_access.role_arns;
485
486 for account_id in aws_access.account_ids {
487 if !merged_account_ids.contains(&account_id) {
488 merged_account_ids.push(account_id);
489 }
490 }
491
492 for region in aws_access.regions {
493 if !merged_regions.contains(®ion) {
494 merged_regions.push(region);
495 }
496 }
497
498 for service_type in aws_access.allowed_service_types {
499 if !merged_service_types.contains(&service_type) {
500 merged_service_types.push(service_type);
501 }
502 }
503
504 for role_arn in aws_access.role_arns {
505 if !merged_role_arns.contains(&role_arn) {
506 merged_role_arns.push(role_arn);
507 }
508 }
509
510 let merged_access = AwsCrossAccountAccess {
511 account_ids: merged_account_ids,
512 regions: merged_regions.clone(),
513 allowed_service_types: merged_service_types,
514 role_arns: merged_role_arns,
515 };
516
517 self.set_full_policy(&full_repo_name, &merged_access)
519 .await?;
520
521 let source_region = self.credentials.region().to_string();
526 for region in &merged_access.regions {
527 if *region == source_region {
528 continue; }
530 match self.credentials.with_region(region).await {
531 Ok(target_creds) => {
532 let http_client = crate::http_client::create_http_client();
533 let target_ecr = EcrClient::new(http_client, target_creds);
534 match self
535 .set_full_policy_with_client(&target_ecr, &full_repo_name, &merged_access)
536 .await
537 {
538 Ok(()) => {
539 info!(
540 repo_name = %full_repo_name,
541 region = %region,
542 "ECR cross-account policy set on replicated repo"
543 );
544 }
545 Err(e) => {
546 warn!(
547 repo_name = %full_repo_name,
548 region = %region,
549 error = %e,
550 "Failed to set ECR policy on replicated repo (may not exist yet)"
551 );
552 }
553 }
554 }
555 Err(e) => {
556 warn!(
557 region = %region,
558 error = %e,
559 "Failed to create credentials for target region"
560 );
561 }
562 }
563 }
564
565 Ok(())
566 }
567
568 async fn remove_cross_account_access(
569 &self,
570 repo_id: &str,
571 access: CrossAccountAccess,
572 ) -> Result<()> {
573 let full_repo_name = self.make_full_repo_name(repo_id);
574
575 let aws_access = match access {
576 CrossAccountAccess::Aws(aws_access) => aws_access,
577 _ => {
578 return Err(AlienError::new(ErrorData::BindingConfigInvalid {
579 binding_name: self.binding_name.clone(),
580 reason: "AWS artifact registry can only accept AWS cross-account access configuration".to_string(),
581 }));
582 }
583 };
584
585 info!(
586 repo_id = %repo_id,
587 full_repo_name = %full_repo_name,
588 account_ids = ?aws_access.account_ids,
589 allowed_service_types = ?aws_access.allowed_service_types,
590 role_arns = ?aws_access.role_arns,
591 "Removing ECR repository cross-account access"
592 );
593
594 let current_permissions = self.get_cross_account_access(repo_id).await?;
596 let current_aws_access = match current_permissions.access {
597 CrossAccountAccess::Aws(aws_access) => aws_access,
598 _ => {
599 info!(repo_id = %repo_id, full_repo_name = %full_repo_name, "No existing AWS cross-account permissions to remove");
601 return Ok(());
602 }
603 };
604
605 let mut filtered_account_ids = current_aws_access.account_ids;
606 let mut filtered_regions = current_aws_access.regions;
607 let mut filtered_service_types = current_aws_access.allowed_service_types;
608 let mut filtered_role_arns = current_aws_access.role_arns;
609
610 filtered_account_ids.retain(|id| !aws_access.account_ids.contains(id));
611 filtered_regions.retain(|r| !aws_access.regions.contains(r));
612 filtered_service_types
613 .retain(|service_type| !aws_access.allowed_service_types.contains(service_type));
614 filtered_role_arns.retain(|arn| !aws_access.role_arns.contains(arn));
615
616 let filtered_access = AwsCrossAccountAccess {
617 account_ids: filtered_account_ids,
618 regions: filtered_regions,
619 allowed_service_types: filtered_service_types,
620 role_arns: filtered_role_arns,
621 };
622
623 self.set_full_policy(&full_repo_name, &filtered_access)
624 .await
625 }
626
627 async fn get_cross_account_access(&self, repo_id: &str) -> Result<CrossAccountPermissions> {
628 let full_repo_name = self.make_full_repo_name(repo_id);
629
630 info!(
631 repo_id = %repo_id,
632 full_repo_name = %full_repo_name,
633 "Getting ECR repository cross-account access"
634 );
635
636 let request = GetRepositoryPolicyRequest::builder()
637 .repository_name(full_repo_name.clone())
638 .build();
639
640 let response = self
641 .ecr_client
642 .get_repository_policy(request)
643 .await
644 .map_err(|e| {
645 warn!(
646 repo_id = %repo_id,
647 full_repo_name = %full_repo_name,
648 error = %e,
649 "Failed to get ECR repository policy (repository may not have a policy)"
650 );
651 e
652 });
653
654 let response = match response {
655 Ok(response) => response,
656 Err(_) => {
657 return Ok(CrossAccountPermissions {
658 access: CrossAccountAccess::Aws(AwsCrossAccountAccess {
659 account_ids: Vec::new(),
660 regions: Vec::new(),
661 allowed_service_types: Vec::new(),
662 role_arns: Vec::new(),
663 }),
664 last_updated: None,
665 });
666 }
667 };
668
669 let policy: Value = serde_json::from_str(&response.policy_text)
671 .into_alien_error()
672 .context(ErrorData::UnexpectedResponseFormat {
673 provider: "aws".to_string(),
674 binding_name: "artifact_registry".to_string(),
675 field: "policy_text".to_string(),
676 response_json: response.policy_text.clone(),
677 })?;
678
679 let mut account_ids = Vec::new();
680 let mut role_arns = Vec::new();
681 let mut allowed_service_types = Vec::new();
682
683 if let Some(statements) = policy["Statement"].as_array() {
684 for statement in statements {
685 if statement["Sid"] == "CrossAccountRolePermission" {
687 if let Some(principals) = statement["Principal"]["AWS"].as_array() {
688 for principal in principals {
689 if let Some(principal_str) = principal.as_str() {
690 if !principal_str.starts_with("arn:") {
694 warn!(
695 principal = %principal_str,
696 "Skipping stale principal in ECR policy (deleted role replaced by unique ID)"
697 );
698 continue;
699 }
700 role_arns.push(principal_str.to_string());
701 if let Some(account_id) = principal_str.split(':').nth(4) {
703 account_ids.push(account_id.to_string());
704 }
705 }
706 }
707 } else if let Some(principal) = statement["Principal"]["AWS"].as_str() {
708 if !principal.starts_with("arn:") {
709 warn!(
710 principal = %principal,
711 "Skipping stale principal in ECR policy (deleted role replaced by unique ID)"
712 );
713 } else {
714 role_arns.push(principal.to_string());
715 if let Some(account_id) = principal.split(':').nth(4) {
716 account_ids.push(account_id.to_string());
717 }
718 }
719 }
720 }
721
722 if statement["Sid"] == "LambdaECRImageCrossAccountRetrievalPolicy"
724 || statement["Sid"] == "LambdaServiceAccess"
725 {
726 if statement["Principal"]["Service"] == "lambda.amazonaws.com" {
727 allowed_service_types.push(ComputeServiceType::Function);
728 }
729 }
730 }
731 }
732
733 account_ids.sort();
735 account_ids.dedup();
736 role_arns.sort();
737 role_arns.dedup();
738 allowed_service_types.sort_by_key(|rt| format!("{:?}", rt));
739 allowed_service_types.dedup();
740
741 info!(
742 repo_id = %repo_id,
743 full_repo_name = %full_repo_name,
744 account_ids = ?account_ids,
745 role_arns = ?role_arns,
746 allowed_service_types = ?allowed_service_types,
747 "Retrieved ECR repository cross-account access"
748 );
749
750 Ok(CrossAccountPermissions {
751 access: CrossAccountAccess::Aws(AwsCrossAccountAccess {
752 account_ids,
753 regions: Vec::new(),
754 allowed_service_types,
755 role_arns,
756 }),
757 last_updated: None,
758 })
759 }
760
761 async fn generate_credentials(
762 &self,
763 repo_id: &str,
764 permissions: ArtifactRegistryPermissions,
765 ttl_seconds: Option<u32>,
766 ) -> Result<ArtifactRegistryCredentials> {
767 info!(
768 repo_id = %repo_id,
769 permissions = ?permissions,
770 ttl_seconds = ?ttl_seconds,
771 "Generating ECR credentials by assuming role"
772 );
773
774 let role_arn = match permissions {
776 ArtifactRegistryPermissions::Pull => self.pull_role_arn.as_ref(),
777 ArtifactRegistryPermissions::PushPull => self.push_role_arn.as_ref(),
778 };
779
780 let ecr_config = if let Some(role_arn) = role_arn {
783 info!(role_arn = %role_arn, "Assuming role for ECR access");
784 self.credentials
785 .config()
786 .impersonate(alien_aws_clients::AwsImpersonationConfig {
787 role_arn: role_arn.clone(),
788 session_name: Some(format!(
789 "alien-ecr-access-{}",
790 chrono::Utc::now().timestamp()
791 )),
792 duration_seconds: ttl_seconds.map(|ttl| ttl.min(43200) as i32),
793 external_id: None,
794 target_region: None,
795 })
796 .await
797 .map_err(|e| {
798 map_cloud_client_error(
799 e,
800 "Failed to assume ECR access role".to_string(),
801 Some(repo_id.to_string()),
802 )
803 })?
804 } else {
805 info!("Using direct credentials for ECR access (no role configured)");
806 self.credentials.config().clone()
807 };
808
809 let ecr_client = alien_aws_clients::ecr::EcrClient::new(
811 crate::http_client::create_http_client(),
812 AwsCredentialProvider::from_config(ecr_config)
813 .await
814 .context(ErrorData::BindingSetupFailed {
815 binding_type: "artifact_registry.ecr".to_string(),
816 reason: "Failed to create credential provider for ECR access".to_string(),
817 })?,
818 );
819
820 let request = alien_aws_clients::ecr::GetAuthorizationTokenRequest::builder().build();
822
823 let response = ecr_client
824 .get_authorization_token(request)
825 .await
826 .map_err(|e| {
827 map_cloud_client_error(
828 e,
829 "Failed to get ECR authorization token with assumed role".to_string(),
830 Some(repo_id.to_string()),
831 )
832 })?;
833
834 if let Some(auth_data) = response.authorization_data.first() {
835 let token_bytes = BASE64
837 .decode(&auth_data.authorization_token)
838 .into_alien_error()
839 .context(ErrorData::UnexpectedResponseFormat {
840 provider: "aws".to_string(),
841 binding_name: "artifact_registry".to_string(),
842 field: "authorization_token".to_string(),
843 response_json: auth_data.authorization_token.clone(),
844 })?;
845
846 let token_str = String::from_utf8(token_bytes.clone())
847 .into_alien_error()
848 .context(ErrorData::UnexpectedResponseFormat {
849 provider: "aws".to_string(),
850 binding_name: "artifact_registry".to_string(),
851 field: "authorization_token".to_string(),
852 response_json: format!("{:?}", token_bytes),
853 })?;
854
855 if let Some((username, password)) = token_str.split_once(':') {
857 let expires_at = if ttl_seconds.is_some() || auth_data.expires_at > 0.0 {
858 DateTime::from_timestamp(auth_data.expires_at as i64, 0)
859 .map(|dt| dt.to_rfc3339())
860 } else {
861 None
862 };
863
864 info!(
865 permissions = ?permissions,
866 "ECR authorization token generated successfully with assumed role"
867 );
868
869 Ok(ArtifactRegistryCredentials {
870 username: username.to_string(),
871 password: password.to_string(),
872 expires_at,
873 })
874 } else {
875 Err(AlienError::new(ErrorData::UnexpectedResponseFormat {
876 provider: "aws".to_string(),
877 binding_name: "artifact_registry".to_string(),
878 field: "authorization_token".to_string(),
879 response_json: token_str.to_string(),
880 }))
881 }
882 } else {
883 Err(AlienError::new(ErrorData::CloudPlatformError {
884 message: "ECR authorization response did not contain authorization data"
885 .to_string(),
886 resource_id: Some(repo_id.to_string()),
887 }))
888 }
889 }
890
891 async fn delete_repository(&self, repo_id: &str) -> Result<()> {
892 let full_repo_name = self.make_full_repo_name(repo_id);
893
894 info!(
895 repo_id = %repo_id,
896 full_repo_name = %full_repo_name,
897 "Deleting ECR repository"
898 );
899
900 let ecr_config = if let Some(push_role_arn) = &self.push_role_arn {
902 self.credentials
903 .config()
904 .impersonate(alien_aws_clients::AwsImpersonationConfig {
905 role_arn: push_role_arn.clone(),
906 session_name: Some("alien-ecr-delete".to_string()),
907 duration_seconds: None,
908 external_id: None,
909 target_region: None,
910 })
911 .await
912 .map_err(|e| {
913 map_cloud_client_error(
914 e,
915 "Failed to assume ECR push role".to_string(),
916 Some(repo_id.to_string()),
917 )
918 })?
919 } else {
920 self.credentials.config().clone()
921 };
922 let ecr_client = alien_aws_clients::ecr::EcrClient::new(
923 crate::http_client::create_http_client(),
924 AwsCredentialProvider::from_config(ecr_config)
925 .await
926 .context(ErrorData::BindingSetupFailed {
927 binding_type: "artifact_registry.ecr".to_string(),
928 reason: "Failed to create credential provider for ECR access".to_string(),
929 })?,
930 );
931
932 let request = alien_aws_clients::ecr::DeleteRepositoryRequest::builder()
933 .repository_name(full_repo_name.clone())
934 .force(true)
935 .build();
936
937 ecr_client.delete_repository(request).await.map_err(|e| {
938 map_cloud_client_error(
939 e,
940 format!("Failed to delete ECR repository '{}'", full_repo_name),
941 Some(repo_id.to_string()),
942 )
943 })?;
944
945 info!(
946 repo_id = %repo_id,
947 full_repo_name = %full_repo_name,
948 "ECR repository deleted successfully"
949 );
950 Ok(())
951 }
952
953 async fn generate_blob_download_url(
954 &self,
955 repo_name: &str,
956 digest: &str,
957 _ttl_seconds: u32,
958 ) -> Result<Option<String>> {
959 let full_repo_name = self.make_full_repo_name(repo_name);
960
961 let request = GetDownloadUrlForLayerRequest::builder()
962 .repository_name(full_repo_name.clone())
963 .layer_digest(digest.to_string())
964 .build();
965
966 let response = self
967 .ecr_client
968 .get_download_url_for_layer(request)
969 .await
970 .map_err(|e| {
971 map_cloud_client_error(
972 e,
973 format!(
974 "Failed to get download URL for layer '{}' in repository '{}'",
975 digest, full_repo_name
976 ),
977 Some(repo_name.to_string()),
978 )
979 })?;
980
981 Ok(Some(response.download_url))
982 }
983}