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..b38e03a --- /dev/null +++ b/crates/stackless-laravel-cloud/src/config.rs @@ -0,0 +1,214 @@ +//! 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"; +} + +/// Whether `name` is a legal Laravel Cloud app 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 == '-') +} + +/// 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 dns_safe_service_names() { + 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 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..61d7beb --- /dev/null +++ b/crates/stackless-laravel-cloud/src/lib.rs @@ -0,0 +1,621 @@ +//! 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, + // Suffix only — resolved as LARAVEL_CLOUD_APP_ID / {RESOURCE}_APP_ID. + &[("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)?; + let service_name = Self::resource_name(def, "i", service); + if !config::is_valid_service_name(&service_name) { + return Err(fault(LaravelCloudError::ConfigInvalid { + location: format!("services.{service}"), + detail: format!( + "derived Laravel Cloud application 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(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(),