1use std::any::Any;
2use std::fmt::Debug;
3use std::sync::{Arc, Mutex, OnceLock};
4
5use anyhow::Result;
6use nanoid::nanoid;
7use serde_json::json;
8
9use super::terraform::{TERRAFORM_ALPHABET, TerraformOutput, TerraformProvider};
10use super::{ClientStrategy, Host, HostTargetType, LaunchedHost, ResourceBatch, ResourceResult};
11use crate::ssh::LaunchedSshHost;
12use crate::{BaseServerStrategy, HostStrategyGetter, PortNetworkHint};
13
14pub struct LaunchedComputeEngine {
15 resource_result: Arc<ResourceResult>,
16 user: String,
17 pub internal_ip: String,
18 pub external_ip: Option<String>,
19}
20
21impl LaunchedSshHost for LaunchedComputeEngine {
22 fn get_external_ip(&self) -> Option<&str> {
23 self.external_ip.as_deref()
24 }
25
26 fn get_internal_ip(&self) -> &str {
27 &self.internal_ip
28 }
29
30 fn get_cloud_provider(&self) -> &'static str {
31 "GCP"
32 }
33
34 fn resource_result(&self) -> &Arc<ResourceResult> {
35 &self.resource_result
36 }
37
38 fn ssh_user(&self) -> &str {
39 self.user.as_str()
40 }
41}
42
43#[derive(Debug)]
44pub struct GcpNetwork {
45 pub project: String,
46 pub existing_vpc: OnceLock<String>,
47 id: String,
48}
49
50impl GcpNetwork {
51 pub fn new(project: impl Into<String>, existing_vpc: Option<String>) -> Arc<Self> {
52 Arc::new(Self {
53 project: project.into(),
54 existing_vpc: existing_vpc.map(From::from).unwrap_or_default(),
55 id: nanoid!(8, &TERRAFORM_ALPHABET),
56 })
57 }
58
59 fn collect_resources(&self, resource_batch: &mut ResourceBatch) -> String {
60 resource_batch
61 .terraform
62 .terraform
63 .required_providers
64 .insert(
65 "google".to_owned(),
66 TerraformProvider {
67 source: "hashicorp/google".to_owned(),
68 version: "4.53.1".to_owned(),
69 },
70 );
71
72 let vpc_network = format!("hydro-vpc-network-{}", self.id);
73
74 if let Some(existing) = self.existing_vpc.get() {
75 if resource_batch
76 .terraform
77 .resource
78 .get("google_compute_network")
79 .is_some_and(|nw| nw.contains_key(existing))
80 {
81 format!("google_compute_network.{existing}")
82 } else {
83 resource_batch
84 .terraform
85 .data
86 .entry("google_compute_network".to_owned())
87 .or_default()
88 .insert(
89 vpc_network.clone(),
90 json!({
91 "name": existing,
92 "project": self.project,
93 }),
94 );
95
96 format!("data.google_compute_network.{vpc_network}")
97 }
98 } else {
99 resource_batch
100 .terraform
101 .resource
102 .entry("google_compute_network".to_owned())
103 .or_default()
104 .insert(
105 vpc_network.clone(),
106 json!({
107 "name": vpc_network,
108 "project": self.project,
109 "auto_create_subnetworks": true
110 }),
111 );
112
113 let firewall_entries = resource_batch
114 .terraform
115 .resource
116 .entry("google_compute_firewall".to_owned())
117 .or_default();
118
119 firewall_entries.insert(
121 format!("{vpc_network}-default-allow-internal"),
122 json!({
123 "name": format!("{vpc_network}-default-allow-internal"),
124 "project": self.project,
125 "network": format!("${{google_compute_network.{vpc_network}.name}}"),
126 "source_ranges": ["10.128.0.0/9"],
127 "allow": [
128 {
129 "protocol": "tcp",
130 "ports": ["0-65535"]
131 },
132 {
133 "protocol": "udp",
134 "ports": ["0-65535"]
135 },
136 {
137 "protocol": "icmp"
138 }
139 ]
140 }),
141 );
142
143 firewall_entries.insert(
145 format!("{vpc_network}-default-allow-ping"),
146 json!({
147 "name": format!("{vpc_network}-default-allow-ping"),
148 "project": self.project,
149 "network": format!("${{google_compute_network.{vpc_network}.name}}"),
150 "source_ranges": ["0.0.0.0/0"],
151 "allow": [
152 {
153 "protocol": "icmp"
154 }
155 ]
156 }),
157 );
158
159 let out = format!("google_compute_network.{vpc_network}");
160 self.existing_vpc.set(vpc_network).unwrap();
161 out
162 }
163 }
164}
165
166pub struct GcpComputeEngineHost {
167 id: usize,
169
170 project: String,
171 machine_type: String,
172 image: String,
173 target_type: HostTargetType,
174 region: String,
175 network: Arc<GcpNetwork>,
176 user: Option<String>,
177 display_name: Option<String>,
178 pub launched: OnceLock<Arc<LaunchedComputeEngine>>, external_ports: Mutex<Vec<u16>>,
180}
181
182impl Debug for GcpComputeEngineHost {
183 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
184 f.write_fmt(format_args!(
185 "GcpComputeEngineHost({} ({:?}))",
186 self.id, self.display_name
187 ))
188 }
189}
190
191impl GcpComputeEngineHost {
192 #[expect(clippy::too_many_arguments, reason = "used via builder pattern")]
193 pub fn new(
194 id: usize,
195 project: impl Into<String>,
196 machine_type: impl Into<String>,
197 image: impl Into<String>,
198 target_type: HostTargetType,
199 region: impl Into<String>,
200 network: Arc<GcpNetwork>,
201 user: Option<String>,
202 display_name: Option<String>,
203 ) -> Self {
204 Self {
205 id,
206 project: project.into(),
207 machine_type: machine_type.into(),
208 image: image.into(),
209 target_type,
210 region: region.into(),
211 network,
212 user,
213 display_name,
214 launched: OnceLock::new(),
215 external_ports: Mutex::new(Vec::new()),
216 }
217 }
218}
219
220impl Host for GcpComputeEngineHost {
221 fn target_type(&self) -> HostTargetType {
222 self.target_type
223 }
224
225 fn request_port_base(&self, bind_type: &BaseServerStrategy) {
226 match bind_type {
227 BaseServerStrategy::UnixSocket => {}
228 BaseServerStrategy::InternalTcpPort(_) => {}
229 BaseServerStrategy::ExternalTcpPort(port) => {
230 let mut external_ports = self.external_ports.lock().unwrap();
231 if !external_ports.contains(port) {
232 if self.launched.get().is_some() {
233 todo!("Cannot adjust firewall after host has been launched");
234 }
235 external_ports.push(*port);
236 }
237 }
238 }
239 }
240
241 fn request_custom_binary(&self) {
242 self.request_port_base(&BaseServerStrategy::ExternalTcpPort(22));
243 }
244
245 fn id(&self) -> usize {
246 self.id
247 }
248
249 fn collect_resources(&self, resource_batch: &mut ResourceBatch) {
250 if self.launched.get().is_some() {
251 return;
252 }
253
254 let vpc_path = self.network.collect_resources(resource_batch);
255
256 let project = self.project.as_str();
257
258 resource_batch
260 .terraform
261 .terraform
262 .required_providers
263 .insert(
264 "google".to_owned(),
265 TerraformProvider {
266 source: "hashicorp/google".to_owned(),
267 version: "4.53.1".to_owned(),
268 },
269 );
270
271 resource_batch
272 .terraform
273 .terraform
274 .required_providers
275 .insert(
276 "local".to_owned(),
277 TerraformProvider {
278 source: "hashicorp/local".to_owned(),
279 version: "2.3.0".to_owned(),
280 },
281 );
282
283 resource_batch
284 .terraform
285 .terraform
286 .required_providers
287 .insert(
288 "tls".to_owned(),
289 TerraformProvider {
290 source: "hashicorp/tls".to_owned(),
291 version: "4.0.4".to_owned(),
292 },
293 );
294
295 resource_batch
297 .terraform
298 .resource
299 .entry("tls_private_key".to_owned())
300 .or_default()
301 .insert(
302 "vm_instance_ssh_key".to_owned(),
303 json!({
304 "algorithm": "RSA",
305 "rsa_bits": 4096
306 }),
307 );
308
309 resource_batch
310 .terraform
311 .resource
312 .entry("local_file".to_owned())
313 .or_default()
314 .insert(
315 "vm_instance_ssh_key_pem".to_owned(),
316 json!({
317 "content": "${tls_private_key.vm_instance_ssh_key.private_key_pem}",
318 "filename": ".ssh/vm_instance_ssh_key_pem",
319 "file_permission": "0600"
320 }),
321 );
322
323 let vm_key = format!("vm-instance-{}", self.id);
324 let mut vm_name = format!("hydro-vm-instance-{}", nanoid!(8, &TERRAFORM_ALPHABET),);
325 if let Some(mut display_name) = self.display_name.clone() {
327 vm_name.push('-');
328 display_name = display_name
329 .replace("_", "-")
330 .replace(":", "-")
331 .to_lowercase();
332
333 let num_chars_to_cut = vm_name.len() + display_name.len() - 63;
335 if num_chars_to_cut > 0 {
336 display_name.drain(0..num_chars_to_cut);
337 }
338 vm_name.push_str(&display_name);
339 }
340
341 let mut tags = vec![];
342 let mut external_interfaces = vec![];
343
344 let external_ports = self.external_ports.lock().unwrap();
345 if external_ports.is_empty() {
346 external_interfaces.push(json!({ "network": format!("${{{vpc_path}.self_link}}") }));
347 } else {
348 external_interfaces.push(json!({
349 "network": format!("${{{vpc_path}.self_link}}"),
350 "access_config": [
351 {
352 "network_tier": "STANDARD"
353 }
354 ]
355 }));
356
357 let my_external_tags =
359 external_ports.iter().map(|port| {
360 let rule_id = nanoid!(8, &TERRAFORM_ALPHABET);
361 let firewall_rule = resource_batch
362 .terraform
363 .resource
364 .entry("google_compute_firewall".to_owned())
365 .or_default()
366 .entry(format!("open-external-port-{}", port))
367 .or_insert_with(|| json!({
368 "name": format!("open-external-port-{}-{}", port, rule_id),
369 "project": project,
370 "network": format!("${{{vpc_path}.name}}"),
371 "target_tags": [format!("open-external-port-tag-{}-{}", port, rule_id)],
372 "source_ranges": ["0.0.0.0/0"],
373 "allow": [
374 {
375 "protocol": "tcp",
376 "ports": vec![port.to_string()]
377 }
378 ]
379 }));
380
381 firewall_rule["target_tags"].as_array().unwrap()[0].clone()
382 });
383
384 tags.extend(my_external_tags);
385
386 resource_batch.terraform.output.insert(
387 format!("{vm_key}-public-ip"),
388 TerraformOutput {
389 value: format!("${{google_compute_instance.{vm_key}.network_interface[0].access_config[0].nat_ip}}")
390 }
391 );
392 }
393 drop(external_ports); let user = self.user.as_deref().unwrap_or("hydro");
396 resource_batch
397 .terraform
398 .resource
399 .entry("google_compute_instance".to_owned())
400 .or_default()
401 .insert(
402 vm_key.clone(),
403 json!({
404 "name": vm_name,
405 "project": project,
406 "machine_type": self.machine_type,
407 "zone": self.region,
408 "tags": tags,
409 "metadata": {
410 "ssh-keys": format!("{user}:${{tls_private_key.vm_instance_ssh_key.public_key_openssh}}")
411 },
412 "boot_disk": [
413 {
414 "initialize_params": [
415 {
416 "image": self.image
417 }
418 ]
419 }
420 ],
421 "network_interface": external_interfaces,
422 }),
423 );
424
425 resource_batch.terraform.output.insert(
426 format!("{vm_key}-internal-ip"),
427 TerraformOutput {
428 value: format!(
429 "${{google_compute_instance.{vm_key}.network_interface[0].network_ip}}"
430 ),
431 },
432 );
433 }
434
435 fn launched(&self) -> Option<Arc<dyn LaunchedHost>> {
436 self.launched
437 .get()
438 .map(|a| a.clone() as Arc<dyn LaunchedHost>)
439 }
440
441 fn provision(&self, resource_result: &Arc<ResourceResult>) -> Arc<dyn LaunchedHost> {
442 self.launched
443 .get_or_init(|| {
444 let id = self.id;
445
446 let internal_ip = resource_result
447 .terraform
448 .outputs
449 .get(&format!("vm-instance-{id}-internal-ip"))
450 .unwrap()
451 .value
452 .clone();
453
454 let external_ip = resource_result
455 .terraform
456 .outputs
457 .get(&format!("vm-instance-{id}-public-ip"))
458 .map(|v| v.value.clone());
459
460 Arc::new(LaunchedComputeEngine {
461 resource_result: resource_result.clone(),
462 user: self
463 .user
464 .as_ref()
465 .cloned()
466 .unwrap_or_else(|| "hydro".to_owned()),
467 internal_ip,
468 external_ip,
469 })
470 })
471 .clone()
472 }
473
474 fn strategy_as_server<'a>(
475 &'a self,
476 client_host: &dyn Host,
477 network_hint: PortNetworkHint,
478 ) -> Result<(ClientStrategy<'a>, HostStrategyGetter)> {
479 if matches!(network_hint, PortNetworkHint::Auto)
480 && client_host.can_connect_to(ClientStrategy::UnixSocket(self.id))
481 {
482 Ok((
483 ClientStrategy::UnixSocket(self.id),
484 Box::new(|_| BaseServerStrategy::UnixSocket),
485 ))
486 } else if matches!(
487 network_hint,
488 PortNetworkHint::Auto | PortNetworkHint::TcpPort(_)
489 ) && client_host.can_connect_to(ClientStrategy::InternalTcpPort(self))
490 {
491 Ok((
492 ClientStrategy::InternalTcpPort(self),
493 Box::new(move |_| {
494 BaseServerStrategy::InternalTcpPort(match network_hint {
495 PortNetworkHint::Auto => None,
496 PortNetworkHint::TcpPort(port) => port,
497 })
498 }),
499 ))
500 } else if matches!(network_hint, PortNetworkHint::Auto)
501 && client_host.can_connect_to(ClientStrategy::ForwardedTcpPort(self))
502 {
503 Ok((
504 ClientStrategy::ForwardedTcpPort(self),
505 Box::new(|me| {
506 me.downcast_ref::<GcpComputeEngineHost>()
507 .unwrap()
508 .request_port_base(&BaseServerStrategy::ExternalTcpPort(22)); BaseServerStrategy::InternalTcpPort(None)
510 }),
511 ))
512 } else {
513 anyhow::bail!("Could not find a strategy to connect to GCP instance")
514 }
515 }
516
517 fn can_connect_to(&self, typ: ClientStrategy) -> bool {
518 match typ {
519 ClientStrategy::UnixSocket(id) => {
520 #[cfg(unix)]
521 {
522 self.id == id
523 }
524
525 #[cfg(not(unix))]
526 {
527 let _ = id;
528 false
529 }
530 }
531 ClientStrategy::InternalTcpPort(target_host) => {
532 if let Some(gcp_target) =
533 <dyn Any>::downcast_ref::<GcpComputeEngineHost>(target_host)
534 {
535 self.project == gcp_target.project
536 } else {
537 false
538 }
539 }
540 ClientStrategy::ForwardedTcpPort(_) => false,
541 }
542 }
543}