hydro_deploy/
azure.rs

1use std::collections::HashMap;
2use std::sync::{Arc, Mutex, OnceLock};
3
4use anyhow::Result;
5use async_trait::async_trait;
6use nanoid::nanoid;
7use serde_json::json;
8
9use super::terraform::{TERRAFORM_ALPHABET, TerraformOutput, TerraformProvider};
10use super::{
11    ClientStrategy, Host, HostTargetType, LaunchedHost, ResourceBatch, ResourceResult,
12    ServerStrategy,
13};
14use crate::HostStrategyGetter;
15use crate::ssh::LaunchedSshHost;
16
17pub struct LaunchedVirtualMachine {
18    resource_result: Arc<ResourceResult>,
19    user: String,
20    pub internal_ip: String,
21    pub external_ip: Option<String>,
22}
23
24impl LaunchedSshHost for LaunchedVirtualMachine {
25    fn get_external_ip(&self) -> Option<String> {
26        self.external_ip.clone()
27    }
28
29    fn get_internal_ip(&self) -> String {
30        self.internal_ip.clone()
31    }
32
33    fn get_cloud_provider(&self) -> String {
34        "Azure".to_string()
35    }
36
37    fn resource_result(&self) -> &Arc<ResourceResult> {
38        &self.resource_result
39    }
40
41    fn ssh_user(&self) -> &str {
42        self.user.as_str()
43    }
44}
45
46pub struct AzureHost {
47    /// ID from [`crate::Deployment::add_host`].
48    id: usize,
49
50    project: String,
51    os_type: String, // linux or windows
52    machine_size: String,
53    image: Option<HashMap<String, String>>,
54    region: String,
55    user: Option<String>,
56    pub launched: OnceLock<Arc<LaunchedVirtualMachine>>, // TODO(mingwei): fix pub
57    external_ports: Mutex<Vec<u16>>,
58}
59
60impl AzureHost {
61    pub fn new(
62        id: usize,
63        project: String,
64        os_type: String, // linux or windows
65        machine_size: String,
66        image: Option<HashMap<String, String>>,
67        region: String,
68        user: Option<String>,
69    ) -> Self {
70        Self {
71            id,
72            project,
73            os_type,
74            machine_size,
75            image,
76            region,
77            user,
78            launched: OnceLock::new(),
79            external_ports: Mutex::new(Vec::new()),
80        }
81    }
82}
83
84#[async_trait]
85impl Host for AzureHost {
86    fn target_type(&self) -> HostTargetType {
87        HostTargetType::Linux
88    }
89
90    fn request_port(&self, bind_type: &ServerStrategy) {
91        match bind_type {
92            ServerStrategy::UnixSocket => {}
93            ServerStrategy::InternalTcpPort => {}
94            ServerStrategy::ExternalTcpPort(port) => {
95                let mut external_ports = self.external_ports.lock().unwrap();
96                if !external_ports.contains(port) {
97                    if self.launched.get().is_some() {
98                        todo!("Cannot adjust firewall after host has been launched");
99                    }
100                    external_ports.push(*port);
101                }
102            }
103            ServerStrategy::Demux(demux) => {
104                for bind_type in demux.values() {
105                    self.request_port(bind_type);
106                }
107            }
108            ServerStrategy::Merge(merge) => {
109                for bind_type in merge {
110                    self.request_port(bind_type);
111                }
112            }
113            ServerStrategy::Tagged(underlying, _) => {
114                self.request_port(underlying);
115            }
116            ServerStrategy::Null => {}
117        }
118    }
119
120    fn request_custom_binary(&self) {
121        self.request_port(&ServerStrategy::ExternalTcpPort(22));
122    }
123
124    fn id(&self) -> usize {
125        self.id
126    }
127
128    fn as_any(&self) -> &dyn std::any::Any {
129        self
130    }
131
132    fn collect_resources(&self, resource_batch: &mut ResourceBatch) {
133        if self.launched.get().is_some() {
134            return;
135        }
136
137        let project = self.project.as_str();
138
139        // first, we import the providers we need
140        resource_batch
141            .terraform
142            .terraform
143            .required_providers
144            .insert(
145                "azurerm".to_string(),
146                TerraformProvider {
147                    source: "hashicorp/azurerm".to_string(),
148                    version: "3.67.0".to_string(),
149                },
150            );
151
152        resource_batch
153            .terraform
154            .terraform
155            .required_providers
156            .insert(
157                "local".to_string(),
158                TerraformProvider {
159                    source: "hashicorp/local".to_string(),
160                    version: "2.3.0".to_string(),
161                },
162            );
163
164        resource_batch
165            .terraform
166            .terraform
167            .required_providers
168            .insert(
169                "tls".to_string(),
170                TerraformProvider {
171                    source: "hashicorp/tls".to_string(),
172                    version: "4.0.4".to_string(),
173                },
174            );
175
176        // we use a single SSH key for all VMs
177        resource_batch
178            .terraform
179            .resource
180            .entry("tls_private_key".to_string())
181            .or_default()
182            .insert(
183                "vm_instance_ssh_key".to_string(),
184                json!({
185                    "algorithm": "RSA",
186                    "rsa_bits": 4096
187                }),
188            );
189
190        resource_batch
191            .terraform
192            .resource
193            .entry("local_file".to_string())
194            .or_default()
195            .insert(
196                "vm_instance_ssh_key_pem".to_string(),
197                json!({
198                    "content": "${tls_private_key.vm_instance_ssh_key.private_key_pem}",
199                    "filename": ".ssh/vm_instance_ssh_key_pem",
200                    "file_permission": "0600"
201                }),
202            );
203
204        let vm_key = format!("vm-instance-{}", self.id);
205        let vm_name = format!("hydro-vm-instance-{}", nanoid!(8, &TERRAFORM_ALPHABET));
206
207        // Handle provider configuration
208        resource_batch.terraform.provider.insert(
209            "azurerm".to_string(),
210            json!({
211                "skip_provider_registration": "true",
212                "features": {},
213            }),
214        );
215
216        // Handle resources
217        resource_batch
218            .terraform
219            .resource
220            .entry("azurerm_resource_group".to_string())
221            .or_default()
222            .insert(
223                vm_key.to_string(),
224                json!({
225                    "name": project,
226                    "location": self.region.clone(),
227                }),
228            );
229
230        resource_batch
231            .terraform
232            .resource
233            .entry("azurerm_virtual_network".to_string())
234            .or_default()
235            .insert(
236                vm_key.to_string(),
237                json!({
238                    "name": format!("{vm_key}-network"),
239                    "address_space": ["10.0.0.0/16"],
240                    "location": self.region.clone(),
241                    "resource_group_name": format!("${{azurerm_resource_group.{vm_key}.name}}")
242                }),
243            );
244
245        resource_batch
246            .terraform
247            .resource
248            .entry("azurerm_subnet".to_string())
249            .or_default()
250            .insert(
251                vm_key.to_string(),
252                json!({
253                    "name": "internal",
254                    "resource_group_name": format!("${{azurerm_resource_group.{vm_key}.name}}"),
255                    "virtual_network_name": format!("${{azurerm_virtual_network.{vm_key}.name}}"),
256                    "address_prefixes": ["10.0.2.0/24"]
257                }),
258            );
259
260        resource_batch
261            .terraform
262            .resource
263            .entry("azurerm_public_ip".to_string())
264            .or_default()
265            .insert(
266                vm_key.to_string(),
267                json!({
268                    "name": "hydropubip",
269                    "resource_group_name": format!("${{azurerm_resource_group.{vm_key}.name}}"),
270                    "location": format!("${{azurerm_resource_group.{vm_key}.location}}"),
271                    "allocation_method": "Static",
272                }),
273            );
274
275        resource_batch
276            .terraform
277            .resource
278            .entry("azurerm_network_interface".to_string())
279            .or_default()
280            .insert(
281                vm_key.to_string(),
282                json!({
283                    "name": format!("{vm_key}-nic"),
284                    "location": format!("${{azurerm_resource_group.{vm_key}.location}}"),
285                    "resource_group_name": format!("${{azurerm_resource_group.{vm_key}.name}}"),
286                    "ip_configuration": {
287                        "name": "internal",
288                        "subnet_id": format!("${{azurerm_subnet.{vm_key}.id}}"),
289                        "private_ip_address_allocation": "Dynamic",
290                        "public_ip_address_id": format!("${{azurerm_public_ip.{vm_key}.id}}"),
291                    }
292                }),
293            );
294
295        // Define network security rules - for now, accept all connections
296        resource_batch
297            .terraform
298            .resource
299            .entry("azurerm_network_security_group".to_string())
300            .or_default()
301            .insert(
302                vm_key.to_string(),
303                json!({
304                    "name": "primary_security_group",
305                    "location": format!("${{azurerm_resource_group.{vm_key}.location}}"),
306                    "resource_group_name": format!("${{azurerm_resource_group.{vm_key}.name}}"),
307                }),
308            );
309
310        resource_batch
311            .terraform
312            .resource
313            .entry("azurerm_network_security_rule".to_string())
314            .or_default()
315            .insert(
316                vm_key.to_string(),
317                json!({
318                    "name": "allowall",
319                    "priority": 100,
320                    "direction": "Inbound",
321                    "access": "Allow",
322                    "protocol": "Tcp",
323                    "source_port_range": "*",
324                    "destination_port_range": "*",
325                    "source_address_prefix": "*",
326                    "destination_address_prefix": "*",
327                    "resource_group_name": format!("${{azurerm_resource_group.{vm_key}.name}}"),
328                    "network_security_group_name": format!("${{azurerm_network_security_group.{vm_key}.name}}"),
329                })
330            );
331
332        resource_batch
333            .terraform
334            .resource
335            .entry("azurerm_subnet_network_security_group_association".to_string())
336            .or_default()
337            .insert(
338                vm_key.to_string(),
339                json!({
340                    "subnet_id": format!("${{azurerm_subnet.{vm_key}.id}}"),
341                    "network_security_group_id": format!("${{azurerm_network_security_group.{vm_key}.id}}"),
342                })
343            );
344
345        let user = self.user.as_ref().cloned().unwrap_or("hydro".to_string());
346        let os_type = format!("azurerm_{}_virtual_machine", self.os_type.clone());
347        let image = self.image.as_ref().cloned().unwrap_or(HashMap::from([
348            ("publisher".to_string(), "Canonical".to_string()),
349            (
350                "offer".to_string(),
351                "0001-com-ubuntu-server-jammy".to_string(),
352            ),
353            ("sku".to_string(), "22_04-lts".to_string()),
354            ("version".to_string(), "latest".to_string()),
355        ]));
356
357        resource_batch
358            .terraform
359            .resource
360            .entry(os_type.clone())
361            .or_default()
362            .insert(
363                vm_key.clone(),
364                json!({
365                    "name": vm_name,
366                    "resource_group_name": format!("${{azurerm_resource_group.{vm_key}.name}}"),
367                    "location": format!("${{azurerm_resource_group.{vm_key}.location}}"),
368                    "size": self.machine_size.clone(),
369                    "network_interface_ids": [format!("${{azurerm_network_interface.{vm_key}.id}}")],
370                    "admin_ssh_key": {
371                        "username": user,
372                        "public_key": "${tls_private_key.vm_instance_ssh_key.public_key_openssh}",
373                    },
374                    "admin_username": user,
375                    "os_disk": {
376                        "caching": "ReadWrite",
377                        "storage_account_type": "Standard_LRS",
378                    },
379                    "source_image_reference": image,
380                }),
381            );
382
383        resource_batch.terraform.output.insert(
384            format!("{vm_key}-public-ip"),
385            TerraformOutput {
386                value: format!("${{azurerm_public_ip.{vm_key}.ip_address}}"),
387            },
388        );
389
390        resource_batch.terraform.output.insert(
391            format!("{vm_key}-internal-ip"),
392            TerraformOutput {
393                value: format!("${{azurerm_network_interface.{vm_key}.private_ip_address}}"),
394            },
395        );
396    }
397
398    fn launched(&self) -> Option<Arc<dyn LaunchedHost>> {
399        self.launched
400            .get()
401            .map(|a| a.clone() as Arc<dyn LaunchedHost>)
402    }
403
404    fn provision(&self, resource_result: &Arc<ResourceResult>) -> Arc<dyn LaunchedHost> {
405        self.launched
406            .get_or_init(|| {
407                let id = self.id;
408
409                let internal_ip = resource_result
410                    .terraform
411                    .outputs
412                    .get(&format!("vm-instance-{id}-internal-ip"))
413                    .unwrap()
414                    .value
415                    .clone();
416
417                let external_ip = resource_result
418                    .terraform
419                    .outputs
420                    .get(&format!("vm-instance-{id}-public-ip"))
421                    .map(|v| v.value.clone());
422
423                Arc::new(LaunchedVirtualMachine {
424                    resource_result: resource_result.clone(),
425                    user: self.user.as_ref().cloned().unwrap_or("hydro".to_string()),
426                    internal_ip,
427                    external_ip,
428                })
429            })
430            .clone()
431    }
432
433    fn strategy_as_server<'a>(
434        &'a self,
435        client_host: &dyn Host,
436    ) -> Result<(ClientStrategy<'a>, HostStrategyGetter)> {
437        if client_host.can_connect_to(ClientStrategy::UnixSocket(self.id)) {
438            Ok((
439                ClientStrategy::UnixSocket(self.id),
440                Box::new(|_| ServerStrategy::UnixSocket),
441            ))
442        } else if client_host.can_connect_to(ClientStrategy::InternalTcpPort(self)) {
443            Ok((
444                ClientStrategy::InternalTcpPort(self),
445                Box::new(|_| ServerStrategy::InternalTcpPort),
446            ))
447        } else if client_host.can_connect_to(ClientStrategy::ForwardedTcpPort(self)) {
448            Ok((
449                ClientStrategy::ForwardedTcpPort(self),
450                Box::new(|me| {
451                    me.downcast_ref::<AzureHost>()
452                        .unwrap()
453                        .request_port(&ServerStrategy::ExternalTcpPort(22)); // needed to forward
454                    ServerStrategy::InternalTcpPort
455                }),
456            ))
457        } else {
458            anyhow::bail!("Could not find a strategy to connect to Azure instance")
459        }
460    }
461
462    fn can_connect_to(&self, typ: ClientStrategy) -> bool {
463        match typ {
464            ClientStrategy::UnixSocket(id) => {
465                #[cfg(unix)]
466                {
467                    self.id == id
468                }
469
470                #[cfg(not(unix))]
471                {
472                    let _ = id;
473                    false
474                }
475            }
476            ClientStrategy::InternalTcpPort(target_host) => {
477                if let Some(provider_target) = target_host.as_any().downcast_ref::<AzureHost>() {
478                    self.project == provider_target.project
479                } else {
480                    false
481                }
482            }
483            ClientStrategy::ForwardedTcpPort(_) => false,
484        }
485    }
486}