floating_ip/lib.rs
1//! Provider-abstracted floating/reserved-IP mobility and ingress failover
2//! (R594-F5, R859-F2).
3//!
4//! [`FloatingIpProvider`] is the domain-level trait each vendor adapter
5//! implements; [`reconcile_assignment`] is the shared idempotent + zone-checked
6//! core all of them run through, so the "no-op when already assigned" /
7//! "reject a cross-zone move" behaviour is written and tested exactly once
8//! instead of once per vendor. [`plan_ingress_owner_effect`] is the pure
9//! decision layer above them: given a raft `ingress_owner` observation, what
10//! should public ingress do?
11//!
12//! # Why this is its own crate
13//!
14//! It was `cloud::provider::floating_ip` until 2026-09-08. R859-F2 landed
15//! [`plan_ingress_owner_effect`] with no production caller, because the caller
16//! belongs in `yubaba`'s scheduler tick and a runtime `yubaba -> cloud`
17//! dependency was not a courier's call to make: `cloud/Cargo.toml`'s
18//! `yah-local-driver` comment records R374-F3 carving *that* crate out of
19//! `cloud` specifically to avoid such an edge, and taking it here would pull
20//! velveteen, velveteen-exec, yah-hetzner, yah-mesofact-bundle and yah-almanac
21//! into the release daemon shipped to every fleet node. The operator's answer
22//! (2026-09-08) was to repeat R374-F3's move rather than reverse it — hence
23//! this crate, which both `cloud` and `yubaba` depend on and neither owns.
24//!
25//! So the dependency budget here is load-bearing, not tidiness: `anyhow` and
26//! `async-trait`, and nothing else, forever. A `reqwest` or a `serde` in this
27//! manifest ships to every node in the fleet.
28//!
29//! # What is above this crate, and where
30//!
31//! Two consumers, and this crate depends on neither:
32//!
33//! - `yah-floating-ip-adapters` — the Hetzner/OVH/Vultr HTTP clients that
34//! implement [`FloatingIpProvider`]. They were in `cloud` until R859-F3 moved
35//! them one layer down so `yubaba`'s ingress effector could actually issue a
36//! reassign rather than only decide on one. That crate carries the `reqwest`
37//! this one refuses to.
38//! - `yah-cloud` — the `floating_ip.*` envoy verb layer
39//! (`cloud::provider::floating_ip_envoy`) and the credentialed constructor
40//! `cloud::provider::floating_ip::floating_ip_provider_for`, which resolves
41//! vault slots through `fob`. Neither an envoy catalog nor a credential vault
42//! belongs on a fleet node, so neither moved.
43//!
44//! This crate holds the seam, the shared core, and the decision logic.
45//!
46//! # The machine facts this layer needs
47//!
48//! [`FloatingIpMachine`] is a five-field value, not `cloud`'s `MachineConfig`.
49//! That is what makes the crate free of `cloud`: the adapters only ever read
50//! `name`, `location`, `region`, `provider` and `ingress_floating_ip` off a
51//! machine, so those five fields are the whole of the contract. `cloud`
52//! converts at the boundary (`impl From<&MachineConfig> for FloatingIpMachine`)
53//! and `yubaba` can build one from whatever it knows about a node without
54//! being able to construct a `MachineConfig` at all — which it cannot, since a
55//! fleet node has no `.yah/infra/machines/` tree.
56//!
57//! This mirrors, at the sovereign-ingress tier, the "external identity follows
58//! placement" property [R591](yah://arch/symbol/R591) names for Headscale via a
59//! Cloudflare Tunnel.
60
61use anyhow::{bail, Context, Result};
62use async_trait::async_trait;
63
64/// The machine facts floating-IP mobility actually needs — the whole contract
65/// between this crate and whatever declares machines.
66///
67/// Five fields because five is what the vendor adapters read. Keeping it a
68/// value type rather than borrowing `cloud`'s `MachineConfig` is the thing that
69/// lets `yubaba` link this crate: a fleet node has no `.yah/infra/machines/`
70/// tree and could not build a `MachineConfig` if it wanted to, but it can name
71/// a machine and say which provider hosts it.
72#[derive(Debug, Clone, Default, PartialEq, Eq)]
73pub struct FloatingIpMachine {
74 /// Declared machine name. Every adapter's `resolve_target` looks the box up
75 /// by this: Hetzner `?name=`, Vultr `?label=`, OVH `serviceName`.
76 pub name: String,
77 /// Provider id — `"hetzner"`, `"ovh"`, `"vultr"`. Selects the adapter.
78 pub provider: String,
79 /// Provider DC code. `None` for static nodes.
80 pub location: Option<String>,
81 /// Coarser provider region, used by OVH when `location` is absent.
82 pub region: Option<String>,
83 /// Which floating/reserved IP follows public ingress onto this machine.
84 /// `None` is the common case and a supported shape: a fleet whose ingress
85 /// moves by DNS alone has no floating IP anywhere.
86 pub ingress_floating_ip: Option<String>,
87}
88
89impl FloatingIpMachine {
90 /// Provider DC code, or `""` when omitted — mirrors
91 /// `cloud::config::MachineConfig::location()` so a moved adapter reads the
92 /// same.
93 pub fn location(&self) -> &str {
94 self.location.as_deref().unwrap_or("")
95 }
96}
97
98/// One provider's floating/reserved-IP transport + mobility policy.
99///
100/// Implementors ship in `yah-floating-ip-adapters`: `HetznerFloatingIp`,
101/// `OvhFloatingIp`, `VultrFloatingIp` (R859-F3).
102#[async_trait]
103pub trait FloatingIpProvider: Send + Sync {
104 /// Provider id, e.g. `"hetzner"` — matches
105 /// [`FloatingIpMachine::provider`].
106 fn id(&self) -> &'static str;
107
108 /// Resolve a target machine into this provider's native attach
109 /// identifier (server id / serviceName / instance UUID) plus the
110 /// mobility zone it lives in. May hit the provider's API (e.g. a
111 /// name→id lookup) — this is a live-data resolution step, not a pure
112 /// function of the declaration.
113 async fn resolve_target(&self, machine: &FloatingIpMachine) -> Result<FloatingIpTarget>;
114
115 /// Current state of the floating/reserved IP: its home zone (fixed for
116 /// the IP's lifetime) and the provider-native id of whatever it's
117 /// attached to right now, if anything.
118 async fn current_assignment(&self, ip_id: &str) -> Result<FloatingIpState>;
119
120 /// Actually move the IP. Callers (namely [`reconcile_assignment`])
121 /// have already checked idempotency and zone match before calling
122 /// this — it always issues the provider call.
123 async fn reassign(&self, ip_id: &str, target: &FloatingIpTarget) -> Result<()>;
124}
125
126/// A resolved reassign target: provider-native attach id + the mobility
127/// zone it lives in.
128#[derive(Debug, Clone, PartialEq, Eq)]
129pub struct FloatingIpTarget {
130 /// Hetzner numeric server id, OVH serviceName, or Vultr instance UUID.
131 pub attach_id: String,
132 /// Hetzner network zone / OVH datacentre-region / Vultr region.
133 pub zone: String,
134}
135
136/// Current provider-side state of a floating/reserved IP.
137#[derive(Debug, Clone, PartialEq, Eq)]
138pub struct FloatingIpState {
139 /// The IP's home mobility zone — fixed for its lifetime.
140 pub zone: String,
141 /// Provider-native id of whatever it's attached to right now, if
142 /// anything.
143 pub attached_to: Option<String>,
144}
145
146/// Outcome of [`reconcile_assignment`] / [`on_ingress_owner_changed`].
147#[derive(Debug, Clone, PartialEq, Eq)]
148pub struct FloatingIpAssignOutcome {
149 /// `true` iff a reassign call was actually issued.
150 pub reassigned: bool,
151 /// The attach target the IP now points at.
152 pub attached_to: String,
153}
154
155/// Idempotent, zone-checked core shared by every provider adapter and by
156/// [`on_ingress_owner_changed`].
157///
158/// 1. Fetch the floating IP's current home zone + attachment.
159/// 2. Refuse a cross-zone move (Hetzner/OVH/Vultr all physically cannot
160/// move an IP outside its mobility zone — W267 §Tier 1) *before*
161/// issuing any reassign call.
162/// 3. If the current attachment already equals `target`, return
163/// `reassigned: false` without calling [`FloatingIpProvider::reassign`]
164/// — the ownership-flip fixture this ticket verifies against relies on
165/// this short-circuit to prove "re-applying the same owner drives ZERO
166/// reassign calls."
167/// 4. Otherwise call [`FloatingIpProvider::reassign`] and report
168/// `reassigned: true`.
169///
170/// Generic over `?Sized` so it accepts both a concrete adapter and a
171/// `&dyn FloatingIpProvider` (R859-F3): `cloud`'s blanket `FloatingIpEnvoy`
172/// impl covers `dyn FloatingIpProvider` itself, and a `&dyn` cannot be
173/// re-unsized to `&dyn` under a `Sized` bound.
174pub async fn reconcile_assignment<P: FloatingIpProvider + ?Sized>(
175 provider: &P,
176 ip_id: &str,
177 target: &FloatingIpTarget,
178) -> Result<FloatingIpAssignOutcome> {
179 let current = provider.current_assignment(ip_id).await?;
180 if current.zone != target.zone {
181 bail!(
182 "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)",
183 provider.id(),
184 current.zone,
185 target.zone,
186 target.attach_id,
187 provider.id(),
188 );
189 }
190 if current.attached_to.as_deref() == Some(target.attach_id.as_str()) {
191 return Ok(FloatingIpAssignOutcome {
192 reassigned: false,
193 attached_to: target.attach_id.clone(),
194 });
195 }
196 provider.reassign(ip_id, target).await?;
197 Ok(FloatingIpAssignOutcome {
198 reassigned: true,
199 attached_to: target.attach_id.clone(),
200 })
201}
202
203/// Callable entry point: react to the raft `ingress_owner` seam naming
204/// `machine` as the box that now owns public ingress, by commanding
205/// `ip_id` to follow it.
206///
207/// This is the *applier*; [`plan_ingress_owner_effect`] is the decision that
208/// should precede it. Calling this directly skips every guard rail R859-F2
209/// wrote (quorum gate, liveness veto, owner resolution) — do that only from an
210/// operator-driven path where a human has already made the call.
211///
212/// `ClearIngressOwner` (`ingress_owner` going to `None`) has no defined
213/// action — there is no "detach the IP" verb because Tier 1 has no
214/// specified safe-unassigned state, and leaving the IP on the last-known-good
215/// node is the correct default. So this function is only meaningful for
216/// `Some(machine)` transitions; [`plan_ingress_owner_effect`] encodes the same
217/// conclusion as a [`NoOp`](IngressOwnerEffect::NoOp).
218pub async fn on_ingress_owner_changed<P: FloatingIpProvider + ?Sized>(
219 provider: &P,
220 machine: &FloatingIpMachine,
221 ip_id: &str,
222) -> Result<FloatingIpAssignOutcome> {
223 let target = provider.resolve_target(machine).await?;
224 reconcile_assignment(provider, ip_id, &target).await
225}
226
227// ── R859-F2: the registry ─────────────────────────────────────────────────
228
229/// Which providers ship a [`FloatingIpProvider`] adapter, and the credential
230/// each one authenticates with — `(provider id, vault slot, env fallback)`.
231///
232/// One table rather than a `match` arm per consumer, because two questions read
233/// it and they must not drift: `cloud`'s `floating_ip_provider_for` builds the
234/// adapter from the slot/env pair, and [`provider_has_floating_ip_adapter`]
235/// answers the same question *without* credentials, for `yah cloud validate`
236/// (which runs on an operator's laptop with no fleet tokens loaded and must
237/// still be able to refuse a machine declaring a floating IP its provider
238/// cannot move).
239///
240/// Public because the constructor that reads the slot/env columns lives in
241/// `cloud` now — the table has to cross the crate boundary to keep being one
242/// table.
243pub const FLOATING_IP_PROVIDERS: &[(&str, &str, &str)] = &[
244 ("hetzner", "hetzner-api-token", "HETZNER_API_TOKEN"),
245 ("ovh", "ovh-consumer-key", "OVH_CONSUMER_KEY"),
246 ("vultr", "vultr-api-key", "VULTR_API_KEY"),
247];
248
249/// Does `provider` have a floating-IP adapter at all?
250///
251/// Credential-free by design — see [`FLOATING_IP_PROVIDERS`]. A `false` here
252/// means [`FloatingIpMachine::ingress_floating_ip`] on such a machine could
253/// never be acted on, which is a declaration worth refusing at validate time
254/// rather than discovering during a failover.
255pub fn provider_has_floating_ip_adapter(provider: &str) -> bool {
256 FLOATING_IP_PROVIDERS.iter().any(|(id, _, _)| *id == provider)
257}
258
259/// The provider ids that ship an adapter, for an error message that names what
260/// *is* supported rather than only what is not.
261pub fn supported_floating_ip_providers() -> String {
262 FLOATING_IP_PROVIDERS
263 .iter()
264 .map(|(id, _, _)| *id)
265 .collect::<Vec<_>>()
266 .join(", ")
267}
268
269// ── R859-F2: the pure ingress-owner effect planner ────────────────────────
270
271/// What a `TransitionTracker`-style hysteresis says about one machine, crossed
272/// into this crate as plain data.
273///
274/// The yubaba-side original is
275/// `yubaba::lease_detector::TransitionTracker::committed`, which answers
276/// `Option<Confirmed>`. It is re-spelled rather than imported because this
277/// crate sits *below* both consumers and must not depend on either. The
278/// crossing is by value.
279#[derive(Debug, Clone, Copy, PartialEq, Eq)]
280pub enum OwnerLiveness {
281 /// The hysteresis has committed this machine as up.
282 ConfirmedUp,
283 /// The hysteresis has committed this machine as down — the only value that
284 /// is positive evidence *against* a machine.
285 ConfirmedDown,
286 /// Never dwelled long enough in either direction to be committed: a
287 /// freshly-elected leader's tracker, a node mid-flap, or no detector at
288 /// all. **Not** the same as down.
289 Unconfirmed,
290}
291
292/// Live consensus health, crossed into this crate as plain data.
293///
294/// The yubaba-side original is `yubaba::quorum_health::QuorumVerdict`, whose
295/// `Unknown` variant collapses into [`Degraded`](Self::Degraded) here: both
296/// refuse a withdrawal, and the distinction survives in the reason string. Same
297/// no-type-dependency rule as [`OwnerLiveness`].
298#[derive(Debug, Clone, PartialEq, Eq)]
299pub enum QuorumHealth {
300 Healthy,
301 Degraded {
302 /// The yubaba-side `QuorumVerdict::reason()`, carried verbatim so a
303 /// refusal names the actual voter counts rather than a generic excuse.
304 reason: String,
305 },
306}
307
308/// What should happen to public ingress, given an `ingress_owner` observation.
309///
310/// The two failover speeds W267 §Tier 1 names appear here as two variants:
311/// [`Reassign`](Self::Reassign) is the intra-provider one (seconds, no DNS
312/// propagation, no cert re-mint), [`Withdraw`](Self::Withdraw) the
313/// cross-provider one (pull the dead origin's A record and let the survivors
314/// take its share).
315#[derive(Debug, Clone, PartialEq, Eq)]
316pub enum IngressOwnerEffect {
317 /// Move `ip_id` onto `machine` — the intra-provider failover.
318 Reassign {
319 /// The machine that now owns public ingress.
320 machine: String,
321 /// Its [`FloatingIpMachine::ingress_floating_ip`].
322 ip_id: String,
323 },
324 /// Drop `machine` from the apex origin set — the cross-provider failover.
325 ///
326 /// Consumed by `cloud::reconciler::domain::public_origins`'s
327 /// health-exclusion argument, which is why this carries a machine name and
328 /// not a record id: the DNS layer already knows how to turn a declared
329 /// machine into an address, and duplicating that here would be a second
330 /// answer to a question R859-F1 settled.
331 Withdraw {
332 machine: String,
333 reason: String,
334 },
335 /// Do nothing, and refuse to do it — positive grounds against acting.
336 ///
337 /// Distinct from [`NoOp`](Self::NoOp) because it is worth *saying*: a
338 /// refusal means the world is in a state where the correct action is known
339 /// and deliberately not taken, which an operator watching a failover needs
340 /// to see. A `NoOp` is not news.
341 Refuse { reason: String },
342 /// Nothing to do.
343 NoOp { reason: String },
344}
345
346impl IngressOwnerEffect {
347 /// `true` for the two variants that command something.
348 pub fn is_action(&self) -> bool {
349 matches!(self, Self::Reassign { .. } | Self::Withdraw { .. })
350 }
351
352 /// One operator-readable line, for a log or a `yah cloud apply` summary.
353 pub fn reason(&self) -> String {
354 match self {
355 Self::Reassign { machine, ip_id } => {
356 format!("reassign floating IP {ip_id} to {machine}")
357 }
358 Self::Withdraw { machine, reason } => {
359 format!("withdraw {machine} from the apex: {reason}")
360 }
361 Self::Refuse { reason } | Self::NoOp { reason } => reason.clone(),
362 }
363 }
364}
365
366/// Resolve an `ingress_owner` string to the machine it names.
367///
368/// **This cannot assume the string is a `.yah/infra/machines/` name.**
369/// `ingress_owner` is written from yubaba's `derive_machine_name()`, which
370/// reads `/etc/hostname`; R841's incident record has it holding
371/// `vps-4c1efa56` for the box declared as `us-west-001`, and
372/// `app/yah/cli/src/mesh.rs`'s R858-T3 gotcha states the mismatch outright.
373/// So the resolution is an exact match against declared names and **nothing
374/// else** — no prefix match, no fuzzy fallback, no "it is probably the only
375/// public-ip box". A wrong guess here reassigns a live public IP onto the
376/// wrong machine, which is the outage R859-F2 exists to prevent, so an
377/// unresolvable owner is a refusal that names both sides.
378pub fn resolve_ingress_owner<'a>(
379 owner: &str,
380 machines: &'a [FloatingIpMachine],
381) -> Result<&'a FloatingIpMachine> {
382 machines
383 .iter()
384 .find(|m| m.name == owner)
385 .with_context(|| {
386 format!(
387 "raft names {owner:?} as the ingress owner, but no .yah/infra/machines/*.toml \
388 declares a machine with that name (declared: {}). Note `ingress_owner` carries \
389 the node's /etc/hostname, which is not always its machine name — R841 saw \
390 `vps-4c1efa56` recorded for the box declared as `us-west-001`. Rename the box's \
391 hostname to match its machine name, or this mapping cannot be made safely.",
392 machines
393 .iter()
394 .map(|m| m.name.as_str())
395 .collect::<Vec<_>>()
396 .join(", "),
397 )
398 })
399}
400
401/// Decide what public ingress should do about an `ingress_owner` observation —
402/// pure, so the decision is testable as arithmetic and the I/O is somebody
403/// else's problem.
404///
405/// Same pure-planner / IO-applier split R859-F1 used for the apex
406/// (`plan_domain_passway` vs `deploy_domain_passway`), and the same one
407/// `yubaba`'s `scheduler::decide_transfer` uses. Nothing here touches a
408/// network, a clock or a config file.
409///
410/// # The gating rule: fail-closed on withdrawal, fail-open on addition
411///
412/// Deliberately the same rule R859-F1 wrote for its apex prune
413/// (`DomainPasswayPlan::origins_complete`), and cited here so the two stay one
414/// rule rather than two coincidences. Taking something *away* — an IP off the
415/// box currently serving it, an A record out of the round-robin — on evidence
416/// we are not sure of is how a leadership flap becomes a public outage. Adding
417/// can never make the apex worse. So:
418///
419/// - A degraded quorum refuses [`Reassign`](IngressOwnerEffect::Reassign) and
420/// [`Withdraw`](IngressOwnerEffect::Withdraw), which are both withdrawals
421/// from somebody's point of view (a reassign takes the IP off the old owner).
422/// This is `yubaba-failover.md` pre-check 1 — *"do not fail over out of a
423/// degraded quorum — you will lose it entirely"* — enforced instead of read.
424/// - Liveness may only ever **veto**, never approve. A `Reassign` proceeds on
425/// [`Unconfirmed`](OwnerLiveness::Unconfirmed) because the `ingress_owner`
426/// write is *itself* a consensus fact that the node came up and served
427/// (`leader.rs`'s `on_became_leader` only writes it after the appliance
428/// starts); demanding a second, independent confirm dwell would stall every
429/// legitimate failover by one dwell and stall a freshly-elected leader — whose
430/// tracker is empty — indefinitely. Only
431/// [`ConfirmedDown`](OwnerLiveness::ConfirmedDown), positive contrary
432/// evidence, refuses. A `Withdraw` is the mirror image: it *requires*
433/// `ConfirmedDown`, because a withdrawal must rest on positive evidence.
434///
435/// # This planner never transfers leadership, and must not learn to
436///
437/// It *reacts* to an `ingress_owner` change and can never *cause* one. Making
438/// the effector transfer leadership would make it a second consensus mechanism
439/// racing the real one — the objection `yubaba`'s `failure_detector` module doc
440/// already makes. `cloud.mesh_failover` (W271) stays the operator path, with
441/// its `ask_user` confirmation and its rollback, and is untouched by this.
442///
443/// # TTL is deliberately not an input
444///
445/// The cross-provider path publishes through R859-F1's apex renderer, which
446/// writes records at the `dns.record.upsert` default `ttl = 1` (Cloudflare
447/// "auto"). Auto-TTL on a DNS-only record is already short enough for a
448/// withdrawal to take effect on the cross-provider timescale, so there is no
449/// manifest TTL field and this function has no TTL parameter. Recorded here so
450/// the next reader does not re-open it.
451pub fn plan_ingress_owner_effect(
452 previous_owner: Option<&str>,
453 current_owner: Option<&str>,
454 current_owner_liveness: OwnerLiveness,
455 quorum: &QuorumHealth,
456 machines: &[FloatingIpMachine],
457) -> IngressOwnerEffect {
458 let Some(owner) = current_owner else {
459 // `ClearIngressOwner`. There is no "detach the IP" verb and Tier 1 has
460 // no specified safe-unassigned state, so leaving the IP where it is —
461 // on the last node known to have served — is the correct default. See
462 // `on_ingress_owner_changed`'s doc, which records the same conclusion.
463 return IngressOwnerEffect::NoOp {
464 reason: match previous_owner {
465 Some(prev) => format!(
466 "ingress owner cleared (was {prev}) — leaving the floating IP on the \
467 last-known-good node; there is no detach verb and no specified \
468 safe-unassigned state at Tier 1"
469 ),
470 None => "no ingress owner recorded".to_string(),
471 },
472 };
473 };
474
475 let machine = match resolve_ingress_owner(owner, machines) {
476 Ok(m) => m,
477 Err(e) => return IngressOwnerEffect::Refuse { reason: format!("{e:#}") },
478 };
479
480 let owner_changed = previous_owner != Some(owner);
481
482 if owner_changed {
483 let Some(ip_id) = machine.ingress_floating_ip.as_deref() else {
484 // The common case, and a clean skip rather than an error: most
485 // machines have no floating IP, and a fleet whose ingress moves by
486 // DNS alone is a supported shape, not a misconfiguration.
487 return IngressOwnerEffect::NoOp {
488 reason: format!(
489 "ingress owner moved to {owner}, which declares no `ingress_floating_ip` — \
490 this machine has no floating-IP path"
491 ),
492 };
493 };
494 if current_owner_liveness == OwnerLiveness::ConfirmedDown {
495 return IngressOwnerEffect::Refuse {
496 reason: format!(
497 "ingress owner moved to {owner}, but liveness has confirmed it DOWN — \
498 refusing to point the public IP at a box we have positive evidence is dead"
499 ),
500 };
501 }
502 if let QuorumHealth::Degraded { reason } = quorum {
503 return IngressOwnerEffect::Refuse {
504 reason: format!(
505 "ingress owner moved to {owner} but the reassign is refused: {reason} \
506 (yubaba-failover.md pre-check 1). A reassign takes the IP off the old \
507 owner, so it is a withdrawal and fails closed."
508 ),
509 };
510 }
511 return IngressOwnerEffect::Reassign {
512 machine: owner.to_string(),
513 ip_id: ip_id.to_string(),
514 };
515 }
516
517 // Owner unchanged. The only thing that can want an action now is the owner
518 // itself dying — the cross-provider case, where no new owner has been
519 // elected (or none can be) and the live apex is still pointing traffic at a
520 // dead box.
521 if current_owner_liveness == OwnerLiveness::ConfirmedDown {
522 if let QuorumHealth::Degraded { reason } = quorum {
523 return IngressOwnerEffect::Refuse {
524 reason: format!(
525 "ingress owner {owner} is confirmed down, but the withdrawal is refused: \
526 {reason} (yubaba-failover.md pre-check 1)"
527 ),
528 };
529 }
530 return IngressOwnerEffect::Withdraw {
531 machine: owner.to_string(),
532 reason: format!("ingress owner {owner} is confirmed down by the lease channel"),
533 };
534 }
535
536 IngressOwnerEffect::NoOp {
537 reason: format!("ingress owner unchanged ({owner}) and not confirmed down"),
538 }
539}
540
541#[cfg(test)]
542mod tests {
543 use super::*;
544 use std::sync::atomic::{AtomicU32, Ordering};
545 use std::sync::Mutex;
546
547 /// A fake, network-free [`FloatingIpProvider`] — proves
548 /// [`reconcile_assignment`]'s idempotency + zone-mismatch-reject logic
549 /// in isolation from any vendor wire format (the per-provider mock-HTTP
550 /// tests in `yah-floating-ip-adapters`'s `hetzner.rs` / `ovh.rs` /
551 /// `vultr.rs` cover the wire-level shape).
552 struct FakeProvider {
553 zone: &'static str,
554 attached_to: Mutex<Option<String>>,
555 reassign_calls: AtomicU32,
556 }
557
558 #[async_trait]
559 impl FloatingIpProvider for FakeProvider {
560 fn id(&self) -> &'static str {
561 "fake"
562 }
563 async fn resolve_target(&self, machine: &FloatingIpMachine) -> Result<FloatingIpTarget> {
564 Ok(FloatingIpTarget {
565 attach_id: machine.name.clone(),
566 zone: self.zone.to_string(),
567 })
568 }
569 async fn current_assignment(&self, _ip_id: &str) -> Result<FloatingIpState> {
570 Ok(FloatingIpState {
571 zone: self.zone.to_string(),
572 attached_to: self.attached_to.lock().unwrap().clone(),
573 })
574 }
575 async fn reassign(&self, _ip_id: &str, target: &FloatingIpTarget) -> Result<()> {
576 self.reassign_calls.fetch_add(1, Ordering::SeqCst);
577 *self.attached_to.lock().unwrap() = Some(target.attach_id.clone());
578 Ok(())
579 }
580 }
581
582 fn machine(name: &str) -> FloatingIpMachine {
583 FloatingIpMachine {
584 name: name.into(),
585 provider: "fake".into(),
586 ..Default::default()
587 }
588 }
589
590 #[tokio::test]
591 async fn ownership_flip_drives_exactly_one_reassign_call() {
592 let provider = FakeProvider {
593 zone: "us-west",
594 attached_to: Mutex::new(Some("old-node".into())),
595 reassign_calls: AtomicU32::new(0),
596 };
597 let outcome = on_ingress_owner_changed(&provider, &machine("new-node"), "ip-1")
598 .await
599 .unwrap();
600 assert!(outcome.reassigned);
601 assert_eq!(outcome.attached_to, "new-node");
602 assert_eq!(provider.reassign_calls.load(Ordering::SeqCst), 1);
603 }
604
605 #[tokio::test]
606 async fn reapplying_the_same_owner_is_a_zero_call_noop() {
607 let provider = FakeProvider {
608 zone: "us-west",
609 attached_to: Mutex::new(Some("new-node".into())),
610 reassign_calls: AtomicU32::new(0),
611 };
612 let outcome = on_ingress_owner_changed(&provider, &machine("new-node"), "ip-1")
613 .await
614 .unwrap();
615 assert!(!outcome.reassigned);
616 assert_eq!(outcome.attached_to, "new-node");
617 assert_eq!(
618 provider.reassign_calls.load(Ordering::SeqCst),
619 0,
620 "idempotent re-apply must not call reassign"
621 );
622 }
623
624 #[tokio::test]
625 async fn never_assigned_ip_gets_a_first_assign_call() {
626 let provider = FakeProvider {
627 zone: "us-west",
628 attached_to: Mutex::new(None),
629 reassign_calls: AtomicU32::new(0),
630 };
631 let outcome = on_ingress_owner_changed(&provider, &machine("new-node"), "ip-1")
632 .await
633 .unwrap();
634 assert!(outcome.reassigned);
635 assert_eq!(provider.reassign_calls.load(Ordering::SeqCst), 1);
636 }
637
638 #[tokio::test]
639 async fn cross_zone_target_is_rejected_before_any_reassign_call() {
640 let provider = FakeProvider {
641 zone: "eu-central",
642 attached_to: Mutex::new(None),
643 reassign_calls: AtomicU32::new(0),
644 };
645 let target = FloatingIpTarget {
646 attach_id: "new-node".into(),
647 zone: "us-west".into(),
648 };
649 let err = reconcile_assignment(&provider, "ip-1", &target)
650 .await
651 .unwrap_err();
652 let msg = format!("{err:#}");
653 assert!(
654 msg.contains("zone"),
655 "expected a zone-mismatch message, got: {msg}"
656 );
657 assert_eq!(
658 provider.reassign_calls.load(Ordering::SeqCst),
659 0,
660 "zone mismatch must never call reassign"
661 );
662 }
663
664 // ── R859-F2: the registry ─────────────────────────────────────────────
665
666 #[test]
667 fn the_three_shipped_adapters_are_all_reachable_by_provider_id() {
668 for id in ["hetzner", "ovh", "vultr"] {
669 assert!(
670 provider_has_floating_ip_adapter(id),
671 "{id} ships a FloatingIpProvider impl but the registry cannot reach it"
672 );
673 }
674 for id in ["digitalocean", "static", "local-docker", ""] {
675 assert!(!provider_has_floating_ip_adapter(id), "{id}");
676 }
677 }
678
679 /// The registry table and the error message that lists it are read by two
680 /// crates now, so a row added here with no constructor in `cloud` would be
681 /// a silent half-registration. This pins the join: every id the table
682 /// advertises appears in the string the refusal shows an operator.
683 #[test]
684 fn every_registered_provider_is_named_in_the_supported_list() {
685 let supported = supported_floating_ip_providers();
686 for (id, _, _) in FLOATING_IP_PROVIDERS {
687 assert!(supported.contains(id), "{id} missing from {supported:?}");
688 }
689 }
690
691 // ── R859-F2: plan_ingress_owner_effect ────────────────────────────────
692
693 fn fleet() -> Vec<FloatingIpMachine> {
694 let mut west = machine("us-west-001");
695 west.provider = "hetzner".into();
696 west.ingress_floating_ip = Some("fip-42".into());
697 let mut east = machine("us-east-001");
698 east.provider = "hetzner".into();
699 east.ingress_floating_ip = Some("fip-42".into());
700 // Declared, but no floating-IP path — the common case.
701 let mesh_only = machine("us-west-002");
702 vec![west, east, mesh_only]
703 }
704
705 fn degraded() -> QuorumHealth {
706 QuorumHealth::Degraded {
707 reason: "quorum AT RISK: 2/3 voters available".into(),
708 }
709 }
710
711 #[test]
712 fn an_ownership_flip_onto_a_machine_with_a_floating_ip_reassigns_it() {
713 let effect = plan_ingress_owner_effect(
714 Some("us-west-001"),
715 Some("us-east-001"),
716 OwnerLiveness::ConfirmedUp,
717 &QuorumHealth::Healthy,
718 &fleet(),
719 );
720 assert_eq!(
721 effect,
722 IngressOwnerEffect::Reassign {
723 machine: "us-east-001".into(),
724 ip_id: "fip-42".into(),
725 }
726 );
727 assert!(effect.is_action());
728 }
729
730 /// Liveness may only ever veto. A freshly-elected leader's tracker is empty,
731 /// so requiring a positive confirm would stall exactly the failover this
732 /// exists to perform — and the `ingress_owner` write is itself evidence the
733 /// node came up and served.
734 #[test]
735 fn an_unconfirmed_new_owner_still_reassigns_because_liveness_may_only_veto() {
736 assert!(matches!(
737 plan_ingress_owner_effect(
738 Some("us-west-001"),
739 Some("us-east-001"),
740 OwnerLiveness::Unconfirmed,
741 &QuorumHealth::Healthy,
742 &fleet(),
743 ),
744 IngressOwnerEffect::Reassign { .. }
745 ));
746 }
747
748 #[test]
749 fn a_new_owner_confirmed_down_is_refused_rather_than_pointed_at() {
750 let effect = plan_ingress_owner_effect(
751 Some("us-west-001"),
752 Some("us-east-001"),
753 OwnerLiveness::ConfirmedDown,
754 &QuorumHealth::Healthy,
755 &fleet(),
756 );
757 assert!(matches!(effect, IngressOwnerEffect::Refuse { .. }), "{effect:?}");
758 assert!(effect.reason().contains("DOWN"), "{}", effect.reason());
759 }
760
761 /// `yubaba-failover.md` pre-check 1, enforced: a reassign takes the IP off
762 /// the old owner, so it is a withdrawal and fails closed.
763 #[test]
764 fn a_degraded_quorum_refuses_the_reassign_and_carries_the_verdicts_reason() {
765 let effect = plan_ingress_owner_effect(
766 Some("us-west-001"),
767 Some("us-east-001"),
768 OwnerLiveness::ConfirmedUp,
769 °raded(),
770 &fleet(),
771 );
772 assert!(matches!(effect, IngressOwnerEffect::Refuse { .. }), "{effect:?}");
773 assert!(
774 effect.reason().contains("2/3 voters available"),
775 "the refusal must carry the quorum verdict's own reason, got: {}",
776 effect.reason()
777 );
778 }
779
780 /// The other half of decision 3, and the half that is easy to get wrong:
781 /// refusing on a degraded quorum applies to withdrawals, never to
782 /// additions. Nothing here gates an upsert — see
783 /// `diff_apex_records`, whose `upsert` is untouched by every gate.
784 #[test]
785 fn a_machine_with_no_floating_ip_is_a_clean_skip_not_an_error() {
786 let effect = plan_ingress_owner_effect(
787 Some("us-west-001"),
788 Some("us-west-002"),
789 OwnerLiveness::ConfirmedUp,
790 &QuorumHealth::Healthy,
791 &fleet(),
792 );
793 assert!(matches!(effect, IngressOwnerEffect::NoOp { .. }), "{effect:?}");
794 assert!(!effect.is_action());
795 assert!(
796 effect.reason().contains("no floating-IP path"),
797 "{}",
798 effect.reason()
799 );
800 }
801
802 #[test]
803 fn a_steady_healthy_owner_does_nothing() {
804 let effect = plan_ingress_owner_effect(
805 Some("us-east-001"),
806 Some("us-east-001"),
807 OwnerLiveness::ConfirmedUp,
808 &QuorumHealth::Healthy,
809 &fleet(),
810 );
811 assert!(matches!(effect, IngressOwnerEffect::NoOp { .. }), "{effect:?}");
812 }
813
814 /// The cross-provider path: no new owner has been elected, and the one we
815 /// have is confirmed dead. A withdrawal REQUIRES the positive
816 /// `ConfirmedDown`, which is the mirror of the reassign's veto-only rule.
817 #[test]
818 fn a_steady_owner_confirmed_down_is_withdrawn_from_the_apex() {
819 let effect = plan_ingress_owner_effect(
820 Some("us-east-001"),
821 Some("us-east-001"),
822 OwnerLiveness::ConfirmedDown,
823 &QuorumHealth::Healthy,
824 &fleet(),
825 );
826 assert_eq!(
827 effect,
828 IngressOwnerEffect::Withdraw {
829 machine: "us-east-001".into(),
830 reason: "ingress owner us-east-001 is confirmed down by the lease channel".into(),
831 }
832 );
833 }
834
835 #[test]
836 fn a_degraded_quorum_refuses_the_withdrawal_too() {
837 let effect = plan_ingress_owner_effect(
838 Some("us-east-001"),
839 Some("us-east-001"),
840 OwnerLiveness::ConfirmedDown,
841 °raded(),
842 &fleet(),
843 );
844 assert!(matches!(effect, IngressOwnerEffect::Refuse { .. }), "{effect:?}");
845 assert!(effect.reason().contains("2/3 voters available"), "{}", effect.reason());
846 }
847
848 /// The mismatch R841 saw live: `ingress_owner` carries `/etc/hostname`,
849 /// which is not always the machine name. Guessing here would reassign a
850 /// live public IP onto the wrong box, so an unresolvable owner refuses and
851 /// names both sides.
852 #[test]
853 fn an_ingress_owner_that_names_no_declared_machine_refuses_loudly() {
854 let effect = plan_ingress_owner_effect(
855 Some("us-west-001"),
856 Some("vps-4c1efa56"),
857 OwnerLiveness::ConfirmedUp,
858 &QuorumHealth::Healthy,
859 &fleet(),
860 );
861 assert!(matches!(effect, IngressOwnerEffect::Refuse { .. }), "{effect:?}");
862 let reason = effect.reason();
863 assert!(reason.contains("vps-4c1efa56"), "{reason}");
864 assert!(
865 reason.contains("us-west-001") && reason.contains("us-east-001"),
866 "the refusal must name the declared machines it compared against: {reason}"
867 );
868 assert!(
869 reason.contains("hostname"),
870 "and must explain WHY the two spaces differ: {reason}"
871 );
872 }
873
874 /// `ClearIngressOwner`. There is no detach verb and Tier 1 specifies no safe
875 /// unassigned state, so the IP stays on the last node known to have served —
876 /// the same conclusion `on_ingress_owner_changed`'s doc reaches.
877 #[test]
878 fn clearing_the_ingress_owner_leaves_the_ip_where_it_is() {
879 let effect = plan_ingress_owner_effect(
880 Some("us-east-001"),
881 None,
882 OwnerLiveness::ConfirmedDown,
883 &QuorumHealth::Healthy,
884 &fleet(),
885 );
886 assert!(matches!(effect, IngressOwnerEffect::NoOp { .. }), "{effect:?}");
887 assert!(
888 effect.reason().contains("last-known-good"),
889 "{}",
890 effect.reason()
891 );
892 }
893
894 #[test]
895 fn no_ingress_owner_at_all_is_a_no_op() {
896 assert!(matches!(
897 plan_ingress_owner_effect(
898 None,
899 None,
900 OwnerLiveness::Unconfirmed,
901 &QuorumHealth::Healthy,
902 &fleet(),
903 ),
904 IngressOwnerEffect::NoOp { .. }
905 ));
906 }
907
908 /// First observation after this process started: `previous_owner` is `None`
909 /// but an owner is recorded. That is a change from this planner's point of
910 /// view and must converge the IP rather than wait for a flip that already
911 /// happened — the planner carries no state across ticks, so "unchanged" can
912 /// only ever mean "unchanged since the last tick I saw".
913 #[test]
914 fn a_first_observation_of_an_existing_owner_converges_the_ip() {
915 assert_eq!(
916 plan_ingress_owner_effect(
917 None,
918 Some("us-east-001"),
919 OwnerLiveness::ConfirmedUp,
920 &QuorumHealth::Healthy,
921 &fleet(),
922 ),
923 IngressOwnerEffect::Reassign {
924 machine: "us-east-001".into(),
925 ip_id: "fip-42".into(),
926 },
927 "reconcile_assignment is idempotent, so a redundant converge costs zero \
928 provider calls — but skipping it would leave a stale IP unfixed forever"
929 );
930 }
931}