Skip to main content

cloud/envoy/
cloud_vps.rs

1//! `cloud.vps.*` verb signatures — the spike catalog (R409-T3).
2//!
3//! Per W144 §"What this doc is *not* deciding" and R409-T11 (catalog-shape
4//! postmortem), the broader `cloud.*` / `dns.*` / etc. surface is gated on
5//! validating this shape against a second native provider (R409-T10:
6//! DigitalOcean). Only `cloud.vps.create`, `cloud.vps.destroy`, and
7//! `cloud.vps.status` land here — the three verbs W144 explicitly calls out
8//! as "already partially present in `MachineProvider`."
9//!
10//! Wire types are plain `serde` structs (with optional `schemars::JsonSchema`
11//! under the `json-schema` feature). They are deliberately **not** the same
12//! as the in-crate domain types ([`crate::provider::ServerSpec`],
13//! [`crate::provider::ServerStatus`]) — the adapter is the boundary that
14//! converts between them. Keeping wire and domain types separate is what
15//! lets the same verb shape serve Hetzner, DigitalOcean, and (eventually)
16//! a synthetic LocalDocker adapter without leaking vendor-specific fields.
17//!
18//! @yah:ticket(R594-F5, "floating_ip.* envoy verb: raft floating-ingress ownership commands the provider IP to follow placement")
19//! @yah:status(review)
20//! @yah:assignee(agent:claude)
21//! @yah:at(2026-07-02T19:56:32Z)
22//! @yah:phase(P4)
23//! @yah:parent(R594)
24//! @yah:next("New envoy verb family alongside dns.* — assign/reassign a provider floating IP (Hetzner floating IP / OVH Additional IP / Vultr reserved IP) to the node that raft says owns floating ingress (raft/mod.rs:8 SetIngressOwner already exists). Provider mobility constraints verified 2026-07 and folded into W267 §Tier 1: Hetzner within network zone, OVH within DC/region, Vultr region-bound. This is the sovereign-tier analog of the external-identity-follows-placement property R591 names for Headscale/CF-Tunnel — LINKED behind R591 (live-peer-owned, never claim) so the two follow-placement mechanisms land coherently, and behind R572-F3 (public-ip taint lives on machine TOML).")
25//! @yah:verify("cargo test -p yah-cloud envoy::floating_ip; fixture: ownership flip drives exactly one reassign call; idempotent re-apply")
26//! @yah:tier(Warrior)
27//! @yah:handoff("Landed the floating_ip.* envoy verb family + Hetzner/OVH/Vultr provider adapters, code+mock-tests only (no live calls, per constraint).\n\nHOMED IN: oss/yubaba/crates/cloud/src/envoy/floating_ip.rs (new module, mirrors dns_record.rs's wire-signature-only shape: FloatingIpAssign/FloatingIpStatus marker types, Input/Output structs, InternalVerb impls). Added VerbCategory::FloatingIp (\"floating_ip\" namespace) to envoy.rs, registered in known_verb_descriptors() (9->11), and fixed VerbCategory's #[serde(rename_all)] from \"lowercase\" to \"snake_case\" (a pre-existing latent bug that only bit once a multi-word variant existed: \"FloatingIp\" lowercased to \"floatingip\" instead of \"floating_ip\" — no-op for the six existing single-word variants).\n\nPROVIDER ABSTRACTION: oss/yubaba/crates/cloud/src/provider/floating_ip.rs defines `FloatingIpProvider` trait (resolve_target/current_assignment/reassign) + `reconcile_assignment` (the shared idempotent+zone-checked core) + `on_ingress_owner_changed` (the Rust-level callable entry point, doc-commented with exactly where leader.rs should call it — not touched, peer-owned). Three adapters, one file each, same shape as hetzner_envoy.rs/digitalocean.rs (thin reqwest client, `with_base_url` override, EnvoyAdapter + FloatingIpProvider impls): hetzner_floating_ip.rs, ovh_floating_ip.rs (new — no prior OVH code existed anywhere in-tree; auth is a placeholder header, flagged for real OVH request-signing before live use), vultr_floating_ip.rs.\n\nMACHINE RESOLUTION: reads existing MachineConfig.location/.region only (no new fields). Hetzner: location (DC code) -> network zone via a small table (hil/ash/fsn1-nbg1-hel1/sin). OVH: location if set else region (today's OVH-labeled machines are provider=\"static\" with region set but no location — R572-F3's problem, not touched). Vultr: location directly (region-bound, no mapping needed) with a sanity cross-check against the live instance's own region.\n\nIDEMPOTENCY: reconcile_assignment fetches current_assignment first; attached_to == target.attach_id -> reassigned:false, zero network calls to the reassign endpoint. Zone mismatch bails before any reassign call. Verified in 3 independent test layers: pure fake-provider unit tests (provider/floating_ip.rs), and per-provider axum in-process mock tests (127.0.0.1:0, stateful Arc<Mutex>+ AtomicU32 call counters — same convention as reconciler/pond.rs and reconciler/static_asset.rs) for Hetzner/OVH/Vultr.\n\nWIRING (deferred, flagged in code): on_ingress_owner_changed's doc comment names the exact call site (leader.rs's raft-reconcile path, on ingress_owner transitioning to Some(machine)) and notes \"which IP is the ingress IP\" needs a small config surface a follow-up should add alongside the wiring, not invented here.\n\nVERIFY: cargo test -p cloud --lib floating_ip -> 27 passed, 0 failed. cargo test -p cloud --lib (full) -> 503 passed, 0 failed, 4 ignored. cargo check -p cloud clean. cargo check -p yubaba clean. Pre-existing unrelated break: cargo test -p cloud (all targets) fails to compile tests/pond_smoke.rs (missing `git` field on ServiceComponent literal) — not touched by this ticket, not caused by this change (file has zero diff from HEAD), looks like another peer's in-flight WIP on the shared tree.\n\nLive-provider verification (real credentials, real reassign against Hetzner/OVH/Vultr) is explicitly deferred to the operator, same as every other cloud envoy adapter.\n\nFiles: oss/yubaba/crates/cloud/src/envoy.rs, oss/yubaba/crates/cloud/src/envoy/floating_ip.rs (new), oss/yubaba/crates/cloud/src/provider/mod.rs, oss/yubaba/crates/cloud/src/provider/floating_ip.rs (new), oss/yubaba/crates/cloud/src/provider/hetzner_floating_ip.rs (new), oss/yubaba/crates/cloud/src/provider/ovh_floating_ip.rs (new), oss/yubaba/crates/cloud/src/provider/vultr_floating_ip.rs (new), oss/yubaba/crates/cloud/src/lib.rs.")
28
29use serde::{Deserialize, Serialize};
30
31use super::{InternalVerb, VerbCategory};
32
33// ── cloud.vps.create ─────────────────────────────────────────────────────
34
35/// Marker type for the `cloud.vps.create` verb.
36pub struct CloudVpsCreate;
37
38/// Request body for `cloud.vps.create`.
39#[derive(Debug, Clone, Serialize, Deserialize)]
40#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
41pub struct CloudVpsCreateInput {
42    /// Server name as it should appear in the provider's UI / API.
43    pub name: String,
44    /// Provider-side machine type slug (e.g. `"cpx22"` on Hetzner,
45    /// `"s-2vcpu-4gb"` on DigitalOcean). Not normalized — vendors disagree
46    /// on shape, so we pass through.
47    pub server_type: String,
48    /// Image slug (e.g. `"debian-12"`).
49    pub image: String,
50    /// Coarse region tag (W144 D5). One of `"na-west"`, `"na-east"`,
51    /// `"eu-central"`; adapters pick the nearest vendor region within the
52    /// tag. New tags follow `<continent>-<direction>` and land monotonically.
53    pub location: String,
54    /// Cloud-init `user_data` script.
55    pub user_data: String,
56    /// Provider-side SSH keys to authorize for `root` at create time.
57    /// Strings — each adapter interprets per its vendor (W144 D7): Hetzner
58    /// expects numeric IDs in string form; DigitalOcean accepts numeric IDs
59    /// or SHA-256 fingerprints. Empty means "no key" — provider behavior on
60    /// missing keys varies (Hetzner emails a random root password;
61    /// DigitalOcean rejects).
62    #[serde(default)]
63    pub ssh_keys: Vec<String>,
64}
65
66/// Response body for `cloud.vps.create`.
67#[derive(Debug, Clone, Serialize, Deserialize)]
68#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
69pub struct CloudVpsCreateOutput {
70    /// Opaque provider-issued id for the new server.
71    pub id: String,
72}
73
74impl InternalVerb for CloudVpsCreate {
75    type Input = CloudVpsCreateInput;
76    type Output = CloudVpsCreateOutput;
77    const ID: &'static str = "cloud.vps.create";
78    const CATEGORY: VerbCategory = VerbCategory::Cloud;
79}
80
81// ── cloud.vps.destroy ────────────────────────────────────────────────────
82
83/// Marker type for the `cloud.vps.destroy` verb.
84pub struct CloudVpsDestroy;
85
86/// Request body for `cloud.vps.destroy`.
87#[derive(Debug, Clone, Serialize, Deserialize)]
88#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
89pub struct CloudVpsDestroyInput {
90    /// Opaque provider-issued id from a prior `cloud.vps.create`.
91    pub id: String,
92}
93
94/// Response body for `cloud.vps.destroy`. Empty — adapters return `Ok({})`
95/// whether the server existed or was already gone (idempotent).
96#[derive(Debug, Clone, Default, Serialize, Deserialize)]
97#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
98pub struct CloudVpsDestroyOutput {}
99
100impl InternalVerb for CloudVpsDestroy {
101    type Input = CloudVpsDestroyInput;
102    type Output = CloudVpsDestroyOutput;
103    const ID: &'static str = "cloud.vps.destroy";
104    const CATEGORY: VerbCategory = VerbCategory::Cloud;
105}
106
107// ── cloud.vps.status ─────────────────────────────────────────────────────
108
109/// Marker type for the `cloud.vps.status` verb.
110pub struct CloudVpsStatus;
111
112/// Request body for `cloud.vps.status`.
113#[derive(Debug, Clone, Serialize, Deserialize)]
114#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
115pub struct CloudVpsStatusInput {
116    /// Opaque provider-issued id from a prior `cloud.vps.create`.
117    pub id: String,
118}
119
120/// Canonical VPS lifecycle phase. Maps onto [`crate::provider::ServerStatus`]
121/// without the `Unknown(String)` payload — the free-form vendor detail rides
122/// in [`CloudVpsStatusOutput::detail`] instead so the wire shape stays a
123/// closed enum that downstream UI can render directly.
124#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
125#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
126#[serde(rename_all = "snake_case")]
127pub enum VpsPhase {
128    Initializing,
129    Starting,
130    Running,
131    Stopping,
132    Off,
133    Deleting,
134    Unknown,
135}
136
137/// Response body for `cloud.vps.status`.
138#[derive(Debug, Clone, Serialize, Deserialize)]
139#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
140pub struct CloudVpsStatusOutput {
141    /// Canonical phase.
142    pub phase: VpsPhase,
143    /// Free-form vendor detail. Populated when `phase` is
144    /// [`VpsPhase::Unknown`] (the adapter saw a status string it didn't
145    /// recognise); may also carry a vendor-side reason string for known
146    /// phases when one is available.
147    #[serde(default, skip_serializing_if = "Option::is_none")]
148    pub detail: Option<String>,
149}
150
151impl InternalVerb for CloudVpsStatus {
152    type Input = CloudVpsStatusInput;
153    type Output = CloudVpsStatusOutput;
154    const ID: &'static str = "cloud.vps.status";
155    const CATEGORY: VerbCategory = VerbCategory::Cloud;
156}
157
158/// Pure conversion: domain [`crate::provider::ServerStatus`] →
159/// wire [`CloudVpsStatusOutput`].
160///
161/// Extracted so both `HetznerEnvoy` and `LocalDockerEnvoy` share one
162/// implementation without pulling the other adapter's module.
163pub fn server_status_to_output(status: crate::provider::ServerStatus) -> CloudVpsStatusOutput {
164    use crate::provider::ServerStatus;
165    let (phase, detail) = match status {
166        ServerStatus::Initializing => (VpsPhase::Initializing, None),
167        ServerStatus::Starting => (VpsPhase::Starting, None),
168        ServerStatus::Running => (VpsPhase::Running, None),
169        ServerStatus::Stopping => (VpsPhase::Stopping, None),
170        ServerStatus::Off => (VpsPhase::Off, None),
171        ServerStatus::Deleting => (VpsPhase::Deleting, None),
172        ServerStatus::Unknown(s) => (VpsPhase::Unknown, Some(s)),
173    };
174    CloudVpsStatusOutput { phase, detail }
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    #[test]
182    fn verb_ids_match_canonical_namespace() {
183        assert_eq!(CloudVpsCreate::ID, "cloud.vps.create");
184        assert_eq!(CloudVpsDestroy::ID, "cloud.vps.destroy");
185        assert_eq!(CloudVpsStatus::ID, "cloud.vps.status");
186        for id in [CloudVpsCreate::ID, CloudVpsDestroy::ID, CloudVpsStatus::ID] {
187            assert!(id.starts_with("cloud."), "{id}");
188        }
189    }
190
191    #[test]
192    fn verbs_are_under_cloud_category() {
193        assert_eq!(CloudVpsCreate::CATEGORY, VerbCategory::Cloud);
194        assert_eq!(CloudVpsDestroy::CATEGORY, VerbCategory::Cloud);
195        assert_eq!(CloudVpsStatus::CATEGORY, VerbCategory::Cloud);
196    }
197
198    #[test]
199    fn create_input_round_trips() {
200        let input = CloudVpsCreateInput {
201            name: "noisetable-na-west-1".into(),
202            server_type: "cpx22".into(),
203            image: "debian-12".into(),
204            location: "na-west".into(),
205            user_data: "#cloud-config\n".into(),
206            ssh_keys: vec!["123".into(), "456".into()],
207        };
208        let wire = serde_json::to_string(&input).unwrap();
209        let back: CloudVpsCreateInput = serde_json::from_str(&wire).unwrap();
210        assert_eq!(back.name, "noisetable-na-west-1");
211        assert_eq!(back.ssh_keys, vec!["123".to_string(), "456".to_string()]);
212    }
213
214    #[test]
215    fn create_input_defaults_ssh_keys_empty() {
216        let wire =
217            r#"{"name":"n","server_type":"t","image":"i","location":"na-west","user_data":""}"#;
218        let parsed: CloudVpsCreateInput = serde_json::from_str(wire).unwrap();
219        assert!(parsed.ssh_keys.is_empty());
220    }
221
222    #[test]
223    fn create_input_rejects_legacy_project_field() {
224        // D6: `project` is no longer a wire field. Inputs that still send it
225        // round-trip cleanly because serde ignores unknown fields by default.
226        let wire = r#"{"project":"old","name":"n","server_type":"t","image":"i","location":"na-west","user_data":""}"#;
227        let parsed: CloudVpsCreateInput = serde_json::from_str(wire).unwrap();
228        assert_eq!(parsed.name, "n");
229    }
230
231    #[test]
232    fn create_input_accepts_fingerprint_ssh_key() {
233        // D7: DigitalOcean accepts SHA-256 fingerprints — wire shape must
234        // pass through arbitrary strings, not just numeric IDs.
235        let wire = r#"{"name":"n","server_type":"t","image":"i","location":"na-west","user_data":"","ssh_keys":["e0:7a:1b:ff:00:11:22:33"]}"#;
236        let parsed: CloudVpsCreateInput = serde_json::from_str(wire).unwrap();
237        assert_eq!(parsed.ssh_keys, vec!["e0:7a:1b:ff:00:11:22:33".to_string()]);
238    }
239
240    #[test]
241    fn destroy_output_serializes_to_empty_object() {
242        let wire = serde_json::to_value(CloudVpsDestroyOutput::default()).unwrap();
243        assert_eq!(wire, serde_json::json!({}));
244    }
245
246    #[test]
247    fn status_phase_is_snake_case() {
248        assert_eq!(
249            serde_json::to_string(&VpsPhase::Initializing).unwrap(),
250            "\"initializing\""
251        );
252        assert_eq!(serde_json::to_string(&VpsPhase::Off).unwrap(), "\"off\"");
253        assert_eq!(
254            serde_json::to_string(&VpsPhase::Unknown).unwrap(),
255            "\"unknown\""
256        );
257    }
258
259    #[test]
260    fn status_output_omits_detail_when_absent() {
261        let out = CloudVpsStatusOutput {
262            phase: VpsPhase::Running,
263            detail: None,
264        };
265        let wire = serde_json::to_value(&out).unwrap();
266        assert_eq!(wire, serde_json::json!({ "phase": "running" }));
267    }
268
269    #[test]
270    fn status_output_includes_detail_when_present() {
271        let out = CloudVpsStatusOutput {
272            phase: VpsPhase::Unknown,
273            detail: Some("rebuilding".into()),
274        };
275        let wire = serde_json::to_value(&out).unwrap();
276        assert_eq!(
277            wire,
278            serde_json::json!({ "phase": "unknown", "detail": "rebuilding" })
279        );
280    }
281
282    #[cfg(feature = "json-schema")]
283    #[test]
284    fn verbs_emit_schemas_via_for_verb() {
285        use super::super::VerbDescriptor;
286
287        let create = VerbDescriptor::for_verb::<CloudVpsCreate>();
288        assert_eq!(create.id, "cloud.vps.create");
289        assert!(create.input_schema.to_string().contains("user_data"));
290
291        let destroy = VerbDescriptor::for_verb::<CloudVpsDestroy>();
292        assert_eq!(destroy.id, "cloud.vps.destroy");
293        assert!(destroy.input_schema.to_string().contains("\"id\""));
294
295        let status = VerbDescriptor::for_verb::<CloudVpsStatus>();
296        assert_eq!(status.id, "cloud.vps.status");
297        assert!(status.output_schema.to_string().contains("phase"));
298    }
299}