Skip to main content

hydro_deploy/
gcp.rs

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            // allow all VMs to communicate with each other over internal IPs
120            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            // allow external pings to all VMs
144            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 from [`crate::Deployment::add_host`].
168    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>>, // TODO(mingwei): fix pub
179    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        // first, we import the providers we need
259        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        // we use a single SSH key for all VMs
296        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        // Name must match regex: (?:[a-z](?:[-a-z0-9]{0,61}[a-z0-9])?), max length = 63 (61 + 1 a-z before and after)
326        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            // Keep the latter half of display_name if it is too long
334            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            // open the external ports that were requested
358            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); // Drop the lock as soon as possible.
394
395        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)); // needed to forward
509                    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}