Skip to main content

alien_bindings/providers/artifact_registry/
ecr.rs

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/// AWS ECR implementation of the ArtifactRegistry binding.
26#[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    /// Creates a new AWS ECR artifact registry binding from binding parameters.
38    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        // Extract values from binding
52        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    /// Constructs the full repository name for ECR using the repository prefix.
105    /// If `repo_name` is empty, returns just the prefix (shared-repo pattern).
106    /// Uses `-` separator to match IAM policy wildcards (e.g., `alien-artifacts-prj_xxx`).
107    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    /// Internal helper to set the complete ECR policy from an AwsCrossAccountAccess configuration
138    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        // Add cross-account access for target accounts + specific role ARNs.
156        // Per AWS docs, Lambda cross-account ECR pulls require the account root
157        // as a principal (arn:aws:iam::{account}:root), not just specific roles.
158        // See: https://github.com/aws-samples/lambda-cross-account-ecr
159        {
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                        // Required for Lambda CreateFunction: Lambda internally
182                        // verifies/sets the ECR repo policy when creating a
183                        // function with a cross-account image. The calling
184                        // principal needs these permissions on the ECR repo.
185                        "ecr:GetRepositoryPolicy",
186                        "ecr:SetRepositoryPolicy"
187                    ]
188                }));
189            }
190        }
191
192        // Add service-specific access based on compute service types
193        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                        // Build sourceArn patterns per AWS docs:
198                        // https://docs.aws.amazon.com/lambda/latest/dg/images-create.html
199                        // Pattern: arn:aws:lambda:{region}:{account_id}:function:*
200                        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        // Create ECR policy JSON
243        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        // Use push role for cross-account, or direct credentials for single-account.
350        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        // ECR repositories are ready immediately after creation
419        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        // Prefer the routable name returned by `create_repository`, but also
435        // accept the logical repository name used by older callers.
436        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        // Assume the pull role for repository reads
445        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        // `repo_id` is already a fully-qualified ECR repository name. For
550        // user-created repositories it's the routable name returned by
551        // `create_repository` (`{prefix}-{logical}`). For the deployment
552        // cross-account flow it's `upstream_repository_prefix()` — the
553        // shared deployment-image repository where `alien release` writes
554        // every function image. Either way, don't re-prefix.
555        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        // Get current permissions
577        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        // Merge new permissions with existing ones
589        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(&region) {
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        // Set policy on the source region's repo (where images are pushed).
626        self.set_full_policy(&full_repo_name, &merged_access)
627            .await?;
628
629        // Also set the policy on replicated repos in target regions.
630        // ECR replication copies images cross-region but NOT repo policies.
631        // Lambda in us-east-2 pulls from the us-east-2 replica, which needs
632        // its own cross-account policy.
633        let source_region = self.credentials.region().to_string();
634        for region in &merged_access.regions {
635            if *region == source_region {
636                continue; // Already set on source region above.
637            }
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        // `repo_id` is already a fully-qualified ECR repository name. For
670        // user-created repositories it's the routable name returned by
671        // `create_repository` (`{prefix}-{logical}`). For the deployment
672        // cross-account flow it's `upstream_repository_prefix()` — the
673        // shared deployment-image repository where `alien release` writes
674        // every function image. Either way, don't re-prefix.
675        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        // Get current permissions
697        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                // No existing permissions to remove from
702                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        // `repo_id` is already a fully-qualified ECR repository name. For
731        // user-created repositories it's the routable name returned by
732        // `create_repository` (`{prefix}-{logical}`). For the deployment
733        // cross-account flow it's `upstream_repository_prefix()` — the
734        // shared deployment-image repository where `alien release` writes
735        // every function image. Either way, don't re-prefix.
736        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        // Parse the policy JSON to extract role ARNs, account IDs, and resource types
778        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                // Check for cross-account role permissions
794                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                                // AWS replaces deleted role ARNs with role unique IDs (e.g. "AROA...")
799                                // in existing policies. Filter these out to avoid "Principal not found"
800                                // errors when rewriting the policy.
801                                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                                // Extract account ID from role ARN: arn:aws:iam::ACCOUNT_ID:role/RoleName
810                                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                // Check for Lambda service access (both old and new Sid names)
831                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        // Remove duplicates
842        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        // Get the role ARN (optional for single-account deployments).
883        // Push credentials use the configured push role consistently with
884        // repository creation; the caller may only be allowed to assume that
885        // role and not call ECR directly.
886        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        // When a role ARN is configured, assume it for cross-account access.
892        // When no role is configured (single-account), use base credentials directly.
893        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        // Create ECR client with resolved credentials
921        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        // Get ECR authorization token
932        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            // Decode the base64 authorization token
947            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            // Token format is "username:password"
967            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        // `repo_id` is already a fully-qualified ECR repository name. For
1005        // user-created repositories it's the routable name returned by
1006        // `create_repository` (`{prefix}-{logical}`). For the deployment
1007        // cross-account flow it's `upstream_repository_prefix()` — the
1008        // shared deployment-image repository where `alien release` writes
1009        // every function image. Either way, don't re-prefix.
1010        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        // Use push role for cross-account, or direct credentials for single-account.
1019        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}