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", "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 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}