Skip to main content

cloud/provider/
hetzner_envoy.rs

1//! [`HetznerEnvoy`] — adapter that exposes the existing [`HetznerDriver`]
2//! through the envoy framework's `cloud.vps.*` verbs (R409-T5).
3//!
4//! This is the spike scope per R409-T11 — only the three verbs from
5//! [`crate::envoy::cloud_vps`] are wired up. The rest of `cloud.*` (object
6//! storage, project, networking, firewalls, load balancers) lands after the
7//! catalog-shape postmortem confirms this shape works against a second
8//! native provider (DigitalOcean — R409-T10).
9//!
10//! Layering: `MachineProvider` (the existing in-crate trait) stays in place
11//! and continues to be the substrate the orchestration code calls into.
12//! `HetznerEnvoy` is a thin translation layer over a [`HetznerDriver`]
13//! that converts wire types ↔ domain types and routes verb-id strings to
14//! typed handler methods. The legacy `MachineProvider` surface retires in
15//! R409-T9 once every caller has migrated to the verb framework.
16
17use 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
31/// Tier-S, native-flavored envoy adapter that bridges the existing
32/// [`HetznerDriver`] to the verb framework.
33pub struct HetznerEnvoy {
34    driver: Arc<HetznerDriver>,
35}
36
37impl HetznerEnvoy {
38    /// Wrap an owned driver.
39    pub fn new(driver: HetznerDriver) -> Self {
40        Self {
41            driver: Arc::new(driver),
42        }
43    }
44
45    /// Wrap a shared driver — useful when the same driver instance is also
46    /// in use behind the legacy `MachineProvider` surface during the
47    /// migration window.
48    pub fn from_arc(driver: Arc<HetznerDriver>) -> Self {
49        Self { driver }
50    }
51
52    /// Typed handler for `cloud.vps.create`. Public so callers that want
53    /// to skip the JSON envelope (e.g. integration tests) can bypass
54    /// [`EnvoyAdapter::dispatch`].
55    ///
56    /// Hetzner has no fingerprint variant for `ssh_keys` (W144 D7), so each
57    /// wire string is parsed as a `u64`. A non-numeric entry is rejected
58    /// before the create call hits the API.
59    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        // W144 D6: there is no per-call project on the wire. Hetzner's
74        // existing project model is token-scoped (one envoy = one project),
75        // so `ensure_project` is the no-op identity; the empty label below
76        // is the convention for "use the envoy's ambient scope."
77        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    /// Typed handler for `cloud.vps.destroy`. Idempotent — the underlying
91    /// driver returns `Ok(())` if the server was already gone.
92    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    /// Typed handler for `cloud.vps.status`. Converts the domain
104    /// [`ServerStatus`] enum (with its free-form `Unknown(String)`) to the
105    /// wire [`VpsPhase`] (closed enum) + optional `detail` string.
106    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
158/// Parse each `ssh_keys` wire string as a Hetzner-numeric key id. Hetzner
159/// has no fingerprint variant (W144 D7), so non-numeric entries are a
160/// caller-side mistake — fail fast with a message that names the bad
161/// element so the caller can fix the input.
162fn 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        // W144 D7: Hetzner doesn't accept fingerprints; the adapter must
201        // surface the offending element so the caller can fix the input.
202        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        // The closed phases never carry detail — only Unknown does.
216        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}