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.replace(['_', ':'], "-").to_lowercase();
329
330            // Keep the latter half of display_name if it is too long
331            let num_chars_to_cut = vm_name.len() + display_name.len() - 63;
332            if num_chars_to_cut > 0 {
333                display_name.drain(0..num_chars_to_cut);
334            }
335            vm_name.push_str(&display_name);
336        }
337
338        let mut tags = vec![];
339        let mut external_interfaces = vec![];
340
341        let external_ports = self.external_ports.lock().unwrap();
342        if external_ports.is_empty() {
343            external_interfaces.push(json!({ "network": format!("${{{vpc_path}.self_link}}") }));
344        } else {
345            external_interfaces.push(json!({
346                "network": format!("${{{vpc_path}.self_link}}"),
347                "access_config": [
348                    {
349                        "network_tier": "STANDARD"
350                    }
351                ]
352            }));
353
354            // open the external ports that were requested
355            let my_external_tags =
356                external_ports.iter().map(|port| {
357                    let rule_id = nanoid!(8, &TERRAFORM_ALPHABET);
358                    let firewall_rule = resource_batch
359                    .terraform
360                    .resource
361                    .entry("google_compute_firewall".to_owned())
362                    .or_default()
363                    .entry(format!("open-external-port-{}", port))
364                    .or_insert_with(|| json!({
365                        "name": format!("open-external-port-{}-{}", port, rule_id),
366                        "project": project,
367                        "network": format!("${{{vpc_path}.name}}"),
368                        "target_tags": [format!("open-external-port-tag-{}-{}", port, rule_id)],
369                        "source_ranges": ["0.0.0.0/0"],
370                        "allow": [
371                            {
372                                "protocol": "tcp",
373                                "ports": vec![port.to_string()]
374                            }
375                        ]
376                    }));
377
378                    firewall_rule["target_tags"].as_array().unwrap()[0].clone()
379                });
380
381            tags.extend(my_external_tags);
382
383            resource_batch.terraform.output.insert(
384                format!("{vm_key}-public-ip"),
385                TerraformOutput {
386                    value: format!("${{google_compute_instance.{vm_key}.network_interface[0].access_config[0].nat_ip}}")
387                }
388            );
389        }
390        drop(external_ports); // Drop the lock as soon as possible.
391
392        let user = self.user.as_deref().unwrap_or("hydro");
393        resource_batch
394            .terraform
395            .resource
396            .entry("google_compute_instance".to_owned())
397            .or_default()
398            .insert(
399                vm_key.clone(),
400                json!({
401                    "name": vm_name,
402                    "project": project,
403                    "machine_type": self.machine_type,
404                    "zone": self.region,
405                    "tags": tags,
406                    "metadata": {
407                        "ssh-keys": format!("{user}:${{tls_private_key.vm_instance_ssh_key.public_key_openssh}}")
408                    },
409                    "boot_disk": [
410                        {
411                            "initialize_params": [
412                                {
413                                    "image": self.image
414                                }
415                            ]
416                        }
417                    ],
418                    "network_interface": external_interfaces,
419                }),
420            );
421
422        resource_batch.terraform.output.insert(
423            format!("{vm_key}-internal-ip"),
424            TerraformOutput {
425                value: format!(
426                    "${{google_compute_instance.{vm_key}.network_interface[0].network_ip}}"
427                ),
428            },
429        );
430    }
431
432    fn launched(&self) -> Option<Arc<dyn LaunchedHost>> {
433        self.launched
434            .get()
435            .map(|a| a.clone() as Arc<dyn LaunchedHost>)
436    }
437
438    fn provision(&self, resource_result: &Arc<ResourceResult>) -> Arc<dyn LaunchedHost> {
439        self.launched
440            .get_or_init(|| {
441                let id = self.id;
442
443                let internal_ip = resource_result
444                    .terraform
445                    .outputs
446                    .get(&format!("vm-instance-{id}-internal-ip"))
447                    .unwrap()
448                    .value
449                    .clone();
450
451                let external_ip = resource_result
452                    .terraform
453                    .outputs
454                    .get(&format!("vm-instance-{id}-public-ip"))
455                    .map(|v| v.value.clone());
456
457                Arc::new(LaunchedComputeEngine {
458                    resource_result: resource_result.clone(),
459                    user: self.user.clone().unwrap_or_else(|| "hydro".to_owned()),
460                    internal_ip,
461                    external_ip,
462                })
463            })
464            .clone()
465    }
466
467    fn strategy_as_server<'a>(
468        &'a self,
469        client_host: &dyn Host,
470        network_hint: PortNetworkHint,
471    ) -> Result<(ClientStrategy<'a>, HostStrategyGetter)> {
472        if matches!(network_hint, PortNetworkHint::Auto)
473            && client_host.can_connect_to(ClientStrategy::UnixSocket(self.id))
474        {
475            Ok((
476                ClientStrategy::UnixSocket(self.id),
477                Box::new(|_| BaseServerStrategy::UnixSocket),
478            ))
479        } else if matches!(
480            network_hint,
481            PortNetworkHint::Auto | PortNetworkHint::TcpPort(_)
482        ) && client_host.can_connect_to(ClientStrategy::InternalTcpPort(self))
483        {
484            Ok((
485                ClientStrategy::InternalTcpPort(self),
486                Box::new(move |_| {
487                    BaseServerStrategy::InternalTcpPort(match network_hint {
488                        PortNetworkHint::Auto => None,
489                        PortNetworkHint::TcpPort(port) => port,
490                    })
491                }),
492            ))
493        } else if matches!(network_hint, PortNetworkHint::Auto)
494            && client_host.can_connect_to(ClientStrategy::ForwardedTcpPort(self))
495        {
496            Ok((
497                ClientStrategy::ForwardedTcpPort(self),
498                Box::new(|me| {
499                    me.downcast_ref::<GcpComputeEngineHost>()
500                        .unwrap()
501                        .request_port_base(&BaseServerStrategy::ExternalTcpPort(22)); // needed to forward
502                    BaseServerStrategy::InternalTcpPort(None)
503                }),
504            ))
505        } else {
506            anyhow::bail!("Could not find a strategy to connect to GCP instance")
507        }
508    }
509
510    fn can_connect_to(&self, typ: ClientStrategy<'_>) -> bool {
511        match typ {
512            ClientStrategy::UnixSocket(id) => {
513                #[cfg(unix)]
514                {
515                    self.id == id
516                }
517
518                #[cfg(not(unix))]
519                {
520                    let _ = id;
521                    false
522                }
523            }
524            ClientStrategy::InternalTcpPort(target_host) => {
525                if let Some(gcp_target) =
526                    <dyn Any>::downcast_ref::<GcpComputeEngineHost>(target_host)
527                {
528                    self.project == gcp_target.project
529                } else {
530                    false
531                }
532            }
533            ClientStrategy::ForwardedTcpPort(_) => false,
534        }
535    }
536}