use anyhow::Result;
use async_trait::async_trait;
use serde::{de::DeserializeOwned, Deserialize, Serialize};
pub mod ci;
pub mod cloud_object;
pub mod cloud_vps;
pub mod dns_record;
pub mod floating_ip;
pub mod messaging;
pub mod observability;
pub mod payments;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Tier {
S,
A,
B,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum AdapterFlavor {
Native,
OpenApiBound,
McpBridged,
Synthetic,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum VerbCategory {
Cloud,
Dns,
FloatingIp,
Observability,
Ci,
Payments,
Messaging,
}
impl VerbCategory {
pub fn as_str(self) -> &'static str {
match self {
VerbCategory::Cloud => "cloud",
VerbCategory::Dns => "dns",
VerbCategory::FloatingIp => "floating_ip",
VerbCategory::Observability => "observability",
VerbCategory::Ci => "ci",
VerbCategory::Payments => "payments",
VerbCategory::Messaging => "messaging",
}
}
}
pub trait InternalVerb: 'static {
type Input: DeserializeOwned + Send + 'static;
type Output: Serialize + Send + 'static;
const ID: &'static str;
const CATEGORY: VerbCategory;
}
#[derive(Debug, Clone)]
pub struct VerbDescriptor {
pub id: &'static str,
pub category: VerbCategory,
pub input_schema: serde_json::Value,
pub output_schema: serde_json::Value,
}
impl VerbDescriptor {
pub fn new(
id: &'static str,
category: VerbCategory,
input_schema: serde_json::Value,
output_schema: serde_json::Value,
) -> Self {
debug_assert!(
id.starts_with(category.as_str())
&& id.as_bytes().get(category.as_str().len()) == Some(&b'.'),
"verb id {id:?} must start with {:?} followed by '.'",
category.as_str()
);
Self {
id,
category,
input_schema,
output_schema,
}
}
#[cfg(feature = "json-schema")]
pub fn for_verb<V>() -> Self
where
V: InternalVerb,
V::Input: schemars::JsonSchema,
V::Output: schemars::JsonSchema,
{
let input_schema = serde_json::to_value(schemars::schema_for!(V::Input))
.expect("schemars schema serializes to Value");
let output_schema = serde_json::to_value(schemars::schema_for!(V::Output))
.expect("schemars schema serializes to Value");
Self::new(V::ID, V::CATEGORY, input_schema, output_schema)
}
}
#[async_trait]
pub trait EnvoyAdapter: Send + Sync {
fn id(&self) -> &str;
fn tier(&self) -> Tier;
fn flavor(&self) -> AdapterFlavor;
fn supported_verb_ids(&self) -> Vec<&'static str>;
async fn dispatch(&self, verb_id: &str, input: serde_json::Value) -> Result<serde_json::Value>;
}
#[cfg(feature = "json-schema")]
pub fn known_verb_descriptors() -> Vec<VerbDescriptor> {
use cloud_object::{CloudObjectBucketCreate, CloudObjectBucketDelete, CloudObjectBucketExists};
use cloud_vps::{CloudVpsCreate, CloudVpsDestroy, CloudVpsStatus};
use dns_record::{DnsRecordDelete, DnsRecordList, DnsRecordUpsert, DnsZoneList};
use floating_ip::{FloatingIpAssign, FloatingIpStatus};
vec![
VerbDescriptor::for_verb::<CloudVpsCreate>(),
VerbDescriptor::for_verb::<CloudVpsDestroy>(),
VerbDescriptor::for_verb::<CloudVpsStatus>(),
VerbDescriptor::for_verb::<CloudObjectBucketCreate>(),
VerbDescriptor::for_verb::<CloudObjectBucketDelete>(),
VerbDescriptor::for_verb::<CloudObjectBucketExists>(),
VerbDescriptor::for_verb::<DnsRecordUpsert>(),
VerbDescriptor::for_verb::<DnsRecordList>(),
VerbDescriptor::for_verb::<DnsRecordDelete>(),
VerbDescriptor::for_verb::<DnsZoneList>(),
VerbDescriptor::for_verb::<FloatingIpAssign>(),
VerbDescriptor::for_verb::<FloatingIpStatus>(),
]
}
pub fn default_adapters() -> Vec<std::sync::Arc<dyn EnvoyAdapter>> {
let mut adapters: Vec<std::sync::Arc<dyn EnvoyAdapter>> = Vec::new();
if let Ok(driver) = crate::provider::HetznerDriver::from_default_sources() {
adapters.push(std::sync::Arc::new(crate::provider::HetznerEnvoy::new(
driver,
)));
}
if let Ok(Some(token)) = fob::get_or_env("digitalocean-api-token", "DIGITALOCEAN_TOKEN") {
let client = crate::provider::DigitalOceanClient::new(token);
adapters.push(std::sync::Arc::new(
crate::provider::DigitalOceanEnvoy::new(client),
));
}
if let Ok(Some(token)) = fob::get_or_env("hetzner-api-token", "HETZNER_API_TOKEN") {
adapters.push(std::sync::Arc::new(
crate::provider::HetznerFloatingIp::new(token),
));
}
if let Ok(Some(key)) = fob::get_or_env("ovh-consumer-key", "OVH_CONSUMER_KEY") {
adapters.push(std::sync::Arc::new(crate::provider::OvhFloatingIp::new(key)));
}
if let Ok(Some(key)) = fob::get_or_env("vultr-api-key", "VULTR_API_KEY") {
adapters.push(std::sync::Arc::new(crate::provider::VultrFloatingIp::new(
key,
)));
}
adapters
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn verb_category_str_matches_serde_repr() {
for c in [
VerbCategory::Cloud,
VerbCategory::Dns,
VerbCategory::FloatingIp,
VerbCategory::Observability,
VerbCategory::Ci,
VerbCategory::Payments,
VerbCategory::Messaging,
] {
let serde_str = serde_json::to_string(&c).unwrap();
let unquoted = serde_str.trim_matches('"');
assert_eq!(unquoted, c.as_str(), "{c:?}");
}
}
#[test]
fn tier_round_trips_lowercase() {
assert_eq!(serde_json::to_string(&Tier::S).unwrap(), "\"s\"");
assert_eq!(serde_json::to_string(&Tier::A).unwrap(), "\"a\"");
assert_eq!(serde_json::to_string(&Tier::B).unwrap(), "\"b\"");
let parsed: Tier = serde_json::from_str("\"s\"").unwrap();
assert_eq!(parsed, Tier::S);
}
#[test]
fn adapter_flavor_snake_case() {
assert_eq!(
serde_json::to_string(&AdapterFlavor::OpenApiBound).unwrap(),
"\"open_api_bound\""
);
assert_eq!(
serde_json::to_string(&AdapterFlavor::McpBridged).unwrap(),
"\"mcp_bridged\""
);
assert_eq!(
serde_json::to_string(&AdapterFlavor::Synthetic).unwrap(),
"\"synthetic\""
);
}
#[test]
fn descriptor_new_accepts_well_prefixed_id() {
let d = VerbDescriptor::new(
"cloud.vps.create",
VerbCategory::Cloud,
json!({}),
json!({}),
);
assert_eq!(d.id, "cloud.vps.create");
assert_eq!(d.category, VerbCategory::Cloud);
}
#[test]
#[should_panic(expected = "must start with")]
fn descriptor_new_rejects_mismatched_prefix() {
let _ = VerbDescriptor::new(
"dns.record.upsert",
VerbCategory::Cloud,
json!({}),
json!({}),
);
}
#[test]
#[should_panic(expected = "must start with")]
fn descriptor_new_rejects_category_substring_without_dot() {
let _ = VerbDescriptor::new("clouds.x", VerbCategory::Cloud, json!({}), json!({}));
}
#[cfg(feature = "json-schema")]
mod feature_gated {
use super::*;
pub struct PingVerb;
#[derive(serde::Deserialize, schemars::JsonSchema)]
#[allow(dead_code)]
pub struct PingInput {
pub project: String,
}
#[derive(serde::Serialize, schemars::JsonSchema)]
#[allow(dead_code)]
pub struct PingOutput {
pub ok: bool,
}
impl InternalVerb for PingVerb {
type Input = PingInput;
type Output = PingOutput;
const ID: &'static str = "cloud.ping";
const CATEGORY: VerbCategory = VerbCategory::Cloud;
}
#[test]
fn for_verb_derives_schemas() {
let d = VerbDescriptor::for_verb::<PingVerb>();
assert_eq!(d.id, "cloud.ping");
assert_eq!(d.category, VerbCategory::Cloud);
assert!(d.input_schema.to_string().contains("project"));
assert!(d.output_schema.to_string().contains("ok"));
}
}
#[cfg(feature = "json-schema")]
#[test]
fn known_verb_descriptors_covers_every_implemented_verb() {
let ids: Vec<&str> = known_verb_descriptors().iter().map(|d| d.id).collect();
for expected in [
"cloud.vps.create",
"cloud.vps.destroy",
"cloud.vps.status",
"cloud.object.bucket.create",
"cloud.object.bucket.delete",
"cloud.object.bucket.exists",
"dns.record.upsert",
"dns.record.list",
"dns.record.delete",
"dns.zone.list",
"floating_ip.assign",
"floating_ip.status",
] {
assert!(ids.contains(&expected), "missing descriptor for {expected}");
}
assert_eq!(ids.len(), 12, "add new verbs here as they land: {ids:?}");
}
#[cfg(feature = "json-schema")]
#[test]
fn known_verb_descriptors_all_have_nonempty_schemas() {
for d in known_verb_descriptors() {
assert!(
d.input_schema.is_object(),
"{}: input schema not an object",
d.id
);
}
}
#[test]
fn default_adapters_never_panics_regardless_of_ambient_env() {
let adapters = default_adapters();
for adapter in &adapters {
assert!(!adapter.id().is_empty());
assert_eq!(adapter.tier(), Tier::S);
}
}
}