Skip to main content

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}