use std::sync::Arc;
use anyhow::{bail, Context, Result};
use async_trait::async_trait;
use serde_json::Value;
use super::{HetznerDriver, Location, MachineProvider, ServerId, ServerSpec};
use crate::envoy::cloud_vps::{
server_status_to_output, CloudVpsCreate, CloudVpsCreateInput, CloudVpsCreateOutput,
CloudVpsDestroy, CloudVpsDestroyInput, CloudVpsDestroyOutput, CloudVpsStatus,
CloudVpsStatusInput, CloudVpsStatusOutput,
};
use crate::envoy::{AdapterFlavor, EnvoyAdapter, InternalVerb, Tier};
pub struct HetznerEnvoy {
driver: Arc<HetznerDriver>,
}
impl HetznerEnvoy {
pub fn new(driver: HetznerDriver) -> Self {
Self {
driver: Arc::new(driver),
}
}
pub fn from_arc(driver: Arc<HetznerDriver>) -> Self {
Self { driver }
}
pub async fn cloud_vps_create(
&self,
input: CloudVpsCreateInput,
) -> Result<CloudVpsCreateOutput> {
let location = Location::try_from(input.location.as_str())
.with_context(|| format!("cloud.vps.create: unknown location {:?}", input.location))?;
let ssh_keys = parse_ssh_keys(&input.ssh_keys)?;
let spec = ServerSpec {
name: input.name,
server_type: input.server_type,
image: input.image,
location,
ssh_keys,
};
let project = self
.driver
.ensure_project("")
.await
.context("cloud.vps.create: ensure_project")?;
let id = self
.driver
.create_server(&project, &spec, &input.user_data)
.await
.context("cloud.vps.create: create_server")?;
Ok(CloudVpsCreateOutput { id: id.0 })
}
pub async fn cloud_vps_destroy(
&self,
input: CloudVpsDestroyInput,
) -> Result<CloudVpsDestroyOutput> {
self.driver
.destroy_server(&ServerId(input.id))
.await
.context("cloud.vps.destroy: destroy_server")?;
Ok(CloudVpsDestroyOutput::default())
}
pub async fn cloud_vps_status(
&self,
input: CloudVpsStatusInput,
) -> Result<CloudVpsStatusOutput> {
let status = self
.driver
.server_status(&ServerId(input.id))
.await
.context("cloud.vps.status: server_status")?;
Ok(server_status_to_output(status))
}
}
#[async_trait]
impl EnvoyAdapter for HetznerEnvoy {
fn id(&self) -> &str {
"hetzner"
}
fn tier(&self) -> Tier {
Tier::S
}
fn flavor(&self) -> AdapterFlavor {
AdapterFlavor::Native
}
fn supported_verb_ids(&self) -> Vec<&'static str> {
vec![CloudVpsCreate::ID, CloudVpsDestroy::ID, CloudVpsStatus::ID]
}
async fn dispatch(&self, verb_id: &str, input: Value) -> Result<Value> {
match verb_id {
id if id == CloudVpsCreate::ID => {
let args: CloudVpsCreateInput =
serde_json::from_value(input).with_context(|| format!("{id}: decode input"))?;
let out = self.cloud_vps_create(args).await?;
Ok(serde_json::to_value(out)?)
}
id if id == CloudVpsDestroy::ID => {
let args: CloudVpsDestroyInput =
serde_json::from_value(input).with_context(|| format!("{id}: decode input"))?;
let out = self.cloud_vps_destroy(args).await?;
Ok(serde_json::to_value(out)?)
}
id if id == CloudVpsStatus::ID => {
let args: CloudVpsStatusInput =
serde_json::from_value(input).with_context(|| format!("{id}: decode input"))?;
let out = self.cloud_vps_status(args).await?;
Ok(serde_json::to_value(out)?)
}
other => bail!("hetzner envoy does not support verb {other:?}"),
}
}
}
fn parse_ssh_keys(raw: &[String]) -> Result<Vec<u64>> {
raw.iter()
.map(|s| {
s.parse::<u64>().with_context(|| {
format!("cloud.vps.create: hetzner ssh_keys entry {s:?} is not a numeric key id")
})
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::envoy::cloud_vps::VpsPhase;
use crate::provider::ServerStatus;
#[test]
fn server_status_running_maps_to_running_phase_no_detail() {
let out = server_status_to_output(ServerStatus::Running);
assert_eq!(out.phase, VpsPhase::Running);
assert!(out.detail.is_none());
}
#[test]
fn server_status_unknown_carries_detail() {
let out = server_status_to_output(ServerStatus::Unknown("rebuilding".into()));
assert_eq!(out.phase, VpsPhase::Unknown);
assert_eq!(out.detail.as_deref(), Some("rebuilding"));
}
#[test]
fn parse_ssh_keys_accepts_numeric_strings() {
let parsed = parse_ssh_keys(&["123".into(), "456".into()]).unwrap();
assert_eq!(parsed, vec![123u64, 456u64]);
}
#[test]
fn parse_ssh_keys_rejects_fingerprint_with_named_entry() {
let err = parse_ssh_keys(&["e0:7a:1b".into()]).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("e0:7a:1b"), "{msg}");
}
#[test]
fn parse_ssh_keys_empty_round_trips() {
let parsed = parse_ssh_keys(&[]).unwrap();
assert!(parsed.is_empty());
}
#[test]
fn server_status_all_known_variants_lose_detail() {
for s in [
ServerStatus::Initializing,
ServerStatus::Starting,
ServerStatus::Running,
ServerStatus::Stopping,
ServerStatus::Off,
ServerStatus::Deleting,
] {
let out = server_status_to_output(s);
assert!(
out.detail.is_none(),
"phase {:?} should not carry detail",
out.phase
);
}
}
}