Skip to main content

cloud/provider/
floating_ip_envoy.rs

1//! The `floating_ip.*` envoy layer over `yah-floating-ip-adapters` (R859-F3).
2//!
3//! Three vendor clients used to live in `provider/{hetzner,ovh,vultr}_floating_ip.rs`,
4//! each being two things at once: a `FloatingIpProvider` (transport) and an
5//! [`EnvoyAdapter`] (a dispatchable `floating_ip.assign` / `floating_ip.status`
6//! verb pair). R859-F3 split them along that seam — the transport half is now
7//! `floating_ip_adapters`, which the fleet daemon can link; the envoy half is
8//! this module, which it cannot and should not.
9//!
10//! # The three copies became one
11//!
12//! Each adapter carried a byte-identical `floating_ip_assign` /
13//! `floating_ip_status` pair and a byte-identical `dispatch` body — three
14//! copies of code that only ever differed in the error string. They are
15//! [`FloatingIpEnvoy`] and [`dispatch_floating_ip_verb`] here, written once
16//! over `dyn FloatingIpProvider`, which is why moving the transports out was a
17//! simplification rather than a shuffle.
18//!
19//! [`FloatingIpEnvoy`] is a *trait* rather than three inherent method pairs
20//! specifically because the vendor types are now foreign: Rust forbids an
21//! inherent impl on a foreign type, but a local trait on a foreign type is
22//! exactly what the orphan rule permits. Same reason the three [`EnvoyAdapter`]
23//! impls below are legal.
24//!
25//! # What did NOT collapse, and why
26//!
27//! The three [`EnvoyAdapter`] impls are written out rather than blanketed over
28//! `T: FloatingIpProvider`, for two reasons that are both about the envoy layer
29//! and not about the transports: the tiers genuinely differ (Hetzner and Vultr
30//! are `Tier::S`, OVH is `Tier::A` because its auth is a placeholder — see the
31//! `ovh` adapter's module doc), and a blanket [`EnvoyAdapter`] impl would claim
32//! every present and future `FloatingIpProvider` implementor, including a test
33//! double, as a dispatchable envoy adapter. Three six-line impls are cheaper
34//! than that coherence surface.
35
36use anyhow::{bail, Context, Result};
37use async_trait::async_trait;
38use serde_json::Value;
39
40use crate::envoy::floating_ip::{
41    FloatingIpAssign, FloatingIpAssignInput, FloatingIpAssignOutput, FloatingIpStatus,
42    FloatingIpStatusInput, FloatingIpStatusOutput,
43};
44use crate::envoy::{AdapterFlavor, EnvoyAdapter, InternalVerb, Tier};
45use floating_ip::{FloatingIpProvider, FloatingIpTarget};
46use floating_ip_adapters::{HetznerFloatingIp, OvhFloatingIp, VultrFloatingIp};
47
48/// The typed (non-JSON) `floating_ip.*` handlers, for callers and tests that
49/// want to bypass the envelope.
50///
51/// Blanket-implemented for every [`FloatingIpProvider`], because the two
52/// handlers are pure translation between the wire types and the seam — there
53/// has never been a per-vendor difference in them, and the three copies that
54/// preceded this were identical byte for byte.
55#[async_trait]
56pub trait FloatingIpEnvoy {
57    /// `floating_ip.assign` — move the IP, idempotently and zone-checked.
58    async fn floating_ip_assign(
59        &self,
60        input: FloatingIpAssignInput,
61    ) -> Result<FloatingIpAssignOutput>;
62
63    /// `floating_ip.status` — report where the IP lives today.
64    async fn floating_ip_status(
65        &self,
66        input: FloatingIpStatusInput,
67    ) -> Result<FloatingIpStatusOutput>;
68}
69
70#[async_trait]
71impl<T: FloatingIpProvider + ?Sized> FloatingIpEnvoy for T {
72    async fn floating_ip_assign(
73        &self,
74        input: FloatingIpAssignInput,
75    ) -> Result<FloatingIpAssignOutput> {
76        let target = FloatingIpTarget {
77            attach_id: input.attach_id,
78            zone: input.zone,
79        };
80        // The idempotency short-circuit and the cross-zone refusal are here,
81        // once, for all three vendors — see `floating_ip::reconcile_assignment`.
82        let outcome = floating_ip::reconcile_assignment(self, &input.ip_id, &target).await?;
83        Ok(FloatingIpAssignOutput {
84            reassigned: outcome.reassigned,
85            attached_to: outcome.attached_to,
86        })
87    }
88
89    async fn floating_ip_status(
90        &self,
91        input: FloatingIpStatusInput,
92    ) -> Result<FloatingIpStatusOutput> {
93        let state = self.current_assignment(&input.ip_id).await?;
94        Ok(FloatingIpStatusOutput {
95            zone: state.zone,
96            attached_to: state.attached_to,
97        })
98    }
99}
100
101/// The `EnvoyAdapter::dispatch` body every `floating_ip.*` adapter shares.
102///
103/// Takes the provider by `&dyn` so the three impls below are a delegation each
104/// rather than a copy each; the unsupported-verb refusal names the provider
105/// from its own [`FloatingIpProvider::id`], so it stays correct for a fourth
106/// vendor without anybody remembering to edit a string literal.
107pub async fn dispatch_floating_ip_verb(
108    provider: &dyn FloatingIpProvider,
109    verb_id: &str,
110    input: Value,
111) -> Result<Value> {
112    match verb_id {
113        id if id == FloatingIpAssign::ID => {
114            let args: FloatingIpAssignInput =
115                serde_json::from_value(input).with_context(|| format!("{id}: decode input"))?;
116            let out = provider.floating_ip_assign(args).await?;
117            Ok(serde_json::to_value(out)?)
118        }
119        id if id == FloatingIpStatus::ID => {
120            let args: FloatingIpStatusInput =
121                serde_json::from_value(input).with_context(|| format!("{id}: decode input"))?;
122            let out = provider.floating_ip_status(args).await?;
123            Ok(serde_json::to_value(out)?)
124        }
125        other => bail!(
126            "{} floating-ip envoy does not support verb {other:?}",
127            provider.id()
128        ),
129    }
130}
131
132/// The two verbs every `floating_ip.*` adapter claims. One list, so a third
133/// verb cannot land on two of the three adapters.
134const FLOATING_IP_VERBS: [&str; 2] = [FloatingIpAssign::ID, FloatingIpStatus::ID];
135
136#[async_trait]
137impl EnvoyAdapter for HetznerFloatingIp {
138    fn id(&self) -> &str {
139        FloatingIpProvider::id(self)
140    }
141    fn tier(&self) -> Tier {
142        Tier::S
143    }
144    fn flavor(&self) -> AdapterFlavor {
145        AdapterFlavor::Native
146    }
147    fn supported_verb_ids(&self) -> Vec<&'static str> {
148        FLOATING_IP_VERBS.to_vec()
149    }
150    async fn dispatch(&self, verb_id: &str, input: Value) -> Result<Value> {
151        dispatch_floating_ip_verb(self, verb_id, input).await
152    }
153}
154
155#[async_trait]
156impl EnvoyAdapter for OvhFloatingIp {
157    fn id(&self) -> &str {
158        FloatingIpProvider::id(self)
159    }
160    /// `A`, not `S`, and deliberately: the adapter's OVH auth is a placeholder
161    /// (bare `X-Ovh-Consumer` header, not OVH's timestamped HMAC), so the verb
162    /// is dispatchable but not live-ready. See `floating_ip_adapters::ovh`.
163    fn tier(&self) -> Tier {
164        Tier::A
165    }
166    fn flavor(&self) -> AdapterFlavor {
167        AdapterFlavor::Native
168    }
169    fn supported_verb_ids(&self) -> Vec<&'static str> {
170        FLOATING_IP_VERBS.to_vec()
171    }
172    async fn dispatch(&self, verb_id: &str, input: Value) -> Result<Value> {
173        dispatch_floating_ip_verb(self, verb_id, input).await
174    }
175}
176
177#[async_trait]
178impl EnvoyAdapter for VultrFloatingIp {
179    fn id(&self) -> &str {
180        FloatingIpProvider::id(self)
181    }
182    fn tier(&self) -> Tier {
183        Tier::S
184    }
185    fn flavor(&self) -> AdapterFlavor {
186        AdapterFlavor::Native
187    }
188    fn supported_verb_ids(&self) -> Vec<&'static str> {
189        FLOATING_IP_VERBS.to_vec()
190    }
191    async fn dispatch(&self, verb_id: &str, input: Value) -> Result<Value> {
192        dispatch_floating_ip_verb(self, verb_id, input).await
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    /// Each adapter's envoy id must equal its provider id, or
201    /// `floating_ip_provider_for` and `default_adapters()` disagree about which
202    /// box a verb reaches — and the disagreement is invisible until a failover.
203    #[test]
204    fn the_envoy_id_is_the_provider_id_for_every_adapter() {
205        let cases: [(&dyn EnvoyAdapter, &str); 3] = [
206            (&HetznerFloatingIp::new("t"), "hetzner"),
207            (&OvhFloatingIp::new("t"), "ovh"),
208            (&VultrFloatingIp::new("t"), "vultr"),
209        ];
210        for (adapter, expected) in cases {
211            assert_eq!(EnvoyAdapter::id(adapter), expected);
212            assert_eq!(
213                adapter.supported_verb_ids(),
214                vec!["floating_ip.assign", "floating_ip.status"]
215            );
216        }
217    }
218
219    /// The shared dispatch refuses an unknown verb by naming the provider it
220    /// was asked of — the three per-vendor copies this replaced each hardcoded
221    /// that name, which is the drift the collapse removes.
222    #[tokio::test]
223    async fn an_unknown_verb_is_refused_and_names_the_provider() {
224        let err = dispatch_floating_ip_verb(
225            &VultrFloatingIp::new("t"),
226            "floating_ip.detach",
227            serde_json::json!({}),
228        )
229        .await
230        .unwrap_err();
231        let msg = format!("{err:#}");
232        assert!(msg.contains("vultr"), "{msg}");
233        assert!(msg.contains("floating_ip.detach"), "{msg}");
234    }
235}