From 58fc673ada75c91e2a1e3be1a98e4139c24b551f Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Sat, 5 Sep 2026 22:37:08 -0700 Subject: [PATCH 1/2] Align guide docs with the code on main A page-by-page audit of docs/guide against the current crates found names that no longer exist, behaviour descriptions the code contradicts, and public surface with no coverage. This commit fixes what the audit turned up. Phantom names: dispatch_with_config / dispatch_with_config_handle (fastly, cloudflare, architecture), AxumProxyClient::default(), EDGEZERO_SECRET_ prefix, wrangler secret put --binding, diff.rs entry point, dispatch_with_*_handle, crate-root imports for types that live in proxy:: / context:: / dev_server::. Behaviour: response streaming is preserved only on Cloudflare (Fastly, Spin and Axum buffer); duplicate routes panic at build rather than first-registered-wins; Axum honours the axum.toml port through the CLI and reads EDGEZERO__LOGGING__LEVEL, not edgezero.toml; Axum KV files are .edgezero/kv--.redb; healthcheck emits status-code only when an HTTP status arrived and degrades to service-level without a token; EDGEZERO_MANIFEST and the missing-manifest fallback apply to build/deploy/serve only; Cloudflare local push selects by --binding; deploy actions use cache/restore@v6 + cache/save@v6 and no checkout; the cache key includes the workspace path and build-args hash. Coverage: Fastly custom entry points (runtime_env_config, dispatch_with_registries, RUNTIME_ENV_STORE_NAME and the two footguns); FastlyService / CloudflareService builders; store extractors (Kv, Config, Secrets, AppConfig); FnMiddleware and middleware_arc; app! argument list; adapter metadata component/host/port and auth-* command overrides; EDGEZERO__LOGGING__* rows; config push --staging as the supported staging path; Spin everywhere it was missing (landing page, platform table, architecture, roadmap, overview tests and capability table, and Logging / Proxy / Context / Testing sections on its page); Axum KV and Secret Store sections; Cloudflare Secret Store and the kv/config merged-id collision; scaffold tree and generated CLI surface; KV page added to the sidebar. --- docs/.vitepress/config.mts | 1 + docs/guide/adapters/axum.md | 58 ++++++++++++--- docs/guide/adapters/cloudflare.md | 48 +++++++++--- docs/guide/adapters/fastly.md | 82 ++++++++++++++++++--- docs/guide/adapters/overview.md | 38 +++++++++- docs/guide/adapters/spin.md | 67 +++++++++++++++++ docs/guide/architecture.md | 10 ++- docs/guide/blob-app-config-migration.md | 56 ++++++++++---- docs/guide/cli-reference.md | 27 ++++--- docs/guide/cli-walkthrough.md | 3 + docs/guide/configuration.md | 67 +++++++++++++---- docs/guide/deploy-github-actions.md | 17 ++++- docs/guide/handlers.md | 24 ++++++ docs/guide/kv.md | 2 +- docs/guide/manifest-store-migration.md | 11 ++- docs/guide/middleware.md | 10 ++- docs/guide/proxying.md | 2 +- docs/guide/roadmap.md | 8 +- docs/guide/routing.md | 7 +- docs/guide/streaming.md | 14 +++- docs/guide/what-is-edgezero.md | 1 + docs/index.md | 2 + docs/specs/edgezero-deploy-github-action.md | 4 +- 23 files changed, 459 insertions(+), 100 deletions(-) diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 81ebdb14..4dd30b7b 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -38,6 +38,7 @@ export default defineConfig({ { text: 'Middleware', link: '/guide/middleware' }, { text: 'Streaming', link: '/guide/streaming' }, { text: 'Proxying', link: '/guide/proxying' }, + { text: 'KV Storage', link: '/guide/kv' }, ], }, { diff --git a/docs/guide/adapters/axum.md b/docs/guide/adapters/axum.md index 62813d79..0955739d 100644 --- a/docs/guide/adapters/axum.md +++ b/docs/guide/adapters/axum.md @@ -27,10 +27,11 @@ crates/my-app-adapter-axum/ The Axum entrypoint wires the adapter: ```rust +use edgezero_adapter_axum::dev_server::run_app; use my_app_core::App; fn main() -> anyhow::Result<()> { - edgezero_adapter_axum::run_app::() + run_app::() } ``` @@ -82,20 +83,26 @@ The binary is placed in `target/release/my-app-adapter-axum`. The Axum adapter provides a native HTTP client for proxying: ```rust -use edgezero_adapter_axum::AxumProxyClient; +use edgezero_adapter_axum::proxy::AxumProxyClient; use edgezero_core::proxy::ProxyService; -let client = AxumProxyClient::default(); +let client = AxumProxyClient::try_new()?; let response = ProxyService::new(client).forward(request).await?; ``` -This uses `reqwest` under the hood for outbound HTTP requests. +This uses `reqwest` under the hood for outbound HTTP requests. `try_new` is +fallible because it builds a `reqwest::Client`; it returns a `reqwest::Error` if +the TLS backend cannot be initialised on the host. ## Logging -The Axum adapter's `run_app` helper installs `simple_logger` and reads logging configuration -from `edgezero.toml` (level and `echo_stdout`). If you want a different logger, wire your own -entrypoint using `App::build_app()` and `AxumDevServer`. +The Axum adapter's `run_app` helper installs `simple_logger` at the level read from +`EDGEZERO__LOGGING__LEVEL`, falling back to `info` when the variable is unset or +unparseable. It does not read `edgezero.toml`, and `echo_stdout` has no effect on +the runtime. To install a different logger, set `owns_logging = true` on your `app!` +declaration so `run_app` skips its own logger, then install yours in `main`. Wiring +`App::build_app()` and `AxumDevServer` by hand remains the fallback if you also need +to control the bind address or store setup. ::: tip Logging status `run_app` wires logging automatically; custom entrypoints should install a logger explicitly. @@ -136,6 +143,31 @@ cargo test -p my-app-core cargo test -p my-app-adapter-axum ``` +## KV Storage + +Each declared `[stores.kv]` id resolves to a `redb`-backed store on disk under +`.edgezero/`, so values persist across dev-server restarts. The file name is +derived from the platform store name, which comes from +`EDGEZERO__STORES__KV____NAME` or defaults to the logical id: + +``` +.edgezero/kv--.redb +``` + +The database file grows over time and does not shrink after deletions. To reclaim +space, delete the file in `.edgezero/`; the data is lost. See [KV Storage](/guide/kv) +for the portable API. + +## Secret Store + +A declared `[stores.secrets]` id resolves to an `EnvSecretStore`, which looks up each +secret name verbatim in the process environment. Axum lists `secrets` in its +`single_store_kinds`, so only one secrets id may be declared: + +```bash +API_KEY=mysecret edgezero serve --adapter axum +``` + ## Config Store For local development, each declared `[stores.config]` id resolves to a @@ -205,8 +237,14 @@ CMD ["my-app-adapter-axum"] Configure the Axum adapter in `edgezero.toml`. See [Configuration](/guide/configuration) for the full manifest reference. -The `axum.toml` file is used by the Axum CLI helper to locate the crate and display the port. -The runtime currently binds to `127.0.0.1:8787` regardless of the `axum.toml` port value. +The `axum.toml` file is used by the Axum CLI helper to locate the crate and carry a +default port. `edgezero serve --adapter axum` resolves the bind address with this +precedence, highest first: the `EDGEZERO__ADAPTER__HOST` / `EDGEZERO__ADAPTER__PORT` +environment variables, then `[adapters.axum.adapter]` in `edgezero.toml`, then +`axum.toml`, then `127.0.0.1:8787`. The CLI passes the resolved address to the child +process as `EDGEZERO__ADAPTER__HOST` / `EDGEZERO__ADAPTER__PORT`. Running the binary +directly bypasses that resolution: it reads only those two environment variables and +otherwise falls back to `127.0.0.1:8787`. ## Development Workflow @@ -231,7 +269,7 @@ A typical development workflow: | Concurrency | Multi-threaded | Single-threaded | ::: tip Development Parity -While Axum provides a convenient development environment, always test on actual edge platforms before deploying. Some edge-specific features (KV stores, geolocation) aren't available in the Axum adapter. +While Axum provides a convenient development environment, always test on actual edge platforms before deploying. Some edge-specific features (geolocation) aren't available in the Axum adapter. ::: ## Next Steps diff --git a/docs/guide/adapters/cloudflare.md b/docs/guide/adapters/cloudflare.md index c22e99e6..6b80eefc 100644 --- a/docs/guide/adapters/cloudflare.md +++ b/docs/guide/adapters/cloudflare.md @@ -28,10 +28,10 @@ The Wrangler manifest configures your Worker: ```toml name = "my-app" main = "build/worker/shim.mjs" -compatibility_date = "2024-01-01" +compatibility_date = "2023-05-01" [build] -command = "edgezero build --adapter cloudflare" +command = "worker-build --release" ``` ### Entrypoint @@ -56,10 +56,14 @@ derived from the baked store ids and queried individually). Per-id request extensions automatically. No `edgezero.toml` is loaded by the runtime — see [the migration guide](../manifest-store-migration.md). -The low-level `dispatch()` helper remains available only for fully manual wiring and does not inject -store metadata. Prefer `run_app` or `dispatch_with_config` for normal use. -`dispatch_with_config_handle` exists for advanced/manual cases where you already have a prepared -`ConfigStoreHandle`. +For fully manual wiring, `CloudflareService::new(&app)` builds a dispatcher one +store at a time: `.with_config(binding)` (a KV binding name), +`.with_config_handle(handle)`, `.with_kv(binding)`, `.with_secrets()`, the +matching `.require_kv()` / `.require_secrets()` flags, and finally +`.dispatch(req)`. This path takes bindings verbatim and does not resolve +`EDGEZERO__STORES__*` selectors, so prefer `run_app` unless you are mocking a +backend. `dispatch_with_registries` is the registry-based dispatcher `run_app` +itself calls. ## Building @@ -98,7 +102,7 @@ wrangler deploy --cwd crates/my-app-adapter-cloudflare Cloudflare Workers use the global `fetch` API for outbound requests: ```rust -use edgezero_adapter_cloudflare::CloudflareProxyClient; +use edgezero_adapter_cloudflare::proxy::CloudflareProxyClient; use edgezero_core::proxy::ProxyService; let client = CloudflareProxyClient; @@ -124,7 +128,7 @@ Access Cloudflare-specific APIs via the request context extensions: ```rust use edgezero_core::context::RequestContext; -use edgezero_adapter_cloudflare::CloudflareRequestContext; +use edgezero_adapter_cloudflare::context::CloudflareRequestContext; async fn handler(ctx: RequestContext) -> Result { if let Some(cf_ctx) = CloudflareRequestContext::get(ctx.request()) { @@ -174,11 +178,37 @@ id = "abc123…" The binding name comes from `EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME` (defaulting to the logical id `app_config` when unset). Populate the -namespace via `wrangler kv:key put`. Missing bindings log a one-time +namespace via `wrangler kv key put`. Missing bindings log a one-time warning and the id is dropped from the registry. See [the migration guide](../manifest-store-migration.md) if you are coming from the pre-rewrite `[vars]`-backed JSON-string form. +KV and config share the same `[[kv_namespaces]]` binding space on Cloudflare, +so the same logical id must not appear under both `[stores.kv]` and +`[stores.config]`; both would resolve to a single underlying namespace at +runtime. `edgezero config validate` rejects the collision. + +## Secret Store + +Worker Secrets is a single flat bag with no namespace concept, so exactly one +`[stores.secrets]` id is permitted; `edgezero config validate --strict` rejects +more than one. Handlers read values through the `Secrets` extractor or +`ctx.secret_store(id)`, and a secret with no matching binding resolves to `None` +rather than erroring. + +```toml +# edgezero.toml +[stores.secrets] +ids = ["default"] +``` + +Populate secrets with the Wrangler CLI; there is no binding flag, since the +secret name is the binding: + +```bash +wrangler secret put API_KEY +``` + ## KV Storage Use Cloudflare KV for edge storage: diff --git a/docs/guide/adapters/fastly.md b/docs/guide/adapters/fastly.md index da0185e9..7b5de78a 100644 --- a/docs/guide/adapters/fastly.md +++ b/docs/guide/adapters/fastly.md @@ -25,7 +25,7 @@ crates/my-app-adapter-fastly/ The Fastly manifest configures your service: ```toml -manifest_version = 2 +manifest_version = 3 name = "my-app" language = "rust" authors = ["you@example.com"] @@ -36,6 +36,11 @@ authors = ["you@example.com"] url = "https://your-origin.example.com" ``` +`edgezero provision --adapter fastly` writes `[setup.kv_stores]`, +`[setup.secret_stores]` and `[setup.config_stores]` entries into `fastly.toml` +for the declared store ids; the `[local_server.*]` tables are the Viceroy-only +mirror of those stores. + ### Entrypoint The Fastly entrypoint wires the adapter: @@ -56,10 +61,14 @@ per-id `KV` / `Config` / `Secret` registries from the portable store metadata baked into `App` by the `app!` macro. No `edgezero.toml` is loaded by the runtime. -The low-level `dispatch()` helper remains available only for fully manual wiring and does not inject -store metadata. Prefer `run_app` or `dispatch_with_config` for normal use. -`dispatch_with_config_handle` exists for advanced/manual cases where you already have a prepared -`ConfigStoreHandle`. +For fully manual wiring, `FastlyService::new(&app)` builds a dispatcher one +store at a time: `.with_config(name)`, `.with_config_handle(handle)`, +`.with_kv(name)`, `.with_secrets()`, the matching `.require_kv()` / +`.require_secrets()` flags, and finally `.dispatch(req)`. This path does not +apply the runtime env overlay. A bare handle binds the config registry's +default key to `"default"` and does not resolve `EDGEZERO__STORES__*` +selectors, so prefer `run_app`, or see +[Custom entry points](#custom-entry-points) for full parity. ### Capturing raw-request signals (JA4, H2 fingerprint) @@ -105,6 +114,55 @@ edgezero_core::app!("edgezero.toml", owns_logging = true); or on a hand-written `Hooks` impl (`fn owns_logging() -> bool { true }`). Every adapter's `run_app` honors it, so the app is responsible for logger setup. +### Custom entry points + +Compute@Edge has no process environment, so the `EDGEZERO__*` runtime overrides +(logging settings, per-store platform names, the config-store `__KEY` selector) +are read from a Fastly Config Store named `edgezero_runtime_env`, exported as +`RUNTIME_ENV_STORE_NAME`. Entries in that store are service-scoped +(`EDGEZERO__SERVICES____…`, see [Config Store](#config-store)); +`runtime_env_config` translates them back to the canonical unscoped keys the +rest of the runtime reads. The name is fixed because staged deploys rely on it: +a staged deploy creates a per-service staging twin and links it into the staged +version under that same name. `run_app` and `run_app_with_request_extensions` +read the store for you. + +An entry point that does its own wiring must call `runtime_env_config` itself, +derive `FastlyLogging` from the result, and dispatch through +`dispatch_with_registries`: + +```rust +use edgezero_adapter_fastly::request::dispatch_with_registries; +use edgezero_adapter_fastly::{FastlyLogging, init_logger, runtime_env_config}; +use edgezero_core::app::Hooks as _; +use my_app_core::App; + +#[fastly::main] +fn main(req: fastly::Request) -> Result { + let stores = App::stores(); + let env = runtime_env_config(stores); + let logging = FastlyLogging::from(&env); + if logging.use_fastly_logger { + let endpoint = logging.endpoint.as_deref().unwrap_or("stdout"); + init_logger(endpoint, logging.level, logging.echo_stdout).expect("init logger"); + } + let app = App::build_app(); + Ok(dispatch_with_registries(&app, req, stores, &env, |_req, _ext| {})?) +} +``` + +Two footguns live on this path. `run_app_with_config` and a hand-built +`FastlyService` do **not** apply the env overlay, so staged and overridden +`__NAME` / `__KEY` selectors are silently ignored and every store falls back to +its baked-in default. And a hand-written `Hooks` impl inherits the default +`stores()`, which is empty; empty metadata derives no `EDGEZERO__STORES__*` keys +at all, so no override ever resolves. Such an impl must override `stores()` or +pass explicit `StoresMetadata`. + +`FastlyLogging::from(&EnvConfig)` derives `use_fastly_logger` from +`endpoint.is_some()`, which is what keeps a local Viceroy run off the reserved +`stdout` endpoint when no endpoint is configured. + ## Building Build for Fastly's Wasm target: @@ -113,8 +171,8 @@ Build for Fastly's Wasm target: # Using the CLI edgezero build --adapter fastly -# Or directly with cargo -cargo build -p my-app-adapter-fastly --target wasm32-wasip1 --release +# Or directly +fastly compute build -C crates/my-app-adapter-fastly ``` The compiled Wasm binary is placed in `target/wasm32-wasip1/release/`. @@ -128,7 +186,7 @@ Run locally with Viceroy (Fastly's local simulator): edgezero serve --adapter fastly # Or directly -fastly compute serve --skip-build +fastly compute serve -C crates/my-app-adapter-fastly ``` This starts a local server at `http://127.0.0.1:7676`. @@ -142,7 +200,7 @@ Deploy to Fastly Compute@Edge: edgezero deploy --adapter fastly # Or directly -fastly compute deploy +fastly compute deploy -C crates/my-app-adapter-fastly ``` ## Backends @@ -151,7 +209,7 @@ EdgeZero's Fastly proxy client uses **dynamic backends** derived from the target You do not need to predeclare backends in `fastly.toml` for EdgeZero proxying. ```rust -use edgezero_adapter_fastly::FastlyProxyClient; +use edgezero_adapter_fastly::proxy::FastlyProxyClient; use edgezero_core::proxy::ProxyService; let client = FastlyProxyClient; @@ -264,7 +322,9 @@ async fn handler(ctx: RequestContext) -> Result { ## Streaming -Fastly supports native streaming via `stream_to_client`. The adapter automatically converts `Body::stream` to Fastly's streaming APIs. +A `Body::Stream` response is drained into a `fastly::Body` before the adapter +returns, so the full payload is materialised in memory rather than streamed to +the client chunk by chunk. See the [Streaming guide](/guide/streaming) for examples and patterns. diff --git a/docs/guide/adapters/overview.md b/docs/guide/adapters/overview.md index 08745634..2702dd77 100644 --- a/docs/guide/adapters/overview.md +++ b/docs/guide/adapters/overview.md @@ -27,7 +27,7 @@ Adapters also expose `from_core_response` (or equivalent) to transform an `edgez - **Map HTTP status codes** verbatim - **Copy headers**, respecting casing rules enforced by the provider -- **Preserve streaming bodies** - `Body::Stream` should be written chunk-by-chunk to the provider output without buffering the entire payload +- **Preserve streaming bodies** - `Body::Stream` should be written chunk-by-chunk to the provider output without buffering the entire payload. Only Cloudflare does this today, via `Response::from_stream`. Fastly drains the stream into a `fastly::Body`, Spin collects it into a `Vec` capped at 16 MiB, and Axum buffers as well - **Handle encoding helpers** (`decode_gzip_stream`, `decode_brotli_stream`) where a provider requires transparent decompression ## Dispatch Helper @@ -59,13 +59,14 @@ Adapters implement `edgezero_core::proxy::ProxyClient` so handlers can forward o - Accept a `ProxyRequest` created with `ProxyRequest::from_request` - Build and send an outbound provider request, reusing headers and streaming the body without buffering - Convert the provider response into a `ProxyResponse`, again preserving streaming behaviour and normalising encodings -- Attach a diagnostic header (e.g., `x-edgezero-proxy`) identifying which adapter forwarded the call (Fastly and Cloudflare do this today) +- Attach a diagnostic header (e.g., `x-edgezero-proxy`) identifying which adapter forwarded the call (Fastly, Cloudflare, and Spin do this today; Axum does not) - Surface provider errors as `EdgeError::internal` so applications can decide how to respond ## Logging Initialisation Each adapter exports an `init_logger` helper for platform-specific logging backends. Fastly wires -`log_fastly`, Cloudflare currently no-ops, and Axum uses `simple_logger` in its `run_app` helper. +`log_fastly`, Cloudflare currently no-ops, Spin no-ops because Spin manages its own logging +internally, and Axum uses `simple_logger` in its `run_app` helper. New adapters should provide a comparable helper so apps consistently opt into logging. ## Contract Tests @@ -103,6 +104,18 @@ cargo test -p edgezero-adapter-cloudflare --features cloudflare --target wasm32- Install a `wasm-bindgen-cli` version that matches the workspace's `wasm-bindgen` entry in `Cargo.lock` before running the Cloudflare tests. +### Spin Tests + +Spin's adapter targets `wasm32-wasip2` and its contract suite runs under Wasmtime: + +```bash +rustup target add wasm32-wasip2 +export CARGO_TARGET_WASM32_WASIP2_RUNNER="wasmtime run" +cargo test -p edgezero-adapter-spin --features spin --target wasm32-wasip2 --test contract +``` + +The `wasmtime` version CI uses is pinned in `.tool-versions`. + ## Onboarding New Adapters When bringing up another adapter: @@ -112,7 +125,7 @@ When bringing up another adapter: 3. **Implement a `dispatch` wrapper** plus logging helper 4. **Wire up a `ProxyClient`** that streams bodies and normalises encodings 5. **Copy the contract test suite**, swapping in the new adapter types. Ensure the tests are gated to the target architecture if the adapter SDK does not compile for native hosts -6. **Register the adapter** with `edgezero-adapter::register_adapter` (typically in a `cli` module using the `ctor` crate) so the CLI can discover it dynamically +6. **Register the adapter** with `edgezero-adapter::register_adapter` (typically in a `cli` module using the `ctor` crate) so the CLI can discover it dynamically. To take part in `provision` and `config push`, override the relevant `Adapter` trait hooks: `single_store_kinds` and `merged_id_kinds` declare the platform's store shape, `validate_adapter_manifest` checks the adapter's own manifest, `provision` creates platform resources, and `push_config_entries` plus `read_config_entry` move config blobs. `single_store_kinds` and `merged_id_kinds` default to empty, `validate_adapter_manifest` defaults to accepting, `provision` defaults to a no-op, and the config push and read hooks default to reporting the operation as unsupported Adapters that fulfil these steps can be dropped into the EdgeZero CLI without requiring changes to application code. @@ -124,3 +137,20 @@ Adapters that fulfil these steps can be dropped into the EdgeZero CLI without re | [Cloudflare](/guide/adapters/cloudflare) | Cloudflare Workers | `wasm32-unknown-unknown` | Stable | | [Spin](/guide/adapters/spin) | Fermyon Spin | `wasm32-wasip2` | Stable | | [Axum](/guide/adapters/axum) | Native (Tokio) | Host | Stable | + +### Store Capabilities + +`single_store_kinds` lists the kinds that allow only one declared id; `merged_id_kinds` +lists the kinds that share one underlying platform resource, so declaring the same +logical id under both is a collision that `config validate` rejects. + +| Adapter | Single-store kinds | Merged kinds | Config GC | Staging lifecycle | +| ---------- | ------------------ | -------------- | --------- | ----------------- | +| Fastly | none | none | Yes | Yes | +| Cloudflare | `secrets` | `kv`, `config` | No | No | +| Spin | `secrets` | `kv`, `config` | No | No | +| Axum | `secrets` | none | No | No | + +Fastly is the only adapter implementing `gc_config_entries` and the staging lifecycle +actions (`DeployStaged`, `EmitVersion`, `Healthcheck`, `Rollback`); the others return +an unsupported error for those. diff --git a/docs/guide/adapters/spin.md b/docs/guide/adapters/spin.md index e9c0e57c..ef283b1b 100644 --- a/docs/guide/adapters/spin.md +++ b/docs/guide/adapters/spin.md @@ -226,6 +226,11 @@ api_token = { required = true, secret = true } api_token = "{{ api_token }}" ``` +`required = true` blocks `spin up` until the variable is supplied by a provider or +`SPIN_VARIABLE_`. For local development, declare `default = ""` with +`secret = true` instead so the component starts without one; that is the form the +demo's `spin.toml` uses. + `config validate` runs a within-secrets canonicalisation check: each `#[secret]` field value is lowercased to mirror the runtime `SpinSecretStore::get_bytes` lookup, must be a valid Spin variable name @@ -246,6 +251,68 @@ blocks to `spin.toml`, which requires knowing the component id. Resolution: adapter-set checks when `spin` is in the target list, so the failure surfaces before `provision` / `config push` run. +## Logging + +`edgezero_adapter_spin::init_logger()` is a no-op today because Spin manages its own +logging internally; `run_app` calls it unless your app sets `owns_logging = true`. An +`[adapters.spin.logging]` table in `edgezero.toml` is not consumed by the Spin runtime. +View output through `spin up` or your Spin host's log files. + +::: tip Logging status +Spin logging is handled by the runtime; install your own `log` implementation in the +entrypoint if you need structured output. +::: + +## Proxy Client + +The Spin adapter forwards outbound requests through `spin_sdk::http::send`: + +```rust +use edgezero_adapter_spin::proxy::SpinProxyClient; +use edgezero_core::proxy::ProxyService; + +let response = ProxyService::new(SpinProxyClient).forward(request).await?; +``` + +Proxied responses carry `x-edgezero-proxy: spin`. Every upstream host must be listed +in the component's `allowed_outbound_hosts` in `spin.toml` or the send fails at +runtime. + +## Context Access + +Access Spin-specific request metadata via the request context extensions: + +```rust +use edgezero_core::context::RequestContext; +use edgezero_adapter_spin::context::SpinRequestContext; + +async fn handler(ctx: RequestContext) -> Result { + if let Some(spin_ctx) = SpinRequestContext::get(ctx.request()) { + let client_addr = spin_ctx.client_addr; + let full_url = spin_ctx.full_url.as_deref(); + // ... + } + + // ... +} +``` + +Spin exposes this data through the `spin-client-addr` and `spin-full-url` headers +rather than a separate runtime object. + +## Testing + +Run contract tests for the Spin adapter: + +```bash +rustup target add wasm32-wasip2 +export CARGO_TARGET_WASM32_WASIP2_RUNNER="wasmtime run" +cargo test -p edgezero-adapter-spin --features spin --target wasm32-wasip2 --test contract +``` + +The tests execute the adapter's real `wasm32-wasip2` request path under Wasmtime. The +`wasmtime` version CI uses is pinned in `.tool-versions`. + ## Manifest Configuration Configure the Spin adapter in `edgezero.toml`. See diff --git a/docs/guide/architecture.md b/docs/guide/architecture.md index 33241fac..b01f54dd 100644 --- a/docs/guide/architecture.md +++ b/docs/guide/architecture.md @@ -12,6 +12,7 @@ edgezero/ │ ├── edgezero-adapter/ # Shared adapter traits and registry │ ├── edgezero-adapter-fastly/ # Fastly Compute@Edge bridge │ ├── edgezero-adapter-cloudflare/ # Cloudflare Workers bridge +│ ├── edgezero-adapter-spin/ # Fermyon Spin bridge │ ├── edgezero-adapter-axum/ # Native Axum/Tokio bridge │ └── edgezero-cli/ # CLI for scaffolding and dev server └── examples/ @@ -66,6 +67,12 @@ Adapters translate between provider-specific types and the portable core model: - Provides `CloudflareRequestContext` for Workers APIs - Implements `CloudflareProxyClient` for fetch operations +### edgezero-adapter-spin + +- Converts `spin_sdk::http::Request` to core request and back +- Provides `SpinRequestContext` for Spin-specific APIs +- Implements `SpinProxyClient` for outbound requests + ### edgezero-adapter-axum - Wraps `RouterService` in Axum/Tokio services @@ -94,7 +101,7 @@ Adapters translate between provider-specific types and the portable core model: │ Adapter │ │ - into_core_request(): Provider Request → Core Request │ │ - from_core_response(): Core Response → Provider Response │ -│ - run_app()/dispatch_with_config(): Canonical lifecycle │ +│ - run_app(): Canonical lifecycle │ │ - dispatch(): Low-level manual lifecycle │ └─────────────────────────────────────────────────────────────┘ │ @@ -123,6 +130,7 @@ Adapter crates use feature flags to gate provider SDKs and CLI integration: | -------------- | --------------------------- | -------------------------------------- | | `fastly` | edgezero-adapter-fastly | Fastly SDK integration | | `cloudflare` | edgezero-adapter-cloudflare | Workers SDK integration | +| `spin` | edgezero-adapter-spin | Spin SDK integration | | `cli` | adapter crates | Register adapters and scaffolding data | | `demo-example` | edgezero-cli | Bundled demo app for development | diff --git a/docs/guide/blob-app-config-migration.md b/docs/guide/blob-app-config-migration.md index 6fdea5d0..0a310ff2 100644 --- a/docs/guide/blob-app-config-migration.md +++ b/docs/guide/blob-app-config-migration.md @@ -105,8 +105,10 @@ is no `--local` flag because Axum's push IS always local. ### Cloudflare The push shells out to `wrangler kv bulk put --namespace-id= --remote` -with one entry: `(, )`. With `--local`, the same -command runs against `.wrangler/state` instead. +with one entry: `(, )`. With `--local`, the push runs +`wrangler kv bulk put --binding --local` against +`.wrangler/state`, selecting the namespace by binding name rather than by +namespace id. The bundled `edgezero` binary calls `wrangler` from your shell; your project's `wrangler.toml` selects the namespace. @@ -187,7 +189,7 @@ non-zero on a non-TTY (per spec §8.3's four-branch UX). ```sh # Cloudflare (per spec §10.2) - wrangler secret put demo_api_token --binding APP_SECRETS + wrangler secret put demo_api_token # Fastly fastly secret-store-entry create --store-id= --name=demo_api_token --value= @@ -196,7 +198,7 @@ non-zero on a non-TTY (per spec §8.3's four-branch UX). echo demo_api_token= >> .env # Axum local - EDGEZERO_SECRET_demo_api_token= cargo run -p -- serve --adapter axum + demo_api_token= cargo run -p -- serve --adapter axum ``` 3. Push the typed config: @@ -214,12 +216,30 @@ non-zero on a non-TTY (per spec §8.3's four-branch UX). ### Per-environment key override Spec 5.4 + 12.7: a single `.toml` covers dev / staging / -production. To swap which blob the runtime reads: +production. Two mechanisms swap which blob the runtime reads. + +For staging, use `--staging`. It writes the config under the +`_staging` key in the same store, so it never overwrites the +production key the live service reads: + +```sh + config push --adapter --staging + config diff --adapter --staging +``` + +`--staging` is mutually exclusive with `--key`, because the staging key is +derived from the store's logical id. On Fastly a staged deploy provisions a +per-service `edgezero_runtime_env_staging_` selector store and +links it automatically, so a staged version reads staged config without any +manual key override. Do not hand-set a `_staging` key in the production +`edgezero_runtime_env` store; that would make production serve staged config. + +For any other environment, `--key` is the general per-environment mechanism. +Each push lands at its own key: ```sh -# Push BOTH variants. Each lands at its own key. config push --adapter --key app_config - config push --adapter --key app_config_staging + config push --adapter --key app_config_canary ``` The override variable is `EDGEZERO__STORES__CONFIG____KEY` -- @@ -228,12 +248,12 @@ packs `default_key` into the `ConfigStoreBinding` at adapter init. **Where you set the override depends on the platform's variable mechanism.** -| Adapter | Where to set `EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY` | -| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Axum** | Process env: `EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY=app_config_staging serve --adapter axum` | -| **Cloudflare** | `.dev.vars` (local) or `wrangler.toml` `[vars]` (deployed) -- wrangler surfaces it to `env.var(...)` in the worker | -| **Spin** | `[application.variables]` in `spin.toml` (defaulted) plus `SPIN_VARIABLE_EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY=app_config_staging spin up` for a per-invocation override | -| **Fastly** | A dedicated `edgezero_runtime_env` Config Store (Compute@Edge has no process env). See below. | +| Adapter | Where to set `EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY` | +| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Axum** | Process env: `EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY=app_config_canary serve --adapter axum` | +| **Cloudflare** | `.dev.vars` (local) or `wrangler.toml` `[vars]` (deployed) -- wrangler surfaces it to `env.var(...)` in the worker | +| **Spin** | `[application.variables]` in `spin.toml` (defaulted) plus `SPIN_VARIABLE_EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY=app_config_canary spin up` for a per-invocation override | +| **Fastly** | A dedicated `edgezero_runtime_env` Config Store (Compute@Edge has no process env). See below. | #### Fastly specifically @@ -250,10 +270,15 @@ fastly config-store list --json | jq -r '.[] | select(.name=="edgezero_runtime_e fastly config-store-entry update \ --store-id= \ --key=EDGEZERO__SERVICES____STORES__CONFIG__APP_CONFIG__KEY \ - --value=app_config_staging \ + --value=app_config_canary \ --upsert ``` +This store is the one the ACTIVE (production) version reads, so never point +it at a `_staging` key. Staged config is isolated by the per-service +`edgezero_runtime_env_staging_` store that a staged deploy +creates and links for you. + Fastly runtime overrides are service-scoped because the Config Store can be linked to multiple services. Legacy unscoped `EDGEZERO__STORES__...` entries are not read; migrate manually managed entries by rewriting them under the service @@ -421,5 +446,6 @@ after push B reconstructs envelope B, not A. - Implementation plan: [`docs/superpowers/plans/2026-06-17-blob-app-config.md`](https://github.com/stackpop/edgezero) - Extractor source: `crates/edgezero-core/src/extractor.rs` - CLI push entry point: `crates/edgezero-cli/src/config.rs::run_config_push_typed` -- CLI diff entry point: `crates/edgezero-cli/src/diff.rs::run_config_diff_typed` +- CLI diff entry point: `crates/edgezero-cli/src/config.rs::run_config_diff_typed` + (`diff.rs` holds only the format renderers) - Fastly chunk-pointer helper: `crates/edgezero-adapter-fastly/src/chunked_config.rs` diff --git a/docs/guide/cli-reference.md b/docs/guide/cli-reference.md index 779c38e7..36bf6915 100644 --- a/docs/guide/cli-reference.md +++ b/docs/guide/cli-reference.md @@ -40,6 +40,7 @@ edgezero new my-app --dir /path/to/projects my-app/ ├── Cargo.toml ├── edgezero.toml +├── my-app.toml # typed app config read by `config validate` / `config push` ├── crates/ │ ├── my-app-core/ │ ├── my-app-cli/ @@ -139,10 +140,10 @@ edgezero serve --adapter axum **Provider behavior:** -- **Fastly**: Runs `fastly compute serve` +- **Fastly**: Runs `fastly compute serve -C ` - **Cloudflare**: Runs `wrangler dev` - **Spin**: Runs `spin up` -- **Axum**: Runs `cargo run -p ` +- **Axum**: Runs `cargo run --manifest-path /Cargo.toml` ### edgezero deploy @@ -183,7 +184,7 @@ edgezero deploy --adapter spin **Provider behavior:** -- **Fastly**: Runs `fastly compute deploy` +- **Fastly**: Runs `fastly compute deploy -C ` - **Cloudflare**: Runs `wrangler deploy` - **Spin**: Runs `spin deploy` @@ -236,9 +237,13 @@ edgezero healthcheck --adapter --service-id --version --domain < - `--retry-delay ` — seconds to wait between attempts. Default: `5`. - `--timeout ` — per-attempt connect/read timeout in seconds. Default: `10`. -Only a **staging** probe needs `FASTLY_API_TOKEN` (to resolve the staging IP); a -production probe just curls the domain and needs no token. Emits `healthy=` -and `status-code=`. Exits `0` only when the probe succeeds. +Only a **staging** probe needs `FASTLY_API_TOKEN` (to resolve the staging IP). A +production probe curls the domain; with the token present it also verifies that +`--version` is the active version before and after probing, and without it the +probe degrades to a service-level check that does not confirm which version +answered. Emits `healthy=`, plus `status-code=` whenever an HTTP status +was received (a transport failure such as DNS or a timeout emits `healthy=false` +alone). Exits `0` only when the probe succeeds. ### edgezero rollback @@ -347,7 +352,7 @@ The store-resolution and shell mechanics below are unchanged; see [the blob migr | `--adapter` | Behaviour | | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `axum` | Writes the envelope JSON to `.edgezero/local-config-.json` (the file `AxumConfigStore` reads back). Creates `.edgezero/` on first use. No shell-out. | -| `cloudflare` | Reads the namespace id from `wrangler.toml` (matched by `binding = `, where `` resolves from `EDGEZERO__STORES__CONFIG____NAME` or falls back to the logical ``), writes the single-entry bulk file (`[{"key": "", "value": ""}]`), and runs `wrangler kv bulk put --namespace-id=` (`--remote` live, `--local` against `.wrangler/state`). Errors with "did you run `provision`?" if the binding is absent. | +| `cloudflare` | Reads the namespace id from `wrangler.toml` (matched by `binding = `, where `` resolves from `EDGEZERO__STORES__CONFIG____NAME` or falls back to the logical ``), writes the single-entry bulk file (`[{"key": "", "value": ""}]`), and runs `wrangler kv bulk put --namespace-id= --remote` (live) or `wrangler kv bulk put --binding --local` (against `.wrangler/state`). Errors with "did you run `provision`?" if the binding is absent. | | `fastly` | Resolves the platform config-store id on demand via `fastly config-store list --json` (matched by `name = `, where `` resolves from `EDGEZERO__STORES__CONFIG____NAME` or falls back to the logical ``), then upserts the envelope with `fastly config-store-entry update --store-id= --key= --upsert --stdin`. `--upsert` makes re-runs idempotent. Errors with "did you run `provision`?" if the store name isn't found. Oversized envelopes are auto-chunked (see [the blob migration guide](./blob-app-config-migration.md#fastly)). | | `spin` | Reads `runtime-config.toml` (default: next to `spin.toml`, override with `--runtime-config `) to dispatch per-backend. **`--local` forces SQLite-direct** writes into `/.spin/sqlite_key_value.db` (Spin's local KV file) regardless of manifest deploy config; non-`default` labels still require a `[key_value_store.