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, 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::<DnsRecordDelete>(),
288 VerbDescriptor::for_verb::<DnsZoneList>(),
289 VerbDescriptor::for_verb::<FloatingIpAssign>(),
290 VerbDescriptor::for_verb::<FloatingIpStatus>(),
291 ]
292}
293
294/// Construct the tier-S adapters this process can source live credentials
295/// for from ambient env/vault state alone — no camp-scoped config needed.
296///
297/// R409-T9: this is the "host" half of the classification process W144
298/// describes — rather than every call site (the `yah-mcp` binary, tests,
299/// future hosts) re-deriving "which providers do we have tokens for," the
300/// policy lives once here. A provider with no credentials present is
301/// silently absent from the result (possibly empty) rather than an error —
302/// same graceful-degradation convention as `cloud.yubaba_status` and
303/// friends: the KgToolRegistry ends up simply not offering that provider's
304/// verbs rather than every session erroring at startup for lack of a
305/// Hetzner token.
306///
307/// Excluded on purpose:
308/// - **Cloudflare** (`dns.*` / `cloud.object.*`) — `CloudflareEnvoy` needs an
309/// `account_id`, which today is camp-scoped config
310/// (`.yah/infra/providers/cloudflare.toml`), not ambient env/vault state.
311/// A caller with a `camp_root` can build one directly
312/// (`CloudflareEnvoy::new(token, account_id)`) and register it alongside
313/// this function's output via `KgToolRegistry::with_envoy_adapters`.
314/// - **LocalDocker** — needs a live containerd socket and sits behind the
315/// `local-docker` cargo feature; wiring it in by default would make every
316/// consumer of this function require a reachable containerd, which most
317/// don't have. Same opt-in path as Cloudflare.
318///
319/// Both are natural follow-ups once a caller has the extra context to
320/// build them; nothing about the verb-tool wiring itself is Hetzner/DO-
321/// specific.
322pub fn default_adapters() -> Vec<std::sync::Arc<dyn EnvoyAdapter>> {
323 let mut adapters: Vec<std::sync::Arc<dyn EnvoyAdapter>> = Vec::new();
324
325 if let Ok(driver) = crate::provider::HetznerDriver::from_default_sources() {
326 adapters.push(std::sync::Arc::new(crate::provider::HetznerEnvoy::new(
327 driver,
328 )));
329 }
330
331 if let Ok(Some(token)) = fob::get_or_env("digitalocean-api-token", "DIGITALOCEAN_TOKEN") {
332 let client = crate::provider::DigitalOceanClient::new(token);
333 adapters.push(std::sync::Arc::new(
334 crate::provider::DigitalOceanEnvoy::new(client),
335 ));
336 }
337
338 adapters
339}
340
341#[cfg(test)]
342mod tests {
343 use super::*;
344 use serde_json::json;
345
346 #[test]
347 fn verb_category_str_matches_serde_repr() {
348 for c in [
349 VerbCategory::Cloud,
350 VerbCategory::Dns,
351 VerbCategory::FloatingIp,
352 VerbCategory::Observability,
353 VerbCategory::Ci,
354 VerbCategory::Payments,
355 VerbCategory::Messaging,
356 ] {
357 let serde_str = serde_json::to_string(&c).unwrap();
358 let unquoted = serde_str.trim_matches('"');
359 assert_eq!(unquoted, c.as_str(), "{c:?}");
360 }
361 }
362
363 #[test]
364 fn tier_round_trips_lowercase() {
365 assert_eq!(serde_json::to_string(&Tier::S).unwrap(), "\"s\"");
366 assert_eq!(serde_json::to_string(&Tier::A).unwrap(), "\"a\"");
367 assert_eq!(serde_json::to_string(&Tier::B).unwrap(), "\"b\"");
368 let parsed: Tier = serde_json::from_str("\"s\"").unwrap();
369 assert_eq!(parsed, Tier::S);
370 }
371
372 #[test]
373 fn adapter_flavor_snake_case() {
374 assert_eq!(
375 serde_json::to_string(&AdapterFlavor::OpenApiBound).unwrap(),
376 "\"open_api_bound\""
377 );
378 assert_eq!(
379 serde_json::to_string(&AdapterFlavor::McpBridged).unwrap(),
380 "\"mcp_bridged\""
381 );
382 assert_eq!(
383 serde_json::to_string(&AdapterFlavor::Synthetic).unwrap(),
384 "\"synthetic\""
385 );
386 }
387
388 #[test]
389 fn descriptor_new_accepts_well_prefixed_id() {
390 let d = VerbDescriptor::new(
391 "cloud.vps.create",
392 VerbCategory::Cloud,
393 json!({}),
394 json!({}),
395 );
396 assert_eq!(d.id, "cloud.vps.create");
397 assert_eq!(d.category, VerbCategory::Cloud);
398 }
399
400 #[test]
401 #[should_panic(expected = "must start with")]
402 fn descriptor_new_rejects_mismatched_prefix() {
403 // Debug-only; cargo test runs debug profile.
404 let _ = VerbDescriptor::new(
405 "dns.record.upsert",
406 VerbCategory::Cloud,
407 json!({}),
408 json!({}),
409 );
410 }
411
412 #[test]
413 #[should_panic(expected = "must start with")]
414 fn descriptor_new_rejects_category_substring_without_dot() {
415 // `clouds.x` starts with `cloud` but the next byte is `s`, not `.`.
416 let _ = VerbDescriptor::new("clouds.x", VerbCategory::Cloud, json!({}), json!({}));
417 }
418
419 // A minimal typed verb to exercise `for_verb` under the json-schema feature.
420 #[cfg(feature = "json-schema")]
421 mod feature_gated {
422 use super::*;
423
424 pub struct PingVerb;
425
426 #[derive(serde::Deserialize, schemars::JsonSchema)]
427 #[allow(dead_code)]
428 pub struct PingInput {
429 pub project: String,
430 }
431
432 #[derive(serde::Serialize, schemars::JsonSchema)]
433 #[allow(dead_code)]
434 pub struct PingOutput {
435 pub ok: bool,
436 }
437
438 impl InternalVerb for PingVerb {
439 type Input = PingInput;
440 type Output = PingOutput;
441 const ID: &'static str = "cloud.ping";
442 const CATEGORY: VerbCategory = VerbCategory::Cloud;
443 }
444
445 #[test]
446 fn for_verb_derives_schemas() {
447 let d = VerbDescriptor::for_verb::<PingVerb>();
448 assert_eq!(d.id, "cloud.ping");
449 assert_eq!(d.category, VerbCategory::Cloud);
450 // The input schema must mention the `project` field.
451 assert!(d.input_schema.to_string().contains("project"));
452 assert!(d.output_schema.to_string().contains("ok"));
453 }
454 }
455
456 #[cfg(feature = "json-schema")]
457 #[test]
458 fn known_verb_descriptors_covers_every_implemented_verb() {
459 let ids: Vec<&str> = known_verb_descriptors().iter().map(|d| d.id).collect();
460 for expected in [
461 "cloud.vps.create",
462 "cloud.vps.destroy",
463 "cloud.vps.status",
464 "cloud.object.bucket.create",
465 "cloud.object.bucket.delete",
466 "cloud.object.bucket.exists",
467 "dns.record.upsert",
468 "dns.record.delete",
469 "dns.zone.list",
470 "floating_ip.assign",
471 "floating_ip.status",
472 ] {
473 assert!(ids.contains(&expected), "missing descriptor for {expected}");
474 }
475 assert_eq!(ids.len(), 11, "add new verbs here as they land: {ids:?}");
476 }
477
478 #[cfg(feature = "json-schema")]
479 #[test]
480 fn known_verb_descriptors_all_have_nonempty_schemas() {
481 for d in known_verb_descriptors() {
482 assert!(
483 d.input_schema.is_object(),
484 "{}: input schema not an object",
485 d.id
486 );
487 }
488 }
489
490 #[test]
491 fn default_adapters_never_panics_regardless_of_ambient_env() {
492 // No assertion on count — this runs in a shared process where
493 // HETZNER_API_TOKEN / DIGITALOCEAN_TOKEN may or may not be set by
494 // other tests or the CI environment. The contract under test is
495 // "never panics, never errors" — a credential-less box gets an
496 // empty Vec instead of a startup failure (W144's graceful-
497 // degradation convention).
498 let adapters = default_adapters();
499 for adapter in &adapters {
500 assert!(!adapter.id().is_empty());
501 assert_eq!(adapter.tier(), Tier::S);
502 }
503 }
504}