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", "account_id", "name"];
45}
46
47impl CloudflareResource for CloudflareHyperdrive {
48    type Config = HyperdriveConfig;
49    const PROVIDER_PREFIX: &'static str = "CLOUDFLARE";
50    const OUTPUT_FIELDS: &'static [(&'static str, &'static str, bool)] = &[
51        ("HYPERDRIVE_ID", "hyperdrive_id", true),
52        ("ACCOUNT_ID", "account_id", false),
53        ("NAME", "name", false),
54    ];
55
56    fn build_config(ctx: &ProvisionContext<'_>) -> Result<HyperdriveConfig, IntegrationError> {
57        let config = super::integration_config(ctx)?;
58        Ok(HyperdriveConfig {
59            name: super::interp_required(ctx, &config, "name")?,
60            database: super::interp_required(ctx, &config, "database")?,
61            host: super::interp_required(ctx, &config, "host")?,
62            password: super::interp_required(ctx, &config, "password")?,
63            port: super::int_required(ctx, &config, "port")?,
64            scheme: super::interp_required(ctx, &config, "scheme")?,
65            user: super::interp_required(ctx, &config, "user")?,
66        })
67    }
68}
69
70pub fn validate_config(
71    name: &str,
72    config: &BTreeMap<String, toml::Value>,
73) -> Result<(), IntegrationError> {
74    for key in ["name", "database", "host", "password", "scheme", "user"] {
75        registry::config_string(config, key).map_err(|err| IntegrationError::ConfigInvalid {
76            location: format!("integrations.{name}.{key}"),
77            detail: err.to_string(),
78        })?;
79    }
80    if config
81        .get("port")
82        .and_then(toml::Value::as_integer)
83        .is_none()
84    {
85        return Err(IntegrationError::ConfigInvalid {
86            location: format!("integrations.{name}.port"),
87            detail: "port is required and must be an integer".into(),
88        });
89    }
90    Ok(())
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96    use crate::ProviderOps;
97    use crate::resource::ResourcePayload as CloudflarePayload;
98    use stackless_core::def::StackDef;
99    use stackless_stripe_projects::stripe::{CommandOutput, StripeProjects};
100    use stackless_stripe_projects::test_support::ScriptedRunner;
101
102    fn out(stdout: &str) -> CommandOutput {
103        CommandOutput {
104            status: 0,
105            stdout: stdout.to_owned(),
106            stderr: String::new(),
107        }
108    }
109
110    fn sample() -> HyperdriveConfig {
111        HyperdriveConfig {
112            name: "stackless-hd".into(),
113            database: "app".into(),
114            host: "db.example.com".into(),
115            password: "secret".into(),
116            port: 5432,
117            scheme: "postgres".into(),
118            user: "app".into(),
119        }
120    }
121
122    #[test]
123    fn hyperdrive_config_matches_catalog() {
124        const FIXTURE: &str = include_str!(concat!(
125            env!("CARGO_MANIFEST_DIR"),
126            "/../stackless-stripe-projects/tests/fixtures/catalog.json"
127        ));
128        let catalog = stackless_stripe_projects::Catalog::from_json_envelope(FIXTURE).unwrap();
129        let failures = stackless_stripe_projects::verify_service(&catalog, &sample());
130        assert!(
131            failures.is_empty(),
132            "cloudflare/hyperdrive catalog gaps:\n{}",
133            failures.join("\n")
134        );
135    }
136
137    const HYPERDRIVE_CATALOG_ENVELOPE: &str = r#"{"ok":true,"command":"projects catalog","data":{
138        "last_updated":"2026-06-16T00:00:00Z","services":[{
139            "id":"prvsvc_hd","object":"v2.provisioning.provider_service_detail",
140            "provider_id":"prvdr_cloudflare","provider_name":"Cloudflare","service_id":"hyperdrive",
141            "categories":["database"],"kind":"deployable","scope":"project","availability":"available",
142            "development":false,"livemode":true,"pricing":{"type":"component"},
143            "configuration_schema":{"type":"object",
144                "required":["name","database","host","password","port","scheme","user"],
145                "additionalProperties":false,
146                "properties":{"name":{"type":"string"},"database":{"type":"string"},
147                    "host":{"type":"string"},"password":{"type":"string"},"port":{"type":"integer"},
148                    "scheme":{"type":"string","enum":["postgres","postgresql","mysql"]},
149                    "user":{"type":"string"}}}
150        }]}}"#;
151
152    fn test_def() -> StackDef {
153        StackDef::parse(
154            r#"
155[stack]
156name = "atto"
157[stack.projects.stripe]
158project = "project_1"
159[integrations.hd]
160provider = "cloudflare-hyperdrive"
161name = "${stack.name}-hd"
162database = "app"
163host = "db.example.com"
164password = "secret"
165port = 5432
166scheme = "postgres"
167user = "app"
168[services.api]
169source = { repo = "r", ref = "main" }
170env = { HD_ID = "${integrations.hd.hyperdrive_id}" }
171health = { path = "/health" }
172[services.api.local]
173run = "true"
174"#,
175        )
176        .unwrap()
177    }
178
179    #[tokio::test]
180    async fn provision_hyperdrive_records_outputs() {
181        let runner = ScriptedRunner::new(vec![
182            out(HYPERDRIVE_CATALOG_ENVELOPE),
183            out(r#"{"ok":true,"data":{"project":{"id":"project_1"}}}"#),
184            out(r#"{"ok":true,"data":{"environments":[{"name":"demo"}]}}"#),
185            out(r#"{"ok":true,"data":null}"#),
186            out(r#"{"ok":true,"data":{"services":[]}}"#),
187            out(&serde_json::json!({"ok":true,"data":{"variables":{
188                "CLOUDFLARE_HYPERDRIVE_ID": "hd_123",
189                "CLOUDFLARE_ACCOUNT_ID": "acct_123",
190                "CLOUDFLARE_NAME": "disco-hyperdrive"
191            }}})
192            .to_string()),
193            out(r#"{"ok":true,"data":null}"#),
194            out(r#"{"ok":true,"data":null}"#),
195        ]);
196        let dir = tempfile::tempdir().unwrap();
197        std::fs::write(
198            dir.path().join("stackless.toml"),
199            "[stack]\nname=\"atto\"\n",
200        )
201        .unwrap();
202        let stripe = StripeProjects::new(&runner, dir.path());
203
204        let resource = CloudflareHyperdrive
205            .provision(
206                &stripe.as_dyn(),
207                &test_def(),
208                dir.path(),
209                "demo",
210                "hd",
211                "local",
212                false,
213            )
214            .await
215            .unwrap();
216        assert_eq!(resource.resource_kind, "integration-cloudflare-hyperdrive");
217        let payload: CloudflarePayload = serde_json::from_str(&resource.payload).unwrap();
218        assert_eq!(payload.outputs["hyperdrive_id"], "hd_123");
219        assert_eq!(payload.outputs["account_id"], "acct_123");
220        assert_eq!(payload.outputs["name"], "disco-hyperdrive");
221
222        // The add config must serialize `port` as an integer (catalog schema).
223        let add = runner
224            .calls()
225            .into_iter()
226            .find(|c| c.first().map(String::as_str) == Some("add"))
227            .unwrap();
228        let cfg_idx = add.iter().position(|a| a == "--config").unwrap();
229        let cfg: serde_json::Value = serde_json::from_str(&add[cfg_idx + 1]).unwrap();
230        assert_eq!(cfg["port"], serde_json::json!(5432));
231    }
232}