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.clone()).await {
386 Ok(response) => response,
387 Err(e) if self.push_role_arn.is_some() => {
388 warn!(
389 repo_name = %repo_name,
390 full_repo_name = %full_repo_name,
391 error = %e,
392 "Failed to create ECR repository with push role, retrying with base credentials"
393 );
394
395 let direct_ecr_client = alien_aws_clients::ecr::EcrClient::new(
396 crate::http_client::create_http_client(),
397 self.credentials.clone(),
398 );
399 match direct_ecr_client.create_repository(request).await {
400 Ok(response) => response,
401 Err(e) => {
402 let error = map_cloud_client_error(
403 e,
404 format!("Failed to create ECR repository '{}'", full_repo_name),
405 Some(repo_name.to_string()),
406 );
407
408 if matches!(error.http_status_code, Some(409)) {
409 info!(
410 repo_name = %repo_name,
411 full_repo_name = %full_repo_name,
412 "ECR repository already exists"
413 );
414
415 return Ok(RepositoryResponse {
416 name: full_repo_name.clone(),
417 uri: Some(self.repository_uri(&full_repo_name)),
418 created_at: None,
419 });
420 }
421
422 return Err(error);
423 }
424 }
425 }
426 Err(e) => {
427 let error = map_cloud_client_error(
428 e,
429 format!("Failed to create ECR repository '{}'", full_repo_name),
430 Some(repo_name.to_string()),
431 );
432
433 if matches!(error.http_status_code, Some(409)) {
434 info!(
435 repo_name = %repo_name,
436 full_repo_name = %full_repo_name,
437 "ECR repository already exists"
438 );
439
440 return Ok(RepositoryResponse {
441 name: full_repo_name.clone(),
442 uri: Some(self.repository_uri(&full_repo_name)),
443 created_at: None,
444 });
445 }
446
447 return Err(error);
448 }
449 };
450
451 info!(
452 repo_name = %repo_name,
453 full_repo_name = %full_repo_name,
454 "ECR repository created successfully"
455 );
456
457 let repository = &response.repository;
459 let created_at = if repository.created_at > 0.0 {
460 DateTime::from_timestamp(repository.created_at as i64, 0).map(|dt| dt.to_rfc3339())
461 } else {
462 None
463 };
464
465 Ok(RepositoryResponse {
466 name: full_repo_name,
467 uri: Some(repository.repository_uri.clone()),
468 created_at,
469 })
470 }
471
472 async fn get_repository(&self, repo_id: &str) -> Result<RepositoryResponse> {
473 let lookup_names = self.repository_lookup_names(repo_id);
476
477 info!(
478 repo_id = %repo_id,
479 lookup_names = ?lookup_names,
480 "Getting ECR repository details"
481 );
482
483 let pull_role_arn = self.pull_role_arn.as_ref().ok_or_else(|| {
485 AlienError::new(ErrorData::BindingConfigInvalid {
486 binding_name: self.binding_name.clone(),
487 reason: "Pull role ARN not available".to_string(),
488 })
489 })?;
490 let impersonated = self
491 .credentials
492 .config()
493 .impersonate(alien_aws_clients::AwsImpersonationConfig {
494 role_arn: pull_role_arn.clone(),
495 session_name: Some("alien-ecr-describe".to_string()),
496 duration_seconds: None,
497 external_id: None,
498 target_region: None,
499 })
500 .await
501 .map_err(|e| {
502 map_cloud_client_error(
503 e,
504 "Failed to assume ECR pull role".to_string(),
505 Some(repo_id.to_string()),
506 )
507 })?;
508 let ecr_client = alien_aws_clients::ecr::EcrClient::new(
509 crate::http_client::create_http_client(),
510 AwsCredentialProvider::from_config(impersonated)
511 .await
512 .context(ErrorData::BindingSetupFailed {
513 binding_type: "artifact_registry.ecr".to_string(),
514 reason: "Failed to create credential provider for impersonated role"
515 .to_string(),
516 })?,
517 );
518
519 let last_lookup_index = lookup_names.len().saturating_sub(1);
520 for (index, full_repo_name) in lookup_names.iter().enumerate() {
521 let request = DescribeRepositoriesRequest::builder()
522 .repository_names(vec![full_repo_name.clone()])
523 .build();
524
525 let response = match ecr_client.describe_repositories(request).await {
526 Ok(response) => response,
527 Err(e) => {
528 let error = map_cloud_client_error(
529 e,
530 format!(
531 "Failed to get ECR repository details for '{}'",
532 full_repo_name
533 ),
534 Some(repo_id.to_string()),
535 );
536
537 if index < last_lookup_index
538 && matches!(error.http_status_code, Some(403 | 404))
539 {
540 continue;
541 }
542
543 return Err(error);
544 }
545 };
546
547 if response.repositories.is_empty() {
548 continue;
549 }
550
551 let repository = &response.repositories[0];
552 let created_at = if repository.created_at > 0.0 {
553 DateTime::from_timestamp(repository.created_at as i64, 0).map(|dt| dt.to_rfc3339())
554 } else {
555 None
556 };
557
558 info!(
559 repo_id = %repo_id,
560 full_repo_name = %full_repo_name,
561 repo_uri = %repository.repository_uri,
562 "ECR repository details retrieved"
563 );
564
565 return Ok(RepositoryResponse {
566 name: repository.repository_name.clone(),
567 uri: Some(repository.repository_uri.clone()),
568 created_at,
569 });
570 }
571
572 warn!(
573 repo_id = %repo_id,
574 lookup_names = ?lookup_names,
575 "ECR repository not found"
576 );
577
578 Err(AlienError::new(ErrorData::ResourceNotFound {
579 resource_id: repo_id.to_string(),
580 }))
581 }
582
583 async fn add_cross_account_access(
584 &self,
585 repo_id: &str,
586 access: CrossAccountAccess,
587 ) -> Result<()> {
588 let full_repo_name = repo_id.to_string();
595
596 let aws_access = match access {
597 CrossAccountAccess::Aws(aws_access) => aws_access,
598 _ => {
599 return Err(AlienError::new(ErrorData::BindingConfigInvalid {
600 binding_name: self.binding_name.clone(),
601 reason: "AWS artifact registry can only accept AWS cross-account access configuration".to_string(),
602 }));
603 }
604 };
605
606 info!(
607 repo_id = %repo_id,
608 full_repo_name = %full_repo_name,
609 account_ids = ?aws_access.account_ids,
610 allowed_service_types = ?aws_access.allowed_service_types,
611 role_arns = ?aws_access.role_arns,
612 "Adding ECR repository cross-account access"
613 );
614
615 let current_permissions = self.get_cross_account_access(repo_id).await?;
617 let current_aws_access = match current_permissions.access {
618 CrossAccountAccess::Aws(aws_access) => aws_access,
619 _ => AwsCrossAccountAccess {
620 account_ids: Vec::new(),
621 regions: Vec::new(),
622 allowed_service_types: Vec::new(),
623 role_arns: Vec::new(),
624 },
625 };
626
627 let mut merged_account_ids = current_aws_access.account_ids;
629 let mut merged_regions = current_aws_access.regions;
630 let mut merged_service_types = current_aws_access.allowed_service_types;
631 let mut merged_role_arns = current_aws_access.role_arns;
632
633 for account_id in aws_access.account_ids {
634 if !merged_account_ids.contains(&account_id) {
635 merged_account_ids.push(account_id);
636 }
637 }
638
639 for region in aws_access.regions {
640 if !merged_regions.contains(®ion) {
641 merged_regions.push(region);
642 }
643 }
644
645 for service_type in aws_access.allowed_service_types {
646 if !merged_service_types.contains(&service_type) {
647 merged_service_types.push(service_type);
648 }
649 }
650
651 for role_arn in aws_access.role_arns {
652 if !merged_role_arns.contains(&role_arn) {
653 merged_role_arns.push(role_arn);
654 }
655 }
656
657 let merged_access = AwsCrossAccountAccess {
658 account_ids: merged_account_ids,
659 regions: merged_regions.clone(),
660 allowed_service_types: merged_service_types,
661 role_arns: merged_role_arns,
662 };
663
664 self.set_full_policy(&full_repo_name, &merged_access)
666 .await?;
667
668 let source_region = self.credentials.region().to_string();
673 for region in &merged_access.regions {
674 if *region == source_region {
675 continue; }
677
678 let target_creds = self.credentials.with_region(region).await.map_err(|e| {
679 map_cloud_client_error(
680 e,
681 format!("Failed to create ECR credentials for region '{}'", region),
682 Some(full_repo_name.clone()),
683 )
684 })?;
685 let http_client = crate::http_client::create_http_client();
686 let target_ecr = EcrClient::new(http_client, target_creds);
687
688 self.wait_for_repository_with_client(&target_ecr, &full_repo_name, region)
689 .await?;
690 self.set_full_policy_with_client(&target_ecr, &full_repo_name, &merged_access)
691 .await?;
692
693 info!(
694 repo_name = %full_repo_name,
695 region = %region,
696 "ECR cross-account policy set on replicated repo"
697 );
698 }
699
700 Ok(())
701 }
702
703 async fn remove_cross_account_access(
704 &self,
705 repo_id: &str,
706 access: CrossAccountAccess,
707 ) -> Result<()> {
708 let full_repo_name = repo_id.to_string();
715
716 let aws_access = match access {
717 CrossAccountAccess::Aws(aws_access) => aws_access,
718 _ => {
719 return Err(AlienError::new(ErrorData::BindingConfigInvalid {
720 binding_name: self.binding_name.clone(),
721 reason: "AWS artifact registry can only accept AWS cross-account access configuration".to_string(),
722 }));
723 }
724 };
725
726 info!(
727 repo_id = %repo_id,
728 full_repo_name = %full_repo_name,
729 account_ids = ?aws_access.account_ids,
730 allowed_service_types = ?aws_access.allowed_service_types,
731 role_arns = ?aws_access.role_arns,
732 "Removing ECR repository cross-account access"
733 );
734
735 let current_permissions = self.get_cross_account_access(repo_id).await?;
737 let current_aws_access = match current_permissions.access {
738 CrossAccountAccess::Aws(aws_access) => aws_access,
739 _ => {
740 info!(repo_id = %repo_id, full_repo_name = %full_repo_name, "No existing AWS cross-account permissions to remove");
742 return Ok(());
743 }
744 };
745
746 let mut filtered_account_ids = current_aws_access.account_ids;
747 let mut filtered_regions = current_aws_access.regions;
748 let mut filtered_service_types = current_aws_access.allowed_service_types;
749 let mut filtered_role_arns = current_aws_access.role_arns;
750
751 filtered_account_ids.retain(|id| !aws_access.account_ids.contains(id));
752 filtered_regions.retain(|r| !aws_access.regions.contains(r));
753 filtered_service_types
754 .retain(|service_type| !aws_access.allowed_service_types.contains(service_type));
755 filtered_role_arns.retain(|arn| !aws_access.role_arns.contains(arn));
756
757 let filtered_access = AwsCrossAccountAccess {
758 account_ids: filtered_account_ids,
759 regions: filtered_regions,
760 allowed_service_types: filtered_service_types,
761 role_arns: filtered_role_arns,
762 };
763
764 self.set_full_policy(&full_repo_name, &filtered_access)
765 .await
766 }
767
768 async fn get_cross_account_access(&self, repo_id: &str) -> Result<CrossAccountPermissions> {
769 let full_repo_name = repo_id.to_string();
776
777 info!(
778 repo_id = %repo_id,
779 full_repo_name = %full_repo_name,
780 "Getting ECR repository cross-account access"
781 );
782
783 let request = GetRepositoryPolicyRequest::builder()
784 .repository_name(full_repo_name.clone())
785 .build();
786
787 let response = self
788 .ecr_client
789 .get_repository_policy(request)
790 .await
791 .map_err(|e| {
792 warn!(
793 repo_id = %repo_id,
794 full_repo_name = %full_repo_name,
795 error = %e,
796 "Failed to get ECR repository policy (repository may not have a policy)"
797 );
798 e
799 });
800
801 let response = match response {
802 Ok(response) => response,
803 Err(_) => {
804 return Ok(CrossAccountPermissions {
805 access: CrossAccountAccess::Aws(AwsCrossAccountAccess {
806 account_ids: Vec::new(),
807 regions: Vec::new(),
808 allowed_service_types: Vec::new(),
809 role_arns: Vec::new(),
810 }),
811 last_updated: None,
812 });
813 }
814 };
815
816 let policy: Value = serde_json::from_str(&response.policy_text)
818 .into_alien_error()
819 .context(ErrorData::UnexpectedResponseFormat {
820 provider: "aws".to_string(),
821 binding_name: "artifact_registry".to_string(),
822 field: "policy_text".to_string(),
823 response_json: response.policy_text.clone(),
824 })?;
825
826 let mut account_ids = Vec::new();
827 let mut role_arns = Vec::new();
828 let mut allowed_service_types = Vec::new();
829
830 if let Some(statements) = policy["Statement"].as_array() {
831 for statement in statements {
832 if statement["Sid"] == "CrossAccountRolePermission" {
834 if let Some(principals) = statement["Principal"]["AWS"].as_array() {
835 for principal in principals {
836 if let Some(principal_str) = principal.as_str() {
837 if !principal_str.starts_with("arn:") {
841 warn!(
842 principal = %principal_str,
843 "Skipping stale principal in ECR policy (deleted role replaced by unique ID)"
844 );
845 continue;
846 }
847 role_arns.push(principal_str.to_string());
848 if let Some(account_id) = principal_str.split(':').nth(4) {
850 account_ids.push(account_id.to_string());
851 }
852 }
853 }
854 } else if let Some(principal) = statement["Principal"]["AWS"].as_str() {
855 if !principal.starts_with("arn:") {
856 warn!(
857 principal = %principal,
858 "Skipping stale principal in ECR policy (deleted role replaced by unique ID)"
859 );
860 } else {
861 role_arns.push(principal.to_string());
862 if let Some(account_id) = principal.split(':').nth(4) {
863 account_ids.push(account_id.to_string());
864 }
865 }
866 }
867 }
868
869 if statement["Sid"] == "LambdaECRImageCrossAccountRetrievalPolicy"
871 || statement["Sid"] == "LambdaServiceAccess"
872 {
873 if statement["Principal"]["Service"] == "lambda.amazonaws.com" {
874 allowed_service_types.push(ComputeServiceType::Worker);
875 }
876 }
877 }
878 }
879
880 account_ids.sort();
882 account_ids.dedup();
883 role_arns.sort();
884 role_arns.dedup();
885 allowed_service_types.sort_by_key(|rt| format!("{:?}", rt));
886 allowed_service_types.dedup();
887
888 info!(
889 repo_id = %repo_id,
890 full_repo_name = %full_repo_name,
891 account_ids = ?account_ids,
892 role_arns = ?role_arns,
893 allowed_service_types = ?allowed_service_types,
894 "Retrieved ECR repository cross-account access"
895 );
896
897 Ok(CrossAccountPermissions {
898 access: CrossAccountAccess::Aws(AwsCrossAccountAccess {
899 account_ids,
900 regions: Vec::new(),
901 allowed_service_types,
902 role_arns,
903 }),
904 last_updated: None,
905 })
906 }
907
908 async fn generate_credentials(
909 &self,
910 repo_id: &str,
911 permissions: ArtifactRegistryPermissions,
912 ttl_seconds: Option<u32>,
913 ) -> Result<ArtifactRegistryCredentials> {
914 info!(
915 repo_id = %repo_id,
916 permissions = ?permissions,
917 ttl_seconds = ?ttl_seconds,
918 "Generating ECR credentials by assuming role"
919 );
920
921 let role_arn = match permissions {
926 ArtifactRegistryPermissions::Pull => self.pull_role_arn.as_ref(),
927 ArtifactRegistryPermissions::PushPull => self.push_role_arn.as_ref(),
928 };
929
930 let ecr_config = if let Some(role_arn) = role_arn {
933 info!(role_arn = %role_arn, "Assuming role for ECR access");
934 self.credentials
935 .config()
936 .impersonate(alien_aws_clients::AwsImpersonationConfig {
937 role_arn: role_arn.clone(),
938 session_name: Some(format!(
939 "alien-ecr-access-{}",
940 chrono::Utc::now().timestamp()
941 )),
942 duration_seconds: ttl_seconds.map(|ttl| ttl.min(43200) as i32),
943 external_id: None,
944 target_region: None,
945 })
946 .await
947 .map_err(|e| {
948 map_cloud_client_error(
949 e,
950 "Failed to assume ECR access role".to_string(),
951 Some(repo_id.to_string()),
952 )
953 })?
954 } else {
955 info!("Using direct credentials for ECR access (no role configured)");
956 self.credentials.config().clone()
957 };
958
959 let ecr_client = alien_aws_clients::ecr::EcrClient::new(
961 crate::http_client::create_http_client(),
962 AwsCredentialProvider::from_config(ecr_config)
963 .await
964 .context(ErrorData::BindingSetupFailed {
965 binding_type: "artifact_registry.ecr".to_string(),
966 reason: "Failed to create credential provider for ECR access".to_string(),
967 })?,
968 );
969
970 let request = alien_aws_clients::ecr::GetAuthorizationTokenRequest::builder().build();
972
973 let response = ecr_client
974 .get_authorization_token(request)
975 .await
976 .map_err(|e| {
977 map_cloud_client_error(
978 e,
979 "Failed to get ECR authorization token with assumed role".to_string(),
980 Some(repo_id.to_string()),
981 )
982 })?;
983
984 if let Some(auth_data) = response.authorization_data.first() {
985 let token_bytes = BASE64
987 .decode(&auth_data.authorization_token)
988 .into_alien_error()
989 .context(ErrorData::UnexpectedResponseFormat {
990 provider: "aws".to_string(),
991 binding_name: "artifact_registry".to_string(),
992 field: "authorization_token".to_string(),
993 response_json: auth_data.authorization_token.clone(),
994 })?;
995
996 let token_str = String::from_utf8(token_bytes.clone())
997 .into_alien_error()
998 .context(ErrorData::UnexpectedResponseFormat {
999 provider: "aws".to_string(),
1000 binding_name: "artifact_registry".to_string(),
1001 field: "authorization_token".to_string(),
1002 response_json: format!("{:?}", token_bytes),
1003 })?;
1004
1005 if let Some((username, password)) = token_str.split_once(':') {
1007 let expires_at = if ttl_seconds.is_some() || auth_data.expires_at > 0.0 {
1008 DateTime::from_timestamp(auth_data.expires_at as i64, 0)
1009 .map(|dt| dt.to_rfc3339())
1010 } else {
1011 None
1012 };
1013
1014 info!(
1015 permissions = ?permissions,
1016 "ECR authorization token generated successfully with assumed role"
1017 );
1018
1019 Ok(ArtifactRegistryCredentials {
1020 auth_method: RegistryAuthMethod::Basic,
1021 username: username.to_string(),
1022 password: password.to_string(),
1023 expires_at,
1024 })
1025 } else {
1026 Err(AlienError::new(ErrorData::UnexpectedResponseFormat {
1027 provider: "aws".to_string(),
1028 binding_name: "artifact_registry".to_string(),
1029 field: "authorization_token".to_string(),
1030 response_json: token_str.to_string(),
1031 }))
1032 }
1033 } else {
1034 Err(AlienError::new(ErrorData::CloudPlatformError {
1035 message: "ECR authorization response did not contain authorization data"
1036 .to_string(),
1037 resource_id: Some(repo_id.to_string()),
1038 }))
1039 }
1040 }
1041
1042 async fn delete_repository(&self, repo_id: &str) -> Result<()> {
1043 let full_repo_name = repo_id.to_string();
1050
1051 info!(
1052 repo_id = %repo_id,
1053 full_repo_name = %full_repo_name,
1054 "Deleting ECR repository"
1055 );
1056
1057 let ecr_config = if let Some(push_role_arn) = &self.push_role_arn {
1059 self.credentials
1060 .config()
1061 .impersonate(alien_aws_clients::AwsImpersonationConfig {
1062 role_arn: push_role_arn.clone(),
1063 session_name: Some("alien-ecr-delete".to_string()),
1064 duration_seconds: None,
1065 external_id: None,
1066 target_region: None,
1067 })
1068 .await
1069 .map_err(|e| {
1070 map_cloud_client_error(
1071 e,
1072 "Failed to assume ECR push role".to_string(),
1073 Some(repo_id.to_string()),
1074 )
1075 })?
1076 } else {
1077 self.credentials.config().clone()
1078 };
1079 let ecr_client = alien_aws_clients::ecr::EcrClient::new(
1080 crate::http_client::create_http_client(),
1081 AwsCredentialProvider::from_config(ecr_config)
1082 .await
1083 .context(ErrorData::BindingSetupFailed {
1084 binding_type: "artifact_registry.ecr".to_string(),
1085 reason: "Failed to create credential provider for ECR access".to_string(),
1086 })?,
1087 );
1088
1089 let request = alien_aws_clients::ecr::DeleteRepositoryRequest::builder()
1090 .repository_name(full_repo_name.clone())
1091 .force(true)
1092 .build();
1093
1094 ecr_client.delete_repository(request).await.map_err(|e| {
1095 map_cloud_client_error(
1096 e,
1097 format!("Failed to delete ECR repository '{}'", full_repo_name),
1098 Some(repo_id.to_string()),
1099 )
1100 })?;
1101
1102 info!(
1103 repo_id = %repo_id,
1104 full_repo_name = %full_repo_name,
1105 "ECR repository deleted successfully"
1106 );
1107 Ok(())
1108 }
1109}