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 }
234 }
235
236 #[tokio::test]
237 async fn ownership_flip_drives_exactly_one_reassign_call() {
238 let provider = FakeProvider {
239 zone: "us-west",
240 attached_to: Mutex::new(Some("old-node".into())),
241 reassign_calls: AtomicU32::new(0),
242 };
243 let outcome = on_ingress_owner_changed(&provider, &machine("new-node"), "ip-1")
244 .await
245 .unwrap();
246 assert!(outcome.reassigned);
247 assert_eq!(outcome.attached_to, "new-node");
248 assert_eq!(provider.reassign_calls.load(Ordering::SeqCst), 1);
249 }
250
251 #[tokio::test]
252 async fn reapplying_the_same_owner_is_a_zero_call_noop() {
253 let provider = FakeProvider {
254 zone: "us-west",
255 attached_to: Mutex::new(Some("new-node".into())),
256 reassign_calls: AtomicU32::new(0),
257 };
258 let outcome = on_ingress_owner_changed(&provider, &machine("new-node"), "ip-1")
259 .await
260 .unwrap();
261 assert!(!outcome.reassigned);
262 assert_eq!(outcome.attached_to, "new-node");
263 assert_eq!(
264 provider.reassign_calls.load(Ordering::SeqCst),
265 0,
266 "idempotent re-apply must not call reassign"
267 );
268 }
269
270 #[tokio::test]
271 async fn never_assigned_ip_gets_a_first_assign_call() {
272 let provider = FakeProvider {
273 zone: "us-west",
274 attached_to: Mutex::new(None),
275 reassign_calls: AtomicU32::new(0),
276 };
277 let outcome = on_ingress_owner_changed(&provider, &machine("new-node"), "ip-1")
278 .await
279 .unwrap();
280 assert!(outcome.reassigned);
281 assert_eq!(provider.reassign_calls.load(Ordering::SeqCst), 1);
282 }
283
284 #[tokio::test]
285 async fn cross_zone_target_is_rejected_before_any_reassign_call() {
286 let provider = FakeProvider {
287 zone: "eu-central",
288 attached_to: Mutex::new(None),
289 reassign_calls: AtomicU32::new(0),
290 };
291 let target = FloatingIpTarget {
292 attach_id: "new-node".into(),
293 zone: "us-west".into(),
294 };
295 let err = reconcile_assignment(&provider, "ip-1", &target)
296 .await
297 .unwrap_err();
298 let msg = format!("{err:#}");
299 assert!(
300 msg.contains("zone"),
301 "expected a zone-mismatch message, got: {msg}"
302 );
303 assert_eq!(
304 provider.reassign_calls.load(Ordering::SeqCst),
305 0,
306 "zone mismatch must never call reassign"
307 );
308 }
309}