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}