1use std::collections::HashSet;
2use std::sync::atomic::{AtomicBool, Ordering};
3
4use base64::Engine;
5use base64::engine::general_purpose::URL_SAFE_NO_PAD;
6use serde::Deserialize;
7
8use super::{Provider, ProviderError, ProviderHost, map_ureq_error};
9
10pub struct Gcp {
11 pub zones: Vec<String>,
12 pub project: String,
13}
14
15pub const GCP_ZONES: &[(&str, &str)] = &[
20 ("us-central1-a", "Iowa A"),
22 ("us-central1-b", "Iowa B"),
23 ("us-central1-c", "Iowa C"),
24 ("us-central1-f", "Iowa F"),
25 ("us-east1-b", "South Carolina B"),
27 ("us-east1-c", "South Carolina C"),
28 ("us-east1-d", "South Carolina D"),
29 ("us-east4-a", "Virginia A"),
30 ("us-east4-b", "Virginia B"),
31 ("us-east4-c", "Virginia C"),
32 ("us-east5-a", "Columbus A"),
33 ("us-east5-b", "Columbus B"),
34 ("us-east5-c", "Columbus C"),
35 ("us-south1-a", "Dallas A"),
37 ("us-south1-b", "Dallas B"),
38 ("us-south1-c", "Dallas C"),
39 ("us-west1-a", "Oregon A"),
41 ("us-west1-b", "Oregon B"),
42 ("us-west1-c", "Oregon C"),
43 ("us-west2-a", "Los Angeles A"),
44 ("us-west2-b", "Los Angeles B"),
45 ("us-west2-c", "Los Angeles C"),
46 ("us-west3-a", "Salt Lake City A"),
47 ("us-west3-b", "Salt Lake City B"),
48 ("us-west3-c", "Salt Lake City C"),
49 ("us-west4-a", "Las Vegas A"),
50 ("us-west4-b", "Las Vegas B"),
51 ("us-west4-c", "Las Vegas C"),
52 ("northamerica-northeast1-a", "Montreal A"),
54 ("northamerica-northeast1-b", "Montreal B"),
55 ("northamerica-northeast1-c", "Montreal C"),
56 ("northamerica-northeast2-a", "Toronto A"),
57 ("northamerica-northeast2-b", "Toronto B"),
58 ("northamerica-northeast2-c", "Toronto C"),
59 ("northamerica-south1-a", "Queretaro A"),
60 ("northamerica-south1-b", "Queretaro B"),
61 ("northamerica-south1-c", "Queretaro C"),
62 ("southamerica-east1-a", "Sao Paulo A"),
64 ("southamerica-east1-b", "Sao Paulo B"),
65 ("southamerica-east1-c", "Sao Paulo C"),
66 ("southamerica-west1-a", "Santiago A"),
67 ("southamerica-west1-b", "Santiago B"),
68 ("southamerica-west1-c", "Santiago C"),
69 ("europe-west1-b", "Belgium B"),
71 ("europe-west1-c", "Belgium C"),
72 ("europe-west1-d", "Belgium D"),
73 ("europe-west2-a", "London A"),
74 ("europe-west2-b", "London B"),
75 ("europe-west2-c", "London C"),
76 ("europe-west3-a", "Frankfurt A"),
77 ("europe-west3-b", "Frankfurt B"),
78 ("europe-west3-c", "Frankfurt C"),
79 ("europe-west4-a", "Netherlands A"),
80 ("europe-west4-b", "Netherlands B"),
81 ("europe-west4-c", "Netherlands C"),
82 ("europe-west6-a", "Zurich A"),
83 ("europe-west6-b", "Zurich B"),
84 ("europe-west6-c", "Zurich C"),
85 ("europe-west8-a", "Milan A"),
86 ("europe-west8-b", "Milan B"),
87 ("europe-west8-c", "Milan C"),
88 ("europe-west9-a", "Paris A"),
89 ("europe-west9-b", "Paris B"),
90 ("europe-west9-c", "Paris C"),
91 ("europe-west10-a", "Berlin A"),
92 ("europe-west10-b", "Berlin B"),
93 ("europe-west10-c", "Berlin C"),
94 ("europe-west12-a", "Turin A"),
95 ("europe-west12-b", "Turin B"),
96 ("europe-west12-c", "Turin C"),
97 ("europe-north1-a", "Finland A"),
99 ("europe-north1-b", "Finland B"),
100 ("europe-north1-c", "Finland C"),
101 ("europe-north2-a", "Stockholm A"),
102 ("europe-north2-b", "Stockholm B"),
103 ("europe-north2-c", "Stockholm C"),
104 ("europe-central2-a", "Warsaw A"),
105 ("europe-central2-b", "Warsaw B"),
106 ("europe-central2-c", "Warsaw C"),
107 ("europe-southwest1-a", "Madrid A"),
108 ("europe-southwest1-b", "Madrid B"),
109 ("europe-southwest1-c", "Madrid C"),
110 ("asia-east1-a", "Taiwan A"),
112 ("asia-east1-b", "Taiwan B"),
113 ("asia-east1-c", "Taiwan C"),
114 ("asia-east2-a", "Hong Kong A"),
115 ("asia-east2-b", "Hong Kong B"),
116 ("asia-east2-c", "Hong Kong C"),
117 ("asia-northeast1-a", "Tokyo A"),
119 ("asia-northeast1-b", "Tokyo B"),
120 ("asia-northeast1-c", "Tokyo C"),
121 ("asia-northeast2-a", "Osaka A"),
122 ("asia-northeast2-b", "Osaka B"),
123 ("asia-northeast2-c", "Osaka C"),
124 ("asia-northeast3-a", "Seoul A"),
125 ("asia-northeast3-b", "Seoul B"),
126 ("asia-northeast3-c", "Seoul C"),
127 ("asia-south1-a", "Mumbai A"),
129 ("asia-south1-b", "Mumbai B"),
130 ("asia-south1-c", "Mumbai C"),
131 ("asia-south2-a", "Delhi A"),
132 ("asia-south2-b", "Delhi B"),
133 ("asia-south2-c", "Delhi C"),
134 ("asia-southeast1-a", "Singapore A"),
136 ("asia-southeast1-b", "Singapore B"),
137 ("asia-southeast1-c", "Singapore C"),
138 ("asia-southeast2-a", "Jakarta A"),
139 ("asia-southeast2-b", "Jakarta B"),
140 ("asia-southeast2-c", "Jakarta C"),
141 ("australia-southeast1-a", "Sydney A"),
143 ("australia-southeast1-b", "Sydney B"),
144 ("australia-southeast1-c", "Sydney C"),
145 ("australia-southeast2-a", "Melbourne A"),
146 ("australia-southeast2-b", "Melbourne B"),
147 ("australia-southeast2-c", "Melbourne C"),
148 ("me-west1-a", "Tel Aviv A"),
150 ("me-west1-b", "Tel Aviv B"),
151 ("me-west1-c", "Tel Aviv C"),
152 ("me-central1-a", "Doha A"),
153 ("me-central1-b", "Doha B"),
154 ("me-central1-c", "Doha C"),
155 ("me-central2-a", "Dammam A"),
156 ("me-central2-b", "Dammam B"),
157 ("me-central2-c", "Dammam C"),
158 ("africa-south1-a", "Johannesburg A"),
160 ("africa-south1-b", "Johannesburg B"),
161 ("africa-south1-c", "Johannesburg C"),
162];
163
164pub const GCP_ZONE_GROUPS: &[(&str, usize, usize)] = &[
166 ("US Central", 0, 4),
167 ("US East", 4, 13),
168 ("US South", 13, 16),
169 ("US West", 16, 28),
170 ("North America", 28, 37),
171 ("South America", 37, 43),
172 ("Europe West", 43, 70),
173 ("Europe Other", 70, 82),
174 ("Asia East", 82, 88),
175 ("Asia Northeast", 88, 97),
176 ("Asia South", 97, 103),
177 ("Asia Southeast", 103, 109),
178 ("Australia", 109, 115),
179 ("Middle East", 115, 124),
180 ("Africa", 124, 127),
181];
182
183#[derive(Deserialize)]
186struct AggregatedListResponse {
187 #[serde(default)]
188 items: std::collections::HashMap<String, InstancesScopedList>,
189 #[serde(rename = "nextPageToken")]
190 next_page_token: Option<String>,
191}
192
193#[derive(Deserialize)]
194struct InstancesScopedList {
195 #[serde(default)]
196 instances: Vec<GcpInstance>,
197}
198
199#[derive(Deserialize)]
200struct GcpInstance {
201 id: String,
202 name: String,
203 #[serde(default)]
204 status: String,
205 #[serde(rename = "machineType", default)]
206 machine_type: String,
207 #[serde(rename = "networkInterfaces", default)]
208 network_interfaces: Vec<NetworkInterface>,
209 #[serde(default)]
210 disks: Vec<Disk>,
211 #[serde(default)]
212 tags: Option<GcpTags>,
213 #[serde(default)]
214 labels: Option<std::collections::HashMap<String, String>>,
215 #[serde(default)]
216 zone: String,
217}
218
219#[derive(Deserialize)]
220struct NetworkInterface {
221 #[serde(rename = "accessConfigs", default)]
222 access_configs: Vec<AccessConfig>,
223 #[serde(rename = "networkIP", default)]
224 network_ip: String,
225 #[serde(rename = "ipv6AccessConfigs", default)]
226 ipv6_access_configs: Vec<Ipv6AccessConfig>,
227}
228
229#[derive(Deserialize)]
230struct AccessConfig {
231 #[serde(rename = "natIP", default)]
232 nat_ip: String,
233}
234
235#[derive(Deserialize)]
236struct Ipv6AccessConfig {
237 #[serde(rename = "externalIpv6", default)]
238 external_ipv6: String,
239}
240
241#[derive(Deserialize)]
242struct Disk {
243 #[serde(default)]
244 licenses: Vec<String>,
245}
246
247#[derive(Deserialize)]
248struct GcpTags {
249 #[serde(default)]
250 items: Vec<String>,
251}
252
253fn last_url_segment(url: &str) -> &str {
255 url.rsplit('/').next().unwrap_or("")
256}
257
258fn select_ip(instance: &GcpInstance) -> Option<String> {
261 for ni in &instance.network_interfaces {
262 for ac in &ni.access_configs {
263 if !ac.nat_ip.is_empty() {
264 return Some(ac.nat_ip.clone());
265 }
266 }
267 }
268 for ni in &instance.network_interfaces {
269 if !ni.network_ip.is_empty() {
270 return Some(ni.network_ip.clone());
271 }
272 }
273 for ni in &instance.network_interfaces {
274 for v6 in &ni.ipv6_access_configs {
275 if !v6.external_ipv6.is_empty() {
276 return Some(v6.external_ipv6.clone());
277 }
278 }
279 }
280 None
281}
282
283fn build_metadata(instance: &GcpInstance) -> Vec<(String, String)> {
285 let mut metadata = Vec::new();
286 let zone = last_url_segment(&instance.zone);
287 if !zone.is_empty() {
288 metadata.push(("zone".to_string(), zone.to_string()));
289 }
290 let machine = last_url_segment(&instance.machine_type);
291 if !machine.is_empty() {
292 metadata.push(("machine".to_string(), machine.to_string()));
293 }
294 if let Some(disk) = instance.disks.first() {
296 if let Some(license) = disk.licenses.first() {
297 let os = last_url_segment(license);
298 if !os.is_empty() {
299 metadata.push(("os".to_string(), os.to_string()));
300 }
301 }
302 }
303 if !instance.status.is_empty() {
304 metadata.push(("status".to_string(), instance.status.clone()));
305 }
306 metadata
307}
308
309fn build_tags(instance: &GcpInstance) -> Vec<String> {
311 let mut tags = Vec::new();
312 if let Some(ref t) = instance.tags {
313 tags.extend(t.items.clone());
314 }
315 if let Some(ref labels) = instance.labels {
316 for (k, v) in labels {
317 if v.is_empty() {
318 tags.push(k.clone());
319 } else {
320 tags.push(format!("{}:{}", k, v));
321 }
322 }
323 }
324 tags
325}
326
327fn is_json_key_file(token: &str) -> bool {
330 token.to_ascii_lowercase().ends_with(".json")
331}
332
333#[derive(Deserialize)]
335struct ServiceAccountKey {
336 client_email: String,
337 private_key: String,
338}
339
340fn resolve_service_account_token(path: &str) -> Result<String, ProviderError> {
342 let content = std::fs::read_to_string(path)
343 .map_err(|e| ProviderError::Http(format!("Failed to read key file {}: {}", path, e)))?;
344 let key: ServiceAccountKey = serde_json::from_str(&content)
345 .map_err(|e| ProviderError::Http(format!("Failed to parse key file: {}", e)))?;
346
347 let now = std::time::SystemTime::now()
348 .duration_since(std::time::UNIX_EPOCH)
349 .unwrap_or_default()
350 .as_secs();
351
352 let header = r#"{"alg":"RS256","typ":"JWT"}"#;
353 let claims = serde_json::json!({
354 "iss": key.client_email,
355 "scope": "https://www.googleapis.com/auth/compute.readonly",
356 "aud": "https://oauth2.googleapis.com/token",
357 "iat": now,
358 "exp": now + 3600
359 });
360 let claims_str = claims.to_string();
361
362 let header_b64 = URL_SAFE_NO_PAD.encode(header.as_bytes());
363 let claims_b64 = URL_SAFE_NO_PAD.encode(claims_str.as_bytes());
364 let signing_input = format!("{}.{}", header_b64, claims_b64);
365
366 let der = rsa::pkcs8::DecodePrivateKey::from_pkcs8_pem(&key.private_key)
368 .map_err(|e| ProviderError::Http(format!("Failed to parse private key: {}", e)))?;
369 let signing_key = rsa::pkcs1v15::SigningKey::<sha2::Sha256>::new(der);
370 use rsa::signature::{SignatureEncoding, Signer};
371 let signature = signing_key.sign(signing_input.as_bytes());
372 let sig_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes());
373
374 let jwt = format!("{}.{}", signing_input, sig_b64);
375
376 let agent = super::http_agent();
378 let mut resp = agent
379 .post("https://oauth2.googleapis.com/token")
380 .send_form([
381 ("grant_type", "urn:ietf:params:oauth:grant_type:jwt-bearer"),
382 ("assertion", jwt.as_str()),
383 ])
384 .map_err(map_ureq_error)?;
385
386 #[derive(Deserialize)]
387 struct TokenResponse {
388 access_token: String,
389 }
390
391 let token_resp: TokenResponse = resp
392 .body_mut()
393 .read_json()
394 .map_err(|e| ProviderError::Parse(format!("Token response: {}", e)))?;
395
396 Ok(token_resp.access_token)
397}
398
399fn resolve_token(token: &str) -> Result<String, ProviderError> {
402 if is_json_key_file(token) {
403 resolve_service_account_token(token)
404 } else {
405 Ok(token.to_string())
406 }
407}
408
409fn url_encode(s: &str) -> String {
411 super::percent_encode(s)
412}
413
414impl Provider for Gcp {
415 fn name(&self) -> &str {
416 "gcp"
417 }
418
419 fn short_label(&self) -> &str {
420 "gcp"
421 }
422
423 fn fetch_hosts_cancellable(
424 &self,
425 token: &str,
426 cancel: &AtomicBool,
427 _env: &crate::runtime::env::Env,
428 ) -> Result<Vec<ProviderHost>, ProviderError> {
429 self.fetch_hosts_with_progress(token, cancel, _env, &|_| {})
430 }
431
432 fn fetch_hosts_with_progress(
433 &self,
434 token: &str,
435 cancel: &AtomicBool,
436 _env: &crate::runtime::env::Env,
437 progress: &dyn Fn(&str),
438 ) -> Result<Vec<ProviderHost>, ProviderError> {
439 if self.project.is_empty() {
440 return Err(ProviderError::Http(
441 "No GCP project configured. Set the Project ID in the provider settings."
442 .to_string(),
443 ));
444 }
445
446 if !self
449 .project
450 .chars()
451 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '-' | '.' | ':'))
452 {
453 return Err(ProviderError::Http(format!(
454 "Invalid GCP project ID '{}'. Must contain only lowercase letters, digits, hyphens, dots and colons.",
455 self.project
456 )));
457 }
458
459 progress("Authenticating...");
460 let access_token = resolve_token(token)?;
461
462 if cancel.load(Ordering::Relaxed) {
463 return Err(ProviderError::Cancelled);
464 }
465
466 let zone_filter: HashSet<&str> = self.zones.iter().map(|s| s.as_str()).collect();
467 let agent = super::http_agent();
468 let mut all_hosts = Vec::new();
469 let mut page_token: Option<String> = None;
470
471 for page in 0u32.. {
472 if cancel.load(Ordering::Relaxed) {
473 return Err(ProviderError::Cancelled);
474 }
475
476 if page > 500 {
478 break;
479 }
480
481 let mut url = format!(
482 "https://compute.googleapis.com/compute/v1/projects/{}/aggregated/instances?maxResults=500&returnPartialSuccess=true",
483 self.project
484 );
485 if let Some(ref pt) = page_token {
486 url.push_str(&format!("&pageToken={}", url_encode(pt)));
487 }
488
489 progress(&format!(
490 "Fetching instances ({} so far)...",
491 all_hosts.len()
492 ));
493
494 let mut response = match agent
495 .get(&url)
496 .header("Authorization", &format!("Bearer {}", access_token))
497 .call()
498 {
499 Ok(r) => r,
500 Err(e) => {
501 let err = map_ureq_error(e);
502 if !all_hosts.is_empty() {
504 let fetched = all_hosts.len();
505 progress(&format!("{} instances, page {} failed", fetched, page + 1));
506 return Err(ProviderError::PartialResult {
507 hosts: all_hosts,
508 failures: 1,
509 total: page as usize + 1,
510 });
511 }
512 return Err(err);
513 }
514 };
515
516 let resp: AggregatedListResponse = match response.body_mut().read_json() {
517 Ok(r) => r,
518 Err(e) => {
519 if !all_hosts.is_empty() {
520 let fetched = all_hosts.len();
521 progress(&format!(
522 "{} instances, page {} failed to parse",
523 fetched,
524 page + 1
525 ));
526 return Err(ProviderError::PartialResult {
527 hosts: all_hosts,
528 failures: 1,
529 total: page as usize + 1,
530 });
531 }
532 return Err(ProviderError::Parse(e.to_string()));
533 }
534 };
535
536 for (scope_key, scoped_list) in &resp.items {
537 let zone = last_url_segment(scope_key);
539
540 if !zone_filter.is_empty() && !zone_filter.contains(zone) {
542 continue;
543 }
544
545 for instance in &scoped_list.instances {
546 if let Some(ip) = select_ip(instance) {
547 all_hosts.push(ProviderHost {
548 server_id: instance.id.clone(),
549 name: instance.name.clone(),
550 ip,
551 tags: build_tags(instance),
552 metadata: build_metadata(instance),
553 });
554 }
555 }
556 }
557
558 match resp.next_page_token {
559 Some(ref t) if !t.is_empty() => page_token = Some(t.clone()),
560 _ => break,
561 }
562 }
563
564 progress(&format!("{} instances", all_hosts.len()));
565 Ok(all_hosts)
566 }
567}
568
569#[cfg(test)]
570#[path = "gcp_tests.rs"]
571mod tests;