Skip to main content

Module floating_ip

Module floating_ip 

Source
Expand description

Provider-abstracted floating/reserved-IP mobility (R594-F5).

FloatingIpProvider is the domain-level trait each vendor adapter (HetznerFloatingIp, OvhFloatingIp, VultrFloatingIp — sibling modules in this directory) implements. reconcile_assignment is the shared idempotent + zone-checked core all three run through, so the “no-op when already assigned” / “reject a cross-zone move” behavior is written and tested exactly once instead of three times.

on_ingress_owner_changed is the Rust-level callable entry point for R594-F5’s ask: given the raft ingress_owner seam (oss/yubaba/crates/yubaba/src/raft/mod.rs’s YubabaRequest::SetIngressOwner / ClearIngressOwner, RaftAppState::ingress_owner) and the machine it currently names, command the provider floating IP to follow. Wiring this to fire automatically whenever ingress_owner transitions lives in the raft-apply / leadership-reconcile path (oss/yubaba/crates/yubaba/src/leader.rs), which is peer-owned and off-limits to this ticket — see on_ingress_owner_changed’s doc comment for the exact call site a follow-up should add. This is the same “mechanism now, wiring later” shape R594-F3 used for service records.

This mirrors, at the sovereign-ingress tier, the “external identity follows placement” property R591 names for Headscale via a Cloudflare Tunnel. R591 is peer-owned and gated on R570 (real multi-node raft HA); this module is not blocked on either — it builds directly on the ingress_owner seam, which already exists.

@yah:ticket(R859-F2, “Wire floating-ip.* provider adapters to ingress_owner transitions + health-checked DNS withdrawal for dead origins”) @yah:at(2026-09-05T10:22:42Z) @yah:assignee(agent:bundle-anthropic-glimmerstone) @yah:parent(R859) @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).”) @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).”) @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.”) @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.”) @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.”) @yah:handoff(“LANDED, uncommitted. Six pieces. (1) THE BLOCKER, fixed as decided: MemberInfo and YubabaRequest::SetMember each gained machine: Option 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 (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.”) @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.”) @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.”) @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/.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.”) @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).”) @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.”) @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.”) @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).”) @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.”) @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.”) @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.”) @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.”) @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 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.”) @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.”) @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.”) @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.”) @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.”) @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/.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.”)

Structs§

FloatingIpAssignOutcome
Outcome of reconcile_assignment / on_ingress_owner_changed.
FloatingIpState
Current provider-side state of a floating/reserved IP.
FloatingIpTarget
A resolved reassign target: provider-native attach id + the mobility zone it lives in.

Enums§

IngressOwnerEffect
What should happen to public ingress, given an ingress_owner observation.
OwnerLiveness
What a TransitionTracker-style hysteresis says about one machine, crossed into this crate as plain data.
QuorumHealth
Live consensus health, crossed into this crate as plain data.

Traits§

FloatingIpProvider
One provider’s floating/reserved-IP transport + mobility policy.

Functions§

floating_ip_provider_for
Resolve machine.provider to a live FloatingIpProvider — the registry R594-F5 left out.
on_ingress_owner_changed
Callable entry point: react to the raft ingress_owner seam naming machine as the box that now owns public ingress, by commanding ip_id to follow it.
plan_ingress_owner_effect
Decide what public ingress should do about an ingress_owner observation — pure, so the decision is testable as arithmetic and the I/O is somebody else’s problem.
provider_has_floating_ip_adapter
Does provider have a floating-IP adapter at all?
reconcile_assignment
Idempotent, zone-checked core shared by every provider adapter and by on_ingress_owner_changed.
resolve_ingress_owner
Resolve an ingress_owner string to the machine it names.