1use crate::{
2 error::{map_cloud_client_error, ErrorData, Result},
3 traits::{
4 ArtifactRegistry, ArtifactRegistryCredentials, ArtifactRegistryPermissions, Binding,
5 ComputeServiceType, CrossAccountAccess, CrossAccountPermissions, GcpCrossAccountAccess,
6 RepositoryResponse,
7 },
8};
9use alien_core::bindings::{ArtifactRegistryBinding, GarArtifactRegistryBinding};
10use alien_error::{AlienError, Context, ContextError, IntoAlienError};
11use alien_gcp_clients::iam::IamPolicy;
12use alien_gcp_clients::{
13 artifactregistry::{ArtifactRegistryApi, ArtifactRegistryClient, Repository, RepositoryFormat},
14 iam::{GenerateAccessTokenRequest, IamApi, IamClient},
15 GcpClientConfig, GcpClientConfigExt as _,
16};
17use async_trait::async_trait;
18use chrono;
19use serde_json::{json, Value};
20use std::collections::HashMap;
21use tracing::{info, warn};
22
23#[derive(Debug)]
25pub struct GarArtifactRegistry {
26 client: ArtifactRegistryClient,
27 binding_name: String,
28 project_id: String,
29 location: String,
30 repository_name: String,
31 pull_service_account_email: Option<String>,
32 push_service_account_email: Option<String>,
33 gcp_config: GcpClientConfig,
34}
35
36impl GarArtifactRegistry {
37 pub async fn new(
39 binding_name: String,
40 binding: ArtifactRegistryBinding,
41 gcp_config: &GcpClientConfig,
42 ) -> Result<Self> {
43 info!(
44 binding_name = %binding_name,
45 "Initializing GCP Artifact Registry"
46 );
47
48 let client = crate::http_client::create_http_client();
49 let artifact_registry_client = ArtifactRegistryClient::new(client, gcp_config.clone());
50
51 let project_id = gcp_config.project_id.clone();
53 let location = gcp_config.region.clone();
54
55 let config = match binding {
57 ArtifactRegistryBinding::Gar(config) => config,
58 _ => {
59 return Err(AlienError::new(ErrorData::BindingConfigInvalid {
60 binding_name: binding_name.clone(),
61 reason: "Expected GAR binding, got different service type".to_string(),
62 }));
63 }
64 };
65
66 let repository_name = config
67 .repository_name
68 .into_value(&binding_name, "repository_name")
69 .context(ErrorData::BindingConfigInvalid {
70 binding_name: binding_name.clone(),
71 reason: "Failed to extract repository_name from binding".to_string(),
72 })?;
73
74 let pull_service_account_email = config
75 .pull_service_account_email
76 .map(|v| {
77 v.into_value(&binding_name, "pull_service_account_email")
78 .context(ErrorData::BindingConfigInvalid {
79 binding_name: binding_name.clone(),
80 reason: "Failed to extract pull_service_account_email from binding"
81 .to_string(),
82 })
83 })
84 .transpose()?;
85
86 let push_service_account_email = config
87 .push_service_account_email
88 .map(|v| {
89 v.into_value(&binding_name, "push_service_account_email")
90 .context(ErrorData::BindingConfigInvalid {
91 binding_name: binding_name.clone(),
92 reason: "Failed to extract push_service_account_email from binding"
93 .to_string(),
94 })
95 })
96 .transpose()?;
97
98 Ok(Self {
99 client: artifact_registry_client,
100 binding_name,
101 project_id,
102 location,
103 repository_name,
104 pull_service_account_email,
105 push_service_account_email,
106 gcp_config: gcp_config.clone(),
107 })
108 }
109
110 fn make_repo_id(&self, repo_name: &str) -> String {
112 format!(
113 "projects/{}/locations/{}/repositories/{}",
114 self.project_id, self.location, repo_name
115 )
116 }
117
118 fn extract_repo_name(&self, repo_id: &str) -> Result<String> {
121 if repo_id.is_empty() {
122 return Ok(self.repository_name.clone());
123 }
124 if let Some(repo_name) = repo_id.split('/').last() {
125 Ok(repo_name.to_string())
126 } else {
127 Err(AlienError::new(ErrorData::BindingConfigInvalid {
128 binding_name: self.binding_name.clone(),
129 reason: format!("Invalid repository ID format: {}", repo_id),
130 }))
131 }
132 }
133
134 async fn update_policy_members(
136 &self,
137 repo_name: &str,
138 mut current_policy: IamPolicy,
139 members: Vec<String>,
140 add_members: bool, ) -> Result<()> {
142 let reader_role = "roles/artifactregistry.reader";
143
144 let mut binding_index = None;
146 for (i, binding) in current_policy.bindings.iter().enumerate() {
147 if binding.role == reader_role {
148 binding_index = Some(i);
149 break;
150 }
151 }
152
153 if add_members {
154 if members.is_empty() {
156 info!(repo_name = %repo_name, "No new members to add");
157 return Ok(());
158 }
159
160 match binding_index {
161 Some(i) => {
162 let binding = &mut current_policy.bindings[i];
164 for member in members {
165 if !binding.members.contains(&member) {
166 binding.members.push(member);
167 }
168 }
169 }
170 None => {
171 current_policy
173 .bindings
174 .push(alien_gcp_clients::iam::Binding {
175 role: reader_role.to_string(),
176 members,
177 condition: None,
178 });
179 }
180 }
181 } else {
182 if let Some(i) = binding_index {
184 let binding = &mut current_policy.bindings[i];
185 binding.members.retain(|member| !members.contains(member));
186
187 if binding.members.is_empty() {
189 current_policy.bindings.remove(i);
190 }
191 }
192 }
194
195 self.client.set_repository_iam_policy(
197 self.project_id.clone(),
198 self.location.clone(),
199 repo_name.to_string(),
200 current_policy,
201 ).await
202 .map_err(|e| map_cloud_client_error(
203 e,
204 format!("Failed to update cross-account access for GCP Artifact Registry repository '{}'", repo_name),
205 Some(repo_name.to_string()),
206 ))?;
207
208 let action = if add_members { "added" } else { "removed" };
209 info!(
210 repo_name = %repo_name,
211 action = %action,
212 "GCP Artifact Registry repository cross-account access updated successfully"
213 );
214 Ok(())
215 }
216}
217
218impl Binding for GarArtifactRegistry {}
219
220#[async_trait]
221impl ArtifactRegistry for GarArtifactRegistry {
222 fn registry_endpoint(&self) -> String {
223 format!("https://{}-docker.pkg.dev", self.location)
224 }
225
226 fn upstream_repository_prefix(&self) -> String {
227 format!("{}/{}", self.project_id, self.repository_name)
228 }
229
230 async fn create_repository(&self, repo_name: &str) -> Result<RepositoryResponse> {
231 info!(
232 repo_name = %repo_name,
233 project_id = %self.project_id,
234 location = %self.location,
235 "Creating GCP Artifact Registry repository"
236 );
237
238 let repository = Repository::builder()
239 .format(RepositoryFormat::Docker)
240 .build();
241
242 let _operation = self
243 .client
244 .create_repository(
245 self.project_id.clone(),
246 self.location.clone(),
247 repo_name.to_string(),
248 repository,
249 )
250 .await
251 .map_err(|e| {
252 map_cloud_client_error(
253 e,
254 format!(
255 "Failed to create GCP Artifact Registry repository '{}'",
256 repo_name
257 ),
258 Some(repo_name.to_string()),
259 )
260 })?;
261
262 info!(
263 repo_name = %repo_name,
264 "GCP Artifact Registry repository creation started"
265 );
266
267 Ok(RepositoryResponse {
269 name: repo_name.to_string(),
270 uri: None, created_at: None, })
273 }
274
275 async fn get_repository(&self, repo_id: &str) -> Result<RepositoryResponse> {
276 let repo_name = self.extract_repo_name(repo_id)?;
277
278 info!(
279 repo_name = %repo_name,
280 project_id = %self.project_id,
281 location = %self.location,
282 "Getting GCP Artifact Registry repository details"
283 );
284
285 let repository = self
286 .client
287 .get_repository(
288 self.project_id.clone(),
289 self.location.clone(),
290 repo_name.clone(),
291 )
292 .await
293 .map_err(|_e| {
294 warn!(
295 repo_name = %repo_name,
296 "GCP Artifact Registry repository not found"
297 );
298
299 AlienError::new(ErrorData::ResourceNotFound {
300 resource_id: repo_name.clone(),
301 })
302 })?;
303
304 let repository_uri = format!(
306 "{}-docker.pkg.dev/{}/{}",
307 self.location, self.project_id, repo_name
308 );
309
310 info!(
311 repo_name = %repo_name,
312 repo_uri = %repository_uri,
313 "GCP Artifact Registry repository details retrieved"
314 );
315
316 Ok(RepositoryResponse {
317 name: repo_name,
318 uri: Some(repository_uri),
319 created_at: repository.create_time,
320 })
321 }
322
323 async fn add_cross_account_access(
324 &self,
325 repo_id: &str,
326 access: CrossAccountAccess,
327 ) -> Result<()> {
328 let repo_name = self.extract_repo_name(repo_id)?;
329
330 let gcp_access = match access {
331 CrossAccountAccess::Gcp(gcp_access) => gcp_access,
332 _ => {
333 return Err(AlienError::new(ErrorData::BindingConfigInvalid {
334 binding_name: self.binding_name.clone(),
335 reason: "GCP artifact registry can only accept GCP cross-account access configuration".to_string(),
336 }));
337 }
338 };
339
340 info!(
341 repo_name = %repo_name,
342 project_numbers = ?gcp_access.project_numbers,
343 allowed_service_types = ?gcp_access.allowed_service_types,
344 service_account_emails = ?gcp_access.service_account_emails,
345 "Adding GCP Artifact Registry repository cross-account access"
346 );
347
348 let current_policy = self.client.get_repository_iam_policy(
350 self.project_id.clone(),
351 self.location.clone(),
352 repo_name.clone(),
353 ).await
354 .map_err(|e| {
355 warn!(
356 repo_name = %repo_name,
357 error = %e,
358 "Failed to get current GCP Artifact Registry repository IAM policy, creating new policy"
359 );
360 e
361 })
362 .unwrap_or_else(|_| IamPolicy {
363 version: Some(1),
364 kind: None,
365 resource_id: None,
366 bindings: vec![],
367 etag: None,
368 });
369
370 let mut new_members = Vec::new();
372
373 for service_type in &gcp_access.allowed_service_types {
375 match service_type {
376 ComputeServiceType::Function => {
377 for project_number in &gcp_access.project_numbers {
379 let serverless_robot_email = format!(
380 "service-{}@serverless-robot-prod.iam.gserviceaccount.com",
381 project_number
382 );
383 new_members.push(format!("serviceAccount:{}", serverless_robot_email));
384 }
385 } }
387 }
388
389 for service_account_email in &gcp_access.service_account_emails {
391 new_members.push(format!("serviceAccount:{}", service_account_email));
392 }
393
394 self.update_policy_members(&repo_name, current_policy, new_members, true)
395 .await
396 }
397
398 async fn remove_cross_account_access(
399 &self,
400 repo_id: &str,
401 access: CrossAccountAccess,
402 ) -> Result<()> {
403 let repo_name = self.extract_repo_name(repo_id)?;
404
405 let gcp_access = match access {
406 CrossAccountAccess::Gcp(gcp_access) => gcp_access,
407 _ => {
408 return Err(AlienError::new(ErrorData::BindingConfigInvalid {
409 binding_name: self.binding_name.clone(),
410 reason: "GCP artifact registry can only accept GCP cross-account access configuration".to_string(),
411 }));
412 }
413 };
414
415 info!(
416 repo_name = %repo_name,
417 project_numbers = ?gcp_access.project_numbers,
418 allowed_service_types = ?gcp_access.allowed_service_types,
419 service_account_emails = ?gcp_access.service_account_emails,
420 "Removing GCP Artifact Registry repository cross-account access"
421 );
422
423 let current_policy = match self
425 .client
426 .get_repository_iam_policy(
427 self.project_id.clone(),
428 self.location.clone(),
429 repo_name.clone(),
430 )
431 .await
432 {
433 Ok(policy) => policy,
434 Err(_) => {
435 info!(repo_name = %repo_name, "No existing GCP IAM policy to remove permissions from");
437 return Ok(());
438 }
439 };
440
441 let mut members_to_remove = Vec::new();
443
444 for service_type in &gcp_access.allowed_service_types {
446 match service_type {
447 ComputeServiceType::Function => {
448 for project_number in &gcp_access.project_numbers {
450 let serverless_robot_email = format!(
451 "service-{}@serverless-robot-prod.iam.gserviceaccount.com",
452 project_number
453 );
454 members_to_remove
455 .push(format!("serviceAccount:{}", serverless_robot_email));
456 }
457 } }
459 }
460
461 for service_account_email in &gcp_access.service_account_emails {
463 members_to_remove.push(format!("serviceAccount:{}", service_account_email));
464 }
465
466 self.update_policy_members(&repo_name, current_policy, members_to_remove, false)
467 .await
468 }
469
470 async fn get_cross_account_access(&self, repo_id: &str) -> Result<CrossAccountPermissions> {
471 let repo_name = self.extract_repo_name(repo_id)?;
472
473 info!(
474 repo_name = %repo_name,
475 "Getting GCP Artifact Registry repository cross-account access"
476 );
477
478 let policy = match self
479 .client
480 .get_repository_iam_policy(
481 self.project_id.clone(),
482 self.location.clone(),
483 repo_name.clone(),
484 )
485 .await
486 {
487 Ok(policy) => policy,
488 Err(e) => {
489 warn!(
490 repo_name = %repo_name,
491 error = %e,
492 "Failed to get GCP Artifact Registry repository IAM policy"
493 );
494 return Ok(CrossAccountPermissions {
496 access: CrossAccountAccess::Gcp(GcpCrossAccountAccess {
497 project_numbers: Vec::new(),
498 allowed_service_types: Vec::new(),
499 service_account_emails: Vec::new(),
500 }),
501 last_updated: None,
502 });
503 }
504 };
505
506 let mut project_numbers = Vec::new();
507 let mut service_account_emails = Vec::new();
508 let mut allowed_service_types = Vec::new();
509
510 for binding in policy.bindings {
511 if binding.role.contains("reader") || binding.role.contains("artifactregistry") {
513 for member in binding.members {
514 if let Some(service_account) = member.strip_prefix("serviceAccount:") {
516 if service_account
518 .contains("@serverless-robot-prod.iam.gserviceaccount.com")
519 {
520 if let Some(project_number) =
522 service_account.strip_prefix("service-").and_then(|s| {
523 s.strip_suffix("@serverless-robot-prod.iam.gserviceaccount.com")
524 })
525 {
526 project_numbers.push(project_number.to_string());
527 if !allowed_service_types.contains(&ComputeServiceType::Function) {
529 allowed_service_types.push(ComputeServiceType::Function);
530 }
531 }
532 } else {
533 service_account_emails.push(service_account.to_string());
535 }
536 }
537 }
538 }
539 }
540
541 project_numbers.sort();
543 project_numbers.dedup();
544 service_account_emails.sort();
545 service_account_emails.dedup();
546 allowed_service_types.sort_by_key(|rt| format!("{:?}", rt));
547 allowed_service_types.dedup();
548
549 info!(
550 repo_name = %repo_name,
551 project_numbers = ?project_numbers,
552 allowed_service_types = ?allowed_service_types,
553 service_account_emails = ?service_account_emails,
554 "Retrieved GCP Artifact Registry repository cross-account access"
555 );
556
557 Ok(CrossAccountPermissions {
558 access: CrossAccountAccess::Gcp(GcpCrossAccountAccess {
559 project_numbers,
560 allowed_service_types,
561 service_account_emails,
562 }),
563 last_updated: None, })
565 }
566
567 async fn generate_credentials(
568 &self,
569 repo_id: &str,
570 permissions: ArtifactRegistryPermissions,
571 ttl_seconds: Option<u32>,
572 ) -> Result<ArtifactRegistryCredentials> {
573 info!(
574 repo_id = %repo_id,
575 permissions = ?permissions,
576 ttl_seconds = ?ttl_seconds,
577 "Generating GCP Artifact Registry credentials by impersonating service account"
578 );
579
580 let project_id = &self.project_id;
583 let location = &self.location;
584
585 let service_account_email = match permissions {
587 ArtifactRegistryPermissions::Pull => {
588 self.pull_service_account_email.clone()
589 .ok_or_else(|| AlienError::new(ErrorData::BindingConfigInvalid {
590 binding_name: self.binding_name.clone(),
591 reason: "Pull service account email not available - ensure the artifact registry resource is properly linked".to_string(),
592 }))?
593 }
594 ArtifactRegistryPermissions::PushPull => {
595 self.push_service_account_email.clone()
596 .ok_or_else(|| AlienError::new(ErrorData::BindingConfigInvalid {
597 binding_name: self.binding_name.clone(),
598 reason: "Push service account email not available - ensure the artifact registry resource is properly linked".to_string(),
599 }))?
600 }
601 };
602
603 info!(
604 service_account_email = %service_account_email,
605 "Using stored service account email for GCP Artifact Registry access"
606 );
607
608 let gcp_config = &self.gcp_config;
610
611 let scopes = vec![
612 "https://www.googleapis.com/auth/cloud-platform".to_string(),
613 "https://www.googleapis.com/auth/devstorage.read_write".to_string(),
614 ];
615
616 let lifetime = ttl_seconds.map(|ttl| format!("{}s", ttl.min(3600))); let impersonation_config = alien_gcp_clients::GcpImpersonationConfig {
619 service_account_email: service_account_email.clone(),
620 scopes,
621 delegates: None,
622 lifetime,
623 target_project_id: None,
624 target_region: None,
625 };
626
627 let impersonated_config =
629 gcp_config
630 .impersonate(impersonation_config)
631 .await
632 .map_err(|e| {
633 map_cloud_client_error(
634 e,
635 "Failed to impersonate GCP service account for artifact registry access"
636 .to_string(),
637 Some(repo_id.to_string()),
638 )
639 })?;
640
641 let access_token = impersonated_config
643 .get_bearer_token("https://www.googleapis.com/")
644 .await
645 .map_err(|e| {
646 map_cloud_client_error(
647 e,
648 "Failed to get OAuth token from impersonated service account".to_string(),
649 Some(repo_id.to_string()),
650 )
651 })?;
652
653 let expires_at = if let Some(ttl) = ttl_seconds {
655 Some(
656 (chrono::Utc::now() + chrono::Duration::seconds(ttl.min(3600) as i64)).to_rfc3339(),
657 )
658 } else {
659 Some((chrono::Utc::now() + chrono::Duration::seconds(3600)).to_rfc3339())
660 };
662
663 info!(
664 permissions = ?permissions,
665 service_account = %service_account_email,
666 "GCP Artifact Registry OAuth token generated successfully with impersonated service account"
667 );
668
669 Ok(ArtifactRegistryCredentials {
671 username: "oauth2accesstoken".to_string(),
672 password: access_token,
673 expires_at,
674 })
675 }
676
677 async fn delete_repository(&self, repo_id: &str) -> Result<()> {
678 let repo_name = self.extract_repo_name(repo_id)?;
679
680 info!(
681 repo_name = %repo_name,
682 project_id = %self.project_id,
683 location = %self.location,
684 "Deleting GCP Artifact Registry repository"
685 );
686
687 let _operation = self
688 .client
689 .delete_repository(
690 self.project_id.clone(),
691 self.location.clone(),
692 repo_name.clone(),
693 )
694 .await
695 .map_err(|e| {
696 map_cloud_client_error(
697 e,
698 format!(
699 "Failed to delete GCP Artifact Registry repository '{}'",
700 repo_name
701 ),
702 Some(repo_name.clone()),
703 )
704 })?;
705
706 info!(
707 repo_name = %repo_name,
708 "GCP Artifact Registry repository deletion started"
709 );
710 Ok(())
711 }
712}