diff --git a/.github/workflows/smoke.yml b/.github/workflows/smoke.yml index 7319d02..665922b 100644 --- a/.github/workflows/smoke.yml +++ b/.github/workflows/smoke.yml @@ -32,7 +32,7 @@ jobs: strategy: fail-fast: false matrix: - provider: [vercel, render, cloudflare] + provider: [vercel, render, cloudflare, fly, netlify] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -91,4 +91,5 @@ jobs: STRIPE_API_KEY: ${{ secrets.STRIPE_API_KEY }} VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} RENDER_API_KEY: ${{ secrets.RENDER_API_KEY }} + FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} run: mise run smoke-${{ matrix.provider }} diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index dc0d102..ecd0e71 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -94,7 +94,7 @@ Decided: adapter (`clerk` → `clerk/auth`, provisioned via Stripe Projects internally). Each provider declares **managed** (Clerk — global config only, runs on provider cloud) or **host-bound** (future — explicit - `local`/`render`/`vercel` support, optional per-host overrides). + `local`/`render`/`vercel`/`fly`/`netlify` support, optional per-host overrides). Provider-specific config is validated by `stackless-integrations`; `${integrations.*}` references are ordering edges in the derived graph. @@ -228,7 +228,7 @@ vision's invariants; flag anything to veto): instance resumes it (invariant 3) — there is no separate resume verb. **`--name` is optional at creation** (`{stack.name}-{uuid}` when omitted); resume needs only `--name`. **The substrate is chosen - at creation only** (`--on local|render|vercel`, required on first + at creation only** (`--on local|render|vercel|fly|netlify`, required on first `up` for a name), becomes part of the instance's identity in the state store, and is never asked for again — @@ -534,6 +534,66 @@ Mirrors §4's Stripe + provider-API split for - **`stackless logs` is not wired for Vercel in v0** — use the Vercel dashboard; Render and local substrates support the `logs` verb. +## 4c. Fly substrate + +Mirrors §4's Stripe + provider-API split for [Fly.io](https://fly.io) +container apps. v0 is **image-only**. + +- **Same Stripe project per stack** as Render/Vercel and hosted + integrations (§4); cloud resource names remain + `{stack}-{instance}-{service}` — which is also the Fly app name, so it + must be a legal Fly app name (`^[a-z][a-z0-9-]{2,62}$`). +- **Catalog resource:** services → `flyio/app` with + `{"app_name": ""}` at provision time. `flyio/app` is + usage-billed (freeform pricing) → always `--confirm-paid`. +- **Stripe Projects provisions; the Fly Machines REST API operates:** + after Stripe creates the app, stackless allocates its public IPs + (shared IPv4 + IPv6), creates a machine running the service's prebuilt + `image` with the interpolated env and an always-on HTTP service + (443/80 → the container's `internal_port`), polls the machine to + `started`, health-gates on `https://{app}.fly.dev`, and on `down` + removes the Stripe resource (Stripe tears down the app). The deploy token + is **Stripe-managed and returned by provisioning** (pinned by + `mise run discover flyio/app`); because it is app-scoped and ephemeral, + `observe`/`down` key off the Stripe resource registration and the Fly API + is touched only at deploy time. + The client (`stackless-fly::fly_api`) is hand-written reqwest/serde + rather than a generated client — the Machines surface we use is a + handful of endpoints and the published spec is Swagger 2.0. +- **No build-from-source and no datastore in v0** — a service supplies a + prebuilt `image`; `flyio/mpg` (managed Postgres) is a separate catalog + integration, and a `[datastores.*]` block is rejected. +- **No root-origin alias on cloud** (same as Render/Vercel); setup is + skipped; prepare runs on the operator's machine from a shallow clone. +- **Spend caps and summaries** follow §4 (`billing update --provider + flyio`; spend line printed after cloud `up`/`down`). +- **`stackless logs` is not wired for Fly in v0** — use the Fly dashboard. + +## 4d. Netlify substrate + +Mirrors §4's Stripe + provider-API split for +[Netlify](https://netlify.com) static sites. v0 is **static upload**. + +- **Same Stripe project per stack** as the other cloud substrates; cloud + resource names remain `{stack}-{instance}-{service}` — also the Netlify + site name. +- **Catalog resource:** services → `netlify/project` with `{"name": ""}`. + `netlify/project` is **free**, so no `--confirm-paid`. +- **Stripe Projects provisions; the Netlify REST API operates:** provisioning + creates the site and returns a Stripe-managed token + site id (pinned by + `mise run discover netlify/project`; the token only surfaces on a refreshed + env read). stackless clones the pinned ref, then runs the **file-digest + deploy** — POST the per-file SHA1 map, PUT only the files Netlify reports as + `required`, poll the deploy to `ready` — and health-gates on the deploy's + `ssl_url`. The client (`stackless-netlify::netlify_api`) is hand-written + reqwest/serde (Swagger-2.0 source, ~5 endpoints). +- **No build step and no datastore in v0** — pre-built static files only; a + `[datastores.*]` block is rejected. +- **No root-origin alias on cloud**; setup is skipped; prepare runs on the + operator's machine. The token is ephemeral, so `observe`/`down` key off the + Stripe resource registration. +- **`stackless logs` is not wired for Netlify in v0** — use the Netlify dashboard. + ## 5. Trust boundary (phased, post-v0) — TBD Recorded from design discussion, to be developed when sequenced: diff --git a/Cargo.lock b/Cargo.lock index 7eb8946..7992b6d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2734,8 +2734,10 @@ dependencies = [ "serde_json", "stackless-core", "stackless-daemon", + "stackless-fly", "stackless-integrations", "stackless-local", + "stackless-netlify", "stackless-render", "stackless-vercel", "tempfile", @@ -2785,6 +2787,25 @@ dependencies = [ "tokio", ] +[[package]] +name = "stackless-fly" +version = "0.1.0" +dependencies = [ + "async-trait", + "reqwest", + "serde", + "serde_json", + "stackless-cloud", + "stackless-core", + "stackless-integrations", + "stackless-stripe-projects", + "tempfile", + "thiserror 2.0.18", + "tokio", + "toml", + "wiremock", +] + [[package]] name = "stackless-git" version = "0.1.0" @@ -2834,6 +2855,27 @@ dependencies = [ "tokio", ] +[[package]] +name = "stackless-netlify" +version = "0.1.0" +dependencies = [ + "async-trait", + "reqwest", + "serde", + "serde_json", + "sha1", + "stackless-cloud", + "stackless-core", + "stackless-git", + "stackless-integrations", + "stackless-stripe-projects", + "tempfile", + "thiserror 2.0.18", + "tokio", + "toml", + "wiremock", +] + [[package]] name = "stackless-render" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 3cb97e6..4f77b1b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,8 @@ members = [ "crates/stackless-git", "crates/stackless-render", "crates/stackless-vercel", + "crates/stackless-fly", + "crates/stackless-netlify", "crates/stackless-stripe-projects", "crates/stackless", "crates/render-client", @@ -40,6 +42,8 @@ stackless-local = { path = "crates/stackless-local" } stackless-git = { path = "crates/stackless-git" } 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-integrations = { path = "crates/stackless-integrations" } stackless-stripe-projects = { path = "crates/stackless-stripe-projects" } diff --git a/README.md b/README.md index 935bedb..a5c3504 100644 --- a/README.md +++ b/README.md @@ -64,8 +64,8 @@ verifiably. No wiki page, no teammate, no manual cleanup. the name. Any number of instances coexist without colliding on ports, names, data, or credentials. - **Substrates** (stack hosts) decide where instances live. Pass - `--on local`, `--on render`, or `--on vercel` at creation (required); - resume uses the recorded substrate and never asks again: + `--on local`, `--on render`, `--on vercel`, `--on fly`, or `--on netlify` + at creation (required); resume uses the recorded substrate and never asks again: - **local** — services run as host processes from your declared commands; datastores run as labeled Docker containers with per-instance volumes; everything meets at a built-in reverse proxy, @@ -87,6 +87,23 @@ verifiably. No wiki page, no teammate, no manual cleanup. env, triggers git deployments, polls until READY, and verifies teardown (`VERCEL_TOKEN` or `.vercel-token`). No managed postgres on Vercel in v0; `source.repo` must be a public GitHub HTTPS remote. + - **fly** — container apps on [Fly.io](https://fly.io) via Stripe + `flyio/app` (paid → `--confirm-paid`). Stripe creates the app and hands + back a scoped deploy token; the Fly Machines REST API uses it to + allocate the app's public IPs, run the service's prebuilt `image` as a + machine, and poll it to `started`, health-gating on + `https://{stack}-{instance}-{service}.fly.dev`. Teardown removes the + Stripe resource and confirms via its registration (no operator API + token needed). v0 is image-only (no build-from-source) and has no + managed datastore. + - **netlify** — static sites on [Netlify](https://netlify.com) via Stripe + `netlify/project` (free). Stripe creates the site and returns a scoped + token; the substrate clones the pinned ref and runs the Netlify + file-digest deploy (SHA1 per file, upload only what's missing), polls to + `ready`, and health-gates on + `https://{stack}-{instance}-{service}.netlify.app`. Teardown removes the + Stripe resource and confirms via its registration. v0 is static-upload + (no build step) and has no managed datastore. - **Sources are git references** (`repo` + `ref`), materialized per instance from a shared object cache. For the edit loop, `--source service=/path/to/checkout` pins a service to your dirty @@ -100,8 +117,8 @@ verifiably. No wiki page, no teammate, no manual cleanup. up when every service's health contract passes through its public origin. `stackless verify` runs the stack's own proof command (the smoke tier) with the instance's origins and env exported. -- **Every instance carries a lease** (local default 24h; render and - vercel default 8h). +- **Every instance carries a lease** (local default 24h; render, + vercel, fly, and netlify default 8h). Mutating verbs and successful `verify` renew it; when it expires, a reaper sends the instance through the same verified teardown as `down`. Teardown refuses to report success while anything that bills @@ -202,7 +219,7 @@ Common commands (also wired as `mise run `): - Tests: `cargo nextest run --workspace` (or `mise run test`) - Hygiene ("cargo crap"): `mise run ci` (fmt + clippy + taplo + nextest + audit + deny + vet) - Individual: `cargo audit`, `cargo deny check`, `cargo vet`, `taplo fmt --check` -- Live smoke tests against real providers: `mise run smoke-vercel` / `mise run smoke-render` / `mise run smoke` (creds from `.stackless.env`) — see [docs/SELFTEST.md](docs/SELFTEST.md) +- Live smoke tests against real providers: `mise run smoke-vercel` / `mise run smoke-render` / `mise run smoke-fly` / `mise run smoke-netlify` / `mise run smoke` (creds from `.stackless.env`) — see [docs/SELFTEST.md](docs/SELFTEST.md) Releases use `cargo-dist` (see generated `.github/workflows/release.yml`). diff --git a/crates/stackless-fly/Cargo.toml b/crates/stackless-fly/Cargo.toml new file mode 100644 index 0000000..a032c02 --- /dev/null +++ b/crates/stackless-fly/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "stackless-fly" +edition.workspace = true +version.workspace = true +license.workspace = true +repository.workspace = true + +[lints] +workspace = true + +[dependencies] +async-trait = "0.1.89" +reqwest = { version = "0.13.4", default-features = false, features = [ + "rustls", + "charset", + "http2", + "json", +] } +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" +wiremock = "0.6.5" diff --git a/crates/stackless-fly/src/codes.rs b/crates/stackless-fly/src/codes.rs new file mode 100644 index 0000000..31b2b6a --- /dev/null +++ b/crates/stackless-fly/src/codes.rs @@ -0,0 +1,28 @@ +//! Stable error codes for the Fly.io substrate (ARCHITECTURE.md §2/§8). +//! +//! Codes live with the provider, not in core — adding a hosting provider adds +//! no codes to `stackless-core`. The binary aggregates every crate's `ALL` for +//! a workspace-wide uniqueness check. + +pub const FLY_CONFIG_INVALID: &str = "fly.config.invalid"; +pub const FLY_API_FAILED: &str = "fly.api.failed"; +pub const FLY_PAYMENT_NOT_CONFIRMED: &str = "fly.payment.not_confirmed"; +pub const FLY_PROVISION_FAILED: &str = "fly.provision.failed"; +pub const FLY_DEPLOY_FAILED: &str = "fly.deploy.failed"; +pub const FLY_DEPLOY_TIMEOUT: &str = "fly.deploy.timeout"; +pub const FLY_HEALTH_FAILED: &str = "fly.health.failed"; +pub const FLY_PREPARE_FAILED: &str = "fly.prepare.failed"; +pub const FLY_TEARDOWN_SURVIVOR: &str = "fly.teardown.survivor"; + +/// Every Fly code, for the workspace uniqueness test. +pub const ALL: &[&str] = &[ + FLY_CONFIG_INVALID, + FLY_API_FAILED, + FLY_PAYMENT_NOT_CONFIRMED, + FLY_PROVISION_FAILED, + FLY_DEPLOY_FAILED, + FLY_DEPLOY_TIMEOUT, + FLY_HEALTH_FAILED, + FLY_PREPARE_FAILED, + FLY_TEARDOWN_SURVIVOR, +]; diff --git a/crates/stackless-fly/src/config.rs b/crates/stackless-fly/src/config.rs new file mode 100644 index 0000000..538841b --- /dev/null +++ b/crates/stackless-fly/src/config.rs @@ -0,0 +1,352 @@ +//! Parsing the fly-specific blocks of the definition (§1 schema). +//! +//! `validate_definition` checks these shapes strictly — unknown keys are a fault +//! (agent-trap protection, mirroring stackless-render). The same parsers feed +//! the Substrate impl so config is read in exactly one place. +//! +//! v0 Fly is **image-only**: a service declares a prebuilt container `image` +//! (and the port it listens on). Building from source via a remote builder is a +//! later enhancement — the deploy lifecycle (provision app → machine → +//! health → teardown) is identical either way. + +use serde::Serialize; +use stackless_core::def::StackDef; + +use crate::SUBSTRATE_NAME; +use crate::error::FlyError; +use stackless_stripe_projects::CatalogService; + +/// Default machine guest (Fly's smallest shared preset). +const DEFAULT_CPU_KIND: &str = "shared"; +const DEFAULT_CPUS: i64 = 1; +const DEFAULT_MEMORY_MB: i64 = 256; +/// Default port the container listens on if `[services.X.fly].internal_port` is +/// omitted (Fly's own convention for autodetected web services). +const DEFAULT_INTERNAL_PORT: i64 = 8080; + +/// A service's `[services.X.fly]` block: a prebuilt image plus the machine +/// shape to run it as. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ServiceFly { + pub image: String, + pub internal_port: u16, + /// Overrides the image entrypoint/cmd (Fly `config.init.cmd` / container + /// args), e.g. `["-text=ok", "-listen=:5678"]`. + pub cmd: Option>, + pub guest: FlyGuest, +} + +/// The Fly machine guest (CPU/memory preset). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FlyGuest { + pub cpu_kind: String, + pub cpus: u32, + pub memory_mb: u32, +} + +impl Default for FlyGuest { + fn default() -> Self { + Self { + cpu_kind: DEFAULT_CPU_KIND.to_owned(), + cpus: DEFAULT_CPUS as u32, + memory_mb: DEFAULT_MEMORY_MB as u32, + } + } +} + +/// The typed `flyio/app` `--config`. `app_name` is the only schema property and +/// IS the catalog contract — the gap test pins it. +#[derive(Debug, Serialize)] +pub struct FlyAppConfig { + pub app_name: String, +} + +impl CatalogService for FlyAppConfig { + const REFERENCE: &'static str = "flyio/app"; +} + +/// Read and shape-check `[services..fly]`. +pub fn service_fly(def: &StackDef, service: &str) -> Result { + let location = format!("services.{service}.fly"); + let block = def + .services + .get(service) + .and_then(|spec| spec.substrates.get(SUBSTRATE_NAME)) + .and_then(|value| value.as_table()) + .ok_or_else(|| FlyError::ConfigInvalid { + location: location.clone(), + detail: "missing [services.X.fly] block".into(), + })?; + + for key in block.keys() { + if !matches!( + key.as_str(), + "image" | "internal_port" | "cmd" | "env" | "cpu_kind" | "cpus" | "memory_mb" + ) { + return Err(FlyError::ConfigInvalid { + location: location.clone(), + detail: format!( + "unknown key {key:?} (known: image, internal_port, cmd, env, cpu_kind, \ + cpus, memory_mb)" + ), + }); + } + } + + let image = required_str(block, "image", &location)?; + let internal_port = + u16::try_from(opt_int(block, "internal_port", &location)?.unwrap_or(DEFAULT_INTERNAL_PORT)) + .map_err(|_| FlyError::ConfigInvalid { + location: format!("{location}.internal_port"), + detail: "must be a TCP port in 1..=65535".into(), + })?; + if internal_port == 0 { + return Err(FlyError::ConfigInvalid { + location: format!("{location}.internal_port"), + detail: "must be a TCP port in 1..=65535".into(), + }); + } + let cmd = opt_string_array(block, "cmd", &location)?; + let guest = FlyGuest { + cpu_kind: opt_str(block, "cpu_kind").unwrap_or_else(|| DEFAULT_CPU_KIND.to_owned()), + cpus: u32::try_from(opt_int(block, "cpus", &location)?.unwrap_or(DEFAULT_CPUS)).map_err( + |_| FlyError::ConfigInvalid { + location: format!("{location}.cpus"), + detail: "must be a positive integer".into(), + }, + )?, + memory_mb: u32::try_from( + opt_int(block, "memory_mb", &location)?.unwrap_or(DEFAULT_MEMORY_MB), + ) + .map_err(|_| FlyError::ConfigInvalid { + location: format!("{location}.memory_mb"), + detail: "must be a positive integer".into(), + })?, + }; + + Ok(ServiceFly { + image, + internal_port, + cmd, + guest, + }) +} + +/// The recorded `[stack.fly].region`, defaulting to `iad` (US-East). +pub fn stack_region(def: &StackDef) -> String { + def.stack + .substrates + .get(SUBSTRATE_NAME) + .and_then(|value| value.as_table()) + .and_then(|table| table.get("region")) + .and_then(|value| value.as_str()) + .unwrap_or("iad") + .to_owned() +} + +/// Whether `name` is a legal Fly app name (catalog pattern +/// `^[a-z][a-z0-9-]{2,62}$`): a lowercase letter then 2..=62 of `[a-z0-9-]`. +/// Checked without a regex dependency. +pub fn is_valid_app_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 == '-') +} + +fn required_str(table: &toml::Table, key: &str, location: &str) -> Result { + table + .get(key) + .and_then(|value| value.as_str()) + .filter(|s| !s.trim().is_empty()) + .map(str::to_owned) + .ok_or_else(|| FlyError::ConfigInvalid { + location: location.to_owned(), + detail: format!("missing or empty `{key}`"), + }) +} + +fn opt_str(table: &toml::Table, key: &str) -> Option { + table + .get(key) + .and_then(|value| value.as_str()) + .filter(|s| !s.trim().is_empty()) + .map(str::to_owned) +} + +fn opt_int(table: &toml::Table, key: &str, location: &str) -> Result, FlyError> { + match table.get(key) { + None => Ok(None), + Some(value) => value + .as_integer() + .map(Some) + .ok_or_else(|| FlyError::ConfigInvalid { + location: format!("{location}.{key}"), + detail: "must be an integer".into(), + }), + } +} + +fn opt_string_array( + table: &toml::Table, + key: &str, + location: &str, +) -> Result>, FlyError> { + let Some(value) = table.get(key) else { + return Ok(None); + }; + let array = value.as_array().ok_or_else(|| FlyError::ConfigInvalid { + location: format!("{location}.{key}"), + detail: "must be an array of strings".into(), + })?; + let mut out = Vec::with_capacity(array.len()); + for item in array { + let s = item.as_str().ok_or_else(|| FlyError::ConfigInvalid { + location: format!("{location}.{key}"), + detail: "every element must be a string".into(), + })?; + out.push(s.to_owned()); + } + Ok(Some(out)) +} + +#[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" +[stack.fly] +region = "iad" +[services.web] +source = { repo = "r", ref = "main" } +env = {} +health = { path = "/" } +[services.web.fly] +image = "hashicorp/http-echo" +internal_port = 5678 +cmd = ["-text=stackless-smoke-ok", "-listen=:5678"] +"#; + + #[test] + fn parses_service_block_with_defaults_and_overrides() { + let def = parse(BASE); + let svc = service_fly(&def, "web").unwrap(); + assert_eq!(svc.image, "hashicorp/http-echo"); + assert_eq!(svc.internal_port, 5678); + assert_eq!( + svc.cmd.as_deref(), + Some( + &[ + "-text=stackless-smoke-ok".to_owned(), + "-listen=:5678".to_owned() + ][..] + ) + ); + assert_eq!(svc.guest, FlyGuest::default()); + assert_eq!(stack_region(&def), "iad"); + } + + #[test] + fn region_defaults_when_absent() { + let def = parse( + r#" +[stack] +name = "atto" +[services.web] +source = { repo = "r", ref = "main" } +env = {} +health = { path = "/" } +[services.web.fly] +image = "nginx" +"#, + ); + assert_eq!(stack_region(&def), "iad"); + let svc = service_fly(&def, "web").unwrap(); + assert_eq!(svc.internal_port, 8080); + } + + #[test] + fn unknown_key_is_rejected() { + let toml = BASE.replace( + "image = \"hashicorp/http-echo\"", + "bogus = 1\nimage = \"x\"", + ); + let err = service_fly(&parse(&toml), "web").unwrap_err(); + assert_eq!( + stackless_core::fault::Fault::code(&err), + crate::codes::FLY_CONFIG_INVALID + ); + } + + #[test] + fn missing_image_is_rejected() { + let toml = BASE.replace("image = \"hashicorp/http-echo\"", ""); + let err = service_fly(&parse(&toml), "web").unwrap_err(); + assert!(matches!(err, FlyError::ConfigInvalid { .. })); + } + + #[test] + fn missing_fly_block_is_rejected() { + let toml = r#" +[stack] +name = "atto" +[services.web] +source = { repo = "r", ref = "main" } +env = {} +health = { path = "/" } +"#; + let err = service_fly(&parse(toml), "web").unwrap_err(); + assert!(matches!(err, FlyError::ConfigInvalid { .. })); + } + + #[test] + fn app_name_pattern_matches_catalog() { + assert!(is_valid_app_name("atto-demo-web")); + assert!(is_valid_app_name("smoke-fly-1718-web")); + assert!(!is_valid_app_name("ab")); // too short + assert!(!is_valid_app_name("1abc")); // must start with a letter + assert!(!is_valid_app_name("Abc")); // no uppercase + assert!(!is_valid_app_name("a_b")); // underscore not allowed + assert!(!is_valid_app_name(&"a".repeat(64))); // too long + } + + #[test] + fn typed_config_carries_its_catalog_reference() { + assert_eq!(FlyAppConfig::REFERENCE, "flyio/app"); + } + + /// Catalog gap check: the `flyio/app` config must validate against the live + /// `configuration_schema` in the committed catalog fixture. Fails loudly if + /// Stripe drifts the `app_name` field. + #[test] + fn fly_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, + &FlyAppConfig { + app_name: "atto-demo-web".into(), + }, + ); + assert!( + failures.is_empty(), + "fly catalog gaps:\n{}", + failures.join("\n") + ); + } +} diff --git a/crates/stackless-fly/src/error.rs b/crates/stackless-fly/src/error.rs new file mode 100644 index 0000000..ce03a78 --- /dev/null +++ b/crates/stackless-fly/src/error.rs @@ -0,0 +1,129 @@ +//! Fly-substrate errors (codes in this crate's `fly.*` registry). + +use stackless_core::fault::{ErrorContext, Fault}; + +use crate::codes; + +#[derive(Debug, thiserror::Error)] +pub enum FlyError { + #[error("[{location}] is invalid: {detail}")] + ConfigInvalid { location: String, detail: String }, + + #[error("Fly API {method} {path} failed: {detail}")] + ApiFailed { + method: String, + path: String, + detail: String, + }, + + #[error("creating paid Fly resources requires explicit consent")] + PaymentNotConfirmed { resource: String }, + + #[error("provisioning {resource:?} on Fly did not complete: {detail}")] + ProvisionFailed { resource: String, detail: String }, + + #[error("machine for {service:?} ended {state}")] + DeployFailed { service: String, state: String }, + + #[error( + "machine for {service:?} did not reach started within {budget_secs}s (last state: {last_state})" + )] + DeployTimeout { + service: String, + budget_secs: u64, + last_state: String, + }, + + #[error("{service:?} failed its health contract ({detail}) within {budget_secs}s at {url}")] + HealthFailed { + service: String, + url: String, + detail: String, + budget_secs: u64, + }, + + #[error("prepare for {service:?} failed: {message}")] + PrepareFailed { + service: String, + command: Option, + message: String, + log_tail: Option, + }, + + #[error("{resource:?} still exists on Fly after teardown (it bills until removed)")] + TeardownSurvivor { resource: String }, +} + +impl Fault for FlyError { + fn code(&self) -> &'static str { + match self { + Self::ConfigInvalid { .. } => codes::FLY_CONFIG_INVALID, + Self::ApiFailed { .. } => codes::FLY_API_FAILED, + Self::PaymentNotConfirmed { .. } => codes::FLY_PAYMENT_NOT_CONFIRMED, + Self::ProvisionFailed { .. } => codes::FLY_PROVISION_FAILED, + Self::DeployFailed { .. } => codes::FLY_DEPLOY_FAILED, + Self::DeployTimeout { .. } => codes::FLY_DEPLOY_TIMEOUT, + Self::HealthFailed { .. } => codes::FLY_HEALTH_FAILED, + Self::PrepareFailed { .. } => codes::FLY_PREPARE_FAILED, + Self::TeardownSurvivor { .. } => codes::FLY_TEARDOWN_SURVIVOR, + } + } + + fn remediation(&self) -> String { + match self { + Self::ConfigInvalid { location, .. } => { + format!("fix the [{location}] block; see ARCHITECTURE.md §1 for the fly schema") + } + Self::ApiFailed { .. } => { + "check the Fly API token's scope and that api.machines.dev is reachable, then \ + re-run `up`" + .into() + } + Self::PaymentNotConfirmed { .. } => { + "re-run with --confirm-paid to consent to Fly charges (bounded by the \ + project's hard spend cap; charges accrue until `down`)" + .into() + } + Self::ProvisionFailed { .. } => { + "wait a minute for Fly to finish provisioning and re-run `up` to resume".into() + } + Self::DeployFailed { service, .. } => format!( + "the machine for {service:?} failed to start; check fly.io/dashboard logs, fix the \ + image/command, and re-run `up`" + ), + Self::DeployTimeout { service, .. } => format!( + "the machine for {service:?} is still starting on Fly; re-run `up` to resume \ + waiting, or check fly.io/dashboard" + ), + Self::HealthFailed { service, .. } => format!( + "the {service:?} service did not pass its health contract; check fly.io/dashboard \ + logs, fix, and re-run `up`" + ), + Self::PrepareFailed { service, .. } => format!( + "inspect context.log_tail; run the {service:?} prepare command by hand; re-run \ + `stackless up `" + ), + Self::TeardownSurvivor { resource } => format!( + "delete the {resource} app at fly.io/dashboard to stop billing, then re-run `down`" + ), + } + } + + 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-fly/src/fly_api.rs b/crates/stackless-fly/src/fly_api.rs new file mode 100644 index 0000000..2b66c6d --- /dev/null +++ b/crates/stackless-fly/src/fly_api.rs @@ -0,0 +1,520 @@ +//! The Fly Machines REST client (ARCHITECTURE.md §4): the post-provisioning +//! steps Stripe Projects can't express — allocate the app's public IPs, create +//! the machine that runs the service image, and poll it to `started`. +//! +//! Hand-written over `reqwest` rather than generated: the Machines API surface +//! we use is six endpoints with flat JSON bodies, and the served spec is Swagger +//! 2.0 (`specs/flyio-openapi.json`, kept as reference). A thin client keeps the +//! request bodies legible and the dependency surface small; responses are parsed +//! leniently (`id`/`state`) so additive provider drift never breaks a deploy. + +use std::time::Duration; + +use reqwest::header::{AUTHORIZATION, HeaderMap, HeaderValue}; +use reqwest::{Client, Method, StatusCode}; +use serde_json::{Value, json}; + +use crate::error::FlyError; + +const DEFAULT_BASE: &str = "https://api.machines.dev/v1"; + +/// A machine boots in well under a minute, but image pull + edge propagation +/// can lag; the budget covers a slow cold start without hanging `up`. +pub const FLY_DEPLOY_BUDGET: Duration = Duration::from_secs(10 * 60); +/// The public-origin health wait budget (§7). +pub const HEALTH_BUDGET: Duration = Duration::from_secs(5 * 60); + +const POLL_INTERVAL: Duration = Duration::from_secs(5); +const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); + +/// The machine shape a `start` step deploys. Borrowed so building it allocates +/// nothing beyond the request body. +#[derive(Debug)] +pub struct MachineSpec<'a> { + /// Machine name (we use the app/resource name). + pub name: &'a str, + pub region: &'a str, + pub image: &'a str, + /// Overrides the image CMD (container args), e.g. http-echo flags. + pub cmd: Option<&'a [String]>, + pub env: &'a [(String, String)], + pub internal_port: u16, + pub cpu_kind: &'a str, + pub cpus: u32, + pub memory_mb: u32, +} + +impl MachineSpec<'_> { + fn to_body(&self) -> Value { + let env: serde_json::Map = self + .env + .iter() + .map(|(k, v)| (k.clone(), Value::String(v.clone()))) + .collect(); + let mut config = json!({ + "image": self.image, + "env": Value::Object(env), + "guest": { + "cpu_kind": self.cpu_kind, + "cpus": self.cpus, + "memory_mb": self.memory_mb, + }, + // One always-on service: the Fly edge terminates TLS on 443 and + // routes to the container's internal port. `autostop: off` + + // `min_machines_running: 1` keep a health-gated service up. + "services": [{ + "internal_port": self.internal_port, + "protocol": "tcp", + "autostart": true, + "autostop": "off", + "min_machines_running": 1, + // `force_https` (the HTTP→HTTPS redirect) belongs on the plain + // HTTP port; Fly rejects it on a port that has the `tls` handler. + "ports": [ + { "port": 443, "handlers": ["http", "tls"] }, + { "port": 80, "handlers": ["http"], "force_https": true } + ] + }], + "restart": { "policy": "on-failure" } + }); + if let Some(cmd) = self.cmd { + config["init"] = json!({ "cmd": cmd }); + } + json!({ "name": self.name, "region": self.region, "config": config }) + } +} + +pub struct FlyApi { + client: Client, + base: String, + /// Overridable so deploy polling is fast in tests. + poll_interval: Duration, +} + +impl std::fmt::Debug for FlyApi { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("FlyApi") + .field("base", &self.base) + .finish_non_exhaustive() + } +} + +fn authed_client(token: &str) -> Client { + let mut headers = HeaderMap::new(); + if let Ok(mut value) = HeaderValue::from_str(&format!("Bearer {token}")) { + value.set_sensitive(true); + headers.insert(AUTHORIZATION, value); + } + Client::builder() + .default_headers(headers) + .connect_timeout(REQUEST_TIMEOUT) + .timeout(REQUEST_TIMEOUT) + .build() + .unwrap_or_else(|_| Client::new()) +} + +fn api_failed(method: &str, path: &str, err: impl std::fmt::Display) -> FlyError { + FlyError::ApiFailed { + method: method.to_owned(), + path: path.to_owned(), + detail: err.to_string(), + } +} + +/// Keep error bodies bounded so a giant HTML error page never floods a fault. +fn truncate(text: &str) -> String { + const MAX: usize = 400; + if text.len() <= MAX { + text.to_owned() + } else { + format!("{}…", &text[..MAX]) + } +} + +impl FlyApi { + pub fn new(token: impl AsRef) -> Self { + Self::with_base(token, DEFAULT_BASE) + } + + pub fn with_base(token: impl AsRef, base: impl Into) -> Self { + Self { + client: authed_client(token.as_ref()), + base: base.into(), + poll_interval: POLL_INTERVAL, + } + } + + /// Tests set a tiny interval so the wait/timeout paths run instantly. + pub fn with_poll_interval(mut self, interval: Duration) -> Self { + self.poll_interval = interval; + self + } + + /// Raw send: returns the status + body text; transport errors map to + /// `ApiFailed`, but a non-2xx status is left for the caller to classify. + async fn send( + &self, + method: Method, + path: &str, + body: Option, + ) -> Result<(StatusCode, String), FlyError> { + let url = format!("{}{path}", self.base); + let mut req = self.client.request(method.clone(), &url); + if let Some(body) = &body { + req = req.json(body); + } + let resp = req + .send() + .await + .map_err(|err| api_failed(method.as_str(), path, err))?; + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + Ok((status, text)) + } + + /// Send and require a 2xx, returning the parsed JSON (or `Null` on an empty + /// body). + async fn send_ok( + &self, + method: Method, + path: &str, + body: Option, + ) -> Result { + let (status, text) = self.send(method.clone(), path, body).await?; + if !status.is_success() { + return Err(api_failed( + method.as_str(), + path, + format!("status {}: {}", status.as_u16(), truncate(&text)), + )); + } + if text.trim().is_empty() { + return Ok(Value::Null); + } + serde_json::from_str(&text) + .map_err(|err| api_failed(method.as_str(), path, format!("bad json: {err}"))) + } + + /// Allocate the app's public IPs (idempotent): a free shared IPv4 and a + /// dedicated IPv6, so `https://.fly.dev` routes to the machine. + pub async fn ensure_ips(&self, app: &str) -> Result<(), FlyError> { + let path = format!("/apps/{app}/ip_assignments"); + let listed = self.send_ok(Method::GET, &path, None).await?; + let ips = listed + .get("ips") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + let has = |v6: bool| { + ips.iter().any(|entry| { + entry + .get("ip") + .and_then(Value::as_str) + .is_some_and(|ip| ip.contains(':') == v6) + }) + }; + if !has(false) { + self.send_ok(Method::POST, &path, Some(json!({ "type": "shared_v4" }))) + .await?; + } + if !has(true) { + self.send_ok(Method::POST, &path, Some(json!({ "type": "v6" }))) + .await?; + } + Ok(()) + } + + /// An existing machine's id by name, for resume idempotency (a re-run after + /// `create_machine` succeeded but the wait failed must not make a duplicate). + pub async fn find_machine(&self, app: &str, name: &str) -> Result, FlyError> { + let path = format!("/apps/{app}/machines"); + let listed = self.send_ok(Method::GET, &path, None).await?; + let machines = listed + .as_array() + .cloned() + .or_else(|| listed.get("machines").and_then(Value::as_array).cloned()) + .unwrap_or_default(); + Ok(machines.into_iter().find_map(|m| { + if m.get("name").and_then(Value::as_str) == Some(name) { + m.get("id").and_then(Value::as_str).map(str::to_owned) + } else { + None + } + })) + } + + /// Create the machine that runs the service image; returns its id. + pub async fn create_machine( + &self, + app: &str, + spec: &MachineSpec<'_>, + ) -> Result { + let path = format!("/apps/{app}/machines"); + let created = self + .send_ok(Method::POST, &path, Some(spec.to_body())) + .await?; + created + .get("id") + .and_then(Value::as_str) + .map(str::to_owned) + .ok_or_else(|| api_failed("POST", &path, "machine create returned no id")) + } + + async fn machine_state(&self, app: &str, machine_id: &str) -> Result { + let path = format!("/apps/{app}/machines/{machine_id}"); + let machine = self.send_ok(Method::GET, &path, None).await?; + let raw = machine + .get("state") + .and_then(Value::as_str) + .unwrap_or("unknown"); + Ok(MachineState::from_api(raw)) + } + + /// Poll the machine until it reaches `started` within `budget`. A `failed` + /// or `destroyed` machine fails fast; a timeout is recoverable (re-run `up`). + pub async fn wait_for_started( + &self, + app: &str, + machine_id: &str, + service: &str, + budget: Duration, + ) -> Result<(), FlyError> { + let deadline = tokio::time::Instant::now() + budget; + loop { + let state = self.machine_state(app, machine_id).await?; + if state.is_started() { + return Ok(()); + } + if state.is_terminal_failed() { + return Err(FlyError::DeployFailed { + service: service.to_owned(), + state: state.as_str().to_owned(), + }); + } + if tokio::time::Instant::now() >= deadline { + return Err(FlyError::DeployTimeout { + service: service.to_owned(), + budget_secs: budget.as_secs(), + last_state: state.as_str().to_owned(), + }); + } + tokio::time::sleep(self.poll_interval).await; + } + } +} + +/// A Fly machine lifecycle state. Modeled as an enum so the polling logic is +/// exhaustive; `Unknown` preserves any state not in Fly's documented set +/// verbatim, so drift (a new/renamed state) is visible in logs/errors instead of +/// being silently misclassified. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MachineState { + Created, + Starting, + Started, + Stopping, + Stopped, + Suspended, + Replacing, + Destroying, + Destroyed, + Failed, + Unknown(String), +} + +impl MachineState { + /// Fly's documented machine states, pinned by `canonical_states_are_modeled`. + pub const CANONICAL: &'static [&'static str] = &[ + "created", + "starting", + "started", + "stopping", + "stopped", + "suspended", + "replacing", + "destroying", + "destroyed", + "failed", + ]; + + pub fn from_api(state: &str) -> Self { + match state { + "created" => Self::Created, + "starting" => Self::Starting, + "started" => Self::Started, + "stopping" => Self::Stopping, + "stopped" => Self::Stopped, + "suspended" => Self::Suspended, + "replacing" => Self::Replacing, + "destroying" => Self::Destroying, + "destroyed" => Self::Destroyed, + "failed" => Self::Failed, + other => Self::Unknown(other.to_owned()), + } + } + + pub fn as_str(&self) -> &str { + match self { + Self::Created => "created", + Self::Starting => "starting", + Self::Started => "started", + Self::Stopping => "stopping", + Self::Stopped => "stopped", + Self::Suspended => "suspended", + Self::Replacing => "replacing", + Self::Destroying => "destroying", + Self::Destroyed => "destroyed", + Self::Failed => "failed", + Self::Unknown(raw) => raw, + } + } + + pub fn is_started(&self) -> bool { + matches!(self, Self::Started) + } + + /// A terminal failure: the machine will not become `started` on its own. A + /// new Fly state containing `fail` still fails fast; a new in-progress state + /// never false-fails. + pub fn is_terminal_failed(&self) -> bool { + match self { + Self::Failed | Self::Destroyed => true, + Self::Unknown(raw) => raw.contains("fail"), + _ => false, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + #[test] + fn canonical_states_are_modeled() { + for state in MachineState::CANONICAL { + let parsed = MachineState::from_api(state); + assert!( + !matches!(parsed, MachineState::Unknown(_)), + "canonical Fly state {state:?} fell through to Unknown — add a variant", + ); + assert_eq!( + parsed.as_str(), + *state, + "state {state:?} does not round-trip" + ); + } + let unknown = MachineState::from_api("warp_speed"); + assert_eq!(unknown.as_str(), "warp_speed"); + assert!(!unknown.is_terminal_failed()); + assert!(MachineState::from_api("create_failed").is_terminal_failed()); + assert!(MachineState::Started.is_started()); + assert!(MachineState::Destroyed.is_terminal_failed()); + } + + #[test] + fn machine_body_carries_image_cmd_env_and_ports() { + let spec = MachineSpec { + name: "atto-demo-web", + region: "iad", + image: "hashicorp/http-echo", + cmd: Some(&["-text=ok".to_owned()]), + env: &[("K".to_owned(), "V".to_owned())], + internal_port: 5678, + cpu_kind: "shared", + cpus: 1, + memory_mb: 256, + }; + let body = spec.to_body(); + assert_eq!(body["name"], "atto-demo-web"); + assert_eq!(body["region"], "iad"); + assert_eq!(body["config"]["image"], "hashicorp/http-echo"); + assert_eq!(body["config"]["env"]["K"], "V"); + assert_eq!(body["config"]["init"]["cmd"][0], "-text=ok"); + assert_eq!(body["config"]["services"][0]["internal_port"], 5678); + assert_eq!(body["config"]["services"][0]["ports"][0]["port"], 443); + // force_https rides the HTTP port; Fly rejects it on the tls port. + assert!( + body["config"]["services"][0]["ports"][0] + .get("force_https") + .is_none() + ); + assert_eq!( + body["config"]["services"][0]["ports"][1]["force_https"], + true + ); + } + + #[tokio::test] + async fn create_machine_then_wait_started() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/apps/app1/machines")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(json!({ "id": "m_1", "state": "created" })), + ) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/apps/app1/machines/m_1")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "state": "started" }))) + .mount(&server) + .await; + let api = + FlyApi::with_base("tok", server.uri()).with_poll_interval(Duration::from_millis(1)); + let spec = MachineSpec { + name: "app1", + region: "iad", + image: "img", + cmd: None, + env: &[], + internal_port: 8080, + cpu_kind: "shared", + cpus: 1, + memory_mb: 256, + }; + let id = api.create_machine("app1", &spec).await.unwrap(); + assert_eq!(id, "m_1"); + api.wait_for_started("app1", &id, "web", Duration::from_secs(5)) + .await + .unwrap(); + } + + #[tokio::test] + async fn find_machine_matches_by_name() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/apps/app1/machines")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!([ + { "id": "m_other", "name": "other" }, + { "id": "m_9", "name": "app1" } + ]))) + .mount(&server) + .await; + let api = FlyApi::with_base("tok", server.uri()); + assert_eq!( + api.find_machine("app1", "app1").await.unwrap().as_deref(), + Some("m_9") + ); + assert_eq!(api.find_machine("app1", "absent").await.unwrap(), None); + } + + #[tokio::test] + async fn wait_fails_fast_on_failed_state() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/apps/app1/machines/m_1")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "state": "failed" }))) + .mount(&server) + .await; + let api = + FlyApi::with_base("tok", server.uri()).with_poll_interval(Duration::from_millis(1)); + let err = api + .wait_for_started("app1", "m_1", "web", Duration::from_secs(5)) + .await + .unwrap_err(); + assert!(matches!(err, FlyError::DeployFailed { .. })); + } +} diff --git a/crates/stackless-fly/src/lib.rs b/crates/stackless-fly/src/lib.rs new file mode 100644 index 0000000..fbe04fa --- /dev/null +++ b/crates/stackless-fly/src/lib.rs @@ -0,0 +1,825 @@ +//! stackless-fly (ARCHITECTURE.md §4): the Fly.io cloud substrate. +//! +//! Mirrors the Render/Vercel cloud flow: Stripe Projects provisions the +//! `flyio/app` resource and tracks spend; the Fly Machines REST API fills its +//! gaps (allocate the app's public IPs, create the machine that runs the service +//! image, poll it to `started`, the health wait). One long-lived Stripe project +//! per stack holds each instance as a named environment. +//! +//! ## Credential model (pinned by `mise run discover flyio/app`) +//! +//! Unlike Render/Vercel (operator-supplied API key), provisioning `flyio/app` +//! returns a Stripe-managed, app-scoped **deploy token** (`DEPLOY_TOKEN`). The +//! substrate reads it from the provision output and uses it as the Machines-API +//! bearer for that one `start` step. Because that token is ephemeral (revoked +//! when the app is removed), `observe`/`destroy` key off the **Stripe resource +//! registration** (like catalog integrations), not the Fly API — the Fly API is +//! only touched at deploy time, when the token is fresh. +//! +//! ## v0 scope and cloud invariants +//! +//! - **Image-only.** A service declares a prebuilt container `image` in +//! `[services.X.fly]`; the substrate deploys it as a Fly machine. Building from +//! source via a remote builder is a later enhancement. +//! - **No managed datastore in v0.** `flyio/mpg` (managed Postgres) is a separate +//! catalog integration; a `[datastores.*]` block is rejected. +//! - **Cloud resource names** are `{stack}-{instance}-{service}` — DNS-safe and a +//! legal Fly app name (`^[a-z][a-z0-9-]{2,62}$`). Origins are +//! `https://{stack}-{instance}-{service}.fly.dev`. +//! - **Setup is skipped on cloud** (recorded as a no-op action). +//! - **Prepare runs on the operator's machine** from a fresh shallow clone. +//! - **Source override is unsupported** — Fly deploys committed refs. + +pub mod codes; +pub mod config; +pub mod error; +pub mod fly_api; +pub mod prepare; + +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::FlyAppConfig; +use crate::error::FlyError; +use crate::fly_api::{FLY_DEPLOY_BUDGET, FlyApi, HEALTH_BUDGET, MachineSpec}; +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 = "fly"; + +/// The hard per-provider spend cap set on first paid confirmation (§4). +/// Bounds a leak to 25 USD even if reaping fails. +pub const SPEND_CAP_USD: u32 = 25; + +/// The provider prefix Stripe uses for `flyio/app` output env vars when the +/// resource is unambiguous (`FLYIO_DEPLOY_TOKEN`); the per-resource form +/// (`{RESOURCE}_DEPLOY_TOKEN`) is tried too. Pinned by `mise run discover`. +const PROVIDER_PREFIX: &str = "FLYIO"; + +fn fault(err: FlyError) -> 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) +} + +/// What a `start:` checkpoint records: the live Fly app + machine. The +/// deploy token is intentionally NOT stored — observe/destroy use Stripe. +#[derive(Debug, Serialize, Deserialize)] +struct ServicePayload { + stripe_resource: String, + app_name: String, + machine_id: String, + origin: String, +} + +/// What a `materialize:` checkpoint records: the pinned source. Owns +/// nothing locally (Fly deploys an image), so observe reports Gone and resume +/// cheaply re-records it. +#[derive(Debug, Serialize, Deserialize)] +struct SourceRefPayload { + repo: String, + #[serde(rename = "ref")] + reference: String, +} + +/// The Fly substrate. Generic over the command runner so tests inject canned +/// Stripe envelopes; production uses the real `stripe` binary. +pub struct FlySubstrate { + /// Where the definition lives — Stripe Projects runs here and the project + /// anchor is written back here (record.definition_dir). + pub definition_dir: PathBuf, + /// Resolved secrets (vault/env-file overlay), injected as env vars. + pub secrets: BTreeMap, + /// Per-invocation paid consent (§2/§4). + pub confirm_paid: bool, + runner: R, + /// Overridable Fly Machines API base (tests point it at a mock server). + api_base: Option, + /// Test-only override of the deploy poll interval, so timeout/poll paths run + /// instantly under wiremock. + poll_interval: Option, + /// Run the instance-wide project/env ensure exactly once per process, + /// re-entrant across whichever step fires first on resume. + ensured: Mutex, +} + +impl std::fmt::Debug for FlySubstrate { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("FlySubstrate") + .field("definition_dir", &self.definition_dir) + .field("confirm_paid", &self.confirm_paid) + .finish_non_exhaustive() + } +} + +impl FlySubstrate { + /// Production constructor: drives the real `stripe` binary and the live Fly + /// Machines API. + pub fn new( + definition_dir: impl Into, + secrets: BTreeMap, + confirm_paid: bool, + ) -> Self { + Self { + definition_dir: definition_dir.into(), + secrets, + confirm_paid, + runner: TokioRunner, + api_base: None, + poll_interval: None, + ensured: Mutex::new(false), + } + } +} + +impl FlySubstrate { + /// Test constructor: inject a fake Stripe runner and point the Fly API at a + /// mock server. + #[cfg(test)] + fn for_test( + runner: R, + definition_dir: impl Into, + api_base: impl Into, + confirm_paid: bool, + ) -> Self { + Self { + definition_dir: definition_dir.into(), + secrets: BTreeMap::new(), + confirm_paid, + runner, + api_base: Some(api_base.into()), + poll_interval: Some(Duration::from_millis(1)), + ensured: Mutex::new(false), + } + } + + fn stripe(&self) -> StripeProjects<&R> { + StripeProjects::new(&self.runner, self.definition_dir.clone()) + } + + /// Build a Machines-API client from the Stripe-returned deploy token (test + /// overrides point it at a mock server with a fast poll interval). + fn fly_with_token(&self, token: &str) -> FlyApi { + let api = match &self.api_base { + Some(base) => FlyApi::with_base(token, base.clone()), + None => FlyApi::new(token), + }; + match self.poll_interval { + Some(interval) => api.with_poll_interval(interval), + None => api, + } + } + + /// `{stack}-{instance}-{service}` (DNS-safe; a legal Fly app name). + fn resource_name(def: &StackDef, instance: &str, node: &str) -> String { + format!("{}-{instance}-{node}", def.stack.name.as_str()) + } + + /// `https://{stack}-{instance}-{service}.fly.dev` — derivable from the name + /// alone, so mutual references are not cycles (§1). + fn origin(def: &StackDef, instance: &str, service: &str) -> String { + format!( + "https://{}.fly.dev", + Self::resource_name(def, instance, service) + ) + } + + /// Build the interpolation namespace: service origins are the fly.dev URLs; + /// v0 Fly has no datastores. Same-named secrets are injected. + 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 + } + + /// The interpolated env for a fly service: common env + the + /// `[services.X.fly].env` overlay, `${...}` resolved, same-named secrets + /// injected. + fn resolved_env( + &self, + def: &StackDef, + instance: &str, + service: &str, + prior: &[Checkpoint], + ) -> Result, SubstrateFault> { + let namespace = self.namespace(def, instance, prior); + let spec = def.services.get(service).ok_or_else(|| { + fault(FlyError::ConfigInvalid { + location: format!("services.{service}"), + detail: "service not in definition".into(), + }) + })?; + let raw = spec.effective_env(service, SUBSTRATE_NAME).map_err(|err| { + fault(FlyError::ConfigInvalid { + location: format!("services.{service}.fly.env"), + detail: err.to_string(), + }) + })?; + let mut resolved = Vec::new(); + for (key, value) in &raw { + let location = format!("services.{service}.env.{key}"); + let value = stackless_core::def::interp::resolve(value, &namespace, &location) + .map_err(|err| { + fault(FlyError::ConfigInvalid { + location, + detail: err.to_string(), + }) + })?; + resolved.push((key.clone(), value)); + } + for key in &spec.secrets { + if let Some(value) = self.secrets.get(key) { + resolved.push((key.clone(), value.clone())); + } + } + Ok(resolved) + } + + /// Instance-wide setup, idempotent and run before any step's own work (§4): + /// anchor the stack's Stripe project, create/activate the instance's named + /// environment, set the spend cap once when paid is consented. + 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 stripe = self.stripe(); + project::ensure_project(&stripe, def, &self.definition_dir) + .await + .map_err(projects_fault)?; + project::ensure_environment(&stripe, instance) + .await + .map_err(projects_fault)?; + if self.confirm_paid { + project::set_spend_cap(&stripe, SPEND_CAP_USD, "flyio") + .await + .map_err(projects_fault)?; + } + *done = true; + Ok(()) + } + + /// Gate paid resource creation on `--confirm-paid` (§2/§4). + fn require_confirm_paid(&self, resource: &str) -> Result<(), SubstrateFault> { + if !self.confirm_paid { + return Err(fault(FlyError::PaymentNotConfirmed { + resource: resource.to_owned(), + })); + } + Ok(()) + } + + async fn start_service( + &self, + def: &StackDef, + instance: &str, + service: &str, + prior: &[Checkpoint], + ) -> Result { + let fly_cfg = config::service_fly(def, service).map_err(fault)?; + let app_name = Self::resource_name(def, instance, service); + let resource = format!("{instance}-{service}"); + let region = config::stack_region(def); + + // Provision the Fly app via Stripe Projects (paid → confirm-gated) and + // capture the Stripe-managed deploy token it returns (the only output + // field, pinned by `mise run discover flyio/app`). + let catalog = self.stripe().catalog().await.map_err(projects_fault)?; + let app_config = FlyAppConfig { + app_name: app_name.clone(), + }; + if requires_confirmation(&catalog, &app_config).unwrap_or(false) { + self.require_confirm_paid(&resource)?; + } + let ctx = ProvisionContext { + def, + instance, + logical_name: service, + definition_dir: &self.definition_dir, + substrate: SUBSTRATE_NAME, + // ensure_project_and_env already ran for this instance in execute(). + skip_instance_context: true, + }; + let (_resource_name, outputs) = provision_outputs( + &self.stripe(), + &catalog, + &ctx, + &app_config, + PROVIDER_PREFIX, + &[("DEPLOY_TOKEN", "deploy_token", true)], + ) + .await + .map_err(projects_fault)?; + let token = outputs.get("deploy_token").ok_or_else(|| { + fault(FlyError::ProvisionFailed { + resource: resource.clone(), + detail: "flyio/app did not return a deploy token".into(), + }) + })?; + + // Allocate the app's public IPs, deploy the machine, wait for it to start. + let fly = self.fly_with_token(token); + fly.ensure_ips(&app_name).await.map_err(fault)?; + let env = self.resolved_env(def, instance, service, prior)?; + let spec = MachineSpec { + name: &app_name, + region: ®ion, + image: &fly_cfg.image, + cmd: fly_cfg.cmd.as_deref(), + env: &env, + internal_port: fly_cfg.internal_port, + cpu_kind: &fly_cfg.guest.cpu_kind, + cpus: fly_cfg.guest.cpus, + memory_mb: fly_cfg.guest.memory_mb, + }; + // Resume idempotency: reuse a machine a prior partial run already created + // (create_machine is not idempotent), so a re-run never duplicates compute. + let machine_id = match fly + .find_machine(&app_name, &app_name) + .await + .map_err(fault)? + { + Some(existing) => existing, + None => fly.create_machine(&app_name, &spec).await.map_err(fault)?, + }; + fly.wait_for_started(&app_name, &machine_id, service, FLY_DEPLOY_BUDGET) + .await + .map_err(fault)?; + + let payload = ServicePayload { + stripe_resource: resource, + app_name: app_name.clone(), + machine_id, + origin: Self::origin(def, instance, service), + }; + Ok(StepResource { + resource_kind: "fly-machine".into(), + resource_id: app_name, + payload: serde_json::to_string(&payload).unwrap_or_default(), + }) + } + + /// Run the service's `prepare` hook on the operator's machine from a fresh + /// shallow checkout, with the resolved service env exported. + async fn run_prepare( + &self, + def: &StackDef, + instance: &str, + service: &str, + prior: &[Checkpoint], + ) -> Result<(), SubstrateFault> { + let spec = def.services.get(service); + let Some(command) = spec.and_then(|s| s.prepare.clone()) else { + return Ok(()); + }; + let Some(spec) = spec else { return Ok(()) }; + + let env = self.resolved_env(def, instance, service, prior)?; + let repo = spec.source.repo.clone(); + let reference = spec.source.reference.clone(); + let service_owned = service.to_owned(); + let command_for_task = command.clone(); + tokio::task::spawn_blocking(move || { + prepare::run_prepare_command(&service_owned, &repo, &reference, &command_for_task, &env) + }) + .await + .map_err(|err| { + fault(FlyError::PrepareFailed { + service: service.to_owned(), + command: Some(command), + message: format!("prepare task panicked: {err}"), + log_tail: None, + }) + })? + .map_err(fault) + } + + async fn health_gate( + &self, + def: &StackDef, + instance: &str, + service: &str, + ) -> Result<(), SubstrateFault> { + let spec = def.services.get(service).ok_or_else(|| { + fault(FlyError::ConfigInvalid { + location: format!("services.{service}"), + detail: "service not in definition".into(), + }) + })?; + let origin = Self::origin(def, instance, service); + let url = format!("{origin}{}", spec.health.path); + let client = reqwest::Client::new(); + let deadline = tokio::time::Instant::now() + HEALTH_BUDGET; + let mut last_detail; + loop { + match client + .get(&url) + .timeout(Duration::from_secs(10)) + .send() + .await + { + Ok(response) => { + let status = response.status().as_u16(); + let body = response.text().await.unwrap_or_default(); + let status_ok = status == spec.health.status.get(); + let contains_ok = spec + .health + .contains + .as_ref() + .is_none_or(|needle| body.contains(needle)); + if status_ok && contains_ok { + return Ok(()); + } + last_detail = format!( + "got {status}, expected {}{}", + spec.health.status, + spec.health + .contains + .as_ref() + .map(|n| format!(" containing {n:?}")) + .unwrap_or_default() + ); + } + Err(err) => last_detail = err.to_string(), + } + if tokio::time::Instant::now() >= deadline { + return Err(fault(FlyError::HealthFailed { + service: service.to_owned(), + url, + detail: last_detail, + budget_secs: HEALTH_BUDGET.as_secs(), + })); + } + tokio::time::sleep(Duration::from_secs(5)).await; + } + } +} + +#[async_trait] +impl Substrate for FlySubstrate { + fn name(&self) -> &str { + SUBSTRATE_NAME + } + + fn validate_definition(&self, def: &StackDef) -> Result<(), SubstrateFault> { + // v0 Fly has no managed datastore (flyio/mpg is a separate catalog + // integration). Trap it early rather than fail mid-provision. + if let Some(name) = def.datastores.keys().next() { + return Err(fault(FlyError::ConfigInvalid { + location: format!("datastores.{name}"), + detail: "the fly substrate has no managed datastore in v0; remove the \ + [datastores.*] block or use a different substrate" + .into(), + })); + } + // Every service needs a well-shaped [services.X.fly] block, and its + // derived app name must be a legal Fly app name. + for service in def.services.keys() { + config::service_fly(def, service).map_err(fault)?; + let app_name = Self::resource_name(def, "i", service); + if !config::is_valid_app_name(&app_name) { + return Err(fault(FlyError::ConfigInvalid { + location: format!("services.{service}"), + detail: format!( + "derived Fly app name {app_name:?} is not a legal app name \ + (^[a-z][a-z0-9-]{{2,62}}$); shorten the stack/service name" + ), + })); + } + } + Ok(()) + } + + fn supports_source_override(&self) -> bool { + // Fly deploys committed refs (§1); the engine errors first. + false + } + + fn default_lease(&self) -> Duration { + // Cloud instances bill, so abandonment must be expensive to nobody (§6). + 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::ProvisionDatastore => Err(fault(FlyError::ConfigInvalid { + location: format!("datastores.{node}"), + detail: "the fly substrate has no managed datastore in v0".into(), + })), + StepKind::Materialize => { + // No local checkout on fly — record the pinned ref. It owns + // nothing destructible: observe reports Gone so teardown drops + // it, and resume cheaply re-records it. + let spec = ctx.def.services.get(node).ok_or_else(|| { + fault(FlyError::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 => { + // Setup is local toolchain provisioning; Fly runs a prebuilt + // image. Record and skip (§4). + 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, ctx.prior) + .await + } + StepKind::HealthGate => { + self.health_gate(ctx.def, ctx.instance, node).await?; + Ok(stackless_core::substrate::action_resource(&ctx.step.id)) + } + } + } + + async fn observe( + &self, + _instance: &str, + checkpoint: &Checkpoint, + ) -> Result { + match checkpoint.resource_kind.as_str() { + // The Fly app's deploy token is ephemeral, so existence is checked + // via the Stripe resource registration (the source of truth for what + // Stripe provisioned), not the Fly API. + "fly-machine" => { + let stripe_resource = serde_json::from_str::(&checkpoint.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) + } + // Hooks, gates, and the source-ref own nothing destructible: Gone, + // so teardown drops their checkpoints and resume re-runs them. + _ => Ok(Observation::Gone), + } + } + + async fn destroy( + &self, + _instance: &str, + checkpoint: &Checkpoint, + ) -> Result<(), SubstrateFault> { + match checkpoint.resource_kind.as_str() { + // Removing the Stripe `flyio/app` resource tears down the Fly app + // (and its machine). `remove_resource` is idempotent; the engine + // then re-`observe`s via Stripe registration to confirm gone. + "fly-machine" => { + let stripe_resource = serde_json::from_str::(&checkpoint.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) + } + // action and source-ref kinds: nothing to destroy. + _ => Ok(()), + } + } + + async fn finalize_teardown(&self, instance: &str) -> Result<(), SubstrateFault> { + stackless_integrations::finalize_stripe_instance(&self.stripe(), instance).await; + Ok(()) + } + + async fn spend_line(&self) -> Option { + let stripe = StripeProjects::new(TokioRunner, self.definition_dir.clone()); + Some(match project::spend_summary(&stripe).await { + Some(data) => format!("spend: {data}"), + None => format!( + "spend: unavailable from the plugin; hard cap is ${SPEND_CAP_USD}/mo \ + (provider flyio) — see fly.io/dashboard" + ), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use stackless_stripe_projects::stripe::{CommandOutput, CommandRunner}; + use stackless_stripe_projects::test_support; + use std::path::Path; + + /// A runner that never gets called in Stripe-free tests. + struct NoRunner; + #[async_trait] + impl CommandRunner for NoRunner { + async fn run(&self, _args: &[String], _cwd: &Path) -> 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 fly_def() -> StackDef { + StackDef::parse( + "[stack]\nname=\"atto\"\n[services.web]\nsource={repo=\"r\",ref=\"main\"}\nenv={}\nhealth={path=\"/\"}\n[services.web.fly]\nimage=\"hashicorp/http-echo\"\ninternal_port=5678\n", + ) + .unwrap() + } + + fn subj() -> (tempfile::TempDir, FlySubstrate) { + let dir = tempfile::tempdir().unwrap(); + let s = FlySubstrate::for_test(NoRunner, dir.path(), "http://127.0.0.1:1", false); + (dir, s) + } + + const SERVICE_PAYLOAD: &str = r#"{"stripe_resource":"demo-web","app_name":"atto-demo-web","machine_id":"m_1","origin":"https://atto-demo-web.fly.dev"}"#; + + #[test] + fn resource_name_and_origin_are_dns_safe() { + let def = fly_def(); + assert_eq!( + FlySubstrate::::resource_name(&def, "demo", "web"), + "atto-demo-web" + ); + let (_dir, s) = subj(); + assert_eq!( + s.service_origin(&def, "demo", "web"), + "https://atto-demo-web.fly.dev" + ); + } + + #[test] + fn fly_substrate_defaults() { + let s = FlySubstrate::new(std::env::temp_dir(), Default::default(), false); + assert_eq!(s.name(), "fly"); + assert!(!s.supports_source_override()); + assert_eq!(s.default_lease(), Duration::from_secs(8 * 3600)); + } + + #[test] + fn validate_rejects_datastores() { + let def = StackDef::parse( + "[stack]\nname=\"atto\"\n[datastores.db]\nengine=\"postgres\"\nversion=\"17\"\n[services.web]\nsource={repo=\"r\",ref=\"main\"}\nenv={}\nhealth={path=\"/\"}\n[services.web.fly]\nimage=\"nginx\"\n", + ) + .unwrap(); + let (_dir, s) = subj(); + let err = s.validate_definition(&def).unwrap_err(); + assert_eq!(err.code, crate::codes::FLY_CONFIG_INVALID); + } + + #[tokio::test] + async fn machine_present_when_stripe_registers_it() { + let runner = test_support::ScriptedRunner::new(vec![test_support::services(&["demo-web"])]); + let dir = tempfile::tempdir().unwrap(); + let s = FlySubstrate::for_test(&runner, dir.path(), "http://127.0.0.1:1", false); + let cp = checkpoint("fly-machine", "start:web", SERVICE_PAYLOAD); + assert_eq!(s.observe("demo", &cp).await.unwrap(), Observation::Present); + } + + #[tokio::test] + async fn machine_gone_when_stripe_does_not_register_it() { + let runner = test_support::ScriptedRunner::new(vec![test_support::services(&[])]); + let dir = tempfile::tempdir().unwrap(); + let s = FlySubstrate::for_test(&runner, dir.path(), "http://127.0.0.1:1", false); + let cp = checkpoint("fly-machine", "start:web", SERVICE_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); + } + + #[tokio::test] + async fn teardown_removes_via_stripe() { + let runner = test_support::ScriptedRunner::new(vec![ + test_support::services(&["demo-web"]), // remove_resource registered pre-check + test_support::ok_empty(), // remove + ]); + let dir = tempfile::tempdir().unwrap(); + let s = FlySubstrate::for_test(&runner, dir.path(), "http://127.0.0.1:1", false); + let cp = checkpoint("fly-machine", "start:web", SERVICE_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-fly/src/prepare.rs b/crates/stackless-fly/src/prepare.rs new file mode 100644 index 0000000..3d5e806 --- /dev/null +++ b/crates/stackless-fly/src/prepare.rs @@ -0,0 +1,22 @@ +//! Operator-side cloud prepare (§4): shallow git checkout on the operator's +//! machine. The mechanics are shared (`stackless_cloud::prepare`); this maps the +//! neutral failure to Fly's error so its `fly.*` code/remediation hold. + +use crate::error::FlyError; + +pub fn run_prepare_command( + service: &str, + repo: &str, + reference: &str, + command: &str, + env: &[(String, String)], +) -> Result<(), FlyError> { + stackless_cloud::prepare::run_prepare_command(service, repo, reference, command, env).map_err( + |f| FlyError::PrepareFailed { + service: f.service, + command: f.command, + message: f.message, + log_tail: f.log_tail, + }, + ) +} diff --git a/crates/stackless-integrations/src/registry.rs b/crates/stackless-integrations/src/registry.rs index 8ee5791..d542e5a 100644 --- a/crates/stackless-integrations/src/registry.rs +++ b/crates/stackless-integrations/src/registry.rs @@ -321,7 +321,7 @@ mod tests { use super::*; - const KNOWN: &[&str] = &["local", "render", "vercel"]; + const KNOWN: &[&str] = &["local", "render", "vercel", "fly", "netlify"]; /// Registry hygiene: every provider string and resource kind is unique, so a /// new `PROVIDERS` row can't silently shadow another's dispatch. diff --git a/crates/stackless-netlify/Cargo.toml b/crates/stackless-netlify/Cargo.toml new file mode 100644 index 0000000..0330e25 --- /dev/null +++ b/crates/stackless-netlify/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "stackless-netlify" +edition.workspace = true +version.workspace = true +license.workspace = true +repository.workspace = true + +[lints] +workspace = true + +[dependencies] +async-trait = "0.1.89" +reqwest = { version = "0.13.4", default-features = false, features = [ + "rustls", + "charset", + "http2", + "json", +] } +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.150" +sha1 = "0.10.6" +stackless-cloud.workspace = true +stackless-core.workspace = true +stackless-git.workspace = true +stackless-integrations.workspace = true +stackless-stripe-projects.workspace = true +tempfile = "3.27.0" +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" +wiremock = "0.6.5" diff --git a/crates/stackless-netlify/src/codes.rs b/crates/stackless-netlify/src/codes.rs new file mode 100644 index 0000000..7f04157 --- /dev/null +++ b/crates/stackless-netlify/src/codes.rs @@ -0,0 +1,27 @@ +//! Stable error codes for the Netlify 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 NETLIFY_CONFIG_INVALID: &str = "netlify.config.invalid"; +pub const NETLIFY_API_FAILED: &str = "netlify.api.failed"; +pub const NETLIFY_PAYMENT_NOT_CONFIRMED: &str = "netlify.payment.not_confirmed"; +pub const NETLIFY_PROVISION_FAILED: &str = "netlify.provision.failed"; +pub const NETLIFY_DEPLOY_FAILED: &str = "netlify.deploy.failed"; +pub const NETLIFY_DEPLOY_TIMEOUT: &str = "netlify.deploy.timeout"; +pub const NETLIFY_HEALTH_FAILED: &str = "netlify.health.failed"; +pub const NETLIFY_PREPARE_FAILED: &str = "netlify.prepare.failed"; +pub const NETLIFY_TEARDOWN_SURVIVOR: &str = "netlify.teardown.survivor"; + +/// Every Netlify code, for the workspace uniqueness test. +pub const ALL: &[&str] = &[ + NETLIFY_CONFIG_INVALID, + NETLIFY_API_FAILED, + NETLIFY_PAYMENT_NOT_CONFIRMED, + NETLIFY_PROVISION_FAILED, + NETLIFY_DEPLOY_FAILED, + NETLIFY_DEPLOY_TIMEOUT, + NETLIFY_HEALTH_FAILED, + NETLIFY_PREPARE_FAILED, + NETLIFY_TEARDOWN_SURVIVOR, +]; diff --git a/crates/stackless-netlify/src/config.rs b/crates/stackless-netlify/src/config.rs new file mode 100644 index 0000000..a392f5d --- /dev/null +++ b/crates/stackless-netlify/src/config.rs @@ -0,0 +1,171 @@ +//! Parsing the netlify-specific blocks of the definition (§1 schema). +//! +//! v0 Netlify is **static upload**: the substrate clones the service's pinned +//! ref, uploads the files under `[services.X.netlify].root` (or the repo root) +//! via the Netlify file-digest deploy API, and serves them at +//! `https://.netlify.app`. A build step (running a framework build before +//! upload) is a later enhancement. + +use serde::Serialize; +use stackless_core::def::StackDef; + +use crate::SUBSTRATE_NAME; +use crate::error::NetlifyError; +use stackless_stripe_projects::CatalogService; + +/// A service's `[services.X.netlify]` block. Optional — an absent block uploads +/// the repo root. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ServiceNetlify { + /// Subdirectory of the cloned source to upload (the publish dir). + pub root: Option, +} + +/// The typed `netlify/project` `--config`. `name` is the only schema property +/// and IS the catalog contract — the gap test pins it. +#[derive(Debug, Serialize)] +pub struct NetlifyProjectConfig { + pub name: String, +} + +impl CatalogService for NetlifyProjectConfig { + const REFERENCE: &'static str = "netlify/project"; +} + +/// Read and shape-check `[services..netlify]` (optional block; unknown +/// keys inside it are a fault, to trap agent typos). +pub fn service_netlify(def: &StackDef, service: &str) -> Result { + let location = format!("services.{service}.netlify"); + let Some(block) = def + .services + .get(service) + .and_then(|spec| spec.substrates.get(SUBSTRATE_NAME)) + else { + return Ok(ServiceNetlify::default()); + }; + let table = block + .as_table() + .ok_or_else(|| NetlifyError::ConfigInvalid { + location: location.clone(), + detail: "must be a table { root?, env? }".into(), + })?; + for key in table.keys() { + if !matches!(key.as_str(), "root" | "env") { + return Err(NetlifyError::ConfigInvalid { + location: location.clone(), + detail: format!("unknown key {key:?} (known: root, env)"), + }); + } + } + let root = match table.get("root") { + None => None, + Some(value) => Some( + value + .as_str() + .filter(|s| !s.trim().is_empty()) + .ok_or_else(|| NetlifyError::ConfigInvalid { + location: format!("{location}.root"), + detail: "must be a non-empty string".into(), + })? + .to_owned(), + ), + }; + Ok(ServiceNetlify { root }) +} + +/// Whether `name` is a legal Netlify site name / subdomain 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.netlify] +root = "fixtures/smoke/site" +"#; + + #[test] + fn parses_root_and_defaults_when_block_absent() { + let def = parse(BASE); + assert_eq!( + service_netlify(&def, "web").unwrap().root.as_deref(), + Some("fixtures/smoke/site") + ); + let no_block = parse( + "[stack]\nname=\"atto\"\n[services.web]\nsource={repo=\"r\",ref=\"main\"}\nenv={}\nhealth={path=\"/\"}\n", + ); + assert_eq!( + service_netlify(&no_block, "web").unwrap(), + ServiceNetlify::default() + ); + } + + #[test] + fn unknown_key_is_rejected() { + let toml = BASE.replace("root = \"fixtures/smoke/site\"", "bogus = 1"); + let err = service_netlify(&parse(&toml), "web").unwrap_err(); + assert_eq!( + stackless_core::fault::Fault::code(&err), + crate::codes::NETLIFY_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!(NetlifyProjectConfig::REFERENCE, "netlify/project"); + } + + /// Catalog gap check: the `netlify/project` config must validate against the + /// committed catalog fixture. Fails loudly if Stripe drifts the schema. + #[test] + fn netlify_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, + &NetlifyProjectConfig { + name: "atto-demo-web".into(), + }, + ); + assert!( + failures.is_empty(), + "netlify catalog gaps:\n{}", + failures.join("\n") + ); + } +} diff --git a/crates/stackless-netlify/src/error.rs b/crates/stackless-netlify/src/error.rs new file mode 100644 index 0000000..8373ad3 --- /dev/null +++ b/crates/stackless-netlify/src/error.rs @@ -0,0 +1,128 @@ +//! Netlify-substrate errors (codes in this crate's `netlify.*` registry). + +use stackless_core::fault::{ErrorContext, Fault}; + +use crate::codes; + +#[derive(Debug, thiserror::Error)] +pub enum NetlifyError { + #[error("[{location}] is invalid: {detail}")] + ConfigInvalid { location: String, detail: String }, + + #[error("Netlify API {method} {path} failed: {detail}")] + ApiFailed { + method: String, + path: String, + detail: String, + }, + + #[error("creating paid Netlify resources requires explicit consent")] + PaymentNotConfirmed { resource: String }, + + #[error("provisioning {resource:?} on Netlify did not complete: {detail}")] + ProvisionFailed { resource: String, detail: String }, + + #[error("deploy of {service:?} ended {state}")] + DeployFailed { service: String, state: String }, + + #[error( + "deploy of {service:?} did not reach ready within {budget_secs}s (last state: {last_state})" + )] + DeployTimeout { + service: String, + budget_secs: u64, + last_state: String, + }, + + #[error("{service:?} failed its health contract ({detail}) within {budget_secs}s at {url}")] + HealthFailed { + service: String, + url: String, + detail: String, + budget_secs: u64, + }, + + #[error("prepare for {service:?} failed: {message}")] + PrepareFailed { + service: String, + command: Option, + message: String, + log_tail: Option, + }, + + #[error("{resource:?} still exists on Netlify after teardown")] + TeardownSurvivor { resource: String }, +} + +impl Fault for NetlifyError { + fn code(&self) -> &'static str { + match self { + Self::ConfigInvalid { .. } => codes::NETLIFY_CONFIG_INVALID, + Self::ApiFailed { .. } => codes::NETLIFY_API_FAILED, + Self::PaymentNotConfirmed { .. } => codes::NETLIFY_PAYMENT_NOT_CONFIRMED, + Self::ProvisionFailed { .. } => codes::NETLIFY_PROVISION_FAILED, + Self::DeployFailed { .. } => codes::NETLIFY_DEPLOY_FAILED, + Self::DeployTimeout { .. } => codes::NETLIFY_DEPLOY_TIMEOUT, + Self::HealthFailed { .. } => codes::NETLIFY_HEALTH_FAILED, + Self::PrepareFailed { .. } => codes::NETLIFY_PREPARE_FAILED, + Self::TeardownSurvivor { .. } => codes::NETLIFY_TEARDOWN_SURVIVOR, + } + } + + fn remediation(&self) -> String { + match self { + Self::ConfigInvalid { location, .. } => { + format!("fix the [{location}] block; see ARCHITECTURE.md §1 for the netlify schema") + } + Self::ApiFailed { .. } => { + "check that api.netlify.com is reachable and the deploy token is valid, then \ + re-run `up`" + .into() + } + Self::PaymentNotConfirmed { .. } => { + "re-run with --confirm-paid to consent to Netlify charges (bounded by the \ + project's hard spend cap)" + .into() + } + Self::ProvisionFailed { .. } => { + "wait a moment for Netlify to finish provisioning and re-run `up` to resume".into() + } + Self::DeployFailed { service, .. } => format!( + "the {service:?} deploy failed; check app.netlify.com logs, fix, and re-run `up`" + ), + Self::DeployTimeout { service, .. } => format!( + "the {service:?} deploy is still processing on Netlify; re-run `up` to resume \ + waiting, or check app.netlify.com" + ), + Self::HealthFailed { service, .. } => format!( + "the {service:?} site did not pass its health contract; check app.netlify.com \ + logs, fix, and re-run `up`" + ), + Self::PrepareFailed { service, .. } => format!( + "inspect context.log_tail; run the {service:?} prepare command by hand; re-run \ + `stackless up `" + ), + Self::TeardownSurvivor { resource } => { + format!("delete the {resource} site at app.netlify.com, then re-run `down`") + } + } + } + + 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-netlify/src/lib.rs b/crates/stackless-netlify/src/lib.rs new file mode 100644 index 0000000..767947c --- /dev/null +++ b/crates/stackless-netlify/src/lib.rs @@ -0,0 +1,835 @@ +//! stackless-netlify (ARCHITECTURE.md §4): the Netlify cloud substrate. +//! +//! Mirrors the Render/Vercel/Fly cloud flow: Stripe Projects provisions +//! `netlify/project` and tracks spend; the Netlify REST API fills its gaps — +//! resolve the site, run the file-digest deploy (upload the pinned ref's files), +//! poll it to `ready`, and the health wait. One long-lived Stripe project per +//! stack holds each instance as a named environment. +//! +//! ## Credential model (pinned by `mise run discover netlify/project`) +//! +//! Like Vercel/Fly, provisioning `netlify/project` returns a Stripe-managed +//! token; the substrate reads it from the provision output and uses it as the +//! Netlify-API bearer for that one `start` step. Because the token is ephemeral, +//! `observe`/`destroy` key off the **Stripe resource registration**, not the +//! Netlify API — the Netlify API is only touched at deploy time. +//! +//! ## v0 scope and cloud invariants +//! +//! - **Static upload.** A service's source files (under `[services.X.netlify].root` +//! or the repo root) are uploaded via the file-digest deploy API; running a +//! framework build first is a later enhancement. `netlify/project` is free. +//! - **No managed datastore** — a `[datastores.*]` block is rejected. +//! - **Cloud resource names** are `{stack}-{instance}-{service}` — DNS-safe and a +//! legal Netlify site name. Origins are +//! `https://{stack}-{instance}-{service}.netlify.app`. +//! - **Setup is skipped on cloud**; **prepare** runs on the operator's machine. +//! - **Source override is unsupported** — Netlify deploys committed refs. + +pub mod codes; +pub mod config; +pub mod error; +pub mod netlify_api; +pub mod prepare; + +use std::collections::BTreeMap; +use std::path::{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::NetlifyProjectConfig; +use crate::error::NetlifyError; +use crate::netlify_api::{HEALTH_BUDGET, NETLIFY_DEPLOY_BUDGET, NetlifyApi, UploadFile}; +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 = "netlify"; + +/// 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 `netlify/project` output env vars. +/// Pinned by `mise run discover netlify/project`. +const PROVIDER_PREFIX: &str = "NETLIFY"; + +fn fault(err: NetlifyError) -> 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) +} + +/// What a `start:` checkpoint records: the live Netlify site. The token +/// is intentionally NOT stored — observe/destroy use Stripe. +#[derive(Debug, Serialize, Deserialize)] +struct NetlifyPayload { + stripe_resource: String, + site_id: String, + 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 Netlify substrate. Generic over the command runner so tests inject canned +/// Stripe envelopes; production uses the real `stripe` binary. +pub struct NetlifySubstrate { + pub definition_dir: PathBuf, + pub secrets: BTreeMap, + pub confirm_paid: bool, + runner: R, + api_base: Option, + poll_interval: Option, + ensured: Mutex, +} + +impl std::fmt::Debug for NetlifySubstrate { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("NetlifySubstrate") + .field("definition_dir", &self.definition_dir) + .field("confirm_paid", &self.confirm_paid) + .finish_non_exhaustive() + } +} + +impl NetlifySubstrate { + pub fn new( + definition_dir: impl Into, + secrets: BTreeMap, + confirm_paid: bool, + ) -> Self { + Self { + definition_dir: definition_dir.into(), + secrets, + confirm_paid, + runner: TokioRunner, + api_base: None, + poll_interval: None, + ensured: Mutex::new(false), + } + } +} + +impl NetlifySubstrate { + #[cfg(test)] + fn for_test( + runner: R, + definition_dir: impl Into, + api_base: impl Into, + confirm_paid: bool, + ) -> Self { + Self { + definition_dir: definition_dir.into(), + secrets: BTreeMap::new(), + confirm_paid, + runner, + api_base: Some(api_base.into()), + poll_interval: Some(Duration::from_millis(1)), + ensured: Mutex::new(false), + } + } + + fn stripe(&self) -> StripeProjects<&R> { + StripeProjects::new(&self.runner, self.definition_dir.clone()) + } + + fn netlify_with_token(&self, token: &str) -> NetlifyApi { + let api = match &self.api_base { + Some(base) => NetlifyApi::with_base(token, base.clone()), + None => NetlifyApi::new(token), + }; + match self.poll_interval { + Some(interval) => api.with_poll_interval(interval), + None => api, + } + } + + /// `{stack}-{instance}-{service}` (DNS-safe; a legal Netlify site name). + fn resource_name(def: &StackDef, instance: &str, node: &str) -> String { + format!("{}-{instance}-{node}", def.stack.name.as_str()) + } + + /// `https://{stack}-{instance}-{service}.netlify.app` — the best-effort origin + /// (the real one is recorded from the deploy's ssl_url). + fn origin(def: &StackDef, instance: &str, service: &str) -> String { + format!( + "https://{}.netlify.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 stripe = self.stripe(); + project::ensure_project(&stripe, def, &self.definition_dir) + .await + .map_err(projects_fault)?; + project::ensure_environment(&stripe, instance) + .await + .map_err(projects_fault)?; + if self.confirm_paid { + project::set_spend_cap(&stripe, SPEND_CAP_USD, "netlify") + .await + .map_err(projects_fault)?; + } + *done = true; + Ok(()) + } + + fn require_confirm_paid(&self, resource: &str) -> Result<(), SubstrateFault> { + if !self.confirm_paid { + return Err(fault(NetlifyError::PaymentNotConfirmed { + resource: resource.to_owned(), + })); + } + Ok(()) + } + + async fn start_service( + &self, + def: &StackDef, + instance: &str, + service: &str, + ) -> Result { + let netlify_cfg = config::service_netlify(def, service).map_err(fault)?; + let site_name = Self::resource_name(def, instance, service); + let resource = format!("{instance}-{service}"); + let spec = def.services.get(service).ok_or_else(|| { + fault(NetlifyError::ConfigInvalid { + location: format!("services.{service}"), + detail: "service not in definition".into(), + }) + })?; + + // Provision the Netlify site via Stripe Projects (free; the paid gate is + // kept for safety) and capture the Stripe-managed token (+ optional site + // id) it returns. + let catalog = self.stripe().catalog().await.map_err(projects_fault)?; + let cfg = NetlifyProjectConfig { + name: site_name.clone(), + }; + 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 + // netlify/project` (Stripe names them `{RESOURCE}_NETLIFY_*`). + &[ + ("NETLIFY_AUTH_TOKEN", "token", true), + ("NETLIFY_SITE_ID", "site_id", false), + ], + ) + .await + .map_err(projects_fault)?; + let token = outputs.get("token").ok_or_else(|| { + fault(NetlifyError::ProvisionFailed { + resource: resource.clone(), + detail: "netlify/project did not return an auth token".into(), + }) + })?; + + let netlify = self.netlify_with_token(token); + // The site: Stripe may hand back its id, else create it by name. + let (site_id, provisioned_url) = match outputs.get("site_id") { + Some(id) => (id.clone(), outputs.get("url").cloned()), + None => { + let site = netlify.create_site(&site_name).await.map_err(fault)?; + (site.id, site.ssl_url) + } + }; + + // Clone the pinned ref and collect the files under the publish root. + let repo = spec.source.repo.clone(); + let reference = spec.source.reference.clone(); + let root = netlify_cfg.root.clone(); + let files = tokio::task::spawn_blocking(move || { + collect_upload_files(&repo, &reference, root.as_deref()) + }) + .await + .map_err(|err| { + fault(NetlifyError::ProvisionFailed { + resource: resource.clone(), + detail: format!("file collection task panicked: {err}"), + }) + })? + .map_err(fault)?; + + let deployed_url = netlify + .deploy(&site_id, &files, service, NETLIFY_DEPLOY_BUDGET) + .await + .map_err(fault)?; + let origin = [deployed_url, provisioned_url.unwrap_or_default()] + .into_iter() + .find(|u| !u.trim().is_empty()) + .unwrap_or_else(|| Self::origin(def, instance, service)); + + let payload = NetlifyPayload { + stripe_resource: resource, + site_id, + site_name: site_name.clone(), + origin, + }; + Ok(StepResource { + resource_kind: "netlify-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 spec = def.services.get(service); + let Some(command) = spec.and_then(|s| s.prepare.clone()) else { + return Ok(()); + }; + let Some(spec) = spec else { return Ok(()) }; + + let namespace = self.namespace(def, instance, prior); + let raw = spec.effective_env(service, SUBSTRATE_NAME).map_err(|err| { + fault(NetlifyError::PrepareFailed { + service: service.to_owned(), + command: Some(command.clone()), + message: err.to_string(), + log_tail: None, + }) + })?; + let mut env: Vec<(String, String)> = Vec::new(); + for (key, value) in &raw { + let location = format!("services.{service}.env.{key}"); + let value = stackless_core::def::interp::resolve(value, &namespace, &location) + .map_err(|err| { + fault(NetlifyError::PrepareFailed { + service: service.to_owned(), + command: Some(command.clone()), + message: err.to_string(), + log_tail: None, + }) + })?; + env.push((key.clone(), value)); + } + for key in &spec.secrets { + if let Some(value) = self.secrets.get(key) { + env.push((key.clone(), value.clone())); + } + } + + let repo = spec.source.repo.clone(); + let reference = spec.source.reference.clone(); + let service_owned = service.to_owned(); + let command_for_task = command.clone(); + tokio::task::spawn_blocking(move || { + prepare::run_prepare_command(&service_owned, &repo, &reference, &command_for_task, &env) + }) + .await + .map_err(|err| { + fault(NetlifyError::PrepareFailed { + service: service.to_owned(), + command: Some(command), + message: format!("prepare task panicked: {err}"), + log_tail: None, + }) + })? + .map_err(fault) + } + + async fn health_gate( + &self, + def: &StackDef, + instance: &str, + service: &str, + prior: &[Checkpoint], + ) -> Result<(), SubstrateFault> { + let spec = def.services.get(service).ok_or_else(|| { + fault(NetlifyError::ConfigInvalid { + location: format!("services.{service}"), + detail: "service not in definition".into(), + }) + })?; + // Prefer the real deploy URL recorded at start; fall back to the derived + // origin (they match when the site name was taken verbatim). + let origin = prior + .iter() + .find(|c| c.resource_kind == "netlify-site" && c.step_id == format!("start:{service}")) + .and_then(|c| serde_json::from_str::(&c.payload).ok()) + .map(|p| p.origin) + .filter(|o| !o.trim().is_empty()) + .unwrap_or_else(|| Self::origin(def, instance, service)); + let url = format!("{origin}{}", spec.health.path); + let client = reqwest::Client::new(); + let deadline = tokio::time::Instant::now() + HEALTH_BUDGET; + let mut last_detail; + loop { + match client + .get(&url) + .timeout(Duration::from_secs(10)) + .send() + .await + { + Ok(response) => { + let status = response.status().as_u16(); + let body = response.text().await.unwrap_or_default(); + let status_ok = status == spec.health.status.get(); + let contains_ok = spec + .health + .contains + .as_ref() + .is_none_or(|needle| body.contains(needle)); + if status_ok && contains_ok { + return Ok(()); + } + last_detail = format!( + "got {status}, expected {}{}", + spec.health.status, + spec.health + .contains + .as_ref() + .map(|n| format!(" containing {n:?}")) + .unwrap_or_default() + ); + } + Err(err) => last_detail = err.to_string(), + } + if tokio::time::Instant::now() >= deadline { + return Err(fault(NetlifyError::HealthFailed { + service: service.to_owned(), + url, + detail: last_detail, + budget_secs: HEALTH_BUDGET.as_secs(), + })); + } + tokio::time::sleep(Duration::from_secs(5)).await; + } + } +} + +/// Check out `repo`@`reference` into a temp dir and read every file under `root` +/// (or the repo root) as [`UploadFile`]s for the file-digest deploy. +fn collect_upload_files( + repo: &str, + reference: &str, + root: Option<&str>, +) -> Result, NetlifyError> { + let provision_fault = |detail: String| NetlifyError::ProvisionFailed { + resource: repo.to_owned(), + detail, + }; + let tmp = tempfile::tempdir().map_err(|err| provision_fault(format!("tempdir: {err}")))?; + stackless_git::clone_checkout( + repo, + reference, + tmp.path(), + &stackless_git::Credentials::default(), + ) + .map_err(|err| provision_fault(format!("clone {repo}@{reference} failed: {err}")))?; + let base = match root { + Some(root) => tmp.path().join(root), + None => tmp.path().to_path_buf(), + }; + if !base.is_dir() { + return Err(provision_fault(format!( + "upload root {:?} not found in {repo}@{reference}", + root.unwrap_or(".") + ))); + } + let mut files = Vec::new(); + collect_dir(&base, &base, &mut files) + .map_err(|err| provision_fault(format!("reading upload files: {err}")))?; + if files.is_empty() { + return Err(provision_fault(format!( + "no files to upload under {:?}", + root.unwrap_or(".") + ))); + } + Ok(files) +} + +fn collect_dir(base: &Path, dir: &Path, out: &mut Vec) -> std::io::Result<()> { + for entry in std::fs::read_dir(dir)? { + let entry = entry?; + if entry.file_name() == ".git" { + continue; + } + let path = entry.path(); + if path.is_dir() { + collect_dir(base, &path, out)?; + } else if path.is_file() { + let rel = path + .strip_prefix(base) + .unwrap_or(&path) + .to_string_lossy() + .replace('\\', "/"); + out.push(UploadFile { + path: rel, + data: std::fs::read(&path)?, + }); + } + } + Ok(()) +} + +#[async_trait] +impl Substrate for NetlifySubstrate { + fn name(&self) -> &str { + SUBSTRATE_NAME + } + + fn validate_definition(&self, def: &StackDef) -> Result<(), SubstrateFault> { + if let Some(name) = def.datastores.keys().next() { + return Err(fault(NetlifyError::ConfigInvalid { + location: format!("datastores.{name}"), + detail: "the netlify substrate has no managed datastore; remove the \ + [datastores.*] block or use a different substrate" + .into(), + })); + } + for service in def.services.keys() { + config::service_netlify(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(NetlifyError::ConfigInvalid { + location: format!("services.{service}"), + detail: format!( + "derived Netlify site name {site_name:?} is not a legal site 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 { + 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::ProvisionDatastore => Err(fault(NetlifyError::ConfigInvalid { + location: format!("datastores.{node}"), + detail: "the netlify substrate has no managed datastore".into(), + })), + StepKind::Materialize => { + let spec = ctx.def.services.get(node).ok_or_else(|| { + fault(NetlifyError::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 => { + self.health_gate(ctx.def, ctx.instance, node, ctx.prior) + .await?; + Ok(stackless_core::substrate::action_resource(&ctx.step.id)) + } + } + } + + async fn observe( + &self, + _instance: &str, + checkpoint: &Checkpoint, + ) -> Result { + match checkpoint.resource_kind.as_str() { + "netlify-site" => { + let stripe_resource = serde_json::from_str::(&checkpoint.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) + } + _ => Ok(Observation::Gone), + } + } + + async fn destroy( + &self, + _instance: &str, + checkpoint: &Checkpoint, + ) -> Result<(), SubstrateFault> { + match checkpoint.resource_kind.as_str() { + "netlify-site" => { + let stripe_resource = serde_json::from_str::(&checkpoint.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) + } + _ => Ok(()), + } + } + + async fn finalize_teardown(&self, instance: &str) -> Result<(), SubstrateFault> { + stackless_integrations::finalize_stripe_instance(&self.stripe(), instance).await; + Ok(()) + } + + async fn spend_line(&self) -> Option { + let stripe = StripeProjects::new(TokioRunner, self.definition_dir.clone()); + Some(match project::spend_summary(&stripe).await { + Some(data) => format!("spend: {data}"), + None => format!( + "spend: unavailable from the plugin; hard cap is ${SPEND_CAP_USD}/mo \ + (provider netlify) — see app.netlify.com" + ), + }) + } +} + +#[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 netlify_def() -> StackDef { + StackDef::parse( + "[stack]\nname=\"atto\"\n[services.web]\nsource={repo=\"r\",ref=\"main\"}\nenv={}\nhealth={path=\"/\"}\n[services.web.netlify]\nroot=\"fixtures/smoke/site\"\n", + ) + .unwrap() + } + + fn subj() -> (tempfile::TempDir, NetlifySubstrate) { + let dir = tempfile::tempdir().unwrap(); + let s = NetlifySubstrate::for_test(NoRunner, dir.path(), "http://127.0.0.1:1", false); + (dir, s) + } + + const PAYLOAD: &str = r#"{"stripe_resource":"demo-web","site_id":"site_1","site_name":"atto-demo-web","origin":"https://atto-demo-web.netlify.app"}"#; + + #[test] + fn resource_name_and_origin_are_dns_safe() { + let def = netlify_def(); + assert_eq!( + NetlifySubstrate::::resource_name(&def, "demo", "web"), + "atto-demo-web" + ); + let (_dir, s) = subj(); + assert_eq!( + s.service_origin(&def, "demo", "web"), + "https://atto-demo-web.netlify.app" + ); + } + + #[test] + fn netlify_substrate_defaults() { + let s = NetlifySubstrate::new(std::env::temp_dir(), Default::default(), false); + assert_eq!(s.name(), "netlify"); + assert!(!s.supports_source_override()); + assert_eq!(s.default_lease(), Duration::from_secs(8 * 3600)); + } + + #[test] + fn validate_rejects_datastores() { + let def = StackDef::parse( + "[stack]\nname=\"atto\"\n[datastores.db]\nengine=\"postgres\"\nversion=\"17\"\n[services.web]\nsource={repo=\"r\",ref=\"main\"}\nenv={}\nhealth={path=\"/\"}\n", + ) + .unwrap(); + let (_dir, s) = subj(); + let err = s.validate_definition(&def).unwrap_err(); + assert_eq!(err.code, crate::codes::NETLIFY_CONFIG_INVALID); + } + + #[tokio::test] + async fn site_present_when_stripe_registers_it() { + let runner = test_support::ScriptedRunner::new(vec![test_support::services(&["demo-web"])]); + let dir = tempfile::tempdir().unwrap(); + let s = NetlifySubstrate::for_test(&runner, dir.path(), "http://127.0.0.1:1", false); + let cp = checkpoint("netlify-site", "start:web", PAYLOAD); + assert_eq!(s.observe("demo", &cp).await.unwrap(), Observation::Present); + } + + #[tokio::test] + async fn site_gone_when_stripe_does_not_register_it() { + let runner = test_support::ScriptedRunner::new(vec![test_support::services(&[])]); + let dir = tempfile::tempdir().unwrap(); + let s = NetlifySubstrate::for_test(&runner, dir.path(), "http://127.0.0.1:1", false); + let cp = checkpoint("netlify-site", "start:web", PAYLOAD); + assert_eq!(s.observe("demo", &cp).await.unwrap(), Observation::Gone); + } + + #[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 = NetlifySubstrate::for_test(&runner, dir.path(), "http://127.0.0.1:1", false); + let cp = checkpoint("netlify-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-netlify/src/netlify_api.rs b/crates/stackless-netlify/src/netlify_api.rs new file mode 100644 index 0000000..47ff9b0 --- /dev/null +++ b/crates/stackless-netlify/src/netlify_api.rs @@ -0,0 +1,519 @@ +//! The Netlify REST client (ARCHITECTURE.md §4): the post-provisioning steps +//! Stripe Projects can't express — create/resolve the site, run the file-digest +//! deploy (POST the per-file SHA1 map, PUT only the files Netlify still needs), +//! and poll the deploy to `ready`. +//! +//! Hand-written over `reqwest`: the deploy lifecycle is ~5 endpoints with flat +//! JSON + raw-bytes uploads, and the served spec is Swagger 2.0 +//! (`netlify/open-api`). Responses are parsed leniently so additive provider +//! drift never breaks a deploy. + +use std::collections::HashSet; +use std::time::Duration; + +use reqwest::header::{AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderValue}; +use reqwest::{Client, Method}; +use serde_json::{Value, json}; +use sha1::{Digest, Sha1}; + +use crate::error::NetlifyError; + +const DEFAULT_BASE: &str = "https://api.netlify.com/api/v1"; + +/// A static deploy (upload + Netlify-side processing) is fast, but a cold +/// upload + edge propagation can lag; the budget covers it without hanging `up`. +pub const NETLIFY_DEPLOY_BUDGET: Duration = Duration::from_secs(10 * 60); +/// The public-origin health wait budget (§7). +pub const HEALTH_BUDGET: Duration = Duration::from_secs(5 * 60); + +const POLL_INTERVAL: Duration = Duration::from_secs(5); +const REQUEST_TIMEOUT: Duration = Duration::from_secs(60); + +/// One file to upload: a repo-relative path (no leading slash) + its bytes. +#[derive(Debug, Clone)] +pub struct UploadFile { + pub path: String, + pub data: Vec, +} + +/// A Netlify site's identity (from create/get). +#[derive(Debug, Clone)] +pub struct SiteInfo { + pub id: String, + /// The production HTTPS URL (`https://.netlify.app`). + pub ssl_url: Option, +} + +pub struct NetlifyApi { + client: Client, + base: String, + poll_interval: Duration, +} + +impl std::fmt::Debug for NetlifyApi { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("NetlifyApi") + .field("base", &self.base) + .finish_non_exhaustive() + } +} + +fn authed_client(token: &str) -> Client { + let mut headers = HeaderMap::new(); + if let Ok(mut value) = HeaderValue::from_str(&format!("Bearer {token}")) { + value.set_sensitive(true); + headers.insert(AUTHORIZATION, value); + } + Client::builder() + .default_headers(headers) + .connect_timeout(REQUEST_TIMEOUT) + .timeout(REQUEST_TIMEOUT) + .build() + .unwrap_or_else(|_| Client::new()) +} + +fn api_failed(method: &str, path: &str, err: impl std::fmt::Display) -> NetlifyError { + NetlifyError::ApiFailed { + method: method.to_owned(), + path: path.to_owned(), + detail: err.to_string(), + } +} + +fn truncate(text: &str) -> String { + const MAX: usize = 400; + if text.len() <= MAX { + text.to_owned() + } else { + format!("{}…", &text[..MAX]) + } +} + +fn sha1_hex(data: &[u8]) -> String { + let mut hasher = Sha1::new(); + hasher.update(data); + hasher + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +impl NetlifyApi { + pub fn new(token: impl AsRef) -> Self { + Self::with_base(token, DEFAULT_BASE) + } + + pub fn with_base(token: impl AsRef, base: impl Into) -> Self { + Self { + client: authed_client(token.as_ref()), + base: base.into(), + poll_interval: POLL_INTERVAL, + } + } + + /// Tests set a tiny interval so the poll/timeout paths run instantly. + pub fn with_poll_interval(mut self, interval: Duration) -> Self { + self.poll_interval = interval; + self + } + + /// Send a JSON request and require a 2xx, returning the parsed body (or + /// `Null` for an empty body). + async fn send_json( + &self, + method: Method, + path: &str, + body: Option, + ) -> Result { + let url = format!("{}{path}", self.base); + let mut req = self.client.request(method.clone(), &url); + if let Some(body) = &body { + req = req.json(body); + } + let resp = req + .send() + .await + .map_err(|err| api_failed(method.as_str(), path, err))?; + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + if !status.is_success() { + return Err(api_failed( + method.as_str(), + path, + format!("status {}: {}", status.as_u16(), truncate(&text)), + )); + } + if text.trim().is_empty() { + return Ok(Value::Null); + } + serde_json::from_str(&text) + .map_err(|err| api_failed(method.as_str(), path, format!("bad json: {err}"))) + } + + /// Create a Netlify site with the given name (used when provisioning did not + /// already hand back a site id). + pub async fn create_site(&self, name: &str) -> Result { + let created = self + .send_json(Method::POST, "/sites", Some(json!({ "name": name }))) + .await?; + site_info(&created) + .ok_or_else(|| api_failed("POST", "/sites", "create returned no site id")) + } + + /// Whether a site still exists (best-effort teardown verification). + pub async fn site_exists(&self, site_id: &str) -> Result { + let path = format!("/sites/{site_id}"); + let url = format!("{}{path}", self.base); + let resp = self + .client + .get(&url) + .send() + .await + .map_err(|err| api_failed("GET", &path, err))?; + match resp.status().as_u16() { + 200 => Ok(true), + 404 => Ok(false), + other => Err(api_failed("GET", &path, format!("status {other}"))), + } + } + + /// Best-effort site delete (Stripe is the authoritative deprovision). + pub async fn delete_site(&self, site_id: &str) -> Result<(), NetlifyError> { + self.send_json(Method::DELETE, &format!("/sites/{site_id}"), None) + .await + .map(|_| ()) + } + + /// Run the full file-digest deploy and return the live HTTPS URL: POST the + /// per-file SHA1 map, PUT each file Netlify reports as `required`, then poll + /// the deploy to `ready`. + pub async fn deploy( + &self, + site_id: &str, + files: &[UploadFile], + service: &str, + budget: Duration, + ) -> Result { + // Per-file SHA1, keyed by leading-slash path (the Netlify file map shape). + let mut digests = serde_json::Map::new(); + for file in files { + digests.insert( + format!("/{}", file.path), + Value::String(sha1_hex(&file.data)), + ); + } + let deploys_path = format!("/sites/{site_id}/deploys"); + let created = self + .send_json( + Method::POST, + &deploys_path, + Some(json!({ "files": Value::Object(digests) })), + ) + .await?; + let deploy_id = created + .get("id") + .and_then(Value::as_str) + .map(str::to_owned) + .ok_or_else(|| api_failed("POST", &deploys_path, "deploy create returned no id"))?; + let required: HashSet = created + .get("required") + .and_then(Value::as_array) + .map(|shas| { + shas.iter() + .filter_map(|s| s.as_str().map(str::to_owned)) + .collect() + }) + .unwrap_or_default(); + + // Upload each required digest once (Netlify dedups by SHA1). + let mut uploaded: HashSet = HashSet::new(); + for file in files { + let sha = sha1_hex(&file.data); + if required.contains(&sha) && uploaded.insert(sha) { + self.upload_file(&deploy_id, &file.path, &file.data).await?; + } + } + + self.wait_for_ready(&deploy_id, service, budget).await + } + + async fn upload_file( + &self, + deploy_id: &str, + rel_path: &str, + bytes: &[u8], + ) -> Result<(), NetlifyError> { + let path = format!("/deploys/{deploy_id}/files/{rel_path}"); + let url = format!("{}{path}", self.base); + let resp = self + .client + .put(&url) + .header(CONTENT_TYPE, "application/octet-stream") + .body(bytes.to_vec()) + .send() + .await + .map_err(|err| api_failed("PUT", &path, err))?; + let status = resp.status(); + if !status.is_success() { + let text = resp.text().await.unwrap_or_default(); + return Err(api_failed( + "PUT", + &path, + format!("status {}: {}", status.as_u16(), truncate(&text)), + )); + } + Ok(()) + } + + /// Poll the deploy until `ready`, returning its live HTTPS URL. + async fn wait_for_ready( + &self, + deploy_id: &str, + service: &str, + budget: Duration, + ) -> Result { + let path = format!("/deploys/{deploy_id}"); + let deadline = tokio::time::Instant::now() + budget; + loop { + let deploy = self.send_json(Method::GET, &path, None).await?; + let state = DeployState::from_api( + deploy + .get("state") + .and_then(Value::as_str) + .unwrap_or("unknown"), + ); + if state.is_ready() { + return Ok(deploy + .get("ssl_url") + .or_else(|| deploy.get("deploy_ssl_url")) + .and_then(Value::as_str) + .unwrap_or_default() + .to_owned()); + } + if state.is_failed() { + return Err(NetlifyError::DeployFailed { + service: service.to_owned(), + state: state.as_str().to_owned(), + }); + } + if tokio::time::Instant::now() >= deadline { + return Err(NetlifyError::DeployTimeout { + service: service.to_owned(), + budget_secs: budget.as_secs(), + last_state: state.as_str().to_owned(), + }); + } + tokio::time::sleep(self.poll_interval).await; + } + } +} + +fn site_info(value: &Value) -> Option { + let id = value.get("id").and_then(Value::as_str)?.to_owned(); + Some(SiteInfo { + id, + ssl_url: value + .get("ssl_url") + .or_else(|| value.get("url")) + .and_then(Value::as_str) + .map(str::to_owned), + }) +} + +/// A Netlify deploy state. Modeled as an enum so the polling logic is +/// exhaustive; `Unknown` preserves any state not in Netlify's documented set so +/// drift is visible instead of silently misclassified. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DeployState { + New, + PendingReview, + Accepted, + Rejected, + Enqueued, + Building, + Uploading, + Uploaded, + Preparing, + Prepared, + Processing, + Processed, + Ready, + Error, + Retrying, + Unknown(String), +} + +impl DeployState { + pub const CANONICAL: &'static [&'static str] = &[ + "new", + "pending_review", + "accepted", + "rejected", + "enqueued", + "building", + "uploading", + "uploaded", + "preparing", + "prepared", + "processing", + "processed", + "ready", + "error", + "retrying", + ]; + + pub fn from_api(state: &str) -> Self { + match state { + "new" => Self::New, + "pending_review" => Self::PendingReview, + "accepted" => Self::Accepted, + "rejected" => Self::Rejected, + "enqueued" => Self::Enqueued, + "building" => Self::Building, + "uploading" => Self::Uploading, + "uploaded" => Self::Uploaded, + "preparing" => Self::Preparing, + "prepared" => Self::Prepared, + "processing" => Self::Processing, + "processed" => Self::Processed, + "ready" => Self::Ready, + "error" => Self::Error, + "retrying" => Self::Retrying, + other => Self::Unknown(other.to_owned()), + } + } + + pub fn as_str(&self) -> &str { + match self { + Self::New => "new", + Self::PendingReview => "pending_review", + Self::Accepted => "accepted", + Self::Rejected => "rejected", + Self::Enqueued => "enqueued", + Self::Building => "building", + Self::Uploading => "uploading", + Self::Uploaded => "uploaded", + Self::Preparing => "preparing", + Self::Prepared => "prepared", + Self::Processing => "processing", + Self::Processed => "processed", + Self::Ready => "ready", + Self::Error => "error", + Self::Retrying => "retrying", + Self::Unknown(raw) => raw, + } + } + + pub fn is_ready(&self) -> bool { + matches!(self, Self::Ready) + } + + /// A terminal failure. A new Netlify state containing `error`/`reject` still + /// fails fast; a new in-progress state never false-fails. + pub fn is_failed(&self) -> bool { + match self { + Self::Error | Self::Rejected => true, + Self::Unknown(raw) => raw.contains("error") || raw.contains("reject"), + _ => false, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + #[test] + fn canonical_states_are_modeled() { + for state in DeployState::CANONICAL { + let parsed = DeployState::from_api(state); + assert!( + !matches!(parsed, DeployState::Unknown(_)), + "canonical Netlify state {state:?} fell through to Unknown", + ); + assert_eq!(parsed.as_str(), *state); + } + assert!(DeployState::from_api("ready").is_ready()); + assert!(DeployState::from_api("error").is_failed()); + assert!(DeployState::from_api("rejected").is_failed()); + assert!(!DeployState::from_api("processing").is_failed()); + assert_eq!(DeployState::from_api("warp").as_str(), "warp"); + } + + #[test] + fn sha1_matches_known_vector() { + // SHA1("abc") = a9993e364706816aba3e25717850c26c9cd0d89d + assert_eq!(sha1_hex(b"abc"), "a9993e364706816aba3e25717850c26c9cd0d89d"); + } + + #[tokio::test] + async fn deploy_uploads_only_required_then_polls_ready() { + let server = MockServer::start().await; + let sha = sha1_hex(b"ok"); + // create deploy → reports our one file as required + Mock::given(method("POST")) + .and(path("/sites/site_1/deploys")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": "dep_1", + "state": "uploading", + "required": [sha], + }))) + .mount(&server) + .await; + // upload the required file + Mock::given(method("PUT")) + .and(path("/deploys/dep_1/files/index.html")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "id": "f1" }))) + .mount(&server) + .await; + // poll → ready + Mock::given(method("GET")) + .and(path("/deploys/dep_1")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "state": "ready", + "ssl_url": "https://site-1.netlify.app", + }))) + .mount(&server) + .await; + + let api = + NetlifyApi::with_base("tok", server.uri()).with_poll_interval(Duration::from_millis(1)); + let files = vec![UploadFile { + path: "index.html".into(), + data: b"ok".to_vec(), + }]; + let url = api + .deploy("site_1", &files, "web", Duration::from_secs(5)) + .await + .unwrap(); + assert_eq!(url, "https://site-1.netlify.app"); + } + + #[tokio::test] + async fn deploy_fails_fast_on_error_state() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/sites/site_1/deploys")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(json!({ "id": "dep_1", "state": "new", "required": [] })), + ) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/deploys/dep_1")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "state": "error" }))) + .mount(&server) + .await; + let api = + NetlifyApi::with_base("tok", server.uri()).with_poll_interval(Duration::from_millis(1)); + let err = api + .deploy("site_1", &[], "web", Duration::from_secs(5)) + .await + .unwrap_err(); + assert!(matches!(err, NetlifyError::DeployFailed { .. })); + } +} diff --git a/crates/stackless-netlify/src/prepare.rs b/crates/stackless-netlify/src/prepare.rs new file mode 100644 index 0000000..a30c0ee --- /dev/null +++ b/crates/stackless-netlify/src/prepare.rs @@ -0,0 +1,22 @@ +//! Operator-side cloud prepare (§4): shallow git checkout on the operator's +//! machine. The mechanics are shared (`stackless_cloud::prepare`); this maps the +//! neutral failure to Netlify's error so its `netlify.*` code/remediation hold. + +use crate::error::NetlifyError; + +pub fn run_prepare_command( + service: &str, + repo: &str, + reference: &str, + command: &str, + env: &[(String, String)], +) -> Result<(), NetlifyError> { + stackless_cloud::prepare::run_prepare_command(service, repo, reference, command, env).map_err( + |f| NetlifyError::PrepareFailed { + service: f.service, + command: f.command, + message: f.message, + log_tail: f.log_tail, + }, + ) +} diff --git a/crates/stackless/Cargo.toml b/crates/stackless/Cargo.toml index 7343cf4..22f26c7 100644 --- a/crates/stackless/Cargo.toml +++ b/crates/stackless/Cargo.toml @@ -19,6 +19,8 @@ stackless-integrations.workspace = true stackless-local.workspace = true stackless-render.workspace = true stackless-vercel.workspace = true +stackless-fly.workspace = true +stackless-netlify.workspace = true thiserror = "2.0.18" tokio = { version = "1.52.3", features = ["rt-multi-thread"] } diff --git a/crates/stackless/src/error.rs b/crates/stackless/src/error.rs index 46ff747..63a6a5d 100644 --- a/crates/stackless/src/error.rs +++ b/crates/stackless/src/error.rs @@ -113,7 +113,7 @@ impl Fault for CliError { } Self::SubstrateRequired { name } => format!( "pass a substrate at creation: `stackless up --name {name} --on local`, \ - `--on render`, or `--on vercel`" + `--on render`, `--on vercel`, `--on fly`, or `--on netlify`" ), Self::SecretsUnresolved { missing, .. } => format!( "add {missing:?} to the {} file next to stackless.toml (KEY=value lines), or \ diff --git a/crates/stackless/src/main.rs b/crates/stackless/src/main.rs index ec192ab..2d00359 100644 --- a/crates/stackless/src/main.rs +++ b/crates/stackless/src/main.rs @@ -41,7 +41,7 @@ enum Command { /// instance's snapshot on resume). #[arg(long)] file: Option, - /// Substrate, required at creation (`local`, `render`, or `vercel`); ignored on resume. + /// Substrate, required at creation (`local`, `render`, `vercel`, `fly`, or `netlify`); ignored on resume. #[arg(long = "on", value_name = "SUBSTRATE")] on: Option, /// Pin a service to a checkout: SERVICE or SERVICE=PATH (PATH diff --git a/crates/stackless/src/substrates.rs b/crates/stackless/src/substrates.rs index 113b9fb..5f52157 100644 --- a/crates/stackless/src/substrates.rs +++ b/crates/stackless/src/substrates.rs @@ -3,7 +3,9 @@ //! only seam). Adding a hosting provider is one row here plus its own crate. use stackless_core::substrate::Substrate; +use stackless_fly::{FlySubstrate, SUBSTRATE_NAME as FLY}; use stackless_local::{LocalSubstrate, SUBSTRATE_NAME as LOCAL}; +use stackless_netlify::{NetlifySubstrate, SUBSTRATE_NAME as NETLIFY}; use stackless_render::{RenderSubstrate, SUBSTRATE_NAME as RENDER}; use stackless_vercel::{SUBSTRATE_NAME as VERCEL, VercelSubstrate}; @@ -30,6 +32,14 @@ static SUBSTRATES: &[SubstrateInfo] = &[ name: VERCEL, build: build_vercel, }, + SubstrateInfo { + name: FLY, + build: build_fly, + }, + SubstrateInfo { + name: NETLIFY, + build: build_netlify, + }, ]; /// Every substrate name the binary can dispatch to. @@ -86,6 +96,22 @@ fn build_vercel(ctx: SubstrateCtx) -> Result, CliError> { ))) } +fn build_fly(ctx: SubstrateCtx) -> Result, CliError> { + Ok(Box::new(FlySubstrate::new( + ctx.definition_dir, + ctx.secrets, + ctx.confirm_paid, + ))) +} + +fn build_netlify(ctx: SubstrateCtx) -> Result, CliError> { + Ok(Box::new(NetlifySubstrate::new( + ctx.definition_dir, + ctx.secrets, + ctx.confirm_paid, + ))) +} + #[cfg(test)] mod tests { use std::collections::BTreeSet; @@ -99,6 +125,8 @@ mod tests { all.extend(stackless_core::fault::codes::ALL); all.extend(stackless_render::codes::ALL); all.extend(stackless_vercel::codes::ALL); + all.extend(stackless_fly::codes::ALL); + all.extend(stackless_netlify::codes::ALL); let unique: BTreeSet<&str> = all.iter().copied().collect(); assert_eq!( unique.len(), diff --git a/docs/ADDING-A-PROVIDER.md b/docs/ADDING-A-PROVIDER.md index 444479a..aac909d 100644 --- a/docs/ADDING-A-PROVIDER.md +++ b/docs/ADDING-A-PROVIDER.md @@ -24,8 +24,43 @@ mise run discover -- --dir fixtures/smoke/cloudflare # provision o ``` `catalog` is offline (reads the committed catalog fixture). `discover` is live -(needs `STRIPE_API_KEY` + a linked project) — it pins the credential **output -envelope**, which the catalog does *not* describe. Both are the `xtask` crate. +(needs a linked Stripe project + the provider linked — see next section) — it pins +the credential **output envelope**, which the catalog does *not* describe. Both are +the `xtask` crate. + +## One-time setup: initialize + link the provider (do this before `discover`/smoke) + +`discover` and the live smoke both drive the `stripe projects` plugin, which only +operates **inside an initialized Stripe-project directory** (one that has a +`.projects/` dir). Each smoke fixture is its own such directory. So before the +first `discover` or smoke of a **new** provider, do this once, from that fixture +dir: + +```sh +cd fixtures/smoke/ +# Creates .projects/ — exactly what `stackless up` runs via ensure_project. +stripe projects init --skip-skills --accept-tos +# Interactive provider OAuth — must be a human; opens a browser/device flow. +stripe projects link +``` + +- **The `link` command needs a project context**, which is why `init` comes first. + Running any `stripe projects` verb from a *non*-initialized dir just prints the + "Get started by running `stripe projects init`" welcome and does nothing — that + is the symptom of skipping `init`. +- **Provider links are account-level**, not per-project: once linked, the provider + shows on *every* project (`stripe projects status` lists them, e.g. + `Providers ✓ Cloudflare, ✓ Vercel, ✓ Render`). So you only link each provider + once per Stripe account, ever — but from inside *some* initialized project dir. +- The `` slug is the lowercased catalog provider name (`cloudflare`, + `flyio`, `render`, `vercel`, …). Re-link a stale provider + (`PENDING_AUTH`/`EXPIRED`) with `stripe projects link --force `. +- Paid providers also need a billing method on the account + (`stripe projects billing add`); confirm the account with `stripe projects status`. +- `stackless up` runs `init` for you on first deploy and records the project id + into the fixture's `[stack.projects.stripe].project`; the `.projects/` runtime + state is gitignored. So the genuinely manual, can't-automate step is just the + one-time `link`. ## Catalog integration (the common case) @@ -90,8 +125,10 @@ A live smoke runs through the shared `fixtures/smoke/run.sh` (always tears down, unique per-run instance name). Add `fixtures/smoke//stackless.toml`, a `smoke-` mise task calling `run.sh`, and a `smoke.yml` matrix entry. Catalog integrations have no deploy target, so they smoke under `--on local` -with a trivial probe service (see `fixtures/smoke/cloudflare`). One-time human -step: `stripe projects link ` (account-level). +with a trivial probe service (see `fixtures/smoke/cloudflare`). Before a new +provider's first smoke, do the one-time `stripe projects init` + `link` from its +fixture dir (see "One-time setup" above) — the smoke fails at the first +`stripe projects add` otherwise. --- diff --git a/docs/SCHEMA.md b/docs/SCHEMA.md index 66a91cf..c31387c 100644 --- a/docs/SCHEMA.md +++ b/docs/SCHEMA.md @@ -3,8 +3,8 @@ This document is sufficient on its own to write a valid stack definition. It describes what the implementation actually enforces; every rule here is checked by `stackless check ` (add -`--on local` / `--on render` / `--on vercel` for substrate-specific -completeness) +`--on local` / `--on render` / `--on vercel` / `--on fly` / `--on netlify` +for substrate-specific completeness) before anything provisions. Validation failures carry stable machine-readable codes (listed throughout) plus a remediation. @@ -93,6 +93,9 @@ region = "oregon" [stack.vercel] # optional: per-substrate stack config plan = "hobby" # hobby (default) or pro (paid → requires --confirm-paid) + +[stack.fly] # optional: per-substrate stack config +region = "iad" # Fly region (default iad) ``` - `name` — required. @@ -108,13 +111,15 @@ plan = "hobby" # hobby (default) or pro (paid → requires --confirm-p cloud resources (Render, Vercel, Clerk, …) share this anchor; re-link a fresh checkout with `stripe projects pull `. - Any other key under `[stack]` must be the name of a registered - substrate (`local`, `render`, `vercel`) and must be a table + substrate (`local`, `render`, `vercel`, `fly`, `netlify`) and must be a table (`def.validate.unknown_key`, `def.validate.substrate_block_invalid`). - `[stack.render]` — optional. `region` defaults to `"oregon"` (Render substrate only). - `[stack.vercel]` — optional. `plan` defaults to `"hobby"`. `"pro"` provisions the Vercel Pro plan through Stripe Projects and requires `--confirm-paid` (`vercel.payment.not_confirmed`). +- `[stack.fly]` — optional. `region` defaults to `"iad"` (Fly substrate + only). ## `[secrets]` @@ -342,6 +347,70 @@ Or a static site: - `stackless logs` is not wired for Vercel in v0 — use the Vercel dashboard. +### `[services..fly]` — how Fly runs it + +Fly v0 is **image-only**: a service declares a prebuilt container image +and the substrate runs it as a Fly machine. + +```toml + [services.web.fly] + image = "ghcr.io/org/web:latest" # required: prebuilt container image + internal_port = 8080 # optional, the port the container listens on (default 8080) + cmd = ["server", "--port", "8080"] # optional: override the image CMD (container args) + cpu_kind = "shared" # optional machine preset (default shared) + cpus = 1 # optional (default 1) + memory_mb = 256 # optional (default 256) + env = { API_ORIGIN = "${services.api.origin}" } # optional overlay +``` + +- Cloud resource names are `{stack}-{instance}-{service}` — also the Fly + app name, so it must be a legal app name (`^[a-z][a-z0-9-]{2,62}$`); + origins are `https://{stack}-{instance}-{service}.fly.dev`. +- `up --on fly` requires every service to carry a `fly` block + (`fly.config.invalid`), refuses `--source` pins + (`engine.source_override.unsupported`), and requires `--confirm-paid` + (`fly.payment.not_confirmed` — `flyio/app` is usage-billed). +- Datastores are not supported on Fly in v0 (`up --on fly` with any + `[datastores.*]` block is rejected); `flyio/mpg` (managed Postgres) is + a separate catalog integration. +- Setup is skipped on cloud (same as Render); prepare runs on the + operator's machine from a shallow `git clone` of the pinned ref. +- **Two layers (same as Render):** Stripe Projects provisions the + `flyio/app` catalog resource and returns a Stripe-managed, app-scoped + deploy token; the Fly Machines REST API uses that token to allocate the + app's public IPs, create the machine, and poll it to `started`. No + operator API token is needed — the credential comes from provisioning, + and `observe`/`down` use the Stripe resource registration. +- `stackless logs` is not wired for Fly in v0 — use the Fly dashboard. + +### `[services..netlify]` — how Netlify runs it + +Netlify v0 is **static upload**: the substrate clones the pinned ref and +uploads the files under `root` (or the repo root) as a Netlify deploy. The +block is optional. + +```toml + [services.web.netlify] + root = "dist" # optional: subdir to publish (default: repo root) +``` + +- Cloud resource names are `{stack}-{instance}-{service}` — also the Netlify + site name; origins are `https://{stack}-{instance}-{service}.netlify.app` + (the real URL is recorded from the deploy's `ssl_url`). +- `up --on netlify` refuses `--source` pins + (`engine.source_override.unsupported`). `netlify/project` is **free**, so no + `--confirm-paid` is required. +- Datastores are not supported on Netlify in v0 (`up --on netlify` with any + `[datastores.*]` block is rejected). +- Setup is skipped on cloud; prepare runs on the operator's machine from a + shallow `git clone` of the pinned ref. +- **Two layers (same as Render):** Stripe Projects provisions the + `netlify/project` catalog resource and returns a Stripe-managed token + site + id; the Netlify REST API runs the file-digest deploy (SHA1 per file, PUT only + the files Netlify still needs) and polls to `ready`. No operator API token is + needed; `observe`/`down` use the Stripe resource registration. +- `stackless logs` is not wired for Netlify in v0 — use the Netlify dashboard. + ## The interpolation namespace Env values (common `env`, substrate `env` overlays, and @@ -351,7 +420,7 @@ Env values (common `env`, substrate `env` overlays, and |---|---| | `${stack.name}` | the stack's declared name | | `${instance.name}` | the instance's name — the one identity everything derives from | -| `${services.X.origin}` | service X's substrate-appropriate origin. Local: `http://x.{instance}.localhost:4444` (the root-origin service resolves to `http://{instance}.localhost:4444` — what browsers actually use). Render: `https://{stack}-{instance}-x.onrender.com`. Vercel: the deployment URL after `start` (best-effort `https://{stack}-{instance}-x.vercel.app` before deploy) | +| `${services.X.origin}` | service X's substrate-appropriate origin. Local: `http://x.{instance}.localhost:4444` (the root-origin service resolves to `http://{instance}.localhost:4444` — what browsers actually use). Render: `https://{stack}-{instance}-x.onrender.com`. Vercel: the deployment URL after `start` (best-effort `https://{stack}-{instance}-x.vercel.app` before deploy). Fly: `https://{stack}-{instance}-x.fly.dev`. Netlify: the deploy's `ssl_url` recorded at `start` (`https://{stack}-{instance}-x.netlify.app`) | | `${datastores.X.url}` | X's connection string. Local: `postgres://...@127.0.0.1:{mapped-port}/postgres`. Render: the internal URL for services, the external one for `prepare` hooks | | `${secrets.KEY}` | the resolved secret value (KEY must be in `[secrets].required`) — for renaming; the `secrets = [...]` list already injects same-named vars | | `${integrations.clerk.secret_key}` | the Clerk secret key selected from Stripe Projects' Clerk environments JSON (`CLERK_AUTH_ENVIRONMENTS` or `CLERK_ENVIRONMENTS`) | diff --git a/fixtures/smoke/flyio/.gitignore b/fixtures/smoke/flyio/.gitignore new file mode 100644 index 0000000..ac460cf --- /dev/null +++ b/fixtures/smoke/flyio/.gitignore @@ -0,0 +1,7 @@ +.projects/cache +.projects/vault +.projects/state.test.json +.projects/state.local.test.json +.env +.env.* +!.env.example diff --git a/fixtures/smoke/flyio/stackless.toml b/fixtures/smoke/flyio/stackless.toml new file mode 100644 index 0000000..ef7eb36 --- /dev/null +++ b/fixtures/smoke/flyio/stackless.toml @@ -0,0 +1,32 @@ +# Live smoke stack: deploys a public `http-echo` image to Fly.io that serves the +# health probe string. Fly v0 is image-only (no build-from-source), so unlike the +# render/vercel smokes this does not deploy fixtures/smoke/site — it runs a +# prebuilt image whose response body is the probe. +# +# Run: stackless up --on fly --file fixtures/smoke/flyio/stackless.toml --name +# One-time human setup (from THIS dir — see docs/ADDING-A-PROVIDER.md "One-time setup"): +# cd fixtures/smoke/flyio +# stripe projects init smoke-fly --skip-skills --accept-tos # creates .projects/ +# stripe projects link flyio # interactive Fly OAuth (account-level) +# The project anchor ([stack.projects.stripe].project) is recorded here on first up. +[stack] +name = "smoke-fly" + +[stack.fly] +region = "iad" + +[stack.projects] +[stack.projects.stripe] +project = "project_61Ushn8q0y2ueHMwV16UhtGl9aA8OIk3xIw9EFWsKEK0" + +# A trivial always-on service: http-echo serves its `-text` on every path, so the +# health probe at `/` returns `stackless-smoke-ok`. +[services.web] +source = { repo = "https://github.com/snowmead/stackless", ref = "main" } +root_origin = true +health = { path = "/", contains = "stackless-smoke-ok" } + +[services.web.fly] +image = "hashicorp/http-echo" +internal_port = 5678 +cmd = ["-text=stackless-smoke-ok", "-listen=:5678"] diff --git a/fixtures/smoke/netlify/.gitignore b/fixtures/smoke/netlify/.gitignore new file mode 100644 index 0000000..ac460cf --- /dev/null +++ b/fixtures/smoke/netlify/.gitignore @@ -0,0 +1,7 @@ +.projects/cache +.projects/vault +.projects/state.test.json +.projects/state.local.test.json +.env +.env.* +!.env.example diff --git a/fixtures/smoke/netlify/stackless.toml b/fixtures/smoke/netlify/stackless.toml new file mode 100644 index 0000000..eaefa67 --- /dev/null +++ b/fixtures/smoke/netlify/stackless.toml @@ -0,0 +1,23 @@ +# Live smoke stack: deploys fixtures/smoke/site to Netlify via the file-digest +# upload API (clone the ref, upload the static files, poll to `ready`). +# +# Run: stackless up --on netlify --file fixtures/smoke/netlify/stackless.toml --name +# One-time human setup (from THIS dir — see docs/ADDING-A-PROVIDER.md "One-time setup"): +# cd fixtures/smoke/netlify +# stripe projects init smoke-netlify --skip-skills --accept-tos # creates .projects/ +# stripe projects link netlify # interactive Netlify OAuth (free tier) +# The project anchor ([stack.projects.stripe].project) is recorded here on first up. +[stack] +name = "smoke-netlify" + +[stack.projects] +[stack.projects.stripe] +project = "project_61UsjMitlMsBzz16g16UhtGl9aA8OIk3xIw9EFWsKVTU" + +[services.web] +source = { repo = "https://github.com/snowmead/stackless", ref = "main" } +root_origin = true +health = { path = "/", contains = "stackless-smoke-ok" } + +[services.web.netlify] +root = "fixtures/smoke/site" diff --git a/fixtures/smoke/run.sh b/fixtures/smoke/run.sh index 172f014..225146f 100644 --- a/fixtures/smoke/run.sh +++ b/fixtures/smoke/run.sh @@ -5,15 +5,21 @@ # (2) a UNIQUE per-run instance name, so a re-run never collides with a resource # that lingered provider-side after a prior teardown. # -# Usage: run.sh -# substrate --on target (vercel | render | local) -# fixture path to the smoke stackless.toml -# name-prefix short instance-name prefix (e.g. smoke-v) +# Usage: run.sh [extra-up-flags...] +# substrate --on target (vercel | render | local | fly) +# fixture path to the smoke stackless.toml +# name-prefix short instance-name prefix (e.g. smoke-v) +# extra-up-flags optional flags appended to `up` (e.g. `--confirm-paid` for +# fly, whose flyio/app is usage-billed; bounded by the cap) set -u substrate="$1" fixture="$2" prefix="$3" +shift 3 +# Optional extra `up` flags (e.g. --confirm-paid). Unquoted on use so a flag +# word-splits; empty when absent (bash 3.2 + set -u safe, unlike an empty array). +extra="$*" # Local env file for creds (CI injects them as env vars instead). [ -f .stackless.env ] && { set -a; . ./.stackless.env; set +a; } @@ -21,7 +27,8 @@ prefix="$3" inst="${prefix}-$(date +%s)" up=0 -cargo run -q -p stackless -- up --name "$inst" --on "$substrate" --file "$fixture" || up=$? +# shellcheck disable=SC2086 # $extra is intentionally word-split +cargo run -q -p stackless -- up --name "$inst" --on "$substrate" --file "$fixture" $extra || up=$? # Teardown always runs; verified-gone is part of `down`. Exit non-zero if either # the up (health gate) or the down (teardown) failed. diff --git a/mise.toml b/mise.toml index 67ad250..4aed96d 100644 --- a/mise.toml +++ b/mise.toml @@ -34,10 +34,18 @@ ci = { depends = ["check", "test", "supply-chain"] } # unique per-run instance name; the task fails if up (health) or down (verified- # gone) fails. Cloudflare is a catalog integration, so it provisions R2+KV under # `--on local` (needs `stripe projects link cloudflare`, account-level). -smoke-vercel = "bash fixtures/smoke/run.sh vercel fixtures/smoke/vercel/stackless.toml smoke-v" -smoke-render = "bash fixtures/smoke/run.sh render fixtures/smoke/render/stackless.toml smoke-r" +smoke-vercel = "bash fixtures/smoke/run.sh vercel fixtures/smoke/vercel/stackless.toml smoke-v" +smoke-render = "bash fixtures/smoke/run.sh render fixtures/smoke/render/stackless.toml smoke-r" smoke-cloudflare = "bash fixtures/smoke/run.sh local fixtures/smoke/cloudflare/stackless.toml smoke-c" -smoke = { depends = ["smoke-vercel", "smoke-render", "smoke-cloudflare"] } +smoke-fly = "bash fixtures/smoke/run.sh fly fixtures/smoke/flyio/stackless.toml smoke-f --confirm-paid" +smoke-netlify = "bash fixtures/smoke/run.sh netlify fixtures/smoke/netlify/stackless.toml smoke-n" +smoke = { depends = [ + "smoke-vercel", + "smoke-render", + "smoke-cloudflare", + "smoke-fly", + "smoke-netlify", +] } # Stripe Projects plugin snapshots. `stripe-refresh` is the bless path (writes # the committed fixtures from the locally installed plugin); it needs the real diff --git a/specs/flyio-openapi.json b/specs/flyio-openapi.json new file mode 100644 index 0000000..06da909 --- /dev/null +++ b/specs/flyio-openapi.json @@ -0,0 +1,6239 @@ +{ + "schemes": ["https"], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "swagger": "2.0", + "info": { + "description": "This site hosts documentation generated from the Fly.io Machines API OpenAPI specification. Visit our complete [Machines API docs](https://fly.io/docs/machines/api/) for how to get started, more information about each endpoint, parameter descriptions, and examples.", + "title": "Machines API", + "contact": {}, + "license": { + "name": "Apache 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0.html" + }, + "version": "1.0" + }, + "host": "api.machines.dev", + "basePath": "/v1", + "paths": { + "/apps": { + "get": { + "description": "List all apps with the ability to filter by organization slug.\n", + "tags": [ + "Apps" + ], + "summary": "List Apps", + "operationId": "Apps_list", + "parameters": [ + { + "type": "string", + "description": "The org slug, or 'personal', to filter apps", + "name": "org_slug", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "Filter apps by role", + "name": "app_role", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/ListAppsResponse" + } + } + } + }, + "post": { + "description": "Create an app with the specified details in the request body.\n", + "tags": [ + "Apps" + ], + "summary": "Create App", + "operationId": "Apps_create", + "parameters": [ + { + "description": "App body", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/CreateAppRequest" + } + } + ], + "responses": { + "201": { + "description": "Created" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + } + } + }, + "/apps/{app_name}": { + "get": { + "description": "Retrieve details about a specific app by its name.\n", + "tags": [ + "Apps" + ], + "summary": "Get App", + "operationId": "Apps_show", + "parameters": [ + { + "type": "string", + "description": "Fly App Name", + "name": "app_name", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/App" + } + } + } + }, + "delete": { + "description": "Delete an app by its name.\n", + "tags": [ + "Apps" + ], + "summary": "Destroy App", + "operationId": "Apps_delete", + "parameters": [ + { + "type": "string", + "description": "Fly App Name", + "name": "app_name", + "in": "path", + "required": true + } + ], + "responses": { + "202": { + "description": "Accepted" + } + } + } + }, + "/apps/{app_name}/certificates": { + "get": { + "tags": [ + "TLS Certificates" + ], + "summary": "List certificates for app", + "operationId": "App_Certificates_list", + "parameters": [ + { + "type": "string", + "description": "Fly App Name", + "name": "app_name", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Hostname filter (substring match)", + "name": "filter", + "in": "query" + }, + { + "type": "string", + "description": "Pagination cursor from previous response", + "name": "cursor", + "in": "query" + }, + { + "type": "integer", + "description": "Number of results per page (default 25, max 500)", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/listCertificatesResponse" + } + } + } + } + }, + "/apps/{app_name}/certificates/acme": { + "post": { + "tags": [ + "TLS Certificates" + ], + "summary": "Request ACME certificate", + "operationId": "App_Certificates_acme_create", + "parameters": [ + { + "type": "string", + "description": "Fly App Name", + "name": "app_name", + "in": "path", + "required": true + }, + { + "description": "ACME certificate request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/createAcmeCertificateRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/CertificateDetail" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + } + } + }, + "/apps/{app_name}/certificates/custom": { + "post": { + "tags": [ + "TLS Certificates" + ], + "summary": "Upload custom certificate", + "operationId": "App_Certificates_custom_create", + "parameters": [ + { + "type": "string", + "description": "Fly App Name", + "name": "app_name", + "in": "path", + "required": true + }, + { + "description": "Custom certificate request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/createCustomCertificateRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/CertificateDetail" + } + }, + "422": { + "description": "Unprocessable Entity", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + } + } + }, + "/apps/{app_name}/certificates/{hostname}": { + "get": { + "tags": [ + "TLS Certificates" + ], + "summary": "Get certificate details", + "operationId": "App_Certificates_show", + "parameters": [ + { + "type": "string", + "description": "Fly App Name", + "name": "app_name", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Certificate Hostname", + "name": "hostname", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/CertificateDetail" + } + } + } + }, + "delete": { + "tags": [ + "TLS Certificates" + ], + "summary": "Remove certificate", + "operationId": "App_Certificates_delete", + "parameters": [ + { + "type": "string", + "description": "Fly App Name", + "name": "app_name", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Certificate Hostname", + "name": "hostname", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + } + } + } + }, + "/apps/{app_name}/certificates/{hostname}/acme": { + "delete": { + "tags": [ + "TLS Certificates" + ], + "summary": "Remove ACME certificates", + "operationId": "App_Certificates_acme_delete", + "parameters": [ + { + "type": "string", + "description": "Fly App Name", + "name": "app_name", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Certificate Hostname", + "name": "hostname", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/CertificateDetail" + } + } + } + } + }, + "/apps/{app_name}/certificates/{hostname}/check": { + "post": { + "tags": [ + "TLS Certificates" + ], + "summary": "Check DNS and re-validate certificate", + "operationId": "App_Certificates_check", + "parameters": [ + { + "type": "string", + "description": "Fly App Name", + "name": "app_name", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Certificate Hostname", + "name": "hostname", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/CertificateCheckResponse" + } + } + } + } + }, + "/apps/{app_name}/certificates/{hostname}/custom": { + "delete": { + "tags": [ + "TLS Certificates" + ], + "summary": "Remove custom certificate", + "operationId": "App_Certificates_custom_delete", + "parameters": [ + { + "type": "string", + "description": "Fly App Name", + "name": "app_name", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Certificate Hostname", + "name": "hostname", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/destroyCustomCertificateResponse" + } + } + } + } + }, + "/apps/{app_name}/deploy_token": { + "post": { + "tags": [ + "Apps" + ], + "summary": "Create App deploy token", + "operationId": "App_create_deploy_token", + "parameters": [ + { + "type": "string", + "description": "Fly App Name", + "name": "app_name", + "in": "path", + "required": true + }, + { + "description": "Request body", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/CreateAppDeployTokenRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/CreateAppResponse" + } + } + } + } + }, + "/apps/{app_name}/ip_assignments": { + "get": { + "tags": [ + "Apps" + ], + "summary": "List IP assignments for app", + "operationId": "App_IPAssignments_list", + "parameters": [ + { + "type": "string", + "description": "Fly App Name", + "name": "app_name", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/listIPAssignmentsResponse" + } + } + } + }, + "post": { + "tags": [ + "Apps" + ], + "summary": "Assign new IP address to app", + "operationId": "App_IPAssignments_create", + "parameters": [ + { + "type": "string", + "description": "Fly App Name", + "name": "app_name", + "in": "path", + "required": true + }, + { + "description": "Assign IP request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/assignIPRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/IPAssignment" + } + } + } + } + }, + "/apps/{app_name}/ip_assignments/{ip}": { + "delete": { + "tags": [ + "Apps" + ], + "summary": "Remove IP assignment from app", + "operationId": "App_IPAssignments_delete", + "parameters": [ + { + "type": "string", + "description": "Fly App Name", + "name": "app_name", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "IP address", + "name": "ip", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + } + } + } + }, + "/apps/{app_name}/machines": { + "get": { + "description": "List all Machines associated with a specific app, with optional filters for including deleted Machines and filtering by region.\n", + "tags": [ + "Machines" + ], + "summary": "List Machines", + "operationId": "Machines_list", + "parameters": [ + { + "type": "string", + "description": "Fly App Name", + "name": "app_name", + "in": "path", + "required": true + }, + { + "type": "boolean", + "description": "Include deleted machines", + "name": "include_deleted", + "in": "query" + }, + { + "type": "string", + "description": "Region filter", + "name": "region", + "in": "query" + }, + { + "type": "string", + "description": "comma separated list of states to filter (created, started, stopped, suspended)", + "name": "state", + "in": "query" + }, + { + "type": "boolean", + "description": "Only return summary info about machines (omit config, checks, events, host_status, nonce, etc.)", + "name": "summary", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/Machine" + } + } + } + } + }, + "post": { + "description": "Create a Machine within a specific app using the details provided in the request body.\n\n**Important**: This request can fail, and you’re responsible for handling that failure. If you ask for a large Machine, or a Machine in a region we happen to be at capacity for, you might need to retry the request, or to fall back to another region. If you’re working directly with the Machines API, you’re taking some responsibility for your own orchestration!\n", + "tags": [ + "Machines" + ], + "summary": "Create Machine", + "operationId": "Machines_create", + "parameters": [ + { + "type": "string", + "description": "Fly App Name", + "name": "app_name", + "in": "path", + "required": true + }, + { + "description": "Create machine request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/CreateMachineRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/Machine" + } + } + } + } + }, + "/apps/{app_name}/machines/{machine_id}": { + "get": { + "description": "Get details of a specific Machine within an app by the Machine ID.\n", + "tags": [ + "Machines" + ], + "summary": "Get Machine", + "operationId": "Machines_show", + "parameters": [ + { + "type": "string", + "description": "Fly App Name", + "name": "app_name", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Machine ID", + "name": "machine_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/Machine" + } + } + } + }, + "post": { + "description": "Update a Machine's configuration using the details provided in the request body.\n", + "tags": [ + "Machines" + ], + "summary": "Update Machine", + "operationId": "Machines_update", + "parameters": [ + { + "type": "string", + "description": "Fly App Name", + "name": "app_name", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Machine ID", + "name": "machine_id", + "in": "path", + "required": true + }, + { + "description": "Request body", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/UpdateMachineRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/Machine" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + } + }, + "delete": { + "description": "Delete a specific Machine within an app by Machine ID, with an optional force parameter to force kill the Machine if it's running.\n", + "tags": [ + "Machines" + ], + "summary": "Destroy Machine", + "operationId": "Machines_delete", + "parameters": [ + { + "type": "string", + "description": "Fly App Name", + "name": "app_name", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Machine ID", + "name": "machine_id", + "in": "path", + "required": true + }, + { + "type": "boolean", + "description": "Force kill the machine if it's running", + "name": "force", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/apps/{app_name}/machines/{machine_id}/cordon": { + "post": { + "description": "“Cordoning” a Machine refers to disabling its services, so the Fly Proxy won’t route requests to it. In flyctl this is used by blue/green deployments; one set of Machines is started up with services disabled, and when they are all healthy, the services are enabled on the new Machines and disabled on the old ones.\n", + "tags": [ + "Machines" + ], + "summary": "Cordon Machine", + "operationId": "Machines_cordon", + "parameters": [ + { + "type": "string", + "description": "Fly App Name", + "name": "app_name", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Machine ID", + "name": "machine_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/apps/{app_name}/machines/{machine_id}/events": { + "get": { + "description": "List all events associated with a specific Machine within an app.\n", + "tags": [ + "Machines" + ], + "summary": "List Events", + "operationId": "Machines_list_events", + "parameters": [ + { + "type": "string", + "description": "Fly App Name", + "name": "app_name", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Machine ID", + "name": "machine_id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "The number of events to fetch (max of 50). If omitted, this is set to 20 by default.", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/MachineEvent" + } + } + } + } + } + }, + "/apps/{app_name}/machines/{machine_id}/exec": { + "post": { + "description": "Execute a command on a specific Machine and return the raw command output bytes.\n", + "produces": [ + "application/octet-stream", + "application/json" + ], + "tags": [ + "Machines" + ], + "summary": "Execute Command", + "operationId": "Machines_exec", + "parameters": [ + { + "type": "string", + "description": "Fly App Name", + "name": "app_name", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Machine ID", + "name": "machine_id", + "in": "path", + "required": true + }, + { + "description": "Request body", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/MachineExecRequest" + } + } + ], + "responses": { + "200": { + "description": "stdout, stderr, exit code, and exit signal are returned", + "schema": { + "$ref": "#/definitions/flydv1.ExecResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + } + } + }, + "/apps/{app_name}/machines/{machine_id}/lease": { + "get": { + "description": "Retrieve the current lease of a specific Machine within an app. Machine leases can be used to obtain an exclusive lock on modifying a Machine.\n", + "tags": [ + "Machines" + ], + "summary": "Get Lease", + "operationId": "Machines_show_lease", + "parameters": [ + { + "type": "string", + "description": "Fly App Name", + "name": "app_name", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Machine ID", + "name": "machine_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/Lease" + } + } + } + }, + "post": { + "description": "Create a lease for a specific Machine within an app using the details provided in the request body. Machine leases can be used to obtain an exclusive lock on modifying a Machine.\n", + "tags": [ + "Machines" + ], + "summary": "Create Lease", + "operationId": "Machines_create_lease", + "parameters": [ + { + "type": "string", + "description": "Fly App Name", + "name": "app_name", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Machine ID", + "name": "machine_id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Existing lease nonce to refresh by ttl, empty or non-existent to create a new lease", + "name": "fly-machine-lease-nonce", + "in": "header" + }, + { + "description": "Request body", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/CreateLeaseRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/Lease" + } + } + } + }, + "delete": { + "description": "Release the lease of a specific Machine within an app. Machine leases can be used to obtain an exclusive lock on modifying a Machine.\n", + "tags": [ + "Machines" + ], + "summary": "Release Lease", + "operationId": "Machines_release_lease", + "parameters": [ + { + "type": "string", + "description": "Fly App Name", + "name": "app_name", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Machine ID", + "name": "machine_id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Existing lease nonce", + "name": "fly-machine-lease-nonce", + "in": "header", + "required": true + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/apps/{app_name}/machines/{machine_id}/memory": { + "get": { + "description": "Get current memory limit and available capacity for a machine", + "tags": [ + "Machines" + ], + "summary": "Get Machine Memory", + "operationId": "Machines_get_memory", + "parameters": [ + { + "type": "string", + "description": "Fly App Name", + "name": "app_name", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Machine ID", + "name": "machine_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/main.memoryResponse" + } + } + } + }, + "put": { + "description": "Set the memory limit for a machine using the balloon device", + "tags": [ + "Machines" + ], + "summary": "Set Machine Memory Limit", + "operationId": "Machines_set_memory_limit", + "parameters": [ + { + "type": "string", + "description": "Fly App Name", + "name": "app_name", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Machine ID", + "name": "machine_id", + "in": "path", + "required": true + }, + { + "description": "Set memory limit request", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/main.setMemoryLimitRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/main.memoryResponse" + } + } + } + } + }, + "/apps/{app_name}/machines/{machine_id}/memory/reclaim": { + "post": { + "description": "Trigger the balloon device to reclaim memory from a machine", + "tags": [ + "Machines" + ], + "summary": "Reclaim Machine Memory", + "operationId": "Machines_reclaim_memory", + "parameters": [ + { + "type": "string", + "description": "Fly App Name", + "name": "app_name", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Machine ID", + "name": "machine_id", + "in": "path", + "required": true + }, + { + "description": "Reclaim memory request", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/main.reclaimMemoryRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/main.reclaimMemoryResponse" + } + } + } + } + }, + "/apps/{app_name}/machines/{machine_id}/metadata": { + "get": { + "description": "Retrieve metadata for a specific Machine within an app.\n", + "tags": [ + "Machines" + ], + "summary": "Get Metadata", + "operationId": "Machines_show_metadata", + "parameters": [ + { + "type": "string", + "description": "Fly App Name", + "name": "app_name", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Machine ID", + "name": "machine_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "put": { + "description": "Update multiple metadata keys at once. Null values and empty strings remove keys.\n+ If `machine_version` is provided and no longer matches the current machine version, returns 412 Precondition Failed.", + "tags": [ + "Machines" + ], + "summary": "Update Metadata (set/remove multiple keys)", + "operationId": "Machines_update_metadata", + "parameters": [ + { + "type": "string", + "description": "Fly App Name", + "name": "app_name", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Machine ID", + "name": "machine_id", + "in": "path", + "required": true + }, + { + "description": "Update metadata request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/updateMetadataRequest" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "412": { + "description": "Precondition Failed", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + } + }, + "patch": { + "description": "Update multiple metadata keys at once. Null values and empty strings remove keys.\n+ If `machine_version` is provided and no longer matches the current machine version, returns 412 Precondition Failed.", + "tags": [ + "Machines" + ], + "summary": "Update Metadata (set/remove multiple keys)", + "operationId": "Machines_update_metadata", + "parameters": [ + { + "type": "string", + "description": "Fly App Name", + "name": "app_name", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Machine ID", + "name": "machine_id", + "in": "path", + "required": true + }, + { + "description": "Update metadata request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/updateMetadataRequest" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "412": { + "description": "Precondition Failed", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + } + } + }, + "/apps/{app_name}/machines/{machine_id}/metadata/{key}": { + "get": { + "description": "Get the value of a specific metadata key", + "tags": [ + "Machines" + ], + "summary": "Get Metadata Value", + "operationId": "Machines_get_metadata_key", + "parameters": [ + { + "type": "string", + "description": "Fly App Name", + "name": "app_name", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Machine ID", + "name": "machine_id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Metadata Key", + "name": "key", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/metadataValueResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + } + }, + "post": { + "description": "Update metadata for a specific machine within an app by providing a metadata key.\n", + "tags": [ + "Machines" + ], + "summary": "Upsert Metadata Key", + "operationId": "Machines_upsert_metadata", + "parameters": [ + { + "type": "string", + "description": "Fly App Name", + "name": "app_name", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Machine ID", + "name": "machine_id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Metadata Key", + "name": "key", + "in": "path", + "required": true + }, + { + "description": "Upsert metadata key request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/upsertMetadataKeyRequest" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + } + }, + "delete": { + "description": "Delete metadata for a specific Machine within an app by providing a metadata key.\n", + "tags": [ + "Machines" + ], + "summary": "Delete Metadata", + "operationId": "Machines_delete_metadata", + "parameters": [ + { + "type": "string", + "description": "Fly App Name", + "name": "app_name", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Machine ID", + "name": "machine_id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Metadata Key", + "name": "key", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + } + } + } + }, + "/apps/{app_name}/machines/{machine_id}/ps": { + "get": { + "description": "List all processes running on a specific Machine within an app, with optional sorting parameters.\n", + "tags": [ + "Machines" + ], + "summary": "List Processes", + "operationId": "Machines_list_processes", + "parameters": [ + { + "type": "string", + "description": "Fly App Name", + "name": "app_name", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Machine ID", + "name": "machine_id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Sort by", + "name": "sort_by", + "in": "query" + }, + { + "type": "string", + "description": "Order", + "name": "order", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/ProcessStat" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + } + } + }, + "/apps/{app_name}/machines/{machine_id}/restart": { + "post": { + "description": "Restart a specific Machine within an app, with an optional timeout parameter.\n", + "tags": [ + "Machines" + ], + "summary": "Restart Machine", + "operationId": "Machines_restart", + "parameters": [ + { + "type": "string", + "description": "Fly App Name", + "name": "app_name", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Machine ID", + "name": "machine_id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Restart timeout as a Go duration string or number of seconds", + "name": "timeout", + "in": "query" + }, + { + "enum": [ + "SIGHUP", + "SIGINT", + "SIGQUIT", + "SIGKILL", + "SIGUSR1", + "SIGUSR2", + "SIGTERM" + ], + "type": "string", + "description": "Unix signal name", + "name": "signal", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + } + } + }, + "/apps/{app_name}/machines/{machine_id}/signal": { + "post": { + "description": "Send a signal to a specific Machine within an app using the details provided in the request body.\n", + "tags": [ + "Machines" + ], + "summary": "Signal Machine", + "operationId": "Machines_signal", + "parameters": [ + { + "type": "string", + "description": "Fly App Name", + "name": "app_name", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Machine ID", + "name": "machine_id", + "in": "path", + "required": true + }, + { + "description": "Request body", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/SignalRequest" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + } + } + }, + "/apps/{app_name}/machines/{machine_id}/start": { + "post": { + "description": "Start a specific Machine within an app.\n", + "tags": [ + "Machines" + ], + "summary": "Start Machine", + "operationId": "Machines_start", + "parameters": [ + { + "type": "string", + "description": "Fly App Name", + "name": "app_name", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Machine ID", + "name": "machine_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/apps/{app_name}/machines/{machine_id}/stop": { + "post": { + "description": "Stop a specific Machine within an app, with an optional request body to specify signal and timeout.\n", + "tags": [ + "Machines" + ], + "summary": "Stop Machine", + "operationId": "Machines_stop", + "parameters": [ + { + "type": "string", + "description": "Fly App Name", + "name": "app_name", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Machine ID", + "name": "machine_id", + "in": "path", + "required": true + }, + { + "description": "Optional request body", + "name": "request", + "in": "body", + "schema": { + "$ref": "#/definitions/StopRequest" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + } + } + }, + "/apps/{app_name}/machines/{machine_id}/suspend": { + "post": { + "description": "Suspend a specific Machine within an app. The next start operation will attempt (but is not guaranteed) to resume the Machine from a snapshot taken at suspension time, rather than performing a cold boot.\n", + "tags": [ + "Machines" + ], + "summary": "Suspend Machine", + "operationId": "Machines_suspend", + "parameters": [ + { + "type": "string", + "description": "Fly App Name", + "name": "app_name", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Machine ID", + "name": "machine_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/apps/{app_name}/machines/{machine_id}/uncordon": { + "post": { + "description": "“Cordoning” a Machine refers to disabling its services, so the Fly Proxy won’t route requests to it. In flyctl this is used by blue/green deployments; one set of Machines is started up with services disabled, and when they are all healthy, the services are enabled on the new Machines and disabled on the old ones.\n", + "tags": [ + "Machines" + ], + "summary": "Uncordon Machine", + "operationId": "Machines_uncordon", + "parameters": [ + { + "type": "string", + "description": "Fly App Name", + "name": "app_name", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Machine ID", + "name": "machine_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/apps/{app_name}/machines/{machine_id}/versions": { + "get": { + "description": "List all versions of the configuration for a specific Machine within an app.\n", + "tags": [ + "Machines" + ], + "summary": "List Versions", + "operationId": "Machines_list_versions", + "parameters": [ + { + "type": "string", + "description": "Fly App Name", + "name": "app_name", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Machine ID", + "name": "machine_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/MachineVersion" + } + } + } + } + } + }, + "/apps/{app_name}/machines/{machine_id}/wait": { + "get": { + "description": "Wait for a Machine to reach a specific state. Specify the desired state with the state parameter. See the [Machine states table](https://fly.io/docs/machines/working-with-machines/#machine-states) for a list of possible states. The default for this parameter is `started`.\n\nThis request will block for up to 60 seconds. Set a shorter timeout with the timeout parameter.\n", + "tags": [ + "Machines" + ], + "summary": "Wait for State", + "operationId": "Machines_wait", + "parameters": [ + { + "type": "string", + "description": "Fly App Name", + "name": "app_name", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Machine ID", + "name": "machine_id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "26-character Machine version ID", + "name": "version", + "in": "query" + }, + { + "type": "string", + "description": "26-character Machine version ID (deprecated; use version)", + "name": "instance_id", + "in": "query" + }, + { + "type": "string", + "description": "26-character Machine event ID to start waiting after", + "name": "from_event_id", + "in": "query" + }, + { + "type": "integer", + "description": "wait timeout. default 60s", + "name": "timeout", + "in": "query" + }, + { + "enum": [ + "started", + "stopped", + "suspended", + "destroyed", + "failed", + "settled" + ], + "type": "string", + "description": "desired state(s), supports repeated or comma-separated values", + "name": "state", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/WaitMachineResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + } + } + }, + "/apps/{app_name}/secretkeys": { + "get": { + "tags": [ + "Secrets" + ], + "summary": "List secret keys belonging to an app", + "operationId": "Secretkeys_list", + "parameters": [ + { + "type": "string", + "description": "Fly App Name", + "name": "app_name", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Minimum secrets version to return. Returned when setting a new secret", + "name": "min_version", + "in": "query" + }, + { + "type": "string", + "description": "Comma-seperated list of secret keys to list", + "name": "types", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/SecretKeys" + } + } + } + } + }, + "/apps/{app_name}/secretkeys/{secret_name}": { + "get": { + "tags": [ + "Secrets" + ], + "summary": "Get an app's secret key", + "operationId": "Secretkey_get", + "parameters": [ + { + "type": "string", + "description": "Fly App Name", + "name": "app_name", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Secret key name", + "name": "secret_name", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Minimum secrets version to return. Returned when setting a new secret", + "name": "min_version", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/SecretKey" + } + } + } + }, + "post": { + "tags": [ + "Secrets" + ], + "summary": "Create or update a secret key", + "operationId": "Secretkey_set", + "parameters": [ + { + "type": "string", + "description": "Fly App Name", + "name": "app_name", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Secret key name", + "name": "secret_name", + "in": "path", + "required": true + }, + { + "description": "Create secret key request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/SetSecretkeyRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/SetSecretkeyResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + } + }, + "delete": { + "tags": [ + "Secrets" + ], + "summary": "Delete an app's secret key", + "operationId": "Secretkey_delete", + "parameters": [ + { + "type": "string", + "description": "Fly App Name", + "name": "app_name", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Secret key name", + "name": "secret_name", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/DeleteSecretkeyResponse" + } + } + } + } + }, + "/apps/{app_name}/secretkeys/{secret_name}/decrypt": { + "post": { + "tags": [ + "Secrets" + ], + "summary": "Decrypt with a secret key", + "operationId": "Secretkey_decrypt", + "parameters": [ + { + "type": "string", + "description": "Fly App Name", + "name": "app_name", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Secret key name", + "name": "secret_name", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Minimum secrets version to return. Returned when setting a new secret", + "name": "min_version", + "in": "query" + }, + { + "description": "Decrypt with secret key request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/DecryptSecretkeyRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/DecryptSecretkeyResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + } + } + }, + "/apps/{app_name}/secretkeys/{secret_name}/encrypt": { + "post": { + "tags": [ + "Secrets" + ], + "summary": "Encrypt with a secret key", + "operationId": "Secretkey_encrypt", + "parameters": [ + { + "type": "string", + "description": "Fly App Name", + "name": "app_name", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Secret key name", + "name": "secret_name", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Minimum secrets version to return. Returned when setting a new secret", + "name": "min_version", + "in": "query" + }, + { + "description": "Encrypt with secret key request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/EncryptSecretkeyRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/EncryptSecretkeyResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + } + } + }, + "/apps/{app_name}/secretkeys/{secret_name}/generate": { + "post": { + "tags": [ + "Secrets" + ], + "summary": "Generate a random secret key", + "operationId": "Secretkey_generate", + "parameters": [ + { + "type": "string", + "description": "Fly App Name", + "name": "app_name", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Secret key name", + "name": "secret_name", + "in": "path", + "required": true + }, + { + "description": "generate secret key request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/SetSecretkeyRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/SetSecretkeyResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + } + } + }, + "/apps/{app_name}/secretkeys/{secret_name}/sign": { + "post": { + "tags": [ + "Secrets" + ], + "summary": "Sign with a secret key", + "operationId": "Secretkey_sign", + "parameters": [ + { + "type": "string", + "description": "Fly App Name", + "name": "app_name", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Secret key name", + "name": "secret_name", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Minimum secrets version to return. Returned when setting a new secret", + "name": "min_version", + "in": "query" + }, + { + "description": "Sign with secret key request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/SignSecretkeyRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/SignSecretkeyResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + } + } + }, + "/apps/{app_name}/secretkeys/{secret_name}/verify": { + "post": { + "tags": [ + "Secrets" + ], + "summary": "Verify with a secret key", + "operationId": "Secretkey_verify", + "parameters": [ + { + "type": "string", + "description": "Fly App Name", + "name": "app_name", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Secret key name", + "name": "secret_name", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Minimum secrets version to return. Returned when setting a new secret", + "name": "min_version", + "in": "query" + }, + { + "description": "Verify with secret key request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/VerifySecretkeyRequest" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + } + } + }, + "/apps/{app_name}/secrets": { + "get": { + "tags": [ + "Secrets" + ], + "summary": "List app secrets belonging to an app", + "operationId": "Secrets_list", + "parameters": [ + { + "type": "string", + "description": "Fly App Name", + "name": "app_name", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Minimum secrets version to return. Returned when setting a new secret", + "name": "min_version", + "in": "query" + }, + { + "type": "boolean", + "description": "Show the secret values.", + "name": "show_secrets", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/AppSecrets" + } + } + } + }, + "post": { + "tags": [ + "Secrets" + ], + "summary": "Update app secrets belonging to an app", + "operationId": "Secrets_update", + "parameters": [ + { + "type": "string", + "description": "Fly App Name", + "name": "app_name", + "in": "path", + "required": true + }, + { + "description": "Update app secret request, with values to set, or nil to unset", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/AppSecretsUpdateRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/AppSecretsUpdateResp" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + } + } + }, + "/apps/{app_name}/secrets/{secret_name}": { + "get": { + "tags": [ + "Secrets" + ], + "summary": "Get an app secret", + "operationId": "Secret_get", + "parameters": [ + { + "type": "string", + "description": "Fly App Name", + "name": "app_name", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "App secret name", + "name": "secret_name", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Minimum secrets version to return. Returned when setting a new secret", + "name": "min_version", + "in": "query" + }, + { + "type": "boolean", + "description": "Show the secret value.", + "name": "show_secrets", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/AppSecret" + } + } + } + }, + "post": { + "tags": [ + "Secrets" + ], + "summary": "Create or update Secret", + "operationId": "Secret_create", + "parameters": [ + { + "type": "string", + "description": "Fly App Name", + "name": "app_name", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "App secret name", + "name": "secret_name", + "in": "path", + "required": true + }, + { + "description": "Create app secret request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/SetAppSecretRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/SetAppSecretResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + } + }, + "delete": { + "tags": [ + "Secrets" + ], + "summary": "Delete an app secret", + "operationId": "Secret_delete", + "parameters": [ + { + "type": "string", + "description": "Fly App Name", + "name": "app_name", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "App secret name", + "name": "secret_name", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/DeleteAppSecretResponse" + } + } + } + } + }, + "/apps/{app_name}/volumes": { + "get": { + "description": "List all volumes associated with a specific app.\n", + "tags": [ + "Volumes" + ], + "summary": "List Volumes", + "operationId": "Volumes_list", + "parameters": [ + { + "type": "string", + "description": "Fly App Name", + "name": "app_name", + "in": "path", + "required": true + }, + { + "type": "boolean", + "description": "Only return summary info about volumes (omit blocks, block size, etc)", + "name": "summary", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/Volume" + } + } + } + } + }, + "post": { + "description": "Create a volume for a specific app using the details provided in the request body.\n", + "tags": [ + "Volumes" + ], + "summary": "Create Volume", + "operationId": "Volumes_create", + "parameters": [ + { + "type": "string", + "description": "Fly App Name", + "name": "app_name", + "in": "path", + "required": true + }, + { + "description": "Request body", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/CreateVolumeRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/Volume" + } + } + } + } + }, + "/apps/{app_name}/volumes/{volume_id}": { + "get": { + "description": "Retrieve details about a specific volume by its ID within an app.\n", + "tags": [ + "Volumes" + ], + "summary": "Get Volume", + "operationId": "Volumes_get_by_id", + "parameters": [ + { + "type": "string", + "description": "Fly App Name", + "name": "app_name", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Volume ID", + "name": "volume_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/Volume" + } + } + } + }, + "put": { + "description": "Update a volume's configuration using the details provided in the request body.\n", + "tags": [ + "Volumes" + ], + "summary": "Update Volume", + "operationId": "Volumes_update", + "parameters": [ + { + "type": "string", + "description": "Fly App Name", + "name": "app_name", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Volume ID", + "name": "volume_id", + "in": "path", + "required": true + }, + { + "description": "Request body", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/UpdateVolumeRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/Volume" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + } + }, + "delete": { + "description": "Delete a specific volume within an app by volume ID.\n", + "tags": [ + "Volumes" + ], + "summary": "Destroy Volume", + "operationId": "Volume_delete", + "parameters": [ + { + "type": "string", + "description": "Fly App Name", + "name": "app_name", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Volume ID", + "name": "volume_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/Volume" + } + } + } + } + }, + "/apps/{app_name}/volumes/{volume_id}/extend": { + "put": { + "description": "Extend a volume's size within an app using the details provided in the request body.\n", + "tags": [ + "Volumes" + ], + "summary": "Extend Volume", + "operationId": "Volumes_extend", + "parameters": [ + { + "type": "string", + "description": "Fly App Name", + "name": "app_name", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Volume ID", + "name": "volume_id", + "in": "path", + "required": true + }, + { + "description": "Request body", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/ExtendVolumeRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/ExtendVolumeResponse" + } + } + } + } + }, + "/apps/{app_name}/volumes/{volume_id}/snapshots": { + "get": { + "description": "List all snapshots for a specific volume within an app.\n", + "tags": [ + "Volumes" + ], + "summary": "List Snapshots", + "operationId": "Volumes_list_snapshots", + "parameters": [ + { + "type": "string", + "description": "Fly App Name", + "name": "app_name", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Volume ID", + "name": "volume_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/VolumeSnapshot" + } + } + } + } + }, + "post": { + "description": "Create a snapshot for a specific volume within an app.\n", + "tags": [ + "Volumes" + ], + "summary": "Create Snapshot", + "operationId": "createVolumeSnapshot", + "parameters": [ + { + "type": "string", + "description": "Fly App Name", + "name": "app_name", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Volume ID", + "name": "volume_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/orgs/{org_slug}/machines": { + "get": { + "description": "List all Machines associated with a specific organization. Machines are sorted by their `updated_at` timestamps, oldest to newest.\n\nThis API call represents \"a point in time\". Recent machine changes, including creations and destructions, may take time to propagate. When polling with `updated_after`, offset your timestamps to catch late-arriving events.\n", + "tags": [ + "Organizations" + ], + "summary": "List All Machines", + "operationId": "Machines_org_list", + "parameters": [ + { + "type": "string", + "description": "Fly Organization Slug", + "name": "org_slug", + "in": "path", + "required": true + }, + { + "type": "boolean", + "description": "Include deleted machines", + "name": "include_deleted", + "in": "query" + }, + { + "type": "string", + "description": "Region filter", + "name": "region", + "in": "query" + }, + { + "type": "string", + "description": "Comma separated list of states to filter (created, started, stopped, suspended)", + "name": "state", + "in": "query" + }, + { + "type": "boolean", + "description": "Omit config from responses", + "name": "summary", + "in": "query" + }, + { + "type": "string", + "description": "Only return machines updated after this time. Timestamp must be in the RFC 3339 format", + "name": "updated_after", + "in": "query" + }, + { + "type": "string", + "description": "Pagination cursor from previous response (takes precedence over updated_after). Note that there is no guarantee that all machines returned by this endpoint are sorted by their updated_at fields. Pagination may reveal machines older than the last updated_at.", + "name": "cursor", + "in": "query" + }, + { + "type": "integer", + "description": "The number of machines to fetch (max of 1000). This limit is advisory. Responses may be shorter, or even empty, even when more machines remain. If omitted, the maximum is used", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/OrgMachinesResponse" + } + } + } + } + }, + "/orgs/{org_slug}/volumes": { + "get": { + "description": "List all volumes for an organization with optional filters and cursor-based pagination.", + "tags": [ + "Organizations" + ], + "summary": "List All Volumes", + "operationId": "Volumes_org_list", + "parameters": [ + { + "type": "string", + "description": "Fly Organization Slug", + "name": "org_slug", + "in": "path", + "required": true + }, + { + "type": "boolean", + "description": "Include deleted volumes", + "name": "include_deleted", + "in": "query" + }, + { + "type": "string", + "description": "Region filter", + "name": "region", + "in": "query" + }, + { + "type": "string", + "description": "Comma separated list of volume states to filter", + "name": "state", + "in": "query" + }, + { + "type": "boolean", + "description": "Only return summary info about volumes (omit blocks, block size, etc)", + "name": "summary", + "in": "query" + }, + { + "type": "string", + "description": "Only return volumes updated after this time. Timestamp must be in the RFC 3339 format", + "name": "updated_after", + "in": "query" + }, + { + "type": "string", + "description": "Pagination cursor from previous response (takes precedence over updated_after)", + "name": "cursor", + "in": "query" + }, + { + "type": "integer", + "description": "The number of volumes to fetch (max of 1000). This limit is advisory. Responses may be shorter, even when more volumes remain. If omitted, the maximum is used", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/OrgVolumesResponse" + } + } + } + } + }, + "/platform/placements": { + "post": { + "description": "Simulates placing the specified number of machines into regions, depending on available capacity and limits.", + "tags": [ + "Platform" + ], + "summary": "Get Placements", + "operationId": "Platform_placements_post", + "parameters": [ + { + "description": "Get placements request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/main.getPlacementsRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/main.getPlacementsResponse" + } + } + } + } + }, + "/platform/regions": { + "get": { + "description": "List all regions on the platform with their details.", + "tags": [ + "Platform" + ], + "summary": "Get Regions", + "operationId": "Platform_regions_get", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/main.regionResponse" + } + } + } + } + }, + "/tokens/authenticate": { + "post": { + "description": "Verify a token header without checking resource access.", + "tags": [ + "Tokens" + ], + "summary": "Authenticate token header", + "operationId": "Tokens_authenticate", + "parameters": [ + { + "description": "Authenticate token request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/authenticateTokenRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/root.VerifiedToken" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + } + } + }, + "/tokens/authorize": { + "post": { + "description": "Verify a token header and validate it against a requested access scope.", + "tags": [ + "Tokens" + ], + "summary": "Authorize token for resource access", + "operationId": "Tokens_authorize", + "parameters": [ + { + "description": "Authorize token request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/authorizeTokenRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/authorizeResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + } + } + }, + "/tokens/current": { + "get": { + "description": "Get information about the current macaroon token(s), including organizations, apps, user identity hashes, and machine restrictions", + "tags": [ + "Tokens" + ], + "summary": "Get Current Token Information", + "operationId": "CurrentToken_show", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/CurrentTokenResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + } + } + }, + "/tokens/kms": { + "post": { + "description": "This site hosts documentation generated from the Fly.io Machines API OpenAPI specification. Visit our complete [Machines API docs](https://fly.io/docs/machines/api/apps-resource/) for details about using the Apps resource.", + "produces": [ + "text/plain" + ], + "tags": [ + "Tokens" + ], + "summary": "Request a Petsem token for accessing KMS", + "operationId": "Tokens_request_Kms", + "responses": { + "200": { + "description": "KMS token", + "schema": { + "type": "string" + } + } + } + } + }, + "/tokens/oidc": { + "post": { + "description": "Request an Open ID Connect token for your machine. Customize the audience claim with the `aud` parameter. This returns a JWT token. Learn more about [using OpenID Connect](/docs/reference/openid-connect/) on Fly.io.\n", + "produces": [ + "text/plain" + ], + "tags": [ + "Tokens" + ], + "summary": "Request an OIDC token", + "operationId": "Tokens_request_OIDC", + "parameters": [ + { + "description": "Optional request body", + "name": "request", + "in": "body", + "schema": { + "$ref": "#/definitions/CreateOIDCTokenRequest" + } + } + ], + "responses": { + "200": { + "description": "OIDC token", + "schema": { + "type": "string" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + } + } + } + }, + "definitions": { + "AcmeChallenge": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "target": { + "type": "string" + } + } + }, + "App": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "internal_numeric_id": { + "type": "integer" + }, + "machine_count": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "network": { + "type": "string" + }, + "organization": { + "$ref": "#/definitions/AppOrganizationInfo" + }, + "status": { + "type": "string" + }, + "volume_count": { + "type": "integer" + } + } + }, + "AppOrganizationInfo": { + "type": "object", + "properties": { + "internal_numeric_id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "slug": { + "type": "string" + } + } + }, + "AppSecret": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "digest": { + "type": "string" + }, + "name": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "value": { + "type": "string" + } + } + }, + "AppSecrets": { + "type": "object", + "properties": { + "secrets": { + "type": "array", + "items": { + "$ref": "#/definitions/AppSecret" + } + } + } + }, + "AppSecretsUpdateRequest": { + "type": "object", + "properties": { + "values": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + }, + "AppSecretsUpdateResp": { + "type": "object", + "properties": { + "Version": { + "description": "DEPRECATED", + "type": "integer" + }, + "secrets": { + "type": "array", + "items": { + "$ref": "#/definitions/AppSecret" + } + }, + "version": { + "type": "integer" + } + } + }, + "CertificateCheckResponse": { + "type": "object", + "properties": { + "acme_requested": { + "type": "boolean" + }, + "certificates": { + "type": "array", + "items": { + "$ref": "#/definitions/CertificateEntry" + } + }, + "configured": { + "type": "boolean" + }, + "dns_provider": { + "type": "string" + }, + "dns_records": { + "$ref": "#/definitions/DNSRecords" + }, + "dns_requirements": { + "$ref": "#/definitions/DNSRequirements" + }, + "hostname": { + "type": "string" + }, + "rate_limited_until": { + "type": "string" + }, + "status": { + "type": "string" + }, + "validation": { + "$ref": "#/definitions/CertificateValidation" + }, + "validation_errors": { + "type": "array", + "items": { + "$ref": "#/definitions/CertificateValidationError" + } + } + } + }, + "CertificateDetail": { + "type": "object", + "properties": { + "acme_requested": { + "type": "boolean" + }, + "certificates": { + "type": "array", + "items": { + "$ref": "#/definitions/CertificateEntry" + } + }, + "configured": { + "type": "boolean" + }, + "dns_provider": { + "type": "string" + }, + "dns_requirements": { + "$ref": "#/definitions/DNSRequirements" + }, + "hostname": { + "type": "string" + }, + "rate_limited_until": { + "type": "string" + }, + "status": { + "type": "string" + }, + "validation": { + "$ref": "#/definitions/CertificateValidation" + }, + "validation_errors": { + "type": "array", + "items": { + "$ref": "#/definitions/CertificateValidationError" + } + } + } + }, + "CertificateEntry": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "expires_at": { + "type": "string" + }, + "issued": { + "type": "array", + "items": { + "$ref": "#/definitions/IssuedCertificate" + } + }, + "issuer": { + "type": "string" + }, + "source": { + "type": "string", + "enum": [ + "custom", + "fly" + ] + }, + "status": { + "type": "string", + "enum": [ + "active", + "pending_ownership", + "pending_validation" + ] + } + } + }, + "CertificateSummary": { + "type": "object", + "properties": { + "acme_alpn_configured": { + "type": "boolean" + }, + "acme_dns_configured": { + "type": "boolean" + }, + "acme_http_configured": { + "type": "boolean" + }, + "acme_requested": { + "type": "boolean" + }, + "configured": { + "type": "boolean" + }, + "created_at": { + "type": "string" + }, + "dns_provider": { + "type": "string" + }, + "has_custom_certificate": { + "type": "boolean" + }, + "has_fly_certificate": { + "type": "boolean" + }, + "hostname": { + "type": "string" + }, + "ownership_txt_configured": { + "type": "boolean" + }, + "status": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "CertificateValidation": { + "type": "object", + "properties": { + "alpn_configured": { + "type": "boolean" + }, + "dns_configured": { + "type": "boolean" + }, + "http_configured": { + "type": "boolean" + }, + "ownership_txt_configured": { + "type": "boolean" + } + } + }, + "CertificateValidationError": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "remediation": { + "type": "string" + }, + "timestamp": { + "type": "string" + } + } + }, + "CheckStatus": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "output": { + "type": "string" + }, + "status": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "CreateAppDeployTokenRequest": { + "type": "object", + "properties": { + "expiry": { + "type": "string" + } + } + }, + "CreateAppRequest": { + "type": "object", + "properties": { + "enable_subdomains": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "network": { + "type": "string" + }, + "org_slug": { + "type": "string" + } + } + }, + "CreateAppResponse": { + "type": "object", + "properties": { + "token": { + "type": "string" + } + } + }, + "CreateLeaseRequest": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "ttl": { + "description": "seconds lease will be valid", + "type": "integer" + } + } + }, + "CreateMachineRequest": { + "type": "object", + "properties": { + "config": { + "description": "An object defining the Machine configuration", + "allOf": [ + { + "$ref": "#/definitions/fly.MachineConfig" + } + ] + }, + "lease_ttl": { + "type": "integer" + }, + "min_secrets_version": { + "type": "integer" + }, + "name": { + "description": "Unique name for this Machine. If omitted, one is generated for you", + "type": "string" + }, + "region": { + "description": "The target region. Omitting this param launches in the same region as your WireGuard peer connection (somewhere near you).", + "type": "string" + }, + "skip_launch": { + "type": "boolean" + }, + "skip_secrets": { + "type": "boolean" + }, + "skip_service_registration": { + "type": "boolean" + } + } + }, + "CreateOIDCTokenRequest": { + "description": "Optional parameters", + "type": "object", + "properties": { + "aud": { + "type": "string", + "example": "https://fly.io/org-slug" + }, + "aws_principal_tags": { + "type": "boolean" + } + } + }, + "CreateVolumeRequest": { + "type": "object", + "properties": { + "auto_backup_enabled": { + "description": "enable scheduled automatic snapshots. Defaults to `true`", + "type": "boolean" + }, + "compute": { + "$ref": "#/definitions/fly.MachineGuest" + }, + "compute_image": { + "type": "string" + }, + "encrypted": { + "type": "boolean" + }, + "fstype": { + "type": "string" + }, + "name": { + "type": "string" + }, + "region": { + "type": "string" + }, + "require_unique_zone": { + "type": "boolean" + }, + "size_gb": { + "type": "integer" + }, + "snapshot_id": { + "description": "restore from snapshot", + "type": "string" + }, + "snapshot_retention": { + "type": "integer" + }, + "source_volume_id": { + "description": "fork from remote volume", + "type": "string" + }, + "unique_zone_app_wide": { + "type": "boolean" + } + } + }, + "CurrentTokenResponse": { + "type": "object", + "properties": { + "tokens": { + "type": "array", + "items": { + "$ref": "#/definitions/main.tokenInfo" + } + } + } + }, + "DNSRecords": { + "type": "object", + "properties": { + "a": { + "type": "array", + "items": { + "type": "string" + } + }, + "aaaa": { + "type": "array", + "items": { + "type": "string" + } + }, + "acme_challenge_cname": { + "type": "string" + }, + "cname": { + "type": "array", + "items": { + "type": "string" + } + }, + "ownership_txt": { + "type": "string" + }, + "resolved_addresses": { + "type": "array", + "items": { + "type": "string" + } + }, + "soa": { + "type": "string" + } + } + }, + "DNSRequirements": { + "type": "object", + "properties": { + "a": { + "type": "array", + "items": { + "type": "string" + } + }, + "aaaa": { + "type": "array", + "items": { + "type": "string" + } + }, + "acme_challenge": { + "$ref": "#/definitions/AcmeChallenge" + }, + "cname": { + "type": "string" + }, + "ownership": { + "$ref": "#/definitions/OwnershipVerification" + } + } + }, + "DecryptSecretkeyRequest": { + "type": "object", + "properties": { + "associated_data": { + "type": "array", + "items": { + "type": "integer" + } + }, + "ciphertext": { + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "DecryptSecretkeyResponse": { + "type": "object", + "properties": { + "plaintext": { + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "DeleteAppSecretResponse": { + "type": "object", + "properties": { + "Version": { + "description": "DEPRECATED", + "type": "integer" + }, + "version": { + "type": "integer" + } + } + }, + "DeleteSecretkeyResponse": { + "type": "object", + "properties": { + "Version": { + "description": "DEPRECATED", + "type": "integer" + }, + "version": { + "type": "integer" + } + } + }, + "EncryptSecretkeyRequest": { + "type": "object", + "properties": { + "associated_data": { + "type": "array", + "items": { + "type": "integer" + } + }, + "plaintext": { + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "EncryptSecretkeyResponse": { + "type": "object", + "properties": { + "ciphertext": { + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "ErrorResponse": { + "type": "object", + "properties": { + "details": { + "description": "Deprecated" + }, + "error": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/main.statusCode" + } + } + }, + "ExtendVolumeRequest": { + "type": "object", + "properties": { + "size_gb": { + "type": "integer" + } + } + }, + "ExtendVolumeResponse": { + "type": "object", + "properties": { + "needs_restart": { + "type": "boolean" + }, + "volume": { + "$ref": "#/definitions/Volume" + } + } + }, + "IPAssignment": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "ip": { + "type": "string" + }, + "region": { + "type": "string" + }, + "service_name": { + "type": "string" + }, + "shared": { + "type": "boolean" + } + } + }, + "ImageRef": { + "type": "object", + "properties": { + "digest": { + "type": "string" + }, + "labels": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "registry": { + "type": "string" + }, + "repository": { + "type": "string" + }, + "tag": { + "type": "string" + } + } + }, + "IssuedCertificate": { + "type": "object", + "properties": { + "certificate_authority": { + "type": "string" + }, + "expires_at": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "rsa", + "ecdsa" + ] + } + } + }, + "Lease": { + "type": "object", + "properties": { + "description": { + "description": "Description or reason for the Lease.", + "type": "string" + }, + "expires_at": { + "description": "ExpiresAt is the unix timestamp in UTC to denote when the Lease will no longer be valid.", + "type": "integer" + }, + "nonce": { + "description": "Nonce is the unique ID autogenerated and associated with the Lease.", + "type": "string" + }, + "owner": { + "description": "Owner is the user identifier which acquired the Lease.", + "type": "string" + }, + "version": { + "description": "Machine version", + "type": "string" + } + } + }, + "ListAppsResponse": { + "type": "object", + "properties": { + "apps": { + "type": "array", + "items": { + "$ref": "#/definitions/App" + } + }, + "total_apps": { + "type": "integer" + } + } + }, + "ListenSocket": { + "type": "object", + "properties": { + "address": { + "type": "string" + }, + "proto": { + "type": "string" + } + } + }, + "Machine": { + "type": "object", + "properties": { + "checks": { + "type": "array", + "items": { + "$ref": "#/definitions/CheckStatus" + } + }, + "config": { + "$ref": "#/definitions/fly.MachineConfig" + }, + "created_at": { + "type": "string" + }, + "events": { + "type": "array", + "items": { + "$ref": "#/definitions/MachineEvent" + } + }, + "host_status": { + "type": "string", + "enum": [ + "ok", + "unknown", + "unreachable" + ] + }, + "id": { + "type": "string" + }, + "image_ref": { + "$ref": "#/definitions/ImageRef" + }, + "incomplete_config": { + "$ref": "#/definitions/fly.MachineConfig" + }, + "instance_id": { + "description": "InstanceID is unique for each version of the machine", + "type": "string" + }, + "name": { + "type": "string" + }, + "nonce": { + "description": "Nonce is only every returned on machine creation if a lease_duration was provided.", + "type": "string" + }, + "private_ip": { + "description": "PrivateIP is the internal 6PN address of the machine.", + "type": "string" + }, + "region": { + "type": "string" + }, + "state": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "MachineEvent": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "request": {}, + "source": { + "type": "string" + }, + "status": { + "type": "string" + }, + "timestamp": { + "type": "integer" + }, + "type": { + "type": "string" + } + } + }, + "MachineExecRequest": { + "type": "object", + "properties": { + "cmd": { + "description": "Deprecated: use Command instead", + "type": "string" + }, + "command": { + "type": "array", + "items": { + "type": "string" + } + }, + "container": { + "type": "string" + }, + "stdin": { + "type": "string" + }, + "timeout": { + "type": "integer" + } + } + }, + "MachineOverviewConfig": { + "type": "object", + "properties": { + "guest": { + "$ref": "#/definitions/fly.MachineGuest" + }, + "image": { + "type": "string" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + }, + "MachineVersion": { + "type": "object", + "properties": { + "user_config": { + "$ref": "#/definitions/fly.MachineConfig" + }, + "version": { + "type": "string" + } + } + }, + "OrgMachine": { + "type": "object", + "properties": { + "app_name": { + "type": "string" + }, + "config": { + "$ref": "#/definitions/MachineOverviewConfig" + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "private_ip": { + "type": "string" + }, + "region": { + "type": "string" + }, + "state": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "version": { + "type": "string" + } + } + }, + "OrgMachinesResponse": { + "type": "object", + "properties": { + "error_regions": { + "type": "array", + "items": { + "type": "string" + } + }, + "last_machine_id": { + "type": "string" + }, + "last_updated_at": { + "type": "string" + }, + "machines": { + "type": "array", + "items": { + "$ref": "#/definitions/OrgMachine" + } + }, + "next_cursor": { + "type": "string" + } + } + }, + "OrgVolume": { + "type": "object", + "properties": { + "app_name": { + "type": "string" + }, + "attached_alloc_id": { + "type": "string" + }, + "attached_machine_id": { + "type": "string" + }, + "auto_backup_enabled": { + "type": "boolean" + }, + "block_size": { + "type": "integer" + }, + "blocks": { + "type": "integer" + }, + "blocks_avail": { + "type": "integer" + }, + "blocks_free": { + "type": "integer" + }, + "bytes_total": { + "type": "integer" + }, + "bytes_used": { + "type": "integer" + }, + "created_at": { + "type": "string" + }, + "encrypted": { + "type": "boolean" + }, + "fstype": { + "type": "string" + }, + "host_status": { + "type": "string", + "enum": [ + "ok", + "unknown", + "unreachable" + ] + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "region": { + "type": "string" + }, + "size_gb": { + "type": "integer" + }, + "snapshot_retention": { + "type": "integer" + }, + "state": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "local", + "cache" + ] + }, + "updated_at": { + "type": "string" + }, + "zone": { + "type": "string" + } + } + }, + "OrgVolumesResponse": { + "type": "object", + "properties": { + "last_updated_at": { + "type": "string" + }, + "last_volume_id": { + "type": "string" + }, + "next_cursor": { + "type": "string" + }, + "volumes": { + "type": "array", + "items": { + "$ref": "#/definitions/OrgVolume" + } + } + } + }, + "OwnershipVerification": { + "type": "object", + "properties": { + "app_value": { + "type": "string" + }, + "name": { + "type": "string" + }, + "org_value": { + "type": "string" + } + } + }, + "ProcessStat": { + "type": "object", + "properties": { + "command": { + "type": "string" + }, + "cpu": { + "type": "integer" + }, + "directory": { + "type": "string" + }, + "listen_sockets": { + "type": "array", + "items": { + "$ref": "#/definitions/ListenSocket" + } + }, + "pid": { + "type": "integer" + }, + "rss": { + "type": "integer" + }, + "rtime": { + "type": "integer" + }, + "stime": { + "type": "integer" + } + } + }, + "SecretKey": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "name": { + "type": "string" + }, + "public_key": { + "type": "array", + "items": { + "type": "integer" + } + }, + "type": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "SecretKeys": { + "type": "object", + "properties": { + "secret_keys": { + "type": "array", + "items": { + "$ref": "#/definitions/SecretKey" + } + } + } + }, + "SetAppSecretRequest": { + "type": "object", + "properties": { + "value": { + "type": "string" + } + } + }, + "SetAppSecretResponse": { + "type": "object", + "properties": { + "Version": { + "description": "DEPRECATED", + "type": "integer" + }, + "created_at": { + "type": "string" + }, + "digest": { + "type": "string" + }, + "name": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "value": { + "type": "string" + }, + "version": { + "type": "integer" + } + } + }, + "SetSecretkeyRequest": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "value": { + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "SetSecretkeyResponse": { + "type": "object", + "properties": { + "Version": { + "description": "DEPRECATED", + "type": "integer" + }, + "created_at": { + "type": "string" + }, + "name": { + "type": "string" + }, + "public_key": { + "type": "array", + "items": { + "type": "integer" + } + }, + "type": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "version": { + "type": "integer" + } + } + }, + "SignSecretkeyRequest": { + "type": "object", + "properties": { + "plaintext": { + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "SignSecretkeyResponse": { + "type": "object", + "properties": { + "signature": { + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "SignalRequest": { + "type": "object", + "properties": { + "signal": { + "type": "string", + "enum": [ + "SIGABRT", + "SIGALRM", + "SIGFPE", + "SIGHUP", + "SIGILL", + "SIGINT", + "SIGKILL", + "SIGPIPE", + "SIGQUIT", + "SIGSEGV", + "SIGTERM", + "SIGTRAP", + "SIGUSR1", + "SIGUSR2" + ] + } + } + }, + "StopRequest": { + "type": "object", + "properties": { + "signal": { + "type": "string", + "enum": [ + "SIGHUP", + "SIGINT", + "SIGQUIT", + "SIGKILL", + "SIGUSR1", + "SIGUSR2", + "SIGTERM" + ], + "example": "SIGTERM" + }, + "timeout": { + "type": "string", + "example": "1s" + } + } + }, + "UpdateMachineRequest": { + "type": "object", + "properties": { + "config": { + "description": "An object defining the Machine configuration", + "allOf": [ + { + "$ref": "#/definitions/fly.MachineConfig" + } + ] + }, + "current_version": { + "type": "string" + }, + "lease_ttl": { + "type": "integer" + }, + "min_secrets_version": { + "type": "integer" + }, + "name": { + "description": "Unique name for this Machine. If omitted, one is generated for you", + "type": "string" + }, + "region": { + "description": "The target region. Omitting this param launches in the same region as your WireGuard peer connection (somewhere near you).", + "type": "string" + }, + "skip_launch": { + "type": "boolean" + }, + "skip_secrets": { + "type": "boolean" + }, + "skip_service_registration": { + "type": "boolean" + } + } + }, + "UpdateVolumeRequest": { + "type": "object", + "properties": { + "auto_backup_enabled": { + "type": "boolean" + }, + "snapshot_retention": { + "type": "integer" + } + } + }, + "VerifySecretkeyRequest": { + "type": "object", + "properties": { + "plaintext": { + "type": "array", + "items": { + "type": "integer" + } + }, + "signature": { + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "Volume": { + "type": "object", + "properties": { + "attached_alloc_id": { + "type": "string" + }, + "attached_machine_id": { + "type": "string" + }, + "auto_backup_enabled": { + "type": "boolean" + }, + "block_size": { + "type": "integer" + }, + "blocks": { + "type": "integer" + }, + "blocks_avail": { + "type": "integer" + }, + "blocks_free": { + "type": "integer" + }, + "bytes_total": { + "type": "integer" + }, + "bytes_used": { + "type": "integer" + }, + "created_at": { + "type": "string" + }, + "encrypted": { + "type": "boolean" + }, + "fstype": { + "type": "string" + }, + "host_status": { + "type": "string", + "enum": [ + "ok", + "unknown", + "unreachable" + ] + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "region": { + "type": "string" + }, + "size_gb": { + "type": "integer" + }, + "snapshot_retention": { + "type": "integer" + }, + "state": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "local", + "cache" + ] + }, + "zone": { + "type": "string" + } + } + }, + "VolumeSnapshot": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "digest": { + "type": "string" + }, + "id": { + "type": "string" + }, + "retention_days": { + "type": "integer" + }, + "size": { + "type": "integer" + }, + "status": { + "type": "string" + }, + "volume_size": { + "type": "integer" + } + } + }, + "WaitMachineResponse": { + "type": "object", + "properties": { + "event_id": { + "type": "string" + }, + "ok": { + "type": "boolean" + }, + "state": { + "type": "string" + }, + "version": { + "type": "string" + } + } + }, + "assignIPRequest": { + "type": "object", + "properties": { + "network": { + "type": "string" + }, + "org_slug": { + "type": "string" + }, + "region": { + "type": "string" + }, + "service_name": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "authenticateTokenRequest": { + "type": "object", + "properties": { + "header": { + "type": "string" + } + } + }, + "authorizeResponse": { + "type": "object", + "properties": { + "access": { + "$ref": "#/definitions/flyio.Access" + }, + "verified_token": { + "$ref": "#/definitions/root.VerifiedToken" + } + } + }, + "authorizeTokenRequest": { + "type": "object", + "properties": { + "access": { + "$ref": "#/definitions/main.TokenAccess" + }, + "header": { + "type": "string" + } + } + }, + "createAcmeCertificateRequest": { + "type": "object", + "properties": { + "hostname": { + "type": "string" + } + } + }, + "createCustomCertificateRequest": { + "type": "object", + "properties": { + "fullchain": { + "type": "string" + }, + "hostname": { + "type": "string" + }, + "private_key": { + "type": "string" + } + } + }, + "destroyCustomCertificateResponse": { + "type": "object", + "properties": { + "acme_requested": { + "type": "boolean" + }, + "certificates": { + "type": "array", + "items": { + "$ref": "#/definitions/CertificateEntry" + } + }, + "configured": { + "type": "boolean" + }, + "dns_provider": { + "type": "string" + }, + "dns_requirements": { + "$ref": "#/definitions/DNSRequirements" + }, + "hostname": { + "type": "string" + }, + "rate_limited_until": { + "type": "string" + }, + "status": { + "type": "string" + }, + "validation": { + "$ref": "#/definitions/CertificateValidation" + }, + "validation_errors": { + "type": "array", + "items": { + "$ref": "#/definitions/CertificateValidationError" + } + }, + "warning": { + "type": "string" + } + } + }, + "fly.ContainerConfig": { + "type": "object", + "properties": { + "cmd": { + "description": "CmdOverride is used to override the default command of the image.", + "type": "array", + "items": { + "type": "string" + } + }, + "depends_on": { + "description": "DependsOn can be used to define dependencies between containers. The container will only be\nstarted after all of its dependent conditions have been satisfied.", + "type": "array", + "items": { + "$ref": "#/definitions/fly.ContainerDependency" + } + }, + "entrypoint": { + "description": "EntrypointOverride is used to override the default entrypoint of the image.", + "type": "array", + "items": { + "type": "string" + } + }, + "env": { + "description": "ExtraEnv is used to add additional environment variables to the container.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "env_from": { + "description": "EnvFrom can be provided to set environment variables from machine fields.", + "type": "array", + "items": { + "$ref": "#/definitions/fly.EnvFrom" + } + }, + "exec": { + "description": "Image Config overrides - these fields are used to override the image configuration.\nIf not provided, the image configuration will be used.\nExecOverride is used to override the default command of the image.", + "type": "array", + "items": { + "type": "string" + } + }, + "files": { + "description": "Files are files that will be written to the container file system.", + "type": "array", + "items": { + "$ref": "#/definitions/fly.File" + } + }, + "healthchecks": { + "description": "Healthchecks determine the health of your containers. Healthchecks can use HTTP, TCP or an Exec command.", + "type": "array", + "items": { + "$ref": "#/definitions/fly.ContainerHealthcheck" + } + }, + "image": { + "description": "Image is the docker image to run.", + "type": "string" + }, + "name": { + "description": "Name is used to identify the container in the machine.", + "type": "string" + }, + "restart": { + "description": "Restart is used to define the restart policy for the container. NOTE: spot-price is not\nsupported for containers.", + "allOf": [ + { + "$ref": "#/definitions/fly.MachineRestart" + } + ] + }, + "secrets": { + "description": "Secrets can be provided at the process level to explicitly indicate which secrets should be\nused for the process. If not provided, the secrets provided at the machine level will be used.", + "type": "array", + "items": { + "$ref": "#/definitions/fly.MachineSecret" + } + }, + "stop": { + "description": "Stop is used to define the signal and timeout for stopping the container.", + "allOf": [ + { + "$ref": "#/definitions/fly.StopConfig" + } + ] + }, + "user": { + "description": "UserOverride is used to override the default user of the image.", + "type": "string" + } + } + }, + "fly.ContainerDependency": { + "type": "object", + "properties": { + "condition": { + "enum": [ + "exited_successfully", + "healthy", + "started" + ], + "allOf": [ + { + "$ref": "#/definitions/fly.ContainerDependencyCondition" + } + ] + }, + "name": { + "type": "string" + } + } + }, + "fly.ContainerDependencyCondition": { + "type": "string", + "enum": [ + "exited_successfully", + "healthy", + "started" + ], + "x-enum-varnames": [ + "ExitedSuccessfully", + "Healthy", + "Started" + ] + }, + "fly.ContainerHealthcheck": { + "type": "object", + "properties": { + "exec": { + "$ref": "#/definitions/fly.ExecHealthcheck" + }, + "failure_threshold": { + "description": "The number of times the check must fail before considering the container unhealthy.", + "type": "integer" + }, + "grace_period": { + "description": "The time in seconds to wait after a container starts before checking its health.", + "type": "integer" + }, + "http": { + "$ref": "#/definitions/fly.HTTPHealthcheck" + }, + "interval": { + "description": "The time in seconds between executing the defined check.", + "type": "integer" + }, + "kind": { + "description": "Kind of healthcheck (readiness, liveness)", + "allOf": [ + { + "$ref": "#/definitions/fly.ContainerHealthcheckKind" + } + ] + }, + "name": { + "description": "The name of the check. Must be unique within the container.", + "type": "string" + }, + "success_threshold": { + "description": "The number of times the check must succeeed before considering the container healthy.", + "type": "integer" + }, + "tcp": { + "$ref": "#/definitions/fly.TCPHealthcheck" + }, + "timeout": { + "description": "The time in seconds to wait for the check to complete.", + "type": "integer" + }, + "unhealthy": { + "description": "Unhealthy policy that determines what action to take if a container is deemed unhealthy", + "allOf": [ + { + "$ref": "#/definitions/fly.UnhealthyPolicy" + } + ] + } + } + }, + "fly.ContainerHealthcheckKind": { + "type": "string", + "enum": [ + "readiness", + "liveness" + ], + "x-enum-varnames": [ + "Readiness", + "Liveness" + ] + }, + "fly.ContainerHealthcheckScheme": { + "type": "string", + "enum": [ + "http", + "https" + ], + "x-enum-varnames": [ + "HTTP", + "HTTPS" + ] + }, + "fly.DNSConfig": { + "type": "object", + "properties": { + "dns_forward_rules": { + "type": "array", + "items": { + "$ref": "#/definitions/fly.dnsForwardRule" + } + }, + "hostname": { + "type": "string" + }, + "hostname_fqdn": { + "type": "string" + }, + "nameservers": { + "type": "array", + "items": { + "type": "string" + } + }, + "options": { + "type": "array", + "items": { + "$ref": "#/definitions/fly.dnsOption" + } + }, + "searches": { + "type": "array", + "items": { + "type": "string" + } + }, + "skip_registration": { + "type": "boolean" + } + } + }, + "fly.EnvFrom": { + "description": "EnvVar defines an environment variable to be populated from a machine field, env_var", + "type": "object", + "properties": { + "env_var": { + "description": "EnvVar is required and is the name of the environment variable that will be set from the\nsecret. It must be a valid environment variable name.", + "type": "string" + }, + "field_ref": { + "description": "FieldRef selects a field of the Machine: supports id, version, app_name, private_ip, region, image.", + "type": "string", + "enum": [ + "id", + "version", + "app_name", + "private_ip", + "region", + "image" + ] + } + } + }, + "fly.ExecHealthcheck": { + "type": "object", + "properties": { + "command": { + "description": "The command to run to check the health of the container (e.g. [\"cat\", \"/tmp/healthy\"])", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "fly.File": { + "description": "A file that will be written to the Machine. One of RawValue or SecretName must be set.", + "type": "object", + "properties": { + "guest_path": { + "description": "GuestPath is the path on the machine where the file will be written and must be an absolute path.\nFor example: /full/path/to/file.json", + "type": "string" + }, + "image_config": { + "description": "The name of an image to use the OCI image config as the file contents.", + "type": "string" + }, + "mode": { + "description": "Mode bits used to set permissions on this file as accepted by chmod(2).", + "type": "integer" + }, + "raw_value": { + "description": "The base64 encoded string of the file contents.", + "type": "string" + }, + "secret_name": { + "description": "The name of the secret that contains the base64 encoded file contents.", + "type": "string" + } + } + }, + "fly.HTTPHealthcheck": { + "type": "object", + "properties": { + "headers": { + "description": "Additional headers to send with the request", + "type": "array", + "items": { + "$ref": "#/definitions/fly.MachineHTTPHeader" + } + }, + "method": { + "description": "The HTTP method to use to when making the request", + "type": "string" + }, + "path": { + "description": "The path to send the request to", + "type": "string" + }, + "port": { + "description": "The port to connect to, often the same as internal_port", + "type": "integer" + }, + "scheme": { + "description": "Whether to use http or https", + "allOf": [ + { + "$ref": "#/definitions/fly.ContainerHealthcheckScheme" + } + ] + }, + "tls_server_name": { + "description": "If the protocol is https, the hostname to use for TLS certificate validation", + "type": "string" + }, + "tls_skip_verify": { + "description": "If the protocol is https, whether or not to verify the TLS certificate", + "type": "boolean" + } + } + }, + "fly.HTTPOptions": { + "type": "object", + "properties": { + "compress": { + "type": "boolean" + }, + "h2_backend": { + "type": "boolean" + }, + "headers_read_timeout": { + "type": "integer" + }, + "idle_timeout": { + "type": "integer" + }, + "replay_cache": { + "type": "array", + "items": { + "$ref": "#/definitions/fly.ReplayCache" + } + }, + "response": { + "$ref": "#/definitions/fly.HTTPResponseOptions" + } + } + }, + "fly.HTTPResponseOptions": { + "type": "object", + "properties": { + "headers": { + "type": "object", + "additionalProperties": {} + }, + "pristine": { + "type": "boolean" + } + } + }, + "fly.MachineCacheDrive": { + "type": "object", + "properties": { + "size_mb": { + "type": "integer" + } + } + }, + "fly.MachineCheck": { + "type": "object", + "properties": { + "grace_period": { + "description": "The time to wait after a VM starts before checking its health", + "type": "string", + "example": "1s" + }, + "headers": { + "type": "array", + "items": { + "$ref": "#/definitions/fly.MachineHTTPHeader" + } + }, + "interval": { + "description": "The time between connectivity checks", + "type": "string", + "example": "15s" + }, + "kind": { + "description": "Kind of the check (informational, readiness)", + "type": "string", + "enum": [ + "informational", + "readiness" + ] + }, + "method": { + "description": "For http checks, the HTTP method to use to when making the request", + "type": "string" + }, + "path": { + "description": "For http checks, the path to send the request to", + "type": "string" + }, + "port": { + "description": "The port to connect to, often the same as internal_port", + "type": "integer" + }, + "protocol": { + "description": "For http checks, whether to use http or https", + "type": "string" + }, + "timeout": { + "description": "The maximum time a connection can take before being reported as failing its health check", + "type": "string", + "example": "2s" + }, + "tls_server_name": { + "description": "If the protocol is https, the hostname to use for TLS certificate validation", + "type": "string" + }, + "tls_skip_verify": { + "description": "For http checks with https protocol, whether or not to verify the TLS certificate", + "type": "boolean" + }, + "type": { + "description": "tcp or http", + "type": "string" + } + } + }, + "fly.MachineConfig": { + "type": "object", + "properties": { + "auto_destroy": { + "description": "Optional boolean telling the Machine to destroy itself once it’s complete (default false)", + "type": "boolean" + }, + "cache_drive": { + "$ref": "#/definitions/fly.MachineCacheDrive" + }, + "checks": { + "description": "An optional object that defines one or more named top-level checks. The key for each check is the check name.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/fly.MachineCheck" + } + }, + "containers": { + "description": "Containers are a list of containers that will run in the machine. Currently restricted to\nonly specific organizations.", + "type": "array", + "items": { + "$ref": "#/definitions/fly.ContainerConfig" + } + }, + "disable_machine_autostart": { + "description": "Deprecated: use Service.Autostart instead", + "type": "boolean" + }, + "dns": { + "$ref": "#/definitions/fly.DNSConfig" + }, + "env": { + "description": "An object filled with key/value pairs to be set as environment variables", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "files": { + "type": "array", + "items": { + "$ref": "#/definitions/fly.File" + } + }, + "guest": { + "$ref": "#/definitions/fly.MachineGuest" + }, + "image": { + "description": "The docker image to run", + "type": "string" + }, + "init": { + "$ref": "#/definitions/fly.MachineInit" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "metrics": { + "$ref": "#/definitions/fly.MachineMetrics" + }, + "mounts": { + "type": "array", + "items": { + "$ref": "#/definitions/fly.MachineMount" + } + }, + "processes": { + "type": "array", + "items": { + "$ref": "#/definitions/fly.MachineProcess" + } + }, + "restart": { + "$ref": "#/definitions/fly.MachineRestart" + }, + "rootfs": { + "$ref": "#/definitions/fly.MachineRootfs" + }, + "schedule": { + "type": "string" + }, + "services": { + "type": "array", + "items": { + "$ref": "#/definitions/fly.MachineService" + } + }, + "size": { + "description": "Deprecated: use Guest instead", + "type": "string" + }, + "spot": { + "$ref": "#/definitions/fly.MachineSpot" + }, + "standbys": { + "description": "Standbys enable a machine to be a standby for another. In the event of a hardware failure,\nthe standby machine will be started.", + "type": "array", + "items": { + "type": "string" + } + }, + "statics": { + "type": "array", + "items": { + "$ref": "#/definitions/fly.Static" + } + }, + "stop_config": { + "$ref": "#/definitions/fly.StopConfig" + } + } + }, + "fly.MachineGuest": { + "type": "object", + "properties": { + "cpu_kind": { + "type": "string" + }, + "cpus": { + "type": "integer" + }, + "gpu_kind": { + "type": "string" + }, + "gpus": { + "type": "integer" + }, + "host_dedication_id": { + "type": "string" + }, + "kernel_args": { + "type": "array", + "items": { + "type": "string" + } + }, + "max_memory_mb": { + "type": "integer" + }, + "memory_mb": { + "type": "integer" + }, + "persist_rootfs": { + "description": "Deprecated: use MachineConfig.Rootfs instead", + "type": "string", + "enum": [ + "never", + "always", + "restart" + ] + } + } + }, + "fly.MachineHTTPHeader": { + "description": "For http checks, an array of objects with string field Name and array of strings field Values. The key/value pairs specify header and header values that will get passed with the check call.", + "type": "object", + "properties": { + "name": { + "description": "The header name", + "type": "string" + }, + "values": { + "description": "The header value", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "fly.MachineInit": { + "type": "object", + "properties": { + "cmd": { + "type": "array", + "items": { + "type": "string" + } + }, + "entrypoint": { + "type": "array", + "items": { + "type": "string" + } + }, + "exec": { + "type": "array", + "items": { + "type": "string" + } + }, + "kernel_args": { + "type": "array", + "items": { + "type": "string" + } + }, + "swap_size_mb": { + "type": "integer" + }, + "tty": { + "type": "boolean" + } + } + }, + "fly.MachineMetrics": { + "type": "object", + "properties": { + "https": { + "type": "boolean" + }, + "path": { + "type": "string" + }, + "port": { + "type": "integer" + } + } + }, + "fly.MachineMount": { + "type": "object", + "properties": { + "add_size_gb": { + "type": "integer" + }, + "encrypted": { + "type": "boolean" + }, + "extend_threshold_percent": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "size_gb": { + "type": "integer" + }, + "size_gb_limit": { + "type": "integer" + }, + "volume": { + "type": "string" + } + } + }, + "fly.MachinePort": { + "type": "object", + "properties": { + "end_port": { + "type": "integer" + }, + "force_https": { + "type": "boolean" + }, + "handlers": { + "type": "array", + "items": { + "type": "string" + } + }, + "http_options": { + "$ref": "#/definitions/fly.HTTPOptions" + }, + "port": { + "type": "integer" + }, + "proxy_proto_options": { + "$ref": "#/definitions/fly.ProxyProtoOptions" + }, + "start_port": { + "type": "integer" + }, + "tls_options": { + "$ref": "#/definitions/fly.TLSOptions" + } + } + }, + "fly.MachineProcess": { + "type": "object", + "properties": { + "cmd": { + "type": "array", + "items": { + "type": "string" + } + }, + "entrypoint": { + "type": "array", + "items": { + "type": "string" + } + }, + "env": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "env_from": { + "description": "EnvFrom can be provided to set environment variables from machine fields.", + "type": "array", + "items": { + "$ref": "#/definitions/fly.EnvFrom" + } + }, + "exec": { + "type": "array", + "items": { + "type": "string" + } + }, + "ignore_app_secrets": { + "description": "IgnoreAppSecrets can be set to true to ignore the secrets for the App the Machine belongs to\nand only use the secrets provided at the process level. The default/legacy behavior is to use\nthe secrets provided at the App level.", + "type": "boolean" + }, + "secrets": { + "description": "Secrets can be provided at the process level to explicitly indicate which secrets should be\nused for the process. If not provided, the secrets provided at the machine level will be used.", + "type": "array", + "items": { + "$ref": "#/definitions/fly.MachineSecret" + } + }, + "user": { + "type": "string" + } + } + }, + "fly.MachineRestart": { + "description": "The Machine restart policy defines whether and how flyd restarts a Machine after its main process exits. See https://fly.io/docs/machines/guides-examples/machine-restart-policy/.", + "type": "object", + "properties": { + "gpu_bid_price": { + "description": "GPU bid price for spot Machines.", + "type": "number" + }, + "max_retries": { + "description": "When policy is on-failure, the maximum number of times to attempt to restart the Machine before letting it stop.", + "type": "integer" + }, + "policy": { + "description": "* no - Never try to restart a Machine automatically when its main process exits, whether that’s on purpose or on a crash.\n* always - Always restart a Machine automatically and never let it enter a stopped state, even when the main process exits cleanly.\n* on-failure - Try up to MaxRetries times to automatically restart the Machine if it exits with a non-zero exit code. Default when no explicit policy is set, and for Machines with schedules.\n* spot-price - Starts the Machine only when there is capacity and the spot price is less than or equal to the bid price.", + "type": "string", + "enum": [ + "no", + "always", + "on-failure", + "spot-price" + ] + } + } + }, + "fly.MachineRootfs": { + "type": "object", + "properties": { + "persist": { + "type": "string", + "enum": [ + "never", + "always", + "restart" + ] + }, + "size_gb": { + "type": "integer" + } + } + }, + "fly.MachineSecret": { + "description": "A Secret needing to be set in the environment of the Machine. env_var is required", + "type": "object", + "properties": { + "env_var": { + "description": "EnvVar is required and is the name of the environment variable that will be set from the\nsecret. It must be a valid environment variable name.", + "type": "string" + }, + "name": { + "description": "Name is optional and when provided is used to reference a secret name where the EnvVar is\ndifferent from what was set as the secret name.", + "type": "string" + } + } + }, + "fly.MachineService": { + "type": "object", + "properties": { + "autostart": { + "type": "boolean" + }, + "autostop": { + "description": "Accepts a string (new format) or a boolean (old format). For backward compatibility with older clients, the API continues to use booleans for \"off\" and \"stop\" in responses.\n* \"off\" or false - Do not autostop the Machine.\n* \"stop\" or true - Automatically stop the Machine.\n* \"suspend\" - Automatically suspend the Machine, falling back to a full stop if this is not possible.", + "type": "string", + "enum": [ + "off", + "stop", + "suspend" + ] + }, + "checks": { + "description": "An optional list of service checks", + "type": "array", + "items": { + "$ref": "#/definitions/fly.MachineServiceCheck" + } + }, + "concurrency": { + "$ref": "#/definitions/fly.MachineServiceConcurrency" + }, + "force_instance_description": { + "type": "string" + }, + "force_instance_key": { + "type": "string" + }, + "internal_port": { + "type": "integer" + }, + "min_machines_running": { + "type": "integer" + }, + "ports": { + "type": "array", + "items": { + "$ref": "#/definitions/fly.MachinePort" + } + }, + "protocol": { + "type": "string" + } + } + }, + "fly.MachineServiceCheck": { + "type": "object", + "properties": { + "grace_period": { + "description": "The time to wait after a VM starts before checking its health", + "type": "string", + "example": "1s" + }, + "headers": { + "type": "array", + "items": { + "$ref": "#/definitions/fly.MachineHTTPHeader" + } + }, + "interval": { + "description": "The time between connectivity checks", + "type": "string", + "example": "15s" + }, + "method": { + "description": "For http checks, the HTTP method to use to when making the request", + "type": "string" + }, + "path": { + "description": "For http checks, the path to send the request to", + "type": "string" + }, + "port": { + "description": "The port to connect to, often the same as internal_port", + "type": "integer" + }, + "protocol": { + "description": "For http checks, whether to use http or https", + "type": "string" + }, + "timeout": { + "description": "The maximum time a connection can take before being reported as failing its health check", + "type": "string", + "example": "2s" + }, + "tls_server_name": { + "description": "If the protocol is https, the hostname to use for TLS certificate validation", + "type": "string" + }, + "tls_skip_verify": { + "description": "For http checks with https protocol, whether or not to verify the TLS certificate", + "type": "boolean" + }, + "type": { + "description": "tcp or http", + "type": "string" + } + } + }, + "fly.MachineServiceConcurrency": { + "type": "object", + "properties": { + "hard_limit": { + "type": "integer" + }, + "soft_limit": { + "type": "integer" + }, + "type": { + "type": "string" + } + } + }, + "fly.MachineSpot": { + "type": "object", + "properties": { + "max_price_fraction": { + "description": "MaxPriceFraction is the maximum fraction of the full Machine price you will pay for this Machine. Range: (0, 1.0]", + "type": "number" + } + } + }, + "fly.ProxyProtoOptions": { + "type": "object", + "properties": { + "version": { + "type": "string" + } + } + }, + "fly.ReplayCache": { + "type": "object", + "properties": { + "allow_bypass": { + "type": "boolean" + }, + "name": { + "description": "Name of the cookie or header to key the cache on", + "type": "string" + }, + "path_prefix": { + "type": "string" + }, + "ttl_seconds": { + "type": "integer" + }, + "type": { + "description": "Currently either \"cookie\" or \"header\"", + "type": "string", + "enum": [ + "cookie", + "header" + ] + } + } + }, + "fly.Static": { + "type": "object", + "required": [ + "guest_path", + "url_prefix" + ], + "properties": { + "guest_path": { + "type": "string" + }, + "index_document": { + "type": "string" + }, + "tigris_bucket": { + "type": "string" + }, + "url_prefix": { + "type": "string" + } + } + }, + "fly.StopConfig": { + "type": "object", + "properties": { + "signal": { + "type": "string", + "enum": [ + "SIGHUP", + "SIGINT", + "SIGQUIT", + "SIGKILL", + "SIGUSR1", + "SIGUSR2", + "SIGTERM" + ] + }, + "timeout": { + "type": "string", + "example": "10s" + } + } + }, + "fly.TCPHealthcheck": { + "type": "object", + "properties": { + "port": { + "description": "The port to connect to, often the same as internal_port", + "type": "integer" + } + } + }, + "fly.TLSOptions": { + "type": "object", + "properties": { + "alpn": { + "type": "array", + "items": { + "type": "string" + } + }, + "default_self_signed": { + "type": "boolean" + }, + "versions": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "fly.UnhealthyPolicy": { + "type": "string", + "enum": [ + "stop" + ], + "x-enum-varnames": [ + "UnhealthyPolicyStop" + ] + }, + "fly.dnsForwardRule": { + "type": "object", + "properties": { + "addr": { + "type": "string" + }, + "basename": { + "type": "string" + } + } + }, + "fly.dnsOption": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "string" + } + } + }, + "flydv1.ExecResponse": { + "type": "object", + "properties": { + "exit_code": { + "type": "integer" + }, + "exit_signal": { + "type": "integer" + }, + "stderr": { + "type": "string" + }, + "stdout": { + "type": "string" + } + } + }, + "flyio.Access": { + "type": "object", + "properties": { + "action": { + "$ref": "#/definitions/resset.Action" + }, + "app_feature": { + "type": "string" + }, + "appid": { + "type": "integer" + }, + "cluster": { + "type": "string" + }, + "command": { + "type": "array", + "items": { + "type": "string" + } + }, + "feature": { + "type": "string" + }, + "machine": { + "type": "string" + }, + "machine_feature": { + "type": "string" + }, + "mutation": { + "type": "string" + }, + "orgid": { + "type": "integer" + }, + "sourceApp": { + "type": "string" + }, + "sourceMachine": { + "type": "string" + }, + "sourceOrganization": { + "type": "string" + }, + "storage_object": { + "type": "string" + }, + "volume": { + "type": "string" + } + } + }, + "listCertificatesResponse": { + "type": "object", + "properties": { + "certificates": { + "type": "array", + "items": { + "$ref": "#/definitions/CertificateSummary" + } + }, + "next_cursor": { + "type": "string" + }, + "total_count": { + "type": "integer" + } + } + }, + "listIPAssignmentsResponse": { + "type": "object", + "properties": { + "ips": { + "type": "array", + "items": { + "$ref": "#/definitions/IPAssignment" + } + } + } + }, + "macaroon.CaveatSet": { + "type": "object", + "properties": { + "caveats": { + "type": "array", + "items": {} + } + } + }, + "macaroon.Nonce": { + "type": "object", + "properties": { + "kid": { + "type": "array", + "items": { + "type": "integer" + } + }, + "proof": { + "type": "boolean" + }, + "rnd": { + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "main.TokenAccess": { + "type": "object", + "properties": { + "action": { + "description": "Action is the action being taken on the specified resource. This is the\ncombination of individual action characters (e.g \"rw\")\n - r: read\n - w: write\n - c: create\n - d: delete\n - C: control", + "allOf": [ + { + "$ref": "#/definitions/resset.Action" + } + ] + }, + "app_feature": { + "description": "AppFeature is a named set of functionality associated with the app. If\nthis is specified, the AppName field must be set.\n - images: images in the fly.io registry", + "type": "string" + }, + "app_name": { + "description": "AppName is the name of the app being accessed.", + "type": "string" + }, + "command": { + "description": "Command is the command being executed on a machine. If this is specified,\nthe Machine must be set.", + "type": "array", + "items": { + "type": "string" + } + }, + "machine_feature": { + "description": "MachineFeature is a named set of functionality associated with the\nmachine. If this is specified, the Machine field must be set.\n - metadata: machine metadata service\n - oidc: OIDC tokens\n - kmstoken: Petsem tokens for KMS access", + "type": "string" + }, + "machine_id": { + "description": "MachineID is the ID of the machine being accessed (e.g. 7811701f564258).", + "type": "string" + }, + "mutation": { + "description": "Mutation is the GraphQL mutation being performed.", + "type": "string" + }, + "org_feature": { + "description": "OrgFeature is a named set of functionality associated with the\norganization. If this is specified, the OrgSlug field must be set.\n - wg: WireGuard peers\n - builder: remote builders\n - addon: addons\n - membership: organization membership\n - billing: billing\n - litefs-cloud: LiteFS Cloud\n - authentication: authentication settings", + "type": "string" + }, + "org_slug": { + "description": "OrgSlug is the slug of the organization being accessed.", + "type": "string" + }, + "source_machine": { + "description": "SourceMachine is the machine ID of the actor attempting access.", + "type": "string" + }, + "storage_object": { + "description": "StorageObject is the storage object being accessed. If this is specified,\nthe OrgSlug must be set.", + "type": "string" + }, + "volume_id": { + "description": "VolumeID is the encoded ID of the volume being accessed (e.g.\nvol_r1p6pln1k9m9j7zr).", + "type": "string" + } + } + }, + "main.getPlacementsRequest": { + "type": "object", + "required": [ + "org_slug" + ], + "properties": { + "compute": { + "description": "Resource requirements for the Machine to simulate. Defaults to a performance-1x machine", + "allOf": [ + { + "$ref": "#/definitions/fly.MachineGuest" + } + ] + }, + "count": { + "description": "Number of machines to simulate placement.\nDefaults to 0, which returns the org-specific limit for each region.", + "type": "integer" + }, + "org_slug": { + "type": "string", + "example": "personal" + }, + "region": { + "description": "Region expression for placement as a comma-delimited set of regions or aliases.\nDefaults to \"[region],any\", to prefer the API endpoint's local region with any other region as fallback.", + "type": "string", + "example": "lhr,eu" + }, + "volume_name": { + "type": "string", + "example": "" + }, + "volume_size_bytes": { + "type": "integer" + }, + "weights": { + "description": "Optional weights to override default placement preferences.", + "allOf": [ + { + "$ref": "#/definitions/placement.Weights" + } + ], + "example": { + "region": 1000, + "spread": 0 + } + } + } + }, + "main.getPlacementsResponse": { + "type": "object", + "properties": { + "regions": { + "type": "array", + "items": { + "$ref": "#/definitions/placement.RegionPlacement" + } + } + } + }, + "main.memoryResponse": { + "type": "object", + "properties": { + "available_mb": { + "type": "integer" + }, + "limit_mb": { + "type": "integer" + } + } + }, + "main.reclaimMemoryRequest": { + "type": "object", + "properties": { + "amount_mb": { + "type": "integer" + } + } + }, + "main.reclaimMemoryResponse": { + "type": "object", + "properties": { + "actual_mb": { + "type": "integer" + } + } + }, + "main.regionResponse": { + "type": "object", + "properties": { + "nearest": { + "type": "string" + }, + "regions": { + "type": "array", + "items": { + "$ref": "#/definitions/main.regionRow" + } + } + } + }, + "main.regionRow": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "deprecated": { + "type": "boolean" + }, + "gateway_available": { + "type": "boolean" + }, + "geo_region": { + "type": "string" + }, + "latitude": { + "type": "number" + }, + "longitude": { + "type": "number" + }, + "name": { + "type": "string" + }, + "requires_paid_plan": { + "type": "boolean" + } + } + }, + "main.setMemoryLimitRequest": { + "type": "object", + "properties": { + "limit_mb": { + "type": "integer" + } + } + }, + "main.statusCode": { + "type": "string", + "enum": [ + "unknown", + "insufficient_capacity" + ], + "x-enum-varnames": [ + "unknown", + "capacityErr" + ] + }, + "main.tokenInfo": { + "type": "object", + "properties": { + "apps": { + "type": "array", + "items": { + "type": "string" + } + }, + "org_slug": { + "type": "string" + }, + "organization": { + "type": "string" + }, + "restricted_to_machine": { + "description": "Machine the token is restricted to (FromMachine caveat)", + "type": "string" + }, + "source_machine_id": { + "description": "Machine making the request", + "type": "string" + }, + "token_id": { + "type": "string" + }, + "user": { + "description": "User identifier if token is for a user", + "type": "string" + } + } + }, + "metadataValueResponse": { + "type": "object", + "properties": { + "value": { + "type": "string" + } + } + }, + "placement.RegionPlacement": { + "type": "object", + "properties": { + "concurrency": { + "type": "integer" + }, + "count": { + "type": "integer" + }, + "region": { + "type": "string" + } + } + }, + "placement.Weights": { + "type": "object", + "additionalProperties": { + "type": "integer" + } + }, + "resset.Action": { + "type": "integer", + "enum": [ + 1, + 2, + 4, + 8, + 16, + 31, + 0 + ], + "x-enum-varnames": [ + "ActionRead", + "ActionWrite", + "ActionCreate", + "ActionDelete", + "ActionControl", + "ActionAll", + "ActionNone" + ] + }, + "root.VerifiedToken": { + "type": "object", + "properties": { + "caveats": { + "$ref": "#/definitions/macaroon.CaveatSet" + }, + "header": { + "type": "string" + }, + "nonce": { + "$ref": "#/definitions/macaroon.Nonce" + }, + "permission_token": { + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "updateMetadataRequest": { + "type": "object", + "properties": { + "machine_version": { + "type": "string" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "updated_at": { + "type": "string" + } + } + }, + "upsertMetadataKeyRequest": { + "type": "object", + "properties": { + "updated_at": { + "type": "string" + }, + "value": { + "type": "string" + } + } + } + }, + "tags": [ + { + "description": "This site hosts documentation generated from the Fly.io Machines API OpenAPI specification. Visit our complete [Machines API docs](https://fly.io/docs/machines/api/apps-resource/) for details about using the Apps resource.", + "name": "Apps" + }, + { + "description": "This site hosts documentation generated from the Fly.io Machines API OpenAPI specification. Visit our complete [Machines API docs](https://fly.io/docs/machines/api/machines-resource/) for details about using the Machines resource.", + "name": "Machines" + }, + { + "description": "This site hosts documentation generated from the Fly.io Machines API OpenAPI specification. Visit our complete [Machines API docs](https://fly.io/docs/machines/api/certificates-resource/) for details about using the TLS Certificates resource.\n", + "name": "TLS Certificates" + }, + { + "description": "This site hosts documentation generated from the Fly.io Machines API OpenAPI specification. Visit our complete [Machines API docs](https://fly.io/docs/machines/api/volumes-resource/) for details about using the Volumes resource.", + "name": "Volumes" + } + ], + "externalDocs": { + "url": "https://fly.io/docs/machines/working-with-machines/" + } +} \ No newline at end of file