Skip to main content

alien_bindings/providers/artifact_registry/
gar.rs

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/// GCP Artifact Registry implementation of the ArtifactRegistry binding.
24#[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    /// Creates a new GCP Artifact Registry binding from binding parameters.
38    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        // Get project_id and location from GCP config instead of binding
52        let project_id = gcp_config.project_id.clone();
53        let location = gcp_config.region.clone();
54
55        // Extract service account emails from binding
56        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    /// Converts a repository name to a full repository ID for GCP operations
111    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    /// Extracts repository name from a repository ID.
119    /// If `repo_id` is empty, returns the binding's configured `repository_name`.
120    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    /// Internal helper to add or remove members from IAM policy bindings
135    async fn update_policy_members(
136        &self,
137        repo_name: &str,
138        mut current_policy: IamPolicy,
139        members: Vec<String>,
140        add_members: bool, // true to add, false to remove
141    ) -> Result<()> {
142        let reader_role = "roles/artifactregistry.reader";
143
144        // Find or create the artifactregistry.reader binding
145        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            // Add members
155            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                    // Add to existing binding
163                    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                    // Create new binding
172                    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            // Remove members
183            if let Some(i) = binding_index {
184                let binding = &mut current_policy.bindings[i];
185                binding.members.retain(|member| !members.contains(member));
186
187                // Remove empty binding
188                if binding.members.is_empty() {
189                    current_policy.bindings.remove(i);
190                }
191            }
192            // If no binding exists, nothing to remove
193        }
194
195        // Set the updated policy with the original etag for optimistic concurrency control
196        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        // GCP repository creation is async, so URI is None until ready
268        Ok(RepositoryResponse {
269            name: repo_name.to_string(),
270            uri: None,        // Will be available once repository is ready
271            created_at: None, // Creation time not available until completion
272        })
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        // Construct the repository URI for GCP Artifact Registry
305        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        // Get current policy with etag
349        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        // Build new members to add
371        let mut new_members = Vec::new();
372
373        // Add service accounts based on compute service types and project numbers
374        for service_type in &gcp_access.allowed_service_types {
375            match service_type {
376                ComputeServiceType::Function => {
377                    // Add serverless robot service accounts for Function service type
378                    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                } // Future service types would be handled here
386            }
387        }
388
389        // Add additional service account emails
390        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        // Get current policy with etag
424        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                // No existing policy, nothing to remove
436                info!(repo_name = %repo_name, "No existing GCP IAM policy to remove permissions from");
437                return Ok(());
438            }
439        };
440
441        // Build members to remove
442        let mut members_to_remove = Vec::new();
443
444        // Add service accounts based on compute service types and project numbers
445        for service_type in &gcp_access.allowed_service_types {
446            match service_type {
447                ComputeServiceType::Function => {
448                    // Add serverless robot service accounts for Function service type
449                    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                } // Future service types would be handled here
458            }
459        }
460
461        // Add additional service account emails
462        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                // If no policy exists, return empty permissions
495                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            // Look for reader roles or artifact registry roles
512            if binding.role.contains("reader") || binding.role.contains("artifactregistry") {
513                for member in binding.members {
514                    // Parse service account members only
515                    if let Some(service_account) = member.strip_prefix("serviceAccount:") {
516                        // Check if this is a serverless robot service account
517                        if service_account
518                            .contains("@serverless-robot-prod.iam.gserviceaccount.com")
519                        {
520                            // Extract project number from: service-{project_number}@serverless-robot-prod.iam.gserviceaccount.com
521                            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 we found a serverless robot, we can infer Function resource type
528                                if !allowed_service_types.contains(&ComputeServiceType::Function) {
529                                    allowed_service_types.push(ComputeServiceType::Function);
530                                }
531                            }
532                        } else {
533                            // Regular service account
534                            service_account_emails.push(service_account.to_string());
535                        }
536                    }
537                }
538            }
539        }
540
541        // Remove duplicates and sort
542        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, // GCP IAM doesn't provide policy modification time
564        })
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        // Parse repo_id to extract project and location info if it's in full format
581        // Just use the configured project/location from this client since they come from the binding
582        let project_id = &self.project_id;
583        let location = &self.location;
584
585        // Get the service account email from stored fields
586        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        // Use the stored GCP configuration for impersonation
609        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))); // Max 1 hour
617
618        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        // Impersonate the service account
628        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        // Get the access token from the impersonated config
642        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        // Calculate expiration time
654        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            // Default 1 hour
661        };
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        // For GCP Artifact Registry, the username is 'oauth2accesstoken' and password is the OAuth token
670        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}