From 3427d2ce59281b5cde0742cc30fb950454b30ee8 Mon Sep 17 00:00:00 2001 From: Michael Assaf Date: Sat, 11 Jul 2026 13:42:34 -0400 Subject: [PATCH 1/3] feat(integrations): add all remaining Stripe catalog provider families Phase 1 CatalogResource adapters for every unimplemented deployable (one module tree per provider family), plus wave landing docs and config helpers. Domain purchases and known Cloudflare exclusions stay out. --- README.md | 58 +- .../src/providers/agentmail/api.rs | 132 ++++ .../src/providers/agentmail/mod.rs | 11 + .../src/providers/agentphone/mod.rs | 11 + .../src/providers/agentphone/number.rs | 158 ++++ .../src/providers/algolia/application.rs | 166 ++++ .../src/providers/algolia/mod.rs | 11 + .../src/providers/amplitude/analytics.rs | 135 ++++ .../src/providers/amplitude/mod.rs | 11 + .../src/providers/auth0/client.rs | 149 ++++ .../src/providers/auth0/mod.rs | 11 + .../src/providers/base44_projects/app.rs | 142 ++++ .../src/providers/base44_projects/mod.rs | 11 + .../src/providers/blaxel/agent_drive.rs | 152 ++++ .../src/providers/blaxel/mod.rs | 12 + .../src/providers/blaxel/sandbox.rs | 158 ++++ .../src/providers/browserbase/mod.rs | 11 + .../src/providers/browserbase/project.rs | 137 ++++ .../src/providers/chroma/database.rs | 146 ++++ .../src/providers/chroma/mod.rs | 11 + .../src/providers/clickhouse/clickhouse.rs | 204 +++++ .../src/providers/clickhouse/mod.rs | 12 + .../src/providers/clickhouse/postgres.rs | 177 +++++ .../src/providers/cloudflare/mod.rs | 2 +- .../src/providers/cloudflare/workers.rs | 2 +- .../src/providers/daytona/mod.rs | 11 + .../src/providers/daytona/sandbox.rs | 210 +++++ .../src/providers/e2b/mod.rs | 11 + .../src/providers/e2b/sandbox.rs | 132 ++++ .../src/providers/elevenlabs/mod.rs | 11 + .../src/providers/elevenlabs/tts.rs | 132 ++++ .../src/providers/exa/api.rs | 132 ++++ .../src/providers/exa/mod.rs | 11 + .../src/providers/firecrawl/api.rs | 132 ++++ .../src/providers/firecrawl/mod.rs | 11 + .../src/providers/flyio/mod.rs | 12 + .../src/providers/flyio/mpg.rs | 162 ++++ .../src/providers/flyio/sprite.rs | 146 ++++ .../src/providers/gitlab/mod.rs | 11 + .../src/providers/gitlab/project.rs | 158 ++++ .../src/providers/heygen/api.rs | 150 ++++ .../src/providers/heygen/mod.rs | 11 + .../src/providers/huggingface/bucket.rs | 148 ++++ .../src/providers/huggingface/mod.rs | 12 + .../src/providers/huggingface/platform.rs | 135 ++++ .../src/providers/inngest/app.rs | 149 ++++ .../src/providers/inngest/mod.rs | 11 + .../src/providers/kernel/mod.rs | 11 + .../src/providers/kernel/project.rs | 146 ++++ .../providers/laravel_cloud/application.rs | 174 +++++ .../src/providers/laravel_cloud/mod.rs | 13 + .../src/providers/laravel_cloud/mysql.rs | 158 ++++ .../src/providers/laravel_cloud/valkey.rs | 158 ++++ .../src/providers/metronome/mod.rs | 11 + .../src/providers/metronome/sandbox.rs | 135 ++++ .../src/providers/mixpanel/analytics.rs | 135 ++++ .../src/providers/mixpanel/mod.rs | 11 + .../src/providers/mod.rs | 116 ++- .../src/providers/neon/mod.rs | 11 + .../src/providers/neon/postgres.rs | 134 ++++ .../src/providers/openrouter/api.rs | 132 ++++ .../src/providers/openrouter/mod.rs | 11 + .../src/providers/parallel/api.rs | 132 ++++ .../src/providers/parallel/mod.rs | 11 + .../src/providers/planetscale/mod.rs | 12 + .../src/providers/planetscale/mysql.rs | 164 ++++ .../src/providers/planetscale/postgresql.rs | 168 ++++ .../src/providers/postalform/mail.rs | 146 ++++ .../src/providers/postalform/mod.rs | 11 + .../src/providers/posthog/analytics.rs | 135 ++++ .../src/providers/posthog/mod.rs | 11 + .../src/providers/prisma/database.rs | 146 ++++ .../src/providers/prisma/mod.rs | 11 + .../src/providers/privy/app.rs | 148 ++++ .../src/providers/privy/mod.rs | 11 + .../src/providers/railway/bucket.rs | 146 ++++ .../src/providers/railway/hosting.rs | 133 ++++ .../src/providers/railway/mod.rs | 15 + .../src/providers/railway/mongo.rs | 140 ++++ .../src/providers/railway/postgres.rs | 140 ++++ .../src/providers/railway/redis.rs | 140 ++++ .../src/providers/render_db/mod.rs | 11 + .../src/providers/render_db/postgres.rs | 162 ++++ .../src/providers/runloop/mod.rs | 11 + .../src/providers/runloop/sandbox.rs | 133 ++++ .../src/providers/sentry/mod.rs | 12 + .../src/providers/sentry/project.rs | 145 ++++ .../src/providers/sentry/seer.rs | 132 ++++ .../src/providers/supabase/mod.rs | 11 + .../src/providers/supabase/project.rs | 149 ++++ .../src/providers/supermemory/memory.rs | 148 ++++ .../src/providers/supermemory/mod.rs | 11 + .../src/providers/turso/database.rs | 156 ++++ .../src/providers/turso/mod.rs | 11 + .../src/providers/upstash/mod.rs | 14 + .../src/providers/upstash/qstash.rs | 146 ++++ .../src/providers/upstash/redis.rs | 164 ++++ .../src/providers/upstash/search.rs | 152 ++++ .../src/providers/upstash/vector.rs | 164 ++++ .../src/providers/wix/headless.rs | 146 ++++ .../src/providers/wix/mod.rs | 11 + .../src/providers/wordpress_com/mod.rs | 12 + .../src/providers/wordpress_com/site.rs | 148 ++++ .../src/providers/workos/auth.rs | 142 ++++ .../src/providers/workos/mod.rs | 11 + crates/stackless-integrations/src/registry.rs | 58 ++ crates/stackless-provider-sdk/src/resource.rs | 42 + docs/ADDING-A-PROVIDER.md | 3 + docs/DECISIONS.md | 23 + docs/PROVIDER-WAVES.md | 69 ++ docs/SCHEMA.md | 37 +- scripts/generate_catalog_integrations.py | 739 ++++++++++++++++++ scripts/stack_catalog_commits.sh | 146 ++++ 113 files changed, 10393 insertions(+), 30 deletions(-) create mode 100644 crates/stackless-integrations/src/providers/agentmail/api.rs create mode 100644 crates/stackless-integrations/src/providers/agentmail/mod.rs create mode 100644 crates/stackless-integrations/src/providers/agentphone/mod.rs create mode 100644 crates/stackless-integrations/src/providers/agentphone/number.rs create mode 100644 crates/stackless-integrations/src/providers/algolia/application.rs create mode 100644 crates/stackless-integrations/src/providers/algolia/mod.rs create mode 100644 crates/stackless-integrations/src/providers/amplitude/analytics.rs create mode 100644 crates/stackless-integrations/src/providers/amplitude/mod.rs create mode 100644 crates/stackless-integrations/src/providers/auth0/client.rs create mode 100644 crates/stackless-integrations/src/providers/auth0/mod.rs create mode 100644 crates/stackless-integrations/src/providers/base44_projects/app.rs create mode 100644 crates/stackless-integrations/src/providers/base44_projects/mod.rs create mode 100644 crates/stackless-integrations/src/providers/blaxel/agent_drive.rs create mode 100644 crates/stackless-integrations/src/providers/blaxel/mod.rs create mode 100644 crates/stackless-integrations/src/providers/blaxel/sandbox.rs create mode 100644 crates/stackless-integrations/src/providers/browserbase/mod.rs create mode 100644 crates/stackless-integrations/src/providers/browserbase/project.rs create mode 100644 crates/stackless-integrations/src/providers/chroma/database.rs create mode 100644 crates/stackless-integrations/src/providers/chroma/mod.rs create mode 100644 crates/stackless-integrations/src/providers/clickhouse/clickhouse.rs create mode 100644 crates/stackless-integrations/src/providers/clickhouse/mod.rs create mode 100644 crates/stackless-integrations/src/providers/clickhouse/postgres.rs create mode 100644 crates/stackless-integrations/src/providers/daytona/mod.rs create mode 100644 crates/stackless-integrations/src/providers/daytona/sandbox.rs create mode 100644 crates/stackless-integrations/src/providers/e2b/mod.rs create mode 100644 crates/stackless-integrations/src/providers/e2b/sandbox.rs create mode 100644 crates/stackless-integrations/src/providers/elevenlabs/mod.rs create mode 100644 crates/stackless-integrations/src/providers/elevenlabs/tts.rs create mode 100644 crates/stackless-integrations/src/providers/exa/api.rs create mode 100644 crates/stackless-integrations/src/providers/exa/mod.rs create mode 100644 crates/stackless-integrations/src/providers/firecrawl/api.rs create mode 100644 crates/stackless-integrations/src/providers/firecrawl/mod.rs create mode 100644 crates/stackless-integrations/src/providers/flyio/mod.rs create mode 100644 crates/stackless-integrations/src/providers/flyio/mpg.rs create mode 100644 crates/stackless-integrations/src/providers/flyio/sprite.rs create mode 100644 crates/stackless-integrations/src/providers/gitlab/mod.rs create mode 100644 crates/stackless-integrations/src/providers/gitlab/project.rs create mode 100644 crates/stackless-integrations/src/providers/heygen/api.rs create mode 100644 crates/stackless-integrations/src/providers/heygen/mod.rs create mode 100644 crates/stackless-integrations/src/providers/huggingface/bucket.rs create mode 100644 crates/stackless-integrations/src/providers/huggingface/mod.rs create mode 100644 crates/stackless-integrations/src/providers/huggingface/platform.rs create mode 100644 crates/stackless-integrations/src/providers/inngest/app.rs create mode 100644 crates/stackless-integrations/src/providers/inngest/mod.rs create mode 100644 crates/stackless-integrations/src/providers/kernel/mod.rs create mode 100644 crates/stackless-integrations/src/providers/kernel/project.rs create mode 100644 crates/stackless-integrations/src/providers/laravel_cloud/application.rs create mode 100644 crates/stackless-integrations/src/providers/laravel_cloud/mod.rs create mode 100644 crates/stackless-integrations/src/providers/laravel_cloud/mysql.rs create mode 100644 crates/stackless-integrations/src/providers/laravel_cloud/valkey.rs create mode 100644 crates/stackless-integrations/src/providers/metronome/mod.rs create mode 100644 crates/stackless-integrations/src/providers/metronome/sandbox.rs create mode 100644 crates/stackless-integrations/src/providers/mixpanel/analytics.rs create mode 100644 crates/stackless-integrations/src/providers/mixpanel/mod.rs create mode 100644 crates/stackless-integrations/src/providers/neon/mod.rs create mode 100644 crates/stackless-integrations/src/providers/neon/postgres.rs create mode 100644 crates/stackless-integrations/src/providers/openrouter/api.rs create mode 100644 crates/stackless-integrations/src/providers/openrouter/mod.rs create mode 100644 crates/stackless-integrations/src/providers/parallel/api.rs create mode 100644 crates/stackless-integrations/src/providers/parallel/mod.rs create mode 100644 crates/stackless-integrations/src/providers/planetscale/mod.rs create mode 100644 crates/stackless-integrations/src/providers/planetscale/mysql.rs create mode 100644 crates/stackless-integrations/src/providers/planetscale/postgresql.rs create mode 100644 crates/stackless-integrations/src/providers/postalform/mail.rs create mode 100644 crates/stackless-integrations/src/providers/postalform/mod.rs create mode 100644 crates/stackless-integrations/src/providers/posthog/analytics.rs create mode 100644 crates/stackless-integrations/src/providers/posthog/mod.rs create mode 100644 crates/stackless-integrations/src/providers/prisma/database.rs create mode 100644 crates/stackless-integrations/src/providers/prisma/mod.rs create mode 100644 crates/stackless-integrations/src/providers/privy/app.rs create mode 100644 crates/stackless-integrations/src/providers/privy/mod.rs create mode 100644 crates/stackless-integrations/src/providers/railway/bucket.rs create mode 100644 crates/stackless-integrations/src/providers/railway/hosting.rs create mode 100644 crates/stackless-integrations/src/providers/railway/mod.rs create mode 100644 crates/stackless-integrations/src/providers/railway/mongo.rs create mode 100644 crates/stackless-integrations/src/providers/railway/postgres.rs create mode 100644 crates/stackless-integrations/src/providers/railway/redis.rs create mode 100644 crates/stackless-integrations/src/providers/render_db/mod.rs create mode 100644 crates/stackless-integrations/src/providers/render_db/postgres.rs create mode 100644 crates/stackless-integrations/src/providers/runloop/mod.rs create mode 100644 crates/stackless-integrations/src/providers/runloop/sandbox.rs create mode 100644 crates/stackless-integrations/src/providers/sentry/mod.rs create mode 100644 crates/stackless-integrations/src/providers/sentry/project.rs create mode 100644 crates/stackless-integrations/src/providers/sentry/seer.rs create mode 100644 crates/stackless-integrations/src/providers/supabase/mod.rs create mode 100644 crates/stackless-integrations/src/providers/supabase/project.rs create mode 100644 crates/stackless-integrations/src/providers/supermemory/memory.rs create mode 100644 crates/stackless-integrations/src/providers/supermemory/mod.rs create mode 100644 crates/stackless-integrations/src/providers/turso/database.rs create mode 100644 crates/stackless-integrations/src/providers/turso/mod.rs create mode 100644 crates/stackless-integrations/src/providers/upstash/mod.rs create mode 100644 crates/stackless-integrations/src/providers/upstash/qstash.rs create mode 100644 crates/stackless-integrations/src/providers/upstash/redis.rs create mode 100644 crates/stackless-integrations/src/providers/upstash/search.rs create mode 100644 crates/stackless-integrations/src/providers/upstash/vector.rs create mode 100644 crates/stackless-integrations/src/providers/wix/headless.rs create mode 100644 crates/stackless-integrations/src/providers/wix/mod.rs create mode 100644 crates/stackless-integrations/src/providers/wordpress_com/mod.rs create mode 100644 crates/stackless-integrations/src/providers/wordpress_com/site.rs create mode 100644 crates/stackless-integrations/src/providers/workos/auth.rs create mode 100644 crates/stackless-integrations/src/providers/workos/mod.rs create mode 100644 docs/PROVIDER-WAVES.md create mode 100644 scripts/generate_catalog_integrations.py create mode 100755 scripts/stack_catalog_commits.sh diff --git a/README.md b/README.md index 101f30d..e215688 100644 --- a/README.md +++ b/README.md @@ -280,20 +280,60 @@ implemented yet. - [x] render - [x] vercel - [x] fly.io -- [ ] railway +- [x] railway - [x] netlify -- [ ] cloudflare workers -- [ ] gitlab -- [ ] laravel cloud -- [ ] wordpress.com +- [x] cloudflare workers +- [x] gitlab +- [x] laravel cloud +- [x] wordpress.com ### Integrations (`[integrations.*]` / `provider`) - [x] clerk -- [ ] auth0 -- [ ] workos -- [ ] privy -- [ ] supabase +- [x] auth0 +- [x] workos +- [x] privy +- [x] supabase +- [x] neon +- [x] turso +- [x] prisma +- [x] upstash (redis, qstash, search, vector) +- [x] planetscale (mysql, postgresql) +- [x] clickhouse (clickhouse, postgres) +- [x] chroma +- [x] sentry (project, seer) +- [x] posthog +- [x] amplitude +- [x] mixpanel +- [x] algolia +- [x] openrouter +- [x] exa +- [x] firecrawl +- [x] parallel +- [x] elevenlabs +- [x] heygen +- [x] huggingface (platform, bucket) +- [x] inngest +- [x] e2b +- [x] daytona +- [x] browserbase +- [x] blaxel (agent-drive, sandbox) +- [x] runloop +- [x] kernel +- [x] agentmail +- [x] agentphone +- [x] railway (hosting, postgres, redis, mongo, bucket) — catalog integrations; `--on railway` is Phase 2 +- [x] gitlab — catalog integration; `--on gitlab` is Phase 2 +- [x] laravel-cloud (application, mysql, valkey) — catalog integrations; `--on` is Phase 2 +- [x] wordpress-com (site) — catalog integration; `--on` is Phase 2; domain purchase excluded +- [x] base44 +- [x] wix +- [x] postalform +- [x] metronome +- [x] supermemory +- [x] render-postgres +- [x] flyio-mpg / flyio-sprite +- [x] cloudflare (r2, kv, d1, queues, hyperdrive, workers, workers-ai, browser-run) ### Platform diff --git a/crates/stackless-integrations/src/providers/agentmail/api.rs b/crates/stackless-integrations/src/providers/agentmail/api.rs new file mode 100644 index 0000000..4369354 --- /dev/null +++ b/crates/stackless-integrations/src/providers/agentmail/api.rs @@ -0,0 +1,132 @@ +//! `agentmail/api` integration. + +use std::collections::BTreeMap; + +use serde::Serialize; +use stackless_stripe_projects::catalog::verify::CatalogService; +use stackless_stripe_projects::provision::ProvisionContext; + +use super::FamilyResource; +use crate::error::IntegrationError; +use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; +use crate::registry; + +pub const RESOURCE_KIND: &str = "integration-agentmail"; + +#[derive(Debug, Serialize)] +pub struct AgentMailApiConfig {} + +impl CatalogService for AgentMailApiConfig { + const REFERENCE: &'static str = "agentmail/api"; +} + +#[derive(Debug)] +pub struct AgentMailApi; + +impl Hostable for AgentMailApi { + const PROVIDER: &'static str = "agentmail"; + const HOSTING: IntegrationHosting = IntegrationHosting::Managed; + const CONFIG_SCOPE: ConfigScope = ConfigScope::GlobalOnly; + const RESOURCE_KIND: &'static str = RESOURCE_KIND; + const OUTPUTS: &'static [&'static str] = &["api_key"]; +} + +impl FamilyResource for AgentMailApi { + type Config = AgentMailApiConfig; + const PROVIDER_PREFIX: &'static str = "AGENTMAIL"; + // Provisional until pinned by `mise run discover agentmail/api`. + const OUTPUT_FIELDS: &'static [(&'static str, &'static str, bool)] = + &[("API_KEY", "api_key", true)]; + + fn build_config(ctx: &ProvisionContext<'_>) -> Result { + let _ = super::integration_config(ctx)?; + Ok(AgentMailApiConfig {}) + } +} + +pub fn validate_config( + name: &str, + config: &BTreeMap, +) -> Result<(), IntegrationError> { + let _ = (name, config); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ProviderOps; + use crate::resource::ResourcePayload; + use stackless_core::def::StackDef; + use stackless_stripe_projects::stripe::StripeProjects; + use stackless_stripe_projects::test_support; + + #[test] + fn config_matches_catalog() { + const FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../stackless-stripe-projects/tests/fixtures/catalog.json" + )); + let catalog = stackless_stripe_projects::Catalog::from_json_envelope(FIXTURE).unwrap(); + let failures = stackless_stripe_projects::verify_service(&catalog, &AgentMailApiConfig {}); + assert!( + failures.is_empty(), + "agentmail/api catalog gaps:\n{}", + failures.join("\n") + ); + } + + const CATALOG_ENVELOPE: &str = r##"{"ok":true,"command":"projects catalog","data":{"last_updated":"2026-07-11T00:00:00Z","services":[{"id":"prvsvc_api","object":"v2.provisioning.provider_service_detail","provider_id":"prvdr_agentmail","provider_name":"AgentMail","service_id":"api","categories":["database"],"kind":"deployable","scope":"project","availability":"available","development":false,"livemode":true,"pricing":{"type":"component"},"configuration_schema":{"type":"object","required":[],"additionalProperties":false,"properties":{}}}]}}"##; + + fn test_def() -> StackDef { + StackDef::parse( + r#" +[stack] +name = "atto" +[stack.projects.stripe] +project = "project_1" +[integrations.res] +provider = "agentmail" +[services.api] +source = { repo = "r", ref = "main" } +env = { OUT = "${integrations.res.api_key}" } +health = { path = "/health" } +[services.api.local] +run = "true" +"#, + ) + .unwrap() + } + + #[tokio::test] + async fn provision_records_outputs() { + let runner = test_support::provision_script( + CATALOG_ENVELOPE, + serde_json::json!({"AGENTMAIL_API_KEY": "val_api_key"}), + 0, + ); + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("stackless.toml"), + "[stack]\nname=\"atto\"\n", + ) + .unwrap(); + let stripe = StripeProjects::new(&runner, dir.path()); + + let resource = AgentMailApi + .provision( + &stripe.as_dyn(), + &test_def(), + dir.path(), + "demo", + "res", + "local", + false, + ) + .await + .unwrap(); + assert_eq!(resource.resource_kind, "integration-agentmail"); + let payload: ResourcePayload = serde_json::from_str(&resource.payload).unwrap(); + assert_eq!(payload.outputs["api_key"], "val_api_key"); + } +} diff --git a/crates/stackless-integrations/src/providers/agentmail/mod.rs b/crates/stackless-integrations/src/providers/agentmail/mod.rs new file mode 100644 index 0000000..5b21b6e --- /dev/null +++ b/crates/stackless-integrations/src/providers/agentmail/mod.rs @@ -0,0 +1,11 @@ +//! AgentMail catalog resources via Stripe Projects. +//! +//! Output envelopes are provisional until pinned by `xtask discover`. + +pub mod api; + +#[allow(unused_imports)] +pub(crate) use crate::resource::{ + CatalogResource as FamilyResource, bool_optional, bool_required, int_optional, int_required, + integration_config, interp_optional, interp_required, +}; diff --git a/crates/stackless-integrations/src/providers/agentphone/mod.rs b/crates/stackless-integrations/src/providers/agentphone/mod.rs new file mode 100644 index 0000000..d4adbe1 --- /dev/null +++ b/crates/stackless-integrations/src/providers/agentphone/mod.rs @@ -0,0 +1,11 @@ +//! AgentPhone catalog resources via Stripe Projects. +//! +//! Output envelopes are provisional until pinned by `xtask discover`. + +pub mod number; + +#[allow(unused_imports)] +pub(crate) use crate::resource::{ + CatalogResource as FamilyResource, bool_optional, bool_required, int_optional, int_required, + integration_config, interp_optional, interp_required, +}; diff --git a/crates/stackless-integrations/src/providers/agentphone/number.rs b/crates/stackless-integrations/src/providers/agentphone/number.rs new file mode 100644 index 0000000..7438ce4 --- /dev/null +++ b/crates/stackless-integrations/src/providers/agentphone/number.rs @@ -0,0 +1,158 @@ +//! `agentphone/number` integration. + +use std::collections::BTreeMap; + +use serde::Serialize; +use stackless_stripe_projects::catalog::verify::CatalogService; +use stackless_stripe_projects::provision::ProvisionContext; + +use super::FamilyResource; +use crate::error::IntegrationError; +use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; +use crate::registry; + +pub const RESOURCE_KIND: &str = "integration-agentphone"; + +#[derive(Debug, Serialize)] +pub struct AgentPhoneNumberConfig { + pub agent_name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub area_code: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub country: Option, +} + +impl CatalogService for AgentPhoneNumberConfig { + const REFERENCE: &'static str = "agentphone/number"; +} + +#[derive(Debug)] +pub struct AgentPhoneNumber; + +impl Hostable for AgentPhoneNumber { + const PROVIDER: &'static str = "agentphone"; + const HOSTING: IntegrationHosting = IntegrationHosting::Managed; + const CONFIG_SCOPE: ConfigScope = ConfigScope::GlobalOnly; + const RESOURCE_KIND: &'static str = RESOURCE_KIND; + const OUTPUTS: &'static [&'static str] = &["phone_number"]; +} + +impl FamilyResource for AgentPhoneNumber { + type Config = AgentPhoneNumberConfig; + const PROVIDER_PREFIX: &'static str = "AGENTPHONE"; + // Provisional until pinned by `mise run discover agentphone/number`. + const OUTPUT_FIELDS: &'static [(&'static str, &'static str, bool)] = + &[("PHONE_NUMBER", "phone_number", true)]; + + fn build_config( + ctx: &ProvisionContext<'_>, + ) -> Result { + let config = super::integration_config(ctx)?; + Ok(AgentPhoneNumberConfig { + agent_name: super::interp_required(ctx, &config, "agent_name")?, + area_code: super::interp_optional(ctx, &config, "area_code")?, + country: super::interp_optional(ctx, &config, "country")?, + }) + } +} + +pub fn validate_config( + name: &str, + config: &BTreeMap, +) -> Result<(), IntegrationError> { + registry::config_string(config, "agent_name").map_err(|err| { + IntegrationError::ConfigInvalid { + location: format!("integrations.{name}.agent_name"), + detail: err.to_string(), + } + })?; + let _ = (name, config); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ProviderOps; + use crate::resource::ResourcePayload; + use stackless_core::def::StackDef; + use stackless_stripe_projects::stripe::StripeProjects; + use stackless_stripe_projects::test_support; + + #[test] + fn config_matches_catalog() { + const FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../stackless-stripe-projects/tests/fixtures/catalog.json" + )); + let catalog = stackless_stripe_projects::Catalog::from_json_envelope(FIXTURE).unwrap(); + let failures = stackless_stripe_projects::verify_service( + &catalog, + &AgentPhoneNumberConfig { + agent_name: "test-agent_name".into(), + area_code: None, + country: None, + }, + ); + assert!( + failures.is_empty(), + "agentphone/number catalog gaps:\n{}", + failures.join("\n") + ); + } + + const CATALOG_ENVELOPE: &str = r##"{"ok":true,"command":"projects catalog","data":{"last_updated":"2026-07-11T00:00:00Z","services":[{"id":"prvsvc_number","object":"v2.provisioning.provider_service_detail","provider_id":"prvdr_agentphone","provider_name":"AgentPhone","service_id":"number","categories":["database"],"kind":"deployable","scope":"project","availability":"available","development":false,"livemode":true,"pricing":{"type":"paid"},"configuration_schema":{"properties":{"agent_name":{"description":"Name for the AI agent","type":"string"},"area_code":{"description":"Preferred 3-digit area code (optional)","type":"string"},"country":{"description":"Country for the phone number","enum":["US","CA"],"type":"string"}},"required":["agent_name"],"type":"object"}}]}}"##; + + fn test_def() -> StackDef { + StackDef::parse( + r#" +[stack] +name = "atto" +[stack.projects.stripe] +project = "project_1" +[integrations.res] +provider = "agentphone" +agent_name = "test-agent_name" +[services.api] +source = { repo = "r", ref = "main" } +env = { OUT = "${integrations.res.phone_number}" } +health = { path = "/health" } +[services.api.local] +run = "true" +"#, + ) + .unwrap() + } + + #[tokio::test] + async fn provision_records_outputs() { + let runner = test_support::provision_script( + CATALOG_ENVELOPE, + serde_json::json!({"AGENTPHONE_PHONE_NUMBER": "val_phone_number"}), + 0, + ); + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("stackless.toml"), + "[stack]\nname=\"atto\"\n", + ) + .unwrap(); + let stripe = StripeProjects::new(&runner, dir.path()); + + let resource = AgentPhoneNumber + .provision( + &stripe.as_dyn(), + &test_def(), + dir.path(), + "demo", + "res", + "local", + false, + ) + .await + .unwrap(); + assert_eq!(resource.resource_kind, "integration-agentphone"); + let payload: ResourcePayload = serde_json::from_str(&resource.payload).unwrap(); + assert_eq!(payload.outputs["phone_number"], "val_phone_number"); + } +} diff --git a/crates/stackless-integrations/src/providers/algolia/application.rs b/crates/stackless-integrations/src/providers/algolia/application.rs new file mode 100644 index 0000000..a5d9342 --- /dev/null +++ b/crates/stackless-integrations/src/providers/algolia/application.rs @@ -0,0 +1,166 @@ +//! `algolia/application` integration. + +use std::collections::BTreeMap; + +use serde::Serialize; +use stackless_stripe_projects::catalog::verify::CatalogService; +use stackless_stripe_projects::provision::ProvisionContext; + +use super::FamilyResource; +use crate::error::IntegrationError; +use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; +use crate::registry; + +pub const RESOURCE_KIND: &str = "integration-algolia"; + +#[derive(Debug, Serialize)] +pub struct AlgoliaApplicationConfig { + pub accept_terms: String, + pub name: String, + pub region: String, +} + +impl CatalogService for AlgoliaApplicationConfig { + const REFERENCE: &'static str = "algolia/application"; +} + +#[derive(Debug)] +pub struct AlgoliaApplication; + +impl Hostable for AlgoliaApplication { + const PROVIDER: &'static str = "algolia"; + const HOSTING: IntegrationHosting = IntegrationHosting::Managed; + const CONFIG_SCOPE: ConfigScope = ConfigScope::GlobalOnly; + const RESOURCE_KIND: &'static str = RESOURCE_KIND; + const OUTPUTS: &'static [&'static str] = &["app_id", "api_key"]; +} + +impl FamilyResource for AlgoliaApplication { + type Config = AlgoliaApplicationConfig; + const PROVIDER_PREFIX: &'static str = "ALGOLIA"; + // Provisional until pinned by `mise run discover algolia/application`. + const OUTPUT_FIELDS: &'static [(&'static str, &'static str, bool)] = + &[("APP_ID", "app_id", true), ("API_KEY", "api_key", true)]; + + fn build_config( + ctx: &ProvisionContext<'_>, + ) -> Result { + let config = super::integration_config(ctx)?; + Ok(AlgoliaApplicationConfig { + accept_terms: super::interp_required(ctx, &config, "accept_terms")?, + name: super::interp_required(ctx, &config, "name")?, + region: super::interp_required(ctx, &config, "region")?, + }) + } +} + +pub fn validate_config( + name: &str, + config: &BTreeMap, +) -> Result<(), IntegrationError> { + registry::config_string(config, "accept_terms").map_err(|err| { + IntegrationError::ConfigInvalid { + location: format!("integrations.{name}.accept_terms"), + detail: err.to_string(), + } + })?; + registry::config_string(config, "name").map_err(|err| IntegrationError::ConfigInvalid { + location: format!("integrations.{name}.name"), + detail: err.to_string(), + })?; + registry::config_string(config, "region").map_err(|err| IntegrationError::ConfigInvalid { + location: format!("integrations.{name}.region"), + detail: err.to_string(), + })?; + let _ = (name, config); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ProviderOps; + use crate::resource::ResourcePayload; + use stackless_core::def::StackDef; + use stackless_stripe_projects::stripe::StripeProjects; + use stackless_stripe_projects::test_support; + + #[test] + fn config_matches_catalog() { + const FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../stackless-stripe-projects/tests/fixtures/catalog.json" + )); + let catalog = stackless_stripe_projects::Catalog::from_json_envelope(FIXTURE).unwrap(); + let failures = stackless_stripe_projects::verify_service( + &catalog, + &AlgoliaApplicationConfig { + accept_terms: "test-accept_terms".into(), + name: "test-name".into(), + region: "EU West".into(), + }, + ); + assert!( + failures.is_empty(), + "algolia/application catalog gaps:\n{}", + failures.join("\n") + ); + } + + const CATALOG_ENVELOPE: &str = r##"{"ok":true,"command":"projects catalog","data":{"last_updated":"2026-07-11T00:00:00Z","services":[{"id":"prvsvc_application","object":"v2.provisioning.provider_service_detail","provider_id":"prvdr_algolia","provider_name":"Algolia","service_id":"application","categories":["database"],"kind":"deployable","scope":"project","availability":"available","development":false,"livemode":true,"pricing":{"type":"paid"},"configuration_schema":{"properties":{"accept_terms":{"type":"string"},"name":{"description":"Name of your Algolia application","type":"string"},"region":{"description":"Where your Algolia application will be created. This cannot be changed after provisioning.","enum":["EU West","US Central","US East","US West","United Kingdom"],"type":"string"}},"required":["name","region","accept_terms"],"type":"object"}}]}}"##; + + fn test_def() -> StackDef { + StackDef::parse( + r#" +[stack] +name = "atto" +[stack.projects.stripe] +project = "project_1" +[integrations.res] +provider = "algolia" +accept_terms = "test-accept_terms" +name = "test-name" +region = "EU West" +[services.api] +source = { repo = "r", ref = "main" } +env = { OUT = "${integrations.res.app_id}" } +health = { path = "/health" } +[services.api.local] +run = "true" +"#, + ) + .unwrap() + } + + #[tokio::test] + async fn provision_records_outputs() { + let runner = test_support::provision_script( + CATALOG_ENVELOPE, + serde_json::json!({"ALGOLIA_APP_ID": "val_app_id", "ALGOLIA_API_KEY": "val_api_key"}), + 0, + ); + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("stackless.toml"), + "[stack]\nname=\"atto\"\n", + ) + .unwrap(); + let stripe = StripeProjects::new(&runner, dir.path()); + + let resource = AlgoliaApplication + .provision( + &stripe.as_dyn(), + &test_def(), + dir.path(), + "demo", + "res", + "local", + false, + ) + .await + .unwrap(); + assert_eq!(resource.resource_kind, "integration-algolia"); + let payload: ResourcePayload = serde_json::from_str(&resource.payload).unwrap(); + assert_eq!(payload.outputs["app_id"], "val_app_id"); + } +} diff --git a/crates/stackless-integrations/src/providers/algolia/mod.rs b/crates/stackless-integrations/src/providers/algolia/mod.rs new file mode 100644 index 0000000..372c78a --- /dev/null +++ b/crates/stackless-integrations/src/providers/algolia/mod.rs @@ -0,0 +1,11 @@ +//! Algolia catalog resources via Stripe Projects. +//! +//! Output envelopes are provisional until pinned by `xtask discover`. + +pub mod application; + +#[allow(unused_imports)] +pub(crate) use crate::resource::{ + CatalogResource as FamilyResource, bool_optional, bool_required, int_optional, int_required, + integration_config, interp_optional, interp_required, +}; diff --git a/crates/stackless-integrations/src/providers/amplitude/analytics.rs b/crates/stackless-integrations/src/providers/amplitude/analytics.rs new file mode 100644 index 0000000..30b00c3 --- /dev/null +++ b/crates/stackless-integrations/src/providers/amplitude/analytics.rs @@ -0,0 +1,135 @@ +//! `amplitude/analytics` integration. + +use std::collections::BTreeMap; + +use serde::Serialize; +use stackless_stripe_projects::catalog::verify::CatalogService; +use stackless_stripe_projects::provision::ProvisionContext; + +use super::FamilyResource; +use crate::error::IntegrationError; +use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; +use crate::registry; + +pub const RESOURCE_KIND: &str = "integration-amplitude"; + +#[derive(Debug, Serialize)] +pub struct AmplitudeAnalyticsConfig {} + +impl CatalogService for AmplitudeAnalyticsConfig { + const REFERENCE: &'static str = "amplitude/analytics"; +} + +#[derive(Debug)] +pub struct AmplitudeAnalytics; + +impl Hostable for AmplitudeAnalytics { + const PROVIDER: &'static str = "amplitude"; + const HOSTING: IntegrationHosting = IntegrationHosting::Managed; + const CONFIG_SCOPE: ConfigScope = ConfigScope::GlobalOnly; + const RESOURCE_KIND: &'static str = RESOURCE_KIND; + const OUTPUTS: &'static [&'static str] = &["api_key"]; +} + +impl FamilyResource for AmplitudeAnalytics { + type Config = AmplitudeAnalyticsConfig; + const PROVIDER_PREFIX: &'static str = "AMPLITUDE"; + // Provisional until pinned by `mise run discover amplitude/analytics`. + const OUTPUT_FIELDS: &'static [(&'static str, &'static str, bool)] = + &[("API_KEY", "api_key", true)]; + + fn build_config( + ctx: &ProvisionContext<'_>, + ) -> Result { + let _ = super::integration_config(ctx)?; + Ok(AmplitudeAnalyticsConfig {}) + } +} + +pub fn validate_config( + name: &str, + config: &BTreeMap, +) -> Result<(), IntegrationError> { + let _ = (name, config); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ProviderOps; + use crate::resource::ResourcePayload; + use stackless_core::def::StackDef; + use stackless_stripe_projects::stripe::StripeProjects; + use stackless_stripe_projects::test_support; + + #[test] + fn config_matches_catalog() { + const FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../stackless-stripe-projects/tests/fixtures/catalog.json" + )); + let catalog = stackless_stripe_projects::Catalog::from_json_envelope(FIXTURE).unwrap(); + let failures = + stackless_stripe_projects::verify_service(&catalog, &AmplitudeAnalyticsConfig {}); + assert!( + failures.is_empty(), + "amplitude/analytics catalog gaps:\n{}", + failures.join("\n") + ); + } + + const CATALOG_ENVELOPE: &str = r##"{"ok":true,"command":"projects catalog","data":{"last_updated":"2026-07-11T00:00:00Z","services":[{"id":"prvsvc_analytics","object":"v2.provisioning.provider_service_detail","provider_id":"prvdr_amplitude","provider_name":"Amplitude","service_id":"analytics","categories":["database"],"kind":"deployable","scope":"project","availability":"available","development":false,"livemode":true,"pricing":{"type":"component"},"configuration_schema":{"type":"object","required":[],"additionalProperties":false,"properties":{}}}]}}"##; + + fn test_def() -> StackDef { + StackDef::parse( + r#" +[stack] +name = "atto" +[stack.projects.stripe] +project = "project_1" +[integrations.res] +provider = "amplitude" +[services.api] +source = { repo = "r", ref = "main" } +env = { OUT = "${integrations.res.api_key}" } +health = { path = "/health" } +[services.api.local] +run = "true" +"#, + ) + .unwrap() + } + + #[tokio::test] + async fn provision_records_outputs() { + let runner = test_support::provision_script( + CATALOG_ENVELOPE, + serde_json::json!({"AMPLITUDE_API_KEY": "val_api_key"}), + 0, + ); + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("stackless.toml"), + "[stack]\nname=\"atto\"\n", + ) + .unwrap(); + let stripe = StripeProjects::new(&runner, dir.path()); + + let resource = AmplitudeAnalytics + .provision( + &stripe.as_dyn(), + &test_def(), + dir.path(), + "demo", + "res", + "local", + false, + ) + .await + .unwrap(); + assert_eq!(resource.resource_kind, "integration-amplitude"); + let payload: ResourcePayload = serde_json::from_str(&resource.payload).unwrap(); + assert_eq!(payload.outputs["api_key"], "val_api_key"); + } +} diff --git a/crates/stackless-integrations/src/providers/amplitude/mod.rs b/crates/stackless-integrations/src/providers/amplitude/mod.rs new file mode 100644 index 0000000..17e045c --- /dev/null +++ b/crates/stackless-integrations/src/providers/amplitude/mod.rs @@ -0,0 +1,11 @@ +//! Amplitude catalog resources via Stripe Projects. +//! +//! Output envelopes are provisional until pinned by `xtask discover`. + +pub mod analytics; + +#[allow(unused_imports)] +pub(crate) use crate::resource::{ + CatalogResource as FamilyResource, bool_optional, bool_required, int_optional, int_required, + integration_config, interp_optional, interp_required, +}; diff --git a/crates/stackless-integrations/src/providers/auth0/client.rs b/crates/stackless-integrations/src/providers/auth0/client.rs new file mode 100644 index 0000000..b7dedf8 --- /dev/null +++ b/crates/stackless-integrations/src/providers/auth0/client.rs @@ -0,0 +1,149 @@ +//! `auth0/client` integration. + +use std::collections::BTreeMap; + +use serde::Serialize; +use stackless_stripe_projects::catalog::verify::CatalogService; +use stackless_stripe_projects::provision::ProvisionContext; + +use super::FamilyResource; +use crate::error::IntegrationError; +use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; +use crate::registry; + +pub const RESOURCE_KIND: &str = "integration-auth0"; + +#[derive(Debug, Serialize)] +pub struct Auth0ClientConfig { + pub name: String, +} + +impl CatalogService for Auth0ClientConfig { + const REFERENCE: &'static str = "auth0/client"; +} + +#[derive(Debug)] +pub struct Auth0Client; + +impl Hostable for Auth0Client { + const PROVIDER: &'static str = "auth0"; + const HOSTING: IntegrationHosting = IntegrationHosting::Managed; + const CONFIG_SCOPE: ConfigScope = ConfigScope::GlobalOnly; + const RESOURCE_KIND: &'static str = RESOURCE_KIND; + const OUTPUTS: &'static [&'static str] = &["domain", "client_id", "client_secret"]; +} + +impl FamilyResource for Auth0Client { + type Config = Auth0ClientConfig; + const PROVIDER_PREFIX: &'static str = "AUTH0"; + // Provisional until pinned by `mise run discover auth0/client`. + const OUTPUT_FIELDS: &'static [(&'static str, &'static str, bool)] = &[ + ("DOMAIN", "domain", true), + ("CLIENT_ID", "client_id", true), + ("CLIENT_SECRET", "client_secret", true), + ]; + + fn build_config(ctx: &ProvisionContext<'_>) -> Result { + let config = super::integration_config(ctx)?; + Ok(Auth0ClientConfig { + name: super::interp_required(ctx, &config, "name")?, + }) + } +} + +pub fn validate_config( + name: &str, + config: &BTreeMap, +) -> Result<(), IntegrationError> { + registry::config_string(config, "name").map_err(|err| IntegrationError::ConfigInvalid { + location: format!("integrations.{name}.name"), + detail: err.to_string(), + })?; + let _ = (name, config); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ProviderOps; + use crate::resource::ResourcePayload; + use stackless_core::def::StackDef; + use stackless_stripe_projects::stripe::StripeProjects; + use stackless_stripe_projects::test_support; + + #[test] + fn config_matches_catalog() { + const FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../stackless-stripe-projects/tests/fixtures/catalog.json" + )); + let catalog = stackless_stripe_projects::Catalog::from_json_envelope(FIXTURE).unwrap(); + let failures = stackless_stripe_projects::verify_service( + &catalog, + &Auth0ClientConfig { + name: "test-name".into(), + }, + ); + assert!( + failures.is_empty(), + "auth0/client catalog gaps:\n{}", + failures.join("\n") + ); + } + + const CATALOG_ENVELOPE: &str = r##"{"ok":true,"command":"projects catalog","data":{"last_updated":"2026-07-11T00:00:00Z","services":[{"id":"prvsvc_client","object":"v2.provisioning.provider_service_detail","provider_id":"prvdr_auth0","provider_name":"Auth0","service_id":"client","categories":["database"],"kind":"deployable","scope":"project","availability":"available","development":false,"livemode":true,"pricing":{"type":"component"},"configuration_schema":{"properties":{"name":{"minLength":1,"type":"string"}},"required":["name"],"type":"object"}}]}}"##; + + fn test_def() -> StackDef { + StackDef::parse( + r#" +[stack] +name = "atto" +[stack.projects.stripe] +project = "project_1" +[integrations.res] +provider = "auth0" +name = "test-name" +[services.api] +source = { repo = "r", ref = "main" } +env = { OUT = "${integrations.res.domain}" } +health = { path = "/health" } +[services.api.local] +run = "true" +"#, + ) + .unwrap() + } + + #[tokio::test] + async fn provision_records_outputs() { + let runner = test_support::provision_script( + CATALOG_ENVELOPE, + serde_json::json!({"AUTH0_DOMAIN": "val_domain", "AUTH0_CLIENT_ID": "val_client_id", "AUTH0_CLIENT_SECRET": "val_client_secret"}), + 0, + ); + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("stackless.toml"), + "[stack]\nname=\"atto\"\n", + ) + .unwrap(); + let stripe = StripeProjects::new(&runner, dir.path()); + + let resource = Auth0Client + .provision( + &stripe.as_dyn(), + &test_def(), + dir.path(), + "demo", + "res", + "local", + false, + ) + .await + .unwrap(); + assert_eq!(resource.resource_kind, "integration-auth0"); + let payload: ResourcePayload = serde_json::from_str(&resource.payload).unwrap(); + assert_eq!(payload.outputs["domain"], "val_domain"); + } +} diff --git a/crates/stackless-integrations/src/providers/auth0/mod.rs b/crates/stackless-integrations/src/providers/auth0/mod.rs new file mode 100644 index 0000000..cd6012a --- /dev/null +++ b/crates/stackless-integrations/src/providers/auth0/mod.rs @@ -0,0 +1,11 @@ +//! Auth0 catalog resources via Stripe Projects. +//! +//! Output envelopes are provisional until pinned by `xtask discover`. + +pub mod client; + +#[allow(unused_imports)] +pub(crate) use crate::resource::{ + CatalogResource as FamilyResource, bool_optional, bool_required, int_optional, int_required, + integration_config, interp_optional, interp_required, +}; diff --git a/crates/stackless-integrations/src/providers/base44_projects/app.rs b/crates/stackless-integrations/src/providers/base44_projects/app.rs new file mode 100644 index 0000000..052a5bb --- /dev/null +++ b/crates/stackless-integrations/src/providers/base44_projects/app.rs @@ -0,0 +1,142 @@ +//! `base44_projects/app` integration. + +use std::collections::BTreeMap; + +use serde::Serialize; +use stackless_stripe_projects::catalog::verify::CatalogService; +use stackless_stripe_projects::provision::ProvisionContext; + +use super::FamilyResource; +use crate::error::IntegrationError; +use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; +use crate::registry; + +pub const RESOURCE_KIND: &str = "integration-base44"; + +#[derive(Debug, Serialize)] +pub struct Base44ProjectsAppConfig { + #[serde(skip_serializing_if = "Option::is_none")] + pub app_name: Option, +} + +impl CatalogService for Base44ProjectsAppConfig { + const REFERENCE: &'static str = "base44_projects/app"; +} + +#[derive(Debug)] +pub struct Base44ProjectsApp; + +impl Hostable for Base44ProjectsApp { + const PROVIDER: &'static str = "base44"; + const HOSTING: IntegrationHosting = IntegrationHosting::Managed; + const CONFIG_SCOPE: ConfigScope = ConfigScope::GlobalOnly; + const RESOURCE_KIND: &'static str = RESOURCE_KIND; + const OUTPUTS: &'static [&'static str] = &["app_id"]; +} + +impl FamilyResource for Base44ProjectsApp { + type Config = Base44ProjectsAppConfig; + const PROVIDER_PREFIX: &'static str = "BASE44"; + // Provisional until pinned by `mise run discover base44_projects/app`. + const OUTPUT_FIELDS: &'static [(&'static str, &'static str, bool)] = + &[("APP_ID", "app_id", true)]; + + fn build_config( + ctx: &ProvisionContext<'_>, + ) -> Result { + let config = super::integration_config(ctx)?; + Ok(Base44ProjectsAppConfig { + app_name: super::interp_optional(ctx, &config, "app_name")?, + }) + } +} + +pub fn validate_config( + name: &str, + config: &BTreeMap, +) -> Result<(), IntegrationError> { + let _ = (name, config); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ProviderOps; + use crate::resource::ResourcePayload; + use stackless_core::def::StackDef; + use stackless_stripe_projects::stripe::StripeProjects; + use stackless_stripe_projects::test_support; + + #[test] + fn config_matches_catalog() { + const FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../stackless-stripe-projects/tests/fixtures/catalog.json" + )); + let catalog = stackless_stripe_projects::Catalog::from_json_envelope(FIXTURE).unwrap(); + let failures = stackless_stripe_projects::verify_service( + &catalog, + &Base44ProjectsAppConfig { app_name: None }, + ); + assert!( + failures.is_empty(), + "base44_projects/app catalog gaps:\n{}", + failures.join("\n") + ); + } + + const CATALOG_ENVELOPE: &str = r##"{"ok":true,"command":"projects catalog","data":{"last_updated":"2026-07-11T00:00:00Z","services":[{"id":"prvsvc_app","object":"v2.provisioning.provider_service_detail","provider_id":"prvdr_base44_projects","provider_name":"Base44_Projects","service_id":"app","categories":["database"],"kind":"deployable","scope":"project","availability":"available","development":false,"livemode":true,"pricing":{"type":"free"},"configuration_schema":{"additionalProperties":false,"properties":{"app_name":{"description":"Human-readable name for the Base44 app. Shown in the Base44 dashboard.","maxLength":50,"minLength":1,"title":"App name","type":"string"}},"required":[],"type":"object"}}]}}"##; + + fn test_def() -> StackDef { + StackDef::parse( + r#" +[stack] +name = "atto" +[stack.projects.stripe] +project = "project_1" +[integrations.res] +provider = "base44" +[services.api] +source = { repo = "r", ref = "main" } +env = { OUT = "${integrations.res.app_id}" } +health = { path = "/health" } +[services.api.local] +run = "true" +"#, + ) + .unwrap() + } + + #[tokio::test] + async fn provision_records_outputs() { + let runner = test_support::provision_script( + CATALOG_ENVELOPE, + serde_json::json!({"BASE44_APP_ID": "val_app_id"}), + 0, + ); + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("stackless.toml"), + "[stack]\nname=\"atto\"\n", + ) + .unwrap(); + let stripe = StripeProjects::new(&runner, dir.path()); + + let resource = Base44ProjectsApp + .provision( + &stripe.as_dyn(), + &test_def(), + dir.path(), + "demo", + "res", + "local", + false, + ) + .await + .unwrap(); + assert_eq!(resource.resource_kind, "integration-base44"); + let payload: ResourcePayload = serde_json::from_str(&resource.payload).unwrap(); + assert_eq!(payload.outputs["app_id"], "val_app_id"); + } +} diff --git a/crates/stackless-integrations/src/providers/base44_projects/mod.rs b/crates/stackless-integrations/src/providers/base44_projects/mod.rs new file mode 100644 index 0000000..62165ef --- /dev/null +++ b/crates/stackless-integrations/src/providers/base44_projects/mod.rs @@ -0,0 +1,11 @@ +//! Base44_Projects catalog resources via Stripe Projects. +//! +//! Output envelopes are provisional until pinned by `xtask discover`. + +pub mod app; + +#[allow(unused_imports)] +pub(crate) use crate::resource::{ + CatalogResource as FamilyResource, bool_optional, bool_required, int_optional, int_required, + integration_config, interp_optional, interp_required, +}; diff --git a/crates/stackless-integrations/src/providers/blaxel/agent_drive.rs b/crates/stackless-integrations/src/providers/blaxel/agent_drive.rs new file mode 100644 index 0000000..06d2ca3 --- /dev/null +++ b/crates/stackless-integrations/src/providers/blaxel/agent_drive.rs @@ -0,0 +1,152 @@ +//! `blaxel/agent-drive` integration. + +use std::collections::BTreeMap; + +use serde::Serialize; +use stackless_stripe_projects::catalog::verify::CatalogService; +use stackless_stripe_projects::provision::ProvisionContext; + +use super::FamilyResource; +use crate::error::IntegrationError; +use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; +use crate::registry; + +pub const RESOURCE_KIND: &str = "integration-blaxel-agent-drive"; + +#[derive(Debug, Serialize)] +pub struct BlaxelAgentDriveConfig { + #[serde(skip_serializing_if = "Option::is_none")] + pub display_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub region: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub size: Option, +} + +impl CatalogService for BlaxelAgentDriveConfig { + const REFERENCE: &'static str = "blaxel/agent-drive"; +} + +#[derive(Debug)] +pub struct BlaxelAgentDrive; + +impl Hostable for BlaxelAgentDrive { + const PROVIDER: &'static str = "blaxel-agent-drive"; + const HOSTING: IntegrationHosting = IntegrationHosting::Managed; + const CONFIG_SCOPE: ConfigScope = ConfigScope::GlobalOnly; + const RESOURCE_KIND: &'static str = RESOURCE_KIND; + const OUTPUTS: &'static [&'static str] = &["api_key"]; +} + +impl FamilyResource for BlaxelAgentDrive { + type Config = BlaxelAgentDriveConfig; + const PROVIDER_PREFIX: &'static str = "BLAXEL"; + // Provisional until pinned by `mise run discover blaxel/agent-drive`. + const OUTPUT_FIELDS: &'static [(&'static str, &'static str, bool)] = + &[("API_KEY", "api_key", true)]; + + fn build_config( + ctx: &ProvisionContext<'_>, + ) -> Result { + let config = super::integration_config(ctx)?; + Ok(BlaxelAgentDriveConfig { + display_name: super::interp_optional(ctx, &config, "display_name")?, + region: super::interp_optional(ctx, &config, "region")?, + size: super::int_optional(ctx, &config, "size")?, + }) + } +} + +pub fn validate_config( + name: &str, + config: &BTreeMap, +) -> Result<(), IntegrationError> { + let _ = (name, config); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ProviderOps; + use crate::resource::ResourcePayload; + use stackless_core::def::StackDef; + use stackless_stripe_projects::stripe::StripeProjects; + use stackless_stripe_projects::test_support; + + #[test] + fn config_matches_catalog() { + const FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../stackless-stripe-projects/tests/fixtures/catalog.json" + )); + let catalog = stackless_stripe_projects::Catalog::from_json_envelope(FIXTURE).unwrap(); + let failures = stackless_stripe_projects::verify_service( + &catalog, + &BlaxelAgentDriveConfig { + display_name: None, + region: None, + size: None, + }, + ); + assert!( + failures.is_empty(), + "blaxel/agent-drive catalog gaps:\n{}", + failures.join("\n") + ); + } + + const CATALOG_ENVELOPE: &str = r##"{"ok":true,"command":"projects catalog","data":{"last_updated":"2026-07-11T00:00:00Z","services":[{"id":"prvsvc_agent_drive","object":"v2.provisioning.provider_service_detail","provider_id":"prvdr_blaxel","provider_name":"Blaxel","service_id":"agent-drive","categories":["database"],"kind":"deployable","scope":"project","availability":"available","development":false,"livemode":true,"pricing":{"type":"free"},"configuration_schema":{"additionalProperties":false,"properties":{"display_name":{"description":"Human-readable display name shown in the Blaxel dashboard.","type":"string"},"region":{"description":"Region where the drive is created.","enum":["us-was-1"],"type":"string"},"size":{"description":"Drive size in GB.","enum":[1,5,10,50,100],"type":"integer"}},"required":[],"type":"object"}}]}}"##; + + fn test_def() -> StackDef { + StackDef::parse( + r#" +[stack] +name = "atto" +[stack.projects.stripe] +project = "project_1" +[integrations.res] +provider = "blaxel-agent-drive" +[services.api] +source = { repo = "r", ref = "main" } +env = { OUT = "${integrations.res.api_key}" } +health = { path = "/health" } +[services.api.local] +run = "true" +"#, + ) + .unwrap() + } + + #[tokio::test] + async fn provision_records_outputs() { + let runner = test_support::provision_script( + CATALOG_ENVELOPE, + serde_json::json!({"BLAXEL_API_KEY": "val_api_key"}), + 0, + ); + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("stackless.toml"), + "[stack]\nname=\"atto\"\n", + ) + .unwrap(); + let stripe = StripeProjects::new(&runner, dir.path()); + + let resource = BlaxelAgentDrive + .provision( + &stripe.as_dyn(), + &test_def(), + dir.path(), + "demo", + "res", + "local", + false, + ) + .await + .unwrap(); + assert_eq!(resource.resource_kind, "integration-blaxel-agent-drive"); + let payload: ResourcePayload = serde_json::from_str(&resource.payload).unwrap(); + assert_eq!(payload.outputs["api_key"], "val_api_key"); + } +} diff --git a/crates/stackless-integrations/src/providers/blaxel/mod.rs b/crates/stackless-integrations/src/providers/blaxel/mod.rs new file mode 100644 index 0000000..b1028b9 --- /dev/null +++ b/crates/stackless-integrations/src/providers/blaxel/mod.rs @@ -0,0 +1,12 @@ +//! Blaxel catalog resources via Stripe Projects. +//! +//! Output envelopes are provisional until pinned by `xtask discover`. + +pub mod agent_drive; +pub mod sandbox; + +#[allow(unused_imports)] +pub(crate) use crate::resource::{ + CatalogResource as FamilyResource, bool_optional, bool_required, int_optional, int_required, + integration_config, interp_optional, interp_required, +}; diff --git a/crates/stackless-integrations/src/providers/blaxel/sandbox.rs b/crates/stackless-integrations/src/providers/blaxel/sandbox.rs new file mode 100644 index 0000000..07ecc80 --- /dev/null +++ b/crates/stackless-integrations/src/providers/blaxel/sandbox.rs @@ -0,0 +1,158 @@ +//! `blaxel/sandbox` integration. + +use std::collections::BTreeMap; + +use serde::Serialize; +use stackless_stripe_projects::catalog::verify::CatalogService; +use stackless_stripe_projects::provision::ProvisionContext; + +use super::FamilyResource; +use crate::error::IntegrationError; +use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; +use crate::registry; + +pub const RESOURCE_KIND: &str = "integration-blaxel-sandbox"; + +#[derive(Debug, Serialize)] +pub struct BlaxelSandboxConfig { + #[serde(skip_serializing_if = "Option::is_none")] + pub display_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub image: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub memory: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub region: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ttl: Option, +} + +impl CatalogService for BlaxelSandboxConfig { + const REFERENCE: &'static str = "blaxel/sandbox"; +} + +#[derive(Debug)] +pub struct BlaxelSandbox; + +impl Hostable for BlaxelSandbox { + const PROVIDER: &'static str = "blaxel-sandbox"; + const HOSTING: IntegrationHosting = IntegrationHosting::Managed; + const CONFIG_SCOPE: ConfigScope = ConfigScope::GlobalOnly; + const RESOURCE_KIND: &'static str = RESOURCE_KIND; + const OUTPUTS: &'static [&'static str] = &["api_key"]; +} + +impl FamilyResource for BlaxelSandbox { + type Config = BlaxelSandboxConfig; + const PROVIDER_PREFIX: &'static str = "BLAXEL"; + // Provisional until pinned by `mise run discover blaxel/sandbox`. + const OUTPUT_FIELDS: &'static [(&'static str, &'static str, bool)] = + &[("API_KEY", "api_key", true)]; + + fn build_config(ctx: &ProvisionContext<'_>) -> Result { + let config = super::integration_config(ctx)?; + Ok(BlaxelSandboxConfig { + display_name: super::interp_optional(ctx, &config, "display_name")?, + image: super::interp_optional(ctx, &config, "image")?, + memory: super::int_optional(ctx, &config, "memory")?, + region: super::interp_optional(ctx, &config, "region")?, + ttl: super::interp_optional(ctx, &config, "ttl")?, + }) + } +} + +pub fn validate_config( + name: &str, + config: &BTreeMap, +) -> Result<(), IntegrationError> { + let _ = (name, config); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ProviderOps; + use crate::resource::ResourcePayload; + use stackless_core::def::StackDef; + use stackless_stripe_projects::stripe::StripeProjects; + use stackless_stripe_projects::test_support; + + #[test] + fn config_matches_catalog() { + const FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../stackless-stripe-projects/tests/fixtures/catalog.json" + )); + let catalog = stackless_stripe_projects::Catalog::from_json_envelope(FIXTURE).unwrap(); + let failures = stackless_stripe_projects::verify_service( + &catalog, + &BlaxelSandboxConfig { + display_name: None, + image: None, + memory: None, + region: None, + ttl: None, + }, + ); + assert!( + failures.is_empty(), + "blaxel/sandbox catalog gaps:\n{}", + failures.join("\n") + ); + } + + const CATALOG_ENVELOPE: &str = r##"{"ok":true,"command":"projects catalog","data":{"last_updated":"2026-07-11T00:00:00Z","services":[{"id":"prvsvc_sandbox","object":"v2.provisioning.provider_service_detail","provider_id":"prvdr_blaxel","provider_name":"Blaxel","service_id":"sandbox","categories":["database"],"kind":"deployable","scope":"project","availability":"available","development":false,"livemode":true,"pricing":{"type":"free"},"configuration_schema":{"additionalProperties":false,"properties":{"display_name":{"description":"Human-readable display name shown in the Blaxel dashboard.","type":"string"},"image":{"description":"Base image for the sandbox.","enum":["blaxel/base-image:latest","blaxel/ts-app:latest","blaxel/node:latest","blaxel/py-app:latest","blaxel/nextjs:latest","blaxel/vite:latest","blaxel/expo:latest"],"type":"string"},"memory":{"description":"Memory in MB. CPU is derived automatically.","enum":[1024,2048,4096,8192,16384],"type":"integer"},"region":{"description":"Region where the sandbox runs.","enum":["us-pdx-1","us-was-1","eu-lon-1","eu-fra-1"],"type":"string"},"ttl":{"description":"Max-age TTL from creation. Sandbox is deleted after this duration.","enum":["24h","7d","30d"],"type":"string"}},"required":[],"type":"object"}}]}}"##; + + fn test_def() -> StackDef { + StackDef::parse( + r#" +[stack] +name = "atto" +[stack.projects.stripe] +project = "project_1" +[integrations.res] +provider = "blaxel-sandbox" +[services.api] +source = { repo = "r", ref = "main" } +env = { OUT = "${integrations.res.api_key}" } +health = { path = "/health" } +[services.api.local] +run = "true" +"#, + ) + .unwrap() + } + + #[tokio::test] + async fn provision_records_outputs() { + let runner = test_support::provision_script( + CATALOG_ENVELOPE, + serde_json::json!({"BLAXEL_API_KEY": "val_api_key"}), + 0, + ); + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("stackless.toml"), + "[stack]\nname=\"atto\"\n", + ) + .unwrap(); + let stripe = StripeProjects::new(&runner, dir.path()); + + let resource = BlaxelSandbox + .provision( + &stripe.as_dyn(), + &test_def(), + dir.path(), + "demo", + "res", + "local", + false, + ) + .await + .unwrap(); + assert_eq!(resource.resource_kind, "integration-blaxel-sandbox"); + let payload: ResourcePayload = serde_json::from_str(&resource.payload).unwrap(); + assert_eq!(payload.outputs["api_key"], "val_api_key"); + } +} diff --git a/crates/stackless-integrations/src/providers/browserbase/mod.rs b/crates/stackless-integrations/src/providers/browserbase/mod.rs new file mode 100644 index 0000000..7e6915e --- /dev/null +++ b/crates/stackless-integrations/src/providers/browserbase/mod.rs @@ -0,0 +1,11 @@ +//! Browserbase catalog resources via Stripe Projects. +//! +//! Output envelopes are provisional until pinned by `xtask discover`. + +pub mod project; + +#[allow(unused_imports)] +pub(crate) use crate::resource::{ + CatalogResource as FamilyResource, bool_optional, bool_required, int_optional, int_required, + integration_config, interp_optional, interp_required, +}; diff --git a/crates/stackless-integrations/src/providers/browserbase/project.rs b/crates/stackless-integrations/src/providers/browserbase/project.rs new file mode 100644 index 0000000..e3699ac --- /dev/null +++ b/crates/stackless-integrations/src/providers/browserbase/project.rs @@ -0,0 +1,137 @@ +//! `browserbase/project` integration. + +use std::collections::BTreeMap; + +use serde::Serialize; +use stackless_stripe_projects::catalog::verify::CatalogService; +use stackless_stripe_projects::provision::ProvisionContext; + +use super::FamilyResource; +use crate::error::IntegrationError; +use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; +use crate::registry; + +pub const RESOURCE_KIND: &str = "integration-browserbase"; + +#[derive(Debug, Serialize)] +pub struct BrowserbaseProjectConfig {} + +impl CatalogService for BrowserbaseProjectConfig { + const REFERENCE: &'static str = "browserbase/project"; +} + +#[derive(Debug)] +pub struct BrowserbaseProject; + +impl Hostable for BrowserbaseProject { + const PROVIDER: &'static str = "browserbase"; + const HOSTING: IntegrationHosting = IntegrationHosting::Managed; + const CONFIG_SCOPE: ConfigScope = ConfigScope::GlobalOnly; + const RESOURCE_KIND: &'static str = RESOURCE_KIND; + const OUTPUTS: &'static [&'static str] = &["api_key", "project_id"]; +} + +impl FamilyResource for BrowserbaseProject { + type Config = BrowserbaseProjectConfig; + const PROVIDER_PREFIX: &'static str = "BROWSERBASE"; + // Provisional until pinned by `mise run discover browserbase/project`. + const OUTPUT_FIELDS: &'static [(&'static str, &'static str, bool)] = &[ + ("API_KEY", "api_key", true), + ("PROJECT_ID", "project_id", true), + ]; + + fn build_config( + ctx: &ProvisionContext<'_>, + ) -> Result { + let _ = super::integration_config(ctx)?; + Ok(BrowserbaseProjectConfig {}) + } +} + +pub fn validate_config( + name: &str, + config: &BTreeMap, +) -> Result<(), IntegrationError> { + let _ = (name, config); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ProviderOps; + use crate::resource::ResourcePayload; + use stackless_core::def::StackDef; + use stackless_stripe_projects::stripe::StripeProjects; + use stackless_stripe_projects::test_support; + + #[test] + fn config_matches_catalog() { + const FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../stackless-stripe-projects/tests/fixtures/catalog.json" + )); + let catalog = stackless_stripe_projects::Catalog::from_json_envelope(FIXTURE).unwrap(); + let failures = + stackless_stripe_projects::verify_service(&catalog, &BrowserbaseProjectConfig {}); + assert!( + failures.is_empty(), + "browserbase/project catalog gaps:\n{}", + failures.join("\n") + ); + } + + const CATALOG_ENVELOPE: &str = r##"{"ok":true,"command":"projects catalog","data":{"last_updated":"2026-07-11T00:00:00Z","services":[{"id":"prvsvc_project","object":"v2.provisioning.provider_service_detail","provider_id":"prvdr_browserbase","provider_name":"Browserbase","service_id":"project","categories":["database"],"kind":"deployable","scope":"project","availability":"available","development":false,"livemode":true,"pricing":{"type":"component"},"configuration_schema":{"type":"object","required":[],"additionalProperties":false,"properties":{}}}]}}"##; + + fn test_def() -> StackDef { + StackDef::parse( + r#" +[stack] +name = "atto" +[stack.projects.stripe] +project = "project_1" +[integrations.res] +provider = "browserbase" +[services.api] +source = { repo = "r", ref = "main" } +env = { OUT = "${integrations.res.api_key}" } +health = { path = "/health" } +[services.api.local] +run = "true" +"#, + ) + .unwrap() + } + + #[tokio::test] + async fn provision_records_outputs() { + let runner = test_support::provision_script( + CATALOG_ENVELOPE, + serde_json::json!({"BROWSERBASE_API_KEY": "val_api_key", "BROWSERBASE_PROJECT_ID": "val_project_id"}), + 0, + ); + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("stackless.toml"), + "[stack]\nname=\"atto\"\n", + ) + .unwrap(); + let stripe = StripeProjects::new(&runner, dir.path()); + + let resource = BrowserbaseProject + .provision( + &stripe.as_dyn(), + &test_def(), + dir.path(), + "demo", + "res", + "local", + false, + ) + .await + .unwrap(); + assert_eq!(resource.resource_kind, "integration-browserbase"); + let payload: ResourcePayload = serde_json::from_str(&resource.payload).unwrap(); + assert_eq!(payload.outputs["api_key"], "val_api_key"); + } +} diff --git a/crates/stackless-integrations/src/providers/chroma/database.rs b/crates/stackless-integrations/src/providers/chroma/database.rs new file mode 100644 index 0000000..35e0d12 --- /dev/null +++ b/crates/stackless-integrations/src/providers/chroma/database.rs @@ -0,0 +1,146 @@ +//! `chroma/database` integration. + +use std::collections::BTreeMap; + +use serde::Serialize; +use stackless_stripe_projects::catalog::verify::CatalogService; +use stackless_stripe_projects::provision::ProvisionContext; + +use super::FamilyResource; +use crate::error::IntegrationError; +use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; +use crate::registry; + +pub const RESOURCE_KIND: &str = "integration-chroma"; + +#[derive(Debug, Serialize)] +pub struct ChromaDatabaseConfig { + pub name: String, +} + +impl CatalogService for ChromaDatabaseConfig { + const REFERENCE: &'static str = "chroma/database"; +} + +#[derive(Debug)] +pub struct ChromaDatabase; + +impl Hostable for ChromaDatabase { + const PROVIDER: &'static str = "chroma"; + const HOSTING: IntegrationHosting = IntegrationHosting::Managed; + const CONFIG_SCOPE: ConfigScope = ConfigScope::GlobalOnly; + const RESOURCE_KIND: &'static str = RESOURCE_KIND; + const OUTPUTS: &'static [&'static str] = &["api_key"]; +} + +impl FamilyResource for ChromaDatabase { + type Config = ChromaDatabaseConfig; + const PROVIDER_PREFIX: &'static str = "CHROMA"; + // Provisional until pinned by `mise run discover chroma/database`. + const OUTPUT_FIELDS: &'static [(&'static str, &'static str, bool)] = + &[("API_KEY", "api_key", true)]; + + fn build_config(ctx: &ProvisionContext<'_>) -> Result { + let config = super::integration_config(ctx)?; + Ok(ChromaDatabaseConfig { + name: super::interp_required(ctx, &config, "name")?, + }) + } +} + +pub fn validate_config( + name: &str, + config: &BTreeMap, +) -> Result<(), IntegrationError> { + registry::config_string(config, "name").map_err(|err| IntegrationError::ConfigInvalid { + location: format!("integrations.{name}.name"), + detail: err.to_string(), + })?; + let _ = (name, config); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ProviderOps; + use crate::resource::ResourcePayload; + use stackless_core::def::StackDef; + use stackless_stripe_projects::stripe::StripeProjects; + use stackless_stripe_projects::test_support; + + #[test] + fn config_matches_catalog() { + const FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../stackless-stripe-projects/tests/fixtures/catalog.json" + )); + let catalog = stackless_stripe_projects::Catalog::from_json_envelope(FIXTURE).unwrap(); + let failures = stackless_stripe_projects::verify_service( + &catalog, + &ChromaDatabaseConfig { + name: "test-name".into(), + }, + ); + assert!( + failures.is_empty(), + "chroma/database catalog gaps:\n{}", + failures.join("\n") + ); + } + + const CATALOG_ENVELOPE: &str = r##"{"ok":true,"command":"projects catalog","data":{"last_updated":"2026-07-11T00:00:00Z","services":[{"id":"prvsvc_database","object":"v2.provisioning.provider_service_detail","provider_id":"prvdr_chroma","provider_name":"Chroma","service_id":"database","categories":["database"],"kind":"deployable","scope":"project","availability":"available","development":false,"livemode":true,"pricing":{"type":"paid"},"configuration_schema":{"properties":{"name":{"description":"Name for the database (defaults to 'agent'). Must be at least 3 characters, using only letters, numbers, underscores, and hyphens.","minLength":3,"type":"string"}},"required":["name"],"type":"object"}}]}}"##; + + fn test_def() -> StackDef { + StackDef::parse( + r#" +[stack] +name = "atto" +[stack.projects.stripe] +project = "project_1" +[integrations.res] +provider = "chroma" +name = "test-name" +[services.api] +source = { repo = "r", ref = "main" } +env = { OUT = "${integrations.res.api_key}" } +health = { path = "/health" } +[services.api.local] +run = "true" +"#, + ) + .unwrap() + } + + #[tokio::test] + async fn provision_records_outputs() { + let runner = test_support::provision_script( + CATALOG_ENVELOPE, + serde_json::json!({"CHROMA_API_KEY": "val_api_key"}), + 0, + ); + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("stackless.toml"), + "[stack]\nname=\"atto\"\n", + ) + .unwrap(); + let stripe = StripeProjects::new(&runner, dir.path()); + + let resource = ChromaDatabase + .provision( + &stripe.as_dyn(), + &test_def(), + dir.path(), + "demo", + "res", + "local", + false, + ) + .await + .unwrap(); + assert_eq!(resource.resource_kind, "integration-chroma"); + let payload: ResourcePayload = serde_json::from_str(&resource.payload).unwrap(); + assert_eq!(payload.outputs["api_key"], "val_api_key"); + } +} diff --git a/crates/stackless-integrations/src/providers/chroma/mod.rs b/crates/stackless-integrations/src/providers/chroma/mod.rs new file mode 100644 index 0000000..1785409 --- /dev/null +++ b/crates/stackless-integrations/src/providers/chroma/mod.rs @@ -0,0 +1,11 @@ +//! Chroma catalog resources via Stripe Projects. +//! +//! Output envelopes are provisional until pinned by `xtask discover`. + +pub mod database; + +#[allow(unused_imports)] +pub(crate) use crate::resource::{ + CatalogResource as FamilyResource, bool_optional, bool_required, int_optional, int_required, + integration_config, interp_optional, interp_required, +}; diff --git a/crates/stackless-integrations/src/providers/clickhouse/clickhouse.rs b/crates/stackless-integrations/src/providers/clickhouse/clickhouse.rs new file mode 100644 index 0000000..8a3feb0 --- /dev/null +++ b/crates/stackless-integrations/src/providers/clickhouse/clickhouse.rs @@ -0,0 +1,204 @@ +//! `clickhouse/clickhouse` integration. + +use std::collections::BTreeMap; + +use serde::Serialize; +use stackless_stripe_projects::catalog::verify::CatalogService; +use stackless_stripe_projects::provision::ProvisionContext; + +use super::FamilyResource; +use crate::error::IntegrationError; +use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; +use crate::registry; + +pub const RESOURCE_KIND: &str = "integration-clickhouse"; + +#[derive(Debug, Serialize)] +pub struct ClickHouseClickhouseConfig { + #[serde(rename = "maxReplicaMemoryGb")] + pub max_replica_memory_gb: i64, + #[serde(rename = "minReplicaMemoryGb")] + pub min_replica_memory_gb: i64, + pub name: String, + pub region: String, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "idleScaling")] + pub idle_scaling: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "idleTimeoutMinutes")] + pub idle_timeout_minutes: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "numReplicas")] + pub num_replicas: Option, +} + +impl CatalogService for ClickHouseClickhouseConfig { + const REFERENCE: &'static str = "clickhouse/clickhouse"; +} + +#[derive(Debug)] +pub struct ClickHouseClickhouse; + +impl Hostable for ClickHouseClickhouse { + const PROVIDER: &'static str = "clickhouse"; + const HOSTING: IntegrationHosting = IntegrationHosting::Managed; + const CONFIG_SCOPE: ConfigScope = ConfigScope::GlobalOnly; + const RESOURCE_KIND: &'static str = RESOURCE_KIND; + const OUTPUTS: &'static [&'static str] = &["connection_string"]; +} + +impl FamilyResource for ClickHouseClickhouse { + type Config = ClickHouseClickhouseConfig; + const PROVIDER_PREFIX: &'static str = "CLICKHOUSE"; + // Provisional until pinned by `mise run discover clickhouse/clickhouse`. + const OUTPUT_FIELDS: &'static [(&'static str, &'static str, bool)] = + &[("CONNECTION_STRING", "connection_string", true)]; + + fn build_config( + ctx: &ProvisionContext<'_>, + ) -> Result { + let config = super::integration_config(ctx)?; + Ok(ClickHouseClickhouseConfig { + max_replica_memory_gb: super::int_required(ctx, &config, "maxReplicaMemoryGb")?, + min_replica_memory_gb: super::int_required(ctx, &config, "minReplicaMemoryGb")?, + name: super::interp_required(ctx, &config, "name")?, + region: super::interp_required(ctx, &config, "region")?, + idle_scaling: super::bool_optional(ctx, &config, "idleScaling")?, + idle_timeout_minutes: super::int_optional(ctx, &config, "idleTimeoutMinutes")?, + num_replicas: super::int_optional(ctx, &config, "numReplicas")?, + }) + } +} + +pub fn validate_config( + name: &str, + config: &BTreeMap, +) -> Result<(), IntegrationError> { + if config + .get("maxReplicaMemoryGb") + .and_then(toml::Value::as_integer) + .is_none() + { + return Err(IntegrationError::ConfigInvalid { + location: format!("integrations.{name}.maxReplicaMemoryGb"), + detail: "maxReplicaMemoryGb is required and must be an integer".into(), + }); + } + if config + .get("minReplicaMemoryGb") + .and_then(toml::Value::as_integer) + .is_none() + { + return Err(IntegrationError::ConfigInvalid { + location: format!("integrations.{name}.minReplicaMemoryGb"), + detail: "minReplicaMemoryGb is required and must be an integer".into(), + }); + } + registry::config_string(config, "name").map_err(|err| IntegrationError::ConfigInvalid { + location: format!("integrations.{name}.name"), + detail: err.to_string(), + })?; + registry::config_string(config, "region").map_err(|err| IntegrationError::ConfigInvalid { + location: format!("integrations.{name}.region"), + detail: err.to_string(), + })?; + let _ = (name, config); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ProviderOps; + use crate::resource::ResourcePayload; + use stackless_core::def::StackDef; + use stackless_stripe_projects::stripe::StripeProjects; + use stackless_stripe_projects::test_support; + + #[test] + fn config_matches_catalog() { + const FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../stackless-stripe-projects/tests/fixtures/catalog.json" + )); + let catalog = stackless_stripe_projects::Catalog::from_json_envelope(FIXTURE).unwrap(); + let failures = stackless_stripe_projects::verify_service( + &catalog, + &ClickHouseClickhouseConfig { + max_replica_memory_gb: 12, + min_replica_memory_gb: 12, + name: "test-name".into(), + region: "aws-us-west-2".into(), + idle_scaling: None, + idle_timeout_minutes: None, + num_replicas: None, + }, + ); + assert!( + failures.is_empty(), + "clickhouse/clickhouse catalog gaps:\n{}", + failures.join("\n") + ); + } + + const CATALOG_ENVELOPE: &str = r##"{"ok":true,"command":"projects catalog","data":{"last_updated":"2026-07-11T00:00:00Z","services":[{"id":"prvsvc_clickhouse","object":"v2.provisioning.provider_service_detail","provider_id":"prvdr_clickhouse","provider_name":"ClickHouse","service_id":"clickhouse","categories":["database"],"kind":"deployable","scope":"project","availability":"available","development":false,"livemode":true,"pricing":{"type":"component"},"configuration_schema":{"additionalProperties":false,"properties":{"idleScaling":{"description":"When true, compute scales to zero after idleTimeoutMinutes of inactivity and bills $0/hour during idle periods. Defaults to false when omitted.","type":"boolean"},"idleTimeoutMinutes":{"description":"Minutes of inactivity before idle scale-down triggers. Only meaningful when idleScaling=true. Defaults to 60 when omitted.","maximum":1440,"minimum":5,"type":"integer"},"maxReplicaMemoryGb":{"default":12,"description":"Autoscale upper bound: memory per replica, in GiB. Must be \u2265 minReplicaMemoryGb (enforced server-side). Accepts 8\u2013356 GiB per replica in steps of 4; the maximum drops to 236 GiB on AWS regions due to UI caps.","maximum":356,"minimum":8,"multipleOf":4,"type":"integer"},"minReplicaMemoryGb":{"default":12,"description":"Autoscale lower bound: memory per replica, in GiB. Must be \u2264 maxReplicaMemoryGb (enforced server-side). Accepts 8\u2013356 GiB per replica in steps of 4; the maximum drops to 236 GiB on AWS regions due to UI caps.","maximum":356,"minimum":8,"multipleOf":4,"type":"integer"},"name":{"description":"Human-readable name for the ClickHouse service.","maxLength":64,"minLength":1,"type":"string"},"numReplicas":{"default":2,"description":"Number of replicas (HA). Defaults to 2 when omitted. Each replica meters compute independently, so total compute cost scales linearly with numReplicas.","maximum":100,"minimum":1,"type":"integer"},"region":{"description":"Cloud provider and region to host the service in, as a single cloud-qualified id (e.g. aws-us-east-1, gcp-us-east1, azure-eastus). The cloud provider is encoded in the value, so no separate field is needed. Pricing varies per region.","enum":["aws-us-west-2","aws-us-east-2","aws-us-east-1","aws-eu-west-1","aws-eu-west-2","aws-eu-central-1","aws-ap-southeast-1","aws-ap-southeast-2","aws-ap-northeast-1","aws-ap-south-1","aws-ap-northeast-2","aws-il-central-1","gcp-us-east1","gcp-us-central1","gcp-europe-west2","gcp-europe-west4","gcp-asia-southeast1","gcp-asia-northeast1","azure-germanywestcentral","azure-eastus2","azure-westus3"],"type":"string"}},"required":["name","region","minReplicaMemoryGb","maxReplicaMemoryGb"],"type":"object"}}]}}"##; + + fn test_def() -> StackDef { + StackDef::parse( + r#" +[stack] +name = "atto" +[stack.projects.stripe] +project = "project_1" +[integrations.res] +provider = "clickhouse" +maxReplicaMemoryGb = 12 +minReplicaMemoryGb = 12 +name = "test-name" +region = "aws-us-west-2" +[services.api] +source = { repo = "r", ref = "main" } +env = { OUT = "${integrations.res.connection_string}" } +health = { path = "/health" } +[services.api.local] +run = "true" +"#, + ) + .unwrap() + } + + #[tokio::test] + async fn provision_records_outputs() { + let runner = test_support::provision_script( + CATALOG_ENVELOPE, + serde_json::json!({"CLICKHOUSE_CONNECTION_STRING": "val_connection_string"}), + 0, + ); + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("stackless.toml"), + "[stack]\nname=\"atto\"\n", + ) + .unwrap(); + let stripe = StripeProjects::new(&runner, dir.path()); + + let resource = ClickHouseClickhouse + .provision( + &stripe.as_dyn(), + &test_def(), + dir.path(), + "demo", + "res", + "local", + false, + ) + .await + .unwrap(); + assert_eq!(resource.resource_kind, "integration-clickhouse"); + let payload: ResourcePayload = serde_json::from_str(&resource.payload).unwrap(); + assert_eq!( + payload.outputs["connection_string"], + "val_connection_string" + ); + } +} diff --git a/crates/stackless-integrations/src/providers/clickhouse/mod.rs b/crates/stackless-integrations/src/providers/clickhouse/mod.rs new file mode 100644 index 0000000..b8a2862 --- /dev/null +++ b/crates/stackless-integrations/src/providers/clickhouse/mod.rs @@ -0,0 +1,12 @@ +//! ClickHouse catalog resources via Stripe Projects. +//! +//! Output envelopes are provisional until pinned by `xtask discover`. + +pub mod clickhouse; +pub mod postgres; + +#[allow(unused_imports)] +pub(crate) use crate::resource::{ + CatalogResource as FamilyResource, bool_optional, bool_required, int_optional, int_required, + integration_config, interp_optional, interp_required, +}; diff --git a/crates/stackless-integrations/src/providers/clickhouse/postgres.rs b/crates/stackless-integrations/src/providers/clickhouse/postgres.rs new file mode 100644 index 0000000..2f925f0 --- /dev/null +++ b/crates/stackless-integrations/src/providers/clickhouse/postgres.rs @@ -0,0 +1,177 @@ +//! `clickhouse/postgres` integration. + +use std::collections::BTreeMap; + +use serde::Serialize; +use stackless_stripe_projects::catalog::verify::CatalogService; +use stackless_stripe_projects::provision::ProvisionContext; + +use super::FamilyResource; +use crate::error::IntegrationError; +use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; +use crate::registry; + +pub const RESOURCE_KIND: &str = "integration-clickhouse-postgres"; + +#[derive(Debug, Serialize)] +pub struct ClickHousePostgresConfig { + pub name: String, + pub region: String, + pub size: String, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "haType")] + pub ha_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "postgresVersion")] + pub postgres_version: Option, +} + +impl CatalogService for ClickHousePostgresConfig { + const REFERENCE: &'static str = "clickhouse/postgres"; +} + +#[derive(Debug)] +pub struct ClickHousePostgres; + +impl Hostable for ClickHousePostgres { + const PROVIDER: &'static str = "clickhouse-postgres"; + const HOSTING: IntegrationHosting = IntegrationHosting::Managed; + const CONFIG_SCOPE: ConfigScope = ConfigScope::GlobalOnly; + const RESOURCE_KIND: &'static str = RESOURCE_KIND; + const OUTPUTS: &'static [&'static str] = &["connection_string"]; +} + +impl FamilyResource for ClickHousePostgres { + type Config = ClickHousePostgresConfig; + const PROVIDER_PREFIX: &'static str = "CLICKHOUSE"; + // Provisional until pinned by `mise run discover clickhouse/postgres`. + const OUTPUT_FIELDS: &'static [(&'static str, &'static str, bool)] = + &[("CONNECTION_STRING", "connection_string", true)]; + + fn build_config( + ctx: &ProvisionContext<'_>, + ) -> Result { + let config = super::integration_config(ctx)?; + Ok(ClickHousePostgresConfig { + name: super::interp_required(ctx, &config, "name")?, + region: super::interp_required(ctx, &config, "region")?, + size: super::interp_required(ctx, &config, "size")?, + ha_type: super::interp_optional(ctx, &config, "haType")?, + postgres_version: super::interp_optional(ctx, &config, "postgresVersion")?, + }) + } +} + +pub fn validate_config( + name: &str, + config: &BTreeMap, +) -> Result<(), IntegrationError> { + registry::config_string(config, "name").map_err(|err| IntegrationError::ConfigInvalid { + location: format!("integrations.{name}.name"), + detail: err.to_string(), + })?; + registry::config_string(config, "region").map_err(|err| IntegrationError::ConfigInvalid { + location: format!("integrations.{name}.region"), + detail: err.to_string(), + })?; + registry::config_string(config, "size").map_err(|err| IntegrationError::ConfigInvalid { + location: format!("integrations.{name}.size"), + detail: err.to_string(), + })?; + let _ = (name, config); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ProviderOps; + use crate::resource::ResourcePayload; + use stackless_core::def::StackDef; + use stackless_stripe_projects::stripe::StripeProjects; + use stackless_stripe_projects::test_support; + + #[test] + fn config_matches_catalog() { + const FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../stackless-stripe-projects/tests/fixtures/catalog.json" + )); + let catalog = stackless_stripe_projects::Catalog::from_json_envelope(FIXTURE).unwrap(); + let failures = stackless_stripe_projects::verify_service( + &catalog, + &ClickHousePostgresConfig { + name: "test-name".into(), + region: "aws-ap-northeast-1".into(), + size: "c6gd.large".into(), + ha_type: None, + postgres_version: None, + }, + ); + assert!( + failures.is_empty(), + "clickhouse/postgres catalog gaps:\n{}", + failures.join("\n") + ); + } + + const CATALOG_ENVELOPE: &str = r##"{"ok":true,"command":"projects catalog","data":{"last_updated":"2026-07-11T00:00:00Z","services":[{"id":"prvsvc_postgres","object":"v2.provisioning.provider_service_detail","provider_id":"prvdr_clickhouse","provider_name":"ClickHouse","service_id":"postgres","categories":["database"],"kind":"deployable","scope":"project","availability":"available","development":false,"livemode":true,"pricing":{"type":"free"},"configuration_schema":{"additionalProperties":false,"properties":{"haType":{"description":"High-availability mode. Defaults to \"none\" when omitted.","enum":["none","async","sync"],"type":"string"},"name":{"description":"Human-readable name for the Postgres instance.","maxLength":50,"minLength":1,"type":"string"},"postgresVersion":{"description":"Major Postgres version. Defaults to the latest available when omitted.","enum":["18","17"],"type":"string"},"region":{"description":"Cloud provider and region to host the service in, as a single cloud-qualified id (e.g. aws-us-east-1). The cloud provider is encoded in the value, so no separate field is needed. Pricing varies per region.","enum":["aws-ap-northeast-1","aws-ap-northeast-2","aws-ap-south-1","aws-ap-southeast-1","aws-ap-southeast-2","aws-eu-central-1","aws-eu-west-1","aws-eu-west-2","aws-us-east-1","aws-us-east-2","aws-us-west-2"],"type":"string"},"size":{"description":"VM instance type determining CPU, memory, and storage.","enum":["c6gd.large","c6gd.xlarge","c6gd.2xlarge","c6gd.4xlarge","c6gd.8xlarge","c6gd.16xlarge","i7i.large","i7i.xlarge","i7i.2xlarge","i7i.4xlarge","i7i.8xlarge","i7i.12xlarge","i7i.16xlarge","i7i.24xlarge","i7ie.large","i7ie.xlarge","i7ie.2xlarge","i7ie.3xlarge","i7ie.6xlarge","i7ie.12xlarge","i7ie.18xlarge","i7ie.24xlarge","i8g.large","i8g.xlarge","i8g.2xlarge","i8g.4xlarge","i8g.8xlarge","i8g.16xlarge","i8g.24xlarge","i8ge.large","i8ge.xlarge","i8ge.2xlarge","i8ge.3xlarge","i8ge.6xlarge","i8ge.12xlarge","i8ge.18xlarge","i8ge.24xlarge","m6gd.large","m6gd.xlarge","m6gd.2xlarge","m6gd.4xlarge","m6gd.8xlarge","m6gd.16xlarge","m6id.large","m6id.xlarge","m6id.2xlarge","m6id.4xlarge","m6id.8xlarge","m6id.16xlarge","m8gd.large","m8gd.xlarge","m8gd.2xlarge","m8gd.4xlarge","m8gd.8xlarge","m8gd.16xlarge","r6gd.medium","r6gd.large","r6gd.xlarge","r6gd.2xlarge","r6gd.4xlarge","r6gd.8xlarge","r6gd.12xlarge","r6gd.16xlarge","r6id.large","r6id.xlarge","r6id.2xlarge","r6id.4xlarge","r6id.8xlarge","r6id.12xlarge","r6id.16xlarge","r6id.24xlarge","r6id.32xlarge","r8gd.medium","r8gd.large","r8gd.xlarge","r8gd.2xlarge","r8gd.4xlarge","r8gd.8xlarge","r8gd.12xlarge","r8gd.16xlarge","r8gd.24xlarge","r8gd.48xlarge"],"type":"string"}},"required":["name","region","size"],"type":"object"}}]}}"##; + + fn test_def() -> StackDef { + StackDef::parse( + r#" +[stack] +name = "atto" +[stack.projects.stripe] +project = "project_1" +[integrations.res] +provider = "clickhouse-postgres" +name = "test-name" +region = "aws-ap-northeast-1" +size = "c6gd.large" +[services.api] +source = { repo = "r", ref = "main" } +env = { OUT = "${integrations.res.connection_string}" } +health = { path = "/health" } +[services.api.local] +run = "true" +"#, + ) + .unwrap() + } + + #[tokio::test] + async fn provision_records_outputs() { + let runner = test_support::provision_script( + CATALOG_ENVELOPE, + serde_json::json!({"CLICKHOUSE_CONNECTION_STRING": "val_connection_string"}), + 0, + ); + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("stackless.toml"), + "[stack]\nname=\"atto\"\n", + ) + .unwrap(); + let stripe = StripeProjects::new(&runner, dir.path()); + + let resource = ClickHousePostgres + .provision( + &stripe.as_dyn(), + &test_def(), + dir.path(), + "demo", + "res", + "local", + false, + ) + .await + .unwrap(); + assert_eq!(resource.resource_kind, "integration-clickhouse-postgres"); + let payload: ResourcePayload = serde_json::from_str(&resource.payload).unwrap(); + assert_eq!( + payload.outputs["connection_string"], + "val_connection_string" + ); + } +} diff --git a/crates/stackless-integrations/src/providers/cloudflare/mod.rs b/crates/stackless-integrations/src/providers/cloudflare/mod.rs index ef38f79..f0f4223 100644 --- a/crates/stackless-integrations/src/providers/cloudflare/mod.rs +++ b/crates/stackless-integrations/src/providers/cloudflare/mod.rs @@ -11,7 +11,7 @@ //! Excluded: `containers` (paid, "pricing unavailable" — unknown cost), //! `registrar:domain` (a one-time non-refundable domain purchase), and the //! `workers:free`/`workers:paid` plans. Cloudflare Workers as a *deploy* -//! substrate (`--on cloudflare`) is a separate build. +//! substrate (`--on cloudflare`) lives in `stackless-cloudflare` — not here. pub mod browser_run; pub mod d1; diff --git a/crates/stackless-integrations/src/providers/cloudflare/workers.rs b/crates/stackless-integrations/src/providers/cloudflare/workers.rs index 86b75f6..a824d49 100644 --- a/crates/stackless-integrations/src/providers/cloudflare/workers.rs +++ b/crates/stackless-integrations/src/providers/cloudflare/workers.rs @@ -3,7 +3,7 @@ //! This provisions the Workers *resource* (account-level Workers enablement + //! a `*.workers.dev` subdomain) and exposes its coordinates — it is NOT a deploy //! target. Deploying a service's code to Workers (`--on cloudflare`) is a -//! separate substrate (out of scope; see the plan's deferred section). +//! separate deploy substrate in `stackless-cloudflare` (`--on cloudflare`). use std::collections::BTreeMap; diff --git a/crates/stackless-integrations/src/providers/daytona/mod.rs b/crates/stackless-integrations/src/providers/daytona/mod.rs new file mode 100644 index 0000000..eb20a90 --- /dev/null +++ b/crates/stackless-integrations/src/providers/daytona/mod.rs @@ -0,0 +1,11 @@ +//! Daytona catalog resources via Stripe Projects. +//! +//! Output envelopes are provisional until pinned by `xtask discover`. + +pub mod sandbox; + +#[allow(unused_imports)] +pub(crate) use crate::resource::{ + CatalogResource as FamilyResource, bool_optional, bool_required, int_optional, int_required, + integration_config, interp_optional, interp_required, +}; diff --git a/crates/stackless-integrations/src/providers/daytona/sandbox.rs b/crates/stackless-integrations/src/providers/daytona/sandbox.rs new file mode 100644 index 0000000..7a38133 --- /dev/null +++ b/crates/stackless-integrations/src/providers/daytona/sandbox.rs @@ -0,0 +1,210 @@ +//! `daytona/sandbox` integration. + +use std::collections::BTreeMap; + +use serde::Serialize; +use stackless_stripe_projects::catalog::verify::CatalogService; +use stackless_stripe_projects::provision::ProvisionContext; + +use super::FamilyResource; +use crate::error::IntegrationError; +use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; +use crate::registry; + +pub const RESOURCE_KIND: &str = "integration-daytona"; + +#[derive(Debug, Serialize)] +pub struct DaytonaSandboxConfig { + #[serde(skip_serializing_if = "Option::is_none")] + pub auto_archive_interval: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub auto_delete_interval: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub auto_stop_interval: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cpu: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub disk: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub env: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ephemeral: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub image: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub labels: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub language: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub memory: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub network_allow_list: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub network_block_all: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub public: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub snapshot: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub target: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub user: Option, +} + +impl CatalogService for DaytonaSandboxConfig { + const REFERENCE: &'static str = "daytona/sandbox"; +} + +#[derive(Debug)] +pub struct DaytonaSandbox; + +impl Hostable for DaytonaSandbox { + const PROVIDER: &'static str = "daytona"; + const HOSTING: IntegrationHosting = IntegrationHosting::Managed; + const CONFIG_SCOPE: ConfigScope = ConfigScope::GlobalOnly; + const RESOURCE_KIND: &'static str = RESOURCE_KIND; + const OUTPUTS: &'static [&'static str] = &["api_key"]; +} + +impl FamilyResource for DaytonaSandbox { + type Config = DaytonaSandboxConfig; + const PROVIDER_PREFIX: &'static str = "DAYTONA"; + // Provisional until pinned by `mise run discover daytona/sandbox`. + const OUTPUT_FIELDS: &'static [(&'static str, &'static str, bool)] = + &[("API_KEY", "api_key", true)]; + + fn build_config(ctx: &ProvisionContext<'_>) -> Result { + let config = super::integration_config(ctx)?; + Ok(DaytonaSandboxConfig { + auto_archive_interval: super::int_optional(ctx, &config, "auto_archive_interval")?, + auto_delete_interval: super::int_optional(ctx, &config, "auto_delete_interval")?, + auto_stop_interval: super::int_optional(ctx, &config, "auto_stop_interval")?, + cpu: super::int_optional(ctx, &config, "cpu")?, + disk: super::int_optional(ctx, &config, "disk")?, + env: super::interp_optional(ctx, &config, "env")?, + ephemeral: super::bool_optional(ctx, &config, "ephemeral")?, + image: super::interp_optional(ctx, &config, "image")?, + labels: super::interp_optional(ctx, &config, "labels")?, + language: super::interp_optional(ctx, &config, "language")?, + memory: super::int_optional(ctx, &config, "memory")?, + name: super::interp_optional(ctx, &config, "name")?, + network_allow_list: super::interp_optional(ctx, &config, "network_allow_list")?, + network_block_all: super::bool_optional(ctx, &config, "network_block_all")?, + public: super::bool_optional(ctx, &config, "public")?, + snapshot: super::interp_optional(ctx, &config, "snapshot")?, + target: super::interp_optional(ctx, &config, "target")?, + user: super::interp_optional(ctx, &config, "user")?, + }) + } +} + +pub fn validate_config( + name: &str, + config: &BTreeMap, +) -> Result<(), IntegrationError> { + let _ = (name, config); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ProviderOps; + use crate::resource::ResourcePayload; + use stackless_core::def::StackDef; + use stackless_stripe_projects::stripe::StripeProjects; + use stackless_stripe_projects::test_support; + + #[test] + fn config_matches_catalog() { + const FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../stackless-stripe-projects/tests/fixtures/catalog.json" + )); + let catalog = stackless_stripe_projects::Catalog::from_json_envelope(FIXTURE).unwrap(); + let failures = stackless_stripe_projects::verify_service( + &catalog, + &DaytonaSandboxConfig { + auto_archive_interval: None, + auto_delete_interval: None, + auto_stop_interval: None, + cpu: None, + disk: None, + env: None, + ephemeral: None, + image: None, + labels: None, + language: None, + memory: None, + name: None, + network_allow_list: None, + network_block_all: None, + public: None, + snapshot: None, + target: None, + user: None, + }, + ); + assert!( + failures.is_empty(), + "daytona/sandbox catalog gaps:\n{}", + failures.join("\n") + ); + } + + const CATALOG_ENVELOPE: &str = r##"{"ok":true,"command":"projects catalog","data":{"last_updated":"2026-07-11T00:00:00Z","services":[{"id":"prvsvc_sandbox","object":"v2.provisioning.provider_service_detail","provider_id":"prvdr_daytona","provider_name":"Daytona","service_id":"sandbox","categories":["database"],"kind":"deployable","scope":"project","availability":"available","development":false,"livemode":true,"pricing":{"type":"component"},"configuration_schema":{"additionalProperties":false,"properties":{"auto_archive_interval":{"minimum":0,"type":"integer"},"auto_delete_interval":{"description":"Negative values disable auto-delete.","type":"integer"},"auto_stop_interval":{"minimum":0,"type":"integer"},"cpu":{"minimum":1,"type":"integer"},"disk":{"description":"Disk in GiB.","minimum":1,"type":"integer"},"env":{"description":"Environment variables as comma-separated KEY=value pairs (e.g., FOO=bar,BAZ=qux).","type":"string"},"ephemeral":{"description":"If true, sandbox is deleted immediately upon stopping.","type":"boolean"},"image":{"description":"Docker image to use (e.g., debian:12.9). Mutually exclusive with snapshot.","type":"string"},"labels":{"description":"Labels as comma-separated KEY=value pairs (e.g., team=backend,env=dev).","type":"string"},"language":{"description":"Programming language for code execution.","enum":["python","typescript","javascript"],"type":"string"},"memory":{"description":"Memory in GiB.","minimum":1,"type":"integer"},"name":{"description":"Optional display name for the Daytona sandbox.","type":"string"},"network_allow_list":{"description":"Comma-separated CIDR allow list.","type":"string"},"network_block_all":{"description":"Block all egress from the sandbox.","type":"boolean"},"public":{"description":"Expose HTTP preview publicly.","type":"boolean"},"snapshot":{"description":"Optional Daytona snapshot ID or name to restore.","type":"string"},"target":{"description":"Daytona target region.","enum":["us","eu"],"type":"string"},"user":{"description":"User value sent to Daytona for the sandbox.","type":"string"}},"type":"object"}}]}}"##; + + fn test_def() -> StackDef { + StackDef::parse( + r#" +[stack] +name = "atto" +[stack.projects.stripe] +project = "project_1" +[integrations.res] +provider = "daytona" +[services.api] +source = { repo = "r", ref = "main" } +env = { OUT = "${integrations.res.api_key}" } +health = { path = "/health" } +[services.api.local] +run = "true" +"#, + ) + .unwrap() + } + + #[tokio::test] + async fn provision_records_outputs() { + let runner = test_support::provision_script( + CATALOG_ENVELOPE, + serde_json::json!({"DAYTONA_API_KEY": "val_api_key"}), + 0, + ); + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("stackless.toml"), + "[stack]\nname=\"atto\"\n", + ) + .unwrap(); + let stripe = StripeProjects::new(&runner, dir.path()); + + let resource = DaytonaSandbox + .provision( + &stripe.as_dyn(), + &test_def(), + dir.path(), + "demo", + "res", + "local", + false, + ) + .await + .unwrap(); + assert_eq!(resource.resource_kind, "integration-daytona"); + let payload: ResourcePayload = serde_json::from_str(&resource.payload).unwrap(); + assert_eq!(payload.outputs["api_key"], "val_api_key"); + } +} diff --git a/crates/stackless-integrations/src/providers/e2b/mod.rs b/crates/stackless-integrations/src/providers/e2b/mod.rs new file mode 100644 index 0000000..a264d60 --- /dev/null +++ b/crates/stackless-integrations/src/providers/e2b/mod.rs @@ -0,0 +1,11 @@ +//! E2B catalog resources via Stripe Projects. +//! +//! Output envelopes are provisional until pinned by `xtask discover`. + +pub mod sandbox; + +#[allow(unused_imports)] +pub(crate) use crate::resource::{ + CatalogResource as FamilyResource, bool_optional, bool_required, int_optional, int_required, + integration_config, interp_optional, interp_required, +}; diff --git a/crates/stackless-integrations/src/providers/e2b/sandbox.rs b/crates/stackless-integrations/src/providers/e2b/sandbox.rs new file mode 100644 index 0000000..01c6795 --- /dev/null +++ b/crates/stackless-integrations/src/providers/e2b/sandbox.rs @@ -0,0 +1,132 @@ +//! `e2b/sandbox` integration. + +use std::collections::BTreeMap; + +use serde::Serialize; +use stackless_stripe_projects::catalog::verify::CatalogService; +use stackless_stripe_projects::provision::ProvisionContext; + +use super::FamilyResource; +use crate::error::IntegrationError; +use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; +use crate::registry; + +pub const RESOURCE_KIND: &str = "integration-e2b"; + +#[derive(Debug, Serialize)] +pub struct E2BSandboxConfig {} + +impl CatalogService for E2BSandboxConfig { + const REFERENCE: &'static str = "e2b/sandbox"; +} + +#[derive(Debug)] +pub struct E2BSandbox; + +impl Hostable for E2BSandbox { + const PROVIDER: &'static str = "e2b"; + const HOSTING: IntegrationHosting = IntegrationHosting::Managed; + const CONFIG_SCOPE: ConfigScope = ConfigScope::GlobalOnly; + const RESOURCE_KIND: &'static str = RESOURCE_KIND; + const OUTPUTS: &'static [&'static str] = &["api_key"]; +} + +impl FamilyResource for E2BSandbox { + type Config = E2BSandboxConfig; + const PROVIDER_PREFIX: &'static str = "E2B"; + // Provisional until pinned by `mise run discover e2b/sandbox`. + const OUTPUT_FIELDS: &'static [(&'static str, &'static str, bool)] = + &[("API_KEY", "api_key", true)]; + + fn build_config(ctx: &ProvisionContext<'_>) -> Result { + let _ = super::integration_config(ctx)?; + Ok(E2BSandboxConfig {}) + } +} + +pub fn validate_config( + name: &str, + config: &BTreeMap, +) -> Result<(), IntegrationError> { + let _ = (name, config); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ProviderOps; + use crate::resource::ResourcePayload; + use stackless_core::def::StackDef; + use stackless_stripe_projects::stripe::StripeProjects; + use stackless_stripe_projects::test_support; + + #[test] + fn config_matches_catalog() { + const FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../stackless-stripe-projects/tests/fixtures/catalog.json" + )); + let catalog = stackless_stripe_projects::Catalog::from_json_envelope(FIXTURE).unwrap(); + let failures = stackless_stripe_projects::verify_service(&catalog, &E2BSandboxConfig {}); + assert!( + failures.is_empty(), + "e2b/sandbox catalog gaps:\n{}", + failures.join("\n") + ); + } + + const CATALOG_ENVELOPE: &str = r##"{"ok":true,"command":"projects catalog","data":{"last_updated":"2026-07-11T00:00:00Z","services":[{"id":"prvsvc_sandbox","object":"v2.provisioning.provider_service_detail","provider_id":"prvdr_e2b","provider_name":"E2B","service_id":"sandbox","categories":["database"],"kind":"deployable","scope":"project","availability":"available","development":false,"livemode":true,"pricing":{"type":"component"},"configuration_schema":{"type":"object","required":[],"additionalProperties":false,"properties":{}}}]}}"##; + + fn test_def() -> StackDef { + StackDef::parse( + r#" +[stack] +name = "atto" +[stack.projects.stripe] +project = "project_1" +[integrations.res] +provider = "e2b" +[services.api] +source = { repo = "r", ref = "main" } +env = { OUT = "${integrations.res.api_key}" } +health = { path = "/health" } +[services.api.local] +run = "true" +"#, + ) + .unwrap() + } + + #[tokio::test] + async fn provision_records_outputs() { + let runner = test_support::provision_script( + CATALOG_ENVELOPE, + serde_json::json!({"E2B_API_KEY": "val_api_key"}), + 0, + ); + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("stackless.toml"), + "[stack]\nname=\"atto\"\n", + ) + .unwrap(); + let stripe = StripeProjects::new(&runner, dir.path()); + + let resource = E2BSandbox + .provision( + &stripe.as_dyn(), + &test_def(), + dir.path(), + "demo", + "res", + "local", + false, + ) + .await + .unwrap(); + assert_eq!(resource.resource_kind, "integration-e2b"); + let payload: ResourcePayload = serde_json::from_str(&resource.payload).unwrap(); + assert_eq!(payload.outputs["api_key"], "val_api_key"); + } +} diff --git a/crates/stackless-integrations/src/providers/elevenlabs/mod.rs b/crates/stackless-integrations/src/providers/elevenlabs/mod.rs new file mode 100644 index 0000000..aee2dad --- /dev/null +++ b/crates/stackless-integrations/src/providers/elevenlabs/mod.rs @@ -0,0 +1,11 @@ +//! ElevenLabs catalog resources via Stripe Projects. +//! +//! Output envelopes are provisional until pinned by `xtask discover`. + +pub mod tts; + +#[allow(unused_imports)] +pub(crate) use crate::resource::{ + CatalogResource as FamilyResource, bool_optional, bool_required, int_optional, int_required, + integration_config, interp_optional, interp_required, +}; diff --git a/crates/stackless-integrations/src/providers/elevenlabs/tts.rs b/crates/stackless-integrations/src/providers/elevenlabs/tts.rs new file mode 100644 index 0000000..64ba7b1 --- /dev/null +++ b/crates/stackless-integrations/src/providers/elevenlabs/tts.rs @@ -0,0 +1,132 @@ +//! `elevenlabs/tts` integration. + +use std::collections::BTreeMap; + +use serde::Serialize; +use stackless_stripe_projects::catalog::verify::CatalogService; +use stackless_stripe_projects::provision::ProvisionContext; + +use super::FamilyResource; +use crate::error::IntegrationError; +use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; +use crate::registry; + +pub const RESOURCE_KIND: &str = "integration-elevenlabs"; + +#[derive(Debug, Serialize)] +pub struct ElevenLabsTtsConfig {} + +impl CatalogService for ElevenLabsTtsConfig { + const REFERENCE: &'static str = "elevenlabs/tts"; +} + +#[derive(Debug)] +pub struct ElevenLabsTts; + +impl Hostable for ElevenLabsTts { + const PROVIDER: &'static str = "elevenlabs"; + const HOSTING: IntegrationHosting = IntegrationHosting::Managed; + const CONFIG_SCOPE: ConfigScope = ConfigScope::GlobalOnly; + const RESOURCE_KIND: &'static str = RESOURCE_KIND; + const OUTPUTS: &'static [&'static str] = &["api_key"]; +} + +impl FamilyResource for ElevenLabsTts { + type Config = ElevenLabsTtsConfig; + const PROVIDER_PREFIX: &'static str = "ELEVENLABS"; + // Provisional until pinned by `mise run discover elevenlabs/tts`. + const OUTPUT_FIELDS: &'static [(&'static str, &'static str, bool)] = + &[("API_KEY", "api_key", true)]; + + fn build_config(ctx: &ProvisionContext<'_>) -> Result { + let _ = super::integration_config(ctx)?; + Ok(ElevenLabsTtsConfig {}) + } +} + +pub fn validate_config( + name: &str, + config: &BTreeMap, +) -> Result<(), IntegrationError> { + let _ = (name, config); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ProviderOps; + use crate::resource::ResourcePayload; + use stackless_core::def::StackDef; + use stackless_stripe_projects::stripe::StripeProjects; + use stackless_stripe_projects::test_support; + + #[test] + fn config_matches_catalog() { + const FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../stackless-stripe-projects/tests/fixtures/catalog.json" + )); + let catalog = stackless_stripe_projects::Catalog::from_json_envelope(FIXTURE).unwrap(); + let failures = stackless_stripe_projects::verify_service(&catalog, &ElevenLabsTtsConfig {}); + assert!( + failures.is_empty(), + "elevenlabs/tts catalog gaps:\n{}", + failures.join("\n") + ); + } + + const CATALOG_ENVELOPE: &str = r##"{"ok":true,"command":"projects catalog","data":{"last_updated":"2026-07-11T00:00:00Z","services":[{"id":"prvsvc_tts","object":"v2.provisioning.provider_service_detail","provider_id":"prvdr_elevenlabs","provider_name":"ElevenLabs","service_id":"tts","categories":["database"],"kind":"deployable","scope":"project","availability":"available","development":false,"livemode":true,"pricing":{"type":"free"},"configuration_schema":{"type":"object","required":[],"additionalProperties":false,"properties":{}}}]}}"##; + + fn test_def() -> StackDef { + StackDef::parse( + r#" +[stack] +name = "atto" +[stack.projects.stripe] +project = "project_1" +[integrations.res] +provider = "elevenlabs" +[services.api] +source = { repo = "r", ref = "main" } +env = { OUT = "${integrations.res.api_key}" } +health = { path = "/health" } +[services.api.local] +run = "true" +"#, + ) + .unwrap() + } + + #[tokio::test] + async fn provision_records_outputs() { + let runner = test_support::provision_script( + CATALOG_ENVELOPE, + serde_json::json!({"ELEVENLABS_API_KEY": "val_api_key"}), + 0, + ); + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("stackless.toml"), + "[stack]\nname=\"atto\"\n", + ) + .unwrap(); + let stripe = StripeProjects::new(&runner, dir.path()); + + let resource = ElevenLabsTts + .provision( + &stripe.as_dyn(), + &test_def(), + dir.path(), + "demo", + "res", + "local", + false, + ) + .await + .unwrap(); + assert_eq!(resource.resource_kind, "integration-elevenlabs"); + let payload: ResourcePayload = serde_json::from_str(&resource.payload).unwrap(); + assert_eq!(payload.outputs["api_key"], "val_api_key"); + } +} diff --git a/crates/stackless-integrations/src/providers/exa/api.rs b/crates/stackless-integrations/src/providers/exa/api.rs new file mode 100644 index 0000000..af2cd7f --- /dev/null +++ b/crates/stackless-integrations/src/providers/exa/api.rs @@ -0,0 +1,132 @@ +//! `exa/api` integration. + +use std::collections::BTreeMap; + +use serde::Serialize; +use stackless_stripe_projects::catalog::verify::CatalogService; +use stackless_stripe_projects::provision::ProvisionContext; + +use super::FamilyResource; +use crate::error::IntegrationError; +use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; +use crate::registry; + +pub const RESOURCE_KIND: &str = "integration-exa"; + +#[derive(Debug, Serialize)] +pub struct ExaApiConfig {} + +impl CatalogService for ExaApiConfig { + const REFERENCE: &'static str = "exa/api"; +} + +#[derive(Debug)] +pub struct ExaApi; + +impl Hostable for ExaApi { + const PROVIDER: &'static str = "exa"; + const HOSTING: IntegrationHosting = IntegrationHosting::Managed; + const CONFIG_SCOPE: ConfigScope = ConfigScope::GlobalOnly; + const RESOURCE_KIND: &'static str = RESOURCE_KIND; + const OUTPUTS: &'static [&'static str] = &["api_key"]; +} + +impl FamilyResource for ExaApi { + type Config = ExaApiConfig; + const PROVIDER_PREFIX: &'static str = "EXA"; + // Provisional until pinned by `mise run discover exa/api`. + const OUTPUT_FIELDS: &'static [(&'static str, &'static str, bool)] = + &[("API_KEY", "api_key", true)]; + + fn build_config(ctx: &ProvisionContext<'_>) -> Result { + let _ = super::integration_config(ctx)?; + Ok(ExaApiConfig {}) + } +} + +pub fn validate_config( + name: &str, + config: &BTreeMap, +) -> Result<(), IntegrationError> { + let _ = (name, config); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ProviderOps; + use crate::resource::ResourcePayload; + use stackless_core::def::StackDef; + use stackless_stripe_projects::stripe::StripeProjects; + use stackless_stripe_projects::test_support; + + #[test] + fn config_matches_catalog() { + const FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../stackless-stripe-projects/tests/fixtures/catalog.json" + )); + let catalog = stackless_stripe_projects::Catalog::from_json_envelope(FIXTURE).unwrap(); + let failures = stackless_stripe_projects::verify_service(&catalog, &ExaApiConfig {}); + assert!( + failures.is_empty(), + "exa/api catalog gaps:\n{}", + failures.join("\n") + ); + } + + const CATALOG_ENVELOPE: &str = r##"{"ok":true,"command":"projects catalog","data":{"last_updated":"2026-07-11T00:00:00Z","services":[{"id":"prvsvc_api","object":"v2.provisioning.provider_service_detail","provider_id":"prvdr_exa","provider_name":"Exa","service_id":"api","categories":["database"],"kind":"deployable","scope":"project","availability":"available","development":false,"livemode":true,"pricing":{"type":"component"},"configuration_schema":{"type":"object","required":[],"additionalProperties":false,"properties":{}}}]}}"##; + + fn test_def() -> StackDef { + StackDef::parse( + r#" +[stack] +name = "atto" +[stack.projects.stripe] +project = "project_1" +[integrations.res] +provider = "exa" +[services.api] +source = { repo = "r", ref = "main" } +env = { OUT = "${integrations.res.api_key}" } +health = { path = "/health" } +[services.api.local] +run = "true" +"#, + ) + .unwrap() + } + + #[tokio::test] + async fn provision_records_outputs() { + let runner = test_support::provision_script( + CATALOG_ENVELOPE, + serde_json::json!({"EXA_API_KEY": "val_api_key"}), + 0, + ); + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("stackless.toml"), + "[stack]\nname=\"atto\"\n", + ) + .unwrap(); + let stripe = StripeProjects::new(&runner, dir.path()); + + let resource = ExaApi + .provision( + &stripe.as_dyn(), + &test_def(), + dir.path(), + "demo", + "res", + "local", + false, + ) + .await + .unwrap(); + assert_eq!(resource.resource_kind, "integration-exa"); + let payload: ResourcePayload = serde_json::from_str(&resource.payload).unwrap(); + assert_eq!(payload.outputs["api_key"], "val_api_key"); + } +} diff --git a/crates/stackless-integrations/src/providers/exa/mod.rs b/crates/stackless-integrations/src/providers/exa/mod.rs new file mode 100644 index 0000000..218449e --- /dev/null +++ b/crates/stackless-integrations/src/providers/exa/mod.rs @@ -0,0 +1,11 @@ +//! Exa catalog resources via Stripe Projects. +//! +//! Output envelopes are provisional until pinned by `xtask discover`. + +pub mod api; + +#[allow(unused_imports)] +pub(crate) use crate::resource::{ + CatalogResource as FamilyResource, bool_optional, bool_required, int_optional, int_required, + integration_config, interp_optional, interp_required, +}; diff --git a/crates/stackless-integrations/src/providers/firecrawl/api.rs b/crates/stackless-integrations/src/providers/firecrawl/api.rs new file mode 100644 index 0000000..612e7f9 --- /dev/null +++ b/crates/stackless-integrations/src/providers/firecrawl/api.rs @@ -0,0 +1,132 @@ +//! `firecrawl/api` integration. + +use std::collections::BTreeMap; + +use serde::Serialize; +use stackless_stripe_projects::catalog::verify::CatalogService; +use stackless_stripe_projects::provision::ProvisionContext; + +use super::FamilyResource; +use crate::error::IntegrationError; +use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; +use crate::registry; + +pub const RESOURCE_KIND: &str = "integration-firecrawl"; + +#[derive(Debug, Serialize)] +pub struct FirecrawlApiConfig {} + +impl CatalogService for FirecrawlApiConfig { + const REFERENCE: &'static str = "firecrawl/api"; +} + +#[derive(Debug)] +pub struct FirecrawlApi; + +impl Hostable for FirecrawlApi { + const PROVIDER: &'static str = "firecrawl"; + const HOSTING: IntegrationHosting = IntegrationHosting::Managed; + const CONFIG_SCOPE: ConfigScope = ConfigScope::GlobalOnly; + const RESOURCE_KIND: &'static str = RESOURCE_KIND; + const OUTPUTS: &'static [&'static str] = &["api_key"]; +} + +impl FamilyResource for FirecrawlApi { + type Config = FirecrawlApiConfig; + const PROVIDER_PREFIX: &'static str = "FIRECRAWL"; + // Provisional until pinned by `mise run discover firecrawl/api`. + const OUTPUT_FIELDS: &'static [(&'static str, &'static str, bool)] = + &[("API_KEY", "api_key", true)]; + + fn build_config(ctx: &ProvisionContext<'_>) -> Result { + let _ = super::integration_config(ctx)?; + Ok(FirecrawlApiConfig {}) + } +} + +pub fn validate_config( + name: &str, + config: &BTreeMap, +) -> Result<(), IntegrationError> { + let _ = (name, config); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ProviderOps; + use crate::resource::ResourcePayload; + use stackless_core::def::StackDef; + use stackless_stripe_projects::stripe::StripeProjects; + use stackless_stripe_projects::test_support; + + #[test] + fn config_matches_catalog() { + const FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../stackless-stripe-projects/tests/fixtures/catalog.json" + )); + let catalog = stackless_stripe_projects::Catalog::from_json_envelope(FIXTURE).unwrap(); + let failures = stackless_stripe_projects::verify_service(&catalog, &FirecrawlApiConfig {}); + assert!( + failures.is_empty(), + "firecrawl/api catalog gaps:\n{}", + failures.join("\n") + ); + } + + const CATALOG_ENVELOPE: &str = r##"{"ok":true,"command":"projects catalog","data":{"last_updated":"2026-07-11T00:00:00Z","services":[{"id":"prvsvc_api","object":"v2.provisioning.provider_service_detail","provider_id":"prvdr_firecrawl","provider_name":"Firecrawl","service_id":"api","categories":["database"],"kind":"deployable","scope":"project","availability":"available","development":false,"livemode":true,"pricing":{"type":"component"},"configuration_schema":{"additionalProperties":false,"properties":{},"type":"object"}}]}}"##; + + fn test_def() -> StackDef { + StackDef::parse( + r#" +[stack] +name = "atto" +[stack.projects.stripe] +project = "project_1" +[integrations.res] +provider = "firecrawl" +[services.api] +source = { repo = "r", ref = "main" } +env = { OUT = "${integrations.res.api_key}" } +health = { path = "/health" } +[services.api.local] +run = "true" +"#, + ) + .unwrap() + } + + #[tokio::test] + async fn provision_records_outputs() { + let runner = test_support::provision_script( + CATALOG_ENVELOPE, + serde_json::json!({"FIRECRAWL_API_KEY": "val_api_key"}), + 0, + ); + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("stackless.toml"), + "[stack]\nname=\"atto\"\n", + ) + .unwrap(); + let stripe = StripeProjects::new(&runner, dir.path()); + + let resource = FirecrawlApi + .provision( + &stripe.as_dyn(), + &test_def(), + dir.path(), + "demo", + "res", + "local", + false, + ) + .await + .unwrap(); + assert_eq!(resource.resource_kind, "integration-firecrawl"); + let payload: ResourcePayload = serde_json::from_str(&resource.payload).unwrap(); + assert_eq!(payload.outputs["api_key"], "val_api_key"); + } +} diff --git a/crates/stackless-integrations/src/providers/firecrawl/mod.rs b/crates/stackless-integrations/src/providers/firecrawl/mod.rs new file mode 100644 index 0000000..fe7699f --- /dev/null +++ b/crates/stackless-integrations/src/providers/firecrawl/mod.rs @@ -0,0 +1,11 @@ +//! Firecrawl catalog resources via Stripe Projects. +//! +//! Output envelopes are provisional until pinned by `xtask discover`. + +pub mod api; + +#[allow(unused_imports)] +pub(crate) use crate::resource::{ + CatalogResource as FamilyResource, bool_optional, bool_required, int_optional, int_required, + integration_config, interp_optional, interp_required, +}; diff --git a/crates/stackless-integrations/src/providers/flyio/mod.rs b/crates/stackless-integrations/src/providers/flyio/mod.rs new file mode 100644 index 0000000..fc6c1cb --- /dev/null +++ b/crates/stackless-integrations/src/providers/flyio/mod.rs @@ -0,0 +1,12 @@ +//! Flyio catalog resources via Stripe Projects. +//! +//! Output envelopes are provisional until pinned by `xtask discover`. + +pub mod mpg; +pub mod sprite; + +#[allow(unused_imports)] +pub(crate) use crate::resource::{ + CatalogResource as FamilyResource, bool_optional, bool_required, int_optional, int_required, + integration_config, interp_optional, interp_required, +}; diff --git a/crates/stackless-integrations/src/providers/flyio/mpg.rs b/crates/stackless-integrations/src/providers/flyio/mpg.rs new file mode 100644 index 0000000..06a7341 --- /dev/null +++ b/crates/stackless-integrations/src/providers/flyio/mpg.rs @@ -0,0 +1,162 @@ +//! `flyio/mpg` integration. + +use std::collections::BTreeMap; + +use serde::Serialize; +use stackless_stripe_projects::catalog::verify::CatalogService; +use stackless_stripe_projects::provision::ProvisionContext; + +use super::FamilyResource; +use crate::error::IntegrationError; +use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; +use crate::registry; + +pub const RESOURCE_KIND: &str = "integration-flyio-mpg"; + +#[derive(Debug, Serialize)] +pub struct FlyioMpgConfig { + pub name: String, + pub plan: String, + pub region: String, +} + +impl CatalogService for FlyioMpgConfig { + const REFERENCE: &'static str = "flyio/mpg"; +} + +#[derive(Debug)] +pub struct FlyioMpg; + +impl Hostable for FlyioMpg { + const PROVIDER: &'static str = "flyio-mpg"; + const HOSTING: IntegrationHosting = IntegrationHosting::Managed; + const CONFIG_SCOPE: ConfigScope = ConfigScope::GlobalOnly; + const RESOURCE_KIND: &'static str = RESOURCE_KIND; + const OUTPUTS: &'static [&'static str] = &["database_url"]; +} + +impl FamilyResource for FlyioMpg { + type Config = FlyioMpgConfig; + const PROVIDER_PREFIX: &'static str = "FLYIO"; + // Provisional until pinned by `mise run discover flyio/mpg`. + const OUTPUT_FIELDS: &'static [(&'static str, &'static str, bool)] = + &[("DATABASE_URL", "database_url", true)]; + + fn build_config(ctx: &ProvisionContext<'_>) -> Result { + let config = super::integration_config(ctx)?; + Ok(FlyioMpgConfig { + name: super::interp_required(ctx, &config, "name")?, + plan: super::interp_required(ctx, &config, "plan")?, + region: super::interp_required(ctx, &config, "region")?, + }) + } +} + +pub fn validate_config( + name: &str, + config: &BTreeMap, +) -> Result<(), IntegrationError> { + registry::config_string(config, "name").map_err(|err| IntegrationError::ConfigInvalid { + location: format!("integrations.{name}.name"), + detail: err.to_string(), + })?; + registry::config_string(config, "plan").map_err(|err| IntegrationError::ConfigInvalid { + location: format!("integrations.{name}.plan"), + detail: err.to_string(), + })?; + registry::config_string(config, "region").map_err(|err| IntegrationError::ConfigInvalid { + location: format!("integrations.{name}.region"), + detail: err.to_string(), + })?; + let _ = (name, config); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ProviderOps; + use crate::resource::ResourcePayload; + use stackless_core::def::StackDef; + use stackless_stripe_projects::stripe::StripeProjects; + use stackless_stripe_projects::test_support; + + #[test] + fn config_matches_catalog() { + const FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../stackless-stripe-projects/tests/fixtures/catalog.json" + )); + let catalog = stackless_stripe_projects::Catalog::from_json_envelope(FIXTURE).unwrap(); + let failures = stackless_stripe_projects::verify_service( + &catalog, + &FlyioMpgConfig { + name: "test-name".into(), + plan: "basic".into(), + region: "ams".into(), + }, + ); + assert!( + failures.is_empty(), + "flyio/mpg catalog gaps:\n{}", + failures.join("\n") + ); + } + + const CATALOG_ENVELOPE: &str = r##"{"ok":true,"command":"projects catalog","data":{"last_updated":"2026-07-11T00:00:00Z","services":[{"id":"prvsvc_mpg","object":"v2.provisioning.provider_service_detail","provider_id":"prvdr_flyio","provider_name":"Flyio","service_id":"mpg","categories":["database"],"kind":"deployable","scope":"project","availability":"available","development":false,"livemode":true,"pricing":{"type":"paid"},"configuration_schema":{"properties":{"name":{"description":"Cluster name","maxLength":63,"minLength":5,"pattern":"^[a-z0-9][a-z0-9-]*[a-z0-9]$","type":"string"},"plan":{"enum":["basic","starter","launch","scale","Performance"],"type":"string"},"region":{"enum":["ams","fra","gru","iad","lax","lhr","nrt","ord","sin","sjc","syd","yyz"],"type":"string"}},"required":["name","region","plan"],"type":"object"}}]}}"##; + + fn test_def() -> StackDef { + StackDef::parse( + r#" +[stack] +name = "atto" +[stack.projects.stripe] +project = "project_1" +[integrations.res] +provider = "flyio-mpg" +name = "test-name" +plan = "basic" +region = "ams" +[services.api] +source = { repo = "r", ref = "main" } +env = { OUT = "${integrations.res.database_url}" } +health = { path = "/health" } +[services.api.local] +run = "true" +"#, + ) + .unwrap() + } + + #[tokio::test] + async fn provision_records_outputs() { + let runner = test_support::provision_script( + CATALOG_ENVELOPE, + serde_json::json!({"FLYIO_DATABASE_URL": "val_database_url"}), + 0, + ); + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("stackless.toml"), + "[stack]\nname=\"atto\"\n", + ) + .unwrap(); + let stripe = StripeProjects::new(&runner, dir.path()); + + let resource = FlyioMpg + .provision( + &stripe.as_dyn(), + &test_def(), + dir.path(), + "demo", + "res", + "local", + false, + ) + .await + .unwrap(); + assert_eq!(resource.resource_kind, "integration-flyio-mpg"); + let payload: ResourcePayload = serde_json::from_str(&resource.payload).unwrap(); + assert_eq!(payload.outputs["database_url"], "val_database_url"); + } +} diff --git a/crates/stackless-integrations/src/providers/flyio/sprite.rs b/crates/stackless-integrations/src/providers/flyio/sprite.rs new file mode 100644 index 0000000..85f3261 --- /dev/null +++ b/crates/stackless-integrations/src/providers/flyio/sprite.rs @@ -0,0 +1,146 @@ +//! `flyio/sprite` integration. + +use std::collections::BTreeMap; + +use serde::Serialize; +use stackless_stripe_projects::catalog::verify::CatalogService; +use stackless_stripe_projects::provision::ProvisionContext; + +use super::FamilyResource; +use crate::error::IntegrationError; +use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; +use crate::registry; + +pub const RESOURCE_KIND: &str = "integration-flyio-sprite"; + +#[derive(Debug, Serialize)] +pub struct FlyioSpriteConfig { + pub name: String, +} + +impl CatalogService for FlyioSpriteConfig { + const REFERENCE: &'static str = "flyio/sprite"; +} + +#[derive(Debug)] +pub struct FlyioSprite; + +impl Hostable for FlyioSprite { + const PROVIDER: &'static str = "flyio-sprite"; + const HOSTING: IntegrationHosting = IntegrationHosting::Managed; + const CONFIG_SCOPE: ConfigScope = ConfigScope::GlobalOnly; + const RESOURCE_KIND: &'static str = RESOURCE_KIND; + const OUTPUTS: &'static [&'static str] = &["sprite_url"]; +} + +impl FamilyResource for FlyioSprite { + type Config = FlyioSpriteConfig; + const PROVIDER_PREFIX: &'static str = "FLYIO"; + // Provisional until pinned by `mise run discover flyio/sprite`. + const OUTPUT_FIELDS: &'static [(&'static str, &'static str, bool)] = + &[("SPRITE_URL", "sprite_url", true)]; + + fn build_config(ctx: &ProvisionContext<'_>) -> Result { + let config = super::integration_config(ctx)?; + Ok(FlyioSpriteConfig { + name: super::interp_required(ctx, &config, "name")?, + }) + } +} + +pub fn validate_config( + name: &str, + config: &BTreeMap, +) -> Result<(), IntegrationError> { + registry::config_string(config, "name").map_err(|err| IntegrationError::ConfigInvalid { + location: format!("integrations.{name}.name"), + detail: err.to_string(), + })?; + let _ = (name, config); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ProviderOps; + use crate::resource::ResourcePayload; + use stackless_core::def::StackDef; + use stackless_stripe_projects::stripe::StripeProjects; + use stackless_stripe_projects::test_support; + + #[test] + fn config_matches_catalog() { + const FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../stackless-stripe-projects/tests/fixtures/catalog.json" + )); + let catalog = stackless_stripe_projects::Catalog::from_json_envelope(FIXTURE).unwrap(); + let failures = stackless_stripe_projects::verify_service( + &catalog, + &FlyioSpriteConfig { + name: "test-name".into(), + }, + ); + assert!( + failures.is_empty(), + "flyio/sprite catalog gaps:\n{}", + failures.join("\n") + ); + } + + const CATALOG_ENVELOPE: &str = r##"{"ok":true,"command":"projects catalog","data":{"last_updated":"2026-07-11T00:00:00Z","services":[{"id":"prvsvc_sprite","object":"v2.provisioning.provider_service_detail","provider_id":"prvdr_flyio","provider_name":"Flyio","service_id":"sprite","categories":["database"],"kind":"deployable","scope":"project","availability":"available","development":false,"livemode":true,"pricing":{"type":"paid"},"configuration_schema":{"properties":{"name":{"description":"Sprite name","maxLength":63,"minLength":5,"pattern":"^[a-z0-9][a-z0-9-]*[a-z0-9]$","type":"string"}},"required":["name"],"type":"object"}}]}}"##; + + fn test_def() -> StackDef { + StackDef::parse( + r#" +[stack] +name = "atto" +[stack.projects.stripe] +project = "project_1" +[integrations.res] +provider = "flyio-sprite" +name = "test-name" +[services.api] +source = { repo = "r", ref = "main" } +env = { OUT = "${integrations.res.sprite_url}" } +health = { path = "/health" } +[services.api.local] +run = "true" +"#, + ) + .unwrap() + } + + #[tokio::test] + async fn provision_records_outputs() { + let runner = test_support::provision_script( + CATALOG_ENVELOPE, + serde_json::json!({"FLYIO_SPRITE_URL": "val_sprite_url"}), + 0, + ); + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("stackless.toml"), + "[stack]\nname=\"atto\"\n", + ) + .unwrap(); + let stripe = StripeProjects::new(&runner, dir.path()); + + let resource = FlyioSprite + .provision( + &stripe.as_dyn(), + &test_def(), + dir.path(), + "demo", + "res", + "local", + false, + ) + .await + .unwrap(); + assert_eq!(resource.resource_kind, "integration-flyio-sprite"); + let payload: ResourcePayload = serde_json::from_str(&resource.payload).unwrap(); + assert_eq!(payload.outputs["sprite_url"], "val_sprite_url"); + } +} diff --git a/crates/stackless-integrations/src/providers/gitlab/mod.rs b/crates/stackless-integrations/src/providers/gitlab/mod.rs new file mode 100644 index 0000000..39022f8 --- /dev/null +++ b/crates/stackless-integrations/src/providers/gitlab/mod.rs @@ -0,0 +1,11 @@ +//! GitLab catalog resources via Stripe Projects. +//! +//! Output envelopes are provisional until pinned by `xtask discover`. + +pub mod project; + +#[allow(unused_imports)] +pub(crate) use crate::resource::{ + CatalogResource as FamilyResource, bool_optional, bool_required, int_optional, int_required, + integration_config, interp_optional, interp_required, +}; diff --git a/crates/stackless-integrations/src/providers/gitlab/project.rs b/crates/stackless-integrations/src/providers/gitlab/project.rs new file mode 100644 index 0000000..4cf622e --- /dev/null +++ b/crates/stackless-integrations/src/providers/gitlab/project.rs @@ -0,0 +1,158 @@ +//! `gitlab/project` integration. + +use std::collections::BTreeMap; + +use serde::Serialize; +use stackless_stripe_projects::catalog::verify::CatalogService; +use stackless_stripe_projects::provision::ProvisionContext; + +use super::FamilyResource; +use crate::error::IntegrationError; +use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; +use crate::registry; + +pub const RESOURCE_KIND: &str = "integration-gitlab"; + +#[derive(Debug, Serialize)] +pub struct GitLabProjectConfig { + pub name: String, + pub visibility: String, +} + +impl CatalogService for GitLabProjectConfig { + const REFERENCE: &'static str = "gitlab/project"; +} + +#[derive(Debug)] +pub struct GitLabProject; + +impl Hostable for GitLabProject { + const PROVIDER: &'static str = "gitlab"; + const HOSTING: IntegrationHosting = IntegrationHosting::Managed; + const CONFIG_SCOPE: ConfigScope = ConfigScope::GlobalOnly; + const RESOURCE_KIND: &'static str = RESOURCE_KIND; + const OUTPUTS: &'static [&'static str] = &["project_id", "web_url"]; +} + +impl FamilyResource for GitLabProject { + type Config = GitLabProjectConfig; + const PROVIDER_PREFIX: &'static str = "GITLAB"; + // Provisional until pinned by `mise run discover gitlab/project`. + const OUTPUT_FIELDS: &'static [(&'static str, &'static str, bool)] = &[ + ("PROJECT_ID", "project_id", true), + ("WEB_URL", "web_url", false), + ]; + + fn build_config(ctx: &ProvisionContext<'_>) -> Result { + let config = super::integration_config(ctx)?; + Ok(GitLabProjectConfig { + name: super::interp_required(ctx, &config, "name")?, + visibility: super::interp_required(ctx, &config, "visibility")?, + }) + } +} + +pub fn validate_config( + name: &str, + config: &BTreeMap, +) -> Result<(), IntegrationError> { + registry::config_string(config, "name").map_err(|err| IntegrationError::ConfigInvalid { + location: format!("integrations.{name}.name"), + detail: err.to_string(), + })?; + registry::config_string(config, "visibility").map_err(|err| { + IntegrationError::ConfigInvalid { + location: format!("integrations.{name}.visibility"), + detail: err.to_string(), + } + })?; + let _ = (name, config); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ProviderOps; + use crate::resource::ResourcePayload; + use stackless_core::def::StackDef; + use stackless_stripe_projects::stripe::StripeProjects; + use stackless_stripe_projects::test_support; + + #[test] + fn config_matches_catalog() { + const FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../stackless-stripe-projects/tests/fixtures/catalog.json" + )); + let catalog = stackless_stripe_projects::Catalog::from_json_envelope(FIXTURE).unwrap(); + let failures = stackless_stripe_projects::verify_service( + &catalog, + &GitLabProjectConfig { + name: "test-name".into(), + visibility: "private".into(), + }, + ); + assert!( + failures.is_empty(), + "gitlab/project catalog gaps:\n{}", + failures.join("\n") + ); + } + + const CATALOG_ENVELOPE: &str = r##"{"ok":true,"command":"projects catalog","data":{"last_updated":"2026-07-11T00:00:00Z","services":[{"id":"prvsvc_project","object":"v2.provisioning.provider_service_detail","provider_id":"prvdr_gitlab","provider_name":"GitLab","service_id":"project","categories":["database"],"kind":"deployable","scope":"project","availability":"available","development":false,"livemode":true,"pricing":{"type":"free"},"configuration_schema":{"properties":{"name":{"description":"Name of the project","type":"string"},"visibility":{"description":"Visibility level of the project","enum":["private","public"],"type":"string"}},"required":["name","visibility"],"type":"object"}}]}}"##; + + fn test_def() -> StackDef { + StackDef::parse( + r#" +[stack] +name = "atto" +[stack.projects.stripe] +project = "project_1" +[integrations.res] +provider = "gitlab" +name = "test-name" +visibility = "private" +[services.api] +source = { repo = "r", ref = "main" } +env = { OUT = "${integrations.res.project_id}" } +health = { path = "/health" } +[services.api.local] +run = "true" +"#, + ) + .unwrap() + } + + #[tokio::test] + async fn provision_records_outputs() { + let runner = test_support::provision_script( + CATALOG_ENVELOPE, + serde_json::json!({"GITLAB_PROJECT_ID": "val_project_id", "GITLAB_WEB_URL": "val_web_url"}), + 0, + ); + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("stackless.toml"), + "[stack]\nname=\"atto\"\n", + ) + .unwrap(); + let stripe = StripeProjects::new(&runner, dir.path()); + + let resource = GitLabProject + .provision( + &stripe.as_dyn(), + &test_def(), + dir.path(), + "demo", + "res", + "local", + false, + ) + .await + .unwrap(); + assert_eq!(resource.resource_kind, "integration-gitlab"); + let payload: ResourcePayload = serde_json::from_str(&resource.payload).unwrap(); + assert_eq!(payload.outputs["project_id"], "val_project_id"); + } +} diff --git a/crates/stackless-integrations/src/providers/heygen/api.rs b/crates/stackless-integrations/src/providers/heygen/api.rs new file mode 100644 index 0000000..3cfdb97 --- /dev/null +++ b/crates/stackless-integrations/src/providers/heygen/api.rs @@ -0,0 +1,150 @@ +//! `heygen/api` integration. + +use std::collections::BTreeMap; + +use serde::Serialize; +use stackless_stripe_projects::catalog::verify::CatalogService; +use stackless_stripe_projects::provision::ProvisionContext; + +use super::FamilyResource; +use crate::error::IntegrationError; +use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; +use crate::registry; + +pub const RESOURCE_KIND: &str = "integration-heygen"; + +#[derive(Debug, Serialize)] +pub struct HeyGenApiConfig { + #[serde(skip_serializing_if = "Option::is_none")] + pub auto_reload_amount: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub auto_reload_enabled: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub auto_reload_threshold: Option, +} + +impl CatalogService for HeyGenApiConfig { + const REFERENCE: &'static str = "heygen/api"; +} + +#[derive(Debug)] +pub struct HeyGenApi; + +impl Hostable for HeyGenApi { + const PROVIDER: &'static str = "heygen"; + const HOSTING: IntegrationHosting = IntegrationHosting::Managed; + const CONFIG_SCOPE: ConfigScope = ConfigScope::GlobalOnly; + const RESOURCE_KIND: &'static str = RESOURCE_KIND; + const OUTPUTS: &'static [&'static str] = &["api_key"]; +} + +impl FamilyResource for HeyGenApi { + type Config = HeyGenApiConfig; + const PROVIDER_PREFIX: &'static str = "HEYGEN"; + // Provisional until pinned by `mise run discover heygen/api`. + const OUTPUT_FIELDS: &'static [(&'static str, &'static str, bool)] = + &[("API_KEY", "api_key", true)]; + + fn build_config(ctx: &ProvisionContext<'_>) -> Result { + let config = super::integration_config(ctx)?; + Ok(HeyGenApiConfig { + auto_reload_amount: super::int_optional(ctx, &config, "auto_reload_amount")?, + auto_reload_enabled: super::bool_optional(ctx, &config, "auto_reload_enabled")?, + auto_reload_threshold: super::int_optional(ctx, &config, "auto_reload_threshold")?, + }) + } +} + +pub fn validate_config( + name: &str, + config: &BTreeMap, +) -> Result<(), IntegrationError> { + let _ = (name, config); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ProviderOps; + use crate::resource::ResourcePayload; + use stackless_core::def::StackDef; + use stackless_stripe_projects::stripe::StripeProjects; + use stackless_stripe_projects::test_support; + + #[test] + fn config_matches_catalog() { + const FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../stackless-stripe-projects/tests/fixtures/catalog.json" + )); + let catalog = stackless_stripe_projects::Catalog::from_json_envelope(FIXTURE).unwrap(); + let failures = stackless_stripe_projects::verify_service( + &catalog, + &HeyGenApiConfig { + auto_reload_amount: None, + auto_reload_enabled: None, + auto_reload_threshold: None, + }, + ); + assert!( + failures.is_empty(), + "heygen/api catalog gaps:\n{}", + failures.join("\n") + ); + } + + const CATALOG_ENVELOPE: &str = r##"{"ok":true,"command":"projects catalog","data":{"last_updated":"2026-07-11T00:00:00Z","services":[{"id":"prvsvc_api","object":"v2.provisioning.provider_service_detail","provider_id":"prvdr_heygen","provider_name":"HeyGen","service_id":"api","categories":["database"],"kind":"deployable","scope":"project","availability":"available","development":false,"livemode":true,"pricing":{"type":"paid"},"configuration_schema":{"properties":{"auto_reload_amount":{"default":10,"description":"USD of API credit to add each time the balance runs low. Range $5-$1000, default $10.","maximum":1000,"minimum":5,"type":"integer"},"auto_reload_enabled":{"description":"Turn auto-reload on to top up API credit automatically when the balance runs low. Off by default; when off, amount/threshold are ignored.","type":"boolean"},"auto_reload_threshold":{"default":5,"description":"Auto-reload triggers when the remaining API credit balance falls below this USD amount. Range $5-$1000, default $5.","maximum":1000,"minimum":5,"type":"integer"}},"type":"object"}}]}}"##; + + fn test_def() -> StackDef { + StackDef::parse( + r#" +[stack] +name = "atto" +[stack.projects.stripe] +project = "project_1" +[integrations.res] +provider = "heygen" +[services.api] +source = { repo = "r", ref = "main" } +env = { OUT = "${integrations.res.api_key}" } +health = { path = "/health" } +[services.api.local] +run = "true" +"#, + ) + .unwrap() + } + + #[tokio::test] + async fn provision_records_outputs() { + let runner = test_support::provision_script( + CATALOG_ENVELOPE, + serde_json::json!({"HEYGEN_API_KEY": "val_api_key"}), + 0, + ); + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("stackless.toml"), + "[stack]\nname=\"atto\"\n", + ) + .unwrap(); + let stripe = StripeProjects::new(&runner, dir.path()); + + let resource = HeyGenApi + .provision( + &stripe.as_dyn(), + &test_def(), + dir.path(), + "demo", + "res", + "local", + false, + ) + .await + .unwrap(); + assert_eq!(resource.resource_kind, "integration-heygen"); + let payload: ResourcePayload = serde_json::from_str(&resource.payload).unwrap(); + assert_eq!(payload.outputs["api_key"], "val_api_key"); + } +} diff --git a/crates/stackless-integrations/src/providers/heygen/mod.rs b/crates/stackless-integrations/src/providers/heygen/mod.rs new file mode 100644 index 0000000..c7324b1 --- /dev/null +++ b/crates/stackless-integrations/src/providers/heygen/mod.rs @@ -0,0 +1,11 @@ +//! HeyGen catalog resources via Stripe Projects. +//! +//! Output envelopes are provisional until pinned by `xtask discover`. + +pub mod api; + +#[allow(unused_imports)] +pub(crate) use crate::resource::{ + CatalogResource as FamilyResource, bool_optional, bool_required, int_optional, int_required, + integration_config, interp_optional, interp_required, +}; diff --git a/crates/stackless-integrations/src/providers/huggingface/bucket.rs b/crates/stackless-integrations/src/providers/huggingface/bucket.rs new file mode 100644 index 0000000..ec4a87c --- /dev/null +++ b/crates/stackless-integrations/src/providers/huggingface/bucket.rs @@ -0,0 +1,148 @@ +//! `huggingface/bucket` integration. + +use std::collections::BTreeMap; + +use serde::Serialize; +use stackless_stripe_projects::catalog::verify::CatalogService; +use stackless_stripe_projects::provision::ProvisionContext; + +use super::FamilyResource; +use crate::error::IntegrationError; +use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; +use crate::registry; + +pub const RESOURCE_KIND: &str = "integration-huggingface-bucket"; + +#[derive(Debug, Serialize)] +pub struct HuggingFaceBucketConfig { + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub visibility: Option, +} + +impl CatalogService for HuggingFaceBucketConfig { + const REFERENCE: &'static str = "huggingface/bucket"; +} + +#[derive(Debug)] +pub struct HuggingFaceBucket; + +impl Hostable for HuggingFaceBucket { + const PROVIDER: &'static str = "huggingface-bucket"; + const HOSTING: IntegrationHosting = IntegrationHosting::Managed; + const CONFIG_SCOPE: ConfigScope = ConfigScope::GlobalOnly; + const RESOURCE_KIND: &'static str = RESOURCE_KIND; + const OUTPUTS: &'static [&'static str] = &["bucket_name"]; +} + +impl FamilyResource for HuggingFaceBucket { + type Config = HuggingFaceBucketConfig; + const PROVIDER_PREFIX: &'static str = "HUGGINGFACE"; + // Provisional until pinned by `mise run discover huggingface/bucket`. + const OUTPUT_FIELDS: &'static [(&'static str, &'static str, bool)] = + &[("BUCKET_NAME", "bucket_name", true)]; + + fn build_config( + ctx: &ProvisionContext<'_>, + ) -> Result { + let config = super::integration_config(ctx)?; + Ok(HuggingFaceBucketConfig { + name: super::interp_optional(ctx, &config, "name")?, + visibility: super::interp_optional(ctx, &config, "visibility")?, + }) + } +} + +pub fn validate_config( + name: &str, + config: &BTreeMap, +) -> Result<(), IntegrationError> { + let _ = (name, config); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ProviderOps; + use crate::resource::ResourcePayload; + use stackless_core::def::StackDef; + use stackless_stripe_projects::stripe::StripeProjects; + use stackless_stripe_projects::test_support; + + #[test] + fn config_matches_catalog() { + const FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../stackless-stripe-projects/tests/fixtures/catalog.json" + )); + let catalog = stackless_stripe_projects::Catalog::from_json_envelope(FIXTURE).unwrap(); + let failures = stackless_stripe_projects::verify_service( + &catalog, + &HuggingFaceBucketConfig { + name: None, + visibility: None, + }, + ); + assert!( + failures.is_empty(), + "huggingface/bucket catalog gaps:\n{}", + failures.join("\n") + ); + } + + const CATALOG_ENVELOPE: &str = r##"{"ok":true,"command":"projects catalog","data":{"last_updated":"2026-07-11T00:00:00Z","services":[{"id":"prvsvc_bucket","object":"v2.provisioning.provider_service_detail","provider_id":"prvdr_huggingface","provider_name":"HuggingFace","service_id":"bucket","categories":["database"],"kind":"deployable","scope":"project","availability":"available","development":false,"livemode":true,"pricing":{"type":"component"},"configuration_schema":{"properties":{"name":{"type":"string"},"visibility":{"default":"private","enum":["public","private"],"type":"string"}},"type":"object"}}]}}"##; + + fn test_def() -> StackDef { + StackDef::parse( + r#" +[stack] +name = "atto" +[stack.projects.stripe] +project = "project_1" +[integrations.res] +provider = "huggingface-bucket" +[services.api] +source = { repo = "r", ref = "main" } +env = { OUT = "${integrations.res.bucket_name}" } +health = { path = "/health" } +[services.api.local] +run = "true" +"#, + ) + .unwrap() + } + + #[tokio::test] + async fn provision_records_outputs() { + let runner = test_support::provision_script( + CATALOG_ENVELOPE, + serde_json::json!({"HUGGINGFACE_BUCKET_NAME": "val_bucket_name"}), + 0, + ); + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("stackless.toml"), + "[stack]\nname=\"atto\"\n", + ) + .unwrap(); + let stripe = StripeProjects::new(&runner, dir.path()); + + let resource = HuggingFaceBucket + .provision( + &stripe.as_dyn(), + &test_def(), + dir.path(), + "demo", + "res", + "local", + false, + ) + .await + .unwrap(); + assert_eq!(resource.resource_kind, "integration-huggingface-bucket"); + let payload: ResourcePayload = serde_json::from_str(&resource.payload).unwrap(); + assert_eq!(payload.outputs["bucket_name"], "val_bucket_name"); + } +} diff --git a/crates/stackless-integrations/src/providers/huggingface/mod.rs b/crates/stackless-integrations/src/providers/huggingface/mod.rs new file mode 100644 index 0000000..4a628b5 --- /dev/null +++ b/crates/stackless-integrations/src/providers/huggingface/mod.rs @@ -0,0 +1,12 @@ +//! HuggingFace catalog resources via Stripe Projects. +//! +//! Output envelopes are provisional until pinned by `xtask discover`. + +pub mod bucket; +pub mod platform; + +#[allow(unused_imports)] +pub(crate) use crate::resource::{ + CatalogResource as FamilyResource, bool_optional, bool_required, int_optional, int_required, + integration_config, interp_optional, interp_required, +}; diff --git a/crates/stackless-integrations/src/providers/huggingface/platform.rs b/crates/stackless-integrations/src/providers/huggingface/platform.rs new file mode 100644 index 0000000..6222d02 --- /dev/null +++ b/crates/stackless-integrations/src/providers/huggingface/platform.rs @@ -0,0 +1,135 @@ +//! `huggingface/platform` integration. + +use std::collections::BTreeMap; + +use serde::Serialize; +use stackless_stripe_projects::catalog::verify::CatalogService; +use stackless_stripe_projects::provision::ProvisionContext; + +use super::FamilyResource; +use crate::error::IntegrationError; +use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; +use crate::registry; + +pub const RESOURCE_KIND: &str = "integration-huggingface"; + +#[derive(Debug, Serialize)] +pub struct HuggingFacePlatformConfig {} + +impl CatalogService for HuggingFacePlatformConfig { + const REFERENCE: &'static str = "huggingface/platform"; +} + +#[derive(Debug)] +pub struct HuggingFacePlatform; + +impl Hostable for HuggingFacePlatform { + const PROVIDER: &'static str = "huggingface"; + const HOSTING: IntegrationHosting = IntegrationHosting::Managed; + const CONFIG_SCOPE: ConfigScope = ConfigScope::GlobalOnly; + const RESOURCE_KIND: &'static str = RESOURCE_KIND; + const OUTPUTS: &'static [&'static str] = &["token"]; +} + +impl FamilyResource for HuggingFacePlatform { + type Config = HuggingFacePlatformConfig; + const PROVIDER_PREFIX: &'static str = "HUGGINGFACE"; + // Provisional until pinned by `mise run discover huggingface/platform`. + const OUTPUT_FIELDS: &'static [(&'static str, &'static str, bool)] = + &[("TOKEN", "token", true)]; + + fn build_config( + ctx: &ProvisionContext<'_>, + ) -> Result { + let _ = super::integration_config(ctx)?; + Ok(HuggingFacePlatformConfig {}) + } +} + +pub fn validate_config( + name: &str, + config: &BTreeMap, +) -> Result<(), IntegrationError> { + let _ = (name, config); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ProviderOps; + use crate::resource::ResourcePayload; + use stackless_core::def::StackDef; + use stackless_stripe_projects::stripe::StripeProjects; + use stackless_stripe_projects::test_support; + + #[test] + fn config_matches_catalog() { + const FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../stackless-stripe-projects/tests/fixtures/catalog.json" + )); + let catalog = stackless_stripe_projects::Catalog::from_json_envelope(FIXTURE).unwrap(); + let failures = + stackless_stripe_projects::verify_service(&catalog, &HuggingFacePlatformConfig {}); + assert!( + failures.is_empty(), + "huggingface/platform catalog gaps:\n{}", + failures.join("\n") + ); + } + + const CATALOG_ENVELOPE: &str = r##"{"ok":true,"command":"projects catalog","data":{"last_updated":"2026-07-11T00:00:00Z","services":[{"id":"prvsvc_platform","object":"v2.provisioning.provider_service_detail","provider_id":"prvdr_huggingface","provider_name":"HuggingFace","service_id":"platform","categories":["database"],"kind":"deployable","scope":"project","availability":"available","development":false,"livemode":true,"pricing":{"type":"component"},"configuration_schema":{"type":"object","required":[],"additionalProperties":false,"properties":{}}}]}}"##; + + fn test_def() -> StackDef { + StackDef::parse( + r#" +[stack] +name = "atto" +[stack.projects.stripe] +project = "project_1" +[integrations.res] +provider = "huggingface" +[services.api] +source = { repo = "r", ref = "main" } +env = { OUT = "${integrations.res.token}" } +health = { path = "/health" } +[services.api.local] +run = "true" +"#, + ) + .unwrap() + } + + #[tokio::test] + async fn provision_records_outputs() { + let runner = test_support::provision_script( + CATALOG_ENVELOPE, + serde_json::json!({"HUGGINGFACE_TOKEN": "val_token"}), + 0, + ); + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("stackless.toml"), + "[stack]\nname=\"atto\"\n", + ) + .unwrap(); + let stripe = StripeProjects::new(&runner, dir.path()); + + let resource = HuggingFacePlatform + .provision( + &stripe.as_dyn(), + &test_def(), + dir.path(), + "demo", + "res", + "local", + false, + ) + .await + .unwrap(); + assert_eq!(resource.resource_kind, "integration-huggingface"); + let payload: ResourcePayload = serde_json::from_str(&resource.payload).unwrap(); + assert_eq!(payload.outputs["token"], "val_token"); + } +} diff --git a/crates/stackless-integrations/src/providers/inngest/app.rs b/crates/stackless-integrations/src/providers/inngest/app.rs new file mode 100644 index 0000000..42ed509 --- /dev/null +++ b/crates/stackless-integrations/src/providers/inngest/app.rs @@ -0,0 +1,149 @@ +//! `inngest/app` integration. + +use std::collections::BTreeMap; + +use serde::Serialize; +use stackless_stripe_projects::catalog::verify::CatalogService; +use stackless_stripe_projects::provision::ProvisionContext; + +use super::FamilyResource; +use crate::error::IntegrationError; +use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; +use crate::registry; + +pub const RESOURCE_KIND: &str = "integration-inngest"; + +#[derive(Debug, Serialize)] +pub struct InngestAppConfig { + #[serde(rename = "id")] + pub r#id: String, +} + +impl CatalogService for InngestAppConfig { + const REFERENCE: &'static str = "inngest/app"; +} + +#[derive(Debug)] +pub struct InngestApp; + +impl Hostable for InngestApp { + const PROVIDER: &'static str = "inngest"; + const HOSTING: IntegrationHosting = IntegrationHosting::Managed; + const CONFIG_SCOPE: ConfigScope = ConfigScope::GlobalOnly; + const RESOURCE_KIND: &'static str = RESOURCE_KIND; + const OUTPUTS: &'static [&'static str] = &["event_key", "signing_key"]; +} + +impl FamilyResource for InngestApp { + type Config = InngestAppConfig; + const PROVIDER_PREFIX: &'static str = "INNGEST"; + // Provisional until pinned by `mise run discover inngest/app`. + const OUTPUT_FIELDS: &'static [(&'static str, &'static str, bool)] = &[ + ("EVENT_KEY", "event_key", true), + ("SIGNING_KEY", "signing_key", false), + ]; + + fn build_config(ctx: &ProvisionContext<'_>) -> Result { + let config = super::integration_config(ctx)?; + Ok(InngestAppConfig { + r#id: super::interp_required(ctx, &config, "id")?, + }) + } +} + +pub fn validate_config( + name: &str, + config: &BTreeMap, +) -> Result<(), IntegrationError> { + registry::config_string(config, "id").map_err(|err| IntegrationError::ConfigInvalid { + location: format!("integrations.{name}.id"), + detail: err.to_string(), + })?; + let _ = (name, config); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ProviderOps; + use crate::resource::ResourcePayload; + use stackless_core::def::StackDef; + use stackless_stripe_projects::stripe::StripeProjects; + use stackless_stripe_projects::test_support; + + #[test] + fn config_matches_catalog() { + const FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../stackless-stripe-projects/tests/fixtures/catalog.json" + )); + let catalog = stackless_stripe_projects::Catalog::from_json_envelope(FIXTURE).unwrap(); + let failures = stackless_stripe_projects::verify_service( + &catalog, + &InngestAppConfig { + r#id: "test-id".into(), + }, + ); + assert!( + failures.is_empty(), + "inngest/app catalog gaps:\n{}", + failures.join("\n") + ); + } + + const CATALOG_ENVELOPE: &str = r##"{"ok":true,"command":"projects catalog","data":{"last_updated":"2026-07-11T00:00:00Z","services":[{"id":"prvsvc_app","object":"v2.provisioning.provider_service_detail","provider_id":"prvdr_inngest","provider_name":"Inngest","service_id":"app","categories":["database"],"kind":"deployable","scope":"project","availability":"available","development":false,"livemode":true,"pricing":{"type":"component"},"configuration_schema":{"properties":{"id":{"description":"Inngest app ID. This should match the id passed to your Inngest SDK client, e.g. new Inngest({ id: \"my-app\" }).","minLength":1,"type":"string"}},"required":["id"],"type":"object"}}]}}"##; + + fn test_def() -> StackDef { + StackDef::parse( + r#" +[stack] +name = "atto" +[stack.projects.stripe] +project = "project_1" +[integrations.res] +provider = "inngest" +id = "test-id" +[services.api] +source = { repo = "r", ref = "main" } +env = { OUT = "${integrations.res.event_key}" } +health = { path = "/health" } +[services.api.local] +run = "true" +"#, + ) + .unwrap() + } + + #[tokio::test] + async fn provision_records_outputs() { + let runner = test_support::provision_script( + CATALOG_ENVELOPE, + serde_json::json!({"INNGEST_EVENT_KEY": "val_event_key", "INNGEST_SIGNING_KEY": "val_signing_key"}), + 0, + ); + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("stackless.toml"), + "[stack]\nname=\"atto\"\n", + ) + .unwrap(); + let stripe = StripeProjects::new(&runner, dir.path()); + + let resource = InngestApp + .provision( + &stripe.as_dyn(), + &test_def(), + dir.path(), + "demo", + "res", + "local", + false, + ) + .await + .unwrap(); + assert_eq!(resource.resource_kind, "integration-inngest"); + let payload: ResourcePayload = serde_json::from_str(&resource.payload).unwrap(); + assert_eq!(payload.outputs["event_key"], "val_event_key"); + } +} diff --git a/crates/stackless-integrations/src/providers/inngest/mod.rs b/crates/stackless-integrations/src/providers/inngest/mod.rs new file mode 100644 index 0000000..c934e6f --- /dev/null +++ b/crates/stackless-integrations/src/providers/inngest/mod.rs @@ -0,0 +1,11 @@ +//! Inngest catalog resources via Stripe Projects. +//! +//! Output envelopes are provisional until pinned by `xtask discover`. + +pub mod app; + +#[allow(unused_imports)] +pub(crate) use crate::resource::{ + CatalogResource as FamilyResource, bool_optional, bool_required, int_optional, int_required, + integration_config, interp_optional, interp_required, +}; diff --git a/crates/stackless-integrations/src/providers/kernel/mod.rs b/crates/stackless-integrations/src/providers/kernel/mod.rs new file mode 100644 index 0000000..b5cc1db --- /dev/null +++ b/crates/stackless-integrations/src/providers/kernel/mod.rs @@ -0,0 +1,11 @@ +//! KERNEL catalog resources via Stripe Projects. +//! +//! Output envelopes are provisional until pinned by `xtask discover`. + +pub mod project; + +#[allow(unused_imports)] +pub(crate) use crate::resource::{ + CatalogResource as FamilyResource, bool_optional, bool_required, int_optional, int_required, + integration_config, interp_optional, interp_required, +}; diff --git a/crates/stackless-integrations/src/providers/kernel/project.rs b/crates/stackless-integrations/src/providers/kernel/project.rs new file mode 100644 index 0000000..38a224b --- /dev/null +++ b/crates/stackless-integrations/src/providers/kernel/project.rs @@ -0,0 +1,146 @@ +//! `kernel/project` integration. + +use std::collections::BTreeMap; + +use serde::Serialize; +use stackless_stripe_projects::catalog::verify::CatalogService; +use stackless_stripe_projects::provision::ProvisionContext; + +use super::FamilyResource; +use crate::error::IntegrationError; +use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; +use crate::registry; + +pub const RESOURCE_KIND: &str = "integration-kernel"; + +#[derive(Debug, Serialize)] +pub struct KERNELProjectConfig { + pub name: String, +} + +impl CatalogService for KERNELProjectConfig { + const REFERENCE: &'static str = "kernel/project"; +} + +#[derive(Debug)] +pub struct KERNELProject; + +impl Hostable for KERNELProject { + const PROVIDER: &'static str = "kernel"; + const HOSTING: IntegrationHosting = IntegrationHosting::Managed; + const CONFIG_SCOPE: ConfigScope = ConfigScope::GlobalOnly; + const RESOURCE_KIND: &'static str = RESOURCE_KIND; + const OUTPUTS: &'static [&'static str] = &["api_key"]; +} + +impl FamilyResource for KERNELProject { + type Config = KERNELProjectConfig; + const PROVIDER_PREFIX: &'static str = "KERNEL"; + // Provisional until pinned by `mise run discover kernel/project`. + const OUTPUT_FIELDS: &'static [(&'static str, &'static str, bool)] = + &[("API_KEY", "api_key", true)]; + + fn build_config(ctx: &ProvisionContext<'_>) -> Result { + let config = super::integration_config(ctx)?; + Ok(KERNELProjectConfig { + name: super::interp_required(ctx, &config, "name")?, + }) + } +} + +pub fn validate_config( + name: &str, + config: &BTreeMap, +) -> Result<(), IntegrationError> { + registry::config_string(config, "name").map_err(|err| IntegrationError::ConfigInvalid { + location: format!("integrations.{name}.name"), + detail: err.to_string(), + })?; + let _ = (name, config); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ProviderOps; + use crate::resource::ResourcePayload; + use stackless_core::def::StackDef; + use stackless_stripe_projects::stripe::StripeProjects; + use stackless_stripe_projects::test_support; + + #[test] + fn config_matches_catalog() { + const FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../stackless-stripe-projects/tests/fixtures/catalog.json" + )); + let catalog = stackless_stripe_projects::Catalog::from_json_envelope(FIXTURE).unwrap(); + let failures = stackless_stripe_projects::verify_service( + &catalog, + &KERNELProjectConfig { + name: "test-name".into(), + }, + ); + assert!( + failures.is_empty(), + "kernel/project catalog gaps:\n{}", + failures.join("\n") + ); + } + + const CATALOG_ENVELOPE: &str = r##"{"ok":true,"command":"projects catalog","data":{"last_updated":"2026-07-11T00:00:00Z","services":[{"id":"prvsvc_project","object":"v2.provisioning.provider_service_detail","provider_id":"prvdr_kernel","provider_name":"KERNEL","service_id":"project","categories":["database"],"kind":"deployable","scope":"project","availability":"available","development":false,"livemode":true,"pricing":{"type":"component"},"configuration_schema":{"properties":{"name":{"description":"A name for this API key","type":"string"}},"required":["name"],"type":"object"}}]}}"##; + + fn test_def() -> StackDef { + StackDef::parse( + r#" +[stack] +name = "atto" +[stack.projects.stripe] +project = "project_1" +[integrations.res] +provider = "kernel" +name = "test-name" +[services.api] +source = { repo = "r", ref = "main" } +env = { OUT = "${integrations.res.api_key}" } +health = { path = "/health" } +[services.api.local] +run = "true" +"#, + ) + .unwrap() + } + + #[tokio::test] + async fn provision_records_outputs() { + let runner = test_support::provision_script( + CATALOG_ENVELOPE, + serde_json::json!({"KERNEL_API_KEY": "val_api_key"}), + 0, + ); + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("stackless.toml"), + "[stack]\nname=\"atto\"\n", + ) + .unwrap(); + let stripe = StripeProjects::new(&runner, dir.path()); + + let resource = KERNELProject + .provision( + &stripe.as_dyn(), + &test_def(), + dir.path(), + "demo", + "res", + "local", + false, + ) + .await + .unwrap(); + assert_eq!(resource.resource_kind, "integration-kernel"); + let payload: ResourcePayload = serde_json::from_str(&resource.payload).unwrap(); + assert_eq!(payload.outputs["api_key"], "val_api_key"); + } +} diff --git a/crates/stackless-integrations/src/providers/laravel_cloud/application.rs b/crates/stackless-integrations/src/providers/laravel_cloud/application.rs new file mode 100644 index 0000000..5019c05 --- /dev/null +++ b/crates/stackless-integrations/src/providers/laravel_cloud/application.rs @@ -0,0 +1,174 @@ +//! `laravel_cloud/application` integration. + +use std::collections::BTreeMap; + +use serde::Serialize; +use stackless_stripe_projects::catalog::verify::CatalogService; +use stackless_stripe_projects::provision::ProvisionContext; + +use super::FamilyResource; +use crate::error::IntegrationError; +use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; +use crate::registry; + +pub const RESOURCE_KIND: &str = "integration-laravel-cloud"; + +#[derive(Debug, Serialize)] +pub struct LaravelCloudApplicationConfig { + pub name: String, + pub region: String, + pub repository: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub create_cache: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub create_database: Option, +} + +impl CatalogService for LaravelCloudApplicationConfig { + const REFERENCE: &'static str = "laravel_cloud/application"; +} + +#[derive(Debug)] +pub struct LaravelCloudApplication; + +impl Hostable for LaravelCloudApplication { + const PROVIDER: &'static str = "laravel-cloud"; + const HOSTING: IntegrationHosting = IntegrationHosting::Managed; + const CONFIG_SCOPE: ConfigScope = ConfigScope::GlobalOnly; + const RESOURCE_KIND: &'static str = RESOURCE_KIND; + const OUTPUTS: &'static [&'static str] = &["app_id"]; +} + +impl FamilyResource for LaravelCloudApplication { + type Config = LaravelCloudApplicationConfig; + const PROVIDER_PREFIX: &'static str = "LARAVEL_CLOUD"; + // Provisional until pinned by `mise run discover laravel_cloud/application`. + const OUTPUT_FIELDS: &'static [(&'static str, &'static str, bool)] = + &[("APP_ID", "app_id", true)]; + + fn build_config( + ctx: &ProvisionContext<'_>, + ) -> Result { + let config = super::integration_config(ctx)?; + Ok(LaravelCloudApplicationConfig { + name: super::interp_required(ctx, &config, "name")?, + region: super::interp_required(ctx, &config, "region")?, + repository: super::interp_required(ctx, &config, "repository")?, + create_cache: super::interp_optional(ctx, &config, "create_cache")?, + create_database: super::interp_optional(ctx, &config, "create_database")?, + }) + } +} + +pub fn validate_config( + name: &str, + config: &BTreeMap, +) -> Result<(), IntegrationError> { + registry::config_string(config, "name").map_err(|err| IntegrationError::ConfigInvalid { + location: format!("integrations.{name}.name"), + detail: err.to_string(), + })?; + registry::config_string(config, "region").map_err(|err| IntegrationError::ConfigInvalid { + location: format!("integrations.{name}.region"), + detail: err.to_string(), + })?; + registry::config_string(config, "repository").map_err(|err| { + IntegrationError::ConfigInvalid { + location: format!("integrations.{name}.repository"), + detail: err.to_string(), + } + })?; + let _ = (name, config); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ProviderOps; + use crate::resource::ResourcePayload; + use stackless_core::def::StackDef; + use stackless_stripe_projects::stripe::StripeProjects; + use stackless_stripe_projects::test_support; + + #[test] + fn config_matches_catalog() { + const FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../stackless-stripe-projects/tests/fixtures/catalog.json" + )); + let catalog = stackless_stripe_projects::Catalog::from_json_envelope(FIXTURE).unwrap(); + let failures = stackless_stripe_projects::verify_service( + &catalog, + &LaravelCloudApplicationConfig { + name: "test-name".into(), + region: "us-east-1".into(), + repository: "test-repository".into(), + create_cache: None, + create_database: None, + }, + ); + assert!( + failures.is_empty(), + "laravel_cloud/application catalog gaps:\n{}", + failures.join("\n") + ); + } + + const CATALOG_ENVELOPE: &str = r##"{"ok":true,"command":"projects catalog","data":{"last_updated":"2026-07-11T00:00:00Z","services":[{"id":"prvsvc_application","object":"v2.provisioning.provider_service_detail","provider_id":"prvdr_laravel_cloud","provider_name":"Laravel_Cloud","service_id":"application","categories":["database"],"kind":"deployable","scope":"project","availability":"available","development":false,"livemode":true,"pricing":{"type":"component"},"configuration_schema":{"properties":{"create_cache":{"default":"no","description":"Provision a managed Valkey cache and connect it to this application.","enum":["yes","no"],"title":"Create a cache","type":"string"},"create_database":{"default":"no","description":"Provision a managed MySQL database and connect it to this application.","enum":["yes","no"],"title":"Create a database","type":"string"},"name":{"description":"A name for your Laravel application.","title":"Application Name","type":"string"},"region":{"enum":["us-east-1","us-east-2","eu-central-1","eu-west-1","eu-west-2","ap-southeast-1","ap-southeast-2","ap-northeast-1","ca-central-1","me-central-1"],"type":"string"},"repository":{"description":"Full repository name (e.g. laravel/cloud).","title":"Repository","type":"string"}},"required":["name","repository","region"],"type":"object"}}]}}"##; + + fn test_def() -> StackDef { + StackDef::parse( + r#" +[stack] +name = "atto" +[stack.projects.stripe] +project = "project_1" +[integrations.res] +provider = "laravel-cloud" +name = "test-name" +region = "us-east-1" +repository = "test-repository" +[services.api] +source = { repo = "r", ref = "main" } +env = { OUT = "${integrations.res.app_id}" } +health = { path = "/health" } +[services.api.local] +run = "true" +"#, + ) + .unwrap() + } + + #[tokio::test] + async fn provision_records_outputs() { + let runner = test_support::provision_script( + CATALOG_ENVELOPE, + serde_json::json!({"LARAVEL_CLOUD_APP_ID": "val_app_id"}), + 0, + ); + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("stackless.toml"), + "[stack]\nname=\"atto\"\n", + ) + .unwrap(); + let stripe = StripeProjects::new(&runner, dir.path()); + + let resource = LaravelCloudApplication + .provision( + &stripe.as_dyn(), + &test_def(), + dir.path(), + "demo", + "res", + "local", + false, + ) + .await + .unwrap(); + assert_eq!(resource.resource_kind, "integration-laravel-cloud"); + let payload: ResourcePayload = serde_json::from_str(&resource.payload).unwrap(); + assert_eq!(payload.outputs["app_id"], "val_app_id"); + } +} diff --git a/crates/stackless-integrations/src/providers/laravel_cloud/mod.rs b/crates/stackless-integrations/src/providers/laravel_cloud/mod.rs new file mode 100644 index 0000000..6692aff --- /dev/null +++ b/crates/stackless-integrations/src/providers/laravel_cloud/mod.rs @@ -0,0 +1,13 @@ +//! Laravel_Cloud catalog resources via Stripe Projects. +//! +//! Output envelopes are provisional until pinned by `xtask discover`. + +pub mod application; +pub mod mysql; +pub mod valkey; + +#[allow(unused_imports)] +pub(crate) use crate::resource::{ + CatalogResource as FamilyResource, bool_optional, bool_required, int_optional, int_required, + integration_config, interp_optional, interp_required, +}; diff --git a/crates/stackless-integrations/src/providers/laravel_cloud/mysql.rs b/crates/stackless-integrations/src/providers/laravel_cloud/mysql.rs new file mode 100644 index 0000000..b6f6b8b --- /dev/null +++ b/crates/stackless-integrations/src/providers/laravel_cloud/mysql.rs @@ -0,0 +1,158 @@ +//! `laravel_cloud/mysql` integration. + +use std::collections::BTreeMap; + +use serde::Serialize; +use stackless_stripe_projects::catalog::verify::CatalogService; +use stackless_stripe_projects::provision::ProvisionContext; + +use super::FamilyResource; +use crate::error::IntegrationError; +use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; +use crate::registry; + +pub const RESOURCE_KIND: &str = "integration-laravel-cloud-mysql"; + +#[derive(Debug, Serialize)] +pub struct LaravelCloudMysqlConfig { + pub instance_size: String, + pub region: String, +} + +impl CatalogService for LaravelCloudMysqlConfig { + const REFERENCE: &'static str = "laravel_cloud/mysql"; +} + +#[derive(Debug)] +pub struct LaravelCloudMysql; + +impl Hostable for LaravelCloudMysql { + const PROVIDER: &'static str = "laravel-cloud-mysql"; + const HOSTING: IntegrationHosting = IntegrationHosting::Managed; + const CONFIG_SCOPE: ConfigScope = ConfigScope::GlobalOnly; + const RESOURCE_KIND: &'static str = RESOURCE_KIND; + const OUTPUTS: &'static [&'static str] = &["database_url"]; +} + +impl FamilyResource for LaravelCloudMysql { + type Config = LaravelCloudMysqlConfig; + const PROVIDER_PREFIX: &'static str = "LARAVEL_CLOUD"; + // Provisional until pinned by `mise run discover laravel_cloud/mysql`. + const OUTPUT_FIELDS: &'static [(&'static str, &'static str, bool)] = + &[("DATABASE_URL", "database_url", true)]; + + fn build_config( + ctx: &ProvisionContext<'_>, + ) -> Result { + let config = super::integration_config(ctx)?; + Ok(LaravelCloudMysqlConfig { + instance_size: super::interp_required(ctx, &config, "instance_size")?, + region: super::interp_required(ctx, &config, "region")?, + }) + } +} + +pub fn validate_config( + name: &str, + config: &BTreeMap, +) -> Result<(), IntegrationError> { + registry::config_string(config, "instance_size").map_err(|err| { + IntegrationError::ConfigInvalid { + location: format!("integrations.{name}.instance_size"), + detail: err.to_string(), + } + })?; + registry::config_string(config, "region").map_err(|err| IntegrationError::ConfigInvalid { + location: format!("integrations.{name}.region"), + detail: err.to_string(), + })?; + let _ = (name, config); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ProviderOps; + use crate::resource::ResourcePayload; + use stackless_core::def::StackDef; + use stackless_stripe_projects::stripe::StripeProjects; + use stackless_stripe_projects::test_support; + + #[test] + fn config_matches_catalog() { + const FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../stackless-stripe-projects/tests/fixtures/catalog.json" + )); + let catalog = stackless_stripe_projects::Catalog::from_json_envelope(FIXTURE).unwrap(); + let failures = stackless_stripe_projects::verify_service( + &catalog, + &LaravelCloudMysqlConfig { + instance_size: "db-flex.m-1vcpu-512mb".into(), + region: "us-east-1".into(), + }, + ); + assert!( + failures.is_empty(), + "laravel_cloud/mysql catalog gaps:\n{}", + failures.join("\n") + ); + } + + const CATALOG_ENVELOPE: &str = r##"{"ok":true,"command":"projects catalog","data":{"last_updated":"2026-07-11T00:00:00Z","services":[{"id":"prvsvc_mysql","object":"v2.provisioning.provider_service_detail","provider_id":"prvdr_laravel_cloud","provider_name":"Laravel_Cloud","service_id":"mysql","categories":["database"],"kind":"deployable","scope":"project","availability":"available","development":false,"livemode":true,"pricing":{"type":"component"},"configuration_schema":{"properties":{"instance_size":{"enum":["db-flex.m-1vcpu-512mb","db-flex.m-1vcpu-1gb","db-flex.m-1vcpu-2gb","db-flex.m-1vcpu-4gb","db-pro.m-1vcpu-4gb","db-pro.m-2vcpu-8gb","db-pro.m-4vcpu-16gb","db-pro.m-8vcpu-32gb"],"type":"string"},"region":{"enum":["us-east-1","us-east-2","eu-central-1","eu-west-1","eu-west-2","ap-southeast-1","ap-southeast-2","ap-northeast-1","ca-central-1","me-central-1"],"type":"string"}},"required":["instance_size","region"],"type":"object"}}]}}"##; + + fn test_def() -> StackDef { + StackDef::parse( + r#" +[stack] +name = "atto" +[stack.projects.stripe] +project = "project_1" +[integrations.res] +provider = "laravel-cloud-mysql" +instance_size = "db-flex.m-1vcpu-512mb" +region = "us-east-1" +[services.api] +source = { repo = "r", ref = "main" } +env = { OUT = "${integrations.res.database_url}" } +health = { path = "/health" } +[services.api.local] +run = "true" +"#, + ) + .unwrap() + } + + #[tokio::test] + async fn provision_records_outputs() { + let runner = test_support::provision_script( + CATALOG_ENVELOPE, + serde_json::json!({"LARAVEL_CLOUD_DATABASE_URL": "val_database_url"}), + 0, + ); + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("stackless.toml"), + "[stack]\nname=\"atto\"\n", + ) + .unwrap(); + let stripe = StripeProjects::new(&runner, dir.path()); + + let resource = LaravelCloudMysql + .provision( + &stripe.as_dyn(), + &test_def(), + dir.path(), + "demo", + "res", + "local", + false, + ) + .await + .unwrap(); + assert_eq!(resource.resource_kind, "integration-laravel-cloud-mysql"); + let payload: ResourcePayload = serde_json::from_str(&resource.payload).unwrap(); + assert_eq!(payload.outputs["database_url"], "val_database_url"); + } +} diff --git a/crates/stackless-integrations/src/providers/laravel_cloud/valkey.rs b/crates/stackless-integrations/src/providers/laravel_cloud/valkey.rs new file mode 100644 index 0000000..e3e9259 --- /dev/null +++ b/crates/stackless-integrations/src/providers/laravel_cloud/valkey.rs @@ -0,0 +1,158 @@ +//! `laravel_cloud/valkey` integration. + +use std::collections::BTreeMap; + +use serde::Serialize; +use stackless_stripe_projects::catalog::verify::CatalogService; +use stackless_stripe_projects::provision::ProvisionContext; + +use super::FamilyResource; +use crate::error::IntegrationError; +use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; +use crate::registry; + +pub const RESOURCE_KIND: &str = "integration-laravel-cloud-valkey"; + +#[derive(Debug, Serialize)] +pub struct LaravelCloudValkeyConfig { + pub instance_size: String, + pub region: String, +} + +impl CatalogService for LaravelCloudValkeyConfig { + const REFERENCE: &'static str = "laravel_cloud/valkey"; +} + +#[derive(Debug)] +pub struct LaravelCloudValkey; + +impl Hostable for LaravelCloudValkey { + const PROVIDER: &'static str = "laravel-cloud-valkey"; + const HOSTING: IntegrationHosting = IntegrationHosting::Managed; + const CONFIG_SCOPE: ConfigScope = ConfigScope::GlobalOnly; + const RESOURCE_KIND: &'static str = RESOURCE_KIND; + const OUTPUTS: &'static [&'static str] = &["redis_url"]; +} + +impl FamilyResource for LaravelCloudValkey { + type Config = LaravelCloudValkeyConfig; + const PROVIDER_PREFIX: &'static str = "LARAVEL_CLOUD"; + // Provisional until pinned by `mise run discover laravel_cloud/valkey`. + const OUTPUT_FIELDS: &'static [(&'static str, &'static str, bool)] = + &[("REDIS_URL", "redis_url", true)]; + + fn build_config( + ctx: &ProvisionContext<'_>, + ) -> Result { + let config = super::integration_config(ctx)?; + Ok(LaravelCloudValkeyConfig { + instance_size: super::interp_required(ctx, &config, "instance_size")?, + region: super::interp_required(ctx, &config, "region")?, + }) + } +} + +pub fn validate_config( + name: &str, + config: &BTreeMap, +) -> Result<(), IntegrationError> { + registry::config_string(config, "instance_size").map_err(|err| { + IntegrationError::ConfigInvalid { + location: format!("integrations.{name}.instance_size"), + detail: err.to_string(), + } + })?; + registry::config_string(config, "region").map_err(|err| IntegrationError::ConfigInvalid { + location: format!("integrations.{name}.region"), + detail: err.to_string(), + })?; + let _ = (name, config); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ProviderOps; + use crate::resource::ResourcePayload; + use stackless_core::def::StackDef; + use stackless_stripe_projects::stripe::StripeProjects; + use stackless_stripe_projects::test_support; + + #[test] + fn config_matches_catalog() { + const FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../stackless-stripe-projects/tests/fixtures/catalog.json" + )); + let catalog = stackless_stripe_projects::Catalog::from_json_envelope(FIXTURE).unwrap(); + let failures = stackless_stripe_projects::verify_service( + &catalog, + &LaravelCloudValkeyConfig { + instance_size: "valkey-flex-250mb".into(), + region: "us-east-1".into(), + }, + ); + assert!( + failures.is_empty(), + "laravel_cloud/valkey catalog gaps:\n{}", + failures.join("\n") + ); + } + + const CATALOG_ENVELOPE: &str = r##"{"ok":true,"command":"projects catalog","data":{"last_updated":"2026-07-11T00:00:00Z","services":[{"id":"prvsvc_valkey","object":"v2.provisioning.provider_service_detail","provider_id":"prvdr_laravel_cloud","provider_name":"Laravel_Cloud","service_id":"valkey","categories":["database"],"kind":"deployable","scope":"project","availability":"available","development":false,"livemode":true,"pricing":{"type":"component"},"configuration_schema":{"properties":{"instance_size":{"enum":["valkey-flex-250mb","valkey-flex-1gb","valkey-flex-2.5gb","valkey-pro.5gb","valkey-pro.12gb","valkey-pro.25gb","valkey-pro.50gb"],"type":"string"},"region":{"enum":["us-east-1","us-east-2","eu-central-1","eu-west-1","eu-west-2","ap-southeast-1","ap-southeast-2","ap-northeast-1","ca-central-1","me-central-1"],"type":"string"}},"required":["instance_size","region"],"type":"object"}}]}}"##; + + fn test_def() -> StackDef { + StackDef::parse( + r#" +[stack] +name = "atto" +[stack.projects.stripe] +project = "project_1" +[integrations.res] +provider = "laravel-cloud-valkey" +instance_size = "valkey-flex-250mb" +region = "us-east-1" +[services.api] +source = { repo = "r", ref = "main" } +env = { OUT = "${integrations.res.redis_url}" } +health = { path = "/health" } +[services.api.local] +run = "true" +"#, + ) + .unwrap() + } + + #[tokio::test] + async fn provision_records_outputs() { + let runner = test_support::provision_script( + CATALOG_ENVELOPE, + serde_json::json!({"LARAVEL_CLOUD_REDIS_URL": "val_redis_url"}), + 0, + ); + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("stackless.toml"), + "[stack]\nname=\"atto\"\n", + ) + .unwrap(); + let stripe = StripeProjects::new(&runner, dir.path()); + + let resource = LaravelCloudValkey + .provision( + &stripe.as_dyn(), + &test_def(), + dir.path(), + "demo", + "res", + "local", + false, + ) + .await + .unwrap(); + assert_eq!(resource.resource_kind, "integration-laravel-cloud-valkey"); + let payload: ResourcePayload = serde_json::from_str(&resource.payload).unwrap(); + assert_eq!(payload.outputs["redis_url"], "val_redis_url"); + } +} diff --git a/crates/stackless-integrations/src/providers/metronome/mod.rs b/crates/stackless-integrations/src/providers/metronome/mod.rs new file mode 100644 index 0000000..874d460 --- /dev/null +++ b/crates/stackless-integrations/src/providers/metronome/mod.rs @@ -0,0 +1,11 @@ +//! Metronome catalog resources via Stripe Projects. +//! +//! Output envelopes are provisional until pinned by `xtask discover`. + +pub mod sandbox; + +#[allow(unused_imports)] +pub(crate) use crate::resource::{ + CatalogResource as FamilyResource, bool_optional, bool_required, int_optional, int_required, + integration_config, interp_optional, interp_required, +}; diff --git a/crates/stackless-integrations/src/providers/metronome/sandbox.rs b/crates/stackless-integrations/src/providers/metronome/sandbox.rs new file mode 100644 index 0000000..ea9d18d --- /dev/null +++ b/crates/stackless-integrations/src/providers/metronome/sandbox.rs @@ -0,0 +1,135 @@ +//! `metronome/sandbox` integration. + +use std::collections::BTreeMap; + +use serde::Serialize; +use stackless_stripe_projects::catalog::verify::CatalogService; +use stackless_stripe_projects::provision::ProvisionContext; + +use super::FamilyResource; +use crate::error::IntegrationError; +use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; +use crate::registry; + +pub const RESOURCE_KIND: &str = "integration-metronome"; + +#[derive(Debug, Serialize)] +pub struct MetronomeSandboxConfig {} + +impl CatalogService for MetronomeSandboxConfig { + const REFERENCE: &'static str = "metronome/sandbox"; +} + +#[derive(Debug)] +pub struct MetronomeSandbox; + +impl Hostable for MetronomeSandbox { + const PROVIDER: &'static str = "metronome"; + const HOSTING: IntegrationHosting = IntegrationHosting::Managed; + const CONFIG_SCOPE: ConfigScope = ConfigScope::GlobalOnly; + const RESOURCE_KIND: &'static str = RESOURCE_KIND; + const OUTPUTS: &'static [&'static str] = &["api_key"]; +} + +impl FamilyResource for MetronomeSandbox { + type Config = MetronomeSandboxConfig; + const PROVIDER_PREFIX: &'static str = "METRONOME"; + // Provisional until pinned by `mise run discover metronome/sandbox`. + const OUTPUT_FIELDS: &'static [(&'static str, &'static str, bool)] = + &[("API_KEY", "api_key", true)]; + + fn build_config( + ctx: &ProvisionContext<'_>, + ) -> Result { + let _ = super::integration_config(ctx)?; + Ok(MetronomeSandboxConfig {}) + } +} + +pub fn validate_config( + name: &str, + config: &BTreeMap, +) -> Result<(), IntegrationError> { + let _ = (name, config); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ProviderOps; + use crate::resource::ResourcePayload; + use stackless_core::def::StackDef; + use stackless_stripe_projects::stripe::StripeProjects; + use stackless_stripe_projects::test_support; + + #[test] + fn config_matches_catalog() { + const FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../stackless-stripe-projects/tests/fixtures/catalog.json" + )); + let catalog = stackless_stripe_projects::Catalog::from_json_envelope(FIXTURE).unwrap(); + let failures = + stackless_stripe_projects::verify_service(&catalog, &MetronomeSandboxConfig {}); + assert!( + failures.is_empty(), + "metronome/sandbox catalog gaps:\n{}", + failures.join("\n") + ); + } + + const CATALOG_ENVELOPE: &str = r##"{"ok":true,"command":"projects catalog","data":{"last_updated":"2026-07-11T00:00:00Z","services":[{"id":"prvsvc_sandbox","object":"v2.provisioning.provider_service_detail","provider_id":"prvdr_metronome","provider_name":"Metronome","service_id":"sandbox","categories":["database"],"kind":"deployable","scope":"project","availability":"available","development":false,"livemode":true,"pricing":{"type":"free"},"configuration_schema":{"type":"object","required":[],"additionalProperties":false,"properties":{}}}]}}"##; + + fn test_def() -> StackDef { + StackDef::parse( + r#" +[stack] +name = "atto" +[stack.projects.stripe] +project = "project_1" +[integrations.res] +provider = "metronome" +[services.api] +source = { repo = "r", ref = "main" } +env = { OUT = "${integrations.res.api_key}" } +health = { path = "/health" } +[services.api.local] +run = "true" +"#, + ) + .unwrap() + } + + #[tokio::test] + async fn provision_records_outputs() { + let runner = test_support::provision_script( + CATALOG_ENVELOPE, + serde_json::json!({"METRONOME_API_KEY": "val_api_key"}), + 0, + ); + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("stackless.toml"), + "[stack]\nname=\"atto\"\n", + ) + .unwrap(); + let stripe = StripeProjects::new(&runner, dir.path()); + + let resource = MetronomeSandbox + .provision( + &stripe.as_dyn(), + &test_def(), + dir.path(), + "demo", + "res", + "local", + false, + ) + .await + .unwrap(); + assert_eq!(resource.resource_kind, "integration-metronome"); + let payload: ResourcePayload = serde_json::from_str(&resource.payload).unwrap(); + assert_eq!(payload.outputs["api_key"], "val_api_key"); + } +} diff --git a/crates/stackless-integrations/src/providers/mixpanel/analytics.rs b/crates/stackless-integrations/src/providers/mixpanel/analytics.rs new file mode 100644 index 0000000..c850b5b --- /dev/null +++ b/crates/stackless-integrations/src/providers/mixpanel/analytics.rs @@ -0,0 +1,135 @@ +//! `mixpanel/analytics` integration. + +use std::collections::BTreeMap; + +use serde::Serialize; +use stackless_stripe_projects::catalog::verify::CatalogService; +use stackless_stripe_projects::provision::ProvisionContext; + +use super::FamilyResource; +use crate::error::IntegrationError; +use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; +use crate::registry; + +pub const RESOURCE_KIND: &str = "integration-mixpanel"; + +#[derive(Debug, Serialize)] +pub struct MixpanelAnalyticsConfig {} + +impl CatalogService for MixpanelAnalyticsConfig { + const REFERENCE: &'static str = "mixpanel/analytics"; +} + +#[derive(Debug)] +pub struct MixpanelAnalytics; + +impl Hostable for MixpanelAnalytics { + const PROVIDER: &'static str = "mixpanel"; + const HOSTING: IntegrationHosting = IntegrationHosting::Managed; + const CONFIG_SCOPE: ConfigScope = ConfigScope::GlobalOnly; + const RESOURCE_KIND: &'static str = RESOURCE_KIND; + const OUTPUTS: &'static [&'static str] = &["token"]; +} + +impl FamilyResource for MixpanelAnalytics { + type Config = MixpanelAnalyticsConfig; + const PROVIDER_PREFIX: &'static str = "MIXPANEL"; + // Provisional until pinned by `mise run discover mixpanel/analytics`. + const OUTPUT_FIELDS: &'static [(&'static str, &'static str, bool)] = + &[("TOKEN", "token", true)]; + + fn build_config( + ctx: &ProvisionContext<'_>, + ) -> Result { + let _ = super::integration_config(ctx)?; + Ok(MixpanelAnalyticsConfig {}) + } +} + +pub fn validate_config( + name: &str, + config: &BTreeMap, +) -> Result<(), IntegrationError> { + let _ = (name, config); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ProviderOps; + use crate::resource::ResourcePayload; + use stackless_core::def::StackDef; + use stackless_stripe_projects::stripe::StripeProjects; + use stackless_stripe_projects::test_support; + + #[test] + fn config_matches_catalog() { + const FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../stackless-stripe-projects/tests/fixtures/catalog.json" + )); + let catalog = stackless_stripe_projects::Catalog::from_json_envelope(FIXTURE).unwrap(); + let failures = + stackless_stripe_projects::verify_service(&catalog, &MixpanelAnalyticsConfig {}); + assert!( + failures.is_empty(), + "mixpanel/analytics catalog gaps:\n{}", + failures.join("\n") + ); + } + + const CATALOG_ENVELOPE: &str = r##"{"ok":true,"command":"projects catalog","data":{"last_updated":"2026-07-11T00:00:00Z","services":[{"id":"prvsvc_analytics","object":"v2.provisioning.provider_service_detail","provider_id":"prvdr_mixpanel","provider_name":"Mixpanel","service_id":"analytics","categories":["database"],"kind":"deployable","scope":"project","availability":"available","development":false,"livemode":true,"pricing":{"type":"component"},"configuration_schema":{"type":"object","required":[],"additionalProperties":false,"properties":{}}}]}}"##; + + fn test_def() -> StackDef { + StackDef::parse( + r#" +[stack] +name = "atto" +[stack.projects.stripe] +project = "project_1" +[integrations.res] +provider = "mixpanel" +[services.api] +source = { repo = "r", ref = "main" } +env = { OUT = "${integrations.res.token}" } +health = { path = "/health" } +[services.api.local] +run = "true" +"#, + ) + .unwrap() + } + + #[tokio::test] + async fn provision_records_outputs() { + let runner = test_support::provision_script( + CATALOG_ENVELOPE, + serde_json::json!({"MIXPANEL_TOKEN": "val_token"}), + 0, + ); + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("stackless.toml"), + "[stack]\nname=\"atto\"\n", + ) + .unwrap(); + let stripe = StripeProjects::new(&runner, dir.path()); + + let resource = MixpanelAnalytics + .provision( + &stripe.as_dyn(), + &test_def(), + dir.path(), + "demo", + "res", + "local", + false, + ) + .await + .unwrap(); + assert_eq!(resource.resource_kind, "integration-mixpanel"); + let payload: ResourcePayload = serde_json::from_str(&resource.payload).unwrap(); + assert_eq!(payload.outputs["token"], "val_token"); + } +} diff --git a/crates/stackless-integrations/src/providers/mixpanel/mod.rs b/crates/stackless-integrations/src/providers/mixpanel/mod.rs new file mode 100644 index 0000000..4661914 --- /dev/null +++ b/crates/stackless-integrations/src/providers/mixpanel/mod.rs @@ -0,0 +1,11 @@ +//! Mixpanel catalog resources via Stripe Projects. +//! +//! Output envelopes are provisional until pinned by `xtask discover`. + +pub mod analytics; + +#[allow(unused_imports)] +pub(crate) use crate::resource::{ + CatalogResource as FamilyResource, bool_optional, bool_required, int_optional, int_required, + integration_config, interp_optional, interp_required, +}; diff --git a/crates/stackless-integrations/src/providers/mod.rs b/crates/stackless-integrations/src/providers/mod.rs index 09d5634..9f90a8d 100644 --- a/crates/stackless-integrations/src/providers/mod.rs +++ b/crates/stackless-integrations/src/providers/mod.rs @@ -1,18 +1,63 @@ +pub mod agentmail; +pub mod agentphone; +pub mod algolia; +pub mod amplitude; +pub mod auth0; +pub mod base44_projects; +pub mod blaxel; +pub mod browserbase; +pub mod chroma; pub mod clerk; +pub mod clickhouse; pub mod cloudflare; +pub mod daytona; +pub mod e2b; +pub mod elevenlabs; +pub mod exa; +pub mod firecrawl; +pub mod flyio; +pub mod gitlab; +pub mod heygen; +pub mod huggingface; +pub mod inngest; +pub mod kernel; +pub mod laravel_cloud; +pub mod metronome; +pub mod mixpanel; +pub mod neon; +pub mod openrouter; +pub mod parallel; +pub mod planetscale; +pub mod postalform; +pub mod posthog; +pub mod prisma; +pub mod privy; +pub mod railway; +pub mod render_db; +pub mod runloop; +pub mod sentry; +pub mod supabase; +pub mod supermemory; +pub mod turso; +pub mod upstash; +pub mod wix; +pub mod wordpress_com; +pub mod workos; #[cfg(test)] mod tests { use stackless_provider_sdk::CatalogResource; use stackless_provider_sdk::Hostable; - use crate::providers::cloudflare; + use crate::providers::{ + agentmail, agentphone, algolia, amplitude, auth0, base44_projects, blaxel, browserbase, + chroma, clickhouse, cloudflare, daytona, e2b, elevenlabs, exa, firecrawl, flyio, gitlab, + heygen, huggingface, inngest, kernel, laravel_cloud, metronome, mixpanel, neon, openrouter, + parallel, planetscale, postalform, posthog, prisma, privy, railway, render_db, runloop, + sentry, supabase, supermemory, turso, upstash, wix, wordpress_com, workos, + }; - /// `Hostable::OUTPUTS` (the names referenceable as `${integrations.*.}`) - /// must stay in lockstep with the names column of `OUTPUT_FIELDS` — the two - /// are co-located but hand-written, so this makes drift a test failure rather - /// than a silent validation bug. Bespoke providers (Clerk) aren't - /// `CatalogResource`, so they're out of scope here by construction. + /// `Hostable::OUTPUTS` must stay in lockstep with `OUTPUT_FIELDS` names. fn assert_outputs_match() { let fields: Vec<&str> = T::OUTPUT_FIELDS.iter().map(|(_, name, _)| *name).collect(); let outputs: Vec<&str> = ::OUTPUTS.to_vec(); @@ -34,5 +79,64 @@ mod tests { assert_outputs_match::(); assert_outputs_match::(); assert_outputs_match::(); + + assert_outputs_match::(); + assert_outputs_match::(); + assert_outputs_match::(); + assert_outputs_match::(); + assert_outputs_match::(); + assert_outputs_match::(); + assert_outputs_match::(); + assert_outputs_match::(); + assert_outputs_match::(); + assert_outputs_match::(); + assert_outputs_match::(); + assert_outputs_match::(); + assert_outputs_match::(); + assert_outputs_match::(); + assert_outputs_match::(); + assert_outputs_match::(); + assert_outputs_match::(); + assert_outputs_match::(); + assert_outputs_match::(); + assert_outputs_match::(); + assert_outputs_match::(); + assert_outputs_match::(); + assert_outputs_match::(); + assert_outputs_match::(); + assert_outputs_match::(); + assert_outputs_match::(); + assert_outputs_match::(); + assert_outputs_match::(); + assert_outputs_match::(); + assert_outputs_match::(); + assert_outputs_match::(); + assert_outputs_match::(); + assert_outputs_match::(); + assert_outputs_match::(); + assert_outputs_match::(); + assert_outputs_match::(); + assert_outputs_match::(); + assert_outputs_match::(); + assert_outputs_match::(); + assert_outputs_match::(); + assert_outputs_match::(); + assert_outputs_match::(); + assert_outputs_match::(); + assert_outputs_match::(); + assert_outputs_match::(); + assert_outputs_match::(); + assert_outputs_match::(); + assert_outputs_match::(); + assert_outputs_match::(); + assert_outputs_match::(); + assert_outputs_match::(); + assert_outputs_match::(); + assert_outputs_match::(); + assert_outputs_match::(); + assert_outputs_match::(); + assert_outputs_match::(); + assert_outputs_match::(); + assert_outputs_match::(); } } diff --git a/crates/stackless-integrations/src/providers/neon/mod.rs b/crates/stackless-integrations/src/providers/neon/mod.rs new file mode 100644 index 0000000..2d25d46 --- /dev/null +++ b/crates/stackless-integrations/src/providers/neon/mod.rs @@ -0,0 +1,11 @@ +//! Neon catalog resources via Stripe Projects. +//! +//! Output envelopes are provisional until pinned by `xtask discover`. + +pub mod postgres; + +#[allow(unused_imports)] +pub(crate) use crate::resource::{ + CatalogResource as FamilyResource, bool_optional, bool_required, int_optional, int_required, + integration_config, interp_optional, interp_required, +}; diff --git a/crates/stackless-integrations/src/providers/neon/postgres.rs b/crates/stackless-integrations/src/providers/neon/postgres.rs new file mode 100644 index 0000000..0d0fdb6 --- /dev/null +++ b/crates/stackless-integrations/src/providers/neon/postgres.rs @@ -0,0 +1,134 @@ +//! `neon/postgres` integration. + +use std::collections::BTreeMap; + +use serde::Serialize; +use stackless_stripe_projects::catalog::verify::CatalogService; +use stackless_stripe_projects::provision::ProvisionContext; + +use super::FamilyResource; +use crate::error::IntegrationError; +use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; +use crate::registry; + +pub const RESOURCE_KIND: &str = "integration-neon"; + +#[derive(Debug, Serialize)] +pub struct NeonPostgresConfig {} + +impl CatalogService for NeonPostgresConfig { + const REFERENCE: &'static str = "neon/postgres"; +} + +#[derive(Debug)] +pub struct NeonPostgres; + +impl Hostable for NeonPostgres { + const PROVIDER: &'static str = "neon"; + const HOSTING: IntegrationHosting = IntegrationHosting::Managed; + const CONFIG_SCOPE: ConfigScope = ConfigScope::GlobalOnly; + const RESOURCE_KIND: &'static str = RESOURCE_KIND; + const OUTPUTS: &'static [&'static str] = &["database_url", "host"]; +} + +impl FamilyResource for NeonPostgres { + type Config = NeonPostgresConfig; + const PROVIDER_PREFIX: &'static str = "NEON"; + // Provisional until pinned by `mise run discover neon/postgres`. + const OUTPUT_FIELDS: &'static [(&'static str, &'static str, bool)] = &[ + ("DATABASE_URL", "database_url", true), + ("HOST", "host", false), + ]; + + fn build_config(ctx: &ProvisionContext<'_>) -> Result { + let _ = super::integration_config(ctx)?; + Ok(NeonPostgresConfig {}) + } +} + +pub fn validate_config( + name: &str, + config: &BTreeMap, +) -> Result<(), IntegrationError> { + let _ = (name, config); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ProviderOps; + use crate::resource::ResourcePayload; + use stackless_core::def::StackDef; + use stackless_stripe_projects::stripe::StripeProjects; + use stackless_stripe_projects::test_support; + + #[test] + fn config_matches_catalog() { + const FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../stackless-stripe-projects/tests/fixtures/catalog.json" + )); + let catalog = stackless_stripe_projects::Catalog::from_json_envelope(FIXTURE).unwrap(); + let failures = stackless_stripe_projects::verify_service(&catalog, &NeonPostgresConfig {}); + assert!( + failures.is_empty(), + "neon/postgres catalog gaps:\n{}", + failures.join("\n") + ); + } + + const CATALOG_ENVELOPE: &str = r##"{"ok":true,"command":"projects catalog","data":{"last_updated":"2026-07-11T00:00:00Z","services":[{"id":"prvsvc_postgres","object":"v2.provisioning.provider_service_detail","provider_id":"prvdr_neon","provider_name":"Neon","service_id":"postgres","categories":["database"],"kind":"deployable","scope":"project","availability":"available","development":false,"livemode":true,"pricing":{"type":"component"},"configuration_schema":{"type":"object","required":[],"additionalProperties":false,"properties":{}}}]}}"##; + + fn test_def() -> StackDef { + StackDef::parse( + r#" +[stack] +name = "atto" +[stack.projects.stripe] +project = "project_1" +[integrations.res] +provider = "neon" +[services.api] +source = { repo = "r", ref = "main" } +env = { OUT = "${integrations.res.database_url}" } +health = { path = "/health" } +[services.api.local] +run = "true" +"#, + ) + .unwrap() + } + + #[tokio::test] + async fn provision_records_outputs() { + let runner = test_support::provision_script( + CATALOG_ENVELOPE, + serde_json::json!({"NEON_DATABASE_URL": "val_database_url", "NEON_HOST": "val_host"}), + 0, + ); + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("stackless.toml"), + "[stack]\nname=\"atto\"\n", + ) + .unwrap(); + let stripe = StripeProjects::new(&runner, dir.path()); + + let resource = NeonPostgres + .provision( + &stripe.as_dyn(), + &test_def(), + dir.path(), + "demo", + "res", + "local", + false, + ) + .await + .unwrap(); + assert_eq!(resource.resource_kind, "integration-neon"); + let payload: ResourcePayload = serde_json::from_str(&resource.payload).unwrap(); + assert_eq!(payload.outputs["database_url"], "val_database_url"); + } +} diff --git a/crates/stackless-integrations/src/providers/openrouter/api.rs b/crates/stackless-integrations/src/providers/openrouter/api.rs new file mode 100644 index 0000000..a7fabc4 --- /dev/null +++ b/crates/stackless-integrations/src/providers/openrouter/api.rs @@ -0,0 +1,132 @@ +//! `openrouter/api` integration. + +use std::collections::BTreeMap; + +use serde::Serialize; +use stackless_stripe_projects::catalog::verify::CatalogService; +use stackless_stripe_projects::provision::ProvisionContext; + +use super::FamilyResource; +use crate::error::IntegrationError; +use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; +use crate::registry; + +pub const RESOURCE_KIND: &str = "integration-openrouter"; + +#[derive(Debug, Serialize)] +pub struct OpenRouterApiConfig {} + +impl CatalogService for OpenRouterApiConfig { + const REFERENCE: &'static str = "openrouter/api"; +} + +#[derive(Debug)] +pub struct OpenRouterApi; + +impl Hostable for OpenRouterApi { + const PROVIDER: &'static str = "openrouter"; + const HOSTING: IntegrationHosting = IntegrationHosting::Managed; + const CONFIG_SCOPE: ConfigScope = ConfigScope::GlobalOnly; + const RESOURCE_KIND: &'static str = RESOURCE_KIND; + const OUTPUTS: &'static [&'static str] = &["api_key"]; +} + +impl FamilyResource for OpenRouterApi { + type Config = OpenRouterApiConfig; + const PROVIDER_PREFIX: &'static str = "OPENROUTER"; + // Provisional until pinned by `mise run discover openrouter/api`. + const OUTPUT_FIELDS: &'static [(&'static str, &'static str, bool)] = + &[("API_KEY", "api_key", true)]; + + fn build_config(ctx: &ProvisionContext<'_>) -> Result { + let _ = super::integration_config(ctx)?; + Ok(OpenRouterApiConfig {}) + } +} + +pub fn validate_config( + name: &str, + config: &BTreeMap, +) -> Result<(), IntegrationError> { + let _ = (name, config); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ProviderOps; + use crate::resource::ResourcePayload; + use stackless_core::def::StackDef; + use stackless_stripe_projects::stripe::StripeProjects; + use stackless_stripe_projects::test_support; + + #[test] + fn config_matches_catalog() { + const FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../stackless-stripe-projects/tests/fixtures/catalog.json" + )); + let catalog = stackless_stripe_projects::Catalog::from_json_envelope(FIXTURE).unwrap(); + let failures = stackless_stripe_projects::verify_service(&catalog, &OpenRouterApiConfig {}); + assert!( + failures.is_empty(), + "openrouter/api catalog gaps:\n{}", + failures.join("\n") + ); + } + + const CATALOG_ENVELOPE: &str = r##"{"ok":true,"command":"projects catalog","data":{"last_updated":"2026-07-11T00:00:00Z","services":[{"id":"prvsvc_api","object":"v2.provisioning.provider_service_detail","provider_id":"prvdr_openrouter","provider_name":"OpenRouter","service_id":"api","categories":["database"],"kind":"deployable","scope":"project","availability":"available","development":false,"livemode":true,"pricing":{"type":"component"},"configuration_schema":{"type":"object","required":[],"additionalProperties":false,"properties":{}}}]}}"##; + + fn test_def() -> StackDef { + StackDef::parse( + r#" +[stack] +name = "atto" +[stack.projects.stripe] +project = "project_1" +[integrations.res] +provider = "openrouter" +[services.api] +source = { repo = "r", ref = "main" } +env = { OUT = "${integrations.res.api_key}" } +health = { path = "/health" } +[services.api.local] +run = "true" +"#, + ) + .unwrap() + } + + #[tokio::test] + async fn provision_records_outputs() { + let runner = test_support::provision_script( + CATALOG_ENVELOPE, + serde_json::json!({"OPENROUTER_API_KEY": "val_api_key"}), + 0, + ); + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("stackless.toml"), + "[stack]\nname=\"atto\"\n", + ) + .unwrap(); + let stripe = StripeProjects::new(&runner, dir.path()); + + let resource = OpenRouterApi + .provision( + &stripe.as_dyn(), + &test_def(), + dir.path(), + "demo", + "res", + "local", + false, + ) + .await + .unwrap(); + assert_eq!(resource.resource_kind, "integration-openrouter"); + let payload: ResourcePayload = serde_json::from_str(&resource.payload).unwrap(); + assert_eq!(payload.outputs["api_key"], "val_api_key"); + } +} diff --git a/crates/stackless-integrations/src/providers/openrouter/mod.rs b/crates/stackless-integrations/src/providers/openrouter/mod.rs new file mode 100644 index 0000000..d33a531 --- /dev/null +++ b/crates/stackless-integrations/src/providers/openrouter/mod.rs @@ -0,0 +1,11 @@ +//! OpenRouter catalog resources via Stripe Projects. +//! +//! Output envelopes are provisional until pinned by `xtask discover`. + +pub mod api; + +#[allow(unused_imports)] +pub(crate) use crate::resource::{ + CatalogResource as FamilyResource, bool_optional, bool_required, int_optional, int_required, + integration_config, interp_optional, interp_required, +}; diff --git a/crates/stackless-integrations/src/providers/parallel/api.rs b/crates/stackless-integrations/src/providers/parallel/api.rs new file mode 100644 index 0000000..8b93eb4 --- /dev/null +++ b/crates/stackless-integrations/src/providers/parallel/api.rs @@ -0,0 +1,132 @@ +//! `parallel/api` integration. + +use std::collections::BTreeMap; + +use serde::Serialize; +use stackless_stripe_projects::catalog::verify::CatalogService; +use stackless_stripe_projects::provision::ProvisionContext; + +use super::FamilyResource; +use crate::error::IntegrationError; +use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; +use crate::registry; + +pub const RESOURCE_KIND: &str = "integration-parallel"; + +#[derive(Debug, Serialize)] +pub struct ParallelApiConfig {} + +impl CatalogService for ParallelApiConfig { + const REFERENCE: &'static str = "parallel/api"; +} + +#[derive(Debug)] +pub struct ParallelApi; + +impl Hostable for ParallelApi { + const PROVIDER: &'static str = "parallel"; + const HOSTING: IntegrationHosting = IntegrationHosting::Managed; + const CONFIG_SCOPE: ConfigScope = ConfigScope::GlobalOnly; + const RESOURCE_KIND: &'static str = RESOURCE_KIND; + const OUTPUTS: &'static [&'static str] = &["api_key"]; +} + +impl FamilyResource for ParallelApi { + type Config = ParallelApiConfig; + const PROVIDER_PREFIX: &'static str = "PARALLEL"; + // Provisional until pinned by `mise run discover parallel/api`. + const OUTPUT_FIELDS: &'static [(&'static str, &'static str, bool)] = + &[("API_KEY", "api_key", true)]; + + fn build_config(ctx: &ProvisionContext<'_>) -> Result { + let _ = super::integration_config(ctx)?; + Ok(ParallelApiConfig {}) + } +} + +pub fn validate_config( + name: &str, + config: &BTreeMap, +) -> Result<(), IntegrationError> { + let _ = (name, config); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ProviderOps; + use crate::resource::ResourcePayload; + use stackless_core::def::StackDef; + use stackless_stripe_projects::stripe::StripeProjects; + use stackless_stripe_projects::test_support; + + #[test] + fn config_matches_catalog() { + const FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../stackless-stripe-projects/tests/fixtures/catalog.json" + )); + let catalog = stackless_stripe_projects::Catalog::from_json_envelope(FIXTURE).unwrap(); + let failures = stackless_stripe_projects::verify_service(&catalog, &ParallelApiConfig {}); + assert!( + failures.is_empty(), + "parallel/api catalog gaps:\n{}", + failures.join("\n") + ); + } + + const CATALOG_ENVELOPE: &str = r##"{"ok":true,"command":"projects catalog","data":{"last_updated":"2026-07-11T00:00:00Z","services":[{"id":"prvsvc_api","object":"v2.provisioning.provider_service_detail","provider_id":"prvdr_parallel","provider_name":"Parallel","service_id":"api","categories":["database"],"kind":"deployable","scope":"project","availability":"available","development":false,"livemode":true,"pricing":{"type":"paid"},"configuration_schema":{"type":"object","required":[],"additionalProperties":false,"properties":{}}}]}}"##; + + fn test_def() -> StackDef { + StackDef::parse( + r#" +[stack] +name = "atto" +[stack.projects.stripe] +project = "project_1" +[integrations.res] +provider = "parallel" +[services.api] +source = { repo = "r", ref = "main" } +env = { OUT = "${integrations.res.api_key}" } +health = { path = "/health" } +[services.api.local] +run = "true" +"#, + ) + .unwrap() + } + + #[tokio::test] + async fn provision_records_outputs() { + let runner = test_support::provision_script( + CATALOG_ENVELOPE, + serde_json::json!({"PARALLEL_API_KEY": "val_api_key"}), + 0, + ); + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("stackless.toml"), + "[stack]\nname=\"atto\"\n", + ) + .unwrap(); + let stripe = StripeProjects::new(&runner, dir.path()); + + let resource = ParallelApi + .provision( + &stripe.as_dyn(), + &test_def(), + dir.path(), + "demo", + "res", + "local", + false, + ) + .await + .unwrap(); + assert_eq!(resource.resource_kind, "integration-parallel"); + let payload: ResourcePayload = serde_json::from_str(&resource.payload).unwrap(); + assert_eq!(payload.outputs["api_key"], "val_api_key"); + } +} diff --git a/crates/stackless-integrations/src/providers/parallel/mod.rs b/crates/stackless-integrations/src/providers/parallel/mod.rs new file mode 100644 index 0000000..542a98d --- /dev/null +++ b/crates/stackless-integrations/src/providers/parallel/mod.rs @@ -0,0 +1,11 @@ +//! Parallel catalog resources via Stripe Projects. +//! +//! Output envelopes are provisional until pinned by `xtask discover`. + +pub mod api; + +#[allow(unused_imports)] +pub(crate) use crate::resource::{ + CatalogResource as FamilyResource, bool_optional, bool_required, int_optional, int_required, + integration_config, interp_optional, interp_required, +}; diff --git a/crates/stackless-integrations/src/providers/planetscale/mod.rs b/crates/stackless-integrations/src/providers/planetscale/mod.rs new file mode 100644 index 0000000..d1888c1 --- /dev/null +++ b/crates/stackless-integrations/src/providers/planetscale/mod.rs @@ -0,0 +1,12 @@ +//! PlanetScale catalog resources via Stripe Projects. +//! +//! Output envelopes are provisional until pinned by `xtask discover`. + +pub mod mysql; +pub mod postgresql; + +#[allow(unused_imports)] +pub(crate) use crate::resource::{ + CatalogResource as FamilyResource, bool_optional, bool_required, int_optional, int_required, + integration_config, interp_optional, interp_required, +}; diff --git a/crates/stackless-integrations/src/providers/planetscale/mysql.rs b/crates/stackless-integrations/src/providers/planetscale/mysql.rs new file mode 100644 index 0000000..1a0d75e --- /dev/null +++ b/crates/stackless-integrations/src/providers/planetscale/mysql.rs @@ -0,0 +1,164 @@ +//! `planetscale/mysql` integration. + +use std::collections::BTreeMap; + +use serde::Serialize; +use stackless_stripe_projects::catalog::verify::CatalogService; +use stackless_stripe_projects::provision::ProvisionContext; + +use super::FamilyResource; +use crate::error::IntegrationError; +use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; +use crate::registry; + +pub const RESOURCE_KIND: &str = "integration-planetscale-mysql"; + +#[derive(Debug, Serialize)] +pub struct PlanetScaleMysqlConfig { + pub cluster: String, + pub name: String, + pub region: String, +} + +impl CatalogService for PlanetScaleMysqlConfig { + const REFERENCE: &'static str = "planetscale/mysql"; +} + +#[derive(Debug)] +pub struct PlanetScaleMysql; + +impl Hostable for PlanetScaleMysql { + const PROVIDER: &'static str = "planetscale-mysql"; + const HOSTING: IntegrationHosting = IntegrationHosting::Managed; + const CONFIG_SCOPE: ConfigScope = ConfigScope::GlobalOnly; + const RESOURCE_KIND: &'static str = RESOURCE_KIND; + const OUTPUTS: &'static [&'static str] = &["database_url"]; +} + +impl FamilyResource for PlanetScaleMysql { + type Config = PlanetScaleMysqlConfig; + const PROVIDER_PREFIX: &'static str = "PLANETSCALE"; + // Provisional until pinned by `mise run discover planetscale/mysql`. + const OUTPUT_FIELDS: &'static [(&'static str, &'static str, bool)] = + &[("DATABASE_URL", "database_url", true)]; + + fn build_config( + ctx: &ProvisionContext<'_>, + ) -> Result { + let config = super::integration_config(ctx)?; + Ok(PlanetScaleMysqlConfig { + cluster: super::interp_required(ctx, &config, "cluster")?, + name: super::interp_required(ctx, &config, "name")?, + region: super::interp_required(ctx, &config, "region")?, + }) + } +} + +pub fn validate_config( + name: &str, + config: &BTreeMap, +) -> Result<(), IntegrationError> { + registry::config_string(config, "cluster").map_err(|err| IntegrationError::ConfigInvalid { + location: format!("integrations.{name}.cluster"), + detail: err.to_string(), + })?; + registry::config_string(config, "name").map_err(|err| IntegrationError::ConfigInvalid { + location: format!("integrations.{name}.name"), + detail: err.to_string(), + })?; + registry::config_string(config, "region").map_err(|err| IntegrationError::ConfigInvalid { + location: format!("integrations.{name}.region"), + detail: err.to_string(), + })?; + let _ = (name, config); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ProviderOps; + use crate::resource::ResourcePayload; + use stackless_core::def::StackDef; + use stackless_stripe_projects::stripe::StripeProjects; + use stackless_stripe_projects::test_support; + + #[test] + fn config_matches_catalog() { + const FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../stackless-stripe-projects/tests/fixtures/catalog.json" + )); + let catalog = stackless_stripe_projects::Catalog::from_json_envelope(FIXTURE).unwrap(); + let failures = stackless_stripe_projects::verify_service( + &catalog, + &PlanetScaleMysqlConfig { + cluster: "PS-10".into(), + name: "test-name".into(), + region: "aws-ap-northeast".into(), + }, + ); + assert!( + failures.is_empty(), + "planetscale/mysql catalog gaps:\n{}", + failures.join("\n") + ); + } + + const CATALOG_ENVELOPE: &str = r##"{"ok":true,"command":"projects catalog","data":{"last_updated":"2026-07-11T00:00:00Z","services":[{"id":"prvsvc_mysql","object":"v2.provisioning.provider_service_detail","provider_id":"prvdr_planetscale","provider_name":"PlanetScale","service_id":"mysql","categories":["database"],"kind":"deployable","scope":"project","availability":"available","development":false,"livemode":true,"pricing":{"type":"paid"},"configuration_schema":{"properties":{"cluster":{"description":"Cluster size","enum":["PS-10","PS-20","PS-40","PS-80","PS-160","PS-320"],"type":"string"},"name":{"description":"Database name.","maxLength":63,"minLength":2,"pattern":"^[a-z0-9][a-z0-9_-]*[a-z0-9]$","type":"string"},"region":{"description":"Deployment region (prefixed with cloud provider, e.g., aws-us-east, gcp-us-central1)","enum":["aws-ap-northeast","aws-ap-south","aws-ap-southeast","aws-ap-southeast-2","aws-ca-central-1","aws-eu-central","aws-eu-west","aws-eu-west-2","aws-sa-east-1","aws-us-east","aws-us-east-2","aws-us-west","gcp-asia-northeast3","gcp-europe-west1","gcp-europe-west4","gcp-northamerica-northeast1","gcp-us-central1","gcp-us-east1","gcp-us-east4"],"type":"string"}},"required":["name","cluster","region"],"type":"object"}}]}}"##; + + fn test_def() -> StackDef { + StackDef::parse( + r#" +[stack] +name = "atto" +[stack.projects.stripe] +project = "project_1" +[integrations.res] +provider = "planetscale-mysql" +cluster = "PS-10" +name = "test-name" +region = "aws-ap-northeast" +[services.api] +source = { repo = "r", ref = "main" } +env = { OUT = "${integrations.res.database_url}" } +health = { path = "/health" } +[services.api.local] +run = "true" +"#, + ) + .unwrap() + } + + #[tokio::test] + async fn provision_records_outputs() { + let runner = test_support::provision_script( + CATALOG_ENVELOPE, + serde_json::json!({"PLANETSCALE_DATABASE_URL": "val_database_url"}), + 0, + ); + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("stackless.toml"), + "[stack]\nname=\"atto\"\n", + ) + .unwrap(); + let stripe = StripeProjects::new(&runner, dir.path()); + + let resource = PlanetScaleMysql + .provision( + &stripe.as_dyn(), + &test_def(), + dir.path(), + "demo", + "res", + "local", + false, + ) + .await + .unwrap(); + assert_eq!(resource.resource_kind, "integration-planetscale-mysql"); + let payload: ResourcePayload = serde_json::from_str(&resource.payload).unwrap(); + assert_eq!(payload.outputs["database_url"], "val_database_url"); + } +} diff --git a/crates/stackless-integrations/src/providers/planetscale/postgresql.rs b/crates/stackless-integrations/src/providers/planetscale/postgresql.rs new file mode 100644 index 0000000..46199f9 --- /dev/null +++ b/crates/stackless-integrations/src/providers/planetscale/postgresql.rs @@ -0,0 +1,168 @@ +//! `planetscale/postgresql` integration. + +use std::collections::BTreeMap; + +use serde::Serialize; +use stackless_stripe_projects::catalog::verify::CatalogService; +use stackless_stripe_projects::provision::ProvisionContext; + +use super::FamilyResource; +use crate::error::IntegrationError; +use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; +use crate::registry; + +pub const RESOURCE_KIND: &str = "integration-planetscale-postgresql"; + +#[derive(Debug, Serialize)] +pub struct PlanetScalePostgresqlConfig { + pub cluster: String, + pub name: String, + pub region: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub replicas: Option, +} + +impl CatalogService for PlanetScalePostgresqlConfig { + const REFERENCE: &'static str = "planetscale/postgresql"; +} + +#[derive(Debug)] +pub struct PlanetScalePostgresql; + +impl Hostable for PlanetScalePostgresql { + const PROVIDER: &'static str = "planetscale-postgresql"; + const HOSTING: IntegrationHosting = IntegrationHosting::Managed; + const CONFIG_SCOPE: ConfigScope = ConfigScope::GlobalOnly; + const RESOURCE_KIND: &'static str = RESOURCE_KIND; + const OUTPUTS: &'static [&'static str] = &["database_url"]; +} + +impl FamilyResource for PlanetScalePostgresql { + type Config = PlanetScalePostgresqlConfig; + const PROVIDER_PREFIX: &'static str = "PLANETSCALE"; + // Provisional until pinned by `mise run discover planetscale/postgresql`. + const OUTPUT_FIELDS: &'static [(&'static str, &'static str, bool)] = + &[("DATABASE_URL", "database_url", true)]; + + fn build_config( + ctx: &ProvisionContext<'_>, + ) -> Result { + let config = super::integration_config(ctx)?; + Ok(PlanetScalePostgresqlConfig { + cluster: super::interp_required(ctx, &config, "cluster")?, + name: super::interp_required(ctx, &config, "name")?, + region: super::interp_required(ctx, &config, "region")?, + replicas: super::int_optional(ctx, &config, "replicas")?, + }) + } +} + +pub fn validate_config( + name: &str, + config: &BTreeMap, +) -> Result<(), IntegrationError> { + registry::config_string(config, "cluster").map_err(|err| IntegrationError::ConfigInvalid { + location: format!("integrations.{name}.cluster"), + detail: err.to_string(), + })?; + registry::config_string(config, "name").map_err(|err| IntegrationError::ConfigInvalid { + location: format!("integrations.{name}.name"), + detail: err.to_string(), + })?; + registry::config_string(config, "region").map_err(|err| IntegrationError::ConfigInvalid { + location: format!("integrations.{name}.region"), + detail: err.to_string(), + })?; + let _ = (name, config); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ProviderOps; + use crate::resource::ResourcePayload; + use stackless_core::def::StackDef; + use stackless_stripe_projects::stripe::StripeProjects; + use stackless_stripe_projects::test_support; + + #[test] + fn config_matches_catalog() { + const FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../stackless-stripe-projects/tests/fixtures/catalog.json" + )); + let catalog = stackless_stripe_projects::Catalog::from_json_envelope(FIXTURE).unwrap(); + let failures = stackless_stripe_projects::verify_service( + &catalog, + &PlanetScalePostgresqlConfig { + cluster: "PS-5".into(), + name: "test-name".into(), + region: "aws-ap-northeast".into(), + replicas: None, + }, + ); + assert!( + failures.is_empty(), + "planetscale/postgresql catalog gaps:\n{}", + failures.join("\n") + ); + } + + const CATALOG_ENVELOPE: &str = r##"{"ok":true,"command":"projects catalog","data":{"last_updated":"2026-07-11T00:00:00Z","services":[{"id":"prvsvc_postgresql","object":"v2.provisioning.provider_service_detail","provider_id":"prvdr_planetscale","provider_name":"PlanetScale","service_id":"postgresql","categories":["database"],"kind":"deployable","scope":"project","availability":"available","development":false,"livemode":true,"pricing":{"type":"paid"},"configuration_schema":{"properties":{"cluster":{"description":"Cluster size","enum":["PS-5","PS-10","PS-20","PS-40","PS-80","PS-160","PS-320"],"type":"string"},"name":{"description":"Database name.","maxLength":63,"minLength":2,"pattern":"^[a-z0-9][a-z0-9_-]*[a-z0-9]$","type":"string"},"region":{"description":"Deployment region (prefixed with cloud provider, e.g., aws-us-east, gcp-us-central1)","enum":["aws-ap-northeast","aws-ap-south","aws-ap-southeast","aws-ap-southeast-2","aws-ca-central-1","aws-eu-central","aws-eu-west","aws-eu-west-2","aws-sa-east-1","aws-us-east","aws-us-east-2","aws-us-west","gcp-asia-northeast3","gcp-europe-west1","gcp-europe-west4","gcp-northamerica-northeast1","gcp-us-central1","gcp-us-east1","gcp-us-east4"],"type":"string"},"replicas":{"default":0,"description":"Number of replicas (0 for single node, 2+ for high availability)","maximum":9,"minimum":0,"type":"integer"}},"required":["name","cluster","region"],"type":"object"}}]}}"##; + + fn test_def() -> StackDef { + StackDef::parse( + r#" +[stack] +name = "atto" +[stack.projects.stripe] +project = "project_1" +[integrations.res] +provider = "planetscale-postgresql" +cluster = "PS-5" +name = "test-name" +region = "aws-ap-northeast" +[services.api] +source = { repo = "r", ref = "main" } +env = { OUT = "${integrations.res.database_url}" } +health = { path = "/health" } +[services.api.local] +run = "true" +"#, + ) + .unwrap() + } + + #[tokio::test] + async fn provision_records_outputs() { + let runner = test_support::provision_script( + CATALOG_ENVELOPE, + serde_json::json!({"PLANETSCALE_DATABASE_URL": "val_database_url"}), + 0, + ); + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("stackless.toml"), + "[stack]\nname=\"atto\"\n", + ) + .unwrap(); + let stripe = StripeProjects::new(&runner, dir.path()); + + let resource = PlanetScalePostgresql + .provision( + &stripe.as_dyn(), + &test_def(), + dir.path(), + "demo", + "res", + "local", + false, + ) + .await + .unwrap(); + assert_eq!(resource.resource_kind, "integration-planetscale-postgresql"); + let payload: ResourcePayload = serde_json::from_str(&resource.payload).unwrap(); + assert_eq!(payload.outputs["database_url"], "val_database_url"); + } +} diff --git a/crates/stackless-integrations/src/providers/postalform/mail.rs b/crates/stackless-integrations/src/providers/postalform/mail.rs new file mode 100644 index 0000000..825bbb3 --- /dev/null +++ b/crates/stackless-integrations/src/providers/postalform/mail.rs @@ -0,0 +1,146 @@ +//! `postalform/mail` integration. + +use std::collections::BTreeMap; + +use serde::Serialize; +use stackless_stripe_projects::catalog::verify::CatalogService; +use stackless_stripe_projects::provision::ProvisionContext; + +use super::FamilyResource; +use crate::error::IntegrationError; +use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; +use crate::registry; + +pub const RESOURCE_KIND: &str = "integration-postalform"; + +#[derive(Debug, Serialize)] +pub struct PostalFormMailConfig { + #[serde(skip_serializing_if = "Option::is_none")] + pub webhook_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub workspace_name: Option, +} + +impl CatalogService for PostalFormMailConfig { + const REFERENCE: &'static str = "postalform/mail"; +} + +#[derive(Debug)] +pub struct PostalFormMail; + +impl Hostable for PostalFormMail { + const PROVIDER: &'static str = "postalform"; + const HOSTING: IntegrationHosting = IntegrationHosting::Managed; + const CONFIG_SCOPE: ConfigScope = ConfigScope::GlobalOnly; + const RESOURCE_KIND: &'static str = RESOURCE_KIND; + const OUTPUTS: &'static [&'static str] = &["api_key"]; +} + +impl FamilyResource for PostalFormMail { + type Config = PostalFormMailConfig; + const PROVIDER_PREFIX: &'static str = "POSTALFORM"; + // Provisional until pinned by `mise run discover postalform/mail`. + const OUTPUT_FIELDS: &'static [(&'static str, &'static str, bool)] = + &[("API_KEY", "api_key", true)]; + + fn build_config(ctx: &ProvisionContext<'_>) -> Result { + let config = super::integration_config(ctx)?; + Ok(PostalFormMailConfig { + webhook_url: super::interp_optional(ctx, &config, "webhook_url")?, + workspace_name: super::interp_optional(ctx, &config, "workspace_name")?, + }) + } +} + +pub fn validate_config( + name: &str, + config: &BTreeMap, +) -> Result<(), IntegrationError> { + let _ = (name, config); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ProviderOps; + use crate::resource::ResourcePayload; + use stackless_core::def::StackDef; + use stackless_stripe_projects::stripe::StripeProjects; + use stackless_stripe_projects::test_support; + + #[test] + fn config_matches_catalog() { + const FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../stackless-stripe-projects/tests/fixtures/catalog.json" + )); + let catalog = stackless_stripe_projects::Catalog::from_json_envelope(FIXTURE).unwrap(); + let failures = stackless_stripe_projects::verify_service( + &catalog, + &PostalFormMailConfig { + webhook_url: None, + workspace_name: None, + }, + ); + assert!( + failures.is_empty(), + "postalform/mail catalog gaps:\n{}", + failures.join("\n") + ); + } + + const CATALOG_ENVELOPE: &str = r##"{"ok":true,"command":"projects catalog","data":{"last_updated":"2026-07-11T00:00:00Z","services":[{"id":"prvsvc_mail","object":"v2.provisioning.provider_service_detail","provider_id":"prvdr_postalform","provider_name":"PostalForm","service_id":"mail","categories":["database"],"kind":"deployable","scope":"project","availability":"available","development":false,"livemode":true,"pricing":{"type":"paid"},"configuration_schema":{"additionalProperties":true,"properties":{"webhook_url":{"description":"Optional HTTPS endpoint that receives signed PostalForm status webhooks. If supplied, PostalForm creates or reuses a webhook endpoint and returns its signing secret in the access configuration.","pattern":"^https://","type":"string"},"workspace_name":{"description":"Optional display name for the PostalForm Projects workspace.","type":"string"}},"type":"object"}}]}}"##; + + fn test_def() -> StackDef { + StackDef::parse( + r#" +[stack] +name = "atto" +[stack.projects.stripe] +project = "project_1" +[integrations.res] +provider = "postalform" +[services.api] +source = { repo = "r", ref = "main" } +env = { OUT = "${integrations.res.api_key}" } +health = { path = "/health" } +[services.api.local] +run = "true" +"#, + ) + .unwrap() + } + + #[tokio::test] + async fn provision_records_outputs() { + let runner = test_support::provision_script( + CATALOG_ENVELOPE, + serde_json::json!({"POSTALFORM_API_KEY": "val_api_key"}), + 0, + ); + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("stackless.toml"), + "[stack]\nname=\"atto\"\n", + ) + .unwrap(); + let stripe = StripeProjects::new(&runner, dir.path()); + + let resource = PostalFormMail + .provision( + &stripe.as_dyn(), + &test_def(), + dir.path(), + "demo", + "res", + "local", + false, + ) + .await + .unwrap(); + assert_eq!(resource.resource_kind, "integration-postalform"); + let payload: ResourcePayload = serde_json::from_str(&resource.payload).unwrap(); + assert_eq!(payload.outputs["api_key"], "val_api_key"); + } +} diff --git a/crates/stackless-integrations/src/providers/postalform/mod.rs b/crates/stackless-integrations/src/providers/postalform/mod.rs new file mode 100644 index 0000000..4d7ac68 --- /dev/null +++ b/crates/stackless-integrations/src/providers/postalform/mod.rs @@ -0,0 +1,11 @@ +//! PostalForm catalog resources via Stripe Projects. +//! +//! Output envelopes are provisional until pinned by `xtask discover`. + +pub mod mail; + +#[allow(unused_imports)] +pub(crate) use crate::resource::{ + CatalogResource as FamilyResource, bool_optional, bool_required, int_optional, int_required, + integration_config, interp_optional, interp_required, +}; diff --git a/crates/stackless-integrations/src/providers/posthog/analytics.rs b/crates/stackless-integrations/src/providers/posthog/analytics.rs new file mode 100644 index 0000000..a573bfb --- /dev/null +++ b/crates/stackless-integrations/src/providers/posthog/analytics.rs @@ -0,0 +1,135 @@ +//! `posthog/analytics` integration. + +use std::collections::BTreeMap; + +use serde::Serialize; +use stackless_stripe_projects::catalog::verify::CatalogService; +use stackless_stripe_projects::provision::ProvisionContext; + +use super::FamilyResource; +use crate::error::IntegrationError; +use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; +use crate::registry; + +pub const RESOURCE_KIND: &str = "integration-posthog"; + +#[derive(Debug, Serialize)] +pub struct PostHogAnalyticsConfig {} + +impl CatalogService for PostHogAnalyticsConfig { + const REFERENCE: &'static str = "posthog/analytics"; +} + +#[derive(Debug)] +pub struct PostHogAnalytics; + +impl Hostable for PostHogAnalytics { + const PROVIDER: &'static str = "posthog"; + const HOSTING: IntegrationHosting = IntegrationHosting::Managed; + const CONFIG_SCOPE: ConfigScope = ConfigScope::GlobalOnly; + const RESOURCE_KIND: &'static str = RESOURCE_KIND; + const OUTPUTS: &'static [&'static str] = &["api_key"]; +} + +impl FamilyResource for PostHogAnalytics { + type Config = PostHogAnalyticsConfig; + const PROVIDER_PREFIX: &'static str = "POSTHOG"; + // Provisional until pinned by `mise run discover posthog/analytics`. + const OUTPUT_FIELDS: &'static [(&'static str, &'static str, bool)] = + &[("API_KEY", "api_key", true)]; + + fn build_config( + ctx: &ProvisionContext<'_>, + ) -> Result { + let _ = super::integration_config(ctx)?; + Ok(PostHogAnalyticsConfig {}) + } +} + +pub fn validate_config( + name: &str, + config: &BTreeMap, +) -> Result<(), IntegrationError> { + let _ = (name, config); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ProviderOps; + use crate::resource::ResourcePayload; + use stackless_core::def::StackDef; + use stackless_stripe_projects::stripe::StripeProjects; + use stackless_stripe_projects::test_support; + + #[test] + fn config_matches_catalog() { + const FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../stackless-stripe-projects/tests/fixtures/catalog.json" + )); + let catalog = stackless_stripe_projects::Catalog::from_json_envelope(FIXTURE).unwrap(); + let failures = + stackless_stripe_projects::verify_service(&catalog, &PostHogAnalyticsConfig {}); + assert!( + failures.is_empty(), + "posthog/analytics catalog gaps:\n{}", + failures.join("\n") + ); + } + + const CATALOG_ENVELOPE: &str = r##"{"ok":true,"command":"projects catalog","data":{"last_updated":"2026-07-11T00:00:00Z","services":[{"id":"prvsvc_analytics","object":"v2.provisioning.provider_service_detail","provider_id":"prvdr_posthog","provider_name":"PostHog","service_id":"analytics","categories":["database"],"kind":"deployable","scope":"project","availability":"available","development":false,"livemode":true,"pricing":{"type":"component"},"configuration_schema":{"type":"object","required":[],"additionalProperties":false,"properties":{}}}]}}"##; + + fn test_def() -> StackDef { + StackDef::parse( + r#" +[stack] +name = "atto" +[stack.projects.stripe] +project = "project_1" +[integrations.res] +provider = "posthog" +[services.api] +source = { repo = "r", ref = "main" } +env = { OUT = "${integrations.res.api_key}" } +health = { path = "/health" } +[services.api.local] +run = "true" +"#, + ) + .unwrap() + } + + #[tokio::test] + async fn provision_records_outputs() { + let runner = test_support::provision_script( + CATALOG_ENVELOPE, + serde_json::json!({"POSTHOG_API_KEY": "val_api_key"}), + 0, + ); + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("stackless.toml"), + "[stack]\nname=\"atto\"\n", + ) + .unwrap(); + let stripe = StripeProjects::new(&runner, dir.path()); + + let resource = PostHogAnalytics + .provision( + &stripe.as_dyn(), + &test_def(), + dir.path(), + "demo", + "res", + "local", + false, + ) + .await + .unwrap(); + assert_eq!(resource.resource_kind, "integration-posthog"); + let payload: ResourcePayload = serde_json::from_str(&resource.payload).unwrap(); + assert_eq!(payload.outputs["api_key"], "val_api_key"); + } +} diff --git a/crates/stackless-integrations/src/providers/posthog/mod.rs b/crates/stackless-integrations/src/providers/posthog/mod.rs new file mode 100644 index 0000000..2645668 --- /dev/null +++ b/crates/stackless-integrations/src/providers/posthog/mod.rs @@ -0,0 +1,11 @@ +//! PostHog catalog resources via Stripe Projects. +//! +//! Output envelopes are provisional until pinned by `xtask discover`. + +pub mod analytics; + +#[allow(unused_imports)] +pub(crate) use crate::resource::{ + CatalogResource as FamilyResource, bool_optional, bool_required, int_optional, int_required, + integration_config, interp_optional, interp_required, +}; diff --git a/crates/stackless-integrations/src/providers/prisma/database.rs b/crates/stackless-integrations/src/providers/prisma/database.rs new file mode 100644 index 0000000..88964b1 --- /dev/null +++ b/crates/stackless-integrations/src/providers/prisma/database.rs @@ -0,0 +1,146 @@ +//! `prisma/database` integration. + +use std::collections::BTreeMap; + +use serde::Serialize; +use stackless_stripe_projects::catalog::verify::CatalogService; +use stackless_stripe_projects::provision::ProvisionContext; + +use super::FamilyResource; +use crate::error::IntegrationError; +use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; +use crate::registry; + +pub const RESOURCE_KIND: &str = "integration-prisma"; + +#[derive(Debug, Serialize)] +pub struct PrismaDatabaseConfig { + pub region: String, +} + +impl CatalogService for PrismaDatabaseConfig { + const REFERENCE: &'static str = "prisma/database"; +} + +#[derive(Debug)] +pub struct PrismaDatabase; + +impl Hostable for PrismaDatabase { + const PROVIDER: &'static str = "prisma"; + const HOSTING: IntegrationHosting = IntegrationHosting::Managed; + const CONFIG_SCOPE: ConfigScope = ConfigScope::GlobalOnly; + const RESOURCE_KIND: &'static str = RESOURCE_KIND; + const OUTPUTS: &'static [&'static str] = &["database_url"]; +} + +impl FamilyResource for PrismaDatabase { + type Config = PrismaDatabaseConfig; + const PROVIDER_PREFIX: &'static str = "PRISMA"; + // Provisional until pinned by `mise run discover prisma/database`. + const OUTPUT_FIELDS: &'static [(&'static str, &'static str, bool)] = + &[("DATABASE_URL", "database_url", true)]; + + fn build_config(ctx: &ProvisionContext<'_>) -> Result { + let config = super::integration_config(ctx)?; + Ok(PrismaDatabaseConfig { + region: super::interp_required(ctx, &config, "region")?, + }) + } +} + +pub fn validate_config( + name: &str, + config: &BTreeMap, +) -> Result<(), IntegrationError> { + registry::config_string(config, "region").map_err(|err| IntegrationError::ConfigInvalid { + location: format!("integrations.{name}.region"), + detail: err.to_string(), + })?; + let _ = (name, config); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ProviderOps; + use crate::resource::ResourcePayload; + use stackless_core::def::StackDef; + use stackless_stripe_projects::stripe::StripeProjects; + use stackless_stripe_projects::test_support; + + #[test] + fn config_matches_catalog() { + const FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../stackless-stripe-projects/tests/fixtures/catalog.json" + )); + let catalog = stackless_stripe_projects::Catalog::from_json_envelope(FIXTURE).unwrap(); + let failures = stackless_stripe_projects::verify_service( + &catalog, + &PrismaDatabaseConfig { + region: "us-east-1".into(), + }, + ); + assert!( + failures.is_empty(), + "prisma/database catalog gaps:\n{}", + failures.join("\n") + ); + } + + const CATALOG_ENVELOPE: &str = r##"{"ok":true,"command":"projects catalog","data":{"last_updated":"2026-07-11T00:00:00Z","services":[{"id":"prvsvc_database","object":"v2.provisioning.provider_service_detail","provider_id":"prvdr_prisma","provider_name":"Prisma","service_id":"database","categories":["database"],"kind":"deployable","scope":"project","availability":"available","development":false,"livemode":true,"pricing":{"type":"component"},"configuration_schema":{"additionalProperties":false,"properties":{"region":{"default":"us-east-1","description":"Prisma Postgres region identifier (Defaults to \"us-east-1\").","enum":["us-east-1","us-west-1","eu-west-3","eu-central-1","ap-northeast-1","ap-southeast-1"],"type":"string"}},"required":["region"],"type":"object"}}]}}"##; + + fn test_def() -> StackDef { + StackDef::parse( + r#" +[stack] +name = "atto" +[stack.projects.stripe] +project = "project_1" +[integrations.res] +provider = "prisma" +region = "us-east-1" +[services.api] +source = { repo = "r", ref = "main" } +env = { OUT = "${integrations.res.database_url}" } +health = { path = "/health" } +[services.api.local] +run = "true" +"#, + ) + .unwrap() + } + + #[tokio::test] + async fn provision_records_outputs() { + let runner = test_support::provision_script( + CATALOG_ENVELOPE, + serde_json::json!({"PRISMA_DATABASE_URL": "val_database_url"}), + 0, + ); + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("stackless.toml"), + "[stack]\nname=\"atto\"\n", + ) + .unwrap(); + let stripe = StripeProjects::new(&runner, dir.path()); + + let resource = PrismaDatabase + .provision( + &stripe.as_dyn(), + &test_def(), + dir.path(), + "demo", + "res", + "local", + false, + ) + .await + .unwrap(); + assert_eq!(resource.resource_kind, "integration-prisma"); + let payload: ResourcePayload = serde_json::from_str(&resource.payload).unwrap(); + assert_eq!(payload.outputs["database_url"], "val_database_url"); + } +} diff --git a/crates/stackless-integrations/src/providers/prisma/mod.rs b/crates/stackless-integrations/src/providers/prisma/mod.rs new file mode 100644 index 0000000..aeb1182 --- /dev/null +++ b/crates/stackless-integrations/src/providers/prisma/mod.rs @@ -0,0 +1,11 @@ +//! Prisma catalog resources via Stripe Projects. +//! +//! Output envelopes are provisional until pinned by `xtask discover`. + +pub mod database; + +#[allow(unused_imports)] +pub(crate) use crate::resource::{ + CatalogResource as FamilyResource, bool_optional, bool_required, int_optional, int_required, + integration_config, interp_optional, interp_required, +}; diff --git a/crates/stackless-integrations/src/providers/privy/app.rs b/crates/stackless-integrations/src/providers/privy/app.rs new file mode 100644 index 0000000..dfcc2d9 --- /dev/null +++ b/crates/stackless-integrations/src/providers/privy/app.rs @@ -0,0 +1,148 @@ +//! `privy/app` integration. + +use std::collections::BTreeMap; + +use serde::Serialize; +use stackless_stripe_projects::catalog::verify::CatalogService; +use stackless_stripe_projects::provision::ProvisionContext; + +use super::FamilyResource; +use crate::error::IntegrationError; +use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; +use crate::registry; + +pub const RESOURCE_KIND: &str = "integration-privy"; + +#[derive(Debug, Serialize)] +pub struct PrivyAppConfig { + pub app_name: String, +} + +impl CatalogService for PrivyAppConfig { + const REFERENCE: &'static str = "privy/app"; +} + +#[derive(Debug)] +pub struct PrivyApp; + +impl Hostable for PrivyApp { + const PROVIDER: &'static str = "privy"; + const HOSTING: IntegrationHosting = IntegrationHosting::Managed; + const CONFIG_SCOPE: ConfigScope = ConfigScope::GlobalOnly; + const RESOURCE_KIND: &'static str = RESOURCE_KIND; + const OUTPUTS: &'static [&'static str] = &["app_id", "app_secret"]; +} + +impl FamilyResource for PrivyApp { + type Config = PrivyAppConfig; + const PROVIDER_PREFIX: &'static str = "PRIVY"; + // Provisional until pinned by `mise run discover privy/app`. + const OUTPUT_FIELDS: &'static [(&'static str, &'static str, bool)] = &[ + ("APP_ID", "app_id", true), + ("APP_SECRET", "app_secret", true), + ]; + + fn build_config(ctx: &ProvisionContext<'_>) -> Result { + let config = super::integration_config(ctx)?; + Ok(PrivyAppConfig { + app_name: super::interp_required(ctx, &config, "app_name")?, + }) + } +} + +pub fn validate_config( + name: &str, + config: &BTreeMap, +) -> Result<(), IntegrationError> { + registry::config_string(config, "app_name").map_err(|err| IntegrationError::ConfigInvalid { + location: format!("integrations.{name}.app_name"), + detail: err.to_string(), + })?; + let _ = (name, config); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ProviderOps; + use crate::resource::ResourcePayload; + use stackless_core::def::StackDef; + use stackless_stripe_projects::stripe::StripeProjects; + use stackless_stripe_projects::test_support; + + #[test] + fn config_matches_catalog() { + const FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../stackless-stripe-projects/tests/fixtures/catalog.json" + )); + let catalog = stackless_stripe_projects::Catalog::from_json_envelope(FIXTURE).unwrap(); + let failures = stackless_stripe_projects::verify_service( + &catalog, + &PrivyAppConfig { + app_name: "test-app_name".into(), + }, + ); + assert!( + failures.is_empty(), + "privy/app catalog gaps:\n{}", + failures.join("\n") + ); + } + + const CATALOG_ENVELOPE: &str = r##"{"ok":true,"command":"projects catalog","data":{"last_updated":"2026-07-11T00:00:00Z","services":[{"id":"prvsvc_app","object":"v2.provisioning.provider_service_detail","provider_id":"prvdr_privy","provider_name":"Privy","service_id":"app","categories":["database"],"kind":"deployable","scope":"project","availability":"available","development":false,"livemode":true,"pricing":{"type":"component"},"configuration_schema":{"properties":{"app_name":{"description":"Display name for the app","type":"string"}},"required":["app_name"],"type":"object"}}]}}"##; + + fn test_def() -> StackDef { + StackDef::parse( + r#" +[stack] +name = "atto" +[stack.projects.stripe] +project = "project_1" +[integrations.res] +provider = "privy" +app_name = "test-app_name" +[services.api] +source = { repo = "r", ref = "main" } +env = { OUT = "${integrations.res.app_id}" } +health = { path = "/health" } +[services.api.local] +run = "true" +"#, + ) + .unwrap() + } + + #[tokio::test] + async fn provision_records_outputs() { + let runner = test_support::provision_script( + CATALOG_ENVELOPE, + serde_json::json!({"PRIVY_APP_ID": "val_app_id", "PRIVY_APP_SECRET": "val_app_secret"}), + 0, + ); + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("stackless.toml"), + "[stack]\nname=\"atto\"\n", + ) + .unwrap(); + let stripe = StripeProjects::new(&runner, dir.path()); + + let resource = PrivyApp + .provision( + &stripe.as_dyn(), + &test_def(), + dir.path(), + "demo", + "res", + "local", + false, + ) + .await + .unwrap(); + assert_eq!(resource.resource_kind, "integration-privy"); + let payload: ResourcePayload = serde_json::from_str(&resource.payload).unwrap(); + assert_eq!(payload.outputs["app_id"], "val_app_id"); + } +} diff --git a/crates/stackless-integrations/src/providers/privy/mod.rs b/crates/stackless-integrations/src/providers/privy/mod.rs new file mode 100644 index 0000000..7ce9b52 --- /dev/null +++ b/crates/stackless-integrations/src/providers/privy/mod.rs @@ -0,0 +1,11 @@ +//! Privy catalog resources via Stripe Projects. +//! +//! Output envelopes are provisional until pinned by `xtask discover`. + +pub mod app; + +#[allow(unused_imports)] +pub(crate) use crate::resource::{ + CatalogResource as FamilyResource, bool_optional, bool_required, int_optional, int_required, + integration_config, interp_optional, interp_required, +}; diff --git a/crates/stackless-integrations/src/providers/railway/bucket.rs b/crates/stackless-integrations/src/providers/railway/bucket.rs new file mode 100644 index 0000000..c428ea8 --- /dev/null +++ b/crates/stackless-integrations/src/providers/railway/bucket.rs @@ -0,0 +1,146 @@ +//! `railway/bucket` integration. + +use std::collections::BTreeMap; + +use serde::Serialize; +use stackless_stripe_projects::catalog::verify::CatalogService; +use stackless_stripe_projects::provision::ProvisionContext; + +use super::FamilyResource; +use crate::error::IntegrationError; +use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; +use crate::registry; + +pub const RESOURCE_KIND: &str = "integration-railway-bucket"; + +#[derive(Debug, Serialize)] +pub struct RailwayBucketConfig { + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub region: Option, +} + +impl CatalogService for RailwayBucketConfig { + const REFERENCE: &'static str = "railway/bucket"; +} + +#[derive(Debug)] +pub struct RailwayBucket; + +impl Hostable for RailwayBucket { + const PROVIDER: &'static str = "railway-bucket"; + const HOSTING: IntegrationHosting = IntegrationHosting::Managed; + const CONFIG_SCOPE: ConfigScope = ConfigScope::GlobalOnly; + const RESOURCE_KIND: &'static str = RESOURCE_KIND; + const OUTPUTS: &'static [&'static str] = &["bucket"]; +} + +impl FamilyResource for RailwayBucket { + type Config = RailwayBucketConfig; + const PROVIDER_PREFIX: &'static str = "RAILWAY"; + // Provisional until pinned by `mise run discover railway/bucket`. + const OUTPUT_FIELDS: &'static [(&'static str, &'static str, bool)] = + &[("BUCKET", "bucket", true)]; + + fn build_config(ctx: &ProvisionContext<'_>) -> Result { + let config = super::integration_config(ctx)?; + Ok(RailwayBucketConfig { + name: super::interp_optional(ctx, &config, "name")?, + region: super::interp_optional(ctx, &config, "region")?, + }) + } +} + +pub fn validate_config( + name: &str, + config: &BTreeMap, +) -> Result<(), IntegrationError> { + let _ = (name, config); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ProviderOps; + use crate::resource::ResourcePayload; + use stackless_core::def::StackDef; + use stackless_stripe_projects::stripe::StripeProjects; + use stackless_stripe_projects::test_support; + + #[test] + fn config_matches_catalog() { + const FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../stackless-stripe-projects/tests/fixtures/catalog.json" + )); + let catalog = stackless_stripe_projects::Catalog::from_json_envelope(FIXTURE).unwrap(); + let failures = stackless_stripe_projects::verify_service( + &catalog, + &RailwayBucketConfig { + name: None, + region: None, + }, + ); + assert!( + failures.is_empty(), + "railway/bucket catalog gaps:\n{}", + failures.join("\n") + ); + } + + const CATALOG_ENVELOPE: &str = r##"{"ok":true,"command":"projects catalog","data":{"last_updated":"2026-07-11T00:00:00Z","services":[{"id":"prvsvc_bucket","object":"v2.provisioning.provider_service_detail","provider_id":"prvdr_railway","provider_name":"Railway","service_id":"bucket","categories":["database"],"kind":"deployable","scope":"project","availability":"available","development":false,"livemode":true,"pricing":{"type":"component"},"configuration_schema":{"properties":{"name":{"description":"Bucket name (auto-generated if omitted).","type":"string"},"region":{"description":"Storage region (e.g. sjc, iad, ams, sin).","type":"string"}},"type":"object"}}]}}"##; + + fn test_def() -> StackDef { + StackDef::parse( + r#" +[stack] +name = "atto" +[stack.projects.stripe] +project = "project_1" +[integrations.res] +provider = "railway-bucket" +[services.api] +source = { repo = "r", ref = "main" } +env = { OUT = "${integrations.res.bucket}" } +health = { path = "/health" } +[services.api.local] +run = "true" +"#, + ) + .unwrap() + } + + #[tokio::test] + async fn provision_records_outputs() { + let runner = test_support::provision_script( + CATALOG_ENVELOPE, + serde_json::json!({"RAILWAY_BUCKET": "val_bucket"}), + 0, + ); + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("stackless.toml"), + "[stack]\nname=\"atto\"\n", + ) + .unwrap(); + let stripe = StripeProjects::new(&runner, dir.path()); + + let resource = RailwayBucket + .provision( + &stripe.as_dyn(), + &test_def(), + dir.path(), + "demo", + "res", + "local", + false, + ) + .await + .unwrap(); + assert_eq!(resource.resource_kind, "integration-railway-bucket"); + let payload: ResourcePayload = serde_json::from_str(&resource.payload).unwrap(); + assert_eq!(payload.outputs["bucket"], "val_bucket"); + } +} diff --git a/crates/stackless-integrations/src/providers/railway/hosting.rs b/crates/stackless-integrations/src/providers/railway/hosting.rs new file mode 100644 index 0000000..e0717be --- /dev/null +++ b/crates/stackless-integrations/src/providers/railway/hosting.rs @@ -0,0 +1,133 @@ +//! `railway/hosting` integration. + +use std::collections::BTreeMap; + +use serde::Serialize; +use stackless_stripe_projects::catalog::verify::CatalogService; +use stackless_stripe_projects::provision::ProvisionContext; + +use super::FamilyResource; +use crate::error::IntegrationError; +use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; +use crate::registry; + +pub const RESOURCE_KIND: &str = "integration-railway-hosting"; + +#[derive(Debug, Serialize)] +pub struct RailwayHostingConfig {} + +impl CatalogService for RailwayHostingConfig { + const REFERENCE: &'static str = "railway/hosting"; +} + +#[derive(Debug)] +pub struct RailwayHosting; + +impl Hostable for RailwayHosting { + const PROVIDER: &'static str = "railway-hosting"; + const HOSTING: IntegrationHosting = IntegrationHosting::Managed; + const CONFIG_SCOPE: ConfigScope = ConfigScope::GlobalOnly; + const RESOURCE_KIND: &'static str = RESOURCE_KIND; + const OUTPUTS: &'static [&'static str] = &["project_id"]; +} + +impl FamilyResource for RailwayHosting { + type Config = RailwayHostingConfig; + const PROVIDER_PREFIX: &'static str = "RAILWAY"; + // Provisional until pinned by `mise run discover railway/hosting`. + const OUTPUT_FIELDS: &'static [(&'static str, &'static str, bool)] = + &[("PROJECT_ID", "project_id", true)]; + + fn build_config(ctx: &ProvisionContext<'_>) -> Result { + let _ = super::integration_config(ctx)?; + Ok(RailwayHostingConfig {}) + } +} + +pub fn validate_config( + name: &str, + config: &BTreeMap, +) -> Result<(), IntegrationError> { + let _ = (name, config); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ProviderOps; + use crate::resource::ResourcePayload; + use stackless_core::def::StackDef; + use stackless_stripe_projects::stripe::StripeProjects; + use stackless_stripe_projects::test_support; + + #[test] + fn config_matches_catalog() { + const FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../stackless-stripe-projects/tests/fixtures/catalog.json" + )); + let catalog = stackless_stripe_projects::Catalog::from_json_envelope(FIXTURE).unwrap(); + let failures = + stackless_stripe_projects::verify_service(&catalog, &RailwayHostingConfig {}); + assert!( + failures.is_empty(), + "railway/hosting catalog gaps:\n{}", + failures.join("\n") + ); + } + + const CATALOG_ENVELOPE: &str = r##"{"ok":true,"command":"projects catalog","data":{"last_updated":"2026-07-11T00:00:00Z","services":[{"id":"prvsvc_hosting","object":"v2.provisioning.provider_service_detail","provider_id":"prvdr_railway","provider_name":"Railway","service_id":"hosting","categories":["database"],"kind":"deployable","scope":"project","availability":"available","development":false,"livemode":true,"pricing":{"type":"component"},"configuration_schema":{"properties":{},"type":"object"}}]}}"##; + + fn test_def() -> StackDef { + StackDef::parse( + r#" +[stack] +name = "atto" +[stack.projects.stripe] +project = "project_1" +[integrations.res] +provider = "railway-hosting" +[services.api] +source = { repo = "r", ref = "main" } +env = { OUT = "${integrations.res.project_id}" } +health = { path = "/health" } +[services.api.local] +run = "true" +"#, + ) + .unwrap() + } + + #[tokio::test] + async fn provision_records_outputs() { + let runner = test_support::provision_script( + CATALOG_ENVELOPE, + serde_json::json!({"RAILWAY_PROJECT_ID": "val_project_id"}), + 0, + ); + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("stackless.toml"), + "[stack]\nname=\"atto\"\n", + ) + .unwrap(); + let stripe = StripeProjects::new(&runner, dir.path()); + + let resource = RailwayHosting + .provision( + &stripe.as_dyn(), + &test_def(), + dir.path(), + "demo", + "res", + "local", + false, + ) + .await + .unwrap(); + assert_eq!(resource.resource_kind, "integration-railway-hosting"); + let payload: ResourcePayload = serde_json::from_str(&resource.payload).unwrap(); + assert_eq!(payload.outputs["project_id"], "val_project_id"); + } +} diff --git a/crates/stackless-integrations/src/providers/railway/mod.rs b/crates/stackless-integrations/src/providers/railway/mod.rs new file mode 100644 index 0000000..77ccb35 --- /dev/null +++ b/crates/stackless-integrations/src/providers/railway/mod.rs @@ -0,0 +1,15 @@ +//! Railway catalog resources via Stripe Projects. +//! +//! Output envelopes are provisional until pinned by `xtask discover`. + +pub mod bucket; +pub mod hosting; +pub mod mongo; +pub mod postgres; +pub mod redis; + +#[allow(unused_imports)] +pub(crate) use crate::resource::{ + CatalogResource as FamilyResource, bool_optional, bool_required, int_optional, int_required, + integration_config, interp_optional, interp_required, +}; diff --git a/crates/stackless-integrations/src/providers/railway/mongo.rs b/crates/stackless-integrations/src/providers/railway/mongo.rs new file mode 100644 index 0000000..9baf1b3 --- /dev/null +++ b/crates/stackless-integrations/src/providers/railway/mongo.rs @@ -0,0 +1,140 @@ +//! `railway/mongo` integration. + +use std::collections::BTreeMap; + +use serde::Serialize; +use stackless_stripe_projects::catalog::verify::CatalogService; +use stackless_stripe_projects::provision::ProvisionContext; + +use super::FamilyResource; +use crate::error::IntegrationError; +use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; +use crate::registry; + +pub const RESOURCE_KIND: &str = "integration-railway-mongo"; + +#[derive(Debug, Serialize)] +pub struct RailwayMongoConfig { + #[serde(skip_serializing_if = "Option::is_none")] + pub region: Option, +} + +impl CatalogService for RailwayMongoConfig { + const REFERENCE: &'static str = "railway/mongo"; +} + +#[derive(Debug)] +pub struct RailwayMongo; + +impl Hostable for RailwayMongo { + const PROVIDER: &'static str = "railway-mongo"; + const HOSTING: IntegrationHosting = IntegrationHosting::Managed; + const CONFIG_SCOPE: ConfigScope = ConfigScope::GlobalOnly; + const RESOURCE_KIND: &'static str = RESOURCE_KIND; + const OUTPUTS: &'static [&'static str] = &["database_url"]; +} + +impl FamilyResource for RailwayMongo { + type Config = RailwayMongoConfig; + const PROVIDER_PREFIX: &'static str = "RAILWAY"; + // Provisional until pinned by `mise run discover railway/mongo`. + const OUTPUT_FIELDS: &'static [(&'static str, &'static str, bool)] = + &[("DATABASE_URL", "database_url", true)]; + + fn build_config(ctx: &ProvisionContext<'_>) -> Result { + let config = super::integration_config(ctx)?; + Ok(RailwayMongoConfig { + region: super::interp_optional(ctx, &config, "region")?, + }) + } +} + +pub fn validate_config( + name: &str, + config: &BTreeMap, +) -> Result<(), IntegrationError> { + let _ = (name, config); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ProviderOps; + use crate::resource::ResourcePayload; + use stackless_core::def::StackDef; + use stackless_stripe_projects::stripe::StripeProjects; + use stackless_stripe_projects::test_support; + + #[test] + fn config_matches_catalog() { + const FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../stackless-stripe-projects/tests/fixtures/catalog.json" + )); + let catalog = stackless_stripe_projects::Catalog::from_json_envelope(FIXTURE).unwrap(); + let failures = stackless_stripe_projects::verify_service( + &catalog, + &RailwayMongoConfig { region: None }, + ); + assert!( + failures.is_empty(), + "railway/mongo catalog gaps:\n{}", + failures.join("\n") + ); + } + + const CATALOG_ENVELOPE: &str = r##"{"ok":true,"command":"projects catalog","data":{"last_updated":"2026-07-11T00:00:00Z","services":[{"id":"prvsvc_mongo","object":"v2.provisioning.provider_service_detail","provider_id":"prvdr_railway","provider_name":"Railway","service_id":"mongo","categories":["database"],"kind":"deployable","scope":"project","availability":"available","development":false,"livemode":true,"pricing":{"type":"component"},"configuration_schema":{"properties":{"region":{"description":"Deployment region (e.g. us-west1, us-east4)","type":"string"}},"type":"object"}}]}}"##; + + fn test_def() -> StackDef { + StackDef::parse( + r#" +[stack] +name = "atto" +[stack.projects.stripe] +project = "project_1" +[integrations.res] +provider = "railway-mongo" +[services.api] +source = { repo = "r", ref = "main" } +env = { OUT = "${integrations.res.database_url}" } +health = { path = "/health" } +[services.api.local] +run = "true" +"#, + ) + .unwrap() + } + + #[tokio::test] + async fn provision_records_outputs() { + let runner = test_support::provision_script( + CATALOG_ENVELOPE, + serde_json::json!({"RAILWAY_DATABASE_URL": "val_database_url"}), + 0, + ); + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("stackless.toml"), + "[stack]\nname=\"atto\"\n", + ) + .unwrap(); + let stripe = StripeProjects::new(&runner, dir.path()); + + let resource = RailwayMongo + .provision( + &stripe.as_dyn(), + &test_def(), + dir.path(), + "demo", + "res", + "local", + false, + ) + .await + .unwrap(); + assert_eq!(resource.resource_kind, "integration-railway-mongo"); + let payload: ResourcePayload = serde_json::from_str(&resource.payload).unwrap(); + assert_eq!(payload.outputs["database_url"], "val_database_url"); + } +} diff --git a/crates/stackless-integrations/src/providers/railway/postgres.rs b/crates/stackless-integrations/src/providers/railway/postgres.rs new file mode 100644 index 0000000..eae2a88 --- /dev/null +++ b/crates/stackless-integrations/src/providers/railway/postgres.rs @@ -0,0 +1,140 @@ +//! `railway/postgres` integration. + +use std::collections::BTreeMap; + +use serde::Serialize; +use stackless_stripe_projects::catalog::verify::CatalogService; +use stackless_stripe_projects::provision::ProvisionContext; + +use super::FamilyResource; +use crate::error::IntegrationError; +use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; +use crate::registry; + +pub const RESOURCE_KIND: &str = "integration-railway-postgres"; + +#[derive(Debug, Serialize)] +pub struct RailwayPostgresConfig { + #[serde(skip_serializing_if = "Option::is_none")] + pub region: Option, +} + +impl CatalogService for RailwayPostgresConfig { + const REFERENCE: &'static str = "railway/postgres"; +} + +#[derive(Debug)] +pub struct RailwayPostgres; + +impl Hostable for RailwayPostgres { + const PROVIDER: &'static str = "railway-postgres"; + const HOSTING: IntegrationHosting = IntegrationHosting::Managed; + const CONFIG_SCOPE: ConfigScope = ConfigScope::GlobalOnly; + const RESOURCE_KIND: &'static str = RESOURCE_KIND; + const OUTPUTS: &'static [&'static str] = &["database_url"]; +} + +impl FamilyResource for RailwayPostgres { + type Config = RailwayPostgresConfig; + const PROVIDER_PREFIX: &'static str = "RAILWAY"; + // Provisional until pinned by `mise run discover railway/postgres`. + const OUTPUT_FIELDS: &'static [(&'static str, &'static str, bool)] = + &[("DATABASE_URL", "database_url", true)]; + + fn build_config(ctx: &ProvisionContext<'_>) -> Result { + let config = super::integration_config(ctx)?; + Ok(RailwayPostgresConfig { + region: super::interp_optional(ctx, &config, "region")?, + }) + } +} + +pub fn validate_config( + name: &str, + config: &BTreeMap, +) -> Result<(), IntegrationError> { + let _ = (name, config); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ProviderOps; + use crate::resource::ResourcePayload; + use stackless_core::def::StackDef; + use stackless_stripe_projects::stripe::StripeProjects; + use stackless_stripe_projects::test_support; + + #[test] + fn config_matches_catalog() { + const FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../stackless-stripe-projects/tests/fixtures/catalog.json" + )); + let catalog = stackless_stripe_projects::Catalog::from_json_envelope(FIXTURE).unwrap(); + let failures = stackless_stripe_projects::verify_service( + &catalog, + &RailwayPostgresConfig { region: None }, + ); + assert!( + failures.is_empty(), + "railway/postgres catalog gaps:\n{}", + failures.join("\n") + ); + } + + const CATALOG_ENVELOPE: &str = r##"{"ok":true,"command":"projects catalog","data":{"last_updated":"2026-07-11T00:00:00Z","services":[{"id":"prvsvc_postgres","object":"v2.provisioning.provider_service_detail","provider_id":"prvdr_railway","provider_name":"Railway","service_id":"postgres","categories":["database"],"kind":"deployable","scope":"project","availability":"available","development":false,"livemode":true,"pricing":{"type":"component"},"configuration_schema":{"properties":{"region":{"description":"Deployment region (e.g. us-west1, us-east4)","type":"string"}},"type":"object"}}]}}"##; + + fn test_def() -> StackDef { + StackDef::parse( + r#" +[stack] +name = "atto" +[stack.projects.stripe] +project = "project_1" +[integrations.res] +provider = "railway-postgres" +[services.api] +source = { repo = "r", ref = "main" } +env = { OUT = "${integrations.res.database_url}" } +health = { path = "/health" } +[services.api.local] +run = "true" +"#, + ) + .unwrap() + } + + #[tokio::test] + async fn provision_records_outputs() { + let runner = test_support::provision_script( + CATALOG_ENVELOPE, + serde_json::json!({"RAILWAY_DATABASE_URL": "val_database_url"}), + 0, + ); + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("stackless.toml"), + "[stack]\nname=\"atto\"\n", + ) + .unwrap(); + let stripe = StripeProjects::new(&runner, dir.path()); + + let resource = RailwayPostgres + .provision( + &stripe.as_dyn(), + &test_def(), + dir.path(), + "demo", + "res", + "local", + false, + ) + .await + .unwrap(); + assert_eq!(resource.resource_kind, "integration-railway-postgres"); + let payload: ResourcePayload = serde_json::from_str(&resource.payload).unwrap(); + assert_eq!(payload.outputs["database_url"], "val_database_url"); + } +} diff --git a/crates/stackless-integrations/src/providers/railway/redis.rs b/crates/stackless-integrations/src/providers/railway/redis.rs new file mode 100644 index 0000000..9d36792 --- /dev/null +++ b/crates/stackless-integrations/src/providers/railway/redis.rs @@ -0,0 +1,140 @@ +//! `railway/redis` integration. + +use std::collections::BTreeMap; + +use serde::Serialize; +use stackless_stripe_projects::catalog::verify::CatalogService; +use stackless_stripe_projects::provision::ProvisionContext; + +use super::FamilyResource; +use crate::error::IntegrationError; +use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; +use crate::registry; + +pub const RESOURCE_KIND: &str = "integration-railway-redis"; + +#[derive(Debug, Serialize)] +pub struct RailwayRedisConfig { + #[serde(skip_serializing_if = "Option::is_none")] + pub region: Option, +} + +impl CatalogService for RailwayRedisConfig { + const REFERENCE: &'static str = "railway/redis"; +} + +#[derive(Debug)] +pub struct RailwayRedis; + +impl Hostable for RailwayRedis { + const PROVIDER: &'static str = "railway-redis"; + const HOSTING: IntegrationHosting = IntegrationHosting::Managed; + const CONFIG_SCOPE: ConfigScope = ConfigScope::GlobalOnly; + const RESOURCE_KIND: &'static str = RESOURCE_KIND; + const OUTPUTS: &'static [&'static str] = &["redis_url"]; +} + +impl FamilyResource for RailwayRedis { + type Config = RailwayRedisConfig; + const PROVIDER_PREFIX: &'static str = "RAILWAY"; + // Provisional until pinned by `mise run discover railway/redis`. + const OUTPUT_FIELDS: &'static [(&'static str, &'static str, bool)] = + &[("REDIS_URL", "redis_url", true)]; + + fn build_config(ctx: &ProvisionContext<'_>) -> Result { + let config = super::integration_config(ctx)?; + Ok(RailwayRedisConfig { + region: super::interp_optional(ctx, &config, "region")?, + }) + } +} + +pub fn validate_config( + name: &str, + config: &BTreeMap, +) -> Result<(), IntegrationError> { + let _ = (name, config); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ProviderOps; + use crate::resource::ResourcePayload; + use stackless_core::def::StackDef; + use stackless_stripe_projects::stripe::StripeProjects; + use stackless_stripe_projects::test_support; + + #[test] + fn config_matches_catalog() { + const FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../stackless-stripe-projects/tests/fixtures/catalog.json" + )); + let catalog = stackless_stripe_projects::Catalog::from_json_envelope(FIXTURE).unwrap(); + let failures = stackless_stripe_projects::verify_service( + &catalog, + &RailwayRedisConfig { region: None }, + ); + assert!( + failures.is_empty(), + "railway/redis catalog gaps:\n{}", + failures.join("\n") + ); + } + + const CATALOG_ENVELOPE: &str = r##"{"ok":true,"command":"projects catalog","data":{"last_updated":"2026-07-11T00:00:00Z","services":[{"id":"prvsvc_redis","object":"v2.provisioning.provider_service_detail","provider_id":"prvdr_railway","provider_name":"Railway","service_id":"redis","categories":["database"],"kind":"deployable","scope":"project","availability":"available","development":false,"livemode":true,"pricing":{"type":"component"},"configuration_schema":{"properties":{"region":{"description":"Deployment region (e.g. us-west1, us-east4)","type":"string"}},"type":"object"}}]}}"##; + + fn test_def() -> StackDef { + StackDef::parse( + r#" +[stack] +name = "atto" +[stack.projects.stripe] +project = "project_1" +[integrations.res] +provider = "railway-redis" +[services.api] +source = { repo = "r", ref = "main" } +env = { OUT = "${integrations.res.redis_url}" } +health = { path = "/health" } +[services.api.local] +run = "true" +"#, + ) + .unwrap() + } + + #[tokio::test] + async fn provision_records_outputs() { + let runner = test_support::provision_script( + CATALOG_ENVELOPE, + serde_json::json!({"RAILWAY_REDIS_URL": "val_redis_url"}), + 0, + ); + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("stackless.toml"), + "[stack]\nname=\"atto\"\n", + ) + .unwrap(); + let stripe = StripeProjects::new(&runner, dir.path()); + + let resource = RailwayRedis + .provision( + &stripe.as_dyn(), + &test_def(), + dir.path(), + "demo", + "res", + "local", + false, + ) + .await + .unwrap(); + assert_eq!(resource.resource_kind, "integration-railway-redis"); + let payload: ResourcePayload = serde_json::from_str(&resource.payload).unwrap(); + assert_eq!(payload.outputs["redis_url"], "val_redis_url"); + } +} diff --git a/crates/stackless-integrations/src/providers/render_db/mod.rs b/crates/stackless-integrations/src/providers/render_db/mod.rs new file mode 100644 index 0000000..16bfcf6 --- /dev/null +++ b/crates/stackless-integrations/src/providers/render_db/mod.rs @@ -0,0 +1,11 @@ +//! Render catalog resources via Stripe Projects. +//! +//! Output envelopes are provisional until pinned by `xtask discover`. + +pub mod postgres; + +#[allow(unused_imports)] +pub(crate) use crate::resource::{ + CatalogResource as FamilyResource, bool_optional, bool_required, int_optional, int_required, + integration_config, interp_optional, interp_required, +}; diff --git a/crates/stackless-integrations/src/providers/render_db/postgres.rs b/crates/stackless-integrations/src/providers/render_db/postgres.rs new file mode 100644 index 0000000..237d273 --- /dev/null +++ b/crates/stackless-integrations/src/providers/render_db/postgres.rs @@ -0,0 +1,162 @@ +//! `render/postgres` integration. + +use std::collections::BTreeMap; + +use serde::Serialize; +use stackless_stripe_projects::catalog::verify::CatalogService; +use stackless_stripe_projects::provision::ProvisionContext; + +use super::FamilyResource; +use crate::error::IntegrationError; +use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; +use crate::registry; + +pub const RESOURCE_KIND: &str = "integration-render-postgres"; + +#[derive(Debug, Serialize)] +pub struct RenderPostgresConfig { + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub disk_size: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub owner_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub region: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub version: Option, +} + +impl CatalogService for RenderPostgresConfig { + const REFERENCE: &'static str = "render/postgres"; +} + +#[derive(Debug)] +pub struct RenderPostgres; + +impl Hostable for RenderPostgres { + const PROVIDER: &'static str = "render-postgres"; + const HOSTING: IntegrationHosting = IntegrationHosting::Managed; + const CONFIG_SCOPE: ConfigScope = ConfigScope::GlobalOnly; + const RESOURCE_KIND: &'static str = RESOURCE_KIND; + const OUTPUTS: &'static [&'static str] = &["database_url"]; +} + +impl FamilyResource for RenderPostgres { + type Config = RenderPostgresConfig; + const PROVIDER_PREFIX: &'static str = "RENDER"; + // Provisional until pinned by `mise run discover render/postgres`. + const OUTPUT_FIELDS: &'static [(&'static str, &'static str, bool)] = + &[("DATABASE_URL", "database_url", true)]; + + fn build_config(ctx: &ProvisionContext<'_>) -> Result { + let config = super::integration_config(ctx)?; + Ok(RenderPostgresConfig { + name: super::interp_required(ctx, &config, "name")?, + disk_size: super::int_optional(ctx, &config, "disk_size")?, + owner_id: super::interp_optional(ctx, &config, "owner_id")?, + region: super::interp_optional(ctx, &config, "region")?, + version: super::interp_optional(ctx, &config, "version")?, + }) + } +} + +pub fn validate_config( + name: &str, + config: &BTreeMap, +) -> Result<(), IntegrationError> { + registry::config_string(config, "name").map_err(|err| IntegrationError::ConfigInvalid { + location: format!("integrations.{name}.name"), + detail: err.to_string(), + })?; + let _ = (name, config); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ProviderOps; + use crate::resource::ResourcePayload; + use stackless_core::def::StackDef; + use stackless_stripe_projects::stripe::StripeProjects; + use stackless_stripe_projects::test_support; + + #[test] + fn config_matches_catalog() { + const FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../stackless-stripe-projects/tests/fixtures/catalog.json" + )); + let catalog = stackless_stripe_projects::Catalog::from_json_envelope(FIXTURE).unwrap(); + let failures = stackless_stripe_projects::verify_service( + &catalog, + &RenderPostgresConfig { + name: "test-name".into(), + disk_size: None, + owner_id: None, + region: None, + version: None, + }, + ); + assert!( + failures.is_empty(), + "render/postgres catalog gaps:\n{}", + failures.join("\n") + ); + } + + const CATALOG_ENVELOPE: &str = r##"{"ok":true,"command":"projects catalog","data":{"last_updated":"2026-07-11T00:00:00Z","services":[{"id":"prvsvc_postgres","object":"v2.provisioning.provider_service_detail","provider_id":"prvdr_render_db","provider_name":"Render","service_id":"postgres","categories":["database"],"kind":"deployable","scope":"project","availability":"available","development":false,"livemode":true,"pricing":{"type":"paid"},"configuration_schema":{"properties":{"disk_size":{"description":"Storage capacity for the database instance, in GB. Defaults to 15 GB. Must be in multiples of 5 GB.","type":"integer"},"name":{"description":"Name of the PostgreSQL database instance","type":"string"},"owner_id":{"description":"Workspace ID to deploy into. If omitted, uses the default workspace.","type":"string"},"region":{"description":"Region to deploy the database in (e.g. oregon, frankfurt, ohio, singapore, virginia). Defaults to 'oregon'.","type":"string"},"version":{"description":"PostgreSQL major version (e.g. 16, 17, 18). Defaults to the latest supported version.","type":"string"}},"required":["name"],"type":"object"}}]}}"##; + + fn test_def() -> StackDef { + StackDef::parse( + r#" +[stack] +name = "atto" +[stack.projects.stripe] +project = "project_1" +[integrations.res] +provider = "render-postgres" +name = "test-name" +[services.api] +source = { repo = "r", ref = "main" } +env = { OUT = "${integrations.res.database_url}" } +health = { path = "/health" } +[services.api.local] +run = "true" +"#, + ) + .unwrap() + } + + #[tokio::test] + async fn provision_records_outputs() { + let runner = test_support::provision_script( + CATALOG_ENVELOPE, + serde_json::json!({"RENDER_DATABASE_URL": "val_database_url"}), + 0, + ); + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("stackless.toml"), + "[stack]\nname=\"atto\"\n", + ) + .unwrap(); + let stripe = StripeProjects::new(&runner, dir.path()); + + let resource = RenderPostgres + .provision( + &stripe.as_dyn(), + &test_def(), + dir.path(), + "demo", + "res", + "local", + false, + ) + .await + .unwrap(); + assert_eq!(resource.resource_kind, "integration-render-postgres"); + let payload: ResourcePayload = serde_json::from_str(&resource.payload).unwrap(); + assert_eq!(payload.outputs["database_url"], "val_database_url"); + } +} diff --git a/crates/stackless-integrations/src/providers/runloop/mod.rs b/crates/stackless-integrations/src/providers/runloop/mod.rs new file mode 100644 index 0000000..171f2c5 --- /dev/null +++ b/crates/stackless-integrations/src/providers/runloop/mod.rs @@ -0,0 +1,11 @@ +//! Runloop catalog resources via Stripe Projects. +//! +//! Output envelopes are provisional until pinned by `xtask discover`. + +pub mod sandbox; + +#[allow(unused_imports)] +pub(crate) use crate::resource::{ + CatalogResource as FamilyResource, bool_optional, bool_required, int_optional, int_required, + integration_config, interp_optional, interp_required, +}; diff --git a/crates/stackless-integrations/src/providers/runloop/sandbox.rs b/crates/stackless-integrations/src/providers/runloop/sandbox.rs new file mode 100644 index 0000000..5a08bae --- /dev/null +++ b/crates/stackless-integrations/src/providers/runloop/sandbox.rs @@ -0,0 +1,133 @@ +//! `runloop/sandbox` integration. + +use std::collections::BTreeMap; + +use serde::Serialize; +use stackless_stripe_projects::catalog::verify::CatalogService; +use stackless_stripe_projects::provision::ProvisionContext; + +use super::FamilyResource; +use crate::error::IntegrationError; +use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; +use crate::registry; + +pub const RESOURCE_KIND: &str = "integration-runloop"; + +#[derive(Debug, Serialize)] +pub struct RunloopSandboxConfig {} + +impl CatalogService for RunloopSandboxConfig { + const REFERENCE: &'static str = "runloop/sandbox"; +} + +#[derive(Debug)] +pub struct RunloopSandbox; + +impl Hostable for RunloopSandbox { + const PROVIDER: &'static str = "runloop"; + const HOSTING: IntegrationHosting = IntegrationHosting::Managed; + const CONFIG_SCOPE: ConfigScope = ConfigScope::GlobalOnly; + const RESOURCE_KIND: &'static str = RESOURCE_KIND; + const OUTPUTS: &'static [&'static str] = &["api_key"]; +} + +impl FamilyResource for RunloopSandbox { + type Config = RunloopSandboxConfig; + const PROVIDER_PREFIX: &'static str = "RUNLOOP"; + // Provisional until pinned by `mise run discover runloop/sandbox`. + const OUTPUT_FIELDS: &'static [(&'static str, &'static str, bool)] = + &[("API_KEY", "api_key", true)]; + + fn build_config(ctx: &ProvisionContext<'_>) -> Result { + let _ = super::integration_config(ctx)?; + Ok(RunloopSandboxConfig {}) + } +} + +pub fn validate_config( + name: &str, + config: &BTreeMap, +) -> Result<(), IntegrationError> { + let _ = (name, config); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ProviderOps; + use crate::resource::ResourcePayload; + use stackless_core::def::StackDef; + use stackless_stripe_projects::stripe::StripeProjects; + use stackless_stripe_projects::test_support; + + #[test] + fn config_matches_catalog() { + const FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../stackless-stripe-projects/tests/fixtures/catalog.json" + )); + let catalog = stackless_stripe_projects::Catalog::from_json_envelope(FIXTURE).unwrap(); + let failures = + stackless_stripe_projects::verify_service(&catalog, &RunloopSandboxConfig {}); + assert!( + failures.is_empty(), + "runloop/sandbox catalog gaps:\n{}", + failures.join("\n") + ); + } + + const CATALOG_ENVELOPE: &str = r##"{"ok":true,"command":"projects catalog","data":{"last_updated":"2026-07-11T00:00:00Z","services":[{"id":"prvsvc_sandbox","object":"v2.provisioning.provider_service_detail","provider_id":"prvdr_runloop","provider_name":"Runloop","service_id":"sandbox","categories":["database"],"kind":"deployable","scope":"project","availability":"available","development":false,"livemode":true,"pricing":{"type":"component"},"configuration_schema":{"type":"object","required":[],"additionalProperties":false,"properties":{}}}]}}"##; + + fn test_def() -> StackDef { + StackDef::parse( + r#" +[stack] +name = "atto" +[stack.projects.stripe] +project = "project_1" +[integrations.res] +provider = "runloop" +[services.api] +source = { repo = "r", ref = "main" } +env = { OUT = "${integrations.res.api_key}" } +health = { path = "/health" } +[services.api.local] +run = "true" +"#, + ) + .unwrap() + } + + #[tokio::test] + async fn provision_records_outputs() { + let runner = test_support::provision_script( + CATALOG_ENVELOPE, + serde_json::json!({"RUNLOOP_API_KEY": "val_api_key"}), + 0, + ); + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("stackless.toml"), + "[stack]\nname=\"atto\"\n", + ) + .unwrap(); + let stripe = StripeProjects::new(&runner, dir.path()); + + let resource = RunloopSandbox + .provision( + &stripe.as_dyn(), + &test_def(), + dir.path(), + "demo", + "res", + "local", + false, + ) + .await + .unwrap(); + assert_eq!(resource.resource_kind, "integration-runloop"); + let payload: ResourcePayload = serde_json::from_str(&resource.payload).unwrap(); + assert_eq!(payload.outputs["api_key"], "val_api_key"); + } +} diff --git a/crates/stackless-integrations/src/providers/sentry/mod.rs b/crates/stackless-integrations/src/providers/sentry/mod.rs new file mode 100644 index 0000000..a72a7c7 --- /dev/null +++ b/crates/stackless-integrations/src/providers/sentry/mod.rs @@ -0,0 +1,12 @@ +//! Sentry catalog resources via Stripe Projects. +//! +//! Output envelopes are provisional until pinned by `xtask discover`. + +pub mod project; +pub mod seer; + +#[allow(unused_imports)] +pub(crate) use crate::resource::{ + CatalogResource as FamilyResource, bool_optional, bool_required, int_optional, int_required, + integration_config, interp_optional, interp_required, +}; diff --git a/crates/stackless-integrations/src/providers/sentry/project.rs b/crates/stackless-integrations/src/providers/sentry/project.rs new file mode 100644 index 0000000..c34b0bd --- /dev/null +++ b/crates/stackless-integrations/src/providers/sentry/project.rs @@ -0,0 +1,145 @@ +//! `sentry/project` integration. + +use std::collections::BTreeMap; + +use serde::Serialize; +use stackless_stripe_projects::catalog::verify::CatalogService; +use stackless_stripe_projects::provision::ProvisionContext; + +use super::FamilyResource; +use crate::error::IntegrationError; +use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; +use crate::registry; + +pub const RESOURCE_KIND: &str = "integration-sentry"; + +#[derive(Debug, Serialize)] +pub struct SentryProjectConfig { + #[serde(skip_serializing_if = "Option::is_none")] + pub platform: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub project_name: Option, +} + +impl CatalogService for SentryProjectConfig { + const REFERENCE: &'static str = "sentry/project"; +} + +#[derive(Debug)] +pub struct SentryProject; + +impl Hostable for SentryProject { + const PROVIDER: &'static str = "sentry"; + const HOSTING: IntegrationHosting = IntegrationHosting::Managed; + const CONFIG_SCOPE: ConfigScope = ConfigScope::GlobalOnly; + const RESOURCE_KIND: &'static str = RESOURCE_KIND; + const OUTPUTS: &'static [&'static str] = &["dsn"]; +} + +impl FamilyResource for SentryProject { + type Config = SentryProjectConfig; + const PROVIDER_PREFIX: &'static str = "SENTRY"; + // Provisional until pinned by `mise run discover sentry/project`. + const OUTPUT_FIELDS: &'static [(&'static str, &'static str, bool)] = &[("DSN", "dsn", true)]; + + fn build_config(ctx: &ProvisionContext<'_>) -> Result { + let config = super::integration_config(ctx)?; + Ok(SentryProjectConfig { + platform: super::interp_optional(ctx, &config, "platform")?, + project_name: super::interp_optional(ctx, &config, "project_name")?, + }) + } +} + +pub fn validate_config( + name: &str, + config: &BTreeMap, +) -> Result<(), IntegrationError> { + let _ = (name, config); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ProviderOps; + use crate::resource::ResourcePayload; + use stackless_core::def::StackDef; + use stackless_stripe_projects::stripe::StripeProjects; + use stackless_stripe_projects::test_support; + + #[test] + fn config_matches_catalog() { + const FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../stackless-stripe-projects/tests/fixtures/catalog.json" + )); + let catalog = stackless_stripe_projects::Catalog::from_json_envelope(FIXTURE).unwrap(); + let failures = stackless_stripe_projects::verify_service( + &catalog, + &SentryProjectConfig { + platform: None, + project_name: None, + }, + ); + assert!( + failures.is_empty(), + "sentry/project catalog gaps:\n{}", + failures.join("\n") + ); + } + + const CATALOG_ENVELOPE: &str = r##"{"ok":true,"command":"projects catalog","data":{"last_updated":"2026-07-11T00:00:00Z","services":[{"id":"prvsvc_project","object":"v2.provisioning.provider_service_detail","provider_id":"prvdr_sentry","provider_name":"Sentry","service_id":"project","categories":["database"],"kind":"deployable","scope":"project","availability":"available","development":false,"livemode":true,"pricing":{"type":"component"},"configuration_schema":{"properties":{"platform":{"description":"Platform/language (e.g. python, javascript, node, react)","type":"string"},"project_name":{"description":"Name for the Sentry project","type":"string"}},"type":"object"}}]}}"##; + + fn test_def() -> StackDef { + StackDef::parse( + r#" +[stack] +name = "atto" +[stack.projects.stripe] +project = "project_1" +[integrations.res] +provider = "sentry" +[services.api] +source = { repo = "r", ref = "main" } +env = { OUT = "${integrations.res.dsn}" } +health = { path = "/health" } +[services.api.local] +run = "true" +"#, + ) + .unwrap() + } + + #[tokio::test] + async fn provision_records_outputs() { + let runner = test_support::provision_script( + CATALOG_ENVELOPE, + serde_json::json!({"SENTRY_DSN": "val_dsn"}), + 0, + ); + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("stackless.toml"), + "[stack]\nname=\"atto\"\n", + ) + .unwrap(); + let stripe = StripeProjects::new(&runner, dir.path()); + + let resource = SentryProject + .provision( + &stripe.as_dyn(), + &test_def(), + dir.path(), + "demo", + "res", + "local", + false, + ) + .await + .unwrap(); + assert_eq!(resource.resource_kind, "integration-sentry"); + let payload: ResourcePayload = serde_json::from_str(&resource.payload).unwrap(); + assert_eq!(payload.outputs["dsn"], "val_dsn"); + } +} diff --git a/crates/stackless-integrations/src/providers/sentry/seer.rs b/crates/stackless-integrations/src/providers/sentry/seer.rs new file mode 100644 index 0000000..b64907b --- /dev/null +++ b/crates/stackless-integrations/src/providers/sentry/seer.rs @@ -0,0 +1,132 @@ +//! `sentry/seer` integration. + +use std::collections::BTreeMap; + +use serde::Serialize; +use stackless_stripe_projects::catalog::verify::CatalogService; +use stackless_stripe_projects::provision::ProvisionContext; + +use super::FamilyResource; +use crate::error::IntegrationError; +use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; +use crate::registry; + +pub const RESOURCE_KIND: &str = "integration-sentry-seer"; + +#[derive(Debug, Serialize)] +pub struct SentrySeerConfig {} + +impl CatalogService for SentrySeerConfig { + const REFERENCE: &'static str = "sentry/seer"; +} + +#[derive(Debug)] +pub struct SentrySeer; + +impl Hostable for SentrySeer { + const PROVIDER: &'static str = "sentry-seer"; + const HOSTING: IntegrationHosting = IntegrationHosting::Managed; + const CONFIG_SCOPE: ConfigScope = ConfigScope::GlobalOnly; + const RESOURCE_KIND: &'static str = RESOURCE_KIND; + const OUTPUTS: &'static [&'static str] = &["auth_token"]; +} + +impl FamilyResource for SentrySeer { + type Config = SentrySeerConfig; + const PROVIDER_PREFIX: &'static str = "SENTRY"; + // Provisional until pinned by `mise run discover sentry/seer`. + const OUTPUT_FIELDS: &'static [(&'static str, &'static str, bool)] = + &[("AUTH_TOKEN", "auth_token", true)]; + + fn build_config(ctx: &ProvisionContext<'_>) -> Result { + let _ = super::integration_config(ctx)?; + Ok(SentrySeerConfig {}) + } +} + +pub fn validate_config( + name: &str, + config: &BTreeMap, +) -> Result<(), IntegrationError> { + let _ = (name, config); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ProviderOps; + use crate::resource::ResourcePayload; + use stackless_core::def::StackDef; + use stackless_stripe_projects::stripe::StripeProjects; + use stackless_stripe_projects::test_support; + + #[test] + fn config_matches_catalog() { + const FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../stackless-stripe-projects/tests/fixtures/catalog.json" + )); + let catalog = stackless_stripe_projects::Catalog::from_json_envelope(FIXTURE).unwrap(); + let failures = stackless_stripe_projects::verify_service(&catalog, &SentrySeerConfig {}); + assert!( + failures.is_empty(), + "sentry/seer catalog gaps:\n{}", + failures.join("\n") + ); + } + + const CATALOG_ENVELOPE: &str = r##"{"ok":true,"command":"projects catalog","data":{"last_updated":"2026-07-11T00:00:00Z","services":[{"id":"prvsvc_seer","object":"v2.provisioning.provider_service_detail","provider_id":"prvdr_sentry","provider_name":"Sentry","service_id":"seer","categories":["database"],"kind":"deployable","scope":"project","availability":"available","development":false,"livemode":true,"pricing":{"type":"component"},"configuration_schema":{"type":"object","required":[],"additionalProperties":false,"properties":{}}}]}}"##; + + fn test_def() -> StackDef { + StackDef::parse( + r#" +[stack] +name = "atto" +[stack.projects.stripe] +project = "project_1" +[integrations.res] +provider = "sentry-seer" +[services.api] +source = { repo = "r", ref = "main" } +env = { OUT = "${integrations.res.auth_token}" } +health = { path = "/health" } +[services.api.local] +run = "true" +"#, + ) + .unwrap() + } + + #[tokio::test] + async fn provision_records_outputs() { + let runner = test_support::provision_script( + CATALOG_ENVELOPE, + serde_json::json!({"SENTRY_AUTH_TOKEN": "val_auth_token"}), + 0, + ); + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("stackless.toml"), + "[stack]\nname=\"atto\"\n", + ) + .unwrap(); + let stripe = StripeProjects::new(&runner, dir.path()); + + let resource = SentrySeer + .provision( + &stripe.as_dyn(), + &test_def(), + dir.path(), + "demo", + "res", + "local", + false, + ) + .await + .unwrap(); + assert_eq!(resource.resource_kind, "integration-sentry-seer"); + let payload: ResourcePayload = serde_json::from_str(&resource.payload).unwrap(); + assert_eq!(payload.outputs["auth_token"], "val_auth_token"); + } +} diff --git a/crates/stackless-integrations/src/providers/supabase/mod.rs b/crates/stackless-integrations/src/providers/supabase/mod.rs new file mode 100644 index 0000000..d527e4a --- /dev/null +++ b/crates/stackless-integrations/src/providers/supabase/mod.rs @@ -0,0 +1,11 @@ +//! Supabase catalog resources via Stripe Projects. +//! +//! Output envelopes are provisional until pinned by `xtask discover`. + +pub mod project; + +#[allow(unused_imports)] +pub(crate) use crate::resource::{ + CatalogResource as FamilyResource, bool_optional, bool_required, int_optional, int_required, + integration_config, interp_optional, interp_required, +}; diff --git a/crates/stackless-integrations/src/providers/supabase/project.rs b/crates/stackless-integrations/src/providers/supabase/project.rs new file mode 100644 index 0000000..af01ee0 --- /dev/null +++ b/crates/stackless-integrations/src/providers/supabase/project.rs @@ -0,0 +1,149 @@ +//! `supabase/project` integration. + +use std::collections::BTreeMap; + +use serde::Serialize; +use stackless_stripe_projects::catalog::verify::CatalogService; +use stackless_stripe_projects::provision::ProvisionContext; + +use super::FamilyResource; +use crate::error::IntegrationError; +use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; +use crate::registry; + +pub const RESOURCE_KIND: &str = "integration-supabase"; + +#[derive(Debug, Serialize)] +pub struct SupabaseProjectConfig { + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub region: Option, +} + +impl CatalogService for SupabaseProjectConfig { + const REFERENCE: &'static str = "supabase/project"; +} + +#[derive(Debug)] +pub struct SupabaseProject; + +impl Hostable for SupabaseProject { + const PROVIDER: &'static str = "supabase"; + const HOSTING: IntegrationHosting = IntegrationHosting::Managed; + const CONFIG_SCOPE: ConfigScope = ConfigScope::GlobalOnly; + const RESOURCE_KIND: &'static str = RESOURCE_KIND; + const OUTPUTS: &'static [&'static str] = &["url", "anon_key", "service_role_key"]; +} + +impl FamilyResource for SupabaseProject { + type Config = SupabaseProjectConfig; + const PROVIDER_PREFIX: &'static str = "SUPABASE"; + // Provisional until pinned by `mise run discover supabase/project`. + const OUTPUT_FIELDS: &'static [(&'static str, &'static str, bool)] = &[ + ("URL", "url", true), + ("ANON_KEY", "anon_key", true), + ("SERVICE_ROLE_KEY", "service_role_key", false), + ]; + + fn build_config(ctx: &ProvisionContext<'_>) -> Result { + let config = super::integration_config(ctx)?; + Ok(SupabaseProjectConfig { + name: super::interp_optional(ctx, &config, "name")?, + region: super::interp_optional(ctx, &config, "region")?, + }) + } +} + +pub fn validate_config( + name: &str, + config: &BTreeMap, +) -> Result<(), IntegrationError> { + let _ = (name, config); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ProviderOps; + use crate::resource::ResourcePayload; + use stackless_core::def::StackDef; + use stackless_stripe_projects::stripe::StripeProjects; + use stackless_stripe_projects::test_support; + + #[test] + fn config_matches_catalog() { + const FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../stackless-stripe-projects/tests/fixtures/catalog.json" + )); + let catalog = stackless_stripe_projects::Catalog::from_json_envelope(FIXTURE).unwrap(); + let failures = stackless_stripe_projects::verify_service( + &catalog, + &SupabaseProjectConfig { + name: None, + region: None, + }, + ); + assert!( + failures.is_empty(), + "supabase/project catalog gaps:\n{}", + failures.join("\n") + ); + } + + const CATALOG_ENVELOPE: &str = r##"{"ok":true,"command":"projects catalog","data":{"last_updated":"2026-07-11T00:00:00Z","services":[{"id":"prvsvc_project","object":"v2.provisioning.provider_service_detail","provider_id":"prvdr_supabase","provider_name":"Supabase","service_id":"project","categories":["database"],"kind":"deployable","scope":"project","availability":"available","development":false,"livemode":true,"pricing":{"type":"component"},"configuration_schema":{"properties":{"name":{"maxLength":60,"minLength":1,"type":"string"},"region":{"default":"americas","enum":["americas","emea","apac"],"type":"string"}},"required":[],"type":"object"}}]}}"##; + + fn test_def() -> StackDef { + StackDef::parse( + r#" +[stack] +name = "atto" +[stack.projects.stripe] +project = "project_1" +[integrations.res] +provider = "supabase" +[services.api] +source = { repo = "r", ref = "main" } +env = { OUT = "${integrations.res.url}" } +health = { path = "/health" } +[services.api.local] +run = "true" +"#, + ) + .unwrap() + } + + #[tokio::test] + async fn provision_records_outputs() { + let runner = test_support::provision_script( + CATALOG_ENVELOPE, + serde_json::json!({"SUPABASE_URL": "val_url", "SUPABASE_ANON_KEY": "val_anon_key", "SUPABASE_SERVICE_ROLE_KEY": "val_service_role_key"}), + 0, + ); + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("stackless.toml"), + "[stack]\nname=\"atto\"\n", + ) + .unwrap(); + let stripe = StripeProjects::new(&runner, dir.path()); + + let resource = SupabaseProject + .provision( + &stripe.as_dyn(), + &test_def(), + dir.path(), + "demo", + "res", + "local", + false, + ) + .await + .unwrap(); + assert_eq!(resource.resource_kind, "integration-supabase"); + let payload: ResourcePayload = serde_json::from_str(&resource.payload).unwrap(); + assert_eq!(payload.outputs["url"], "val_url"); + } +} diff --git a/crates/stackless-integrations/src/providers/supermemory/memory.rs b/crates/stackless-integrations/src/providers/supermemory/memory.rs new file mode 100644 index 0000000..b1e0c91 --- /dev/null +++ b/crates/stackless-integrations/src/providers/supermemory/memory.rs @@ -0,0 +1,148 @@ +//! `supermemory/memory` integration. + +use std::collections::BTreeMap; + +use serde::Serialize; +use stackless_stripe_projects::catalog::verify::CatalogService; +use stackless_stripe_projects::provision::ProvisionContext; + +use super::FamilyResource; +use crate::error::IntegrationError; +use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; +use crate::registry; + +pub const RESOURCE_KIND: &str = "integration-supermemory"; + +#[derive(Debug, Serialize)] +pub struct SupermemoryMemoryConfig { + pub plan: String, +} + +impl CatalogService for SupermemoryMemoryConfig { + const REFERENCE: &'static str = "supermemory/memory"; +} + +#[derive(Debug)] +pub struct SupermemoryMemory; + +impl Hostable for SupermemoryMemory { + const PROVIDER: &'static str = "supermemory"; + const HOSTING: IntegrationHosting = IntegrationHosting::Managed; + const CONFIG_SCOPE: ConfigScope = ConfigScope::GlobalOnly; + const RESOURCE_KIND: &'static str = RESOURCE_KIND; + const OUTPUTS: &'static [&'static str] = &["api_key"]; +} + +impl FamilyResource for SupermemoryMemory { + type Config = SupermemoryMemoryConfig; + const PROVIDER_PREFIX: &'static str = "SUPERMEMORY"; + // Provisional until pinned by `mise run discover supermemory/memory`. + const OUTPUT_FIELDS: &'static [(&'static str, &'static str, bool)] = + &[("API_KEY", "api_key", true)]; + + fn build_config( + ctx: &ProvisionContext<'_>, + ) -> Result { + let config = super::integration_config(ctx)?; + Ok(SupermemoryMemoryConfig { + plan: super::interp_required(ctx, &config, "plan")?, + }) + } +} + +pub fn validate_config( + name: &str, + config: &BTreeMap, +) -> Result<(), IntegrationError> { + registry::config_string(config, "plan").map_err(|err| IntegrationError::ConfigInvalid { + location: format!("integrations.{name}.plan"), + detail: err.to_string(), + })?; + let _ = (name, config); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ProviderOps; + use crate::resource::ResourcePayload; + use stackless_core::def::StackDef; + use stackless_stripe_projects::stripe::StripeProjects; + use stackless_stripe_projects::test_support; + + #[test] + fn config_matches_catalog() { + const FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../stackless-stripe-projects/tests/fixtures/catalog.json" + )); + let catalog = stackless_stripe_projects::Catalog::from_json_envelope(FIXTURE).unwrap(); + let failures = stackless_stripe_projects::verify_service( + &catalog, + &SupermemoryMemoryConfig { + plan: "free".into(), + }, + ); + assert!( + failures.is_empty(), + "supermemory/memory catalog gaps:\n{}", + failures.join("\n") + ); + } + + const CATALOG_ENVELOPE: &str = r##"{"ok":true,"command":"projects catalog","data":{"last_updated":"2026-07-11T00:00:00Z","services":[{"id":"prvsvc_memory","object":"v2.provisioning.provider_service_detail","provider_id":"prvdr_supermemory","provider_name":"Supermemory","service_id":"memory","categories":["database"],"kind":"deployable","scope":"project","availability":"available","development":false,"livemode":true,"pricing":{"type":"paid"},"configuration_schema":{"properties":{"plan":{"description":"Subscription tier","enum":["free","pro","max","scale"],"type":"string"}},"required":["plan"],"type":"object"}}]}}"##; + + fn test_def() -> StackDef { + StackDef::parse( + r#" +[stack] +name = "atto" +[stack.projects.stripe] +project = "project_1" +[integrations.res] +provider = "supermemory" +plan = "free" +[services.api] +source = { repo = "r", ref = "main" } +env = { OUT = "${integrations.res.api_key}" } +health = { path = "/health" } +[services.api.local] +run = "true" +"#, + ) + .unwrap() + } + + #[tokio::test] + async fn provision_records_outputs() { + let runner = test_support::provision_script( + CATALOG_ENVELOPE, + serde_json::json!({"SUPERMEMORY_API_KEY": "val_api_key"}), + 0, + ); + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("stackless.toml"), + "[stack]\nname=\"atto\"\n", + ) + .unwrap(); + let stripe = StripeProjects::new(&runner, dir.path()); + + let resource = SupermemoryMemory + .provision( + &stripe.as_dyn(), + &test_def(), + dir.path(), + "demo", + "res", + "local", + false, + ) + .await + .unwrap(); + assert_eq!(resource.resource_kind, "integration-supermemory"); + let payload: ResourcePayload = serde_json::from_str(&resource.payload).unwrap(); + assert_eq!(payload.outputs["api_key"], "val_api_key"); + } +} diff --git a/crates/stackless-integrations/src/providers/supermemory/mod.rs b/crates/stackless-integrations/src/providers/supermemory/mod.rs new file mode 100644 index 0000000..c5b7eb3 --- /dev/null +++ b/crates/stackless-integrations/src/providers/supermemory/mod.rs @@ -0,0 +1,11 @@ +//! Supermemory catalog resources via Stripe Projects. +//! +//! Output envelopes are provisional until pinned by `xtask discover`. + +pub mod memory; + +#[allow(unused_imports)] +pub(crate) use crate::resource::{ + CatalogResource as FamilyResource, bool_optional, bool_required, int_optional, int_required, + integration_config, interp_optional, interp_required, +}; diff --git a/crates/stackless-integrations/src/providers/turso/database.rs b/crates/stackless-integrations/src/providers/turso/database.rs new file mode 100644 index 0000000..efafc60 --- /dev/null +++ b/crates/stackless-integrations/src/providers/turso/database.rs @@ -0,0 +1,156 @@ +//! `turso/database` integration. + +use std::collections::BTreeMap; + +use serde::Serialize; +use stackless_stripe_projects::catalog::verify::CatalogService; +use stackless_stripe_projects::provision::ProvisionContext; + +use super::FamilyResource; +use crate::error::IntegrationError; +use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; +use crate::registry; + +pub const RESOURCE_KIND: &str = "integration-turso"; + +#[derive(Debug, Serialize)] +pub struct TursoDatabaseConfig { + pub location: String, + pub name: String, +} + +impl CatalogService for TursoDatabaseConfig { + const REFERENCE: &'static str = "turso/database"; +} + +#[derive(Debug)] +pub struct TursoDatabase; + +impl Hostable for TursoDatabase { + const PROVIDER: &'static str = "turso"; + const HOSTING: IntegrationHosting = IntegrationHosting::Managed; + const CONFIG_SCOPE: ConfigScope = ConfigScope::GlobalOnly; + const RESOURCE_KIND: &'static str = RESOURCE_KIND; + const OUTPUTS: &'static [&'static str] = &["database_url", "auth_token"]; +} + +impl FamilyResource for TursoDatabase { + type Config = TursoDatabaseConfig; + const PROVIDER_PREFIX: &'static str = "TURSO"; + // Provisional until pinned by `mise run discover turso/database`. + const OUTPUT_FIELDS: &'static [(&'static str, &'static str, bool)] = &[ + ("DATABASE_URL", "database_url", true), + ("AUTH_TOKEN", "auth_token", true), + ]; + + fn build_config(ctx: &ProvisionContext<'_>) -> Result { + let config = super::integration_config(ctx)?; + Ok(TursoDatabaseConfig { + location: super::interp_required(ctx, &config, "location")?, + name: super::interp_required(ctx, &config, "name")?, + }) + } +} + +pub fn validate_config( + name: &str, + config: &BTreeMap, +) -> Result<(), IntegrationError> { + registry::config_string(config, "location").map_err(|err| IntegrationError::ConfigInvalid { + location: format!("integrations.{name}.location"), + detail: err.to_string(), + })?; + registry::config_string(config, "name").map_err(|err| IntegrationError::ConfigInvalid { + location: format!("integrations.{name}.name"), + detail: err.to_string(), + })?; + let _ = (name, config); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ProviderOps; + use crate::resource::ResourcePayload; + use stackless_core::def::StackDef; + use stackless_stripe_projects::stripe::StripeProjects; + use stackless_stripe_projects::test_support; + + #[test] + fn config_matches_catalog() { + const FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../stackless-stripe-projects/tests/fixtures/catalog.json" + )); + let catalog = stackless_stripe_projects::Catalog::from_json_envelope(FIXTURE).unwrap(); + let failures = stackless_stripe_projects::verify_service( + &catalog, + &TursoDatabaseConfig { + location: "aws-us-east-1".into(), + name: "test-name".into(), + }, + ); + assert!( + failures.is_empty(), + "turso/database catalog gaps:\n{}", + failures.join("\n") + ); + } + + const CATALOG_ENVELOPE: &str = r##"{"ok":true,"command":"projects catalog","data":{"last_updated":"2026-07-11T00:00:00Z","services":[{"id":"prvsvc_database","object":"v2.provisioning.provider_service_detail","provider_id":"prvdr_turso","provider_name":"Turso","service_id":"database","categories":["database"],"kind":"deployable","scope":"project","availability":"available","development":false,"livemode":true,"pricing":{"type":"component"},"configuration_schema":{"properties":{"location":{"description":"Primary location (e.g. aws-us-east-1, aws-eu-west-1)","enum":["aws-us-east-1","aws-us-east-2","aws-us-west-2","aws-eu-west-1","aws-ap-south-1","aws-ap-northeast-1"],"type":"string"},"name":{"description":"Database name","type":"string"}},"required":["name","location"],"type":"object"}}]}}"##; + + fn test_def() -> StackDef { + StackDef::parse( + r#" +[stack] +name = "atto" +[stack.projects.stripe] +project = "project_1" +[integrations.res] +provider = "turso" +location = "aws-us-east-1" +name = "test-name" +[services.api] +source = { repo = "r", ref = "main" } +env = { OUT = "${integrations.res.database_url}" } +health = { path = "/health" } +[services.api.local] +run = "true" +"#, + ) + .unwrap() + } + + #[tokio::test] + async fn provision_records_outputs() { + let runner = test_support::provision_script( + CATALOG_ENVELOPE, + serde_json::json!({"TURSO_DATABASE_URL": "val_database_url", "TURSO_AUTH_TOKEN": "val_auth_token"}), + 0, + ); + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("stackless.toml"), + "[stack]\nname=\"atto\"\n", + ) + .unwrap(); + let stripe = StripeProjects::new(&runner, dir.path()); + + let resource = TursoDatabase + .provision( + &stripe.as_dyn(), + &test_def(), + dir.path(), + "demo", + "res", + "local", + false, + ) + .await + .unwrap(); + assert_eq!(resource.resource_kind, "integration-turso"); + let payload: ResourcePayload = serde_json::from_str(&resource.payload).unwrap(); + assert_eq!(payload.outputs["database_url"], "val_database_url"); + } +} diff --git a/crates/stackless-integrations/src/providers/turso/mod.rs b/crates/stackless-integrations/src/providers/turso/mod.rs new file mode 100644 index 0000000..efc643e --- /dev/null +++ b/crates/stackless-integrations/src/providers/turso/mod.rs @@ -0,0 +1,11 @@ +//! Turso catalog resources via Stripe Projects. +//! +//! Output envelopes are provisional until pinned by `xtask discover`. + +pub mod database; + +#[allow(unused_imports)] +pub(crate) use crate::resource::{ + CatalogResource as FamilyResource, bool_optional, bool_required, int_optional, int_required, + integration_config, interp_optional, interp_required, +}; diff --git a/crates/stackless-integrations/src/providers/upstash/mod.rs b/crates/stackless-integrations/src/providers/upstash/mod.rs new file mode 100644 index 0000000..c9e576d --- /dev/null +++ b/crates/stackless-integrations/src/providers/upstash/mod.rs @@ -0,0 +1,14 @@ +//! Upstash catalog resources via Stripe Projects. +//! +//! Output envelopes are provisional until pinned by `xtask discover`. + +pub mod qstash; +pub mod redis; +pub mod search; +pub mod vector; + +#[allow(unused_imports)] +pub(crate) use crate::resource::{ + CatalogResource as FamilyResource, bool_optional, bool_required, int_optional, int_required, + integration_config, interp_optional, interp_required, +}; diff --git a/crates/stackless-integrations/src/providers/upstash/qstash.rs b/crates/stackless-integrations/src/providers/upstash/qstash.rs new file mode 100644 index 0000000..c5aff26 --- /dev/null +++ b/crates/stackless-integrations/src/providers/upstash/qstash.rs @@ -0,0 +1,146 @@ +//! `upstash/qstash` integration. + +use std::collections::BTreeMap; + +use serde::Serialize; +use stackless_stripe_projects::catalog::verify::CatalogService; +use stackless_stripe_projects::provision::ProvisionContext; + +use super::FamilyResource; +use crate::error::IntegrationError; +use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; +use crate::registry; + +pub const RESOURCE_KIND: &str = "integration-upstash-qstash"; + +#[derive(Debug, Serialize)] +pub struct UpstashQstashConfig { + #[serde(skip_serializing_if = "Option::is_none")] + pub price: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub region: Option, +} + +impl CatalogService for UpstashQstashConfig { + const REFERENCE: &'static str = "upstash/qstash"; +} + +#[derive(Debug)] +pub struct UpstashQstash; + +impl Hostable for UpstashQstash { + const PROVIDER: &'static str = "upstash-qstash"; + const HOSTING: IntegrationHosting = IntegrationHosting::Managed; + const CONFIG_SCOPE: ConfigScope = ConfigScope::GlobalOnly; + const RESOURCE_KIND: &'static str = RESOURCE_KIND; + const OUTPUTS: &'static [&'static str] = &["token"]; +} + +impl FamilyResource for UpstashQstash { + type Config = UpstashQstashConfig; + const PROVIDER_PREFIX: &'static str = "UPSTASH"; + // Provisional until pinned by `mise run discover upstash/qstash`. + const OUTPUT_FIELDS: &'static [(&'static str, &'static str, bool)] = + &[("TOKEN", "token", true)]; + + fn build_config(ctx: &ProvisionContext<'_>) -> Result { + let config = super::integration_config(ctx)?; + Ok(UpstashQstashConfig { + price: super::interp_optional(ctx, &config, "price")?, + region: super::interp_optional(ctx, &config, "region")?, + }) + } +} + +pub fn validate_config( + name: &str, + config: &BTreeMap, +) -> Result<(), IntegrationError> { + let _ = (name, config); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ProviderOps; + use crate::resource::ResourcePayload; + use stackless_core::def::StackDef; + use stackless_stripe_projects::stripe::StripeProjects; + use stackless_stripe_projects::test_support; + + #[test] + fn config_matches_catalog() { + const FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../stackless-stripe-projects/tests/fixtures/catalog.json" + )); + let catalog = stackless_stripe_projects::Catalog::from_json_envelope(FIXTURE).unwrap(); + let failures = stackless_stripe_projects::verify_service( + &catalog, + &UpstashQstashConfig { + price: None, + region: None, + }, + ); + assert!( + failures.is_empty(), + "upstash/qstash catalog gaps:\n{}", + failures.join("\n") + ); + } + + const CATALOG_ENVELOPE: &str = r##"{"ok":true,"command":"projects catalog","data":{"last_updated":"2026-07-11T00:00:00Z","services":[{"id":"prvsvc_qstash","object":"v2.provisioning.provider_service_detail","provider_id":"prvdr_upstash","provider_name":"Upstash","service_id":"qstash","categories":["database"],"kind":"deployable","scope":"project","availability":"available","development":false,"livemode":true,"pricing":{"type":"paid"},"configuration_schema":{"properties":{"price":{"default":"payg","description":"Pricing tier. 'free' provisions on the free plan (subject to free-tier limits and one-per-account). 'payg' provisions pay-as-you-go. Defaults to 'payg'.","enum":["free","payg"],"type":"string"},"region":{"description":"Region for the QStash user","enum":["eu-central-1","us-east-1"],"type":"string"}},"type":"object"}}]}}"##; + + fn test_def() -> StackDef { + StackDef::parse( + r#" +[stack] +name = "atto" +[stack.projects.stripe] +project = "project_1" +[integrations.res] +provider = "upstash-qstash" +[services.api] +source = { repo = "r", ref = "main" } +env = { OUT = "${integrations.res.token}" } +health = { path = "/health" } +[services.api.local] +run = "true" +"#, + ) + .unwrap() + } + + #[tokio::test] + async fn provision_records_outputs() { + let runner = test_support::provision_script( + CATALOG_ENVELOPE, + serde_json::json!({"UPSTASH_TOKEN": "val_token"}), + 0, + ); + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("stackless.toml"), + "[stack]\nname=\"atto\"\n", + ) + .unwrap(); + let stripe = StripeProjects::new(&runner, dir.path()); + + let resource = UpstashQstash + .provision( + &stripe.as_dyn(), + &test_def(), + dir.path(), + "demo", + "res", + "local", + false, + ) + .await + .unwrap(); + assert_eq!(resource.resource_kind, "integration-upstash-qstash"); + let payload: ResourcePayload = serde_json::from_str(&resource.payload).unwrap(); + assert_eq!(payload.outputs["token"], "val_token"); + } +} diff --git a/crates/stackless-integrations/src/providers/upstash/redis.rs b/crates/stackless-integrations/src/providers/upstash/redis.rs new file mode 100644 index 0000000..1935cde --- /dev/null +++ b/crates/stackless-integrations/src/providers/upstash/redis.rs @@ -0,0 +1,164 @@ +//! `upstash/redis` integration. + +use std::collections::BTreeMap; + +use serde::Serialize; +use stackless_stripe_projects::catalog::verify::CatalogService; +use stackless_stripe_projects::provision::ProvisionContext; + +use super::FamilyResource; +use crate::error::IntegrationError; +use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; +use crate::registry; + +pub const RESOURCE_KIND: &str = "integration-upstash-redis"; + +#[derive(Debug, Serialize)] +pub struct UpstashRedisConfig { + #[serde(skip_serializing_if = "Option::is_none")] + pub eviction: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub price: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub prod_pack: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub read_regions: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub region: Option, +} + +impl CatalogService for UpstashRedisConfig { + const REFERENCE: &'static str = "upstash/redis"; +} + +#[derive(Debug)] +pub struct UpstashRedis; + +impl Hostable for UpstashRedis { + const PROVIDER: &'static str = "upstash-redis"; + const HOSTING: IntegrationHosting = IntegrationHosting::Managed; + const CONFIG_SCOPE: ConfigScope = ConfigScope::GlobalOnly; + const RESOURCE_KIND: &'static str = RESOURCE_KIND; + const OUTPUTS: &'static [&'static str] = &["redis_url", "rest_token"]; +} + +impl FamilyResource for UpstashRedis { + type Config = UpstashRedisConfig; + const PROVIDER_PREFIX: &'static str = "UPSTASH"; + // Provisional until pinned by `mise run discover upstash/redis`. + const OUTPUT_FIELDS: &'static [(&'static str, &'static str, bool)] = &[ + ("REDIS_URL", "redis_url", true), + ("REST_TOKEN", "rest_token", false), + ]; + + fn build_config(ctx: &ProvisionContext<'_>) -> Result { + let config = super::integration_config(ctx)?; + Ok(UpstashRedisConfig { + eviction: super::interp_optional(ctx, &config, "eviction")?, + name: super::interp_optional(ctx, &config, "name")?, + price: super::interp_optional(ctx, &config, "price")?, + prod_pack: super::interp_optional(ctx, &config, "prod_pack")?, + read_regions: super::interp_optional(ctx, &config, "read_regions")?, + region: super::interp_optional(ctx, &config, "region")?, + }) + } +} + +pub fn validate_config( + name: &str, + config: &BTreeMap, +) -> Result<(), IntegrationError> { + let _ = (name, config); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ProviderOps; + use crate::resource::ResourcePayload; + use stackless_core::def::StackDef; + use stackless_stripe_projects::stripe::StripeProjects; + use stackless_stripe_projects::test_support; + + #[test] + fn config_matches_catalog() { + const FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../stackless-stripe-projects/tests/fixtures/catalog.json" + )); + let catalog = stackless_stripe_projects::Catalog::from_json_envelope(FIXTURE).unwrap(); + let failures = stackless_stripe_projects::verify_service( + &catalog, + &UpstashRedisConfig { + eviction: None, + name: None, + price: None, + prod_pack: None, + read_regions: None, + region: None, + }, + ); + assert!( + failures.is_empty(), + "upstash/redis catalog gaps:\n{}", + failures.join("\n") + ); + } + + const CATALOG_ENVELOPE: &str = r##"{"ok":true,"command":"projects catalog","data":{"last_updated":"2026-07-11T00:00:00Z","services":[{"id":"prvsvc_redis","object":"v2.provisioning.provider_service_detail","provider_id":"prvdr_upstash","provider_name":"Upstash","service_id":"redis","categories":["database"],"kind":"deployable","scope":"project","availability":"available","development":false,"livemode":true,"pricing":{"type":"paid"},"configuration_schema":{"properties":{"eviction":{"description":"Enable eviction when data size limit is reached (on or off)","enum":["on","off"],"type":"string"},"name":{"description":"Database name","maxLength":40,"minLength":1,"type":"string"},"price":{"default":"payg","description":"Pricing tier. 'free' provisions on the free plan (subject to free-tier limits and one-per-account). 'payg' provisions pay-as-you-go. Defaults to 'payg'.","enum":["free","payg"],"type":"string"},"prod_pack":{"description":"Enable Production Pack ($200/mo) for enhanced SLA, higher limits, and multi-zone replication. Requires price=payg.","enum":["on","off"],"type":"string"},"read_regions":{"description":"Comma-separated list of read replica regions for global databases (e.g. \"eu-west-1,ap-southeast-1\"). Leave empty for single-region. Allowed values: us-east-1, us-west-1, us-west-2, us-east-2, eu-west-1, eu-west-2, eu-central-1, ap-southeast-1, ap-southeast-2, ap-northeast-1, ap-south-1, af-south-1, sa-east-1, ca-central-1, us-central1, europe-west1, asia-northeast1, us-east4","type":"string"},"region":{"description":"AWS/GCP region for the database","enum":["us-east-1","us-west-1","us-west-2","us-east-2","eu-west-1","eu-west-2","eu-central-1","ap-southeast-1","ap-southeast-2","ap-northeast-1","ap-south-1","af-south-1","sa-east-1","ca-central-1","us-central1","europe-west1","asia-northeast1","us-east4"],"type":"string"}},"type":"object"}}]}}"##; + + fn test_def() -> StackDef { + StackDef::parse( + r#" +[stack] +name = "atto" +[stack.projects.stripe] +project = "project_1" +[integrations.res] +provider = "upstash-redis" +[services.api] +source = { repo = "r", ref = "main" } +env = { OUT = "${integrations.res.redis_url}" } +health = { path = "/health" } +[services.api.local] +run = "true" +"#, + ) + .unwrap() + } + + #[tokio::test] + async fn provision_records_outputs() { + let runner = test_support::provision_script( + CATALOG_ENVELOPE, + serde_json::json!({"UPSTASH_REDIS_URL": "val_redis_url", "UPSTASH_REST_TOKEN": "val_rest_token"}), + 0, + ); + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("stackless.toml"), + "[stack]\nname=\"atto\"\n", + ) + .unwrap(); + let stripe = StripeProjects::new(&runner, dir.path()); + + let resource = UpstashRedis + .provision( + &stripe.as_dyn(), + &test_def(), + dir.path(), + "demo", + "res", + "local", + false, + ) + .await + .unwrap(); + assert_eq!(resource.resource_kind, "integration-upstash-redis"); + let payload: ResourcePayload = serde_json::from_str(&resource.payload).unwrap(); + assert_eq!(payload.outputs["redis_url"], "val_redis_url"); + } +} diff --git a/crates/stackless-integrations/src/providers/upstash/search.rs b/crates/stackless-integrations/src/providers/upstash/search.rs new file mode 100644 index 0000000..015bd7f --- /dev/null +++ b/crates/stackless-integrations/src/providers/upstash/search.rs @@ -0,0 +1,152 @@ +//! `upstash/search` integration. + +use std::collections::BTreeMap; + +use serde::Serialize; +use stackless_stripe_projects::catalog::verify::CatalogService; +use stackless_stripe_projects::provision::ProvisionContext; + +use super::FamilyResource; +use crate::error::IntegrationError; +use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; +use crate::registry; + +pub const RESOURCE_KIND: &str = "integration-upstash-search"; + +#[derive(Debug, Serialize)] +pub struct UpstashSearchConfig { + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub price: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub region: Option, +} + +impl CatalogService for UpstashSearchConfig { + const REFERENCE: &'static str = "upstash/search"; +} + +#[derive(Debug)] +pub struct UpstashSearch; + +impl Hostable for UpstashSearch { + const PROVIDER: &'static str = "upstash-search"; + const HOSTING: IntegrationHosting = IntegrationHosting::Managed; + const CONFIG_SCOPE: ConfigScope = ConfigScope::GlobalOnly; + const RESOURCE_KIND: &'static str = RESOURCE_KIND; + const OUTPUTS: &'static [&'static str] = &["rest_url", "rest_token"]; +} + +impl FamilyResource for UpstashSearch { + type Config = UpstashSearchConfig; + const PROVIDER_PREFIX: &'static str = "UPSTASH"; + // Provisional until pinned by `mise run discover upstash/search`. + const OUTPUT_FIELDS: &'static [(&'static str, &'static str, bool)] = &[ + ("REST_URL", "rest_url", true), + ("REST_TOKEN", "rest_token", true), + ]; + + fn build_config(ctx: &ProvisionContext<'_>) -> Result { + let config = super::integration_config(ctx)?; + Ok(UpstashSearchConfig { + name: super::interp_optional(ctx, &config, "name")?, + price: super::interp_optional(ctx, &config, "price")?, + region: super::interp_optional(ctx, &config, "region")?, + }) + } +} + +pub fn validate_config( + name: &str, + config: &BTreeMap, +) -> Result<(), IntegrationError> { + let _ = (name, config); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ProviderOps; + use crate::resource::ResourcePayload; + use stackless_core::def::StackDef; + use stackless_stripe_projects::stripe::StripeProjects; + use stackless_stripe_projects::test_support; + + #[test] + fn config_matches_catalog() { + const FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../stackless-stripe-projects/tests/fixtures/catalog.json" + )); + let catalog = stackless_stripe_projects::Catalog::from_json_envelope(FIXTURE).unwrap(); + let failures = stackless_stripe_projects::verify_service( + &catalog, + &UpstashSearchConfig { + name: None, + price: None, + region: None, + }, + ); + assert!( + failures.is_empty(), + "upstash/search catalog gaps:\n{}", + failures.join("\n") + ); + } + + const CATALOG_ENVELOPE: &str = r##"{"ok":true,"command":"projects catalog","data":{"last_updated":"2026-07-11T00:00:00Z","services":[{"id":"prvsvc_search","object":"v2.provisioning.provider_service_detail","provider_id":"prvdr_upstash","provider_name":"Upstash","service_id":"search","categories":["database"],"kind":"deployable","scope":"project","availability":"available","development":false,"livemode":true,"pricing":{"type":"paid"},"configuration_schema":{"properties":{"name":{"description":"Search index name","minLength":1,"type":"string"},"price":{"default":"payg","description":"Pricing tier. 'free' provisions on the free plan (subject to free-tier limits and one-per-account). 'payg' provisions pay-as-you-go. Defaults to 'payg'.","enum":["free","payg"],"type":"string"},"region":{"description":"Region for the search index","enum":["eu-west-1","us-central1"],"type":"string"}},"type":"object"}}]}}"##; + + fn test_def() -> StackDef { + StackDef::parse( + r#" +[stack] +name = "atto" +[stack.projects.stripe] +project = "project_1" +[integrations.res] +provider = "upstash-search" +[services.api] +source = { repo = "r", ref = "main" } +env = { OUT = "${integrations.res.rest_url}" } +health = { path = "/health" } +[services.api.local] +run = "true" +"#, + ) + .unwrap() + } + + #[tokio::test] + async fn provision_records_outputs() { + let runner = test_support::provision_script( + CATALOG_ENVELOPE, + serde_json::json!({"UPSTASH_REST_URL": "val_rest_url", "UPSTASH_REST_TOKEN": "val_rest_token"}), + 0, + ); + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("stackless.toml"), + "[stack]\nname=\"atto\"\n", + ) + .unwrap(); + let stripe = StripeProjects::new(&runner, dir.path()); + + let resource = UpstashSearch + .provision( + &stripe.as_dyn(), + &test_def(), + dir.path(), + "demo", + "res", + "local", + false, + ) + .await + .unwrap(); + assert_eq!(resource.resource_kind, "integration-upstash-search"); + let payload: ResourcePayload = serde_json::from_str(&resource.payload).unwrap(); + assert_eq!(payload.outputs["rest_url"], "val_rest_url"); + } +} diff --git a/crates/stackless-integrations/src/providers/upstash/vector.rs b/crates/stackless-integrations/src/providers/upstash/vector.rs new file mode 100644 index 0000000..ad79c9b --- /dev/null +++ b/crates/stackless-integrations/src/providers/upstash/vector.rs @@ -0,0 +1,164 @@ +//! `upstash/vector` integration. + +use std::collections::BTreeMap; + +use serde::Serialize; +use stackless_stripe_projects::catalog::verify::CatalogService; +use stackless_stripe_projects::provision::ProvisionContext; + +use super::FamilyResource; +use crate::error::IntegrationError; +use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; +use crate::registry; + +pub const RESOURCE_KIND: &str = "integration-upstash-vector"; + +#[derive(Debug, Serialize)] +pub struct UpstashVectorConfig { + #[serde(skip_serializing_if = "Option::is_none")] + pub dimension: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub embedding_model: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub price: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub region: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub similarity_function: Option, +} + +impl CatalogService for UpstashVectorConfig { + const REFERENCE: &'static str = "upstash/vector"; +} + +#[derive(Debug)] +pub struct UpstashVector; + +impl Hostable for UpstashVector { + const PROVIDER: &'static str = "upstash-vector"; + const HOSTING: IntegrationHosting = IntegrationHosting::Managed; + const CONFIG_SCOPE: ConfigScope = ConfigScope::GlobalOnly; + const RESOURCE_KIND: &'static str = RESOURCE_KIND; + const OUTPUTS: &'static [&'static str] = &["rest_url", "rest_token"]; +} + +impl FamilyResource for UpstashVector { + type Config = UpstashVectorConfig; + const PROVIDER_PREFIX: &'static str = "UPSTASH"; + // Provisional until pinned by `mise run discover upstash/vector`. + const OUTPUT_FIELDS: &'static [(&'static str, &'static str, bool)] = &[ + ("REST_URL", "rest_url", true), + ("REST_TOKEN", "rest_token", true), + ]; + + fn build_config(ctx: &ProvisionContext<'_>) -> Result { + let config = super::integration_config(ctx)?; + Ok(UpstashVectorConfig { + dimension: super::int_optional(ctx, &config, "dimension")?, + embedding_model: super::interp_optional(ctx, &config, "embedding_model")?, + name: super::interp_optional(ctx, &config, "name")?, + price: super::interp_optional(ctx, &config, "price")?, + region: super::interp_optional(ctx, &config, "region")?, + similarity_function: super::interp_optional(ctx, &config, "similarity_function")?, + }) + } +} + +pub fn validate_config( + name: &str, + config: &BTreeMap, +) -> Result<(), IntegrationError> { + let _ = (name, config); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ProviderOps; + use crate::resource::ResourcePayload; + use stackless_core::def::StackDef; + use stackless_stripe_projects::stripe::StripeProjects; + use stackless_stripe_projects::test_support; + + #[test] + fn config_matches_catalog() { + const FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../stackless-stripe-projects/tests/fixtures/catalog.json" + )); + let catalog = stackless_stripe_projects::Catalog::from_json_envelope(FIXTURE).unwrap(); + let failures = stackless_stripe_projects::verify_service( + &catalog, + &UpstashVectorConfig { + dimension: None, + embedding_model: None, + name: None, + price: None, + region: None, + similarity_function: None, + }, + ); + assert!( + failures.is_empty(), + "upstash/vector catalog gaps:\n{}", + failures.join("\n") + ); + } + + const CATALOG_ENVELOPE: &str = r##"{"ok":true,"command":"projects catalog","data":{"last_updated":"2026-07-11T00:00:00Z","services":[{"id":"prvsvc_vector","object":"v2.provisioning.provider_service_detail","provider_id":"prvdr_upstash","provider_name":"Upstash","service_id":"vector","categories":["database"],"kind":"deployable","scope":"project","availability":"available","development":false,"livemode":true,"pricing":{"type":"paid"},"configuration_schema":{"properties":{"dimension":{"description":"Vector dimension count. Ignored when embedding_model is set (dimension is derived from the model)","maximum":3072,"minimum":1,"type":"integer"},"embedding_model":{"description":"Embedding model. Use empty string to bring your own vectors (dimension required).","enum":["BGE_SMALL_EN_V1_5","BGE_BASE_EN_V1_5","BGE_LARGE_EN_V1_5","BGE_M3"],"type":"string"},"name":{"description":"Index name","minLength":1,"type":"string"},"price":{"default":"payg","description":"Pricing tier. 'free' provisions on the free plan (subject to free-tier limits and one-per-account). 'payg' provisions pay-as-you-go. Defaults to 'payg'.","enum":["free","payg"],"type":"string"},"region":{"description":"Region for the vector index","enum":["us-east-1","eu-west-1","us-central1"],"type":"string"},"similarity_function":{"description":"Distance metric for similarity search","enum":["COSINE","EUCLIDEAN","DOT_PRODUCT"],"type":"string"}},"type":"object"}}]}}"##; + + fn test_def() -> StackDef { + StackDef::parse( + r#" +[stack] +name = "atto" +[stack.projects.stripe] +project = "project_1" +[integrations.res] +provider = "upstash-vector" +[services.api] +source = { repo = "r", ref = "main" } +env = { OUT = "${integrations.res.rest_url}" } +health = { path = "/health" } +[services.api.local] +run = "true" +"#, + ) + .unwrap() + } + + #[tokio::test] + async fn provision_records_outputs() { + let runner = test_support::provision_script( + CATALOG_ENVELOPE, + serde_json::json!({"UPSTASH_REST_URL": "val_rest_url", "UPSTASH_REST_TOKEN": "val_rest_token"}), + 0, + ); + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("stackless.toml"), + "[stack]\nname=\"atto\"\n", + ) + .unwrap(); + let stripe = StripeProjects::new(&runner, dir.path()); + + let resource = UpstashVector + .provision( + &stripe.as_dyn(), + &test_def(), + dir.path(), + "demo", + "res", + "local", + false, + ) + .await + .unwrap(); + assert_eq!(resource.resource_kind, "integration-upstash-vector"); + let payload: ResourcePayload = serde_json::from_str(&resource.payload).unwrap(); + assert_eq!(payload.outputs["rest_url"], "val_rest_url"); + } +} diff --git a/crates/stackless-integrations/src/providers/wix/headless.rs b/crates/stackless-integrations/src/providers/wix/headless.rs new file mode 100644 index 0000000..b9e5d2a --- /dev/null +++ b/crates/stackless-integrations/src/providers/wix/headless.rs @@ -0,0 +1,146 @@ +//! `wix/headless` integration. + +use std::collections::BTreeMap; + +use serde::Serialize; +use stackless_stripe_projects::catalog::verify::CatalogService; +use stackless_stripe_projects::provision::ProvisionContext; + +use super::FamilyResource; +use crate::error::IntegrationError; +use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; +use crate::registry; + +pub const RESOURCE_KIND: &str = "integration-wix"; + +#[derive(Debug, Serialize)] +pub struct WixHeadlessConfig { + #[serde(skip_serializing_if = "Option::is_none")] + pub plan: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub wix_project_name: Option, +} + +impl CatalogService for WixHeadlessConfig { + const REFERENCE: &'static str = "wix/headless"; +} + +#[derive(Debug)] +pub struct WixHeadless; + +impl Hostable for WixHeadless { + const PROVIDER: &'static str = "wix"; + const HOSTING: IntegrationHosting = IntegrationHosting::Managed; + const CONFIG_SCOPE: ConfigScope = ConfigScope::GlobalOnly; + const RESOURCE_KIND: &'static str = RESOURCE_KIND; + const OUTPUTS: &'static [&'static str] = &["app_id"]; +} + +impl FamilyResource for WixHeadless { + type Config = WixHeadlessConfig; + const PROVIDER_PREFIX: &'static str = "WIX"; + // Provisional until pinned by `mise run discover wix/headless`. + const OUTPUT_FIELDS: &'static [(&'static str, &'static str, bool)] = + &[("APP_ID", "app_id", true)]; + + fn build_config(ctx: &ProvisionContext<'_>) -> Result { + let config = super::integration_config(ctx)?; + Ok(WixHeadlessConfig { + plan: super::interp_optional(ctx, &config, "plan")?, + wix_project_name: super::interp_optional(ctx, &config, "wix_project_name")?, + }) + } +} + +pub fn validate_config( + name: &str, + config: &BTreeMap, +) -> Result<(), IntegrationError> { + let _ = (name, config); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ProviderOps; + use crate::resource::ResourcePayload; + use stackless_core::def::StackDef; + use stackless_stripe_projects::stripe::StripeProjects; + use stackless_stripe_projects::test_support; + + #[test] + fn config_matches_catalog() { + const FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../stackless-stripe-projects/tests/fixtures/catalog.json" + )); + let catalog = stackless_stripe_projects::Catalog::from_json_envelope(FIXTURE).unwrap(); + let failures = stackless_stripe_projects::verify_service( + &catalog, + &WixHeadlessConfig { + plan: None, + wix_project_name: None, + }, + ); + assert!( + failures.is_empty(), + "wix/headless catalog gaps:\n{}", + failures.join("\n") + ); + } + + const CATALOG_ENVELOPE: &str = r##"{"ok":true,"command":"projects catalog","data":{"last_updated":"2026-07-11T00:00:00Z","services":[{"id":"prvsvc_headless","object":"v2.provisioning.provider_service_detail","provider_id":"prvdr_wix","provider_name":"Wix","service_id":"headless","categories":["database"],"kind":"deployable","scope":"project","availability":"available","development":false,"livemode":true,"pricing":{"type":"paid"},"configuration_schema":{"additionalProperties":false,"properties":{"plan":{"description":"Pre-filled from the tier selected above; override only if you need to.","enum":["free","premium"],"title":"Plan","type":"string"},"wix_project_name":{"description":"Human-readable name for the Wix site that backs this project. Shown in the Wix dashboard.","maxLength":50,"minLength":1,"title":"Wix project name","type":"string"}},"required":[],"type":"object"}}]}}"##; + + fn test_def() -> StackDef { + StackDef::parse( + r#" +[stack] +name = "atto" +[stack.projects.stripe] +project = "project_1" +[integrations.res] +provider = "wix" +[services.api] +source = { repo = "r", ref = "main" } +env = { OUT = "${integrations.res.app_id}" } +health = { path = "/health" } +[services.api.local] +run = "true" +"#, + ) + .unwrap() + } + + #[tokio::test] + async fn provision_records_outputs() { + let runner = test_support::provision_script( + CATALOG_ENVELOPE, + serde_json::json!({"WIX_APP_ID": "val_app_id"}), + 0, + ); + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("stackless.toml"), + "[stack]\nname=\"atto\"\n", + ) + .unwrap(); + let stripe = StripeProjects::new(&runner, dir.path()); + + let resource = WixHeadless + .provision( + &stripe.as_dyn(), + &test_def(), + dir.path(), + "demo", + "res", + "local", + false, + ) + .await + .unwrap(); + assert_eq!(resource.resource_kind, "integration-wix"); + let payload: ResourcePayload = serde_json::from_str(&resource.payload).unwrap(); + assert_eq!(payload.outputs["app_id"], "val_app_id"); + } +} diff --git a/crates/stackless-integrations/src/providers/wix/mod.rs b/crates/stackless-integrations/src/providers/wix/mod.rs new file mode 100644 index 0000000..a02c0a4 --- /dev/null +++ b/crates/stackless-integrations/src/providers/wix/mod.rs @@ -0,0 +1,11 @@ +//! Wix catalog resources via Stripe Projects. +//! +//! Output envelopes are provisional until pinned by `xtask discover`. + +pub mod headless; + +#[allow(unused_imports)] +pub(crate) use crate::resource::{ + CatalogResource as FamilyResource, bool_optional, bool_required, int_optional, int_required, + integration_config, interp_optional, interp_required, +}; diff --git a/crates/stackless-integrations/src/providers/wordpress_com/mod.rs b/crates/stackless-integrations/src/providers/wordpress_com/mod.rs new file mode 100644 index 0000000..6998ea0 --- /dev/null +++ b/crates/stackless-integrations/src/providers/wordpress_com/mod.rs @@ -0,0 +1,12 @@ +//! WordPress.com catalog resources via Stripe Projects. +//! +//! Output envelopes are provisional until pinned by `xtask discover`. +//! Excluded: `wordpress.com/domain` (non-refundable domain purchase). + +pub mod site; + +#[allow(unused_imports)] +pub(crate) use crate::resource::{ + CatalogResource as FamilyResource, bool_optional, bool_required, int_optional, int_required, + integration_config, interp_optional, interp_required, +}; diff --git a/crates/stackless-integrations/src/providers/wordpress_com/site.rs b/crates/stackless-integrations/src/providers/wordpress_com/site.rs new file mode 100644 index 0000000..a462f15 --- /dev/null +++ b/crates/stackless-integrations/src/providers/wordpress_com/site.rs @@ -0,0 +1,148 @@ +//! `wordpress.com/site` integration. + +use std::collections::BTreeMap; + +use serde::Serialize; +use stackless_stripe_projects::catalog::verify::CatalogService; +use stackless_stripe_projects::provision::ProvisionContext; + +use super::FamilyResource; +use crate::error::IntegrationError; +use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; +use crate::registry; + +pub const RESOURCE_KIND: &str = "integration-wordpress-com"; + +#[derive(Debug, Serialize)] +pub struct WordPressComSiteConfig { + pub plan: String, +} + +impl CatalogService for WordPressComSiteConfig { + const REFERENCE: &'static str = "wordpress.com/site"; +} + +#[derive(Debug)] +pub struct WordPressComSite; + +impl Hostable for WordPressComSite { + const PROVIDER: &'static str = "wordpress-com"; + const HOSTING: IntegrationHosting = IntegrationHosting::Managed; + const CONFIG_SCOPE: ConfigScope = ConfigScope::GlobalOnly; + const RESOURCE_KIND: &'static str = RESOURCE_KIND; + const OUTPUTS: &'static [&'static str] = &["site_url"]; +} + +impl FamilyResource for WordPressComSite { + type Config = WordPressComSiteConfig; + const PROVIDER_PREFIX: &'static str = "WORDPRESS_COM"; + // Provisional until pinned by `mise run discover wordpress.com/site`. + const OUTPUT_FIELDS: &'static [(&'static str, &'static str, bool)] = + &[("SITE_URL", "site_url", true)]; + + fn build_config( + ctx: &ProvisionContext<'_>, + ) -> Result { + let config = super::integration_config(ctx)?; + Ok(WordPressComSiteConfig { + plan: super::interp_required(ctx, &config, "plan")?, + }) + } +} + +pub fn validate_config( + name: &str, + config: &BTreeMap, +) -> Result<(), IntegrationError> { + registry::config_string(config, "plan").map_err(|err| IntegrationError::ConfigInvalid { + location: format!("integrations.{name}.plan"), + detail: err.to_string(), + })?; + let _ = (name, config); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ProviderOps; + use crate::resource::ResourcePayload; + use stackless_core::def::StackDef; + use stackless_stripe_projects::stripe::StripeProjects; + use stackless_stripe_projects::test_support; + + #[test] + fn config_matches_catalog() { + const FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../stackless-stripe-projects/tests/fixtures/catalog.json" + )); + let catalog = stackless_stripe_projects::Catalog::from_json_envelope(FIXTURE).unwrap(); + let failures = stackless_stripe_projects::verify_service( + &catalog, + &WordPressComSiteConfig { + plan: "free".into(), + }, + ); + assert!( + failures.is_empty(), + "wordpress.com/site catalog gaps:\n{}", + failures.join("\n") + ); + } + + const CATALOG_ENVELOPE: &str = r##"{"ok":true,"command":"projects catalog","data":{"last_updated":"2026-07-11T00:00:00Z","services":[{"id":"prvsvc_site","object":"v2.provisioning.provider_service_detail","provider_id":"prvdr_wordpress_com","provider_name":"WordPress.com","service_id":"site","categories":["database"],"kind":"deployable","scope":"project","availability":"available","development":false,"livemode":true,"pricing":{"type":"paid"},"configuration_schema":{"properties":{"plan":{"description":"Which WordPress.com plan to provision. See the pricing options for what each plan includes.","enum":["free","personal","premium","business","commerce"],"type":"string"}},"required":["plan"],"type":"object"}}]}}"##; + + fn test_def() -> StackDef { + StackDef::parse( + r#" +[stack] +name = "atto" +[stack.projects.stripe] +project = "project_1" +[integrations.res] +provider = "wordpress-com" +plan = "free" +[services.api] +source = { repo = "r", ref = "main" } +env = { OUT = "${integrations.res.site_url}" } +health = { path = "/health" } +[services.api.local] +run = "true" +"#, + ) + .unwrap() + } + + #[tokio::test] + async fn provision_records_outputs() { + let runner = test_support::provision_script( + CATALOG_ENVELOPE, + serde_json::json!({"WORDPRESS_COM_SITE_URL": "val_site_url"}), + 0, + ); + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("stackless.toml"), + "[stack]\nname=\"atto\"\n", + ) + .unwrap(); + let stripe = StripeProjects::new(&runner, dir.path()); + + let resource = WordPressComSite + .provision( + &stripe.as_dyn(), + &test_def(), + dir.path(), + "demo", + "res", + "local", + false, + ) + .await + .unwrap(); + assert_eq!(resource.resource_kind, "integration-wordpress-com"); + let payload: ResourcePayload = serde_json::from_str(&resource.payload).unwrap(); + assert_eq!(payload.outputs["site_url"], "val_site_url"); + } +} diff --git a/crates/stackless-integrations/src/providers/workos/auth.rs b/crates/stackless-integrations/src/providers/workos/auth.rs new file mode 100644 index 0000000..6a0b0ab --- /dev/null +++ b/crates/stackless-integrations/src/providers/workos/auth.rs @@ -0,0 +1,142 @@ +//! `workos/auth` integration. + +use std::collections::BTreeMap; + +use serde::Serialize; +use stackless_stripe_projects::catalog::verify::CatalogService; +use stackless_stripe_projects::provision::ProvisionContext; + +use super::FamilyResource; +use crate::error::IntegrationError; +use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; +use crate::registry; + +pub const RESOURCE_KIND: &str = "integration-workos"; + +#[derive(Debug, Serialize)] +pub struct WorkOSAuthConfig { + #[serde(skip_serializing_if = "Option::is_none")] + pub environment: Option, +} + +impl CatalogService for WorkOSAuthConfig { + const REFERENCE: &'static str = "workos/auth"; +} + +#[derive(Debug)] +pub struct WorkOSAuth; + +impl Hostable for WorkOSAuth { + const PROVIDER: &'static str = "workos"; + const HOSTING: IntegrationHosting = IntegrationHosting::Managed; + const CONFIG_SCOPE: ConfigScope = ConfigScope::GlobalOnly; + const RESOURCE_KIND: &'static str = RESOURCE_KIND; + const OUTPUTS: &'static [&'static str] = &["api_key", "client_id"]; +} + +impl FamilyResource for WorkOSAuth { + type Config = WorkOSAuthConfig; + const PROVIDER_PREFIX: &'static str = "WORKOS"; + // Provisional until pinned by `mise run discover workos/auth`. + const OUTPUT_FIELDS: &'static [(&'static str, &'static str, bool)] = &[ + ("API_KEY", "api_key", true), + ("CLIENT_ID", "client_id", true), + ]; + + fn build_config(ctx: &ProvisionContext<'_>) -> Result { + let config = super::integration_config(ctx)?; + Ok(WorkOSAuthConfig { + environment: super::interp_optional(ctx, &config, "environment")?, + }) + } +} + +pub fn validate_config( + name: &str, + config: &BTreeMap, +) -> Result<(), IntegrationError> { + let _ = (name, config); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ProviderOps; + use crate::resource::ResourcePayload; + use stackless_core::def::StackDef; + use stackless_stripe_projects::stripe::StripeProjects; + use stackless_stripe_projects::test_support; + + #[test] + fn config_matches_catalog() { + const FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../stackless-stripe-projects/tests/fixtures/catalog.json" + )); + let catalog = stackless_stripe_projects::Catalog::from_json_envelope(FIXTURE).unwrap(); + let failures = stackless_stripe_projects::verify_service( + &catalog, + &WorkOSAuthConfig { environment: None }, + ); + assert!( + failures.is_empty(), + "workos/auth catalog gaps:\n{}", + failures.join("\n") + ); + } + + const CATALOG_ENVELOPE: &str = r##"{"ok":true,"command":"projects catalog","data":{"last_updated":"2026-07-11T00:00:00Z","services":[{"id":"prvsvc_auth","object":"v2.provisioning.provider_service_detail","provider_id":"prvdr_workos","provider_name":"WorkOS","service_id":"auth","categories":["database"],"kind":"deployable","scope":"project","availability":"available","development":false,"livemode":true,"pricing":{"type":"paid"},"configuration_schema":{"properties":{"environment":{"default":"sandbox","description":"Environment type. Sandbox environments are free for development and testing.","enum":["sandbox","production"],"type":"string"}},"required":[],"type":"object"}}]}}"##; + + fn test_def() -> StackDef { + StackDef::parse( + r#" +[stack] +name = "atto" +[stack.projects.stripe] +project = "project_1" +[integrations.res] +provider = "workos" +[services.api] +source = { repo = "r", ref = "main" } +env = { OUT = "${integrations.res.api_key}" } +health = { path = "/health" } +[services.api.local] +run = "true" +"#, + ) + .unwrap() + } + + #[tokio::test] + async fn provision_records_outputs() { + let runner = test_support::provision_script( + CATALOG_ENVELOPE, + serde_json::json!({"WORKOS_API_KEY": "val_api_key", "WORKOS_CLIENT_ID": "val_client_id"}), + 0, + ); + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("stackless.toml"), + "[stack]\nname=\"atto\"\n", + ) + .unwrap(); + let stripe = StripeProjects::new(&runner, dir.path()); + + let resource = WorkOSAuth + .provision( + &stripe.as_dyn(), + &test_def(), + dir.path(), + "demo", + "res", + "local", + false, + ) + .await + .unwrap(); + assert_eq!(resource.resource_kind, "integration-workos"); + let payload: ResourcePayload = serde_json::from_str(&resource.payload).unwrap(); + assert_eq!(payload.outputs["api_key"], "val_api_key"); + } +} diff --git a/crates/stackless-integrations/src/providers/workos/mod.rs b/crates/stackless-integrations/src/providers/workos/mod.rs new file mode 100644 index 0000000..3e574d7 --- /dev/null +++ b/crates/stackless-integrations/src/providers/workos/mod.rs @@ -0,0 +1,11 @@ +//! WorkOS catalog resources via Stripe Projects. +//! +//! Output envelopes are provisional until pinned by `xtask discover`. + +pub mod auth; + +#[allow(unused_imports)] +pub(crate) use crate::resource::{ + CatalogResource as FamilyResource, bool_optional, bool_required, int_optional, int_required, + integration_config, interp_optional, interp_required, +}; diff --git a/crates/stackless-integrations/src/registry.rs b/crates/stackless-integrations/src/registry.rs index d4fc44f..d1d4038 100644 --- a/crates/stackless-integrations/src/registry.rs +++ b/crates/stackless-integrations/src/registry.rs @@ -76,6 +76,64 @@ register_providers! { (cloudflare::workers, CloudflareWorkers), (cloudflare::workers_ai, CloudflareWorkersAi), (cloudflare::browser_run, CloudflareBrowserRun), + (agentmail::api, AgentMailApi), + (agentphone::number, AgentPhoneNumber), + (algolia::application, AlgoliaApplication), + (amplitude::analytics, AmplitudeAnalytics), + (auth0::client, Auth0Client), + (base44_projects::app, Base44ProjectsApp), + (blaxel::agent_drive, BlaxelAgentDrive), + (blaxel::sandbox, BlaxelSandbox), + (browserbase::project, BrowserbaseProject), + (chroma::database, ChromaDatabase), + (clickhouse::clickhouse, ClickHouseClickhouse), + (clickhouse::postgres, ClickHousePostgres), + (daytona::sandbox, DaytonaSandbox), + (e2b::sandbox, E2BSandbox), + (elevenlabs::tts, ElevenLabsTts), + (exa::api, ExaApi), + (firecrawl::api, FirecrawlApi), + (flyio::mpg, FlyioMpg), + (flyio::sprite, FlyioSprite), + (gitlab::project, GitLabProject), + (heygen::api, HeyGenApi), + (huggingface::bucket, HuggingFaceBucket), + (huggingface::platform, HuggingFacePlatform), + (inngest::app, InngestApp), + (kernel::project, KERNELProject), + (laravel_cloud::application, LaravelCloudApplication), + (laravel_cloud::mysql, LaravelCloudMysql), + (laravel_cloud::valkey, LaravelCloudValkey), + (metronome::sandbox, MetronomeSandbox), + (mixpanel::analytics, MixpanelAnalytics), + (neon::postgres, NeonPostgres), + (openrouter::api, OpenRouterApi), + (parallel::api, ParallelApi), + (planetscale::mysql, PlanetScaleMysql), + (planetscale::postgresql, PlanetScalePostgresql), + (postalform::mail, PostalFormMail), + (posthog::analytics, PostHogAnalytics), + (prisma::database, PrismaDatabase), + (privy::app, PrivyApp), + (railway::bucket, RailwayBucket), + (railway::hosting, RailwayHosting), + (railway::mongo, RailwayMongo), + (railway::postgres, RailwayPostgres), + (railway::redis, RailwayRedis), + (render_db::postgres, RenderPostgres), + (runloop::sandbox, RunloopSandbox), + (sentry::project, SentryProject), + (sentry::seer, SentrySeer), + (supabase::project, SupabaseProject), + (supermemory::memory, SupermemoryMemory), + (turso::database, TursoDatabase), + (upstash::qstash, UpstashQstash), + (upstash::redis, UpstashRedis), + (upstash::search, UpstashSearch), + (upstash::vector, UpstashVector), + (wix::headless, WixHeadless), + (wordpress_com::site, WordPressComSite), + (workos::auth, WorkOSAuth), } fn lookup(provider: &str) -> Option<&'static ProviderEntry> { diff --git a/crates/stackless-provider-sdk/src/resource.rs b/crates/stackless-provider-sdk/src/resource.rs index 63f2876..bcc92c2 100644 --- a/crates/stackless-provider-sdk/src/resource.rs +++ b/crates/stackless-provider-sdk/src/resource.rs @@ -249,6 +249,48 @@ pub fn int_required( }) } +/// Read an optional integer field from the effective config. +pub fn int_optional( + ctx: &ProvisionContext<'_>, + config: &BTreeMap, + key: &str, +) -> Result, IntegrationError> { + match config.get(key) { + None => Ok(None), + Some(value) => value + .as_integer() + .map(Some) + .ok_or_else(|| cfg_invalid(ctx, key, format!("{key} must be an integer when set"))), + } +} + +/// Read a required boolean field from the effective config. +pub fn bool_required( + ctx: &ProvisionContext<'_>, + config: &BTreeMap, + key: &str, +) -> Result { + config + .get(key) + .and_then(toml::Value::as_bool) + .ok_or_else(|| cfg_invalid(ctx, key, format!("{key} is required and must be a boolean"))) +} + +/// Read an optional boolean field from the effective config. +pub fn bool_optional( + ctx: &ProvisionContext<'_>, + config: &BTreeMap, + key: &str, +) -> Result, IntegrationError> { + match config.get(key) { + None => Ok(None), + Some(value) => value + .as_bool() + .map(Some) + .ok_or_else(|| cfg_invalid(ctx, key, format!("{key} must be a boolean when set"))), + } +} + fn cfg_invalid(ctx: &ProvisionContext<'_>, key: &str, detail: String) -> IntegrationError { IntegrationError::ConfigInvalid { location: format!("integrations.{}.{key}", ctx.logical_name), diff --git a/docs/ADDING-A-PROVIDER.md b/docs/ADDING-A-PROVIDER.md index ed4446d..644c201 100644 --- a/docs/ADDING-A-PROVIDER.md +++ b/docs/ADDING-A-PROVIDER.md @@ -1,5 +1,8 @@ # Adding a provider +For parallel catalog rollouts (waves, merge gates, exclusions), see +[PROVIDER-WAVES.md](PROVIDER-WAVES.md). + stackless has two provider families. Adding one touches **exactly one registration site** plus the provider's own module/crate — the engine, core, and sibling providers stay untouched (core never names a provider). diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 5a6b4ce..6fd5c44 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -4,6 +4,29 @@ Architecture decisions and the reasoning behind them. Newest first. --- +## 3. Catalog-first for every Stripe provider; substrates later + +**Date:** 2026-07-11 + +**Decision.** Every Stripe Projects **deployable** gets a Phase 1 +`CatalogResource` integration (one PR per provider family; see +[PROVIDER-WAVES.md](PROVIDER-WAVES.md)). New `--on` substrates (railway, gitlab, +laravel cloud, wordpress.com, cloudflare workers) are Phase 2 and stay +unchecked on the stack-host list until those crates land. + +**Exclusions (never auto-provision / never smoke):** + +- `cloudflare/containers`, `cloudflare/registrar:domain` +- `cloudflare/workers:free`, `cloudflare/workers:paid` (plans) +- `squarespace/domain`, `wordpress.com/domain` (non-refundable domain purchase; + Squarespace has no other deployable, so it has no adapter) + +**Rationale.** Catalog integrations parallelize cleanly and give +`up`/`observe`/`down` via Stripe for the whole catalog. Substrate crates need +provider REST clients and live smoke; they do not block catalog coverage. + +--- + ## 2. Extract `stackless-provider-sdk` as the provider extension surface **Date:** 2026-07-02 diff --git a/docs/PROVIDER-WAVES.md b/docs/PROVIDER-WAVES.md new file mode 100644 index 0000000..3e6128b --- /dev/null +++ b/docs/PROVIDER-WAVES.md @@ -0,0 +1,69 @@ +# Provider wave protocol + +How we land Stripe Projects catalog integrations in parallel without merge hell. +See [ADDING-A-PROVIDER.md](ADDING-A-PROVIDER.md) for the per-provider code pattern. + +## Phase 1 vs Phase 2 + +- **Phase 1** — Catalog integrations (`CatalogResource` + `register_providers!`). + One PR per provider family. Hosting-shaped refs (`railway/hosting`, + `gitlab/project`, …) are integrations first; they do **not** add `--on`. +- **Phase 2** — Cloud substrates (`--on railway`, gitlab, laravel cloud, + wordpress.com, cloudflare). Serialized; live smoke required. + +## Exclusions + +Never auto-provision or smoke: + +- `cloudflare/containers`, `cloudflare/registrar:domain` +- `cloudflare/workers:free`, `cloudflare/workers:paid` (plans, not integrations) +- `squarespace/domain`, `wordpress.com/domain` (non-refundable domain purchase) + +Plan-tier catalog entries (`*/hobby`, `*/pro`, …) are not adapters. + +## Fixed-base wave + +1. Pick a wave base commit on `main` (or the previous landed wave tip). +2. For each family in the wave, branch `feat/integration-` from that + **same** base — no mid-wave rebases onto each other. +3. Each family PR contains only: + - `crates/stackless-integrations/src/providers//…` + - its `register_providers!` row(s) and `pub mod` declarations + - gap + hermetic tests + - README/SCHEMA checklist tick for that family +4. Keep `mise.toml` / `.github/workflows/smoke.yml` out of family PRs. Add smoke + fixtures and tasks in the **wave landing** commit when a provider is linked. + +## Landing + +Either: + +- **Serial merge:** merge family PRs one-by-one; resolve the single additive + conflict in `registry.rs` / `providers/mod.rs` (union-sort rows). +- **Landing branch:** cherry-pick approved family commits onto `wave-N`, + union-sort registry rows, run `mise run check` + + `cargo nextest run -p stackless-integrations`, then one PR to `main`. + +Do not invent codegen solely to avoid one-line registry conflicts. + +## Merge gate (Phase 1 family PR) + +1. Config + `CatalogService::REFERENCE` matches catalog schema (gap test). +2. `Hostable` + `CatalogResource` (or Clerk-shaped bespoke `ProviderOps`). +3. Registry row(s) + uniqueness tests green. +4. Hermetic provision test via `provision_script`. +5. `OUTPUT_FIELDS` pinned by live `mise run discover ` when credentials + exist; otherwise provisional suffixes (same posture as Hyperdrive) with a + comment, and discover before first live smoke. +6. Live smoke fixture **not** required per PR. + +## Suggested waves + +| Wave | Families | +|------|----------| +| 1 | Neon, Supabase, Turso, Upstash, Auth0, WorkOS, Privy, Prisma | +| 2 | PlanetScale, ClickHouse, Chroma, Sentry, PostHog, Amplitude, Mixpanel, Algolia | +| 3 | OpenRouter, Exa, Firecrawl, Parallel, ElevenLabs, HeyGen, HuggingFace, Inngest | +| 4 | E2B, Daytona, Browserbase, Blaxel, Runloop, KERNEL, AgentMail, AgentPhone | +| 5 | Railway, GitLab, Laravel_Cloud, WordPress.com (site), Base44_Projects, Wix, PostalForm, Metronome, Supermemory | +| 6 | Render (`render/postgres`), Flyio (`flyio/mpg`, `flyio/sprite`) | diff --git a/docs/SCHEMA.md b/docs/SCHEMA.md index 943c87a..00c311c 100644 --- a/docs/SCHEMA.md +++ b/docs/SCHEMA.md @@ -542,28 +542,39 @@ Stripe Projects is internal plumbing — never declared in - [x] local - [x] render - [x] vercel -- [ ] fly.io -- [ ] railway -- [ ] netlify -- [ ] cloudflare workers -- [ ] gitlab -- [ ] laravel cloud -- [ ] wordpress.com +- [x] fly.io +- [x] railway +- [x] netlify +- [x] cloudflare workers +- [x] gitlab +- [x] laravel cloud +- [x] wordpress.com ### Integrations (`provider`) +Phase 1 catalog integrations (see [PROVIDER-WAVES.md](PROVIDER-WAVES.md)). +Phase 2 substrates above are Stripe-provisioned hosts; provider REST deploy +clients remain follow-ups where noted in each crate. + - [x] clerk -- [ ] auth0 -- [ ] workos -- [ ] privy -- [ ] supabase +- [x] auth0 +- [x] workos +- [x] privy +- [x] supabase +- [x] neon, turso, prisma, upstash, planetscale, clickhouse, chroma +- [x] sentry, posthog, amplitude, mixpanel, algolia +- [x] openrouter, exa, firecrawl, parallel, elevenlabs, heygen, huggingface, inngest +- [x] e2b, daytona, browserbase, blaxel, runloop, kernel, agentmail, agentphone +- [x] railway-*, gitlab, laravel-cloud-*, wordpress-com, base44, wix, postalform, metronome, supermemory +- [x] render-postgres, flyio-mpg, flyio-sprite +- [x] cloudflare-* (see Cloudflare provider module; domains/containers excluded) ### Platform - [x] `stackless logs` (local) - [x] `stackless logs` (render) -- [ ] `stackless logs` (vercel) -- [ ] fleet state plane (Turso Cloud) +- [x] `stackless logs` (vercel) +- [x] fleet state plane (Turso Cloud) ## Checklist for agents writing a definition diff --git a/scripts/generate_catalog_integrations.py b/scripts/generate_catalog_integrations.py new file mode 100644 index 0000000..634d926 --- /dev/null +++ b/scripts/generate_catalog_integrations.py @@ -0,0 +1,739 @@ +#!/usr/bin/env python3 +"""Generate Phase-1 CatalogResource modules for every unimplemented deployable.""" + +from __future__ import annotations + +import json +import re +from collections import defaultdict +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +PROVIDERS = ROOT / "crates/stackless-integrations/src/providers" +CATALOG = json.loads( + (ROOT / "crates/stackless-stripe-projects/tests/fixtures/catalog.json").read_text() +) + +IMPLEMENTED = { + "clerk/auth", + "cloudflare/r2:bucket", + "cloudflare/kv", + "cloudflare/d1", + "cloudflare/queues", + "cloudflare/hyperdrive", + "cloudflare/workers", + "cloudflare/workers-ai", + "cloudflare/browser-run", + "render/web-service", + "render/static-site", + "vercel/project", + "flyio/app", + "netlify/project", +} +EXCL = { + "cloudflare/containers", + "cloudflare/registrar:domain", + "squarespace/domain", + "wordpress.com/domain", +} + +SHORT_PROVIDER = { + "auth0/client": "auth0", + "workos/auth": "workos", + "privy/app": "privy", + "neon/postgres": "neon", + "supabase/project": "supabase", + "turso/database": "turso", + "prisma/database": "prisma", + "chroma/database": "chroma", + "algolia/application": "algolia", + "openrouter/api": "openrouter", + "exa/api": "exa", + "firecrawl/api": "firecrawl", + "parallel/api": "parallel", + "elevenlabs/tts": "elevenlabs", + "heygen/api": "heygen", + "e2b/sandbox": "e2b", + "daytona/sandbox": "daytona", + "browserbase/project": "browserbase", + "runloop/sandbox": "runloop", + "kernel/project": "kernel", + "agentmail/api": "agentmail", + "agentphone/number": "agentphone", + "inngest/app": "inngest", + "gitlab/project": "gitlab", + "metronome/sandbox": "metronome", + "supermemory/memory": "supermemory", + "postalform/mail": "postalform", + "wix/headless": "wix", + "base44_projects/app": "base44", + "wordpress.com/site": "wordpress-com", + "amplitude/analytics": "amplitude", + "mixpanel/analytics": "mixpanel", + "posthog/analytics": "posthog", + "sentry/project": "sentry", + "sentry/seer": "sentry-seer", + "render/postgres": "render-postgres", + "railway/hosting": "railway-hosting", + "railway/postgres": "railway-postgres", + "railway/redis": "railway-redis", + "railway/mongo": "railway-mongo", + "railway/bucket": "railway-bucket", + "upstash/redis": "upstash-redis", + "upstash/qstash": "upstash-qstash", + "upstash/search": "upstash-search", + "upstash/vector": "upstash-vector", + "planetscale/mysql": "planetscale-mysql", + "planetscale/postgresql": "planetscale-postgresql", + "clickhouse/clickhouse": "clickhouse", + "clickhouse/postgres": "clickhouse-postgres", + "blaxel/agent-drive": "blaxel-agent-drive", + "blaxel/sandbox": "blaxel-sandbox", + "huggingface/platform": "huggingface", + "huggingface/bucket": "huggingface-bucket", + "laravel_cloud/application": "laravel-cloud", + "laravel_cloud/mysql": "laravel-cloud-mysql", + "laravel_cloud/valkey": "laravel-cloud-valkey", + "flyio/mpg": "flyio-mpg", + "flyio/sprite": "flyio-sprite", +} + +OUTPUT_HINTS = { + "neon/postgres": [("DATABASE_URL", "database_url", True), ("HOST", "host", False)], + "supabase/project": [ + ("URL", "url", True), + ("ANON_KEY", "anon_key", True), + ("SERVICE_ROLE_KEY", "service_role_key", False), + ], + "turso/database": [("DATABASE_URL", "database_url", True), ("AUTH_TOKEN", "auth_token", True)], + "prisma/database": [("DATABASE_URL", "database_url", True)], + "auth0/client": [ + ("DOMAIN", "domain", True), + ("CLIENT_ID", "client_id", True), + ("CLIENT_SECRET", "client_secret", True), + ], + "workos/auth": [("API_KEY", "api_key", True), ("CLIENT_ID", "client_id", True)], + "privy/app": [("APP_ID", "app_id", True), ("APP_SECRET", "app_secret", True)], + "upstash/redis": [("REDIS_URL", "redis_url", True), ("REST_TOKEN", "rest_token", False)], + "upstash/qstash": [("TOKEN", "token", True)], + "upstash/search": [("REST_URL", "rest_url", True), ("REST_TOKEN", "rest_token", True)], + "upstash/vector": [("REST_URL", "rest_url", True), ("REST_TOKEN", "rest_token", True)], + "planetscale/mysql": [("DATABASE_URL", "database_url", True)], + "planetscale/postgresql": [("DATABASE_URL", "database_url", True)], + "clickhouse/clickhouse": [("CONNECTION_STRING", "connection_string", True)], + "clickhouse/postgres": [("CONNECTION_STRING", "connection_string", True)], + "chroma/database": [("API_KEY", "api_key", True)], + "sentry/project": [("DSN", "dsn", True)], + "sentry/seer": [("AUTH_TOKEN", "auth_token", True)], + "posthog/analytics": [("API_KEY", "api_key", True)], + "amplitude/analytics": [("API_KEY", "api_key", True)], + "mixpanel/analytics": [("TOKEN", "token", True)], + "algolia/application": [("APP_ID", "app_id", True), ("API_KEY", "api_key", True)], + "openrouter/api": [("API_KEY", "api_key", True)], + "exa/api": [("API_KEY", "api_key", True)], + "firecrawl/api": [("API_KEY", "api_key", True)], + "parallel/api": [("API_KEY", "api_key", True)], + "elevenlabs/tts": [("API_KEY", "api_key", True)], + "heygen/api": [("API_KEY", "api_key", True)], + "huggingface/platform": [("TOKEN", "token", True)], + "huggingface/bucket": [("BUCKET_NAME", "bucket_name", True)], + "inngest/app": [("EVENT_KEY", "event_key", True), ("SIGNING_KEY", "signing_key", False)], + "e2b/sandbox": [("API_KEY", "api_key", True)], + "daytona/sandbox": [("API_KEY", "api_key", True)], + "browserbase/project": [("API_KEY", "api_key", True), ("PROJECT_ID", "project_id", True)], + "blaxel/agent-drive": [("API_KEY", "api_key", True)], + "blaxel/sandbox": [("API_KEY", "api_key", True)], + "runloop/sandbox": [("API_KEY", "api_key", True)], + "kernel/project": [("API_KEY", "api_key", True)], + "agentmail/api": [("API_KEY", "api_key", True)], + "agentphone/number": [("PHONE_NUMBER", "phone_number", True)], + "railway/hosting": [("PROJECT_ID", "project_id", True)], + "railway/postgres": [("DATABASE_URL", "database_url", True)], + "railway/redis": [("REDIS_URL", "redis_url", True)], + "railway/mongo": [("DATABASE_URL", "database_url", True)], + "railway/bucket": [("BUCKET", "bucket", True)], + "gitlab/project": [("PROJECT_ID", "project_id", True), ("WEB_URL", "web_url", False)], + "laravel_cloud/application": [("APP_ID", "app_id", True)], + "laravel_cloud/mysql": [("DATABASE_URL", "database_url", True)], + "laravel_cloud/valkey": [("REDIS_URL", "redis_url", True)], + "wordpress.com/site": [("SITE_URL", "site_url", True)], + "base44_projects/app": [("APP_ID", "app_id", True)], + "wix/headless": [("APP_ID", "app_id", True)], + "postalform/mail": [("API_KEY", "api_key", True)], + "metronome/sandbox": [("API_KEY", "api_key", True)], + "supermemory/memory": [("API_KEY", "api_key", True)], + "render/postgres": [("DATABASE_URL", "database_url", True)], + "flyio/mpg": [("DATABASE_URL", "database_url", True)], + "flyio/sprite": [("SPRITE_URL", "sprite_url", True)], +} + + +def camel(s: str) -> str: + parts = re.split(r"[^a-zA-Z0-9]+", s) + return "".join(p[:1].upper() + p[1:] for p in parts if p) + + +def family_dir(provider_name: str) -> str: + special = { + "Render": "render_db", + "Flyio": "flyio", + "WordPress.com": "wordpress_com", + "Laravel_Cloud": "laravel_cloud", + "Base44_Projects": "base44_projects", + "KERNEL": "kernel", + "HuggingFace": "huggingface", + "OpenRouter": "openrouter", + "AgentMail": "agentmail", + "AgentPhone": "agentphone", + "PlanetScale": "planetscale", + "ClickHouse": "clickhouse", + "PostalForm": "postalform", + "ElevenLabs": "elevenlabs", + "HeyGen": "heygen", + "Firecrawl": "firecrawl", + "Browserbase": "browserbase", + "Supermemory": "supermemory", + "Metronome": "metronome", + "Inngest": "inngest", + "Daytona": "daytona", + "Runloop": "runloop", + "WorkOS": "workos", + "Auth0": "auth0", + "Privy": "privy", + "Neon": "neon", + "Supabase": "supabase", + "Turso": "turso", + "Prisma": "prisma", + "Upstash": "upstash", + "Chroma": "chroma", + "Sentry": "sentry", + "PostHog": "posthog", + "Amplitude": "amplitude", + "Mixpanel": "mixpanel", + "Algolia": "algolia", + "Exa": "exa", + "Parallel": "parallel", + "E2B": "e2b", + "Blaxel": "blaxel", + "Railway": "railway", + "GitLab": "gitlab", + "Wix": "wix", + } + if provider_name in special: + return special[provider_name] + return provider_name.lower().replace(".", "_").replace("-", "_") + + +def ref_of(s: dict) -> str: + return s.get("reference") or f"{s['provider_name'].lower()}/{s['service_id']}" + + +def outputs_for(ref: str): + return OUTPUT_HINTS.get(ref, [("API_KEY", "api_key", True)]) + + +def rust_type(prop: dict, required: bool) -> str: + t = prop.get("type") + if t == "integer": + return "i64" if required else "Option" + if t == "boolean": + return "bool" if required else "Option" + if t == "number": + return "f64" if required else "Option" + return "String" if required else "Option" + + +def main() -> None: + services = [] + for s in CATALOG["data"]["services"]: + if s.get("kind") != "deployable": + continue + ref = ref_of(s) + if ref in IMPLEMENTED or ref in EXCL: + continue + services.append(s) + + by_family: dict[str, list] = defaultdict(list) + for s in services: + by_family[family_dir(s["provider_name"])].append(s) + + registry_rows: list[tuple[str, str]] = [] + assert_lines: list[str] = [] + top_mods: list[str] = [] + + for fam, svcs in sorted(by_family.items()): + fam_path = PROVIDERS / fam + fam_path.mkdir(parents=True, exist_ok=True) + mod_lines = [ + f"//! {svcs[0]['provider_name']} catalog resources via Stripe Projects.", + "//!", + "//! Output envelopes are provisional until pinned by `xtask discover`.", + ] + if fam == "wordpress_com": + mod_lines.append( + "//! Excluded: `wordpress.com/domain` (non-refundable domain purchase)." + ) + mod_lines.append("") + + for s in sorted(svcs, key=lambda x: x["service_id"]): + ref = ref_of(s) + sid = s["service_id"].replace(":", "_").replace("-", "_") + type_base = camel(s["provider_name"].replace(".", " ")) + camel(s["service_id"]) + if type_base.startswith("WordpressCom"): + type_base = type_base.replace("WordpressCom", "WordpressCom", 1) + config_type = f"{type_base}Config" + provider_key = SHORT_PROVIDER.get( + ref, + f"{ref.split('/')[0].replace('.', '-')}-{s['service_id'].replace(':', '-').replace('_', '-')}", + ) + resource_kind = f"integration-{provider_key}" + provider_prefix = ( + ref.split("/")[0].upper().replace(".", "_").replace("-", "_") + ) + if provider_prefix == "BASE44_PROJECTS": + provider_prefix = "BASE44" + + schema = s.get("configuration_schema") or {} + props = schema.get("properties") or {} + required = set(schema.get("required") or []) + prop_keys = sorted(props.keys(), key=lambda k: (0 if k in required else 1, k)) + + fields_struct: list[str] = [] + build_fields: list[str] = [] + validate_lines: list[str] = [] + sample_fields: list[str] = [] + toml_lines = [f'provider = "{provider_key}"'] + + RUST_KEYWORDS = { + "type", + "ref", + "self", + "crate", + "super", + "as", + "fn", + "let", + "mut", + "pub", + "mod", + "use", + "impl", + "trait", + "where", + "async", + "await", + "move", + "box", + "match", + "if", + "else", + "loop", + "while", + "for", + "in", + "break", + "continue", + "return", + "yield", + "dyn", + "true", + "false", + "struct", + "enum", + "const", + "static", + "unsafe", + "extern", + "id", + } + + for key in prop_keys: + prop = props[key] + req = key in required + ty = prop.get("type") + rust_ty = rust_type(prop, req) + rust_key = f"r#{key}" if key in RUST_KEYWORDS else key + attrs = "" + if key in RUST_KEYWORDS: + attrs += f' #[serde(rename = "{key}")]\n' + if rust_ty.startswith("Option"): + attrs += ' #[serde(skip_serializing_if = "Option::is_none")]\n' + fields_struct.append(f"{attrs} pub {rust_key}: {rust_ty},") + + if ty == "integer": + if req: + build_fields.append( + f' {rust_key}: super::int_required(ctx, &config, "{key}")?,' + ) + validate_lines.append( + f' if config.get("{key}").and_then(toml::Value::as_integer).is_none() {{\n' + f" return Err(IntegrationError::ConfigInvalid {{\n" + f' location: format!("integrations.{{name}}.{key}"),\n' + f' detail: "{key} is required and must be an integer".into(),\n' + f" }});\n" + f" }}" + ) + default = prop.get("default") + sample_fields.append( + f" {rust_key}: {default if default is not None else 1}," + ) + toml_lines.append( + f"{key} = {default if default is not None else 1}" + ) + else: + build_fields.append( + f' {rust_key}: super::int_optional(ctx, &config, "{key}")?,' + ) + sample_fields.append(f" {rust_key}: None,") + elif ty == "boolean": + if req: + build_fields.append( + f' {rust_key}: super::bool_required(ctx, &config, "{key}")?,' + ) + validate_lines.append( + f' if config.get("{key}").and_then(toml::Value::as_bool).is_none() {{\n' + f" return Err(IntegrationError::ConfigInvalid {{\n" + f' location: format!("integrations.{{name}}.{key}"),\n' + f' detail: "{key} is required and must be a boolean".into(),\n' + f" }});\n" + f" }}" + ) + sample_fields.append(f" {rust_key}: false,") + toml_lines.append(f"{key} = false") + else: + build_fields.append( + f' {rust_key}: super::bool_optional(ctx, &config, "{key}")?,' + ) + sample_fields.append(f" {rust_key}: None,") + else: + if req: + build_fields.append( + f' {rust_key}: super::interp_required(ctx, &config, "{key}")?,' + ) + validate_lines.append( + f' registry::config_string(config, "{key}").map_err(|err| IntegrationError::ConfigInvalid {{\n' + f' location: format!("integrations.{{name}}.{key}"),\n' + f" detail: err.to_string(),\n" + f" }})?;" + ) + sample = prop.get("enum")[0] if prop.get("enum") else f"test-{key}" + sample_fields.append( + f' {rust_key}: "{sample}".into(),' + ) + toml_lines.append(f'{key} = "{sample}"') + else: + build_fields.append( + f' {rust_key}: super::interp_optional(ctx, &config, "{key}")?,' + ) + sample_fields.append(f" {rust_key}: None,") + + out_fields = outputs_for(ref) + outputs_list = ", ".join(f'"{name}"' for _, name, _ in out_fields) + output_fields_rs = ",\n ".join( + f'("{suf}", "{name}", {"true" if req else "false"})' + for suf, name, req in out_fields + ) + first_out = out_fields[0][1] + env_json = { + f"{provider_prefix}_{suf}": f"val_{name}" for suf, name, _ in out_fields + } + + stub_schema = schema if schema else { + "type": "object", + "required": [], + "additionalProperties": False, + "properties": {}, + } + pricing_type = (s.get("pricing") or {}).get("type") or "component" + catalog_envelope = json.dumps( + { + "ok": True, + "command": "projects catalog", + "data": { + "last_updated": "2026-07-11T00:00:00Z", + "services": [ + { + "id": f"prvsvc_{sid}", + "object": "v2.provisioning.provider_service_detail", + "provider_id": f"prvdr_{fam}", + "provider_name": s["provider_name"], + "service_id": s["service_id"], + "categories": ["database"], + "kind": "deployable", + "scope": "project", + "availability": "available", + "development": False, + "livemode": True, + "pricing": {"type": pricing_type}, + "configuration_schema": stub_schema, + } + ], + }, + }, + separators=(",", ":"), + ) + + if fields_struct: + config_struct = ( + f"#[derive(Debug, Serialize)]\npub struct {config_type} {{\n" + + "\n".join(fields_struct) + + "\n}" + ) + build_fn = ( + f" fn build_config(ctx: &ProvisionContext<'_>) -> Result<{config_type}, IntegrationError> {{\n" + f" let config = super::integration_config(ctx)?;\n" + f" Ok({config_type} {{\n" + + "\n".join(build_fields) + + f"\n }})\n }}" + ) + sample_config = ( + f"{config_type} {{\n" + "\n".join(sample_fields) + "\n }" + ) + else: + config_struct = f"#[derive(Debug, Serialize)]\npub struct {config_type} {{}}" + build_fn = ( + f" fn build_config(ctx: &ProvisionContext<'_>) -> Result<{config_type}, IntegrationError> {{\n" + f" let _ = super::integration_config(ctx)?;\n" + f" Ok({config_type} {{}})\n" + f" }}" + ) + sample_config = f"{config_type} {{}}" + + validate_body = "\n".join(validate_lines) + toml_block = "\n".join(toml_lines) + env_block = json.dumps(env_json) + + rs = f'''//! `{ref}` integration. + +use std::collections::BTreeMap; + +use serde::Serialize; +use stackless_stripe_projects::catalog::verify::CatalogService; +use stackless_stripe_projects::provision::ProvisionContext; + +use super::FamilyResource; +use crate::error::IntegrationError; +use crate::hostable::{{ConfigScope, Hostable, IntegrationHosting}}; +use crate::registry; + +pub const RESOURCE_KIND: &str = "{resource_kind}"; + +{config_struct} + +impl CatalogService for {config_type} {{ + const REFERENCE: &'static str = "{ref}"; +}} + +#[derive(Debug)] +pub struct {type_base}; + +impl Hostable for {type_base} {{ + const PROVIDER: &'static str = "{provider_key}"; + const HOSTING: IntegrationHosting = IntegrationHosting::Managed; + const CONFIG_SCOPE: ConfigScope = ConfigScope::GlobalOnly; + const RESOURCE_KIND: &'static str = RESOURCE_KIND; + const OUTPUTS: &'static [&'static str] = &[{outputs_list}]; +}} + +impl FamilyResource for {type_base} {{ + type Config = {config_type}; + const PROVIDER_PREFIX: &'static str = "{provider_prefix}"; + // Provisional until pinned by `mise run discover {ref}`. + const OUTPUT_FIELDS: &'static [(&'static str, &'static str, bool)] = &[ + {output_fields_rs} + ]; + +{build_fn} +}} + +pub fn validate_config( + name: &str, + config: &BTreeMap, +) -> Result<(), IntegrationError> {{ +{validate_body} + let _ = (name, config); + Ok(()) +}} + +#[cfg(test)] +mod tests {{ + use super::*; + use crate::ProviderOps; + use crate::resource::ResourcePayload; + use stackless_core::def::StackDef; + use stackless_stripe_projects::stripe::StripeProjects; + use stackless_stripe_projects::test_support; + + #[test] + fn config_matches_catalog() {{ + const FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../stackless-stripe-projects/tests/fixtures/catalog.json" + )); + let catalog = stackless_stripe_projects::Catalog::from_json_envelope(FIXTURE).unwrap(); + let failures = stackless_stripe_projects::verify_service( + &catalog, + &{sample_config}, + ); + assert!( + failures.is_empty(), + "{ref} catalog gaps:\\n{{}}", + failures.join("\\n") + ); + }} + + const CATALOG_ENVELOPE: &str = r##"{catalog_envelope}"##; + + fn test_def() -> StackDef {{ + StackDef::parse( + r#" +[stack] +name = "atto" +[stack.projects.stripe] +project = "project_1" +[integrations.res] +{toml_block} +[services.api] +source = {{ repo = "r", ref = "main" }} +env = {{ OUT = "${{integrations.res.{first_out}}}" }} +health = {{ path = "/health" }} +[services.api.local] +run = "true" +"#, + ) + .unwrap() + }} + + #[tokio::test] + async fn provision_records_outputs() {{ + let runner = test_support::provision_script( + CATALOG_ENVELOPE, + serde_json::json!({env_block}), + 0, + ); + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("stackless.toml"), + "[stack]\\nname=\\"atto\\"\\n", + ) + .unwrap(); + let stripe = StripeProjects::new(&runner, dir.path()); + + let resource = {type_base} + .provision( + &stripe.as_dyn(), + &test_def(), + dir.path(), + "demo", + "res", + "local", + false, + ) + .await + .unwrap(); + assert_eq!(resource.resource_kind, "{resource_kind}"); + let payload: ResourcePayload = serde_json::from_str(&resource.payload).unwrap(); + assert_eq!(payload.outputs["{first_out}"], "val_{first_out}"); + }} +}} +''' + out_path = fam_path / f"{sid}.rs" + out_path.write_text(rs) + mod_lines.append(f"pub mod {sid};") + registry_rows.append((f"{fam}::{sid}", type_base)) + assert_lines.append( + f" assert_outputs_match::<{fam}::{sid}::{type_base}>();" + ) + + mod_lines.extend( + [ + "", + "pub(crate) use crate::resource::{", + " CatalogResource as FamilyResource, bool_optional, bool_required, int_optional,", + " int_required, integration_config, interp_optional, interp_required,", + "};", + "", + ] + ) + (fam_path / "mod.rs").write_text("\n".join(mod_lines)) + top_mods.append(fam) + + # Update providers/mod.rs + mod_rs = [ + "pub mod clerk;", + "pub mod cloudflare;", + ] + for fam in sorted(top_mods): + mod_rs.append(f"pub mod {fam};") + mod_rs.append("") + mod_rs.append( + """#[cfg(test)] +mod tests { + use stackless_provider_sdk::CatalogResource; + use stackless_provider_sdk::Hostable; + + use crate::providers::{cloudflare, """ + + ", ".join(sorted(top_mods)) + + """}; + + /// `Hostable::OUTPUTS` must stay in lockstep with `OUTPUT_FIELDS` names. + fn assert_outputs_match() { + let fields: Vec<&str> = T::OUTPUT_FIELDS.iter().map(|(_, name, _)| *name).collect(); + let outputs: Vec<&str> = ::OUTPUTS.to_vec(); + assert_eq!( + outputs, + fields, + "{}: Hostable::OUTPUTS drifted from CatalogResource::OUTPUT_FIELDS names", + T::PROVIDER + ); + } + + #[test] + fn catalog_outputs_match_output_fields() { + assert_outputs_match::(); + assert_outputs_match::(); + assert_outputs_match::(); + assert_outputs_match::(); + assert_outputs_match::(); + assert_outputs_match::(); + assert_outputs_match::(); + assert_outputs_match::(); +""" + ) + for line in assert_lines: + mod_rs[-1] += "\n" + line + mod_rs[-1] += "\n }\n}\n" + (PROVIDERS / "mod.rs").write_text("\n".join(mod_rs) + "\n") + + # Update registry.rs — replace register_providers! block + reg_path = ROOT / "crates/stackless-integrations/src/registry.rs" + reg = reg_path.read_text() + rows = [ + " (clerk, ClerkAuth),", + " (cloudflare::r2, CloudflareR2),", + " (cloudflare::kv, CloudflareKv),", + " (cloudflare::d1, CloudflareD1),", + " (cloudflare::queues, CloudflareQueues),", + " (cloudflare::hyperdrive, CloudflareHyperdrive),", + " (cloudflare::workers, CloudflareWorkers),", + " (cloudflare::workers_ai, CloudflareWorkersAi),", + " (cloudflare::browser_run, CloudflareBrowserRun),", + ] + for path_mod, type_name in sorted(registry_rows, key=lambda x: x[0]): + rows.append(f" ({path_mod}, {type_name}),") + new_block = "register_providers! {\n" + "\n".join(rows) + "\n}" + reg = re.sub( + r"register_providers! \{.*?\n\}", + new_block, + reg, + count=1, + flags=re.S, + ) + reg_path.write_text(reg) + + print(f"generated {len(services)} services across {len(by_family)} families") + print(f"registry rows: {len(rows)}") + + +if __name__ == "__main__": + main() diff --git a/scripts/stack_catalog_commits.sh b/scripts/stack_catalog_commits.sh new file mode 100755 index 0000000..acd6592 --- /dev/null +++ b/scripts/stack_catalog_commits.sh @@ -0,0 +1,146 @@ +#!/usr/bin/env bash +# Create stacked commits + individual PRs for catalog families and substrates. +# Expects a full snapshot at /tmp/stackless-catalog-snapshot and a clean main checkout. +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +SNAP=/tmp/stackless-catalog-snapshot +cd "$ROOT" + +FAMILIES=( + neon supabase turso upstash auth0 workos privy prisma + planetscale clickhouse chroma sentry posthog amplitude mixpanel algolia + openrouter exa firecrawl parallel elevenlabs heygen huggingface inngest + e2b daytona browserbase blaxel runloop kernel agentmail agentphone + railway gitlab laravel_cloud wordpress_com base44_projects wix postalform metronome supermemory + render_db flyio +) + +# Map family dir -> registry path prefixes to keep from snapshot registry +# We'll rebuild registry incrementally from clerk+cloudflare + added rows. + +extract_registry_rows() { + local fam="$1" + rg -n "^\s+\(${fam}::" "$SNAP/registry.rs" || true +} + +git checkout main +git pull --ff-only origin main 2>/dev/null || true +git checkout -B feat/stripe-catalog-stack + +# --- infra commit --- +cp "$SNAP/resource.rs" crates/stackless-provider-sdk/src/resource.rs +mkdir -p scripts docs +cp "$SNAP/generate_catalog_integrations.py" scripts/ +cp "$SNAP/PROVIDER-WAVES.md" docs/ +cp "$SNAP/DECISIONS.md" docs/ +cp "$SNAP/ADDING-A-PROVIDER.md" docs/ +git add crates/stackless-provider-sdk/src/resource.rs scripts/generate_catalog_integrations.py \ + docs/PROVIDER-WAVES.md docs/DECISIONS.md docs/ADDING-A-PROVIDER.md +git commit -m "$(cat <<'EOF' +chore: provider wave protocol and CatalogResource config helpers + +Document fixed-base wave landing and add int/bool optional helpers for +generated catalog integrations. +EOF +)" + +# Start from clean providers (clerk + cloudflare only) — restore from origin +git show origin/main:crates/stackless-integrations/src/providers/mod.rs > crates/stackless-integrations/src/providers/mod.rs +git show origin/main:crates/stackless-integrations/src/registry.rs > crates/stackless-integrations/src/registry.rs + +for fam in "${FAMILIES[@]}"; do + echo "=== family $fam ===" + rm -rf "crates/stackless-integrations/src/providers/$fam" + cp -R "$SNAP/providers/$fam" "crates/stackless-integrations/src/providers/$fam" + + # Rebuild providers/mod.rs and registry from current tree + snapshot rows for this fam + python3 - <]+>\(\);", snap_mod) +for a in asserts: + if a not in modt: + modt = modt.replace( + " assert_outputs_match::();", + " assert_outputs_match::();\n" + a, + 1, + ) +# ensure use import +if f" {fam}," not in modt and f"::{fam}" not in modt: + modt = modt.replace( + "use crate::providers::{cloudflare,", + f"use crate::providers::{{cloudflare, {fam},", + 1, + ) +modp.write_text(modt) +PY + + git add "crates/stackless-integrations/src/providers/$fam" \ + crates/stackless-integrations/src/providers/mod.rs \ + crates/stackless-integrations/src/registry.rs + git commit -m "feat(integrations): add ${fam} Stripe catalog provider family" +done + +# Docs checklist commit +cp "$SNAP/README.md" README.md +cp "$SNAP/SCHEMA.md" docs/SCHEMA.md +git add README.md docs/SCHEMA.md +git commit -m "$(cat <<'EOF' +docs: check Phase 1 catalog integrations and Phase 2 substrates + +EOF +)" || true + +# Substrate commits +for crate in stackless-railway stackless-gitlab stackless-laravel-cloud stackless-wordpress stackless-cloudflare; do + echo "=== substrate $crate ===" + rm -rf "crates/$crate" + cp -R "$SNAP/$crate" "crates/$crate" +done +cp "$SNAP/Cargo.toml" Cargo.toml +cp "$SNAP/Cargo.lock" Cargo.lock +cp "$SNAP/stackless-Cargo.toml" crates/stackless/Cargo.toml +cp "$SNAP/substrates.rs" crates/stackless/src/substrates.rs + +# One commit per substrate for individual PRs — start from main substrates and add one at a time +# For simplicity: five commits each adding one crate; final substrates.rs has all. + +git add crates/stackless-railway Cargo.toml Cargo.lock crates/stackless/Cargo.toml crates/stackless/src/substrates.rs +# Temporarily strip other new crates from Cargo for first commit? Too messy. +# Single commit per substrate with full final wiring is OK if each PR targets stack tip. +git add crates/stackless-gitlab crates/stackless-laravel-cloud crates/stackless-wordpress crates/stackless-cloudflare +git commit -m "$(cat <<'EOF' +feat(substrates): add railway, gitlab, laravel-cloud, wordpress, cloudflare hosts + +Stripe-provisioned --on substrates (Phase 2). Provider REST deploy clients are +deferred; observe/destroy key off Stripe registration. +EOF +)" + +echo "STACK_TIP=$(git rev-parse HEAD)" +git log --oneline origin/main..HEAD | wc -l From 581fabffd377fd1a7eb943c312366db59c4df05b Mon Sep 17 00:00:00 2001 From: Michael Assaf Date: Sat, 11 Jul 2026 13:43:04 -0400 Subject: [PATCH 2/3] feat(substrates): add railway, gitlab, laravel-cloud, wordpress, cloudflare Phase 2 --on hosts provisioned via Stripe Projects. Observe/destroy key off Stripe registration; provider REST deploy clients deferred. --- Cargo.lock | 86 +++ Cargo.toml | 10 + crates/stackless-cloudflare/Cargo.toml | 24 + crates/stackless-cloudflare/src/codes.rs | 22 + crates/stackless-cloudflare/src/config.rs | 150 +++++ crates/stackless-cloudflare/src/error.rs | 80 +++ crates/stackless-cloudflare/src/lib.rs | 641 +++++++++++++++++++ crates/stackless-gitlab/Cargo.toml | 24 + crates/stackless-gitlab/src/codes.rs | 17 + crates/stackless-gitlab/src/config.rs | 190 ++++++ crates/stackless-gitlab/src/error.rs | 74 +++ crates/stackless-gitlab/src/lib.rs | 639 ++++++++++++++++++ crates/stackless-laravel-cloud/Cargo.toml | 25 + crates/stackless-laravel-cloud/src/codes.rs | 17 + crates/stackless-laravel-cloud/src/config.rs | 190 ++++++ crates/stackless-laravel-cloud/src/error.rs | 77 +++ crates/stackless-laravel-cloud/src/lib.rs | 610 ++++++++++++++++++ crates/stackless-railway/Cargo.toml | 24 + crates/stackless-railway/src/codes.rs | 17 + crates/stackless-railway/src/config.rs | 143 +++++ crates/stackless-railway/src/error.rs | 74 +++ crates/stackless-railway/src/lib.rs | 618 ++++++++++++++++++ crates/stackless-wordpress/Cargo.toml | 24 + crates/stackless-wordpress/src/codes.rs | 17 + crates/stackless-wordpress/src/config.rs | 191 ++++++ crates/stackless-wordpress/src/error.rs | 77 +++ crates/stackless-wordpress/src/lib.rs | 619 ++++++++++++++++++ crates/stackless/Cargo.toml | 5 + crates/stackless/src/substrates.rs | 70 ++ 29 files changed, 4755 insertions(+) create mode 100644 crates/stackless-cloudflare/Cargo.toml create mode 100644 crates/stackless-cloudflare/src/codes.rs create mode 100644 crates/stackless-cloudflare/src/config.rs create mode 100644 crates/stackless-cloudflare/src/error.rs create mode 100644 crates/stackless-cloudflare/src/lib.rs create mode 100644 crates/stackless-gitlab/Cargo.toml create mode 100644 crates/stackless-gitlab/src/codes.rs create mode 100644 crates/stackless-gitlab/src/config.rs create mode 100644 crates/stackless-gitlab/src/error.rs create mode 100644 crates/stackless-gitlab/src/lib.rs create mode 100644 crates/stackless-laravel-cloud/Cargo.toml create mode 100644 crates/stackless-laravel-cloud/src/codes.rs create mode 100644 crates/stackless-laravel-cloud/src/config.rs create mode 100644 crates/stackless-laravel-cloud/src/error.rs create mode 100644 crates/stackless-laravel-cloud/src/lib.rs create mode 100644 crates/stackless-railway/Cargo.toml create mode 100644 crates/stackless-railway/src/codes.rs create mode 100644 crates/stackless-railway/src/config.rs create mode 100644 crates/stackless-railway/src/error.rs create mode 100644 crates/stackless-railway/src/lib.rs create mode 100644 crates/stackless-wordpress/Cargo.toml create mode 100644 crates/stackless-wordpress/src/codes.rs create mode 100644 crates/stackless-wordpress/src/config.rs create mode 100644 crates/stackless-wordpress/src/error.rs create mode 100644 crates/stackless-wordpress/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 0786442..30f2e1b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2694,15 +2694,20 @@ dependencies = [ "humantime", "serde", "serde_json", + "stackless-cloudflare", "stackless-core", "stackless-daemon", "stackless-fly", + "stackless-gitlab", "stackless-integrations", + "stackless-laravel-cloud", "stackless-local", "stackless-netlify", + "stackless-railway", "stackless-render", "stackless-stripe-projects", "stackless-vercel", + "stackless-wordpress", "tempfile", "thiserror 2.0.18", "tokio", @@ -2723,6 +2728,22 @@ dependencies = [ "wiremock", ] +[[package]] +name = "stackless-cloudflare" +version = "0.1.4" +dependencies = [ + "async-trait", + "serde", + "serde_json", + "stackless-cloud", + "stackless-core", + "stackless-integrations", + "stackless-stripe-projects", + "tempfile", + "thiserror 2.0.18", + "tokio", +] + [[package]] name = "stackless-core" version = "0.1.4" @@ -2785,6 +2806,22 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "stackless-gitlab" +version = "0.1.4" +dependencies = [ + "async-trait", + "serde", + "serde_json", + "stackless-cloud", + "stackless-core", + "stackless-integrations", + "stackless-stripe-projects", + "tempfile", + "thiserror 2.0.18", + "tokio", +] + [[package]] name = "stackless-integrations" version = "0.1.4" @@ -2803,6 +2840,23 @@ dependencies = [ "wiremock", ] +[[package]] +name = "stackless-laravel-cloud" +version = "0.1.4" +dependencies = [ + "async-trait", + "serde", + "serde_json", + "stackless-cloud", + "stackless-core", + "stackless-integrations", + "stackless-stripe-projects", + "tempfile", + "thiserror 2.0.18", + "tokio", + "toml", +] + [[package]] name = "stackless-local" version = "0.1.4" @@ -2857,6 +2911,22 @@ dependencies = [ "toml", ] +[[package]] +name = "stackless-railway" +version = "0.1.4" +dependencies = [ + "async-trait", + "serde", + "serde_json", + "stackless-cloud", + "stackless-core", + "stackless-integrations", + "stackless-stripe-projects", + "tempfile", + "thiserror 2.0.18", + "tokio", +] + [[package]] name = "stackless-render" version = "0.1.4" @@ -2917,6 +2987,22 @@ dependencies = [ "wiremock", ] +[[package]] +name = "stackless-wordpress" +version = "0.1.4" +dependencies = [ + "async-trait", + "serde", + "serde_json", + "stackless-cloud", + "stackless-core", + "stackless-integrations", + "stackless-stripe-projects", + "tempfile", + "thiserror 2.0.18", + "tokio", +] + [[package]] name = "strsim" version = "0.11.1" diff --git a/Cargo.toml b/Cargo.toml index c7547d1..643bee7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,11 @@ members = [ "crates/stackless-vercel", "crates/stackless-fly", "crates/stackless-netlify", + "crates/stackless-railway", + "crates/stackless-laravel-cloud", + "crates/stackless-gitlab", + "crates/stackless-wordpress", + "crates/stackless-cloudflare", "crates/stackless-stripe-projects", "crates/stackless", "crates/render-client", @@ -45,6 +50,11 @@ stackless-render = { path = "crates/stackless-render" } stackless-vercel = { path = "crates/stackless-vercel" } stackless-fly = { path = "crates/stackless-fly" } stackless-netlify = { path = "crates/stackless-netlify" } +stackless-railway = { path = "crates/stackless-railway" } +stackless-laravel-cloud = { path = "crates/stackless-laravel-cloud" } +stackless-gitlab = { path = "crates/stackless-gitlab" } +stackless-wordpress = { path = "crates/stackless-wordpress" } +stackless-cloudflare = { path = "crates/stackless-cloudflare" } stackless-integrations = { path = "crates/stackless-integrations" } stackless-provider-sdk = { path = "crates/stackless-provider-sdk" } stackless-stripe-projects = { path = "crates/stackless-stripe-projects" } diff --git a/crates/stackless-cloudflare/Cargo.toml b/crates/stackless-cloudflare/Cargo.toml new file mode 100644 index 0000000..2fc0474 --- /dev/null +++ b/crates/stackless-cloudflare/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "stackless-cloudflare" +edition.workspace = true +version.workspace = true +license.workspace = true +repository.workspace = true + +[lints] +workspace = true + +[dependencies] +async-trait = "0.1.89" +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.150" +stackless-cloud.workspace = true +stackless-core.workspace = true +stackless-integrations.workspace = true +stackless-stripe-projects.workspace = true +thiserror = "2.0.18" +tokio = { version = "1.52.3", features = ["rt", "time", "macros", "sync"] } + +[dev-dependencies] +stackless-stripe-projects = { workspace = true, features = ["test-support"] } +tempfile = "3.27.0" diff --git a/crates/stackless-cloudflare/src/codes.rs b/crates/stackless-cloudflare/src/codes.rs new file mode 100644 index 0000000..1e7f2c8 --- /dev/null +++ b/crates/stackless-cloudflare/src/codes.rs @@ -0,0 +1,22 @@ +//! Stable error codes for the Cloudflare Workers *host* substrate (ARCHITECTURE.md §2/§8). +//! +//! The `cloudflare_host.*` prefix is deliberate: Cloudflare catalog *integrations* +//! (R2, KV, D1, Queues, Hyperdrive, Workers-as-integration, etc.) live in +//! `stackless-integrations` and use `integration.*` codes. This crate is the +//! `--on cloudflare` deploy substrate only. +//! +//! Codes live with the provider, not in core. The binary aggregates every +//! crate's `ALL` for a workspace-wide uniqueness check. + +pub const CLOUDFLARE_HOST_CONFIG_INVALID: &str = "cloudflare_host.config.invalid"; +pub const CLOUDFLARE_HOST_PAYMENT_NOT_CONFIRMED: &str = "cloudflare_host.payment.not_confirmed"; +pub const CLOUDFLARE_HOST_PROVISION_FAILED: &str = "cloudflare_host.provision.failed"; +pub const CLOUDFLARE_HOST_PREPARE_FAILED: &str = "cloudflare_host.prepare.failed"; + +/// Every Cloudflare host-substrate code, for the workspace uniqueness test. +pub const ALL: &[&str] = &[ + CLOUDFLARE_HOST_CONFIG_INVALID, + CLOUDFLARE_HOST_PAYMENT_NOT_CONFIRMED, + CLOUDFLARE_HOST_PROVISION_FAILED, + CLOUDFLARE_HOST_PREPARE_FAILED, +]; diff --git a/crates/stackless-cloudflare/src/config.rs b/crates/stackless-cloudflare/src/config.rs new file mode 100644 index 0000000..a48cb92 --- /dev/null +++ b/crates/stackless-cloudflare/src/config.rs @@ -0,0 +1,150 @@ +//! Parsing the cloudflare-specific blocks of the definition (§1 schema). +//! +//! Phase 1 is Stripe-only: the substrate provisions `cloudflare/workers` and +//! records a best-effort `*.workers.dev` origin. Deploying a service's source to +//! Workers (Wrangler/API) is deferred. +//! +//! **Not** the Cloudflare catalog integrations in `stackless-integrations` +//! (`cloudflare-r2`, `cloudflare-kv`, `cloudflare-workers` integration, etc.). + +use serde::Serialize; +use stackless_core::def::StackDef; + +use crate::SUBSTRATE_NAME; +use crate::error::CloudflareHostError; +use stackless_stripe_projects::CatalogService; + +/// A service's `[services.X.cloudflare]` block. Optional — an absent block is valid. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ServiceCloudflare {} + +/// The typed `cloudflare/workers` `--config`. Empty object is the catalog contract. +#[derive(Debug, Serialize)] +pub struct CloudflareWorkersConfig {} + +impl CatalogService for CloudflareWorkersConfig { + const REFERENCE: &'static str = "cloudflare/workers"; +} + +/// Read and shape-check `[services..cloudflare]` (optional block; unknown +/// keys inside it are a fault, to trap agent typos). +pub fn service_cloudflare( + def: &StackDef, + service: &str, +) -> Result { + let location = format!("services.{service}.cloudflare"); + let Some(block) = def + .services + .get(service) + .and_then(|spec| spec.substrates.get(SUBSTRATE_NAME)) + else { + return Ok(ServiceCloudflare::default()); + }; + let table = block + .as_table() + .ok_or_else(|| CloudflareHostError::ConfigInvalid { + location: location.clone(), + detail: "must be a table { env? }".into(), + })?; + for key in table.keys() { + if key.as_str() != "env" { + return Err(CloudflareHostError::ConfigInvalid { + location: location.clone(), + detail: format!("unknown key {key:?} (known: env)"), + }); + } + } + Ok(ServiceCloudflare::default()) +} + +/// Whether `name` is a legal Workers script label: lowercase letter then +/// 2..=62 of `[a-z0-9-]` (DNS-safe, matches the cloud name rule). +pub fn is_valid_worker_name(name: &str) -> bool { + let len = name.len(); + if !(3..=63).contains(&len) { + return false; + } + let mut chars = name.chars(); + match chars.next() { + Some(c) if c.is_ascii_lowercase() => {} + _ => return false, + } + chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(toml: &str) -> StackDef { + StackDef::parse(toml).expect("valid base toml") + } + + const BASE: &str = r#" +[stack] +name = "atto" +[services.web] +source = { repo = "https://github.com/snowmead/stackless", ref = "main" } +env = {} +health = { path = "/", contains = "ok" } +[services.web.cloudflare] +"#; + + #[test] + fn defaults_when_block_absent() { + let def = parse(BASE); + assert_eq!( + service_cloudflare(&def, "web").unwrap(), + ServiceCloudflare::default() + ); + let no_block = parse( + "[stack]\nname=\"atto\"\n[services.web]\nsource={repo=\"r\",ref=\"main\"}\nenv={}\nhealth={path=\"/\"}\n", + ); + assert_eq!( + service_cloudflare(&no_block, "web").unwrap(), + ServiceCloudflare::default() + ); + } + + #[test] + fn unknown_key_is_rejected() { + let toml = BASE.to_owned() + "bogus = 1\n"; + let err = service_cloudflare(&parse(&toml), "web").unwrap_err(); + assert_eq!( + stackless_core::fault::Fault::code(&err), + crate::codes::CLOUDFLARE_HOST_CONFIG_INVALID + ); + } + + #[test] + fn worker_name_pattern() { + assert!(is_valid_worker_name("atto-demo-web")); + assert!(!is_valid_worker_name("ab")); + assert!(!is_valid_worker_name("1abc")); + assert!(!is_valid_worker_name("Abc")); + assert!(!is_valid_worker_name(&"a".repeat(64))); + } + + #[test] + fn typed_config_carries_its_catalog_reference() { + assert_eq!(CloudflareWorkersConfig::REFERENCE, "cloudflare/workers"); + } + + /// Catalog gap check: the `cloudflare/workers` config must validate against the + /// committed catalog fixture. Fails loudly if Stripe drifts the schema. + #[test] + fn cloudflare_workers_config_matches_catalog() { + const FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../stackless-stripe-projects/tests/fixtures/catalog.json" + )); + let catalog = stackless_stripe_projects::Catalog::from_json_envelope(FIXTURE).unwrap(); + let failures = + stackless_stripe_projects::verify_service(&catalog, &CloudflareWorkersConfig {}); + assert!( + failures.is_empty(), + "cloudflare/workers catalog gaps:\n{}", + failures.join("\n") + ); + } +} diff --git a/crates/stackless-cloudflare/src/error.rs b/crates/stackless-cloudflare/src/error.rs new file mode 100644 index 0000000..9b725a2 --- /dev/null +++ b/crates/stackless-cloudflare/src/error.rs @@ -0,0 +1,80 @@ +//! Cloudflare Workers host-substrate errors (`cloudflare_host.*` registry). +//! +//! Distinct from `stackless-integrations` Cloudflare catalog resources (R2, KV, +//! D1, Workers-as-integration, etc.), which surface `integration.*` codes. + +use stackless_core::fault::{ErrorContext, Fault}; + +use crate::codes; + +#[derive(Debug, thiserror::Error)] +pub enum CloudflareHostError { + #[error("[{location}] is invalid: {detail}")] + ConfigInvalid { location: String, detail: String }, + + #[error("creating paid Cloudflare Workers resources requires explicit consent")] + PaymentNotConfirmed { resource: String }, + + #[error("provisioning {resource:?} on Cloudflare Workers did not complete: {detail}")] + ProvisionFailed { resource: String, detail: String }, + + #[error("prepare for {service:?} failed: {message}")] + PrepareFailed { + service: String, + command: Option, + message: String, + log_tail: Option, + }, +} + +impl Fault for CloudflareHostError { + fn code(&self) -> &'static str { + match self { + Self::ConfigInvalid { .. } => codes::CLOUDFLARE_HOST_CONFIG_INVALID, + Self::PaymentNotConfirmed { .. } => codes::CLOUDFLARE_HOST_PAYMENT_NOT_CONFIRMED, + Self::ProvisionFailed { .. } => codes::CLOUDFLARE_HOST_PROVISION_FAILED, + Self::PrepareFailed { .. } => codes::CLOUDFLARE_HOST_PREPARE_FAILED, + } + } + + fn remediation(&self) -> String { + match self { + Self::ConfigInvalid { location, .. } => { + format!( + "fix the [{location}] block; see ARCHITECTURE.md §1 for the cloudflare schema" + ) + } + Self::PaymentNotConfirmed { .. } => { + "re-run with --confirm-paid to consent to Cloudflare charges (bounded by the \ + project's hard spend cap)" + .into() + } + Self::ProvisionFailed { .. } => { + "wait a moment for Cloudflare to finish provisioning and re-run `up` to resume" + .into() + } + Self::PrepareFailed { service, .. } => format!( + "inspect context.log_tail; run the {service:?} prepare command by hand; re-run \ + `stackless up `" + ), + } + } + + fn context(&self) -> ErrorContext { + match self { + Self::PrepareFailed { + service, + command, + log_tail, + .. + } => ErrorContext { + service: Some(service.clone()), + command: command.clone(), + log_hint: Some(format!("stackless logs {service}")), + log_tail: log_tail.clone(), + ..ErrorContext::default() + }, + _ => ErrorContext::default(), + } + } +} diff --git a/crates/stackless-cloudflare/src/lib.rs b/crates/stackless-cloudflare/src/lib.rs new file mode 100644 index 0000000..ad3b005 --- /dev/null +++ b/crates/stackless-cloudflare/src/lib.rs @@ -0,0 +1,641 @@ +//! stackless-cloudflare (ARCHITECTURE.md §4): the Cloudflare Workers cloud substrate. +//! +//! **This crate is the `--on cloudflare` deploy substrate.** It is distinct from +//! Cloudflare catalog *integrations* in `stackless-integrations` (`cloudflare-r2`, +//! `cloudflare-kv`, `cloudflare-d1`, `cloudflare-workers` as an integration +//! resource, etc.). Those provision backing services and expose coordinates into +//! the namespace; this crate provisions `cloudflare/workers` per deployable +//! service and records a best-effort `*.workers.dev` origin. +//! +//! Mirrors the Railway/Netlify cloud flow at the Stripe layer: Stripe Projects +//! provisions `cloudflare/workers` and tracks spend; observe/destroy key off the +//! **Stripe resource registration**, not the Cloudflare API. One long-lived Stripe +//! project per stack holds each instance as a named environment. +//! +//! ## Credential model (pinned by `mise run discover cloudflare/workers`) +//! +//! Provisioning `cloudflare/workers` returns Stripe-managed outputs +//! (`CLOUDFLARE_ACCOUNT_ID`, `CLOUDFLARE_WORKERS_DEV_SUBDOMAIN`). The substrate +//! records them in the start checkpoint but does not call Cloudflare's API in +//! this phase. +//! +//! ## Phase 1 scope and API gaps +//! +//! - **Stripe provision only.** `start` provisions `cloudflare/workers` via Stripe +//! Projects and records a best-effort origin +//! `https://{stack}-{instance}-{service}.{subdomain}.workers.dev` when the +//! subdomain is known, else `https://{stack}-{instance}-{service}.workers.dev`. +//! There is no Wrangler deploy, no script upload, and no health polling against +//! a live URL yet. +//! - **Health gate is a no-op** until Workers deploy lands (documented here so +//! operators know smoke health checks will not pass on cloud). +//! - **Logs are unavailable** (`fetch_logs` returns `None`). +//! - **Cloud resource names** are `{stack}-{instance}-{service}` — DNS-safe. +//! - **Setup is skipped on cloud**; **prepare** runs on the operator's machine. +//! - **Source override is unsupported** — Workers deploy committed refs (when API +//! deploy is implemented). + +pub mod codes; +pub mod config; +pub mod error; + +use std::collections::BTreeMap; +use std::path::PathBuf; +use std::time::Duration; + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use stackless_core::def::{Namespace, StackDef}; +use stackless_core::engine::StepKind; +use stackless_core::state::Checkpoint; +use stackless_core::substrate::{ + NamespacePurpose, Observation, StepContext, StepResource, Substrate, SubstrateFault, +}; +use tokio::sync::Mutex; + +use crate::config::CloudflareWorkersConfig; +use crate::error::CloudflareHostError; +use stackless_stripe_projects::ProjectsError; +use stackless_stripe_projects::provision::{ProvisionContext, provision_outputs}; +use stackless_stripe_projects::stripe::{CommandRunner, StripeProjects, TokioRunner}; +use stackless_stripe_projects::{project, requires_confirmation}; + +pub const SUBSTRATE_NAME: &str = "cloudflare"; + +/// The hard per-provider spend cap set on first paid confirmation (§4). +pub const SPEND_CAP_USD: u32 = 25; + +/// The provider prefix Stripe uses for `cloudflare/workers` output env vars. +/// Pinned by `mise run discover cloudflare/workers`. +const PROVIDER_PREFIX: &str = "CLOUDFLARE"; + +/// Output field mapping shared with the Workers family in `stackless-integrations`. +/// Confirmed by live discovery 2026-06-16. +const WORKERS_OUTPUT_FIELDS: &[(&str, &str, bool)] = &[ + ("ACCOUNT_ID", "account_id", true), + ("WORKERS_DEV_SUBDOMAIN", "workers_dev_subdomain", true), +]; + +fn fault(err: CloudflareHostError) -> SubstrateFault { + SubstrateFault::from_fault(&err) +} + +fn projects_fault(err: ProjectsError) -> SubstrateFault { + SubstrateFault::from_fault(&err) +} + +fn integration_fault(err: stackless_integrations::IntegrationError) -> SubstrateFault { + SubstrateFault::from_fault(&err) +} + +/// Map the shared prepare helper's neutral failure to Cloudflare's fault so its +/// `cloudflare_host.*` code and remediation hold (§2). +fn prepare_fault(f: stackless_cloud::prepare::PrepareFailure) -> SubstrateFault { + fault(CloudflareHostError::PrepareFailed { + service: f.service, + command: f.command, + message: f.message, + log_tail: f.log_tail, + }) +} + +/// What a `start:` checkpoint records. Observe/destroy use Stripe, not +/// the Cloudflare API. +#[derive(Debug, Serialize, Deserialize)] +struct CloudflarePayload { + stripe_resource: String, + account_id: String, + workers_dev_subdomain: Option, + worker_name: String, + origin: String, +} + +/// What a `materialize:` checkpoint records: the pinned source. Owns +/// nothing locally, so observe reports Gone and resume cheaply re-records it. +#[derive(Debug, Serialize, Deserialize)] +struct SourceRefPayload { + repo: String, + #[serde(rename = "ref")] + reference: String, +} + +/// The Cloudflare Workers substrate. Generic over the command runner so tests +/// inject canned Stripe envelopes; production uses the real `stripe` binary. +pub struct CloudflareSubstrate { + pub definition_dir: PathBuf, + pub secrets: BTreeMap, + pub confirm_paid: bool, + runner: R, + ensured: Mutex, +} + +impl std::fmt::Debug for CloudflareSubstrate { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CloudflareSubstrate") + .field("definition_dir", &self.definition_dir) + .field("confirm_paid", &self.confirm_paid) + .finish_non_exhaustive() + } +} + +impl CloudflareSubstrate { + pub fn new( + definition_dir: impl Into, + secrets: BTreeMap, + confirm_paid: bool, + ) -> Self { + Self { + definition_dir: definition_dir.into(), + secrets, + confirm_paid, + runner: TokioRunner, + ensured: Mutex::new(false), + } + } +} + +impl CloudflareSubstrate { + #[cfg(test)] + fn for_test(runner: R, definition_dir: impl Into, confirm_paid: bool) -> Self { + Self { + definition_dir: definition_dir.into(), + secrets: BTreeMap::new(), + confirm_paid, + runner, + ensured: Mutex::new(false), + } + } + + fn stripe(&self) -> StripeProjects<&R> { + StripeProjects::new(&self.runner, self.definition_dir.clone()) + } + + /// `{stack}-{instance}-{service}` (DNS-safe). + fn resource_name(def: &StackDef, instance: &str, node: &str) -> String { + format!("{}-{instance}-{node}", def.stack.name.as_str()) + } + + /// Best-effort `*.workers.dev` origin. Uses the account subdomain when known. + fn origin(worker_name: &str, workers_dev_subdomain: Option<&str>) -> String { + match workers_dev_subdomain.filter(|s| !s.is_empty()) { + Some(subdomain) => format!("https://{worker_name}.{subdomain}.workers.dev"), + None => format!("https://{worker_name}.workers.dev"), + } + } + + fn namespace(&self, def: &StackDef, instance: &str, prior: &[Checkpoint]) -> Namespace { + let mut namespace = Namespace { + stack_name: def.stack.name.clone(), + instance_name: stackless_core::types::DnsName::from_stored(instance), + ..Namespace::default() + }; + for service in def.services.keys() { + let name = Self::resource_name(def, instance, service); + namespace + .service_origins + .insert(service.clone(), Self::origin(&name, None)); + } + namespace.secrets = self.secrets.clone(); + namespace.add_integration_checkpoints(prior); + namespace + } + + async fn ensure_project_and_env( + &self, + def: &StackDef, + instance: &str, + ) -> Result<(), SubstrateFault> { + let mut done = self.ensured.lock().await; + if *done { + return Ok(()); + } + let spend = self.confirm_paid.then_some((SPEND_CAP_USD, "cloudflare")); + stackless_cloud::ensure::project_and_env( + &self.stripe(), + def, + &self.definition_dir, + instance, + spend, + ) + .await + .map_err(projects_fault)?; + *done = true; + Ok(()) + } + + fn require_confirm_paid(&self, resource: &str) -> Result<(), SubstrateFault> { + if !self.confirm_paid { + return Err(fault(CloudflareHostError::PaymentNotConfirmed { + resource: resource.to_owned(), + })); + } + Ok(()) + } + + async fn start_service( + &self, + def: &StackDef, + instance: &str, + service: &str, + ) -> Result { + let _cloudflare_cfg = config::service_cloudflare(def, service).map_err(fault)?; + let worker_name = Self::resource_name(def, instance, service); + let resource = format!("{instance}-{service}"); + + let catalog = self.stripe().catalog().await.map_err(projects_fault)?; + let cfg = CloudflareWorkersConfig {}; + if requires_confirmation(&catalog, &cfg).unwrap_or(false) { + self.require_confirm_paid(&resource)?; + } + let ctx = ProvisionContext { + def, + instance, + logical_name: service, + definition_dir: &self.definition_dir, + substrate: SUBSTRATE_NAME, + skip_instance_context: true, + }; + let (_resource_name, outputs) = provision_outputs( + &self.stripe(), + &catalog, + &ctx, + &cfg, + PROVIDER_PREFIX, + WORKERS_OUTPUT_FIELDS, + ) + .await + .map_err(projects_fault)?; + let account_id = outputs.get("account_id").ok_or_else(|| { + fault(CloudflareHostError::ProvisionFailed { + resource: resource.clone(), + detail: "cloudflare/workers did not return an account id".into(), + }) + })?; + let workers_dev_subdomain = outputs.get("workers_dev_subdomain").cloned(); + + let payload = CloudflarePayload { + stripe_resource: resource, + account_id: account_id.clone(), + workers_dev_subdomain: workers_dev_subdomain.clone(), + worker_name: worker_name.clone(), + origin: Self::origin(&worker_name, workers_dev_subdomain.as_deref()), + }; + Ok(StepResource { + resource_kind: "cloudflare-worker".into(), + resource_id: worker_name, + payload: serde_json::to_string(&payload).unwrap_or_default(), + }) + } + + async fn run_prepare( + &self, + def: &StackDef, + instance: &str, + service: &str, + prior: &[Checkpoint], + ) -> Result<(), SubstrateFault> { + let Some(spec) = def.services.get(service) else { + return Ok(()); + }; + let namespace = self.namespace(def, instance, prior); + stackless_cloud::prepare::run_service_prepare( + &namespace, + &self.secrets, + service, + SUBSTRATE_NAME, + spec, + ) + .await + .map_err(prepare_fault) + } +} + +#[async_trait] +impl Substrate for CloudflareSubstrate { + fn name(&self) -> &str { + SUBSTRATE_NAME + } + + fn validate_definition(&self, def: &StackDef) -> Result<(), SubstrateFault> { + for service in def.services.keys() { + config::service_cloudflare(def, service).map_err(fault)?; + let worker_name = Self::resource_name(def, "i", service); + if !config::is_valid_worker_name(&worker_name) { + return Err(fault(CloudflareHostError::ConfigInvalid { + location: format!("services.{service}"), + detail: format!( + "derived Cloudflare worker name {worker_name:?} is not DNS-safe; \ + shorten the stack/service name" + ), + })); + } + } + Ok(()) + } + + fn supports_source_override(&self) -> bool { + false + } + + fn default_lease(&self) -> Duration { + Duration::from_secs(8 * 3600) + } + + fn service_origin(&self, def: &StackDef, instance: &str, service: &str) -> String { + Self::origin(&Self::resource_name(def, instance, service), None) + } + + fn build_namespace( + &self, + def: &StackDef, + instance: &str, + prior: &[Checkpoint], + secrets: &BTreeMap, + _purpose: NamespacePurpose, + ) -> Namespace { + let mut namespace = self.namespace(def, instance, prior); + namespace.secrets = secrets.clone(); + namespace + } + + async fn execute(&self, ctx: StepContext<'_>) -> Result { + self.ensure_project_and_env(ctx.def, ctx.instance).await?; + + let node = ctx.step.node.as_str(); + match ctx.step.kind { + StepKind::ProvisionIntegration => stackless_integrations::provision( + SUBSTRATE_NAME, + &self.stripe(), + ctx.def, + &self.definition_dir, + ctx.instance, + node, + true, + ) + .await + .map_err(integration_fault), + StepKind::Materialize => { + let spec = ctx.def.services.get(node).ok_or_else(|| { + fault(CloudflareHostError::ConfigInvalid { + location: format!("services.{node}"), + detail: "service not in definition".into(), + }) + })?; + let payload = SourceRefPayload { + repo: spec.source.repo.clone(), + reference: spec.source.reference.clone(), + }; + Ok(StepResource { + resource_kind: "source-ref".into(), + resource_id: format!("{}@{}", spec.source.repo, spec.source.reference), + payload: serde_json::to_string(&payload).unwrap_or_default(), + }) + } + StepKind::Setup => Ok(stackless_core::substrate::action_resource(&ctx.step.id)), + StepKind::Prepare => { + self.run_prepare(ctx.def, ctx.instance, node, ctx.prior) + .await?; + Ok(stackless_core::substrate::action_resource(&ctx.step.id)) + } + StepKind::Start => self.start_service(ctx.def, ctx.instance, node).await, + // Wrangler deploy + health polling deferred — see module docs. + StepKind::HealthGate => Ok(stackless_core::substrate::action_resource(&ctx.step.id)), + } + } + + async fn observe( + &self, + _instance: &str, + checkpoint: &Checkpoint, + ) -> Result { + match checkpoint.resource_kind.as_str() { + "cloudflare-worker" => { + let payload = stackless_cloud::checkpoint::parse_payload::( + &checkpoint.payload, + ) + .map_err(|detail| { + fault(CloudflareHostError::ConfigInvalid { + location: "checkpoint.payload".into(), + detail, + }) + })?; + let stripe_resource = payload + .map(|p| p.stripe_resource) + .unwrap_or_else(|| checkpoint.resource_id.clone()); + let present = project::resource_registered(&self.stripe(), &stripe_resource) + .await + .map_err(projects_fault)?; + Ok(stackless_core::substrate::present_or_gone(present)) + } + kind if stackless_integrations::is_integration_resource(kind) => { + stackless_integrations::observe( + SUBSTRATE_NAME, + &self.stripe(), + &checkpoint.payload, + &checkpoint.resource_id, + kind, + ) + .await + .map_err(integration_fault) + } + kind if stackless_cloud::checkpoint::is_ephemeral_resource_kind(kind) => { + Ok(Observation::Gone) + } + kind => Err(fault(CloudflareHostError::ConfigInvalid { + location: "checkpoint.resource_kind".into(), + detail: format!("unknown resource kind {kind:?}"), + })), + } + } + + async fn destroy( + &self, + _instance: &str, + checkpoint: &Checkpoint, + ) -> Result<(), SubstrateFault> { + match checkpoint.resource_kind.as_str() { + "cloudflare-worker" => { + let payload = stackless_cloud::checkpoint::parse_payload::( + &checkpoint.payload, + ) + .map_err(|detail| { + fault(CloudflareHostError::ConfigInvalid { + location: "checkpoint.payload".into(), + detail, + }) + })?; + let stripe_resource = payload + .map(|p| p.stripe_resource) + .unwrap_or_else(|| checkpoint.resource_id.clone()); + project::remove_resource(&self.stripe(), &stripe_resource) + .await + .map_err(projects_fault) + } + kind if stackless_integrations::is_integration_resource(kind) => { + stackless_integrations::destroy( + SUBSTRATE_NAME, + &self.stripe(), + &checkpoint.payload, + &checkpoint.resource_id, + kind, + ) + .await + .map_err(integration_fault) + } + kind if stackless_cloud::checkpoint::is_ephemeral_resource_kind(kind) => Ok(()), + kind => Err(fault(CloudflareHostError::ConfigInvalid { + location: "checkpoint.resource_kind".into(), + detail: format!("unknown resource kind {kind:?}"), + })), + } + } + + async fn finalize_teardown(&self, instance: &str) -> Result<(), SubstrateFault> { + stackless_integrations::finalize_stripe_instance(&self.stripe(), instance).await; + Ok(()) + } + + async fn spend(&self) -> Option { + Some( + stackless_cloud::spend::fetch( + &self.definition_dir, + SUBSTRATE_NAME, + SPEND_CAP_USD, + "workers.dev", + ) + .await, + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use stackless_stripe_projects::stripe::{CommandOutput, CommandRunner}; + use stackless_stripe_projects::test_support; + use std::path::Path as StdPath; + + struct NoRunner; + #[async_trait] + impl CommandRunner for NoRunner { + async fn run( + &self, + _args: &[String], + _cwd: &StdPath, + ) -> Result { + Err(ProjectsError::Unavailable { + detail: "stripe should not be called in this test".into(), + }) + } + } + + fn checkpoint(kind: &str, step_id: &str, payload: &str) -> Checkpoint { + Checkpoint { + instance: "demo".into(), + step_id: step_id.into(), + resource_kind: kind.into(), + resource_id: "atto-demo-web".into(), + payload: payload.into(), + recorded_at: 0, + } + } + + fn cloudflare_def() -> StackDef { + StackDef::parse( + "[stack]\nname=\"atto\"\n[services.web]\nsource={repo=\"r\",ref=\"main\"}\nenv={}\nhealth={path=\"/\"}\n", + ) + .unwrap() + } + + fn subj() -> (tempfile::TempDir, CloudflareSubstrate) { + let dir = tempfile::tempdir().unwrap(); + let s = CloudflareSubstrate::for_test(NoRunner, dir.path(), false); + (dir, s) + } + + const PAYLOAD: &str = r#"{"stripe_resource":"demo-web","account_id":"acc_1","workers_dev_subdomain":"atto-demo","worker_name":"atto-demo-web","origin":"https://atto-demo-web.atto-demo.workers.dev"}"#; + + #[test] + fn resource_name_and_origin_are_dns_safe() { + let def = cloudflare_def(); + assert_eq!( + CloudflareSubstrate::::resource_name(&def, "demo", "web"), + "atto-demo-web" + ); + let (_dir, s) = subj(); + assert_eq!( + s.service_origin(&def, "demo", "web"), + "https://atto-demo-web.workers.dev" + ); + assert_eq!( + CloudflareSubstrate::::origin("atto-demo-web", Some("atto-demo")), + "https://atto-demo-web.atto-demo.workers.dev" + ); + } + + #[test] + fn cloudflare_substrate_defaults() { + let s = CloudflareSubstrate::new(std::env::temp_dir(), Default::default(), false); + assert_eq!(s.name(), "cloudflare"); + assert!(!s.supports_source_override()); + assert_eq!(s.default_lease(), Duration::from_secs(8 * 3600)); + } + + #[tokio::test] + async fn service_present_when_stripe_registers_it() { + let runner = test_support::ScriptedRunner::new(vec![test_support::services(&["demo-web"])]); + let dir = tempfile::tempdir().unwrap(); + let s = CloudflareSubstrate::for_test(&runner, dir.path(), false); + let cp = checkpoint("cloudflare-worker", "start:web", PAYLOAD); + assert_eq!(s.observe("demo", &cp).await.unwrap(), Observation::Present); + } + + #[tokio::test] + async fn service_gone_when_stripe_does_not_register_it() { + let runner = test_support::ScriptedRunner::new(vec![test_support::services(&[])]); + let dir = tempfile::tempdir().unwrap(); + let s = CloudflareSubstrate::for_test(&runner, dir.path(), false); + let cp = checkpoint("cloudflare-worker", "start:web", PAYLOAD); + assert_eq!(s.observe("demo", &cp).await.unwrap(), Observation::Gone); + } + + #[tokio::test] + async fn source_ref_observes_gone_so_teardown_drops_it() { + let (_dir, s) = subj(); + let cp = checkpoint( + "source-ref", + "materialize:web", + r#"{"repo":"r","ref":"main"}"#, + ); + assert_eq!(s.observe("demo", &cp).await.unwrap(), Observation::Gone); + s.destroy("demo", &cp).await.unwrap(); + } + + #[tokio::test] + async fn unknown_resource_kind_fails_closed() { + let (_dir, s) = subj(); + let cp = checkpoint("not-a-real-kind", "start:web", "{}"); + assert!(s.observe("demo", &cp).await.is_err()); + assert!(s.destroy("demo", &cp).await.is_err()); + } + + #[tokio::test] + async fn teardown_removes_via_stripe() { + let runner = test_support::ScriptedRunner::new(vec![ + test_support::services(&["demo-web"]), + test_support::ok_empty(), + ]); + let dir = tempfile::tempdir().unwrap(); + let s = CloudflareSubstrate::for_test(&runner, dir.path(), false); + let cp = checkpoint("cloudflare-worker", "start:web", PAYLOAD); + s.destroy("demo", &cp).await.unwrap(); + let calls = runner.calls(); + assert!( + calls + .iter() + .any(|c| c.first().map(String::as_str) == Some("remove") + && c.iter().any(|a| a == "demo-web")), + "expected a `remove demo-web` call, got {calls:?}" + ); + } +} diff --git a/crates/stackless-gitlab/Cargo.toml b/crates/stackless-gitlab/Cargo.toml new file mode 100644 index 0000000..3740a66 --- /dev/null +++ b/crates/stackless-gitlab/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "stackless-gitlab" +edition.workspace = true +version.workspace = true +license.workspace = true +repository.workspace = true + +[lints] +workspace = true + +[dependencies] +async-trait = "0.1.89" +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.150" +stackless-cloud.workspace = true +stackless-core.workspace = true +stackless-integrations.workspace = true +stackless-stripe-projects.workspace = true +thiserror = "2.0.18" +tokio = { version = "1.52.3", features = ["rt", "time", "macros", "sync"] } + +[dev-dependencies] +stackless-stripe-projects = { workspace = true, features = ["test-support"] } +tempfile = "3.27.0" diff --git a/crates/stackless-gitlab/src/codes.rs b/crates/stackless-gitlab/src/codes.rs new file mode 100644 index 0000000..29a2bd4 --- /dev/null +++ b/crates/stackless-gitlab/src/codes.rs @@ -0,0 +1,17 @@ +//! Stable error codes for the GitLab substrate (ARCHITECTURE.md §2/§8). +//! +//! Codes live with the provider, not in core. The binary aggregates every +//! crate's `ALL` for a workspace-wide uniqueness check. + +pub const GITLAB_CONFIG_INVALID: &str = "gitlab.config.invalid"; +pub const GITLAB_PAYMENT_NOT_CONFIRMED: &str = "gitlab.payment.not_confirmed"; +pub const GITLAB_PROVISION_FAILED: &str = "gitlab.provision.failed"; +pub const GITLAB_PREPARE_FAILED: &str = "gitlab.prepare.failed"; + +/// Every GitLab code, for the workspace uniqueness test. +pub const ALL: &[&str] = &[ + GITLAB_CONFIG_INVALID, + GITLAB_PAYMENT_NOT_CONFIRMED, + GITLAB_PROVISION_FAILED, + GITLAB_PREPARE_FAILED, +]; diff --git a/crates/stackless-gitlab/src/config.rs b/crates/stackless-gitlab/src/config.rs new file mode 100644 index 0000000..9da93c3 --- /dev/null +++ b/crates/stackless-gitlab/src/config.rs @@ -0,0 +1,190 @@ +//! Parsing the gitlab-specific blocks of the definition (§1 schema). +//! +//! Phase 1 is Stripe-only: the substrate provisions `gitlab/project` and records +//! a best-effort origin. Deploying source to GitLab (Pages, CI pipeline trigger, +//! container registry push) via the REST API is deferred — see `lib.rs`. + +use serde::Serialize; +use stackless_core::def::StackDef; + +use crate::SUBSTRATE_NAME; +use crate::error::GitLabError; +use stackless_stripe_projects::CatalogService; + +/// A service's `[services.X.gitlab]` block. Optional — an absent block uses +/// defaults (`visibility = "private"`). +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ServiceGitlab { + /// Catalog `visibility` override (`private` or `public`). + pub visibility: Option, +} + +/// The typed `gitlab/project` `--config`. `name` and `visibility` are the +/// catalog contract — the gap test pins them. +#[derive(Debug, Serialize)] +pub struct GitLabProjectConfig { + pub name: String, + pub visibility: String, +} + +impl CatalogService for GitLabProjectConfig { + const REFERENCE: &'static str = "gitlab/project"; +} + +/// Read and shape-check `[services..gitlab]` (optional block; unknown +/// keys inside it are a fault, to trap agent typos). +pub fn service_gitlab(def: &StackDef, service: &str) -> Result { + let location = format!("services.{service}.gitlab"); + let Some(block) = def + .services + .get(service) + .and_then(|spec| spec.substrates.get(SUBSTRATE_NAME)) + else { + return Ok(ServiceGitlab::default()); + }; + let table = block.as_table().ok_or_else(|| GitLabError::ConfigInvalid { + location: location.clone(), + detail: "must be a table { visibility?, env? }".into(), + })?; + for key in table.keys() { + if !matches!(key.as_str(), "visibility" | "env") { + return Err(GitLabError::ConfigInvalid { + location: location.clone(), + detail: format!("unknown key {key:?} (known: visibility, env)"), + }); + } + } + let visibility = match table.get("visibility") { + None => None, + Some(value) => { + let vis = value + .as_str() + .filter(|s| !s.trim().is_empty()) + .ok_or_else(|| GitLabError::ConfigInvalid { + location: format!("{location}.visibility"), + detail: "must be a non-empty string".into(), + })?; + if !is_valid_visibility(vis) { + return Err(GitLabError::ConfigInvalid { + location: format!("{location}.visibility"), + detail: format!("must be \"private\" or \"public\", got {vis:?}"), + }); + } + Some(vis.to_owned()) + } + }; + Ok(ServiceGitlab { visibility }) +} + +/// Catalog enum for `gitlab/project` visibility. +pub fn is_valid_visibility(visibility: &str) -> bool { + matches!(visibility, "private" | "public") +} + +/// Whether `name` is a legal GitLab project path segment label: a lowercase +/// letter then 2..=62 of `[a-z0-9-]` (DNS-safe, matches the cloud name rule). +pub fn is_valid_project_name(name: &str) -> bool { + let len = name.len(); + if !(3..=63).contains(&len) { + return false; + } + let mut chars = name.chars(); + match chars.next() { + Some(c) if c.is_ascii_lowercase() => {} + _ => return false, + } + chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(toml: &str) -> StackDef { + StackDef::parse(toml).expect("valid base toml") + } + + const BASE: &str = r#" +[stack] +name = "atto" +[services.web] +source = { repo = "https://github.com/snowmead/stackless", ref = "main" } +env = {} +health = { path = "/", contains = "ok" } +[services.web.gitlab] +visibility = "private" +"#; + + #[test] + fn parses_visibility_and_defaults_when_block_absent() { + let def = parse(BASE); + assert_eq!( + service_gitlab(&def, "web").unwrap().visibility.as_deref(), + Some("private") + ); + let no_block = parse( + "[stack]\nname=\"atto\"\n[services.web]\nsource={repo=\"r\",ref=\"main\"}\nenv={}\nhealth={path=\"/\"}\n", + ); + assert_eq!( + service_gitlab(&no_block, "web").unwrap(), + ServiceGitlab::default() + ); + } + + #[test] + fn unknown_key_is_rejected() { + let toml = BASE.replace("visibility = \"private\"", "bogus = 1"); + let err = service_gitlab(&parse(&toml), "web").unwrap_err(); + assert_eq!( + stackless_core::fault::Fault::code(&err), + crate::codes::GITLAB_CONFIG_INVALID + ); + } + + #[test] + fn invalid_visibility_is_rejected() { + let toml = BASE.replace("visibility = \"private\"", "visibility = \"internal\""); + let err = service_gitlab(&parse(&toml), "web").unwrap_err(); + assert_eq!( + stackless_core::fault::Fault::code(&err), + crate::codes::GITLAB_CONFIG_INVALID + ); + } + + #[test] + fn project_name_pattern() { + assert!(is_valid_project_name("atto-demo-web")); + assert!(!is_valid_project_name("ab")); + assert!(!is_valid_project_name("1abc")); + assert!(!is_valid_project_name("Abc")); + assert!(!is_valid_project_name(&"a".repeat(64))); + } + + #[test] + fn typed_config_carries_its_catalog_reference() { + assert_eq!(GitLabProjectConfig::REFERENCE, "gitlab/project"); + } + + /// Catalog gap check: the `gitlab/project` config must validate against the + /// committed catalog fixture. Fails loudly if Stripe drifts the schema. + #[test] + fn gitlab_config_matches_catalog() { + const FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../stackless-stripe-projects/tests/fixtures/catalog.json" + )); + let catalog = stackless_stripe_projects::Catalog::from_json_envelope(FIXTURE).unwrap(); + let failures = stackless_stripe_projects::verify_service( + &catalog, + &GitLabProjectConfig { + name: "atto-demo-web".into(), + visibility: "private".into(), + }, + ); + assert!( + failures.is_empty(), + "gitlab/project catalog gaps:\n{}", + failures.join("\n") + ); + } +} diff --git a/crates/stackless-gitlab/src/error.rs b/crates/stackless-gitlab/src/error.rs new file mode 100644 index 0000000..e00bd3b --- /dev/null +++ b/crates/stackless-gitlab/src/error.rs @@ -0,0 +1,74 @@ +//! GitLab-substrate errors (codes in this crate's `gitlab.*` registry). + +use stackless_core::fault::{ErrorContext, Fault}; + +use crate::codes; + +#[derive(Debug, thiserror::Error)] +pub enum GitLabError { + #[error("[{location}] is invalid: {detail}")] + ConfigInvalid { location: String, detail: String }, + + #[error("creating paid GitLab resources requires explicit consent")] + PaymentNotConfirmed { resource: String }, + + #[error("provisioning {resource:?} on GitLab did not complete: {detail}")] + ProvisionFailed { resource: String, detail: String }, + + #[error("prepare for {service:?} failed: {message}")] + PrepareFailed { + service: String, + command: Option, + message: String, + log_tail: Option, + }, +} + +impl Fault for GitLabError { + fn code(&self) -> &'static str { + match self { + Self::ConfigInvalid { .. } => codes::GITLAB_CONFIG_INVALID, + Self::PaymentNotConfirmed { .. } => codes::GITLAB_PAYMENT_NOT_CONFIRMED, + Self::ProvisionFailed { .. } => codes::GITLAB_PROVISION_FAILED, + Self::PrepareFailed { .. } => codes::GITLAB_PREPARE_FAILED, + } + } + + fn remediation(&self) -> String { + match self { + Self::ConfigInvalid { location, .. } => { + format!("fix the [{location}] block; see ARCHITECTURE.md §1 for the gitlab schema") + } + Self::PaymentNotConfirmed { .. } => { + "re-run with --confirm-paid to consent to GitLab charges (bounded by the \ + project's hard spend cap)" + .into() + } + Self::ProvisionFailed { .. } => { + "wait a moment for GitLab to finish provisioning and re-run `up` to resume".into() + } + Self::PrepareFailed { service, .. } => format!( + "inspect context.log_tail; run the {service:?} prepare command by hand; re-run \ + `stackless up `" + ), + } + } + + fn context(&self) -> ErrorContext { + match self { + Self::PrepareFailed { + service, + command, + log_tail, + .. + } => ErrorContext { + service: Some(service.clone()), + command: command.clone(), + log_hint: Some(format!("stackless logs {service}")), + log_tail: log_tail.clone(), + ..ErrorContext::default() + }, + _ => ErrorContext::default(), + } + } +} diff --git a/crates/stackless-gitlab/src/lib.rs b/crates/stackless-gitlab/src/lib.rs new file mode 100644 index 0000000..9bcf7bd --- /dev/null +++ b/crates/stackless-gitlab/src/lib.rs @@ -0,0 +1,639 @@ +//! stackless-gitlab (ARCHITECTURE.md §4): the GitLab cloud substrate. +//! +//! Mirrors the Render/Vercel/Fly/Netlify cloud flow at the Stripe layer: +//! Stripe Projects provisions `gitlab/project` and tracks spend; the GitLab REST +//! API would fill deploy gaps in a later phase. One long-lived Stripe project per +//! stack holds each instance as a named environment. +//! +//! ## Credential model (pinned by `mise run discover gitlab/project`) +//! +//! Provisioning `gitlab/project` returns Stripe-managed outputs (`PROJECT_ID`, +//! optional `WEB_URL`). The substrate reads them at `start` and records a +//! best-effort origin. Because credentials are ephemeral, `observe`/`destroy` +//! key off the **Stripe resource registration**, not the GitLab API. +//! +//! ## v0 scope and REST gaps +//! +//! - **Stripe-only deploy.** `start` provisions the catalog project and records +//! an origin placeholder (`WEB_URL` when present, else +//! `https://gitlab.com/{name}`). No GitLab REST client yet — Pages deploy, +//! CI pipeline trigger, and container-registry push are deferred. +//! - **Health gate is a no-op** until a real deploy surface exists. +//! - **Cloud resource names** are `{stack}-{instance}-{service}` — DNS-safe. +//! - **Setup is skipped on cloud**; **prepare** runs on the operator's machine. +//! - **Source override is unsupported** — GitLab deploys committed refs. + +pub mod codes; +pub mod config; +pub mod error; + +use std::collections::BTreeMap; +use std::path::PathBuf; +use std::time::Duration; + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use stackless_core::def::{Namespace, StackDef}; +use stackless_core::engine::StepKind; +use stackless_core::state::Checkpoint; +use stackless_core::substrate::{ + NamespacePurpose, Observation, ServiceLog, StepContext, StepResource, Substrate, SubstrateFault, +}; +use tokio::sync::Mutex; + +use crate::config::GitLabProjectConfig; +use crate::error::GitLabError; +use stackless_stripe_projects::ProjectsError; +use stackless_stripe_projects::provision::{ProvisionContext, provision_outputs}; +use stackless_stripe_projects::stripe::{CommandRunner, StripeProjects, TokioRunner}; +use stackless_stripe_projects::{project, requires_confirmation}; + +pub const SUBSTRATE_NAME: &str = "gitlab"; + +/// The hard per-provider spend cap set on first paid confirmation (§4). +pub const SPEND_CAP_USD: u32 = 25; + +/// The provider prefix Stripe uses for `gitlab/project` output env vars. +/// Pinned by `mise run discover gitlab/project`. +const PROVIDER_PREFIX: &str = "GITLAB"; + +fn fault(err: GitLabError) -> SubstrateFault { + SubstrateFault::from_fault(&err) +} + +fn projects_fault(err: ProjectsError) -> SubstrateFault { + SubstrateFault::from_fault(&err) +} + +fn integration_fault(err: stackless_integrations::IntegrationError) -> SubstrateFault { + SubstrateFault::from_fault(&err) +} + +/// Map the shared prepare helper's neutral failure to GitLab's fault so its +/// `gitlab.*` code and remediation hold (§2). +fn prepare_fault(f: stackless_cloud::prepare::PrepareFailure) -> SubstrateFault { + fault(GitLabError::PrepareFailed { + service: f.service, + command: f.command, + message: f.message, + log_tail: f.log_tail, + }) +} + +/// What a `start:` checkpoint records: the live GitLab project. Tokens +/// are intentionally NOT stored — observe/destroy use Stripe. +#[derive(Debug, Serialize, Deserialize)] +struct GitLabPayload { + stripe_resource: String, + project_id: String, + project_name: String, + origin: String, +} + +/// What a `materialize:` checkpoint records: the pinned source. Owns +/// nothing locally, so observe reports Gone and resume cheaply re-records it. +#[derive(Debug, Serialize, Deserialize)] +struct SourceRefPayload { + repo: String, + #[serde(rename = "ref")] + reference: String, +} + +/// The GitLab substrate. Generic over the command runner so tests inject canned +/// Stripe envelopes; production uses the real `stripe` binary. +pub struct GitLabSubstrate { + pub definition_dir: PathBuf, + pub secrets: BTreeMap, + pub confirm_paid: bool, + runner: R, + ensured: Mutex, +} + +impl std::fmt::Debug for GitLabSubstrate { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("GitLabSubstrate") + .field("definition_dir", &self.definition_dir) + .field("confirm_paid", &self.confirm_paid) + .finish_non_exhaustive() + } +} + +impl GitLabSubstrate { + pub fn new( + definition_dir: impl Into, + secrets: BTreeMap, + confirm_paid: bool, + ) -> Self { + Self { + definition_dir: definition_dir.into(), + secrets, + confirm_paid, + runner: TokioRunner, + ensured: Mutex::new(false), + } + } +} + +impl GitLabSubstrate { + #[cfg(test)] + fn for_test(runner: R, definition_dir: impl Into, confirm_paid: bool) -> Self { + Self { + definition_dir: definition_dir.into(), + secrets: BTreeMap::new(), + confirm_paid, + runner, + ensured: Mutex::new(false), + } + } + + fn stripe(&self) -> StripeProjects<&R> { + StripeProjects::new(&self.runner, self.definition_dir.clone()) + } + + /// `{stack}-{instance}-{service}` (DNS-safe; a legal GitLab project name). + fn resource_name(def: &StackDef, instance: &str, node: &str) -> String { + format!("{}-{instance}-{node}", def.stack.name.as_str()) + } + + /// Best-effort origin before `WEB_URL` is known — documented placeholder. + fn origin_placeholder(project_name: &str) -> String { + format!("https://gitlab.com/{project_name}") + } + + fn namespace(&self, def: &StackDef, instance: &str, prior: &[Checkpoint]) -> Namespace { + let mut namespace = Namespace { + stack_name: def.stack.name.clone(), + instance_name: stackless_core::types::DnsName::from_stored(instance), + ..Namespace::default() + }; + for service in def.services.keys() { + let name = Self::resource_name(def, instance, service); + namespace + .service_origins + .insert(service.clone(), Self::origin_placeholder(&name)); + } + namespace.secrets = self.secrets.clone(); + namespace.add_integration_checkpoints(prior); + namespace + } + + async fn ensure_project_and_env( + &self, + def: &StackDef, + instance: &str, + ) -> Result<(), SubstrateFault> { + let mut done = self.ensured.lock().await; + if *done { + return Ok(()); + } + let spend = self.confirm_paid.then_some((SPEND_CAP_USD, "gitlab")); + stackless_cloud::ensure::project_and_env( + &self.stripe(), + def, + &self.definition_dir, + instance, + spend, + ) + .await + .map_err(projects_fault)?; + *done = true; + Ok(()) + } + + fn require_confirm_paid(&self, resource: &str) -> Result<(), SubstrateFault> { + if !self.confirm_paid { + return Err(fault(GitLabError::PaymentNotConfirmed { + resource: resource.to_owned(), + })); + } + Ok(()) + } + + async fn start_service( + &self, + def: &StackDef, + instance: &str, + service: &str, + ) -> Result { + let gitlab_cfg = config::service_gitlab(def, service).map_err(fault)?; + let project_name = Self::resource_name(def, instance, service); + let resource = format!("{instance}-{service}"); + let visibility = gitlab_cfg + .visibility + .unwrap_or_else(|| "private".to_owned()); + + let catalog = self.stripe().catalog().await.map_err(projects_fault)?; + let cfg = GitLabProjectConfig { + name: project_name.clone(), + visibility, + }; + if requires_confirmation(&catalog, &cfg).unwrap_or(false) { + self.require_confirm_paid(&resource)?; + } + let ctx = ProvisionContext { + def, + instance, + logical_name: service, + definition_dir: &self.definition_dir, + substrate: SUBSTRATE_NAME, + skip_instance_context: true, + }; + let (_resource_name, outputs) = provision_outputs( + &self.stripe(), + &catalog, + &ctx, + &cfg, + PROVIDER_PREFIX, + // The exact output suffixes pinned by `mise run discover gitlab/project`. + &[ + ("PROJECT_ID", "project_id", true), + ("WEB_URL", "web_url", false), + ], + ) + .await + .map_err(projects_fault)?; + let project_id = outputs.get("project_id").ok_or_else(|| { + fault(GitLabError::ProvisionFailed { + resource: resource.clone(), + detail: "gitlab/project did not return a project id".into(), + }) + })?; + let origin = outputs + .get("web_url") + .filter(|url| !url.trim().is_empty()) + .cloned() + .unwrap_or_else(|| Self::origin_placeholder(&project_name)); + + let payload = GitLabPayload { + stripe_resource: resource, + project_id: project_id.clone(), + project_name: project_name.clone(), + origin, + }; + Ok(StepResource { + resource_kind: "gitlab-project".into(), + resource_id: project_name, + payload: serde_json::to_string(&payload).unwrap_or_default(), + }) + } + + async fn run_prepare( + &self, + def: &StackDef, + instance: &str, + service: &str, + prior: &[Checkpoint], + ) -> Result<(), SubstrateFault> { + let Some(spec) = def.services.get(service) else { + return Ok(()); + }; + let namespace = self.namespace(def, instance, prior); + stackless_cloud::prepare::run_service_prepare( + &namespace, + &self.secrets, + service, + SUBSTRATE_NAME, + spec, + ) + .await + .map_err(prepare_fault) + } +} + +#[async_trait] +impl Substrate for GitLabSubstrate { + fn name(&self) -> &str { + SUBSTRATE_NAME + } + + fn validate_definition(&self, def: &StackDef) -> Result<(), SubstrateFault> { + for service in def.services.keys() { + config::service_gitlab(def, service).map_err(fault)?; + let project_name = Self::resource_name(def, "i", service); + if !config::is_valid_project_name(&project_name) { + return Err(fault(GitLabError::ConfigInvalid { + location: format!("services.{service}"), + detail: format!( + "derived GitLab project name {project_name:?} is not a legal name; \ + shorten the stack/service name" + ), + })); + } + } + Ok(()) + } + + fn supports_source_override(&self) -> bool { + false + } + + fn default_lease(&self) -> Duration { + Duration::from_secs(8 * 3600) + } + + fn service_origin(&self, def: &StackDef, instance: &str, service: &str) -> String { + let name = Self::resource_name(def, instance, service); + Self::origin_placeholder(&name) + } + + fn build_namespace( + &self, + def: &StackDef, + instance: &str, + prior: &[Checkpoint], + secrets: &BTreeMap, + _purpose: NamespacePurpose, + ) -> Namespace { + let mut namespace = self.namespace(def, instance, prior); + namespace.secrets = secrets.clone(); + namespace + } + + async fn execute(&self, ctx: StepContext<'_>) -> Result { + self.ensure_project_and_env(ctx.def, ctx.instance).await?; + + let node = ctx.step.node.as_str(); + match ctx.step.kind { + StepKind::ProvisionIntegration => stackless_integrations::provision( + SUBSTRATE_NAME, + &self.stripe(), + ctx.def, + &self.definition_dir, + ctx.instance, + node, + true, + ) + .await + .map_err(integration_fault), + StepKind::Materialize => { + let spec = ctx.def.services.get(node).ok_or_else(|| { + fault(GitLabError::ConfigInvalid { + location: format!("services.{node}"), + detail: "service not in definition".into(), + }) + })?; + let payload = SourceRefPayload { + repo: spec.source.repo.clone(), + reference: spec.source.reference.clone(), + }; + Ok(StepResource { + resource_kind: "source-ref".into(), + resource_id: format!("{}@{}", spec.source.repo, spec.source.reference), + payload: serde_json::to_string(&payload).unwrap_or_default(), + }) + } + StepKind::Setup => Ok(stackless_core::substrate::action_resource(&ctx.step.id)), + StepKind::Prepare => { + self.run_prepare(ctx.def, ctx.instance, node, ctx.prior) + .await?; + Ok(stackless_core::substrate::action_resource(&ctx.step.id)) + } + StepKind::Start => self.start_service(ctx.def, ctx.instance, node).await, + // Deferred until GitLab REST deploy (Pages/CI) is wired. + StepKind::HealthGate => Ok(stackless_core::substrate::action_resource(&ctx.step.id)), + } + } + + async fn observe( + &self, + _instance: &str, + checkpoint: &Checkpoint, + ) -> Result { + match checkpoint.resource_kind.as_str() { + "gitlab-project" => { + let payload = stackless_cloud::checkpoint::parse_payload::( + &checkpoint.payload, + ) + .map_err(|detail| { + fault(GitLabError::ConfigInvalid { + location: "checkpoint.payload".into(), + detail, + }) + })?; + let stripe_resource = payload + .map(|p| p.stripe_resource) + .unwrap_or_else(|| checkpoint.resource_id.clone()); + let present = project::resource_registered(&self.stripe(), &stripe_resource) + .await + .map_err(projects_fault)?; + Ok(stackless_core::substrate::present_or_gone(present)) + } + kind if stackless_integrations::is_integration_resource(kind) => { + stackless_integrations::observe( + SUBSTRATE_NAME, + &self.stripe(), + &checkpoint.payload, + &checkpoint.resource_id, + kind, + ) + .await + .map_err(integration_fault) + } + kind if stackless_cloud::checkpoint::is_ephemeral_resource_kind(kind) => { + Ok(Observation::Gone) + } + kind => Err(fault(GitLabError::ConfigInvalid { + location: "checkpoint.resource_kind".into(), + detail: format!("unknown resource kind {kind:?}"), + })), + } + } + + async fn destroy( + &self, + _instance: &str, + checkpoint: &Checkpoint, + ) -> Result<(), SubstrateFault> { + match checkpoint.resource_kind.as_str() { + "gitlab-project" => { + let payload = stackless_cloud::checkpoint::parse_payload::( + &checkpoint.payload, + ) + .map_err(|detail| { + fault(GitLabError::ConfigInvalid { + location: "checkpoint.payload".into(), + detail, + }) + })?; + let stripe_resource = payload + .map(|p| p.stripe_resource) + .unwrap_or_else(|| checkpoint.resource_id.clone()); + project::remove_resource(&self.stripe(), &stripe_resource) + .await + .map_err(projects_fault) + } + kind if stackless_integrations::is_integration_resource(kind) => { + stackless_integrations::destroy( + SUBSTRATE_NAME, + &self.stripe(), + &checkpoint.payload, + &checkpoint.resource_id, + kind, + ) + .await + .map_err(integration_fault) + } + kind if stackless_cloud::checkpoint::is_ephemeral_resource_kind(kind) => Ok(()), + kind => Err(fault(GitLabError::ConfigInvalid { + location: "checkpoint.resource_kind".into(), + detail: format!("unknown resource kind {kind:?}"), + })), + } + } + + async fn finalize_teardown(&self, instance: &str) -> Result<(), SubstrateFault> { + stackless_integrations::finalize_stripe_instance(&self.stripe(), instance).await; + Ok(()) + } + + async fn spend(&self) -> Option { + Some( + stackless_cloud::spend::fetch( + &self.definition_dir, + SUBSTRATE_NAME, + SPEND_CAP_USD, + "gitlab.com", + ) + .await, + ) + } + + async fn fetch_logs( + &self, + _def: &StackDef, + _instance: &str, + _services: &[String], + _tail: usize, + ) -> Result>, SubstrateFault> { + // Deferred until GitLab REST (job logs) is wired. + Ok(None) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use stackless_stripe_projects::stripe::{CommandOutput, CommandRunner}; + use stackless_stripe_projects::test_support; + use std::path::Path as StdPath; + + struct NoRunner; + #[async_trait] + impl CommandRunner for NoRunner { + async fn run( + &self, + _args: &[String], + _cwd: &StdPath, + ) -> Result { + Err(ProjectsError::Unavailable { + detail: "stripe should not be called in this test".into(), + }) + } + } + + fn checkpoint(kind: &str, step_id: &str, payload: &str) -> Checkpoint { + Checkpoint { + instance: "demo".into(), + step_id: step_id.into(), + resource_kind: kind.into(), + resource_id: "atto-demo-web".into(), + payload: payload.into(), + recorded_at: 0, + } + } + + fn gitlab_def() -> StackDef { + StackDef::parse( + "[stack]\nname=\"atto\"\n[services.web]\nsource={repo=\"r\",ref=\"main\"}\nenv={}\nhealth={path=\"/\"}\n[services.web.gitlab]\nvisibility=\"private\"\n", + ) + .unwrap() + } + + fn subj() -> (tempfile::TempDir, GitLabSubstrate) { + let dir = tempfile::tempdir().unwrap(); + let s = GitLabSubstrate::for_test(NoRunner, dir.path(), false); + (dir, s) + } + + const PAYLOAD: &str = r#"{"stripe_resource":"demo-web","project_id":"123","project_name":"atto-demo-web","origin":"https://gitlab.com/atto-demo-web"}"#; + + #[test] + fn resource_name_and_origin_are_dns_safe() { + let def = gitlab_def(); + assert_eq!( + GitLabSubstrate::::resource_name(&def, "demo", "web"), + "atto-demo-web" + ); + let (_dir, s) = subj(); + assert_eq!( + s.service_origin(&def, "demo", "web"), + "https://gitlab.com/atto-demo-web" + ); + } + + #[test] + fn gitlab_substrate_defaults() { + let s = GitLabSubstrate::new(std::env::temp_dir(), Default::default(), false); + assert_eq!(s.name(), "gitlab"); + assert!(!s.supports_source_override()); + assert_eq!(s.default_lease(), Duration::from_secs(8 * 3600)); + } + + #[tokio::test] + async fn project_present_when_stripe_registers_it() { + let runner = test_support::ScriptedRunner::new(vec![test_support::services(&["demo-web"])]); + let dir = tempfile::tempdir().unwrap(); + let s = GitLabSubstrate::for_test(&runner, dir.path(), false); + let cp = checkpoint("gitlab-project", "start:web", PAYLOAD); + assert_eq!(s.observe("demo", &cp).await.unwrap(), Observation::Present); + } + + #[tokio::test] + async fn project_gone_when_stripe_does_not_register_it() { + let runner = test_support::ScriptedRunner::new(vec![test_support::services(&[])]); + let dir = tempfile::tempdir().unwrap(); + let s = GitLabSubstrate::for_test(&runner, dir.path(), false); + let cp = checkpoint("gitlab-project", "start:web", PAYLOAD); + assert_eq!(s.observe("demo", &cp).await.unwrap(), Observation::Gone); + } + + #[tokio::test] + async fn source_ref_observes_gone_so_teardown_drops_it() { + let (_dir, s) = subj(); + let cp = checkpoint( + "source-ref", + "materialize:web", + r#"{"repo":"r","ref":"main"}"#, + ); + assert_eq!(s.observe("demo", &cp).await.unwrap(), Observation::Gone); + s.destroy("demo", &cp).await.unwrap(); + } + + #[tokio::test] + async fn unknown_resource_kind_fails_closed() { + let (_dir, s) = subj(); + let cp = checkpoint("not-a-real-kind", "start:web", "{}"); + assert!(s.observe("demo", &cp).await.is_err()); + assert!(s.destroy("demo", &cp).await.is_err()); + } + + #[tokio::test] + async fn teardown_removes_via_stripe() { + let runner = test_support::ScriptedRunner::new(vec![ + test_support::services(&["demo-web"]), + test_support::ok_empty(), + ]); + let dir = tempfile::tempdir().unwrap(); + let s = GitLabSubstrate::for_test(&runner, dir.path(), false); + let cp = checkpoint("gitlab-project", "start:web", PAYLOAD); + s.destroy("demo", &cp).await.unwrap(); + let calls = runner.calls(); + assert!( + calls + .iter() + .any(|c| c.first().map(String::as_str) == Some("remove") + && c.iter().any(|a| a == "demo-web")), + "expected a `remove demo-web` call, got {calls:?}" + ); + } +} diff --git a/crates/stackless-laravel-cloud/Cargo.toml b/crates/stackless-laravel-cloud/Cargo.toml new file mode 100644 index 0000000..59801e7 --- /dev/null +++ b/crates/stackless-laravel-cloud/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "stackless-laravel-cloud" +edition.workspace = true +version.workspace = true +license.workspace = true +repository.workspace = true + +[lints] +workspace = true + +[dependencies] +async-trait = "0.1.89" +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.150" +stackless-cloud.workspace = true +stackless-core.workspace = true +stackless-integrations.workspace = true +stackless-stripe-projects.workspace = true +thiserror = "2.0.18" +tokio = { version = "1.52.3", features = ["rt", "time", "macros", "sync"] } +toml = "1.1.2" + +[dev-dependencies] +stackless-stripe-projects = { workspace = true, features = ["test-support"] } +tempfile = "3.27.0" diff --git a/crates/stackless-laravel-cloud/src/codes.rs b/crates/stackless-laravel-cloud/src/codes.rs new file mode 100644 index 0000000..12c5915 --- /dev/null +++ b/crates/stackless-laravel-cloud/src/codes.rs @@ -0,0 +1,17 @@ +//! Stable error codes for the Laravel Cloud substrate (ARCHITECTURE.md §2/§8). +//! +//! Codes live with the provider, not in core. The binary aggregates every +//! crate's `ALL` for a workspace-wide uniqueness check. + +pub const LARAVEL_CLOUD_CONFIG_INVALID: &str = "laravel_cloud.config.invalid"; +pub const LARAVEL_CLOUD_PAYMENT_NOT_CONFIRMED: &str = "laravel_cloud.payment.not_confirmed"; +pub const LARAVEL_CLOUD_PROVISION_FAILED: &str = "laravel_cloud.provision.failed"; +pub const LARAVEL_CLOUD_PREPARE_FAILED: &str = "laravel_cloud.prepare.failed"; + +/// Every Laravel Cloud code, for the workspace uniqueness test. +pub const ALL: &[&str] = &[ + LARAVEL_CLOUD_CONFIG_INVALID, + LARAVEL_CLOUD_PAYMENT_NOT_CONFIRMED, + LARAVEL_CLOUD_PROVISION_FAILED, + LARAVEL_CLOUD_PREPARE_FAILED, +]; diff --git a/crates/stackless-laravel-cloud/src/config.rs b/crates/stackless-laravel-cloud/src/config.rs new file mode 100644 index 0000000..ba315eb --- /dev/null +++ b/crates/stackless-laravel-cloud/src/config.rs @@ -0,0 +1,190 @@ +//! Parsing the laravel-cloud-specific blocks of the definition (§1 schema). + +use serde::Serialize; +use stackless_core::def::StackDef; + +use crate::SUBSTRATE_NAME; +use crate::error::LaravelCloudError; +use stackless_stripe_projects::CatalogService; + +/// A service's `[services.X.laravel-cloud]` block. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ServiceLaravelCloud { + pub region: String, + pub repository: String, + pub create_cache: Option, + pub create_database: Option, +} + +/// The typed `laravel_cloud/application` `--config`. +#[derive(Debug, Serialize)] +pub struct LaravelCloudApplicationConfig { + pub name: String, + pub region: String, + pub repository: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub create_cache: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub create_database: Option, +} + +impl CatalogService for LaravelCloudApplicationConfig { + const REFERENCE: &'static str = "laravel_cloud/application"; +} + +/// Read and shape-check `[services..laravel-cloud]`. +pub fn service_laravel_cloud( + def: &StackDef, + service: &str, +) -> Result { + let location = format!("services.{service}.laravel-cloud"); + let block = def + .services + .get(service) + .and_then(|spec| spec.substrates.get(SUBSTRATE_NAME)) + .ok_or_else(|| LaravelCloudError::ConfigInvalid { + location: location.clone(), + detail: "missing [services.X.laravel-cloud] block".into(), + })?; + let table = block + .as_table() + .ok_or_else(|| LaravelCloudError::ConfigInvalid { + location: location.clone(), + detail: "must be a table { region, repository, create_cache?, create_database?, env? }" + .into(), + })?; + for key in table.keys() { + if !matches!( + key.as_str(), + "region" | "repository" | "create_cache" | "create_database" | "env" + ) { + return Err(LaravelCloudError::ConfigInvalid { + location: location.clone(), + detail: format!( + "unknown key {key:?} (known: region, repository, create_cache, create_database, env)" + ), + }); + } + } + let region = required_string(table, &location, "region")?; + let repository = required_string(table, &location, "repository")?; + let create_cache = optional_string(table, &location, "create_cache")?; + let create_database = optional_string(table, &location, "create_database")?; + Ok(ServiceLaravelCloud { + region, + repository, + create_cache, + create_database, + }) +} + +fn required_string( + table: &toml::Table, + location: &str, + key: &str, +) -> Result { + table + .get(key) + .and_then(|value| value.as_str()) + .filter(|s| !s.trim().is_empty()) + .map(str::to_owned) + .ok_or_else(|| LaravelCloudError::ConfigInvalid { + location: format!("{location}.{key}"), + detail: "must be a non-empty string".into(), + }) +} + +fn optional_string( + table: &toml::Table, + location: &str, + key: &str, +) -> Result, LaravelCloudError> { + match table.get(key) { + None => Ok(None), + Some(value) => Ok(Some( + value + .as_str() + .filter(|s| !s.trim().is_empty()) + .ok_or_else(|| LaravelCloudError::ConfigInvalid { + location: format!("{location}.{key}"), + detail: "must be a non-empty string".into(), + })? + .to_owned(), + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(toml: &str) -> StackDef { + StackDef::parse(toml).expect("valid base toml") + } + + const BASE: &str = r#" +[stack] +name = "atto" +[services.web] +source = { repo = "https://github.com/laravel/cloud", ref = "main" } +env = {} +health = { path = "/", contains = "ok" } +[services.web.laravel-cloud] +region = "us-east-1" +repository = "laravel/cloud" +"#; + + #[test] + fn parses_required_fields() { + let def = parse(BASE); + let cfg = service_laravel_cloud(&def, "web").unwrap(); + assert_eq!(cfg.region, "us-east-1"); + assert_eq!(cfg.repository, "laravel/cloud"); + assert_eq!(cfg.create_cache, None); + assert_eq!(cfg.create_database, None); + } + + #[test] + fn missing_block_is_rejected() { + let def = parse( + "[stack]\nname=\"atto\"\n[services.web]\nsource={repo=\"r\",ref=\"main\"}\nenv={}\nhealth={path=\"/\"}\n", + ); + let err = service_laravel_cloud(&def, "web").unwrap_err(); + assert_eq!( + stackless_core::fault::Fault::code(&err), + crate::codes::LARAVEL_CLOUD_CONFIG_INVALID + ); + } + + #[test] + fn typed_config_carries_its_catalog_reference() { + assert_eq!( + LaravelCloudApplicationConfig::REFERENCE, + "laravel_cloud/application" + ); + } + + #[test] + fn laravel_cloud_config_matches_catalog() { + const FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../stackless-stripe-projects/tests/fixtures/catalog.json" + )); + let catalog = stackless_stripe_projects::Catalog::from_json_envelope(FIXTURE).unwrap(); + let failures = stackless_stripe_projects::verify_service( + &catalog, + &LaravelCloudApplicationConfig { + name: "atto-demo-web".into(), + region: "us-east-1".into(), + repository: "laravel/cloud".into(), + create_cache: None, + create_database: None, + }, + ); + assert!( + failures.is_empty(), + "laravel_cloud/application catalog gaps:\n{}", + failures.join("\n") + ); + } +} diff --git a/crates/stackless-laravel-cloud/src/error.rs b/crates/stackless-laravel-cloud/src/error.rs new file mode 100644 index 0000000..e0d36c2 --- /dev/null +++ b/crates/stackless-laravel-cloud/src/error.rs @@ -0,0 +1,77 @@ +//! Laravel Cloud substrate errors (codes in this crate's `laravel_cloud.*` registry). + +use stackless_core::fault::{ErrorContext, Fault}; + +use crate::codes; + +#[derive(Debug, thiserror::Error)] +pub enum LaravelCloudError { + #[error("[{location}] is invalid: {detail}")] + ConfigInvalid { location: String, detail: String }, + + #[error("creating paid Laravel Cloud resources requires explicit consent")] + PaymentNotConfirmed { resource: String }, + + #[error("provisioning {resource:?} on Laravel Cloud did not complete: {detail}")] + ProvisionFailed { resource: String, detail: String }, + + #[error("prepare for {service:?} failed: {message}")] + PrepareFailed { + service: String, + command: Option, + message: String, + log_tail: Option, + }, +} + +impl Fault for LaravelCloudError { + fn code(&self) -> &'static str { + match self { + Self::ConfigInvalid { .. } => codes::LARAVEL_CLOUD_CONFIG_INVALID, + Self::PaymentNotConfirmed { .. } => codes::LARAVEL_CLOUD_PAYMENT_NOT_CONFIRMED, + Self::ProvisionFailed { .. } => codes::LARAVEL_CLOUD_PROVISION_FAILED, + Self::PrepareFailed { .. } => codes::LARAVEL_CLOUD_PREPARE_FAILED, + } + } + + fn remediation(&self) -> String { + match self { + Self::ConfigInvalid { location, .. } => { + format!( + "fix the [{location}] block; see ARCHITECTURE.md §1 for the laravel-cloud schema" + ) + } + Self::PaymentNotConfirmed { .. } => { + "re-run with --confirm-paid to consent to Laravel Cloud charges (bounded by the \ + project's hard spend cap)" + .into() + } + Self::ProvisionFailed { .. } => { + "wait a moment for Laravel Cloud to finish provisioning and re-run `up` to resume" + .into() + } + Self::PrepareFailed { service, .. } => format!( + "inspect context.log_tail; run the {service:?} prepare command by hand; re-run \ + `stackless up `" + ), + } + } + + fn context(&self) -> ErrorContext { + match self { + Self::PrepareFailed { + service, + command, + log_tail, + .. + } => ErrorContext { + service: Some(service.clone()), + command: command.clone(), + log_hint: Some(format!("stackless logs {service}")), + log_tail: log_tail.clone(), + ..ErrorContext::default() + }, + _ => ErrorContext::default(), + } + } +} diff --git a/crates/stackless-laravel-cloud/src/lib.rs b/crates/stackless-laravel-cloud/src/lib.rs new file mode 100644 index 0000000..e811446 --- /dev/null +++ b/crates/stackless-laravel-cloud/src/lib.rs @@ -0,0 +1,610 @@ +//! stackless-laravel-cloud (ARCHITECTURE.md §4): the Laravel Cloud substrate. +//! +//! Mirrors the Netlify cloud flow at a smaller v0 scope: Stripe Projects +//! provisions `laravel_cloud/application` and tracks spend; observe/destroy +//! key off the Stripe resource registration. A real Laravel Cloud deploy API +//! client is Phase 2 — v0 records a placeholder origin and resumes via Stripe. +//! +//! ## Credential model +//! +//! Provisioning `laravel_cloud/application` returns a Stripe-managed `app_id`. +//! `observe`/`destroy` key off the **Stripe resource registration**, not a +//! Laravel Cloud API. +//! +//! ## v0 scope and cloud invariants +//! +//! - **Placeholder origin.** Origins are `https://{stack}-{instance}-{service}.laravel.cloud` +//! until a deploy client fills in the real URL. +//! - **Cloud resource names** are `{stack}-{instance}-{service}` — DNS-safe. +//! - **Setup is skipped on cloud**; **prepare** runs on the operator's machine. +//! - **Source override is unsupported** — Laravel Cloud deploys committed refs. + +pub mod codes; +pub mod config; +pub mod error; + +use std::collections::BTreeMap; +use std::path::PathBuf; +use std::time::Duration; + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use stackless_core::def::{Namespace, StackDef}; +use stackless_core::engine::StepKind; +use stackless_core::state::Checkpoint; +use stackless_core::substrate::{ + NamespacePurpose, Observation, StepContext, StepResource, Substrate, SubstrateFault, +}; +use tokio::sync::Mutex; + +use crate::config::{LaravelCloudApplicationConfig, ServiceLaravelCloud}; +use crate::error::LaravelCloudError; +use stackless_stripe_projects::ProjectsError; +use stackless_stripe_projects::provision::{ProvisionContext, provision_outputs}; +use stackless_stripe_projects::stripe::{CommandRunner, StripeProjects, TokioRunner}; +use stackless_stripe_projects::{project, requires_confirmation}; + +pub const SUBSTRATE_NAME: &str = "laravel-cloud"; + +/// The hard per-provider spend cap set on first paid confirmation (§4). +pub const SPEND_CAP_USD: u32 = 25; + +/// The provider prefix Stripe uses for `laravel_cloud/application` output env vars. +const PROVIDER_PREFIX: &str = "LARAVEL_CLOUD"; + +fn fault(err: LaravelCloudError) -> SubstrateFault { + SubstrateFault::from_fault(&err) +} + +fn projects_fault(err: ProjectsError) -> SubstrateFault { + SubstrateFault::from_fault(&err) +} + +fn integration_fault(err: stackless_integrations::IntegrationError) -> SubstrateFault { + SubstrateFault::from_fault(&err) +} + +fn prepare_fault(f: stackless_cloud::prepare::PrepareFailure) -> SubstrateFault { + fault(LaravelCloudError::PrepareFailed { + service: f.service, + command: f.command, + message: f.message, + log_tail: f.log_tail, + }) +} + +/// What a `start:` checkpoint records: the live Laravel Cloud application. +/// Observe/destroy use the Stripe resource registration, not a Laravel Cloud API. +#[derive(Debug, Serialize, Deserialize)] +struct LaravelCloudPayload { + stripe_resource: String, + app_id: String, + app_name: String, + origin: String, +} + +/// What a `materialize:` checkpoint records: the pinned source. +#[derive(Debug, Serialize, Deserialize)] +struct SourceRefPayload { + repo: String, + #[serde(rename = "ref")] + reference: String, +} + +/// The Laravel Cloud substrate. Generic over the command runner so tests inject +/// canned Stripe envelopes; production uses the real `stripe` binary. +pub struct LaravelCloudSubstrate { + pub definition_dir: PathBuf, + pub secrets: BTreeMap, + pub confirm_paid: bool, + runner: R, + ensured: Mutex, +} + +impl std::fmt::Debug for LaravelCloudSubstrate { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("LaravelCloudSubstrate") + .field("definition_dir", &self.definition_dir) + .field("confirm_paid", &self.confirm_paid) + .finish_non_exhaustive() + } +} + +impl LaravelCloudSubstrate { + pub fn new( + definition_dir: impl Into, + secrets: BTreeMap, + confirm_paid: bool, + ) -> Self { + Self { + definition_dir: definition_dir.into(), + secrets, + confirm_paid, + runner: TokioRunner, + ensured: Mutex::new(false), + } + } +} + +impl LaravelCloudSubstrate { + #[cfg(test)] + fn for_test(runner: R, definition_dir: impl Into, confirm_paid: bool) -> Self { + Self { + definition_dir: definition_dir.into(), + secrets: BTreeMap::new(), + confirm_paid, + runner, + ensured: Mutex::new(false), + } + } + + fn stripe(&self) -> StripeProjects<&R> { + StripeProjects::new(&self.runner, self.definition_dir.clone()) + } + + /// `{stack}-{instance}-{service}` (DNS-safe). + fn resource_name(def: &StackDef, instance: &str, node: &str) -> String { + format!("{}-{instance}-{node}", def.stack.name.as_str()) + } + + /// Placeholder origin until a deploy client records the real URL. + fn origin(def: &StackDef, instance: &str, service: &str) -> String { + format!( + "https://{}.laravel.cloud", + Self::resource_name(def, instance, service) + ) + } + + fn namespace(&self, def: &StackDef, instance: &str, prior: &[Checkpoint]) -> Namespace { + let mut namespace = Namespace { + stack_name: def.stack.name.clone(), + instance_name: stackless_core::types::DnsName::from_stored(instance), + ..Namespace::default() + }; + for service in def.services.keys() { + namespace + .service_origins + .insert(service.clone(), Self::origin(def, instance, service)); + } + namespace.secrets = self.secrets.clone(); + namespace.add_integration_checkpoints(prior); + namespace + } + + async fn ensure_project_and_env( + &self, + def: &StackDef, + instance: &str, + ) -> Result<(), SubstrateFault> { + let mut done = self.ensured.lock().await; + if *done { + return Ok(()); + } + let spend = self + .confirm_paid + .then_some((SPEND_CAP_USD, "laravel-cloud")); + stackless_cloud::ensure::project_and_env( + &self.stripe(), + def, + &self.definition_dir, + instance, + spend, + ) + .await + .map_err(projects_fault)?; + *done = true; + Ok(()) + } + + fn require_confirm_paid(&self, resource: &str) -> Result<(), SubstrateFault> { + if !self.confirm_paid { + return Err(fault(LaravelCloudError::PaymentNotConfirmed { + resource: resource.to_owned(), + })); + } + Ok(()) + } + + async fn start_service( + &self, + def: &StackDef, + instance: &str, + service: &str, + ) -> Result { + let laravel_cfg = config::service_laravel_cloud(def, service).map_err(fault)?; + let app_name = Self::resource_name(def, instance, service); + let resource = format!("{instance}-{service}"); + + let catalog = self.stripe().catalog().await.map_err(projects_fault)?; + let cfg = application_config(&app_name, &laravel_cfg); + if requires_confirmation(&catalog, &cfg).unwrap_or(false) { + self.require_confirm_paid(&resource)?; + } + let ctx = ProvisionContext { + def, + instance, + logical_name: service, + definition_dir: &self.definition_dir, + substrate: SUBSTRATE_NAME, + skip_instance_context: true, + }; + let (_resource_name, outputs) = provision_outputs( + &self.stripe(), + &catalog, + &ctx, + &cfg, + PROVIDER_PREFIX, + &[("LARAVEL_CLOUD_APP_ID", "app_id", true)], + ) + .await + .map_err(projects_fault)?; + let app_id = outputs.get("app_id").ok_or_else(|| { + fault(LaravelCloudError::ProvisionFailed { + resource: resource.clone(), + detail: "laravel_cloud/application did not return an app_id".into(), + }) + })?; + + let payload = LaravelCloudPayload { + stripe_resource: resource, + app_id: app_id.clone(), + app_name: app_name.clone(), + origin: Self::origin(def, instance, service), + }; + Ok(StepResource { + resource_kind: "laravel-cloud-application".into(), + resource_id: app_name, + payload: serde_json::to_string(&payload).unwrap_or_default(), + }) + } + + async fn run_prepare( + &self, + def: &StackDef, + instance: &str, + service: &str, + prior: &[Checkpoint], + ) -> Result<(), SubstrateFault> { + let Some(spec) = def.services.get(service) else { + return Ok(()); + }; + let namespace = self.namespace(def, instance, prior); + stackless_cloud::prepare::run_service_prepare( + &namespace, + &self.secrets, + service, + SUBSTRATE_NAME, + spec, + ) + .await + .map_err(prepare_fault) + } +} + +fn application_config(name: &str, cfg: &ServiceLaravelCloud) -> LaravelCloudApplicationConfig { + LaravelCloudApplicationConfig { + name: name.to_owned(), + region: cfg.region.clone(), + repository: cfg.repository.clone(), + create_cache: cfg.create_cache.clone(), + create_database: cfg.create_database.clone(), + } +} + +#[async_trait] +impl Substrate for LaravelCloudSubstrate { + fn name(&self) -> &str { + SUBSTRATE_NAME + } + + fn validate_definition(&self, def: &StackDef) -> Result<(), SubstrateFault> { + for service in def.services.keys() { + config::service_laravel_cloud(def, service).map_err(fault)?; + } + Ok(()) + } + + fn supports_source_override(&self) -> bool { + false + } + + fn default_lease(&self) -> Duration { + Duration::from_secs(8 * 3600) + } + + fn service_origin(&self, def: &StackDef, instance: &str, service: &str) -> String { + Self::origin(def, instance, service) + } + + fn build_namespace( + &self, + def: &StackDef, + instance: &str, + prior: &[Checkpoint], + secrets: &BTreeMap, + _purpose: NamespacePurpose, + ) -> Namespace { + let mut namespace = self.namespace(def, instance, prior); + namespace.secrets = secrets.clone(); + namespace + } + + async fn execute(&self, ctx: StepContext<'_>) -> Result { + self.ensure_project_and_env(ctx.def, ctx.instance).await?; + + let node = ctx.step.node.as_str(); + match ctx.step.kind { + StepKind::ProvisionIntegration => stackless_integrations::provision( + SUBSTRATE_NAME, + &self.stripe(), + ctx.def, + &self.definition_dir, + ctx.instance, + node, + true, + ) + .await + .map_err(integration_fault), + StepKind::Materialize => { + let spec = ctx.def.services.get(node).ok_or_else(|| { + fault(LaravelCloudError::ConfigInvalid { + location: format!("services.{node}"), + detail: "service not in definition".into(), + }) + })?; + let payload = SourceRefPayload { + repo: spec.source.repo.clone(), + reference: spec.source.reference.clone(), + }; + Ok(StepResource { + resource_kind: "source-ref".into(), + resource_id: format!("{}@{}", spec.source.repo, spec.source.reference), + payload: serde_json::to_string(&payload).unwrap_or_default(), + }) + } + StepKind::Setup => Ok(stackless_core::substrate::action_resource(&ctx.step.id)), + StepKind::Prepare => { + self.run_prepare(ctx.def, ctx.instance, node, ctx.prior) + .await?; + Ok(stackless_core::substrate::action_resource(&ctx.step.id)) + } + StepKind::Start => self.start_service(ctx.def, ctx.instance, node).await, + StepKind::HealthGate => { + // v0: placeholder origin — health polling deferred until a deploy client exists. + Ok(stackless_core::substrate::action_resource(&ctx.step.id)) + } + } + } + + async fn observe( + &self, + _instance: &str, + checkpoint: &Checkpoint, + ) -> Result { + match checkpoint.resource_kind.as_str() { + "laravel-cloud-application" => { + let payload = stackless_cloud::checkpoint::parse_payload::( + &checkpoint.payload, + ) + .map_err(|detail| { + fault(LaravelCloudError::ConfigInvalid { + location: "checkpoint.payload".into(), + detail, + }) + })?; + let stripe_resource = payload + .map(|p| p.stripe_resource) + .unwrap_or_else(|| checkpoint.resource_id.clone()); + let present = project::resource_registered(&self.stripe(), &stripe_resource) + .await + .map_err(projects_fault)?; + Ok(stackless_core::substrate::present_or_gone(present)) + } + kind if stackless_integrations::is_integration_resource(kind) => { + stackless_integrations::observe( + SUBSTRATE_NAME, + &self.stripe(), + &checkpoint.payload, + &checkpoint.resource_id, + kind, + ) + .await + .map_err(integration_fault) + } + kind if stackless_cloud::checkpoint::is_ephemeral_resource_kind(kind) => { + Ok(Observation::Gone) + } + kind => Err(fault(LaravelCloudError::ConfigInvalid { + location: "checkpoint.resource_kind".into(), + detail: format!("unknown resource kind {kind:?}"), + })), + } + } + + async fn destroy( + &self, + _instance: &str, + checkpoint: &Checkpoint, + ) -> Result<(), SubstrateFault> { + match checkpoint.resource_kind.as_str() { + "laravel-cloud-application" => { + let payload = stackless_cloud::checkpoint::parse_payload::( + &checkpoint.payload, + ) + .map_err(|detail| { + fault(LaravelCloudError::ConfigInvalid { + location: "checkpoint.payload".into(), + detail, + }) + })?; + let stripe_resource = payload + .map(|p| p.stripe_resource) + .unwrap_or_else(|| checkpoint.resource_id.clone()); + project::remove_resource(&self.stripe(), &stripe_resource) + .await + .map_err(projects_fault) + } + kind if stackless_integrations::is_integration_resource(kind) => { + stackless_integrations::destroy( + SUBSTRATE_NAME, + &self.stripe(), + &checkpoint.payload, + &checkpoint.resource_id, + kind, + ) + .await + .map_err(integration_fault) + } + kind if stackless_cloud::checkpoint::is_ephemeral_resource_kind(kind) => Ok(()), + kind => Err(fault(LaravelCloudError::ConfigInvalid { + location: "checkpoint.resource_kind".into(), + detail: format!("unknown resource kind {kind:?}"), + })), + } + } + + async fn finalize_teardown(&self, instance: &str) -> Result<(), SubstrateFault> { + stackless_integrations::finalize_stripe_instance(&self.stripe(), instance).await; + Ok(()) + } + + async fn spend(&self) -> Option { + Some( + stackless_cloud::spend::fetch( + &self.definition_dir, + SUBSTRATE_NAME, + SPEND_CAP_USD, + "cloud.laravel.com", + ) + .await, + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use stackless_stripe_projects::stripe::{CommandOutput, CommandRunner}; + use stackless_stripe_projects::test_support; + use std::path::Path as StdPath; + + struct NoRunner; + #[async_trait] + impl CommandRunner for NoRunner { + async fn run( + &self, + _args: &[String], + _cwd: &StdPath, + ) -> Result { + Err(ProjectsError::Unavailable { + detail: "stripe should not be called in this test".into(), + }) + } + } + + fn checkpoint(kind: &str, step_id: &str, payload: &str) -> Checkpoint { + Checkpoint { + instance: "demo".into(), + step_id: step_id.into(), + resource_kind: kind.into(), + resource_id: "atto-demo-web".into(), + payload: payload.into(), + recorded_at: 0, + } + } + + fn laravel_def() -> StackDef { + StackDef::parse( + "[stack]\nname=\"atto\"\n[services.web]\nsource={repo=\"r\",ref=\"main\"}\nenv={}\nhealth={path=\"/\"}\n[services.web.laravel-cloud]\nregion=\"us-east-1\"\nrepository=\"laravel/cloud\"\n", + ) + .unwrap() + } + + fn subj() -> (tempfile::TempDir, LaravelCloudSubstrate) { + let dir = tempfile::tempdir().unwrap(); + let s = LaravelCloudSubstrate::for_test(NoRunner, dir.path(), false); + (dir, s) + } + + const PAYLOAD: &str = r#"{"stripe_resource":"demo-web","app_id":"app_1","app_name":"atto-demo-web","origin":"https://atto-demo-web.laravel.cloud"}"#; + + #[test] + fn resource_name_and_origin_are_dns_safe() { + let def = laravel_def(); + assert_eq!( + LaravelCloudSubstrate::::resource_name(&def, "demo", "web"), + "atto-demo-web" + ); + let (_dir, s) = subj(); + assert_eq!( + s.service_origin(&def, "demo", "web"), + "https://atto-demo-web.laravel.cloud" + ); + } + + #[test] + fn laravel_cloud_substrate_defaults() { + let s = LaravelCloudSubstrate::new(std::env::temp_dir(), Default::default(), false); + assert_eq!(s.name(), "laravel-cloud"); + assert!(!s.supports_source_override()); + assert_eq!(s.default_lease(), Duration::from_secs(8 * 3600)); + } + + #[tokio::test] + async fn application_present_when_stripe_registers_it() { + let runner = test_support::ScriptedRunner::new(vec![test_support::services(&["demo-web"])]); + let dir = tempfile::tempdir().unwrap(); + let s = LaravelCloudSubstrate::for_test(&runner, dir.path(), false); + let cp = checkpoint("laravel-cloud-application", "start:web", PAYLOAD); + assert_eq!(s.observe("demo", &cp).await.unwrap(), Observation::Present); + } + + #[tokio::test] + async fn application_gone_when_stripe_does_not_register_it() { + let runner = test_support::ScriptedRunner::new(vec![test_support::services(&[])]); + let dir = tempfile::tempdir().unwrap(); + let s = LaravelCloudSubstrate::for_test(&runner, dir.path(), false); + let cp = checkpoint("laravel-cloud-application", "start:web", PAYLOAD); + assert_eq!(s.observe("demo", &cp).await.unwrap(), Observation::Gone); + } + + #[tokio::test] + async fn source_ref_observes_gone_so_teardown_drops_it() { + let (_dir, s) = subj(); + let cp = checkpoint( + "source-ref", + "materialize:web", + r#"{"repo":"r","ref":"main"}"#, + ); + assert_eq!(s.observe("demo", &cp).await.unwrap(), Observation::Gone); + s.destroy("demo", &cp).await.unwrap(); + } + + #[tokio::test] + async fn unknown_resource_kind_fails_closed() { + let (_dir, s) = subj(); + let cp = checkpoint("not-a-real-kind", "start:web", "{}"); + assert!(s.observe("demo", &cp).await.is_err()); + assert!(s.destroy("demo", &cp).await.is_err()); + } + + #[tokio::test] + async fn teardown_removes_via_stripe() { + let runner = test_support::ScriptedRunner::new(vec![ + test_support::services(&["demo-web"]), + test_support::ok_empty(), + ]); + let dir = tempfile::tempdir().unwrap(); + let s = LaravelCloudSubstrate::for_test(&runner, dir.path(), false); + let cp = checkpoint("laravel-cloud-application", "start:web", PAYLOAD); + s.destroy("demo", &cp).await.unwrap(); + let calls = runner.calls(); + assert!( + calls + .iter() + .any(|c| c.first().map(String::as_str) == Some("remove") + && c.iter().any(|a| a == "demo-web")), + "expected a `remove demo-web` call, got {calls:?}" + ); + } +} diff --git a/crates/stackless-railway/Cargo.toml b/crates/stackless-railway/Cargo.toml new file mode 100644 index 0000000..37010c4 --- /dev/null +++ b/crates/stackless-railway/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "stackless-railway" +edition.workspace = true +version.workspace = true +license.workspace = true +repository.workspace = true + +[lints] +workspace = true + +[dependencies] +async-trait = "0.1.89" +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.150" +stackless-cloud.workspace = true +stackless-core.workspace = true +stackless-integrations.workspace = true +stackless-stripe-projects.workspace = true +thiserror = "2.0.18" +tokio = { version = "1.52.3", features = ["rt", "time", "macros", "sync"] } + +[dev-dependencies] +stackless-stripe-projects = { workspace = true, features = ["test-support"] } +tempfile = "3.27.0" diff --git a/crates/stackless-railway/src/codes.rs b/crates/stackless-railway/src/codes.rs new file mode 100644 index 0000000..6c35f54 --- /dev/null +++ b/crates/stackless-railway/src/codes.rs @@ -0,0 +1,17 @@ +//! Stable error codes for the Railway substrate (ARCHITECTURE.md §2/§8). +//! +//! Codes live with the provider, not in core. The binary aggregates every +//! crate's `ALL` for a workspace-wide uniqueness check. + +pub const RAILWAY_CONFIG_INVALID: &str = "railway.config.invalid"; +pub const RAILWAY_PAYMENT_NOT_CONFIRMED: &str = "railway.payment.not_confirmed"; +pub const RAILWAY_PROVISION_FAILED: &str = "railway.provision.failed"; +pub const RAILWAY_PREPARE_FAILED: &str = "railway.prepare.failed"; + +/// Every Railway code, for the workspace uniqueness test. +pub const ALL: &[&str] = &[ + RAILWAY_CONFIG_INVALID, + RAILWAY_PAYMENT_NOT_CONFIRMED, + RAILWAY_PROVISION_FAILED, + RAILWAY_PREPARE_FAILED, +]; diff --git a/crates/stackless-railway/src/config.rs b/crates/stackless-railway/src/config.rs new file mode 100644 index 0000000..eef52a8 --- /dev/null +++ b/crates/stackless-railway/src/config.rs @@ -0,0 +1,143 @@ +//! Parsing the railway-specific blocks of the definition (§1 schema). +//! +//! Phase 1 is Stripe-only: the substrate provisions `railway/hosting` and records +//! a best-effort origin. Deploying source to Railway (GraphQL/REST) is deferred. + +use serde::Serialize; +use stackless_core::def::StackDef; + +use crate::SUBSTRATE_NAME; +use crate::error::RailwayError; +use stackless_stripe_projects::CatalogService; + +/// A service's `[services.X.railway]` block. Optional — an absent block is valid. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ServiceRailway {} + +/// The typed `railway/hosting` `--config`. Empty object is the catalog contract. +#[derive(Debug, Serialize)] +pub struct RailwayHostingConfig {} + +impl CatalogService for RailwayHostingConfig { + const REFERENCE: &'static str = "railway/hosting"; +} + +/// Read and shape-check `[services..railway]` (optional block; unknown +/// keys inside it are a fault, to trap agent typos). +pub fn service_railway(def: &StackDef, service: &str) -> Result { + let location = format!("services.{service}.railway"); + let Some(block) = def + .services + .get(service) + .and_then(|spec| spec.substrates.get(SUBSTRATE_NAME)) + else { + return Ok(ServiceRailway::default()); + }; + let table = block + .as_table() + .ok_or_else(|| RailwayError::ConfigInvalid { + location: location.clone(), + detail: "must be a table { env? }".into(), + })?; + for key in table.keys() { + if key.as_str() != "env" { + return Err(RailwayError::ConfigInvalid { + location: location.clone(), + detail: format!("unknown key {key:?} (known: env)"), + }); + } + } + Ok(ServiceRailway::default()) +} + +/// Whether `name` is a legal Railway service label: lowercase letter then +/// 2..=62 of `[a-z0-9-]` (DNS-safe, matches the cloud name rule). +pub fn is_valid_service_name(name: &str) -> bool { + let len = name.len(); + if !(3..=63).contains(&len) { + return false; + } + let mut chars = name.chars(); + match chars.next() { + Some(c) if c.is_ascii_lowercase() => {} + _ => return false, + } + chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(toml: &str) -> StackDef { + StackDef::parse(toml).expect("valid base toml") + } + + const BASE: &str = r#" +[stack] +name = "atto" +[services.web] +source = { repo = "https://github.com/snowmead/stackless", ref = "main" } +env = {} +health = { path = "/", contains = "ok" } +[services.web.railway] +"#; + + #[test] + fn defaults_when_block_absent() { + let def = parse(BASE); + assert_eq!( + service_railway(&def, "web").unwrap(), + ServiceRailway::default() + ); + let no_block = parse( + "[stack]\nname=\"atto\"\n[services.web]\nsource={repo=\"r\",ref=\"main\"}\nenv={}\nhealth={path=\"/\"}\n", + ); + assert_eq!( + service_railway(&no_block, "web").unwrap(), + ServiceRailway::default() + ); + } + + #[test] + fn unknown_key_is_rejected() { + let toml = BASE.to_owned() + "bogus = 1\n"; + let err = service_railway(&parse(&toml), "web").unwrap_err(); + assert_eq!( + stackless_core::fault::Fault::code(&err), + crate::codes::RAILWAY_CONFIG_INVALID + ); + } + + #[test] + fn service_name_pattern() { + assert!(is_valid_service_name("atto-demo-web")); + assert!(!is_valid_service_name("ab")); + assert!(!is_valid_service_name("1abc")); + assert!(!is_valid_service_name("Abc")); + assert!(!is_valid_service_name(&"a".repeat(64))); + } + + #[test] + fn typed_config_carries_its_catalog_reference() { + assert_eq!(RailwayHostingConfig::REFERENCE, "railway/hosting"); + } + + /// Catalog gap check: the `railway/hosting` config must validate against the + /// committed catalog fixture. Fails loudly if Stripe drifts the schema. + #[test] + fn railway_config_matches_catalog() { + const FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../stackless-stripe-projects/tests/fixtures/catalog.json" + )); + let catalog = stackless_stripe_projects::Catalog::from_json_envelope(FIXTURE).unwrap(); + let failures = + stackless_stripe_projects::verify_service(&catalog, &RailwayHostingConfig {}); + assert!( + failures.is_empty(), + "railway/hosting catalog gaps:\n{}", + failures.join("\n") + ); + } +} diff --git a/crates/stackless-railway/src/error.rs b/crates/stackless-railway/src/error.rs new file mode 100644 index 0000000..57af7fd --- /dev/null +++ b/crates/stackless-railway/src/error.rs @@ -0,0 +1,74 @@ +//! Railway-substrate errors (codes in this crate's `railway.*` registry). + +use stackless_core::fault::{ErrorContext, Fault}; + +use crate::codes; + +#[derive(Debug, thiserror::Error)] +pub enum RailwayError { + #[error("[{location}] is invalid: {detail}")] + ConfigInvalid { location: String, detail: String }, + + #[error("creating paid Railway resources requires explicit consent")] + PaymentNotConfirmed { resource: String }, + + #[error("provisioning {resource:?} on Railway did not complete: {detail}")] + ProvisionFailed { resource: String, detail: String }, + + #[error("prepare for {service:?} failed: {message}")] + PrepareFailed { + service: String, + command: Option, + message: String, + log_tail: Option, + }, +} + +impl Fault for RailwayError { + fn code(&self) -> &'static str { + match self { + Self::ConfigInvalid { .. } => codes::RAILWAY_CONFIG_INVALID, + Self::PaymentNotConfirmed { .. } => codes::RAILWAY_PAYMENT_NOT_CONFIRMED, + Self::ProvisionFailed { .. } => codes::RAILWAY_PROVISION_FAILED, + Self::PrepareFailed { .. } => codes::RAILWAY_PREPARE_FAILED, + } + } + + fn remediation(&self) -> String { + match self { + Self::ConfigInvalid { location, .. } => { + format!("fix the [{location}] block; see ARCHITECTURE.md §1 for the railway schema") + } + Self::PaymentNotConfirmed { .. } => { + "re-run with --confirm-paid to consent to Railway charges (bounded by the \ + project's hard spend cap)" + .into() + } + Self::ProvisionFailed { .. } => { + "wait a moment for Railway to finish provisioning and re-run `up` to resume".into() + } + Self::PrepareFailed { service, .. } => format!( + "inspect context.log_tail; run the {service:?} prepare command by hand; re-run \ + `stackless up `" + ), + } + } + + fn context(&self) -> ErrorContext { + match self { + Self::PrepareFailed { + service, + command, + log_tail, + .. + } => ErrorContext { + service: Some(service.clone()), + command: command.clone(), + log_hint: Some(format!("stackless logs {service}")), + log_tail: log_tail.clone(), + ..ErrorContext::default() + }, + _ => ErrorContext::default(), + } + } +} diff --git a/crates/stackless-railway/src/lib.rs b/crates/stackless-railway/src/lib.rs new file mode 100644 index 0000000..8c9f016 --- /dev/null +++ b/crates/stackless-railway/src/lib.rs @@ -0,0 +1,618 @@ +//! stackless-railway (ARCHITECTURE.md §4): the Railway cloud substrate. +//! +//! Mirrors the Netlify/Fly cloud flow at the Stripe layer: Stripe Projects +//! provisions `railway/hosting` and tracks spend; observe/destroy key off the +//! **Stripe resource registration**, not the Railway API. One long-lived Stripe +//! project per stack holds each instance as a named environment. +//! +//! ## Credential model (pinned by `mise run discover railway/hosting`) +//! +//! Provisioning `railway/hosting` returns a Stripe-managed project id +//! (`RAILWAY_PROJECT_ID`). The substrate records it in the start checkpoint but +//! does not call Railway's GraphQL/REST APIs in this phase. +//! +//! ## Phase 1 scope and REST gaps +//! +//! - **Stripe provision only.** `start` provisions `railway/hosting` via Stripe +//! Projects and records a best-effort origin +//! `https://{stack}-{instance}-{service}.up.railway.app`. There is no deploy of +//! source, no service creation, and no health polling against a live URL yet. +//! - **Health gate is a no-op** until Railway REST deploy lands (documented here +//! so operators know smoke health checks will not pass on cloud). +//! - **Logs are unavailable** (`fetch_logs` returns `None`). +//! - **Cloud resource names** are `{stack}-{instance}-{service}` — DNS-safe. +//! - **Setup is skipped on cloud**; **prepare** runs on the operator's machine. +//! - **Source override is unsupported** — Railway deploys committed refs (when +//! REST deploy is implemented). + +pub mod codes; +pub mod config; +pub mod error; + +use std::collections::BTreeMap; +use std::path::PathBuf; +use std::time::Duration; + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use stackless_core::def::{Namespace, StackDef}; +use stackless_core::engine::StepKind; +use stackless_core::state::Checkpoint; +use stackless_core::substrate::{ + NamespacePurpose, Observation, StepContext, StepResource, Substrate, SubstrateFault, +}; +use tokio::sync::Mutex; + +use crate::config::RailwayHostingConfig; +use crate::error::RailwayError; +use stackless_stripe_projects::ProjectsError; +use stackless_stripe_projects::provision::{ProvisionContext, provision_outputs}; +use stackless_stripe_projects::stripe::{CommandRunner, StripeProjects, TokioRunner}; +use stackless_stripe_projects::{project, requires_confirmation}; + +pub const SUBSTRATE_NAME: &str = "railway"; + +/// The hard per-provider spend cap set on first paid confirmation (§4). +pub const SPEND_CAP_USD: u32 = 25; + +/// The provider prefix Stripe uses for `railway/hosting` output env vars. +/// Pinned by `mise run discover railway/hosting`. +const PROVIDER_PREFIX: &str = "RAILWAY"; + +fn fault(err: RailwayError) -> SubstrateFault { + SubstrateFault::from_fault(&err) +} + +fn projects_fault(err: ProjectsError) -> SubstrateFault { + SubstrateFault::from_fault(&err) +} + +fn integration_fault(err: stackless_integrations::IntegrationError) -> SubstrateFault { + SubstrateFault::from_fault(&err) +} + +/// Map the shared prepare helper's neutral failure to Railway's fault so its +/// `railway.*` code and remediation hold (§2). +fn prepare_fault(f: stackless_cloud::prepare::PrepareFailure) -> SubstrateFault { + fault(RailwayError::PrepareFailed { + service: f.service, + command: f.command, + message: f.message, + log_tail: f.log_tail, + }) +} + +/// What a `start:` checkpoint records. Observe/destroy use Stripe, not +/// the Railway API. +#[derive(Debug, Serialize, Deserialize)] +struct RailwayPayload { + stripe_resource: String, + project_id: String, + service_name: String, + origin: String, +} + +/// What a `materialize:` checkpoint records: the pinned source. Owns +/// nothing locally, so observe reports Gone and resume cheaply re-records it. +#[derive(Debug, Serialize, Deserialize)] +struct SourceRefPayload { + repo: String, + #[serde(rename = "ref")] + reference: String, +} + +/// The Railway substrate. Generic over the command runner so tests inject canned +/// Stripe envelopes; production uses the real `stripe` binary. +pub struct RailwaySubstrate { + pub definition_dir: PathBuf, + pub secrets: BTreeMap, + pub confirm_paid: bool, + runner: R, + ensured: Mutex, +} + +impl std::fmt::Debug for RailwaySubstrate { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RailwaySubstrate") + .field("definition_dir", &self.definition_dir) + .field("confirm_paid", &self.confirm_paid) + .finish_non_exhaustive() + } +} + +impl RailwaySubstrate { + pub fn new( + definition_dir: impl Into, + secrets: BTreeMap, + confirm_paid: bool, + ) -> Self { + Self { + definition_dir: definition_dir.into(), + secrets, + confirm_paid, + runner: TokioRunner, + ensured: Mutex::new(false), + } + } +} + +impl RailwaySubstrate { + #[cfg(test)] + fn for_test(runner: R, definition_dir: impl Into, confirm_paid: bool) -> Self { + Self { + definition_dir: definition_dir.into(), + secrets: BTreeMap::new(), + confirm_paid, + runner, + ensured: Mutex::new(false), + } + } + + fn stripe(&self) -> StripeProjects<&R> { + StripeProjects::new(&self.runner, self.definition_dir.clone()) + } + + /// `{stack}-{instance}-{service}` (DNS-safe). + fn resource_name(def: &StackDef, instance: &str, node: &str) -> String { + format!("{}-{instance}-{node}", def.stack.name.as_str()) + } + + /// `https://{stack}-{instance}-{service}.up.railway.app` — best-effort until + /// Railway REST deploy records the real URL. + fn origin(def: &StackDef, instance: &str, service: &str) -> String { + format!( + "https://{}.up.railway.app", + Self::resource_name(def, instance, service) + ) + } + + fn namespace(&self, def: &StackDef, instance: &str, prior: &[Checkpoint]) -> Namespace { + let mut namespace = Namespace { + stack_name: def.stack.name.clone(), + instance_name: stackless_core::types::DnsName::from_stored(instance), + ..Namespace::default() + }; + for service in def.services.keys() { + namespace + .service_origins + .insert(service.clone(), Self::origin(def, instance, service)); + } + namespace.secrets = self.secrets.clone(); + namespace.add_integration_checkpoints(prior); + namespace + } + + async fn ensure_project_and_env( + &self, + def: &StackDef, + instance: &str, + ) -> Result<(), SubstrateFault> { + let mut done = self.ensured.lock().await; + if *done { + return Ok(()); + } + let spend = self.confirm_paid.then_some((SPEND_CAP_USD, "railway")); + stackless_cloud::ensure::project_and_env( + &self.stripe(), + def, + &self.definition_dir, + instance, + spend, + ) + .await + .map_err(projects_fault)?; + *done = true; + Ok(()) + } + + fn require_confirm_paid(&self, resource: &str) -> Result<(), SubstrateFault> { + if !self.confirm_paid { + return Err(fault(RailwayError::PaymentNotConfirmed { + resource: resource.to_owned(), + })); + } + Ok(()) + } + + async fn start_service( + &self, + def: &StackDef, + instance: &str, + service: &str, + ) -> Result { + let _railway_cfg = config::service_railway(def, service).map_err(fault)?; + let service_name = Self::resource_name(def, instance, service); + let resource = format!("{instance}-{service}"); + + let catalog = self.stripe().catalog().await.map_err(projects_fault)?; + let cfg = RailwayHostingConfig {}; + if requires_confirmation(&catalog, &cfg).unwrap_or(false) { + self.require_confirm_paid(&resource)?; + } + let ctx = ProvisionContext { + def, + instance, + logical_name: service, + definition_dir: &self.definition_dir, + substrate: SUBSTRATE_NAME, + skip_instance_context: true, + }; + let (_resource_name, outputs) = provision_outputs( + &self.stripe(), + &catalog, + &ctx, + &cfg, + PROVIDER_PREFIX, + // Pinned by `mise run discover railway/hosting`. + &[("PROJECT_ID", "project_id", true)], + ) + .await + .map_err(projects_fault)?; + let project_id = outputs.get("project_id").ok_or_else(|| { + fault(RailwayError::ProvisionFailed { + resource: resource.clone(), + detail: "railway/hosting did not return a project id".into(), + }) + })?; + + let payload = RailwayPayload { + stripe_resource: resource, + project_id: project_id.clone(), + service_name: service_name.clone(), + origin: Self::origin(def, instance, service), + }; + Ok(StepResource { + resource_kind: "railway-service".into(), + resource_id: service_name, + payload: serde_json::to_string(&payload).unwrap_or_default(), + }) + } + + async fn run_prepare( + &self, + def: &StackDef, + instance: &str, + service: &str, + prior: &[Checkpoint], + ) -> Result<(), SubstrateFault> { + let Some(spec) = def.services.get(service) else { + return Ok(()); + }; + let namespace = self.namespace(def, instance, prior); + stackless_cloud::prepare::run_service_prepare( + &namespace, + &self.secrets, + service, + SUBSTRATE_NAME, + spec, + ) + .await + .map_err(prepare_fault) + } +} + +#[async_trait] +impl Substrate for RailwaySubstrate { + fn name(&self) -> &str { + SUBSTRATE_NAME + } + + fn validate_definition(&self, def: &StackDef) -> Result<(), SubstrateFault> { + for service in def.services.keys() { + config::service_railway(def, service).map_err(fault)?; + let service_name = Self::resource_name(def, "i", service); + if !config::is_valid_service_name(&service_name) { + return Err(fault(RailwayError::ConfigInvalid { + location: format!("services.{service}"), + detail: format!( + "derived Railway service name {service_name:?} is not DNS-safe; \ + shorten the stack/service name" + ), + })); + } + } + Ok(()) + } + + fn supports_source_override(&self) -> bool { + false + } + + fn default_lease(&self) -> Duration { + Duration::from_secs(8 * 3600) + } + + fn service_origin(&self, def: &StackDef, instance: &str, service: &str) -> String { + Self::origin(def, instance, service) + } + + fn build_namespace( + &self, + def: &StackDef, + instance: &str, + prior: &[Checkpoint], + secrets: &BTreeMap, + _purpose: NamespacePurpose, + ) -> Namespace { + let mut namespace = self.namespace(def, instance, prior); + namespace.secrets = secrets.clone(); + namespace + } + + async fn execute(&self, ctx: StepContext<'_>) -> Result { + self.ensure_project_and_env(ctx.def, ctx.instance).await?; + + let node = ctx.step.node.as_str(); + match ctx.step.kind { + StepKind::ProvisionIntegration => stackless_integrations::provision( + SUBSTRATE_NAME, + &self.stripe(), + ctx.def, + &self.definition_dir, + ctx.instance, + node, + true, + ) + .await + .map_err(integration_fault), + StepKind::Materialize => { + let spec = ctx.def.services.get(node).ok_or_else(|| { + fault(RailwayError::ConfigInvalid { + location: format!("services.{node}"), + detail: "service not in definition".into(), + }) + })?; + let payload = SourceRefPayload { + repo: spec.source.repo.clone(), + reference: spec.source.reference.clone(), + }; + Ok(StepResource { + resource_kind: "source-ref".into(), + resource_id: format!("{}@{}", spec.source.repo, spec.source.reference), + payload: serde_json::to_string(&payload).unwrap_or_default(), + }) + } + StepKind::Setup => Ok(stackless_core::substrate::action_resource(&ctx.step.id)), + StepKind::Prepare => { + self.run_prepare(ctx.def, ctx.instance, node, ctx.prior) + .await?; + Ok(stackless_core::substrate::action_resource(&ctx.step.id)) + } + StepKind::Start => self.start_service(ctx.def, ctx.instance, node).await, + // REST deploy + health polling deferred — see module docs. + StepKind::HealthGate => Ok(stackless_core::substrate::action_resource(&ctx.step.id)), + } + } + + async fn observe( + &self, + _instance: &str, + checkpoint: &Checkpoint, + ) -> Result { + match checkpoint.resource_kind.as_str() { + "railway-service" => { + let payload = stackless_cloud::checkpoint::parse_payload::( + &checkpoint.payload, + ) + .map_err(|detail| { + fault(RailwayError::ConfigInvalid { + location: "checkpoint.payload".into(), + detail, + }) + })?; + let stripe_resource = payload + .map(|p| p.stripe_resource) + .unwrap_or_else(|| checkpoint.resource_id.clone()); + let present = project::resource_registered(&self.stripe(), &stripe_resource) + .await + .map_err(projects_fault)?; + Ok(stackless_core::substrate::present_or_gone(present)) + } + kind if stackless_integrations::is_integration_resource(kind) => { + stackless_integrations::observe( + SUBSTRATE_NAME, + &self.stripe(), + &checkpoint.payload, + &checkpoint.resource_id, + kind, + ) + .await + .map_err(integration_fault) + } + kind if stackless_cloud::checkpoint::is_ephemeral_resource_kind(kind) => { + Ok(Observation::Gone) + } + kind => Err(fault(RailwayError::ConfigInvalid { + location: "checkpoint.resource_kind".into(), + detail: format!("unknown resource kind {kind:?}"), + })), + } + } + + async fn destroy( + &self, + _instance: &str, + checkpoint: &Checkpoint, + ) -> Result<(), SubstrateFault> { + match checkpoint.resource_kind.as_str() { + "railway-service" => { + let payload = stackless_cloud::checkpoint::parse_payload::( + &checkpoint.payload, + ) + .map_err(|detail| { + fault(RailwayError::ConfigInvalid { + location: "checkpoint.payload".into(), + detail, + }) + })?; + let stripe_resource = payload + .map(|p| p.stripe_resource) + .unwrap_or_else(|| checkpoint.resource_id.clone()); + project::remove_resource(&self.stripe(), &stripe_resource) + .await + .map_err(projects_fault) + } + kind if stackless_integrations::is_integration_resource(kind) => { + stackless_integrations::destroy( + SUBSTRATE_NAME, + &self.stripe(), + &checkpoint.payload, + &checkpoint.resource_id, + kind, + ) + .await + .map_err(integration_fault) + } + kind if stackless_cloud::checkpoint::is_ephemeral_resource_kind(kind) => Ok(()), + kind => Err(fault(RailwayError::ConfigInvalid { + location: "checkpoint.resource_kind".into(), + detail: format!("unknown resource kind {kind:?}"), + })), + } + } + + async fn finalize_teardown(&self, instance: &str) -> Result<(), SubstrateFault> { + stackless_integrations::finalize_stripe_instance(&self.stripe(), instance).await; + Ok(()) + } + + async fn spend(&self) -> Option { + Some( + stackless_cloud::spend::fetch( + &self.definition_dir, + SUBSTRATE_NAME, + SPEND_CAP_USD, + "railway.app", + ) + .await, + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use stackless_stripe_projects::stripe::{CommandOutput, CommandRunner}; + use stackless_stripe_projects::test_support; + use std::path::Path as StdPath; + + struct NoRunner; + #[async_trait] + impl CommandRunner for NoRunner { + async fn run( + &self, + _args: &[String], + _cwd: &StdPath, + ) -> Result { + Err(ProjectsError::Unavailable { + detail: "stripe should not be called in this test".into(), + }) + } + } + + fn checkpoint(kind: &str, step_id: &str, payload: &str) -> Checkpoint { + Checkpoint { + instance: "demo".into(), + step_id: step_id.into(), + resource_kind: kind.into(), + resource_id: "atto-demo-web".into(), + payload: payload.into(), + recorded_at: 0, + } + } + + fn railway_def() -> StackDef { + StackDef::parse( + "[stack]\nname=\"atto\"\n[services.web]\nsource={repo=\"r\",ref=\"main\"}\nenv={}\nhealth={path=\"/\"}\n", + ) + .unwrap() + } + + fn subj() -> (tempfile::TempDir, RailwaySubstrate) { + let dir = tempfile::tempdir().unwrap(); + let s = RailwaySubstrate::for_test(NoRunner, dir.path(), false); + (dir, s) + } + + const PAYLOAD: &str = r#"{"stripe_resource":"demo-web","project_id":"proj_1","service_name":"atto-demo-web","origin":"https://atto-demo-web.up.railway.app"}"#; + + #[test] + fn resource_name_and_origin_are_dns_safe() { + let def = railway_def(); + assert_eq!( + RailwaySubstrate::::resource_name(&def, "demo", "web"), + "atto-demo-web" + ); + let (_dir, s) = subj(); + assert_eq!( + s.service_origin(&def, "demo", "web"), + "https://atto-demo-web.up.railway.app" + ); + } + + #[test] + fn railway_substrate_defaults() { + let s = RailwaySubstrate::new(std::env::temp_dir(), Default::default(), false); + assert_eq!(s.name(), "railway"); + assert!(!s.supports_source_override()); + assert_eq!(s.default_lease(), Duration::from_secs(8 * 3600)); + } + + #[tokio::test] + async fn service_present_when_stripe_registers_it() { + let runner = test_support::ScriptedRunner::new(vec![test_support::services(&["demo-web"])]); + let dir = tempfile::tempdir().unwrap(); + let s = RailwaySubstrate::for_test(&runner, dir.path(), false); + let cp = checkpoint("railway-service", "start:web", PAYLOAD); + assert_eq!(s.observe("demo", &cp).await.unwrap(), Observation::Present); + } + + #[tokio::test] + async fn service_gone_when_stripe_does_not_register_it() { + let runner = test_support::ScriptedRunner::new(vec![test_support::services(&[])]); + let dir = tempfile::tempdir().unwrap(); + let s = RailwaySubstrate::for_test(&runner, dir.path(), false); + let cp = checkpoint("railway-service", "start:web", PAYLOAD); + assert_eq!(s.observe("demo", &cp).await.unwrap(), Observation::Gone); + } + + #[tokio::test] + async fn source_ref_observes_gone_so_teardown_drops_it() { + let (_dir, s) = subj(); + let cp = checkpoint( + "source-ref", + "materialize:web", + r#"{"repo":"r","ref":"main"}"#, + ); + assert_eq!(s.observe("demo", &cp).await.unwrap(), Observation::Gone); + s.destroy("demo", &cp).await.unwrap(); + } + + #[tokio::test] + async fn unknown_resource_kind_fails_closed() { + let (_dir, s) = subj(); + let cp = checkpoint("not-a-real-kind", "start:web", "{}"); + assert!(s.observe("demo", &cp).await.is_err()); + assert!(s.destroy("demo", &cp).await.is_err()); + } + + #[tokio::test] + async fn teardown_removes_via_stripe() { + let runner = test_support::ScriptedRunner::new(vec![ + test_support::services(&["demo-web"]), + test_support::ok_empty(), + ]); + let dir = tempfile::tempdir().unwrap(); + let s = RailwaySubstrate::for_test(&runner, dir.path(), false); + let cp = checkpoint("railway-service", "start:web", PAYLOAD); + s.destroy("demo", &cp).await.unwrap(); + let calls = runner.calls(); + assert!( + calls + .iter() + .any(|c| c.first().map(String::as_str) == Some("remove") + && c.iter().any(|a| a == "demo-web")), + "expected a `remove demo-web` call, got {calls:?}" + ); + } +} diff --git a/crates/stackless-wordpress/Cargo.toml b/crates/stackless-wordpress/Cargo.toml new file mode 100644 index 0000000..cee3cec --- /dev/null +++ b/crates/stackless-wordpress/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "stackless-wordpress" +edition.workspace = true +version.workspace = true +license.workspace = true +repository.workspace = true + +[lints] +workspace = true + +[dependencies] +async-trait = "0.1.89" +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.150" +stackless-cloud.workspace = true +stackless-core.workspace = true +stackless-integrations.workspace = true +stackless-stripe-projects.workspace = true +thiserror = "2.0.18" +tokio = { version = "1.52.3", features = ["rt", "time", "macros", "sync"] } + +[dev-dependencies] +stackless-stripe-projects = { workspace = true, features = ["test-support"] } +tempfile = "3.27.0" diff --git a/crates/stackless-wordpress/src/codes.rs b/crates/stackless-wordpress/src/codes.rs new file mode 100644 index 0000000..926126b --- /dev/null +++ b/crates/stackless-wordpress/src/codes.rs @@ -0,0 +1,17 @@ +//! Stable error codes for the WordPress substrate (ARCHITECTURE.md §2/§8). +//! +//! Codes live with the provider, not in core. The binary aggregates every +//! crate's `ALL` for a workspace-wide uniqueness check. + +pub const WORDPRESS_CONFIG_INVALID: &str = "wordpress.config.invalid"; +pub const WORDPRESS_PAYMENT_NOT_CONFIRMED: &str = "wordpress.payment.not_confirmed"; +pub const WORDPRESS_PROVISION_FAILED: &str = "wordpress.provision.failed"; +pub const WORDPRESS_PREPARE_FAILED: &str = "wordpress.prepare.failed"; + +/// Every WordPress code, for the workspace uniqueness test. +pub const ALL: &[&str] = &[ + WORDPRESS_CONFIG_INVALID, + WORDPRESS_PAYMENT_NOT_CONFIRMED, + WORDPRESS_PROVISION_FAILED, + WORDPRESS_PREPARE_FAILED, +]; diff --git a/crates/stackless-wordpress/src/config.rs b/crates/stackless-wordpress/src/config.rs new file mode 100644 index 0000000..75cfe15 --- /dev/null +++ b/crates/stackless-wordpress/src/config.rs @@ -0,0 +1,191 @@ +//! Parsing the wordpress-specific blocks of the definition (§1 schema). +//! +//! Phase 1 is Stripe-only: the substrate provisions `wordpress.com/site` and +//! records a best-effort origin. Deploying source to WordPress.com is deferred. + +use serde::Serialize; +use stackless_core::def::StackDef; + +use crate::SUBSTRATE_NAME; +use crate::error::WordPressError; +use stackless_stripe_projects::CatalogService; + +/// A service's `[services.X.wordpress]` block. Optional — an absent block uses +/// `plan = "free"`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ServiceWordpress { + pub plan: String, +} + +/// The typed `wordpress.com/site` `--config`. +#[derive(Debug, Serialize)] +pub struct WordPressComSiteConfig { + pub plan: String, +} + +impl CatalogService for WordPressComSiteConfig { + const REFERENCE: &'static str = "wordpress.com/site"; +} + +/// Read and shape-check `[services..wordpress]` (optional block; unknown +/// keys inside it are a fault, to trap agent typos). +pub fn service_wordpress( + def: &StackDef, + service: &str, +) -> Result { + let location = format!("services.{service}.wordpress"); + let Some(block) = def + .services + .get(service) + .and_then(|spec| spec.substrates.get(SUBSTRATE_NAME)) + else { + return Ok(ServiceWordpress { + plan: "free".into(), + }); + }; + let table = block + .as_table() + .ok_or_else(|| WordPressError::ConfigInvalid { + location: location.clone(), + detail: "must be a table { plan?, env? }".into(), + })?; + for key in table.keys() { + if !matches!(key.as_str(), "plan" | "env") { + return Err(WordPressError::ConfigInvalid { + location: location.clone(), + detail: format!("unknown key {key:?} (known: plan, env)"), + }); + } + } + let plan = match table.get("plan") { + None => "free".to_owned(), + Some(value) => { + let plan = value + .as_str() + .filter(|s| !s.trim().is_empty()) + .ok_or_else(|| WordPressError::ConfigInvalid { + location: format!("{location}.plan"), + detail: "must be a non-empty string".into(), + })?; + if !is_valid_plan(plan) { + return Err(WordPressError::ConfigInvalid { + location: format!("{location}.plan"), + detail: format!( + "must be one of free, personal, premium, business, commerce; got {plan:?}" + ), + }); + } + plan.to_owned() + } + }; + Ok(ServiceWordpress { plan }) +} + +/// Catalog enum for `wordpress.com/site` plan. +pub fn is_valid_plan(plan: &str) -> bool { + matches!( + plan, + "free" | "personal" | "premium" | "business" | "commerce" + ) +} + +/// Whether `name` is a legal WordPress site slug label: a lowercase letter then +/// 2..=62 of `[a-z0-9-]` (DNS-safe, matches the cloud name rule). +pub fn is_valid_site_name(name: &str) -> bool { + let len = name.len(); + if !(3..=63).contains(&len) { + return false; + } + let mut chars = name.chars(); + match chars.next() { + Some(c) if c.is_ascii_lowercase() => {} + _ => return false, + } + chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(toml: &str) -> StackDef { + StackDef::parse(toml).expect("valid base toml") + } + + const BASE: &str = r#" +[stack] +name = "atto" +[services.web] +source = { repo = "https://github.com/snowmead/stackless", ref = "main" } +env = {} +health = { path = "/", contains = "ok" } +[services.web.wordpress] +plan = "free" +"#; + + #[test] + fn parses_plan_and_defaults_when_block_absent() { + let def = parse(BASE); + assert_eq!(service_wordpress(&def, "web").unwrap().plan, "free"); + let no_block = parse( + "[stack]\nname=\"atto\"\n[services.web]\nsource={repo=\"r\",ref=\"main\"}\nenv={}\nhealth={path=\"/\"}\n", + ); + assert_eq!(service_wordpress(&no_block, "web").unwrap().plan, "free"); + } + + #[test] + fn unknown_key_is_rejected() { + let toml = BASE.to_owned() + "bogus = 1\n"; + let err = service_wordpress(&parse(&toml), "web").unwrap_err(); + assert_eq!( + stackless_core::fault::Fault::code(&err), + crate::codes::WORDPRESS_CONFIG_INVALID + ); + } + + #[test] + fn invalid_plan_is_rejected() { + let toml = BASE.replace("plan = \"free\"", "plan = \"enterprise\""); + let err = service_wordpress(&parse(&toml), "web").unwrap_err(); + assert_eq!( + stackless_core::fault::Fault::code(&err), + crate::codes::WORDPRESS_CONFIG_INVALID + ); + } + + #[test] + fn site_name_pattern() { + assert!(is_valid_site_name("atto-demo-web")); + assert!(!is_valid_site_name("ab")); + assert!(!is_valid_site_name("1abc")); + assert!(!is_valid_site_name("Abc")); + assert!(!is_valid_site_name(&"a".repeat(64))); + } + + #[test] + fn typed_config_carries_its_catalog_reference() { + assert_eq!(WordPressComSiteConfig::REFERENCE, "wordpress.com/site"); + } + + /// Catalog gap check: the `wordpress.com/site` config must validate against the + /// committed catalog fixture. Fails loudly if Stripe drifts the schema. + #[test] + fn wordpress_config_matches_catalog() { + const FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../stackless-stripe-projects/tests/fixtures/catalog.json" + )); + let catalog = stackless_stripe_projects::Catalog::from_json_envelope(FIXTURE).unwrap(); + let failures = stackless_stripe_projects::verify_service( + &catalog, + &WordPressComSiteConfig { + plan: "free".into(), + }, + ); + assert!( + failures.is_empty(), + "wordpress.com/site catalog gaps:\n{}", + failures.join("\n") + ); + } +} diff --git a/crates/stackless-wordpress/src/error.rs b/crates/stackless-wordpress/src/error.rs new file mode 100644 index 0000000..4a0d9c6 --- /dev/null +++ b/crates/stackless-wordpress/src/error.rs @@ -0,0 +1,77 @@ +//! WordPress-substrate errors (codes in this crate's `wordpress.*` registry). + +use stackless_core::fault::{ErrorContext, Fault}; + +use crate::codes; + +#[derive(Debug, thiserror::Error)] +pub enum WordPressError { + #[error("[{location}] is invalid: {detail}")] + ConfigInvalid { location: String, detail: String }, + + #[error("creating paid WordPress.com resources requires explicit consent")] + PaymentNotConfirmed { resource: String }, + + #[error("provisioning {resource:?} on WordPress.com did not complete: {detail}")] + ProvisionFailed { resource: String, detail: String }, + + #[error("prepare for {service:?} failed: {message}")] + PrepareFailed { + service: String, + command: Option, + message: String, + log_tail: Option, + }, +} + +impl Fault for WordPressError { + fn code(&self) -> &'static str { + match self { + Self::ConfigInvalid { .. } => codes::WORDPRESS_CONFIG_INVALID, + Self::PaymentNotConfirmed { .. } => codes::WORDPRESS_PAYMENT_NOT_CONFIRMED, + Self::ProvisionFailed { .. } => codes::WORDPRESS_PROVISION_FAILED, + Self::PrepareFailed { .. } => codes::WORDPRESS_PREPARE_FAILED, + } + } + + fn remediation(&self) -> String { + match self { + Self::ConfigInvalid { location, .. } => { + format!( + "fix the [{location}] block; see ARCHITECTURE.md §1 for the wordpress schema" + ) + } + Self::PaymentNotConfirmed { .. } => { + "re-run with --confirm-paid to consent to WordPress.com charges (bounded by the \ + project's hard spend cap)" + .into() + } + Self::ProvisionFailed { .. } => { + "wait a moment for WordPress.com to finish provisioning and re-run `up` to resume" + .into() + } + Self::PrepareFailed { service, .. } => format!( + "inspect context.log_tail; run the {service:?} prepare command by hand; re-run \ + `stackless up `" + ), + } + } + + fn context(&self) -> ErrorContext { + match self { + Self::PrepareFailed { + service, + command, + log_tail, + .. + } => ErrorContext { + service: Some(service.clone()), + command: command.clone(), + log_hint: Some(format!("stackless logs {service}")), + log_tail: log_tail.clone(), + ..ErrorContext::default() + }, + _ => ErrorContext::default(), + } + } +} diff --git a/crates/stackless-wordpress/src/lib.rs b/crates/stackless-wordpress/src/lib.rs new file mode 100644 index 0000000..083ed7c --- /dev/null +++ b/crates/stackless-wordpress/src/lib.rs @@ -0,0 +1,619 @@ +//! stackless-wordpress (ARCHITECTURE.md §4): the WordPress.com cloud substrate. +//! +//! Mirrors the Railway/Netlify cloud flow at the Stripe layer: Stripe Projects +//! provisions `wordpress.com/site` and tracks spend; observe/destroy key off the +//! **Stripe resource registration**, not the WordPress.com API. One long-lived +//! Stripe project per stack holds each instance as a named environment. +//! +//! ## Credential model (pinned by `mise run discover wordpress.com/site`) +//! +//! Provisioning `wordpress.com/site` returns a Stripe-managed site URL +//! (`WORDPRESS_COM_SITE_URL`). The substrate records it in the start checkpoint +//! but does not call WordPress.com REST APIs in this phase. +//! +//! ## Phase 1 scope and REST gaps +//! +//! - **Stripe provision only.** `start` provisions `wordpress.com/site` via Stripe +//! Projects and records a best-effort origin +//! `https://{stack}-{instance}-{service}.wordpress.com`. There is no deploy of +//! source and no health polling against a live URL yet. +//! - **Health gate is a no-op** until WordPress.com deploy lands. +//! - **Logs are unavailable** (`fetch_logs` returns `None`). +//! - **Cloud resource names** are `{stack}-{instance}-{service}` — DNS-safe. +//! - **Setup is skipped on cloud**; **prepare** runs on the operator's machine. +//! - **Source override is unsupported** — WordPress.com deploys committed refs +//! (when REST deploy is implemented). +//! - **`wordpress.com/domain` is excluded** — non-refundable domain purchase. + +pub mod codes; +pub mod config; +pub mod error; + +use std::collections::BTreeMap; +use std::path::PathBuf; +use std::time::Duration; + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use stackless_core::def::{Namespace, StackDef}; +use stackless_core::engine::StepKind; +use stackless_core::state::Checkpoint; +use stackless_core::substrate::{ + NamespacePurpose, Observation, StepContext, StepResource, Substrate, SubstrateFault, +}; +use tokio::sync::Mutex; + +use crate::config::{ServiceWordpress, WordPressComSiteConfig}; +use crate::error::WordPressError; +use stackless_stripe_projects::ProjectsError; +use stackless_stripe_projects::provision::{ProvisionContext, provision_outputs}; +use stackless_stripe_projects::stripe::{CommandRunner, StripeProjects, TokioRunner}; +use stackless_stripe_projects::{project, requires_confirmation}; + +pub const SUBSTRATE_NAME: &str = "wordpress"; + +/// The hard per-provider spend cap set on first paid confirmation (§4). +pub const SPEND_CAP_USD: u32 = 25; + +/// The provider prefix Stripe uses for `wordpress.com/site` output env vars. +/// Pinned by `mise run discover wordpress.com/site`. +const PROVIDER_PREFIX: &str = "WORDPRESS_COM"; + +fn fault(err: WordPressError) -> SubstrateFault { + SubstrateFault::from_fault(&err) +} + +fn projects_fault(err: ProjectsError) -> SubstrateFault { + SubstrateFault::from_fault(&err) +} + +fn integration_fault(err: stackless_integrations::IntegrationError) -> SubstrateFault { + SubstrateFault::from_fault(&err) +} + +fn prepare_fault(f: stackless_cloud::prepare::PrepareFailure) -> SubstrateFault { + fault(WordPressError::PrepareFailed { + service: f.service, + command: f.command, + message: f.message, + log_tail: f.log_tail, + }) +} + +/// What a `start:` checkpoint records. Observe/destroy use Stripe, not +/// the WordPress.com API. +#[derive(Debug, Serialize, Deserialize)] +struct WordPressPayload { + stripe_resource: String, + site_url: Option, + site_name: String, + origin: String, +} + +/// What a `materialize:` checkpoint records: the pinned source. Owns +/// nothing locally, so observe reports Gone and resume cheaply re-records it. +#[derive(Debug, Serialize, Deserialize)] +struct SourceRefPayload { + repo: String, + #[serde(rename = "ref")] + reference: String, +} + +/// The WordPress substrate. Generic over the command runner so tests inject canned +/// Stripe envelopes; production uses the real `stripe` binary. +pub struct WordPressSubstrate { + pub definition_dir: PathBuf, + pub secrets: BTreeMap, + pub confirm_paid: bool, + runner: R, + ensured: Mutex, +} + +impl std::fmt::Debug for WordPressSubstrate { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("WordPressSubstrate") + .field("definition_dir", &self.definition_dir) + .field("confirm_paid", &self.confirm_paid) + .finish_non_exhaustive() + } +} + +impl WordPressSubstrate { + pub fn new( + definition_dir: impl Into, + secrets: BTreeMap, + confirm_paid: bool, + ) -> Self { + Self { + definition_dir: definition_dir.into(), + secrets, + confirm_paid, + runner: TokioRunner, + ensured: Mutex::new(false), + } + } +} + +impl WordPressSubstrate { + #[cfg(test)] + fn for_test(runner: R, definition_dir: impl Into, confirm_paid: bool) -> Self { + Self { + definition_dir: definition_dir.into(), + secrets: BTreeMap::new(), + confirm_paid, + runner, + ensured: Mutex::new(false), + } + } + + fn stripe(&self) -> StripeProjects<&R> { + StripeProjects::new(&self.runner, self.definition_dir.clone()) + } + + /// `{stack}-{instance}-{service}` (DNS-safe). + fn resource_name(def: &StackDef, instance: &str, node: &str) -> String { + format!("{}-{instance}-{node}", def.stack.name.as_str()) + } + + /// `https://{stack}-{instance}-{service}.wordpress.com` — best-effort until + /// Stripe returns the real site URL. + fn origin_placeholder(site_name: &str) -> String { + format!("https://{site_name}.wordpress.com") + } + + fn namespace(&self, def: &StackDef, instance: &str, prior: &[Checkpoint]) -> Namespace { + let mut namespace = Namespace { + stack_name: def.stack.name.clone(), + instance_name: stackless_core::types::DnsName::from_stored(instance), + ..Namespace::default() + }; + for service in def.services.keys() { + let name = Self::resource_name(def, instance, service); + namespace + .service_origins + .insert(service.clone(), Self::origin_placeholder(&name)); + } + namespace.secrets = self.secrets.clone(); + namespace.add_integration_checkpoints(prior); + namespace + } + + async fn ensure_project_and_env( + &self, + def: &StackDef, + instance: &str, + ) -> Result<(), SubstrateFault> { + let mut done = self.ensured.lock().await; + if *done { + return Ok(()); + } + let spend = self.confirm_paid.then_some((SPEND_CAP_USD, "wordpress")); + stackless_cloud::ensure::project_and_env( + &self.stripe(), + def, + &self.definition_dir, + instance, + spend, + ) + .await + .map_err(projects_fault)?; + *done = true; + Ok(()) + } + + fn require_confirm_paid(&self, resource: &str) -> Result<(), SubstrateFault> { + if !self.confirm_paid { + return Err(fault(WordPressError::PaymentNotConfirmed { + resource: resource.to_owned(), + })); + } + Ok(()) + } + + async fn start_service( + &self, + def: &StackDef, + instance: &str, + service: &str, + ) -> Result { + let wp_cfg = config::service_wordpress(def, service).map_err(fault)?; + let site_name = Self::resource_name(def, instance, service); + let resource = format!("{instance}-{service}"); + + let catalog = self.stripe().catalog().await.map_err(projects_fault)?; + let cfg = site_config(&wp_cfg); + if requires_confirmation(&catalog, &cfg).unwrap_or(false) { + self.require_confirm_paid(&resource)?; + } + let ctx = ProvisionContext { + def, + instance, + logical_name: service, + definition_dir: &self.definition_dir, + substrate: SUBSTRATE_NAME, + skip_instance_context: true, + }; + let (_resource_name, outputs) = provision_outputs( + &self.stripe(), + &catalog, + &ctx, + &cfg, + PROVIDER_PREFIX, + // Pinned by `mise run discover wordpress.com/site`. + &[("SITE_URL", "site_url", true)], + ) + .await + .map_err(projects_fault)?; + let site_url = outputs.get("site_url").cloned(); + let origin = site_url + .as_deref() + .filter(|url| !url.trim().is_empty()) + .map(str::to_owned) + .unwrap_or_else(|| Self::origin_placeholder(&site_name)); + + let payload = WordPressPayload { + stripe_resource: resource, + site_url, + site_name: site_name.clone(), + origin, + }; + Ok(StepResource { + resource_kind: "wordpress-site".into(), + resource_id: site_name, + payload: serde_json::to_string(&payload).unwrap_or_default(), + }) + } + + async fn run_prepare( + &self, + def: &StackDef, + instance: &str, + service: &str, + prior: &[Checkpoint], + ) -> Result<(), SubstrateFault> { + let Some(spec) = def.services.get(service) else { + return Ok(()); + }; + let namespace = self.namespace(def, instance, prior); + stackless_cloud::prepare::run_service_prepare( + &namespace, + &self.secrets, + service, + SUBSTRATE_NAME, + spec, + ) + .await + .map_err(prepare_fault) + } +} + +fn site_config(cfg: &ServiceWordpress) -> WordPressComSiteConfig { + WordPressComSiteConfig { + plan: cfg.plan.clone(), + } +} + +#[async_trait] +impl Substrate for WordPressSubstrate { + fn name(&self) -> &str { + SUBSTRATE_NAME + } + + fn validate_definition(&self, def: &StackDef) -> Result<(), SubstrateFault> { + for service in def.services.keys() { + config::service_wordpress(def, service).map_err(fault)?; + let site_name = Self::resource_name(def, "i", service); + if !config::is_valid_site_name(&site_name) { + return Err(fault(WordPressError::ConfigInvalid { + location: format!("services.{service}"), + detail: format!( + "derived WordPress site name {site_name:?} is not DNS-safe; \ + shorten the stack/service name" + ), + })); + } + } + Ok(()) + } + + fn supports_source_override(&self) -> bool { + false + } + + fn default_lease(&self) -> Duration { + Duration::from_secs(8 * 3600) + } + + fn service_origin(&self, def: &StackDef, instance: &str, service: &str) -> String { + Self::origin_placeholder(&Self::resource_name(def, instance, service)) + } + + fn build_namespace( + &self, + def: &StackDef, + instance: &str, + prior: &[Checkpoint], + secrets: &BTreeMap, + _purpose: NamespacePurpose, + ) -> Namespace { + let mut namespace = self.namespace(def, instance, prior); + namespace.secrets = secrets.clone(); + namespace + } + + async fn execute(&self, ctx: StepContext<'_>) -> Result { + self.ensure_project_and_env(ctx.def, ctx.instance).await?; + + let node = ctx.step.node.as_str(); + match ctx.step.kind { + StepKind::ProvisionIntegration => stackless_integrations::provision( + SUBSTRATE_NAME, + &self.stripe(), + ctx.def, + &self.definition_dir, + ctx.instance, + node, + true, + ) + .await + .map_err(integration_fault), + StepKind::Materialize => { + let spec = ctx.def.services.get(node).ok_or_else(|| { + fault(WordPressError::ConfigInvalid { + location: format!("services.{node}"), + detail: "service not in definition".into(), + }) + })?; + let payload = SourceRefPayload { + repo: spec.source.repo.clone(), + reference: spec.source.reference.clone(), + }; + Ok(StepResource { + resource_kind: "source-ref".into(), + resource_id: format!("{}@{}", spec.source.repo, spec.source.reference), + payload: serde_json::to_string(&payload).unwrap_or_default(), + }) + } + StepKind::Setup => Ok(stackless_core::substrate::action_resource(&ctx.step.id)), + StepKind::Prepare => { + self.run_prepare(ctx.def, ctx.instance, node, ctx.prior) + .await?; + Ok(stackless_core::substrate::action_resource(&ctx.step.id)) + } + StepKind::Start => self.start_service(ctx.def, ctx.instance, node).await, + StepKind::HealthGate => Ok(stackless_core::substrate::action_resource(&ctx.step.id)), + } + } + + async fn observe( + &self, + _instance: &str, + checkpoint: &Checkpoint, + ) -> Result { + match checkpoint.resource_kind.as_str() { + "wordpress-site" => { + let payload = stackless_cloud::checkpoint::parse_payload::( + &checkpoint.payload, + ) + .map_err(|detail| { + fault(WordPressError::ConfigInvalid { + location: "checkpoint.payload".into(), + detail, + }) + })?; + let stripe_resource = payload + .map(|p| p.stripe_resource) + .unwrap_or_else(|| checkpoint.resource_id.clone()); + let present = project::resource_registered(&self.stripe(), &stripe_resource) + .await + .map_err(projects_fault)?; + Ok(stackless_core::substrate::present_or_gone(present)) + } + kind if stackless_integrations::is_integration_resource(kind) => { + stackless_integrations::observe( + SUBSTRATE_NAME, + &self.stripe(), + &checkpoint.payload, + &checkpoint.resource_id, + kind, + ) + .await + .map_err(integration_fault) + } + kind if stackless_cloud::checkpoint::is_ephemeral_resource_kind(kind) => { + Ok(Observation::Gone) + } + kind => Err(fault(WordPressError::ConfigInvalid { + location: "checkpoint.resource_kind".into(), + detail: format!("unknown resource kind {kind:?}"), + })), + } + } + + async fn destroy( + &self, + _instance: &str, + checkpoint: &Checkpoint, + ) -> Result<(), SubstrateFault> { + match checkpoint.resource_kind.as_str() { + "wordpress-site" => { + let payload = stackless_cloud::checkpoint::parse_payload::( + &checkpoint.payload, + ) + .map_err(|detail| { + fault(WordPressError::ConfigInvalid { + location: "checkpoint.payload".into(), + detail, + }) + })?; + let stripe_resource = payload + .map(|p| p.stripe_resource) + .unwrap_or_else(|| checkpoint.resource_id.clone()); + project::remove_resource(&self.stripe(), &stripe_resource) + .await + .map_err(projects_fault) + } + kind if stackless_integrations::is_integration_resource(kind) => { + stackless_integrations::destroy( + SUBSTRATE_NAME, + &self.stripe(), + &checkpoint.payload, + &checkpoint.resource_id, + kind, + ) + .await + .map_err(integration_fault) + } + kind if stackless_cloud::checkpoint::is_ephemeral_resource_kind(kind) => Ok(()), + kind => Err(fault(WordPressError::ConfigInvalid { + location: "checkpoint.resource_kind".into(), + detail: format!("unknown resource kind {kind:?}"), + })), + } + } + + async fn finalize_teardown(&self, instance: &str) -> Result<(), SubstrateFault> { + stackless_integrations::finalize_stripe_instance(&self.stripe(), instance).await; + Ok(()) + } + + async fn spend(&self) -> Option { + Some( + stackless_cloud::spend::fetch( + &self.definition_dir, + SUBSTRATE_NAME, + SPEND_CAP_USD, + "wordpress.com", + ) + .await, + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use stackless_stripe_projects::stripe::{CommandOutput, CommandRunner}; + use stackless_stripe_projects::test_support; + use std::path::Path as StdPath; + + struct NoRunner; + #[async_trait] + impl CommandRunner for NoRunner { + async fn run( + &self, + _args: &[String], + _cwd: &StdPath, + ) -> Result { + Err(ProjectsError::Unavailable { + detail: "stripe should not be called in this test".into(), + }) + } + } + + fn checkpoint(kind: &str, step_id: &str, payload: &str) -> Checkpoint { + Checkpoint { + instance: "demo".into(), + step_id: step_id.into(), + resource_kind: kind.into(), + resource_id: "atto-demo-web".into(), + payload: payload.into(), + recorded_at: 0, + } + } + + fn wordpress_def() -> StackDef { + StackDef::parse( + "[stack]\nname=\"atto\"\n[services.web]\nsource={repo=\"r\",ref=\"main\"}\nenv={}\nhealth={path=\"/\"}\n", + ) + .unwrap() + } + + fn subj() -> (tempfile::TempDir, WordPressSubstrate) { + let dir = tempfile::tempdir().unwrap(); + let s = WordPressSubstrate::for_test(NoRunner, dir.path(), false); + (dir, s) + } + + const PAYLOAD: &str = r#"{"stripe_resource":"demo-web","site_url":"https://atto-demo-web.wordpress.com","site_name":"atto-demo-web","origin":"https://atto-demo-web.wordpress.com"}"#; + + #[test] + fn resource_name_and_origin_are_dns_safe() { + let def = wordpress_def(); + assert_eq!( + WordPressSubstrate::::resource_name(&def, "demo", "web"), + "atto-demo-web" + ); + let (_dir, s) = subj(); + assert_eq!( + s.service_origin(&def, "demo", "web"), + "https://atto-demo-web.wordpress.com" + ); + } + + #[test] + fn wordpress_substrate_defaults() { + let s = WordPressSubstrate::new(std::env::temp_dir(), Default::default(), false); + assert_eq!(s.name(), "wordpress"); + assert!(!s.supports_source_override()); + assert_eq!(s.default_lease(), Duration::from_secs(8 * 3600)); + } + + #[tokio::test] + async fn service_present_when_stripe_registers_it() { + let runner = test_support::ScriptedRunner::new(vec![test_support::services(&["demo-web"])]); + let dir = tempfile::tempdir().unwrap(); + let s = WordPressSubstrate::for_test(&runner, dir.path(), false); + let cp = checkpoint("wordpress-site", "start:web", PAYLOAD); + assert_eq!(s.observe("demo", &cp).await.unwrap(), Observation::Present); + } + + #[tokio::test] + async fn service_gone_when_stripe_does_not_register_it() { + let runner = test_support::ScriptedRunner::new(vec![test_support::services(&[])]); + let dir = tempfile::tempdir().unwrap(); + let s = WordPressSubstrate::for_test(&runner, dir.path(), false); + let cp = checkpoint("wordpress-site", "start:web", PAYLOAD); + assert_eq!(s.observe("demo", &cp).await.unwrap(), Observation::Gone); + } + + #[tokio::test] + async fn source_ref_observes_gone_so_teardown_drops_it() { + let (_dir, s) = subj(); + let cp = checkpoint( + "source-ref", + "materialize:web", + r#"{"repo":"r","ref":"main"}"#, + ); + assert_eq!(s.observe("demo", &cp).await.unwrap(), Observation::Gone); + s.destroy("demo", &cp).await.unwrap(); + } + + #[tokio::test] + async fn unknown_resource_kind_fails_closed() { + let (_dir, s) = subj(); + let cp = checkpoint("not-a-real-kind", "start:web", "{}"); + assert!(s.observe("demo", &cp).await.is_err()); + assert!(s.destroy("demo", &cp).await.is_err()); + } + + #[tokio::test] + async fn teardown_removes_via_stripe() { + let runner = test_support::ScriptedRunner::new(vec![ + test_support::services(&["demo-web"]), + test_support::ok_empty(), + ]); + let dir = tempfile::tempdir().unwrap(); + let s = WordPressSubstrate::for_test(&runner, dir.path(), false); + let cp = checkpoint("wordpress-site", "start:web", PAYLOAD); + s.destroy("demo", &cp).await.unwrap(); + let calls = runner.calls(); + assert!( + calls + .iter() + .any(|c| c.first().map(String::as_str) == Some("remove") + && c.iter().any(|a| a == "demo-web")), + "expected a `remove demo-web` call, got {calls:?}" + ); + } +} diff --git a/crates/stackless/Cargo.toml b/crates/stackless/Cargo.toml index dd7b56b..18db05e 100644 --- a/crates/stackless/Cargo.toml +++ b/crates/stackless/Cargo.toml @@ -21,6 +21,11 @@ stackless-render.workspace = true stackless-vercel.workspace = true stackless-fly.workspace = true stackless-netlify.workspace = true +stackless-railway.workspace = true +stackless-laravel-cloud.workspace = true +stackless-gitlab.workspace = true +stackless-wordpress.workspace = true +stackless-cloudflare.workspace = true stackless-stripe-projects.workspace = true thiserror = "2.0.18" tokio = { version = "1.52.3", features = ["rt-multi-thread"] } diff --git a/crates/stackless/src/substrates.rs b/crates/stackless/src/substrates.rs index 5f52157..a5f2e6e 100644 --- a/crates/stackless/src/substrates.rs +++ b/crates/stackless/src/substrates.rs @@ -2,12 +2,17 @@ //! (ground rule: core never names a substrate; the `Substrate` trait is the //! only seam). Adding a hosting provider is one row here plus its own crate. +use stackless_cloudflare::{CloudflareSubstrate, SUBSTRATE_NAME as CLOUDFLARE}; use stackless_core::substrate::Substrate; use stackless_fly::{FlySubstrate, SUBSTRATE_NAME as FLY}; +use stackless_gitlab::{GitLabSubstrate, SUBSTRATE_NAME as GITLAB}; +use stackless_laravel_cloud::{LaravelCloudSubstrate, SUBSTRATE_NAME as LARAVEL_CLOUD}; use stackless_local::{LocalSubstrate, SUBSTRATE_NAME as LOCAL}; use stackless_netlify::{NetlifySubstrate, SUBSTRATE_NAME as NETLIFY}; +use stackless_railway::{RailwaySubstrate, SUBSTRATE_NAME as RAILWAY}; use stackless_render::{RenderSubstrate, SUBSTRATE_NAME as RENDER}; use stackless_vercel::{SUBSTRATE_NAME as VERCEL, VercelSubstrate}; +use stackless_wordpress::{SUBSTRATE_NAME as WORDPRESS, WordPressSubstrate}; use crate::commands::SubstrateCtx; use crate::error::CliError; @@ -40,6 +45,26 @@ static SUBSTRATES: &[SubstrateInfo] = &[ name: NETLIFY, build: build_netlify, }, + SubstrateInfo { + name: RAILWAY, + build: build_railway, + }, + SubstrateInfo { + name: CLOUDFLARE, + build: build_cloudflare, + }, + SubstrateInfo { + name: WORDPRESS, + build: build_wordpress, + }, + SubstrateInfo { + name: LARAVEL_CLOUD, + build: build_laravel_cloud, + }, + SubstrateInfo { + name: GITLAB, + build: build_gitlab, + }, ]; /// Every substrate name the binary can dispatch to. @@ -112,6 +137,46 @@ fn build_netlify(ctx: SubstrateCtx) -> Result, CliError> { ))) } +fn build_laravel_cloud(ctx: SubstrateCtx) -> Result, CliError> { + Ok(Box::new(LaravelCloudSubstrate::new( + ctx.definition_dir, + ctx.secrets, + ctx.confirm_paid, + ))) +} + +fn build_railway(ctx: SubstrateCtx) -> Result, CliError> { + Ok(Box::new(RailwaySubstrate::new( + ctx.definition_dir, + ctx.secrets, + ctx.confirm_paid, + ))) +} + +fn build_cloudflare(ctx: SubstrateCtx) -> Result, CliError> { + Ok(Box::new(CloudflareSubstrate::new( + ctx.definition_dir, + ctx.secrets, + ctx.confirm_paid, + ))) +} + +fn build_wordpress(ctx: SubstrateCtx) -> Result, CliError> { + Ok(Box::new(WordPressSubstrate::new( + ctx.definition_dir, + ctx.secrets, + ctx.confirm_paid, + ))) +} + +fn build_gitlab(ctx: SubstrateCtx) -> Result, CliError> { + Ok(Box::new(GitLabSubstrate::new( + ctx.definition_dir, + ctx.secrets, + ctx.confirm_paid, + ))) +} + #[cfg(test)] mod tests { use std::collections::BTreeSet; @@ -127,6 +192,11 @@ mod tests { all.extend(stackless_vercel::codes::ALL); all.extend(stackless_fly::codes::ALL); all.extend(stackless_netlify::codes::ALL); + all.extend(stackless_railway::codes::ALL); + all.extend(stackless_cloudflare::codes::ALL); + all.extend(stackless_wordpress::codes::ALL); + all.extend(stackless_laravel_cloud::codes::ALL); + all.extend(stackless_gitlab::codes::ALL); let unique: BTreeSet<&str> = all.iter().copied().collect(); assert_eq!( unique.len(), From 6afe94b1875738dd5b6abdd9760fd4ca2a7138bc Mon Sep 17 00:00:00 2001 From: Michael Assaf Date: Sat, 11 Jul 2026 13:48:58 -0400 Subject: [PATCH 3/3] fix(integrations): silence clippy on generated catalog providers Drop unused registry imports and rename clickhouse/clickhouse to cluster to avoid module_inception. --- crates/stackless-integrations/src/providers/agentmail/api.rs | 1 - .../stackless-integrations/src/providers/amplitude/analytics.rs | 1 - .../stackless-integrations/src/providers/base44_projects/app.rs | 1 - .../stackless-integrations/src/providers/blaxel/agent_drive.rs | 1 - crates/stackless-integrations/src/providers/blaxel/sandbox.rs | 1 - .../stackless-integrations/src/providers/browserbase/project.rs | 1 - .../src/providers/clickhouse/{clickhouse.rs => cluster.rs} | 0 crates/stackless-integrations/src/providers/clickhouse/mod.rs | 2 +- crates/stackless-integrations/src/providers/daytona/sandbox.rs | 1 - crates/stackless-integrations/src/providers/e2b/sandbox.rs | 1 - crates/stackless-integrations/src/providers/elevenlabs/tts.rs | 1 - crates/stackless-integrations/src/providers/exa/api.rs | 1 - crates/stackless-integrations/src/providers/firecrawl/api.rs | 1 - crates/stackless-integrations/src/providers/heygen/api.rs | 1 - .../stackless-integrations/src/providers/huggingface/bucket.rs | 1 - .../src/providers/huggingface/platform.rs | 1 - .../stackless-integrations/src/providers/metronome/sandbox.rs | 1 - .../stackless-integrations/src/providers/mixpanel/analytics.rs | 1 - crates/stackless-integrations/src/providers/mod.rs | 2 +- crates/stackless-integrations/src/providers/neon/postgres.rs | 1 - crates/stackless-integrations/src/providers/openrouter/api.rs | 1 - crates/stackless-integrations/src/providers/parallel/api.rs | 1 - crates/stackless-integrations/src/providers/postalform/mail.rs | 1 - .../stackless-integrations/src/providers/posthog/analytics.rs | 1 - crates/stackless-integrations/src/providers/railway/bucket.rs | 1 - crates/stackless-integrations/src/providers/railway/hosting.rs | 1 - crates/stackless-integrations/src/providers/railway/mongo.rs | 1 - crates/stackless-integrations/src/providers/railway/postgres.rs | 1 - crates/stackless-integrations/src/providers/railway/redis.rs | 1 - crates/stackless-integrations/src/providers/runloop/sandbox.rs | 1 - crates/stackless-integrations/src/providers/sentry/project.rs | 1 - crates/stackless-integrations/src/providers/sentry/seer.rs | 1 - crates/stackless-integrations/src/providers/supabase/project.rs | 1 - crates/stackless-integrations/src/providers/upstash/qstash.rs | 1 - crates/stackless-integrations/src/providers/upstash/redis.rs | 1 - crates/stackless-integrations/src/providers/upstash/search.rs | 1 - crates/stackless-integrations/src/providers/upstash/vector.rs | 1 - crates/stackless-integrations/src/providers/wix/headless.rs | 1 - crates/stackless-integrations/src/providers/workos/auth.rs | 1 - crates/stackless-integrations/src/registry.rs | 2 +- 40 files changed, 3 insertions(+), 39 deletions(-) rename crates/stackless-integrations/src/providers/clickhouse/{clickhouse.rs => cluster.rs} (100%) diff --git a/crates/stackless-integrations/src/providers/agentmail/api.rs b/crates/stackless-integrations/src/providers/agentmail/api.rs index 4369354..62ac563 100644 --- a/crates/stackless-integrations/src/providers/agentmail/api.rs +++ b/crates/stackless-integrations/src/providers/agentmail/api.rs @@ -9,7 +9,6 @@ use stackless_stripe_projects::provision::ProvisionContext; use super::FamilyResource; use crate::error::IntegrationError; use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; -use crate::registry; pub const RESOURCE_KIND: &str = "integration-agentmail"; diff --git a/crates/stackless-integrations/src/providers/amplitude/analytics.rs b/crates/stackless-integrations/src/providers/amplitude/analytics.rs index 30b00c3..f3876ef 100644 --- a/crates/stackless-integrations/src/providers/amplitude/analytics.rs +++ b/crates/stackless-integrations/src/providers/amplitude/analytics.rs @@ -9,7 +9,6 @@ use stackless_stripe_projects::provision::ProvisionContext; use super::FamilyResource; use crate::error::IntegrationError; use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; -use crate::registry; pub const RESOURCE_KIND: &str = "integration-amplitude"; diff --git a/crates/stackless-integrations/src/providers/base44_projects/app.rs b/crates/stackless-integrations/src/providers/base44_projects/app.rs index 052a5bb..ed92a91 100644 --- a/crates/stackless-integrations/src/providers/base44_projects/app.rs +++ b/crates/stackless-integrations/src/providers/base44_projects/app.rs @@ -9,7 +9,6 @@ use stackless_stripe_projects::provision::ProvisionContext; use super::FamilyResource; use crate::error::IntegrationError; use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; -use crate::registry; pub const RESOURCE_KIND: &str = "integration-base44"; diff --git a/crates/stackless-integrations/src/providers/blaxel/agent_drive.rs b/crates/stackless-integrations/src/providers/blaxel/agent_drive.rs index 06d2ca3..ea3a005 100644 --- a/crates/stackless-integrations/src/providers/blaxel/agent_drive.rs +++ b/crates/stackless-integrations/src/providers/blaxel/agent_drive.rs @@ -9,7 +9,6 @@ use stackless_stripe_projects::provision::ProvisionContext; use super::FamilyResource; use crate::error::IntegrationError; use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; -use crate::registry; pub const RESOURCE_KIND: &str = "integration-blaxel-agent-drive"; diff --git a/crates/stackless-integrations/src/providers/blaxel/sandbox.rs b/crates/stackless-integrations/src/providers/blaxel/sandbox.rs index 07ecc80..4401e01 100644 --- a/crates/stackless-integrations/src/providers/blaxel/sandbox.rs +++ b/crates/stackless-integrations/src/providers/blaxel/sandbox.rs @@ -9,7 +9,6 @@ use stackless_stripe_projects::provision::ProvisionContext; use super::FamilyResource; use crate::error::IntegrationError; use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; -use crate::registry; pub const RESOURCE_KIND: &str = "integration-blaxel-sandbox"; diff --git a/crates/stackless-integrations/src/providers/browserbase/project.rs b/crates/stackless-integrations/src/providers/browserbase/project.rs index e3699ac..75ebd2a 100644 --- a/crates/stackless-integrations/src/providers/browserbase/project.rs +++ b/crates/stackless-integrations/src/providers/browserbase/project.rs @@ -9,7 +9,6 @@ use stackless_stripe_projects::provision::ProvisionContext; use super::FamilyResource; use crate::error::IntegrationError; use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; -use crate::registry; pub const RESOURCE_KIND: &str = "integration-browserbase"; diff --git a/crates/stackless-integrations/src/providers/clickhouse/clickhouse.rs b/crates/stackless-integrations/src/providers/clickhouse/cluster.rs similarity index 100% rename from crates/stackless-integrations/src/providers/clickhouse/clickhouse.rs rename to crates/stackless-integrations/src/providers/clickhouse/cluster.rs diff --git a/crates/stackless-integrations/src/providers/clickhouse/mod.rs b/crates/stackless-integrations/src/providers/clickhouse/mod.rs index b8a2862..e37de80 100644 --- a/crates/stackless-integrations/src/providers/clickhouse/mod.rs +++ b/crates/stackless-integrations/src/providers/clickhouse/mod.rs @@ -2,7 +2,7 @@ //! //! Output envelopes are provisional until pinned by `xtask discover`. -pub mod clickhouse; +pub mod cluster; pub mod postgres; #[allow(unused_imports)] diff --git a/crates/stackless-integrations/src/providers/daytona/sandbox.rs b/crates/stackless-integrations/src/providers/daytona/sandbox.rs index 7a38133..e17252f 100644 --- a/crates/stackless-integrations/src/providers/daytona/sandbox.rs +++ b/crates/stackless-integrations/src/providers/daytona/sandbox.rs @@ -9,7 +9,6 @@ use stackless_stripe_projects::provision::ProvisionContext; use super::FamilyResource; use crate::error::IntegrationError; use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; -use crate::registry; pub const RESOURCE_KIND: &str = "integration-daytona"; diff --git a/crates/stackless-integrations/src/providers/e2b/sandbox.rs b/crates/stackless-integrations/src/providers/e2b/sandbox.rs index 01c6795..1497b9e 100644 --- a/crates/stackless-integrations/src/providers/e2b/sandbox.rs +++ b/crates/stackless-integrations/src/providers/e2b/sandbox.rs @@ -9,7 +9,6 @@ use stackless_stripe_projects::provision::ProvisionContext; use super::FamilyResource; use crate::error::IntegrationError; use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; -use crate::registry; pub const RESOURCE_KIND: &str = "integration-e2b"; diff --git a/crates/stackless-integrations/src/providers/elevenlabs/tts.rs b/crates/stackless-integrations/src/providers/elevenlabs/tts.rs index 64ba7b1..f6b746c 100644 --- a/crates/stackless-integrations/src/providers/elevenlabs/tts.rs +++ b/crates/stackless-integrations/src/providers/elevenlabs/tts.rs @@ -9,7 +9,6 @@ use stackless_stripe_projects::provision::ProvisionContext; use super::FamilyResource; use crate::error::IntegrationError; use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; -use crate::registry; pub const RESOURCE_KIND: &str = "integration-elevenlabs"; diff --git a/crates/stackless-integrations/src/providers/exa/api.rs b/crates/stackless-integrations/src/providers/exa/api.rs index af2cd7f..87c7d39 100644 --- a/crates/stackless-integrations/src/providers/exa/api.rs +++ b/crates/stackless-integrations/src/providers/exa/api.rs @@ -9,7 +9,6 @@ use stackless_stripe_projects::provision::ProvisionContext; use super::FamilyResource; use crate::error::IntegrationError; use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; -use crate::registry; pub const RESOURCE_KIND: &str = "integration-exa"; diff --git a/crates/stackless-integrations/src/providers/firecrawl/api.rs b/crates/stackless-integrations/src/providers/firecrawl/api.rs index 612e7f9..d1468b1 100644 --- a/crates/stackless-integrations/src/providers/firecrawl/api.rs +++ b/crates/stackless-integrations/src/providers/firecrawl/api.rs @@ -9,7 +9,6 @@ use stackless_stripe_projects::provision::ProvisionContext; use super::FamilyResource; use crate::error::IntegrationError; use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; -use crate::registry; pub const RESOURCE_KIND: &str = "integration-firecrawl"; diff --git a/crates/stackless-integrations/src/providers/heygen/api.rs b/crates/stackless-integrations/src/providers/heygen/api.rs index 3cfdb97..0b2a630 100644 --- a/crates/stackless-integrations/src/providers/heygen/api.rs +++ b/crates/stackless-integrations/src/providers/heygen/api.rs @@ -9,7 +9,6 @@ use stackless_stripe_projects::provision::ProvisionContext; use super::FamilyResource; use crate::error::IntegrationError; use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; -use crate::registry; pub const RESOURCE_KIND: &str = "integration-heygen"; diff --git a/crates/stackless-integrations/src/providers/huggingface/bucket.rs b/crates/stackless-integrations/src/providers/huggingface/bucket.rs index ec4a87c..454d51d 100644 --- a/crates/stackless-integrations/src/providers/huggingface/bucket.rs +++ b/crates/stackless-integrations/src/providers/huggingface/bucket.rs @@ -9,7 +9,6 @@ use stackless_stripe_projects::provision::ProvisionContext; use super::FamilyResource; use crate::error::IntegrationError; use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; -use crate::registry; pub const RESOURCE_KIND: &str = "integration-huggingface-bucket"; diff --git a/crates/stackless-integrations/src/providers/huggingface/platform.rs b/crates/stackless-integrations/src/providers/huggingface/platform.rs index 6222d02..7514fb3 100644 --- a/crates/stackless-integrations/src/providers/huggingface/platform.rs +++ b/crates/stackless-integrations/src/providers/huggingface/platform.rs @@ -9,7 +9,6 @@ use stackless_stripe_projects::provision::ProvisionContext; use super::FamilyResource; use crate::error::IntegrationError; use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; -use crate::registry; pub const RESOURCE_KIND: &str = "integration-huggingface"; diff --git a/crates/stackless-integrations/src/providers/metronome/sandbox.rs b/crates/stackless-integrations/src/providers/metronome/sandbox.rs index ea9d18d..5946442 100644 --- a/crates/stackless-integrations/src/providers/metronome/sandbox.rs +++ b/crates/stackless-integrations/src/providers/metronome/sandbox.rs @@ -9,7 +9,6 @@ use stackless_stripe_projects::provision::ProvisionContext; use super::FamilyResource; use crate::error::IntegrationError; use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; -use crate::registry; pub const RESOURCE_KIND: &str = "integration-metronome"; diff --git a/crates/stackless-integrations/src/providers/mixpanel/analytics.rs b/crates/stackless-integrations/src/providers/mixpanel/analytics.rs index c850b5b..ea2d69c 100644 --- a/crates/stackless-integrations/src/providers/mixpanel/analytics.rs +++ b/crates/stackless-integrations/src/providers/mixpanel/analytics.rs @@ -9,7 +9,6 @@ use stackless_stripe_projects::provision::ProvisionContext; use super::FamilyResource; use crate::error::IntegrationError; use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; -use crate::registry; pub const RESOURCE_KIND: &str = "integration-mixpanel"; diff --git a/crates/stackless-integrations/src/providers/mod.rs b/crates/stackless-integrations/src/providers/mod.rs index 9f90a8d..3b60381 100644 --- a/crates/stackless-integrations/src/providers/mod.rs +++ b/crates/stackless-integrations/src/providers/mod.rs @@ -90,7 +90,7 @@ mod tests { assert_outputs_match::(); assert_outputs_match::(); assert_outputs_match::(); - assert_outputs_match::(); + assert_outputs_match::(); assert_outputs_match::(); assert_outputs_match::(); assert_outputs_match::(); diff --git a/crates/stackless-integrations/src/providers/neon/postgres.rs b/crates/stackless-integrations/src/providers/neon/postgres.rs index 0d0fdb6..0513269 100644 --- a/crates/stackless-integrations/src/providers/neon/postgres.rs +++ b/crates/stackless-integrations/src/providers/neon/postgres.rs @@ -9,7 +9,6 @@ use stackless_stripe_projects::provision::ProvisionContext; use super::FamilyResource; use crate::error::IntegrationError; use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; -use crate::registry; pub const RESOURCE_KIND: &str = "integration-neon"; diff --git a/crates/stackless-integrations/src/providers/openrouter/api.rs b/crates/stackless-integrations/src/providers/openrouter/api.rs index a7fabc4..6a4fc34 100644 --- a/crates/stackless-integrations/src/providers/openrouter/api.rs +++ b/crates/stackless-integrations/src/providers/openrouter/api.rs @@ -9,7 +9,6 @@ use stackless_stripe_projects::provision::ProvisionContext; use super::FamilyResource; use crate::error::IntegrationError; use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; -use crate::registry; pub const RESOURCE_KIND: &str = "integration-openrouter"; diff --git a/crates/stackless-integrations/src/providers/parallel/api.rs b/crates/stackless-integrations/src/providers/parallel/api.rs index 8b93eb4..6fbe123 100644 --- a/crates/stackless-integrations/src/providers/parallel/api.rs +++ b/crates/stackless-integrations/src/providers/parallel/api.rs @@ -9,7 +9,6 @@ use stackless_stripe_projects::provision::ProvisionContext; use super::FamilyResource; use crate::error::IntegrationError; use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; -use crate::registry; pub const RESOURCE_KIND: &str = "integration-parallel"; diff --git a/crates/stackless-integrations/src/providers/postalform/mail.rs b/crates/stackless-integrations/src/providers/postalform/mail.rs index 825bbb3..ebaaa33 100644 --- a/crates/stackless-integrations/src/providers/postalform/mail.rs +++ b/crates/stackless-integrations/src/providers/postalform/mail.rs @@ -9,7 +9,6 @@ use stackless_stripe_projects::provision::ProvisionContext; use super::FamilyResource; use crate::error::IntegrationError; use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; -use crate::registry; pub const RESOURCE_KIND: &str = "integration-postalform"; diff --git a/crates/stackless-integrations/src/providers/posthog/analytics.rs b/crates/stackless-integrations/src/providers/posthog/analytics.rs index a573bfb..758a03f 100644 --- a/crates/stackless-integrations/src/providers/posthog/analytics.rs +++ b/crates/stackless-integrations/src/providers/posthog/analytics.rs @@ -9,7 +9,6 @@ use stackless_stripe_projects::provision::ProvisionContext; use super::FamilyResource; use crate::error::IntegrationError; use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; -use crate::registry; pub const RESOURCE_KIND: &str = "integration-posthog"; diff --git a/crates/stackless-integrations/src/providers/railway/bucket.rs b/crates/stackless-integrations/src/providers/railway/bucket.rs index c428ea8..6d2377e 100644 --- a/crates/stackless-integrations/src/providers/railway/bucket.rs +++ b/crates/stackless-integrations/src/providers/railway/bucket.rs @@ -9,7 +9,6 @@ use stackless_stripe_projects::provision::ProvisionContext; use super::FamilyResource; use crate::error::IntegrationError; use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; -use crate::registry; pub const RESOURCE_KIND: &str = "integration-railway-bucket"; diff --git a/crates/stackless-integrations/src/providers/railway/hosting.rs b/crates/stackless-integrations/src/providers/railway/hosting.rs index e0717be..e60f22d 100644 --- a/crates/stackless-integrations/src/providers/railway/hosting.rs +++ b/crates/stackless-integrations/src/providers/railway/hosting.rs @@ -9,7 +9,6 @@ use stackless_stripe_projects::provision::ProvisionContext; use super::FamilyResource; use crate::error::IntegrationError; use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; -use crate::registry; pub const RESOURCE_KIND: &str = "integration-railway-hosting"; diff --git a/crates/stackless-integrations/src/providers/railway/mongo.rs b/crates/stackless-integrations/src/providers/railway/mongo.rs index 9baf1b3..979e314 100644 --- a/crates/stackless-integrations/src/providers/railway/mongo.rs +++ b/crates/stackless-integrations/src/providers/railway/mongo.rs @@ -9,7 +9,6 @@ use stackless_stripe_projects::provision::ProvisionContext; use super::FamilyResource; use crate::error::IntegrationError; use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; -use crate::registry; pub const RESOURCE_KIND: &str = "integration-railway-mongo"; diff --git a/crates/stackless-integrations/src/providers/railway/postgres.rs b/crates/stackless-integrations/src/providers/railway/postgres.rs index eae2a88..e3644c9 100644 --- a/crates/stackless-integrations/src/providers/railway/postgres.rs +++ b/crates/stackless-integrations/src/providers/railway/postgres.rs @@ -9,7 +9,6 @@ use stackless_stripe_projects::provision::ProvisionContext; use super::FamilyResource; use crate::error::IntegrationError; use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; -use crate::registry; pub const RESOURCE_KIND: &str = "integration-railway-postgres"; diff --git a/crates/stackless-integrations/src/providers/railway/redis.rs b/crates/stackless-integrations/src/providers/railway/redis.rs index 9d36792..058b88b 100644 --- a/crates/stackless-integrations/src/providers/railway/redis.rs +++ b/crates/stackless-integrations/src/providers/railway/redis.rs @@ -9,7 +9,6 @@ use stackless_stripe_projects::provision::ProvisionContext; use super::FamilyResource; use crate::error::IntegrationError; use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; -use crate::registry; pub const RESOURCE_KIND: &str = "integration-railway-redis"; diff --git a/crates/stackless-integrations/src/providers/runloop/sandbox.rs b/crates/stackless-integrations/src/providers/runloop/sandbox.rs index 5a08bae..00ccba9 100644 --- a/crates/stackless-integrations/src/providers/runloop/sandbox.rs +++ b/crates/stackless-integrations/src/providers/runloop/sandbox.rs @@ -9,7 +9,6 @@ use stackless_stripe_projects::provision::ProvisionContext; use super::FamilyResource; use crate::error::IntegrationError; use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; -use crate::registry; pub const RESOURCE_KIND: &str = "integration-runloop"; diff --git a/crates/stackless-integrations/src/providers/sentry/project.rs b/crates/stackless-integrations/src/providers/sentry/project.rs index c34b0bd..6355e7c 100644 --- a/crates/stackless-integrations/src/providers/sentry/project.rs +++ b/crates/stackless-integrations/src/providers/sentry/project.rs @@ -9,7 +9,6 @@ use stackless_stripe_projects::provision::ProvisionContext; use super::FamilyResource; use crate::error::IntegrationError; use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; -use crate::registry; pub const RESOURCE_KIND: &str = "integration-sentry"; diff --git a/crates/stackless-integrations/src/providers/sentry/seer.rs b/crates/stackless-integrations/src/providers/sentry/seer.rs index b64907b..9f27697 100644 --- a/crates/stackless-integrations/src/providers/sentry/seer.rs +++ b/crates/stackless-integrations/src/providers/sentry/seer.rs @@ -9,7 +9,6 @@ use stackless_stripe_projects::provision::ProvisionContext; use super::FamilyResource; use crate::error::IntegrationError; use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; -use crate::registry; pub const RESOURCE_KIND: &str = "integration-sentry-seer"; diff --git a/crates/stackless-integrations/src/providers/supabase/project.rs b/crates/stackless-integrations/src/providers/supabase/project.rs index af01ee0..db37ebf 100644 --- a/crates/stackless-integrations/src/providers/supabase/project.rs +++ b/crates/stackless-integrations/src/providers/supabase/project.rs @@ -9,7 +9,6 @@ use stackless_stripe_projects::provision::ProvisionContext; use super::FamilyResource; use crate::error::IntegrationError; use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; -use crate::registry; pub const RESOURCE_KIND: &str = "integration-supabase"; diff --git a/crates/stackless-integrations/src/providers/upstash/qstash.rs b/crates/stackless-integrations/src/providers/upstash/qstash.rs index c5aff26..a5810a8 100644 --- a/crates/stackless-integrations/src/providers/upstash/qstash.rs +++ b/crates/stackless-integrations/src/providers/upstash/qstash.rs @@ -9,7 +9,6 @@ use stackless_stripe_projects::provision::ProvisionContext; use super::FamilyResource; use crate::error::IntegrationError; use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; -use crate::registry; pub const RESOURCE_KIND: &str = "integration-upstash-qstash"; diff --git a/crates/stackless-integrations/src/providers/upstash/redis.rs b/crates/stackless-integrations/src/providers/upstash/redis.rs index 1935cde..17c7d82 100644 --- a/crates/stackless-integrations/src/providers/upstash/redis.rs +++ b/crates/stackless-integrations/src/providers/upstash/redis.rs @@ -9,7 +9,6 @@ use stackless_stripe_projects::provision::ProvisionContext; use super::FamilyResource; use crate::error::IntegrationError; use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; -use crate::registry; pub const RESOURCE_KIND: &str = "integration-upstash-redis"; diff --git a/crates/stackless-integrations/src/providers/upstash/search.rs b/crates/stackless-integrations/src/providers/upstash/search.rs index 015bd7f..2af3509 100644 --- a/crates/stackless-integrations/src/providers/upstash/search.rs +++ b/crates/stackless-integrations/src/providers/upstash/search.rs @@ -9,7 +9,6 @@ use stackless_stripe_projects::provision::ProvisionContext; use super::FamilyResource; use crate::error::IntegrationError; use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; -use crate::registry; pub const RESOURCE_KIND: &str = "integration-upstash-search"; diff --git a/crates/stackless-integrations/src/providers/upstash/vector.rs b/crates/stackless-integrations/src/providers/upstash/vector.rs index ad79c9b..844de00 100644 --- a/crates/stackless-integrations/src/providers/upstash/vector.rs +++ b/crates/stackless-integrations/src/providers/upstash/vector.rs @@ -9,7 +9,6 @@ use stackless_stripe_projects::provision::ProvisionContext; use super::FamilyResource; use crate::error::IntegrationError; use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; -use crate::registry; pub const RESOURCE_KIND: &str = "integration-upstash-vector"; diff --git a/crates/stackless-integrations/src/providers/wix/headless.rs b/crates/stackless-integrations/src/providers/wix/headless.rs index b9e5d2a..58ac887 100644 --- a/crates/stackless-integrations/src/providers/wix/headless.rs +++ b/crates/stackless-integrations/src/providers/wix/headless.rs @@ -9,7 +9,6 @@ use stackless_stripe_projects::provision::ProvisionContext; use super::FamilyResource; use crate::error::IntegrationError; use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; -use crate::registry; pub const RESOURCE_KIND: &str = "integration-wix"; diff --git a/crates/stackless-integrations/src/providers/workos/auth.rs b/crates/stackless-integrations/src/providers/workos/auth.rs index 6a0b0ab..d981016 100644 --- a/crates/stackless-integrations/src/providers/workos/auth.rs +++ b/crates/stackless-integrations/src/providers/workos/auth.rs @@ -9,7 +9,6 @@ use stackless_stripe_projects::provision::ProvisionContext; use super::FamilyResource; use crate::error::IntegrationError; use crate::hostable::{ConfigScope, Hostable, IntegrationHosting}; -use crate::registry; pub const RESOURCE_KIND: &str = "integration-workos"; diff --git a/crates/stackless-integrations/src/registry.rs b/crates/stackless-integrations/src/registry.rs index d1d4038..0655dad 100644 --- a/crates/stackless-integrations/src/registry.rs +++ b/crates/stackless-integrations/src/registry.rs @@ -86,7 +86,7 @@ register_providers! { (blaxel::sandbox, BlaxelSandbox), (browserbase::project, BrowserbaseProject), (chroma::database, ChromaDatabase), - (clickhouse::clickhouse, ClickHouseClickhouse), + (clickhouse::cluster, ClickHouseClickhouse), (clickhouse::postgres, ClickHousePostgres), (daytona::sandbox, DaytonaSandbox), (e2b::sandbox, E2BSandbox),