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//!
29//! @yah:ticket(R859-F2, "Wire floating-ip.* provider adapters to ingress_owner transitions + health-checked DNS withdrawal for dead origins")
30//! @yah:at(2026-09-04T19:06:57Z)
31//! @yah:status(open)
32//! @yah:assignee(agent:user-custom-char-gul2)
33//! @yah:parent(R859)
34//! @yah:next("The verbs and adapters exist with zero callers: envoy/floating_ip.rs + provider/{vultr,hetzner,ovh}_floating_ip.rs are dead code today. Raft already holds and applies ingress_owner (raft/mod.rs:597,1182) — the missing piece is the effector that commands the provider when it changes, which is exactly W267 Tier 1's 'external identity follows placement' (the R591 property).")
35//! @yah:next("Two failover speeds, both currently manual: intra-provider = floating-IP reassign (seconds, no DNS propagation, no cert re-mint — mind the W267-verified mobility constraints: Hetzner per network zone, OVH per DC region, Vultr region-bound); cross-provider = short-TTL DNS withdrawal of the dead origin's A record (needs R859-F1's rendering).")
36//! @yah:next("The health signal for withdrawal must NOT come from raft health (W267 §'Where liveness lives' — reachability is observer-relative); use the supervisor-level fact only: a machine leaving the fleet / its yubaba unreachable from quorum, not a per-proxy probe.")
37//! @yah:next("cloud.mesh_failover (W271) is the existing manual verb — keep it as the operator path; this ticket automates the effector both paths share.")
38//! @yah:next("Tier: Wizard — touches live-fleet failover semantics; wrong wiring here turns a leadership flap into a public outage. Design the guard rails (hysteresis, refuse-on-degraded-quorum per yubaba-failover.md) before the effector.")
39
40use anyhow::{bail, Result};
41use async_trait::async_trait;
42
43use crate::config::MachineConfig;
44
45/// One provider's floating/reserved-IP transport + mobility policy.
46///
47/// Implementors: [`super::hetzner_floating_ip::HetznerFloatingIp`],
48/// [`super::ovh_floating_ip::OvhFloatingIp`],
49/// [`super::vultr_floating_ip::VultrFloatingIp`].
50#[async_trait]
51pub trait FloatingIpProvider: Send + Sync {
52    /// Provider id, e.g. `"hetzner"` — matches [`MachineConfig::provider`].
53    fn id(&self) -> &'static str;
54
55    /// Resolve a target machine into this provider's native attach
56    /// identifier (server id / serviceName / instance UUID) plus the
57    /// mobility zone it lives in. May hit the provider's API (e.g. a
58    /// name→id lookup) — this is a live-data resolution step, not a pure
59    /// function of the TOML.
60    async fn resolve_target(&self, machine: &MachineConfig) -> Result<FloatingIpTarget>;
61
62    /// Current state of the floating/reserved IP: its home zone (fixed for
63    /// the IP's lifetime) and the provider-native id of whatever it's
64    /// attached to right now, if anything.
65    async fn current_assignment(&self, ip_id: &str) -> Result<FloatingIpState>;
66
67    /// Actually move the IP. Callers (namely [`reconcile_assignment`])
68    /// have already checked idempotency and zone match before calling
69    /// this — it always issues the provider call.
70    async fn reassign(&self, ip_id: &str, target: &FloatingIpTarget) -> Result<()>;
71}
72
73/// A resolved reassign target: provider-native attach id + the mobility
74/// zone it lives in.
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct FloatingIpTarget {
77    /// Hetzner numeric server id, OVH serviceName, or Vultr instance UUID.
78    pub attach_id: String,
79    /// Hetzner network zone / OVH datacentre-region / Vultr region.
80    pub zone: String,
81}
82
83/// Current provider-side state of a floating/reserved IP.
84#[derive(Debug, Clone, PartialEq, Eq)]
85pub struct FloatingIpState {
86    /// The IP's home mobility zone — fixed for its lifetime.
87    pub zone: String,
88    /// Provider-native id of whatever it's attached to right now, if
89    /// anything.
90    pub attached_to: Option<String>,
91}
92
93/// Outcome of [`reconcile_assignment`] / [`on_ingress_owner_changed`].
94#[derive(Debug, Clone, PartialEq, Eq)]
95pub struct FloatingIpAssignOutcome {
96    /// `true` iff a reassign call was actually issued.
97    pub reassigned: bool,
98    /// The attach target the IP now points at.
99    pub attached_to: String,
100}
101
102/// Idempotent, zone-checked core shared by every provider adapter and by
103/// [`on_ingress_owner_changed`].
104///
105/// 1. Fetch the floating IP's current home zone + attachment.
106/// 2. Refuse a cross-zone move (Hetzner/OVH/Vultr all physically cannot
107///    move an IP outside its mobility zone — W267 §Tier 1) *before*
108///    issuing any reassign call.
109/// 3. If the current attachment already equals `target`, return
110///    `reassigned: false` without calling [`FloatingIpProvider::reassign`]
111///    — the ownership-flip fixture this ticket verifies against relies on
112///    this short-circuit to prove "re-applying the same owner drives ZERO
113///    reassign calls."
114/// 4. Otherwise call [`FloatingIpProvider::reassign`] and report
115///    `reassigned: true`.
116pub async fn reconcile_assignment(
117    provider: &dyn FloatingIpProvider,
118    ip_id: &str,
119    target: &FloatingIpTarget,
120) -> Result<FloatingIpAssignOutcome> {
121    let current = provider.current_assignment(ip_id).await?;
122    if current.zone != target.zone {
123        bail!(
124            "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)",
125            provider.id(),
126            current.zone,
127            target.zone,
128            target.attach_id,
129            provider.id(),
130        );
131    }
132    if current.attached_to.as_deref() == Some(target.attach_id.as_str()) {
133        return Ok(FloatingIpAssignOutcome {
134            reassigned: false,
135            attached_to: target.attach_id.clone(),
136        });
137    }
138    provider.reassign(ip_id, target).await?;
139    Ok(FloatingIpAssignOutcome {
140        reassigned: true,
141        attached_to: target.attach_id.clone(),
142    })
143}
144
145/// Callable entry point: react to the raft `ingress_owner` seam naming
146/// `machine` as the box that now owns public ingress, by commanding
147/// `ip_id` to follow it.
148///
149/// **Wiring (not done here — deliberately out of scope, see the ticket's
150/// hard constraints):** the raft apply loop
151/// (`oss/yubaba/crates/yubaba/src/raft/mod.rs::apply`) already mutates
152/// `RaftAppState::ingress_owner` on `YubabaRequest::SetIngressOwner` /
153/// `ClearIngressOwner`. A follow-up ticket should call this function from
154/// the leadership/reconcile path (`oss/yubaba/crates/yubaba/src/leader.rs`
155/// — peer-owned, not touched here) at the point where it observes
156/// `ingress_owner` transition from `old` to `Some(new_machine)`: look up
157/// `new_machine`'s [`MachineConfig`] (already available there via
158/// `WorkspaceConfig`), pick the [`FloatingIpProvider`] matching
159/// `machine.provider`, and call
160/// `on_ingress_owner_changed(provider, &machine, ip_id).await`. `ip_id`
161/// itself (which floating IP is "the" ingress IP) has no home today —
162/// that's a small config surface (likely a field alongside the
163/// `public-ip` taint R572-F3 is adding) a follow-up should introduce
164/// alongside the wiring, not invented speculatively here.
165///
166/// `ClearIngressOwner` (`ingress_owner` going to `None`) has no defined
167/// action yet — there is no "detach the IP" verb because Tier 1 has no
168/// specified safe-unassigned state (leaving the IP on the last-known-good
169/// node is arguably the correct default). Extend when that need
170/// materializes; until then this function is only meaningful for
171/// `Some(machine)` transitions.
172pub async fn on_ingress_owner_changed(
173    provider: &dyn FloatingIpProvider,
174    machine: &MachineConfig,
175    ip_id: &str,
176) -> Result<FloatingIpAssignOutcome> {
177    let target = provider.resolve_target(machine).await?;
178    reconcile_assignment(provider, ip_id, &target).await
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184    use std::sync::atomic::{AtomicU32, Ordering};
185    use std::sync::Mutex;
186
187    /// A fake, network-free [`FloatingIpProvider`] — proves
188    /// [`reconcile_assignment`]'s idempotency + zone-mismatch-reject logic
189    /// in isolation from any vendor wire format (the per-provider mock-HTTP
190    /// tests in `hetzner_floating_ip.rs` / `ovh_floating_ip.rs` /
191    /// `vultr_floating_ip.rs` cover the wire-level shape).
192    struct FakeProvider {
193        zone: &'static str,
194        attached_to: Mutex<Option<String>>,
195        reassign_calls: AtomicU32,
196    }
197
198    #[async_trait]
199    impl FloatingIpProvider for FakeProvider {
200        fn id(&self) -> &'static str {
201            "fake"
202        }
203        async fn resolve_target(&self, machine: &MachineConfig) -> Result<FloatingIpTarget> {
204            Ok(FloatingIpTarget {
205                attach_id: machine.name.clone(),
206                zone: self.zone.to_string(),
207            })
208        }
209        async fn current_assignment(&self, _ip_id: &str) -> Result<FloatingIpState> {
210            Ok(FloatingIpState {
211                zone: self.zone.to_string(),
212                attached_to: self.attached_to.lock().unwrap().clone(),
213            })
214        }
215        async fn reassign(&self, _ip_id: &str, target: &FloatingIpTarget) -> Result<()> {
216            self.reassign_calls.fetch_add(1, Ordering::SeqCst);
217            *self.attached_to.lock().unwrap() = Some(target.attach_id.clone());
218            Ok(())
219        }
220    }
221
222    fn machine(name: &str) -> MachineConfig {
223        MachineConfig {
224            name: name.into(),
225            provider: "fake".into(),
226            location: None,
227            server_type: None,
228            hosts_mirrors: vec![],
229            mesh_tags: vec![],
230            region: None,
231            zone: None,
232            arch: None,
233            bucket: None,
234            vendor: None,
235            nickname: None,
236            legacy_hostkey_fingerprint: None,
237            registration: Default::default(),
238            ssh_keys: vec![],
239            cloudflared: None,
240            hosts_operator_bridge: false,
241            connect: None,
242            allocatable: None,
243            taints: vec![],
244            sovereign_group: None,
245            sovereign_role: None,
246        }
247    }
248
249    #[tokio::test]
250    async fn ownership_flip_drives_exactly_one_reassign_call() {
251        let provider = FakeProvider {
252            zone: "us-west",
253            attached_to: Mutex::new(Some("old-node".into())),
254            reassign_calls: AtomicU32::new(0),
255        };
256        let outcome = on_ingress_owner_changed(&provider, &machine("new-node"), "ip-1")
257            .await
258            .unwrap();
259        assert!(outcome.reassigned);
260        assert_eq!(outcome.attached_to, "new-node");
261        assert_eq!(provider.reassign_calls.load(Ordering::SeqCst), 1);
262    }
263
264    #[tokio::test]
265    async fn reapplying_the_same_owner_is_a_zero_call_noop() {
266        let provider = FakeProvider {
267            zone: "us-west",
268            attached_to: Mutex::new(Some("new-node".into())),
269            reassign_calls: AtomicU32::new(0),
270        };
271        let outcome = on_ingress_owner_changed(&provider, &machine("new-node"), "ip-1")
272            .await
273            .unwrap();
274        assert!(!outcome.reassigned);
275        assert_eq!(outcome.attached_to, "new-node");
276        assert_eq!(
277            provider.reassign_calls.load(Ordering::SeqCst),
278            0,
279            "idempotent re-apply must not call reassign"
280        );
281    }
282
283    #[tokio::test]
284    async fn never_assigned_ip_gets_a_first_assign_call() {
285        let provider = FakeProvider {
286            zone: "us-west",
287            attached_to: Mutex::new(None),
288            reassign_calls: AtomicU32::new(0),
289        };
290        let outcome = on_ingress_owner_changed(&provider, &machine("new-node"), "ip-1")
291            .await
292            .unwrap();
293        assert!(outcome.reassigned);
294        assert_eq!(provider.reassign_calls.load(Ordering::SeqCst), 1);
295    }
296
297    #[tokio::test]
298    async fn cross_zone_target_is_rejected_before_any_reassign_call() {
299        let provider = FakeProvider {
300            zone: "eu-central",
301            attached_to: Mutex::new(None),
302            reassign_calls: AtomicU32::new(0),
303        };
304        let target = FloatingIpTarget {
305            attach_id: "new-node".into(),
306            zone: "us-west".into(),
307        };
308        let err = reconcile_assignment(&provider, "ip-1", &target)
309            .await
310            .unwrap_err();
311        let msg = format!("{err:#}");
312        assert!(
313            msg.contains("zone"),
314            "expected a zone-mismatch message, got: {msg}"
315        );
316        assert_eq!(
317            provider.reassign_calls.load(Ordering::SeqCst),
318            0,
319            "zone mismatch must never call reassign"
320        );
321    }
322}