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