Skip to main content

stackless_integrations/providers/cloudflare/
workers.rs

1//! Cloudflare Workers compute resource (`cloudflare/workers`).
2//!
3//! This provisions the Workers *resource* (account-level Workers enablement +
4//! a `*.workers.dev` subdomain) and exposes its coordinates — it is NOT a deploy
5//! target. Deploying a service's code to Workers (`--on cloudflare`) is a
6//! separate substrate (out of scope; see the plan's deferred section).
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};
17
18pub const RESOURCE_KIND: &str = "integration-cloudflare-workers";
19
20/// `cloudflare/workers` takes no configuration.
21#[derive(Debug, Serialize)]
22pub struct WorkersConfig {}
23
24impl CatalogService for WorkersConfig {
25    const REFERENCE: &'static str = "cloudflare/workers";
26}
27
28#[derive(Debug)]
29pub struct CloudflareWorkers;
30
31impl Hostable for CloudflareWorkers {
32    const PROVIDER: &'static str = "cloudflare-workers";
33    const HOSTING: IntegrationHosting = IntegrationHosting::Managed;
34    const CONFIG_SCOPE: ConfigScope = ConfigScope::GlobalOnly;
35    const RESOURCE_KIND: &'static str = RESOURCE_KIND;
36    const OUTPUTS: &'static [&'static str] = super::WORKERS_FAMILY_OUTPUTS;
37}
38
39impl CloudflareResource for CloudflareWorkers {
40    type Config = WorkersConfig;
41    const PROVIDER_PREFIX: &'static str = "CLOUDFLARE";
42    // Shared across the Workers family; confirmed by live provisioning 2026-06-16.
43    const OUTPUT_FIELDS: &'static [(&'static str, &'static str, bool)] =
44        super::WORKERS_FAMILY_OUTPUT_FIELDS;
45
46    fn build_config(_ctx: &ProvisionContext<'_>) -> Result<WorkersConfig, IntegrationError> {
47        Ok(WorkersConfig {})
48    }
49}
50
51pub fn validate_config(
52    _name: &str,
53    _config: &BTreeMap<String, toml::Value>,
54) -> Result<(), IntegrationError> {
55    Ok(())
56}
57
58#[cfg(test)]
59mod tests {
60    use super::*;
61    use crate::ProviderOps;
62    use crate::resource::ResourcePayload as CloudflarePayload;
63    use stackless_core::def::StackDef;
64    use stackless_stripe_projects::stripe::{CommandOutput, StripeProjects};
65    use stackless_stripe_projects::test_support::ScriptedRunner;
66
67    fn out(stdout: &str) -> CommandOutput {
68        CommandOutput {
69            status: 0,
70            stdout: stdout.to_owned(),
71            stderr: String::new(),
72        }
73    }
74
75    #[test]
76    fn workers_config_matches_catalog() {
77        const FIXTURE: &str = include_str!(concat!(
78            env!("CARGO_MANIFEST_DIR"),
79            "/../stackless-stripe-projects/tests/fixtures/catalog.json"
80        ));
81        let catalog = stackless_stripe_projects::Catalog::from_json_envelope(FIXTURE).unwrap();
82        let failures = stackless_stripe_projects::verify_service(&catalog, &WorkersConfig {});
83        assert!(
84            failures.is_empty(),
85            "cloudflare/workers catalog gaps:\n{}",
86            failures.join("\n")
87        );
88    }
89
90    const WORKERS_CATALOG_ENVELOPE: &str = r#"{"ok":true,"command":"projects catalog","data":{
91        "last_updated":"2026-06-16T00:00:00Z","services":[{
92            "id":"prvsvc_workers","object":"v2.provisioning.provider_service_detail",
93            "provider_id":"prvdr_cloudflare","provider_name":"Cloudflare","service_id":"workers",
94            "categories":["compute"],"kind":"deployable","scope":"project","availability":"available",
95            "development":false,"livemode":true,"pricing":{"type":"component"},
96            "configuration_schema":{"type":"object","additionalProperties":false,"properties":{}}
97        }]}}"#;
98
99    fn test_def() -> StackDef {
100        StackDef::parse(
101            r#"
102[stack]
103name = "atto"
104[stack.projects.stripe]
105project = "project_1"
106[integrations.edge]
107provider = "cloudflare-workers"
108[services.api]
109source = { repo = "r", ref = "main" }
110env = { CF_SUBDOMAIN = "${integrations.edge.workers_dev_subdomain}" }
111health = { path = "/health" }
112[services.api.local]
113run = "true"
114"#,
115        )
116        .unwrap()
117    }
118
119    #[tokio::test]
120    async fn provision_workers_records_outputs() {
121        let runner = ScriptedRunner::new(vec![
122            out(WORKERS_CATALOG_ENVELOPE),
123            out(r#"{"ok":true,"data":{"project":{"id":"project_1"}}}"#),
124            out(r#"{"ok":true,"data":{"environments":[{"name":"demo"}]}}"#),
125            out(r#"{"ok":true,"data":null}"#),
126            out(r#"{"ok":true,"data":{"services":[]}}"#),
127            out(&serde_json::json!({"ok":true,"data":{"variables":{
128                "CLOUDFLARE_ACCOUNT_ID": "acc_1",
129                "CLOUDFLARE_WORKERS_DEV_SUBDOMAIN": "atto-demo",
130                "CLOUDFLARE_API_BASE_URL": "https://api.cloudflare.com/client/v4",
131                "CLOUDFLARE_DASHBOARD_URL": "https://dash.cloudflare.com/acc_1/workers",
132                "CLOUDFLARE_PLAN_SERVICE_ID": "svc_1"
133            }}})
134            .to_string()),
135            out(r#"{"ok":true,"data":null}"#),
136            out(r#"{"ok":true,"data":null}"#),
137        ]);
138        let dir = tempfile::tempdir().unwrap();
139        std::fs::write(
140            dir.path().join("stackless.toml"),
141            "[stack]\nname=\"atto\"\n",
142        )
143        .unwrap();
144        let stripe = StripeProjects::new(&runner, dir.path());
145
146        let resource = CloudflareWorkers
147            .provision(
148                &stripe.as_dyn(),
149                &test_def(),
150                dir.path(),
151                "demo",
152                "edge",
153                "local",
154                false,
155            )
156            .await
157            .unwrap();
158        assert_eq!(resource.resource_kind, "integration-cloudflare-workers");
159        let payload: CloudflarePayload = serde_json::from_str(&resource.payload).unwrap();
160        assert_eq!(payload.outputs["workers_dev_subdomain"], "atto-demo");
161        assert_eq!(payload.outputs["account_id"], "acc_1");
162    }
163}