Skip to main content

cloud/provider/
floating_ip.rs

1//! Provider-abstracted floating/reserved-IP mobility (R594-F5).
2//!
3//! [`FloatingIpProvider`] is the domain-level trait each vendor adapter
4//! (`HetznerFloatingIp`, `OvhFloatingIp`, `VultrFloatingIp` — sibling
5//! modules in this directory) implements. [`reconcile_assignment`] is the
6//! shared idempotent + zone-checked core all three run through, so the
7//! "no-op when already assigned" / "reject a cross-zone move" behavior is
8//! written and tested exactly once instead of three times.
9//!
10//! [`on_ingress_owner_changed`] is the Rust-level callable entry point for
11//! R594-F5's ask: given the raft `ingress_owner` seam
12//! (`oss/yubaba/crates/yubaba/src/raft/mod.rs`'s `YubabaRequest::SetIngressOwner`
13//! / `ClearIngressOwner`, `RaftAppState::ingress_owner`) and the machine it
14//! currently names, command the provider floating IP to follow. Wiring
15//! this to fire *automatically* whenever `ingress_owner` transitions lives
16//! in the raft-apply / leadership-reconcile path
17//! (`oss/yubaba/crates/yubaba/src/leader.rs`), which is peer-owned and
18//! off-limits to this ticket — see [`on_ingress_owner_changed`]'s doc
19//! comment for the exact call site a follow-up should add. This is the
20//! same "mechanism now, wiring later" shape R594-F3 used for service
21//! records.
22//!
23//! This mirrors, at the sovereign-ingress tier, the "external identity
24//! follows placement" property [R591](yah://arch/symbol/R591) names for
25//! Headscale via a Cloudflare Tunnel. R591 is peer-owned and gated on R570
26//! (real multi-node raft HA); this module is not blocked on either — it
27//! builds directly on the `ingress_owner` seam, which already exists.
28//!
29//! @yah:ticket(R859-F2, "Wire floating-ip.* provider adapters to ingress_owner transitions + health-checked DNS withdrawal for dead origins")
30//! @yah:at(2026-09-05T10:22:42Z)
31//! @yah:assignee(agent:bundle-anthropic-glimmerstone)
32//! @yah:parent(R859)
33//! @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).")
34//! @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).")
35//! @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.")
36//! @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.")
37//! @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.")
38//! @yah:handoff("LANDED, uncommitted. Six pieces. (1) THE BLOCKER, fixed as decided: MemberInfo and YubabaRequest::SetMember each gained machine: Option<String> with #[serde(default)] (oss/yubaba/crates/yubaba/src/raft/mod.rs), written by member_registration from leader::derive_machine_name() — the SAME function that writes ingress_owner, so the two strings are comparable by construction. Accessors YubabaStateMachine::machine_for_node / node_for_machine at raft/store.rs:769. (2) quorum_health.rs (new, oss/yubaba/crates/yubaba/src/): pure judge_quorum(voters, LivenessReport) -> QuorumVerdict{Healthy{voters,available,margin} | Degraded{reason} | Unknown{reason}} + permits_withdrawal(); thin caller wired into scheduler.rs's tick loop where both inputs are already in hand. (3) cloud::provider::floating_ip gained the registry R594-F5 left out: floating_ip_provider_for(&MachineConfig), provider_has_floating_ip_adapter(&str), and one FLOATING_IP_PROVIDERS table both read so they cannot drift. (4) All three adapters registered in envoy.rs default_adapters() — floating_ip.assign/status are now genuinely dispatchable. (5) MachineConfig.ingress_floating_ip: Option<String> (config.rs, beside `cloudflared`) + cloud::validate::check_ingress_floating_ip, wired into `yah cloud validate` (error) and the apply preflight (warning), same split as R605-F12. (6) DNS withdrawal through F1's EXISTING seam: public_origins gained a health_excluded arg and returns ResolvedOrigins{origins, health_withdrawn}; DomainPasswayPlan gained health_withdrawn; diff_apex_records prunes a health-withdrawn address regardless of origins_complete.")
39//! @yah:handoff("THE DEPENDENCY FORK, resolved with evidence, and the answer is NOT the one the brief's criterion predicts. Read both manifests: oss/yubaba/crates/cloud/Cargo.toml has NO yubaba dep, and oss/yubaba/crates/yubaba/Cargo.toml already carries `cloud = { package = \"yah-cloud\", path = \"../cloud\" }` — but under [dev-dependencies]. So a runtime yubaba -> cloud edge would create NO cargo cycle. I did not take it anyway, and the reason is a documented architectural rule the brief's cycle-check could not see: cloud/Cargo.toml's `local-driver` dep comment records that local-driver was carved out of cloud in R374-F3 SPECIFICALLY \"so yubaba could own MinIO lifecycle without a reverse yubaba->cloud dep\". Adding that edge would put velveteen, velveteen-exec, yah-hetzner, yah-mesofact-bundle and yah-almanac into the release daemon shipped to every fleet node — an architecture call outside a courier's blast radius. So I took the SECOND branch: plan_ingress_owner_effect() is pure, fully tested, and UNWIRED. Everything else in the ticket ships.")
40//! @yah:handoff("WHERE THE PLANNER LIVES, and why there. plan_ingress_owner_effect is in cloud (provider/floating_ip.rs), not yubaba, because its inputs include MachineConfig and its outputs command cloud adapters. The two yubaba-side facts cross the boundary AS PLAIN DATA, never as types: OwnerLiveness{ConfirmedUp,ConfirmedDown,Unconfirmed} re-spells TransitionTracker::committed, and QuorumHealth{Healthy,Degraded{reason}} re-spells QuorumVerdict (its Unknown collapses into Degraded — both refuse, and the distinction survives in the reason string). That honours decision 7: raft stays read-only from the cloud side, and no dep edge is created in either direction. Signature: plan_ingress_owner_effect(previous_owner, current_owner, current_owner_liveness, &quorum, machines) -> IngressOwnerEffect{Reassign{machine,ip_id} | Withdraw{machine,reason} | Refuse{reason} | NoOp{reason}}. NoOp carries a reason (the brief wrote it bare) because a log line saying which of the five no-op paths was taken is worth six characters. The two action variants ARE W267's two failover speeds: Reassign is intra-provider, Withdraw feeds public_origins' health_excluded set for the cross-provider path — which is what gives the enum's fourth variant real work rather than a placeholder.")
41//! @yah:gotcha("READ THIS BEFORE ATTACHING THE EFFECTOR — the identity bridge is narrower than its name. `ingress_owner` and the new `MemberInfo.machine` both carry `/etc/hostname` (leader::derive_machine_name), which is NOT reliably the .yah/infra/machines/<name>.toml name. Evidence, not inference: app/yah/cli/src/mesh.rs:108's R858-T3 gotcha states it outright, and R841's incident record (app/yah/cli/src/rollout/executor.rs) has ingress_owner holding `vps-4c1efa56` for the box declared `us-west-001`. The brief's decided fix assumed these were machine names; they are not, and I corrected the field's doc comment rather than shipping a plausible-looking lie. What the bridge DOES guarantee is exact: node_id <-> ingress_owner, because both strings come from one derivation. Resolving that string to a MachineConfig is a SEPARATE, fail-loud step — cloud::provider::floating_ip::resolve_ingress_owner matches declared names EXACTLY (no prefix match, no fuzzy fallback, no \"it is probably the only public-ip box\") and refuses by name, listing every declared machine and explaining the hostname mismatch. Pinned by an_ingress_owner_that_names_no_declared_machine_refuses_loudly. So on today's fleet a us-west-001 flip would REFUSE rather than misfire. Closing it properly means renaming hostnames to match machine names, or adding a declared hostname alias to MachineConfig — separable work, not R859-F2's.")
42//! @yah:handoff("TWO DESIGN CALLS I MADE THAT ARE NOT IN THE BRIEF, both forced by a test that failed. (a) `Degraded` means A VOTER IS DOWN, not \"this topology has no redundancy\". My first judge_quorum keyed purely on margin and my own 1-voter test failed it: a rig has zero margin at its healthiest, so a margin-only rule calls its best possible state degraded and refuses every withdrawal forever — an inert feature wearing the costume of a safety check. Rule is now `Degraded` iff available < majority, OR available == majority AND available < voters. A fully-available cluster is Healthy at any size with margin stating the slack honestly. Pinned by an_intact_two_voter_cluster_is_healthy_but_a_three_voter_one_reduced_to_two_is_not — identical `available`, opposite verdicts, because one lost a voter and the other did not. (b) The empty-apex guard in plan_domain_passway is checked against the origins that SURVIVE health exclusion, which makes it the health failover's backstop for free: if every declared front door is confirmed down, plan_domain_passway refuses. \"All our front doors are down\" must never render as \"withdraw every A record\" — a dead origin still in DNS is a partial outage, an empty apex is a total one. The health withdrawal is therefore capped at all-but-the-last origin by construction, with no second rule to keep in sync. Same reasoning one level finer: an address a SURVIVING origin still answers on is dropped from health_withdrawn (two machines can share a floating IP).")
43//! @yah:handoff("THE origins_complete x health CROSS-PRODUCT, decided and tested as four cells (health_withdrawal_and_declaration_completeness_are_independent). complete+healthy -> prune. complete+down -> prune. incomplete+healthy -> withheld_prune. incomplete+down -> PRUNE ANYWAY. The bottom-right cell is the whole point and it is a judgement, so here is the reasoning: origins_complete=false protects against mistaking an ABSENCE for a withdrawal, and a health withdrawal is not an absence — it is a positive observation about a machine the collation resolved, taint-checked and address-checked on the way into health_withdrawn. An unrelated service's broken TOML is not evidence about a box we watched go down; letting it veto the prune would leave a dead origin taking its share of the round-robin for as long as that typo lives. Fail-closed-on-withdrawal is NOT weakened: the gate on a health withdrawal is the QUORUM verdict, applied one layer up in plan_ingress_owner_effect, which refuses to emit the exclusion at all out of a degraded quorum. Two withdrawal paths, each fail-closed on the evidence actually relevant to it. Also tested: an_incomplete_collation_prunes_only_the_health_withdrawn_surplus (both reasons coexist in one diff — health-excluded pruned, merely-absent still withheld). Decisions 1, 8 and 9 held as written: TransitionTracker/HysteresisPolicy reused with no new debounce type; no TTL parameter anywhere and a doc comment saying why so the next reader does not re-open it; cloud.mesh_failover untouched and the planner cannot transfer leadership.")
44//! @yah:verify("Baselines measured BEFORE any edit, on this tree. cloud (`cargo test --manifest-path oss/yubaba/crates/cloud/Cargo.toml --features json-schema`): 1124 passed / 0 failed / 4 ignored (lib) + 3/0/1 + 2/0/0 + 0/0/1. yubaba (`--manifest-path oss/yubaba/crates/yubaba/Cargo.toml --lib`): 708 passed / 0 failed. `cargo build --workspace`: GREEN before I started — it was not already red, so nothing here is inherited. AFTER: cloud 1152 / 0 / 4 (+28, same other three targets); yubaba lib 724 / 0 / 0 (+16); `cargo build --workspace` green; `cargo check --manifest-path oss/yubaba/Cargo.toml --all-targets` clean (covers the two integration-test files I touched). Epoch gate: `RUSTC_WRAPPER='' cargo run -p xtask -- cluster-epochs` GREEN — both axes were red from my raft/mod.rs + raft/store.rs edits, verdict NOT BREAKING on both, hashes re-recorded, cluster_protocol stays 5 and state_epoch stays 4, with a full why_not_a_bump entry in cluster-epochs.json surface_rerecords[2026-09-05]. Both drifted surfaces were verified to contain ONLY my hunks (git diff -U0: store.rs is one 47-line insertion) before writing, so no peer's unanalysed change was swept into a verdict. `scripts/check-workload-spec-ts.sh`: ok.")
45//! @yah:gotcha("scripts/check-schema-drift.sh is RED, and it is NOT this ticket's drift. The gate regenerates and then `git diff --quiet -- .yah/schema`, so it fails for ANY uncommitted regeneration, in sync or not — exactly the condition R860-T1 already recorded (\"both gates go red for that reason; a pathspec commit of the generated paths was attempted and DENIED by the approval gate\"). It was red before I started (.yah/schema/{machine,workload}.toml.schema.json were both already dirty in the tree at session start). I ran `cargo run -p xtask -- emit-schemas` as required — MachineConfig gained a field — and machine.toml.schema.json:77 now carries `ingress_floating_ip`. Note the regen ALSO shrank workload.toml.schema.json's WorkloadSpec description, because R860-T1's @yah: annotations have since left workload-spec/src/lib.rs; that is a correct regeneration of a generated artifact, not damage, and any peer running the same command gets the same output. The gate goes green when those two paths are committed. I did not commit (instructed not to).")
46//! @yah:gotcha("OVH's floating-IP adapter is now REGISTERED but is NOT live-ready, and registering it was still right. ovh_floating_ip.rs's own module doc records that its auth is a placeholder — OVH signs with an application key + secret + consumer key + timestamped HMAC, not the bare `X-Ovh-Consumer` header the adapter sends. Registering it in default_adapters() makes the verb dispatchable (the latent bug decision 4 names); it does not make it correct against api.ovh.com. All three registrations are gated on their credential being present via fob::get_or_env, so the adapter is absent from every camp that has not deliberately set `ovh-consumer-key`/$OVH_CONSUMER_KEY. Swap in real OVH request signing before pointing it at anything live. Checked rather than assumed: HetznerEnvoy (cloud.vps.*) and HetznerFloatingIp (floating_ip.*) share the adapter id \"hetzner\" but claim DISJOINT verb sets, and agent-tools/src/envoy_tools.rs:214 groups by VERB id rather than adapter id — so neither shadows the other, and the per-verb `provider` enum still gets three distinct choices.")
47//! @yah:next("ATTACHING THE EFFECTOR is the one genuine operator/architecture call left, and it is the cycle question the brief anticipated — just with a different answer than \"cycle: yes/no\". There is no cargo cycle; there IS a documented rule (R374-F3, recorded in cloud/Cargo.toml's local-driver comment) against a runtime yubaba -> cloud edge, and taking it would put velveteen/velveteen-exec/yah-hetzner/yah-mesofact-bundle/yah-almanac into the fleet daemon. Three options for whoever decides: (a) accept the edge and call plan_ingress_owner_effect from scheduler.rs:370's tick loop, which already computes is_leader, owns the TransitionTracker, and now computes the quorum verdict — the call site is ready and the three facts it needs are all in scope there; (b) extract the floating-IP provider trait + adapters into a small crate both depend on, the same move R374-F3 made for local-driver; (c) leave it operator-driven and expose the planner through a cloud verb. Nothing else in this ticket is blocked on the answer — the decision logic and every gate are landed and tested either way.")
48//! @yah:next("Two follow-ups worth their own tickets, both genuinely separable rather than deferred work I was standing on. (1) The hostname vs machine-name gap (see gotcha): today an ingress_owner of `vps-4c1efa56` REFUSES loudly instead of misfiring, which is safe but means the effector is inert on any box whose /etc/hostname differs from its declared name. Fix is either renaming those hostnames or adding a declared hostname alias to MachineConfig that resolve_ingress_owner also matches — a fleet-config decision, not a code one. (2) Real OVH request signing (application key + secret + consumer key + timestamped HMAC) before the ovh floating_ip.* verbs touch api.ovh.com.")
49//! @yah:handoff("FILES (all uncommitted; tree anchor f086233d, the commit this session started from — quote that SHA, not HEAD, in any restore instruction). yubaba: raft/mod.rs (field on MemberInfo + SetMember, apply arm, 3 new tests), raft/store.rs:769 (machine_for_node/node_for_machine), quorum_health.rs (NEW, 12 tests), lib.rs (module decl), scheduler.rs (judge_quorum caller + debug! import), member_registration.rs (machine param through spawn/run/plan_registration/write_row, 3 new tests, 12 existing call sites updated), leader.rs (derive_machine_name now pub, doc), main.rs (passes it), leader_pin.rs + headroom.rs (MemberInfo literals), tests/raft_tenant_placement.rs, yubaba-test-harness/src/solo_node.rs (per-node stand-in name — /etc/hostname would make every in-process node identical and node_for_machine ambiguous), cluster-epochs.json. cloud: provider/floating_ip.rs (registry + planner + resolver + 14 tests), provider/mod.rs (re-exports), envoy.rs (default_adapters), config.rs (ingress_floating_ip + 4 test literals), validate.rs (check_ingress_floating_ip + 8 tests), reconciler/domain.rs (ResolvedOrigins, health_withdrawn, diff rule, 5 new tests), plus mechanical `ingress_floating_ip: None,` in 10 more files' MachineConfig literals. app/yah/cli/src/cloud.rs: lint wired at both sites + tally. .yah/schema/{machine,workload}.toml.schema.json regenerated. COLLISION CHECK: envoy.rs has 5 hunks and only ONE is mine (default_adapters); the other four are R859-F1's known_verb_descriptors work — expected, not a collision. I did not touch the foreign hunks the brief named (proc_control.rs, topology.rs, cloud.rs's header) and no unexpected diffs appeared in any file I own.")
50//! @yah:handoff("LEADER SIGN-OFF, independently verified by a separate session that re-ran every gate and traced each claim to file:line — not taken on the implementer's word. Commands: cloud tests EXIT=0 at 1152 passed / 0 failed / 4 ignored (baseline 1124/0/4 after R859-F1, +28); yubaba --lib EXIT=0 at 724/0 (baseline 708/0, +16); cargo build --workspace EXIT=0; cargo check oss/yubaba --all-targets EXIT=0. Confirmed in code: MemberInfo.machine and SetMember.machine are Option<String> with #[serde(default)] (raft/mod.rs:1159, :352) — the rollout-safety property, pinned by a_pre_r859_f2_snapshot_loads_untagged_and_a_downgrade_reads_a_tagged_one (:1612) and a_pre_r859_f2_set_member_still_applies_with_no_machine (:1651), so a live cluster's existing JSON snapshot still deserializes. judge_quorum (quorum_health.rs:154) is pure, Degraded requires a voter actually down, a 1-voter rig reads Healthy, and scheduler.rs:445 feeds it real voter_ids() plus the raft LivenessReport rather than fabricated data. Withdrawal and reassign are refused on Degraded (floating_ip.rs:509, :529) while upserts and tenant placement stay ungated — the same fail-closed-on-withdrawal / fail-open-on-addition rule R859-F1 established for its prune gate, now applied consistently across both children. The four-cell cross-product is tested in health_withdrawal_and_declaration_completeness_are_independent (domain.rs:1673), and diff_apex_records (:833) remains the SOLE prune path, so withdrawal went through F1's existing public_origins -> plan_domain_passway seam with no parallel route to DNS mutation. Registry, the three default_adapters() registrations, unknown-provider bail (floating_ip.rs:238), ingress_floating_ip lint wiring (cloud.rs:9641/:10735) with absent-config as a clean skip (validate.rs:770), and TransitionTracker/HysteresisPolicy reuse with no second debounce all verify.")
51//! @yah:handoff("Tree anchor at handoff: f086233d6b092de2f32cafad5e0010494078269c — the shared tree as I left it. Diff against it (`git diff f086233d6b092de2f32cafad5e0010494078269c..HEAD`) to see what landed under you, and quote this SHA rather than 'HEAD' in any revert/restore instruction.")
52//! @yah:gotcha("check-schema-drift.sh exits 1, and it is NOT this relay's drift. Verified by hashing .yah/schema/*.json before and after: the script's own regeneration produces byte-identical files, so the gate is red purely on its `git diff --quiet` uncommitted-artifact condition — R860-T1's recorded state, independently corroborated by an unrelated session's gotcha at topology.rs:67. Nothing was changed. Note the uncommitted schema diff is MIXED: machine.toml.schema.json:77 carries this ticket's own regenerated ingress_floating_ip entry alongside R860's description churn, so whoever commits must not assume the whole diff is theirs.")
53//! @yah:assumes("QuorumVerdict::Unknown -> QuorumHealth::Degraded is documented but has no conversion code yet. That is consistent with plan_ingress_owner_effect being unwired — the collapse only becomes reachable when the effector is attached — but it is the first thing to implement if it is.")
54//! @yah:handoff("DELIVERED BUT UNWIRED, and this is the relay's one genuine operator call. plan_ingress_owner_effect() landed pure, fully tested, and with no production caller: inputs are (previous ingress_owner, current ingress_owner, quorum verdict, hysteresis verdict, machine configs), output is an action enum. scheduler.rs's call site is prepared and already computes the quorum verdict the effector would need, so attaching it is a small change — but it is not a courier's call to make. There is NO cargo cycle today (cloud has no yubaba dep; yubaba depends on cloud only under [dev-dependencies], Cargo.toml:152). What blocks it is a deliberate architectural decision, not a technical impossibility: cloud/Cargo.toml:75-78's `local-driver` comment records R374-F3 carving that crate out SPECIFICALLY to avoid a reverse yubaba->cloud dependency, and taking that edge would pull velveteen/hetzner/mesofact/almanac into the fleet daemon. Reversing a documented carve-out is an operator decision, so everything else in the ticket shipped and this one seam waits on an answer.")
55//! @yah:gotcha("PREMISE CORRECTION, found by the implementer against a claim the Leader's dispatch had asserted — the dispatch said to populate the new machine tag from derive_machine_name(), assuming it yields the .yah/infra/machines/<name>.toml name. It does not: it reads /etc/hostname (leader.rs:876-884), exactly as app/yah/cli/src/mesh.rs:108 already states, and R841's incident record has ingress_owner holding `vps-4c1efa56` for the box declared `us-west-001`. The bridge is still exact where it matters, because node_id and ingress_owner come from ONE derivation — but turning that string into a MachineConfig is now a separate fail-loud step, resolve_ingress_owner (floating_ip.rs:383), which refuses by exact name rather than guessing (test at :887). On today's fleet an ingress_owner flip would therefore REFUSE rather than misfire — correct, but it means the floating-IP path is inert until hostnames and machine-TOML names are reconciled. That reconciliation is not in this relay.")
56
57use anyhow::{bail, Context, Result};
58use async_trait::async_trait;
59
60use crate::config::MachineConfig;
61
62/// One provider's floating/reserved-IP transport + mobility policy.
63///
64/// Implementors: [`super::hetzner_floating_ip::HetznerFloatingIp`],
65/// [`super::ovh_floating_ip::OvhFloatingIp`],
66/// [`super::vultr_floating_ip::VultrFloatingIp`].
67#[async_trait]
68pub trait FloatingIpProvider: Send + Sync {
69    /// Provider id, e.g. `"hetzner"` — matches [`MachineConfig::provider`].
70    fn id(&self) -> &'static str;
71
72    /// Resolve a target machine into this provider's native attach
73    /// identifier (server id / serviceName / instance UUID) plus the
74    /// mobility zone it lives in. May hit the provider's API (e.g. a
75    /// name→id lookup) — this is a live-data resolution step, not a pure
76    /// function of the TOML.
77    async fn resolve_target(&self, machine: &MachineConfig) -> Result<FloatingIpTarget>;
78
79    /// Current state of the floating/reserved IP: its home zone (fixed for
80    /// the IP's lifetime) and the provider-native id of whatever it's
81    /// attached to right now, if anything.
82    async fn current_assignment(&self, ip_id: &str) -> Result<FloatingIpState>;
83
84    /// Actually move the IP. Callers (namely [`reconcile_assignment`])
85    /// have already checked idempotency and zone match before calling
86    /// this — it always issues the provider call.
87    async fn reassign(&self, ip_id: &str, target: &FloatingIpTarget) -> Result<()>;
88}
89
90/// A resolved reassign target: provider-native attach id + the mobility
91/// zone it lives in.
92#[derive(Debug, Clone, PartialEq, Eq)]
93pub struct FloatingIpTarget {
94    /// Hetzner numeric server id, OVH serviceName, or Vultr instance UUID.
95    pub attach_id: String,
96    /// Hetzner network zone / OVH datacentre-region / Vultr region.
97    pub zone: String,
98}
99
100/// Current provider-side state of a floating/reserved IP.
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct FloatingIpState {
103    /// The IP's home mobility zone — fixed for its lifetime.
104    pub zone: String,
105    /// Provider-native id of whatever it's attached to right now, if
106    /// anything.
107    pub attached_to: Option<String>,
108}
109
110/// Outcome of [`reconcile_assignment`] / [`on_ingress_owner_changed`].
111#[derive(Debug, Clone, PartialEq, Eq)]
112pub struct FloatingIpAssignOutcome {
113    /// `true` iff a reassign call was actually issued.
114    pub reassigned: bool,
115    /// The attach target the IP now points at.
116    pub attached_to: String,
117}
118
119/// Idempotent, zone-checked core shared by every provider adapter and by
120/// [`on_ingress_owner_changed`].
121///
122/// 1. Fetch the floating IP's current home zone + attachment.
123/// 2. Refuse a cross-zone move (Hetzner/OVH/Vultr all physically cannot
124///    move an IP outside its mobility zone — W267 §Tier 1) *before*
125///    issuing any reassign call.
126/// 3. If the current attachment already equals `target`, return
127///    `reassigned: false` without calling [`FloatingIpProvider::reassign`]
128///    — the ownership-flip fixture this ticket verifies against relies on
129///    this short-circuit to prove "re-applying the same owner drives ZERO
130///    reassign calls."
131/// 4. Otherwise call [`FloatingIpProvider::reassign`] and report
132///    `reassigned: true`.
133pub async fn reconcile_assignment(
134    provider: &dyn FloatingIpProvider,
135    ip_id: &str,
136    target: &FloatingIpTarget,
137) -> Result<FloatingIpAssignOutcome> {
138    let current = provider.current_assignment(ip_id).await?;
139    if current.zone != target.zone {
140        bail!(
141            "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)",
142            provider.id(),
143            current.zone,
144            target.zone,
145            target.attach_id,
146            provider.id(),
147        );
148    }
149    if current.attached_to.as_deref() == Some(target.attach_id.as_str()) {
150        return Ok(FloatingIpAssignOutcome {
151            reassigned: false,
152            attached_to: target.attach_id.clone(),
153        });
154    }
155    provider.reassign(ip_id, target).await?;
156    Ok(FloatingIpAssignOutcome {
157        reassigned: true,
158        attached_to: target.attach_id.clone(),
159    })
160}
161
162/// Callable entry point: react to the raft `ingress_owner` seam naming
163/// `machine` as the box that now owns public ingress, by commanding
164/// `ip_id` to follow it.
165///
166/// **Wiring (not done here — deliberately out of scope, see the ticket's
167/// hard constraints):** the raft apply loop
168/// (`oss/yubaba/crates/yubaba/src/raft/mod.rs::apply`) already mutates
169/// `RaftAppState::ingress_owner` on `YubabaRequest::SetIngressOwner` /
170/// `ClearIngressOwner`. A follow-up ticket should call this function from
171/// the leadership/reconcile path (`oss/yubaba/crates/yubaba/src/leader.rs`
172/// — peer-owned, not touched here) at the point where it observes
173/// `ingress_owner` transition from `old` to `Some(new_machine)`: look up
174/// `new_machine`'s [`MachineConfig`] (already available there via
175/// `WorkspaceConfig`), pick the [`FloatingIpProvider`] matching
176/// `machine.provider`, and call
177/// `on_ingress_owner_changed(provider, &machine, ip_id).await`. `ip_id`
178/// itself (which floating IP is "the" ingress IP) has no home today —
179/// that's a small config surface (likely a field alongside the
180/// `public-ip` taint R572-F3 is adding) a follow-up should introduce
181/// alongside the wiring, not invented speculatively here.
182///
183/// `ClearIngressOwner` (`ingress_owner` going to `None`) has no defined
184/// action yet — there is no "detach the IP" verb because Tier 1 has no
185/// specified safe-unassigned state (leaving the IP on the last-known-good
186/// node is arguably the correct default). Extend when that need
187/// materializes; until then this function is only meaningful for
188/// `Some(machine)` transitions.
189pub async fn on_ingress_owner_changed(
190    provider: &dyn FloatingIpProvider,
191    machine: &MachineConfig,
192    ip_id: &str,
193) -> Result<FloatingIpAssignOutcome> {
194    let target = provider.resolve_target(machine).await?;
195    reconcile_assignment(provider, ip_id, &target).await
196}
197
198// ── R859-F2: the registry ─────────────────────────────────────────────────
199
200/// Which providers ship a [`FloatingIpProvider`] adapter, and the credential
201/// each one authenticates with — `(provider id, vault slot, env fallback)`.
202///
203/// One table rather than a `match` arm per consumer, because two questions read
204/// it and they must not drift: [`floating_ip_provider_for`] builds the adapter,
205/// and [`provider_has_floating_ip_adapter`] answers the same question *without*
206/// credentials, for `yah cloud validate` (which runs on an operator's laptop
207/// with no fleet tokens loaded and must still be able to refuse a machine
208/// declaring a floating IP its provider cannot move).
209const FLOATING_IP_PROVIDERS: &[(&str, &str, &str)] = &[
210    ("hetzner", "hetzner-api-token", "HETZNER_API_TOKEN"),
211    ("ovh", "ovh-consumer-key", "OVH_CONSUMER_KEY"),
212    ("vultr", "vultr-api-key", "VULTR_API_KEY"),
213];
214
215/// Does `provider` have a floating-IP adapter at all?
216///
217/// Credential-free by design — see [`FLOATING_IP_PROVIDERS`]. A `false` here
218/// means [`MachineConfig::ingress_floating_ip`] on such a machine could never
219/// be acted on, which is a declaration worth refusing at validate time rather
220/// than discovering during a failover.
221pub fn provider_has_floating_ip_adapter(provider: &str) -> bool {
222    FLOATING_IP_PROVIDERS.iter().any(|(id, _, _)| *id == provider)
223}
224
225/// Resolve `machine.provider` to a live [`FloatingIpProvider`] — the registry
226/// R594-F5 left out.
227///
228/// Without this the three adapters were unreachable from any caller holding a
229/// [`MachineConfig`]: each knows its own wire format, and nothing mapped a
230/// declared provider onto one. Credentials come from the same
231/// `fob`-then-env source [`super::HetznerDriver::from_default_sources`] uses,
232/// so a camp that can already drive a provider can drive its floating IPs with
233/// no extra configuration.
234///
235/// Two distinct failures, kept distinct because they want different fixes: a
236/// provider with no adapter is a *declaration* error (nothing will ever move
237/// that IP), while a missing credential is an *environment* error (the
238/// declaration is fine, this process cannot act on it).
239pub fn floating_ip_provider_for(machine: &MachineConfig) -> Result<Box<dyn FloatingIpProvider>> {
240    let Some((_, slot, env)) = FLOATING_IP_PROVIDERS
241        .iter()
242        .find(|(id, _, _)| *id == machine.provider)
243    else {
244        bail!(
245            "machine {:?} declares provider {:?}, which has no floating-IP adapter — \
246             floating/reserved IPs are implemented for {} only",
247            machine.name,
248            machine.provider,
249            FLOATING_IP_PROVIDERS
250                .iter()
251                .map(|(id, _, _)| *id)
252                .collect::<Vec<_>>()
253                .join(", "),
254        );
255    };
256    let token = fob::get_or_env(slot, env)
257        .with_context(|| format!("reading {slot} for machine {:?}", machine.name))?
258        .with_context(|| {
259            format!(
260                "machine {:?} needs {:?} credentials to move its floating IP, but neither the \
261                 `{slot}` vault slot nor ${env} is set",
262                machine.name, machine.provider,
263            )
264        })?;
265    Ok(match machine.provider.as_str() {
266        "hetzner" => Box::new(super::HetznerFloatingIp::new(token)),
267        "ovh" => Box::new(super::OvhFloatingIp::new(token)),
268        "vultr" => Box::new(super::VultrFloatingIp::new(token)),
269        // Unreachable: the lookup above already refused anything not in the
270        // table. Kept as a loud bail rather than an `unreachable!` so adding a
271        // row to the table without a constructor here is a runtime error naming
272        // the omission, not a panic in a failover path.
273        other => bail!("floating-ip registry: no constructor wired for provider {other:?}"),
274    })
275}
276
277// ── R859-F2: the pure ingress-owner effect planner ────────────────────────
278
279/// What a `TransitionTracker`-style hysteresis says about one machine, crossed
280/// into this crate as plain data.
281///
282/// The yubaba-side original is
283/// `yubaba::lease_detector::TransitionTracker::committed`, which answers
284/// `Option<Confirmed>`. It is re-spelled rather than imported because
285/// `cloud` does not depend on `yubaba` and must not start to — this module's
286/// header records that raft is **read-only from the cloud side**, and a type
287/// dependency is not a read. The crossing is by value, over the existing
288/// read-only surface.
289#[derive(Debug, Clone, Copy, PartialEq, Eq)]
290pub enum OwnerLiveness {
291    /// The hysteresis has committed this machine as up.
292    ConfirmedUp,
293    /// The hysteresis has committed this machine as down — the only value that
294    /// is positive evidence *against* a machine.
295    ConfirmedDown,
296    /// Never dwelled long enough in either direction to be committed: a
297    /// freshly-elected leader's tracker, a node mid-flap, or no detector at
298    /// all. **Not** the same as down.
299    Unconfirmed,
300}
301
302/// Live consensus health, crossed into this crate as plain data.
303///
304/// The yubaba-side original is `yubaba::quorum_health::QuorumVerdict`, whose
305/// `Unknown` variant collapses into [`Degraded`](Self::Degraded) here: both
306/// refuse a withdrawal, and the distinction survives in the reason string. Same
307/// no-type-dependency rule as [`OwnerLiveness`].
308#[derive(Debug, Clone, PartialEq, Eq)]
309pub enum QuorumHealth {
310    Healthy,
311    Degraded {
312        /// The yubaba-side `QuorumVerdict::reason()`, carried verbatim so a
313        /// refusal names the actual voter counts rather than a generic excuse.
314        reason: String,
315    },
316}
317
318/// What should happen to public ingress, given an `ingress_owner` observation.
319///
320/// The two failover speeds W267 §Tier 1 names appear here as two variants:
321/// [`Reassign`](Self::Reassign) is the intra-provider one (seconds, no DNS
322/// propagation, no cert re-mint), [`Withdraw`](Self::Withdraw) the
323/// cross-provider one (pull the dead origin's A record and let the survivors
324/// take its share).
325#[derive(Debug, Clone, PartialEq, Eq)]
326pub enum IngressOwnerEffect {
327    /// Move `ip_id` onto `machine` — the intra-provider failover.
328    Reassign {
329        /// The machine that now owns public ingress.
330        machine: String,
331        /// Its [`MachineConfig::ingress_floating_ip`].
332        ip_id: String,
333    },
334    /// Drop `machine` from the apex origin set — the cross-provider failover.
335    ///
336    /// Consumed by
337    /// [`public_origins`](crate::reconciler::domain::public_origins)'s
338    /// health-exclusion argument, which is why this carries a machine name and
339    /// not a record id: the DNS layer already knows how to turn a declared
340    /// machine into an address, and duplicating that here would be a second
341    /// answer to a question R859-F1 settled.
342    Withdraw {
343        machine: String,
344        reason: String,
345    },
346    /// Do nothing, and refuse to do it — positive grounds against acting.
347    ///
348    /// Distinct from [`NoOp`](Self::NoOp) because it is worth *saying*: a
349    /// refusal means the world is in a state where the correct action is known
350    /// and deliberately not taken, which an operator watching a failover needs
351    /// to see. A `NoOp` is not news.
352    Refuse { reason: String },
353    /// Nothing to do.
354    NoOp { reason: String },
355}
356
357impl IngressOwnerEffect {
358    /// `true` for the two variants that command something.
359    pub fn is_action(&self) -> bool {
360        matches!(self, Self::Reassign { .. } | Self::Withdraw { .. })
361    }
362
363    /// One operator-readable line, for a log or a `yah cloud apply` summary.
364    pub fn reason(&self) -> String {
365        match self {
366            Self::Reassign { machine, ip_id } => {
367                format!("reassign floating IP {ip_id} to {machine}")
368            }
369            Self::Withdraw { machine, reason } => {
370                format!("withdraw {machine} from the apex: {reason}")
371            }
372            Self::Refuse { reason } | Self::NoOp { reason } => reason.clone(),
373        }
374    }
375}
376
377/// Resolve an `ingress_owner` string to the machine it names.
378///
379/// **This cannot assume the string is a `.yah/infra/machines/` name.**
380/// `ingress_owner` is written from yubaba's `derive_machine_name()`, which
381/// reads `/etc/hostname`; R841's incident record has it holding
382/// `vps-4c1efa56` for the box declared as `us-west-001`, and
383/// `app/yah/cli/src/mesh.rs`'s R858-T3 gotcha states the mismatch outright.
384/// So the resolution is an exact match against declared names and **nothing
385/// else** — no prefix match, no fuzzy fallback, no "it is probably the only
386/// public-ip box". A wrong guess here reassigns a live public IP onto the
387/// wrong machine, which is the outage R859-F2 exists to prevent, so an
388/// unresolvable owner is a refusal that names both sides.
389pub fn resolve_ingress_owner<'a>(
390    owner: &str,
391    machines: &'a [MachineConfig],
392) -> Result<&'a MachineConfig> {
393    machines
394        .iter()
395        .find(|m| m.name == owner)
396        .with_context(|| {
397            format!(
398                "raft names {owner:?} as the ingress owner, but no .yah/infra/machines/*.toml \
399                 declares a machine with that name (declared: {}). Note `ingress_owner` carries \
400                 the node's /etc/hostname, which is not always its machine name — R841 saw \
401                 `vps-4c1efa56` recorded for the box declared as `us-west-001`. Rename the box's \
402                 hostname to match its machine name, or this mapping cannot be made safely.",
403                machines
404                    .iter()
405                    .map(|m| m.name.as_str())
406                    .collect::<Vec<_>>()
407                    .join(", "),
408            )
409        })
410}
411
412/// Decide what public ingress should do about an `ingress_owner` observation —
413/// pure, so the decision is testable as arithmetic and the I/O is somebody
414/// else's problem.
415///
416/// Same pure-planner / IO-applier split R859-F1 used for the apex
417/// ([`plan_domain_passway`](crate::reconciler::domain::plan_domain_passway) vs
418/// [`deploy_domain_passway`](crate::reconciler::domain::deploy_domain_passway)),
419/// and the same one `yubaba`'s `scheduler::decide_transfer` uses. Nothing here
420/// touches a network, a clock or a config file.
421///
422/// # The gating rule: fail-closed on withdrawal, fail-open on addition
423///
424/// Deliberately the same rule R859-F1 wrote for its apex prune
425/// ([`DomainPasswayPlan::origins_complete`](crate::reconciler::domain::DomainPasswayPlan::origins_complete)),
426/// and cited here so the two stay one rule rather than two coincidences.
427/// Taking something *away* — an IP off the box currently serving it, an A
428/// record out of the round-robin — on evidence we are not sure of is how a
429/// leadership flap becomes a public outage. Adding can never make the apex
430/// worse. So:
431///
432/// - A degraded quorum refuses [`Reassign`](IngressOwnerEffect::Reassign) and
433///   [`Withdraw`](IngressOwnerEffect::Withdraw), which are both withdrawals
434///   from somebody's point of view (a reassign takes the IP off the old owner).
435///   This is `yubaba-failover.md` pre-check 1 — *"do not fail over out of a
436///   degraded quorum — you will lose it entirely"* — enforced instead of read.
437/// - Liveness may only ever **veto**, never approve. A `Reassign` proceeds on
438///   [`Unconfirmed`](OwnerLiveness::Unconfirmed) because the `ingress_owner`
439///   write is *itself* a consensus fact that the node came up and served
440///   (`leader.rs`'s `on_became_leader` only writes it after the appliance
441///   starts); demanding a second, independent confirm dwell would stall every
442///   legitimate failover by one dwell and stall a freshly-elected leader — whose
443///   tracker is empty — indefinitely. Only
444///   [`ConfirmedDown`](OwnerLiveness::ConfirmedDown), positive contrary
445///   evidence, refuses. A `Withdraw` is the mirror image: it *requires*
446///   `ConfirmedDown`, because a withdrawal must rest on positive evidence.
447///
448/// # This planner never transfers leadership, and must not learn to
449///
450/// It *reacts* to an `ingress_owner` change and can never *cause* one. Making
451/// the effector transfer leadership would make it a second consensus mechanism
452/// racing the real one — the objection `yubaba`'s `failure_detector` module doc
453/// already makes. `cloud.mesh_failover` (W271) stays the operator path, with
454/// its `ask_user` confirmation and its rollback, and is untouched by this.
455///
456/// # TTL is deliberately not an input
457///
458/// The cross-provider path publishes through R859-F1's apex renderer, which
459/// writes records at the `dns.record.upsert` default `ttl = 1` (Cloudflare
460/// "auto"). Auto-TTL on a DNS-only record is already short enough for a
461/// withdrawal to take effect on the cross-provider timescale, so there is no
462/// manifest TTL field and this function has no TTL parameter. Recorded here so
463/// the next reader does not re-open it.
464pub fn plan_ingress_owner_effect(
465    previous_owner: Option<&str>,
466    current_owner: Option<&str>,
467    current_owner_liveness: OwnerLiveness,
468    quorum: &QuorumHealth,
469    machines: &[MachineConfig],
470) -> IngressOwnerEffect {
471    let Some(owner) = current_owner else {
472        // `ClearIngressOwner`. There is no "detach the IP" verb and Tier 1 has
473        // no specified safe-unassigned state, so leaving the IP where it is —
474        // on the last node known to have served — is the correct default. See
475        // `on_ingress_owner_changed`'s doc, which records the same conclusion.
476        return IngressOwnerEffect::NoOp {
477            reason: match previous_owner {
478                Some(prev) => format!(
479                    "ingress owner cleared (was {prev}) — leaving the floating IP on the \
480                     last-known-good node; there is no detach verb and no specified \
481                     safe-unassigned state at Tier 1"
482                ),
483                None => "no ingress owner recorded".to_string(),
484            },
485        };
486    };
487
488    let machine = match resolve_ingress_owner(owner, machines) {
489        Ok(m) => m,
490        Err(e) => return IngressOwnerEffect::Refuse { reason: format!("{e:#}") },
491    };
492
493    let owner_changed = previous_owner != Some(owner);
494
495    if owner_changed {
496        let Some(ip_id) = machine.ingress_floating_ip.as_deref() else {
497            // The common case, and a clean skip rather than an error: most
498            // machines have no floating IP, and a fleet whose ingress moves by
499            // DNS alone is a supported shape, not a misconfiguration.
500            return IngressOwnerEffect::NoOp {
501                reason: format!(
502                    "ingress owner moved to {owner}, which declares no `ingress_floating_ip` — \
503                     this machine has no floating-IP path"
504                ),
505            };
506        };
507        if current_owner_liveness == OwnerLiveness::ConfirmedDown {
508            return IngressOwnerEffect::Refuse {
509                reason: format!(
510                    "ingress owner moved to {owner}, but liveness has confirmed it DOWN — \
511                     refusing to point the public IP at a box we have positive evidence is dead"
512                ),
513            };
514        }
515        if let QuorumHealth::Degraded { reason } = quorum {
516            return IngressOwnerEffect::Refuse {
517                reason: format!(
518                    "ingress owner moved to {owner} but the reassign is refused: {reason} \
519                     (yubaba-failover.md pre-check 1). A reassign takes the IP off the old \
520                     owner, so it is a withdrawal and fails closed."
521                ),
522            };
523        }
524        return IngressOwnerEffect::Reassign {
525            machine: owner.to_string(),
526            ip_id: ip_id.to_string(),
527        };
528    }
529
530    // Owner unchanged. The only thing that can want an action now is the owner
531    // itself dying — the cross-provider case, where no new owner has been
532    // elected (or none can be) and the live apex is still pointing traffic at a
533    // dead box.
534    if current_owner_liveness == OwnerLiveness::ConfirmedDown {
535        if let QuorumHealth::Degraded { reason } = quorum {
536            return IngressOwnerEffect::Refuse {
537                reason: format!(
538                    "ingress owner {owner} is confirmed down, but the withdrawal is refused: \
539                     {reason} (yubaba-failover.md pre-check 1)"
540                ),
541            };
542        }
543        return IngressOwnerEffect::Withdraw {
544            machine: owner.to_string(),
545            reason: format!("ingress owner {owner} is confirmed down by the lease channel"),
546        };
547    }
548
549    IngressOwnerEffect::NoOp {
550        reason: format!("ingress owner unchanged ({owner}) and not confirmed down"),
551    }
552}
553
554#[cfg(test)]
555mod tests {
556    use super::*;
557    use std::sync::atomic::{AtomicU32, Ordering};
558    use std::sync::Mutex;
559
560    /// A fake, network-free [`FloatingIpProvider`] — proves
561    /// [`reconcile_assignment`]'s idempotency + zone-mismatch-reject logic
562    /// in isolation from any vendor wire format (the per-provider mock-HTTP
563    /// tests in `hetzner_floating_ip.rs` / `ovh_floating_ip.rs` /
564    /// `vultr_floating_ip.rs` cover the wire-level shape).
565    struct FakeProvider {
566        zone: &'static str,
567        attached_to: Mutex<Option<String>>,
568        reassign_calls: AtomicU32,
569    }
570
571    #[async_trait]
572    impl FloatingIpProvider for FakeProvider {
573        fn id(&self) -> &'static str {
574            "fake"
575        }
576        async fn resolve_target(&self, machine: &MachineConfig) -> Result<FloatingIpTarget> {
577            Ok(FloatingIpTarget {
578                attach_id: machine.name.clone(),
579                zone: self.zone.to_string(),
580            })
581        }
582        async fn current_assignment(&self, _ip_id: &str) -> Result<FloatingIpState> {
583            Ok(FloatingIpState {
584                zone: self.zone.to_string(),
585                attached_to: self.attached_to.lock().unwrap().clone(),
586            })
587        }
588        async fn reassign(&self, _ip_id: &str, target: &FloatingIpTarget) -> Result<()> {
589            self.reassign_calls.fetch_add(1, Ordering::SeqCst);
590            *self.attached_to.lock().unwrap() = Some(target.attach_id.clone());
591            Ok(())
592        }
593    }
594
595    fn machine(name: &str) -> MachineConfig {
596        MachineConfig {
597            name: name.into(),
598            provider: "fake".into(),
599            location: None,
600            server_type: None,
601            hosts_mirrors: vec![],
602            mesh_tags: vec![],
603            region: None,
604            zone: None,
605            arch: None,
606            bucket: None,
607            vendor: None,
608            nickname: None,
609            legacy_hostkey_fingerprint: None,
610            registration: Default::default(),
611            ssh_keys: vec![],
612            cloudflared: None,
613            hosts_operator_bridge: false,
614            connect: None,
615            allocatable: None,
616            taints: vec![],
617            sovereign_group: None,
618            sovereign_role: None,
619            ingress_floating_ip: None,
620        }
621    }
622
623    #[tokio::test]
624    async fn ownership_flip_drives_exactly_one_reassign_call() {
625        let provider = FakeProvider {
626            zone: "us-west",
627            attached_to: Mutex::new(Some("old-node".into())),
628            reassign_calls: AtomicU32::new(0),
629        };
630        let outcome = on_ingress_owner_changed(&provider, &machine("new-node"), "ip-1")
631            .await
632            .unwrap();
633        assert!(outcome.reassigned);
634        assert_eq!(outcome.attached_to, "new-node");
635        assert_eq!(provider.reassign_calls.load(Ordering::SeqCst), 1);
636    }
637
638    #[tokio::test]
639    async fn reapplying_the_same_owner_is_a_zero_call_noop() {
640        let provider = FakeProvider {
641            zone: "us-west",
642            attached_to: Mutex::new(Some("new-node".into())),
643            reassign_calls: AtomicU32::new(0),
644        };
645        let outcome = on_ingress_owner_changed(&provider, &machine("new-node"), "ip-1")
646            .await
647            .unwrap();
648        assert!(!outcome.reassigned);
649        assert_eq!(outcome.attached_to, "new-node");
650        assert_eq!(
651            provider.reassign_calls.load(Ordering::SeqCst),
652            0,
653            "idempotent re-apply must not call reassign"
654        );
655    }
656
657    #[tokio::test]
658    async fn never_assigned_ip_gets_a_first_assign_call() {
659        let provider = FakeProvider {
660            zone: "us-west",
661            attached_to: Mutex::new(None),
662            reassign_calls: AtomicU32::new(0),
663        };
664        let outcome = on_ingress_owner_changed(&provider, &machine("new-node"), "ip-1")
665            .await
666            .unwrap();
667        assert!(outcome.reassigned);
668        assert_eq!(provider.reassign_calls.load(Ordering::SeqCst), 1);
669    }
670
671    #[tokio::test]
672    async fn cross_zone_target_is_rejected_before_any_reassign_call() {
673        let provider = FakeProvider {
674            zone: "eu-central",
675            attached_to: Mutex::new(None),
676            reassign_calls: AtomicU32::new(0),
677        };
678        let target = FloatingIpTarget {
679            attach_id: "new-node".into(),
680            zone: "us-west".into(),
681        };
682        let err = reconcile_assignment(&provider, "ip-1", &target)
683            .await
684            .unwrap_err();
685        let msg = format!("{err:#}");
686        assert!(
687            msg.contains("zone"),
688            "expected a zone-mismatch message, got: {msg}"
689        );
690        assert_eq!(
691            provider.reassign_calls.load(Ordering::SeqCst),
692            0,
693            "zone mismatch must never call reassign"
694        );
695    }
696
697    // ── R859-F2: the registry ─────────────────────────────────────────────
698
699    #[test]
700    fn the_three_shipped_adapters_are_all_reachable_by_provider_id() {
701        for id in ["hetzner", "ovh", "vultr"] {
702            assert!(
703                provider_has_floating_ip_adapter(id),
704                "{id} ships a FloatingIpProvider impl but the registry cannot reach it"
705            );
706        }
707        for id in ["digitalocean", "static", "local-docker", ""] {
708            assert!(!provider_has_floating_ip_adapter(id), "{id}");
709        }
710    }
711
712    /// The declaration error and the environment error are different failures
713    /// wanting different fixes, so they must not collapse into one message.
714    #[test]
715    fn a_provider_with_no_adapter_is_refused_by_name_before_any_credential_lookup() {
716        let mut m = machine("us-west-002");
717        m.provider = "digitalocean".into();
718        let err = match floating_ip_provider_for(&m) {
719            Ok(_) => panic!("digitalocean has no floating-IP adapter but the registry built one"),
720            Err(e) => e,
721        };
722        let msg = format!("{err:#}");
723        assert!(msg.contains("us-west-002"), "{msg}");
724        assert!(msg.contains("digitalocean"), "{msg}");
725        assert!(
726            msg.contains("hetzner") && msg.contains("ovh") && msg.contains("vultr"),
727            "the refusal should name what IS supported: {msg}"
728        );
729    }
730
731    // ── R859-F2: plan_ingress_owner_effect ────────────────────────────────
732
733    fn fleet() -> Vec<MachineConfig> {
734        let mut west = machine("us-west-001");
735        west.provider = "hetzner".into();
736        west.ingress_floating_ip = Some("fip-42".into());
737        let mut east = machine("us-east-001");
738        east.provider = "hetzner".into();
739        east.ingress_floating_ip = Some("fip-42".into());
740        // Declared, but no floating-IP path — the common case.
741        let mesh_only = machine("us-west-002");
742        vec![west, east, mesh_only]
743    }
744
745    fn degraded() -> QuorumHealth {
746        QuorumHealth::Degraded {
747            reason: "quorum AT RISK: 2/3 voters available".into(),
748        }
749    }
750
751    #[test]
752    fn an_ownership_flip_onto_a_machine_with_a_floating_ip_reassigns_it() {
753        let effect = plan_ingress_owner_effect(
754            Some("us-west-001"),
755            Some("us-east-001"),
756            OwnerLiveness::ConfirmedUp,
757            &QuorumHealth::Healthy,
758            &fleet(),
759        );
760        assert_eq!(
761            effect,
762            IngressOwnerEffect::Reassign {
763                machine: "us-east-001".into(),
764                ip_id: "fip-42".into(),
765            }
766        );
767        assert!(effect.is_action());
768    }
769
770    /// Liveness may only ever veto. A freshly-elected leader's tracker is empty,
771    /// so requiring a positive confirm would stall exactly the failover this
772    /// exists to perform — and the `ingress_owner` write is itself evidence the
773    /// node came up and served.
774    #[test]
775    fn an_unconfirmed_new_owner_still_reassigns_because_liveness_may_only_veto() {
776        assert!(matches!(
777            plan_ingress_owner_effect(
778                Some("us-west-001"),
779                Some("us-east-001"),
780                OwnerLiveness::Unconfirmed,
781                &QuorumHealth::Healthy,
782                &fleet(),
783            ),
784            IngressOwnerEffect::Reassign { .. }
785        ));
786    }
787
788    #[test]
789    fn a_new_owner_confirmed_down_is_refused_rather_than_pointed_at() {
790        let effect = plan_ingress_owner_effect(
791            Some("us-west-001"),
792            Some("us-east-001"),
793            OwnerLiveness::ConfirmedDown,
794            &QuorumHealth::Healthy,
795            &fleet(),
796        );
797        assert!(matches!(effect, IngressOwnerEffect::Refuse { .. }), "{effect:?}");
798        assert!(effect.reason().contains("DOWN"), "{}", effect.reason());
799    }
800
801    /// `yubaba-failover.md` pre-check 1, enforced: a reassign takes the IP off
802    /// the old owner, so it is a withdrawal and fails closed.
803    #[test]
804    fn a_degraded_quorum_refuses_the_reassign_and_carries_the_verdicts_reason() {
805        let effect = plan_ingress_owner_effect(
806            Some("us-west-001"),
807            Some("us-east-001"),
808            OwnerLiveness::ConfirmedUp,
809            &degraded(),
810            &fleet(),
811        );
812        assert!(matches!(effect, IngressOwnerEffect::Refuse { .. }), "{effect:?}");
813        assert!(
814            effect.reason().contains("2/3 voters available"),
815            "the refusal must carry the quorum verdict's own reason, got: {}",
816            effect.reason()
817        );
818    }
819
820    /// The other half of decision 3, and the half that is easy to get wrong:
821    /// refusing on a degraded quorum applies to withdrawals, never to
822    /// additions. Nothing here gates an upsert — see
823    /// `diff_apex_records`, whose `upsert` is untouched by every gate.
824    #[test]
825    fn a_machine_with_no_floating_ip_is_a_clean_skip_not_an_error() {
826        let effect = plan_ingress_owner_effect(
827            Some("us-west-001"),
828            Some("us-west-002"),
829            OwnerLiveness::ConfirmedUp,
830            &QuorumHealth::Healthy,
831            &fleet(),
832        );
833        assert!(matches!(effect, IngressOwnerEffect::NoOp { .. }), "{effect:?}");
834        assert!(!effect.is_action());
835        assert!(
836            effect.reason().contains("no floating-IP path"),
837            "{}",
838            effect.reason()
839        );
840    }
841
842    #[test]
843    fn a_steady_healthy_owner_does_nothing() {
844        let effect = plan_ingress_owner_effect(
845            Some("us-east-001"),
846            Some("us-east-001"),
847            OwnerLiveness::ConfirmedUp,
848            &QuorumHealth::Healthy,
849            &fleet(),
850        );
851        assert!(matches!(effect, IngressOwnerEffect::NoOp { .. }), "{effect:?}");
852    }
853
854    /// The cross-provider path: no new owner has been elected, and the one we
855    /// have is confirmed dead. A withdrawal REQUIRES the positive
856    /// `ConfirmedDown`, which is the mirror of the reassign's veto-only rule.
857    #[test]
858    fn a_steady_owner_confirmed_down_is_withdrawn_from_the_apex() {
859        let effect = plan_ingress_owner_effect(
860            Some("us-east-001"),
861            Some("us-east-001"),
862            OwnerLiveness::ConfirmedDown,
863            &QuorumHealth::Healthy,
864            &fleet(),
865        );
866        assert_eq!(
867            effect,
868            IngressOwnerEffect::Withdraw {
869                machine: "us-east-001".into(),
870                reason: "ingress owner us-east-001 is confirmed down by the lease channel".into(),
871            }
872        );
873    }
874
875    #[test]
876    fn a_degraded_quorum_refuses_the_withdrawal_too() {
877        let effect = plan_ingress_owner_effect(
878            Some("us-east-001"),
879            Some("us-east-001"),
880            OwnerLiveness::ConfirmedDown,
881            &degraded(),
882            &fleet(),
883        );
884        assert!(matches!(effect, IngressOwnerEffect::Refuse { .. }), "{effect:?}");
885        assert!(effect.reason().contains("2/3 voters available"), "{}", effect.reason());
886    }
887
888    /// The mismatch R841 saw live: `ingress_owner` carries `/etc/hostname`,
889    /// which is not always the machine name. Guessing here would reassign a
890    /// live public IP onto the wrong box, so an unresolvable owner refuses and
891    /// names both sides.
892    #[test]
893    fn an_ingress_owner_that_names_no_declared_machine_refuses_loudly() {
894        let effect = plan_ingress_owner_effect(
895            Some("us-west-001"),
896            Some("vps-4c1efa56"),
897            OwnerLiveness::ConfirmedUp,
898            &QuorumHealth::Healthy,
899            &fleet(),
900        );
901        assert!(matches!(effect, IngressOwnerEffect::Refuse { .. }), "{effect:?}");
902        let reason = effect.reason();
903        assert!(reason.contains("vps-4c1efa56"), "{reason}");
904        assert!(
905            reason.contains("us-west-001") && reason.contains("us-east-001"),
906            "the refusal must name the declared machines it compared against: {reason}"
907        );
908        assert!(
909            reason.contains("hostname"),
910            "and must explain WHY the two spaces differ: {reason}"
911        );
912    }
913
914    /// `ClearIngressOwner`. There is no detach verb and Tier 1 specifies no safe
915    /// unassigned state, so the IP stays on the last node known to have served —
916    /// the same conclusion `on_ingress_owner_changed`'s doc reaches.
917    #[test]
918    fn clearing_the_ingress_owner_leaves_the_ip_where_it_is() {
919        let effect = plan_ingress_owner_effect(
920            Some("us-east-001"),
921            None,
922            OwnerLiveness::ConfirmedDown,
923            &QuorumHealth::Healthy,
924            &fleet(),
925        );
926        assert!(matches!(effect, IngressOwnerEffect::NoOp { .. }), "{effect:?}");
927        assert!(
928            effect.reason().contains("last-known-good"),
929            "{}",
930            effect.reason()
931        );
932    }
933
934    #[test]
935    fn no_ingress_owner_at_all_is_a_no_op() {
936        assert!(matches!(
937            plan_ingress_owner_effect(
938                None,
939                None,
940                OwnerLiveness::Unconfirmed,
941                &QuorumHealth::Healthy,
942                &fleet(),
943            ),
944            IngressOwnerEffect::NoOp { .. }
945        ));
946    }
947
948    /// First observation after this process started: `previous_owner` is `None`
949    /// but an owner is recorded. That is a change from this planner's point of
950    /// view and must converge the IP rather than wait for a flip that already
951    /// happened — the planner carries no state across ticks, so "unchanged" can
952    /// only ever mean "unchanged since the last tick I saw".
953    #[test]
954    fn a_first_observation_of_an_existing_owner_converges_the_ip() {
955        assert_eq!(
956            plan_ingress_owner_effect(
957                None,
958                Some("us-east-001"),
959                OwnerLiveness::ConfirmedUp,
960                &QuorumHealth::Healthy,
961                &fleet(),
962            ),
963            IngressOwnerEffect::Reassign {
964                machine: "us-east-001".into(),
965                ip_id: "fip-42".into(),
966            },
967            "reconcile_assignment is idempotent, so a redundant converge costs zero \
968             provider calls — but skipping it would leave a stale IP unfixed forever"
969        );
970    }
971}