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#[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 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 let project_id = gcp_config.project_id.clone();
50 let location = gcp_config.region.clone();
51
52 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 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 async fn update_policy_members(
131 &self,
132 repo_name: &str,
133 mut current_policy: IamPolicy,
134 members: Vec<String>,
135 add_members: bool, ) -> Result<()> {
137 let reader_role = "roles/artifactregistry.reader";
138
139 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 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 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 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 if let Some(i) = binding_index {
179 let binding = &mut current_policy.bindings[i];
180 binding.members.retain(|member| !members.contains(member));
181
182 if binding.members.is_empty() {
184 current_policy.bindings.remove(i);
185 }
186 }
187 }
189
190 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 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 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 let _ = repo_id; 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 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 let mut new_members = Vec::new();
311
312 for service_type in &gcp_access.allowed_service_types {
314 match service_type {
315 ComputeServiceType::Worker => {
316 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 } }
326 }
327
328 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 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 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 info!(repo_name = %repo_name, "No existing GCP IAM policy to remove permissions from");
379 return Ok(());
380 }
381 };
382
383 let mut members_to_remove = Vec::new();
385
386 for service_type in &gcp_access.allowed_service_types {
388 match service_type {
389 ComputeServiceType::Worker => {
390 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 } }
401 }
402
403 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 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 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 if binding.role.contains("reader") || binding.role.contains("artifactregistry") {
457 for member in binding.members {
458 if let Some(service_account) = member.strip_prefix("serviceAccount:") {
460 if service_account
462 .contains("@serverless-robot-prod.iam.gserviceaccount.com")
463 {
464 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 !allowed_service_types.contains(&ComputeServiceType::Worker) {
473 allowed_service_types.push(ComputeServiceType::Worker);
474 }
475 }
476 } else {
477 service_account_emails.push(service_account.to_string());
479 }
480 }
481 }
482 }
483 }
484
485 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, })
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 let _project_id = &self.project_id;
527 let _location = &self.location;
528
529 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 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))); 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 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 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 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 };
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 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 debug!(
639 repo_id = %repo_id,
640 "GCP Artifact Registry delete_repository: no-op (image paths are implicit)"
641 );
642 Ok(())
643 }
644}