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        RegistryAuthMethod, RepositoryResponse,
7    },
8};
9use alien_core::bindings::ArtifactRegistryBinding;
10use alien_error::{AlienError, Context};
11use alien_gcp_clients::iam::IamPolicy;
12use alien_gcp_clients::{
13    artifactregistry::{ArtifactRegistryApi, ArtifactRegistryClient},
14    GcpClientConfig, GcpClientConfigExt as _,
15};
16use async_trait::async_trait;
17use chrono;
18use tracing::{debug, info, warn};
19
20/// GCP Artifact Registry implementation of the ArtifactRegistry binding.
21#[derive(Debug)]
22pub struct GarArtifactRegistry {
23    client: ArtifactRegistryClient,
24    binding_name: String,
25    project_id: String,
26    location: String,
27    repository_name: String,
28    pull_service_account_email: Option<String>,
29    push_service_account_email: Option<String>,
30    gcp_config: GcpClientConfig,
31}
32
33impl GarArtifactRegistry {
34    /// Creates a new GCP Artifact Registry binding from binding parameters.
35    pub async fn new(
36        binding_name: String,
37        binding: ArtifactRegistryBinding,
38        gcp_config: &GcpClientConfig,
39    ) -> Result<Self> {
40        info!(
41            binding_name = %binding_name,
42            "Initializing GCP Artifact Registry"
43        );
44
45        let client = crate::http_client::create_http_client();
46        let artifact_registry_client = ArtifactRegistryClient::new(client, gcp_config.clone());
47
48        // Get project_id and location from GCP config instead of binding
49        let project_id = gcp_config.project_id.clone();
50        let location = gcp_config.region.clone();
51
52        // Extract service account emails from binding
53        let config = match binding {
54            ArtifactRegistryBinding::Gar(config) => config,
55            _ => {
56                return Err(AlienError::new(ErrorData::BindingConfigInvalid {
57                    binding_name: binding_name.clone(),
58                    reason: "Expected GAR binding, got different service type".to_string(),
59                }));
60            }
61        };
62
63        let repository_name = config
64            .repository_name
65            .into_value(&binding_name, "repository_name")
66            .context(ErrorData::BindingConfigInvalid {
67                binding_name: binding_name.clone(),
68                reason: "Failed to extract repository_name from binding".to_string(),
69            })?;
70
71        let pull_service_account_email = config
72            .pull_service_account_email
73            .map(|v| {
74                v.into_value(&binding_name, "pull_service_account_email")
75                    .context(ErrorData::BindingConfigInvalid {
76                        binding_name: binding_name.clone(),
77                        reason: "Failed to extract pull_service_account_email from binding"
78                            .to_string(),
79                    })
80            })
81            .transpose()?;
82
83        let push_service_account_email = config
84            .push_service_account_email
85            .map(|v| {
86                v.into_value(&binding_name, "push_service_account_email")
87                    .context(ErrorData::BindingConfigInvalid {
88                        binding_name: binding_name.clone(),
89                        reason: "Failed to extract push_service_account_email from binding"
90                            .to_string(),
91                    })
92            })
93            .transpose()?;
94
95        Ok(Self {
96            client: artifact_registry_client,
97            binding_name,
98            project_id,
99            location,
100            repository_name,
101            pull_service_account_email,
102            push_service_account_email,
103            gcp_config: gcp_config.clone(),
104        })
105    }
106
107    /// Extracts a name segment from a repo ID or routable name.
108    /// If `repo_id` is empty, returns the binding's configured `repository_name`
109    /// (the GAR repository resource, used for IAM operations).
110    fn extract_repo_name(&self, repo_id: &str) -> Result<String> {
111        if repo_id.is_empty() {
112            return Ok(self.repository_name.clone());
113        }
114        if let Some(name) = repo_id.split('/').last() {
115            Ok(name.to_string())
116        } else {
117            Err(AlienError::new(ErrorData::BindingConfigInvalid {
118                binding_name: self.binding_name.clone(),
119                reason: format!("Invalid repository ID format: {}", repo_id),
120            }))
121        }
122    }
123
124    /// Internal helper to add or remove members from IAM policy bindings
125    async fn update_policy_members(
126        &self,
127        repo_name: &str,
128        mut current_policy: IamPolicy,
129        members: Vec<String>,
130        add_members: bool, // true to add, false to remove
131    ) -> Result<()> {
132        let reader_role = "roles/artifactregistry.reader";
133
134        // Find or create the artifactregistry.reader binding
135        let mut binding_index = None;
136        for (i, binding) in current_policy.bindings.iter().enumerate() {
137            if binding.role == reader_role {
138                binding_index = Some(i);
139                break;
140            }
141        }
142
143        if add_members {
144            // Add members
145            if members.is_empty() {
146                info!(repo_name = %repo_name, "No new members to add");
147                return Ok(());
148            }
149
150            match binding_index {
151                Some(i) => {
152                    // Add to existing binding
153                    let binding = &mut current_policy.bindings[i];
154                    for member in members {
155                        if !binding.members.contains(&member) {
156                            binding.members.push(member);
157                        }
158                    }
159                }
160                None => {
161                    // Create new binding
162                    current_policy
163                        .bindings
164                        .push(alien_gcp_clients::iam::Binding {
165                            role: reader_role.to_string(),
166                            members,
167                            condition: None,
168                        });
169                }
170            }
171        } else {
172            // Remove members
173            if let Some(i) = binding_index {
174                let binding = &mut current_policy.bindings[i];
175                binding.members.retain(|member| !members.contains(member));
176
177                // Remove empty binding
178                if binding.members.is_empty() {
179                    current_policy.bindings.remove(i);
180                }
181            }
182            // If no binding exists, nothing to remove
183        }
184
185        // Set the updated policy with the original etag for optimistic concurrency control
186        self.client.set_repository_iam_policy(
187            self.project_id.clone(),
188            self.location.clone(),
189            repo_name.to_string(),
190            current_policy,
191        ).await
192            .map_err(|e| map_cloud_client_error(
193                e,
194                format!("Failed to update cross-account access for GCP Artifact Registry repository '{}'", repo_name),
195                Some(repo_name.to_string()),
196            ))?;
197
198        let action = if add_members { "added" } else { "removed" };
199        info!(
200            repo_name = %repo_name,
201            action = %action,
202            "GCP Artifact Registry repository cross-account access updated successfully"
203        );
204        Ok(())
205    }
206}
207
208impl Binding for GarArtifactRegistry {}
209
210#[async_trait]
211impl ArtifactRegistry for GarArtifactRegistry {
212    fn registry_endpoint(&self) -> String {
213        format!("https://{}-docker.pkg.dev", self.location)
214    }
215
216    fn upstream_repository_prefix(&self) -> String {
217        format!("{}/{}", self.project_id, self.repository_name)
218    }
219
220    async fn create_repository(&self, repo_name: &str) -> Result<RepositoryResponse> {
221        // In GAR, the binding's GAR repository (e.g., "alien-artifacts") IS the registry.
222        // Image paths within it (e.g., "prj_abc123") are created automatically on first push.
223        // No GAR API call needed — the GAR repository is created by alien-infra at provisioning time.
224        // Underscores are valid in image paths (OCI distribution spec allows [a-z0-9._-/]).
225        let routable_name = format!("{}/{}", self.upstream_repository_prefix(), repo_name);
226        Ok(RepositoryResponse {
227            name: routable_name,
228            uri: None,
229            created_at: None,
230        })
231    }
232
233    async fn get_repository(&self, repo_id: &str) -> Result<RepositoryResponse> {
234        // Image paths within a GAR repository are implicit — no GAR API call needed.
235        // Just return the routable name, same as create_repository.
236        let image_path = self.extract_repo_name(repo_id)?;
237        let routable_name = format!("{}/{}", self.upstream_repository_prefix(), image_path);
238        let repository_uri = format!(
239            "{}-docker.pkg.dev/{}/{}",
240            self.location, self.project_id, image_path
241        );
242
243        Ok(RepositoryResponse {
244            name: routable_name,
245            uri: Some(repository_uri),
246            created_at: None,
247        })
248    }
249
250    async fn add_cross_account_access(
251        &self,
252        repo_id: &str,
253        access: CrossAccountAccess,
254    ) -> Result<()> {
255        // Cross-account access in GAR is enforced via IAM bindings on the
256        // *parent* repository (the GAR resource provisioned by alien-infra),
257        // not on individual image paths. `repo_id` may name an image path
258        // within the parent (it's the routable name from `create_repository`),
259        // but IAM ops always target the parent registry.
260        let _ = repo_id; // The image path doesn't have its own IAM scope.
261        let repo_name = self.repository_name.clone();
262
263        let gcp_access = match access {
264            CrossAccountAccess::Gcp(gcp_access) => gcp_access,
265            _ => {
266                return Err(AlienError::new(ErrorData::BindingConfigInvalid {
267                    binding_name: self.binding_name.clone(),
268                    reason: "GCP artifact registry can only accept GCP cross-account access configuration".to_string(),
269                }));
270            }
271        };
272
273        info!(
274            repo_name = %repo_name,
275            project_numbers = ?gcp_access.project_numbers,
276            allowed_service_types = ?gcp_access.allowed_service_types,
277            service_account_emails = ?gcp_access.service_account_emails,
278            "Adding GCP Artifact Registry repository cross-account access"
279        );
280
281        // Get current policy with etag
282        let current_policy = self.client.get_repository_iam_policy(
283            self.project_id.clone(),
284            self.location.clone(),
285            repo_name.clone(),
286        ).await
287            .map_err(|e| {
288                warn!(
289                    repo_name = %repo_name,
290                    error = %e,
291                    "Failed to get current GCP Artifact Registry repository IAM policy, creating new policy"
292                );
293                e
294            })
295            .unwrap_or_else(|_| IamPolicy {
296                version: Some(1),
297                kind: None,
298                resource_id: None,
299                bindings: vec![],
300                etag: None,
301            });
302
303        // Build new members to add
304        let mut new_members = Vec::new();
305
306        // Add service accounts based on compute service types and project numbers
307        for service_type in &gcp_access.allowed_service_types {
308            match service_type {
309                ComputeServiceType::Worker => {
310                    // Add serverless robot service accounts for Worker service type
311                    for project_number in &gcp_access.project_numbers {
312                        let serverless_robot_email = format!(
313                            "service-{}@serverless-robot-prod.iam.gserviceaccount.com",
314                            project_number
315                        );
316                        new_members.push(format!("serviceAccount:{}", serverless_robot_email));
317                    }
318                } // Future service types would be handled here
319            }
320        }
321
322        // Add additional service account emails
323        for service_account_email in &gcp_access.service_account_emails {
324            new_members.push(format!("serviceAccount:{}", service_account_email));
325        }
326
327        self.update_policy_members(&repo_name, current_policy, new_members, true)
328            .await
329    }
330
331    async fn remove_cross_account_access(
332        &self,
333        repo_id: &str,
334        access: CrossAccountAccess,
335    ) -> Result<()> {
336        // IAM ops target the parent registry. See `add_cross_account_access`.
337        let _ = repo_id;
338        let repo_name = self.repository_name.clone();
339
340        let gcp_access = match access {
341            CrossAccountAccess::Gcp(gcp_access) => gcp_access,
342            _ => {
343                return Err(AlienError::new(ErrorData::BindingConfigInvalid {
344                    binding_name: self.binding_name.clone(),
345                    reason: "GCP artifact registry can only accept GCP cross-account access configuration".to_string(),
346                }));
347            }
348        };
349
350        info!(
351            repo_name = %repo_name,
352            project_numbers = ?gcp_access.project_numbers,
353            allowed_service_types = ?gcp_access.allowed_service_types,
354            service_account_emails = ?gcp_access.service_account_emails,
355            "Removing GCP Artifact Registry repository cross-account access"
356        );
357
358        // Get current policy with etag
359        let current_policy = match self
360            .client
361            .get_repository_iam_policy(
362                self.project_id.clone(),
363                self.location.clone(),
364                repo_name.clone(),
365            )
366            .await
367        {
368            Ok(policy) => policy,
369            Err(_) => {
370                // No existing policy, nothing to remove
371                info!(repo_name = %repo_name, "No existing GCP IAM policy to remove permissions from");
372                return Ok(());
373            }
374        };
375
376        // Build members to remove
377        let mut members_to_remove = Vec::new();
378
379        // Add service accounts based on compute service types and project numbers
380        for service_type in &gcp_access.allowed_service_types {
381            match service_type {
382                ComputeServiceType::Worker => {
383                    // Add serverless robot service accounts for Worker service type
384                    for project_number in &gcp_access.project_numbers {
385                        let serverless_robot_email = format!(
386                            "service-{}@serverless-robot-prod.iam.gserviceaccount.com",
387                            project_number
388                        );
389                        members_to_remove
390                            .push(format!("serviceAccount:{}", serverless_robot_email));
391                    }
392                } // Future service types would be handled here
393            }
394        }
395
396        // Add additional service account emails
397        for service_account_email in &gcp_access.service_account_emails {
398            members_to_remove.push(format!("serviceAccount:{}", service_account_email));
399        }
400
401        self.update_policy_members(&repo_name, current_policy, members_to_remove, false)
402            .await
403    }
404
405    async fn get_cross_account_access(&self, repo_id: &str) -> Result<CrossAccountPermissions> {
406        // IAM ops target the parent registry. See `add_cross_account_access`.
407        let _ = repo_id;
408        let repo_name = self.repository_name.clone();
409
410        info!(
411            repo_name = %repo_name,
412            "Getting GCP Artifact Registry repository cross-account access"
413        );
414
415        let policy = match self
416            .client
417            .get_repository_iam_policy(
418                self.project_id.clone(),
419                self.location.clone(),
420                repo_name.clone(),
421            )
422            .await
423        {
424            Ok(policy) => policy,
425            Err(e) => {
426                warn!(
427                    repo_name = %repo_name,
428                    error = %e,
429                    "Failed to get GCP Artifact Registry repository IAM policy"
430                );
431                // If no policy exists, return empty permissions
432                return Ok(CrossAccountPermissions {
433                    access: CrossAccountAccess::Gcp(GcpCrossAccountAccess {
434                        project_numbers: Vec::new(),
435                        allowed_service_types: Vec::new(),
436                        service_account_emails: Vec::new(),
437                    }),
438                    last_updated: None,
439                });
440            }
441        };
442
443        let mut project_numbers = Vec::new();
444        let mut service_account_emails = Vec::new();
445        let mut allowed_service_types = Vec::new();
446
447        for binding in policy.bindings {
448            // Look for reader roles or artifact registry roles
449            if binding.role.contains("reader") || binding.role.contains("artifactregistry") {
450                for member in binding.members {
451                    // Parse service account members only
452                    if let Some(service_account) = member.strip_prefix("serviceAccount:") {
453                        // Check if this is a serverless robot service account
454                        if service_account
455                            .contains("@serverless-robot-prod.iam.gserviceaccount.com")
456                        {
457                            // Extract project number from: service-{project_number}@serverless-robot-prod.iam.gserviceaccount.com
458                            if let Some(project_number) =
459                                service_account.strip_prefix("service-").and_then(|s| {
460                                    s.strip_suffix("@serverless-robot-prod.iam.gserviceaccount.com")
461                                })
462                            {
463                                project_numbers.push(project_number.to_string());
464                                // If we found a serverless robot, we can infer Worker resource type
465                                if !allowed_service_types.contains(&ComputeServiceType::Worker) {
466                                    allowed_service_types.push(ComputeServiceType::Worker);
467                                }
468                            }
469                        } else {
470                            // Regular service account
471                            service_account_emails.push(service_account.to_string());
472                        }
473                    }
474                }
475            }
476        }
477
478        // Remove duplicates and sort
479        project_numbers.sort();
480        project_numbers.dedup();
481        service_account_emails.sort();
482        service_account_emails.dedup();
483        allowed_service_types.sort_by_key(|rt| format!("{:?}", rt));
484        allowed_service_types.dedup();
485
486        info!(
487            repo_name = %repo_name,
488            project_numbers = ?project_numbers,
489            allowed_service_types = ?allowed_service_types,
490            service_account_emails = ?service_account_emails,
491            "Retrieved GCP Artifact Registry repository cross-account access"
492        );
493
494        Ok(CrossAccountPermissions {
495            access: CrossAccountAccess::Gcp(GcpCrossAccountAccess {
496                project_numbers,
497                allowed_service_types,
498                service_account_emails,
499            }),
500            last_updated: None, // GCP IAM doesn't provide policy modification time
501        })
502    }
503
504    async fn generate_credentials(
505        &self,
506        repo_id: &str,
507        permissions: ArtifactRegistryPermissions,
508        ttl_seconds: Option<u32>,
509    ) -> Result<ArtifactRegistryCredentials> {
510        info!(
511            repo_id = %repo_id,
512            permissions = ?permissions,
513            ttl_seconds = ?ttl_seconds,
514            "Generating GCP Artifact Registry credentials by impersonating service account"
515        );
516
517        // Parse repo_id to extract project and location info if it's in full format
518        // Just use the configured project/location from this client since they come from the binding
519        let _project_id = &self.project_id;
520        let _location = &self.location;
521
522        // Get the service account email from stored fields
523        let service_account_email = match permissions {
524            ArtifactRegistryPermissions::Pull => {
525                self.pull_service_account_email.clone()
526                    .ok_or_else(|| AlienError::new(ErrorData::BindingConfigInvalid {
527                        binding_name: self.binding_name.clone(),
528                        reason: "Pull service account email not available - ensure the artifact registry resource is properly linked".to_string(),
529                    }))?
530            }
531            ArtifactRegistryPermissions::PushPull => {
532                self.push_service_account_email.clone()
533                    .ok_or_else(|| AlienError::new(ErrorData::BindingConfigInvalid {
534                        binding_name: self.binding_name.clone(),
535                        reason: "Push service account email not available - ensure the artifact registry resource is properly linked".to_string(),
536                    }))?
537            }
538        };
539
540        info!(
541            service_account_email = %service_account_email,
542            "Using stored service account email for GCP Artifact Registry access"
543        );
544
545        // Use the stored GCP configuration for impersonation
546        let gcp_config = &self.gcp_config;
547
548        let scopes = vec![
549            "https://www.googleapis.com/auth/cloud-platform".to_string(),
550            "https://www.googleapis.com/auth/devstorage.read_write".to_string(),
551        ];
552
553        let lifetime = ttl_seconds.map(|ttl| format!("{}s", ttl.min(3600))); // Max 1 hour
554
555        let impersonation_config = alien_gcp_clients::GcpImpersonationConfig {
556            service_account_email: service_account_email.clone(),
557            scopes,
558            delegates: None,
559            lifetime,
560            target_project_id: None,
561            target_region: None,
562        };
563
564        // Impersonate the service account
565        let impersonated_config =
566            gcp_config
567                .impersonate(impersonation_config)
568                .await
569                .map_err(|e| {
570                    map_cloud_client_error(
571                        e,
572                        "Failed to impersonate GCP service account for artifact registry access"
573                            .to_string(),
574                        Some(repo_id.to_string()),
575                    )
576                })?;
577
578        // Get the access token from the impersonated config
579        let access_token = impersonated_config
580            .get_bearer_token("https://www.googleapis.com/")
581            .await
582            .map_err(|e| {
583                map_cloud_client_error(
584                    e,
585                    "Failed to get OAuth token from impersonated service account".to_string(),
586                    Some(repo_id.to_string()),
587                )
588            })?;
589
590        // Calculate expiration time
591        let expires_at = if let Some(ttl) = ttl_seconds {
592            Some(
593                (chrono::Utc::now() + chrono::Duration::seconds(ttl.min(3600) as i64)).to_rfc3339(),
594            )
595        } else {
596            Some((chrono::Utc::now() + chrono::Duration::seconds(3600)).to_rfc3339())
597            // Default 1 hour
598        };
599
600        info!(
601            permissions = ?permissions,
602            service_account = %service_account_email,
603            "GCP Artifact Registry OAuth token generated successfully with impersonated service account"
604        );
605
606        // For GCP Artifact Registry, the username is 'oauth2accesstoken' and password is the OAuth token
607        Ok(ArtifactRegistryCredentials {
608            auth_method: RegistryAuthMethod::Basic,
609            username: "oauth2accesstoken".to_string(),
610            password: access_token,
611            expires_at,
612        })
613    }
614
615    async fn delete_repository(&self, repo_id: &str) -> Result<()> {
616        // No-op, mirroring `create_repository`/`get_repository`.
617        //
618        // In GAR, image paths within the parent repository are implicit —
619        // they materialise on first push, and there's no "delete a path"
620        // operation in the GAR API. The parent repository itself is owned
621        // by `alien-infra` (provisioned at deployment time), not by the
622        // binding; deleting it would tear down the whole registry every
623        // user shares.
624        //
625        // Garbage collection of unused image data is a separate concern,
626        // achievable via `deletePackage` if/when needed. Not done here
627        // because the test pattern `create → ... → delete` should be
628        // idempotent and side-effect-free at the registry-resource level.
629        debug!(
630            repo_id = %repo_id,
631            "GCP Artifact Registry delete_repository: no-op (image paths are implicit)"
632        );
633        Ok(())
634    }
635}