Skip to main content

alien_bindings/providers/artifact_registry/
ecr.rs

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