cloud/provider/
hetzner_envoy.rs1use std::sync::Arc;
18
19use anyhow::{bail, Context, Result};
20use async_trait::async_trait;
21use serde_json::Value;
22
23use super::{HetznerDriver, Location, MachineProvider, ServerId, ServerSpec};
24use crate::envoy::cloud_vps::{
25 server_status_to_output, CloudVpsCreate, CloudVpsCreateInput, CloudVpsCreateOutput,
26 CloudVpsDestroy, CloudVpsDestroyInput, CloudVpsDestroyOutput, CloudVpsStatus,
27 CloudVpsStatusInput, CloudVpsStatusOutput,
28};
29use crate::envoy::{AdapterFlavor, EnvoyAdapter, InternalVerb, Tier};
30
31pub struct HetznerEnvoy {
34 driver: Arc<HetznerDriver>,
35}
36
37impl HetznerEnvoy {
38 pub fn new(driver: HetznerDriver) -> Self {
40 Self {
41 driver: Arc::new(driver),
42 }
43 }
44
45 pub fn from_arc(driver: Arc<HetznerDriver>) -> Self {
49 Self { driver }
50 }
51
52 pub async fn cloud_vps_create(
60 &self,
61 input: CloudVpsCreateInput,
62 ) -> Result<CloudVpsCreateOutput> {
63 let location = Location::try_from(input.location.as_str())
64 .with_context(|| format!("cloud.vps.create: unknown location {:?}", input.location))?;
65 let ssh_keys = parse_ssh_keys(&input.ssh_keys)?;
66 let spec = ServerSpec {
67 name: input.name,
68 server_type: input.server_type,
69 image: input.image,
70 location,
71 ssh_keys,
72 };
73 let project = self
78 .driver
79 .ensure_project("")
80 .await
81 .context("cloud.vps.create: ensure_project")?;
82 let id = self
83 .driver
84 .create_server(&project, &spec, &input.user_data)
85 .await
86 .context("cloud.vps.create: create_server")?;
87 Ok(CloudVpsCreateOutput { id: id.0 })
88 }
89
90 pub async fn cloud_vps_destroy(
93 &self,
94 input: CloudVpsDestroyInput,
95 ) -> Result<CloudVpsDestroyOutput> {
96 self.driver
97 .destroy_server(&ServerId(input.id))
98 .await
99 .context("cloud.vps.destroy: destroy_server")?;
100 Ok(CloudVpsDestroyOutput::default())
101 }
102
103 pub async fn cloud_vps_status(
107 &self,
108 input: CloudVpsStatusInput,
109 ) -> Result<CloudVpsStatusOutput> {
110 let status = self
111 .driver
112 .server_status(&ServerId(input.id))
113 .await
114 .context("cloud.vps.status: server_status")?;
115 Ok(server_status_to_output(status))
116 }
117}
118
119#[async_trait]
120impl EnvoyAdapter for HetznerEnvoy {
121 fn id(&self) -> &str {
122 "hetzner"
123 }
124 fn tier(&self) -> Tier {
125 Tier::S
126 }
127 fn flavor(&self) -> AdapterFlavor {
128 AdapterFlavor::Native
129 }
130 fn supported_verb_ids(&self) -> Vec<&'static str> {
131 vec![CloudVpsCreate::ID, CloudVpsDestroy::ID, CloudVpsStatus::ID]
132 }
133 async fn dispatch(&self, verb_id: &str, input: Value) -> Result<Value> {
134 match verb_id {
135 id if id == CloudVpsCreate::ID => {
136 let args: CloudVpsCreateInput =
137 serde_json::from_value(input).with_context(|| format!("{id}: decode input"))?;
138 let out = self.cloud_vps_create(args).await?;
139 Ok(serde_json::to_value(out)?)
140 }
141 id if id == CloudVpsDestroy::ID => {
142 let args: CloudVpsDestroyInput =
143 serde_json::from_value(input).with_context(|| format!("{id}: decode input"))?;
144 let out = self.cloud_vps_destroy(args).await?;
145 Ok(serde_json::to_value(out)?)
146 }
147 id if id == CloudVpsStatus::ID => {
148 let args: CloudVpsStatusInput =
149 serde_json::from_value(input).with_context(|| format!("{id}: decode input"))?;
150 let out = self.cloud_vps_status(args).await?;
151 Ok(serde_json::to_value(out)?)
152 }
153 other => bail!("hetzner envoy does not support verb {other:?}"),
154 }
155 }
156}
157
158fn parse_ssh_keys(raw: &[String]) -> Result<Vec<u64>> {
163 raw.iter()
164 .map(|s| {
165 s.parse::<u64>().with_context(|| {
166 format!("cloud.vps.create: hetzner ssh_keys entry {s:?} is not a numeric key id")
167 })
168 })
169 .collect()
170}
171
172#[cfg(test)]
173mod tests {
174 use super::*;
175 use crate::envoy::cloud_vps::VpsPhase;
176 use crate::provider::ServerStatus;
177
178 #[test]
179 fn server_status_running_maps_to_running_phase_no_detail() {
180 let out = server_status_to_output(ServerStatus::Running);
181 assert_eq!(out.phase, VpsPhase::Running);
182 assert!(out.detail.is_none());
183 }
184
185 #[test]
186 fn server_status_unknown_carries_detail() {
187 let out = server_status_to_output(ServerStatus::Unknown("rebuilding".into()));
188 assert_eq!(out.phase, VpsPhase::Unknown);
189 assert_eq!(out.detail.as_deref(), Some("rebuilding"));
190 }
191
192 #[test]
193 fn parse_ssh_keys_accepts_numeric_strings() {
194 let parsed = parse_ssh_keys(&["123".into(), "456".into()]).unwrap();
195 assert_eq!(parsed, vec![123u64, 456u64]);
196 }
197
198 #[test]
199 fn parse_ssh_keys_rejects_fingerprint_with_named_entry() {
200 let err = parse_ssh_keys(&["e0:7a:1b".into()]).unwrap_err();
203 let msg = format!("{err:#}");
204 assert!(msg.contains("e0:7a:1b"), "{msg}");
205 }
206
207 #[test]
208 fn parse_ssh_keys_empty_round_trips() {
209 let parsed = parse_ssh_keys(&[]).unwrap();
210 assert!(parsed.is_empty());
211 }
212
213 #[test]
214 fn server_status_all_known_variants_lose_detail() {
215 for s in [
217 ServerStatus::Initializing,
218 ServerStatus::Starting,
219 ServerStatus::Running,
220 ServerStatus::Stopping,
221 ServerStatus::Off,
222 ServerStatus::Deleting,
223 ] {
224 let out = server_status_to_output(s);
225 assert!(
226 out.detail.is_none(),
227 "phase {:?} should not carry detail",
228 out.phase
229 );
230 }
231 }
232}