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: usize,
49
50 project: String,
51 os_type: String, machine_size: String,
53 image: Option<HashMap<String, String>>,
54 region: String,
55 user: Option<String>,
56 pub launched: OnceLock<Arc<LaunchedVirtualMachine>>, external_ports: Mutex<Vec<u16>>,
58}
59
60impl AzureHost {
61 pub fn new(
62 id: usize,
63 project: String,
64 os_type: String, 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 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 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 resource_batch.terraform.provider.insert(
209 "azurerm".to_string(),
210 json!({
211 "skip_provider_registration": "true",
212 "features": {},
213 }),
214 );
215
216 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 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)); 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}