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