Skip to main content

cloud/provider/
floating_ip.rs

1//! Provider-abstracted floating/reserved-IP mobility (R594-F5).
2//!
3//! [`FloatingIpProvider`] is the domain-level trait each vendor adapter
4//! (`HetznerFloatingIp`, `OvhFloatingIp`, `VultrFloatingIp` — sibling
5//! modules in this directory) implements. [`reconcile_assignment`] is the
6//! shared idempotent + zone-checked core all three run through, so the
7//! "no-op when already assigned" / "reject a cross-zone move" behavior is
8//! written and tested exactly once instead of three times.
9//!
10//! [`on_ingress_owner_changed`] is the Rust-level callable entry point for
11//! R594-F5's ask: given the raft `ingress_owner` seam
12//! (`oss/yubaba/crates/yubaba/src/raft/mod.rs`'s `YubabaRequest::SetIngressOwner`
13//! / `ClearIngressOwner`, `RaftAppState::ingress_owner`) and the machine it
14//! currently names, command the provider floating IP to follow. Wiring
15//! this to fire *automatically* whenever `ingress_owner` transitions lives
16//! in the raft-apply / leadership-reconcile path
17//! (`oss/yubaba/crates/yubaba/src/leader.rs`), which is peer-owned and
18//! off-limits to this ticket — see [`on_ingress_owner_changed`]'s doc
19//! comment for the exact call site a follow-up should add. This is the
20//! same "mechanism now, wiring later" shape R594-F3 used for service
21//! records.
22//!
23//! This mirrors, at the sovereign-ingress tier, the "external identity
24//! follows placement" property [R591](yah://arch/symbol/R591) names for
25//! Headscale via a Cloudflare Tunnel. R591 is peer-owned and gated on R570
26//! (real multi-node raft HA); this module is not blocked on either — it
27//! builds directly on the `ingress_owner` seam, which already exists.
28
29use anyhow::{bail, Result};
30use async_trait::async_trait;
31
32use crate::config::MachineConfig;
33
34/// One provider's floating/reserved-IP transport + mobility policy.
35///
36/// Implementors: [`super::hetzner_floating_ip::HetznerFloatingIp`],
37/// [`super::ovh_floating_ip::OvhFloatingIp`],
38/// [`super::vultr_floating_ip::VultrFloatingIp`].
39#[async_trait]
40pub trait FloatingIpProvider: Send + Sync {
41    /// Provider id, e.g. `"hetzner"` — matches [`MachineConfig::provider`].
42    fn id(&self) -> &'static str;
43
44    /// Resolve a target machine into this provider's native attach
45    /// identifier (server id / serviceName / instance UUID) plus the
46    /// mobility zone it lives in. May hit the provider's API (e.g. a
47    /// name→id lookup) — this is a live-data resolution step, not a pure
48    /// function of the TOML.
49    async fn resolve_target(&self, machine: &MachineConfig) -> Result<FloatingIpTarget>;
50
51    /// Current state of the floating/reserved IP: its home zone (fixed for
52    /// the IP's lifetime) and the provider-native id of whatever it's
53    /// attached to right now, if anything.
54    async fn current_assignment(&self, ip_id: &str) -> Result<FloatingIpState>;
55
56    /// Actually move the IP. Callers (namely [`reconcile_assignment`])
57    /// have already checked idempotency and zone match before calling
58    /// this — it always issues the provider call.
59    async fn reassign(&self, ip_id: &str, target: &FloatingIpTarget) -> Result<()>;
60}
61
62/// A resolved reassign target: provider-native attach id + the mobility
63/// zone it lives in.
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct FloatingIpTarget {
66    /// Hetzner numeric server id, OVH serviceName, or Vultr instance UUID.
67    pub attach_id: String,
68    /// Hetzner network zone / OVH datacentre-region / Vultr region.
69    pub zone: String,
70}
71
72/// Current provider-side state of a floating/reserved IP.
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct FloatingIpState {
75    /// The IP's home mobility zone — fixed for its lifetime.
76    pub zone: String,
77    /// Provider-native id of whatever it's attached to right now, if
78    /// anything.
79    pub attached_to: Option<String>,
80}
81
82/// Outcome of [`reconcile_assignment`] / [`on_ingress_owner_changed`].
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct FloatingIpAssignOutcome {
85    /// `true` iff a reassign call was actually issued.
86    pub reassigned: bool,
87    /// The attach target the IP now points at.
88    pub attached_to: String,
89}
90
91/// Idempotent, zone-checked core shared by every provider adapter and by
92/// [`on_ingress_owner_changed`].
93///
94/// 1. Fetch the floating IP's current home zone + attachment.
95/// 2. Refuse a cross-zone move (Hetzner/OVH/Vultr all physically cannot
96///    move an IP outside its mobility zone — W267 §Tier 1) *before*
97///    issuing any reassign call.
98/// 3. If the current attachment already equals `target`, return
99///    `reassigned: false` without calling [`FloatingIpProvider::reassign`]
100///    — the ownership-flip fixture this ticket verifies against relies on
101///    this short-circuit to prove "re-applying the same owner drives ZERO
102///    reassign calls."
103/// 4. Otherwise call [`FloatingIpProvider::reassign`] and report
104///    `reassigned: true`.
105pub async fn reconcile_assignment(
106    provider: &dyn FloatingIpProvider,
107    ip_id: &str,
108    target: &FloatingIpTarget,
109) -> Result<FloatingIpAssignOutcome> {
110    let current = provider.current_assignment(ip_id).await?;
111    if current.zone != target.zone {
112        bail!(
113            "floating_ip.assign: {} ip {ip_id:?} is homed to zone {:?}, cannot move it into zone {:?} (target attach id {:?}) — {} floating/reserved IPs are not mobile across zones (W267 §Tier 1)",
114            provider.id(),
115            current.zone,
116            target.zone,
117            target.attach_id,
118            provider.id(),
119        );
120    }
121    if current.attached_to.as_deref() == Some(target.attach_id.as_str()) {
122        return Ok(FloatingIpAssignOutcome {
123            reassigned: false,
124            attached_to: target.attach_id.clone(),
125        });
126    }
127    provider.reassign(ip_id, target).await?;
128    Ok(FloatingIpAssignOutcome {
129        reassigned: true,
130        attached_to: target.attach_id.clone(),
131    })
132}
133
134/// Callable entry point: react to the raft `ingress_owner` seam naming
135/// `machine` as the box that now owns public ingress, by commanding
136/// `ip_id` to follow it.
137///
138/// **Wiring (not done here — deliberately out of scope, see the ticket's
139/// hard constraints):** the raft apply loop
140/// (`oss/yubaba/crates/yubaba/src/raft/mod.rs::apply`) already mutates
141/// `RaftAppState::ingress_owner` on `YubabaRequest::SetIngressOwner` /
142/// `ClearIngressOwner`. A follow-up ticket should call this function from
143/// the leadership/reconcile path (`oss/yubaba/crates/yubaba/src/leader.rs`
144/// — peer-owned, not touched here) at the point where it observes
145/// `ingress_owner` transition from `old` to `Some(new_machine)`: look up
146/// `new_machine`'s [`MachineConfig`] (already available there via
147/// `WorkspaceConfig`), pick the [`FloatingIpProvider`] matching
148/// `machine.provider`, and call
149/// `on_ingress_owner_changed(provider, &machine, ip_id).await`. `ip_id`
150/// itself (which floating IP is "the" ingress IP) has no home today —
151/// that's a small config surface (likely a field alongside the
152/// `public-ip` taint R572-F3 is adding) a follow-up should introduce
153/// alongside the wiring, not invented speculatively here.
154///
155/// `ClearIngressOwner` (`ingress_owner` going to `None`) has no defined
156/// action yet — there is no "detach the IP" verb because Tier 1 has no
157/// specified safe-unassigned state (leaving the IP on the last-known-good
158/// node is arguably the correct default). Extend when that need
159/// materializes; until then this function is only meaningful for
160/// `Some(machine)` transitions.
161pub async fn on_ingress_owner_changed(
162    provider: &dyn FloatingIpProvider,
163    machine: &MachineConfig,
164    ip_id: &str,
165) -> Result<FloatingIpAssignOutcome> {
166    let target = provider.resolve_target(machine).await?;
167    reconcile_assignment(provider, ip_id, &target).await
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173    use std::sync::atomic::{AtomicU32, Ordering};
174    use std::sync::Mutex;
175
176    /// A fake, network-free [`FloatingIpProvider`] — proves
177    /// [`reconcile_assignment`]'s idempotency + zone-mismatch-reject logic
178    /// in isolation from any vendor wire format (the per-provider mock-HTTP
179    /// tests in `hetzner_floating_ip.rs` / `ovh_floating_ip.rs` /
180    /// `vultr_floating_ip.rs` cover the wire-level shape).
181    struct FakeProvider {
182        zone: &'static str,
183        attached_to: Mutex<Option<String>>,
184        reassign_calls: AtomicU32,
185    }
186
187    #[async_trait]
188    impl FloatingIpProvider for FakeProvider {
189        fn id(&self) -> &'static str {
190            "fake"
191        }
192        async fn resolve_target(&self, machine: &MachineConfig) -> Result<FloatingIpTarget> {
193            Ok(FloatingIpTarget {
194                attach_id: machine.name.clone(),
195                zone: self.zone.to_string(),
196            })
197        }
198        async fn current_assignment(&self, _ip_id: &str) -> Result<FloatingIpState> {
199            Ok(FloatingIpState {
200                zone: self.zone.to_string(),
201                attached_to: self.attached_to.lock().unwrap().clone(),
202            })
203        }
204        async fn reassign(&self, _ip_id: &str, target: &FloatingIpTarget) -> Result<()> {
205            self.reassign_calls.fetch_add(1, Ordering::SeqCst);
206            *self.attached_to.lock().unwrap() = Some(target.attach_id.clone());
207            Ok(())
208        }
209    }
210
211    fn machine(name: &str) -> MachineConfig {
212        MachineConfig {
213            name: name.into(),
214            provider: "fake".into(),
215            location: None,
216            server_type: None,
217            hosts_mirrors: vec![],
218            mesh_tags: vec![],
219            region: None,
220            zone: None,
221            arch: None,
222            bucket: None,
223            vendor: None,
224            nickname: None,
225            legacy_hostkey_fingerprint: None,
226            registration: Default::default(),
227            ssh_keys: vec![],
228            cloudflared: None,
229            hosts_operator_bridge: false,
230            connect: None,
231            allocatable: None,
232            taints: vec![],
233            sovereign_group: None,
234            sovereign_role: None,
235        }
236    }
237
238    #[tokio::test]
239    async fn ownership_flip_drives_exactly_one_reassign_call() {
240        let provider = FakeProvider {
241            zone: "us-west",
242            attached_to: Mutex::new(Some("old-node".into())),
243            reassign_calls: AtomicU32::new(0),
244        };
245        let outcome = on_ingress_owner_changed(&provider, &machine("new-node"), "ip-1")
246            .await
247            .unwrap();
248        assert!(outcome.reassigned);
249        assert_eq!(outcome.attached_to, "new-node");
250        assert_eq!(provider.reassign_calls.load(Ordering::SeqCst), 1);
251    }
252
253    #[tokio::test]
254    async fn reapplying_the_same_owner_is_a_zero_call_noop() {
255        let provider = FakeProvider {
256            zone: "us-west",
257            attached_to: Mutex::new(Some("new-node".into())),
258            reassign_calls: AtomicU32::new(0),
259        };
260        let outcome = on_ingress_owner_changed(&provider, &machine("new-node"), "ip-1")
261            .await
262            .unwrap();
263        assert!(!outcome.reassigned);
264        assert_eq!(outcome.attached_to, "new-node");
265        assert_eq!(
266            provider.reassign_calls.load(Ordering::SeqCst),
267            0,
268            "idempotent re-apply must not call reassign"
269        );
270    }
271
272    #[tokio::test]
273    async fn never_assigned_ip_gets_a_first_assign_call() {
274        let provider = FakeProvider {
275            zone: "us-west",
276            attached_to: Mutex::new(None),
277            reassign_calls: AtomicU32::new(0),
278        };
279        let outcome = on_ingress_owner_changed(&provider, &machine("new-node"), "ip-1")
280            .await
281            .unwrap();
282        assert!(outcome.reassigned);
283        assert_eq!(provider.reassign_calls.load(Ordering::SeqCst), 1);
284    }
285
286    #[tokio::test]
287    async fn cross_zone_target_is_rejected_before_any_reassign_call() {
288        let provider = FakeProvider {
289            zone: "eu-central",
290            attached_to: Mutex::new(None),
291            reassign_calls: AtomicU32::new(0),
292        };
293        let target = FloatingIpTarget {
294            attach_id: "new-node".into(),
295            zone: "us-west".into(),
296        };
297        let err = reconcile_assignment(&provider, "ip-1", &target)
298            .await
299            .unwrap_err();
300        let msg = format!("{err:#}");
301        assert!(
302            msg.contains("zone"),
303            "expected a zone-mismatch message, got: {msg}"
304        );
305        assert_eq!(
306            provider.reassign_calls.load(Ordering::SeqCst),
307            0,
308            "zone mismatch must never call reassign"
309        );
310    }
311}