cloud/envoy.rs
1//! Envoy framework types — `Tier`, `AdapterFlavor`, `VerbCategory`,
2//! `InternalVerb`, `VerbDescriptor`.
3//!
4//! W144 D1–D4 scaffold (R409-T2). Concrete verb structs (e.g. `cloud.vps.create`)
5//! land in R409-T3 onward; this module establishes only the type framework
6//! so adapters and the host can refer to it before any verb is defined.
7//!
8//! ## D1 — schemars-derived schemas
9//!
10//! Verb input/output shapes are Rust types decorated with `serde` +
11//! (optionally) `schemars::JsonSchema`. The trait itself is schema-agnostic
12//! so the `cloud` crate doesn't pull schemars unconditionally — schema
13//! emission lives in [`VerbDescriptor::for_verb`] under the `json-schema`
14//! feature, which is what xtask + the verb-registration entrypoint enable.
15//!
16//! ## D3 — `AdapterFlavor::Synthetic`
17//!
18//! Orthogonal to [`Tier`]: `LocalDockerProvider` is tier-S with adapter
19//! flavor [`AdapterFlavor::Synthetic`]. Tier drives policy (drift, naming,
20//! contract-test scope); flavor drives implementation strategy.
21
22use anyhow::Result;
23use async_trait::async_trait;
24use serde::{de::DeserializeOwned, Deserialize, Serialize};
25
26pub mod ci;
27pub mod cloud_object;
28pub mod cloud_vps;
29pub mod dns_record;
30pub mod floating_ip;
31pub mod messaging;
32pub mod observability;
33pub mod payments;
34
35/// Provider tier — policy bucket (drift handling, naming rules, contract-test
36/// coverage). Cardinality is roughly fixed; new tiers are a deliberate design
37/// move, not an extension point.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
39#[serde(rename_all = "lowercase")]
40pub enum Tier {
41 /// First-class (≤ 7). Full contract tests, drift→deny, no vendor-named
42 /// passthrough. Today: Hetzner, Cloudflare, LocalDocker.
43 S,
44 /// Semi-automated (≤ 25). OpenAPI- or MCP-bound, drift→warn, vendor-named
45 /// passthrough allowed for non-mappable operations.
46 A,
47 /// Bring-your-own (200+). Vendor surface verbatim under
48 /// `mcp__yah__envoy__<id>__<tool>`; no internal-verb mapping; untrusted
49 /// by default (see W145).
50 B,
51}
52
53/// How an adapter is built. Orthogonal to [`Tier`].
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(rename_all = "snake_case")]
56#[non_exhaustive]
57pub enum AdapterFlavor {
58 /// Hand-written Rust against the vendor's SDK or REST API.
59 Native,
60 /// Code-generated from the vendor's OpenAPI/Swagger spec.
61 OpenApiBound,
62 /// Proxy to a vendor-supplied MCP server.
63 McpBridged,
64 /// Local emulation, no upstream vendor (e.g. `LocalDockerProvider`).
65 /// Contract tests apply but drift detection is a no-op — there is no
66 /// upstream to drift from.
67 Synthetic,
68}
69
70/// Verb category — the namespace prefix in `<category>.<verb>`
71/// (e.g. `cloud.vps.create`). Marked non-exhaustive: the catalog grows
72/// through diligence/refine (see W144 §"What the internal contract covers").
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
74// `snake_case` (not `lowercase`) so multi-word variants like `FloatingIp`
75// serialize with the underscore its `as_str()` / verb-id prefix needs
76// (`"floating_ip"`, not `"floatingip"`) — a no-op change for every
77// existing single-word variant (Cloud/Dns/Observability/Ci/Payments/
78// Messaging serialize identically under both).
79#[serde(rename_all = "snake_case")]
80#[non_exhaustive]
81pub enum VerbCategory {
82 /// `cloud.*` — VPS, object storage, networking, firewalls, load balancers.
83 Cloud,
84 /// `dns.*` — record CRUD, zone listing.
85 Dns,
86 /// `floating_ip.*` — provider floating/reserved-IP assign + status
87 /// (R594-F5: raft `ingress_owner` follow-placement for sovereign-tier
88 /// public ingress, W267 §Tier 1).
89 FloatingIp,
90 /// `observability.*` — alerts, incidents, dashboards.
91 Observability,
92 /// `ci.*` — pipelines, artifacts (external CI; yah's own scheduler is qed).
93 Ci,
94 /// `payments.*` — charges, subscriptions, webhook verify (yubaba-side).
95 Payments,
96 /// `messaging.*` — email, SMS, webhook dispatch.
97 Messaging,
98}
99
100impl VerbCategory {
101 /// Lowercase namespace prefix used in verb ids.
102 pub fn as_str(self) -> &'static str {
103 match self {
104 VerbCategory::Cloud => "cloud",
105 VerbCategory::Dns => "dns",
106 VerbCategory::FloatingIp => "floating_ip",
107 VerbCategory::Observability => "observability",
108 VerbCategory::Ci => "ci",
109 VerbCategory::Payments => "payments",
110 VerbCategory::Messaging => "messaging",
111 }
112 }
113}
114
115/// One internal verb. Implementors are zero-sized marker types (e.g.
116/// `pub struct CloudVpsCreate;`); the associated `Input` / `Output` types
117/// carry the wire shape.
118///
119/// Per W144 D1, schemas are derived from the Rust types via `schemars` at
120/// registration time — see [`VerbDescriptor::for_verb`]. The trait itself
121/// stays schema-agnostic so the crate doesn't pull schemars unconditionally.
122///
123/// Example (will land in R409-T3):
124/// ```ignore
125/// pub struct CloudVpsCreate;
126///
127/// #[derive(serde::Deserialize, schemars::JsonSchema)]
128/// pub struct CloudVpsCreateInput { /* ... */ }
129///
130/// #[derive(serde::Serialize, schemars::JsonSchema)]
131/// pub struct CloudVpsCreateOutput { /* ... */ }
132///
133/// impl InternalVerb for CloudVpsCreate {
134/// type Input = CloudVpsCreateInput;
135/// type Output = CloudVpsCreateOutput;
136/// const ID: &'static str = "cloud.vps.create";
137/// const CATEGORY: VerbCategory = VerbCategory::Cloud;
138/// }
139/// ```
140pub trait InternalVerb: 'static {
141 /// Wire input type. Typically derives `Deserialize` + `schemars::JsonSchema`.
142 type Input: DeserializeOwned + Send + 'static;
143 /// Wire output type. Typically derives `Serialize` + `schemars::JsonSchema`.
144 type Output: Serialize + Send + 'static;
145 /// Stable verb id, e.g. `"cloud.vps.create"`. Must begin with
146 /// `Self::CATEGORY.as_str()` followed by `'.'`.
147 const ID: &'static str;
148 /// Verb category — drives namespace and tool naming.
149 const CATEGORY: VerbCategory;
150}
151
152/// Type-erased verb descriptor — what an adapter registers with the host.
153///
154/// D2 puts `supported_verbs: [String]` on the adapter manifest; at load time
155/// the host iterates that list, resolves each id back to its `VerbDescriptor`,
156/// and registers the (id, input_schema, dispatch_fn) tuple under
157/// `mcp__yah__<category>_<verb>` (R409-T9).
158#[derive(Debug, Clone)]
159pub struct VerbDescriptor {
160 /// Stable verb id, e.g. `"cloud.vps.create"`.
161 pub id: &'static str,
162 /// Verb category. Redundant with the id's prefix but cached for routing.
163 pub category: VerbCategory,
164 /// JSON Schema for the verb's request body. Derived from the Rust type
165 /// via `schemars::schema_for!` when constructed via [`Self::for_verb`].
166 pub input_schema: serde_json::Value,
167 /// JSON Schema for the verb's response body.
168 pub output_schema: serde_json::Value,
169}
170
171impl VerbDescriptor {
172 /// Build a descriptor with hand-supplied schemas. Useful for adapters
173 /// whose verbs are expressed as raw JSON Schema (OpenAPI-bound) rather
174 /// than typed Rust structs.
175 ///
176 /// Debug-asserts that `id` is prefixed with `category.as_str() + "."`.
177 pub fn new(
178 id: &'static str,
179 category: VerbCategory,
180 input_schema: serde_json::Value,
181 output_schema: serde_json::Value,
182 ) -> Self {
183 debug_assert!(
184 id.starts_with(category.as_str())
185 && id.as_bytes().get(category.as_str().len()) == Some(&b'.'),
186 "verb id {id:?} must start with {:?} followed by '.'",
187 category.as_str()
188 );
189 Self {
190 id,
191 category,
192 input_schema,
193 output_schema,
194 }
195 }
196
197 /// Build a descriptor from a typed verb by deriving the input/output
198 /// schemas via `schemars::schema_for!`. Requires the `json-schema`
199 /// feature on the `cloud` crate and `JsonSchema` impls on
200 /// `V::Input` / `V::Output`.
201 #[cfg(feature = "json-schema")]
202 pub fn for_verb<V>() -> Self
203 where
204 V: InternalVerb,
205 V::Input: schemars::JsonSchema,
206 V::Output: schemars::JsonSchema,
207 {
208 let input_schema = serde_json::to_value(schemars::schema_for!(V::Input))
209 .expect("schemars schema serializes to Value");
210 let output_schema = serde_json::to_value(schemars::schema_for!(V::Output))
211 .expect("schemars schema serializes to Value");
212 Self::new(V::ID, V::CATEGORY, input_schema, output_schema)
213 }
214}
215
216/// Provider adapter — what the host registers to expose a vendor's verbs.
217///
218/// Each adapter declares which verb ids it supports and accepts dispatch
219/// calls with raw JSON input/output. The host (R409-T9) is responsible for
220/// validating the input against the verb's schemars-derived schema *before*
221/// calling [`EnvoyAdapter::dispatch`]; the adapter only needs to handle
222/// shape errors caused by adapter-side translation (e.g. a `location` string
223/// the adapter doesn't recognise).
224///
225/// Why untyped JSON at the trait boundary instead of typed associated
226/// types: an adapter typically supports several unrelated verbs (Hetzner
227/// implements `cloud.vps.create`, `cloud.vps.destroy`, `cloud.vps.status`).
228/// A single trait that fans out over verb ids keeps registration uniform —
229/// the host stores `Vec<Arc<dyn EnvoyAdapter>>` and routes by id rather
230/// than juggling per-verb generic parameters. Adapter-side, the typed
231/// translation lives in private methods (see `HetznerEnvoy`) where the
232/// JSON is decoded once into `V::Input` and re-encoded from `V::Output`.
233#[async_trait]
234pub trait EnvoyAdapter: Send + Sync {
235 /// Stable provider id, e.g. `"hetzner"`. Used for tool-namespace
236 /// composition (`mcp__yah__envoy__hetzner__<verb>`) and for matching
237 /// against `.yah/envoys/<id>/`.
238 fn id(&self) -> &str;
239 /// Tier this adapter is registered at.
240 fn tier(&self) -> Tier;
241 /// How the adapter is built.
242 fn flavor(&self) -> AdapterFlavor;
243 /// Verb ids this adapter supports. The host calls this once at
244 /// registration time; per W144 D2 the result becomes the adapter's
245 /// "supported_verbs" claim in its manifest.
246 fn supported_verb_ids(&self) -> Vec<&'static str>;
247 /// Dispatch one verb call. `verb_id` is one of the entries in
248 /// [`Self::supported_verb_ids`]; `input` is the raw JSON request body.
249 /// Returns the verb's response body as JSON.
250 ///
251 /// Unsupported ids should return `Err` — they are a programmer error
252 /// (the host shouldn't dispatch unsupported verbs), not a runtime
253 /// vendor failure.
254 async fn dispatch(&self, verb_id: &str, input: serde_json::Value) -> Result<serde_json::Value>;
255}
256
257/// Every [`VerbDescriptor`] for a verb at least one shipped adapter
258/// implements, keyed by [`VerbDescriptor::id`] via the caller.
259///
260/// R409-T9: the host (`KgToolRegistry`) resolves each adapter's
261/// [`EnvoyAdapter::supported_verb_ids`] against this catalog to build the
262/// (schema, dispatch) pair it registers per verb — one call site instead of
263/// every host needing to know the full concrete verb-type list.
264///
265/// Deliberately hand-enumerated rather than reflected: W144 D1 wants the
266/// Rust structs to be the source of truth and adding a verb to the catalog
267/// is a deliberate diligence/refine act (W144 §"What the internal contract
268/// covers"), not something that should silently expand via a derive macro.
269/// Extend this list when a new verb type lands in one of the `envoy::*`
270/// submodules — a verb an adapter claims but that's missing here just
271/// doesn't get registered (see the host's skip-with-warning behavior).
272#[cfg(feature = "json-schema")]
273pub fn known_verb_descriptors() -> Vec<VerbDescriptor> {
274 use cloud_object::{CloudObjectBucketCreate, CloudObjectBucketDelete, CloudObjectBucketExists};
275 use cloud_vps::{CloudVpsCreate, CloudVpsDestroy, CloudVpsStatus};
276 use dns_record::{DnsRecordDelete, DnsRecordList, DnsRecordUpsert, DnsZoneList};
277 use floating_ip::{FloatingIpAssign, FloatingIpStatus};
278
279 vec![
280 VerbDescriptor::for_verb::<CloudVpsCreate>(),
281 VerbDescriptor::for_verb::<CloudVpsDestroy>(),
282 VerbDescriptor::for_verb::<CloudVpsStatus>(),
283 VerbDescriptor::for_verb::<CloudObjectBucketCreate>(),
284 VerbDescriptor::for_verb::<CloudObjectBucketDelete>(),
285 VerbDescriptor::for_verb::<CloudObjectBucketExists>(),
286 VerbDescriptor::for_verb::<DnsRecordUpsert>(),
287 VerbDescriptor::for_verb::<DnsRecordList>(),
288 VerbDescriptor::for_verb::<DnsRecordDelete>(),
289 VerbDescriptor::for_verb::<DnsZoneList>(),
290 VerbDescriptor::for_verb::<FloatingIpAssign>(),
291 VerbDescriptor::for_verb::<FloatingIpStatus>(),
292 ]
293}
294
295/// Construct the tier-S adapters this process can source live credentials
296/// for from ambient env/vault state alone — no camp-scoped config needed.
297///
298/// R409-T9: this is the "host" half of the classification process W144
299/// describes — rather than every call site (the `yah-mcp` binary, tests,
300/// future hosts) re-deriving "which providers do we have tokens for," the
301/// policy lives once here. A provider with no credentials present is
302/// silently absent from the result (possibly empty) rather than an error —
303/// same graceful-degradation convention as `cloud.yubaba_status` and
304/// friends: the KgToolRegistry ends up simply not offering that provider's
305/// verbs rather than every session erroring at startup for lack of a
306/// Hetzner token.
307///
308/// Excluded on purpose:
309/// - **Cloudflare** (`dns.*` / `cloud.object.*`) — `CloudflareEnvoy` needs an
310/// `account_id`, which today is camp-scoped config
311/// (`.yah/infra/providers/cloudflare.toml`), not ambient env/vault state.
312/// A caller with a `camp_root` can build one directly
313/// (`CloudflareEnvoy::new(token, account_id)`) and register it alongside
314/// this function's output via `KgToolRegistry::with_envoy_adapters`.
315/// - **LocalDocker** — needs a live containerd socket and sits behind the
316/// `local-docker` cargo feature; wiring it in by default would make every
317/// consumer of this function require a reachable containerd, which most
318/// don't have. Same opt-in path as Cloudflare.
319///
320/// Both are natural follow-ups once a caller has the extra context to
321/// build them; nothing about the verb-tool wiring itself is Hetzner/DO-
322/// specific.
323pub fn default_adapters() -> Vec<std::sync::Arc<dyn EnvoyAdapter>> {
324 let mut adapters: Vec<std::sync::Arc<dyn EnvoyAdapter>> = Vec::new();
325
326 if let Ok(driver) = crate::provider::HetznerDriver::from_default_sources() {
327 adapters.push(std::sync::Arc::new(crate::provider::HetznerEnvoy::new(
328 driver,
329 )));
330 }
331
332 if let Ok(Some(token)) = fob::get_or_env("digitalocean-api-token", "DIGITALOCEAN_TOKEN") {
333 let client = crate::provider::DigitalOceanClient::new(token);
334 adapters.push(std::sync::Arc::new(
335 crate::provider::DigitalOceanEnvoy::new(client),
336 ));
337 }
338
339 // R859-F2: the three `floating_ip.*` adapters. R594-F5 shipped them as
340 // `EnvoyAdapter` impls and listed their verbs in `known_verb_descriptors`,
341 // but registered none of them here — so `floating_ip.assign` /
342 // `floating_ip.status` were *described* by the catalog and dispatchable by
343 // nothing. A verb the registry cannot reach is a latent bug, not a
344 // half-finished feature, so they are registered on the same
345 // credentials-present-or-silently-absent policy as the two above.
346 //
347 // These carry their own credentials rather than reusing the Hetzner driver's
348 // because each is a purpose-built client scoped to its provider's
349 // floating-IP endpoints (see each adapter's module docs).
350 //
351 // NOTE on OVH: `OvhFloatingIp`'s module doc records that its auth is a
352 // **placeholder** — OVH signs requests with an application key + secret +
353 // consumer key + timestamped HMAC, not a bare header. Registering it makes
354 // the verb dispatchable, which is correct; it does not make it live-ready,
355 // and the real signing scheme must land before anyone points it at
356 // api.ovh.com. Gating on the credential's presence keeps it absent from
357 // every camp that has not deliberately set one.
358 if let Ok(Some(token)) = fob::get_or_env("hetzner-api-token", "HETZNER_API_TOKEN") {
359 adapters.push(std::sync::Arc::new(
360 crate::provider::HetznerFloatingIp::new(token),
361 ));
362 }
363 if let Ok(Some(key)) = fob::get_or_env("ovh-consumer-key", "OVH_CONSUMER_KEY") {
364 adapters.push(std::sync::Arc::new(crate::provider::OvhFloatingIp::new(key)));
365 }
366 if let Ok(Some(key)) = fob::get_or_env("vultr-api-key", "VULTR_API_KEY") {
367 adapters.push(std::sync::Arc::new(crate::provider::VultrFloatingIp::new(
368 key,
369 )));
370 }
371
372 adapters
373}
374
375#[cfg(test)]
376mod tests {
377 use super::*;
378 use serde_json::json;
379
380 #[test]
381 fn verb_category_str_matches_serde_repr() {
382 for c in [
383 VerbCategory::Cloud,
384 VerbCategory::Dns,
385 VerbCategory::FloatingIp,
386 VerbCategory::Observability,
387 VerbCategory::Ci,
388 VerbCategory::Payments,
389 VerbCategory::Messaging,
390 ] {
391 let serde_str = serde_json::to_string(&c).unwrap();
392 let unquoted = serde_str.trim_matches('"');
393 assert_eq!(unquoted, c.as_str(), "{c:?}");
394 }
395 }
396
397 #[test]
398 fn tier_round_trips_lowercase() {
399 assert_eq!(serde_json::to_string(&Tier::S).unwrap(), "\"s\"");
400 assert_eq!(serde_json::to_string(&Tier::A).unwrap(), "\"a\"");
401 assert_eq!(serde_json::to_string(&Tier::B).unwrap(), "\"b\"");
402 let parsed: Tier = serde_json::from_str("\"s\"").unwrap();
403 assert_eq!(parsed, Tier::S);
404 }
405
406 #[test]
407 fn adapter_flavor_snake_case() {
408 assert_eq!(
409 serde_json::to_string(&AdapterFlavor::OpenApiBound).unwrap(),
410 "\"open_api_bound\""
411 );
412 assert_eq!(
413 serde_json::to_string(&AdapterFlavor::McpBridged).unwrap(),
414 "\"mcp_bridged\""
415 );
416 assert_eq!(
417 serde_json::to_string(&AdapterFlavor::Synthetic).unwrap(),
418 "\"synthetic\""
419 );
420 }
421
422 #[test]
423 fn descriptor_new_accepts_well_prefixed_id() {
424 let d = VerbDescriptor::new(
425 "cloud.vps.create",
426 VerbCategory::Cloud,
427 json!({}),
428 json!({}),
429 );
430 assert_eq!(d.id, "cloud.vps.create");
431 assert_eq!(d.category, VerbCategory::Cloud);
432 }
433
434 #[test]
435 #[should_panic(expected = "must start with")]
436 fn descriptor_new_rejects_mismatched_prefix() {
437 // Debug-only; cargo test runs debug profile.
438 let _ = VerbDescriptor::new(
439 "dns.record.upsert",
440 VerbCategory::Cloud,
441 json!({}),
442 json!({}),
443 );
444 }
445
446 #[test]
447 #[should_panic(expected = "must start with")]
448 fn descriptor_new_rejects_category_substring_without_dot() {
449 // `clouds.x` starts with `cloud` but the next byte is `s`, not `.`.
450 let _ = VerbDescriptor::new("clouds.x", VerbCategory::Cloud, json!({}), json!({}));
451 }
452
453 // A minimal typed verb to exercise `for_verb` under the json-schema feature.
454 #[cfg(feature = "json-schema")]
455 mod feature_gated {
456 use super::*;
457
458 pub struct PingVerb;
459
460 #[derive(serde::Deserialize, schemars::JsonSchema)]
461 #[allow(dead_code)]
462 pub struct PingInput {
463 pub project: String,
464 }
465
466 #[derive(serde::Serialize, schemars::JsonSchema)]
467 #[allow(dead_code)]
468 pub struct PingOutput {
469 pub ok: bool,
470 }
471
472 impl InternalVerb for PingVerb {
473 type Input = PingInput;
474 type Output = PingOutput;
475 const ID: &'static str = "cloud.ping";
476 const CATEGORY: VerbCategory = VerbCategory::Cloud;
477 }
478
479 #[test]
480 fn for_verb_derives_schemas() {
481 let d = VerbDescriptor::for_verb::<PingVerb>();
482 assert_eq!(d.id, "cloud.ping");
483 assert_eq!(d.category, VerbCategory::Cloud);
484 // The input schema must mention the `project` field.
485 assert!(d.input_schema.to_string().contains("project"));
486 assert!(d.output_schema.to_string().contains("ok"));
487 }
488 }
489
490 #[cfg(feature = "json-schema")]
491 #[test]
492 fn known_verb_descriptors_covers_every_implemented_verb() {
493 let ids: Vec<&str> = known_verb_descriptors().iter().map(|d| d.id).collect();
494 for expected in [
495 "cloud.vps.create",
496 "cloud.vps.destroy",
497 "cloud.vps.status",
498 "cloud.object.bucket.create",
499 "cloud.object.bucket.delete",
500 "cloud.object.bucket.exists",
501 "dns.record.upsert",
502 "dns.record.list",
503 "dns.record.delete",
504 "dns.zone.list",
505 "floating_ip.assign",
506 "floating_ip.status",
507 ] {
508 assert!(ids.contains(&expected), "missing descriptor for {expected}");
509 }
510 assert_eq!(ids.len(), 12, "add new verbs here as they land: {ids:?}");
511 }
512
513 #[cfg(feature = "json-schema")]
514 #[test]
515 fn known_verb_descriptors_all_have_nonempty_schemas() {
516 for d in known_verb_descriptors() {
517 assert!(
518 d.input_schema.is_object(),
519 "{}: input schema not an object",
520 d.id
521 );
522 }
523 }
524
525 #[test]
526 fn default_adapters_never_panics_regardless_of_ambient_env() {
527 // No assertion on count — this runs in a shared process where
528 // HETZNER_API_TOKEN / DIGITALOCEAN_TOKEN may or may not be set by
529 // other tests or the CI environment. The contract under test is
530 // "never panics, never errors" — a credential-less box gets an
531 // empty Vec instead of a startup failure (W144's graceful-
532 // degradation convention).
533 let adapters = default_adapters();
534 for adapter in &adapters {
535 assert!(!adapter.id().is_empty());
536 assert_eq!(adapter.tier(), Tier::S);
537 }
538 }
539}