cloud/envoy/floating_ip.rs
1//! `floating_ip.*` verb signatures (R594-F5).
2//!
3//! Two verbs, mirroring the `dns.*` shape (`envoy/dns_record.rs`):
4//! `floating_ip.assign` moves a provider floating/reserved IP to a resolved
5//! target, `floating_ip.status` reports where it lives today.
6//!
7//! This is the sovereign-tier ingress analog of the "external identity
8//! follows placement" property [R591](yah://arch/symbol/R591) names for
9//! Headscale via a Cloudflare Tunnel. R591 is peer-owned and gated on R570
10//! (real multi-node raft HA, not yet up) — `floating_ip.*` is **not**
11//! blocked on either: it builds directly on the raft `ingress_owner` seam,
12//! which already exists and already has a passing test —
13//! `oss/yubaba/crates/yubaba/src/raft/mod.rs`'s `YubabaRequest::SetIngressOwner`
14//! / `YubabaRequest::ClearIngressOwner` and `RaftAppState::ingress_owner`
15//! (raft/mod.rs is read-only from this ticket's side; it is not modified
16//! here).
17//!
18//! ## Wire shape: already-resolved target, not a bare machine name
19//!
20//! `FloatingIpAssignInput` takes `attach_id` + `zone` rather than a machine
21//! name — the same "adapter is the boundary, caller supplies resolved-enough
22//! data" split `cloud.vps.create`'s `location` coarse-region tag uses. The
23//! machine-TOML → `(attach_id, zone)` resolution is provider-specific
24//! (Hetzner needs a live name→numeric-server-id lookup; OVH assumes
25//! serviceName == machine name; Vultr looks up by instance label) and lives
26//! on each adapter's [`crate::provider::FloatingIpProvider::resolve_target`]
27//! impl, not the wire layer — matching the existing wire-vs-domain split
28//! documented on [`super::cloud_vps`].
29//!
30//! The Rust-level entry point that *does* start from a machine (for the
31//! raft-reconcile caller, which already has a `MachineConfig` in hand and
32//! has no reason to round-trip through JSON) is
33//! [`crate::provider::on_ingress_owner_changed`] — see its doc comment for
34//! exactly where that gets wired to fire automatically on an
35//! `ingress_owner` change.
36//!
37//! ## Provider mobility constraints
38//!
39//! Verified 2026-07, folded into W267 §Tier 1
40//! (`.yah/docs/working/W267-sovereign-public-ingress.md`):
41//!
42//! - **Hetzner** floating IPs reassign via API within a **network zone**
43//! (+ same project — a single envoy is already scoped to one Hetzner
44//! project by convention, so cross-project moves are not a case this
45//! verb needs to handle).
46//! - **OVH** Additional IPs move via API within a **datacentre/country
47//! region** (the eu-west GRA/RBX/SBG trio has cross-DC flexibility
48//! *within* that region, per W267).
49//! - **Vultr** reserved IPs are **region-bound** (BGP-implemented inside
50//! AS20473).
51//!
52//! `floating_ip.assign` is idempotent — reassigning to the IP's current
53//! target is a no-op (zero calls to the provider's reassign endpoint), and
54//! a cross-zone target is rejected with an error *before* any reassign call
55//! is attempted. See [`crate::provider::reconcile_assignment`] for the
56//! shared core all three providers run through.
57
58use serde::{Deserialize, Serialize};
59
60use super::{InternalVerb, VerbCategory};
61
62// ── floating_ip.assign ────────────────────────────────────────────────────
63
64/// Marker type for the `floating_ip.assign` verb.
65pub struct FloatingIpAssign;
66
67/// Request body for `floating_ip.assign`.
68#[derive(Debug, Clone, Serialize, Deserialize)]
69#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
70pub struct FloatingIpAssignInput {
71 /// Provider-issued floating/reserved IP identifier (Hetzner numeric id
72 /// as a string; OVH IP address, e.g. `"51.81.85.200"`; Vultr
73 /// reserved-ip UUID).
74 pub ip_id: String,
75 /// Provider-native attach target the IP should point at — Hetzner
76 /// numeric server id, OVH serviceName, Vultr instance UUID. Resolved
77 /// by the caller (see module docs); the adapter treats it as opaque,
78 /// the same convention `cloud.vps.destroy`'s `id` field uses.
79 pub attach_id: String,
80 /// Mobility zone `attach_id` lives in (Hetzner network zone / OVH
81 /// datacentre-region / Vultr region). Checked against the floating
82 /// IP's home zone before any reassign call — a mismatch is a hard
83 /// error, never a silent fallback (the provider physically cannot
84 /// move the IP there).
85 pub zone: String,
86}
87
88/// Response body for `floating_ip.assign`.
89#[derive(Debug, Clone, Serialize, Deserialize)]
90#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
91pub struct FloatingIpAssignOutput {
92 /// `true` iff a reassign call was actually issued to the provider.
93 /// `false` means the IP was already pointed at `attach_id` — an
94 /// idempotent no-op, not an error.
95 pub reassigned: bool,
96 /// The attach target the IP now points at. Equals the input
97 /// `attach_id` on success, whether or not a network call was needed.
98 pub attached_to: String,
99}
100
101impl InternalVerb for FloatingIpAssign {
102 type Input = FloatingIpAssignInput;
103 type Output = FloatingIpAssignOutput;
104 const ID: &'static str = "floating_ip.assign";
105 const CATEGORY: VerbCategory = VerbCategory::FloatingIp;
106}
107
108// ── floating_ip.status ────────────────────────────────────────────────────
109
110/// Marker type for the `floating_ip.status` verb.
111pub struct FloatingIpStatus;
112
113/// Request body for `floating_ip.status`.
114#[derive(Debug, Clone, Serialize, Deserialize)]
115#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
116pub struct FloatingIpStatusInput {
117 /// Provider-issued floating/reserved IP identifier.
118 pub ip_id: String,
119}
120
121/// Response body for `floating_ip.status`.
122#[derive(Debug, Clone, Serialize, Deserialize)]
123#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
124pub struct FloatingIpStatusOutput {
125 /// The IP's home mobility zone — fixed for the IP's lifetime; this is
126 /// the boundary the provider's API enforces on `floating_ip.assign`.
127 pub zone: String,
128 /// Provider-native id of whatever the IP is currently attached to, if
129 /// anything.
130 #[serde(default, skip_serializing_if = "Option::is_none")]
131 pub attached_to: Option<String>,
132}
133
134impl InternalVerb for FloatingIpStatus {
135 type Input = FloatingIpStatusInput;
136 type Output = FloatingIpStatusOutput;
137 const ID: &'static str = "floating_ip.status";
138 const CATEGORY: VerbCategory = VerbCategory::FloatingIp;
139}
140
141#[cfg(test)]
142mod tests {
143 use super::*;
144
145 #[test]
146 fn verb_ids_match_canonical_namespace() {
147 assert_eq!(FloatingIpAssign::ID, "floating_ip.assign");
148 assert_eq!(FloatingIpStatus::ID, "floating_ip.status");
149 for id in [FloatingIpAssign::ID, FloatingIpStatus::ID] {
150 assert!(id.starts_with("floating_ip."), "{id}");
151 }
152 }
153
154 #[test]
155 fn verbs_are_under_floating_ip_category() {
156 assert_eq!(FloatingIpAssign::CATEGORY, VerbCategory::FloatingIp);
157 assert_eq!(FloatingIpStatus::CATEGORY, VerbCategory::FloatingIp);
158 }
159
160 #[test]
161 fn assign_input_round_trips() {
162 let input = FloatingIpAssignInput {
163 ip_id: "42".into(),
164 attach_id: "123".into(),
165 zone: "eu-central".into(),
166 };
167 let wire = serde_json::to_string(&input).unwrap();
168 let back: FloatingIpAssignInput = serde_json::from_str(&wire).unwrap();
169 assert_eq!(back.ip_id, "42");
170 assert_eq!(back.attach_id, "123");
171 assert_eq!(back.zone, "eu-central");
172 }
173
174 #[test]
175 fn assign_output_round_trips() {
176 let out = FloatingIpAssignOutput {
177 reassigned: true,
178 attached_to: "123".into(),
179 };
180 let wire = serde_json::to_value(&out).unwrap();
181 assert_eq!(
182 wire,
183 serde_json::json!({ "reassigned": true, "attached_to": "123" })
184 );
185 }
186
187 #[test]
188 fn status_output_omits_attached_to_when_absent() {
189 let out = FloatingIpStatusOutput {
190 zone: "us-east".into(),
191 attached_to: None,
192 };
193 let wire = serde_json::to_value(&out).unwrap();
194 assert_eq!(wire, serde_json::json!({ "zone": "us-east" }));
195 }
196
197 #[test]
198 fn status_output_includes_attached_to_when_present() {
199 let out = FloatingIpStatusOutput {
200 zone: "us-east".into(),
201 attached_to: Some("123".into()),
202 };
203 let wire = serde_json::to_value(&out).unwrap();
204 assert_eq!(
205 wire,
206 serde_json::json!({ "zone": "us-east", "attached_to": "123" })
207 );
208 }
209
210 #[cfg(feature = "json-schema")]
211 #[test]
212 fn verbs_emit_schemas_via_for_verb() {
213 use super::super::VerbDescriptor;
214
215 let assign = VerbDescriptor::for_verb::<FloatingIpAssign>();
216 assert_eq!(assign.id, "floating_ip.assign");
217 assert!(assign.input_schema.to_string().contains("attach_id"));
218
219 let status = VerbDescriptor::for_verb::<FloatingIpStatus>();
220 assert_eq!(status.id, "floating_ip.status");
221 assert!(status.output_schema.to_string().contains("zone"));
222 }
223}