1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
//! The three vendor floating/reserved-IP HTTP adapters (R594-F5, R859-F3).
//!
//! [`HetznerFloatingIp`], [`OvhFloatingIp`] and [`VultrFloatingIp`] each
//! implement [`floating_ip::FloatingIpProvider`] — resolve a machine to a
//! provider-native attach id, read where the IP lives now, and move it. The
//! idempotency and zone checks are *not* here; they are
//! [`floating_ip::reconcile_assignment`], written once above these three.
//!
//! # Why this is its own crate
//!
//! It was `cloud::provider::{hetzner,ovh,vultr}_floating_ip` until 2026-09-08.
//! R859-F2 extracted the seam into `yah-floating-ip` so `yubaba` could plan a
//! failover, but left the adapters behind — which meant the fleet daemon could
//! *decide* to move an IP and had no way to move it, and
//! `ingress_effector::apply_effect`'s `Reassign` arm returned a `NotApplied`
//! naming its own missing transport. R859-F3 is that transport.
//!
//! Three crates, three jobs, and the split is load-bearing in both directions:
//!
//! - `yah-floating-ip` — the seam, the shared reconcile core, the pure planner.
//! Two dependencies (`anyhow`, `async-trait`) and no more, forever: it links
//! into the fleet daemon, so a `reqwest` in its manifest ships a TLS stack to
//! every node. **Do not solve an adapter problem by adding a dep there.**
//! - **this crate** — a reqwest client per vendor and the wire types it
//! decodes. Owned by neither consumer, which is exactly what lets `cloud` and
//! `yubaba` both link it without either depending on the other.
//! - `yah-cloud` — the `floating_ip.*` envoy verb layer
//! (`cloud::provider::floating_ip_envoy`) and the credentialed constructor
//! `floating_ip_provider_for`, which resolves vault slots through `fob`.
//! Neither an envoy verb catalog nor a credential vault belongs on a fleet
//! node, so neither moved.
//!
//! # The credential is an argument, not a lookup
//!
//! [`adapter_for`] takes the token; it never reads one. `cloud` resolves it
//! from `fob` (an operator laptop with a vault), `yubaba` reads it from a
//! `fob`-injected token file (a fleet node with a mounted secret) — two
//! different rails onto one constructor, and the reason this crate has no
//! opinion about where a secret comes from.
//!
//! # None of this is exercised on today's fleet
//!
//! Every machine in `.yah/infra/machines/*.toml` declares `provider =
//! "static"`, which has no adapter here, and none declares an
//! `ingress_floating_ip` — so `plan_ingress_owner_effect` cannot emit
//! `Reassign` at all. The mock suites below pin the request/response shapes and
//! the refusals; they do not and cannot pin these against a live vendor API.
//! OVH in particular is **not live-ready** — see [`ovh`]'s module doc.
use ;
use FloatingIpProvider;
pub use HetznerFloatingIp;
pub use OvhFloatingIp;
pub use VultrFloatingIp;
/// Build the adapter for `provider`, authenticated with `credential`.
///
/// The one place a provider id becomes a client, so
/// [`FLOATING_IP_PROVIDERS`]'s rows and the constructors that serve them cannot
/// drift apart. Both callers reach it: `cloud::provider::floating_ip_provider_for`
/// after a `fob` vault lookup, and `yubaba`'s ingress effector after reading a
/// mounted token file.
///
/// `credential` is whatever that provider authenticates with — a Hetzner Cloud
/// API token, an OVH consumer key, a Vultr personal access token. Checking it
/// is the vendor's job; an empty one is refused here only because a blank
/// secret is always a configuration error and never an intent.