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}