1use crate::{
2 error::{binding_env_var, map_cloud_client_error, ErrorData, Result},
3 traits::{
4 ArtifactRegistry, ArtifactRegistryCredentials, ArtifactRegistryPermissions,
5 AwsCrossAccountAccess, Binding, ComputeServiceType, CrossAccountAccess,
6 CrossAccountPermissions, RegistryAuthMethod, RepositoryResponse,
7 },
8};
9use alien_aws_clients::{
10 ecr::{
11 CreateRepositoryRequest, DescribeRepositoriesRequest, EcrApi, EcrClient,
12 GetRepositoryPolicyRequest, SetRepositoryPolicyRequest,
13 },
14 AwsClientConfigExt as _, AwsCredentialProvider,
15};
16use alien_core::bindings::ArtifactRegistryBinding;
17use alien_error::{AlienError, Context, IntoAlienError};
18use async_trait::async_trait;
19use base64::engine::{general_purpose::STANDARD as BASE64, Engine as _};
20use chrono::DateTime;
21use serde_json::{json, Value};
22use tokio::time::{sleep, Duration, Instant};
23use tracing::{info, warn};
24
25#[derive(Debug)]
27pub struct EcrArtifactRegistry {
28 credentials: AwsCredentialProvider,
29 ecr_client: EcrClient,
30 binding_name: String,
31 repository_prefix: String,
32 pull_role_arn: Option<String>,
33 push_role_arn: Option<String>,
34}
35
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 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 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 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 {
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 "ecr:GetRepositoryPolicy",
190 "ecr:SetRepositoryPolicy"
191 ]
192 }));
193 }
194 }
195
196 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 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 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 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 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 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 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 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 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 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(®ion) {
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 self.set_full_policy(&full_repo_name, &merged_access)
633 .await?;
634
635 let source_region = self.credentials.region().to_string();
640 for region in &merged_access.regions {
641 if *region == source_region {
642 continue; }
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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}