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#[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 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 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 async fn update_policy_members(
126 &self,
127 repo_name: &str,
128 mut current_policy: IamPolicy,
129 members: Vec<String>,
130 add_members: bool, ) -> Result<()> {
132 let reader_role = "roles/artifactregistry.reader";
133
134 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 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 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 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 if let Some(i) = binding_index {
174 let binding = &mut current_policy.bindings[i];
175 binding.members.retain(|member| !members.contains(member));
176
177 if binding.members.is_empty() {
179 current_policy.bindings.remove(i);
180 }
181 }
182 }
184
185 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 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 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 let _ = repo_id; 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 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 let mut new_members = Vec::new();
305
306 for service_type in &gcp_access.allowed_service_types {
308 match service_type {
309 ComputeServiceType::Worker => {
310 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 } }
320 }
321
322 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 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 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 info!(repo_name = %repo_name, "No existing GCP IAM policy to remove permissions from");
372 return Ok(());
373 }
374 };
375
376 let mut members_to_remove = Vec::new();
378
379 for service_type in &gcp_access.allowed_service_types {
381 match service_type {
382 ComputeServiceType::Worker => {
383 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 } }
394 }
395
396 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 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 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 if binding.role.contains("reader") || binding.role.contains("artifactregistry") {
450 for member in binding.members {
451 if let Some(service_account) = member.strip_prefix("serviceAccount:") {
453 if service_account
455 .contains("@serverless-robot-prod.iam.gserviceaccount.com")
456 {
457 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 !allowed_service_types.contains(&ComputeServiceType::Worker) {
466 allowed_service_types.push(ComputeServiceType::Worker);
467 }
468 }
469 } else {
470 service_account_emails.push(service_account.to_string());
472 }
473 }
474 }
475 }
476 }
477
478 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, })
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 let _project_id = &self.project_id;
520 let _location = &self.location;
521
522 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 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))); 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 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 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 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 };
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 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 debug!(
630 repo_id = %repo_id,
631 "GCP Artifact Registry delete_repository: no-op (image paths are implicit)"
632 );
633 Ok(())
634 }
635}