Skip to main content

alien_bindings/providers/artifact_registry/
gar.rs

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