Skip to main content

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}