Skip to main content

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            &degraded(),
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            &degraded(),
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}