Skip to main content

stackless_integrations/providers/cloudflare/
hyperdrive.rs

1//! Cloudflare Hyperdrive — pooled/cached access to an external SQL origin
2//! (`cloudflare/hyperdrive`).
3//!
4//! Models the required origin-connection fields. Optional tuning (caching, mTLS,
5//! Access) can be added as further `Option` fields later — the catalog schema
6//! validates a subset, so required-only provisions cleanly.
7
8use std::collections::BTreeMap;
9
10use serde::Serialize;
11use stackless_stripe_projects::catalog::verify::CatalogService;
12use stackless_stripe_projects::provision::ProvisionContext;
13
14use super::CloudflareResource;
15use crate::error::IntegrationError;
16use crate::hostable::{ConfigScope, Hostable, IntegrationHosting};
17use crate::registry;
18
19pub const RESOURCE_KIND: &str = "integration-cloudflare-hyperdrive";
20
21#[derive(Debug, Serialize)]
22pub struct HyperdriveConfig {
23    pub name: String,
24    pub database: String,
25    pub host: String,
26    pub password: String,
27    pub port: i64,
28    pub scheme: String,
29    pub user: String,
30}
31
32impl CatalogService for HyperdriveConfig {
33    const REFERENCE: &'static str = "cloudflare/hyperdrive";
34}
35
36#[derive(Debug)]
37pub struct CloudflareHyperdrive;
38
39impl Hostable for CloudflareHyperdrive {
40    const PROVIDER: &'static str = "cloudflare-hyperdrive";
41    const HOSTING: IntegrationHosting = IntegrationHosting::Managed;
42    const CONFIG_SCOPE: ConfigScope = ConfigScope::GlobalOnly;
43    const RESOURCE_KIND: &'static str = RESOURCE_KIND;
44    const OUTPUTS: &'static [&'static str] = &["hyperdrive_id", "connection_string", "account_id"];
45}
46
47impl CloudflareResource for CloudflareHyperdrive {
48    type Config = HyperdriveConfig;
49    const PROVIDER_PREFIX: &'static str = "CLOUDFLARE";
50    // Best-guess; unverified (Hyperdrive needs a real origin DB, so it is not in
51    // the live smoke). Pinned when first provisioned against a real origin.
52    const OUTPUT_FIELDS: &'static [(&'static str, &'static str, bool)] = &[
53        ("HYPERDRIVE_ID", "hyperdrive_id", true),
54        ("CONNECTION_STRING", "connection_string", false),
55        ("ACCOUNT_ID", "account_id", false),
56    ];
57
58    fn build_config(ctx: &ProvisionContext<'_>) -> Result<HyperdriveConfig, IntegrationError> {
59        let config = super::integration_config(ctx)?;
60        Ok(HyperdriveConfig {
61            name: super::interp_required(ctx, &config, "name")?,
62            database: super::interp_required(ctx, &config, "database")?,
63            host: super::interp_required(ctx, &config, "host")?,
64            password: super::interp_required(ctx, &config, "password")?,
65            port: super::int_required(ctx, &config, "port")?,
66            scheme: super::interp_required(ctx, &config, "scheme")?,
67            user: super::interp_required(ctx, &config, "user")?,
68        })
69    }
70}
71
72pub fn validate_config(
73    name: &str,
74    config: &BTreeMap<String, toml::Value>,
75) -> Result<(), IntegrationError> {
76    for key in ["name", "database", "host", "password", "scheme", "user"] {
77        registry::config_string(config, key).map_err(|err| IntegrationError::ConfigInvalid {
78            location: format!("integrations.{name}.{key}"),
79            detail: err.to_string(),
80        })?;
81    }
82    if config
83        .get("port")
84        .and_then(toml::Value::as_integer)
85        .is_none()
86    {
87        return Err(IntegrationError::ConfigInvalid {
88            location: format!("integrations.{name}.port"),
89            detail: "port is required and must be an integer".into(),
90        });
91    }
92    Ok(())
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98    use crate::ProviderOps;
99    use crate::resource::ResourcePayload as CloudflarePayload;
100    use stackless_core::def::StackDef;
101    use stackless_stripe_projects::stripe::{CommandOutput, StripeProjects};
102    use stackless_stripe_projects::test_support::ScriptedRunner;
103
104    fn out(stdout: &str) -> CommandOutput {
105        CommandOutput {
106            status: 0,
107            stdout: stdout.to_owned(),
108            stderr: String::new(),
109        }
110    }
111
112    fn sample() -> HyperdriveConfig {
113        HyperdriveConfig {
114            name: "stackless-hd".into(),
115            database: "app".into(),
116            host: "db.example.com".into(),
117            password: "secret".into(),
118            port: 5432,
119            scheme: "postgres".into(),
120            user: "app".into(),
121        }
122    }
123
124    #[test]
125    fn hyperdrive_config_matches_catalog() {
126        const FIXTURE: &str = include_str!(concat!(
127            env!("CARGO_MANIFEST_DIR"),
128            "/../stackless-stripe-projects/tests/fixtures/catalog.json"
129        ));
130        let catalog = stackless_stripe_projects::Catalog::from_json_envelope(FIXTURE).unwrap();
131        let failures = stackless_stripe_projects::verify_service(&catalog, &sample());
132        assert!(
133            failures.is_empty(),
134            "cloudflare/hyperdrive catalog gaps:\n{}",
135            failures.join("\n")
136        );
137    }
138
139    const HYPERDRIVE_CATALOG_ENVELOPE: &str = r#"{"ok":true,"command":"projects catalog","data":{
140        "last_updated":"2026-06-16T00:00:00Z","services":[{
141            "id":"prvsvc_hd","object":"v2.provisioning.provider_service_detail",
142            "provider_id":"prvdr_cloudflare","provider_name":"Cloudflare","service_id":"hyperdrive",
143            "categories":["database"],"kind":"deployable","scope":"project","availability":"available",
144            "development":false,"livemode":true,"pricing":{"type":"component"},
145            "configuration_schema":{"type":"object",
146                "required":["name","database","host","password","port","scheme","user"],
147                "additionalProperties":false,
148                "properties":{"name":{"type":"string"},"database":{"type":"string"},
149                    "host":{"type":"string"},"password":{"type":"string"},"port":{"type":"integer"},
150                    "scheme":{"type":"string","enum":["postgres","postgresql","mysql"]},
151                    "user":{"type":"string"}}}
152        }]}}"#;
153
154    fn test_def() -> StackDef {
155        StackDef::parse(
156            r#"
157[stack]
158name = "atto"
159[stack.projects.stripe]
160project = "project_1"
161[integrations.hd]
162provider = "cloudflare-hyperdrive"
163name = "${stack.name}-hd"
164database = "app"
165host = "db.example.com"
166password = "secret"
167port = 5432
168scheme = "postgres"
169user = "app"
170[services.api]
171source = { repo = "r", ref = "main" }
172env = { HD_URL = "${integrations.hd.connection_string}" }
173health = { path = "/health" }
174[services.api.local]
175run = "true"
176"#,
177        )
178        .unwrap()
179    }
180
181    #[tokio::test]
182    async fn provision_hyperdrive_records_outputs() {
183        let runner = ScriptedRunner::new(vec![
184            out(HYPERDRIVE_CATALOG_ENVELOPE),
185            out(r#"{"ok":true,"data":{"project":{"id":"project_1"}}}"#),
186            out(r#"{"ok":true,"data":{"environments":[{"name":"demo"}]}}"#),
187            out(r#"{"ok":true,"data":null}"#),
188            out(r#"{"ok":true,"data":{"services":[]}}"#),
189            out(&serde_json::json!({"ok":true,"data":{"variables":{
190                "CLOUDFLARE_HYPERDRIVE_ID": "hd_123",
191                "CLOUDFLARE_CONNECTION_STRING": "postgresql://hyperdrive/atto"
192            }}})
193            .to_string()),
194            out(r#"{"ok":true,"data":null}"#),
195            out(r#"{"ok":true,"data":null}"#),
196        ]);
197        let dir = tempfile::tempdir().unwrap();
198        std::fs::write(
199            dir.path().join("stackless.toml"),
200            "[stack]\nname=\"atto\"\n",
201        )
202        .unwrap();
203        let stripe = StripeProjects::new(&runner, dir.path());
204
205        let resource = CloudflareHyperdrive
206            .provision(
207                &stripe.as_dyn(),
208                &test_def(),
209                dir.path(),
210                "demo",
211                "hd",
212                "local",
213                false,
214            )
215            .await
216            .unwrap();
217        assert_eq!(resource.resource_kind, "integration-cloudflare-hyperdrive");
218        let payload: CloudflarePayload = serde_json::from_str(&resource.payload).unwrap();
219        assert_eq!(payload.outputs["hyperdrive_id"], "hd_123");
220        assert_eq!(
221            payload.outputs["connection_string"],
222            "postgresql://hyperdrive/atto"
223        );
224
225        // The add config must serialize `port` as an integer (catalog schema).
226        let add = runner
227            .calls()
228            .into_iter()
229            .find(|c| c.first().map(String::as_str) == Some("add"))
230            .unwrap();
231        let cfg_idx = add.iter().position(|a| a == "--config").unwrap();
232        let cfg: serde_json::Value = serde_json::from_str(&add[cfg_idx + 1]).unwrap();
233        assert_eq!(cfg["port"], serde_json::json!(5432));
234    }
235}