use std::any::Any;
use std::collections::HashMap;
use std::fmt::Debug;
use std::sync::{Arc, Mutex, OnceLock};
use anyhow::Result;
use nanoid::nanoid;
use serde_json::json;
use super::terraform::{TERRAFORM_ALPHABET, TerraformOutput, TerraformProvider};
use super::{ClientStrategy, Host, HostTargetType, LaunchedHost, ResourceBatch, ResourceResult};
use crate::ssh::LaunchedSshHost;
use crate::{BaseServerStrategy, HostStrategyGetter, PortNetworkHint};
pub struct LaunchedVirtualMachine {
resource_result: Arc<ResourceResult>,
user: String,
pub internal_ip: String,
pub external_ip: Option<String>,
}
impl LaunchedSshHost for LaunchedVirtualMachine {
fn get_external_ip(&self) -> Option<&str> {
self.external_ip.as_deref()
}
fn get_internal_ip(&self) -> &str {
&self.internal_ip
}
fn get_cloud_provider(&self) -> &'static str {
"Azure"
}
fn resource_result(&self) -> &Arc<ResourceResult> {
&self.resource_result
}
fn ssh_user(&self) -> &str {
self.user.as_str()
}
}
pub struct AzureHost {
id: usize,
project: String,
os_type: String, machine_size: String,
image: Option<HashMap<String, String>>,
target_type: HostTargetType,
region: String,
user: Option<String>,
pub launched: OnceLock<Arc<LaunchedVirtualMachine>>, external_ports: Mutex<Vec<u16>>,
}
impl AzureHost {
#[expect(clippy::too_many_arguments, reason = "used via builder pattern")]
pub fn new(
id: usize,
project: String,
os_type: String, machine_size: String,
image: Option<HashMap<String, String>>,
target_type: HostTargetType,
region: String,
user: Option<String>,
) -> Self {
Self {
id,
project,
os_type,
machine_size,
image,
target_type,
region,
user,
launched: OnceLock::new(),
external_ports: Mutex::new(Vec::new()),
}
}
}
impl Debug for AzureHost {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_fmt(format_args!("AzureHost({})", self.id))
}
}
impl Host for AzureHost {
fn target_type(&self) -> HostTargetType {
self.target_type
}
fn request_port_base(&self, bind_type: &BaseServerStrategy) {
match bind_type {
BaseServerStrategy::UnixSocket => {}
BaseServerStrategy::InternalTcpPort(_) => {}
BaseServerStrategy::ExternalTcpPort(port) => {
let mut external_ports = self.external_ports.lock().unwrap();
if !external_ports.contains(port) {
if self.launched.get().is_some() {
todo!("Cannot adjust firewall after host has been launched");
}
external_ports.push(*port);
}
}
}
}
fn request_custom_binary(&self) {
self.request_port_base(&BaseServerStrategy::ExternalTcpPort(22));
}
fn id(&self) -> usize {
self.id
}
fn collect_resources(&self, resource_batch: &mut ResourceBatch) {
if self.launched.get().is_some() {
return;
}
let project = self.project.as_str();
resource_batch
.terraform
.terraform
.required_providers
.insert(
"azurerm".to_owned(),
TerraformProvider {
source: "hashicorp/azurerm".to_owned(),
version: "3.67.0".to_owned(),
},
);
resource_batch
.terraform
.terraform
.required_providers
.insert(
"local".to_owned(),
TerraformProvider {
source: "hashicorp/local".to_owned(),
version: "2.3.0".to_owned(),
},
);
resource_batch
.terraform
.terraform
.required_providers
.insert(
"tls".to_owned(),
TerraformProvider {
source: "hashicorp/tls".to_owned(),
version: "4.0.4".to_owned(),
},
);
resource_batch
.terraform
.resource
.entry("tls_private_key".to_owned())
.or_default()
.insert(
"vm_instance_ssh_key".to_owned(),
json!({
"algorithm": "RSA",
"rsa_bits": 4096
}),
);
resource_batch
.terraform
.resource
.entry("local_file".to_owned())
.or_default()
.insert(
"vm_instance_ssh_key_pem".to_owned(),
json!({
"content": "${tls_private_key.vm_instance_ssh_key.private_key_pem}",
"filename": ".ssh/vm_instance_ssh_key_pem",
"file_permission": "0600"
}),
);
let vm_key = format!("vm-instance-{}", self.id);
let vm_name = format!("hydro-vm-instance-{}", nanoid!(8, &TERRAFORM_ALPHABET));
resource_batch.terraform.provider.insert(
"azurerm".to_owned(),
json!({
"skip_provider_registration": "true",
"features": {},
}),
);
resource_batch
.terraform
.resource
.entry("azurerm_resource_group".to_owned())
.or_default()
.insert(
vm_key.to_string(),
json!({
"name": project,
"location": self.region.clone(),
}),
);
resource_batch
.terraform
.resource
.entry("azurerm_virtual_network".to_owned())
.or_default()
.insert(
vm_key.to_string(),
json!({
"name": format!("{vm_key}-network"),
"address_space": ["10.0.0.0/16"],
"location": self.region.clone(),
"resource_group_name": format!("${{azurerm_resource_group.{vm_key}.name}}")
}),
);
resource_batch
.terraform
.resource
.entry("azurerm_subnet".to_owned())
.or_default()
.insert(
vm_key.to_string(),
json!({
"name": "internal",
"resource_group_name": format!("${{azurerm_resource_group.{vm_key}.name}}"),
"virtual_network_name": format!("${{azurerm_virtual_network.{vm_key}.name}}"),
"address_prefixes": ["10.0.2.0/24"]
}),
);
resource_batch
.terraform
.resource
.entry("azurerm_public_ip".to_owned())
.or_default()
.insert(
vm_key.to_string(),
json!({
"name": "hydropubip",
"resource_group_name": format!("${{azurerm_resource_group.{vm_key}.name}}"),
"location": format!("${{azurerm_resource_group.{vm_key}.location}}"),
"allocation_method": "Static",
}),
);
resource_batch
.terraform
.resource
.entry("azurerm_network_interface".to_owned())
.or_default()
.insert(
vm_key.to_string(),
json!({
"name": format!("{vm_key}-nic"),
"location": format!("${{azurerm_resource_group.{vm_key}.location}}"),
"resource_group_name": format!("${{azurerm_resource_group.{vm_key}.name}}"),
"ip_configuration": {
"name": "internal",
"subnet_id": format!("${{azurerm_subnet.{vm_key}.id}}"),
"private_ip_address_allocation": "Dynamic",
"public_ip_address_id": format!("${{azurerm_public_ip.{vm_key}.id}}"),
}
}),
);
resource_batch
.terraform
.resource
.entry("azurerm_network_security_group".to_owned())
.or_default()
.insert(
vm_key.to_string(),
json!({
"name": "primary_security_group",
"location": format!("${{azurerm_resource_group.{vm_key}.location}}"),
"resource_group_name": format!("${{azurerm_resource_group.{vm_key}.name}}"),
}),
);
resource_batch
.terraform
.resource
.entry("azurerm_network_security_rule".to_owned())
.or_default()
.insert(
vm_key.to_string(),
json!({
"name": "allowall",
"priority": 100,
"direction": "Inbound",
"access": "Allow",
"protocol": "Tcp",
"source_port_range": "*",
"destination_port_range": "*",
"source_address_prefix": "*",
"destination_address_prefix": "*",
"resource_group_name": format!("${{azurerm_resource_group.{vm_key}.name}}"),
"network_security_group_name": format!("${{azurerm_network_security_group.{vm_key}.name}}"),
})
);
resource_batch
.terraform
.resource
.entry("azurerm_subnet_network_security_group_association".to_owned())
.or_default()
.insert(
vm_key.to_string(),
json!({
"subnet_id": format!("${{azurerm_subnet.{vm_key}.id}}"),
"network_security_group_id": format!("${{azurerm_network_security_group.{vm_key}.id}}"),
})
);
let user = self.user.as_ref().cloned().unwrap_or("hydro".to_owned());
let os_type = format!("azurerm_{}_virtual_machine", self.os_type.clone());
let image = self.image.as_ref().cloned().unwrap_or(HashMap::from([
("publisher".to_owned(), "Canonical".to_owned()),
(
"offer".to_owned(),
"0001-com-ubuntu-server-jammy".to_owned(),
),
("sku".to_owned(), "22_04-lts".to_owned()),
("version".to_owned(), "latest".to_owned()),
]));
resource_batch
.terraform
.resource
.entry(os_type)
.or_default()
.insert(
vm_key.clone(),
json!({
"name": vm_name,
"resource_group_name": format!("${{azurerm_resource_group.{vm_key}.name}}"),
"location": format!("${{azurerm_resource_group.{vm_key}.location}}"),
"size": self.machine_size.clone(),
"network_interface_ids": [format!("${{azurerm_network_interface.{vm_key}.id}}")],
"admin_ssh_key": {
"username": user,
"public_key": "${tls_private_key.vm_instance_ssh_key.public_key_openssh}",
},
"admin_username": user,
"os_disk": {
"caching": "ReadWrite",
"storage_account_type": "Standard_LRS",
},
"source_image_reference": image,
}),
);
resource_batch.terraform.output.insert(
format!("{vm_key}-public-ip"),
TerraformOutput {
value: format!("${{azurerm_public_ip.{vm_key}.ip_address}}"),
},
);
resource_batch.terraform.output.insert(
format!("{vm_key}-internal-ip"),
TerraformOutput {
value: format!("${{azurerm_network_interface.{vm_key}.private_ip_address}}"),
},
);
}
fn launched(&self) -> Option<Arc<dyn LaunchedHost>> {
self.launched
.get()
.map(|a| a.clone() as Arc<dyn LaunchedHost>)
}
fn provision(&self, resource_result: &Arc<ResourceResult>) -> Arc<dyn LaunchedHost> {
self.launched
.get_or_init(|| {
let id = self.id;
let internal_ip = resource_result
.terraform
.outputs
.get(&format!("vm-instance-{id}-internal-ip"))
.unwrap()
.value
.clone();
let external_ip = resource_result
.terraform
.outputs
.get(&format!("vm-instance-{id}-public-ip"))
.map(|v| v.value.clone());
Arc::new(LaunchedVirtualMachine {
resource_result: resource_result.clone(),
user: self.user.as_ref().cloned().unwrap_or("hydro".to_owned()),
internal_ip,
external_ip,
})
})
.clone()
}
fn strategy_as_server<'a>(
&'a self,
client_host: &dyn Host,
network_hint: PortNetworkHint,
) -> Result<(ClientStrategy<'a>, HostStrategyGetter)> {
if matches!(network_hint, PortNetworkHint::Auto)
&& client_host.can_connect_to(ClientStrategy::UnixSocket(self.id))
{
Ok((
ClientStrategy::UnixSocket(self.id),
Box::new(|_| BaseServerStrategy::UnixSocket),
))
} else if matches!(
network_hint,
PortNetworkHint::Auto | PortNetworkHint::TcpPort(_)
) && client_host.can_connect_to(ClientStrategy::InternalTcpPort(self))
{
Ok((
ClientStrategy::InternalTcpPort(self),
Box::new(move |_| {
BaseServerStrategy::InternalTcpPort(match network_hint {
PortNetworkHint::Auto => None,
PortNetworkHint::TcpPort(port) => port,
})
}),
))
} else if matches!(network_hint, PortNetworkHint::Auto)
&& client_host.can_connect_to(ClientStrategy::ForwardedTcpPort(self))
{
Ok((
ClientStrategy::ForwardedTcpPort(self),
Box::new(|me| {
me.downcast_ref::<AzureHost>()
.unwrap()
.request_port_base(&BaseServerStrategy::ExternalTcpPort(22)); BaseServerStrategy::InternalTcpPort(None)
}),
))
} else {
anyhow::bail!("Could not find a strategy to connect to Azure instance")
}
}
fn can_connect_to(&self, typ: ClientStrategy) -> bool {
match typ {
ClientStrategy::UnixSocket(id) => {
#[cfg(unix)]
{
self.id == id
}
#[cfg(not(unix))]
{
let _ = id;
false
}
}
ClientStrategy::InternalTcpPort(target_host) => {
if let Some(provider_target) = <dyn Any>::downcast_ref::<AzureHost>(target_host) {
self.project == provider_target.project
} else {
false
}
}
ClientStrategy::ForwardedTcpPort(_) => false,
}
}
}