stackless_integrations/providers/cloudflare/
hyperdrive.rs1use 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 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 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}