Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/smoke.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 }}
64 changes: 62 additions & 2 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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 —
Expand Down Expand Up @@ -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": "<resource-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": "<site>"}`.
`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:
Expand Down
42 changes: 42 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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" }

Expand Down
27 changes: 22 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -202,7 +219,7 @@ Common commands (also wired as `mise run <task>`):
- 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`).

Expand Down
32 changes: 32 additions & 0 deletions crates/stackless-fly/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
28 changes: 28 additions & 0 deletions crates/stackless-fly/src/codes.rs
Original file line number Diff line number Diff line change
@@ -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,
];
Loading
Loading