diff --git a/.claude/agents/build-validator.md b/.claude/agents/build-validator.md index 076f1b4e..a17269dc 100644 --- a/.claude/agents/build-validator.md +++ b/.claude/agents/build-validator.md @@ -27,7 +27,7 @@ cargo check -p edgezero-core --all-features cargo check -p edgezero-adapter-fastly --features cli cargo check -p edgezero-adapter-cloudflare --features cli cargo check -p edgezero-adapter-axum --features axum -cargo check -p edgezero-cli --features dev-example +cargo check -p edgezero-cli --features demo-example ``` ## Demo apps diff --git a/.claude/agents/verify-app.md b/.claude/agents/verify-app.md index e3a7407e..36ac6a18 100644 --- a/.claude/agents/verify-app.md +++ b/.claude/agents/verify-app.md @@ -50,7 +50,7 @@ Demo adapters must build for their respective WASM targets. ## 6. Dev server smoke test ``` -cargo run -p edgezero-cli --features dev-example -- dev & +cargo run -p edgezero-cli --features demo-example -- demo & pid=$! trap 'kill "$pid" 2>/dev/null || true; wait "$pid" 2>/dev/null || true' EXIT sleep 3 diff --git a/.claude/settings.json b/.claude/settings.json index c671bef2..e7e94052 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,32 +1,30 @@ { "permissions": { "allow": [ - "Bash(ls:*)", - "Bash(cat:*)", - "Bash(head:*)", - "Bash(tail:*)", - "Bash(wc:*)", - "Bash(tree:*)", - "Bash(which:*)", - "Bash(cargo build:*)", - "Bash(cargo test:*)", "Bash(cargo check:*)", + "Bash(cargo clippy:*)", + "Bash(cargo fmt:*)", "Bash(cargo metadata:*)", "Bash(cargo run -p edgezero-cli:*)", - - "Bash(cargo fmt:*)", - "Bash(cargo clippy:*)", - + "Bash(cargo test:*)", + "Bash(cat:*)", + "Bash(git branch:*)", + "Bash(git diff:*)", + "Bash(git log:*)", + "Bash(git status:*)", + "Bash(head:*)", + "Bash(ls:*)", "Bash(npm ci:*)", "Bash(npm run:*)", - "Bash(rustup target:*)", - - "Bash(git status:*)", - "Bash(git diff:*)", - "Bash(git log:*)", - "Bash(git branch:*)" + "Bash(tail:*)", + "Bash(tree:*)", + "Bash(wc:*)", + "Bash(which:*)" ] + }, + "enabledPlugins": { + "superpowers@claude-plugins-official": true } } diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index ef309532..7e2ec4dc 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -25,9 +25,12 @@ Closes # - [ ] `cargo test --workspace --all-targets` - [ ] `cargo clippy --workspace --all-targets --all-features -- -D warnings` -- [ ] `cargo check --workspace --all-targets --features "fastly cloudflare"` -- [ ] WASM builds: `wasm32-wasip1` (Fastly) / `wasm32-unknown-unknown` (Cloudflare) -- [ ] Manual testing via `edgezero-cli dev` +- [ ] `cargo fmt --all -- --check` +- [ ] `cargo check --workspace --all-targets --features "fastly cloudflare spin"` +- [ ] WASM builds: `wasm32-wasip1` (Fastly) / `wasm32-wasip2` (Spin) / `wasm32-unknown-unknown` (Cloudflare) +- [ ] `examples/app-demo` workspace: `cd examples/app-demo && cargo test --workspace --all-targets` +- [ ] Docs build: `cd docs && npm run lint && npm run format && npm run build` +- [ ] Manual testing via `edgezero serve --adapter axum` (the pre-rewrite `edgezero-cli dev` was renamed; see [cli-reference](docs/guide/cli-reference.md#edgezero-demo)) - [ ] Other: ## Checklist @@ -36,5 +39,6 @@ Closes # - [ ] No Tokio deps added to core or adapter crates - [ ] Route params use `{id}` syntax (not `:id`) - [ ] Types imported from `edgezero_core` (not `http` crate) +- [ ] Store wiring goes through `KvRegistry` / `ConfigRegistry` / `SecretRegistry` (not the legacy single-handle setters) — see spec §6.6 - [ ] New code has tests - [ ] No secrets or credentials committed diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index 981e3bc5..8a842537 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -52,6 +52,19 @@ jobs: - name: Run cargo clippy run: cargo clippy --workspace --all-targets --all-features -- -D warnings + # Plan task 8.3 corollary: `examples/app-demo` is excluded + # from the root workspace, so the fmt/clippy steps above + # don't cover it. Run the same gates against it explicitly + # — the app-demo workspace has the same strict-clippy + # config and is the showcase a new user clones. + - name: Run cargo fmt (app-demo workspace) + working-directory: examples/app-demo + run: cargo fmt --all -- --check + + - name: Run cargo clippy (app-demo workspace) + working-directory: examples/app-demo + run: cargo clippy --workspace --all-targets --all-features -- -D warnings + adapter-wasm-clippy: name: ${{ matrix.adapter }} wasm clippy runs-on: ubuntu-latest @@ -64,7 +77,7 @@ jobs: - adapter: fastly target: wasm32-wasip1 - adapter: spin - target: wasm32-wasip1 + target: wasm32-wasip2 steps: - uses: actions/checkout@v6 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 440243cb..80f52f4c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -44,7 +44,7 @@ jobs: toolchain: ${{ steps.rust-version.outputs.rust-version }} - name: Add wasm targets - run: rustup target add wasm32-wasip1 wasm32-unknown-unknown + run: rustup target add wasm32-wasip1 wasm32-wasip2 wasm32-unknown-unknown - name: Fetch dependencies (locked) run: cargo fetch --locked @@ -55,6 +55,23 @@ jobs: - name: Check feature compilation run: cargo check --workspace --all-targets --features "fastly cloudflare spin" + - name: Verify a generated project compiles + run: cargo test -p edgezero-cli --test generated_project_builds -- --ignored + + # `examples/app-demo` is excluded from the root workspace, so + # `cargo test --workspace` above does not cover it. Run its own + # workspace tests separately. An end-to-end push → + # AxumConfigStore → handler roundtrip in + # `app-demo-cli/tests/config_flow.rs` exists to be exercised by + # THIS step — without it, a regression in the JSON-file contract + # between `config push --adapter axum` and + # `AxumConfigStore::from_path` would not be caught by CI. + # Axum-only path, no live external calls — intentionally kept + # off the wasm matrix. + - name: Run app-demo workspace tests + working-directory: examples/app-demo + run: cargo test --workspace --all-targets + adapter-wasm-tests: name: ${{ matrix.adapter }} wasm tests runs-on: ubuntu-latest @@ -71,8 +88,8 @@ jobs: runner_env: CARGO_TARGET_WASM32_WASIP1_RUNNER runner_value: viceroy run - adapter: spin - target: wasm32-wasip1 - runner_env: CARGO_TARGET_WASM32_WASIP1_RUNNER + target: wasm32-wasip2 + runner_env: CARGO_TARGET_WASM32_WASIP2_RUNNER runner_value: wasmtime run steps: - uses: actions/checkout@v6 diff --git a/.gitignore b/.gitignore index 7dc139ca..ce82db2f 100644 --- a/.gitignore +++ b/.gitignore @@ -18,9 +18,6 @@ target/ # Worktrees .worktrees/ -# Superpowers plans -docs/superpowers/ - # Editors .claude/* !.claude/settings.json diff --git a/CLAUDE.md b/CLAUDE.md index 849b7385..6a0495c5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,9 +17,9 @@ crates/ edgezero-adapter/ # Adapter registry and traits edgezero-adapter-fastly/ # Fastly Compute bridge (wasm32-wasip1) edgezero-adapter-cloudflare/# Cloudflare Workers bridge (wasm32-unknown-unknown) - edgezero-adapter-spin/ # Fermyon Spin bridge (wasm32-wasip1) + edgezero-adapter-spin/ # Fermyon Spin bridge (wasm32-wasip2) edgezero-adapter-axum/ # Axum/Tokio bridge (native, dev server) - edgezero-cli/ # CLI: new, build, deploy, dev, serve + edgezero-cli/ # CLI lib + bin: new, build, deploy, serve, auth, provision, config (validate|push), demo examples/app-demo/ # Reference app with all 4 adapters (excluded from workspace) docs/ # VitePress documentation site (Node.js) scripts/ # Build/deploy/test helper scripts @@ -53,10 +53,10 @@ cargo clippy --workspace --all-targets --all-features -- -D warnings cargo check --workspace --all-targets --features "fastly cloudflare spin" # Spin wasm32 compilation check -cargo check -p edgezero-adapter-spin --target wasm32-wasip1 --features spin +cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin -# Run the demo dev server -cargo run -p edgezero-cli --features dev-example -- dev +# Run the demo server +cargo run -p edgezero-cli --features demo-example -- demo # Docs site cd docs && npm ci && npm run dev @@ -71,7 +71,7 @@ faster iteration on a single crate. | ---------- | ------------------------ | ---------------------------------- | | Fastly | `wasm32-wasip1` | Requires Viceroy for local testing | | Cloudflare | `wasm32-unknown-unknown` | Requires `wrangler` for dev/deploy | -| Spin | `wasm32-wasip1` | Requires `spin` CLI for dev/deploy | +| Spin | `wasm32-wasip2` | Requires `spin` CLI for dev/deploy | | Axum | Native (host triple) | Standard Tokio runtime | ## Coding Conventions @@ -170,7 +170,7 @@ Each adapter follows the same structure: - `response.rs` — core response → platform response conversion - `proxy.rs` — platform-specific proxy client - `logger.rs` — platform-specific logging init -- `cli.rs` — build/deploy commands (behind `cli` feature) +- `cli.rs` — adapter dispatch behind the `cli` feature: `build` / `deploy` / `serve` (legacy) plus `Adapter::execute` for `auth` (login/logout/status) and dedicated trait methods `provision` (Stage 6 — platform-resource creation) and `push_config_entries` (Stage 7 — `config push` writeback). Self-registers via `#[ctor]` into the `edgezero-adapter` registry. Contract tests live in `tests/contract.rs` within each adapter crate. @@ -187,7 +187,7 @@ Every PR must pass: 2. `cargo clippy --workspace --all-targets --all-features -- -D warnings` 3. `cargo test --workspace --all-targets` 4. `cargo check --workspace --all-targets --features "fastly cloudflare spin"` -5. `cargo check -p edgezero-adapter-spin --target wasm32-wasip1 --features spin` +5. `cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin` Docs CI additionally runs ESLint + Prettier on the `docs/` directory. diff --git a/Cargo.lock b/Cargo.lock index 4541ad74..d2988207 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,18 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -162,15 +174,15 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.16.3" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" dependencies = [ "aws-lc-sys", "zeroize", @@ -178,9 +190,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.40.0" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" dependencies = [ "cc", "cmake", @@ -290,9 +302,9 @@ dependencies = [ [[package]] name = "brotli" -version = "8.0.2" +version = "8.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +checksum = "8119e4516436f5708bbc474a9d395bf12f1b5395e93a92a56e647ac3388c8610" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -301,9 +313,9 @@ dependencies = [ [[package]] name = "brotli-decompressor" -version = "5.0.0" +version = "5.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +checksum = "5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -311,9 +323,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.20.2" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytes" @@ -329,9 +341,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cc" -version = "1.2.61" +version = "1.2.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" dependencies = [ "find-msvc-tools", "jobserver", @@ -339,12 +351,6 @@ dependencies = [ "shlex", ] -[[package]] -name = "cesu8" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" - [[package]] name = "cfg-if" version = "1.0.4" @@ -613,9 +619,9 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", @@ -660,6 +666,7 @@ dependencies = [ "redb", "reqwest", "serde", + "serde_json", "simple_logger", "tempfile", "thiserror 2.0.18", @@ -686,6 +693,8 @@ dependencies = [ "futures-util", "log", "serde_json", + "tempfile", + "toml_edit", "walkdir", "wasm-bindgen-test", "web-sys", @@ -712,8 +721,10 @@ dependencies = [ "futures-util", "log", "log-fastly", + "serde_json", "tempfile", "thiserror 2.0.18", + "toml_edit", "walkdir", ] @@ -731,9 +742,17 @@ dependencies = [ "flate2", "futures", "futures-util", + "http-body-util", "log", + "rusqlite", + "serde", + "serde_json", "spin-sdk", + "subtle", "tempfile", + "thiserror 2.0.18", + "toml", + "toml_edit", "walkdir", ] @@ -758,6 +777,7 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "toml", + "validator", ] [[package]] @@ -794,6 +814,7 @@ dependencies = [ name = "edgezero-macros" version = "0.1.0" dependencies = [ + "edgezero-core", "log", "proc-macro2", "quote", @@ -801,14 +822,15 @@ dependencies = [ "syn 2.0.117", "tempfile", "toml", + "trybuild", "validator", ] [[package]] name = "either" -version = "1.15.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" [[package]] name = "elsa" @@ -836,11 +858,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastly" -version = "0.12.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16393f187c703d5460d095201e194940a190479cd5a45aa7e324e8c97f4a3df4" +checksum = "531e4c3df48350d9f4fc95b4deaf87fd29820336b7926bb84bf460457c2a126b" dependencies = [ "anyhow", "bytes", @@ -866,9 +900,9 @@ dependencies = [ [[package]] name = "fastly-macros" -version = "0.12.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e11b9b78e4d8d0fab4b9d7d8ba289c30d62d641e649e89153bc4d5446c88db2" +checksum = "cc2aef5f9690b04c8890f9a54ddb591b12b9779ec25ee0e572d207106e52e3d8" dependencies = [ "proc-macro2", "quote", @@ -877,9 +911,9 @@ dependencies = [ [[package]] name = "fastly-shared" -version = "0.12.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4ca5a9664c64b9f85188426aa1598e9885d6dbb247d6155fd9ebe043b551800" +checksum = "080ad138403159fd366d3e0b14bb49cb0c01dc18c25095bbbd1c85e3338f5413" dependencies = [ "bitflags 1.3.2", "http", @@ -887,9 +921,9 @@ dependencies = [ [[package]] name = "fastly-sys" -version = "0.12.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5dacc6ac7a7400e0b38757f48fbf1db09971812ef3dbb1f1a90a50746df662f" +checksum = "de75ef193f6c29c43d667458bede648970715aedd5db2d42c2eba3ffa3ad738b" dependencies = [ "bitflags 1.3.2", "fastly-shared", @@ -941,6 +975,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1063,7 +1103,7 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "wasi 0.11.1+wasi-snapshot-preview1", + "wasi", "wasm-bindgen", ] @@ -1091,14 +1131,20 @@ dependencies = [ "libc", "r-efi 6.0.0", "wasip2", - "wasip3", + "wasip3 0.4.0+wasi-0.3.0-rc-2026-01-06", ] +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + [[package]] name = "handlebars" -version = "6.4.0" +version = "6.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b3f9296c208515b87bd915a2f5d1163d4b3f863ba83337d7713cf478055948e" +checksum = "d43ccdfe15a81ab0a8af639e90254227c9a46afd9c5f5b6ec7efaa345c4b0f00" dependencies = [ "derive_builder", "log", @@ -1110,20 +1156,41 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + [[package]] name = "hashbrown" version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "foldhash", + "foldhash 0.1.5", ] [[package]] name = "hashbrown" -version = "0.17.0" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash 0.2.0", +] + +[[package]] +name = "hashlink" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] [[package]] name = "heck" @@ -1133,9 +1200,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "http" -version = "1.4.0" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" dependencies = [ "bytes", "itoa", @@ -1178,9 +1245,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "1.9.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" dependencies = [ "atomic-waker", "bytes", @@ -1366,9 +1433,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ "icu_normalizer", "icu_properties", @@ -1381,7 +1448,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.17.0", + "hashbrown 0.17.1", "serde", "serde_core", ] @@ -1392,16 +1459,6 @@ version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" -[[package]] -name = "iri-string" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" -dependencies = [ - "memchr", - "serde", -] - [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -1425,27 +1482,32 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jni" -version = "0.21.1" +version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" dependencies = [ - "cesu8", "cfg-if", "combine", - "jni-sys 0.3.1", + "jni-macros", + "jni-sys", "log", - "thiserror 1.0.69", + "simd_cesu8", + "thiserror 2.0.18", "walkdir", - "windows-sys 0.45.0", + "windows-link", ] [[package]] -name = "jni-sys" -version = "0.3.1" +name = "jni-macros" +version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" dependencies = [ - "jni-sys 0.4.1", + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.117", ] [[package]] @@ -1479,9 +1541,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.95" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" dependencies = [ "cfg-if", "futures-util", @@ -1513,11 +1575,22 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "link-section" -version = "0.18.2" +version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2b1dd6fe32e55c0fc0ea9493aa57459ca3cf4ff3c857c7d0302290150da6e4f" +checksum = "014e440054ce8170890229eeef5bcda955305e056ec713de40ed366944483f09" [[package]] name = "linktime-proc-macro" @@ -1539,15 +1612,15 @@ checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "log" -version = "0.4.29" +version = "0.4.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" [[package]] name = "log-fastly" -version = "0.12.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68896fe30b7c6c46d38cb33ade05daff20ad03a51d2dc422eab3138f2419fc51" +checksum = "51dae5def13a2d557fdb63862d642f8d4641ec3773c036bb14092697b6764013" dependencies = [ "fastly", "log", @@ -1580,9 +1653,9 @@ checksum = "8863b587001c1b9a8a4e36008cebc6b3612cb1226fe2de94858e06092687b608" [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" [[package]] name = "mime" @@ -1612,12 +1685,12 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", - "wasi 0.11.1+wasi-snapshot-preview1", + "wasi", "windows-sys 0.61.2", ] @@ -1632,9 +1705,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-modular" @@ -1751,18 +1824,18 @@ dependencies = [ [[package]] name = "pin-project" -version = "1.1.11" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.11" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", @@ -1775,6 +1848,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + [[package]] name = "potential_utf" version = "0.1.5" @@ -1986,13 +2065,15 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "reqwest" -version = "0.13.2" +version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ "base64", "bytes", + "futures-channel", "futures-core", + "futures-util", "http", "http-body", "http-body-util", @@ -2007,6 +2088,8 @@ dependencies = [ "rustls", "rustls-pki-types", "rustls-platform-verifier", + "serde", + "serde_json", "sync_wrapper", "tokio", "tokio-rustls", @@ -2034,13 +2117,17 @@ dependencies = [ ] [[package]] -name = "routefinder" -version = "0.5.4" +name = "rusqlite" +version = "0.32.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0971d3c8943a6267d6bd0d782fdc4afa7593e7381a92a3df950ff58897e066b5" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" dependencies = [ - "smartcow", - "smartstring", + "bitflags 2.11.1", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", ] [[package]] @@ -2049,6 +2136,15 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "1.1.4" @@ -2064,9 +2160,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.39" +version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c2c118cb077cca2822033836dfb1b975355dfb784b5e8da48f7b6c5db74e60e" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ "aws-lc-rs", "once_cell", @@ -2078,9 +2174,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" dependencies = [ "openssl-probe", "rustls-pki-types", @@ -2100,9 +2196,9 @@ dependencies = [ [[package]] name = "rustls-platform-verifier" -version = "0.6.2" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" dependencies = [ "core-foundation", "core-foundation-sys", @@ -2239,9 +2335,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", @@ -2319,9 +2415,9 @@ dependencies = [ [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signal-hook-registry" @@ -2339,6 +2435,22 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "simple_logger" version = "5.2.0" @@ -2363,55 +2475,22 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" -[[package]] -name = "smartcow" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "656fcb1c1fca8c4655372134ce87d8afdf5ec5949ebabe8d314be0141d8b5da2" -dependencies = [ - "smartstring", -] - -[[package]] -name = "smartstring" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fb72c633efbaa2dd666986505016c32c3044395ceaf881518399d2f4127ee29" -dependencies = [ - "autocfg", - "static_assertions", - "version_check", -] - [[package]] name = "socket2" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", "windows-sys 0.61.2", ] -[[package]] -name = "spin-executor" -version = "5.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bba409d00af758cd5de128da4a801e891af0545138f66a688f025f6d4e33870b" -dependencies = [ - "futures", - "once_cell", - "wasi 0.13.1+wasi-0.2.0", -] - [[package]] name = "spin-macro" -version = "5.2.0" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f959f16928e3c023468e41da9ebb77442e2ce22315e8dab11508fe76b3567ee1" +checksum = "11e483b94d5bcfac493caf0427fa875063e3e8604d0466a4ab491ec200a42857" dependencies = [ - "anyhow", - "bytes", "proc-macro2", "quote", "syn 1.0.109", @@ -2419,24 +2498,19 @@ dependencies = [ [[package]] name = "spin-sdk" -version = "5.2.0" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8951c7c4ab7f87f332d497789eeed9631c8116988b628b4851eb2fa999ead019" +checksum = "4fd2abac3eb2ee249c2241ab87f7b1287f36172c8cc1ea815c19c85e41ede44d" dependencies = [ "anyhow", - "async-trait", "bytes", - "chrono", - "form_urlencoded", "futures", "http", - "once_cell", - "routefinder", - "spin-executor", + "http-body", + "http-body-util", "spin-macro", "thiserror 2.0.18", - "wasi 0.13.1+wasi-0.2.0", - "wit-bindgen 0.51.0", + "wasip3 0.6.0+wasi-0.3.0-rc-2026-03-15", ] [[package]] @@ -2445,12 +2519,6 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" -[[package]] -name = "static_assertions" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" - [[package]] name = "strsim" version = "0.11.1" @@ -2526,6 +2594,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "target-triple" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "591ef38edfb78ca4771ee32cf494cb8771944bee237a9b91fc9c1424ac4b777b" + [[package]] name = "tempfile" version = "3.27.0" @@ -2539,6 +2613,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -2639,9 +2722,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.52.1" +version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", @@ -2683,10 +2766,19 @@ dependencies = [ "indexmap", "serde_core", "serde_spanned", - "toml_datetime", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "toml_writer", - "winnow", + "winnow 1.0.3", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", ] [[package]] @@ -2698,13 +2790,26 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.23.10+spec-1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +dependencies = [ + "indexmap", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + [[package]] name = "toml_parser" version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow", + "winnow 1.0.3", ] [[package]] @@ -2731,20 +2836,20 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.8" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "bitflags 2.11.1", "bytes", "futures-util", "http", "http-body", - "iri-string", "pin-project-lite", "tower", "tower-layer", "tower-service", + "url", ] [[package]] @@ -2797,11 +2902,26 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "trybuild" +version = "1.0.116" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47c635f0191bd3a2941013e5062667100969f8c4e9cd787c14f977265d73616e" +dependencies = [ + "glob", + "serde", + "serde_derive", + "serde_json", + "target-triple", + "termcolor", + "toml", +] + [[package]] name = "typenum" -version = "1.20.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "ucd-trie" @@ -2881,6 +3001,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" @@ -2912,15 +3038,6 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" -[[package]] -name = "wasi" -version = "0.13.1+wasi-0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f43d1c36145feb89a3e61aa0ba3e582d976a8ab77f1474aa0adb80800fe0cf8" -dependencies = [ - "wit-bindgen-rt", -] - [[package]] name = "wasip2" version = "1.0.3+wasi-0.2.9" @@ -2939,11 +3056,24 @@ dependencies = [ "wit-bindgen 0.51.0", ] +[[package]] +name = "wasip3" +version = "0.6.0+wasi-0.3.0-rc-2026-03-15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed83456dd6a0b8581998c0365e4651fa2997e5093b49243b7f35391afaa7a3d9" +dependencies = [ + "bytes", + "http", + "http-body", + "thiserror 2.0.18", + "wit-bindgen 0.57.1", +] + [[package]] name = "wasm-bindgen" -version = "0.2.118" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" dependencies = [ "cfg-if", "once_cell", @@ -2954,9 +3084,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.68" +version = "0.4.72" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" dependencies = [ "js-sys", "wasm-bindgen", @@ -2964,9 +3094,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.118" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2974,9 +3104,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.118" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" dependencies = [ "bumpalo", "proc-macro2", @@ -2987,18 +3117,18 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.118" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" dependencies = [ "unicode-ident", ] [[package]] name = "wasm-bindgen-test" -version = "0.3.68" +version = "0.3.72" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bb55e2540ad1c56eec35fd63e2aea15f83b11ce487fd2de9ad11578dfc047ea" +checksum = "74fde991ccdc895cb7fbaa14b137d62af74d9011be67b71c694bfc40edd3119c" dependencies = [ "async-trait", "cast", @@ -3018,9 +3148,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-test-macro" -version = "0.3.68" +version = "0.3.72" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caf0ca1bd612b988616bac1ab34c4e4290ef18f7148a1d8b7f31c150080e9295" +checksum = "e925354648d2a4d1bf205412e36d520a800280622eef4719678d268e5d40e978" dependencies = [ "proc-macro2", "quote", @@ -3029,9 +3159,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-test-shared" -version = "0.2.118" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23cda5ecc67248c48d3e705d3e03e00af905769b78b9d2a1678b663b8b9d4472" +checksum = "684365b586a9a6256c1cc3544eee8680de48d6041142f581776ec7b139622ae9" [[package]] name = "wasm-encoder" @@ -3040,7 +3170,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" dependencies = [ "leb128fmt", - "wasmparser", + "wasmparser 0.244.0", +] + +[[package]] +name = "wasm-encoder" +version = "0.247.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30b6733b8b91d010a6ac5b0fb237dc46a19650bc4c67db66857e2e787d437204" +dependencies = [ + "leb128fmt", + "wasmparser 0.247.0", ] [[package]] @@ -3051,8 +3191,20 @@ checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", "indexmap", - "wasm-encoder", - "wasmparser", + "wasm-encoder 0.244.0", + "wasmparser 0.244.0", +] + +[[package]] +name = "wasm-metadata" +version = "0.247.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "665fe59e56cc9b419ca6fcca56673e3421d1a5011e3b65caf6b726fd9e041d10" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder 0.247.0", + "wasmparser 0.247.0", ] [[package]] @@ -3080,11 +3232,23 @@ dependencies = [ "semver", ] +[[package]] +name = "wasmparser" +version = "0.247.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e6fb4c2bee46c5ea4d40f8cdb5c131725cd976718ec56f1c8e82fbde5fa2a80" +dependencies = [ + "bitflags 2.11.1", + "hashbrown 0.17.1", + "indexmap", + "semver", +] + [[package]] name = "web-sys" -version = "0.3.95" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" dependencies = [ "js-sys", "wasm-bindgen", @@ -3177,15 +3341,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-sys" -version = "0.45.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" -dependencies = [ - "windows-targets 0.42.2", -] - [[package]] name = "windows-sys" version = "0.52.0" @@ -3213,21 +3368,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-targets" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" -dependencies = [ - "windows_aarch64_gnullvm 0.42.2", - "windows_aarch64_msvc 0.42.2", - "windows_i686_gnu 0.42.2", - "windows_i686_msvc 0.42.2", - "windows_x86_64_gnu 0.42.2", - "windows_x86_64_gnullvm 0.42.2", - "windows_x86_64_msvc 0.42.2", -] - [[package]] name = "windows-targets" version = "0.52.6" @@ -3261,12 +3401,6 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -3279,12 +3413,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" -[[package]] -name = "windows_aarch64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -3297,12 +3425,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" -[[package]] -name = "windows_i686_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" - [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -3327,12 +3449,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" -[[package]] -name = "windows_i686_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" - [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -3345,12 +3461,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" -[[package]] -name = "windows_x86_64_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -3363,12 +3473,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -3381,12 +3485,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" -[[package]] -name = "windows_x86_64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -3401,9 +3499,18 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "1.0.2" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" [[package]] name = "wit-bindgen" @@ -3412,7 +3519,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" dependencies = [ "bitflags 2.11.1", - "wit-bindgen-rust-macro", + "wit-bindgen-rust-macro 0.51.0", ] [[package]] @@ -3422,6 +3529,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" dependencies = [ "bitflags 2.11.1", + "futures", + "wit-bindgen-rust-macro 0.57.1", ] [[package]] @@ -3432,16 +3541,18 @@ checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" dependencies = [ "anyhow", "heck", - "wit-parser", + "wit-parser 0.244.0", ] [[package]] -name = "wit-bindgen-rt" -version = "0.24.0" +name = "wit-bindgen-core" +version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0780cf7046630ed70f689a098cd8d56c5c3b22f2a7379bbdb088879963ff96" +checksum = "02dee27a2dc20d1008016c742ec9fc6ea498492994ba3750be7454cbc97ff04c" dependencies = [ - "bitflags 2.11.1", + "anyhow", + "heck", + "wit-parser 0.247.0", ] [[package]] @@ -3455,9 +3566,25 @@ dependencies = [ "indexmap", "prettyplease", "syn 2.0.117", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", + "wasm-metadata 0.244.0", + "wit-bindgen-core 0.51.0", + "wit-component 0.244.0", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5007dae772945b7a5003d69d90a3a4a78929d41f19d004e980c4259a6af4484" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.117", + "wasm-metadata 0.247.0", + "wit-bindgen-core 0.57.1", + "wit-component 0.247.0", ] [[package]] @@ -3471,8 +3598,23 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.117", - "wit-bindgen-core", - "wit-bindgen-rust", + "wit-bindgen-core 0.51.0", + "wit-bindgen-rust 0.51.0", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9237d678e3513ad24e96fe98beacdc0db6405284ba2a2400418cf0d42caa89" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core 0.57.1", + "wit-bindgen-rust 0.57.1", ] [[package]] @@ -3488,10 +3630,29 @@ dependencies = [ "serde", "serde_derive", "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", + "wasm-encoder 0.244.0", + "wasm-metadata 0.244.0", + "wasmparser 0.244.0", + "wit-parser 0.244.0", +] + +[[package]] +name = "wit-component" +version = "0.247.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d567162a6b9843080e5e0053f696623ff694bae8ae017c9ec536d1873bbe3d8" +dependencies = [ + "anyhow", + "bitflags 2.11.1", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder 0.247.0", + "wasm-metadata 0.247.0", + "wasmparser 0.247.0", + "wit-parser 0.247.0", ] [[package]] @@ -3509,14 +3670,33 @@ dependencies = [ "serde_derive", "serde_json", "unicode-xid", - "wasmparser", + "wasmparser 0.244.0", +] + +[[package]] +name = "wit-parser" +version = "0.247.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ffe4064318cdf3c08cb99343b44c039fcefe61ccdf58aa9975285f13d74d1fc" +dependencies = [ + "anyhow", + "hashbrown 0.17.1", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser 0.247.0", ] [[package]] name = "worker" -version = "0.8.1" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4afd7ae4f7fcc11e0e5e64b964890b3dda90f1290b0612f7cd821b381cc18826" +checksum = "2d3c60a70414db58e1890f3675d02692adace736657cb66994f220ae3780c90d" dependencies = [ "async-trait", "bytes", @@ -3532,6 +3712,7 @@ dependencies = [ "serde-wasm-bindgen", "serde_json", "serde_urlencoded", + "strum", "tokio", "url", "wasm-bindgen", @@ -3544,9 +3725,9 @@ dependencies = [ [[package]] name = "worker-macros" -version = "0.8.1" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6371f41ac538c9f6dbe4d40cf7db58ed451eb0529a66f3e29ab8726217fc8a05" +checksum = "60bcb459a67977fcb79698a3123ae58a928b1b24cc3035eaec033dbdfc139438" dependencies = [ "async-trait", "proc-macro2", @@ -3561,9 +3742,9 @@ dependencies = [ [[package]] name = "worker-sys" -version = "0.8.1" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c8de95c532944cee89d63fa8d7945f3db6260ca75ee3da42f7acfeebf538e4c" +checksum = "c0e59a8504685d87649b8fda877d95fcc48f8c8177dbd77a4dc8e67f8fc80240" dependencies = [ "cfg-if", "js-sys", @@ -3602,18 +3783,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.48" +version = "0.8.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.48" +version = "0.8.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" dependencies = [ "proc-macro2", "quote", @@ -3622,9 +3803,9 @@ dependencies = [ [[package]] name = "zerofrom" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] diff --git a/Cargo.toml b/Cargo.toml index b964cd81..c8b4c3cf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,7 @@ edgezero-adapter-cloudflare = { path = "crates/edgezero-adapter-cloudflare", def edgezero-adapter-fastly = { path = "crates/edgezero-adapter-fastly", default-features = false } edgezero-adapter-spin = { path = "crates/edgezero-adapter-spin", default-features = false } edgezero-core = { path = "crates/edgezero-core", default-features = false } +edgezero-cli = { path = "crates/edgezero-cli", default-features = false } fastly = "0.12" fern = "0.7" flate2 = { version = "1", features = ["rust_backend"] } @@ -46,23 +47,34 @@ futures-util = { version = "0.3", features = ["alloc", "io"] } handlebars = "6" http = "1" http-body = "1" +http-body-util = "0.1" log = "0.4" log-fastly = "0.12" matchit = "0.9" once_cell = "1" redb = "4.1.0" -reqwest = { version = "0.13", default-features = false, features = ["rustls"] } +reqwest = { version = "0.13", default-features = false, features = ["rustls", "blocking", "json"] } +# `bundled` ships SQLite source so operators don't need a system +# `libsqlite3-sys` install. Used by `edgezero-adapter-spin`'s CLI-only +# `config push --adapter spin` writer to write into Spin's local KV +# file (`/.spin/sqlite_key_value.db`) using the schema +# vendored from spinframework/spin's `crates/key-value-spin/src/store.rs`. +rusqlite = { version = "0.32", default-features = false, features = ["bundled"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +subtle = "2" serde_urlencoded = "0.7" simple_logger = "5" -# TODO: spin-sdk 6.0 is API-breaking (Method variants → http::Method constants, -# IncomingRequest removed, Builder::build → .body()). Migration deferred to a -# focused PR; stay on 5.2 until then. -spin-sdk = { version = "5.2", default-features = false } +# Pinned to the `~6.0` range (allows 6.0.x, blocks 6.1+) so a minor +# bump that touches `key_value::Store::open`'s async signature or the +# wasi-http import surface fails at build time rather than at `spin +# up` (where a runtime mismatch surfaces as opaque WIT linker errors). +spin-sdk = { version = "~6.0", default-features = false, features = ["http", "key-value", "variables"] } tempfile = "3" +toml_edit = "0.23" thiserror = "2" tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal"] } +trybuild = "1" toml = { version = "1.1" } tower = { version = "0.5", features = ["util"] } tower-layer = "0.3" diff --git a/README.md b/README.md index a98f8297..e8c78b92 100644 --- a/README.md +++ b/README.md @@ -9,11 +9,11 @@ Production-ready toolkit for portable edge HTTP workloads. Write once, deploy to cargo install --path crates/edgezero-cli # Create a new project -edgezero-cli new my-app +edgezero new my-app cd my-app -# Start the dev server -edgezero-cli dev +# Run it locally on the Axum adapter +edgezero serve --adapter axum # Test it curl http://127.0.0.1:8787/ @@ -34,7 +34,7 @@ Full documentation is available at **[stackpop.github.io/edgezero](https://stack | ------------------ | ------------------------ | ------ | | Fastly Compute | `wasm32-wasip1` | Stable | | Cloudflare Workers | `wasm32-unknown-unknown` | Stable | -| Fermyon Spin | `wasm32-wasip1` | Preview | +| Fermyon Spin | `wasm32-wasip2` | Preview | | Axum (Native) | Host | Stable | ## License diff --git a/TODO.md b/TODO.md index 384a7d63..19bf99e0 100644 --- a/TODO.md +++ b/TODO.md @@ -35,7 +35,7 @@ High-level backlog and decisions to drive the next milestones. - [ ] Adapters: assert error-path mapping for Fastly/Cloudflare request conversion and re-enable the ignored Cloudflare response header test. - [ ] CLI: add integration tests for `edgezero new` scaffolding, feature-flag builds, and `dev` fallback app. - [ ] CLI: cover `dev_server`, generator, and template scaffolding flows with tempdir-based integration tests to guard manual HTTP parsing and shell commands. -- [ ] CI: verify feature combinations (without `dev-example`, `json`, `form`) compile and run basic smoke tests. +- [ ] CI: verify feature combinations (without `demo-example`, `json`, `form`) compile and run basic smoke tests. - [ ] Macros: add trybuild coverage for `app!` manifest expansion (route/middleware generation and error surfacing). - [x] Core: unit-test `App::build_app`/`Hooks` wiring and `PathParams::deserialize` edge cases beyond indirect coverage. _(Added targeted unit tests in `crates/edgezero-core/src/app.rs` and `crates/edgezero-core/src/params.rs`.)_ - [x] Coverage hygiene: consolidate duplicate router/extractor request-parsing tests and share adapter contract fixtures to reduce redundant maintenance. _(Router duplicates trimmed; extractor suite now owns request parsing checks.)_ @@ -158,7 +158,7 @@ High-level backlog and decisions to drive the next milestones. ## Review (2025-09-18 03:08 UTC) - Implemented `edgezero build|deploy --adapter fastly` by wiring cargo wasm32 builds and Fastly CLI invocation in the CLI. -- Documented optional `dev-example` dependency in `edgezero-cli/README.md` and added error handling for unsupported adapters. +- Documented optional `demo-example` dependency in `edgezero-cli/README.md` and added error handling for unsupported adapters. - Verified builds with `cargo test -p edgezero-cli`. ## Review (2025-09-18 03:27 UTC) diff --git a/crates/edgezero-adapter-axum/Cargo.toml b/crates/edgezero-adapter-axum/Cargo.toml index a8fcbbf6..3351b99f 100644 --- a/crates/edgezero-adapter-axum/Cargo.toml +++ b/crates/edgezero-adapter-axum/Cargo.toml @@ -42,6 +42,7 @@ http = { workspace = true } log = { workspace = true } redb = { workspace = true, optional = true } reqwest = { workspace = true, optional = true } +serde_json = { workspace = true } simple_logger = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true, optional = true } diff --git a/crates/edgezero-adapter-axum/src/cli.rs b/crates/edgezero-adapter-axum/src/cli.rs index 1c394a53..c97b4b22 100644 --- a/crates/edgezero-adapter-axum/src/cli.rs +++ b/crates/edgezero-adapter-axum/src/cli.rs @@ -1,3 +1,4 @@ +use std::collections::BTreeMap; use std::env; use std::fs; use std::net::{IpAddr, SocketAddr}; @@ -8,7 +9,9 @@ use ctor::ctor; use edgezero_adapter::cli_support::{ find_manifest_upwards, find_workspace_root, path_distance, read_package_name, }; -use edgezero_adapter::registry::{register_adapter, Adapter, AdapterAction}; +use edgezero_adapter::registry::{ + register_adapter, Adapter, AdapterAction, AdapterPushContext, ProvisionStores, ResolvedStoreId, +}; use edgezero_adapter::scaffold::{ register_adapter_blueprint, AdapterBlueprint, AdapterFileSpec, CommandTemplates, DependencySpec, LoggingDefaults, ManifestSpec, ReadmeInfo, TemplateRegistration, @@ -95,7 +98,7 @@ static AXUM_BLUEPRINT: AdapterBlueprint = AdapterBlueprint { dev_heading: "{display} (local)", dev_steps: &[ "`cd {crate_dir}`", - "`cargo run` or `edgezero-cli serve --adapter axum`", + "`cargo run` or `edgezero serve --adapter axum`", ], }, run_module: "edgezero_adapter_axum", @@ -124,9 +127,22 @@ struct EdgezeroAxumConfig { port: Option, } +#[expect( + clippy::missing_trait_methods, + reason = "axum has no validate_app_config_keys / validate_adapter_manifest / validate_typed_secrets requirements; those three trait defaults are intentionally inherited. `single_store_kinds` IS overridden below (returns `&[\"secrets\"]`)." +)] impl Adapter for AxumCliAdapter { fn execute(&self, action: AdapterAction, args: &[String]) -> Result<(), String> { match action { + // The axum adapter is the in-process native dev server — + // there is no remote auth provider to sign in/out of. + // Per spec this is an explicit no-op. + AdapterAction::AuthLogin | AdapterAction::AuthLogout | AdapterAction::AuthStatus => { + log::info!( + "[edgezero] axum has no remote auth surface; `auth` is a no-op for this adapter" + ); + Ok(()) + } AdapterAction::Build => build(args), AdapterAction::Deploy => deploy(args), AdapterAction::Serve => serve(args), @@ -137,6 +153,135 @@ impl Adapter for AxumCliAdapter { fn name(&self) -> &'static str { "axum" } + + fn provision( + &self, + _manifest_root: &Path, + _adapter_manifest_path: Option<&str>, + _component_selector: Option<&str>, + stores: &ProvisionStores<'_>, + _dry_run: bool, + ) -> Result, String> { + //: axum has no remote resources. Print one note per + // declared store id so the operator sees the CLI heard + // them — same shape `dry_run` would have, since there is + // nothing to actually perform. + let mut out = Vec::with_capacity( + stores + .kv + .len() + .saturating_add(stores.config.len()) + .saturating_add(stores.secrets.len()), + ); + for store in stores.kv { + let logical = store.logical.as_str(); + out.push(format!( + "axum KV store `{logical}` is in-memory; nothing to provision" + )); + } + for store in stores.config { + // Axum reads `.edgezero/local-config-.json`. + // The platform name is informational here -- the env + // overlay isn't used for local file paths because the + // path encoding is the spec's canonical form. + let logical = store.logical.as_str(); + out.push(format!( + "axum config store `{logical}` reads `.edgezero/local-config-{logical}.json`; nothing to provision" + )); + } + for store in stores.secrets { + let logical = store.logical.as_str(); + out.push(format!( + "axum secret store `{logical}` reads env vars; nothing to provision" + )); + } + if out.is_empty() { + out.push("axum has no declared stores to provision".to_owned()); + } + Ok(out) + } + + fn push_config_entries( + &self, + manifest_root: &Path, + _adapter_manifest_path: Option<&str>, + _component_selector: Option<&str>, + store: &ResolvedStoreId, + entries: &[(String, String)], + _push_ctx: &AdapterPushContext<'_>, + dry_run: bool, + ) -> Result, String> { + //: axum is local-only. Push writes the same flat + // `string -> string` JSON object `AxumConfigStore` reads + // back from `.edgezero/local-config-.json`. The path + // is keyed on the LOGICAL id, not the env-resolved + // platform name -- the local file flow is the spec's + // canonical form and isn't subject to the per-store env + // overlay (which targets platform store names, not local + // file paths). + let logical = store.logical.as_str(); + let local_dir = manifest_root.join(".edgezero"); + let target = local_dir.join(format!("local-config-{logical}.json")); + if dry_run { + return Ok(vec![format!( + "would write {} entries to {}", + entries.len(), + target.display() + )]); + } + fs::create_dir_all(&local_dir) + .map_err(|err| format!("failed to create {}: {err}", local_dir.display()))?; + let map: BTreeMap<&str, &str> = entries + .iter() + .map(|(key, value)| (key.as_str(), value.as_str())) + .collect(); + let json = serde_json::to_string_pretty(&map) + .map_err(|err| format!("failed to serialize config to JSON: {err}"))?; + fs::write(&target, json) + .map_err(|err| format!("failed to write {}: {err}", target.display()))?; + Ok(vec![format!( + "wrote {} entries to {}", + entries.len(), + target.display() + )]) + } + + fn push_config_entries_local( + &self, + manifest_root: &Path, + adapter_manifest_path: Option<&str>, + component_selector: Option<&str>, + store: &ResolvedStoreId, + entries: &[(String, String)], + push_ctx: &AdapterPushContext<'_>, + dry_run: bool, + ) -> Result, String> { + // Axum is local-only: the default push already writes + // `.edgezero/local-config-.json`, which is what the + // running dev server reads. `--local` is therefore the + // same as the default; we delegate and prepend a notice + // so the operator who typed `--local` for parity with + // fastly/cloudflare knows there was nothing extra to do. + let mut lines = self.push_config_entries( + manifest_root, + adapter_manifest_path, + component_selector, + store, + entries, + push_ctx, + dry_run, + )?; + let notice = + "axum push is always local: `--local` has no separate effect (writes the same `.edgezero/local-config-.json` either way)".to_owned(); + lines.insert(0, notice); + Ok(lines) + } + + fn single_store_kinds(&self) -> &'static [&'static str] { + //: axum is Multi for KV (local file dirs) and Config + // (local JSON files), Single for Secrets (env vars). + &["secrets"] + } } #[inline] @@ -193,8 +338,12 @@ fn run_cargo(project: &AxumProject, subcommand: &str, extra_args: &[String]) -> ); command.args(extra_args); command.current_dir(&project.crate_dir); - command.env("EDGEZERO_HOST", bind_addr.ip().to_string()); - command.env("EDGEZERO_PORT", bind_addr.port().to_string()); + // Canonical env vars. The runtime's `EnvConfig` reads only the + // `EDGEZERO__*` form (see `crates/edgezero-core/src/env_config.rs`); + // setting the legacy `EDGEZERO_HOST` / `EDGEZERO_PORT` here would be a + // no-op for the child process. + command.env("EDGEZERO__ADAPTER__HOST", bind_addr.ip().to_string()); + command.env("EDGEZERO__ADAPTER__PORT", bind_addr.port().to_string()); let status = command .status() .map_err(|err| format!("failed to run cargo {subcommand}: {err}"))?; @@ -258,7 +407,7 @@ fn resolve_subprocess_host( match value.parse() { Ok(host) => return host, Err(_) => warnings.push(format!( - "EDGEZERO_HOST={value:?} is not a valid IP address (hostnames like \"localhost\" are not supported); falling back" + "EDGEZERO__ADAPTER__HOST={value:?} is not a valid IP address (hostnames like \"localhost\" are not supported); falling back" )), } } @@ -293,11 +442,11 @@ fn resolve_subprocess_port( if let Some(value) = env_port { match value.parse::() { Ok(0) => warnings.push( - "EDGEZERO_PORT=\"0\" is not supported (would bind to a random OS port); falling back".to_owned(), + "EDGEZERO__ADAPTER__PORT=\"0\" is not supported (would bind to a random OS port); falling back".to_owned(), ), Ok(port) => return port, Err(_) => warnings.push(format!( - "EDGEZERO_PORT={value:?} is not a valid port number; falling back" + "EDGEZERO__ADAPTER__PORT={value:?} is not a valid port number; falling back" )), } } @@ -375,8 +524,16 @@ fn find_axum_manifest(start: &Path) -> Result { } fn read_axum_project(manifest: &Path) -> Result { - let env_host = env::var("EDGEZERO_HOST").ok(); - let env_port = env::var("EDGEZERO_PORT").ok(); + // Per the spec hard-cutoff: only the canonical + // `EDGEZERO__ADAPTER__HOST` / `EDGEZERO__ADAPTER__PORT` env + // vars are honoured. The pre-rewrite `EDGEZERO_HOST` / + // `EDGEZERO_PORT` shim is gone -- the core runtime stopped + // reading those names, and keeping the axum wrapper compatible + // with them silently revived a precedence path the rest of + // the codebase had cut. Operators with legacy CI scripts must + // rename to the canonical form. + let env_host = env::var("EDGEZERO__ADAPTER__HOST").ok(); + let env_port = env::var("EDGEZERO__ADAPTER__PORT").ok(); read_axum_project_with_env(manifest, env_host.as_deref(), env_port.as_deref()) } @@ -955,4 +1112,99 @@ mod tests { assert_eq!(AXUM_BLUEPRINT.id, "axum"); assert_eq!(AXUM_BLUEPRINT.display_name, "Axum"); } + + // ---------- push_config_entries ---------- + + #[test] + fn push_writes_flat_json_to_local_config_file() { + let dir = tempfile::tempdir().expect("tempdir"); + let entries = vec![ + ("greeting".to_owned(), "hello".to_owned()), + ("service.timeout_ms".to_owned(), "1500".to_owned()), + ]; + let lines = AxumCliAdapter + .push_config_entries( + dir.path(), + None, + None, + &ResolvedStoreId::from_logical("app_config"), + &entries, + &AdapterPushContext::new(), + false, + ) + .expect("push succeeds"); + assert_eq!(lines.len(), 1); + assert!( + lines[0].contains("wrote 2 entries"), + "status line names count: {lines:?}" + ); + let json_path = dir.path().join(".edgezero/local-config-app_config.json"); + let raw = fs::read_to_string(&json_path).expect("read written file"); + let parsed: serde_json::Value = serde_json::from_str(&raw).expect("valid JSON"); + assert_eq!(parsed["greeting"], "hello"); + assert_eq!(parsed["service.timeout_ms"], "1500"); + } + + #[test] + fn push_dry_run_does_not_create_local_dir_or_file() { + let dir = tempfile::tempdir().expect("tempdir"); + let entries = vec![("greeting".to_owned(), "hello".to_owned())]; + let lines = AxumCliAdapter + .push_config_entries( + dir.path(), + None, + None, + &ResolvedStoreId::from_logical("app_config"), + &entries, + &AdapterPushContext::new(), + true, + ) + .expect("dry-run succeeds"); + assert!( + lines[0].contains("would write 1 entries"), + "dry-run line: {lines:?}" + ); + assert!( + !dir.path().join(".edgezero").exists(), + ".edgezero must not exist after dry-run" + ); + } + + #[test] + fn push_creates_dot_edgezero_directory_when_missing() { + let dir = tempfile::tempdir().expect("tempdir"); + let entries = vec![("key".to_owned(), "value".to_owned())]; + AxumCliAdapter + .push_config_entries( + dir.path(), + None, + None, + &ResolvedStoreId::from_logical("x"), + &entries, + &AdapterPushContext::new(), + false, + ) + .expect("push succeeds"); + assert!(dir.path().join(".edgezero").is_dir(), ".edgezero created"); + } + + #[test] + fn push_with_empty_entries_writes_empty_json_object() { + let dir = tempfile::tempdir().expect("tempdir"); + AxumCliAdapter + .push_config_entries( + dir.path(), + None, + None, + &ResolvedStoreId::from_logical("empty"), + &[], + &AdapterPushContext::new(), + false, + ) + .expect("push succeeds even with no entries"); + let raw = fs::read_to_string(dir.path().join(".edgezero/local-config-empty.json")) + .expect("read written file"); + let parsed: serde_json::Value = serde_json::from_str(&raw).expect("valid JSON"); + assert_eq!(parsed, serde_json::json!({})); + } } diff --git a/crates/edgezero-adapter-axum/src/config_store.rs b/crates/edgezero-adapter-axum/src/config_store.rs index 8fe373dc..94d7c37d 100644 --- a/crates/edgezero-adapter-axum/src/config_store.rs +++ b/crates/edgezero-adapter-axum/src/config_store.rs @@ -1,170 +1,343 @@ -//! Axum adapter config store: env vars with in-memory defaults fallback. +//! Axum adapter config store: reads from a per-id local JSON file. +//! +//! Each declared `[stores.config].ids` id maps to a file at +//! `.edgezero/local-config-.json`. The file holds a flat object of +//! `string -> string` pairs — the same shape `edgezero config push +//! --adapter axum` writes. +//! +//! If the file is absent the store is empty (`get` returns `Ok(None)` for +//! every key). This keeps `edgezero serve --adapter axum` permissive when +//! the project hasn't seeded any local config yet. use std::collections::HashMap; use std::env; +use std::fs; +use std::io::ErrorKind; +use std::path::{Path, PathBuf}; +use async_trait::async_trait; use edgezero_core::config_store::{ConfigStore, ConfigStoreError}; -/// Config store for local dev / Axum. Reads from env vars with manifest -/// defaults as fallback. Env vars take precedence over defaults. +/// Local-file config store used by the Axum dev server. /// -/// # Note on `from_env` -/// -/// [`AxumConfigStore::from_env`] only reads environment variables for keys -/// declared in `[stores.config.defaults]`. Use an empty-string default when a -/// key should be overrideable from env without carrying a real default value. +/// Construction is fallible only when the backing file is present but +/// malformed JSON — a missing file is a documented "no values seeded yet" +/// state, not an error. pub struct AxumConfigStore { - defaults: HashMap, - env: HashMap, + data: HashMap, } impl AxumConfigStore { - /// Create from the current process environment and manifest defaults. + fn empty() -> Self { + Self { + data: HashMap::new(), + } + } + + /// Open the local-file config store for a given logical id. + /// + /// Reads `.edgezero/local-config-.json` if present and parses it + /// as a flat `string -> string` JSON object. A missing file yields an + /// empty store. A malformed file yields + /// [`ConfigStoreError::Unavailable`] so the dev-server log surfaces + /// the problem at startup rather than at first request. + /// + /// # Errors + /// Returns [`ConfigStoreError::Unavailable`] when the backing file + /// exists but cannot be read or parsed. #[inline] - pub fn from_env(defaults: D) -> Self - where - D: IntoIterator, - { - Self::from_lookup(defaults, |key| env::var(key).ok()) + pub fn from_local_file(id: &str) -> Result { + Self::from_path(&Self::local_path(id)) } - fn from_lookup(defaults: D, mut lookup: F) -> Self + /// Build a store from an explicit `{key -> value}` map. Intended for + /// tests and for callers that already have parsed config in memory. + #[inline] + pub fn from_map(entries: E) -> Self where - D: IntoIterator, - F: FnMut(&str) -> Option, + E: IntoIterator, { - let collected: HashMap = defaults.into_iter().collect(); - let env = collected - .keys() - .filter_map(|key| lookup(key).map(|value| (key.clone(), value))) - .collect(); Self { - defaults: collected, - env, + data: entries.into_iter().collect(), } } - /// Create from env vars and optional manifest defaults. + /// Open the local-file config store at an explicit path + /// (overrides the `.edgezero/local-config-.json` default + /// from [`Self::from_local_file`]). Intended for downstream + /// integration tests that want to load a JSON payload written + /// by `config push --adapter axum` to a tempdir, without + /// changing the process CWD. + /// + /// The file must contain a flat JSON object of `string -> string` + /// pairs, matching what `config push --adapter axum` writes: + /// + /// ```json + /// { + /// "greeting": "hello", + /// "feature.new_checkout": "false", + /// "service.timeout_ms": "1500" + /// } + /// ``` + /// + /// Dotted keys are stored verbatim (no nesting): the runtime + /// extractors look up the dotted form as a single key. Non-string + /// values (`{"x": 42}`, nested objects, arrays) are rejected. + /// + /// Behaviour matches `from_local_file`: a missing file yields + /// an empty store; a present-but-malformed file yields + /// [`ConfigStoreError::Unavailable`]. + /// + /// # Errors + /// Returns [`ConfigStoreError::Unavailable`] when the file + /// exists but cannot be read or parsed. #[inline] - pub fn new(env: E, defaults: D) -> Self - where - E: IntoIterator, - D: IntoIterator, - { - Self { - defaults: defaults.into_iter().collect(), - env: env.into_iter().collect(), + pub fn from_path(path: &Path) -> Result { + let raw = match fs::read_to_string(path) { + Ok(raw) => raw, + Err(err) if err.kind() == ErrorKind::NotFound => { + return Ok(Self::empty()); + } + Err(err) => { + return Err(ConfigStoreError::unavailable(format!( + "failed to read {}: {err}", + path.display() + ))); + } + }; + let data: HashMap = serde_json::from_str(&raw).map_err(|err| { + ConfigStoreError::unavailable(format!( + "{} is not a flat string -> string JSON object: {err}", + path.display() + )) + })?; + Ok(Self { data }) + } + + /// Resolve the on-disk path for the given logical config id. + /// + /// Resolution order: + /// + /// 1. Walk up from the process cwd looking for an ancestor that + /// contains `edgezero.toml` (the manifest marker), the same + /// way cargo finds `Cargo.toml`. If found, return + /// `/.edgezero/local-config-.json`. + /// 2. Fall back to the cwd-relative `./.edgezero/local-config-.json`. + /// + /// Why the walk-up: `edgezero config push --adapter axum` writes + /// to `/.edgezero/local-config-.json`, but the + /// axum runtime binary can legitimately be launched from any of + /// the workspace root, the adapter crate dir, or an out-of-tree + /// `cargo run` cwd. Without the walk-up, the runtime would read + /// `/.edgezero/...` and silently see an empty store + /// whenever cwd doesn't happen to equal the manifest root. + /// Walking up matches the directory model push uses, so the two + /// always agree regardless of launch cwd. + /// + /// In a deployed binary (no `edgezero.toml` shipped alongside), + /// the walk-up returns `None` and the cwd-relative fallback + /// preserves the pre-fix behaviour. That deployment shape sets + /// the cwd to where it dropped `.edgezero/` already, so the + /// fallback is correct there too. + #[must_use] + #[inline] + pub fn local_path(id: &str) -> PathBuf { + let suffix = PathBuf::from(".edgezero").join(format!("local-config-{id}.json")); + if let Some(root) = find_project_root_dir() { + return root.join(suffix); } + suffix } } +#[async_trait(?Send)] impl ConfigStore for AxumConfigStore { #[inline] - fn get(&self, key: &str) -> Result, ConfigStoreError> { - Ok(self - .env - .get(key) - .or_else(|| self.defaults.get(key)) - .cloned()) + async fn get(&self, key: &str) -> Result, ConfigStoreError> { + Ok(self.data.get(key).cloned()) + } +} + +/// Walk up from the process cwd looking for an ancestor that +/// contains an `edgezero.toml` file (the manifest marker, same +/// convention cargo uses for `Cargo.toml`). Returns the first +/// matching ancestor, or `None` if the walk hits the filesystem +/// root without finding one. +/// +/// Used by [`AxumConfigStore::local_path`] to keep push and runtime +/// on the same path regardless of launch cwd. Pulled out as a free +/// function so the same discovery rule can be reused by other +/// runtime helpers in the future. +fn find_project_root_dir() -> Option { + find_project_root_dir_from(&env::current_dir().ok()?) +} + +/// Test-visible inner walk: same behaviour as +/// [`find_project_root_dir`] but with the starting directory passed +/// in explicitly so unit tests don't depend on the process cwd. +fn find_project_root_dir_from(start: &Path) -> Option { + for ancestor in start.ancestors() { + if ancestor.join("edgezero.toml").is_file() { + return Some(ancestor.to_path_buf()); + } } + None } #[cfg(test)] mod tests { - // Run the shared contract tests against AxumConfigStore (defaults path). - edgezero_core::config_store_contract_tests!(axum_config_store_defaults_contract, { - AxumConfigStore::new( - [], - [ - ("contract.key.a".to_owned(), "value_a".to_owned()), - ("contract.key.b".to_owned(), "value_b".to_owned()), - ], - ) - }); - - // Run the shared contract tests against AxumConfigStore (env path). - edgezero_core::config_store_contract_tests!(axum_config_store_env_contract, { - AxumConfigStore::new( - [ - ("contract.key.a".to_owned(), "value_a".to_owned()), - ("contract.key.b".to_owned(), "value_b".to_owned()), - ], - [], - ) + // Run the shared contract tests against AxumConfigStore. + edgezero_core::config_store_contract_tests!(axum_config_store_contract, { + AxumConfigStore::from_map([ + ("contract.key.a".to_owned(), "value_a".to_owned()), + ("contract.key.b".to_owned(), "value_b".to_owned()), + ]) }); use super::*; + use futures::executor::block_on; + use tempfile::tempdir; - fn store(env: &[(&str, &str)], defaults: &[(&str, &str)]) -> AxumConfigStore { - AxumConfigStore::new( - env.iter() - .map(|(key, value)| ((*key).to_owned(), (*value).to_owned())), - defaults - .iter() - .map(|(key, value)| ((*key).to_owned(), (*value).to_owned())), - ) + #[test] + fn axum_config_store_from_map_returns_values() { + let cs = AxumConfigStore::from_map([("greeting".to_owned(), "hello".to_owned())]); + assert_eq!( + block_on(cs.get("greeting")).expect("config value"), + Some("hello".to_owned()) + ); + assert_eq!(block_on(cs.get("missing")).expect("missing config"), None); } #[test] - fn axum_config_store_env_overrides_defaults() { - let cs = store(&[("KEY", "from_env")], &[("KEY", "from_default")]); - assert_eq!( - cs.get("KEY").expect("config value"), - Some("from_env".to_owned()) + fn find_project_root_dir_from_returns_none_when_no_edgezero_toml_in_ancestors() { + // Regression for the push/serve cwd mismatch: when the + // launch cwd has no `edgezero.toml` anywhere up the chain + // (e.g. a deployed binary in an isolated runtime image), + // discovery must return None so `local_path` falls back to + // cwd-relative `.edgezero/`. Pre-fix the runtime + // unconditionally used `.edgezero/` relative to cwd, which + // worked here too — confirm the fallback path is preserved. + let temp = tempdir().expect("tempdir"); + assert!( + find_project_root_dir_from(temp.path()).is_none(), + "tempdir with no edgezero.toml must NOT match" ); } #[test] - fn axum_config_store_falls_back_to_defaults() { - let cs = store(&[], &[("KEY", "default_val")]); + fn find_project_root_dir_from_finds_ancestor_with_edgezero_toml() { + // The fix: when an ancestor contains `edgezero.toml`, + // discovery returns it. This is the case that breaks pre- + // fix when serve runs from a crate dir but push wrote to + // the workspace root. + let temp = tempdir().expect("tempdir"); + fs::write(temp.path().join("edgezero.toml"), "").expect("write marker"); + // Simulate cwd two levels deep inside the project. + let nested = temp.path().join("crates").join("my-app-adapter-axum"); + fs::create_dir_all(&nested).expect("nested dir"); + + let resolved = + find_project_root_dir_from(&nested).expect("ancestor with edgezero.toml must match"); + // Canonicalize both sides — on macOS `/tmp` is a symlink to + // `/private/tmp`, which makes the raw tempdir path and the + // resolved ancestor inequal byte-for-byte. assert_eq!( - cs.get("KEY").expect("default config"), - Some("default_val".to_owned()) + fs::canonicalize(&resolved).expect("canonicalize resolved"), + fs::canonicalize(temp.path()).expect("canonicalize tempdir") ); } #[test] - fn axum_config_store_from_env_reads_only_declared_keys() { - let cs = AxumConfigStore::from_lookup( - [ - ("feature.new_checkout".to_owned(), "false".to_owned()), - ("service.timeout_ms".to_owned(), "1500".to_owned()), - ], - |key| match key { - "feature.new_checkout" => Some("true".to_owned()), - "DATABASE_URL" => Some("postgres://secret".to_owned()), - _ => None, - }, - ); + fn find_project_root_dir_from_stops_at_first_match() { + // If two ancestors both have `edgezero.toml`, pick the + // nearest one — analogous to how cargo resolves + // `Cargo.toml` workspace vs. package roots. + let temp = tempdir().expect("tempdir"); + fs::write(temp.path().join("edgezero.toml"), "outer").expect("outer"); + let inner = temp.path().join("inner"); + fs::create_dir_all(&inner).expect("inner dir"); + fs::write(inner.join("edgezero.toml"), "inner").expect("inner marker"); + let nested = inner.join("deeper"); + fs::create_dir_all(&nested).expect("nested dir"); + let resolved = find_project_root_dir_from(&nested).expect("match"); assert_eq!( - cs.get("feature.new_checkout") - .expect("allowed env override"), - Some("true".to_owned()) + fs::canonicalize(&resolved).expect("canonicalize resolved"), + fs::canonicalize(&inner).expect("canonicalize inner") ); + } + + #[test] + fn axum_config_store_from_path_returns_empty_for_missing_file() { + let temp = tempdir().expect("tempdir"); + let cs = AxumConfigStore::from_path(&temp.path().join("nope.json")) + .expect("missing file is permissive"); + assert_eq!(block_on(cs.get("anything")).expect("empty store"), None); + } + + #[test] + fn axum_config_store_from_path_reads_flat_json() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("local-config-app_config.json"); + fs::write( + &path, + r#"{"greeting":"hello from file","feature.new_checkout":"false"}"#, + ) + .expect("write json"); + + let cs = AxumConfigStore::from_path(&path).expect("parse json"); assert_eq!( - cs.get("service.timeout_ms").expect("default fallback"), - Some("1500".to_owned()) + block_on(cs.get("greeting")).expect("value"), + Some("hello from file".to_owned()) ); assert_eq!( - cs.get("DATABASE_URL") - .expect("undeclared key should stay hidden"), - None + block_on(cs.get("feature.new_checkout")).expect("dotted value"), + Some("false".to_owned()) ); + assert_eq!(block_on(cs.get("missing")).expect("missing"), None); + } + + #[test] + fn axum_config_store_from_path_rejects_malformed_json() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("local-config-bad.json"); + fs::write(&path, "{not json}").expect("write"); + + match AxumConfigStore::from_path(&path) { + Err(ConfigStoreError::Unavailable { .. }) => {} + Err(other) => panic!("expected Unavailable, got {other:?}"), + Ok(_) => panic!("malformed JSON must surface as error"), + } } #[test] - fn axum_config_store_returns_none_for_missing() { - let cs = store(&[], &[]); - assert_eq!(cs.get("NOPE").expect("missing config"), None); + fn axum_config_store_from_path_rejects_non_string_values() { + let temp = tempdir().expect("tempdir"); + let path = temp.path().join("local-config-numeric.json"); + fs::write(&path, r#"{"greeting":42}"#).expect("write"); + + match AxumConfigStore::from_path(&path) { + Err(ConfigStoreError::Unavailable { .. }) => {} + Err(other) => panic!("expected Unavailable, got {other:?}"), + Ok(_) => panic!("non-string values must surface as error"), + } } #[test] - fn axum_config_store_returns_values() { - let cs = store(&[("MY_KEY", "my_val")], &[]); - assert_eq!( - cs.get("MY_KEY").expect("config value"), - Some("my_val".to_owned()) + fn local_path_is_keyed_by_logical_id() { + // The path's TAIL is the stable contract; the prefix may + // be cwd-relative (`./.edgezero/...`) or rooted at the + // discovered project ancestor (`/.edgezero/...`) + // depending on whether the test runner's cwd has an + // `edgezero.toml` ancestor. Both forms are correct — we + // assert only on the suffix so the test doesn't flake when + // someone adds an `edgezero.toml` at the workspace root. + let path = AxumConfigStore::local_path("app_config"); + let suffix = PathBuf::from(".edgezero").join("local-config-app_config.json"); + assert!( + path.ends_with(&suffix), + "local_path must always end in `.edgezero/local-config-.json`; got `{}`", + path.display() ); } } diff --git a/crates/edgezero-adapter-axum/src/dev_server.rs b/crates/edgezero-adapter-axum/src/dev_server.rs index d15c7e46..b12fb1be 100644 --- a/crates/edgezero-adapter-axum/src/dev_server.rs +++ b/crates/edgezero-adapter-axum/src/dev_server.rs @@ -1,7 +1,9 @@ -use std::env; use std::fs; +#[cfg(test)] +use std::iter; use std::net::{SocketAddr, TcpListener as StdTcpListener}; use std::path::{Path, PathBuf}; +use std::str::FromStr as _; use std::sync::Arc; use anyhow::Context as _; @@ -12,14 +14,18 @@ use tokio::signal; use tower::{service_fn, Service as _}; use edgezero_core::addr; -use edgezero_core::app::{Hooks, AXUM_ADAPTER}; +use edgezero_core::app::{Hooks, StoreMetadata, StoresMetadata}; use edgezero_core::config_store::ConfigStoreHandle; +use edgezero_core::env_config::EnvConfig; use edgezero_core::key_value_store::KvHandle; -use edgezero_core::manifest::{Manifest, ManifestLoader, DEFAULT_KV_STORE_NAME}; use edgezero_core::router::RouterService; use edgezero_core::secret_store::SecretHandle; +use edgezero_core::store_registry::{ + BoundSecretStore, ConfigRegistry, KvRegistry, SecretRegistry, StoreRegistry, +}; use log::LevelFilter; use simple_logger::SimpleLogger; +use std::collections::BTreeMap; use crate::config_store::AxumConfigStore; use crate::key_value_store::PersistentKvStore; @@ -51,15 +57,15 @@ impl Default for AxumDevServerConfig { /// Optional store handles attached to every request processed by the dev server. /// -/// Build with struct init and `..Default::default()` for the fields you do not need: -/// -/// ```rust,ignore -/// let stores = Stores { kv: Some(kv_handle), ..Default::default() }; -/// ``` +/// Both single-handle fields and registry fields can be set; the service inserts +/// whichever are present. Registries take precedence in `RequestContext`. #[derive(Default)] struct Stores { + config_registry: Option, config_store: Option, kv: Option, + kv_registry: Option, + secret_registry: Option, secrets: Option, } @@ -133,6 +139,13 @@ impl AxumDevServer { } } + #[must_use] + #[inline] + pub fn with_config_registry(mut self, registry: ConfigRegistry) -> Self { + self.stores.config_registry = Some(registry); + self + } + #[must_use] #[inline] pub fn with_config_store(mut self, handle: ConfigStoreHandle) -> Self { @@ -151,6 +164,14 @@ impl AxumDevServer { self } + /// Attach an id-keyed KV registry to the dev server. + #[must_use] + #[inline] + pub fn with_kv_registry(mut self, registry: KvRegistry) -> Self { + self.stores.kv_registry = Some(registry); + self + } + /// Attach a secret store to the dev server. /// /// The handle is shared across all requests, making the `Secrets` extractor @@ -161,10 +182,18 @@ impl AxumDevServer { self.stores.secrets = Some(handle); self } + + /// Attach an id-keyed secret registry to the dev server. + #[must_use] + #[inline] + pub fn with_secret_registry(mut self, registry: SecretRegistry) -> Self { + self.stores.secret_registry = Some(registry); + self + } } -fn kv_init_requirement(manifest: &Manifest) -> KvInitRequirement { - if manifest.stores.kv.is_some() { +fn kv_init_requirement(stores: StoresMetadata) -> KvInitRequirement { + if stores.kv.is_some() { KvInitRequirement::Required } else { KvInitRequirement::Optional @@ -172,10 +201,12 @@ fn kv_init_requirement(manifest: &Manifest) -> KvInitRequirement { } fn kv_store_path(store_name: &str) -> PathBuf { - if store_name == DEFAULT_KV_STORE_NAME { - return PathBuf::from(".edgezero/kv.redb"); - } - + // Every declared id gets its own slug-based filename. The + // pre-rewrite hard-coded `.edgezero/kv.redb` shortcut for + // store_name == "EDGEZERO_KV" is gone -- the runtime no longer + // hands out a default name; if you reach here you have a real + // declared id and the slug encoding handles every shape + // uniformly. PathBuf::from(".edgezero").join(format!( "kv-{}-{:016x}.redb", store_name_slug(store_name), @@ -248,12 +279,21 @@ async fn serve_with_stores( ) -> anyhow::Result<()> { let service = { let mut service = EdgeZeroAxumService::new(router); + if let Some(registry) = stores.config_registry { + service = service.with_config_registry(registry); + } if let Some(handle) = stores.config_store { service = service.with_config_store_handle(handle); } + if let Some(registry) = stores.kv_registry { + service = service.with_kv_registry(registry); + } if let Some(handle) = stores.kv { service = service.with_kv_handle(handle); } + if let Some(registry) = stores.secret_registry { + service = service.with_secret_registry(registry); + } if let Some(handle) = stores.secrets { service = service.with_secret_handle(handle); } @@ -280,28 +320,29 @@ async fn serve_with_stores( Ok(()) } +/// Entry point for an Axum dev-server application. +/// +/// Portable store config is baked into `A` by the `app!` macro; adapter-specific +/// values (platform store names, bind host/port, logging level) are read at +/// runtime from `EDGEZERO__*` environment variables. No `edgezero.toml` is +/// required. +/// /// # Errors /// Returns an error if the dev server fails to bind or any required store handle cannot be initialised. #[inline] -pub fn run_app(manifest_src: &str) -> anyhow::Result<()> { - let manifest = ManifestLoader::try_load_from_str(manifest_src)?; - let manifest_data = manifest.manifest(); - let logging = manifest_data.logging_or_default(AXUM_ADAPTER); - let kv_init_requirement = kv_init_requirement(manifest_data); - let kv_store_name = manifest_data.kv_store_name(AXUM_ADAPTER).to_owned(); - let kv_path = kv_store_path(&kv_store_name); - let has_secret_store = manifest_data.secret_store_enabled("axum"); - - let configured_level: LevelFilter = logging.level.into(); - let level = if logging.echo_stdout.unwrap_or(true) { - configured_level - } else { - LevelFilter::Off - }; +pub fn run_app() -> anyhow::Result<()> { + let env = EnvConfig::from_env(); + let stores = A::stores(); + let kv_init_requirement = kv_init_requirement(stores); + + let level = env + .logging_level() + .and_then(|raw| LevelFilter::from_str(raw).ok()) + .unwrap_or(LevelFilter::Info); let _logger_init = SimpleLogger::new().with_level(level).init(); - let resolution = resolve_addr(manifest_data); + let resolution = resolve_addr(&env); for warning in &resolution.warnings { log::warn!("{warning}"); } @@ -325,74 +366,144 @@ pub fn run_app(manifest_src: &str) -> anyhow::Result<()> { let listener = TokioTcpListener::from_std(std_listener) .context("failed to adopt std listener into tokio")?; - let kv_handle = match kv_handle_from_path(&kv_path) { - Ok(handle) => Some(handle), - Err(err) => { - match kv_init_requirement { - KvInitRequirement::Optional => { - log::warn!( - "KV store '{}' could not be initialized at {}: {}", - kv_store_name, - kv_path.display(), - err - ); - None - } - KvInitRequirement::Required => { - return Err(err.context(format!( - "KV store '{}' is explicitly configured for axum but could not be initialized at {}", - kv_store_name, - kv_path.display() - ))); - } - } - } - }; - // Axum always resolves the config store from the manifest only. - // Unlike Fastly and Cloudflare, it does not check A::config_store() first. - // If a user implements Hooks::config_store() without a [stores.config] section - // in edgezero.toml, the override is silently ignored on Axum. - if A::config_store().is_some() && manifest_data.stores.config.is_none() { - log::warn!("A::config_store() is set but [stores.config] is missing in the manifest. This override is ignored on Axum."); - } - let config_store_handle = manifest_data.stores.config.as_ref().map(|cfg| { - let defaults = cfg.config_store_defaults().clone(); - let store = AxumConfigStore::from_env(defaults); - ConfigStoreHandle::new(Arc::new(store)) - }); - let secret = has_secret_store.then(|| { log::info!("Secret store: reading from environment variables"); SecretHandle::new(Arc::new( - EnvSecretStore::new(), - )) }); - let stores = Stores { - config_store: config_store_handle, - kv: kv_handle, - secrets: secret, + let kv_registry = build_kv_registry(stores.kv, &env, kv_init_requirement)?; + let config_registry = build_config_registry(stores.config); + let secret_registry = build_secret_registry(stores.secrets, &env); + + let request_stores = Stores { + config_registry, + kv_registry, + secret_registry, + ..Stores::default() }; - serve_with_stores(router, listener, true, stores).await + serve_with_stores(router, listener, true, request_stores).await }) } -/// Resolve the bind address from environment variables and manifest config. +/// Build the per-request KV registry from baked store metadata. /// -/// Precedence (highest wins): -/// 1. `EDGEZERO_HOST` / `EDGEZERO_PORT` environment variables -/// 2. `[adapters.axum.adapter]` host/port in the manifest -/// 3. Default: `127.0.0.1:8787` -pub(crate) fn resolve_addr(manifest: &Manifest) -> addr::BindAddrResolution { - let env_host = env::var("EDGEZERO_HOST").ok(); - let env_port = env::var("EDGEZERO_PORT").ok(); - resolve_addr_from_parts(manifest, env_host.as_deref(), env_port.as_deref()) +/// Each declared id resolves to a [`PersistentKvStore`] at +/// `.edgezero/kv--.redb`, where the file name is derived from the +/// platform store name (`EDGEZERO__STORES__KV____NAME` or the id default). +fn build_kv_registry( + kv_meta: Option, + env: &EnvConfig, + init: KvInitRequirement, +) -> anyhow::Result> { + let Some(meta) = kv_meta else { + return Ok(None); + }; + + let mut by_id: BTreeMap = BTreeMap::new(); + for id in meta.ids { + let store_name = env.store_name("kv", id); + let kv_path = kv_store_path(&store_name); + let handle = match kv_handle_from_path(&kv_path) { + Ok(handle) => handle, + Err(err) => match init { + KvInitRequirement::Optional => { + log::warn!( + "KV store '{}' (id `{}`) could not be initialized at {}: {}", + store_name, + id, + kv_path.display(), + err + ); + continue; + } + KvInitRequirement::Required => { + return Err(err.context(format!( + "KV store '{}' (id `{}`) is explicitly configured for axum but could not be initialized at {}", + store_name, + id, + kv_path.display() + ))); + } + }, + }; + by_id.insert((*id).to_owned(), handle); + } + + let default_id = meta.default.to_owned(); + if !by_id.contains_key(&default_id) { + log::warn!( + "KV registry default id `{default_id}` failed to initialize; dropping the KV registry — \ + handlers will see no KV store" + ); + } + Ok(StoreRegistry::from_parts(by_id, default_id)) +} + +/// Build the per-request config registry from the per-id local-file stores. +/// +/// Each declared id reads `.edgezero/local-config-.json`. A missing +/// file yields an empty store for that id — the dev server stays usable +/// before any `config push` has populated the file. A malformed file logs a +/// warning and the id is dropped from the registry rather than failing +/// startup, matching the cloudflare config-binding behaviour. +fn build_config_registry(config_meta: Option) -> Option { + let meta = config_meta?; + let mut by_id: BTreeMap = BTreeMap::new(); + for id in meta.ids { + let store = match AxumConfigStore::from_local_file(id) { + Ok(store) => store, + Err(err) => { + log::warn!( + "config store for id `{}` could not be loaded from {}: {}; \ + dropping this id from the registry", + id, + AxumConfigStore::local_path(id).display(), + err + ); + continue; + } + }; + by_id.insert((*id).to_owned(), ConfigStoreHandle::new(Arc::new(store))); + } + let default_id = meta.default.to_owned(); + if !by_id.contains_key(&default_id) { + log::warn!( + "config registry default id `{default_id}` failed to load; dropping the config registry — \ + handlers will see no config store" + ); + } + StoreRegistry::from_parts(by_id, default_id) } -fn resolve_addr_from_parts( - manifest: &Manifest, - env_host: Option<&str>, - env_port: Option<&str>, -) -> addr::BindAddrResolution { - let adapter = manifest.adapters.get("axum"); - let config_host = adapter.and_then(|entry| entry.adapter.host.as_deref()); - let config_port = adapter.and_then(|entry| entry.adapter.port); - addr::resolve_bind_addr(env_host, env_port, config_host, config_port) +/// Build the per-request secret registry. Axum is `Single` for secrets — every +/// declared id maps to the same env-backed [`EnvSecretStore`]. Each binding +/// captures the platform store name resolved from +/// `EDGEZERO__STORES__SECRETS____NAME` (defaulting to the logical id); +/// the axum env-secret backend ignores the name on lookup, so the binding +/// is observable only via [`BoundSecretStore::store_name`]. +fn build_secret_registry( + secret_meta: Option, + env: &EnvConfig, +) -> Option { + let meta = secret_meta?; + log::info!("Secret store: reading from environment variables"); + let handle = SecretHandle::new(Arc::new(EnvSecretStore::new())); + let mut by_id: BTreeMap = BTreeMap::new(); + for id in meta.ids { + let store_name = env.store_name("secrets", id); + by_id.insert( + (*id).to_owned(), + BoundSecretStore::new(handle.clone(), store_name), + ); + } + // Secret backends are infallible here, so the default id is always + // present in `by_id`; `from_parts` keeps the API symmetric with the + // KV / config builders without changing observable behaviour. + StoreRegistry::from_parts(by_id, meta.default.to_owned()) +} + +/// Resolve the bind address from `EDGEZERO__ADAPTER__*` environment config. +/// +/// Precedence (highest wins): +/// 1. `EDGEZERO__ADAPTER__HOST` / `EDGEZERO__ADAPTER__PORT` +/// 2. Default: `127.0.0.1:8787` +pub(crate) fn resolve_addr(env: &EnvConfig) -> addr::BindAddrResolution { + addr::resolve_bind_addr(env.adapter_host(), env.adapter_port(), None, None) } #[cfg(test)] @@ -458,34 +569,51 @@ mod tests { } #[test] - fn default_store_name_uses_legacy_kv_path() { - assert_eq!( - kv_store_path(DEFAULT_KV_STORE_NAME), - PathBuf::from(".edgezero/kv.redb") + fn every_store_name_gets_a_slug_based_path() { + // The pre-rewrite shortcut hard-coded `.edgezero/kv.redb` + // when the store name equalled the legacy `EDGEZERO_KV` + // constant. Hard cutoff: now every name -- including any + // historical value an operator might still set -- flows + // through the slug+hash encoder, so no name gets a + // special shortcut path. + let legacy = kv_store_path("EDGEZERO_KV"); + assert_ne!( + legacy, + PathBuf::from(".edgezero/kv.redb"), + "post-cutoff: the legacy default name no longer gets the bare `kv.redb` shortcut: {legacy:?}" + ); + assert!( + legacy.to_string_lossy().starts_with(".edgezero/kv-"), + "legacy name still gets a slug-based path: {legacy:?}" + ); + let custom = kv_store_path("sessions"); + assert!( + custom.to_string_lossy().contains("sessions"), + "regular name gets a slug-based filename: {custom:?}" ); + assert_ne!(legacy, custom); } #[test] fn implicit_default_kv_is_optional() { - let manifest = ManifestLoader::load_from_str(""); assert_eq!( - kv_init_requirement(manifest.manifest()), + kv_init_requirement(StoresMetadata::default()), KvInitRequirement::Optional ); } #[test] fn explicit_kv_config_is_required() { - let manifest = ManifestLoader::load_from_str( - r#" -[stores.kv] -name = "EDGEZERO_KV" -"#, - ); - assert_eq!( - kv_init_requirement(manifest.manifest()), - KvInitRequirement::Required - ); + use edgezero_core::app::StoreMetadata; + + let stores = StoresMetadata { + kv: Some(StoreMetadata { + default: "edgezero_kv", + ids: &["edgezero_kv"], + }), + ..StoresMetadata::default() + }; + assert_eq!(kv_init_requirement(stores), KvInitRequirement::Required); } #[test] @@ -521,74 +649,39 @@ name = "EDGEZERO_KV" } #[test] - fn resolve_addr_defaults_without_manifest_config() { - // Note: env var tests use resolve_addr_from_parts to avoid races. - let loader = ManifestLoader::load_from_str(""); - let resolution = resolve_addr_from_parts(loader.manifest(), None, None); + fn resolve_addr_defaults_without_env_config() { + let empty: [(&str, &str); 0] = []; + let resolution = resolve_addr(&EnvConfig::from_vars(empty)); assert_eq!(resolution.addr, SocketAddr::from(([127, 0, 0, 1], 8787))); assert!(resolution.warnings.is_empty()); } #[test] - fn resolve_addr_reads_manifest_host_and_port() { - let manifest = r#" -[adapters.axum.adapter] -host = "0.0.0.0" -port = 3000 -"#; - let loader = ManifestLoader::load_from_str(manifest); - let resolution = resolve_addr_from_parts(loader.manifest(), None, None); + fn resolve_addr_reads_env_host_and_port() { + let env = EnvConfig::from_vars([ + ("EDGEZERO__ADAPTER__HOST", "0.0.0.0"), + ("EDGEZERO__ADAPTER__PORT", "3000"), + ]); + let resolution = resolve_addr(&env); assert_eq!(resolution.addr, SocketAddr::from(([0, 0, 0, 0], 3000))); assert!(resolution.warnings.is_empty()); } - #[test] - fn resolve_addr_env_overrides_manifest() { - let manifest = r#" -[adapters.axum.adapter] -host = "127.0.0.1" -port = 3000 -"#; - let loader = ManifestLoader::load_from_str(manifest); - let resolution = resolve_addr_from_parts(loader.manifest(), Some("0.0.0.0"), Some("4000")); - assert_eq!(resolution.addr, SocketAddr::from(([0, 0, 0, 0], 4000))); - assert!(resolution.warnings.is_empty()); - } - #[test] fn resolve_addr_partial_env_override() { - let manifest = " -[adapters.axum.adapter] -port = 5000 -"; - let loader = ManifestLoader::load_from_str(manifest); - let resolution = resolve_addr_from_parts(loader.manifest(), Some("0.0.0.0"), None); - assert_eq!(resolution.addr, SocketAddr::from(([0, 0, 0, 0], 5000))); + let env = EnvConfig::from_vars([("EDGEZERO__ADAPTER__HOST", "0.0.0.0")]); + let resolution = resolve_addr(&env); + assert_eq!(resolution.addr, SocketAddr::from(([0, 0, 0, 0], 8787))); assert!(resolution.warnings.is_empty()); } #[test] - fn resolve_addr_invalid_env_falls_back_to_manifest() { - let manifest = r#" -[adapters.axum.adapter] -host = "0.0.0.0" -port = 5000 -"#; - let loader = ManifestLoader::load_from_str(manifest); - let resolution = resolve_addr_from_parts(loader.manifest(), Some("not-an-ip"), Some("abc")); - assert_eq!(resolution.addr, SocketAddr::from(([0, 0, 0, 0], 5000))); - assert_eq!(resolution.warnings.len(), 2); - } - - #[test] - fn resolve_addr_invalid_manifest_falls_back_to_default() { - let manifest = r#" -[adapters.axum.adapter] -host = "localhost" -port = 0 -"#; - let loader = ManifestLoader::load_from_str(manifest); - let resolution = resolve_addr_from_parts(loader.manifest(), None, None); + fn resolve_addr_invalid_env_falls_back_to_default() { + let env = EnvConfig::from_vars([ + ("EDGEZERO__ADAPTER__HOST", "not-an-ip"), + ("EDGEZERO__ADAPTER__PORT", "abc"), + ]); + let resolution = resolve_addr(&env); assert_eq!(resolution.addr, SocketAddr::from(([127, 0, 0, 1], 8787))); assert_eq!(resolution.warnings.len(), 2); } @@ -603,7 +696,6 @@ mod integration_tests { use edgezero_core::extractor::Secrets; use edgezero_core::router::RouterService; use edgezero_core::secret_store::SecretHandle as CoreSecretHandle; - use std::iter; use std::time::{Duration, Instant}; use tokio::task::{spawn_blocking, JoinHandle}; use tokio::time::sleep; @@ -780,13 +872,13 @@ mod integration_tests { #[tokio::test(flavor = "multi_thread")] async fn kv_store_persists_across_requests() { async fn write_handler(ctx: RequestContext) -> Result<&'static str, EdgeError> { - let store = ctx.kv_handle().expect("kv configured"); + let store = ctx.kv_store_default().expect("kv configured"); store.put("counter", &42_i32).await?; Ok("written") } async fn read_handler(ctx: RequestContext) -> Result { - let store = ctx.kv_handle().expect("kv configured"); + let store = ctx.kv_store_default().expect("kv configured"); let val: i32 = store.get_or("counter", 0_i32).await?; Ok(val.to_string()) } @@ -819,19 +911,19 @@ mod integration_tests { #[tokio::test(flavor = "multi_thread")] async fn kv_store_delete_across_requests() { async fn write_handler(ctx: RequestContext) -> Result<&'static str, EdgeError> { - let kv = ctx.kv_handle().expect("kv configured"); + let kv = ctx.kv_store_default().expect("kv configured"); kv.put("temp", &"to_delete").await?; Ok("written") } async fn delete_handler(ctx: RequestContext) -> Result<&'static str, EdgeError> { - let kv = ctx.kv_handle().expect("kv configured"); + let kv = ctx.kv_store_default().expect("kv configured"); kv.delete("temp").await?; Ok("deleted") } async fn check_handler(ctx: RequestContext) -> Result { - let kv = ctx.kv_handle().expect("kv configured"); + let kv = ctx.kv_store_default().expect("kv configured"); let exists = kv.exists("temp").await?; Ok(format!("exists={exists}")) } @@ -869,7 +961,7 @@ mod integration_tests { #[tokio::test(flavor = "multi_thread")] async fn kv_store_update_across_requests() { async fn increment_handler(ctx: RequestContext) -> Result { - let kv = ctx.kv_handle().expect("kv configured"); + let kv = ctx.kv_store_default().expect("kv configured"); let val = kv .read_modify_write("counter", 0_i32, |n| n + 1_i32) .await?; @@ -899,7 +991,7 @@ mod integration_tests { #[tokio::test(flavor = "multi_thread")] async fn kv_store_returns_not_found_gracefully() { async fn read_handler(ctx: RequestContext) -> Result { - let kv = ctx.kv_handle().expect("kv configured"); + let kv = ctx.kv_store_default().expect("kv configured"); let val: i32 = kv.get_or("nonexistent", -1_i32).await?; Ok(val.to_string()) } @@ -928,7 +1020,7 @@ mod integration_tests { } async fn write_handler(ctx: RequestContext) -> Result<&'static str, EdgeError> { - let kv = ctx.kv_handle().expect("kv configured"); + let kv = ctx.kv_store_default().expect("kv configured"); let profile = UserProfile { name: "Alice".to_owned(), age: 30, @@ -939,7 +1031,7 @@ mod integration_tests { } async fn read_handler(ctx: RequestContext) -> Result { - let kv = ctx.kv_handle().expect("kv configured"); + let kv = ctx.kv_store_default().expect("kv configured"); let profile: Option = kv.get("user:alice").await?; match profile { Some(found) => Ok(format!("{}:{}", found.name, found.age)), @@ -999,11 +1091,11 @@ mod integration_tests { } #[action] - async fn secret_value_handler(Secrets(store): Secrets) -> Result { - store - .require_str("test-store", "API_KEY") - .await - .map_err(EdgeError::from) + async fn secret_value_handler(secrets: Secrets) -> Result { + let store = secrets + .default() + .ok_or_else(|| EdgeError::service_unavailable("no default secret store registered"))?; + store.require_str("API_KEY").await.map_err(EdgeError::from) } // ----------------------------------------------------------------------- @@ -1018,8 +1110,10 @@ mod integration_tests { let router = RouterService::builder() .get("/secret", secret_value_handler) .build(); - let store = - InMemorySecretStore::new([("test-store/API_KEY", bytes::Bytes::from("s3cr3t"))]); + // The legacy single-handle wiring binds under `"default"` (see + // `Secrets::from_request` fallback), so the in-memory store is + // keyed under that prefix. + let store = InMemorySecretStore::new([("default/API_KEY", bytes::Bytes::from("s3cr3t"))]); let handle = SecretHandle::new(Arc::new(store)); let server = start_test_server_with_store_handle(router, Some(handle)).await; diff --git a/crates/edgezero-adapter-axum/src/secret_store.rs b/crates/edgezero-adapter-axum/src/secret_store.rs index 93827d33..80e2eb7f 100644 --- a/crates/edgezero-adapter-axum/src/secret_store.rs +++ b/crates/edgezero-adapter-axum/src/secret_store.rs @@ -4,7 +4,7 @@ //! variables before starting the dev server: //! //! ```bash -//! API_KEY=mysecret cargo edgezero dev +//! API_KEY=mysecret edgezero serve --adapter axum //! ``` use std::env; diff --git a/crates/edgezero-adapter-axum/src/service.rs b/crates/edgezero-adapter-axum/src/service.rs index 9e88ca12..d726ba06 100644 --- a/crates/edgezero-adapter-axum/src/service.rs +++ b/crates/edgezero-adapter-axum/src/service.rs @@ -10,6 +10,7 @@ use edgezero_core::http::StatusCode; use edgezero_core::key_value_store::KvHandle; use edgezero_core::router::RouterService; use edgezero_core::secret_store::SecretHandle; +use edgezero_core::store_registry::{BoundSecretStore, ConfigRegistry, KvRegistry, SecretRegistry}; use tokio::{runtime::Handle, task}; use tower::Service; @@ -19,10 +20,13 @@ use crate::response::into_axum_response; /// Tower service that adapts `EdgeZero` router requests to Axum/Hyper compatible responses. #[derive(Clone)] pub struct EdgeZeroAxumService { + config_registry: Option, config_store_handle: Option, kv_handle: Option, + kv_registry: Option, router: RouterService, secret_handle: Option, + secret_registry: Option, } impl EdgeZeroAxumService { @@ -30,17 +34,34 @@ impl EdgeZeroAxumService { #[inline] pub fn new(router: RouterService) -> Self { Self { + config_registry: None, config_store_handle: None, kv_handle: None, + kv_registry: None, router, secret_handle: None, + secret_registry: None, } } + /// Attach an id-keyed config-store registry to this service. + #[must_use] + #[inline] + pub fn with_config_registry(mut self, registry: ConfigRegistry) -> Self { + self.config_registry = Some(registry); + self + } + /// Attach a shared config store to this service. /// - /// The handle is cloned into every request's extensions, making - /// `ctx.config_store()` available in handlers. + /// Single-handle setter; the dispatcher synthesises a one-id + /// `ConfigRegistry` keyed under `"default"`. Handlers read it + /// via `ctx.config_store_default()` or the `Config` extractor + /// (the pre-rewrite `ctx.config_handle()` accessor is gone -- + /// see the runtime-store-API hard-cutoff in + /// docs/guide/manifest-store-migration.md). New code that + /// declares multiple ids should use [`Self::with_config_registry`] + /// directly. #[must_use] #[inline] pub fn with_config_store_handle(mut self, handle: ConfigStoreHandle) -> Self { @@ -50,8 +71,14 @@ impl EdgeZeroAxumService { /// Attach a shared KV store to this service. /// - /// The handle is cloned into every request's extensions, making - /// the `Kv` extractor available in handlers. + /// Single-handle setter; the dispatcher synthesises a one-id + /// `KvRegistry` keyed under `"default"`. Handlers read it via + /// `ctx.kv_store_default()` or the `Kv` extractor (the + /// pre-rewrite `ctx.kv_handle()` accessor is gone -- see the + /// runtime-store-API hard-cutoff in + /// docs/guide/manifest-store-migration.md). New code that + /// declares multiple ids should use [`Self::with_kv_registry`] + /// directly. #[must_use] #[inline] pub fn with_kv_handle(mut self, handle: KvHandle) -> Self { @@ -59,16 +86,39 @@ impl EdgeZeroAxumService { self } + /// Attach an id-keyed KV registry to this service. + #[must_use] + #[inline] + pub fn with_kv_registry(mut self, registry: KvRegistry) -> Self { + self.kv_registry = Some(registry); + self + } + /// Attach a shared secret store to this service. /// - /// The handle is cloned into every request's extensions, making - /// the `Secrets` extractor available in handlers. + /// Single-handle setter; the dispatcher synthesises a one-id + /// `SecretRegistry` keyed under `"default"` (the handle is + /// bound to the platform store name `"default"`). Handlers + /// read it via `ctx.secret_store_default()` or the `Secrets` + /// extractor (the pre-rewrite `ctx.secret_handle()` accessor + /// is gone -- see the runtime-store-API hard-cutoff in + /// docs/guide/manifest-store-migration.md). New code that + /// declares multiple ids should use + /// [`Self::with_secret_registry`] directly. #[must_use] #[inline] pub fn with_secret_handle(mut self, handle: SecretHandle) -> Self { self.secret_handle = Some(handle); self } + + /// Attach an id-keyed secret-store registry to this service. + #[must_use] + #[inline] + pub fn with_secret_registry(mut self, registry: SecretRegistry) -> Self { + self.secret_registry = Some(registry); + self + } } impl Service> for EdgeZeroAxumService { @@ -79,9 +129,38 @@ impl Service> for EdgeZeroAxumService { #[inline] fn call(&mut self, req: Request) -> Self::Future { let router = self.router.clone(); - let config_store_handle = self.config_store_handle.clone(); - let kv_handle = self.kv_handle.clone(); - let secret_handle = self.secret_handle.clone(); + // Hard-cutoff: legacy bare `KvHandle` / + // `ConfigStoreHandle` / `SecretHandle` entries are NO + // LONGER inserted into request extensions. The legacy + // `with_*_handle` constructors still take a single + // handle, but the dispatcher synthesises a one-id + // `Registry` under the conventional `"default"` + // id from that handle — and only the registry goes into + // extensions. Handlers must use the registry-aware + // `RequestContext` accessors (`kv_store_default`, + // `config_store_default`, `secret_store_default`) or + // the `Kv` / `Config` / `Secrets` extractors. The + // pre-rewrite `ctx.kv_handle()` / `config_handle()` / + // `secret_handle()` accessors are gone (spec + // hard-cutoff). + let config_registry = self.config_registry.clone().or_else(|| { + self.config_store_handle + .clone() + .map(|handle| ConfigRegistry::single_id("default".to_owned(), handle)) + }); + let kv_registry = self.kv_registry.clone().or_else(|| { + self.kv_handle + .clone() + .map(|handle| KvRegistry::single_id("default".to_owned(), handle)) + }); + let secret_registry = self.secret_registry.clone().or_else(|| { + self.secret_handle.clone().map(|handle| { + SecretRegistry::single_id( + "default".to_owned(), + BoundSecretStore::new(handle, "default".to_owned()), + ) + }) + }); Box::pin(async move { let mut core_request = match into_core_request(req).await { Ok(converted) => converted, @@ -93,16 +172,14 @@ impl Service> for EdgeZeroAxumService { } }; - if let Some(handle) = config_store_handle { - core_request.extensions_mut().insert(handle); + if let Some(registry) = config_registry { + core_request.extensions_mut().insert(registry); } - - if let Some(handle) = kv_handle { - core_request.extensions_mut().insert(handle); + if let Some(registry) = kv_registry { + core_request.extensions_mut().insert(registry); } - - if let Some(handle) = secret_handle { - core_request.extensions_mut().insert(handle); + if let Some(registry) = secret_registry { + core_request.extensions_mut().insert(registry); } let core_response = task::block_in_place(move || { @@ -142,8 +219,9 @@ mod tests { struct FixedConfigStore(String); + #[async_trait::async_trait(?Send)] impl ConfigStore for FixedConfigStore { - fn get(&self, _key: &str) -> Result, ConfigStoreError> { + async fn get(&self, _key: &str) -> Result, ConfigStoreError> { Ok(Some(self.0.clone())) } } @@ -168,13 +246,20 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn with_config_store_handle_injects_into_request() { + // Hard-cutoff: legacy `ctx.config_handle()` is + // gone. The service synthesises a one-id `ConfigRegistry` + // from the wired handle at the dispatch boundary, so + // `ctx.config_store_default()` resolves the same store. let handle = ConfigStoreHandle::new(Arc::new(FixedConfigStore("injected".to_owned()))); let router = RouterService::builder() .get("/check", |ctx: RequestContext| async move { - let store = ctx.config_store().expect("config store should be present"); + let store = ctx + .config_store_default() + .expect("config store should be present"); let val = store .get("any_key") + .await .expect("config lookup should succeed") .unwrap_or_default(); let response = response_builder() @@ -209,7 +294,9 @@ mod tests { let router = RouterService::builder() .get("/check", |ctx: RequestContext| async move { - let kv = ctx.kv_handle().expect("kv handle should be present"); + // Hard-cutoff: see + // `with_config_store_handle_injects_into_request`. + let kv = ctx.kv_store_default().expect("kv handle should be present"); let val: String = kv.get_or("test_key", String::new()).await.unwrap(); let response = response_builder() .status(StatusCode::OK) @@ -231,11 +318,144 @@ mod tests { assert_eq!(&*body, b"injected"); } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn kv_registry_wins_over_bare_handle_when_both_wired() { + // Documents the precedence rule baked into the dispatcher: + // `self.kv_registry.clone().or_else(|| self.kv_handle.map(...single_id))`. + // If a caller wires BOTH `.with_kv_registry(...)` and + // `.with_kv_handle(...)`, the registry wins outright -- the + // bare handle is NOT used as a fallback for ids the registry + // doesn't define, and is NOT synthesised into a "default" + // entry alongside the registry's ids. + use crate::key_value_store::PersistentKvStore; + use edgezero_core::store_registry::{KvRegistry, StoreRegistry}; + use std::collections::BTreeMap; + + let temp_dir = tempfile::tempdir().unwrap(); + let registry_store: Arc = + Arc::new(PersistentKvStore::new(temp_dir.path().join("registry.redb")).unwrap()); + let registry_handle = KvHandle::new(Arc::clone(®istry_store)); + registry_handle + .put("marker", &"from_registry") + .await + .unwrap(); + + let handle_store: Arc = + Arc::new(PersistentKvStore::new(temp_dir.path().join("handle.redb")).unwrap()); + let bare_handle = KvHandle::new(Arc::clone(&handle_store)); + bare_handle.put("marker", &"from_bare").await.unwrap(); + + // Registry binds only `sessions` (NOT `default`). If the + // dispatcher merged in the bare handle, `default` would + // resolve to the bare-handle store; the test asserts it does + // NOT. + let by_id: BTreeMap = [("sessions".to_owned(), registry_handle)] + .into_iter() + .collect(); + let registry: KvRegistry = StoreRegistry::new(by_id, "sessions".to_owned()); + + let router = RouterService::builder() + .get("/probe", |ctx: RequestContext| async move { + // Registry's id resolves to the registry's store. + let named = ctx.kv_store("sessions").expect("registry binding"); + let from_named: String = named.get_or("marker", String::new()).await.unwrap(); + // Default ALSO resolves to the registry (registry's + // own declared default), NOT the bare handle. + let default = ctx.kv_store_default().expect("registry default"); + let from_default: String = default.get_or("marker", String::new()).await.unwrap(); + // The bare handle's synthesised `default` id is NOT + // exposed -- registry wins outright. + let bare_default_visible = ctx.kv_store("default").is_some(); + let response = response_builder() + .status(StatusCode::OK) + .body(Body::from(format!( + "named={from_named} default={from_default} bare_default={bare_default_visible}" + ))) + .expect("response"); + Ok::<_, EdgeError>(response) + }) + .build(); + // Wire BOTH: registry first, then a bare handle. The bare + // handle would synthesise a "default" id under the legacy + // path; the dispatcher's `or_else` precedence must skip it. + let mut service = EdgeZeroAxumService::new(router) + .with_kv_registry(registry) + .with_kv_handle(bare_handle); + + let request = Request::builder() + .uri("/probe") + .body(AxumBody::empty()) + .unwrap(); + let response = service.ready().await.unwrap().call(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + assert_eq!( + &*body, b"named=from_registry default=from_registry bare_default=false", + "registry must win: bare handle is neither merged in nor a fallback" + ); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn with_kv_handle_synthesises_one_id_registry_under_default() { + // Verifies the one-id-registry contract for the setup API: + // `with_kv_handle(h)` wraps `h` in a `KvRegistry` with the + // logical id `"default"`. So in a handler: + // - `ctx.kv_store_default()` must resolve. + // - `ctx.kv_store("default")` must resolve to the same handle. + // - `ctx.kv_store("any-other-id")` must return None (the + // registry has only one id; named lookups for anything + // else are misses, not silent fallbacks). + // This is the precedence guarantee that lets handlers use + // the named-lookup path uniformly across adapters with one + // or many declared stores. + use crate::key_value_store::PersistentKvStore; + + let temp_dir = tempfile::tempdir().unwrap(); + let db_path = temp_dir.path().join("test.redb"); + let store: Arc = Arc::new(PersistentKvStore::new(db_path).unwrap()); + let handle = KvHandle::new(Arc::clone(&store)); + handle.put("k", &"v").await.unwrap(); + + let router = RouterService::builder() + .get("/probe", |ctx: RequestContext| async move { + let by_default = ctx.kv_store_default().is_some(); + let by_default_name = ctx.kv_store("default").is_some(); + let unknown = ctx.kv_store("custom-id").is_none(); + let response = response_builder() + .status(StatusCode::OK) + .body(Body::from(format!( + "default={by_default} named_default={by_default_name} unknown_is_none={unknown}" + ))) + .expect("response"); + Ok::<_, EdgeError>(response) + }) + .build(); + let mut service = EdgeZeroAxumService::new(router).with_kv_handle(handle); + + let request = Request::builder() + .uri("/probe") + .body(AxumBody::empty()) + .unwrap(); + let response = service.ready().await.unwrap().call(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + assert_eq!( + &*body, b"default=true named_default=true unknown_is_none=true", + "synthesised one-id registry: default + named-`default` resolve; unknown id misses" + ); + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn service_without_config_store_handle_still_works() { let router = RouterService::builder() .get("/no-config", |ctx: RequestContext| async move { - let has_config = ctx.config_store().is_some(); + // Hard-cutoff: with no handle and no + // registry wired, the registry-aware accessor + // returns None — same observable result as the + // legacy `config_handle().is_some()` check. + let has_config = ctx.config_store_default().is_some(); let response = response_builder() .status(StatusCode::OK) .body(Body::from(format!("has_config={has_config}"))) @@ -262,17 +482,25 @@ mod tests { use edgezero_core::secret_store::{InMemorySecretStore, SecretHandle}; use std::sync::Arc; + // Hard-cutoff: the service synthesises a one-id + // `SecretRegistry` from `with_secret_handle`, binding the + // handle under the platform store name `"default"`. The + // fixture keys mirror that bound name (`"default/"`) + // so the registry-aware lookup resolves. let handle = SecretHandle::new(Arc::new(InMemorySecretStore::new([( - "env/__EDGEZERO_SERVICE_TEST_SECRET__", + "default/__EDGEZERO_SERVICE_TEST_SECRET__", Bytes::from("injected_value"), )]))); let router = RouterService::builder() .get("/check", |ctx: RequestContext| async move { + // `BoundSecretStore::get_bytes(key)` is single-arg — + // the platform store name is bound by the + // dispatcher's synthesis. let secrets = ctx - .secret_handle() - .expect("secret handle should be present"); + .secret_store_default() + .expect("secret store should be present"); let val = secrets - .get_bytes("env", "__EDGEZERO_SERVICE_TEST_SECRET__") + .get_bytes("__EDGEZERO_SERVICE_TEST_SECRET__") .await .unwrap() .map(|bytes| String::from_utf8_lossy(&bytes).into_owned()) @@ -300,7 +528,9 @@ mod tests { async fn service_without_kv_handle_still_works() { let router = RouterService::builder() .get("/no-kv", |ctx: RequestContext| async move { - let has_kv = ctx.kv_handle().is_some(); + // Hard-cutoff: see + // `service_without_config_store_handle_still_works`. + let has_kv = ctx.kv_store_default().is_some(); let response = response_builder() .status(StatusCode::OK) .body(Body::from(format!("has_kv={has_kv}"))) @@ -320,4 +550,131 @@ mod tests { let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); assert_eq!(&*body, b"has_kv=false"); } + + /// Two-id KV registry: `ctx.kv_store("sessions")` and + /// `ctx.kv_store("cache")` must each resolve to their own backing store. + /// `ctx.kv_store_default()` must resolve to the registered default id. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn with_kv_registry_resolves_named_and_default() { + use crate::key_value_store::PersistentKvStore; + use edgezero_core::store_registry::{KvRegistry, StoreRegistry}; + use std::collections::BTreeMap; + + let temp_dir = tempfile::tempdir().unwrap(); + + let sessions_store: Arc = + Arc::new(PersistentKvStore::new(temp_dir.path().join("sessions.redb")).unwrap()); + let sessions_handle = KvHandle::new(Arc::clone(&sessions_store)); + sessions_handle + .put("greeting", &"hello-from-sessions") + .await + .unwrap(); + + let cache_store: Arc = + Arc::new(PersistentKvStore::new(temp_dir.path().join("cache.redb")).unwrap()); + let cache_handle = KvHandle::new(Arc::clone(&cache_store)); + cache_handle + .put("greeting", &"hello-from-cache") + .await + .unwrap(); + + let by_id: BTreeMap = [ + ("sessions".to_owned(), sessions_handle), + ("cache".to_owned(), cache_handle), + ] + .into_iter() + .collect(); + let registry: KvRegistry = StoreRegistry::new(by_id, "sessions".to_owned()); + + let router = RouterService::builder() + .get("/named/{id}", |ctx: RequestContext| async move { + let id = ctx + .path_params() + .get("id") + .map(ToOwned::to_owned) + .unwrap_or_default(); + let store = ctx + .kv_store(&id) + .ok_or_else(|| EdgeError::not_found(format!("kv id `{id}` not registered")))?; + let value: String = store.get_or("greeting", String::new()).await.unwrap(); + let response = response_builder() + .status(StatusCode::OK) + .body(Body::from(value)) + .expect("response"); + Ok::<_, EdgeError>(response) + }) + .get("/default", |ctx: RequestContext| async move { + let store = ctx + .kv_store_default() + .expect("default kv store is registered"); + let value: String = store.get_or("greeting", String::new()).await.unwrap(); + let response = response_builder() + .status(StatusCode::OK) + .body(Body::from(value)) + .expect("response"); + Ok::<_, EdgeError>(response) + }) + .build(); + let service = EdgeZeroAxumService::new(router).with_kv_registry(registry); + + assert_eq!( + body_at(&service, "/named/sessions").await, + "hello-from-sessions" + ); + assert_eq!(body_at(&service, "/named/cache").await, "hello-from-cache"); + assert_eq!(body_at(&service, "/default").await, "hello-from-sessions"); + } + + /// Unknown ids on a wired registry yield `None` — strict lookup, no + /// fallback to the default. The handler returns 404 in that case. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn kv_registry_lookup_is_strict_for_unknown_ids() { + use crate::key_value_store::PersistentKvStore; + use edgezero_core::store_registry::{KvRegistry, StoreRegistry}; + use std::collections::BTreeMap; + + let temp_dir = tempfile::tempdir().unwrap(); + let only_store: Arc = + Arc::new(PersistentKvStore::new(temp_dir.path().join("only.redb")).unwrap()); + let only_handle = KvHandle::new(Arc::clone(&only_store)); + + let by_id: BTreeMap = + [("only".to_owned(), only_handle)].into_iter().collect(); + let registry: KvRegistry = StoreRegistry::new(by_id, "only".to_owned()); + + let router = RouterService::builder() + .get("/lookup/{id}", |ctx: RequestContext| async move { + let id = ctx + .path_params() + .get("id") + .map(ToOwned::to_owned) + .unwrap_or_default(); + let present = ctx.kv_store(&id).is_some(); + let response = response_builder() + .status(StatusCode::OK) + .body(Body::from(format!("present={present}"))) + .expect("response"); + Ok::<_, EdgeError>(response) + }) + .build(); + let service = EdgeZeroAxumService::new(router).with_kv_registry(registry); + + assert_eq!(body_at(&service, "/lookup/only").await, "present=true"); + assert_eq!(body_at(&service, "/lookup/missing").await, "present=false"); + } + + /// Send a GET request through `service` and return the response body as a UTF-8 string. + /// Lifted out of the registry-aware tests so each can stay flat (clippy + /// `items_after_statements` rejects nested `async fn` definitions). + async fn body_at(service: &EdgeZeroAxumService, path: &str) -> String { + let request = Request::builder() + .uri(path) + .body(AxumBody::empty()) + .unwrap(); + let mut svc = service.clone(); + let response = svc.ready().await.unwrap().call(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + String::from_utf8(body.to_vec()).unwrap() + } } diff --git a/crates/edgezero-adapter-axum/src/templates/src/main.rs.hbs b/crates/edgezero-adapter-axum/src/templates/src/main.rs.hbs index f7f713cf..a73eb876 100644 --- a/crates/edgezero-adapter-axum/src/templates/src/main.rs.hbs +++ b/crates/edgezero-adapter-axum/src/templates/src/main.rs.hbs @@ -1,6 +1,6 @@ use edgezero_adapter_axum::dev_server::run_app; fn main() -> anyhow::Result<()> { - run_app::<{{proj_core_mod}}::App>(include_str!("../../../edgezero.toml"))?; + run_app::<{{proj_core_mod}}::App>()?; Ok(()) } diff --git a/crates/edgezero-adapter-cloudflare/Cargo.toml b/crates/edgezero-adapter-cloudflare/Cargo.toml index 48a7aac9..71deb247 100644 --- a/crates/edgezero-adapter-cloudflare/Cargo.toml +++ b/crates/edgezero-adapter-cloudflare/Cargo.toml @@ -15,6 +15,9 @@ cli = [ "dep:edgezero-adapter", "edgezero-adapter/cli", "dep:ctor", + "dep:serde_json", + "dep:tempfile", + "dep:toml_edit", "dep:walkdir", ] @@ -33,11 +36,14 @@ futures-util = { workspace = true } log = { workspace = true } ctor = { workspace = true, optional = true } serde_json = { workspace = true, optional = true } +tempfile = { workspace = true, optional = true } +toml_edit = { workspace = true, optional = true } worker = { version = "0.8", default-features = false, features = ["http"], optional = true } walkdir = { workspace = true, optional = true } [dev-dependencies] edgezero-core = { path = "../edgezero-core", features = ["test-utils"] } +tempfile = { workspace = true } wasm-bindgen-test = "0.3" web-sys = { version = "0.3", features = [ "Window", diff --git a/crates/edgezero-adapter-cloudflare/src/cli.rs b/crates/edgezero-adapter-cloudflare/src/cli.rs index 805bded4..d9513c8f 100644 --- a/crates/edgezero-adapter-cloudflare/src/cli.rs +++ b/crates/edgezero-adapter-cloudflare/src/cli.rs @@ -1,13 +1,17 @@ +use std::collections::BTreeSet; use std::env; use std::fs; +use std::io::ErrorKind; use std::path::{Path, PathBuf}; use std::process::Command; use ctor::ctor; use edgezero_adapter::cli_support::{ - find_manifest_upwards, find_workspace_root, path_distance, read_package_name, + find_manifest_upwards, find_workspace_root, path_distance, read_package_name, run_native_cli, +}; +use edgezero_adapter::registry::{ + register_adapter, Adapter, AdapterAction, AdapterPushContext, ProvisionStores, ResolvedStoreId, }; -use edgezero_adapter::registry::{register_adapter, Adapter, AdapterAction}; use edgezero_adapter::scaffold::{ register_adapter_blueprint, AdapterBlueprint, AdapterFileSpec, CommandTemplates, DependencySpec, LoggingDefaults, ManifestSpec, ReadmeInfo, TemplateRegistration, @@ -45,7 +49,7 @@ static CLOUDFLARE_BLUEPRINT: AdapterBlueprint = AdapterBlueprint { readme: ReadmeInfo { description: "{display} entrypoint.", dev_heading: "{display} (local)", - dev_steps: &["`edgezero-cli serve --adapter cloudflare`"], + dev_steps: &["`edgezero serve --adapter cloudflare`"], }, run_module: "edgezero_adapter_cloudflare", }; @@ -121,12 +125,31 @@ static CLOUDFLARE_TEMPLATE_REGISTRATIONS: &[TemplateRegistration] = &[ const TARGET_TRIPLE: &str = "wasm32-unknown-unknown"; +const WRANGLER_INSTALL_HINT: &str = + "install the Cloudflare CLI (`npm install -g wrangler`) and try again"; + struct CloudflareCliAdapter; +#[expect( + clippy::missing_trait_methods, + reason = "cloudflare has no validate_app_config_keys / validate_adapter_manifest / validate_typed_secrets requirements; those three trait defaults are intentionally inherited. `single_store_kinds` IS overridden below (returns `&[\"secrets\"]`)." +)] impl Adapter for CloudflareCliAdapter { fn execute(&self, action: AdapterAction, args: &[String]) -> Result<(), String> { match action { - AdapterAction::Build => build().map(|artifact| { + // `wrangler` is the native sign-in surface for Cloudflare + // Workers. EdgeZero stores no credentials — this is a thin + // shell-out. + AdapterAction::AuthLogin => { + run_native_cli("wrangler", &["login"], WRANGLER_INSTALL_HINT) + } + AdapterAction::AuthLogout => { + run_native_cli("wrangler", &["logout"], WRANGLER_INSTALL_HINT) + } + AdapterAction::AuthStatus => { + run_native_cli("wrangler", &["whoami"], WRANGLER_INSTALL_HINT) + } + AdapterAction::Build => build(args).map(|artifact| { log::info!( "[edgezero] Cloudflare build artifact -> {}", artifact.display() @@ -138,15 +161,668 @@ impl Adapter for CloudflareCliAdapter { } } + fn merged_id_kinds(&self) -> &'static [&'static str] { + // Both KV and Config back to Worker KV namespaces via the + // same `[[kv_namespaces]] binding = ` + // wrangler.toml entry. Declaring the same logical id under + // both kinds (e.g. `[stores.kv].ids = ["x"]` AND + // `[stores.config].ids = ["x"]`) resolves to a SINGLE + // underlying KV namespace at runtime — KV writes from the + // app silently clobber config-shaped entries (and vice + // versa). Provision compounds the hazard: the second + // binding would already be present from the first kind's + // `upsert_kv_namespace` and get reported as "already + // provisioned" instead of failing the collision. + // + // CLI `config validate` rejects this collision before any + // wrangler shell-out happens. + &["kv", "config"] + } + fn name(&self) -> &'static str { "cloudflare" } + + fn provision( + &self, + manifest_root: &Path, + adapter_manifest_path: Option<&str>, + _component_selector: Option<&str>, + stores: &ProvisionStores<'_>, + dry_run: bool, + ) -> Result, String> { + //: KV ids and config ids both back to Cloudflare KV + // namespaces. Secrets are runtime-managed via + // `wrangler secret put` — provision is a no-op for them. + let Some(rel) = adapter_manifest_path else { + return Err( + "[adapters.cloudflare.adapter].manifest must point at wrangler.toml for provision" + .to_owned(), + ); + }; + let wrangler_path = manifest_root.join(rel); + + let mut out = Vec::new(); + for store in stores.kv.iter().chain(stores.config.iter()) { + let logical = &store.logical; + // The Cloudflare KV binding name is what the runtime + // calls `env.kv(...)` with -- it's resolved at request + // time from `EDGEZERO__STORES______NAME` + // (default = logical id). Provision must write the + // resolved PLATFORM name into wrangler.toml, otherwise + // the runtime will look up a binding the CLI never + // created. + let binding = &store.platform; + // Idempotency check BEFORE shelling out: if a + // [[kv_namespaces]] entry with `binding = ` + // is already present and has a real namespace id, skip. + // Without this guard a re-run of provision would invoke + // `wrangler kv namespace create` again and orphan the + // previously-created namespace -- wasting account quota. + // A placeholder id (anything that isn't a 32-char + // lowercase hex string, like the + // `local-dev-placeholder` the scaffold wrangler.toml + // writes) is treated as "not yet provisioned" so the + // entry gets rewritten with the real id. + // + // We deliberately do NOT cross-check the stored id + // against Cloudflare's API (e.g. by calling `wrangler + // kv namespace list` to confirm the id still exists). + // Verifying every entry on every provision run would + // add a network round-trip per id and require parsing + // yet another wrangler subcommand output. The skip + // line names the existing id explicitly so the operator + // can verify it themselves and, if the Cloudflare-side + // namespace was deleted out-of-band, remove the stale + // entry by hand before re-running provision. + let existing = existing_real_namespace_id(&wrangler_path, binding)?; + if let Some(existing_id) = existing { + out.push(format!( + "binding `{binding}` (logical id `{logical}`) already provisioned (id={existing_id} in {}); skipping. To force a fresh namespace: delete the [[kv_namespaces]] entry for binding `{binding}` AND run `wrangler kv namespace delete --namespace-id={existing_id}` (the old remote namespace lingers otherwise), then re-run provision.", + wrangler_path.display() + )); + continue; + } + // Pre-flight the writeback shape BEFORE shelling + // `wrangler kv namespace create`. `read_namespace_id` + // tolerates both `[[kv_namespaces]]` (array-of-tables) + // and `kv_namespaces = [{ binding = "...", id = "..." }]` + // (inline-array) forms, but `upsert_kv_namespace` only + // writes back through the array-of-tables shape. Without + // this guard, an inline-array manifest passes the + // "already provisioned?" probe (because no id is + // present), the remote `create` succeeds, and then the + // upsert errors out — leaving the freshly-created + // namespace orphaned on Cloudflare with no local + // writeback to track it. + // + // Refuse early so the operator fixes the manifest shape + // BEFORE any account-side mutation. + check_kv_namespaces_writeback_shape(&wrangler_path)?; + if dry_run { + out.push(format!( + "would run `wrangler kv namespace create {binding}` and append [[kv_namespaces]] binding = \"{binding}\" to {} (logical id `{logical}`)", + wrangler_path.display() + )); + continue; + } + let namespace_id = create_kv_namespace(binding)?; + upsert_kv_namespace(&wrangler_path, binding, &namespace_id)?; + out.push(format!( + "created KV namespace `{binding}` (logical id `{logical}`, namespace id={namespace_id}); written to {}", + wrangler_path.display() + )); + } + for store in stores.secrets { + let logical = &store.logical; + let platform = &store.platform; + out.push(format!( + "cloudflare secret `{platform}` (logical id `{logical}`) is runtime-managed via `wrangler secret put`; nothing to provision" + )); + } + if out.is_empty() { + out.push("cloudflare has no declared stores to provision".to_owned()); + } + Ok(out) + } + + fn push_config_entries( + &self, + manifest_root: &Path, + adapter_manifest_path: Option<&str>, + _component_selector: Option<&str>, + store: &ResolvedStoreId, + entries: &[(String, String)], + _push_ctx: &AdapterPushContext<'_>, + dry_run: bool, + ) -> Result, String> { + //: read namespace id from wrangler.toml (matched by + // `binding = `), then `wrangler kv bulk put + // --namespace-id= --remote`. Keys in + // dotted form — the CLI already flattened them. + // + // **--remote** is mandatory for the prod-push path: + // wrangler v4 defaults KV bulk-put to LOCAL storage when + // the command supports both — meaning a v4 user running + // `wrangler kv bulk put` without `--remote` would silently + // populate Miniflare state under `.wrangler/state` and + // report success while leaving the live Cloudflare + // namespace empty. Explicit `--remote` removes the + // ambiguity. + let Some(rel) = adapter_manifest_path else { + return Err( + "[adapters.cloudflare.adapter].manifest must point at wrangler.toml for config push" + .to_owned(), + ); + }; + let wrangler_path = manifest_root.join(rel); + let binding = store.platform.as_str(); + let logical = store.logical.as_str(); + // Dry-run is lenient about a missing/unresolved binding so + // operators can preview the keyset BEFORE running provision. + // Real runs still err loudly so we don't silently push to + // a non-existent namespace. + if dry_run { + let header = find_namespace_id(&wrangler_path, binding).map_or_else( + |_| format!( + "would run `wrangler kv bulk put --namespace-id= --remote` with {} entries for binding `{binding}` (logical id `{logical}`, binding not yet provisioned -- run `edgezero provision --adapter cloudflare` to resolve the namespace id)", + entries.len() + ), + |ns_id| format!( + "would run `wrangler kv bulk put --namespace-id={ns_id} --remote` with {} entries for binding `{binding}` (logical id `{logical}`)", + entries.len() + ), + ); + let mut out = vec![header]; + for (key, _) in entries { + out.push(format!(" would create entry `{key}`")); + } + return Ok(out); + } + let namespace_id = find_namespace_id(&wrangler_path, binding)?; + if entries.is_empty() { + return Ok(vec![format!( + "no config entries to push to KV namespace `{binding}` (logical id `{logical}`, id={namespace_id})" + )]); + } + let payload = bulk_payload(entries)?; + let temp = tempfile::Builder::new() + .prefix("edgezero-cf-push-") + .suffix(".json") + .tempfile() + .map_err(|err| { + format!("failed to create temp file for wrangler bulk payload: {err}") + })?; + fs::write(temp.path(), payload.as_bytes()) + .map_err(|err| format!("failed to write {}: {err}", temp.path().display()))?; + let temp_arg = temp + .path() + .to_str() + .ok_or_else(|| format!("temp file path {} is not UTF-8", temp.path().display()))?; + let namespace_arg = format!("--namespace-id={namespace_id}"); + // Run from the wrangler.toml's directory so wrangler picks + // up its `account_id` / `--env` resolution + persistence + // settings the same way `wrangler dev` / `wrangler deploy` + // do for this project. + let project_dir = wrangler_path.parent().unwrap_or(manifest_root); + let output = Command::new("wrangler") + .current_dir(project_dir) + .args([ + "kv", + "bulk", + "put", + temp_arg, + namespace_arg.as_str(), + "--remote", + ]) + .output() + .map_err(|err| { + if err.kind() == ErrorKind::NotFound { + format!("`wrangler` not found on PATH; {WRANGLER_INSTALL_HINT}") + } else { + format!("failed to spawn `wrangler`: {err}") + } + })?; + if !output.status.success() { + return Err(format!( + "`wrangler kv bulk put --remote` exited with status {}\nstderr: {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + )); + } + Ok(vec![format!( + "pushed {} entries to KV namespace `{binding}` (logical id `{logical}`, id={namespace_id})", + entries.len() + )]) + } + + fn push_config_entries_local( + &self, + manifest_root: &Path, + adapter_manifest_path: Option<&str>, + _component_selector: Option<&str>, + store: &ResolvedStoreId, + entries: &[(String, String)], + _push_ctx: &AdapterPushContext<'_>, + dry_run: bool, + ) -> Result, String> { + // Local push: address the binding directly via + // `wrangler kv bulk put --binding --local`. + // Crucially we do NOT resolve a namespace id here — the + // scaffold ships with `local-dev-placeholder` ids, so an + // operator that hasn't run `edgezero provision` yet should + // still be able to seed `.wrangler/state` from the manifest + // (matching wrangler's own local KV docs). Wrangler stores + // local entries keyed by binding, not namespace id, so the + // follow-up `wrangler dev --local` / `edgezero serve + // --adapter cloudflare` reads them back through the same + // binding name. + let Some(rel) = adapter_manifest_path else { + return Err( + "[adapters.cloudflare.adapter].manifest must point at wrangler.toml for config push --local" + .to_owned(), + ); + }; + let wrangler_path = manifest_root.join(rel); + let project_dir = wrangler_path.parent().unwrap_or(manifest_root); + let binding = store.platform.as_str(); + let logical = store.logical.as_str(); + if dry_run { + let mut out = vec![format!( + "would run `wrangler kv bulk put --binding {binding} --local` with {} entries for binding `{binding}` (logical id `{logical}`)", + entries.len() + )]; + for (key, _) in entries { + out.push(format!(" would create local entry `{key}`")); + } + return Ok(out); + } + if entries.is_empty() { + return Ok(vec![format!( + "no config entries to push to local KV namespace `{binding}` (logical id `{logical}`)" + )]); + } + let payload = bulk_payload(entries)?; + let temp = tempfile::Builder::new() + .prefix("edgezero-cf-push-local-") + .suffix(".json") + .tempfile() + .map_err(|err| { + format!("failed to create temp file for wrangler bulk payload: {err}") + })?; + fs::write(temp.path(), payload.as_bytes()) + .map_err(|err| format!("failed to write {}: {err}", temp.path().display()))?; + let temp_arg = temp + .path() + .to_str() + .ok_or_else(|| format!("temp file path {} is not UTF-8", temp.path().display()))?; + let output = Command::new("wrangler") + .current_dir(project_dir) + .args([ + "kv", + "bulk", + "put", + temp_arg, + "--binding", + binding, + "--local", + ]) + .output() + .map_err(|err| { + if err.kind() == ErrorKind::NotFound { + format!("`wrangler` not found on PATH; {WRANGLER_INSTALL_HINT}") + } else { + format!("failed to spawn `wrangler`: {err}") + } + })?; + if !output.status.success() { + return Err(format!( + "`wrangler kv bulk put --binding {binding} --local` exited with status {}\nstderr: {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + )); + } + Ok(vec![format!( + "pushed {} entries to local KV namespace bound as `{binding}` (logical id `{logical}`); `.wrangler/state` updated", + entries.len() + )]) + } + + fn single_store_kinds(&self) -> &'static [&'static str] { + //: cloudflare is Multi for KV (KV namespaces) and + // Config (KV namespaces), Single for Secrets (Worker + // Secrets is a single flat bag). + &["secrets"] + } +} + +/// Shell out to `wrangler kv namespace create `, capture +/// stdout, and parse the resulting namespace id. The CLI's +/// `provision` command resolves this against the user's +/// `wrangler.toml` and writes the `[[kv_namespaces]]` entry. +/// +/// # Errors +/// Returns an error if `wrangler` isn't on `PATH`, the child fails +/// to spawn, the exit status is non-zero, or stdout doesn't +/// include a parseable `id = "..."` line. +fn create_kv_namespace(binding: &str) -> Result { + let output = Command::new("wrangler") + .args(["kv", "namespace", "create", binding]) + .output() + .map_err(|err| { + if err.kind() == ErrorKind::NotFound { + format!("`wrangler` not found on PATH; {WRANGLER_INSTALL_HINT}") + } else { + format!("failed to spawn `wrangler`: {err}") + } + })?; + if !output.status.success() { + return Err(format!( + "`wrangler kv namespace create {binding}` exited with status {}\nstderr: {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + )); + } + let stdout = String::from_utf8_lossy(&output.stdout); + extract_namespace_id(&stdout).ok_or_else(|| { + format!( + "wrangler created `{binding}` but stdout did not include a parseable `id = \"...\"` line -- wrangler may have changed its output format; pin a known-compatible wrangler version or file an issue. Raw stdout:\n{stdout}" + ) + }) +} + +/// Pull the namespace id out of `wrangler kv namespace create` +/// stdout. Wrangler 3+ prints (something like): +/// +/// ```text +/// 🌀 Creating namespace with title "..." +/// ✨ Success! +/// Add the following to your configuration file in your kv_namespaces array: +/// [[kv_namespaces]] +/// binding = "my-kv" +/// id = "abc123..." +/// ``` +/// +/// We tolerate leading whitespace + surrounding decoration. To +/// avoid grabbing a stray informational line like +/// `id = ""` printed somewhere else in wrangler +/// output (or a hypothetical future `id = ...` line that names a +/// non-KV resource), we anchor to the `[[kv_namespaces]]` table +/// header AND require the value to be 32-char lowercase hex +/// (Cloudflare's actual namespace-id shape). The scan walks +/// lines top-down: when we see `[[kv_namespaces]]` we set a +/// scope flag; the next `id = "<32-char-hex>"` line within that +/// scope is the result. A new top-level header resets the scope. +fn extract_namespace_id(stdout: &str) -> Option { + let mut in_kv_namespaces = false; + for line in stdout.lines() { + let trimmed = line.trim(); + if trimmed == "[[kv_namespaces]]" { + in_kv_namespaces = true; + continue; + } + // Any other table header ends the scope so we don't reach + // forward into a sibling block. + if trimmed.starts_with('[') && trimmed.ends_with(']') { + in_kv_namespaces = false; + continue; + } + if !in_kv_namespaces { + continue; + } + let Some(after_id_kw) = trimmed.strip_prefix("id") else { + continue; + }; + let Some(after_eq) = after_id_kw.trim_start().strip_prefix('=') else { + continue; + }; + let Some(quoted) = after_eq.trim_start().strip_prefix('"') else { + continue; + }; + let Some((id, _)) = quoted.split_once('"') else { + continue; + }; + if is_real_namespace_id(id) { + return Some(id.to_owned()); + } + } + None +} + +/// Heuristic: is `id` a real Cloudflare KV namespace id (32-char +/// lowercase hex), as opposed to a scaffold placeholder like +/// `local-dev-placeholder`? Cloudflare's API consistently returns +/// 32-char lowercase hex, so we use that as a tight cheap signal. +/// +/// Additionally rejects hex-shape sentinels that LOOK like real +/// ids but are obviously hand-typed placeholders: anything with +/// fewer than 6 distinct hex characters (catches all-zeros, +/// all-`a`, `deadbeefdeadbeefdeadbeefdeadbeef`, etc.). A real id +/// generated by Cloudflare's API has effectively uniform random +/// hex distribution: expected distinct chars over 32 draws from +/// 16 symbols is ~14, and the dominant term P(=5 distinct) is on +/// the order of 10^-13 -- so false rejections of real ids are +/// astronomically unlikely. +fn is_real_namespace_id(id: &str) -> bool { + if id.len() != 32 { + return false; + } + if !id + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) + { + return false; + } + // Distinct-byte count via a BTreeSet: 32 inserts is trivial, + // and the set form avoids the arithmetic-side-effect / + // silent-as / indexing-panic shapes the project's clippy + // profile rejects. + let distinct: BTreeSet = id.bytes().collect(); + distinct.len() >= 6 +} + +/// If `path` already declares a `[[kv_namespaces]]` entry with +/// `binding = binding` AND its `id` looks like a real Cloudflare +/// namespace id, return that id. Returns `Ok(None)` if the binding +/// is absent OR present with a placeholder id (so provision can +/// treat both cases as "needs (re-)create"). A failure to read / +/// parse the file is a hard error -- provision needs an authoritative +/// answer. +fn existing_real_namespace_id(path: &Path, binding: &str) -> Result, String> { + let Some(existing) = read_namespace_id(path, binding)? else { + return Ok(None); + }; + if is_real_namespace_id(&existing) { + Ok(Some(existing)) + } else { + Ok(None) + } +} + +/// Internal: look up `binding`'s `id` in `wrangler.toml` without +/// the "did you run provision?" error path that `find_namespace_id` +/// adds. Missing file -> `Ok(None)`. Returns the raw id whether or +/// not it looks like a real Cloudflare id. +/// +/// Errors loudly if `kv_namespaces` exists but is neither an +/// array-of-tables nor an inline-array (e.g. the operator typed +/// `kv_namespaces = "oops"`). Silently returning `None` there +/// surfaces downstream as "did you run provision?" -- misleading, +/// because the actual problem is a malformed manifest. +fn read_namespace_id(path: &Path, binding: &str) -> Result, String> { + use toml_edit::{DocumentMut, Item, Value}; + + let raw = match fs::read_to_string(path) { + Ok(raw) => raw, + Err(err) if err.kind() == ErrorKind::NotFound => return Ok(None), + Err(err) => return Err(format!("failed to read {}: {err}", path.display())), + }; + let doc: DocumentMut = raw + .parse() + .map_err(|err| format!("failed to parse {}: {err}", path.display()))?; + let id = match doc.get("kv_namespaces") { + Some(Item::ArrayOfTables(arr)) => arr.iter().find_map(|table| { + if table.get("binding").and_then(Item::as_str) == Some(binding) { + table.get("id").and_then(Item::as_str).map(str::to_owned) + } else { + None + } + }), + Some(Item::Value(Value::Array(arr))) => arr.iter().find_map(|item| { + let table = item.as_inline_table()?; + if table.get("binding").and_then(Value::as_str) == Some(binding) { + table.get("id").and_then(Value::as_str).map(str::to_owned) + } else { + None + } + }), + Some(other) => { + return Err(format!( + "{}: `kv_namespaces` exists but is neither `[[kv_namespaces]]` (array-of-tables) nor an inline array of `{{ binding, id }}` records; got TOML item of type `{}`", + path.display(), + item_kind(other) + )); + } + None => None, + }; + Ok(id) +} + +/// Refuse to provision a new namespace when `wrangler.toml`'s +/// `kv_namespaces` exists in a form that `upsert_kv_namespace` +/// can't write back to. Today that means the inline-array form +/// (`kv_namespaces = [{ binding = "...", id = "..." }]`), which +/// `read_namespace_id` tolerates but `upsert_kv_namespace`'s +/// `as_array_of_tables_mut()` rejects. Without this guard, the +/// orphan-namespace hazard documented in `upsert_kv_namespace` +/// reappears: `wrangler kv namespace create` succeeds, then +/// upsert errors out and the new namespace lingers on +/// Cloudflare with no local writeback to track it. Missing or +/// array-of-tables forms are OK. +fn check_kv_namespaces_writeback_shape(path: &Path) -> Result<(), String> { + use toml_edit::{DocumentMut, Item, Value}; + + let raw = match fs::read_to_string(path) { + Ok(text) => text, + Err(err) if err.kind() == ErrorKind::NotFound => return Ok(()), + Err(err) => return Err(format!("failed to read {}: {err}", path.display())), + }; + let doc: DocumentMut = raw + .parse() + .map_err(|err| format!("failed to parse {}: {err}", path.display()))?; + match doc.get("kv_namespaces") { + None | Some(Item::ArrayOfTables(_)) => Ok(()), + Some(Item::Value(Value::Array(_))) => Err(format!( + "{}: `kv_namespaces` is declared as an inline array (`kv_namespaces = [{{ binding = \"...\", id = \"...\" }}]`); provision can only write back through the `[[kv_namespaces]]` array-of-tables form. Convert each entry to a `[[kv_namespaces]]` block BEFORE re-running provision; otherwise a successful `wrangler kv namespace create` would leave the new namespace orphaned on Cloudflare with no local entry to track it.", + path.display() + )), + Some(other) => Err(format!( + "{}: `kv_namespaces` exists but is neither `[[kv_namespaces]]` (array-of-tables) nor an inline array of `{{ binding, id }}` records; got TOML item of type `{}`. Convert it manually before re-running provision.", + path.display(), + item_kind(other) + )), + } +} + +/// One-line label for a `toml_edit::Item` (for diagnostic +/// messages -- not a canonical TOML type description). +fn item_kind(item: &toml_edit::Item) -> &'static str { + use toml_edit::{Item, Value}; + match item { + Item::None => "none", + Item::Value(Value::String(_)) => "string", + Item::Value(Value::Integer(_)) => "integer", + Item::Value(Value::Float(_)) => "float", + Item::Value(Value::Boolean(_)) => "boolean", + Item::Value(Value::Datetime(_)) => "datetime", + Item::Value(Value::Array(_)) => "array", + Item::Value(Value::InlineTable(_)) => "inline-table", + Item::Table(_) => "table", + Item::ArrayOfTables(_) => "array-of-tables", + } +} + +/// Insert OR update the `[[kv_namespaces]]` entry for `binding`, +/// rewriting `id` if the binding already exists (e.g. provision +/// is replacing a `local-dev-placeholder`). Used by provision so +/// re-running on a scaffolded wrangler.toml replaces the placeholder +/// with the real id instead of silently skipping. +/// +/// Caveat: `toml_edit::Table::insert` replaces the value's `Item`, +/// which drops any trailing inline comment that was attached to +/// the prior `id = "..."` line (e.g. `id = "old" # delete me`). +/// Sibling fields under the same `[[kv_namespaces]]` table are +/// preserved verbatim -- only the `id` line's decor is lost. +/// +/// Concurrency: provision is NOT safe to run concurrently against +/// the same `wrangler.toml`. Two concurrent runs may both miss the +/// idempotency check, both call `wrangler kv namespace create` +/// remotely, then race the file write -- the loser's namespace +/// becomes an orphan in the Cloudflare account. `EdgeZero` does not +/// take a lockfile; operators must serialise provision themselves. +fn upsert_kv_namespace(path: &Path, binding: &str, id: &str) -> Result<(), String> { + use toml_edit::{value, ArrayOfTables, DocumentMut, Item, Table}; + + // Treat NotFound as "start with empty document" symmetrically with + // `read_namespace_id` so the orphan-namespace hazard goes away: if + // wrangler.toml is missing entirely (e.g. operator deleted it + // between scaffold and provision), the upsert that follows a + // successful `wrangler kv namespace create` would otherwise error + // out, leaving the remote namespace orphaned. + let raw = match fs::read_to_string(path) { + Ok(text) => text, + Err(err) if err.kind() == ErrorKind::NotFound => String::new(), + Err(err) => return Err(format!("failed to read {}: {err}", path.display())), + }; + let mut doc: DocumentMut = raw + .parse() + .map_err(|err| format!("failed to parse {}: {err}", path.display()))?; + + let entry = doc + .entry("kv_namespaces") + .or_insert_with(|| Item::ArrayOfTables(ArrayOfTables::new())); + let arr_of_tables = entry.as_array_of_tables_mut().ok_or_else(|| { + format!( + "{}: `kv_namespaces` exists but is not an array-of-tables (`[[kv_namespaces]]`); convert it manually before re-running provision", + path.display() + ) + })?; + + let existing_idx = arr_of_tables + .iter() + .position(|table| table.get("binding").and_then(Item::as_str) == Some(binding)); + if let Some(idx) = existing_idx { + if let Some(existing) = arr_of_tables.get_mut(idx) { + existing.insert("id", value(id)); + } + } else { + let mut new_table = Table::new(); + new_table.insert("binding", value(binding)); + new_table.insert("id", value(id)); + arr_of_tables.push(new_table); + } + + fs::write(path, doc.to_string()) + .map_err(|err| format!("failed to write {}: {err}", path.display()))?; + Ok(()) +} + +/// Render the entries as the `[{"key": "...", "value": "..."}, …]` +/// JSON wrangler expects for `kv bulk put`. Keys arrive pre-flattened +/// from the CLI (dotted form,); cloudflare passes them through. +fn bulk_payload(entries: &[(String, String)]) -> Result { + let payload: Vec = entries + .iter() + .map(|(key, value)| serde_json::json!({ "key": key, "value": value })) + .collect(); + serde_json::to_string(&payload) + .map_err(|err| format!("failed to serialize wrangler bulk payload: {err}")) } /// # Errors /// Returns an error if the Cloudflare wrangler build command fails. #[inline] -pub fn build() -> Result { +pub fn build(extra_args: &[String]) -> Result { let manifest = find_wrangler_manifest(env::current_dir().map_err(|err| err.to_string())?.as_path())?; let manifest_dir = manifest @@ -166,6 +842,7 @@ pub fn build() -> Result { .to_str() .ok_or("invalid Cargo manifest path")?, ]) + .args(extra_args) .status() .map_err(|err| format!("failed to run cargo build: {err}"))?; if !status.success() { @@ -210,6 +887,38 @@ pub fn deploy(extra_args: &[String]) -> Result<(), String> { Ok(()) } +/// Look up the namespace id wrangler.toml has bound to `binding`, +/// rejecting placeholder ids (anything that isn't a 32-char +/// lowercase hex Cloudflare API id). +/// +/// Accepts both `[[kv_namespaces]]` (array-of-tables, what +/// `provision` writes and wrangler's own post-create hint prints) +/// and the inline-array form. Returns Err with a "did you run +/// provision?" hint if the binding is absent OR holds a placeholder +/// like `local-dev-placeholder` — without this check `push` would +/// shell out to `wrangler kv bulk put --namespace-id=`, +/// which fails at wrangler with a less actionable error. +fn find_namespace_id(wrangler_path: &Path, binding: &str) -> Result { + // read_namespace_id returns Ok(None) for both + // missing-file AND binding-not-present; for `find_namespace_id` + // the user wants a "did you run provision?" hint in both cases, + // so collapse them into the same error message. + let raw = read_namespace_id(wrangler_path, binding)?.ok_or_else(|| { + format!( + "{}: no [[kv_namespaces]] entry with binding = {binding:?} (did you run `edgezero provision --adapter cloudflare`?)", + wrangler_path.display() + ) + })?; + if is_real_namespace_id(&raw) { + Ok(raw) + } else { + Err(format!( + "{}: binding {binding:?} has id {raw:?}, which doesn't look like a real Cloudflare KV namespace id (expected 32-char lowercase hex). This is usually a scaffold placeholder -- run `edgezero provision --adapter cloudflare` to create a real namespace and overwrite the entry.", + wrangler_path.display() + )) + } +} + fn find_wrangler_manifest(start: &Path) -> Result { if let Some(found) = find_manifest_upwards(start, "wrangler.toml") { return Ok(found); @@ -318,3 +1027,787 @@ pub fn serve(extra_args: &[String]) -> Result<(), String> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + // Shared fixture names. Pinning these as consts (instead of + // inline `"sessions"` / `"app_config"` per call site) keeps the + // setup-vs-assertion pair in sync -- a typo in one place no + // longer silently divorces from the other, because both reference + // the same const. Also names the intent: these are the LOGICAL + // store ids the cloudflare adapter operates on, not arbitrary + // strings. + const TEST_KV_ID: &str = "sessions"; + const TEST_KV_ID_ALT: &str = "cache"; + const TEST_CONFIG_ID: &str = "app_config"; + const TEST_SECRET_ID: &str = "default"; + + // ---------- extract_namespace_id ---------- + + #[test] + fn extract_namespace_id_parses_wrangler_3_output() { + // wrangler decorates these lines with unicode glyphs in real + // output; we drop them from the fixture to keep the source + // file ASCII-only (clippy::non_ascii_literal). The parser + // requires both the `[[kv_namespaces]]` anchor and a + // 32-char-lowercase-hex id. + let stdout = r#"Creating namespace with title "my-kv" +Success! +Add the following to your configuration file in your kv_namespaces array: +[[kv_namespaces]] +binding = "my-kv" +id = "00112233445566778899aabbccddeeff" +"#; + assert_eq!( + extract_namespace_id(stdout).as_deref(), + Some("00112233445566778899aabbccddeeff") + ); + } + + #[test] + fn extract_namespace_id_tolerates_extra_whitespace() { + let stdout = "[[kv_namespaces]]\n id = \"00112233445566778899aabbccddeeff\" \n"; + assert_eq!( + extract_namespace_id(stdout).as_deref(), + Some("00112233445566778899aabbccddeeff") + ); + } + + #[test] + fn extract_namespace_id_returns_none_on_missing_id_line() { + assert!(extract_namespace_id("nothing to see here").is_none()); + assert!(extract_namespace_id("").is_none()); + assert!( + extract_namespace_id("[[kv_namespaces]]\nid = \"\"").is_none(), + "empty value not a real id" + ); + } + + #[test] + fn extract_namespace_id_ignores_unrelated_lines_starting_with_id() { + // `identifier = "..."` doesn't match -- we strip exactly the + // prefix `id` then require `=`. Also doesn't match because + // there's no `[[kv_namespaces]]` anchor. + assert!(extract_namespace_id("[[kv_namespaces]]\nidentifier = \"x\"").is_none()); + } + + #[test] + fn extract_namespace_id_requires_kv_namespaces_anchor() { + // A bare `id = "<32-char-hex>"` line that isn't preceded by + // `[[kv_namespaces]]` must not match -- otherwise a future + // wrangler info line like `id = ""` printed + // somewhere else in stdout would be picked up as the + // namespace id and silently corrupt wrangler.toml on writeback. + let unanchored = "id = \"00112233445566778899aabbccddeeff\"\n"; + assert!(extract_namespace_id(unanchored).is_none()); + + // A different table header BEFORE the `id` line scopes us + // out of the kv-namespaces context. + let other_block = "[[d1_databases]]\nid = \"00112233445566778899aabbccddeeff\"\n"; + assert!(extract_namespace_id(other_block).is_none()); + } + + #[test] + fn extract_namespace_id_rejects_non_real_id_inside_kv_namespaces_anchor() { + // Even with the anchor, the value must look like a real + // Cloudflare id (32-char lowercase hex with the diversity + // floor). Shorter or non-hex values are skipped, not + // returned -- forces the operator to investigate stdout + // drift rather than silently writing a bogus id. + let stdout = "[[kv_namespaces]]\nbinding = \"my-kv\"\nid = \"abc123\"\n"; + assert!(extract_namespace_id(stdout).is_none()); + } + + fn write_wrangler(dir: &Path, contents: &str) -> PathBuf { + let path = dir.join("wrangler.toml"); + fs::write(&path, contents).expect("write wrangler.toml"); + path + } + + // ---------- is_real_namespace_id ---------- + + #[test] + fn is_real_namespace_id_accepts_32_char_lowercase_hex_with_sufficient_diversity() { + // 16-distinct-char fixture: maximum diversity. + assert!(is_real_namespace_id("00112233445566778899aabbccddeeff")); + // Realistic randomish fixture: 14 distinct chars. + assert!(is_real_namespace_id("4a8f3c2b9e1d5670adef2839c4b6e1f0")); + } + + #[test] + fn is_real_namespace_id_rejects_placeholder_or_short_id() { + assert!(!is_real_namespace_id("local-dev-placeholder")); + assert!(!is_real_namespace_id("abc123")); + assert!(!is_real_namespace_id("")); + } + + #[test] + fn is_real_namespace_id_rejects_uppercase_or_non_hex() { + // Uppercase rejected: Cloudflare's API returns lowercase. + assert!(!is_real_namespace_id("00112233445566778899AABBCCDDEEFF")); + // Non-hex digits rejected. + assert!(!is_real_namespace_id("z0112233445566778899aabbccddeeff")); + } + + #[test] + fn is_real_namespace_id_rejects_hex_shape_sentinels() { + // 32-char lowercase hex but obvious hand-typed placeholder: + // distinct-hex-digit count is below the diversity floor. + // Real Cloudflare ids have effectively uniform random hex, + // so collisions with this guard are astronomical. + assert!( + !is_real_namespace_id("00000000000000000000000000000000"), + "all-zeros rejected" + ); + assert!( + !is_real_namespace_id("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), + "all-a rejected" + ); + assert!( + !is_real_namespace_id("deadbeefdeadbeefdeadbeefdeadbeef"), + "deadbeef rejected (only 5 distinct chars: d,e,a,b,f)" + ); + // Boundary: a real-looking id with the diversity floor or + // more must still pass. + assert!( + is_real_namespace_id("00112233445566778899aabbccddeeff"), + "16-distinct-char fixture must still pass" + ); + // Exactly 6 distinct chars (a,b,c,d,e,f): on the boundary, + // must pass. + assert!( + is_real_namespace_id("aabbccddeeffaabbccddeeffaabbccdd"), + "6-distinct-char fixture (boundary) passes" + ); + } + + // ---------- read_namespace_id ---------- + + #[test] + fn read_namespace_id_errors_when_kv_namespaces_is_non_array_value() { + // `kv_namespaces = "oops"` is a malformed manifest. Silently + // returning None there bubbles up as "did you run provision?" + // -- a misleading error. The right surface is "manifest + // doesn't match the expected shape". + let dir = tempdir().expect("tempdir"); + let path = write_wrangler(dir.path(), "name = \"demo\"\nkv_namespaces = \"oops\"\n"); + let err = read_namespace_id(&path, TEST_CONFIG_ID) + .expect_err("non-array kv_namespaces must error"); + assert!( + err.contains("array-of-tables") || err.contains("inline array"), + "error names the expected shapes: {err}" + ); + assert!( + err.contains("string"), + "error names the offending kind: {err}" + ); + } + + // ---------- extract_namespace_id (pinning behaviour) ---------- + + #[test] + fn extract_namespace_id_returns_first_real_match_inside_kv_namespaces_anchor() { + // Pin: top-down scan, first qualifying line inside the + // `[[kv_namespaces]]` anchor wins. Real wrangler output has + // exactly one. A hypothetical future format with multiple + // qualifying lines would surface the earliest, but only + // values that look like real Cloudflare ids count. + let stdout = "[[kv_namespaces]]\n\ + id = \"00112233445566778899aabbccddeeff\"\n\ + id = \"ffeeddccbbaa99887766554433221100\"\n"; + assert_eq!( + extract_namespace_id(stdout).as_deref(), + Some("00112233445566778899aabbccddeeff") + ); + } + + // ---------- upsert_kv_namespace ---------- + + #[test] + fn upsert_kv_namespace_replaces_placeholder_id_for_existing_binding() { + let dir = tempdir().expect("tempdir"); + let path = write_wrangler( + dir.path(), + "[[kv_namespaces]]\nbinding = \"sessions\"\nid = \"local-dev-placeholder\"\n", + ); + upsert_kv_namespace(&path, TEST_KV_ID, "00112233445566778899aabbccddeeff").expect("upsert"); + let after = fs::read_to_string(&path).expect("read"); + assert!( + after.contains("id = \"00112233445566778899aabbccddeeff\""), + "placeholder replaced: {after}" + ); + assert!( + !after.contains("local-dev-placeholder"), + "placeholder removed: {after}" + ); + assert_eq!( + after.matches("binding = \"sessions\"").count(), + 1, + "no duplicate binding: {after}" + ); + } + + #[test] + fn upsert_kv_namespace_appends_when_binding_absent() { + let dir = tempdir().expect("tempdir"); + let path = write_wrangler(dir.path(), "name = \"demo\"\n"); + upsert_kv_namespace(&path, TEST_KV_ID, "00112233445566778899aabbccddeeff").expect("upsert"); + let after = fs::read_to_string(&path).expect("read"); + assert!( + after.contains("binding = \"sessions\"") + && after.contains("id = \"00112233445566778899aabbccddeeff\""), + "appended new entry: {after}" + ); + assert!( + after.contains("name = \"demo\""), + "preserved original keys: {after}" + ); + } + + #[test] + fn upsert_kv_namespace_appends_next_to_existing_entries() { + let dir = tempdir().expect("tempdir"); + let path = write_wrangler( + dir.path(), + "[[kv_namespaces]]\nbinding = \"cache\"\nid = \"old\"\n", + ); + upsert_kv_namespace(&path, TEST_KV_ID, "00112233445566778899aabbccddeeff").expect("upsert"); + let after = fs::read_to_string(&path).expect("read"); + assert!( + after.contains("binding = \"cache\"") && after.contains("id = \"old\""), + "existing entry kept: {after}" + ); + assert!( + after.contains("binding = \"sessions\""), + "new entry added: {after}" + ); + assert_eq!( + after.matches("[[kv_namespaces]]").count(), + 2, + "two entries: {after}" + ); + } + + #[test] + fn upsert_kv_namespace_preserves_top_comments() { + let dir = tempdir().expect("tempdir"); + let path = write_wrangler( + dir.path(), + "# managed by hand -- please keep this line\nname = \"my-worker\"\n", + ); + upsert_kv_namespace(&path, TEST_KV_ID, "00112233445566778899aabbccddeeff").expect("upsert"); + let after = fs::read_to_string(&path).expect("read"); + assert!( + after.contains("# managed by hand"), + "preserved comment: {after}" + ); + } + + #[test] + fn upsert_kv_namespace_preserves_sibling_fields_on_existing_entry() { + // toml_edit replaces only the `id` Item when we update it; + // sibling fields on the same `[[kv_namespaces]]` table + // (e.g. `preview_id`, custom annotations the user added) + // must survive the rewrite. Pinning this so a future + // toml_edit upgrade or a refactor can't silently drop + // operator data. + let dir = tempdir().expect("tempdir"); + let path = write_wrangler( + dir.path(), + "[[kv_namespaces]]\nbinding = \"sessions\"\nid = \"local-dev-placeholder\"\npreview_id = \"local-preview\"\ndescription = \"hand-added by ops\"\n", + ); + upsert_kv_namespace(&path, TEST_KV_ID, "00112233445566778899aabbccddeeff").expect("upsert"); + let after = fs::read_to_string(&path).expect("read"); + assert!( + after.contains("id = \"00112233445566778899aabbccddeeff\""), + "id rewritten: {after}" + ); + assert!( + after.contains("preview_id = \"local-preview\""), + "preserved preview_id: {after}" + ); + assert!( + after.contains("description = \"hand-added by ops\""), + "preserved description: {after}" + ); + } + + #[test] + fn upsert_kv_namespace_creates_file_when_wrangler_toml_missing() { + // Orphan-namespace hazard: if `wrangler kv namespace create` + // succeeds but wrangler.toml is missing at writeback time, + // erroring here would leave the remote namespace orphaned + // with no local reference. Symmetric with read_namespace_id's + // NotFound -> Ok(None) behaviour: upsert treats NotFound as + // "start with empty document" and writes the entry. + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("missing.toml"); + assert!(!path.exists(), "precondition: file must not exist"); + upsert_kv_namespace(&path, TEST_KV_ID, "00112233445566778899aabbccddeeff") + .expect("missing file is permissive"); + let after = fs::read_to_string(&path).expect("file now exists"); + assert!( + after.contains("binding = \"sessions\""), + "created file with new entry: {after}" + ); + assert!( + after.contains("id = \"00112233445566778899aabbccddeeff\""), + "id written: {after}" + ); + } + + // ---------- writeback shape pre-check ---------- + + #[test] + fn check_kv_namespaces_writeback_shape_ok_when_file_missing() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("missing.toml"); + check_kv_namespaces_writeback_shape(&path) + .expect("missing file is permissive (upsert creates it)"); + } + + #[test] + fn check_kv_namespaces_writeback_shape_ok_when_kv_namespaces_absent() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("wrangler.toml"); + fs::write(&path, "name = \"demo\"\n").expect("write wrangler.toml"); + check_kv_namespaces_writeback_shape(&path).expect("no kv_namespaces => OK"); + } + + #[test] + fn check_kv_namespaces_writeback_shape_ok_when_array_of_tables() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("wrangler.toml"); + fs::write( + &path, + "name = \"demo\"\n[[kv_namespaces]]\nbinding = \"sessions\"\nid = \"local-dev-placeholder\"\n", + ) + .expect("write wrangler.toml"); + check_kv_namespaces_writeback_shape(&path) + .expect("[[kv_namespaces]] is the writeback-supported shape"); + } + + #[test] + fn check_kv_namespaces_writeback_shape_rejects_inline_array_with_actionable_message() { + // Regression for the orphan-namespace hazard: pre-fix, a + // `kv_namespaces = [{ binding = "sessions" }]` manifest (no + // id present) made `read_namespace_id` return None ("not yet + // provisioned") so provision shelled `wrangler kv namespace + // create` successfully, then `upsert_kv_namespace`'s + // `as_array_of_tables_mut()` returned None and the upsert + // errored — leaving the freshly-created namespace orphaned + // on Cloudflare. The pre-flight rejects the inline-array + // shape BEFORE any account-side call. + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("wrangler.toml"); + fs::write( + &path, + "name = \"demo\"\nkv_namespaces = [{ binding = \"sessions\" }]\n", + ) + .expect("write wrangler.toml"); + let err = check_kv_namespaces_writeback_shape(&path) + .expect_err("inline-array form must be rejected before provision shells out"); + assert!( + err.contains("inline array") + && err.contains("[[kv_namespaces]]") + && err.contains("orphaned"), + "error must name the inline-array form, the supported [[kv_namespaces]] form, AND the orphan hazard so the operator knows what's at stake: {err}" + ); + } + + // ---------- provision (dry-run + error path) ---------- + + #[test] + fn provision_dry_run_does_not_invoke_wrangler() { + let dir = tempdir().expect("tempdir"); + write_wrangler(dir.path(), "name = \"demo\"\n"); + let kv_ids: Vec = + ResolvedStoreId::from_logicals(&[TEST_KV_ID, TEST_KV_ID_ALT]); + let config_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_CONFIG_ID]); + let secret_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_SECRET_ID]); + let stores = ProvisionStores { + config: &config_ids, + kv: &kv_ids, + secrets: &secret_ids, + }; + let out = CloudflareCliAdapter + .provision(dir.path(), Some("wrangler.toml"), None, &stores, true) + .expect("dry-run succeeds"); + // 2 KV + 1 config + 1 secret = 4 status lines. + assert_eq!(out.len(), 4); + assert!(out[0].contains("would run `wrangler kv namespace create sessions`")); + assert!(out[1].contains("would run `wrangler kv namespace create cache`")); + assert!(out[2].contains("would run `wrangler kv namespace create app_config`")); + assert!(out[3].contains("runtime-managed via `wrangler secret put`")); + // Manifest untouched. + let after = fs::read_to_string(dir.path().join("wrangler.toml")).expect("read"); + assert_eq!(after, "name = \"demo\"\n", "dry-run mutated wrangler.toml"); + } + + #[test] + fn provision_dry_run_writes_resolved_platform_name_into_binding() { + // Regression: provision used to receive only logical ids + // and write them verbatim into wrangler.toml. With the + // platform-name flow, an operator who sets + // `EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME=prod_config` + // sees `prod_config` land as the binding name (matching what + // the runtime resolves via `env.kv(...)`), with the logical + // id still mentioned for human-facing wording. + let dir = tempdir().expect("tempdir"); + write_wrangler(dir.path(), "name = \"demo\"\n"); + let config_ids = vec![ResolvedStoreId::new(TEST_CONFIG_ID, "prod_config")]; + let stores = ProvisionStores { + config: &config_ids, + kv: &[], + secrets: &[], + }; + let out = CloudflareCliAdapter + .provision(dir.path(), Some("wrangler.toml"), None, &stores, true) + .expect("dry-run succeeds"); + assert_eq!(out.len(), 1); + assert!( + out[0].contains("wrangler kv namespace create prod_config"), + "dry-run uses platform name in the `wrangler` invocation: {out:?}" + ); + assert!( + out[0].contains("binding = \"prod_config\""), + "dry-run writes platform name as the binding: {out:?}" + ); + assert!( + out[0].contains("logical id `app_config`"), + "logical id is preserved for operator wording: {out:?}" + ); + } + + #[test] + fn provision_errors_when_adapter_manifest_path_missing() { + let dir = tempdir().expect("tempdir"); + let kv_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_KV_ID]); + let stores = ProvisionStores { + config: &[], + kv: &kv_ids, + secrets: &[], + }; + let err = CloudflareCliAdapter + .provision(dir.path(), None, None, &stores, true) + .expect_err("missing adapter manifest path must error"); + assert!( + err.contains("wrangler.toml"), + "error names what's missing: {err}" + ); + } + + #[test] + fn provision_dry_run_skips_bindings_already_provisioned_with_real_id() { + let dir = tempdir().expect("tempdir"); + // 32-char lowercase hex id == real Cloudflare namespace id. + let path = write_wrangler( + dir.path(), + "name = \"demo\"\n[[kv_namespaces]]\nbinding = \"sessions\"\nid = \"00112233445566778899aabbccddeeff\"\n", + ); + let kv_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_KV_ID]); + let stores = ProvisionStores { + config: &[], + kv: &kv_ids, + secrets: &[], + }; + let out = CloudflareCliAdapter + .provision(dir.path(), Some("wrangler.toml"), None, &stores, true) + .expect("dry-run succeeds"); + assert_eq!(out.len(), 1); + assert!( + out[0].contains("already provisioned") + && out[0].contains("00112233445566778899aabbccddeeff"), + "skip line names the existing id: {out:?}" + ); + let after = fs::read_to_string(&path).expect("read"); + assert!( + after.contains("00112233445566778899aabbccddeeff"), + "did not touch existing id: {after}" + ); + } + + #[test] + fn provision_dry_run_treats_placeholder_id_as_unprovisioned() { + // A scaffolded wrangler.toml ships with placeholder ids the + // user is expected to overwrite by running provision. + // Dry-run should report the would-be create call, NOT the + // already-provisioned skip. + let dir = tempdir().expect("tempdir"); + write_wrangler( + dir.path(), + "name = \"demo\"\n[[kv_namespaces]]\nbinding = \"sessions\"\nid = \"local-dev-placeholder\"\n", + ); + let kv_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_KV_ID]); + let stores = ProvisionStores { + config: &[], + kv: &kv_ids, + secrets: &[], + }; + let out = CloudflareCliAdapter + .provision(dir.path(), Some("wrangler.toml"), None, &stores, true) + .expect("dry-run succeeds"); + assert_eq!(out.len(), 1); + assert!( + out[0].contains("would run `wrangler kv namespace create sessions`"), + "placeholder id is treated as unprovisioned: {out:?}" + ); + } + + #[test] + fn provision_with_no_declared_stores_says_so() { + let dir = tempdir().expect("tempdir"); + write_wrangler(dir.path(), "name = \"demo\"\n"); + let stores = ProvisionStores { + config: &[], + kv: &[], + secrets: &[], + }; + let out = CloudflareCliAdapter + .provision(dir.path(), Some("wrangler.toml"), None, &stores, false) + .expect("no-store provision is fine"); + assert_eq!(out, vec!["cloudflare has no declared stores to provision"]); + } + + // ---------- find_namespace_id ---------- + + #[test] + fn find_namespace_id_reads_array_of_tables() { + let dir = tempdir().expect("tempdir"); + let path = write_wrangler( + dir.path(), + "name = \"demo\"\n[[kv_namespaces]]\nbinding = \"app_config\"\nid = \"00112233445566778899aabbccddeeff\"\n", + ); + let id = find_namespace_id(&path, TEST_CONFIG_ID).expect("found"); + assert_eq!(id, "00112233445566778899aabbccddeeff"); + } + + #[test] + fn find_namespace_id_reads_inline_array() { + let dir = tempdir().expect("tempdir"); + let path = write_wrangler( + dir.path(), + "name = \"demo\"\nkv_namespaces = [{ binding = \"app_config\", id = \"ffeeddccbbaa99887766554433221100\" }]\n", + ); + let id = find_namespace_id(&path, TEST_CONFIG_ID).expect("found"); + assert_eq!(id, "ffeeddccbbaa99887766554433221100"); + } + + #[test] + fn find_namespace_id_errors_with_provision_hint_when_binding_absent() { + let dir = tempdir().expect("tempdir"); + let path = write_wrangler( + dir.path(), + "name = \"demo\"\n[[kv_namespaces]]\nbinding = \"other\"\nid = \"00112233445566778899aabbccddeeff\"\n", + ); + let err = find_namespace_id(&path, TEST_CONFIG_ID).expect_err("missing must error"); + assert!( + err.contains(TEST_CONFIG_ID) && err.contains("provision"), + "error names the binding and points at provision: {err}" + ); + } + + #[test] + fn find_namespace_id_rejects_placeholder_id_with_provision_hint() { + // A binding with `id = "local-dev-placeholder"` (or any + // other non-32-char-hex value) is treated the same as + // a missing binding: the operator needs to run provision + // before the id is usable for `wrangler kv bulk put`. + // Without this guard, push would shell out with the + // placeholder as `--namespace-id=...` and fail at wrangler + // with a less actionable error. + let dir = tempdir().expect("tempdir"); + let path = write_wrangler( + dir.path(), + "name = \"demo\"\n[[kv_namespaces]]\nbinding = \"app_config\"\nid = \"local-dev-placeholder\"\n", + ); + let err = + find_namespace_id(&path, TEST_CONFIG_ID).expect_err("placeholder id must be rejected"); + assert!( + err.contains("local-dev-placeholder") && err.contains("provision"), + "error names the placeholder and points at provision: {err}" + ); + } + + #[test] + fn find_namespace_id_errors_with_provision_hint_when_file_missing() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("does-not-exist.toml"); + let err = + find_namespace_id(&path, TEST_CONFIG_ID).expect_err("missing wrangler.toml must error"); + assert!( + err.contains("provision"), + "error points at provision: {err}" + ); + } + + // ---------- bulk_payload ---------- + + #[test] + fn bulk_payload_emits_wrangler_array_of_key_value_objects() { + let entries = vec![ + ("greeting".to_owned(), "hello".to_owned()), + ("service.timeout_ms".to_owned(), "1500".to_owned()), + ]; + let raw = bulk_payload(&entries).expect("payload"); + let parsed: serde_json::Value = serde_json::from_str(&raw).expect("valid JSON"); + let array = parsed.as_array().expect("array"); + assert_eq!(array.len(), 2); + assert_eq!(array[0]["key"], "greeting"); + assert_eq!(array[0]["value"], "hello"); + assert_eq!(array[1]["key"], "service.timeout_ms"); + assert_eq!(array[1]["value"], "1500"); + } + + #[test] + fn bulk_payload_with_no_entries_is_empty_array() { + let raw = bulk_payload(&[]).expect("empty payload"); + let parsed: serde_json::Value = serde_json::from_str(&raw).expect("valid JSON"); + assert_eq!(parsed, serde_json::json!([])); + } + + // ---------- push_config_entries (dry-run + error paths) ---------- + + #[test] + fn push_dry_run_resolves_namespace_id_and_does_not_invoke_wrangler() { + let dir = tempdir().expect("tempdir"); + let original = + "name = \"demo\"\n[[kv_namespaces]]\nbinding = \"app_config\"\nid = \"00112233445566778899aabbccddeeff\"\n"; + let path = write_wrangler(dir.path(), original); + let entries = vec![ + ("greeting".to_owned(), "hello".to_owned()), + ("feature.new_checkout".to_owned(), "false".to_owned()), + ]; + let out = CloudflareCliAdapter + .push_config_entries( + dir.path(), + Some("wrangler.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &entries, + &AdapterPushContext::new(), + true, + ) + .expect("dry-run succeeds"); + // Header + per-entry preview, matching the fastly dry-run shape. + assert_eq!(out.len(), 1 + entries.len(), "header + per-entry preview"); + assert!( + out[0].contains("would run `wrangler kv bulk put") + && out[0].contains("--namespace-id=00112233445566778899aabbccddeeff"), + "dry-run header names namespace id: {out:?}" + ); + assert!( + out.iter().any(|line| line.contains("`greeting`")), + "dry-run lists `greeting`: {out:?}" + ); + assert!( + out.iter() + .any(|line| line.contains("`feature.new_checkout`")), + "dry-run lists `feature.new_checkout`: {out:?}" + ); + let after = fs::read_to_string(&path).expect("read"); + assert_eq!(after, original, "dry-run must not mutate wrangler.toml"); + } + + #[test] + fn push_dry_run_is_lenient_when_binding_not_yet_provisioned() { + let dir = tempdir().expect("tempdir"); + write_wrangler(dir.path(), "name = \"demo\"\n"); + let entries = vec![("greeting".to_owned(), "hello".to_owned())]; + let out = CloudflareCliAdapter + .push_config_entries( + dir.path(), + Some("wrangler.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &entries, + &AdapterPushContext::new(), + true, + ) + .expect("dry-run is lenient: pre-provision preview is allowed"); + assert!( + out[0].contains("") && out[0].contains("provision"), + "dry-run header explains the namespace is unresolved and points at provision: {out:?}" + ); + assert!( + out.iter().any(|line| line.contains("`greeting`")), + "dry-run still lists the entries it would push: {out:?}" + ); + } + + #[test] + fn push_errors_when_adapter_manifest_path_missing() { + let dir = tempdir().expect("tempdir"); + let entries = vec![("k".to_owned(), "v".to_owned())]; + let err = CloudflareCliAdapter + .push_config_entries( + dir.path(), + None, + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &entries, + &AdapterPushContext::new(), + true, + ) + .expect_err("missing adapter manifest path must error"); + assert!( + err.contains("wrangler.toml") && err.contains("config push"), + "error explains the missing manifest pointer: {err}" + ); + } + + #[test] + fn push_real_run_errors_with_provision_hint_when_binding_absent() { + // dry-run is now lenient (see + // `push_dry_run_is_lenient_when_binding_not_yet_provisioned`), + // but a real run still must err so we don't silently push + // to a non-existent namespace. + let dir = tempdir().expect("tempdir"); + write_wrangler(dir.path(), "name = \"demo\"\n"); + let entries = vec![("greeting".to_owned(), "hello".to_owned())]; + let err = CloudflareCliAdapter + .push_config_entries( + dir.path(), + Some("wrangler.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &entries, + &AdapterPushContext::new(), + false, + ) + .expect_err("missing binding must error on real run"); + assert!( + err.contains("provision") && err.contains(TEST_CONFIG_ID), + "error points at provision: {err}" + ); + } + + #[test] + fn push_with_no_entries_reports_no_op_after_resolving_namespace() { + let dir = tempdir().expect("tempdir"); + write_wrangler( + dir.path(), + "name = \"demo\"\n[[kv_namespaces]]\nbinding = \"app_config\"\nid = \"00112233445566778899aabbccddeeff\"\n", + ); + let out = CloudflareCliAdapter + .push_config_entries( + dir.path(), + Some("wrangler.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &[], + &AdapterPushContext::new(), + false, + ) + .expect("zero-entry push is fine"); + assert_eq!(out.len(), 1); + assert!( + out[0].contains("no config entries") + && out[0].contains("00112233445566778899aabbccddeeff"), + "status line names empty + namespace id: {out:?}" + ); + } +} diff --git a/crates/edgezero-adapter-cloudflare/src/config_store.rs b/crates/edgezero-adapter-cloudflare/src/config_store.rs index 50cdac0a..d2fea09c 100644 --- a/crates/edgezero-adapter-cloudflare/src/config_store.rs +++ b/crates/edgezero-adapter-cloudflare/src/config_store.rs @@ -1,195 +1,102 @@ -//! Cloudflare Workers adapter config store: reads a single JSON env var. +//! Cloudflare Workers adapter config store: reads from a KV namespace. //! -//! Config is stored as one Cloudflare string binding (set in `wrangler.toml [vars]`) -//! whose value is a JSON object, e.g.: +//! Each declared config id maps to its own Cloudflare KV namespace binding, +//! resolved at request time from `EDGEZERO__STORES__CONFIG____NAME`. +//! Reads are async (`worker::kv::KvStore::get(key).text().await`). //! //! ```toml -//! [vars] -//! app_config = '{"greeting":"hello","feature.new_checkout":"false"}' +//! # wrangler.toml +//! [[kv_namespaces]] +//! binding = "app_config" +//! id = "abc123…" //! ``` //! -//! This allows arbitrary string keys (including dots) on a platform whose binding -//! names are restricted to JavaScript identifier syntax. - -use std::collections::{HashMap, VecDeque}; -use std::sync::{Arc, Mutex, OnceLock, PoisonError}; +//! This replaces the pre-rewrite `[vars]`-backed JSON-string config store. +//! `[vars]` bindings are restricted to JavaScript identifier syntax, so +//! arbitrary dotted keys had to be JSON-packed inside one variable. The KV +//! backing has no such restriction. +use async_trait::async_trait; use edgezero_core::config_store::{ConfigStore, ConfigStoreError}; +#[cfg(test)] +use std::collections::HashMap; +#[cfg(not(any(all(feature = "cloudflare", target_arch = "wasm32"), test)))] +use std::convert::Infallible; +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +use worker::kv::KvStore as WorkerKvStore; +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] use worker::Env; -/// Maximum number of distinct binding names to remember in the parse cache. +/// Config store backed by a Cloudflare KV namespace. /// -/// A single Worker typically uses one or two config bindings; 64 is a generous -/// ceiling that bounds isolate memory without any practical limit for real apps. -/// When the cache is full, the oldest entry is evicted (LRU-style) to make room. -const CONFIG_CACHE_LIMIT: usize = 64; - -type ConfigMap = HashMap; - -#[derive(Clone)] -enum CacheEntry { - Missing, - Present(Arc), -} - -#[derive(Default)] -struct ConfigCache { - entries: HashMap, - order: VecDeque, -} - -/// Config store backed by a single Cloudflare JSON string binding. -/// -/// At construction time the binding value is parsed into a `HashMap`. -/// Reads are then O(1) map lookups with no further JS interop. +/// The namespace binding is opened at construction; individual reads are +/// async KV lookups against that namespace. pub struct CloudflareConfigStore { - data: Arc, + inner: CloudflareConfigBackend, } -impl ConfigCache { - fn get(&self, key: &str) -> Option { - self.entries.get(key).cloned() - } - - fn get_or_insert( - &mut self, - key: &str, - entry: CacheEntry, - limit: usize, - ) -> Option> { - if let Some(existing) = self.entries.get(key) { - return entry_to_value(existing); - } - - if limit > 0 && self.order.len() >= limit { - if let Some(oldest) = self.order.pop_front() { - self.entries.remove(&oldest); - } - } - - let owned_key = key.to_owned(); - self.order.push_back(owned_key.clone()); - let resolved = entry_to_value(&entry); - self.entries.insert(owned_key, entry); - resolved - } +enum CloudflareConfigBackend { + #[cfg(test)] + InMemory(HashMap), + #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] + Kv(WorkerKvStore), + /// Never constructed; keeps the enum inhabited off production/test cfgs. + #[cfg(not(any(all(feature = "cloudflare", target_arch = "wasm32"), test)))] + _Uninhabited(Infallible), } impl CloudflareConfigStore { - fn empty() -> Self { - Self { - data: Arc::new(HashMap::new()), - } - } - #[cfg(test)] fn from_entries(entries: impl IntoIterator) -> Self { Self { - data: Arc::new(entries.into_iter().collect()), + inner: CloudflareConfigBackend::InMemory(entries.into_iter().collect()), } } - /// Build a store by reading and parsing the JSON binding named `binding_name`. - /// - /// Returns an empty store (every key returns `None`) if the binding is absent or - /// its value is not valid JSON. Missing or invalid bindings are logged at `warn` - /// level (once per binding name per isolate lifetime) via the same path as - /// [`Self::try_new`], so misconfigured binding names will surface in logs. - /// Use [`Self::try_new`] when you need to distinguish a missing/invalid binding - /// from a valid but empty config at the call site. - #[inline] - pub fn new_or_empty(env: &Env, binding_name: &str) -> Self { - Self::try_new(env, binding_name).unwrap_or_else(Self::empty) - } - - /// Build a store only when the configured Cloudflare binding exists and parses successfully. + /// Open the KV namespace bound as `binding_name`. /// - /// Missing bindings or invalid JSON are treated as configuration problems, logged at warn - /// level (once per binding name per isolate lifetime), and return `None` so the adapter - /// can skip injecting the handle. + /// # Errors + /// Returns [`ConfigStoreError::Unavailable`] when the binding is missing + /// or cannot be opened. + #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] #[inline] - #[must_use] - pub fn try_new(env: &Env, binding_name: &str) -> Option { - Some(Self { - data: lookup_cached(env, binding_name)?, + pub fn from_env(env: &Env, binding_name: &str) -> Result { + let store = env.kv(binding_name).map_err(|err| { + ConfigStoreError::unavailable(format!( + "failed to open config KV binding '{binding_name}': {err}" + )) + })?; + Ok(Self { + inner: CloudflareConfigBackend::Kv(store), }) } } +#[async_trait(?Send)] impl ConfigStore for CloudflareConfigStore { #[inline] - fn get(&self, key: &str) -> Result, ConfigStoreError> { - Ok(self.data.get(key).cloned()) - } -} - -fn config_cache() -> &'static Mutex { - static CACHE: OnceLock> = OnceLock::new(); - CACHE.get_or_init(|| Mutex::new(ConfigCache::default())) -} - -fn entry_to_value(entry: &CacheEntry) -> Option> { - match entry { - CacheEntry::Missing => None, - CacheEntry::Present(arc) => Some(Arc::clone(arc)), - } -} - -/// Parse-and-cache the config map for `binding_name`. -/// -/// Keyed only by name: Cloudflare env vars are immutable within an isolate -/// lifetime, so the parsed result for a given binding name never changes. -/// Warnings are suppressed for recently seen binding names via a bounded cache. -/// -/// # WASM safety -/// `std::sync::Mutex` compiles for `wasm32-unknown-unknown` and is safe here because -/// WASM is single-threaded — the lock can never be contested and poisoning cannot -/// occur via a concurrent thread panic. -fn lookup_cached(env: &Env, binding_name: &str) -> Option> { - // Fast path: already cached. - if let Some(entry) = config_cache() - .lock() - .unwrap_or_else(PoisonError::into_inner) - .get(binding_name) - { - return entry_to_value(&entry); - } - - // Cache miss: resolve from the JS env (synchronous interop, safe outside the lock). - let resolved = match env.var(binding_name).ok().map(|value| value.to_string()) { - None => { - log::warn!( - "configured config store binding '{binding_name}' is missing from the Worker environment; skipping config-store injection" - ); - CacheEntry::Missing - } - Some(raw) => match serde_json::from_str::(&raw) { - Ok(data) => CacheEntry::Present(Arc::new(data)), - Err(err) => { - log::warn!( - "configured config store binding '{binding_name}' contains invalid JSON: {err}; skipping config-store injection" - ); - CacheEntry::Missing + async fn get(&self, key: &str) -> Result, ConfigStoreError> { + match &self.inner { + #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] + CloudflareConfigBackend::Kv(store) => store.get(key).text().await.map_err(|err| { + ConfigStoreError::internal(anyhow::anyhow!("kv config get failed: {err}")) + }), + #[cfg(test)] + CloudflareConfigBackend::InMemory(data) => Ok(data.get(key).cloned()), + #[cfg(not(any(all(feature = "cloudflare", target_arch = "wasm32"), test)))] + CloudflareConfigBackend::_Uninhabited(never) => { + let _: &str = key; + match *never {} } - }, - }; - - // Cache the resolved value — including Missing for absent/invalid bindings. - // This is safe because Cloudflare string bindings are immutable within an - // isolate lifetime: the parsed result for a given binding name never changes, - // so caching a failed parse prevents redundant warnings on every request. - config_cache() - .lock() - .unwrap_or_else(PoisonError::into_inner) - .get_or_insert(binding_name, resolved, CONFIG_CACHE_LIMIT) + } + } } #[cfg(test)] mod tests { use super::*; - use wasm_bindgen_test::wasm_bindgen_test; - edgezero_core::config_store_contract_tests!(cloudflare_config_store_contract, #[wasm_bindgen_test], { + edgezero_core::config_store_contract_tests!(cloudflare_config_store_contract, { CloudflareConfigStore::from_entries([ ("contract.key.a".to_owned(), "value_a".to_owned()), ("contract.key.b".to_owned(), "value_b".to_owned()), diff --git a/crates/edgezero-adapter-cloudflare/src/lib.rs b/crates/edgezero-adapter-cloudflare/src/lib.rs index a63889c0..8288cbeb 100644 --- a/crates/edgezero-adapter-cloudflare/src/lib.rs +++ b/crates/edgezero-adapter-cloudflare/src/lib.rs @@ -3,7 +3,9 @@ #[cfg(feature = "cli")] pub mod cli; -#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +// `config_store` compiles on host for its `InMemory` test backend; the +// production `Kv` backend is feature-gated internally. +#[cfg(any(test, all(feature = "cloudflare", target_arch = "wasm32")))] pub mod config_store; #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] pub mod context; @@ -19,42 +21,12 @@ pub mod response; pub mod secret_store; #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] -use core::future::Future; -#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] -use core::pin::Pin; -#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] -use edgezero_core::app::{App, Hooks, CLOUDFLARE_ADAPTER}; +use edgezero_core::app::{Hooks, StoresMetadata}; #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] -use edgezero_core::manifest::ManifestLoader; +use edgezero_core::env_config::EnvConfig; #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] use worker::{Context, Env, Error as WorkerError, Request, Response}; -#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] -pub trait AppExt { - #[deprecated( - note = "AppExt::dispatch() is the low-level manual path and does not inject config-store metadata; prefer run_app(), dispatch_with_config(), or dispatch_with_config_handle()" - )] - fn dispatch<'app>( - &'app self, - req: Request, - env: Env, - ctx: Context, - ) -> Pin> + 'app>>; -} - -#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] -impl AppExt for App { - #[inline] - fn dispatch<'app>( - &'app self, - req: Request, - env: Env, - ctx: Context, - ) -> Pin> + 'app>> { - Box::pin(request::dispatch_raw(self, req, env, ctx)) - } -} - /// # Errors /// Never; this is currently a no-op on Cloudflare Workers (Workers manages /// its own logging). The signature still returns [`log::SetLoggerError`] so @@ -74,20 +46,50 @@ pub fn init_logger() -> Result<(), log::SetLoggerError> { Ok(()) } +/// Build an [`EnvConfig`] from a Cloudflare `Env`. Workers have no +/// `std::env`, and the `Env` binding object cannot be enumerated, so the exact +/// `EDGEZERO__STORES______NAME` keys are derived from the baked +/// store metadata and queried individually, alongside the fixed +/// `EDGEZERO__ADAPTER__*` / `EDGEZERO__LOGGING__*` keys. +#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] +fn env_config_from_worker(env: &Env, stores: StoresMetadata) -> EnvConfig { + let mut keys: Vec = vec![ + "EDGEZERO__ADAPTER__HOST".to_owned(), + "EDGEZERO__ADAPTER__PORT".to_owned(), + "EDGEZERO__LOGGING__LEVEL".to_owned(), + ]; + for (kind, store_meta) in [ + ("CONFIG", stores.config), + ("KV", stores.kv), + ("SECRETS", stores.secrets), + ] { + if let Some(meta) = store_meta { + for id in meta.ids { + keys.push(format!( + "EDGEZERO__STORES__{kind}__{}__NAME", + id.to_ascii_uppercase() + )); + } + } + } + let vars = keys + .into_iter() + .filter_map(|key| env.var(&key).ok().map(|value| (key, value.to_string()))); + EnvConfig::from_vars(vars) +} + /// Entry point for a Cloudflare Workers application. /// -/// **Breaking change (pre-1.0):** `manifest_src` is now a required parameter. -/// Callers previously using `run_app_with_manifest` can rename to `run_app` — -/// the signatures are identical. +/// Portable store config is baked into `A` by the `app!` macro; adapter-specific +/// values (platform store names) are read at runtime from `EDGEZERO__*` +/// variables on the worker `Env`. No `edgezero.toml` is required. /// /// # Errors -/// Returns [`worker::Error`] if the manifest cannot be parsed, the -/// inner dispatch fails, or any required store binding cannot be -/// resolved. +/// Returns [`worker::Error`] if the inner dispatch fails or any required +/// store binding cannot be opened. #[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] #[inline] pub async fn run_app( - manifest_src: &str, req: Request, env: Env, ctx: Context, @@ -95,54 +97,20 @@ pub async fn run_app( // Best-effort: if a logger is already installed, ignore the error rather // than panicking — every Worker request re-enters this function. drop(init_logger()); - let manifest_loader = ManifestLoader::try_load_from_str(manifest_src) - .map_err(|err| WorkerError::RustError(err.to_string()))?; - let manifest = manifest_loader.manifest(); - let kv_binding = manifest.kv_store_name(CLOUDFLARE_ADAPTER); - let kv_required = manifest.stores.kv.is_some(); - // Two-path resolution: `A::config_store()` is set at compile time by the - // `#[app]` macro and is the common case. The manifest fallback handles - // callers that implement `Hooks` manually without the macro — in that case - // `A::config_store()` returns `None` while `[stores.config]` in - // `edgezero.toml` may still be present. - let config_binding = A::config_store() - .map(|cfg| cfg.name_for_adapter(CLOUDFLARE_ADAPTER)) - .or_else(|| { - manifest - .stores - .config - .as_ref() - .map(|cfg| cfg.config_store_name(CLOUDFLARE_ADAPTER)) - }); - let secrets_required = manifest.secret_store_enabled("cloudflare"); + let stores = A::stores(); + let env_config = env_config_from_worker(&env, stores); let app = A::build_app(); - request::dispatch_with_bindings( + request::dispatch_with_registries( &app, req, env, ctx, - request::RuntimeBindings { - config: config_binding, - kv: kv_binding, - kv_required, - secrets_required, + request::RegistryInputs { + config_meta: stores.config, + kv_meta: stores.kv, + secret_meta: stores.secrets, + env_config: &env_config, }, ) .await } - -/// Deprecated: use [`run_app`] which now takes `manifest_src` directly. -/// -/// # Errors -/// Same conditions as [`run_app`]. -#[cfg(all(feature = "cloudflare", target_arch = "wasm32"))] -#[deprecated(note = "use run_app instead, which now takes manifest_src")] -#[inline] -pub async fn run_app_with_manifest( - manifest_src: &str, - req: Request, - env: Env, - ctx: Context, -) -> Result { - run_app::(manifest_src, req, env, ctx).await -} diff --git a/crates/edgezero-adapter-cloudflare/src/request.rs b/crates/edgezero-adapter-cloudflare/src/request.rs index 7ebe5dee..e0e95629 100644 --- a/crates/edgezero-adapter-cloudflare/src/request.rs +++ b/crates/edgezero-adapter-cloudflare/src/request.rs @@ -1,31 +1,29 @@ -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; use std::fmt::Display; use std::sync::{Arc, Mutex, OnceLock}; -use crate::config_store::CloudflareConfigStore; -use crate::context::CloudflareRequestContext; -use crate::key_value_store::CloudflareKvStore; -use crate::proxy::CloudflareProxyClient; -use crate::response::from_core_response; -use crate::secret_store::CloudflareSecretStore; -use edgezero_core::app::App; +use edgezero_core::app::{App, StoreMetadata}; use edgezero_core::body::Body; use edgezero_core::config_store::ConfigStoreHandle; +use edgezero_core::env_config::EnvConfig; use edgezero_core::error::EdgeError; use edgezero_core::http::{request_builder, Method as CoreMethod, Request, Uri}; use edgezero_core::key_value_store::KvHandle; -use edgezero_core::manifest::DEFAULT_KV_STORE_NAME; use edgezero_core::proxy::ProxyHandle; use edgezero_core::secret_store::SecretHandle; +use edgezero_core::store_registry::{ + BoundSecretStore, ConfigRegistry, KvRegistry, SecretRegistry, StoreRegistry, +}; use worker::{ Context, Env, Error as WorkerError, Method, Request as CfRequest, Response as CfResponse, }; -/// Default Cloudflare Workers KV binding name. -/// -/// If a KV namespace with this binding exists in your `wrangler.toml`, -/// it will be automatically available to handlers via the `Kv` extractor. -pub const DEFAULT_KV_BINDING: &str = DEFAULT_KV_STORE_NAME; +use crate::config_store::CloudflareConfigStore; +use crate::context::CloudflareRequestContext; +use crate::key_value_store::CloudflareKvStore; +use crate::proxy::CloudflareProxyClient; +use crate::response::from_core_response; +use crate::secret_store::CloudflareSecretStore; /// Groups the optional per-request store handles injected at dispatch time. /// @@ -36,26 +34,211 @@ pub const DEFAULT_KV_BINDING: &str = DEFAULT_KV_STORE_NAME; /// ``` #[derive(Default)] pub(crate) struct Stores { - pub config_store: Option, - pub kv: Option, - pub secrets: Option, + config_registry: Option, + config_store: Option, + kv: Option, + kv_registry: Option, + secret_registry: Option, + secrets: Option, } -/// Binding-resolution inputs for [`dispatch_with_bindings`]. Grouped to keep -/// the dispatch signature within clippy's `too_many_arguments` limit. -pub(crate) struct RuntimeBindings<'binding> { - pub config: Option<&'binding str>, - pub kv: &'binding str, - pub kv_required: bool, - pub secrets_required: bool, +/// Cloudflare per-request dispatch service. +/// +/// Builds a Worker invocation with the stores the operator wants +/// injected into request extensions, then dispatches one request +/// against the wrapped `App`. The store wiring is a per-Service +/// decision; on Cloudflare Workers that means per-request (the +/// runtime invokes the entrypoint per HTTP request), but the +/// Service type itself is cheap to build. +/// +/// Replaces the prior `dispatch_with_*` variant fan-out. Each +/// builder method is independent: enable any combination of KV, +/// config, and secret stores by chaining the relevant `with_*` / +/// `require_*` calls. The manifest-driven `run_app` is still the +/// recommended entrypoint for normal flows -- the Service builder +/// is for manual / no-manifest deployments. +/// +/// ```rust,ignore +/// CloudflareService::new(&app) +/// .with_kv("sessions").require_kv() +/// .with_config("app_config") +/// .with_secrets() +/// .dispatch(req, env, ctx).await +/// ``` +pub struct CloudflareService<'app> { + app: &'app App, + config: ConfigSource, + kv: Option, + secrets: SecretSource, } -/// Convert a Cloudflare `CfRequest` into an `EdgeZero` core [`Request`]. +enum ConfigSource { + Binding(String), + Handle(ConfigStoreHandle), + None, +} + +struct KvSource { + binding: String, + required: bool, +} + +enum SecretSource { + Off, + On { required: bool }, +} + +impl<'app> CloudflareService<'app> { + /// Resolve every wired store at request time and dispatch + /// against the wrapped `App`. `env` and `ctx` come from the + /// Worker runtime per request, NOT the Service builder. + /// Consumes the service so a builder can't be reused with stale + /// wiring. + /// + /// # Errors + /// Returns [`worker::Error`] if a required store binding cannot be + /// opened, the core request cannot be built, or the inner router + /// dispatch fails. + #[inline] + pub async fn dispatch( + self, + req: CfRequest, + env: Env, + ctx: Context, + ) -> Result { + let config_store = match self.config { + ConfigSource::Binding(binding) => open_config_or_warn(&env, &binding), + ConfigSource::Handle(handle) => Some(handle), + ConfigSource::None => None, + }; + let kv = match self.kv { + Some(source) => resolve_kv_handle(&env, &source.binding, source.required)?, + None => None, + }; + let secrets = match self.secrets { + SecretSource::Off => None, + // Always Some — post-PR-269 fix; `required=false` + // (set by `.with_secrets()` without + // `.require_secrets()`) no longer suppresses the + // handle. See `resolve_secret_handle`. + SecretSource::On { required } => Some(resolve_secret_handle(&env, required)), + }; + dispatch_with_handles( + self.app, + req, + env, + ctx, + Stores { + config_store, + kv, + secrets, + ..Default::default() + }, + ) + .await + } + + /// Build a new service that dispatches against `app` with NO + /// stores wired. Chain `.with_*` / `.require_*` to add stores. + #[must_use] + #[inline] + pub fn new(app: &'app App) -> Self { + Self { + app, + config: ConfigSource::None, + kv: None, + secrets: SecretSource::Off, + } + } + + /// Promote the previously-wired KV binding to required: an + /// unavailable namespace causes dispatch to return an error. + /// No-op when `with_kv` wasn't called. + #[must_use] + #[inline] + pub fn require_kv(mut self) -> Self { + if let Some(kv) = self.kv.as_mut() { + kv.required = true; + } + self + } + + /// Promote the previously-wired secret store to required. + /// No-op when `with_secrets` wasn't called. + #[must_use] + #[inline] + pub fn require_secrets(mut self) -> Self { + if let SecretSource::On { ref mut required } = self.secrets { + *required = true; + } + self + } + + /// Open the KV namespace bound as `binding` (per `wrangler.toml`) + /// as a Cloudflare config store and inject its handle. If the + /// binding is absent the dispatcher logs once and proceeds + /// without it. + #[must_use] + #[inline] + pub fn with_config>(mut self, binding: S) -> Self { + self.config = ConfigSource::Binding(binding.into()); + self + } + + /// Inject a pre-built `ConfigStoreHandle`. Use this when the + /// caller has already opened (or mocked) the backend. Mutually + /// exclusive with `with_config(binding)` -- the last call wins. + #[must_use] + #[inline] + pub fn with_config_handle(mut self, handle: ConfigStoreHandle) -> Self { + self.config = ConfigSource::Handle(handle); + self + } + + /// Open the KV namespace bound as `binding` and inject its + /// handle. Non-required by default: an absent binding logs + /// once and dispatch continues. Pair with `require_kv()` when + /// the manifest declares `[stores.kv]`. + #[must_use] + #[inline] + pub fn with_kv>(mut self, binding: S) -> Self { + self.kv = Some(KvSource { + binding: binding.into(), + required: false, + }); + self + } + + /// Enable Cloudflare Worker secrets and inject the secret-store + /// handle. Worker secrets have no namespace concept, so no + /// name is needed. Non-required by default; pair with + /// `require_secrets()` when the manifest declares + /// `[stores.secrets]`. Individual missing secrets surface as + /// `SecretError::NotFound` at access time. + #[must_use] + #[inline] + pub fn with_secrets(mut self) -> Self { + self.secrets = SecretSource::On { required: false }; + self + } +} + +/// Groups the multi-id store metadata + env config inputs threaded into +/// the registry-based dispatcher. Carved out so `dispatch_with_registries` +/// stays under the `too_many_arguments` ceiling. +pub(crate) struct RegistryInputs<'env> { + pub config_meta: Option, + pub env_config: &'env EnvConfig, + pub kv_meta: Option, + pub secret_meta: Option, +} + +/// Convert a Cloudflare Worker request into an `EdgeZero` core request. /// /// # Errors -/// Returns [`EdgeError::bad_request`] if the request URL is invalid or the -/// URI cannot be parsed, and [`EdgeError::internal`] if the body cannot be -/// read or the builder rejects the assembled request. +/// Returns [`EdgeError::bad_request`] if the URL or URI cannot be parsed, +/// and [`EdgeError::internal`] if the body cannot be read or the core +/// request cannot be built. #[inline] pub async fn into_core_request( mut req: CfRequest, @@ -90,252 +273,163 @@ pub async fn into_core_request( Ok(request) } -pub(crate) async fn dispatch_raw( - app: &App, - req: CfRequest, - env: Env, - ctx: Context, -) -> Result { - dispatch_with_kv(app, req, env, ctx, DEFAULT_KV_BINDING, false).await -} - -/// Low-level manual dispatch. -/// -/// This path does not resolve or inject config-store metadata from a manifest. -/// Prefer `run_app` or `dispatch_with_config` for normal config-store-aware -/// dispatch. Use `dispatch_with_config_handle` only when you already have a -/// prepared `ConfigStoreHandle`. -/// -/// # Errors -/// Propagates any error from [`dispatch_raw`]: manifest, KV, dispatch, and -/// response-translation failures all surface here. -#[deprecated( - note = "dispatch() is the low-level manual path and does not inject config-store metadata; prefer run_app(), dispatch_with_config(), or dispatch_with_config_handle()" -)] -#[inline] -pub async fn dispatch( +pub(crate) async fn dispatch_with_handles( app: &App, req: CfRequest, env: Env, ctx: Context, + stores: Stores, ) -> Result { - dispatch_raw(app, req, env, ctx).await + let core_request = into_core_request(req, env, ctx) + .await + .map_err(|err| edge_error_to_worker(&err))?; + dispatch_core_request(app, core_request, stores).await } -/// Dispatch a Cloudflare Worker request with a custom KV binding name. -/// -/// `kv_required` should be `true` when `[stores.kv]` is explicitly present -/// in the manifest, causing the request to fail if the binding is unavailable -/// rather than silently degrading. +/// Dispatch with per-id store registries built from baked metadata. /// -/// # Errors -/// Returns [`WorkerError::RustError`] when `kv_required` is `true` and the -/// configured binding cannot be opened, or any error propagated from the -/// inner dispatch and response translation. -#[inline] -pub async fn dispatch_with_kv( +/// Cloudflare capability map: +/// - KV (Multi): each declared id opens its own KV namespace binding via +/// `EDGEZERO__STORES__KV____NAME` (default = id). +/// - Config (Multi): each declared id opens its own KV namespace via +/// `EDGEZERO__STORES__CONFIG____NAME`, read asynchronously. +/// - Secrets (Single): one shared [`CloudflareSecretStore`] is registered +/// under every declared id. +pub(crate) async fn dispatch_with_registries( app: &App, req: CfRequest, env: Env, ctx: Context, - kv_binding: &str, - kv_required: bool, + inputs: RegistryInputs<'_>, ) -> Result { - let kv = resolve_kv_handle(&env, kv_binding, kv_required)?; + let kv_registry = build_kv_registry(&env, inputs.kv_meta, inputs.env_config)?; + let config_registry = build_config_registry(&env, inputs.config_meta, inputs.env_config); + let secret_registry = build_secret_registry(&env, inputs.secret_meta, inputs.env_config); dispatch_with_handles( app, req, env, ctx, Stores { - kv, + config_registry, + kv_registry, + secret_registry, ..Default::default() }, ) .await } -/// Dispatch a request with a prepared config-store handle injected. -/// -/// This is the advanced/manual path. Prefer `dispatch_with_config` when you -/// want the adapter to resolve the configured backend for you. -/// -/// The KV namespace bound to [`DEFAULT_KV_BINDING`] is also resolved and injected -/// (non-required: missing bindings are silently skipped). -/// -/// # Errors -/// Returns any error propagated from the inner dispatch. -#[inline] -pub async fn dispatch_with_config_handle( - app: &App, - req: CfRequest, - env: Env, - ctx: Context, - config_store_handle: ConfigStoreHandle, -) -> Result { - let kv = resolve_kv_handle(&env, DEFAULT_KV_BINDING, false)?; - dispatch_with_handles( - app, - req, - env, - ctx, - Stores { - config_store: Some(config_store_handle), - kv, - ..Default::default() - }, - ) - .await +pub(crate) fn resolve_kv_handle( + env: &Env, + kv_binding: &str, + kv_required: bool, +) -> Result, WorkerError> { + match CloudflareKvStore::from_env(env, kv_binding) { + Ok(store) => Ok(Some(KvHandle::new(Arc::new(store)))), + Err(err) => { + if kv_required { + return Err(WorkerError::RustError(format!( + "KV binding '{kv_binding}' is explicitly configured but could not be opened: {err}" + ))); + } + warn_missing_kv_binding_once(kv_binding, &err); + Ok(None) + } + } } -/// Dispatch a request with a Cloudflare JSON config store injected. +/// Construct the Cloudflare secret-store handle. Called from the +/// `SecretSource::On { .. }` arm of `dispatch`, so it always builds +/// the handle — the `_required` parameter is preserved (and ignored) +/// for symmetry with the kv path's `resolve_kv_handle(_, _, required)` +/// signature, where `required` decides whether a runtime open +/// failure is fatal or silently degrades. `CloudflareSecretStore` is +/// a thin `env`-wrapper whose construction can't fail, so there's +/// nothing for `required` to gate at handle-construction time — +/// `_required` is reserved for whichever future per-secret-lookup +/// availability policy we add. /// -/// Reads `binding_name` from `env` (a `[vars]` string whose value is a JSON object), -/// parses it into a `CloudflareConfigStore`, and injects the handle before dispatch -/// when the binding is present and valid. -/// -/// The KV namespace bound to [`DEFAULT_KV_BINDING`] is also resolved and injected -/// (non-required: missing bindings are silently skipped). -/// -/// # Errors -/// Returns any error propagated from the inner dispatch. -#[inline] -pub async fn dispatch_with_config( - app: &App, - req: CfRequest, - env: Env, - ctx: Context, - binding_name: &str, -) -> Result { - let config_store_handle = CloudflareConfigStore::try_new(&env, binding_name) - .map(|store| ConfigStoreHandle::new(Arc::new(store))); - let kv = resolve_kv_handle(&env, DEFAULT_KV_BINDING, false)?; - dispatch_with_handles( - app, - req, - env, - ctx, - Stores { - config_store: config_store_handle, - kv, - ..Default::default() - }, - ) - .await -} - -pub(crate) async fn dispatch_with_bindings( - app: &App, - req: CfRequest, - env: Env, - ctx: Context, - bindings: RuntimeBindings<'_>, -) -> Result { - let config_store_handle = bindings.config.and_then(|binding_name| { - CloudflareConfigStore::try_new(&env, binding_name) - .map(|store| ConfigStoreHandle::new(Arc::new(store))) - }); - let kv = resolve_kv_handle(&env, bindings.kv, bindings.kv_required)?; - let secrets = resolve_secret_handle(&env, bindings.secrets_required); - dispatch_with_handles( - app, - req, - env, - ctx, - Stores { - config_store: config_store_handle, - kv, - secrets, - }, - ) - .await +/// Pre-fix, this short-circuited to `None` when `!_required`, which +/// silently swallowed `.with_secrets()` (which sets `required: +/// false`): handlers ran without a `SecretRegistry` even though the +/// builder claimed to inject one. +pub(crate) fn resolve_secret_handle(env: &Env, _required: bool) -> SecretHandle { + let secret_store = CloudflareSecretStore::from_env(env.clone()); + SecretHandle::new(Arc::new(secret_store)) } -/// Dispatch a Cloudflare Worker request with a secret store attached (no KV store). -/// -/// Use this when your application accesses secrets but does not need a KV store. -/// For applications that need both, use [`dispatch_with_kv_and_secrets`] instead. -/// -/// For most applications, prefer [`crate::run_app`] which resolves all stores -/// from the manifest automatically. Use `dispatch_with_secrets` only when you -/// need direct control over the dispatch lifecycle without a manifest. -/// -/// The store is only attached when `secrets_required` is `true`. -/// Individual missing secrets surface as `SecretError::NotFound` at access time. -/// -/// # Errors -/// Returns any error propagated from the inner dispatch. -#[inline] -pub async fn dispatch_with_secrets( - app: &App, - req: CfRequest, - env: Env, - ctx: Context, - secrets_required: bool, -) -> Result { - let secrets = resolve_secret_handle(&env, secrets_required); - dispatch_with_handles( - app, - req, - env, - ctx, - Stores { - secrets, - ..Default::default() - }, - ) - .await +fn build_config_registry( + env: &Env, + config_meta: Option, + env_config: &EnvConfig, +) -> Option { + let meta = config_meta?; + let mut by_id: BTreeMap = BTreeMap::new(); + for id in meta.ids { + let binding = env_config.store_name("config", id); + if let Some(handle) = open_config_or_warn(env, &binding) { + by_id.insert((*id).to_owned(), handle); + } + } + let default_id = meta.default.to_owned(); + if !by_id.contains_key(&default_id) { + log::warn!( + "config registry default id `{default_id}` could not be opened; dropping the config registry" + ); + } + StoreRegistry::from_parts(by_id, default_id) } -/// Dispatch a Cloudflare Worker request with both KV and secret stores attached. -/// -/// Note: Cloudflare secrets have no namespace concept, so no secret binding name is needed. -/// -/// For most applications, prefer [`crate::run_app`] which resolves all stores -/// from the manifest automatically. Use `dispatch_with_kv_and_secrets` only -/// when you need direct control over the dispatch lifecycle without a manifest. -/// -/// # Errors -/// Returns [`WorkerError::RustError`] when `kv_required` is `true` and the -/// configured binding cannot be opened, or any error propagated from the -/// inner dispatch. -#[inline] -pub async fn dispatch_with_kv_and_secrets( - app: &App, - req: CfRequest, - env: Env, - ctx: Context, - kv_binding: &str, - kv_required: bool, - secrets_required: bool, -) -> Result { - let kv = resolve_kv_handle(&env, kv_binding, kv_required)?; - let secrets = resolve_secret_handle(&env, secrets_required); - dispatch_with_handles( - app, - req, - env, - ctx, - Stores { - kv, - secrets, - ..Default::default() - }, - ) - .await +fn build_kv_registry( + env: &Env, + kv_meta: Option, + env_config: &EnvConfig, +) -> Result, WorkerError> { + let Some(meta) = kv_meta else { + return Ok(None); + }; + let mut by_id: BTreeMap = BTreeMap::new(); + for id in meta.ids { + let binding = env_config.store_name("kv", id); + // Required per-id: `[stores.kv]` is declared, so failure to open is a + // runtime error rather than a silent skip. + let Some(handle) = resolve_kv_handle(env, &binding, true)? else { + continue; + }; + by_id.insert((*id).to_owned(), handle); + } + let default_id = meta.default.to_owned(); + if !by_id.contains_key(&default_id) { + log::warn!( + "KV registry default id `{default_id}` could not be opened; dropping the KV registry" + ); + } + Ok(StoreRegistry::from_parts(by_id, default_id)) } -pub(crate) async fn dispatch_with_handles( - app: &App, - req: CfRequest, - env: Env, - ctx: Context, - stores: Stores, -) -> Result { - let core_request = into_core_request(req, env, ctx) - .await - .map_err(|err| edge_error_to_worker(&err))?; - dispatch_core_request(app, core_request, stores).await +fn build_secret_registry( + env: &Env, + secret_meta: Option, + env_config: &EnvConfig, +) -> Option { + let meta = secret_meta?; + // Cloudflare is `Single` for secrets — one shared handle binds every id. + // `CloudflareSecretStore::get_bytes` ignores `store_name` (worker + // secrets are a flat namespace), so the per-id bound name is + // observable only via [`BoundSecretStore::store_name`]. + let handle = SecretHandle::new(Arc::new(CloudflareSecretStore::from_env(env.clone()))); + let mut by_id: BTreeMap = BTreeMap::new(); + for id in meta.ids { + let store_name = env_config.store_name("secrets", id); + by_id.insert( + (*id).to_owned(), + BoundSecretStore::new(handle.clone(), store_name), + ); + } + // Cloudflare secret handles are infallible to construct; `from_parts` + // keeps the API symmetric with the KV / config builders. + StoreRegistry::from_parts(by_id, meta.default.to_owned()) } async fn dispatch_core_request( @@ -343,14 +437,19 @@ async fn dispatch_core_request( mut core_request: Request, stores: Stores, ) -> Result { - if let Some(handle) = stores.config_store { - core_request.extensions_mut().insert(handle); + // Hard-cutoff: see fastly's `dispatch_core_request` + // for the rationale. Only registries go into extensions — + // legacy bare handles are synthesised into a one-id registry + // at the dispatch boundary. + let (config_registry, kv_registry, secret_registry) = synthesise_store_registries(stores); + if let Some(registry) = config_registry { + core_request.extensions_mut().insert(registry); } - if let Some(handle) = stores.kv { - core_request.extensions_mut().insert(handle); + if let Some(registry) = kv_registry { + core_request.extensions_mut().insert(registry); } - if let Some(handle) = stores.secrets { - core_request.extensions_mut().insert(handle); + if let Some(registry) = secret_registry { + core_request.extensions_mut().insert(registry); } let svc = app.router().clone(); let response = svc @@ -360,43 +459,91 @@ async fn dispatch_core_request( from_core_response(response).map_err(|err| edge_error_to_worker(&err)) } -pub(crate) fn resolve_kv_handle( - env: &Env, - kv_binding: &str, - kv_required: bool, -) -> Result, WorkerError> { - match CloudflareKvStore::from_env(env, kv_binding) { - Ok(store) => Ok(Some(KvHandle::new(Arc::new(store)))), +fn edge_error_to_worker(err: &EdgeError) -> WorkerError { + WorkerError::RustError(err.to_string()) +} + +fn into_core_method(method: &Method) -> CoreMethod { + let bytes = method.as_ref().as_bytes(); + CoreMethod::from_bytes(bytes).unwrap_or_else(|_| { + log::warn!( + "unknown HTTP method {:?}, defaulting to GET", + method.as_ref() + ); + CoreMethod::GET + }) +} + +fn open_config_or_warn(env: &Env, binding_name: &str) -> Option { + match CloudflareConfigStore::from_env(env, binding_name) { + Ok(store) => Some(ConfigStoreHandle::new(Arc::new(store))), Err(err) => { - if kv_required { - return Err(WorkerError::RustError(format!( - "KV binding '{kv_binding}' is explicitly configured but could not be opened: {err}" - ))); - } - warn_missing_kv_binding_once(kv_binding, &err); - Ok(None) + warn_missing_config_binding_once(binding_name, &err.to_string()); + None } } } -pub(crate) fn resolve_secret_handle(env: &Env, secrets_required: bool) -> Option { - if !secrets_required { - return None; - } - - let secret_store = CloudflareSecretStore::from_env(env.clone()); - Some(SecretHandle::new(Arc::new(secret_store))) +/// Pure synthesis: collapse a `Stores` (which may carry both a +/// wired multi-id registry AND a legacy bare handle) into the +/// three registries that go into request extensions. Precedence +/// is "registry wins": a wired registry is taken verbatim; only +/// in its absence is a bare handle wrapped into a one-id registry +/// keyed under `"default"`. The bare handle is never merged in, +/// never used as a fallback for ids the registry doesn't define. +/// Pulled out as a pure function so the precedence contract is +/// unit-testable without spinning up a real `Request` and async +/// dispatcher. +fn synthesise_store_registries( + stores: Stores, +) -> ( + Option, + Option, + Option, +) { + let config_registry = stores.config_registry.or_else(|| { + stores + .config_store + .map(|handle| ConfigRegistry::single_id("default".to_owned(), handle)) + }); + let kv_registry = stores.kv_registry.or_else(|| { + stores + .kv + .map(|handle| KvRegistry::single_id("default".to_owned(), handle)) + }); + let secret_registry = stores.secret_registry.or_else(|| { + stores.secrets.map(|handle| { + SecretRegistry::single_id( + "default".to_owned(), + BoundSecretStore::new(handle, "default".to_owned()), + ) + }) + }); + (config_registry, kv_registry, secret_registry) } -fn edge_error_to_worker(err: &EdgeError) -> WorkerError { - WorkerError::RustError(err.to_string()) +fn warn_missing_config_binding_once(binding: &str, error: &impl Display) { + static WARNED_BINDINGS: OnceLock>> = OnceLock::new(); + let warned_bindings = WARNED_BINDINGS.get_or_init(|| Mutex::new(BTreeSet::new())); + + match warned_bindings.lock() { + Ok(mut guard) => { + if !guard.insert(binding.to_owned()) { + return; + } + log::warn!("config KV binding '{binding}' not available: {error}"); + } + Err(_) => { + log::warn!("config KV binding '{binding}' not available: {error}"); + } + } } fn warn_missing_kv_binding_once(kv_binding: &str, error: &impl Display) { static WARNED_BINDINGS: OnceLock>> = OnceLock::new(); - let warned = WARNED_BINDINGS.get_or_init(|| Mutex::new(BTreeSet::new())); + let warned_bindings = WARNED_BINDINGS.get_or_init(|| Mutex::new(BTreeSet::new())); - match warned.lock() { + match warned_bindings.lock() { Ok(mut guard) => { if !guard.insert(kv_binding.to_owned()) { return; @@ -409,22 +556,17 @@ fn warn_missing_kv_binding_once(kv_binding: &str, error: &impl Display) { } } -fn into_core_method(method: &Method) -> CoreMethod { - let bytes = method.as_ref().as_bytes(); - CoreMethod::from_bytes(bytes).unwrap_or_else(|_| { - log::warn!( - "unknown HTTP method {:?}, defaulting to GET", - method.as_ref() - ); - CoreMethod::GET - }) -} - #[cfg(test)] mod tests { use super::*; use wasm_bindgen_test::wasm_bindgen_test; + #[wasm_bindgen_test] + fn into_http_method_defaults_unknown_to_get() { + let method = Method::from("FOO".to_owned()); + assert_eq!(into_core_method(&method), CoreMethod::GET); + } + #[wasm_bindgen_test] fn into_http_method_maps_known_methods() { assert_eq!(into_core_method(&Method::Get), CoreMethod::GET); @@ -432,10 +574,100 @@ mod tests { assert_eq!(into_core_method(&Method::Put), CoreMethod::PUT); assert_eq!(into_core_method(&Method::Delete), CoreMethod::DELETE); } +} - #[wasm_bindgen_test] - fn into_http_method_defaults_unknown_to_get() { - let method = Method::from("FOO".to_owned()); - assert_eq!(into_core_method(&method), CoreMethod::GET); +#[cfg(test)] +mod synthesis_tests { + use std::collections::BTreeMap; + use std::sync::Arc; + + use edgezero_core::config_store::{ConfigStore, ConfigStoreError, ConfigStoreHandle}; + use edgezero_core::key_value_store::{KvStore, NoopKvStore}; + use edgezero_core::secret_store::{NoopSecretStore, SecretHandle}; + + use super::*; + + struct StubConfig; + #[async_trait::async_trait(?Send)] + impl ConfigStore for StubConfig { + async fn get(&self, _key: &str) -> Result, ConfigStoreError> { + Ok(None) + } + } + + fn config_handle() -> ConfigStoreHandle { + ConfigStoreHandle::new(Arc::new(StubConfig)) + } + + fn kv_handle() -> KvHandle { + let store: Arc = Arc::new(NoopKvStore); + KvHandle::new(store) + } + + fn secret_handle() -> SecretHandle { + SecretHandle::new(Arc::new(NoopSecretStore)) + } + + #[test] + fn synthesis_handles_config_and_secret_bare_handles_symmetrically() { + let stores = Stores { + config_store: Some(config_handle()), + secrets: Some(secret_handle()), + ..Default::default() + }; + let (config, _, secret) = synthesise_store_registries(stores); + assert_eq!(config.expect("config").default_id(), "default"); + let secret_registry = secret.expect("secret"); + assert_eq!(secret_registry.default_id(), "default"); + // BoundSecretStore binds the synthesised secret to platform + // store name "default". A handler reading via + // `ctx.secret_store_default()?.require_str(key)` resolves + // the cloudflare Worker Secret literally named "default"; + // if the operator's wrangler.toml uses a different name, + // the runtime require_str() surfaces a clear store-name + // error rather than a silent miss. + assert_eq!( + secret_registry.default().expect("bound").store_name(), + "default" + ); + } + + #[test] + fn synthesis_registry_wins_over_bare_handle_when_both_wired() { + let mut by_id: BTreeMap = BTreeMap::new(); + by_id.insert("sessions".to_owned(), kv_handle()); + let registry = KvRegistry::new(by_id, "sessions".to_owned()); + let stores = Stores { + kv: Some(kv_handle()), + kv_registry: Some(registry), + ..Default::default() + }; + let (_, kv, _) = synthesise_store_registries(stores); + let kv_registry = kv.expect("registry survives"); + assert_eq!(kv_registry.default_id(), "sessions"); + assert!( + kv_registry.named("default").is_none(), + "bare handle's `default` synth NOT merged in" + ); + } + + #[test] + fn synthesis_returns_none_for_each_kind_with_no_wiring() { + let (config, kv, secret) = synthesise_store_registries(Stores::default()); + assert!(config.is_none() && kv.is_none() && secret.is_none()); + } + + #[test] + fn synthesis_wraps_bare_kv_handle_under_default_when_no_registry() { + let stores = Stores { + kv: Some(kv_handle()), + ..Default::default() + }; + let (config, kv, secret) = synthesise_store_registries(stores); + assert!(config.is_none()); + assert!(secret.is_none()); + let kv_registry = kv.expect("kv registry synthesised"); + assert_eq!(kv_registry.default_id(), "default"); + assert!(kv_registry.named("other").is_none()); } } diff --git a/crates/edgezero-adapter-cloudflare/src/templates/src/lib.rs.hbs b/crates/edgezero-adapter-cloudflare/src/templates/src/lib.rs.hbs index cea01a1e..ff0688d1 100644 --- a/crates/edgezero-adapter-cloudflare/src/templates/src/lib.rs.hbs +++ b/crates/edgezero-adapter-cloudflare/src/templates/src/lib.rs.hbs @@ -12,11 +12,5 @@ use worker::{event, Context, Env, Request, Response, Result}; #[event(fetch)] #[inline] pub async fn main(req: Request, env: Env, ctx: Context) -> Result { - edgezero_adapter_cloudflare::run_app::<{{proj_core_mod}}::App>( - include_str!("../../../edgezero.toml"), - req, - env, - ctx, - ) - .await + edgezero_adapter_cloudflare::run_app::<{{proj_core_mod}}::App>(req, env, ctx).await } diff --git a/crates/edgezero-adapter-cloudflare/tests/contract.rs b/crates/edgezero-adapter-cloudflare/tests/contract.rs index 94b7615c..99f15d86 100644 --- a/crates/edgezero-adapter-cloudflare/tests/contract.rs +++ b/crates/edgezero-adapter-cloudflare/tests/contract.rs @@ -1,11 +1,4 @@ #![cfg(all(feature = "cloudflare", target_arch = "wasm32"))] -// Keep coverage for the deprecated low-level dispatch path while it remains -// public. -#![allow( - deprecated, - reason = "the deprecated dispatch helper is still part of the public API; \ - contract coverage stays until the helper is removed" -)] // Compile-time check: CloudflareSecretStore implements SecretStore. mod secret_store_compile_check { @@ -21,11 +14,11 @@ mod secret_store_compile_check { #[cfg(test)] mod tests { + use std::sync::Arc; + use bytes::Bytes; use edgezero_adapter_cloudflare::context::CloudflareRequestContext; - use edgezero_adapter_cloudflare::request::{ - dispatch, dispatch_with_config, dispatch_with_config_handle, into_core_request, - }; + use edgezero_adapter_cloudflare::request::{into_core_request, CloudflareService}; use edgezero_adapter_cloudflare::response::from_core_response; use edgezero_core::app::App; use edgezero_core::body::Body; @@ -35,21 +28,19 @@ mod tests { use edgezero_core::http::{response_builder, Method, Response, StatusCode}; use edgezero_core::router::RouterService; use futures::stream; - use std::sync::Arc; use wasm_bindgen_test::{wasm_bindgen_test, wasm_bindgen_test_configure}; - use worker::js_sys::{Object, Uint8Array}; + use worker::js_sys::Object; use worker::wasm_bindgen::{JsCast as _, JsValue}; use worker::worker_sys::Context as WorkerSysContext; - use worker::{ - Context, Env, Headers as CfHeaders, Method as CfMethod, Request as CfRequest, RequestInit, - }; + use worker::{Context, Env, Method as CfMethod, Request as CfRequest, RequestInit}; wasm_bindgen_test_configure!(run_in_browser); struct FixedConfigStore(&'static str); + #[async_trait::async_trait(?Send)] impl ConfigStore for FixedConfigStore { - fn get(&self, _key: &str) -> Result, ConfigStoreError> { + async fn get(&self, _key: &str) -> Result, ConfigStoreError> { Ok(Some(self.0.to_owned())) } } @@ -74,7 +65,11 @@ mod tests { } async fn config_presence(ctx: RequestContext) -> Result { - let present = if ctx.config_store().is_some() { + // Hard-cutoff: legacy `ctx.config_handle()` is + // gone. The dispatch boundary now synthesises a one-id + // `ConfigRegistry` from the wired `ConfigStoreHandle`, so + // the registry-aware accessor resolves the same store. + let present = if ctx.config_store_default().is_some() { "yes" } else { "no" @@ -100,10 +95,17 @@ mod tests { } async fn config_value(ctx: RequestContext) -> Result { - let value = ctx - .config_store() - .and_then(|store| store.get("greeting").ok().flatten()) - .unwrap_or_else(|| "missing".to_owned()); + // Hard-cutoff: legacy `ctx.config_handle()` is + // gone. See `config_presence` for the migration rationale. + let value = match ctx.config_store_default() { + Some(store) => store + .get("greeting") + .await + .ok() + .flatten() + .unwrap_or_else(|| "missing".to_owned()), + None => "missing".to_owned(), + }; let response = response_builder() .status(StatusCode::OK) .body(Body::text(value)) @@ -123,17 +125,19 @@ mod tests { } fn cf_request(method: CfMethod, path: &str, body: Option<&[u8]>) -> CfRequest { + use worker::js_sys::Uint8Array; + let mut init = RequestInit::new(); init.with_method(method); - let headers = CfHeaders::new(); + let headers = worker::Headers::new(); headers.set("host", "example.com").expect("host header"); headers.set("x-edgezero-test", "1").expect("custom header"); init.with_headers(headers); if let Some(bytes) = body { let array = Uint8Array::from(bytes); - init.with_body(Some(JsValue::from(array))); + init.with_body(Some(JsValue::from(array))); // Uint8Array -> JsValue } let url = format!("https://example.com{path}"); @@ -147,38 +151,51 @@ mod tests { } #[wasm_bindgen_test] - async fn into_core_request_preserves_method_uri_headers_body_and_context() { - let req = cf_request(CfMethod::Post, "/mirror?foo=bar", Some(b"payload")); + async fn dispatch_passes_request_body_to_handlers() { + let app = build_test_app(); + let req = cf_request(CfMethod::Post, "/mirror", Some(b"echo")); let (env, ctx) = test_env_ctx(); - let core_request = into_core_request(req, env, ctx) + let mut response = CloudflareService::new(&app) + .dispatch(req, env, ctx) .await - .expect("core request"); + .expect("cf response"); - assert_eq!(core_request.method(), &Method::POST, "method preserved"); - assert_eq!(core_request.uri().path(), "/mirror", "uri path preserved"); - assert_eq!( - core_request.uri().query(), - Some("foo=bar"), - "uri query preserved" - ); + assert_eq!(response.status_code(), StatusCode::OK.as_u16()); + let bytes = response.bytes().await.expect("bytes"); + assert_eq!(bytes.as_slice(), b"echo"); + } - let header = core_request - .headers() - .get("x-edgezero-test") - .and_then(|value| value.to_str().ok()); - assert_eq!(header, Some("1"), "custom header preserved"); + #[wasm_bindgen_test] + async fn dispatch_runs_router_and_returns_response() { + let app = build_test_app(); + let req = cf_request(CfMethod::Get, "/uri", None); + let (env, ctx) = test_env_ctx(); - assert_eq!( - core_request.body().as_bytes().expect("buffered"), - b"payload", - "body bytes preserved" - ); + let mut response = CloudflareService::new(&app) + .dispatch(req, env, ctx) + .await + .expect("cf response"); - assert!( - CloudflareRequestContext::get(&core_request).is_some(), - "FastlyRequestContext attached" - ); + assert_eq!(response.status_code(), StatusCode::OK.as_u16()); + let body = response.text().await.expect("text"); + assert_eq!(body, "https://example.com/uri"); + } + + #[wasm_bindgen_test] + async fn dispatch_streaming_route_preserves_chunks() { + let app = build_test_app(); + let req = cf_request(CfMethod::Get, "/stream", None); + let (env, ctx) = test_env_ctx(); + + let mut response = CloudflareService::new(&app) + .dispatch(req, env, ctx) + .await + .expect("cf response"); + + assert_eq!(response.status_code(), StatusCode::OK.as_u16()); + let bytes = response.bytes().await.expect("bytes"); + assert_eq!(bytes.as_slice(), b"chunk-1chunk-2"); } #[wasm_bindgen_test] @@ -195,92 +212,77 @@ mod tests { let mut cf_response = from_core_response(response).expect("cf response"); - assert_eq!( - cf_response.status_code(), - StatusCode::CREATED.as_u16(), - "status code translated" - ); - let header = cf_response - .headers() - .get("x-edgezero-res") - .expect("header set"); - assert_eq!(header.as_deref(), Some("1"), "response header preserved"); + assert_eq!(cf_response.status_code(), StatusCode::CREATED.as_u16()); + let header = cf_response.headers().get("x-edgezero-res").unwrap(); + assert_eq!(header.as_deref(), Some("1")); let bytes = cf_response.bytes().await.expect("bytes"); - assert_eq!(bytes.as_slice(), b"hello world", "streaming body collected"); - } - - #[wasm_bindgen_test] - async fn dispatch_runs_router_and_returns_response() { - let app = build_test_app(); - let req = cf_request(CfMethod::Get, "/uri", None); - let (env, ctx) = test_env_ctx(); - - let mut response = dispatch(&app, req, env, ctx).await.expect("cf response"); - - assert_eq!(response.status_code(), StatusCode::OK.as_u16(), "status OK"); - let body = response.text().await.expect("text"); - assert_eq!(body, "https://example.com/uri", "echoed uri"); + assert_eq!(bytes.as_slice(), b"hello world"); } #[wasm_bindgen_test] - async fn dispatch_streaming_route_preserves_chunks() { - let app = build_test_app(); - let req = cf_request(CfMethod::Get, "/stream", None); + async fn into_core_request_preserves_method_uri_headers_body_and_context() { + let req = cf_request(CfMethod::Post, "/mirror?foo=bar", Some(b"payload")); let (env, ctx) = test_env_ctx(); - let mut response = dispatch(&app, req, env, ctx).await.expect("cf response"); + let core_request = into_core_request(req, env, ctx) + .await + .expect("core request"); - assert_eq!(response.status_code(), StatusCode::OK.as_u16(), "status OK"); - let bytes = response.bytes().await.expect("bytes"); - assert_eq!(bytes.as_slice(), b"chunk-1chunk-2", "chunks concatenated"); - } + assert_eq!(core_request.method(), &Method::POST); + assert_eq!(core_request.uri().path(), "/mirror"); + assert_eq!(core_request.uri().query(), Some("foo=bar")); - #[wasm_bindgen_test] - async fn dispatch_passes_request_body_to_handlers() { - let app = build_test_app(); - let req = cf_request(CfMethod::Post, "/mirror", Some(b"echo")); - let (env, ctx) = test_env_ctx(); + let header = core_request + .headers() + .get("x-edgezero-test") + .and_then(|value| value.to_str().ok()); + assert_eq!(header, Some("1")); - let mut response = dispatch(&app, req, env, ctx).await.expect("cf response"); + assert_eq!( + core_request.body().as_bytes().expect("buffered"), + b"payload" + ); - assert_eq!(response.status_code(), StatusCode::OK.as_u16(), "status OK"); - let bytes = response.bytes().await.expect("bytes"); - assert_eq!(bytes.as_slice(), b"echo", "request body echoed"); + assert!(CloudflareRequestContext::get(&core_request).is_some()); } #[wasm_bindgen_test] - async fn dispatch_with_config_missing_binding_skips_injection() { - // The test env is an empty JS object; any env.var() call returns None. - // dispatch_with_config should log a warning and dispatch without - // injecting a config-store handle, so the handler receives - // ctx.config_store() == None. + async fn service_with_config_handle_injects_handle() { let app = build_test_app(); - let req = cf_request(CfMethod::Get, "/has-config", None); + let req = cf_request(CfMethod::Get, "/config-value", None); let (env, ctx) = test_env_ctx(); + let handle = ConfigStoreHandle::new(Arc::new(FixedConfigStore("hello from cf test"))); - let mut response = dispatch_with_config(&app, req, env, ctx, "nonexistent_binding") + let mut response = CloudflareService::new(&app) + .with_config_handle(handle) + .dispatch(req, env, ctx) .await .expect("cf response"); - assert_eq!(response.status_code(), StatusCode::OK.as_u16(), "status OK"); + assert_eq!(response.status_code(), StatusCode::OK.as_u16()); let body = response.text().await.expect("text"); - assert_eq!(body, "no", "handler observed missing config store"); + assert_eq!(body, "hello from cf test"); } #[wasm_bindgen_test] - async fn dispatch_with_config_handle_injects_handle() { + async fn service_with_config_missing_binding_skips_injection() { + // The test env is an empty JS object; any env.var() call returns None. + // `CloudflareService::with_config(name)` should log a warning and + // dispatch without injecting a config-store handle, so the handler + // sees `ctx.config_store_default()` return `None`. let app = build_test_app(); - let req = cf_request(CfMethod::Get, "/config-value", None); + let req = cf_request(CfMethod::Get, "/has-config", None); let (env, ctx) = test_env_ctx(); - let handle = ConfigStoreHandle::new(Arc::new(FixedConfigStore("hello from cf test"))); - let mut response = dispatch_with_config_handle(&app, req, env, ctx, handle) + let mut response = CloudflareService::new(&app) + .with_config("nonexistent_binding") + .dispatch(req, env, ctx) .await .expect("cf response"); - assert_eq!(response.status_code(), StatusCode::OK.as_u16(), "status OK"); + assert_eq!(response.status_code(), StatusCode::OK.as_u16()); let body = response.text().await.expect("text"); - assert_eq!(body, "hello from cf test", "config value injected"); + assert_eq!(body, "no"); } } diff --git a/crates/edgezero-adapter-fastly/Cargo.toml b/crates/edgezero-adapter-fastly/Cargo.toml index 3b923035..8e79f513 100644 --- a/crates/edgezero-adapter-fastly/Cargo.toml +++ b/crates/edgezero-adapter-fastly/Cargo.toml @@ -14,6 +14,8 @@ cli = [ "dep:edgezero-adapter", "edgezero-adapter/cli", "dep:ctor", + "dep:serde_json", + "dep:toml_edit", "dep:walkdir", ] fastly = ["dep:fastly", "dep:log-fastly"] @@ -37,7 +39,9 @@ log = { workspace = true } log-fastly = { workspace = true, optional = true } fern = { workspace = true } chrono = { workspace = true } +serde_json = { workspace = true, optional = true } thiserror = { workspace = true } +toml_edit = { workspace = true, optional = true } walkdir = { workspace = true, optional = true } [dev-dependencies] diff --git a/crates/edgezero-adapter-fastly/src/cli.rs b/crates/edgezero-adapter-fastly/src/cli.rs index 529be984..d627d99d 100644 --- a/crates/edgezero-adapter-fastly/src/cli.rs +++ b/crates/edgezero-adapter-fastly/src/cli.rs @@ -1,13 +1,17 @@ use std::env; use std::fs; +use std::io::{ErrorKind, Write as _}; use std::path::{Path, PathBuf}; use std::process::Command; +use std::process::Stdio; use ctor::ctor; use edgezero_adapter::cli_support::{ - find_manifest_upwards, find_workspace_root, path_distance, read_package_name, + find_manifest_upwards, find_workspace_root, path_distance, read_package_name, run_native_cli, +}; +use edgezero_adapter::registry::{ + register_adapter, Adapter, AdapterAction, AdapterPushContext, ProvisionStores, ResolvedStoreId, }; -use edgezero_adapter::registry::{register_adapter, Adapter, AdapterAction}; use edgezero_adapter::scaffold::{ register_adapter_blueprint, AdapterBlueprint, AdapterFileSpec, CommandTemplates, DependencySpec, LoggingDefaults, ManifestSpec, ReadmeInfo, TemplateRegistration, @@ -45,7 +49,7 @@ static FASTLY_BLUEPRINT: AdapterBlueprint = AdapterBlueprint { readme: ReadmeInfo { description: "{display} entrypoint.", dev_heading: "{display} (local)", - dev_steps: &["`cd {crate_dir}`", "`edgezero-cli serve --adapter fastly`"], + dev_steps: &["`cd {crate_dir}`", "`edgezero serve --adapter fastly`"], }, run_module: "edgezero_adapter_fastly", }; @@ -111,11 +115,72 @@ static FASTLY_TEMPLATE_REGISTRATIONS: &[TemplateRegistration] = &[ }, ]; +const FASTLY_INSTALL_HINT: &str = + "install the Fastly CLI (https://www.fastly.com/documentation/reference/tools/cli/) and try again"; + struct FastlyCliAdapter; +/// Outcome of scanning `fastly config-store list --json` for a +/// platform store id by `name`. Distinguishes three cases the +/// caller wants to act on differently: +/// +/// - `Found(id)` — happy path. +/// - `NotFound` — JSON parsed cleanly and the array contains +/// entries with well-formed `name` + `id` string fields, but no +/// entry matched `name`. Operator likely needs to run +/// `provision`. +/// - `SchemaDrift(detail)` — the JSON parsed but doesn't match +/// the expected shape (no `items` envelope nor bare array, OR +/// entries are missing `name` / `id` string fields, OR the +/// bytes didn't parse as JSON at all). Likely a fastly CLI +/// version bump that changed the output schema; surface the +/// detail so the operator can pin a known-compatible version. +#[derive(Debug)] +enum ConfigStoreLookup { + Found(String), + NotFound, + SchemaDrift(String), +} + +// The three `validate_*` trait methods exist on `Adapter` because +// spin requires them (variable-name regex, `[component.*]` +// discovery, flat-namespace collision). The trait surface is typed +// generically so any future adapter with similar constraints can +// override — but fastly has no equivalent platform requirements, +// so the no-op defaults are correct: +// +// - `validate_app_config_keys`: Fastly Config Store keys accept +// alphanumeric + `-` / `_` / `.` up to 256 chars. Any reasonable +// Rust struct field name passes; no regex check needed. +// - `validate_adapter_manifest`: would require shelling out to +// `fastly compute validate` at validate-time. We keep +// `config validate` pure-Rust so it stays fast and +// tool-independent. +// - `validate_typed_secrets`: Fastly's KV / Config / Secret +// stores are independent namespaces — no spin-style flat- +// namespace collision risk to detect. +// +// `single_store_kinds` IS overridden below — explicitly returns +// `&[]` for documentation, matching the inherited default. +#[expect( + clippy::missing_trait_methods, + reason = "see the explanatory block comment immediately above; fastly's no-op defaults for the three validate_* hooks are intentional and documented. `single_store_kinds` IS overridden below (returns `&[]`)." +)] impl Adapter for FastlyCliAdapter { fn execute(&self, action: AdapterAction, args: &[String]) -> Result<(), String> { match action { + // `fastly profile {create|delete|list}` is the native + // sign-in surface for Fastly Compute. EdgeZero stores no + // credentials — this is a thin shell-out. + AdapterAction::AuthLogin => { + run_native_cli("fastly", &["profile", "create"], FASTLY_INSTALL_HINT) + } + AdapterAction::AuthLogout => { + run_native_cli("fastly", &["profile", "delete"], FASTLY_INSTALL_HINT) + } + AdapterAction::AuthStatus => { + run_native_cli("fastly", &["profile", "list"], FASTLY_INSTALL_HINT) + } AdapterAction::Build => { let artifact = build(args)?; log::info!("[edgezero] Fastly build complete -> {}", artifact.display()); @@ -130,6 +195,666 @@ impl Adapter for FastlyCliAdapter { fn name(&self) -> &'static str { "fastly" } + + fn provision( + &self, + manifest_root: &Path, + adapter_manifest_path: Option<&str>, + _component_selector: Option<&str>, + stores: &ProvisionStores<'_>, + dry_run: bool, + ) -> Result, String> { + // Fastly is Multi for every store kind. Each id maps 1:1 + // to a Fastly resource (kv-store / config-store / + // secret-store) created via the Fastly CLI; the manifest + // writeback declares the resource link for `fastly + // compute deploy` and the local viceroy server. + let Some(rel) = adapter_manifest_path else { + return Err( + "[adapters.fastly.adapter].manifest must point at fastly.toml for provision" + .to_owned(), + ); + }; + let fastly_path = manifest_root.join(rel); + + let mut out = Vec::new(); + for (kind, ids) in [ + ("kv", stores.kv), + ("config", stores.config), + ("secret", stores.secrets), + ] { + for store in ids { + // Fastly setup tables key on the resource name the + // CLI creates. The runtime resolves that same name + // via `EDGEZERO__STORES______NAME`, + // so provision must use the env-resolved PLATFORM + // name -- the logical id stays in status lines for + // human-facing wording. + let logical = store.logical.as_str(); + let name = store.platform.as_str(); + if dry_run { + out.push(format!( + "would run `fastly {kind}-store create --name={name}` and append [setup.{kind}_stores.{name}] to {} (logical id `{logical}`)", + fastly_path.display() + )); + continue; + } + if setup_block_present(&fastly_path, kind, name)? { + out.push(format!( + "fastly {kind}-store `{name}` (logical id `{logical}`) already declared in {}; skipping. To force a fresh remote: delete the [setup.{kind}_stores.{name}] block AND run `fastly {kind}-store delete --name={name}` (the old remote store lingers otherwise), then re-run provision.", + fastly_path.display() + )); + continue; + } + create_fastly_store(kind, name)?; + // If the platform store was created but the + // writeback fails, remote state and the local + // manifest are out of sync. Re-running `provision` + // would attempt to create the platform store again + // and fail with "already exists". Surface the + // recovery path explicitly so the operator isn't + // stuck. + append_fastly_setup(&fastly_path, kind, name).map_err(|err| { + format!( + "fastly {kind}-store `{name}` (logical id `{logical}`) was created remotely, but writeback to {path} failed: {err}\n To recover, either:\n 1. Manually append `[setup.{kind}_stores.{name}]` to {path} and re-run, or\n 2. Delete the orphan remote store via `fastly {kind}-store delete --name={name}` and re-run `edgezero provision --adapter fastly`.", + path = fastly_path.display() + ) + })?; + // Fastly's `[setup._stores.]` table is + // consumed ONLY when `fastly compute deploy` is + // creating a NEW service. If `service_id` is + // already present in fastly.toml, the service has + // been deployed at least once and subsequent + // deploys skip `[setup]` entirely — so the store + // exists in the account but has no resource link + // tying it to a service version, and the running + // Compute service can't open it. + // + // Detect that case and EMIT the exact one-shot + // command the operator should run to link the + // store. We deliberately don't auto-run it: the + // link cones the active version (`--autoclone`), + // and silently mutating an already-deployed + // service is surprising. The instruction names + // both the store-id lookup AND the link command so + // the operator can audit before committing. + let post_create_note = read_fastly_service_id(&fastly_path)?.map(|svc_id| { + format!( + " fastly.toml declares `service_id = \"{svc_id}\"`, so this service is already deployed — `[setup]` will NOT be re-run on the next `fastly compute deploy`. The store exists in the account but is NOT yet linked to the service. To finish provisioning, look up the store id with `fastly {kind}-store list --json` (match by name=`{name}`), then run:\n fastly resource-link create --service-id={svc_id} --resource-id= --version=latest --autoclone --name={name}\n (the link clones the active version so existing traffic is not affected until you `fastly service-version activate`)." + ) + }); + let mut line = format!( + "created fastly {kind}-store `{name}` (logical id `{logical}`); appended setup tables to {}", + fastly_path.display() + ); + if let Some(note) = post_create_note { + line.push('\n'); + line.push_str(¬e); + } + out.push(line); + } + } + if out.is_empty() { + out.push("fastly has no declared stores to provision".to_owned()); + } + Ok(out) + } + + fn push_config_entries( + &self, + _manifest_root: &Path, + _adapter_manifest_path: Option<&str>, + _component_selector: Option<&str>, + store: &ResolvedStoreId, + entries: &[(String, String)], + _push_ctx: &AdapterPushContext<'_>, + dry_run: bool, + ) -> Result, String> { + // Resolve the platform config-store id on demand via + // `fastly config-store list --json` (matched by name = + // `store.platform`), then `fastly config-store-entry create + // --store-id= --key= --value=` per key. Keys + // arrive pre-flattened from the CLI (dotted form). + let logical = store.logical.as_str(); + let name = store.platform.as_str(); + if entries.is_empty() { + return Ok(vec![format!( + "no config entries to push to fastly config-store `{name}` (logical id `{logical}`)" + )]); + } + if dry_run { + // List each entry so the operator can verify intent + // before committing. Matches the spin dry-run preview + // shape. + let mut out = Vec::with_capacity(entries.len().saturating_add(1)); + out.push(format!( + "would resolve fastly config-store `{name}` (logical id `{logical}`) via `fastly config-store list --json` and run `fastly config-store-entry create` for {} entries:", + entries.len() + )); + for (key, _) in entries { + out.push(format!(" would create entry `{key}`")); + } + return Ok(out); + } + let resolved_id = resolve_remote_config_store_id(name)?; + push_entries_with_committer(entries, |key, value| { + create_config_store_entry(&resolved_id, key, value) + })?; + Ok(vec![format!( + "pushed {} entries to fastly config-store `{name}` (logical id `{logical}`, id={resolved_id})", + entries.len() + )]) + } + + fn push_config_entries_local( + &self, + manifest_root: &Path, + adapter_manifest_path: Option<&str>, + _component_selector: Option<&str>, + store: &ResolvedStoreId, + entries: &[(String, String)], + _push_ctx: &AdapterPushContext<'_>, + dry_run: bool, + ) -> Result, String> { + // Local-emulator path: edit + // `[local_server.config_stores..contents]` in + // `fastly.toml`. Viceroy reads it on startup, so a + // subsequent `fastly compute serve` exposes the new values + // to the wasm component. No shell-out to the production + // Fastly CLI -- the operator may not be authenticated and + // wouldn't want a local push to touch production anyway. + let Some(rel) = adapter_manifest_path else { + return Err( + "[adapters.fastly.adapter].manifest must point at fastly.toml for config push --local" + .to_owned(), + ); + }; + let fastly_path = manifest_root.join(rel); + let logical = store.logical.as_str(); + let name = store.platform.as_str(); + if entries.is_empty() { + return Ok(vec![format!( + "no config entries to push to `[local_server.config_stores.{name}]` in {} (logical id `{logical}`)", + fastly_path.display() + )]); + } + if dry_run { + let mut out = Vec::with_capacity(entries.len().saturating_add(1)); + out.push(format!( + "would edit `[local_server.config_stores.{name}.contents]` in {} (logical id `{logical}`) with {} entries:", + fastly_path.display(), + entries.len() + )); + for (key, _) in entries { + out.push(format!(" would set `{key}`")); + } + return Ok(out); + } + write_fastly_local_config_store(&fastly_path, name, entries)?; + Ok(vec![format!( + "wrote {} entries to `[local_server.config_stores.{name}.contents]` in {} (logical id `{logical}`); restart `fastly compute serve` to pick up changes", + entries.len(), + fastly_path.display() + )]) + } + + fn single_store_kinds(&self) -> &'static [&'static str] { + // Explicit `&[]` rather than inheriting the trait default, + // so the "Multi for every store kind" intent is documented + // at the call site. Fastly KV / Config / Secrets all + // support multiple distinct platform resources per kind, + // unlike spin's flat-namespace single-store model. + &[] + } +} + +/// Shell out to `fastly -store create --name=`. The +/// caller resolves `` from `EDGEZERO__STORES______NAME` +/// (falling back to the logical id), so this helper takes whatever the +/// caller hands it and does not re-translate. Returns `Ok(())` on success; +/// surfaces the CLI's stderr verbatim on failure (including the "already +/// exists" error, which is the caller's signal to fix the toml or use a +/// different name). +/// +/// # Errors +/// Returns an error if `fastly` isn't on `PATH`, the child fails to +/// spawn, or the exit status is non-zero. +fn create_fastly_store(kind: &str, name: &str) -> Result<(), String> { + let subcommand = format!("{kind}-store"); + let name_arg = format!("--name={name}"); + let output = Command::new("fastly") + .args([subcommand.as_str(), "create", name_arg.as_str()]) + .output() + .map_err(|err| { + if err.kind() == ErrorKind::NotFound { + format!("`fastly` not found on PATH; {FASTLY_INSTALL_HINT}") + } else { + format!("failed to spawn `fastly`: {err}") + } + })?; + if output.status.success() { + return Ok(()); + } + // Idempotency: the fastly CLI returns non-zero with an + // "already exists" message when a store of this name was + // created by a prior provision run. Treat that as success so + // the operator's recovery path -- "either manually append the + // setup block or delete the remote and re-run provision" -- + // doesn't get blocked. The append step is itself idempotent, + // so re-running provision after a writeback failure is the + // documented recovery and now actually works. + let stderr = String::from_utf8_lossy(&output.stderr); + if looks_like_already_exists(&stderr, kind) { + return Ok(()); + } + Err(format!( + "`fastly {subcommand} create --name={name}` exited with status {}\nstderr: {}", + output.status, + stderr.trim() + )) +} + +/// Heuristic: does the stderr blob look like a "store of this +/// kind, by this name, already exists" failure from the fastly +/// CLI? Different CLI versions phrase this slightly differently +/// ("a kv-store with that name already exists", +/// `"Conflict: duplicate kv_store name"`, etc.); we require BOTH +/// a conflict-signal keyword AND a store-kind reference so an +/// unrelated 409 ("Error: 409 Conflict on /service/...") cannot +/// be misread as idempotent success. The earlier wider heuristic +/// would have swallowed any stderr containing the word +/// "conflict" and let provision march on to writeback against a +/// nonexistent store, surfacing as a confusing deploy-time error. +fn looks_like_already_exists(stderr: &str, kind: &str) -> bool { + let lower = stderr.to_ascii_lowercase(); + let conflict_signal = lower.contains("already exists") + || (lower.contains("duplicate") && lower.contains("name")) + || lower.contains("conflict"); + if !conflict_signal { + return false; + } + // Accept the three common spellings of `-store` / + // `_store` / ` store` so a fastly CLI version + // bump that reshuffles punctuation still hits. + let dashed = format!("{kind}-store"); + let underscored = format!("{kind}_store"); + let spaced = format!("{kind} store"); + lower.contains(&dashed) || lower.contains(&underscored) || lower.contains(&spaced) +} + +/// Read the top-level `service_id` from `fastly.toml`. Returns +/// `Ok(None)` when the file is absent (scaffold state before first +/// `fastly compute deploy`) or when `service_id` is missing / +/// empty. Used by `provision` to detect when an already-deployed +/// service needs a separate resource-link step beyond `[setup]` +/// (which `compute deploy` only consumes on the FIRST deploy). +fn read_fastly_service_id(path: &Path) -> Result, String> { + let raw = match fs::read_to_string(path) { + Ok(text) => text, + Err(err) if err.kind() == ErrorKind::NotFound => return Ok(None), + Err(err) => return Err(format!("failed to read {}: {err}", path.display())), + }; + let doc: toml_edit::DocumentMut = raw + .parse() + .map_err(|err| format!("failed to parse {}: {err}", path.display()))?; + let svc = doc + .get("service_id") + .and_then(|item| item.as_str()) + .map(str::to_owned) + .filter(|svc_id| !svc_id.is_empty()); + Ok(svc) +} + +/// Probe `fastly.toml` for the existence of `[setup._stores.]`. +/// Treats a missing file as "not present" so the first provision call +/// can create it. +/// +/// Why only `[setup]` (no longer `[local_server]`): an empty +/// `[local_server._stores.]` table doesn't satisfy +/// fastly's local-server schema — config-stores need +/// `format = "inline-toml"` + a contents table, kv/secret stores +/// need a JSON `file = "..."` or an array of `{key, data}` entries. +/// Writing an empty table makes `fastly compute serve` skip the +/// declared store or error at startup. `provision`'s job is the +/// remote / `[setup]` half; local-server stanzas are written by +/// `edgezero config push --adapter fastly --local` +/// (config-stores only), and kv/secret local-server seeding is +/// hand-edited until we add equivalent writers for those kinds. +fn setup_block_present(path: &Path, kind: &str, id: &str) -> Result { + let raw = match fs::read_to_string(path) { + Ok(text) => text, + Err(err) if err.kind() == ErrorKind::NotFound => return Ok(false), + Err(err) => return Err(format!("failed to read {}: {err}", path.display())), + }; + let doc: toml_edit::DocumentMut = raw + .parse() + .map_err(|err| format!("failed to parse {}: {err}", path.display()))?; + let plural = format!("{kind}_stores"); + Ok(doc + .get("setup") + .and_then(|root| root.get(plural.as_str())) + .and_then(|kind_tbl| kind_tbl.get(id)) + .is_some()) +} + +/// Append `[setup._stores.]` to `fastly.toml`. Creates +/// the file (and the parent `[setup]` table) if absent. The block +/// is written as an empty table — that's what +/// `fastly compute deploy` consumes the first time it creates a +/// service: the resource-link declaration is enough, and the +/// account-level resource itself is already created in the +/// preceding `create_fastly_store` shellout. +/// +/// We DON'T write `[local_server._stores.]` here: see +/// `setup_block_present`'s doc for the schema rationale. The local- +/// server seeding moved to `config push --local` (config-stores +/// only), so provision only owns the remote / setup half. +fn append_fastly_setup(path: &Path, kind: &str, id: &str) -> Result<(), String> { + use toml_edit::{table, DocumentMut, Item}; + + let raw = match fs::read_to_string(path) { + Ok(text) => text, + Err(err) if err.kind() == ErrorKind::NotFound => String::new(), + Err(err) => return Err(format!("failed to read {}: {err}", path.display())), + }; + let mut doc: DocumentMut = raw + .parse() + .map_err(|err| format!("failed to parse {}: {err}", path.display()))?; + + let plural = format!("{kind}_stores"); + let parent_entry = doc.entry("setup").or_insert_with(table); + let parent_tbl = parent_entry.as_table_mut().ok_or_else(|| { + format!( + "{}: `setup` exists but is not a table; refusing to edit in place", + path.display() + ) + })?; + let kind_entry = parent_tbl + .entry(plural.as_str()) + .or_insert_with(|| Item::Table(toml_edit::Table::new())); + let kind_tbl = kind_entry.as_table_mut().ok_or_else(|| { + format!( + "{}: `setup.{plural}` exists but is not a table; refusing to edit in place", + path.display() + ) + })?; + if !kind_tbl.contains_key(id) { + kind_tbl.insert(id, Item::Table(toml_edit::Table::new())); + } + + fs::write(path, doc.to_string()) + .map_err(|err| format!("failed to write {}: {err}", path.display()))?; + Ok(()) +} + +/// Write the local-server config-store entries to `fastly.toml`: +/// `[local_server.config_stores.]` becomes +/// `format = "inline-toml"`, and `[local_server.config_stores..contents]` +/// gets the flat `key = "value"` pairs (overwriting any previous +/// values). Idempotent — re-running just rewrites `contents`. Other +/// blocks in `fastly.toml` (setup, scripts, the actual `[local_server]` +/// secret stores, etc.) are preserved via `toml_edit`. +fn write_fastly_local_config_store( + path: &Path, + platform_name: &str, + entries: &[(String, String)], +) -> Result<(), String> { + use toml_edit::{table, DocumentMut, Item, Table, Value}; + + let raw = match fs::read_to_string(path) { + Ok(text) => text, + Err(err) if err.kind() == ErrorKind::NotFound => String::new(), + Err(err) => return Err(format!("failed to read {}: {err}", path.display())), + }; + let mut doc: DocumentMut = raw + .parse() + .map_err(|err| format!("failed to parse {}: {err}", path.display()))?; + + let local_server_entry = doc.entry("local_server").or_insert_with(table); + let local_server_tbl = local_server_entry.as_table_mut().ok_or_else(|| { + format!( + "{}: `local_server` exists but is not a table; refusing to edit in place", + path.display() + ) + })?; + let config_stores_entry = local_server_tbl + .entry("config_stores") + .or_insert_with(|| Item::Table(Table::new())); + let config_stores_tbl = config_stores_entry.as_table_mut().ok_or_else(|| { + format!( + "{}: `local_server.config_stores` exists but is not a table; refusing to edit in place", + path.display() + ) + })?; + + // Replace the per-store block wholesale so stale entries don't + // linger across pushes (the inverse of provision's "preserve + // existing tables" rule -- here the push is the source of truth + // for the contents). + let mut store_tbl = Table::new(); + store_tbl.insert("format", toml_edit::value("inline-toml")); + let mut contents_tbl = Table::new(); + for (key, value) in entries { + contents_tbl.insert(key, Item::Value(Value::from(value.clone()))); + } + store_tbl.insert("contents", Item::Table(contents_tbl)); + config_stores_tbl.insert(platform_name, Item::Table(store_tbl)); + + fs::write(path, doc.to_string()) + .map_err(|err| format!("failed to write {}: {err}", path.display()))?; + Ok(()) +} + +// ------------------------------------------------------------------- +// `config push` helpers +// ------------------------------------------------------------------- + +/// Shell out to `fastly config-store-entry create --store-id= +/// --key= --value=` for a single entry. Surfaces fastly's +/// stderr verbatim on failure — including the "entry already +/// exists" error, which is the operator's signal to delete the +/// entry (or use `config-store-entry update` manually) before +/// re-running push. +/// Drive a sequential per-entry commit loop and produce the +/// partial-failure diagnostic when the committer fails mid-way. +/// Pure (no I/O) so the diagnostic shape is unit-testable without +/// the fastly CLI on PATH; production calls it with a closure that +/// shells out via `create_config_store_entry`. On success returns +/// the count of committed entries; on failure returns an error +/// string naming committed / failed / not-attempted keys so the +/// operator can resume from a known boundary. +fn push_entries_with_committer( + entries: &[(String, String)], + mut committer: F, +) -> Result +where + F: FnMut(&str, &str) -> Result<(), String>, +{ + let mut pushed: Vec = Vec::with_capacity(entries.len()); + for (key, value) in entries { + if let Err(err) = committer(key, value) { + let remaining: Vec<&str> = entries + .iter() + .skip(pushed.len().saturating_add(1)) + .map(|(remaining_key, _)| remaining_key.as_str()) + .collect(); + return Err(format!( + "fastly push failed at entry `{key}` after committing {committed} of {total} entries; the remaining {remaining_count} entries were NOT pushed.\n Committed (safe to skip on retry): {pushed:?}\n Failed: `{key}` — {err}\n Not attempted (re-push these): {remaining:?}", + committed = pushed.len(), + total = entries.len(), + remaining_count = remaining.len() + )); + } + pushed.push(key.clone()); + } + Ok(pushed.len()) +} + +/// Shell `fastly config-store-entry update --upsert --stdin` with +/// the value piped through stdin instead of `--value=` on +/// argv. +/// +/// Two reasons for this exact invocation: +/// +/// 1. `--upsert` (vs. the original `create` subcommand): the prior +/// `create` form errored on any key that already existed in the +/// config store, which made `config push` non-repeatable — +/// after the first push, every follow-up push triggered by a +/// config edit would fail at the first unchanged key. +/// `update --upsert` is documented as "insert or update", which +/// matches the convergent semantic the other config-push paths +/// already have (axum overwrites the JSON, cloudflare's +/// `wrangler kv bulk put` overwrites, spin's +/// `cloud key-value set` overwrites). +/// +/// 2. `--stdin` (vs. `--value=`): `--value=` exposed every +/// config entry's bytes in `ps`/`/proc//cmdline` listings +/// AND was bounded by the host's `ARG_MAX` (4 KiB to 256 KiB +/// depending on platform — easy to trip with a JSON blob). +/// `--stdin` reads the value from stdin instead — keeps value +/// bytes out of argv and lifts the size cap to whatever the OS +/// pipe buffer + the CLI's read accept (megabytes in practice). +fn create_config_store_entry(store_id: &str, key: &str, value: &str) -> Result<(), String> { + let store_arg = format!("--store-id={store_id}"); + let key_arg = format!("--key={key}"); + let mut child = Command::new("fastly") + .args([ + "config-store-entry", + "update", + store_arg.as_str(), + key_arg.as_str(), + "--upsert", + "--stdin", + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|err| { + if err.kind() == ErrorKind::NotFound { + format!("`fastly` not found on PATH; {FASTLY_INSTALL_HINT}") + } else { + format!("failed to spawn `fastly`: {err}") + } + })?; + // Move stdin OUT of child via `take` so the ChildStdin drops at + // end of scope — that closes the pipe and lets the CLI see EOF. + // `child.wait_with_output()` then consumes child cleanly. + let mut stdin = child + .stdin + .take() + .ok_or_else(|| "failed to open stdin pipe to `fastly`".to_owned())?; + stdin + .write_all(value.as_bytes()) + .map_err(|err| format!("failed to write value to `fastly` stdin: {err}"))?; + drop(stdin); + let output = child + .wait_with_output() + .map_err(|err| format!("failed to wait on `fastly`: {err}"))?; + if output.status.success() { + return Ok(()); + } + Err(format!( + "`fastly config-store-entry update --store-id={store_id} --key={key} --upsert --stdin` exited with status {}\nstderr: {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + )) +} + +/// Parse `fastly config-store list --json` output and return the +/// platform `id` of the store whose `name` matches `name`. Accepts +/// both a bare array (`[ {"id": "...", "name": "..."}, ... ]`) +/// and an `{"items": [...]}` envelope so this stays compatible +/// across fastly CLI versions. +fn find_config_store_id(stdout: &str, name: &str) -> ConfigStoreLookup { + let parsed: serde_json::Value = match serde_json::from_str(stdout) { + Ok(value) => value, + Err(err) => { + return ConfigStoreLookup::SchemaDrift(format!("stdout did not parse as JSON: {err}")); + } + }; + let Some(array) = parsed + .as_array() + .or_else(|| parsed.get("items").and_then(serde_json::Value::as_array)) + else { + return ConfigStoreLookup::SchemaDrift(format!( + "expected a bare array `[...]` or an `{{\"items\": [...]}}` envelope; got JSON of shape `{}`", + shape_summary(&parsed) + )); + }; + let mut any_well_formed = false; + for entry in array { + let entry_name = entry.get("name").and_then(serde_json::Value::as_str); + let entry_id = entry.get("id").and_then(serde_json::Value::as_str); + if entry_name.is_some() && entry_id.is_some() { + any_well_formed = true; + } + if entry_name == Some(name) { + return entry_id.map_or_else( + || { + ConfigStoreLookup::SchemaDrift(format!( + "entry matched name `{name}` but is missing a string `id` field" + )) + }, + |id| ConfigStoreLookup::Found(id.to_owned()), + ); + } + } + if array.is_empty() || any_well_formed { + ConfigStoreLookup::NotFound + } else { + ConfigStoreLookup::SchemaDrift( + "no entry has both string `name` and `id` fields -- fastly CLI may have changed its output schema" + .to_owned(), + ) + } +} + +/// One-line type label for a `serde_json::Value` (for diagnostic +/// error messages — not a canonical JSON-schema description). +fn shape_summary(value: &serde_json::Value) -> &'static str { + match value { + serde_json::Value::Null => "null", + serde_json::Value::Bool(_) => "bool", + serde_json::Value::Number(_) => "number", + serde_json::Value::String(_) => "string", + serde_json::Value::Array(_) => "array", + serde_json::Value::Object(_) => "object", + } +} + +/// Resolve the platform config-store id on demand: shell out to +/// `fastly config-store list --json`, parse the JSON, match by +/// `name`. The provision flow doesn't persist this id, so push +/// has to re-fetch every time. +fn resolve_remote_config_store_id(name: &str) -> Result { + let output = Command::new("fastly") + .args(["config-store", "list", "--json"]) + .output() + .map_err(|err| { + if err.kind() == ErrorKind::NotFound { + format!("`fastly` not found on PATH; {FASTLY_INSTALL_HINT}") + } else { + format!("failed to spawn `fastly`: {err}") + } + })?; + if !output.status.success() { + return Err(format!( + "`fastly config-store list --json` exited with status {}\nstderr: {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + )); + } + let stdout = String::from_utf8_lossy(&output.stdout); + match find_config_store_id(&stdout, name) { + ConfigStoreLookup::Found(id) => Ok(id), + ConfigStoreLookup::NotFound => Err(format!( + "no fastly config-store matches `{name}` (did you run `edgezero provision --adapter fastly`?)" + )), + ConfigStoreLookup::SchemaDrift(detail) => Err(format!( + "could not parse `fastly config-store list --json` output: {detail}.\n The fastly CLI may have changed its JSON schema in a recent version. Please file a bug report at https://github.com/stackpop/edgezero/issues with the fastly CLI version (`fastly version`) and the raw stdout. Workaround: pin to a known-compatible fastly CLI version." + )), + } } /// # Errors @@ -311,6 +1036,16 @@ mod tests { use edgezero_adapter::cli_support::read_package_name; use tempfile::tempdir; + // Shared fixture names. Pinning these as consts (instead of + // inline `"sessions"` / `"app_config"` per call site) keeps the + // setup-vs-assertion pair in sync -- a typo in one place no + // longer silently divorces from the other, because both reference + // the same const. Also names the intent: these are the LOGICAL + // store ids the fastly adapter operates on, not arbitrary strings. + const TEST_KV_ID: &str = "sessions"; + const TEST_CONFIG_ID: &str = "app_config"; + const TEST_SECRET_ID: &str = "default"; + #[test] fn finds_closest_manifest_when_multiple_exist() { let dir = tempdir().unwrap(); @@ -373,4 +1108,698 @@ mod tests { let name = read_package_name(&manifest).unwrap(); assert_eq!(name, "demo"); } + + // ---------- push_entries_with_committer ---------- + + #[test] + fn push_entries_with_committer_returns_count_when_all_succeed() { + let entries = vec![ + ("a".to_owned(), "1".to_owned()), + ("b".to_owned(), "2".to_owned()), + ("c".to_owned(), "3".to_owned()), + ]; + let pushed = push_entries_with_committer(&entries, |_, _| Ok(())).expect("all succeed"); + assert_eq!(pushed, 3); + } + + #[test] + fn push_entries_with_committer_zero_entries_is_ok() { + let pushed = push_entries_with_committer(&[], |_, _| Ok(())).expect("empty is fine"); + assert_eq!(pushed, 0); + } + + #[test] + fn push_entries_with_committer_failure_surfaces_committed_failed_not_attempted() { + // Mock committer: succeed for first 2 keys, fail at third. + let entries = vec![ + ("k1".to_owned(), "v1".to_owned()), + ("k2".to_owned(), "v2".to_owned()), + ("k3".to_owned(), "v3".to_owned()), + ("k4".to_owned(), "v4".to_owned()), + ("k5".to_owned(), "v5".to_owned()), + ]; + let mut calls: usize = 0; + let err = push_entries_with_committer(&entries, |key, _| { + calls = calls.saturating_add(1); + if key == "k3" { + Err("simulated fastly stderr".to_owned()) + } else { + Ok(()) + } + }) + .expect_err("middle failure must error"); + // Committer was invoked for k1, k2, k3 and stopped. + assert_eq!(calls, 3_usize, "no retries beyond failure point"); + // Error names all three categories. + assert!(err.contains("k1") && err.contains("k2"), "committed: {err}"); + assert!( + err.contains("Failed: `k3`"), + "failed entry named exactly: {err}" + ); + assert!( + err.contains("k4") && err.contains("k5"), + "not-attempted: {err}" + ); + assert!(err.contains("simulated fastly stderr"), "inner err: {err}"); + // Counts are sane. + assert!( + err.contains("committing 2 of 5 entries"), + "committed/total count: {err}" + ); + } + + #[test] + fn push_entries_with_committer_first_entry_failure_reports_zero_committed() { + let entries = vec![ + ("only".to_owned(), "val".to_owned()), + ("never".to_owned(), "tried".to_owned()), + ]; + let err = push_entries_with_committer(&entries, |_, _| Err("nope".to_owned())) + .expect_err("first-entry failure"); + assert!(err.contains("committing 0 of 2"), "zero committed: {err}"); + assert!( + err.contains("Failed: `only`"), + "first-entry failure named: {err}" + ); + assert!( + err.contains("never"), + "second entry as not-attempted: {err}" + ); + } + + #[test] + fn push_entries_with_committer_last_entry_failure_reports_n_minus_one_committed() { + let entries = vec![ + ("a".to_owned(), "1".to_owned()), + ("b".to_owned(), "2".to_owned()), + ("c".to_owned(), "3".to_owned()), + ]; + let err = push_entries_with_committer(&entries, |key, _| { + if key == "c" { + Err("late failure".to_owned()) + } else { + Ok(()) + } + }) + .expect_err("last-entry failure"); + assert!(err.contains("committing 2 of 3"), "n-1 committed: {err}"); + assert!( + err.contains("the remaining 0 entries"), + "zero not-attempted when last fails: {err}" + ); + } + + // ---------- looks_like_already_exists ---------- + + #[test] + fn looks_like_already_exists_recognises_common_phrasings() { + // Real-shaped fastly CLI error strings (paraphrased; the + // CLI varies across versions). Each must be detected so + // create_fastly_store can treat it as idempotent success. + assert!(looks_like_already_exists( + "Error: a kv-store with that name already exists", + "kv", + )); + assert!(looks_like_already_exists( + "ERROR: Conflict (409): duplicate kv_store name", + "kv", + )); + assert!(looks_like_already_exists( + "A config-store with this name already exists", + "config", + )); + // Spaced form: some fastly CLI versions emit prose + // ("kv store"); accept it alongside the punctuated forms. + assert!(looks_like_already_exists( + "Error: kv store conflict: name already in use", + "kv", + )); + } + + #[test] + fn looks_like_already_exists_rejects_unrelated_errors() { + assert!(!looks_like_already_exists( + "Error: unauthenticated; run `fastly profile create`", + "kv", + )); + assert!(!looks_like_already_exists( + "Error: network unreachable", + "kv", + )); + assert!(!looks_like_already_exists("", "kv")); + } + + #[test] + fn looks_like_already_exists_rejects_unrelated_conflict_errors() { + // The earlier wider heuristic swallowed ANY stderr + // containing "conflict" or "already exists", which would + // misread an unrelated 409 from a different fastly + // subcommand (e.g. a service-version conflict during a + // parallel deploy) as idempotent store-create success. + // Now we require the kind context too, so unrelated + // conflicts surface as failures. + assert!( + !looks_like_already_exists( + "Error: 409 Conflict on /service/abc/version/42 -- already exists", + "kv", + ), + "service-version conflict must NOT be misread as kv-store idempotency" + ); + assert!( + !looks_like_already_exists( + "Error: invalid duplicate request; check name resolution", + "kv", + ), + "unrelated `duplicate ... name` AND-match must NOT trigger" + ); + // And the kind must match: a config-store conflict must + // not look-like-already-exists for a kv-store create call. + assert!( + !looks_like_already_exists("Error: a config-store with that name already exists", "kv",), + "wrong-kind conflict must NOT trigger" + ); + } + + // ---------- setup_block_present ---------- + + #[test] + fn setup_block_present_true_when_table_exists() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write( + &path, + "name = \"demo\"\n[setup.kv_stores.sessions]\n[local_server.kv_stores.sessions]\n", + ) + .expect("write"); + assert!(setup_block_present(&path, "kv", TEST_KV_ID).expect("probe")); + } + + #[test] + fn setup_block_present_false_when_id_missing() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write(&path, "name = \"demo\"\n[setup.kv_stores.other]\n").expect("write"); + assert!(!setup_block_present(&path, "kv", TEST_KV_ID).expect("probe")); + } + + #[test] + fn setup_block_present_false_for_missing_file() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("does-not-exist.toml"); + assert!(!setup_block_present(&path, "kv", TEST_KV_ID).expect("probe")); + } + + #[test] + fn setup_block_present_true_when_only_setup_exists() { + // Post-F6 (PR #269 round 2): `setup_block_present` only + // checks `[setup._stores.]`. The pre-fix check + // ALSO required `[local_server._stores.]`, but + // writing an empty `[local_server.*]` table didn't match + // fastly's local-server schema (config-stores need + // `format` + contents, kv/secret stores need a JSON file + // or `{key, data}` entries). Local-server seeding moved + // to `config push --adapter fastly --local`, so probe + // only cares about `[setup]` now. + let dir = tempdir().expect("tempdir"); + let only_setup = dir.path().join("only_setup.toml"); + fs::write(&only_setup, "name = \"demo\"\n[setup.kv_stores.sessions]\n").expect("write"); + assert!( + setup_block_present(&only_setup, "kv", TEST_KV_ID).expect("probe"), + "[setup.*] alone is now sufficient: {only_setup:?}" + ); + + let only_local = dir.path().join("only_local.toml"); + fs::write( + &only_local, + "name = \"demo\"\n[local_server.kv_stores.sessions]\n", + ) + .expect("write"); + assert!( + !setup_block_present(&only_local, "kv", TEST_KV_ID).expect("probe"), + "[local_server.*] alone is NOT a provisioned-setup signal" + ); + } + + // ---------- append_fastly_setup ---------- + + #[test] + fn append_fastly_setup_creates_setup_table_in_minimal_file() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write(&path, "name = \"demo\"\n").expect("write"); + append_fastly_setup(&path, "kv", TEST_KV_ID).expect("append"); + let after = fs::read_to_string(&path).expect("read back"); + assert!( + after.contains("[setup.kv_stores.sessions]"), + "setup table added: {after}" + ); + // Post-F6: no `[local_server.*]` write — that empty stanza + // didn't satisfy fastly's local-server schema and made + // `fastly compute serve` error or skip the store. Local- + // server seeding is now `config push --adapter fastly + // --local`'s job. + assert!( + !after.contains("[local_server.kv_stores.sessions]"), + "[local_server.*] empty table no longer written by provision: {after}" + ); + assert!( + after.contains("name = \"demo\""), + "preserved original keys: {after}" + ); + } + + #[test] + fn append_fastly_setup_appends_alongside_existing_kind_tables() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write(&path, "[setup.kv_stores.cache]\n").expect("write"); + append_fastly_setup(&path, "kv", TEST_KV_ID).expect("append"); + let after = fs::read_to_string(&path).expect("read back"); + assert!( + after.contains("[setup.kv_stores.cache]"), + "existing entry kept: {after}" + ); + assert!( + after.contains("[setup.kv_stores.sessions]"), + "new entry added: {after}" + ); + } + + #[test] + fn append_fastly_setup_is_idempotent_on_duplicate_id() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write(&path, "[setup.kv_stores.sessions]\nfoo = \"keep\"\n").expect("write"); + append_fastly_setup(&path, "kv", TEST_KV_ID).expect("idempotent append"); + let after = fs::read_to_string(&path).expect("read back"); + assert!( + after.contains("foo = \"keep\""), + "did not stomp existing key: {after}" + ); + } + + #[test] + fn append_fastly_setup_creates_file_when_missing() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + // Note: no fs::write — file starts absent. + append_fastly_setup(&path, "config", TEST_CONFIG_ID).expect("create"); + let after = fs::read_to_string(&path).expect("read back"); + assert!(after.contains("[setup.config_stores.app_config]")); + assert!( + !after.contains("[local_server.config_stores.app_config]"), + "[local_server.*] no longer written by provision: {after}" + ); + } + + #[test] + fn append_fastly_setup_preserves_top_comments() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write( + &path, + "# managed by hand -- please keep this line\nname = \"demo\"\n", + ) + .expect("write"); + append_fastly_setup(&path, "secret", TEST_SECRET_ID).expect("append"); + let after = fs::read_to_string(&path).expect("read back"); + assert!( + after.contains("# managed by hand"), + "preserved comment: {after}" + ); + } + + // ---------- write_fastly_local_config_store (config push --local) ---------- + + #[test] + fn write_fastly_local_config_store_creates_inline_block_in_minimal_file() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write(&path, "name = \"demo\"\n").expect("write"); + let entries = vec![ + ("greeting".to_owned(), "hello".to_owned()), + ("service.timeout_ms".to_owned(), "1500".to_owned()), + ]; + write_fastly_local_config_store(&path, TEST_CONFIG_ID, &entries).expect("write"); + let after = fs::read_to_string(&path).expect("read back"); + assert!( + after.contains(&format!("[local_server.config_stores.{TEST_CONFIG_ID}]")), + "store table: {after}" + ); + assert!( + after.contains("format = \"inline-toml\""), + "format field: {after}" + ); + assert!( + after.contains(&format!( + "[local_server.config_stores.{TEST_CONFIG_ID}.contents]" + )), + "contents table: {after}" + ); + assert!(after.contains("greeting = \"hello\""), "key 1: {after}"); + assert!( + after.contains("\"service.timeout_ms\" = \"1500\""), + "dotted key quoted: {after}" + ); + assert!(after.contains("name = \"demo\""), "preserved: {after}"); + } + + #[test] + fn write_fastly_local_config_store_replaces_existing_block_on_re_push() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write(&path, "name = \"demo\"\n").expect("write"); + write_fastly_local_config_store( + &path, + TEST_CONFIG_ID, + &[("greeting".to_owned(), "stale".to_owned())], + ) + .expect("first write"); + write_fastly_local_config_store( + &path, + TEST_CONFIG_ID, + &[("greeting".to_owned(), "fresh".to_owned())], + ) + .expect("second write"); + let after = fs::read_to_string(&path).expect("read back"); + assert!(after.contains("greeting = \"fresh\""), "new value: {after}"); + assert!( + !after.contains("greeting = \"stale\""), + "stale value dropped: {after}" + ); + } + + #[test] + fn write_fastly_local_config_store_preserves_unrelated_blocks() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + let original = "\ +[setup.kv_stores.sessions] + +[[local_server.kv_stores.sessions]] +key = \"__init__\" +data = \"\" + +[scripts] +build = \"cargo build --release\" +"; + fs::write(&path, original).expect("write"); + write_fastly_local_config_store( + &path, + TEST_CONFIG_ID, + &[("greeting".to_owned(), "hi".to_owned())], + ) + .expect("write"); + let after = fs::read_to_string(&path).expect("read back"); + assert!( + after.contains("[setup.kv_stores.sessions]"), + "setup KV kept: {after}" + ); + assert!(after.contains("[scripts]"), "scripts table kept: {after}"); + assert!( + after.contains("build = \"cargo build --release\""), + "scripts value kept: {after}" + ); + assert!( + after.contains(&format!( + "[local_server.config_stores.{TEST_CONFIG_ID}.contents]" + )), + "new config_stores block added: {after}" + ); + } + + #[test] + fn write_fastly_local_config_store_creates_file_when_missing() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + // No fs::write — file absent. + write_fastly_local_config_store( + &path, + TEST_CONFIG_ID, + &[("greeting".to_owned(), "hi".to_owned())], + ) + .expect("write"); + let after = fs::read_to_string(&path).expect("read back"); + assert!(after.contains(&format!( + "[local_server.config_stores.{TEST_CONFIG_ID}.contents]" + ))); + assert!(after.contains("greeting = \"hi\"")); + } + + // ---------- provision (dry-run + error path) ---------- + + #[test] + fn provision_dry_run_does_not_invoke_fastly() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write(&path, "name = \"demo\"\n").expect("write"); + let kv_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_KV_ID]); + let config_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_CONFIG_ID]); + let secret_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_SECRET_ID]); + let stores = ProvisionStores { + config: &config_ids, + kv: &kv_ids, + secrets: &secret_ids, + }; + let out = FastlyCliAdapter + .provision(dir.path(), Some("fastly.toml"), None, &stores, true) + .expect("dry-run succeeds"); + // 1 KV + 1 config + 1 secret = 3 status lines. + assert_eq!(out.len(), 3); + assert!(out[0].contains("would run `fastly kv-store create --name=sessions`")); + assert!(out[1].contains("would run `fastly config-store create --name=app_config`")); + assert!(out[2].contains("would run `fastly secret-store create --name=default`")); + // Manifest untouched. + let after = fs::read_to_string(&path).expect("read"); + assert_eq!(after, "name = \"demo\"\n", "dry-run mutated fastly.toml"); + } + + #[test] + fn provision_errors_when_adapter_manifest_path_missing() { + let dir = tempdir().expect("tempdir"); + let kv_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_KV_ID]); + let stores = ProvisionStores { + config: &[], + kv: &kv_ids, + secrets: &[], + }; + let err = FastlyCliAdapter + .provision(dir.path(), None, None, &stores, true) + .expect_err("missing adapter manifest path must error"); + assert!( + err.contains("fastly.toml"), + "error names what's missing: {err}" + ); + } + + #[test] + fn provision_with_no_declared_stores_says_so() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write(&path, "name = \"demo\"\n").expect("write"); + let stores = ProvisionStores { + config: &[], + kv: &[], + secrets: &[], + }; + let out = FastlyCliAdapter + .provision(dir.path(), Some("fastly.toml"), None, &stores, false) + .expect("no-store provision is fine"); + assert_eq!(out, vec!["fastly has no declared stores to provision"]); + } + + #[test] + fn provision_skips_id_when_setup_block_already_present() { + // setup_block_present's role in the flow: re-running + // provision after the user already declared a store in + // fastly.toml must be a no-op (no shell-out to fastly). + // We can verify this in a real (non-dry-run) call because + // the skip path bypasses create_fastly_store entirely. + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write( + &path, + "[setup.kv_stores.sessions]\n[local_server.kv_stores.sessions]\n", + ) + .expect("write"); + let kv_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_KV_ID]); + let stores = ProvisionStores { + config: &[], + kv: &kv_ids, + secrets: &[], + }; + let out = FastlyCliAdapter + .provision(dir.path(), Some("fastly.toml"), None, &stores, false) + .expect("skip path succeeds without invoking fastly"); + assert_eq!(out.len(), 1); + assert!(out[0].contains("already declared"), "got: {out:?}"); + } + + // ---------- find_config_store_id ---------- + + #[test] + fn find_config_store_id_matches_bare_array_by_name() { + let stdout = format!( + r#"[ + {{"id": "abc123", "name": "{TEST_CONFIG_ID}"}}, + {{"id": "def456", "name": "other_store"}} + ]"# + ); + match find_config_store_id(&stdout, TEST_CONFIG_ID) { + ConfigStoreLookup::Found(id) => assert_eq!(id, "abc123"), + ConfigStoreLookup::NotFound => panic!("expected Found, got NotFound"), + ConfigStoreLookup::SchemaDrift(detail) => { + panic!("expected Found, got SchemaDrift({detail})") + } + } + } + + #[test] + fn find_config_store_id_tolerates_items_envelope() { + let stdout = format!( + r#"{{"items": [ + {{"id": "xyz789", "name": "{TEST_CONFIG_ID}"}} + ]}}"# + ); + match find_config_store_id(&stdout, TEST_CONFIG_ID) { + ConfigStoreLookup::Found(id) => assert_eq!(id, "xyz789"), + ConfigStoreLookup::NotFound => panic!("expected Found, got NotFound"), + ConfigStoreLookup::SchemaDrift(detail) => { + panic!("expected Found, got SchemaDrift({detail})") + } + } + } + + #[test] + fn find_config_store_id_distinguishes_not_found_from_match_failure() { + // JSON parses cleanly, entries are well-formed + // (`name` + `id` strings present), but no entry matches + // → NotFound. Operator likely needs to run `provision`. + let stdout = r#"[{"id": "abc", "name": "other"}]"#; + assert!(matches!( + find_config_store_id(stdout, "missing"), + ConfigStoreLookup::NotFound + )); + } + + #[test] + fn find_config_store_id_flags_schema_drift_on_malformed_json() { + // Unparseable bytes are NOT a "store not found" — they're + // a "fastly CLI output format changed" signal. Operator + // needs different recovery (file a bug, pin CLI version) + // than for the "store doesn't exist yet" case. + let drift = find_config_store_id("not json", "anything"); + assert!( + matches!(drift, ConfigStoreLookup::SchemaDrift(_)), + "non-JSON stdout must be schema drift, got {drift:?}" + ); + let empty = find_config_store_id("", "anything"); + assert!( + matches!(empty, ConfigStoreLookup::SchemaDrift(_)), + "empty stdout must be schema drift, got {empty:?}" + ); + } + + #[test] + fn find_config_store_id_flags_schema_drift_when_shape_unexpected() { + // JSON parses but the top-level is neither a bare array + // nor an `{items: [...]}` envelope. + let stdout = r#"{"namespace": "fastly", "list": []}"#; + match find_config_store_id(stdout, "any") { + ConfigStoreLookup::SchemaDrift(detail) => { + assert!( + detail.contains("bare array") || detail.contains("items"), + "schema-drift detail names the expected shapes: {detail}" + ); + } + ConfigStoreLookup::Found(id) => panic!("expected SchemaDrift, got Found({id})"), + ConfigStoreLookup::NotFound => panic!("expected SchemaDrift, got NotFound"), + } + } + + #[test] + fn find_config_store_id_flags_schema_drift_when_entries_lack_name_id() { + // Array of objects but none have BOTH string `name` and + // string `id` fields — suggests schema rename (e.g. + // fastly renamed `name` → `title`). + let stdout = format!(r#"[{{"title": "{TEST_CONFIG_ID}", "uid": "abc"}}]"#); + let drift = find_config_store_id(&stdout, TEST_CONFIG_ID); + assert!( + matches!(drift, ConfigStoreLookup::SchemaDrift(_)), + "entries lacking name/id must be schema drift, got {drift:?}" + ); + } + + #[test] + fn find_config_store_id_returns_not_found_for_empty_array() { + // Empty array IS a valid "store doesn't exist yet" signal, + // not schema drift — fastly CLI legitimately returns `[]` + // when no config-stores exist. + let drift = find_config_store_id("[]", "any"); + assert!( + matches!(drift, ConfigStoreLookup::NotFound), + "empty array must be NotFound, got {drift:?}" + ); + } + + // ---------- push_config_entries (dry-run + error paths) ---------- + + #[test] + fn push_dry_run_does_not_invoke_fastly() { + let dir = tempdir().expect("tempdir"); + let entries = vec![ + ("greeting".to_owned(), "hello".to_owned()), + ("feature.new_checkout".to_owned(), "false".to_owned()), + ]; + let out = FastlyCliAdapter + .push_config_entries( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &entries, + &AdapterPushContext::new(), + true, + ) + .expect("dry-run succeeds"); + // First line names the resolve+publish flow; subsequent lines preview + // each key the push would create (so callers can eyeball the keyset + // before running for real). + assert_eq!(out.len(), 1 + entries.len(), "header + per-entry preview"); + assert!( + out[0].contains("would resolve fastly config-store `app_config`") + && out[0].contains("config-store-entry create"), + "dry-run header describes the would-be flow: {out:?}" + ); + assert!( + out.iter().any(|line| line.contains("`greeting`")), + "dry-run lists `greeting`: {out:?}" + ); + assert!( + out.iter() + .any(|line| line.contains("`feature.new_checkout`")), + "dry-run lists `feature.new_checkout`: {out:?}" + ); + } + + #[test] + fn push_with_no_entries_reports_no_op_without_invoking_fastly() { + let dir = tempdir().expect("tempdir"); + let out = FastlyCliAdapter + .push_config_entries( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &[], + &AdapterPushContext::new(), + false, + ) + .expect("zero-entry push is fine"); + assert_eq!(out.len(), 1); + assert!( + out[0].contains("no config entries"), + "status line names the no-op: {out:?}" + ); + } } diff --git a/crates/edgezero-adapter-fastly/src/config_store.rs b/crates/edgezero-adapter-fastly/src/config_store.rs index e6834f97..4723cb77 100644 --- a/crates/edgezero-adapter-fastly/src/config_store.rs +++ b/crates/edgezero-adapter-fastly/src/config_store.rs @@ -3,6 +3,7 @@ #[cfg(test)] use std::collections::HashMap; +use async_trait::async_trait; use edgezero_core::config_store::{ConfigStore, ConfigStoreError}; use fastly::config_store::{LookupError, OpenError}; use fastly::ConfigStore as FastlyConfigStoreInner; @@ -40,9 +41,10 @@ impl FastlyConfigStore { } } +#[async_trait(?Send)] impl ConfigStore for FastlyConfigStore { #[inline] - fn get(&self, key: &str) -> Result, ConfigStoreError> { + async fn get(&self, key: &str) -> Result, ConfigStoreError> { match &self.inner { FastlyConfigStoreBackend::Fastly(inner) => { inner.try_get(key).map_err(|err| map_lookup_error(&err)) diff --git a/crates/edgezero-adapter-fastly/src/lib.rs b/crates/edgezero-adapter-fastly/src/lib.rs index af75f0c3..9c28730a 100644 --- a/crates/edgezero-adapter-fastly/src/lib.rs +++ b/crates/edgezero-adapter-fastly/src/lib.rs @@ -20,30 +20,11 @@ pub mod response; pub mod secret_store; #[cfg(feature = "fastly")] -use edgezero_core::app::{App, Hooks, FASTLY_ADAPTER}; +use edgezero_core::app::Hooks; #[cfg(feature = "fastly")] -use edgezero_core::manifest::{ManifestLoader, ResolvedLoggingConfig}; +use edgezero_core::env_config::EnvConfig; #[cfg(feature = "fastly")] -use request::DEFAULT_KV_STORE_NAME; - -#[cfg(feature = "fastly")] -pub trait AppExt { - #[deprecated( - note = "AppExt::dispatch() is the low-level manual path and does not inject config-store metadata; prefer run_app(), dispatch_with_config(), or dispatch_with_config_handle()" - )] - /// # Errors - /// Returns an error if the underlying handler returns an error or the response cannot be converted into a Fastly response. - fn dispatch(&self, req: fastly::Request) -> Result; -} - -#[cfg(feature = "fastly")] -impl AppExt for App { - #[inline] - fn dispatch(&self, req: fastly::Request) -> Result { - request::dispatch_raw(self, req) - } -} - +use edgezero_core::manifest::ResolvedLoggingConfig; #[cfg(feature = "fastly")] #[derive(Debug, Clone)] pub struct FastlyLogging { @@ -66,17 +47,6 @@ impl From for FastlyLogging { } } -/// Whether each optional store is required to be present at startup. -/// -/// Using a named struct instead of positional `bool` arguments prevents -/// accidental parameter swaps between `kv_required` and `secrets_required`. -#[cfg(feature = "fastly")] -#[derive(Default)] -struct StoreRequirements { - kv_required: bool, - secrets_required: bool, -} - /// # Errors /// Returns [`logger::InitLoggerError::Build`] if the underlying logger /// builder rejects its inputs (e.g. an empty endpoint), or @@ -104,52 +74,57 @@ pub fn init_logger( Ok(()) } +/// Resolve [`FastlyLogging`] from `EDGEZERO__LOGGING__LEVEL`, falling back to +/// the adapter default when the variable is unset or unparseable. +#[cfg(feature = "fastly")] +fn logging_from_env(env: &EnvConfig) -> FastlyLogging { + use std::str::FromStr as _; + + let level = env + .logging_level() + .and_then(|raw| log::LevelFilter::from_str(raw).ok()) + .unwrap_or(log::LevelFilter::Info); + // Only attach Fastly's named-endpoint logger when `EDGEZERO__LOGGING__ENDPOINT` + // is set. Production deployments set it to a real `[log_endpoints]` entry from + // `fastly.toml`; local Viceroy runs leave it unset and avoid the + // "endpoint not found, or is reserved" error that fires when the adapter + // would otherwise fall back to a reserved name like `stdout`. + let endpoint = env.logging_endpoint().map(str::to_owned); + let use_fastly_logger = endpoint.is_some(); + FastlyLogging { + echo_stdout: true, + endpoint, + level, + use_fastly_logger, + } +} + /// Entry point for a Fastly Compute application. /// -/// **Breaking change (pre-1.0):** `manifest_src` is now a required parameter. +/// Portable store config is baked into `A` by the `app!` macro; adapter-specific +/// values (platform store names, logging level) are read at runtime from +/// `EDGEZERO__*` environment variables. No `edgezero.toml` is required. /// /// # Errors -/// Returns an error if the manifest is invalid or any required store cannot be opened. +/// Returns an error if logger setup fails or any required store cannot be opened. #[cfg(feature = "fastly")] #[inline] -pub fn run_app( - manifest_src: &str, - req: fastly::Request, -) -> Result { - let manifest_loader = ManifestLoader::try_load_from_str(manifest_src) - .map_err(|err| fastly::Error::msg(err.to_string()))?; - let manifest = manifest_loader.manifest(); - let resolved_logging = manifest.logging_or_default(FASTLY_ADAPTER); - // Two-path resolution: `A::config_store()` is set at compile time by the - // `#[app]` macro and is the common case. The manifest fallback handles - // callers that implement `Hooks` manually without the macro — in that case - // `A::config_store()` returns `None` while `[stores.config]` in - // `edgezero.toml` may still be present. - let config_name = A::config_store() - .map(|cfg| cfg.name_for_adapter(FASTLY_ADAPTER).to_owned()) - .or_else(|| { - manifest - .stores - .config - .as_ref() - .map(|cfg| cfg.config_store_name(FASTLY_ADAPTER).to_owned()) - }); - let kv_name = manifest.kv_store_name(FASTLY_ADAPTER).to_owned(); - let requirements = StoreRequirements { - kv_required: manifest.stores.kv.is_some(), - secrets_required: manifest.secret_store_enabled("fastly"), - }; - let logging: FastlyLogging = resolved_logging.into(); - run_app_with_stores::( - &logging, - req, - config_name.as_deref(), - &kv_name, - &requirements, - ) +pub fn run_app(req: fastly::Request) -> Result { + let env = EnvConfig::from_env(); + let stores = A::stores(); + let logging = logging_from_env(&env); + if logging.use_fastly_logger { + let endpoint = logging.endpoint.as_deref().unwrap_or("stdout"); + init_logger(endpoint, logging.level, logging.echo_stdout)?; + } + let app = A::build_app(); + request::dispatch_with_registries(&app, req, stores.config, stores.kv, stores.secrets, &env) } -/// Dispatch with a config store. Prefer this over `run_app_with_logging` for new code. +/// Dispatch with a config store wired explicitly. Use `run_app` for +/// the manifest-driven flow that resolves stores automatically. KV +/// is NOT auto-injected on this path; chain `.with_kv(name)` on a +/// `FastlyService` builder if you need KV alongside the config store. /// /// # Errors /// Returns an error if logger setup fails or the underlying handler returns an error. @@ -159,57 +134,17 @@ pub fn run_app_with_config( logging: &FastlyLogging, req: fastly::Request, config_store_name: Option<&str>, -) -> Result { - run_app_with_stores::( - logging, - req, - config_store_name, - DEFAULT_KV_STORE_NAME, - &StoreRequirements::default(), - ) -} - -/// Compatibility wrapper for callers that do not use a config store. -/// -/// # Errors -/// Returns an error if logger setup fails or the underlying handler returns an error. -#[cfg(feature = "fastly")] -#[inline] -pub fn run_app_with_logging( - logging: &FastlyLogging, - req: fastly::Request, -) -> Result { - run_app_with_stores::( - logging, - req, - None, - DEFAULT_KV_STORE_NAME, - &StoreRequirements::default(), - ) -} - -#[cfg(feature = "fastly")] -fn run_app_with_stores( - logging: &FastlyLogging, - req: fastly::Request, - config_store_name: Option<&str>, - kv_store_name: &str, - requirements: &StoreRequirements, ) -> Result { if logging.use_fastly_logger { let endpoint = logging.endpoint.as_deref().unwrap_or("stdout"); init_logger(endpoint, logging.level, logging.echo_stdout)?; } - let app = A::build_app(); - request::dispatch_with_store_names( - &app, - req, - config_store_name, - kv_store_name, - requirements.kv_required, - requirements.secrets_required, - ) + let mut service = request::FastlyService::new(&app); + if let Some(name) = config_store_name { + service = service.with_config(name); + } + service.dispatch(req) } #[cfg(test)] diff --git a/crates/edgezero-adapter-fastly/src/request.rs b/crates/edgezero-adapter-fastly/src/request.rs index ce353283..c76cd0f5 100644 --- a/crates/edgezero-adapter-fastly/src/request.rs +++ b/crates/edgezero-adapter-fastly/src/request.rs @@ -3,17 +3,21 @@ use std::fmt::Display; use std::io::Read as _; use std::sync::{Arc, Mutex, OnceLock, PoisonError}; -use edgezero_core::app::App; +use edgezero_core::app::{App, StoreMetadata}; use edgezero_core::body::Body; use edgezero_core::config_store::ConfigStoreHandle; +use edgezero_core::env_config::EnvConfig; use edgezero_core::error::EdgeError; use edgezero_core::http::{request_builder, Request}; use edgezero_core::key_value_store::KvHandle; -use edgezero_core::manifest::DEFAULT_KV_STORE_NAME as CORE_DEFAULT_KV_STORE_NAME; use edgezero_core::proxy::ProxyHandle; use edgezero_core::secret_store::SecretHandle; +use edgezero_core::store_registry::{ + BoundSecretStore, ConfigRegistry, KvRegistry, SecretRegistry, StoreRegistry, +}; use fastly::{Error as FastlyError, Request as FastlyRequest, Response as FastlyResponse}; use futures::executor; +use std::collections::BTreeMap; use crate::config_store::FastlyConfigStore; use crate::context::FastlyRequestContext; @@ -22,12 +26,6 @@ use crate::proxy::FastlyProxyClient; use crate::response::{from_core_response, parse_uri}; use crate::secret_store::FastlySecretStore; -/// Default Fastly KV Store name. -/// -/// If a KV Store with this name exists in your Fastly service, it will -/// be automatically available to handlers via the `Kv` extractor. -pub const DEFAULT_KV_STORE_NAME: &str = CORE_DEFAULT_KV_STORE_NAME; - const WARNED_STORE_CACHE_LIMIT: usize = 64; #[derive(Default)] @@ -61,25 +59,193 @@ impl RecentStringSet { /// ``` #[derive(Default)] struct Stores { + config_registry: Option, config_store: Option, kv: Option, + kv_registry: Option, + secret_registry: Option, secrets: Option, } -/// Low-level manual dispatch. +enum ConfigSource { + Handle(ConfigStoreHandle), + Name(String), + None, +} + +/// Fastly per-request dispatch service. /// -/// This path does not resolve or inject config-store metadata from a manifest. -/// Prefer `run_app` or `dispatch_with_config` for normal config-store-aware -/// dispatch. Use `dispatch_with_config_handle` only when you already have a -/// prepared `ConfigStoreHandle`. -#[deprecated( - note = "dispatch() is the low-level manual path and does not inject config-store metadata; prefer run_app(), dispatch_with_config(), or dispatch_with_config_handle()" -)] -/// # Errors -/// Returns an error if request conversion fails or the underlying handler returns an error. -#[inline] -pub fn dispatch(app: &App, req: FastlyRequest) -> Result { - dispatch_raw(app, req) +/// Builds a router invocation with the stores the operator wants +/// injected into request extensions, then dispatches one request +/// against the wrapped `App`. The store wiring is a per-Service +/// decision; on Fastly Compute that means per-request (the worker +/// model invokes the entrypoint per HTTP request), but the Service +/// type itself is cheap to build. +/// +/// Replaces the prior `dispatch_with_*` variant fan-out. Each +/// builder method is independent: enable any combination of KV, +/// config, and secret stores by chaining the relevant `with_*` / +/// `require_*` calls. The manifest-driven `run_app` is still the +/// recommended entrypoint for normal flows -- the Service builder +/// is for manual / no-manifest deployments. +/// +/// ```rust,ignore +/// FastlyService::new(&app) +/// .with_kv("sessions").require_kv() +/// .with_config("app_config") +/// .with_secrets() +/// .dispatch(req) +/// ``` +pub struct FastlyService<'app> { + app: &'app App, + config: ConfigSource, + kv: Option, + secrets: SecretSource, +} + +struct KvSource { + name: String, + required: bool, +} + +enum SecretSource { + Off, + On { required: bool }, +} + +impl<'app> FastlyService<'app> { + /// Resolve every wired store at request time and dispatch + /// against the wrapped `App`. Consumes the service so a builder + /// can't be reused with stale wiring. + /// + /// # Errors + /// Returns an error if a required store cannot be opened or + /// the underlying handler returns an error. + #[inline] + pub fn dispatch(self, req: FastlyRequest) -> Result { + let config_store = match self.config { + ConfigSource::Handle(handle) => Some(handle), + ConfigSource::Name(name) => match FastlyConfigStore::try_open(&name) { + Ok(store) => Some(ConfigStoreHandle::new(Arc::new(store))), + Err(err) => { + warn_missing_store_once(&name, &err.to_string()); + None + } + }, + ConfigSource::None => None, + }; + let kv = match self.kv { + Some(source) => resolve_kv_handle(&source.name, source.required)?, + None => None, + }; + let secrets = match self.secrets { + SecretSource::Off => None, + SecretSource::On { required } => Some(resolve_secret_handle(required)), + }; + dispatch_with_handles( + self.app, + req, + Stores { + config_store, + kv, + secrets, + ..Default::default() + }, + ) + } + + /// Build a new service that dispatches against `app` with NO + /// stores wired. Chain `.with_*` / `.require_*` to add stores. + #[must_use] + #[inline] + pub fn new(app: &'app App) -> Self { + Self { + app, + config: ConfigSource::None, + kv: None, + secrets: SecretSource::Off, + } + } + + /// Promote the previously-wired KV store to required: an + /// unavailable store causes dispatch to return an error + /// instead of silently degrading. No-op when `with_kv` wasn't + /// called. + #[must_use] + #[inline] + pub fn require_kv(mut self) -> Self { + if let Some(kv) = self.kv.as_mut() { + kv.required = true; + } + self + } + + /// Promote the previously-wired secret store to required. + /// No-op when `with_secrets` wasn't called. + #[must_use] + #[inline] + pub fn require_secrets(mut self) -> Self { + if let SecretSource::On { ref mut required } = self.secrets { + *required = true; + } + self + } + + /// Open the Fastly Config Store named `name` and inject its + /// handle into request extensions. If the store is unavailable + /// at request time, the dispatcher logs the warning once and + /// proceeds without it. + #[must_use] + #[inline] + pub fn with_config>(mut self, name: S) -> Self { + self.config = ConfigSource::Name(name.into()); + self + } + + /// Inject a pre-built `ConfigStoreHandle`. Use this when the + /// caller has already opened (or mocked) the backend. Mutually + /// exclusive with `with_config(name)` -- the last call wins. + #[must_use] + #[inline] + pub fn with_config_handle(mut self, handle: ConfigStoreHandle) -> Self { + self.config = ConfigSource::Handle(handle); + self + } + + /// Open a Fastly KV Store by `name` and inject its handle. + /// Non-required by default: an absent store logs once and + /// dispatch continues. Pair with `require_kv()` when the + /// manifest declares `[stores.kv]` and a missing store should + /// fail loudly. + #[must_use] + #[inline] + pub fn with_kv>(mut self, name: S) -> Self { + self.kv = Some(KvSource { + name: name.into(), + required: false, + }); + self + } + + /// Enable the Fastly Secret Store and inject its handle. + /// Non-required by default: an absent store leaves no secret + /// handle in extensions and dispatch continues. Pair with + /// `require_secrets()` when the manifest declares + /// `[stores.secrets]`. + /// + /// Platform-name binding: the synthesised `SecretRegistry` + /// binds the handle to platform store name `"default"`. + /// Handlers reading `ctx.secret_store_default()?.require_str(key)` + /// open a Fastly Secret Store literally named `"default"`. Use + /// the manifest-aware `run_app` if your account uses a + /// different store name -- it routes through the env-overlay + /// resolution path instead. + #[must_use] + #[inline] + pub fn with_secrets(mut self) -> Self { + self.secrets = SecretSource::On { required: false }; + self + } } fn dispatch_core_request( @@ -87,89 +253,29 @@ fn dispatch_core_request( mut core_request: Request, stores: Stores, ) -> Result { - if let Some(handle) = stores.config_store { - core_request.extensions_mut().insert(handle); + // Hard-cutoff: legacy bare handles are no longer + // inserted into request extensions. `with_config_handle` + // still accepts a `ConfigStoreHandle`, but the dispatcher + // synthesises a one-id `Registry` from any wired handle + // and only the registry goes into extensions. The + // `ctx.{config,kv,secret}_handle()` accessors are gone; handlers + // use `ctx.{config,kv,secret}_store_default()` or the + // `Kv` / `Config` / `Secrets` extractors. + let (config_registry, kv_registry, secret_registry) = synthesise_store_registries(stores); + if let Some(registry) = config_registry { + core_request.extensions_mut().insert(registry); } - if let Some(handle) = stores.kv { - core_request.extensions_mut().insert(handle); + if let Some(registry) = kv_registry { + core_request.extensions_mut().insert(registry); } - if let Some(handle) = stores.secrets { - core_request.extensions_mut().insert(handle); + if let Some(registry) = secret_registry { + core_request.extensions_mut().insert(registry); } let response = executor::block_on(app.router().oneshot(core_request)) .map_err(|err| map_edge_error(&err))?; from_core_response(response).map_err(|err| map_edge_error(&err)) } -pub(crate) fn dispatch_raw(app: &App, req: FastlyRequest) -> Result { - dispatch_with_kv(app, req, DEFAULT_KV_STORE_NAME, false) -} - -/// Dispatch a request with a Fastly Config Store injected into extensions. -/// -/// If the named store is not available, suppresses repeated warnings for -/// recently seen store names and dispatches without it. -/// -/// The KV store named [`DEFAULT_KV_STORE_NAME`] is also resolved and injected -/// (non-required: unavailable stores are silently skipped). -/// -/// # Errors -/// Missing or unreadable config stores are logged and skipped — not -/// surfaced as errors. Returns an error only if request conversion, KV -/// resolution, handler dispatch, or response conversion fails. -#[inline] -pub fn dispatch_with_config( - app: &App, - req: FastlyRequest, - store_name: &str, -) -> Result { - let config_store_handle = match FastlyConfigStore::try_open(store_name) { - Ok(store) => Some(ConfigStoreHandle::new(Arc::new(store))), - Err(err) => { - warn_missing_store_once(store_name, &err.to_string()); - None - } - }; - let kv = resolve_kv_handle(DEFAULT_KV_STORE_NAME, false)?; - dispatch_with_handles( - app, - req, - Stores { - config_store: config_store_handle, - kv, - ..Default::default() - }, - ) -} - -/// Dispatch a request with a prepared config-store handle injected into extensions. -/// -/// This is the advanced/manual path. Prefer `dispatch_with_config` when you -/// want the adapter to resolve the configured backend for you. -/// -/// The KV store named [`DEFAULT_KV_STORE_NAME`] is also resolved and injected -/// (non-required: unavailable stores are silently skipped). -/// -/// # Errors -/// Returns an error if request conversion fails or the underlying handler returns an error. -#[inline] -pub fn dispatch_with_config_handle( - app: &App, - req: FastlyRequest, - config_store_handle: ConfigStoreHandle, -) -> Result { - let kv = resolve_kv_handle(DEFAULT_KV_STORE_NAME, false)?; - dispatch_with_handles( - app, - req, - Stores { - config_store: Some(config_store_handle), - kv, - ..Default::default() - }, - ) -} - fn dispatch_with_handles( app: &App, req: FastlyRequest, @@ -179,118 +285,147 @@ fn dispatch_with_handles( dispatch_core_request(app, core_request, stores) } -/// Dispatch a Fastly request with a custom KV store name. -/// -/// `kv_required` should be `true` when `[stores.kv]` is explicitly present -/// in the manifest, causing the request to fail if the store is unavailable -/// rather than silently degrading. +/// Dispatch with per-id store registries built from baked metadata. /// -/// # Errors -/// Returns an error if request conversion, handler dispatch, or response -/// conversion fails. Also returns an error when `kv_required` is `true` and -/// the named KV store cannot be opened; if `kv_required` is `false`, missing -/// stores are logged and dispatched without the KV handle injected. -#[inline] -pub fn dispatch_with_kv( +/// Fastly is `Multi` for all three kinds, so each declared id resolves to +/// its own platform store via `EDGEZERO__STORES______NAME` (or the +/// id default). KV failures escalate via [`resolve_kv_handle`]'s +/// `kv_required=true` path; missing config / secret stores degrade silently +/// with a one-time warning. +pub(crate) fn dispatch_with_registries( app: &App, req: FastlyRequest, - kv_store_name: &str, - kv_required: bool, + config_meta: Option, + kv_meta: Option, + secret_meta: Option, + env: &EnvConfig, ) -> Result { - let kv = resolve_kv_handle(kv_store_name, kv_required)?; + let kv_registry = build_kv_registry(kv_meta, env)?; + let config_registry = build_config_registry(config_meta, env); + let secret_registry = build_secret_registry(secret_meta, env); dispatch_with_handles( app, req, Stores { - kv, + config_registry, + kv_registry, + secret_registry, ..Default::default() }, ) } -/// Dispatch a Fastly request with both KV and secret stores attached. -/// -/// For most applications, prefer [`crate::run_app`] which resolves all stores -/// from the manifest automatically. Use `dispatch_with_kv_and_secrets` only -/// when you need direct control over the dispatch lifecycle without a manifest. -/// -/// # Errors -/// Returns an error if a required store cannot be opened or the underlying handler returns an error. -#[inline] -pub fn dispatch_with_kv_and_secrets( - app: &App, - req: FastlyRequest, - kv_store_name: &str, - kv_required: bool, - secrets_required: bool, -) -> Result { - let kv = resolve_kv_handle(kv_store_name, kv_required)?; - let secrets = resolve_secret_handle(secrets_required); - dispatch_with_handles( - app, - req, - Stores { - kv, - secrets, - ..Default::default() - }, - ) +/// Pure synthesis: collapse a `Stores` (which may carry both a +/// wired multi-id registry AND a legacy bare handle) into the +/// three registries that go into request extensions. Precedence +/// is "registry wins": a wired registry is taken verbatim; only +/// in its absence is a bare handle wrapped into a one-id registry +/// keyed under `"default"`. The bare handle is never merged +/// in, never used as a fallback for ids the registry doesn't +/// define. Pulled out as a pure function so the precedence +/// contract is unit-testable without spinning up a real +/// `Request` and async dispatcher. +fn synthesise_store_registries( + stores: Stores, +) -> ( + Option, + Option, + Option, +) { + let config_registry = stores.config_registry.or_else(|| { + stores + .config_store + .map(|handle| ConfigRegistry::single_id("default".to_owned(), handle)) + }); + let kv_registry = stores.kv_registry.or_else(|| { + stores + .kv + .map(|handle| KvRegistry::single_id("default".to_owned(), handle)) + }); + let secret_registry = stores.secret_registry.or_else(|| { + stores.secrets.map(|handle| { + SecretRegistry::single_id( + "default".to_owned(), + BoundSecretStore::new(handle, "default".to_owned()), + ) + }) + }); + (config_registry, kv_registry, secret_registry) } -/// Dispatch a Fastly request with a secret store attached. -/// -/// For most applications, prefer [`crate::run_app`] which resolves all stores -/// from the manifest automatically. Use `dispatch_with_secrets` only when you -/// need direct control over the dispatch lifecycle without a manifest. -/// -/// # Errors -/// Returns an error if the named secret store is required but cannot be opened, or the underlying handler returns an error. -#[inline] -pub fn dispatch_with_secrets( - app: &App, - req: FastlyRequest, - secrets_required: bool, -) -> Result { - let secrets = resolve_secret_handle(secrets_required); - dispatch_with_handles( - app, - req, - Stores { - secrets, - ..Default::default() - }, - ) +fn build_kv_registry( + kv_meta: Option, + env: &EnvConfig, +) -> Result, FastlyError> { + let Some(meta) = kv_meta else { + return Ok(None); + }; + let mut by_id: BTreeMap = BTreeMap::new(); + for id in meta.ids { + let store_name = env.store_name("kv", id); + // KV is required: if `[stores.kv]` is declared, an id failing to open + // is a runtime error rather than a silent degradation. + let Some(handle) = resolve_kv_handle(&store_name, true)? else { + continue; + }; + by_id.insert((*id).to_owned(), handle); + } + let default_id = meta.default.to_owned(); + if !by_id.contains_key(&default_id) { + log::warn!( + "KV registry default id `{default_id}` could not be opened; dropping the KV registry" + ); + } + Ok(StoreRegistry::from_parts(by_id, default_id)) } -pub(crate) fn dispatch_with_store_names( - app: &App, - req: FastlyRequest, - config_store_name: Option<&str>, - kv_store_name: &str, - kv_required: bool, - secrets_required: bool, -) -> Result { - let config_store_handle = match config_store_name { - Some(store_name) => match FastlyConfigStore::try_open(store_name) { - Ok(store) => Some(ConfigStoreHandle::new(Arc::new(store))), - Err(err) => { - warn_missing_store_once(store_name, &err.to_string()); - None +fn build_config_registry( + config_meta: Option, + env: &EnvConfig, +) -> Option { + let meta = config_meta?; + let mut by_id: BTreeMap = BTreeMap::new(); + for id in meta.ids { + let store_name = env.store_name("config", id); + match FastlyConfigStore::try_open(&store_name) { + Ok(store) => { + by_id.insert((*id).to_owned(), ConfigStoreHandle::new(Arc::new(store))); } - }, - None => None, - }; - let kv = resolve_kv_handle(kv_store_name, kv_required)?; - let secrets = resolve_secret_handle(secrets_required); - dispatch_with_handles( - app, - req, - Stores { - config_store: config_store_handle, - kv, - secrets, - }, - ) + Err(err) => warn_missing_store_once(&store_name, &err.to_string()), + } + } + let default_id = meta.default.to_owned(); + if !by_id.contains_key(&default_id) { + log::warn!( + "config registry default id `{default_id}` could not be opened; dropping the config registry" + ); + } + StoreRegistry::from_parts(by_id, default_id) +} + +fn build_secret_registry( + secret_meta: Option, + env: &EnvConfig, +) -> Option { + let meta = secret_meta?; + // Fastly is `Multi` for secrets. The provider trait is stateless — + // `FastlySecretStore::get_bytes(store_name, key)` opens the named Fastly + // Secret Store per call — so we share one provider handle across all + // bindings, then capture the per-id platform store name in the bound + // wrapper. `EDGEZERO__STORES__SECRETS____NAME` (default = the logical + // id) decides which Fastly store each id resolves to at runtime. + let handle = SecretHandle::new(Arc::new(FastlySecretStore)); + let mut by_id: BTreeMap = BTreeMap::new(); + for id in meta.ids { + let store_name = env.store_name("secrets", id); + by_id.insert( + (*id).to_owned(), + BoundSecretStore::new(handle.clone(), store_name), + ); + } + // Fastly's secret-store handle wrappers are infallible to construct; + // `from_parts` keeps the API symmetric with the KV / config builders. + StoreRegistry::from_parts(by_id, meta.default.to_owned()) } /// # Errors @@ -346,11 +481,26 @@ fn resolve_kv_handle( } } -fn resolve_secret_handle(secrets_required: bool) -> Option { - if !secrets_required { - return None; - } - Some(SecretHandle::new(Arc::new(FastlySecretStore))) +/// Construct the Fastly secret-store handle. Called from the +/// `SecretSource::On { .. }` arm of `dispatch`. The `_required` +/// parameter is preserved (and ignored) for symmetry with the kv +/// path's `resolve_kv_handle(_, _required)`, where `required` +/// decides whether a runtime open failure is fatal or silently +/// degrades. `FastlySecretStore` is a unit struct whose +/// construction can't fail, so there's nothing for `required` to +/// gate here — and `clippy::unnecessary_wraps` would flag an +/// `Option` return on a function that never returns +/// None. The caller wraps the result in `Some(...)` so the +/// `SecretSource::Off => None` branch still produces the right +/// `Option`. +/// +/// Pre-fix, the return type was `Option` and the +/// body short-circuited to `None` when `!_required`, which +/// silently swallowed `.with_secrets()` (which sets `required: +/// false`): handlers ran without a `SecretRegistry` even though the +/// builder claimed to inject one. +fn resolve_secret_handle(_required: bool) -> SecretHandle { + SecretHandle::new(Arc::new(FastlySecretStore)) } fn warn_missing_kv_store_once(kv_store_name: &str, error: &impl Display) { @@ -380,3 +530,137 @@ fn warn_missing_store_once(store_name: &str, detail: &str) { &format!("{detail}; skipping config-store injection"), ); } + +#[cfg(test)] +mod synthesis_tests { + use super::*; + use edgezero_core::config_store::{ConfigStore, ConfigStoreError, ConfigStoreHandle}; + use edgezero_core::key_value_store::{KvStore, NoopKvStore}; + use edgezero_core::secret_store::{NoopSecretStore, SecretHandle}; + use std::collections::BTreeMap; + use std::sync::Arc; + + struct StubConfig; + #[async_trait::async_trait(?Send)] + impl ConfigStore for StubConfig { + async fn get(&self, _key: &str) -> Result, ConfigStoreError> { + Ok(None) + } + } + + fn kv_handle() -> KvHandle { + let store: Arc = Arc::new(NoopKvStore); + KvHandle::new(store) + } + + fn config_handle() -> ConfigStoreHandle { + ConfigStoreHandle::new(Arc::new(StubConfig)) + } + + fn secret_handle() -> SecretHandle { + SecretHandle::new(Arc::new(NoopSecretStore)) + } + + #[test] + fn synthesis_wraps_bare_kv_handle_under_default_when_no_registry() { + let stores = Stores { + kv: Some(kv_handle()), + ..Default::default() + }; + let (config_out, kv_out, secret_out) = synthesise_store_registries(stores); + assert!( + config_out.is_none(), + "no config wiring -> no config registry" + ); + assert!( + secret_out.is_none(), + "no secret wiring -> no secret registry" + ); + let kv_reg = kv_out.expect("kv registry synthesised from bare handle"); + assert_eq!( + kv_reg.default_id(), + "default", + "synthesised id is `default`" + ); + assert!(kv_reg.named("default").is_some()); + assert!( + kv_reg.named("other").is_none(), + "synthesised registry only knows the `default` id" + ); + } + + #[test] + fn synthesis_registry_wins_over_bare_handle_when_both_wired() { + // Multi-id registry declaring only `sessions` paired with a + // bare handle that would otherwise synthesise to a + // `default`-keyed entry. Precedence rule: the bare handle + // is dropped entirely; the registry stands alone with no + // `default` id. + let mut by_id: BTreeMap = BTreeMap::new(); + by_id.insert("sessions".to_owned(), kv_handle()); + let registry = KvRegistry::new(by_id, "sessions".to_owned()); + let stores = Stores { + kv: Some(kv_handle()), + kv_registry: Some(registry), + ..Default::default() + }; + let (_, kv_out, _) = synthesise_store_registries(stores); + let kv_reg = kv_out.expect("registry survives synthesis"); + assert_eq!(kv_reg.default_id(), "sessions"); + assert!( + kv_reg.named("default").is_none(), + "bare handle's `default` synth NOT merged in" + ); + } + + #[test] + fn synthesis_returns_none_for_each_kind_with_no_wiring() { + let (config, kv, secret) = synthesise_store_registries(Stores::default()); + assert!(config.is_none() && kv.is_none() && secret.is_none()); + } + + #[test] + fn synthesis_handles_config_and_secret_bare_handles_symmetrically() { + let stores = Stores { + config_store: Some(config_handle()), + secrets: Some(secret_handle()), + ..Default::default() + }; + let (config_out, _, secret_out) = synthesise_store_registries(stores); + let config_reg = config_out.expect("config wrapped"); + assert_eq!(config_reg.default_id(), "default"); + let secret_reg = secret_out.expect("secret wrapped"); + assert_eq!(secret_reg.default_id(), "default"); + // BoundSecretStore binds the synthesised secret to platform + // store name "default" -- if the underlying Fastly account + // has no Secret Store literally named "default", the + // require_str() call from a handler will fail with a clear + // store-name error rather than silent miss. + assert_eq!( + secret_reg.default().expect("default bound").store_name(), + "default" + ); + } + + // Regression for the `.with_secrets()` bug — the pre-fix + // `resolve_secret_handle(false)` short-circuited to `None`, so the + // documented `.with_secrets().dispatch(...)` path silently ran + // handlers without a `SecretRegistry`. After the fix, the handle + // is always built when `SecretSource::On` is selected; `_required` + // is reserved for whichever future per-secret-lookup availability + // policy lands. + + #[test] + fn resolve_secret_handle_builds_handle_when_required_false_matches_with_secrets_default() { + // The return type is unconditionally `SecretHandle` (post- + // clippy::unnecessary_wraps cleanup); just exercise the + // call to lock in that `.with_secrets()`-shaped paths + // (required=false) still build a handle without panicking. + let _handle = resolve_secret_handle(false); + } + + #[test] + fn resolve_secret_handle_builds_handle_when_required_true_matches_require_secrets() { + let _handle = resolve_secret_handle(true); + } +} diff --git a/crates/edgezero-adapter-fastly/src/templates/src/main.rs.hbs b/crates/edgezero-adapter-fastly/src/templates/src/main.rs.hbs index 82f47d10..42d97d16 100644 --- a/crates/edgezero-adapter-fastly/src/templates/src/main.rs.hbs +++ b/crates/edgezero-adapter-fastly/src/templates/src/main.rs.hbs @@ -9,10 +9,7 @@ use fastly::{Error, Request, Response}; #[cfg(target_arch = "wasm32")] #[fastly::main] pub fn main(req: Request) -> Result { - edgezero_adapter_fastly::run_app::<{{proj_core_mod}}::App>( - include_str!("../../../edgezero.toml"), - req, - ) + edgezero_adapter_fastly::run_app::<{{proj_core_mod}}::App>(req) } #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/edgezero-adapter-fastly/tests/contract.rs b/crates/edgezero-adapter-fastly/tests/contract.rs index 3584769d..483ac4ff 100644 --- a/crates/edgezero-adapter-fastly/tests/contract.rs +++ b/crates/edgezero-adapter-fastly/tests/contract.rs @@ -1,11 +1,4 @@ #![cfg(all(feature = "fastly", target_arch = "wasm32"))] -// Keep coverage for the deprecated low-level dispatch path while it remains -// public. -#![allow( - deprecated, - reason = "the deprecated dispatch helper is still part of the public API; \ - contract coverage stays until the helper is removed" -)] // Compile-time check: FastlySecretStore implements SecretStore. mod secret_store_compile_check { @@ -23,9 +16,7 @@ mod secret_store_compile_check { mod tests { use bytes::Bytes; use edgezero_adapter_fastly::context::FastlyRequestContext; - use edgezero_adapter_fastly::request::{ - dispatch, dispatch_with_config_handle, into_core_request, - }; + use edgezero_adapter_fastly::request::{into_core_request, FastlyService}; use edgezero_adapter_fastly::response::from_core_response; use edgezero_core::app::App; use edgezero_core::body::Body; @@ -41,8 +32,9 @@ mod tests { struct FixedConfigStore(&'static str); + #[async_trait::async_trait(?Send)] impl ConfigStore for FixedConfigStore { - fn get(&self, _key: &str) -> Result, ConfigStoreError> { + async fn get(&self, _key: &str) -> Result, ConfigStoreError> { Ok(Some(self.0.to_owned())) } } @@ -80,10 +72,18 @@ mod tests { } async fn config_value(ctx: RequestContext) -> Result { - let value = ctx - .config_store() - .and_then(|store| store.get("greeting").ok().flatten()) - .unwrap_or_else(|| "missing".to_owned()); + // Hard-cutoff: legacy `ctx.config_handle()` is + // gone. The dispatch boundary now synthesises a one-id + // `ConfigRegistry` from the wired `ConfigStoreHandle`. + let value = match ctx.config_store_default() { + Some(store) => store + .get("greeting") + .await + .ok() + .flatten() + .unwrap_or_else(|| "missing".to_owned()), + None => "missing".to_owned(), + }; let response = response_builder() .status(StatusCode::OK) .body(Body::text(value)) @@ -165,7 +165,9 @@ mod tests { let app = build_test_app(); let req = fastly_request(FastlyMethod::GET, "/uri", None); - let mut response = dispatch(&app, req).expect("fastly response"); + let mut response = FastlyService::new(&app) + .dispatch(req) + .expect("fastly response"); assert_eq!(response.get_status(), FastlyStatus::OK); assert_eq!(response.take_body_bytes(), b"http://example.com/uri"); @@ -176,7 +178,9 @@ mod tests { let app = build_test_app(); let req = fastly_request(FastlyMethod::GET, "/stream", None); - let mut response = dispatch(&app, req).expect("fastly response"); + let mut response = FastlyService::new(&app) + .dispatch(req) + .expect("fastly response"); assert_eq!(response.get_status(), FastlyStatus::OK); assert_eq!(response.take_body_bytes(), b"chunk-1chunk-2"); @@ -187,19 +191,24 @@ mod tests { let app = build_test_app(); let req = fastly_request(FastlyMethod::POST, "/mirror", Some(b"echo")); - let mut response = dispatch(&app, req).expect("fastly response"); + let mut response = FastlyService::new(&app) + .dispatch(req) + .expect("fastly response"); assert_eq!(response.get_status(), FastlyStatus::OK); assert_eq!(response.take_body_bytes(), b"echo"); } #[test] - fn dispatch_with_config_handle_injects_handle() { + fn service_with_config_handle_injects_handle() { let app = build_test_app(); let req = fastly_request(FastlyMethod::GET, "/config", None); let handle = ConfigStoreHandle::new(Arc::new(FixedConfigStore("hello from fastly test"))); - let mut response = dispatch_with_config_handle(&app, req, handle).expect("fastly response"); + let mut response = FastlyService::new(&app) + .with_config_handle(handle) + .dispatch(req) + .expect("fastly response"); assert_eq!(response.get_status(), FastlyStatus::OK); assert_eq!(response.take_body_bytes(), b"hello from fastly test"); diff --git a/crates/edgezero-adapter-spin/.cargo/config.toml b/crates/edgezero-adapter-spin/.cargo/config.toml index aae9fc0e..788dbb50 100644 --- a/crates/edgezero-adapter-spin/.cargo/config.toml +++ b/crates/edgezero-adapter-spin/.cargo/config.toml @@ -1,9 +1,9 @@ [build] -target = "wasm32-wasip1" +target = "wasm32-wasip2" # Wasmtime runs the spin contract tests (no Fastly host imports needed). # Only applies when cargo is invoked from inside this package directory. -# CI overrides via `CARGO_TARGET_WASM32_WASIP1_RUNNER` env var in +# CI overrides via `CARGO_TARGET_WASM32_WASIP2_RUNNER` env var in # `.github/workflows/test.yml`. [target.'cfg(target_arch = "wasm32")'] runner = "wasmtime run" diff --git a/crates/edgezero-adapter-spin/Cargo.toml b/crates/edgezero-adapter-spin/Cargo.toml index b8259b56..875a2d39 100644 --- a/crates/edgezero-adapter-spin/Cargo.toml +++ b/crates/edgezero-adapter-spin/Cargo.toml @@ -11,7 +11,7 @@ workspace = true [features] default = [] spin = ["dep:spin-sdk"] -cli = ["dep:edgezero-adapter", "edgezero-adapter/cli", "dep:ctor", "dep:walkdir"] +cli = ["dep:edgezero-adapter", "edgezero-adapter/cli", "dep:ctor", "dep:rusqlite", "dep:toml", "dep:toml_edit", "dep:walkdir"] [dependencies] edgezero-core = { path = "../edgezero-core" } @@ -24,9 +24,24 @@ flate2 = { workspace = true } futures = { workspace = true } futures-util = { workspace = true } log = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } spin-sdk = { workspace = true, optional = true } +subtle = { workspace = true } +thiserror = { workspace = true } ctor = { workspace = true, optional = true } +toml = { workspace = true, optional = true } +toml_edit = { workspace = true, optional = true } walkdir = { workspace = true, optional = true } +# rusqlite is CLI-only and host-only — `bundled` ships SQLite source +# (no host libsqlite3 install needed), and the `[target.…]` gate keeps +# it out of the wasm artifact entirely. The wasm32 builds never see +# rusqlite even when `features = ["spin", "cli"]` is on host. +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +rusqlite = { workspace = true, optional = true } + [dev-dependencies] +edgezero-core = { path = "../edgezero-core", features = ["test-utils"] } +http-body-util = { workspace = true } tempfile = { workspace = true } diff --git a/crates/edgezero-adapter-spin/src/cli.rs b/crates/edgezero-adapter-spin/src/cli.rs index c8bebf31..b2724254 100644 --- a/crates/edgezero-adapter-spin/src/cli.rs +++ b/crates/edgezero-adapter-spin/src/cli.rs @@ -1,3 +1,13 @@ +#![expect( + clippy::self_named_module_files, + reason = "Workspace lint policy denies BOTH `self_named_module_files` (wants `cli/mod.rs`) and `mod_module_files` (wants `cli.rs`) -- they contradict, so any file with submodules must opt out of one. The repo convention is the self-named form (`cli.rs` with submodules under `cli/`); allow accordingly." +)] +#![expect( + clippy::arbitrary_source_item_ordering, + reason = "submodule declarations sit between the `use` block and the rest of the file's items by Rust convention; the strict-ordering lint disagrees but no human convention puts `mod` blocks AFTER trait impls" +)] + +use std::collections::HashSet; use std::env; use std::fs; use std::path::{Path, PathBuf}; @@ -5,15 +15,21 @@ use std::process::Command; use ctor::ctor; use edgezero_adapter::cli_support::{ - find_manifest_upwards, find_workspace_root, path_distance, read_package_name, + find_manifest_upwards, find_workspace_root, path_distance, read_package_name, run_native_cli, +}; +use edgezero_adapter::registry::{ + register_adapter, Adapter, AdapterAction, AdapterPushContext, ProvisionStores, ResolvedStoreId, }; -use edgezero_adapter::registry::{register_adapter, Adapter, AdapterAction}; use edgezero_adapter::scaffold::{ register_adapter_blueprint, AdapterBlueprint, AdapterFileSpec, CommandTemplates, DependencySpec, LoggingDefaults, ManifestSpec, ReadmeInfo, TemplateRegistration, }; use walkdir::WalkDir; +mod push_cloud; +mod push_sqlite; +mod runtime_config; + static SPIN_ADAPTER: SpinCliAdapter = SpinCliAdapter; static SPIN_BLUEPRINT: AdapterBlueprint = AdapterBlueprint { @@ -28,14 +44,14 @@ static SPIN_BLUEPRINT: AdapterBlueprint = AdapterBlueprint { dependencies: SPIN_DEPENDENCIES, manifest: ManifestSpec { manifest_filename: "spin.toml", - build_target: "wasm32-wasip1", + build_target: "wasm32-wasip2", build_profile: "release", build_features: &["spin"], }, commands: CommandTemplates { - build: "cargo build --target wasm32-wasip1 --release -p {crate}", + build: "cargo build --target wasm32-wasip2 --release -p {crate}", deploy: "spin deploy --from {crate_dir}", - serve: "spin up --from {crate_dir}", + serve: "spin up --from {crate_dir} --runtime-config-file {crate_dir}/runtime-config.toml", }, logging: LoggingDefaults { endpoint: None, @@ -45,7 +61,7 @@ static SPIN_BLUEPRINT: AdapterBlueprint = AdapterBlueprint { readme: ReadmeInfo { description: "{display} entrypoint.", dev_heading: "{display} (local)", - dev_steps: &["`edgezero-cli serve --adapter spin`"], + dev_steps: &["`edgezero serve --adapter spin`"], }, run_module: "edgezero_adapter_spin", }; @@ -78,6 +94,10 @@ static SPIN_FILE_SPECS: &[AdapterFileSpec] = &[ template: "spin_Cargo_toml", output: "Cargo.toml", }, + AdapterFileSpec { + template: "spin_runtime_config_toml", + output: "runtime-config.toml", + }, AdapterFileSpec { template: "spin_src_lib_rs", output: "src/lib.rs", @@ -93,6 +113,10 @@ static SPIN_TEMPLATE_REGISTRATIONS: &[TemplateRegistration] = &[ name: "spin_Cargo_toml", contents: include_str!("templates/Cargo.toml.hbs"), }, + TemplateRegistration { + name: "spin_runtime_config_toml", + contents: include_str!("templates/runtime-config.toml.hbs"), + }, TemplateRegistration { name: "spin_src_lib_rs", contents: include_str!("templates/src/lib.rs.hbs"), @@ -103,13 +127,31 @@ static SPIN_TEMPLATE_REGISTRATIONS: &[TemplateRegistration] = &[ }, ]; -const TARGET_TRIPLE: &str = "wasm32-wasip1"; +const TARGET_TRIPLE: &str = "wasm32-wasip2"; + +const SPIN_INSTALL_HINT: &str = "install the Spin CLI (https://spinframework.dev/) and try again"; struct SpinCliAdapter; +#[expect( + clippy::missing_trait_methods, + reason = "Stage 6: KV-backed config dropped Spin's `^[a-z][a-z0-9_]*$` key rule and the config-vs-secret collision check, so `validate_app_config_keys` falls back to the trait default `Ok(())`. `validate_typed_secrets` IS overridden below (secret-value canonicalisation + within-secrets uniqueness still apply). `validate_adapter_manifest` IS overridden below (Spin's multi-component disambiguation)." +)] impl Adapter for SpinCliAdapter { fn execute(&self, action: AdapterAction, args: &[String]) -> Result<(), String> { match action { + // `spin cloud {login|logout|info}` is the native sign-in + // surface for Fermyon Cloud. EdgeZero stores no + // credentials — this is a thin shell-out. + AdapterAction::AuthLogin => { + run_native_cli("spin", &["cloud", "login"], SPIN_INSTALL_HINT) + } + AdapterAction::AuthLogout => { + run_native_cli("spin", &["cloud", "logout"], SPIN_INSTALL_HINT) + } + AdapterAction::AuthStatus => { + run_native_cli("spin", &["cloud", "info"], SPIN_INSTALL_HINT) + } AdapterAction::Build => { let artifact = build(args)?; log::info!("[edgezero] Spin build complete -> {}", artifact.display()); @@ -121,9 +163,680 @@ impl Adapter for SpinCliAdapter { } } + fn merged_id_kinds(&self) -> &'static [&'static str] { + // Both KV and Config back to `spin_sdk::key_value::Store` via + // the same `provision` path; declaring the same logical id + // under both kinds resolves to one underlying store with + // silent write-collisions. CLI validate rejects. + &["kv", "config"] + } + fn name(&self) -> &'static str { "spin" } + + fn provision( + &self, + manifest_root: &Path, + adapter_manifest_path: Option<&str>, + component_selector: Option<&str>, + stores: &ProvisionStores<'_>, + dry_run: bool, + ) -> Result, String> { + //: spin provision is pure spin.toml editing — no + // shell-out (Spin KV stores are provisioned by the Spin + // runtime / Fermyon at deploy). For each declared KV id + // AND each declared CONFIG id (KV-backed since Stage 5 + // of the spin-kv-config plan), append the env-resolved + // platform label to the component's `key_value_stores` + // array. Secret variables are manually declared by the + // developer in spin.toml -- secrets stay on Spin + // variables for the platform's `secret = true` flagging. + let Some(rel) = adapter_manifest_path else { + return Err( + "[adapters.spin.adapter].manifest must point at spin.toml for provision".to_owned(), + ); + }; + let spin_path = manifest_root.join(rel); + + let mut out = Vec::new(); + // Resolve the component once if either KV or config has + // anything to provision. + let needs_component = !stores.kv.is_empty() || !stores.config.is_empty(); + if needs_component { + let component_id = resolve_spin_component(&spin_path, component_selector)?; + for (kind, store) in stores + .kv + .iter() + .map(|store| ("KV", store)) + .chain(stores.config.iter().map(|store| ("config", store))) + { + let logical = store.logical.as_str(); + // The label the runtime opens is what + // `EDGEZERO__STORES______NAME` + // resolves to (default = the logical id). Provision + // writes the PLATFORM label into + // `[component.X].key_value_stores` so that both the + // KV runtime lookup AND the KV-backed config + // runtime lookup match. + let label = store.platform.as_str(); + if dry_run { + out.push(format!( + "would ensure {kind} label `{label}` (logical id `{logical}`) is in [component.{component_id}].key_value_stores in {}", + spin_path.display() + )); + continue; + } + let added = ensure_kv_label_in_component(&spin_path, &component_id, label)?; + if added { + out.push(format!( + "added {kind} label `{label}` (logical id `{logical}`) to [component.{component_id}].key_value_stores in {}", + spin_path.display() + )); + } else { + out.push(format!( + "{kind} label `{label}` (logical id `{logical}`) already present in [component.{component_id}].key_value_stores in {}; skipping", + spin_path.display() + )); + } + } + } + for store in stores.secrets { + let logical = store.logical.as_str(); + let platform = store.platform.as_str(); + out.push(format!( + "spin secret id `{logical}` (platform name `{platform}`) requires manual `[variables].* secret = true` + `[component.*.variables].*` declarations in spin.toml; nothing to do here" + )); + } + if out.is_empty() { + out.push("spin has no declared stores to provision".to_owned()); + } + Ok(out) + } + + fn push_config_entries( + &self, + manifest_root: &Path, + adapter_manifest_path: Option<&str>, + _component_selector: Option<&str>, + store: &ResolvedStoreId, + entries: &[(String, String)], + push_ctx: &AdapterPushContext<'_>, + dry_run: bool, + ) -> Result, String> { + dispatch_push( + manifest_root, + adapter_manifest_path, + store, + entries, + push_ctx, + dry_run, + ) + } + + fn push_config_entries_local( + &self, + manifest_root: &Path, + adapter_manifest_path: Option<&str>, + _component_selector: Option<&str>, + store: &ResolvedStoreId, + entries: &[(String, String)], + push_ctx: &AdapterPushContext<'_>, + dry_run: bool, + ) -> Result, String> { + // `--local` lives in `push_ctx.local`. `dispatch_push` honours + // it by suppressing the Fermyon Cloud auto-detect so the + // operator can force a SQLite-direct write even when the + // manifest's deploy command shells to `spin deploy`. + dispatch_push( + manifest_root, + adapter_manifest_path, + store, + entries, + push_ctx, + dry_run, + ) + } + + fn single_store_kinds(&self) -> &'static [&'static str] { + //: Multi for KV AND Config (both label-backed via the + // Spin KV API since Stage 5 of the spin-kv-config plan). + // Single for Secrets (still flat-variable namespace). + &["secrets"] + } + + fn validate_adapter_manifest( + &self, + manifest_root: &Path, + adapter_manifest_path: Option<&str>, + component_selector: Option<&str>, + ) -> Result<(), String> { + // check 3: spin.toml must exist and either declare + // exactly one `[component.*]` or carry an explicit selector + // that matches one of the declared ids. + let Some(rel) = adapter_manifest_path else { + return Err( + "[adapters.spin.adapter].manifest must point at spin.toml for Spin component discovery".to_owned() + ); + }; + let spin_path = manifest_root.join(rel); + let raw = fs::read_to_string(&spin_path).map_err(|err| { + format!( + "failed to read spin manifest at {}: {err}", + spin_path.display() + ) + })?; + let parsed: toml::Value = toml::from_str(&raw) + .map_err(|err| format!("failed to parse {} as TOML: {err}", spin_path.display()))?; + let component_ids = collect_spin_component_ids(&parsed); + + if component_ids.is_empty() { + return Err(format!( + "{}: no [component.*] declarations found", + spin_path.display() + )); + } + + if let Some(selector) = component_selector { + if component_ids.iter().any(|id| id == selector) { + return Ok(()); + } + return Err(format!( + "[adapters.spin.adapter].component = {:?} is not declared in {} (available: {})", + selector, + spin_path.display(), + component_ids.join(", ") + )); + } + + if component_ids.len() == 1 { + return Ok(()); + } + Err(format!( + "{} declares {} components ({}) but [adapters.spin.adapter].component is unset; set one explicitly", + spin_path.display(), + component_ids.len(), + component_ids.join(", ") + )) + } + + fn validate_typed_secrets(&self, plain_secrets: &[(&str, &str)]) -> Result<(), String> { + // Stage 5+: KV-backed config no longer shares Spin's flat + // variable namespace, so config keys are NOT considered here + // (and the trait dropped the parameter in Stage 6+) — config + // can use arbitrary UTF-8 keys without colliding with + // `#[secret]` values. Secrets still resolve through + // `spin_sdk::variables`, so two checks remain: + // 1. each `#[secret]` value canonicalises (lowercase, no + // `.→__` — secrets don't get translated at runtime) + // to a valid Spin variable name, so invalid chars + // (dashes, digit-first) fail validation rather than + // at runtime with an opaque `InvalidName`; + // 2. no two `#[secret]` values collapse to the same + // lowercased Spin variable, since Spin's flat + // namespace cannot disambiguate them. + let mut seen: HashSet = HashSet::with_capacity(plain_secrets.len()); + for (field_name, value) in plain_secrets { + let spin_var = value.to_ascii_lowercase(); + if !is_valid_spin_key(&spin_var) { + let reason = spin_key_rule_violation(&spin_var); + return Err(format!( + "`#[secret]` field `{field_name}` value `{value}` translates to Spin variable `{spin_var}`, which is not a valid Spin variable name. {reason}. Pick a `#[secret]` value that conforms." + )); + } + if !seen.insert(spin_var.clone()) { + return Err(format!( + "Spin variable `{spin_var}` (from `#[secret]` field `{field_name}`) collides with another `#[secret]` value resolving to the same lowercased name; Spin's flat variable namespace cannot disambiguate them" + )); + } + } + Ok(()) + } +} + +fn is_valid_spin_key(key: &str) -> bool { + let mut chars = key.chars(); + let Some(first) = chars.next() else { + return false; + }; + if !first.is_ascii_lowercase() { + return false; + } + chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_') +} + +/// Return a per-failure-mode diagnostic for a key that failed +/// `is_valid_spin_key`. Spin's variable-name rule +/// (`^[a-z][a-z0-9_]*$`) is one regex but the operator usually +/// wants to know WHICH bit they broke: digit-leading, uppercase, +/// or stray punctuation. Returns a short phrase to splice into +/// the caller's full error. +fn spin_key_rule_violation(key: &str) -> &'static str { + // Callers only invoke this AFTER `is_valid_spin_key` returned + // false; in production the per-char branches below exhaust the + // failure modes and the catch-all at the bottom is unreachable. + // It's kept defensively so a future regex tweak (e.g. allowing + // a new char class) doesn't crash the diagnostic helper with + // an unreachable!() before the caller can produce its error. + // + // Reachability notes for the per-mode branches: + // - `push_config_entries` translates keys via + // `translate_key_for_spin` (which lowercases) BEFORE this + // call, so the uppercase-first branch is unreachable from + // that site. It IS reachable from `validate_app_config_keys` + // and `validate_typed_secrets`, which check raw user input. + let mut chars = key.chars(); + let Some(first) = chars.next() else { + return "Spin variable names must not be empty"; + }; + if first.is_ascii_digit() { + return "Spin variable names must start with a lowercase letter, not a digit"; + } + if first.is_ascii_uppercase() { + return "Spin variable names must be lowercase (uppercase letters are not allowed)"; + } + if !first.is_ascii_lowercase() { + return "Spin variable names must start with a lowercase ASCII letter"; + } + for ch in chars { + if ch.is_ascii_uppercase() { + return "Spin variable names must be lowercase (uppercase letters are not allowed)"; + } + if !(ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_') { + return "Spin variable names may only contain lowercase letters, digits, and underscores"; + } + } + debug_assert!( + false, + "spin_key_rule_violation called with key `{key}` that satisfies the regex; check is_valid_spin_key + caller agreement" + ); + "Spin variable names must match `^[a-z][a-z0-9_]*$`" +} + +fn collect_spin_component_ids(parsed: &toml::Value) -> Vec { + parsed + .as_table() + .and_then(|root| root.get("component")) + .and_then(toml::Value::as_table) + .map(|components| components.keys().cloned().collect()) + .unwrap_or_default() +} + +/// Read `[application].name` from `spin.toml`. Required by the +/// Fermyon Cloud writer to address KV stores via the app-scoped +/// label model (`--app --label
__…__` (the prefix is the project +# name, uppercased with `-`→`_`; nested sections are joined by `__`) +# as long as the key already exists below. The loader infers the type +# from the parsed value and coerces the env string accordingly. +# Example: `{{EnvPrefix}}__SERVICE__TIMEOUT_MS=2500` overrides the +# `[service] timeout_ms` field below. + +greeting = "hello from {{name}}" + +[service] +timeout_ms = 1500 + +# When you uncomment `#[secret] api_token` in the AppConfig struct +# (see `crates/{{proj_core}}/src/config.rs`), the matching key here +# is the *name* of the secret -- the runtime resolves it through +# the wired secret store via +# `ctx.secret_store_default()?.require_str(&cfg.api_token)`. +# Uncomment alongside the corresponding `[stores.secrets]` block +# in `edgezero.toml`. +# +# api_token = "demo_api_token" diff --git a/crates/edgezero-cli/src/templates/cli/Cargo.toml.hbs b/crates/edgezero-cli/src/templates/cli/Cargo.toml.hbs new file mode 100644 index 00000000..d9ce46cb --- /dev/null +++ b/crates/edgezero-cli/src/templates/cli/Cargo.toml.hbs @@ -0,0 +1,14 @@ +[package] +name = "{{proj_cli}}" +version = "0.1.0" +edition = "2021" +publish = false + +[lints] +workspace = true + +[dependencies] +{{proj_core}} = { path = "../{{proj_core}}" } +{{{dep_edgezero_cli}}} +clap = { workspace = true } +log = { workspace = true } diff --git a/crates/edgezero-cli/src/templates/cli/src/main.rs.hbs b/crates/edgezero-cli/src/templates/cli/src/main.rs.hbs new file mode 100644 index 00000000..8a4f97e0 --- /dev/null +++ b/crates/edgezero-cli/src/templates/cli/src/main.rs.hbs @@ -0,0 +1,87 @@ +//! {{name}} CLI — built on the `edgezero-cli` library. +//! +//! This binary reuses every built-in `edgezero` command via the +//! `edgezero_cli` library and is the place to add your own +//! subcommands. The `Config` arm dispatches the **typed** validate +//! and push paths, parameterised over `{{NameUpperCamel}}Config` — +//! the struct your `{{proj_core}}` crate owns. The default +//! `edgezero` binary runs the *raw* paths because it has no typed +//! struct in scope; a downstream CLI like this one upgrades to +//! typed so `validator` rules, `#[secret]` / `#[secret(store_ref)]` +//! checks, and the Spin namespace collision check all run. + +use clap::{Parser, Subcommand}; +use edgezero_cli::args::{ + AuthArgs, BuildArgs, ConfigPushArgs, ConfigValidateArgs, DeployArgs, NewArgs, ProvisionArgs, + ServeArgs, +}; +use {{proj_core_mod}}::config::{{NameUpperCamel}}Config; + +#[derive(Parser, Debug)] +#[command(name = "{{proj_cli}}", about = "{{name}} edge CLI")] +struct Args { + #[command(subcommand)] + cmd: Cmd, +} + +#[derive(Subcommand, Debug)] +enum Cmd { + /// Sign in / out / status against the adapter's native CLI + /// (`wrangler` / `fastly` / `spin`). See spec. + Auth(AuthArgs), + /// Build the project for a target edge. + Build(BuildArgs), + /// Inspect or mutate the typed `{{name}}.toml` app config. + #[command(subcommand)] + Config({{NameUpperCamel}}ConfigCmd), + /// Deploy to a target edge. + Deploy(DeployArgs), + /// Create a new `EdgeZero` app skeleton. + New(NewArgs), + /// Create the platform resources backing the declared + /// `[stores.].ids`. + Provision(ProvisionArgs), + /// Run a local simulation (adapter-specific). + Serve(ServeArgs), +} + +/// Mirrors `edgezero_cli::args::ConfigCmd` but dispatches both +/// `validate` and `push` to the **typed** entry points +/// parameterised over `{{NameUpperCamel}}Config` — the downstream +/// project owns the struct, so it can enforce the typed +/// deserialise, `validator` rules, and `#[secret]` / +/// `#[secret(store_ref)]` checks the raw default-binary path skips +///. +#[derive(Subcommand, Debug)] +enum {{NameUpperCamel}}ConfigCmd { + /// Push `{{name}}.toml` (flattened, secret-stripped) to the + /// adapter's config store. + Push(ConfigPushArgs), + /// Validate `edgezero.toml` and `{{name}}.toml` against the + /// typed `{{NameUpperCamel}}Config` contract. + Validate(ConfigValidateArgs), +} + +fn main() { + use std::process; + + edgezero_cli::init_cli_logger(); + let result = match Args::parse().cmd { + Cmd::Auth(args) => edgezero_cli::run_auth(&args), + Cmd::Build(args) => edgezero_cli::run_build(&args), + Cmd::Config({{NameUpperCamel}}ConfigCmd::Push(args)) => { + edgezero_cli::run_config_push_typed::<{{NameUpperCamel}}Config>(&args) + } + Cmd::Config({{NameUpperCamel}}ConfigCmd::Validate(args)) => { + edgezero_cli::run_config_validate_typed::<{{NameUpperCamel}}Config>(&args) + } + Cmd::Deploy(args) => edgezero_cli::run_deploy(&args), + Cmd::New(args) => edgezero_cli::run_new(&args), + Cmd::Provision(args) => edgezero_cli::run_provision(&args), + Cmd::Serve(args) => edgezero_cli::run_serve(&args), + }; + if let Err(err) = result { + log::error!("[{{name}}] {err}"); + process::exit(1); + } +} diff --git a/crates/edgezero-cli/src/templates/core/Cargo.toml.hbs b/crates/edgezero-cli/src/templates/core/Cargo.toml.hbs index 17395d80..578cfa62 100644 --- a/crates/edgezero-cli/src/templates/core/Cargo.toml.hbs +++ b/crates/edgezero-cli/src/templates/core/Cargo.toml.hbs @@ -12,6 +12,10 @@ bytes = { workspace = true } {{{dep_edgezero_core}}} futures = { workspace = true } serde = { workspace = true } +# `#[derive(Validate)]` on the generated `{{NameUpperCamel}}Config` +# struct. `edgezero_core::AppConfig` comes through the +# `edgezero-core` re-export — no `edgezero-macros` dep needed. +validator = { workspace = true } [dev-dependencies] async-trait = "0.1" diff --git a/crates/edgezero-cli/src/templates/core/src/config.rs.hbs b/crates/edgezero-cli/src/templates/core/src/config.rs.hbs new file mode 100644 index 00000000..af20d750 --- /dev/null +++ b/crates/edgezero-cli/src/templates/core/src/config.rs.hbs @@ -0,0 +1,62 @@ +//! Typed application config, loaded from `{{name}}.toml` via +//! `edgezero_core::app_config::load_app_config::<{{NameUpperCamel}}Config>`. +//! +//! The TOML file maps directly onto this struct — there is no +//! `[config]` wrapper; top-level keys correspond to top-level +//! fields. The `{{EnvPrefix}}__
__…__` env-var +//! overlay (project name uppercased with `-`→`_`, nested sections +//! joined by `__`) overrides any key already present. + +#![expect( + clippy::module_name_repetitions, + reason = "`Config` is the canonical struct name the generator emits; the duplication with the `config` module is intentional" +)] + +use serde::{Deserialize, Serialize}; +use validator::Validate; + +#[derive(Debug, Deserialize, Serialize, Validate, edgezero_core::AppConfig)] +#[serde(deny_unknown_fields)] +pub struct {{NameUpperCamel}}Config { + /// Free-form greeting surfaced by example handlers. Replace or + /// remove as the app grows. + pub greeting: String, + + /// Nested section — exercises the env-var overlay + /// (`{{EnvPrefix}}__SERVICE__TIMEOUT_MS=…` at runtime). + /// `#[validate(nested)]` makes the outer `validate()` recurse + /// into `ServiceConfig`; without it the inner `range` rule on + /// `timeout_ms` silently no-ops. + #[validate(nested)] + pub service: ServiceConfig, + // `#[secret]` — uncomment when the project declares + // `[stores.secrets]` in `edgezero.toml` (with at least a + // `default` id) and the handler loads the secret bytes at + // runtime via `ctx.secret_store_default()?.require_str(&cfg.api_token)`. + // The value here is the *key* in the default secret store, + // NOT the secret bytes; the runtime resolves it through the + // wired adapter binding (Cloudflare worker secret, Fastly + // secret-store, Spin secret variable, ...). EdgeZero's typed + // validator rejects the field unless the secret store is + // declared, so this is opt-in to keep the scaffold's `serve` + // path runnable out of the box. + // + // #[secret] + // pub api_token: String, + // + // `#[secret(store_ref)]` — uncomment when the project declares + // more than one secret store id under `[stores.secrets].ids`. + // The value is then the logical id of the secret store to + // resolve at runtime via `ctx.secret_store(&cfg.vault)?`. + // Single-secret-store projects don't need this. + // + // #[secret(store_ref)] + // pub vault: String, +} + +#[derive(Debug, Deserialize, Serialize, Validate)] +#[serde(deny_unknown_fields)] +pub struct ServiceConfig { + #[validate(range(min = 100_u32, max = 60_000_u32))] + pub timeout_ms: u32, +} diff --git a/crates/edgezero-cli/src/templates/core/src/handlers.rs.hbs b/crates/edgezero-cli/src/templates/core/src/handlers.rs.hbs index 24bf25c3..382a2ad5 100644 --- a/crates/edgezero-cli/src/templates/core/src/handlers.rs.hbs +++ b/crates/edgezero-cli/src/templates/core/src/handlers.rs.hbs @@ -126,6 +126,7 @@ mod tests { use futures::executor::block_on; use std::collections::HashMap; use std::env; + use std::sync::{Mutex, MutexGuard, OnceLock}; struct TestProxyClient; @@ -138,6 +139,39 @@ mod tests { } } + /// Serializes every test that reads or writes the `API_BASE_URL` + /// process-global env var — concurrent `env::set_var` / `env::var` + /// across threads is unsound, so these tests must not overlap. + fn env_lock() -> MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + /// Restores `API_BASE_URL` to its prior value when dropped, so a + /// panicking assertion cannot leak process-global state. + struct EnvVarGuard { + original: Option, + } + + impl EnvVarGuard { + fn set(value: &str) -> Self { + let original = env::var("API_BASE_URL").ok(); + env::set_var("API_BASE_URL", value); + Self { original } + } + } + + impl Drop for EnvVarGuard { + fn drop(&mut self) { + match &self.original { + Some(value) => env::set_var("API_BASE_URL", value), + None => env::remove_var("API_BASE_URL"), + } + } + } + #[test] fn root_returns_static_body() { let ctx = empty_context("/"); @@ -210,28 +244,27 @@ mod tests { #[test] fn build_proxy_target_merges_segments_and_query() { - env::set_var("API_BASE_URL", "https://example.com/api"); + let _lock = env_lock(); + let _env = EnvVarGuard::set("https://example.com/api"); let original = Uri::from_static("/proxy/status?foo=bar"); let target = build_proxy_target("status/200", &original).expect("target uri"); assert_eq!( target.to_string(), "https://example.com/api/status/200?foo=bar" ); - env::remove_var("API_BASE_URL"); } #[test] fn proxy_demo_without_handle_returns_placeholder() { - env::set_var("API_BASE_URL", "https://example.com/api"); + let _lock = env_lock(); let ctx = context_with_params("/proxy/status/200", &[("rest", "status/200")]); let response = block_on(proxy_demo(ctx)).expect("response"); assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED); - env::remove_var("API_BASE_URL"); } #[test] fn proxy_demo_uses_injected_handle() { - env::set_var("API_BASE_URL", "https://example.com/api"); + let _lock = env_lock(); let mut request = request_builder() .method(Method::GET) @@ -248,8 +281,6 @@ mod tests { let response = block_on(proxy_demo(ctx)).expect("response"); assert_eq!(response.status(), StatusCode::CREATED); - - env::remove_var("API_BASE_URL"); } fn empty_context(path: &str) -> RequestContext { diff --git a/crates/edgezero-cli/src/templates/core/src/lib.rs.hbs b/crates/edgezero-cli/src/templates/core/src/lib.rs.hbs index d8939a11..dee67bfe 100644 --- a/crates/edgezero-cli/src/templates/core/src/lib.rs.hbs +++ b/crates/edgezero-cli/src/templates/core/src/lib.rs.hbs @@ -1,3 +1,4 @@ +pub mod config; mod handlers; edgezero_core::app!("../../edgezero.toml"); diff --git a/crates/edgezero-cli/src/templates/root/Cargo.toml.hbs b/crates/edgezero-cli/src/templates/root/Cargo.toml.hbs index b8ebff19..a7c02b6a 100644 --- a/crates/edgezero-cli/src/templates/root/Cargo.toml.hbs +++ b/crates/edgezero-cli/src/templates/root/Cargo.toml.hbs @@ -1,6 +1,7 @@ [workspace] members = [ "crates/{{proj_core}}", + "crates/{{proj_cli}}", {{{workspace_members}}} ] resolver = "2" diff --git a/crates/edgezero-cli/src/templates/root/README.md.hbs b/crates/edgezero-cli/src/templates/root/README.md.hbs index 376fc87e..59c7fb6c 100644 --- a/crates/edgezero-cli/src/templates/root/README.md.hbs +++ b/crates/edgezero-cli/src/templates/root/README.md.hbs @@ -3,6 +3,7 @@ This workspace demonstrates a multi-target EdgeZero app. - `crates/{{proj_core}}`: reusable application logic built with `edgezero-core`. +- `crates/{{proj_cli}}`: your project's CLI binary, built on the reusable `edgezero-cli` library. Extend it with your own subcommands here — `edgezero` itself stays generic. {{{readme_adapter_crates}}} ## Routes @@ -20,4 +21,4 @@ This workspace demonstrates a multi-target EdgeZero app. ## Configuration -Environment variables are declared in `edgezero.toml`. Set `API_BASE_URL` to the upstream origin you want `/proxy/...` to target and provide adapter-specific secrets (for example `API_TOKEN`) when deploying. +Environment variables are declared in `edgezero.toml`. Set `API_BASE_URL` to the upstream origin you want `/proxy/...` to target. Adapter-specific secrets are opt-in: uncomment the `#[secret]` field in `crates/{{proj_core}}/src/config.rs` and the matching `[stores.secrets]` block in `edgezero.toml`, then provide the secret value through the wired backend (Cloudflare Worker secret, Fastly Secret Store, Spin secret variable, ...). diff --git a/crates/edgezero-cli/src/templates/root/edgezero.toml.hbs b/crates/edgezero-cli/src/templates/root/edgezero.toml.hbs index 48c902d2..5f5daae7 100644 --- a/crates/edgezero-cli/src/templates/root/edgezero.toml.hbs +++ b/crates/edgezero-cli/src/templates/root/edgezero.toml.hbs @@ -52,6 +52,28 @@ methods = ["GET", "POST"] handler = "{{proj_core_mod}}::handlers::proxy_demo" adapters = [{{{adapter_list}}}] +# -- Stores ---------------------------------------------------------------- +# +# `[stores.]` declares logical store ids only. `default` is required +# when more than one id is declared; with a single id it resolves to that id. +# +# The default scaffold ships with no stores so `edgezero serve --adapter +# ` starts cleanly without per-platform KV / config / secret bindings. +# The scaffolded `::AppConfig` correspondingly has no `#[secret]` / +# `#[kv]` fields; uncomment the kinds your handlers will use, then provision +# the matching platform bindings (see docs/guide/manifest-store-migration.md +# and the per-adapter guides for the wrangler.toml / spin.toml / fastly.toml +# entries). +# +# [stores.kv] +# ids = ["app_kv"] +# +# [stores.config] +# ids = ["app_config"] +# +# [stores.secrets] +# ids = ["default"] + # [environment] # # [[environment.variables]] diff --git a/crates/edgezero-cli/src/templates/root/gitignore.hbs b/crates/edgezero-cli/src/templates/root/gitignore.hbs index b99f0fe0..5bf2f985 100644 --- a/crates/edgezero-cli/src/templates/root/gitignore.hbs +++ b/crates/edgezero-cli/src/templates/root/gitignore.hbs @@ -3,6 +3,17 @@ bin/ pkg/ target/ +# local emulator / runtime state — each adapter's local KV / config +# store lives under one of these directories: +# - Cloudflare local KV -> .wrangler/state/v3/kv/*/db.sqlite +# - Spin local KV -> .spin/sqlite_key_value.db +# - Axum local config -> .edgezero/local-config-.json +# All three are populated by `config push --local` (or by the +# runtime itself on first read) and should not be committed. +.wrangler/ +.spin/ +.edgezero/ + # env .env diff --git a/crates/edgezero-cli/src/templates/root/tool-versions.hbs b/crates/edgezero-cli/src/templates/root/tool-versions.hbs new file mode 100644 index 00000000..efa7c9ae --- /dev/null +++ b/crates/edgezero-cli/src/templates/root/tool-versions.hbs @@ -0,0 +1 @@ +{{{tool_versions_contents}}} \ No newline at end of file diff --git a/crates/edgezero-cli/src/test_support.rs b/crates/edgezero-cli/src/test_support.rs new file mode 100644 index 00000000..55e1efdd --- /dev/null +++ b/crates/edgezero-cli/src/test_support.rs @@ -0,0 +1,144 @@ +//! Test-only fixtures shared across `auth`, `provision`, `build`, +//! `deploy`, `serve`, and `config` test modules. +//! +//! Each of those modules calls into the global `EDGEZERO_MANIFEST` +//! env var and the adapter registry, both of which are process-wide +//! state. The `manifest_guard()` mutex serialises tests that touch +//! either; the `EnvOverride` RAII guard restores the prior env value +//! when dropped, so a panic in one test cannot leak state into the +//! next. +//! +//! Kept under `pub(crate)` so the in-module test files (per the +//! "colocate tests with implementation" convention in CLAUDE.md) +//! can share the harness without each duplicating the BASIC / +//! PROVISION manifest fixtures. + +use std::env; +use std::sync::{Mutex, OnceLock}; + +/// `provision` dispatch fixture: declares axum + fastly + +/// cloudflare + spin (every adapter the build registers), with +/// store ids per kind so axum has something to print and the +/// other adapters' stubs are exercised against a non-empty input. +pub(crate) const PROVISION_MANIFEST: &str = r#" +[app] +name = "demo-app" + +[adapters.axum.adapter] +crate = "crates/demo-axum" +[adapters.axum.commands] +build = "echo" +deploy = "echo" +serve = "echo" + +[adapters.cloudflare.adapter] +crate = "crates/demo-cf" +manifest = "wrangler.toml" + +[adapters.cloudflare.commands] +build = "echo" +deploy = "echo" +serve = "echo" + +[adapters.fastly.adapter] +crate = "crates/demo-fastly" +manifest = "fastly.toml" + +[adapters.fastly.commands] +build = "echo" +deploy = "echo" +serve = "echo" + +[adapters.spin.adapter] +crate = "crates/demo-spin" +manifest = "spin.toml" +[adapters.spin.commands] +build = "echo" +deploy = "echo" +serve = "echo" + +[stores.kv] +ids = ["sessions", "cache"] +default = "sessions" + +[stores.config] +ids = ["app_config"] + +[stores.secrets] +ids = ["default"] +"#; + +/// Minimal manifest covering the auth + build/deploy/serve dispatch +/// surface. Only fastly is declared because its command overrides +/// (`auth-login` etc.) are what the auth orchestration tests +/// substitute with `echo` to keep CI hermetic. +pub(crate) const BASIC_MANIFEST: &str = r#" +[app] +name = "demo-app" +entry = "crates/demo-core" + +[adapters.fastly.adapter] +crate = "crates/demo-fastly" +manifest = "crates/demo-fastly/fastly.toml" + +[adapters.fastly.build] +target = "wasm32-unknown-unknown" +profile = "release" + +[adapters.fastly.commands] +build = "echo build" +deploy = "echo deploy" +serve = "echo serve" +auth-login = "echo logged in" +auth-logout = "echo logged out" +auth-status = "echo whoami" +"#; + +/// RAII guard that sets a process-global env var for the duration +/// of a test and restores the prior value (or removes it) on drop. +/// Use together with [`manifest_guard`] when overriding +/// `EDGEZERO_MANIFEST` so concurrent tests don't observe the +/// override. +pub(crate) struct EnvOverride { + key: &'static str, + original: Option, +} + +impl Drop for EnvOverride { + fn drop(&mut self) { + if let Some(original) = &self.original { + env::set_var(self.key, original); + } else { + env::remove_var(self.key); + } + } +} + +impl EnvOverride { + /// Remove the env var (if set) for the duration of the test + /// scope, capturing the prior value so drop can restore it. + /// Use when a test needs the "no override" code path but the + /// parent shell may have exported a value. + pub(crate) fn remove(key: &'static str) -> Self { + let original = env::var(key).ok(); + env::remove_var(key); + Self { key, original } + } + + /// Set the env var to `value` for the duration of the test + /// scope, capturing the prior value so drop can restore it. + pub(crate) fn set(key: &'static str, value: &str) -> Self { + let original = env::var(key).ok(); + env::set_var(key, value); + Self { key, original } + } +} + +/// Process-wide mutex serialising tests that mutate `EDGEZERO_MANIFEST` +/// or otherwise observe global adapter-registry state. Acquire it +/// BEFORE constructing the `EnvOverride` so two parallel tests +/// don't race the env-var write. +pub(crate) fn manifest_guard() -> &'static Mutex<()> { + static GUARD: OnceLock> = OnceLock::new(); + GUARD.get_or_init(|| Mutex::new(())) +} diff --git a/crates/edgezero-cli/tests/generated_project_builds.rs b/crates/edgezero-cli/tests/generated_project_builds.rs new file mode 100644 index 00000000..adae2db7 --- /dev/null +++ b/crates/edgezero-cli/tests/generated_project_builds.rs @@ -0,0 +1,160 @@ +//! Opt-in integration test: a freshly scaffolded project compiles. +//! +//! Ignored by default — it runs `cargo check` on a generated workspace +//! (host plus each adapter's wasm target), which recompiles the edgezero +//! stack and may fetch crates (minutes, not milliseconds). The fast +//! `generator` unit tests assert that the scaffold resolves edgezero crates +//! to local path dependencies; this test additionally proves the generated +//! workspace — the CLI crate that imports `edgezero_cli`, and the +//! target-gated adapter entrypoints — compiles end to end. +//! +//! Run it explicitly (and in CI): +//! +//! ```sh +//! cargo test -p edgezero-cli --test generated_project_builds -- --ignored +//! ``` + +#[cfg(test)] +mod tests { + use std::path::Path; + use std::process::Command; + + /// Targets installed for the toolchain that builds `project`. A wasm + /// check is skipped when its target is absent (e.g. a local run where + /// the project sits outside a checkout that pins the wasm targets); CI + /// installs both wasm targets, so the full set always runs there. + fn installed_targets(project: &Path) -> String { + Command::new("rustup") + .args(["target", "list", "--installed"]) + .current_dir(project) + .output() + .map(|out| String::from_utf8_lossy(&out.stdout).into_owned()) + .unwrap_or_default() + } + + #[test] + #[ignore = "compiles a generated workspace and may fetch crates; run explicitly"] + #[expect( + clippy::print_stderr, + reason = "an opt-in test surfacing a skipped wasm check" + )] + fn generated_workspace_compiles() { + let temp = tempfile::tempdir().expect("temp dir"); + let new_status = Command::new(env!("CARGO_BIN_EXE_edgezero")) + .arg("new") + .arg("scaffold-probe") + .arg("--dir") + .arg(temp.path()) + .status() + .expect("run `edgezero new`"); + assert!(new_status.success(), "`edgezero new` should succeed"); + + let project = temp.path().join("scaffold-probe"); + + // The scaffold's `edgezero.toml` + `.toml` + AppConfig + // must be internally consistent (no `#[secret]` field + // without a matching `[stores.secrets]`, no env-overlay + // mismatches). `edgezero config validate` exercises the + // typed config validator end-to-end. We do this BEFORE + // `cargo check` so a manifest/config drift surfaces as a + // fast, clear error -- not as a compilation cascade from + // a downstream macro tripping over the bad config. + let validate = Command::new(env!("CARGO_BIN_EXE_edgezero")) + .args(["config", "validate"]) + .current_dir(&project) + .status() + .expect("run `edgezero config validate` on the generated workspace"); + assert!( + validate.success(), + "generated workspace should pass `edgezero config validate`", + ); + + // Also exercise --strict so the capability matrix + // (`strict_capability_completeness`) and the handler-path + // rule (`strict_handler_paths`) fire against a freshly + // generated project. A scaffold that emits a triggers list + // with a malformed handler or a manifest that violates the + // adapter capability matrix would silently pass plain + // validate but fail under strict. + let validate_strict = Command::new(env!("CARGO_BIN_EXE_edgezero")) + .args(["config", "validate", "--strict"]) + .current_dir(&project) + .status() + .expect("run `edgezero config validate --strict` on the generated workspace"); + assert!( + validate_strict.success(), + "generated workspace should pass `edgezero config validate --strict`", + ); + + // Host target: the whole workspace, including the generated CLI + // crate that imports `edgezero_cli`. + let host = Command::new(env!("CARGO")) + .args(["check", "--workspace"]) + .current_dir(&project) + .status() + .expect("run `cargo check` on the generated workspace"); + assert!( + host.success(), + "generated workspace should compile for the host target", + ); + + // Typed config validation via the generated `-cli` binary. + // The raw `edgezero config validate` above exercises the manifest + // schema and capability matrix; the generated CLI additionally + // runs the user's `#[derive(Validate)]` impl on `AppConfig` plus + // the `#[app]` macro-emitted `#[secret]` discovery. Without this + // step, template drift that compiles but produces an invalid + // typed config (e.g. `#[secret]` on a non-scalar field) would + // slip through. + let typed_validate = Command::new(env!("CARGO")) + .args([ + "run", + "-p", + "scaffold-probe-cli", + "--quiet", + "--", + "config", + "validate", + "--strict", + ]) + .current_dir(&project) + .status() + .expect("run the generated typed CLI `config validate --strict`"); + assert!( + typed_validate.success(), + "generated typed CLI should pass `config validate --strict`", + ); + + // Per-adapter wasm targets: where target-gated template code lives + // (entrypoint signatures, macro-generated unsafe exports). + let targets = installed_targets(&project); + for (adapter, target) in [ + ("cloudflare", "wasm32-unknown-unknown"), + ("fastly", "wasm32-wasip1"), + ("spin", "wasm32-wasip2"), + ] { + if !targets.contains(target) { + eprintln!("skipping {adapter} wasm check: target {target} not installed"); + continue; + } + let crate_name = format!("scaffold-probe-adapter-{adapter}"); + let wasm = Command::new(env!("CARGO")) + .args([ + "check", + "-p", + &crate_name, + "--target", + target, + "--features", + adapter, + ]) + .current_dir(&project) + .status() + .expect("run `cargo check` for a wasm adapter target"); + assert!( + wasm.success(), + "generated {adapter} adapter should compile for {target}", + ); + } + } +} diff --git a/crates/edgezero-cli/tests/lib_consumer.rs b/crates/edgezero-cli/tests/lib_consumer.rs new file mode 100644 index 00000000..d164f911 --- /dev/null +++ b/crates/edgezero-cli/tests/lib_consumer.rs @@ -0,0 +1,68 @@ +//! External-consumer integration test. +//! +//! Exercises the `edgezero_cli` public API exactly as a downstream +//! binary would — proving the library surface (`args::BuildArgs`, +//! `run_build`) is usable from outside the crate. +//! +//! This module deliberately contains exactly one `#[test]`: it mutates +//! the process-global `EDGEZERO_MANIFEST` env var, and a single test +//! means no in-binary parallelism on it. If a second env-touching test +//! is ever added here, gate both with a shared `Mutex` guard. + +#[cfg(test)] +mod tests { + use edgezero_cli::args::BuildArgs; + use edgezero_cli::run_build; + use std::env; + use std::fs; + use tempfile::TempDir; + + const BASIC_MANIFEST: &str = r#" +[app] +name = "consumer-app" +entry = "crates/consumer-core" + +[adapters.fastly.commands] +build = "echo build" +deploy = "echo deploy" +serve = "echo serve" +"#; + + /// RAII guard that restores `EDGEZERO_MANIFEST` to its prior value on drop. + struct EnvOverride { + original: Option, + } + + impl Drop for EnvOverride { + fn drop(&mut self) { + match &self.original { + Some(value) => env::set_var("EDGEZERO_MANIFEST", value), + None => env::remove_var("EDGEZERO_MANIFEST"), + } + } + } + + impl EnvOverride { + fn set(value: &str) -> Self { + let original = env::var("EDGEZERO_MANIFEST").ok(); + env::set_var("EDGEZERO_MANIFEST", value); + Self { original } + } + } + + #[cfg(not(windows))] + #[test] + fn external_consumer_can_call_run_build() { + let temp = TempDir::new().expect("temp dir"); + let manifest_path = temp.path().join("edgezero.toml"); + fs::write(&manifest_path, BASIC_MANIFEST).expect("write manifest"); + let _env = EnvOverride::set(&manifest_path.to_string_lossy()); + + // Construct via `Default` + field mutation — the path that works for + // an external crate even though `BuildArgs` is `#[non_exhaustive]`. + let mut args = BuildArgs::default(); + args.adapter = "fastly".to_owned(); + + run_build(&args).expect("external consumer can run_build"); + } +} diff --git a/crates/edgezero-core/src/addr.rs b/crates/edgezero-core/src/addr.rs index 628d68a4..50aa2130 100644 --- a/crates/edgezero-core/src/addr.rs +++ b/crates/edgezero-core/src/addr.rs @@ -20,7 +20,7 @@ pub struct BindAddrResolution { /// Resolve a bind address from optional environment and config values. /// /// Precedence (highest wins): -/// 1. `env_host` / `env_port` (typically `EDGEZERO_HOST` / `EDGEZERO_PORT`) +/// 1. `env_host` / `env_port` (typically `EDGEZERO__ADAPTER__HOST` / `EDGEZERO__ADAPTER__PORT`) /// 2. `config_host` / `config_port` (from manifest or adapter config) /// 3. Defaults: `127.0.0.1:8787` /// @@ -52,7 +52,7 @@ fn resolve_host( match value.parse() { Ok(host) => return host, Err(_) => warnings.push(format!( - "EDGEZERO_HOST={value:?} is not a valid IP address (hostnames like \"localhost\" are not supported); falling back" + "EDGEZERO__ADAPTER__HOST={value:?} is not a valid IP address (hostnames like \"localhost\" are not supported); falling back" )), } } @@ -77,12 +77,12 @@ fn resolve_port( if let Some(value) = env_port { match value.parse::() { Ok(0) => warnings.push( - "EDGEZERO_PORT=\"0\" is not supported (would bind to a random OS port); falling back" + "EDGEZERO__ADAPTER__PORT=\"0\" is not supported (would bind to a random OS port); falling back" .to_owned(), ), Ok(port) => return port, Err(_) => warnings.push(format!( - "EDGEZERO_PORT={value:?} is not a valid port number; falling back" + "EDGEZERO__ADAPTER__PORT={value:?} is not a valid port number; falling back" )), } } @@ -147,7 +147,7 @@ mod tests { let resolution = resolve_bind_addr(Some("not-an-ip"), None, Some("0.0.0.0"), None); assert_eq!(resolution.addr.ip(), IpAddr::V4(Ipv4Addr::UNSPECIFIED)); assert_eq!(resolution.warnings.len(), 1); - assert!(resolution.warnings[0].contains("EDGEZERO_HOST")); + assert!(resolution.warnings[0].contains("EDGEZERO__ADAPTER__HOST")); assert!(resolution.warnings[0].contains("not a valid IP address")); } @@ -156,7 +156,7 @@ mod tests { let resolution = resolve_bind_addr(None, Some("abc"), None, Some(3000)); assert_eq!(resolution.addr.port(), 3000); assert_eq!(resolution.warnings.len(), 1); - assert!(resolution.warnings[0].contains("EDGEZERO_PORT")); + assert!(resolution.warnings[0].contains("EDGEZERO__ADAPTER__PORT")); assert!(resolution.warnings[0].contains("not a valid port number")); } diff --git a/crates/edgezero-core/src/app.rs b/crates/edgezero-core/src/app.rs index 11606c45..ea5ac161 100644 --- a/crates/edgezero-core/src/app.rs +++ b/crates/edgezero-core/src/app.rs @@ -74,73 +74,31 @@ impl App { } } -/// Adapter-specific config-store override metadata generated from `[stores.config.adapters.*]`. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct ConfigStoreAdapterMetadata { - adapter: &'static str, - name: &'static str, +/// Compile-time metadata for one logical store kind, baked by the `app!` macro. +/// +/// Carries only the portable facts declared in `[stores.]`: the logical +/// store ids and the resolved default. Platform names are resolved at runtime +/// from `EDGEZERO__STORES__*` environment variables. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct StoreMetadata { + /// Resolved default logical store id. + pub default: &'static str, + /// All declared logical store ids (non-empty). + pub ids: &'static [&'static str], } -impl ConfigStoreAdapterMetadata { - #[must_use] - #[inline] - pub fn adapter(&self) -> &'static str { - self.adapter - } - - #[must_use] - #[inline] - pub fn name(&self) -> &'static str { - self.name - } - - #[must_use] - #[inline] - pub const fn new(adapter: &'static str, name: &'static str) -> Self { - Self { adapter, name } - } -} - -/// Provider-neutral config-store metadata generated from `[stores.config]`. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct ConfigStoreMetadata { - adapters: &'static [ConfigStoreAdapterMetadata], - default_name: &'static str, -} - -impl ConfigStoreMetadata { - #[must_use] - #[inline] - pub fn adapters(&self) -> &'static [ConfigStoreAdapterMetadata] { - self.adapters - } - - #[must_use] - #[inline] - pub fn default_name(&self) -> &'static str { - self.default_name - } - - #[must_use] - #[inline] - pub fn name_for_adapter(&self, adapter: &str) -> &'static str { - self.adapters - .iter() - .find(|entry| entry.adapter.eq_ignore_ascii_case(adapter)) - .map_or(self.default_name, |entry| entry.name) - } - - #[must_use] - #[inline] - pub const fn new( - default_name: &'static str, - adapters: &'static [ConfigStoreAdapterMetadata], - ) -> Self { - Self { - adapters, - default_name, - } - } +/// Portable store config baked into the `App` by the `app!` macro. +/// +/// A `Hooks` implementation built without the macro leaves every field `None`, +/// so a downstream binary compiles and runs with no `edgezero.toml` present. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct StoresMetadata { + /// `[stores.config]` declaration, if present. + pub config: Option, + /// `[stores.kv]` declaration, if present. + pub kv: Option, + /// `[stores.secrets]` declaration, if present. + pub secrets: Option, } /// Trait implemented by application hook adapters. @@ -157,15 +115,6 @@ pub trait Hooks { app } - /// Structured config-store metadata for the application, if declared. - /// - /// Macro-generated apps derive this from `[stores.config]` in `edgezero.toml`. - #[must_use] - #[inline] - fn config_store() -> Option<&'static ConfigStoreMetadata> { - None - } - /// Allow implementations to mutate the freshly constructed application before use. /// The default implementation performs no changes. #[inline] @@ -180,6 +129,17 @@ pub trait Hooks { /// Build the router service for the application. fn routes() -> RouterService; + + /// Portable store metadata for the application. + /// + /// Macro-generated apps derive this from `[stores.*]` in `edgezero.toml`. + /// The default is empty, so an `App` built without the `app!` macro — and a + /// downstream binary built without an `edgezero.toml` — still compiles. + #[must_use] + #[inline] + fn stores() -> StoresMetadata { + StoresMetadata::default() + } } #[cfg(test)] @@ -204,6 +164,10 @@ mod tests { fn routes() -> RouterService { RouterService::builder().build() } + + fn stores() -> StoresMetadata { + StoresMetadata::default() + } } #[expect( @@ -211,17 +175,6 @@ mod tests { reason = "test stub — `build_app` intentionally uses the trait default; other methods are overridden for test coverage" )] impl Hooks for TestHooks { - fn config_store() -> Option<&'static ConfigStoreMetadata> { - static CONFIG_STORE: ConfigStoreMetadata = ConfigStoreMetadata::new( - "default-config", - &[ConfigStoreAdapterMetadata::new( - CLOUDFLARE_ADAPTER, - "cf-config", - )], - ); - Some(&CONFIG_STORE) - } - fn configure(app: &mut App) { app.set_name("configured"); } @@ -237,6 +190,20 @@ mod tests { RouterService::builder().get("/test", handler).build() } + + fn stores() -> StoresMetadata { + StoresMetadata { + config: Some(StoreMetadata { + default: "app_config", + ids: &["app_config"], + }), + kv: Some(StoreMetadata { + default: "sessions", + ids: &["sessions", "cache"], + }), + secrets: None, + } + } } fn empty_router() -> RouterService { @@ -247,12 +214,14 @@ mod tests { fn build_app_invokes_hooks_for_routes_and_configuration() { let app = TestHooks::build_app(); assert_eq!(app.name(), "configured"); - let config = TestHooks::config_store().expect("config store metadata"); - assert_eq!(config.name_for_adapter(CLOUDFLARE_ADAPTER), "cf-config"); - assert_eq!(config.name_for_adapter("CLOUDFLARE"), "cf-config"); - assert_eq!(config.name_for_adapter(FASTLY_ADAPTER), "default-config"); - assert_eq!(config.default_name(), "default-config"); - assert_eq!(config.adapters().len(), 1); + let stores = TestHooks::stores(); + let config = stores.config.expect("config store metadata"); + assert_eq!(config.default, "app_config"); + assert_eq!(config.ids, &["app_config"]); + let kv = stores.kv.expect("kv store metadata"); + assert_eq!(kv.default, "sessions"); + assert_eq!(kv.ids, &["sessions", "cache"]); + assert!(stores.secrets.is_none()); let request = request_builder() .method(Method::GET) @@ -275,7 +244,7 @@ mod tests { fn default_hooks_use_default_name_and_into_router() { let app = DefaultHooks::build_app(); assert_eq!(app.name(), App::default_name()); - assert_eq!(DefaultHooks::config_store(), None); + assert_eq!(DefaultHooks::stores(), StoresMetadata::default()); let router = app.into_router(); assert!(router.routes().is_empty()); } diff --git a/crates/edgezero-core/src/app_config.rs b/crates/edgezero-core/src/app_config.rs new file mode 100644 index 00000000..cf09b117 --- /dev/null +++ b/crates/edgezero-core/src/app_config.rs @@ -0,0 +1,807 @@ +//! Typed app-config loading. +//! +//! Loader for downstream `.toml` files (e.g. `app-demo.toml`). +//! Reads the file's top-level table verbatim — there is no `[config]` +//! wrapper — optionally applies the `__
__…` +//! env-var overlay, and either: +//! +//! - Deserialises into a downstream `C: DeserializeOwned + Validate` +//! and runs `validator::Validate::validate()` — +//! [`load_app_config`] / [`load_app_config_with_options`]. +//! - Returns the parsed root table as raw `toml::Value` for tools +//! that don't have access to the typed struct (the raw `config +//! push` flow) — [`load_app_config_raw`] / +//! [`load_app_config_raw_with_options`]. + +use std::any; +use std::collections::HashMap; +use std::env; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +use serde::de::DeserializeOwned; +use thiserror::Error; +use toml::de::Error as TomlDeError; +use toml::value::Datetime; +use toml::Value; +use validator::{Validate, ValidationErrors}; + +/// Per-field metadata emitted by `#[derive(AppConfig)]`. The +/// derive enumerates every field annotated with `#[secret]` / +/// `#[secret(store_ref)]`; `config validate` and `config push` +/// reflect over this array to gate secret-aware behaviour. +pub trait AppConfigMeta { + /// Every `#[secret]` / `#[secret(store_ref)]` field on the struct. + const SECRET_FIELDS: &'static [SecretField]; +} + +/// One field's worth of secret-annotation metadata. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct SecretField { + /// Whether the field's value is a key in the default secret store + /// or the logical id of a `[stores.secrets]` entry. + pub kind: SecretKind, + /// Rust field name verbatim (no `serde(rename)` translation — + /// `#[secret]` rejects renames at compile time). + pub name: &'static str, +} + +/// Discriminator on a [`SecretField`] capturing which secret-store +/// resolution the field participates in. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SecretKind { + /// `#[secret]` — the field's value is a key in the resolved + /// default secret store. + KeyInDefault, + /// `#[secret(store_ref)]` — the field's value is the logical id + /// of a `[stores.secrets]` declaration. + StoreRef, +} + +/// Options for the app-config loader. +/// +/// Constructed with `Default::default()` (overlay on) by the simple +/// loader functions; `--no-env` on the CLI flips `env_overlay` to +/// `false`. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub struct AppConfigLoadOptions { + /// When `true`, apply the `__…__` env-var overlay + /// after parsing the file's root table; when `false`, the parsed + /// values are used as-is. + pub env_overlay: bool, +} + +impl Default for AppConfigLoadOptions { + #[inline] + fn default() -> Self { + Self { env_overlay: true } + } +} + +/// Errors returned by the app-config loader. +/// +/// The TOML errors are boxed because `toml::de::Error` is large and a +/// fat `Err` variant would inflate every `Result` on the loader's +/// hot path (`clippy::result_large_err`). +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum AppConfigError { + /// Deserialising the file's top-level table into the typed `C` + /// failed — missing required fields, wrong types, unknown fields + /// (when the struct opts in to `#[serde(deny_unknown_fields)]`), + /// etc. + #[error("failed to deserialise {} into {target_type}: {source}", path.display())] + Deserialize { + path: PathBuf, + target_type: &'static str, + #[source] + source: Box, + }, + /// The env-overlay step failed — ambiguous sibling-key + /// mapping, value not parseable against the existing TOML type, + /// etc. + #[error("env overlay failed for {}: {message}", path.display())] + EnvOverlay { path: PathBuf, message: String }, + /// Failed to read the on-disk file (missing, permission denied, + /// etc.). + #[error("failed to read {}: {source}", path.display())] + Io { + path: PathBuf, + #[source] + source: io::Error, + }, + /// The file exists but is not valid TOML. + #[error("failed to parse {} as TOML: {source}", path.display())] + Parse { + path: PathBuf, + #[source] + source: Box, + }, + /// `validator::Validate::validate()` rejected the parsed values + /// (range / length / regex / custom validators). + #[error("validation failed for {}: {source}", path.display())] + Validation { + path: PathBuf, + #[source] + source: Box, + }, +} + +/// Env-var lookup abstracted over the process env so tests can stub +/// it without manipulating `std::env`. +struct EnvLookup { + vars: HashMap, +} + +impl EnvLookup { + #[cfg(test)] + fn from_pairs(pairs: I) -> Self + where + I: IntoIterator, + K: Into, + V: Into, + { + Self { + vars: pairs + .into_iter() + .map(|(key, val)| (key.into(), val.into())) + .collect(), + } + } + + fn from_process_env() -> Self { + Self { + vars: env::vars().collect(), + } + } + + fn get(&self, key: &str) -> Option<&str> { + self.vars.get(key).map(String::as_str) + } +} + +/// Load and validate a typed app-config from `.toml`. +/// +/// `env_overlay` is on by default; pass [`AppConfigLoadOptions`] +/// explicitly via [`load_app_config_with_options`] to disable it. +/// +/// `app_name` is `[app].name` (uppercased + `-`→`_`) used as the env-var +/// prefix when the overlay is on. It is accepted (not derived from the +/// file) so the loader is decoupled from manifest discovery — callers +/// (`config validate`, `config push`, the axum demo server) already have +/// it. +/// +/// # Errors +/// See [`AppConfigError`]. +#[inline] +pub fn load_app_config(path: &Path, app_name: &str) -> Result +where + C: DeserializeOwned + Validate + AppConfigMeta, +{ + load_app_config_with_options(path, app_name, &AppConfigLoadOptions::default()) +} + +/// [`load_app_config`] with an explicit [`AppConfigLoadOptions`]. +/// +/// # Errors +/// See [`AppConfigError`]. +#[inline] +pub fn load_app_config_with_options( + path: &Path, + app_name: &str, + opts: &AppConfigLoadOptions, +) -> Result +where + C: DeserializeOwned + Validate + AppConfigMeta, +{ + let config_table = load_app_config_raw_with_options(path, app_name, opts)?; + let typed: C = + config_table + .try_into() + .map_err(|source: TomlDeError| AppConfigError::Deserialize { + path: path.to_path_buf(), + target_type: any::type_name::(), + source: Box::new(source), + })?; + typed + .validate() + .map_err(|source| AppConfigError::Validation { + path: path.to_path_buf(), + source: Box::new(source), + })?; + Ok(typed) +} + +/// Read the file's root table as a raw `toml::Value`, with the env +/// overlay applied (when on). Used by `config push` and +/// other tools that don't have access to the typed struct. +/// +/// # Errors +/// See [`AppConfigError`]. +#[inline] +pub fn load_app_config_raw(path: &Path, app_name: &str) -> Result { + load_app_config_raw_with_options(path, app_name, &AppConfigLoadOptions::default()) +} + +/// [`load_app_config_raw`] with an explicit [`AppConfigLoadOptions`]. +/// +/// # Errors +/// See [`AppConfigError`]. +#[inline] +pub fn load_app_config_raw_with_options( + path: &Path, + app_name: &str, + opts: &AppConfigLoadOptions, +) -> Result { + let raw = fs::read_to_string(path).map_err(|source| AppConfigError::Io { + path: path.to_path_buf(), + source, + })?; + let mut document: Value = toml::from_str(&raw).map_err(|source| AppConfigError::Parse { + path: path.to_path_buf(), + source: Box::new(source), + })?; + if opts.env_overlay { + apply_env_overlay(&mut document, app_name, path)?; + } + Ok(document) +} + +/// Apply the `__
__…__` env-var overlay +/// against the parsed root table. +/// +/// The overlay only overrides keys that already exist in the parsed +/// tree (the existing TOML value's type drives coercion of the env +/// string). Two sibling keys mapping to the same env segment is an +/// `AppConfigError::EnvOverlay`; a string that can't be coerced to +/// the existing type is also an `EnvOverlay` error. +fn apply_env_overlay( + config_table: &mut Value, + app_name: &str, + path: &Path, +) -> Result<(), AppConfigError> { + let prefix = app_name_prefix(app_name); + let lookup = EnvLookup::from_process_env(); + walk_and_overlay(config_table, &prefix, &lookup, path) +} + +/// Normalise an app name to the env-var prefix (`` form +/// from): uppercase, `-`→`_`. A single leading `_` from a +/// project name that starts with a digit is preserved. +/// +/// Exposed as `pub` so the scaffold generator can mirror this rule +/// exactly when emitting `{{EnvPrefix}}__...` documentation -- if +/// the two derivations drift, operators see env-var spellings the +/// runtime silently ignores. +#[must_use] +#[inline] +pub fn app_name_prefix(app_name: &str) -> String { + app_name.to_ascii_uppercase().replace('-', "_") +} + +/// Parse `raw` (env string) into the same `toml::Value` variant as +/// `existing`. Parse failure → `AppConfigError::EnvOverlay`. +fn coerce_env_value( + existing: &Value, + raw: &str, + env_var: &str, + path: &Path, +) -> Result { + let coerced = match existing { + Value::String(_) => Value::String(raw.to_owned()), + Value::Integer(_) => raw + .parse::() + .map(Value::Integer) + .map_err(|err| coercion_error(env_var, raw, "integer", &err.to_string(), path))?, + Value::Float(_) => raw + .parse::() + .map(Value::Float) + .map_err(|err| coercion_error(env_var, raw, "float", &err.to_string(), path))?, + Value::Boolean(_) => match raw { + "true" | "1" => Value::Boolean(true), + "false" | "0" => Value::Boolean(false), + other => { + return Err(coercion_error( + env_var, + other, + "boolean (true/false/1/0)", + "expected true/false/1/0", + path, + )); + } + }, + Value::Datetime(_) => raw + .parse::() + .map(Value::Datetime) + .map_err(|err| coercion_error(env_var, raw, "datetime", &err.to_string(), path))?, + Value::Array(_) | Value::Table(_) => { + return Err(AppConfigError::EnvOverlay { + path: path.to_path_buf(), + message: format!( + "env var `{env_var}` cannot override array / table values — \ + env overlay supports scalar leaves only" + ), + }); + } + }; + Ok(coerced) +} + +fn coercion_error( + env_var: &str, + raw: &str, + target: &str, + detail: &str, + path: &Path, +) -> AppConfigError { + AppConfigError::EnvOverlay { + path: path.to_path_buf(), + message: format!("env var `{env_var}={raw}` cannot be coerced to {target}: {detail}"), + } +} + +/// Translate a config field name into its env-segment form: +/// uppercase, `_` left as-is. Sibling keys that produce the same +/// segment are rejected by the caller as ambiguous. +fn env_segment(field_name: &str) -> String { + field_name.to_ascii_uppercase() +} + +fn walk_and_overlay( + node: &mut Value, + env_prefix: &str, + lookup: &EnvLookup, + path: &Path, +) -> Result<(), AppConfigError> { + let Value::Table(table) = node else { + return Ok(()); + }; + + // Reject keys containing `__` — that's the env-overlay segment + // separator, so `foo__bar` (scalar at this level) would alias + // `[foo] bar` (nested table reaching the same leaf). Both build + // env path `__FOO__BAR` and a single env var would + // ambiguously override both leaves. Catch the source-level + // collision instead of trying to disambiguate at overlay time. + // Mirrors the manifest-side `store_id_format` rule that rejects + // `__` in `[stores.].ids` for the same reason. + for key in table.keys() { + if key.contains("__") { + return Err(AppConfigError::EnvOverlay { + path: path.to_path_buf(), + message: format!( + "config key `{key}` contains `__` (double underscore), which is reserved \ + as the env-overlay segment separator under prefix `{env_prefix}__…`. A key like \ + `foo__bar` would alias the nested `[foo] bar` leaf — a single env var would \ + override both. Rename it to a single-underscore form (`foo_bar`) or split it \ + into nested tables." + ), + }); + } + } + + // Detect ambiguous sibling-key mappings before applying any + // overlay so a failure leaves the table untouched. + let mut segment_owners: HashMap = HashMap::new(); + for key in table.keys() { + let segment = env_segment(key); + if let Some(prior) = segment_owners.insert(segment.clone(), key.clone()) { + return Err(AppConfigError::EnvOverlay { + path: path.to_path_buf(), + message: format!( + "sibling config keys `{prior}` and `{key}` both map to env segment \ + `{segment}` under prefix `{env_prefix}__…`; rename one to disambiguate" + ), + }); + } + } + + // Iterate over a snapshot of the keys so we can mutate `table` + // inside the loop without borrowing it twice. + let snapshot: Vec = table.keys().cloned().collect(); + for key in snapshot { + let segment = env_segment(&key); + let next_prefix = format!("{env_prefix}__{segment}"); + let Some(value) = table.get_mut(&key) else { + continue; + }; + match value { + Value::Table(_) => walk_and_overlay(value, &next_prefix, lookup, path)?, + Value::String(_) + | Value::Integer(_) + | Value::Float(_) + | Value::Boolean(_) + | Value::Datetime(_) + | Value::Array(_) => { + if let Some(raw) = lookup.get(&next_prefix) { + *value = coerce_env_value(value, raw, &next_prefix, path)?; + } + } + } + } + Ok(()) +} + +#[cfg(test)] +#[expect( + clippy::default_numeric_fallback, + clippy::wildcard_enum_match_arm, + reason = "test fixtures: `validator` range bounds default to the field's int type; \ + match arms in `expect_err` assertions intentionally collapse all unexpected \ + variants into a single panic" +)] +mod tests { + use super::*; + use serde::Deserialize; + use std::io::Write as _; + use tempfile::NamedTempFile; + + // `AppConfigMeta` is hand-impl'd here rather than derived: the + // `#[derive(AppConfig)]` proc macro emits absolute paths + // (`::edgezero_core::…`) that don't resolve inside the defining + // crate's own modules. The downstream integration test in + // `edgezero-macros/tests/app_config_derive.rs` exercises the derive + // itself; this fixture only needs the trait bound to satisfy + // `load_app_config`. + #[derive(Debug, Deserialize, Validate, PartialEq)] + #[serde(deny_unknown_fields)] + struct FixtureConfig { + greeting: String, + #[validate(range(min = 100, max = 60_000))] + timeout_ms: u32, + } + + impl AppConfigMeta for FixtureConfig { + const SECRET_FIELDS: &'static [SecretField] = &[]; + } + + fn write_fixture(contents: &str) -> NamedTempFile { + let mut file = NamedTempFile::new().expect("tempfile"); + file.write_all(contents.as_bytes()).expect("write"); + file + } + + #[test] + fn load_app_config_round_trips_a_valid_file() { + let file = write_fixture( + r#" +greeting = "hello" +timeout_ms = 1500 +"#, + ); + let cfg: FixtureConfig = load_app_config(file.path(), "fixture").expect("load"); + assert_eq!( + cfg, + FixtureConfig { + greeting: "hello".to_owned(), + timeout_ms: 1500, + } + ); + } + + #[test] + fn load_app_config_errors_with_io_variant_for_missing_file() { + let path = PathBuf::from("/definitely/not/a/real/path/app.toml"); + let err = load_app_config::(&path, "fixture") + .expect_err("missing file must error"); + assert!( + matches!(err, AppConfigError::Io { .. }), + "expected Io variant, got {err:?}" + ); + } + + #[test] + fn load_app_config_errors_with_parse_variant_for_bad_toml() { + let file = write_fixture("{not toml"); + let err = load_app_config::(file.path(), "fixture") + .expect_err("bad TOML must error"); + assert!( + matches!(err, AppConfigError::Parse { .. }), + "expected Parse variant, got {err:?}" + ); + } + + #[test] + fn load_app_config_errors_with_deserialize_variant_for_unknown_fields() { + let file = write_fixture( + r#" +greeting = "hello" +timeout_ms = 1500 +extra_unknown = "rejected by deny_unknown_fields" +"#, + ); + let err = load_app_config::(file.path(), "fixture") + .expect_err("unknown field must error"); + assert!( + matches!(err, AppConfigError::Deserialize { .. }), + "expected Deserialize variant, got {err:?}" + ); + } + + #[test] + fn load_app_config_errors_with_validation_variant() { + // `timeout_ms = 99` violates `range(min = 100, ..)`. + let file = write_fixture( + r#" +greeting = "hello" +timeout_ms = 99 +"#, + ); + let err = load_app_config::(file.path(), "fixture") + .expect_err("validation must error"); + assert!( + matches!(err, AppConfigError::Validation { .. }), + "expected Validation variant, got {err:?}" + ); + } + + #[test] + fn load_app_config_raw_returns_the_root_table() { + let file = write_fixture( + r#" +greeting = "hello" + +[service] +timeout_ms = 1500 +"#, + ); + let raw = load_app_config_raw(file.path(), "fixture").expect("load raw"); + let table = raw.as_table().expect("raw value is a table"); + assert_eq!(table.get("greeting").and_then(Value::as_str), Some("hello"),); + assert!( + table.get("service").and_then(Value::as_table).is_some(), + "nested [service] survives raw load" + ); + } + + #[test] + fn default_load_options_have_env_overlay_on() { + assert_eq!( + AppConfigLoadOptions::default(), + AppConfigLoadOptions { env_overlay: true } + ); + } + + // -- Env overlay ------------------------------------------------ + + fn parse_root_table(contents: &str) -> Value { + toml::from_str(contents).expect("parse fixture") + } + + fn overlay_with_lookup( + config_table: &mut Value, + app_name: &str, + pairs: &[(&str, &str)], + ) -> Result<(), AppConfigError> { + let lookup = EnvLookup::from_pairs(pairs.iter().copied()); + let prefix = app_name_prefix(app_name); + walk_and_overlay(config_table, &prefix, &lookup, Path::new("fixture.toml")) + } + + #[test] + fn env_overlay_overrides_top_level_string() { + let mut table = parse_root_table( + r#" +greeting = "hello" +"#, + ); + overlay_with_lookup(&mut table, "app-demo", &[("APP_DEMO__GREETING", "hola")]) + .expect("overlay"); + assert_eq!(table.get("greeting").and_then(Value::as_str), Some("hola")); + } + + #[test] + fn env_overlay_overrides_nested_integer_with_coercion() { + let mut table = parse_root_table( + " +[service] +timeout_ms = 1500 +", + ); + overlay_with_lookup( + &mut table, + "app-demo", + &[("APP_DEMO__SERVICE__TIMEOUT_MS", "3000")], + ) + .expect("overlay"); + assert_eq!( + table + .get("service") + .and_then(Value::as_table) + .and_then(|service| service.get("timeout_ms")) + .and_then(Value::as_integer), + Some(3000) + ); + } + + #[test] + fn env_overlay_coerces_boolean_from_true_false_or_numeric() { + for (raw, expected) in [("true", true), ("false", false), ("1", true), ("0", false)] { + let mut table = parse_root_table( + " +feature_new_checkout = false +", + ); + overlay_with_lookup( + &mut table, + "app-demo", + &[("APP_DEMO__FEATURE_NEW_CHECKOUT", raw)], + ) + .expect("overlay"); + assert_eq!( + table.get("feature_new_checkout").and_then(Value::as_bool), + Some(expected), + "raw={raw:?}" + ); + } + } + + #[test] + fn env_overlay_errors_when_value_cannot_be_coerced_to_existing_type() { + let mut table = parse_root_table( + " +[service] +timeout_ms = 1500 +", + ); + let err = overlay_with_lookup( + &mut table, + "app-demo", + &[("APP_DEMO__SERVICE__TIMEOUT_MS", "not-a-number")], + ) + .expect_err("non-numeric env value must error"); + match err { + AppConfigError::EnvOverlay { message, .. } => { + assert!( + message.contains("APP_DEMO__SERVICE__TIMEOUT_MS"), + "error names the env var: {message}" + ); + assert!( + message.contains("integer"), + "error names the target type: {message}" + ); + } + other => panic!("expected EnvOverlay variant, got {other:?}"), + } + } + + #[test] + fn env_overlay_rejects_sibling_keys_with_same_env_segment() { + // `greeting_a` and `GREETING_A` would both translate to env + // segment `GREETING_A` (uppercase). Since TOML keys are + // case-sensitive but env segments aren't, we need a guard. + let mut table = parse_root_table( + r#" +greeting_a = "lower" +GREETING_A = "upper" +"#, + ); + let err = overlay_with_lookup(&mut table, "app-demo", &[]) + .expect_err("ambiguous siblings must error"); + match err { + AppConfigError::EnvOverlay { message, .. } => { + assert!( + message.contains("GREETING_A"), + "names env segment: {message}" + ); + assert!( + message.contains("rename one to disambiguate"), + "explains the remediation: {message}" + ); + } + other => panic!("expected EnvOverlay variant, got {other:?}"), + } + } + + #[test] + fn env_overlay_rejects_scalar_key_containing_double_underscore() { + // PR #269 round 4 / F3: `foo__bar` (scalar) and + // `[foo] bar` (nested) both build env path + // `__FOO__BAR`. A single env var would + // ambiguously override both, so reject at the source. + let mut table = parse_root_table( + r#" +foo__bar = "ambiguous" +"#, + ); + let err = overlay_with_lookup(&mut table, "app-demo", &[]) + .expect_err("`__` in a config key must error"); + match err { + AppConfigError::EnvOverlay { message, .. } => { + assert!( + message.contains("`foo__bar`") + && message.contains("reserved") + && message.contains("env-overlay segment separator"), + "must explain the `__` collision: {message}" + ); + } + other => panic!("expected EnvOverlay variant, got {other:?}"), + } + } + + #[test] + fn env_overlay_rejects_double_underscore_in_nested_table_key_too() { + // The rule applies at every level, not just the root. A + // nested `[outer] inner__key` would alias + // `[outer.inner] key` under the same env path. + let mut table = parse_root_table( + r#" +[outer] +inner__key = "x" +"#, + ); + let err = overlay_with_lookup(&mut table, "app-demo", &[]) + .expect_err("nested `__` keys must also error"); + match err { + AppConfigError::EnvOverlay { message, .. } => { + assert!( + message.contains("`inner__key`"), + "must name the offending nested key: {message}" + ); + } + other => panic!("expected EnvOverlay variant, got {other:?}"), + } + } + + #[test] + fn env_overlay_disabled_skips_walker_entirely() { + // With `env_overlay: false`, even when the env var is set the + // parsed value is returned untouched. Uses a unique app-name + // prefix so the temporary env var can't leak into other + // tests run in parallel (cargo test does not isolate + // process env between threads). + let file = write_fixture( + r#" +greeting = "hello" +timeout_ms = 1500 +"#, + ); + let app_name = "overlay_disabled_test"; + let env_key = "OVERLAY_DISABLED_TEST__GREETING"; + env::set_var(env_key, "should-be-ignored"); + let cfg = load_app_config_with_options::( + file.path(), + app_name, + &AppConfigLoadOptions { env_overlay: false }, + ) + .expect("load"); + env::remove_var(env_key); + assert_eq!(cfg.greeting, "hello", "overlay disabled: file value wins"); + } + + #[test] + fn env_overlay_only_overrides_existing_keys() { + // An env var for a key that is not already present in the + // parsed table is silently ignored (the overlay never adds + // new keys — "env vars override existing keys only"). + let mut table = parse_root_table( + r#" +greeting = "hello" +"#, + ); + overlay_with_lookup( + &mut table, + "app-demo", + &[("APP_DEMO__UNKNOWN_KEY", "ignored")], + ) + .expect("overlay"); + assert!( + table.get("unknown_key").is_none(), + "overlay must not synthesise keys" + ); + assert_eq!( + table.get("greeting").and_then(Value::as_str), + Some("hello"), + "existing key untouched when no env var present" + ); + } + + #[test] + fn app_name_prefix_uppercases_and_translates_dash_to_underscore() { + assert_eq!(app_name_prefix("app-demo"), "APP_DEMO"); + assert_eq!(app_name_prefix("my_app"), "MY_APP"); + assert_eq!(app_name_prefix("a-b-c"), "A_B_C"); + } +} diff --git a/crates/edgezero-core/src/config_store.rs b/crates/edgezero-core/src/config_store.rs index 58950b93..67086233 100644 --- a/crates/edgezero-core/src/config_store.rs +++ b/crates/edgezero-core/src/config_store.rs @@ -1,12 +1,14 @@ //! Provider-neutral read-only configuration store abstraction. //! -//! All platforms expose config reads as synchronous operations, so no -//! `async_trait` is needed here. +//! `ConfigStore::get` is `async` because the Cloudflare config store reads +//! from a KV namespace whose `get` is JS-interop and asynchronous. Other +//! backends complete synchronously and resolve immediately. use std::fmt; use std::sync::Arc; use anyhow::Error as AnyError; +use async_trait::async_trait; use thiserror::Error; // --------------------------------------------------------------------------- @@ -27,13 +29,10 @@ use thiserror::Error; /// /// ```rust,ignore /// edgezero_core::config_store_contract_tests!(axum_config_store_contract, { -/// AxumConfigStore::new( -/// [ -/// ("contract.key.a".to_owned(), "value_a".to_owned()), -/// ("contract.key.b".to_owned(), "value_b".to_owned()), -/// ], -/// [], -/// ) +/// AxumConfigStore::from_map([ +/// ("contract.key.a".to_owned(), "value_a".to_owned()), +/// ("contract.key.b".to_owned(), "value_b".to_owned()), +/// ]) /// }); /// ``` #[macro_export] @@ -43,52 +42,72 @@ macro_rules! config_store_contract_tests { use super::*; use $crate::config_store::ConfigStore; + fn run(future: Fut) -> Fut::Output { + ::futures::executor::block_on(future) + } + #[$test_attr] fn contract_get_returns_value_for_existing_key() { let store = $factory; - assert_eq!( - store.get("contract.key.a").expect("config value"), - Some("value_a".to_owned()) - ); + run(async { + assert_eq!( + store.get("contract.key.a").await.expect("config value"), + Some("value_a".to_owned()) + ); + }); } #[$test_attr] fn contract_get_returns_none_for_missing_key() { let store = $factory; - assert_eq!(store.get("contract.key.missing").expect("config miss"), None); + run(async { + assert_eq!( + store.get("contract.key.missing").await.expect("config miss"), + None + ); + }); } #[$test_attr] fn contract_multiple_keys_are_independent() { let store = $factory; - assert_eq!( - store.get("contract.key.a").expect("first config value"), - Some("value_a".to_owned()) - ); - assert_eq!( - store.get("contract.key.b").expect("second config value"), - Some("value_b".to_owned()) - ); + run(async { + assert_eq!( + store.get("contract.key.a").await.expect("first config value"), + Some("value_a".to_owned()) + ); + assert_eq!( + store.get("contract.key.b").await.expect("second config value"), + Some("value_b".to_owned()) + ); + }); } #[$test_attr] fn contract_key_lookup_is_case_sensitive() { let store = $factory; - // lowercase "contract.key.a" exists; uppercase must not match - assert_eq!(store.get("CONTRACT.KEY.A").expect("case-sensitive miss"), None); + run(async { + // lowercase "contract.key.a" exists; uppercase must not match + assert_eq!( + store.get("CONTRACT.KEY.A").await.expect("case-sensitive miss"), + None + ); + }); } #[$test_attr] fn contract_empty_key_returns_none_or_invalid_key() { let store = $factory; - // Backends may either return Ok(None) or Err(InvalidKey) for an empty key. - // Fastly's Config Store SDK may reject empty keys rather than returning None. - match store.get("") { - Ok(None) => {} - Ok(Some(_)) => panic!("empty key should not return a value"), - Err($crate::config_store::ConfigStoreError::InvalidKey { .. }) => {} - Err(err) => panic!("unexpected error for empty key: {}", err), - } + run(async { + // Backends may either return Ok(None) or Err(InvalidKey) for an empty key. + // Fastly's Config Store SDK may reject empty keys rather than returning None. + match store.get("").await { + Ok(None) => {} + Ok(Some(_)) => panic!("empty key should not return a value"), + Err($crate::config_store::ConfigStoreError::InvalidKey { .. }) => {} + Err(err) => panic!("unexpected error for empty key: {}", err), + } + }); } #[$test_attr] @@ -97,11 +116,16 @@ macro_rules! config_store_contract_tests { use $crate::config_store::ConfigStoreHandle; let handle = ConfigStoreHandle::new(Arc::new($factory)); - assert_eq!( - handle.get("contract.key.a").expect("handle value"), - Some("value_a".to_owned()) - ); - assert_eq!(handle.get("contract.key.missing").expect("handle miss"), None); + run(async { + assert_eq!( + handle.get("contract.key.a").await.expect("handle value"), + Some("value_a".to_owned()) + ); + assert_eq!( + handle.get("contract.key.missing").await.expect("handle miss"), + None + ); + }); } #[$test_attr] @@ -111,14 +135,16 @@ macro_rules! config_store_contract_tests { let h1 = ConfigStoreHandle::new(Arc::new($factory)); let h2 = h1.clone(); - assert_eq!( - h1.get("contract.key.a").expect("first handle value"), - h2.get("contract.key.a").expect("second handle value") - ); - assert_eq!( - h1.get("contract.key.missing").expect("first handle miss"), - h2.get("contract.key.missing").expect("second handle miss") - ); + run(async { + assert_eq!( + h1.get("contract.key.a").await.expect("first handle value"), + h2.get("contract.key.a").await.expect("second handle value") + ); + assert_eq!( + h1.get("contract.key.missing").await.expect("first handle miss"), + h2.get("contract.key.missing").await.expect("second handle miss") + ); + }); } } }; @@ -182,14 +208,15 @@ impl ConfigStoreError { /// Implementations exist per adapter: /// - `AxumConfigStore` (axum adapter) — env vars + in-memory defaults for dev /// - `FastlyConfigStore` (fastly adapter) — Fastly Config Store -/// - `CloudflareConfigStore` (cloudflare adapter) — Cloudflare env bindings -/// - `SpinConfigStore` (spin adapter) — Spin component variables +/// - `CloudflareConfigStore` (cloudflare adapter) — Cloudflare KV namespace +/// - `SpinConfigStore` (spin adapter) — Spin KV (`spin_sdk::key_value::Store`) +#[async_trait(?Send)] pub trait ConfigStore: Send + Sync { /// Retrieve a config value by key. Returns `None` if the key does not exist. /// /// # Errors /// Returns [`ConfigStoreError`] if `key` is invalid or the backend is unavailable. - fn get(&self, key: &str) -> Result, ConfigStoreError>; + async fn get(&self, key: &str) -> Result, ConfigStoreError>; } // --------------------------------------------------------------------------- @@ -215,8 +242,8 @@ impl ConfigStoreHandle { /// # Errors /// Returns [`ConfigStoreError`] if `key` is invalid or the backend is unavailable. #[inline] - pub fn get(&self, key: &str) -> Result, ConfigStoreError> { - self.store.get(key) + pub async fn get(&self, key: &str) -> Result, ConfigStoreError> { + self.store.get(key).await } /// Create a new handle wrapping a config store implementation. @@ -239,6 +266,7 @@ mod tests { ); use super::*; + use futures::executor::block_on; use std::collections::HashMap; struct FailingConfigStore; @@ -247,14 +275,16 @@ mod tests { data: HashMap, } + #[async_trait(?Send)] impl ConfigStore for FailingConfigStore { - fn get(&self, _key: &str) -> Result, ConfigStoreError> { + async fn get(&self, _key: &str) -> Result, ConfigStoreError> { Err(ConfigStoreError::unavailable("backend offline")) } } + #[async_trait(?Send)] impl ConfigStore for TestConfigStore { - fn get(&self, key: &str) -> Result, ConfigStoreError> { + async fn get(&self, key: &str) -> Result, ConfigStoreError> { Ok(self.data.get(key).cloned()) } } @@ -278,7 +308,7 @@ mod tests { fn config_store_get_returns_none_for_missing_key() { let store_handle = handle(&[]); assert_eq!( - store_handle.get("nonexistent").expect("missing config"), + block_on(store_handle.get("nonexistent")).expect("missing config"), None ); } @@ -287,7 +317,7 @@ mod tests { fn config_store_get_returns_value_for_existing_key() { let store_handle = handle(&[("feature.checkout", "true")]); assert_eq!( - store_handle.get("feature.checkout").expect("config value"), + block_on(store_handle.get("feature.checkout")).expect("config value"), Some("true".to_owned()) ); } @@ -304,8 +334,8 @@ mod tests { let h1 = handle(&[("key", "val")]); let h2 = h1.clone(); assert_eq!( - h1.get("key").expect("first handle value"), - h2.get("key").expect("second handle value") + block_on(h1.get("key")).expect("first handle value"), + block_on(h2.get("key")).expect("second handle value") ); } @@ -314,7 +344,7 @@ mod tests { let store = Arc::new(TestConfigStore::new(&[("a", "1")])); let store_handle = ConfigStoreHandle::new(store); assert_eq!( - store_handle.get("a").expect("arc-backed config"), + block_on(store_handle.get("a")).expect("arc-backed config"), Some("1".to_owned()) ); } @@ -322,9 +352,7 @@ mod tests { #[test] fn config_store_handle_propagates_backend_errors() { let handle = ConfigStoreHandle::new(Arc::new(FailingConfigStore)); - let err = handle - .get("feature.checkout") - .expect_err("expected backend error"); + let err = block_on(handle.get("feature.checkout")).expect_err("expected backend error"); assert!(matches!(err, ConfigStoreError::Unavailable { .. })); } @@ -332,9 +360,12 @@ mod tests { fn config_store_handle_wraps_and_delegates() { let store_handle = handle(&[("timeout_ms", "1500")]); assert_eq!( - store_handle.get("timeout_ms").expect("config value"), + block_on(store_handle.get("timeout_ms")).expect("config value"), Some("1500".to_owned()) ); - assert_eq!(store_handle.get("missing").expect("missing config"), None); + assert_eq!( + block_on(store_handle.get("missing")).expect("missing config"), + None + ); } } diff --git a/crates/edgezero-core/src/context.rs b/crates/edgezero-core/src/context.rs index 9e444565..c2b38c08 100644 --- a/crates/edgezero-core/src/context.rs +++ b/crates/edgezero-core/src/context.rs @@ -1,11 +1,12 @@ use crate::body::Body; -use crate::config_store::ConfigStoreHandle; use crate::error::EdgeError; use crate::http::Request; -use crate::key_value_store::KvHandle; use crate::params::PathParams; use crate::proxy::ProxyHandle; -use crate::secret_store::SecretHandle; +use crate::store_registry::{ + BoundConfigStore, BoundKvStore, BoundSecretStore, ConfigRegistry, KvRegistry, SecretRegistry, + StoreRegistry, +}; use serde::de::DeserializeOwned; /// Request context exposed to handlers and middleware. @@ -20,12 +21,29 @@ impl RequestContext { self.request.body() } + /// Resolve the [`BoundConfigStore`] for `id`. Strict lookup: when a + /// [`ConfigRegistry`] is wired, an unregistered id yields `None`. When + /// no registry is wired this returns `None` — adapter dispatchers + /// normalise legacy bare-handle inputs to a single-id registry under + /// the conventional `"default"` id, so a missing registry is a real + /// bug rather than a hand-wired single-handle adapter (spec hard-cutoff). #[inline] - pub fn config_store(&self) -> Option { + pub fn config_store(&self, id: &str) -> Option { self.request .extensions() - .get::() - .cloned() + .get::() + .and_then(|registry| registry.named(id)) + } + + /// Resolve the default [`BoundConfigStore`] — the wired registry's + /// declared default id, or `None` when no registry is in extensions. + /// See [`Self::config_store`] for the hard-cutoff rationale. + #[inline] + pub fn config_store_default(&self) -> Option { + self.request + .extensions() + .get::() + .and_then(StoreRegistry::default) } /// # Errors @@ -62,10 +80,28 @@ impl RequestContext { .map_err(|err| EdgeError::bad_request(format!("invalid JSON payload: {err}"))) } - /// Returns the KV store handle if one was configured for this request. + /// Resolve the [`BoundKvStore`] for `id`. Strict lookup: when a + /// [`KvRegistry`] is wired, an unregistered id yields `None`. When no + /// registry is wired this returns `None` — adapter dispatchers + /// normalise legacy bare-handle inputs to a single-id registry under + /// the conventional `"default"` id (spec hard-cutoff). #[inline] - pub fn kv_handle(&self) -> Option { - self.request.extensions().get::().cloned() + pub fn kv_store(&self, id: &str) -> Option { + self.request + .extensions() + .get::() + .and_then(|registry| registry.named(id)) + } + + /// Resolve the default [`BoundKvStore`] — the wired registry's + /// declared default id, or `None` when no registry is in extensions. + /// See [`Self::kv_store`] for the hard-cutoff rationale. + #[inline] + pub fn kv_store_default(&self) -> Option { + self.request + .extensions() + .get::() + .and_then(StoreRegistry::default) } #[inline] @@ -120,10 +156,28 @@ impl RequestContext { &mut self.request } - /// Returns the secret store handle if one was configured for this request. + /// Resolve the [`BoundSecretStore`] for `id`. Strict lookup: when a + /// [`SecretRegistry`] is wired, an unregistered id yields `None`. + /// When no registry is wired this returns `None` — adapter + /// dispatchers normalise legacy bare-handle inputs to a single-id + /// registry under the conventional `"default"` id (spec hard-cutoff). + #[inline] + pub fn secret_store(&self, id: &str) -> Option { + self.request + .extensions() + .get::() + .and_then(|registry| registry.named(id)) + } + + /// Resolve the default [`BoundSecretStore`] — the wired registry's + /// declared default id, or `None` when no registry is in extensions. + /// See [`Self::secret_store`] for the hard-cutoff rationale. #[inline] - pub fn secret_handle(&self) -> Option { - self.request.extensions().get::().cloned() + pub fn secret_store_default(&self) -> Option { + self.request + .extensions() + .get::() + .and_then(StoreRegistry::default) } } @@ -171,43 +225,9 @@ mod tests { PathParams::new(inner) } - #[test] - fn config_store_is_retrieved_when_present() { - use crate::config_store::{ConfigStore, ConfigStoreError, ConfigStoreHandle}; - use std::sync::Arc; - - struct FixedStore; - impl ConfigStore for FixedStore { - fn get(&self, _key: &str) -> Result, ConfigStoreError> { - Ok(Some("value".to_owned())) - } - } - - let mut request = request_builder() - .method(Method::GET) - .uri("/config") - .body(Body::empty()) - .expect("request"); - request - .extensions_mut() - .insert(ConfigStoreHandle::new(Arc::new(FixedStore))); - - let ctx = RequestContext::new(request, PathParams::default()); - assert!(ctx.config_store().is_some()); - assert_eq!( - ctx.config_store() - .unwrap() - .get("any") - .expect("config value"), - Some("value".to_owned()) - ); - } - - #[test] - fn config_store_returns_none_when_absent() { - let ctx = ctx("/test", Body::empty(), PathParams::default()); - assert!(ctx.config_store().is_none()); - } + // `RequestContext::config_handle()` was removed. The + // present/absent behaviour is now covered by + // `config_store_*` tests against a wired `ConfigRegistry`. #[test] fn form_deserialises_successfully() { @@ -324,29 +344,9 @@ mod tests { ); } - #[test] - fn kv_handle_is_retrieved_when_present() { - use crate::key_value_store::{KvHandle, NoopKvStore}; - use std::sync::Arc; - - let mut request = request_builder() - .method(Method::GET) - .uri("/kv") - .body(Body::empty()) - .expect("request"); - request - .extensions_mut() - .insert(KvHandle::new(Arc::new(NoopKvStore))); - - let ctx = RequestContext::new(request, PathParams::default()); - assert!(ctx.kv_handle().is_some()); - } - - #[test] - fn kv_handle_returns_none_when_absent() { - let ctx = ctx("/test", Body::empty(), PathParams::default()); - assert!(ctx.kv_handle().is_none()); - } + // `RequestContext::kv_handle()` was removed. The + // present/absent behaviour is now covered by `kv_store_*` + // tests against a wired `KvRegistry`. #[test] fn path_deserialises_successfully() { @@ -427,27 +427,180 @@ mod tests { assert_eq!(request.uri().path(), "/items/123"); } + // `RequestContext::secret_handle()` was removed. The + // present/absent behaviour is now covered by `secret_store_*` + // tests against a wired `SecretRegistry`. + #[test] - fn secret_handle_is_retrieved_when_present() { - use crate::secret_store::{NoopSecretStore, SecretHandle}; + fn kv_store_resolves_named_handle_from_registry() { + use crate::key_value_store::{KvHandle, NoopKvStore}; + use crate::store_registry::{KvRegistry, StoreRegistry}; + use std::collections::BTreeMap; use std::sync::Arc; + let sessions = KvHandle::new(Arc::new(NoopKvStore)); + let cache = KvHandle::new(Arc::new(NoopKvStore)); + let by_id: BTreeMap = [ + ("sessions".to_owned(), sessions), + ("cache".to_owned(), cache), + ] + .into_iter() + .collect(); + let registry: KvRegistry = StoreRegistry::new(by_id, "sessions".to_owned()); + let mut request = request_builder() .method(Method::GET) - .uri("/secrets") + .uri("/kv") + .body(Body::empty()) + .expect("request"); + request.extensions_mut().insert(registry); + + let ctx = RequestContext::new(request, PathParams::default()); + assert!(ctx.kv_store("sessions").is_some()); + assert!(ctx.kv_store("cache").is_some()); + assert!( + ctx.kv_store("unknown").is_none(), + "registry lookups are strict: unknown ids must yield None" + ); + assert!(ctx.kv_store_default().is_some()); + } + + #[test] + fn kv_store_returns_none_when_only_legacy_handle_wired() { + // Hard-cutoff: a bare `KvHandle` in extensions + // is ignored by the registry-aware accessor. Adapter + // dispatchers no longer insert bare handles — they + // always synthesise a `KvRegistry` from any wired handle + // first — so this code path only fires when a test or + // callsite bypasses the dispatcher and inserts a bare + // handle directly into extensions. The accessor must + // surface that as a missing registry (None) rather than + // silently upgrading. + use crate::key_value_store::{KvHandle, NoopKvStore}; + use std::sync::Arc; + + let mut request = request_builder() + .method(Method::GET) + .uri("/kv") .body(Body::empty()) .expect("request"); request .extensions_mut() - .insert(SecretHandle::new(Arc::new(NoopSecretStore))); + .insert(KvHandle::new(Arc::new(NoopKvStore))); let ctx = RequestContext::new(request, PathParams::default()); - assert!(ctx.secret_handle().is_some()); + assert!( + ctx.kv_store("anything").is_none(), + "registry-aware accessor must not auto-upgrade a bare handle" + ); + assert!( + ctx.kv_store_default().is_none(), + "registry-aware default accessor must not auto-upgrade a bare handle" + ); } #[test] - fn secret_handle_returns_none_when_absent() { - let ctx = ctx("/test", Body::empty(), PathParams::default()); - assert!(ctx.secret_handle().is_none()); + fn config_store_resolves_named_handle_from_registry() { + use crate::config_store::{ConfigStore, ConfigStoreError, ConfigStoreHandle}; + use crate::store_registry::{ConfigRegistry, StoreRegistry}; + use std::collections::BTreeMap; + use std::sync::Arc; + + struct FixedStore(&'static str); + #[async_trait(?Send)] + impl ConfigStore for FixedStore { + async fn get(&self, _key: &str) -> Result, ConfigStoreError> { + Ok(Some(self.0.to_owned())) + } + } + + let primary_handle = ConfigStoreHandle::new(Arc::new(FixedStore("primary"))); + let analytics_handle = ConfigStoreHandle::new(Arc::new(FixedStore("analytics"))); + let by_id: BTreeMap = [ + ("primary".to_owned(), primary_handle), + ("analytics".to_owned(), analytics_handle), + ] + .into_iter() + .collect(); + let registry: ConfigRegistry = StoreRegistry::new(by_id, "primary".to_owned()); + + let mut request = request_builder() + .method(Method::GET) + .uri("/config") + .body(Body::empty()) + .expect("request"); + request.extensions_mut().insert(registry); + + let ctx = RequestContext::new(request, PathParams::default()); + let resolved = ctx.config_store("analytics").expect("analytics handle"); + assert_eq!( + block_on(resolved.get("key")).expect("config value"), + Some("analytics".to_owned()) + ); + assert!(ctx.config_store("unknown").is_none()); + let default = ctx.config_store_default().expect("default handle"); + assert_eq!( + block_on(default.get("key")).expect("default config value"), + Some("primary".to_owned()) + ); + } + + #[test] + fn secret_store_resolves_named_handle_from_registry() { + use crate::secret_store::{NoopSecretStore, SecretHandle}; + use crate::store_registry::{BoundSecretStore, SecretRegistry, StoreRegistry}; + use std::collections::BTreeMap; + use std::sync::Arc; + + let handle = SecretHandle::new(Arc::new(NoopSecretStore)); + let by_id: BTreeMap = [( + "default".to_owned(), + // The registry binds the logical id to the platform store name — + // in production that's `EDGEZERO__STORES__SECRETS__DEFAULT__NAME` + // resolved against the env (falling back to the logical id). + BoundSecretStore::new(handle, "platform-secret-store".to_owned()), + )] + .into_iter() + .collect(); + let registry: SecretRegistry = StoreRegistry::new(by_id, "default".to_owned()); + + let mut request = request_builder() + .method(Method::GET) + .uri("/secrets") + .body(Body::empty()) + .expect("request"); + request.extensions_mut().insert(registry); + + let ctx = RequestContext::new(request, PathParams::default()); + let bound = ctx.secret_store("default").expect("default bound store"); + assert_eq!(bound.store_name(), "platform-secret-store"); + assert!(ctx.secret_store("unknown").is_none()); + assert!(ctx.secret_store_default().is_some()); + } + + #[test] + fn secret_store_default_returns_none_when_only_legacy_handle_wired() { + // Hard-cutoff: same semantics as + // `kv_store_returns_none_when_only_legacy_handle_wired` — + // a bare `SecretHandle` in extensions (a state that + // only arises if a test bypasses the dispatcher) must + // not auto-upgrade into a synthetic registry. + use crate::secret_store::{NoopSecretStore, SecretHandle}; + use std::sync::Arc; + + let mut request = request_builder() + .method(Method::GET) + .uri("/secrets") + .body(Body::empty()) + .expect("request"); + request + .extensions_mut() + .insert(SecretHandle::new(Arc::new(NoopSecretStore))); + + let ctx = RequestContext::new(request, PathParams::default()); + assert!( + ctx.secret_store_default().is_none(), + "registry-aware default accessor must not auto-upgrade a bare handle" + ); } } diff --git a/crates/edgezero-core/src/env_config.rs b/crates/edgezero-core/src/env_config.rs new file mode 100644 index 00000000..aa27546a --- /dev/null +++ b/crates/edgezero-core/src/env_config.rs @@ -0,0 +1,297 @@ +//! `EDGEZERO__*` environment-config layer. +//! +//! Adapter-specific runtime config — platform store names, per-store tuning, +//! bind host/port, and logging level — is supplied at runtime through +//! `EDGEZERO__`-prefixed environment variables. `__` (double underscore) +//! separates key-path segments, so `EDGEZERO__STORES__KV__SESSIONS__NAME` +//! parses to the segment path `["stores", "kv", "sessions", "name"]`. +//! +//! Every segment is lower-cased on parse, and lookup arguments are lower-cased +//! before matching — callers pass lower-case logical ids and get a +//! case-insensitive match against the upper-case env-var convention. + +use std::collections::BTreeMap; +use std::env; + +/// The prefix every recognised variable must start with. +const PREFIX: &str = "EDGEZERO__"; +/// The key-path segment separator. +const SEPARATOR: &str = "__"; + +/// Adapter runtime config resolved from `EDGEZERO__*` environment variables. +/// +/// Keys are lower-cased segment paths; values are the raw environment-variable +/// strings. Build one with [`EnvConfig::from_env`] (native targets) or +/// [`EnvConfig::from_vars`] (e.g. Cloudflare Workers, which have no +/// `std::env`). +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct EnvConfig { + entries: BTreeMap, String>, +} + +impl EnvConfig { + /// `EDGEZERO__ADAPTER__HOST`. + #[must_use] + #[inline] + pub fn adapter_host(&self) -> Option<&str> { + self.get(&["adapter", "host"]) + } + + /// `EDGEZERO__ADAPTER__PORT` (raw string — callers parse it). + #[must_use] + #[inline] + pub fn adapter_port(&self) -> Option<&str> { + self.get(&["adapter", "port"]) + } + + /// Read all `EDGEZERO__`-prefixed variables from the process environment + /// (`std::env::vars()`). On targets without a process environment (e.g. + /// `wasm32-unknown-unknown`) this yields an empty config. + #[must_use] + #[inline] + pub fn from_env() -> Self { + Self::from_vars(env::vars()) + } + + /// Build from an explicit `(key, value)` iterator. Cloudflare Workers have + /// no `std::env`; that adapter enumerates its `Env` binding object and + /// calls this instead of [`EnvConfig::from_env`]. + #[must_use] + #[inline] + pub fn from_vars(vars: I) -> Self + where + I: IntoIterator, + K: AsRef, + V: Into, + { + let mut entries = BTreeMap::new(); + for (key, value) in vars { + let Some(rest) = key.as_ref().strip_prefix(PREFIX) else { + continue; + }; + let segments: Vec = + rest.split(SEPARATOR).map(str::to_ascii_lowercase).collect(); + if segments.is_empty() || segments.iter().any(String::is_empty) { + continue; + } + entries.insert(segments, value.into()); + } + Self { entries } + } + + /// Generic lookup by segment path. Segments are matched case-insensitively + /// — they are lower-cased before comparison, matching the lower-cased + /// parsed keys. + #[must_use] + #[inline] + pub fn get(&self, segments: &[&str]) -> Option<&str> { + let path: Vec = segments + .iter() + .map(|seg| seg.to_ascii_lowercase()) + .collect(); + self.entries.get(&path).map(String::as_str) + } + + /// `EDGEZERO__LOGGING__ENDPOINT`. Adapters that wire a platform-specific + /// logger (e.g. Fastly's named log endpoints) read this to know which + /// endpoint to attach to; a `None` value means "don't init a platform + /// logger" — useful under local emulators (Viceroy) that reject reserved + /// names like `stdout`. + #[must_use] + #[inline] + pub fn logging_endpoint(&self) -> Option<&str> { + self.get(&["logging", "endpoint"]) + } + + /// `EDGEZERO__LOGGING__LEVEL`. + #[must_use] + #[inline] + pub fn logging_level(&self) -> Option<&str> { + self.get(&["logging", "level"]) + } + + /// Platform name for a logical store — `EDGEZERO__STORES______NAME` + /// — falling back to `id` itself when the variable is unset OR when + /// the value is empty / whitespace-only. `kind` is `"kv"` / + /// `"config"` / `"secrets"`. + /// + /// The empty/whitespace skip is deliberate: an env var like + /// `EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME=` (set but blank) + /// would otherwise flow into `wrangler kv namespace create ""` + /// or `fastly config-store create --name=` or be written as + /// the binding name in wrangler.toml -- all of which fail at + /// the platform with confusing errors rather than the clear + /// "did you forget to set the env var" message you'd expect. + /// Falling back to the logical id is consistent with the + /// "unset" path and gives the operator a working default. + /// + /// Control characters are similarly rejected because no + /// platform (cloudflare bindings, fastly store names, spin + /// labels) accepts them as resource identifiers. + #[must_use] + #[inline] + pub fn store_name(&self, kind: &str, id: &str) -> String { + self.get(&["stores", kind, id, "name"]) + .filter(|value| !is_blank_or_control(value)) + .map_or_else(|| id.to_owned(), str::to_owned) + } + + /// Free-form per-store tuning — `EDGEZERO__STORES______`. + #[must_use] + #[inline] + pub fn store_setting(&self, kind: &str, id: &str, key: &str) -> Option<&str> { + self.get(&["stores", kind, id, key]) + } +} + +/// `true` if `value` is empty, made entirely of whitespace, or +/// contains any ASCII / Unicode control character. Used to reject +/// platform-name overrides that would otherwise flow as empty +/// strings (or control chars) into platform-side resource names. +fn is_blank_or_control(value: &str) -> bool { + value.is_empty() + || value.chars().all(char::is_whitespace) + || value.chars().any(char::is_control) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample() -> EnvConfig { + EnvConfig::from_vars([ + ("EDGEZERO__STORES__KV__SESSIONS__NAME", "prod-sessions"), + ("EDGEZERO__STORES__KV__SESSIONS__MAX_LIST_KEYS", "500"), + ("EDGEZERO__ADAPTER__HOST", "0.0.0.0"), + ("EDGEZERO__ADAPTER__PORT", "9000"), + ("EDGEZERO__LOGGING__LEVEL", "debug"), + ("PATH", "/usr/bin"), + ]) + } + + #[test] + fn parses_and_lower_cases_segments() { + let cfg = sample(); + assert_eq!( + cfg.get(&["stores", "kv", "sessions", "name"]), + Some("prod-sessions") + ); + } + + #[test] + fn get_is_case_insensitive() { + let cfg = sample(); + assert_eq!( + cfg.get(&["STORES", "KV", "Sessions", "NAME"]), + Some("prod-sessions") + ); + } + + #[test] + fn store_name_hit() { + let cfg = sample(); + assert_eq!(cfg.store_name("kv", "sessions"), "prod-sessions"); + } + + #[test] + fn store_name_falls_back_to_id() { + let cfg = sample(); + assert_eq!(cfg.store_name("kv", "cache"), "cache"); + } + + #[test] + fn store_name_falls_back_to_id_when_env_value_is_empty() { + // An exported but empty `EDGEZERO__STORES______NAME=` + // would otherwise flow into a platform `create` call with + // an empty name and a binding written as `binding = ""` in + // wrangler.toml. Treat it the same as unset. + let cfg = EnvConfig::from_vars([("EDGEZERO__STORES__KV__SESSIONS__NAME", "")]); + assert_eq!(cfg.store_name("kv", "sessions"), "sessions"); + } + + #[test] + fn store_name_falls_back_to_id_when_env_value_is_whitespace_only() { + let cfg = EnvConfig::from_vars([("EDGEZERO__STORES__KV__SESSIONS__NAME", " \t ")]); + assert_eq!(cfg.store_name("kv", "sessions"), "sessions"); + } + + #[test] + fn store_name_falls_back_to_id_when_env_value_has_control_chars() { + // A literal newline or NUL embedded in the override would + // be passed through to `wrangler kv namespace create + // ` and similar. Reject and fall back to the id. + let with_newline = + EnvConfig::from_vars([("EDGEZERO__STORES__KV__SESSIONS__NAME", "prod\nname")]); + assert_eq!(with_newline.store_name("kv", "sessions"), "sessions"); + let with_nul = + EnvConfig::from_vars([("EDGEZERO__STORES__KV__SESSIONS__NAME", "prod\x00name")]); + assert_eq!(with_nul.store_name("kv", "sessions"), "sessions"); + } + + #[test] + fn store_name_accepts_real_world_punctuation() { + // Underscores, dashes, and dots are valid in every platform + // store-name we target. Don't false-reject them. + let cfg = EnvConfig::from_vars([( + "EDGEZERO__STORES__KV__SESSIONS__NAME", + "prod-app_v2.sessions", + )]); + assert_eq!(cfg.store_name("kv", "sessions"), "prod-app_v2.sessions"); + } + + #[test] + fn store_setting_lookup() { + let cfg = sample(); + assert_eq!( + cfg.store_setting("kv", "sessions", "max_list_keys"), + Some("500") + ); + assert_eq!(cfg.store_setting("kv", "sessions", "ttl"), None); + } + + #[test] + fn adapter_and_logging_accessors() { + let cfg = sample(); + assert_eq!(cfg.adapter_host(), Some("0.0.0.0")); + assert_eq!(cfg.adapter_port(), Some("9000")); + assert_eq!(cfg.logging_level(), Some("debug")); + } + + #[test] + fn empty_config_returns_none_and_fallbacks() { + let empty: [(&str, &str); 0] = []; + let cfg = EnvConfig::from_vars(empty); + assert_eq!(cfg.adapter_host(), None); + assert_eq!(cfg.adapter_port(), None); + assert_eq!(cfg.logging_level(), None); + assert_eq!(cfg.store_setting("kv", "sessions", "name"), None); + assert_eq!(cfg.get(&["stores", "kv", "sessions", "name"]), None); + assert_eq!(cfg.store_name("kv", "sessions"), "sessions"); + } + + #[test] + fn non_prefixed_variable_is_ignored() { + let cfg = EnvConfig::from_vars([ + ("PATH", "/usr/bin"), + ("EDGEZERO_HOST", "ignored-no-double-underscore"), + ("EDGEZERO__ADAPTER__HOST", "kept"), + ]); + assert_eq!(cfg.adapter_host(), Some("kept")); + assert_eq!(cfg.get(&["host"]), None); + } + + #[test] + fn malformed_variables_are_skipped() { + // `EDGEZERO__` alone, a trailing `__`, and an interior empty segment + // must all be skipped without panicking. + let cfg = EnvConfig::from_vars([ + ("EDGEZERO__", "empty"), + ("EDGEZERO__ADAPTER__", "trailing"), + ("EDGEZERO__ADAPTER____PORT", "interior-empty"), + ("EDGEZERO__ADAPTER__HOST", "good"), + ]); + assert_eq!(cfg.adapter_host(), Some("good")); + assert_eq!(cfg.adapter_port(), None); + assert_eq!(cfg.get(&["adapter"]), None); + } +} diff --git a/crates/edgezero-core/src/error.rs b/crates/edgezero-core/src/error.rs index 6c984e84..26b089c4 100644 --- a/crates/edgezero-core/src/error.rs +++ b/crates/edgezero-core/src/error.rs @@ -23,6 +23,8 @@ pub enum EdgeError { MethodNotAllowed { method: Method, allowed: String }, #[error("no route matched path: {path}")] NotFound { path: String }, + #[error("not implemented: {message}")] + NotImplemented { message: String }, #[error("service unavailable: {message}")] ServiceUnavailable { message: String }, #[error("validation error: {message}")] @@ -50,6 +52,7 @@ impl EdgeError { EdgeError::Internal { source } => Some(source), EdgeError::BadRequest { .. } | EdgeError::NotFound { .. } + | EdgeError::NotImplemented { .. } | EdgeError::MethodNotAllowed { .. } | EdgeError::Validation { .. } | EdgeError::ServiceUnavailable { .. } => None, @@ -72,6 +75,7 @@ impl EdgeError { match self { EdgeError::BadRequest { message } | EdgeError::Validation { message } + | EdgeError::NotImplemented { message } | EdgeError::ServiceUnavailable { message } => message.clone(), EdgeError::NotFound { path } => format!("no route matched path: {path}"), EdgeError::MethodNotAllowed { method, allowed } => { @@ -105,6 +109,13 @@ impl EdgeError { EdgeError::NotFound { path: path.into() } } + #[inline] + pub fn not_implemented>(message: S) -> Self { + EdgeError::NotImplemented { + message: message.into(), + } + } + #[inline] pub fn service_unavailable>(message: S) -> Self { EdgeError::ServiceUnavailable { @@ -120,6 +131,7 @@ impl EdgeError { EdgeError::Validation { .. } => StatusCode::UNPROCESSABLE_ENTITY, EdgeError::NotFound { .. } => StatusCode::NOT_FOUND, EdgeError::MethodNotAllowed { .. } => StatusCode::METHOD_NOT_ALLOWED, + EdgeError::NotImplemented { .. } => StatusCode::NOT_IMPLEMENTED, EdgeError::ServiceUnavailable { .. } => StatusCode::SERVICE_UNAVAILABLE, EdgeError::Internal { .. } => StatusCode::INTERNAL_SERVER_ERROR, } diff --git a/crates/edgezero-core/src/extractor.rs b/crates/edgezero-core/src/extractor.rs index 5bbde8e2..c96d8207 100644 --- a/crates/edgezero-core/src/extractor.rs +++ b/crates/edgezero-core/src/extractor.rs @@ -8,8 +8,9 @@ use validator::Validate; use crate::context::RequestContext; use crate::error::EdgeError; use crate::http::HeaderMap; -use crate::key_value_store::KvHandle; -use crate::secret_store::SecretHandle; +use crate::store_registry::{ + BoundConfigStore, BoundKvStore, BoundSecretStore, ConfigRegistry, KvRegistry, SecretRegistry, +}; #[async_trait(?Send)] pub trait FromRequest: Sized { @@ -448,112 +449,204 @@ impl ValidatedForm { } } -/// Extracts the [`KvHandle`] from the request context. +/// Extractor that yields the per-request [`KvRegistry`]. /// -/// Returns `EdgeError::Internal` if no KV store was configured for this request. +/// Handlers pick a bound store by id at the call site: /// -/// # Example /// ```ignore /// #[action] -/// pub async fn handler(Kv(store): Kv) -> Result { +/// pub async fn handler(kv: Kv) -> Result { +/// let store = kv.default().ok_or_else(|| EdgeError::internal(anyhow::anyhow!("no default kv")))?; /// let count: i32 = store.get_or("visits", 0).await?; /// store.put("visits", &(count + 1)).await?; /// Ok(format!("visits: {}", count + 1)) /// } /// ``` -#[derive(Debug)] -pub struct Kv(pub KvHandle); +/// +/// Or, for a non-default id: +/// +/// ```ignore +/// let cache = kv.named("cache").ok_or_else(|| EdgeError::internal(anyhow::anyhow!("no `cache` kv")))?; +/// ``` +#[derive(Clone, Debug)] +pub struct Kv(KvRegistry); #[async_trait(?Send)] impl FromRequest for Kv { #[inline] async fn from_request(ctx: &RequestContext) -> Result { - ctx.kv_handle().map(Kv).ok_or_else(|| { - EdgeError::internal(anyhow::anyhow!( - "no kv store configured -- check [stores.kv] in edgezero.toml and platform bindings" - )) - }) + // Spec hard-cutoff (§ intro): no backward compatibility for + // the pre-rewrite runtime store API. Pre-Stage-9.3 this + // extractor silently synthesised a one-id registry from a + // lone `ctx.kv_handle()` when no `KvRegistry` was wired, + // which masked missing registry wiring. Adapter dispatchers + // (axum / cloudflare / fastly / spin) now normalise + // legacy bare-handle inputs to single-id registries at the + // dispatch boundary, so this path no longer needs a + // fallback — a missing registry is a real bug. + ctx.request() + .extensions() + .get::() + .cloned() + .map(Kv) + .ok_or_else(|| { + EdgeError::internal(anyhow::anyhow!( + "no kv store configured -- check [stores.kv] in edgezero.toml and platform bindings" + )) + }) } } -impl Deref for Kv { - type Target = KvHandle; - +impl Kv { + /// Resolve the default [`BoundKvStore`]. + #[must_use] #[inline] - fn deref(&self) -> &Self::Target { - &self.0 + pub fn default(&self) -> Option { + self.0.default() } -} -impl DerefMut for Kv { + /// Resolve the [`BoundKvStore`] for `id`. Strict lookup — unknown ids + /// yield `None`. + #[must_use] #[inline] - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.0 + pub fn named(&self, id: &str) -> Option { + self.0.named(id) } -} -impl Kv { + /// Access the underlying registry directly (rarely needed; most handlers + /// should use [`Self::default`] / [`Self::named`]). #[must_use] #[inline] - pub fn into_inner(self) -> KvHandle { - self.0 + pub fn registry(&self) -> &KvRegistry { + &self.0 } } -/// Extracts the [`SecretHandle`] from the request context. +/// Extractor that yields the per-request [`SecretRegistry`]. /// -/// Returns `EdgeError::Internal` if no secret store was configured for this request. +/// The returned [`BoundSecretStore`] is pre-bound to a platform store name +/// (resolved per id from `EDGEZERO__STORES__SECRETS____NAME`), so +/// handler code passes only the key: /// -/// # Example /// ```ignore /// #[action] -/// pub async fn handler(Secrets(secrets): Secrets) -> Result { -/// let key = secrets.require_str("api-keys", "API_KEY").await.map_err(EdgeError::from)?; -/// // use key ... +/// pub async fn handler(secrets: Secrets) -> Result { +/// let bound = secrets.default().ok_or_else(|| EdgeError::internal(anyhow::anyhow!("no secrets")))?; +/// let key = bound.require_str("API_KEY").await.map_err(EdgeError::from)?; +/// // ... /// } /// ``` -#[derive(Debug)] -pub struct Secrets(pub SecretHandle); +#[derive(Clone, Debug)] +pub struct Secrets(SecretRegistry); #[async_trait(?Send)] impl FromRequest for Secrets { #[inline] async fn from_request(ctx: &RequestContext) -> Result { - // ctx.secret_handle() returns a handle object, not secret bytes. - // The error message below contains only store configuration info — no secret values - // are included, so this is safe from a cleartext-logging perspective. - ctx.secret_handle().map(Secrets).ok_or_else(|| { - EdgeError::internal(anyhow::anyhow!( - "no secret store configured -- check [stores.secrets] in edgezero.toml and platform bindings" - )) - }) + // Hard-cutoff: see `impl FromRequest for Kv`. Adapter + // dispatchers normalise legacy bare-handle inputs to + // single-id `SecretRegistry`s at the dispatch boundary. + ctx.request() + .extensions() + .get::() + .cloned() + .map(Secrets) + .ok_or_else(|| { + EdgeError::internal(anyhow::anyhow!( + "no secret store configured -- check [stores.secrets] in edgezero.toml and platform bindings" + )) + }) } } -impl Deref for Secrets { - type Target = SecretHandle; +impl Secrets { + /// Resolve the default [`BoundSecretStore`]. + #[must_use] + #[inline] + pub fn default(&self) -> Option { + self.0.default() + } + + /// Resolve the [`BoundSecretStore`] for `id`. Strict lookup — unknown ids + /// yield `None`. + #[must_use] + #[inline] + pub fn named(&self, id: &str) -> Option { + self.0.named(id) + } + /// Access the underlying registry directly. + #[must_use] #[inline] - fn deref(&self) -> &Self::Target { + pub fn registry(&self) -> &SecretRegistry { &self.0 } } -impl DerefMut for Secrets { +/// Extractor that yields the per-request [`ConfigRegistry`]. +/// +/// ```ignore +/// #[action] +/// pub async fn handler(config: Config) -> Result { +/// let bound = config.default().ok_or_else(|| EdgeError::internal(anyhow::anyhow!("no config")))?; +/// let greeting = bound.get("greeting").await?.unwrap_or_default(); +/// // ... +/// } +/// ``` +#[derive(Clone, Debug)] +pub struct Config(ConfigRegistry); + +#[async_trait(?Send)] +impl FromRequest for Config { #[inline] - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.0 + async fn from_request(ctx: &RequestContext) -> Result { + // Hard-cutoff: see `impl FromRequest for Kv`. Adapter + // dispatchers normalise legacy bare-handle inputs to + // single-id `ConfigRegistry`s at the dispatch boundary. + ctx.request() + .extensions() + .get::() + .cloned() + .map(Config) + .ok_or_else(|| { + EdgeError::internal(anyhow::anyhow!( + "no config store configured -- check [stores.config] in edgezero.toml and platform bindings" + )) + }) } } -impl Secrets { +impl Config { + /// Resolve the default [`BoundConfigStore`]. #[must_use] #[inline] - pub fn into_inner(self) -> SecretHandle { - self.0 + pub fn default(&self) -> Option { + self.0.default() + } + + /// Resolve the [`BoundConfigStore`] for `id`. Strict lookup — unknown ids + /// yield `None`. + #[must_use] + #[inline] + pub fn named(&self, id: &str) -> Option { + self.0.named(id) + } + + /// Access the underlying registry directly. + #[must_use] + #[inline] + pub fn registry(&self) -> &ConfigRegistry { + &self.0 } } +// removed the private `single_id_registry` helper that +// the Kv/Config/Secrets extractors used to synthesise a one-id +// registry from a legacy bare handle. The equivalent normalisation +// now happens at each adapter's dispatch boundary via +// `StoreRegistry::single_id`, so this fallback is no longer +// reachable from the extractor path. + #[cfg(test)] mod tests { use super::*; @@ -561,6 +654,7 @@ mod tests { use crate::context::RequestContext; use crate::http::{request_builder, HeaderValue, Method, StatusCode}; use crate::params::PathParams; + use crate::store_registry::StoreRegistry; use futures::executor::block_on; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -1067,10 +1161,21 @@ mod tests { assert_eq!(inner, "example.com"); } - // -- Kv extractor ------------------------------------------------------- + // -- Kv / Secrets / Config extractors (registry-aware) ----------------- #[test] - fn kv_extractor_returns_handle_when_configured() { + fn kv_extractor_errors_when_only_legacy_handle_wired() { + // Hard-cutoff: the extractor used to synthesise + // a one-id registry from a lone `ctx.kv_handle()` when no + // `KvRegistry` was in extensions. That path silently + // masked missing registry wiring, which violates the + // spec's "no backward compatibility" promise for the + // runtime store API. Adapter dispatchers (axum / + // cloudflare / fastly / spin) now normalise legacy bare- + // handle inputs to a single-id `KvRegistry` at the + // dispatch boundary, so this code path only fires when a + // test or callsite bypasses a dispatcher. In that case + // the extractor must surface the wiring bug. use crate::key_value_store::{KvHandle, NoopKvStore}; use std::sync::Arc; @@ -1084,7 +1189,43 @@ mod tests { .insert(KvHandle::new(Arc::new(NoopKvStore))); let ctx = RequestContext::new(request, PathParams::default()); - block_on(Kv::from_request(&ctx)).expect("Kv extractor when handle present"); + let err = block_on(Kv::from_request(&ctx)) + .expect_err("extractor must surface missing-registry as an error, not auto-upgrade"); + assert!( + err.message().contains("no kv store configured"), + "error names the wiring gap: {err:?}" + ); + } + + #[test] + fn kv_extractor_prefers_registry_over_legacy_handle() { + use crate::key_value_store::{KvHandle, NoopKvStore}; + use std::collections::BTreeMap; + use std::sync::Arc; + + let registry: KvRegistry = StoreRegistry::new( + [ + ("sessions".to_owned(), KvHandle::new(Arc::new(NoopKvStore))), + ("cache".to_owned(), KvHandle::new(Arc::new(NoopKvStore))), + ] + .into_iter() + .collect::>(), + "sessions".to_owned(), + ); + + let mut request = request_builder() + .method(Method::GET) + .uri("/kv") + .body(Body::empty()) + .expect("request"); + request.extensions_mut().insert(registry); + + let ctx = RequestContext::new(request, PathParams::default()); + let kv = block_on(Kv::from_request(&ctx)).expect("Kv extractor when registry present"); + assert!(kv.named("sessions").is_some()); + assert!(kv.named("cache").is_some()); + assert!(kv.named("unknown").is_none()); + assert_eq!(kv.registry().default_id(), "sessions"); } #[test] @@ -1101,52 +1242,180 @@ mod tests { } #[test] - fn kv_deref_and_into_inner() { - use crate::key_value_store::{KvHandle, NoopKvStore}; + fn secrets_extractor_errors_when_only_legacy_handle_wired() { + // Hard-cutoff — same semantics as + // `kv_extractor_errors_when_only_legacy_handle_wired`. + use crate::secret_store::{NoopSecretStore, SecretHandle}; use std::sync::Arc; - let handle = KvHandle::new(Arc::new(NoopKvStore)); - let kv = Kv(handle); + let mut request = request_builder() + .method(Method::GET) + .uri("/secrets") + .body(Body::empty()) + .expect("request"); + request + .extensions_mut() + .insert(SecretHandle::new(Arc::new(NoopSecretStore))); + let ctx = RequestContext::new(request, PathParams::default()); + let err = block_on(Secrets::from_request(&ctx)) + .expect_err("extractor must surface missing-registry as an error"); + assert!( + err.message().contains("no secret store configured"), + "error names the wiring gap: {err:?}" + ); + } - // Debug works - let debug = format!("{kv:?}"); - assert!(debug.contains("Kv")); + #[test] + fn secrets_extractor_preserves_registry_per_id_platform_name() { + use crate::secret_store::{NoopSecretStore, SecretHandle}; + use std::collections::BTreeMap; + use std::sync::Arc; + + let handle = SecretHandle::new(Arc::new(NoopSecretStore)); + let by_id: BTreeMap = [ + ( + "primary".to_owned(), + BoundSecretStore::new(handle.clone(), "primary-vault".to_owned()), + ), + ( + "analytics".to_owned(), + BoundSecretStore::new(handle, "analytics-vault".to_owned()), + ), + ] + .into_iter() + .collect(); + let registry: SecretRegistry = StoreRegistry::new(by_id, "primary".to_owned()); + + let mut request = request_builder() + .method(Method::GET) + .uri("/secrets") + .body(Body::empty()) + .expect("request"); + request.extensions_mut().insert(registry); + let ctx = RequestContext::new(request, PathParams::default()); - // Deref works - let _: &KvHandle = &kv; + let secrets = + block_on(Secrets::from_request(&ctx)).expect("Secrets extractor when registry present"); + // The per-id binding survives the extractor — each named store + // resolves to its own platform name. + assert_eq!( + secrets.named("primary").expect("primary").store_name(), + "primary-vault" + ); + assert_eq!( + secrets.named("analytics").expect("analytics").store_name(), + "analytics-vault" + ); + assert_eq!( + secrets.default().expect("default").store_name(), + "primary-vault" + ); + assert!(secrets.named("missing").is_none()); + } - // into_inner works - let _inner = kv.into_inner(); + #[test] + fn secrets_extractor_errors_when_absent() { + let request = request_builder() + .method(Method::GET) + .uri("/secrets") + .body(Body::empty()) + .expect("request"); + let ctx = RequestContext::new(request, PathParams::default()); + let err = block_on(Secrets::from_request(&ctx)).unwrap_err(); + assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR); } - // -- Secrets extractor -------------------------------------------------- + #[test] + fn config_extractor_resolves_from_registry() { + use crate::config_store::{ConfigStore, ConfigStoreError, ConfigStoreHandle}; + use std::collections::BTreeMap; + use std::sync::Arc; + + struct FixedStore(&'static str); + #[async_trait(?Send)] + impl ConfigStore for FixedStore { + async fn get(&self, _key: &str) -> Result, ConfigStoreError> { + Ok(Some(self.0.to_owned())) + } + } + + let registry: ConfigRegistry = StoreRegistry::new( + [ + ( + "primary".to_owned(), + ConfigStoreHandle::new(Arc::new(FixedStore("primary"))), + ), + ( + "analytics".to_owned(), + ConfigStoreHandle::new(Arc::new(FixedStore("analytics"))), + ), + ] + .into_iter() + .collect::>(), + "primary".to_owned(), + ); + + let mut request = request_builder() + .method(Method::GET) + .uri("/config") + .body(Body::empty()) + .expect("request"); + request.extensions_mut().insert(registry); + + let ctx = RequestContext::new(request, PathParams::default()); + let config = + block_on(Config::from_request(&ctx)).expect("Config extractor when registry present"); + let analytics = config.named("analytics").expect("analytics handle"); + assert_eq!( + block_on(analytics.get("any")).expect("config value"), + Some("analytics".to_owned()) + ); + assert!(config.named("missing").is_none()); + assert!(config.default().is_some()); + } #[test] - fn secrets_extractor_returns_handle_when_present() { - use crate::secret_store::{NoopSecretStore, SecretHandle}; + fn config_extractor_errors_when_only_legacy_handle_wired() { + // Hard-cutoff — same semantics as + // `kv_extractor_errors_when_only_legacy_handle_wired`. + use crate::config_store::{ConfigStore, ConfigStoreError, ConfigStoreHandle}; use std::sync::Arc; + struct AnyStore; + #[async_trait(?Send)] + impl ConfigStore for AnyStore { + async fn get(&self, _key: &str) -> Result, ConfigStoreError> { + Ok(Some("legacy".to_owned())) + } + } + let mut request = request_builder() .method(Method::GET) - .uri("/secrets") + .uri("/config") .body(Body::empty()) .expect("request"); request .extensions_mut() - .insert(SecretHandle::new(Arc::new(NoopSecretStore))); + .insert(ConfigStoreHandle::new(Arc::new(AnyStore))); let ctx = RequestContext::new(request, PathParams::default()); - block_on(Secrets::from_request(&ctx)).expect("Secrets extractor when handle present"); + let err = block_on(Config::from_request(&ctx)) + .expect_err("extractor must surface missing-registry as an error"); + assert!( + err.message().contains("no config store configured"), + "error names the wiring gap: {err:?}" + ); } #[test] - fn secrets_extractor_errors_when_absent() { + fn config_extractor_errors_when_absent() { let request = request_builder() .method(Method::GET) - .uri("/secrets") + .uri("/config") .body(Body::empty()) .expect("request"); let ctx = RequestContext::new(request, PathParams::default()); - let err = block_on(Secrets::from_request(&ctx)).unwrap_err(); + let err = block_on(Config::from_request(&ctx)).expect_err("expected error"); assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert!(err.message().contains("check [stores.config]")); } } diff --git a/crates/edgezero-core/src/key_value_store.rs b/crates/edgezero-core/src/key_value_store.rs index 292aa584..5169606f 100644 --- a/crates/edgezero-core/src/key_value_store.rs +++ b/crates/edgezero-core/src/key_value_store.rs @@ -23,22 +23,28 @@ //! //! # Usage //! -//! Access the KV store in a handler via [`crate::context::RequestContext::kv_handle`]: +//! Use the [`crate::extractor::Kv`] extractor with the `#[action]` +//! macro and pick a store by id at the call site (portable +//! store API): //! //! ```rust,ignore -//! async fn visit_counter(ctx: RequestContext) -> Result { -//! let kv = ctx.kv_handle().expect("kv store configured"); -//! let count: i32 = kv.read_modify_write("visits", 0, |n| n + 1).await?; +//! #[action] +//! async fn visit_counter(kv: Kv) -> Result { +//! let store = kv +//! .default() +//! .ok_or_else(|| EdgeError::service_unavailable("no default kv"))?; +//! let count: i32 = store.read_modify_write("visits", 0, |n| n + 1).await?; //! Ok(format!("Visit #{count}")) //! } //! ``` //! -//! Or use the [`crate::extractor::Kv`] extractor with the `#[action]` macro: +//! Or reach the store through [`crate::context::RequestContext`] +//! when you have a context instead of an extractor: //! //! ```rust,ignore -//! #[action] -//! async fn visit_counter(Kv(store): Kv) -> Result { -//! let count: i32 = store.read_modify_write("visits", 0, |n| n + 1).await?; +//! async fn visit_counter(ctx: RequestContext) -> Result { +//! let kv = ctx.kv_store_default().expect("default kv configured"); +//! let count: i32 = kv.read_modify_write("visits", 0, |n| n + 1).await?; //! Ok(format!("Visit #{count}")) //! } //! ``` @@ -299,6 +305,11 @@ pub enum KvError { #[error("kv store error: {0}")] Internal(#[from] anyhow::Error), + /// A backend listing or paging limit was exceeded (e.g. Spin's + /// `max_list_keys` cap on `get_keys`). + #[error("kv backend limit exceeded: {message}")] + LimitExceeded { message: String }, + /// The requested key was not found (used by `delete` when strict). #[error("key not found: {key}")] NotFound { key: String }, @@ -311,6 +322,11 @@ pub enum KvError { #[error("kv store unavailable")] Unavailable, + /// The operation is not supported by the active backend (e.g. TTL writes + /// on Spin, where `key_value::Store::set` accepts no expiry). + #[error("kv operation not supported by backend: {operation}")] + Unsupported { operation: String }, + /// A validation error (e.g., invalid key or value). #[error("validation error: {0}")] Validation(String), @@ -325,7 +341,10 @@ pub enum KvError { /// /// ```ignore /// #[action] -/// async fn handler(Kv(store): Kv) -> Result { +/// async fn handler(kv: Kv) -> Result { +/// let store = kv +/// .default() +/// .ok_or_else(|| EdgeError::service_unavailable("no default kv"))?; /// let count: i32 = store.get_or("visits", 0).await?; /// store.put("visits", &(count + 1)).await?; /// Ok(format!("visits: {}", count + 1)) @@ -682,6 +701,12 @@ impl From for EdgeError { EdgeError::internal(anyhow::anyhow!("kv serialization error: {msg}")) } KvError::Internal(source) => EdgeError::internal(source), + KvError::Unsupported { operation } => EdgeError::not_implemented(format!( + "kv operation not supported by backend: {operation}" + )), + KvError::LimitExceeded { message } => { + EdgeError::service_unavailable(format!("kv backend limit exceeded: {message}")) + } } } } @@ -1051,6 +1076,26 @@ mod tests { assert_eq!(edge_err.status(), StatusCode::SERVICE_UNAVAILABLE); } + #[test] + fn kv_error_unsupported_converts_to_not_implemented() { + let kv_err = KvError::Unsupported { + operation: "put_bytes_with_ttl".to_owned(), + }; + let edge_err: EdgeError = kv_err.into(); + assert_eq!(edge_err.status(), StatusCode::NOT_IMPLEMENTED); + assert!(edge_err.message().contains("put_bytes_with_ttl")); + } + + #[test] + fn kv_error_limit_exceeded_converts_to_service_unavailable() { + let kv_err = KvError::LimitExceeded { + message: "max_list_keys=1000 exceeded".to_owned(), + }; + let edge_err: EdgeError = kv_err.into(); + assert_eq!(edge_err.status(), StatusCode::SERVICE_UNAVAILABLE); + assert!(edge_err.message().contains("max_list_keys")); + } + #[test] fn kv_handle_debug_output() { let kv = handle(); diff --git a/crates/edgezero-core/src/lib.rs b/crates/edgezero-core/src/lib.rs index a37f8d8d..03197e73 100644 --- a/crates/edgezero-core/src/lib.rs +++ b/crates/edgezero-core/src/lib.rs @@ -11,10 +11,12 @@ pub mod addr; pub mod app; +pub mod app_config; pub mod body; pub mod compression; pub mod config_store; pub mod context; +pub mod env_config; pub mod error; pub mod extractor; pub mod handler; @@ -28,5 +30,6 @@ pub mod responder; pub mod response; pub mod router; pub mod secret_store; +pub mod store_registry; -pub use edgezero_macros::{action, app}; +pub use edgezero_macros::{action, app, AppConfig}; diff --git a/crates/edgezero-core/src/manifest.rs b/crates/edgezero-core/src/manifest.rs index d2efc146..227e8e34 100644 --- a/crates/edgezero-core/src/manifest.rs +++ b/crates/edgezero-core/src/manifest.rs @@ -1,23 +1,12 @@ use log::LevelFilter; use serde::de::Error as DeError; -use serde::{Deserialize, Serialize}; -use std::collections::BTreeMap; +use serde::Deserialize; +use std::collections::{BTreeMap, BTreeSet}; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::{env, fs, io}; use validator::{Validate, ValidationError}; -pub const DEFAULT_CONFIG_STORE_NAME: &str = "EDGEZERO_CONFIG"; -/// Default KV store / binding name used when `[stores.kv]` is omitted. -pub const DEFAULT_KV_STORE_NAME: &str = "EDGEZERO_KV"; -/// Default secret store / binding name used when `[stores.secrets]` is omitted. -pub const DEFAULT_SECRET_STORE_NAME: &str = "EDGEZERO_SECRETS"; -// Spin config values come from Spin component variables (flat namespace); -// there is no runtime store-name concept, so adapter-name overrides for spin -// would be silently ignored. Keep spin out of the allowed set to surface -// misconfiguration at validation time rather than at runtime. -const SUPPORTED_CONFIG_STORE_ADAPTERS: &[&str] = &["axum", "cloudflare", "fastly"]; - pub struct ManifestLoader { manifest: Arc, } @@ -42,21 +31,29 @@ impl ManifestLoader { }) } - /// Loads a manifest from a static, compile-time-embedded TOML string - /// (typically `include_str!("edgezero.toml")` inside an adapter binary). + /// Loads a manifest from a static, in-process TOML string — + /// fixture data in tests, build-time compile-checks, and the + /// `app!` macro's compile-time consumption are the in-tree callers. + /// The portable store-registry rewrite removed the per-adapter + /// `run_app(include_str!("edgezero.toml"), …)` shape, so an adapter + /// binary no longer carries the manifest at runtime; the portable + /// store registry it would have extracted is baked into + /// `Hooks::stores()` by the macro instead. /// /// # Panics - /// Panics if `contents` is not valid TOML or fails validation. Because - /// `contents` is baked into the binary at build time, a parse/validation - /// failure means the binary itself is malformed — there is no runtime - /// recovery path, and surfacing the error as a panic with a clear - /// message is the correct behavior. Callers with a fallible input - /// source (file paths, network, user input) should use - /// [`ManifestLoader::try_load_from_str`] or [`ManifestLoader::from_path`]. + /// Panics if `contents` is not valid TOML or fails validation. + /// Because `contents` is statically known to the caller (a + /// compile-time literal in the macro / tests), a parse failure + /// indicates corruption that can't be recovered at runtime, and + /// surfacing it as a clear panic is the right behaviour. Callers + /// with a fallible input source (file paths, network, user input) + /// should use [`ManifestLoader::try_load_from_str`] or + /// [`ManifestLoader::from_path`]. #[expect( clippy::panic, - reason = "load_from_str only consumes binary-embedded manifests; \ - a parse error means the binary is corrupt and cannot recover" + reason = "load_from_str only consumes statically-known manifest \ + literals (macro/tests); a parse error means the caller's \ + static input is corrupt and cannot recover" )] #[must_use] #[inline] @@ -87,6 +84,7 @@ impl ManifestLoader { } #[derive(Debug, Deserialize, Validate)] +#[validate(schema(function = "validate_manifest_adapter_keys_case_unique"))] #[expect( clippy::partial_pub_fields, reason = "deserialized fields are pub for the public API; internal state is private" @@ -117,6 +115,27 @@ pub struct Manifest { } impl Manifest { + /// Look up a `[adapters.]` entry by adapter name, matching + /// case-insensitively against the manifest's declared keys. + /// + /// Returns `(canonical_key, config)` where `canonical_key` is the + /// EXACT spelling the operator wrote in `edgezero.toml` — callers + /// use it for error messages and downstream lookups so the + /// surface presented to the user matches the manifest text. + /// + /// Case-folded duplicates (e.g. both `[adapters.fastly]` and + /// `[adapters.Fastly]` declared) are rejected at manifest load + /// time by `validate_manifest_adapter_keys_case_unique`, so this + /// helper sees at most one match. + #[must_use] + #[inline] + pub fn adapter_entry(&self, name: &str) -> Option<(&String, &ManifestAdapter)> { + let needle = name.to_ascii_lowercase(); + self.adapters + .iter() + .find(|(key, _cfg)| key.to_ascii_lowercase() == needle) + } + #[must_use] #[inline] pub fn environment(&self) -> &ManifestEnvironment { @@ -167,29 +186,6 @@ impl Manifest { self.logging_resolved = resolved; } - /// Returns the KV store name for a given adapter. - /// - /// Resolution order: - /// 1. Per-adapter override (`[stores.kv.adapters.]`) - /// 2. Global name (`[stores.kv] name = "..."`) - /// 3. Default: `"EDGEZERO_KV"` - #[must_use] - #[inline] - pub fn kv_store_name(&self, adapter: &str) -> &str { - let Some(kv) = self.stores.kv.as_ref() else { - return DEFAULT_KV_STORE_NAME; - }; - let adapter_lower = adapter.to_ascii_lowercase(); - if let Some(adapter_cfg) = kv - .adapters - .iter() - .find(|&(name, _)| name.eq_ignore_ascii_case(&adapter_lower)) - { - return &adapter_cfg.1.name; - } - &kv.name - } - #[must_use] #[inline] pub fn logging_for(&self, adapter: &str) -> Option<&ResolvedLoggingConfig> { @@ -208,47 +204,13 @@ impl Manifest { self.root.as_deref() } - /// Returns the secret store binding identifier for a given adapter. - /// - /// Resolution order: - /// 1. Per-adapter override (`[stores.secrets.adapters.]`) - /// 2. Global name (`[stores.secrets] name = "..."`) - /// 3. Default: `"EDGEZERO_SECRETS"` - #[must_use] - #[inline] - pub fn secret_store_binding(&self, adapter: &str) -> &str { - let Some(secrets) = self.stores.secrets.as_ref() else { - return DEFAULT_SECRET_STORE_NAME; - }; - let adapter_lower = adapter.to_ascii_lowercase(); - if let Some(adapter_cfg) = secrets - .adapters - .iter() - .find(|&(name, _)| name.eq_ignore_ascii_case(&adapter_lower)) - { - if let Some(name) = adapter_cfg.1.name.as_deref() { - return name; - } - } - &secrets.name - } - /// Returns whether the secret store should be attached for a given adapter. + /// + /// True whenever a `[stores.secrets]` section is declared. #[must_use] #[inline] - pub fn secret_store_enabled(&self, adapter: &str) -> bool { - let Some(secrets) = self.stores.secrets.as_ref() else { - return false; - }; - let adapter_lower = adapter.to_ascii_lowercase(); - if let Some(adapter_cfg) = secrets - .adapters - .iter() - .find(|&(name, _)| name.eq_ignore_ascii_case(&adapter_lower)) - { - return adapter_cfg.1.enabled; - } - secrets.enabled + pub fn secret_store_enabled(&self, _adapter: &str) -> bool { + self.stores.secrets.is_some() } } @@ -381,6 +343,7 @@ pub struct ResolvedEnvironment { #[derive(Debug, Default, Deserialize, Validate)] #[non_exhaustive] +#[validate(schema(function = "validate_manifest_adapter"))] pub struct ManifestAdapter { #[serde(default)] #[validate(nested)] @@ -391,6 +354,12 @@ pub struct ManifestAdapter { #[serde(default)] #[validate(nested)] pub commands: ManifestAdapterCommands, + /// Catch-all for any sub-table other than the four canonical ones + /// (`adapter`, `build`, `commands`, `logging`). The pre-rewrite + /// `[adapters..stores.*]` tables land here and are rejected by + /// [`validate_manifest_adapter`] with the migration-guide message. + #[serde(flatten)] + pub legacy: BTreeMap, #[serde(default)] #[validate(nested)] pub logging: ManifestLoggingConfig, @@ -398,7 +367,15 @@ pub struct ManifestAdapter { #[derive(Debug, Default, Deserialize, Validate)] #[non_exhaustive] +#[validate(schema(function = "validate_manifest_adapter_definition"))] pub struct ManifestAdapterDefinition { + /// Spin component id, when the adapter's `manifest` (`spin.toml`) declares + /// more than one `[component.*]`. Read by `provision` and + /// `config push`; ignored at runtime. `config validate --strict` + /// requires it when `spin.toml` declares multiple components. + #[serde(default)] + #[validate(length(min = 1_u64))] + pub component: Option, #[serde(rename = "crate")] #[serde(default)] #[validate(length(min = 1_u64))] @@ -411,6 +388,12 @@ pub struct ManifestAdapterDefinition { #[serde(default)] #[validate(length(min = 1_u64))] pub host: Option, + /// Catch-all for any field other than the declared ones above. The + /// portable manifest has no per-adapter runtime tuning surface, so an + /// unknown key under `[adapters..adapter]` is rejected at load + /// time rather than silently ignored. + #[serde(flatten)] + pub legacy: BTreeMap, #[serde(default)] #[validate(length(min = 1_u64))] pub manifest: Option, @@ -435,6 +418,20 @@ pub struct ManifestAdapterBuild { #[derive(Debug, Default, Deserialize, Validate)] #[non_exhaustive] pub struct ManifestAdapterCommands { + /// Per-project override for `edgezero auth login --adapter `. + /// `None` (the default) means "use the adapter's built-in + /// command" — `wrangler login`, `fastly profile create`, etc. + #[serde(default, rename = "auth-login")] + #[validate(length(min = 1_u64))] + pub auth_login: Option, + /// Per-project override for `edgezero auth logout --adapter `. + #[serde(default, rename = "auth-logout")] + #[validate(length(min = 1_u64))] + pub auth_logout: Option, + /// Per-project override for `edgezero auth status --adapter `. + #[serde(default, rename = "auth-status")] + #[validate(length(min = 1_u64))] + pub auth_status: Option, #[serde(default)] #[validate(length(min = 1_u64))] pub build: Option, @@ -451,71 +448,67 @@ pub struct ManifestAdapterCommands { // --------------------------------------------------------------------------- /// Top-level `[stores]` section. +/// +/// `deny_unknown_fields` catches typos like `[stores.secret]` (vs. +/// the correct `[stores.secrets]`) at manifest load time. Without +/// it, a typo passes parsing silently — the runtime sees no +/// declaration for that kind and the app starts without a wired +/// store. The known declarations (`StoreDeclaration` itself, +/// adapter sections, etc.) already reject legacy fields below this +/// level, so adding the rejection HERE seals the only remaining +/// silent-typo path. #[derive(Debug, Default, Deserialize, Validate)] +#[serde(deny_unknown_fields)] #[non_exhaustive] pub struct ManifestStores { #[serde(default)] #[validate(nested)] - pub config: Option, + pub config: Option, #[serde(default)] #[validate(nested)] - pub kv: Option, + pub kv: Option, #[serde(default)] #[validate(nested)] - pub secrets: Option, + pub secrets: Option, } -/// `[stores.config]` section — provider-neutral config store. +/// Portable `[stores.]` declaration. +/// +/// Declares logical store ids only — the portable fact that "this app uses a +/// KV/config/secrets store called ``". No platform names, no per-adapter +/// tuning. Platform-specific runtime config (store names, tuning) is supplied +/// out of band; in this interim model a store's name resolves to its logical +/// [`StoreDeclaration::default_id`]. #[derive(Debug, Deserialize, Validate)] #[non_exhaustive] -pub struct ManifestConfigStoreConfig { - /// Per-adapter name overrides, keyed by supported lowercase adapter name - /// (`axum`, `cloudflare`, or `fastly`). Spin config uses component - /// variables in a flat namespace, so `stores.config.adapters.spin` is - /// rejected during validation. +#[validate(schema(function = "validate_store_declaration"))] +pub struct StoreDeclaration { + /// Logical default store id. Required when `ids.len() > 1`; when there is + /// exactly one id it resolves to `ids[0]`. #[serde(default)] - #[validate(nested)] - #[validate(custom(function = "validate_config_store_adapter_keys"))] - pub adapters: BTreeMap, - /// Optional default values used for local dev (Axum adapter). + pub default: Option, + /// Logical store ids — non-empty (enforced in validation, not by serde, so + /// a legacy manifest is rejected with the migration-guide message rather + /// than a bare "missing field `ids`" parse error). #[serde(default)] - pub defaults: BTreeMap, - /// Global store/binding name used when no adapter-specific override is set. - #[serde(default)] - #[validate(length(min = 1_u64))] - pub name: Option, -} - -/// `[stores.config.adapters.]` override. -#[derive(Debug, Deserialize, Serialize, Validate)] -#[non_exhaustive] -pub struct ManifestConfigAdapterConfig { - #[validate(length(min = 1_u64))] - pub name: String, + pub ids: Vec, + /// Any field other than `ids` / `default` — the pre-rewrite store schema + /// (`name`, `enabled`, `adapters`, `defaults`) lands here and is rejected + /// with a migration-guide message during validation. + #[serde(flatten)] + pub legacy: BTreeMap, } -impl ManifestConfigStoreConfig { - /// Access the default key-value pairs for local dev. +impl StoreDeclaration { + /// Resolve the default logical store id (the explicit `default`, else the + /// first declared id). #[must_use] #[inline] - pub fn config_store_defaults(&self) -> &BTreeMap { - &self.defaults - } - - /// Resolve the config store name for a given adapter. - /// - /// Priority: adapter override → global name → `DEFAULT_CONFIG_STORE_NAME`. - #[must_use] - #[inline] - pub fn config_store_name(&self, adapter: &str) -> &str { - let adapter_lower = adapter.to_ascii_lowercase(); - if let Some(override_cfg) = self.adapters.get(&adapter_lower) { - return &override_cfg.name; - } - if let Some(name) = self.name.as_deref() { - return name; - } - DEFAULT_CONFIG_STORE_NAME + pub fn default_id(&self) -> &str { + self.default + .as_deref() + .or_else(|| self.ids.first().map(String::as_str)) + .unwrap_or("") } } @@ -583,62 +576,6 @@ impl ManifestLoggingConfig { } } -/// Global KV store configuration. -#[derive(Debug, Deserialize, Validate)] -#[non_exhaustive] -pub struct ManifestKvConfig { - /// Per-adapter name overrides. - #[serde(default)] - #[validate(nested)] - pub adapters: BTreeMap, - - /// Store / binding name (default: `"EDGEZERO_KV"`). - #[serde(default = "default_kv_name")] - #[validate(length(min = 1_u64))] - pub name: String, -} - -/// Per-adapter KV binding / store name override. -#[derive(Debug, Deserialize, Validate)] -#[non_exhaustive] -pub struct ManifestKvAdapterConfig { - #[validate(length(min = 1_u64))] - pub name: String, -} - -/// Global secret store configuration. -#[derive(Debug, Deserialize, Validate)] -#[non_exhaustive] -pub struct ManifestSecretsConfig { - /// Per-adapter name overrides. - #[serde(default)] - #[validate(nested)] - pub adapters: BTreeMap, - - /// Whether the secret store is enabled for adapters without overrides. - #[serde(default = "default_enabled")] - pub enabled: bool, - - /// Store / binding name (default: `"EDGEZERO_SECRETS"`). - #[serde(default = "default_secret_name")] - #[validate(length(min = 1_u64))] - pub name: String, -} - -/// Per-adapter secret store name override. -#[derive(Debug, Deserialize, Validate)] -#[non_exhaustive] -pub struct ManifestSecretsAdapterConfig { - /// Whether the secret store is enabled for this adapter. - #[serde(default = "default_enabled")] - pub enabled: bool, - - /// Optional per-adapter secret store name override. - #[serde(default)] - #[validate(length(min = 1_u64))] - pub name: Option, -} - #[derive(Clone, Copy, Debug, Eq, PartialEq)] #[non_exhaustive] pub enum HttpMethod { @@ -797,18 +734,6 @@ impl<'de> Deserialize<'de> for LogLevel { } } -fn default_enabled() -> bool { - true -} - -fn default_kv_name() -> String { - DEFAULT_KV_STORE_NAME.to_owned() -} - -fn default_secret_name() -> String { - DEFAULT_SECRET_STORE_NAME.to_owned() -} - fn resolve_root_path(path: &Path, cwd: &Path) -> PathBuf { match path.parent() { Some(parent) if parent.as_os_str().is_empty() => cwd.to_path_buf(), @@ -818,45 +743,214 @@ fn resolve_root_path(path: &Path, cwd: &Path) -> PathBuf { } } -fn validate_config_store_adapter_keys( - adapters: &BTreeMap, +/// Validates a single `[adapters..adapter]` block. The portable +/// manifest model lists the declared fields explicitly; an unknown key +/// would otherwise be silently dropped by serde, so we surface it as a +/// hard load error with the migration-guide pointer (consistent with the +/// hard-cutoff on `[stores.]` and `[adapters..]`). +fn validate_manifest_adapter_definition( + definition: &ManifestAdapterDefinition, ) -> Result<(), ValidationError> { - let mixed_case_keys = adapters - .keys() - .filter(|key| key.as_str() != key.to_ascii_lowercase()) - .cloned() - .collect::>(); - if !mixed_case_keys.is_empty() { - let mut error = ValidationError::new("config_store_adapter_keys_lowercase"); + if !definition.legacy.is_empty() { + let mut keys = definition.legacy.keys().cloned().collect::>(); + keys.sort(); + let mut error = ValidationError::new("legacy_adapter_definition_schema"); error.message = Some( format!( - "config store adapter override keys must be lowercase: {}", - mixed_case_keys.join(", ") + "unknown field(s) under `[adapters..adapter]`: {}. The portable \ + manifest has no per-adapter runtime tuning surface beyond \ + `component`, `crate`, `host`, `manifest`, `port` -- see \ + docs/guide/manifest-store-migration.md", + keys.join(", ") ) .into(), ); return Err(error); } + Ok(()) +} - let unknown_keys = adapters - .keys() - .filter(|key| !SUPPORTED_CONFIG_STORE_ADAPTERS.contains(&key.as_str())) - .cloned() - .collect::>(); - if unknown_keys.is_empty() { - return Ok(()); +/// Validates a single `[adapters.]` block. The portable manifest model +/// has no per-adapter store / runtime tuning surface — all of that moved to +/// `EDGEZERO__*` env vars. The pre-rewrite +/// `[adapters..stores.]` tables and the legacy +/// `[adapters..adapter] runtime` block were silently ignored by the +/// deserializer before this hard-cutoff, so projects could carry over +/// stale entries without noticing. +fn validate_manifest_adapter(adapter: &ManifestAdapter) -> Result<(), ValidationError> { + if !adapter.legacy.is_empty() { + let mut keys = adapter.legacy.keys().cloned().collect::>(); + keys.sort(); + let mut error = ValidationError::new("legacy_adapter_schema"); + error.message = Some( + format!( + "the pre-rewrite `[adapters..]` subtables are no longer \ + supported (offending field(s): {}); per-adapter store / runtime \ + tuning moved to `EDGEZERO__*` env vars -- see \ + docs/guide/manifest-store-migration.md", + keys.join(", ") + ) + .into(), + ); + return Err(error); } + Ok(()) +} - let mut error = ValidationError::new("config_store_adapter_keys_known"); - error.message = Some( - format!( - "config store adapter override keys must match supported adapters ({}): {}", - SUPPORTED_CONFIG_STORE_ADAPTERS.join(", "), - unknown_keys.join(", ") - ) - .into(), - ); - Err(error) +/// Reject case-fold duplicate `[adapters.*]` keys at manifest load +/// time so the case-insensitive `adapter_entry` lookup is never +/// ambiguous. +/// +/// Pre-fix, an operator could declare BOTH `[adapters.fastly]` AND +/// `[adapters.Fastly]` in the same manifest. TOML treats them as +/// distinct keys, and the downstream code's mix of exact-case +/// `get()` and case-insensitive lookups would resolve them +/// inconsistently. Catch the collision once, at load time, instead +/// of leaving every consumer to decide which spelling wins. +fn validate_manifest_adapter_keys_case_unique(manifest: &Manifest) -> Result<(), ValidationError> { + let mut seen_ci: BTreeMap = BTreeMap::new(); + for key in manifest.adapters.keys() { + let folded = key.to_ascii_lowercase(); + if let Some(prior) = seen_ci.insert(folded.clone(), key) { + let mut error = ValidationError::new("adapters_case_duplicate"); + error.message = Some( + format!( + "manifest declares `[adapters.{prior}]` AND `[adapters.{key}]`, which differ only in case; adapter names are looked up case-insensitively at runtime so the two would alias to the same registry entry. Pick one spelling." + ) + .into(), + ); + return Err(error); + } + } + Ok(()) +} + +/// Validates a single `[stores.]` declaration against the portable +/// schema. +/// +/// Rejects the pre-rewrite store fields (`name`, `enabled`, `adapters`, +/// `defaults`) with an error pointing at the migration guide, and enforces the +/// `ids` / `default` invariants. +fn validate_store_declaration(declaration: &StoreDeclaration) -> Result<(), ValidationError> { + if !declaration.legacy.is_empty() { + let mut keys = declaration.legacy.keys().cloned().collect::>(); + keys.sort(); + let mut error = ValidationError::new("legacy_store_schema"); + error.message = Some( + format!( + "the pre-rewrite `[stores.]` schema is no longer supported \ + (offending field(s): {}); migrate to the portable `ids` / `default` \ + form -- see docs/guide/manifest-store-migration.md", + keys.join(", ") + ) + .into(), + ); + return Err(error); + } + + if declaration.ids.is_empty() { + let mut error = ValidationError::new("store_ids_empty"); + error.message = + Some("`[stores.].ids` must declare at least one logical store id".into()); + return Err(error); + } + + if let Some(blank) = declaration + .ids + .iter() + .find(|id| id.trim().is_empty() || id.chars().any(char::is_control)) + { + let mut error = ValidationError::new("store_id_blank"); + error.message = Some( + format!( + "`[stores.].ids` entries must be non-empty and printable \ + (offending value: {blank:?})" + ) + .into(), + ); + return Err(error); + } + + // Enforce a portable segment shape: each id is later used as + // - an `EDGEZERO__STORES______NAME` env-var path segment + // (`__` is the SEGMENT SEPARATOR, so an id containing `__` would + // make `__NAME` ambiguous); + // - a filename component (e.g. axum's + // `.edgezero/local-config-.json`, so `/` would escape the + // intended directory); + // - a registry key and TOML table label. + // + // Permit only `[A-Za-z0-9_-]` and reject `__` — strict enough to + // be safe across all four consumers, loose enough to cover every + // id in the scaffold and example suite (`app_config`, + // `feature_flags`, `sessions`, …). + if let Some(bad) = declaration.ids.iter().find(|id| { + let chars_bad = id + .chars() + .any(|ch| !(ch.is_ascii_alphanumeric() || ch == '_' || ch == '-')); + let double_underscore = id.contains("__"); + chars_bad || double_underscore + }) { + let mut error = ValidationError::new("store_id_format"); + error.message = Some( + format!( + "`[stores.].ids` entry `{bad}` is not a portable segment: only ASCII alphanumeric / `_` / `-` are allowed, and `__` (double underscore) is reserved as the `EDGEZERO__STORES__…` env-var separator. Rename it to something like `app_config` / `feature-flags`." + ) + .into(), + ); + return Err(error); + } + + // Exact-duplicate check (preserved for the simple case). + let mut seen: BTreeSet<&str> = BTreeSet::new(); + if let Some(dup) = declaration.ids.iter().find(|id| !seen.insert(id.as_str())) { + let mut error = ValidationError::new("store_id_duplicate"); + error.message = Some(format!("`[stores.].ids` contains duplicate id `{dup}`").into()); + return Err(error); + } + + // Case-insensitive duplicate check: env-var lookup + // (`EDGEZERO__STORES______NAME` with `` upper-cased) + // would alias `foo` and `FOO`, and several downstream consumers + // lower-case segments before lookup. Reject ASCII case-only + // duplicates so the operator sees a manifest error instead of + // silent shadowing at runtime. + let mut seen_ci: BTreeSet = BTreeSet::new(); + if let Some(dup_ci) = declaration + .ids + .iter() + .find(|id| !seen_ci.insert(id.to_ascii_lowercase())) + { + let mut error = ValidationError::new("store_id_case_duplicate"); + error.message = Some( + format!( + "`[stores.].ids` contains case-insensitive duplicate id `{dup_ci}`; ids must be unique under ASCII case-folding because `EDGEZERO__STORES______NAME` env-var lookup upper-cases the id and several downstream consumers lower-case it again. Pick distinct names." + ) + .into(), + ); + return Err(error); + } + + if declaration.ids.len() > 1 && declaration.default.is_none() { + let mut error = ValidationError::new("store_default_required"); + error.message = Some( + "`default` is required when `[stores.]` declares more than one id \ + -- see docs/guide/manifest-store-migration.md" + .into(), + ); + return Err(error); + } + + if let Some(default) = declaration.default.as_deref() { + if !declaration.ids.iter().any(|id| id == default) { + let mut error = ValidationError::new("store_default_unknown"); + error.message = + Some(format!("`default` (`{default}`) must be one of the declared `ids`").into()); + return Err(error); + } + } + + Ok(()) } #[cfg(test)] @@ -916,15 +1010,15 @@ env = "APP_TOKEN" #[test] fn try_load_from_str_rejects_failed_validation() { - // `[stores.config]` requires a non-empty `name` when set; an empty - // string trips `validator` and surfaces as InvalidData. + // `[stores.config]` requires a non-empty `ids` list; an empty list + // trips `validator` and surfaces as InvalidData. let err = ManifestLoader::try_load_from_str( r#" [app] name = "demo" [stores.config] -name = "" +ids = [] "#, ) .err() @@ -1483,161 +1577,297 @@ manifest = "fastly.toml" assert_eq!(HttpMethod::Head.as_str(), "HEAD"); } - // Config store tests + // -- Portable store declarations --------------------------------------- + #[test] - fn config_store_name_falls_back_to_default_constant() { - // [stores.config] present but no name and no adapter overrides: - // config_store_name() must return DEFAULT_CONFIG_STORE_NAME. - let toml = "[stores.config]\n"; - let mfest = ManifestLoader::load_from_str(toml); - let config = mfest.manifest().stores.config.as_ref().unwrap(); - assert_eq!( - config.config_store_name("fastly"), - DEFAULT_CONFIG_STORE_NAME - ); - assert_eq!( - config.config_store_name("cloudflare"), - DEFAULT_CONFIG_STORE_NAME - ); - assert_eq!(config.config_store_name("axum"), DEFAULT_CONFIG_STORE_NAME); + fn store_declaration_round_trips() { + let toml = r#" +[stores.kv] +ids = ["sessions", "cache"] +default = "sessions" + +[stores.config] +ids = ["app_config"] + +[stores.secrets] +ids = ["default"] +"#; + let loader = ManifestLoader::load_from_str(toml); + let stores = &loader.manifest().stores; + + let kv = stores.kv.as_ref().expect("kv declared"); + assert_eq!(kv.ids, ["sessions", "cache"]); + assert_eq!(kv.default_id(), "sessions"); + + let config = stores.config.as_ref().expect("config declared"); + assert_eq!(config.ids, ["app_config"]); + assert_eq!(config.default_id(), "app_config"); + + let secrets = stores.secrets.as_ref().expect("secrets declared"); + assert_eq!(secrets.default_id(), "default"); } #[test] - fn config_store_name_defaults_when_omitted() { - // No [stores.config] section at all: callers skip the config store entirely. - let manifest = ManifestLoader::load_from_str(""); - assert!(manifest.manifest().stores.config.is_none()); + fn store_declaration_default_id_falls_back_to_first_id() { + let loader = ManifestLoader::load_from_str("[stores.kv]\nids = [\"only\"]\n"); + let kv = loader.manifest().stores.kv.as_ref().expect("kv declared"); + assert!(kv.default.is_none()); + assert_eq!(kv.default_id(), "only"); } #[test] - fn config_store_name_uses_global_name() { - let toml = r#" -[stores.config] -name = "app_config" -"#; - let mfest = ManifestLoader::load_from_str(toml); - let config = mfest.manifest().stores.config.as_ref().unwrap(); - assert_eq!(config.config_store_name("fastly"), "app_config"); - assert_eq!(config.config_store_name("cloudflare"), "app_config"); - assert_eq!(config.config_store_name("axum"), "app_config"); + fn store_declaration_empty_ids_fails_validation() { + let manifest: Manifest = toml::from_str("[stores.kv]\nids = []\n").expect("should parse"); + assert!( + manifest.validate().is_err(), + "empty `ids` list should fail validation" + ); } #[test] - fn config_store_name_adapter_override() { - let toml = r#" -[stores.config] -name = "global_config" - -[stores.config.adapters.fastly] -name = "my-config-link" + fn store_declaration_blank_id_fails_validation() { + for raw in [ + "[stores.kv]\nids = [\"\"]\n", + "[stores.kv]\nids = [\" \"]\n", + "[stores.kv]\nids = [\"good\", \"\\n\"]\ndefault = \"good\"\n", + ] { + let manifest: Manifest = toml::from_str(raw).expect("should parse"); + let err = manifest + .validate() + .expect_err("blank/whitespace/control id should fail validation"); + assert!( + err.to_string().contains("non-empty and printable"), + "error should mention printable rule, got: {err}" + ); + } + } -[stores.config.adapters.cloudflare] -name = "APP_CONFIG_BINDING" -"#; - let mfest = ManifestLoader::load_from_str(toml); - let config = mfest.manifest().stores.config.as_ref().unwrap(); - assert_eq!(config.config_store_name("fastly"), "my-config-link"); - assert_eq!(config.config_store_name("cloudflare"), "APP_CONFIG_BINDING"); - assert_eq!(config.config_store_name("axum"), "global_config"); + #[test] + fn store_declaration_duplicate_id_fails_validation() { + let manifest: Manifest = toml::from_str( + "[stores.kv]\nids = [\"app_config\", \"app_config\"]\ndefault = \"app_config\"\n", + ) + .expect("should parse"); + let err = manifest + .validate() + .expect_err("duplicate ids should fail validation"); + assert!( + err.to_string().contains("duplicate id"), + "error should mention duplicate, got: {err}" + ); } #[test] - fn config_store_name_case_insensitive() { - let toml = r#" -[stores.config.adapters.fastly] -name = "fastly-store" -"#; - let mfest = ManifestLoader::load_from_str(toml); - let config = mfest.manifest().stores.config.as_ref().unwrap(); - assert_eq!(config.config_store_name("FASTLY"), "fastly-store"); - assert_eq!(config.config_store_name("Fastly"), "fastly-store"); - assert_eq!(config.config_store_name("fastly"), "fastly-store"); + fn manifest_rejects_case_fold_duplicate_adapter_keys() { + // PR #269 round 4: case-fold dup detection. `[adapters.xenon]` + // and `[adapters.Xenon]` are distinct TOML keys but resolve + // to the same `adapter_entry` lookup at runtime — reject at + // load time so the case-insensitive lookup is never + // ambiguous. + // + // Uses a synthetic adapter name (`xenon`) so the test + // exercises the manifest validator without coupling to any + // real adapter crate's identity. + let manifest: Manifest = + toml::from_str("[adapters.xenon]\n[adapters.Xenon]\n").expect("should parse"); + let err = manifest + .validate() + .expect_err("case-fold dup adapter keys must fail validation"); + assert!( + err.to_string().contains("case"), + "error must call out the case collision: {err}" + ); } #[test] - fn config_store_mixed_case_adapter_key_fails_validation() { - let src = r#" -[stores.config.adapters.Fastly] -name = "fastly-store" -"#; - let manifest: Manifest = toml::from_str(src).expect("should parse"); - let result = manifest.validate(); + fn manifest_adapter_entry_matches_case_insensitively_returning_canonical_key() { + // Lookup helper used everywhere in the CLI: takes a name + // and returns the manifest's spelling so error messages + // surface the operator's exact text. Confirm the lookup + // works for both upper-case AND mixed-case needles + // against a lower-case stored key. + // + // Synthetic adapter name (`xenon`) keeps this manifest- + // level test independent of any specific adapter crate. + let manifest: Manifest = toml::from_str("[adapters.xenon]\n").expect("should parse"); + let (upper_match, _ignored_upper) = manifest + .adapter_entry("XENON") + .expect("uppercase needle must match"); + assert_eq!(upper_match, "xenon", "returns the manifest's spelling"); + let (mixed_match, _ignored_mixed) = manifest + .adapter_entry("Xenon") + .expect("mixed-case needle must match"); + assert_eq!(mixed_match, "xenon"); assert!( - result.is_err(), - "mixed-case config store adapter key should fail validation" + manifest.adapter_entry("krypton").is_none(), + "absent name returns None" ); } #[test] - fn config_store_unknown_adapter_key_fails_validation() { - let src = r#" -[stores.config.adapters.clouflare] -name = "APP_CONFIG" -"#; - let manifest: Manifest = toml::from_str(src).expect("should parse"); - let result = manifest.validate(); + fn manifest_stores_rejects_unknown_kind_at_parse_time() { + // PR #269 round 4 / F2: `deny_unknown_fields` on + // ManifestStores catches typos like `[stores.secret]` (vs + // the correct `[stores.secrets]`). Pre-fix, a typo passed + // parsing silently and the runtime saw no secrets + // declaration. + let err = toml::from_str::("[stores.secret]\nids = [\"default\"]\n") + .expect_err("unknown `secret` kind must fail at parse time"); + let msg = err.to_string(); assert!( - result.is_err(), - "unknown config store adapter key should fail validation" + msg.contains("secret") && msg.contains("unknown field"), + "error must call out the typo: {msg}" ); } #[test] - fn config_store_spin_adapter_key_fails_validation() { - // Spin config values come from component variables; there is no - // runtime store-name concept, so a spin adapter override would be - // silently ignored. Validation rejects it to surface the mistake early. - let src = r#" -[stores.config.adapters.spin] -name = "SPIN_CONFIG" -"#; - let manifest: Manifest = toml::from_str(src).expect("should parse"); + fn store_declaration_rejects_id_with_path_separator() { + // `foo/bar` would let the axum config writer create + // `.edgezero/local-config-foo/bar.json`, which traverses + // into a subdirectory. Reject at manifest load. + let manifest: Manifest = + toml::from_str("[stores.kv]\nids = [\"foo/bar\"]\n").expect("should parse"); + let err = manifest + .validate() + .expect_err("non-portable id `foo/bar` must fail validation"); assert!( - manifest.validate().is_err(), - "spin config store adapter key should fail validation" + err.to_string().contains("portable segment"), + "error must explain the segment rule: {err}" ); } #[test] - fn config_store_defaults_accessible() { - let toml = r#" -[stores.config.defaults] -"feature.checkout" = "true" -"service.timeout_ms" = "1500" -"#; - let mfest = ManifestLoader::load_from_str(toml); - let config = mfest.manifest().stores.config.as_ref().unwrap(); - let defaults = config.config_store_defaults(); - assert_eq!( - defaults.get("feature.checkout").map(String::as_str), - Some("true") + fn store_declaration_rejects_double_underscore_in_id() { + // `foo__bar` would collide with `EDGEZERO__STORES__KV__FOO` + // + segment `BAR__NAME` parsing on the env-overlay side. + let manifest: Manifest = + toml::from_str("[stores.kv]\nids = [\"foo__bar\"]\n").expect("should parse"); + let err = manifest + .validate() + .expect_err("`__` is reserved as the env-var segment separator"); + assert!( + err.to_string().contains("`__` (double underscore)"), + "error must call out the env-segment separator: {err}" ); - assert_eq!( - defaults.get("service.timeout_ms").map(String::as_str), - Some("1500") + } + + #[test] + fn store_declaration_rejects_case_only_duplicates() { + // `foo` and `FOO` upper-case to the same + // `EDGEZERO__STORES__KV__FOO` env-var path; reject the + // shadow at manifest load instead of letting one silently + // win at runtime. + let manifest: Manifest = + toml::from_str("[stores.kv]\nids = [\"foo\", \"FOO\"]\ndefault = \"foo\"\n") + .expect("should parse"); + let err = manifest + .validate() + .expect_err("ASCII case-folded duplicates must fail validation"); + assert!( + err.to_string().contains("case-insensitive duplicate"), + "error must call out the case-fold collision: {err}" ); } #[test] - fn empty_manifest_has_no_config_store() { - let mfest = ManifestLoader::load_from_str(""); - assert!(mfest.manifest().stores.config.is_none()); + fn store_declaration_accepts_portable_alphanumeric_ids() { + // Sanity: the new format check doesn't accidentally reject + // the canonical scaffold shapes — single underscore, single + // hyphen, mixed case, digits. + for ids in [ + "[\"app_config\"]", + "[\"feature-flags\"]", + "[\"appConfig\"]", + "[\"v1\", \"v2\"]\ndefault = \"v1\"", + ] { + let toml = format!("[stores.kv]\nids = {ids}\n"); + let manifest: Manifest = toml::from_str(&toml).expect("should parse"); + manifest + .validate() + .unwrap_or_else(|err| panic!("portable ids must validate: {ids} -> {err}")); + } } #[test] - fn config_store_empty_global_name_fails_validation() { - let src = r#" -[stores.config] -name = "" -"#; - let manifest: Manifest = toml::from_str(src).expect("should parse"); - let result = manifest.validate(); + fn store_declaration_requires_default_with_multiple_ids() { + let manifest: Manifest = + toml::from_str("[stores.kv]\nids = [\"a\", \"b\"]\n").expect("should parse"); + let err = manifest + .validate() + .expect_err("missing `default` with >1 id should fail validation"); assert!( - result.is_err(), - "empty global config store name should fail validation" + err.to_string().contains("default"), + "error should mention `default`, got: {err}" ); } + #[test] + fn store_declaration_default_must_be_a_declared_id() { + let manifest: Manifest = + toml::from_str("[stores.kv]\nids = [\"a\", \"b\"]\ndefault = \"c\"\n") + .expect("should parse"); + let err = manifest + .validate() + .expect_err("`default` outside `ids` should fail validation"); + assert!( + err.to_string().contains("declared `ids`"), + "error should explain the `default` constraint, got: {err}" + ); + } + + #[test] + fn legacy_store_schema_is_a_hard_load_error() { + for legacy in [ + "[stores.kv]\nname = \"MY_KV\"\n", + "[stores.config]\nids = [\"app_config\"]\n\n[stores.config.defaults]\nkey = \"value\"\n", + "[stores.kv]\nids = [\"sessions\"]\n\n[stores.kv.adapters.spin]\nname = \"label\"\n", + "[stores.secrets]\nids = [\"default\"]\nenabled = false\n", + ] { + let err = ManifestLoader::try_load_from_str(legacy) + .err() + .unwrap_or_else(|| panic!("legacy manifest must fail to load: {legacy}")); + assert_eq!(err.kind(), io::ErrorKind::InvalidData); + assert!( + err.to_string() + .contains("docs/guide/manifest-store-migration.md"), + "legacy-schema error must reference the migration guide, got: {err}" + ); + } + } + + #[test] + fn legacy_adapter_subtables_are_a_hard_load_error() { + // Pre-rewrite manifests carried per-adapter store / runtime tuning + // under `[adapters..]`. The portable model moved all of + // that to `EDGEZERO__*` env vars; stale subtables left in a + // migrated manifest must surface as a hard load error rather than + // be silently ignored. + for legacy in [ + // legacy per-adapter KV-store override (old [stores.kv.adapters.spin] hoisted) + "[adapters.spin.stores.kv.default]\nname = \"EDGEZERO_KV\"\n", + "[adapters.fastly.stores.config]\nname = \"app_config\"\n", + "[adapters.cloudflare.stores.secrets.default]\nname = \"WORKER_SECRETS\"\n", + // legacy runtime-tuning subtable under [adapters.axum] + "[adapters.axum.runtime]\nthreads = 4\n", + ] { + let err = ManifestLoader::try_load_from_str(legacy) + .err() + .unwrap_or_else(|| panic!("legacy adapter subtable must fail to load: {legacy}")); + assert_eq!(err.kind(), io::ErrorKind::InvalidData); + assert!( + err.to_string() + .contains("docs/guide/manifest-store-migration.md"), + "legacy adapter-subtable error must reference the migration guide, got: {err}" + ); + } + } + + #[test] + fn empty_manifest_has_no_config_store() { + let mfest = ManifestLoader::load_from_str(""); + assert!(mfest.manifest().stores.config.is_none()); + } + // Multiple triggers test #[test] fn triggers_with_all_fields() { @@ -1665,123 +1895,8 @@ body-mode = "buffered" assert_eq!(trigger.body_mode, Some(BodyMode::Buffered)); } - // -- KV store config --------------------------------------------------- - - #[test] - fn kv_store_name_defaults_when_omitted() { - let toml_str = r#" -[app] -name = "test" -"#; - let loader = ManifestLoader::load_from_str(toml_str); - let manifest = loader.manifest(); - assert_eq!(manifest.kv_store_name("fastly"), "EDGEZERO_KV"); - assert_eq!(manifest.kv_store_name("cloudflare"), "EDGEZERO_KV"); - } - - #[test] - fn kv_store_name_uses_global_name() { - let toml_str = r#" -[app] -name = "test" - -[stores.kv] -name = "MY_KV" -"#; - let loader = ManifestLoader::load_from_str(toml_str); - let manifest = loader.manifest(); - assert_eq!(manifest.kv_store_name("fastly"), "MY_KV"); - assert_eq!(manifest.kv_store_name("cloudflare"), "MY_KV"); - } - - #[test] - fn kv_store_name_adapter_override() { - let toml_str = r#" -[app] -name = "test" - -[stores.kv] -name = "GLOBAL_KV" - -[stores.kv.adapters.cloudflare] -name = "CF_BINDING" -"#; - let loader = ManifestLoader::load_from_str(toml_str); - let manifest = loader.manifest(); - assert_eq!(manifest.kv_store_name("cloudflare"), "CF_BINDING"); - assert_eq!(manifest.kv_store_name("fastly"), "GLOBAL_KV"); - } - - #[test] - fn kv_store_name_case_insensitive() { - let toml_str = r#" -[app] -name = "test" - -[stores.kv] -name = "DEFAULT" - -[stores.kv.adapters.Fastly] -name = "FASTLY_STORE" -"#; - let loader = ManifestLoader::load_from_str(toml_str); - let manifest = loader.manifest(); - assert_eq!(manifest.kv_store_name("fastly"), "FASTLY_STORE"); - assert_eq!(manifest.kv_store_name("FASTLY"), "FASTLY_STORE"); - } - // -- Secret store config ----------------------------------------------- - #[test] - fn secret_store_binding_defaults_to_constant_when_absent() { - let manifest = ManifestLoader::load_from_str("[app]\nname = \"x\"\n"); - assert_eq!( - manifest.manifest().secret_store_binding("fastly"), - DEFAULT_SECRET_STORE_NAME - ); - } - - #[test] - fn secret_store_binding_uses_global_name_when_declared() { - let manifest = ManifestLoader::load_from_str("[stores.secrets]\nname = \"MY_SECRETS\"\n"); - assert_eq!( - manifest.manifest().secret_store_binding("fastly"), - "MY_SECRETS" - ); - assert_eq!( - manifest.manifest().secret_store_binding("cloudflare"), - "MY_SECRETS" - ); - } - - #[test] - fn secret_store_binding_uses_per_adapter_override() { - let manifest = ManifestLoader::load_from_str( - "[stores.secrets]\nname = \"MY_SECRETS\"\n\ - [stores.secrets.adapters.fastly]\nname = \"FASTLY_STORE\"\n", - ); - assert_eq!( - manifest.manifest().secret_store_binding("fastly"), - "FASTLY_STORE" - ); - assert_eq!( - manifest.manifest().secret_store_binding("cloudflare"), - "MY_SECRETS" - ); - } - - #[test] - fn secrets_required_is_false_when_absent() { - let manifest = ManifestLoader::load_from_str("[app]\nname = \"x\"\n"); - assert!(manifest.manifest().stores.secrets.is_none()); - } - - #[test] - fn secrets_required_is_true_when_declared() { - let manifest = ManifestLoader::load_from_str("[stores.secrets]\nname = \"MY_SECRETS\"\n"); - assert!(manifest.manifest().stores.secrets.is_some()); - } - #[test] fn secret_store_enabled_is_false_when_absent() { let manifest = ManifestLoader::load_from_str("[app]\nname = \"x\"\n"); @@ -1791,39 +1906,12 @@ name = "FASTLY_STORE" #[test] fn secret_store_enabled_is_true_when_declared() { - let manifest = ManifestLoader::load_from_str("[stores.secrets]\nname = \"MY_SECRETS\"\n"); + let manifest = ManifestLoader::load_from_str("[stores.secrets]\nids = [\"default\"]\n"); + assert!(manifest.manifest().stores.secrets.is_some()); assert!(manifest.manifest().secret_store_enabled("fastly")); assert!(manifest.manifest().secret_store_enabled("cloudflare")); } - #[test] - fn secret_store_enabled_can_be_disabled_per_adapter() { - let manifest = ManifestLoader::load_from_str( - "[stores.secrets]\nname = \"MY_SECRETS\"\n\ - [stores.secrets.adapters.cloudflare]\nenabled = false\n", - ); - assert!(manifest.manifest().secret_store_enabled("fastly")); - assert!(!manifest.manifest().secret_store_enabled("cloudflare")); - } - - #[test] - fn secret_store_enabled_can_be_enabled_only_for_specific_adapter() { - let manifest = ManifestLoader::load_from_str( - "[stores.secrets]\nenabled = false\n\ - [stores.secrets.adapters.fastly]\nenabled = true\nname = \"FASTLY_STORE\"\n", - ); - assert!(manifest.manifest().secret_store_enabled("fastly")); - assert!(!manifest.manifest().secret_store_enabled("cloudflare")); - assert_eq!( - manifest.manifest().secret_store_binding("fastly"), - "FASTLY_STORE" - ); - assert_eq!( - manifest.manifest().secret_store_binding("cloudflare"), - DEFAULT_SECRET_STORE_NAME - ); - } - // -- Adapter host/port config ------------------------------------------ #[test] @@ -1853,4 +1941,49 @@ crate = "crates/axum-adapter" assert!(adapter.adapter.host.is_none()); assert!(adapter.adapter.port.is_none()); } + + #[test] + fn adapter_definition_accepts_spin_component_field() { + // `component` is the Spin component id used by `provision` + // and `config push` when `spin.toml` declares multiple + // `[component.*]`. Documented in docs/guide/adapters/spin.md and + // must round-trip through the manifest model now even though the + // runtime ignores it. + let manifest = r#" +[adapters.spin.adapter] +crate = "crates/my-app-adapter-spin" +manifest = "crates/my-app-adapter-spin/spin.toml" +component = "my-app" +"#; + let loader = ManifestLoader::load_from_str(manifest); + let manifest_data = loader.manifest(); + let adapter = &manifest_data.adapters["spin"]; + assert_eq!(adapter.adapter.component.as_deref(), Some("my-app")); + } + + #[test] + fn adapter_definition_rejects_unknown_field_with_migration_pointer() { + // Hard cutoff: the portable manifest enumerates the per-adapter + // tuning surface explicitly. Anything else (e.g. a stale + // pre-rewrite `runtime` knob, or a typo'd `compnent`) is a load + // error rather than a silent drop. + let manifest = r#" +[adapters.axum.adapter] +crate = "crates/axum-adapter" +runtime_threads = 4 +"#; + let err = ManifestLoader::try_load_from_str(manifest) + .err() + .expect("unknown adapter-definition field must fail to load"); + assert_eq!(err.kind(), io::ErrorKind::InvalidData); + let msg = err.to_string(); + assert!( + msg.contains("runtime_threads"), + "error should name the offending field, got: {msg}" + ); + assert!( + msg.contains("docs/guide/manifest-store-migration.md"), + "error should reference the migration guide, got: {msg}" + ); + } } diff --git a/crates/edgezero-core/src/store_registry.rs b/crates/edgezero-core/src/store_registry.rs new file mode 100644 index 00000000..f2b8a6ed --- /dev/null +++ b/crates/edgezero-core/src/store_registry.rs @@ -0,0 +1,263 @@ +//! Per-request store registry — one entry per logical store id. +//! +//! Each adapter builds a [`StoreRegistry`] at request setup, keyed by the +//! logical ids declared in `[stores.]`. Handlers resolve a handle by id +//! (or via the `_default()` helper for the common single-store case). For +//! adapters that are *Single* for a given store kind (per the +//! capability matrix in the design doc) every id maps to the same +//! flat handle. +//! +//! Type aliases: +//! - [`KvRegistry`] = `StoreRegistry` +//! - [`ConfigRegistry`] = `StoreRegistry` +//! - [`SecretRegistry`] = `StoreRegistry` +//! +//! KV and config handles are already bound to a single backing store by +//! construction, so the `Bound*` aliases for those are just the existing +//! handle types. [`BoundSecretStore`] is a real wrapper because the +//! underlying [`SecretHandle::get_bytes`] takes a `store_name` argument — +//! the registry captures the per-id platform name (resolved from +//! `EDGEZERO__STORES__SECRETS____NAME`) so handlers can call +//! [`BoundSecretStore::get_bytes`] with just the key. + +use std::collections::BTreeMap; + +use bytes::Bytes; + +use crate::config_store::ConfigStoreHandle; +use crate::key_value_store::KvHandle; +use crate::secret_store::{SecretError, SecretHandle}; + +/// A per-bind KV handle, returned by [`KvRegistry::named`] / [`KvRegistry::default`]. +pub type BoundKvStore = KvHandle; + +/// A per-bind config handle, returned by +/// [`ConfigRegistry::named`] / [`ConfigRegistry::default`]. +pub type BoundConfigStore = ConfigStoreHandle; + +/// A per-bind secret handle: a [`SecretHandle`] pre-bound to a platform +/// store name. The registry resolves the name per logical id at request +/// setup from `EDGEZERO__STORES__SECRETS____NAME` (defaulting to the +/// logical id), so handler code reads +/// `secrets.named(id)?.require_str(key)` without re-passing the platform +/// name on every call. +#[derive(Clone, Debug)] +pub struct BoundSecretStore { + handle: SecretHandle, + store_name: String, +} + +impl BoundSecretStore { + /// Retrieve a secret by key against the bound platform store. + /// + /// # Errors + /// See [`SecretHandle::get_bytes`]. + #[inline] + pub async fn get_bytes(&self, key: &str) -> Result, SecretError> { + self.handle.get_bytes(&self.store_name, key).await + } + + /// Underlying [`SecretHandle`] (escape hatch for callers that need the + /// store-name argument explicitly). + #[inline] + #[must_use] + pub fn handle(&self) -> &SecretHandle { + &self.handle + } + + /// Bind `handle` to the platform store name `store_name`. + #[inline] + #[must_use] + pub fn new(handle: SecretHandle, store_name: String) -> Self { + Self { handle, store_name } + } + + /// Retrieve a secret as raw bytes, error on absent. + /// + /// # Errors + /// See [`SecretHandle::require_bytes`]. + #[inline] + pub async fn require_bytes(&self, key: &str) -> Result { + self.handle.require_bytes(&self.store_name, key).await + } + + /// Retrieve a secret as a UTF-8 string, error on absent. + /// + /// # Errors + /// See [`SecretHandle::require_str`]. + #[inline] + pub async fn require_str(&self, key: &str) -> Result { + self.handle.require_str(&self.store_name, key).await + } + + /// Platform store name this binding resolves to. + #[inline] + #[must_use] + pub fn store_name(&self) -> &str { + &self.store_name + } +} + +/// Registry of per-id store handles, with a declared default. +/// +/// Constructed by adapters at request setup from the baked store metadata +/// (`Hooks::stores()`) plus the `EDGEZERO__STORES__*` environment overlay. +#[derive(Clone, Debug)] +pub struct StoreRegistry { + by_id: BTreeMap, + default_id: String, +} + +impl StoreRegistry { + /// Return the default handle. + /// + /// Always `Some` for a registry constructed via [`Self::new`] — the + /// invariant is enforced at construction time. `Option` is kept on the + /// signature for API symmetry with [`Self::named`]. + #[must_use] + #[inline] + pub fn default(&self) -> Option { + self.by_id.get(&self.default_id).cloned() + } + + /// The resolved default id for this kind. + #[must_use] + #[inline] + pub fn default_id(&self) -> &str { + &self.default_id + } + + /// Try to build a registry from a pre-built id → handle map and the + /// declared default id, dropping it entirely when the default id is + /// not registered. Adapters that skip a failed-to-open backend per id + /// (logging a warning) call this instead of [`Self::new`] so the + /// registry isn't constructed with a default that has nowhere to + /// resolve to. Returning `None` in that case bubbles up as "no + /// registry wired", which surfaces as a clear 503 at the handler + /// rather than a silent `None` from [`Self::default`]. + #[must_use] + #[inline] + pub fn from_parts(by_id: BTreeMap, default_id: String) -> Option { + if by_id.is_empty() || !by_id.contains_key(&default_id) { + return None; + } + Some(Self { by_id, default_id }) + } + + /// Iterate over the registered logical ids. + #[inline] + pub fn ids(&self) -> impl Iterator { + self.by_id.keys().map(String::as_str) + } + + /// Look up the handle for `id`. Returns `None` if `id` was not registered. + #[must_use] + #[inline] + pub fn named(&self, id: &str) -> Option { + self.by_id.get(id).cloned() + } + + /// Create a registry from a pre-built id → handle map and the resolved + /// default id. + /// + /// # Panics + /// Panics (in both debug and release) if `default_id` is not a key in + /// `by_id`. Adapter builders that drop a failed-to-open id must ensure + /// they don't construct a registry whose declared default is missing — + /// either skip the whole registry, or fail the request loudly. + /// Surfacing this as a panic enforces the [`Self::default`] invariant + /// at construction time, matching the spec's intent that a declared + /// default always resolves. + #[must_use] + #[inline] + pub fn new(by_id: BTreeMap, default_id: String) -> Self { + assert!( + by_id.contains_key(&default_id), + "StoreRegistry default id `{default_id}` is not present among the registered ids: {ids:?}", + ids = by_id.keys().collect::>() + ); + Self { by_id, default_id } + } + + /// Build a one-id registry from a single handle, used when an + /// adapter has a single store and wants to normalise its + /// wiring to the registry path (so the extractor and + /// registry-aware accessors don't need a legacy-handle + /// fallback). `id` is the logical id the handle is registered + /// under AND the resolved default. + #[must_use] + #[inline] + pub fn single_id(id: String, handle: H) -> Self { + let mut by_id: BTreeMap = BTreeMap::new(); + by_id.insert(id.clone(), handle); + Self::new(by_id, id) + } +} + +/// Registry of per-id KV handles. +pub type KvRegistry = StoreRegistry; +/// Registry of per-id config handles. +pub type ConfigRegistry = StoreRegistry; +/// Registry of per-id secret handles. +pub type SecretRegistry = StoreRegistry; + +#[cfg(test)] +mod tests { + use super::*; + + fn build_registry(entries: &[(&str, &str)], default_id: &str) -> StoreRegistry { + let by_id: BTreeMap = entries + .iter() + .map(|(id, value)| ((*id).to_owned(), (*value).to_owned())) + .collect(); + StoreRegistry::new(by_id, default_id.to_owned()) + } + + #[test] + fn named_returns_handle_for_known_id() { + let registry = build_registry(&[("sessions", "a"), ("cache", "b")], "sessions"); + assert_eq!(registry.named("cache"), Some("b".to_owned())); + } + + #[test] + fn named_returns_none_for_unknown_id() { + let registry = build_registry(&[("sessions", "a")], "sessions"); + assert_eq!(registry.named("missing"), None); + } + + #[test] + fn default_returns_default_handle() { + let registry = build_registry(&[("sessions", "a"), ("cache", "b")], "cache"); + assert_eq!(registry.default(), Some("b".to_owned())); + } + + #[test] + fn default_id_returns_resolved_default() { + let registry = build_registry(&[("sessions", "a"), ("cache", "b")], "cache"); + assert_eq!(registry.default_id(), "cache"); + } + + #[test] + fn ids_yields_all_registered_ids_in_sorted_order() { + let registry = build_registry(&[("cache", "b"), ("sessions", "a")], "sessions"); + let ids: Vec<&str> = registry.ids().collect(); + assert_eq!(ids, vec!["cache", "sessions"]); + } + + #[test] + fn registry_is_cloneable() { + let r1 = build_registry(&[("a", "1")], "a"); + let r2 = r1.clone(); + assert_eq!(r1.named("a"), r2.named("a")); + } + + #[test] + #[should_panic(expected = "is not present among the registered ids")] + fn new_panics_when_default_is_not_among_registered_ids() { + // The invariant is enforced in both debug and release builds — a + // builder that drops a failed-to-open default id must not still + // call `new(by_id, missing_default)`. Catching this loudly avoids + // silent registries whose `default()` returns `None`. + let _registry: StoreRegistry = build_registry(&[("sessions", "a")], "cache"); + } +} diff --git a/crates/edgezero-macros/Cargo.toml b/crates/edgezero-macros/Cargo.toml index 63c3b589..c108d1ca 100644 --- a/crates/edgezero-macros/Cargo.toml +++ b/crates/edgezero-macros/Cargo.toml @@ -21,4 +21,10 @@ toml = { workspace = true } validator = { workspace = true, features = ["derive"] } [dev-dependencies] +# `edgezero-core` re-exports `AppConfig`; the derive tests assert +# against the trait/types over the re-export path the way downstream +# users will. Cargo allows dev-dep cycles (only the main dep edge +# matters for build ordering). +edgezero-core = { workspace = true } tempfile = { workspace = true } +trybuild = { workspace = true } diff --git a/crates/edgezero-macros/src/app.rs b/crates/edgezero-macros/src/app.rs index 0c6991ab..4858d5a1 100644 --- a/crates/edgezero-macros/src/app.rs +++ b/crates/edgezero-macros/src/app.rs @@ -1,4 +1,4 @@ -use crate::manifest_definitions::{Manifest, DEFAULT_CONFIG_STORE_NAME}; +use crate::manifest_definitions::{Manifest, StoreDeclaration}; use proc_macro::TokenStream; use proc_macro2::{Span, TokenStream as TokenStream2}; use quote::quote; @@ -30,39 +30,37 @@ impl Parse for AppArgs { } } -fn build_config_store_tokens(manifest: &Manifest) -> TokenStream2 { - let Some(config) = manifest.stores.config.as_ref() else { - return quote! { - fn config_store() -> Option<&'static edgezero_core::app::ConfigStoreMetadata> { - None - } - }; +/// Render a `StoreMetadata { default, ids }` literal for one `[stores.]` +/// declaration, or `None` when the declaration is absent. +fn store_metadata_tokens(maybe_declaration: Option<&StoreDeclaration>) -> TokenStream2 { + let Some(declaration) = maybe_declaration else { + return quote! { None }; }; - - let fallback_name = config.name.as_deref().unwrap_or(DEFAULT_CONFIG_STORE_NAME); - let fallback_name_lit = LitStr::new(fallback_name, Span::call_site()); - let override_entries: Vec<_> = config - .adapters + let default_lit = LitStr::new(declaration.default_id(), Span::call_site()); + let id_lits = declaration + .ids .iter() - .map(|(adapter, cfg)| { - let adapter_lit = LitStr::new(adapter, Span::call_site()); - let name_lit = LitStr::new(&cfg.name, Span::call_site()); - quote! { - edgezero_core::app::ConfigStoreAdapterMetadata::new(#adapter_lit, #name_lit), - } + .map(|id| LitStr::new(id, Span::call_site())); + quote! { + Some(edgezero_core::app::StoreMetadata { + default: #default_lit, + ids: &[#(#id_lits),*], }) - .collect(); + } +} +/// Codegen the `Hooks::stores()` impl from the portable `[stores.*]` schema. +fn build_stores_tokens(manifest: &Manifest) -> TokenStream2 { + let config = store_metadata_tokens(manifest.stores.config.as_ref()); + let kv = store_metadata_tokens(manifest.stores.kv.as_ref()); + let secrets = store_metadata_tokens(manifest.stores.secrets.as_ref()); quote! { - fn config_store() -> Option<&'static edgezero_core::app::ConfigStoreMetadata> { - static CONFIG_STORE: edgezero_core::app::ConfigStoreMetadata = - edgezero_core::app::ConfigStoreMetadata::new( - #fallback_name_lit, - &[ - #(#override_entries)* - ], - ); - Some(&CONFIG_STORE) + fn stores() -> edgezero_core::app::StoresMetadata { + edgezero_core::app::StoresMetadata { + config: #config, + kv: #kv, + secrets: #secrets, + } } } } @@ -140,7 +138,7 @@ pub fn expand_app(input: TokenStream) -> TokenStream { Ok(tokens) => tokens, Err(msg) => return quote!(compile_error!(#msg);).into(), }; - let config_store_tokens = build_config_store_tokens(&manifest); + let stores_tokens = build_stores_tokens(&manifest); // The emitted `Hooks` impl below explicitly defines `configure` and // `build_app` even though their bodies mirror the trait defaults. This is @@ -161,7 +159,7 @@ pub fn expand_app(input: TokenStream) -> TokenStream { #app_name_lit } - #config_store_tokens + #stores_tokens fn build_app() -> edgezero_core::app::App { let mut app = edgezero_core::app::App::with_name(Self::routes(), Self::name()); diff --git a/crates/edgezero-macros/src/app_config.rs b/crates/edgezero-macros/src/app_config.rs new file mode 100644 index 00000000..0b151089 --- /dev/null +++ b/crates/edgezero-macros/src/app_config.rs @@ -0,0 +1,272 @@ +//! `#[derive(AppConfig)]` derive. +//! +//! Scans the input struct for `#[secret]` / `#[secret(store_ref)]` +//! field annotations, enforces the compile-time constraints, and +//! emits `impl ::edgezero_core::app_config::AppConfigMeta` with the +//! `SECRET_FIELDS` array. + +use proc_macro::TokenStream; +use proc_macro2::TokenStream as TokenStream2; +use quote::quote; +use syn::punctuated::Punctuated; +use syn::{ + parse_macro_input, Attribute, Data, DeriveInput, Field, Fields, Ident, Meta, Path, Type, +}; + +/// Recognised `#[secret(...)]` annotation kinds. +enum SecretAnnotation { + /// Plain `#[secret]` — the field value is a key in the resolved + /// default secret store. + KeyInDefault, + /// `#[secret(store_ref)]` — the field value is a `[stores.secrets]` + /// logical id. + StoreRef, +} + +/// Per-field annotation result captured during scanning. +struct FieldAnnotation { + kind: SecretAnnotation, + name: Ident, +} + +/// Inspect the input struct, emit `impl AppConfigMeta` with the +/// `SECRET_FIELDS` array. Errors surface as `compile_error!` tokens +/// substituted in place of the impl. +#[inline] +pub fn derive(tokens: TokenStream) -> TokenStream { + let parsed = parse_macro_input!(tokens as DeriveInput); + expand(&parsed) + .unwrap_or_else(|err| err.to_compile_error()) + .into() +} + +fn expand(input: &DeriveInput) -> Result { + let struct_ident = &input.ident; + let (impl_generics, type_generics, where_clause) = input.generics.split_for_impl(); + + let fields = struct_fields(input)?; + let mut annotations: Vec = Vec::new(); + for field in fields { + if let Some(annotation) = scan_field(field)? { + annotations.push(annotation); + } + } + + // SECRET_FIELDS emits the Rust field name verbatim. A container- + // level `#[serde(rename_all = ...)]` would desync that metadata + // from what `config validate` (and the Spin collision check) sees + // on the wire — silently — so reject it whenever any + // secret field is present. Structs with no secret fields are + // unaffected: SECRET_FIELDS is empty and the validator never + // compares names. + if !annotations.is_empty() { + enforce_no_container_rename_all(&input.attrs)?; + } + + let entries = annotations.iter().map(|annotation| { + let name_lit = annotation.name.to_string(); + let kind_tokens = match annotation.kind { + SecretAnnotation::KeyInDefault => { + quote!(::edgezero_core::app_config::SecretKind::KeyInDefault) + } + SecretAnnotation::StoreRef => quote!(::edgezero_core::app_config::SecretKind::StoreRef), + }; + quote! { + ::edgezero_core::app_config::SecretField { + name: #name_lit, + kind: #kind_tokens, + } + } + }); + + Ok(quote! { + #[automatically_derived] + impl #impl_generics ::edgezero_core::app_config::AppConfigMeta + for #struct_ident #type_generics #where_clause + { + const SECRET_FIELDS: &'static [::edgezero_core::app_config::SecretField] = + &[#(#entries),*]; + } + }) +} + +/// Borrow the struct's named fields, or error with a clear message. +fn struct_fields(input: &DeriveInput) -> Result<&Punctuated, syn::Error> { + let data = match &input.data { + Data::Struct(data) => data, + Data::Enum(_) | Data::Union(_) => { + return Err(syn::Error::new_spanned( + &input.ident, + "`#[derive(AppConfig)]` is only supported on structs", + )); + } + }; + match &data.fields { + Fields::Named(named) => Ok(&named.named), + Fields::Unnamed(_) => Err(syn::Error::new_spanned( + &input.ident, + "`#[derive(AppConfig)]` is only supported on structs with named fields", + )), + Fields::Unit => Err(syn::Error::new_spanned( + &input.ident, + "`#[derive(AppConfig)]` is only supported on structs with named fields (this struct has no fields)", + )), + } +} + +/// Inspect a single field. Returns `Ok(Some(...))` when the field +/// carries a recognised `#[secret]` annotation, `Ok(None)` when it +/// carries none, and `Err` for an invalid combination. +fn scan_field(field: &Field) -> Result, syn::Error> { + let Some(name) = field.ident.clone() else { + return Ok(None); + }; + + let mut secret_attrs = field + .attrs + .iter() + .filter(|attr| attr.path().is_ident("secret")); + let Some(first) = secret_attrs.next() else { + return Ok(None); + }; + if let Some(duplicate) = secret_attrs.next() { + return Err(syn::Error::new_spanned( + duplicate, + "duplicate `#[secret]` annotation on the same field", + )); + } + let kind = parse_secret_kind(first)?; + + enforce_scalar_string_type(field)?; + enforce_no_disallowed_serde_attrs(field)?; + + Ok(Some(FieldAnnotation { kind, name })) +} + +/// Decode `#[secret]` (`KeyInDefault`) and `#[secret(store_ref)]` +/// (`StoreRef`). Any other token list is a compile error. +fn parse_secret_kind(attr: &Attribute) -> Result { + match &attr.meta { + Meta::Path(_) => Ok(SecretAnnotation::KeyInDefault), + Meta::List(list) => { + let inner: Path = syn::parse2(list.tokens.clone()).map_err(|_unused| { + syn::Error::new_spanned( + &list.tokens, + "`#[secret(...)]` accepts only `store_ref` (e.g. `#[secret(store_ref)]`)", + ) + })?; + if inner.is_ident("store_ref") { + Ok(SecretAnnotation::StoreRef) + } else { + Err(syn::Error::new_spanned( + &list.tokens, + "`#[secret(...)]` accepts only `store_ref` (e.g. `#[secret(store_ref)]`)", + )) + } + } + Meta::NameValue(_) => Err(syn::Error::new_spanned( + attr, + "`#[secret = \"...\"]` form is not supported; use `#[secret]` or `#[secret(store_ref)]`", + )), + } +} + +/// `#[secret]` may only annotate a scalar string field. Per we +/// accept bare `String` only — generic or qualified forms (e.g. +/// `Option`, `Cow<'_, str>`) are intentionally rejected so +/// `cfg.api_token` resolves to a value at every call site. +fn enforce_scalar_string_type(field: &Field) -> Result<(), syn::Error> { + if !is_scalar_string_type(&field.ty) { + return Err(syn::Error::new_spanned( + &field.ty, + "`#[secret]` / `#[secret(store_ref)]` may only annotate a scalar string field (e.g. `String`)", + )); + } + Ok(()) +} + +fn is_scalar_string_type(ty: &Type) -> bool { + if let Type::Path(type_path) = ty { + if type_path.qself.is_none() { + if let Some(last) = type_path.path.segments.last() { + return last.ident == "String" && last.arguments.is_empty(); + } + } + } + false +} + +/// Container-level guard: a struct that carries any `#[secret]` field +/// must not also carry `#[serde(rename_all = ...)]`. The derive emits +/// `SECRET_FIELDS` with Rust field names verbatim, but `rename_all` +/// would translate the on-the-wire key name (e.g. `kebab-case` → +/// `api-token`), silently desyncing the typed `config validate` secret +/// checks from what the deserialiser actually accepts. Reject this at +/// compile time so the desync can't ship. +fn enforce_no_container_rename_all(attrs: &[Attribute]) -> Result<(), syn::Error> { + for attr in attrs { + if !attr.path().is_ident("serde") { + continue; + } + let mut offending = false; + let _parse_result: syn::Result<()> = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("rename_all") { + offending = true; + } + Ok(()) + }); + if offending { + return Err(syn::Error::new_spanned( + attr, + "`#[derive(AppConfig)]` rejects `#[serde(rename_all = ...)]` on structs with `#[secret]` fields: SECRET_FIELDS uses Rust field names verbatim, so a container rename would silently desync `config validate` from runtime deserialisation", + )); + } + } + Ok(()) +} + +/// `#[secret]` cannot coexist with `#[serde(flatten)]` / +/// `#[serde(rename)]` / `#[serde(skip*)]` because the derive emits the +/// Rust field name verbatim and downstream tooling (config validate / +/// config push) expects that name to round-trip via TOML serde without +/// translation or omission. +fn enforce_no_disallowed_serde_attrs(field: &Field) -> Result<(), syn::Error> { + for attr in &field.attrs { + if !attr.path().is_ident("serde") { + continue; + } + let mut offending: Option<&'static str> = None; + // `parse_nested_meta` walks each comma-separated entry in the + // `#[serde(...)]` list. We swallow its own parse errors — those + // belong to the user's serde macros, not ours — and only react + // when a disallowed key is observed. + let _parse_result: syn::Result<()> = attr.parse_nested_meta(|meta| { + if let Some(ident) = meta.path.get_ident() { + offending = match ident.to_string().as_str() { + "flatten" => Some("flatten"), + "rename" => Some("rename"), + "skip" => Some("skip"), + "skip_deserializing" => Some("skip_deserializing"), + "skip_serializing" => Some("skip_serializing"), + // `skip_serializing_if = "..."` also omits the + // field from round-trips (config push reads + // SECRET_FIELDS, then serialises the typed + // struct), so reject it alongside the + // unconditional skip family. + "skip_serializing_if" => Some("skip_serializing_if"), + _ => offending, + }; + } + Ok(()) + }); + if let Some(name) = offending { + return Err(syn::Error::new_spanned( + attr, + format!( + "`#[secret]` is incompatible with `#[serde({name})]` — the derive emits the Rust field name verbatim and config validate / push round-trip it via TOML", + ), + )); + } + } + Ok(()) +} diff --git a/crates/edgezero-macros/src/lib.rs b/crates/edgezero-macros/src/lib.rs index 0a786201..17a572d9 100644 --- a/crates/edgezero-macros/src/lib.rs +++ b/crates/edgezero-macros/src/lib.rs @@ -1,5 +1,6 @@ mod action; mod app; +mod app_config; mod manifest_definitions; use proc_macro::TokenStream; @@ -15,3 +16,9 @@ pub fn action(attr: TokenStream, item: TokenStream) -> TokenStream { pub fn app(input: TokenStream) -> TokenStream { app::expand_app(input) } + +#[proc_macro_derive(AppConfig, attributes(secret))] +#[inline] +pub fn app_config_derive(input: TokenStream) -> TokenStream { + app_config::derive(input) +} diff --git a/crates/edgezero-macros/tests/app_config_derive.rs b/crates/edgezero-macros/tests/app_config_derive.rs new file mode 100644 index 00000000..1a816e11 --- /dev/null +++ b/crates/edgezero-macros/tests/app_config_derive.rs @@ -0,0 +1,105 @@ +//! Happy-path coverage for `#[derive(AppConfig)]`. Compile- +//! fail coverage lives next to `tests/ui/*.rs` and runs via `trybuild`. + +#[cfg(test)] +mod tests { + use edgezero_core::app_config::{AppConfigMeta as _, SecretField, SecretKind}; + + #[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] + #[serde(deny_unknown_fields)] + struct ConfigNoSecrets { + _greeting: String, + } + + // The `#[secret]`-annotated fields below are exercised only via the + // `SECRET_FIELDS` associated constant the derive emits — Rust still + // counts them as "never read", so silence the dead-code lint at the + // struct level. + #[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] + #[serde(deny_unknown_fields)] + #[expect( + dead_code, + reason = "fields exist only to feed `#[derive(AppConfig)]`; the SECRET_FIELDS array reads them via the derive, not via Rust field access" + )] + struct ConfigKeyInDefault { + _greeting: String, + #[secret] + api_token: String, + } + + #[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] + #[serde(deny_unknown_fields)] + #[expect( + dead_code, + reason = "fields exist only to feed `#[derive(AppConfig)]`; the SECRET_FIELDS array reads them via the derive, not via Rust field access" + )] + struct ConfigStoreRef { + _greeting: String, + #[secret(store_ref)] + vault: String, + } + + #[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] + #[serde(deny_unknown_fields)] + #[expect( + dead_code, + reason = "fields exist only to feed `#[derive(AppConfig)]`; the SECRET_FIELDS array reads them via the derive, not via Rust field access" + )] + struct ConfigBothKinds { + _greeting: String, + #[secret] + api_token: String, + #[secret(store_ref)] + vault: String, + } + + #[test] + fn no_secret_annotation_yields_empty_secret_fields() { + assert!(ConfigNoSecrets::SECRET_FIELDS.is_empty()); + } + + #[test] + fn plain_secret_attribute_yields_key_in_default() { + assert_eq!( + ConfigKeyInDefault::SECRET_FIELDS, + &[SecretField { + name: "api_token", + kind: SecretKind::KeyInDefault, + }] + ); + } + + #[test] + fn secret_store_ref_attribute_yields_store_ref() { + assert_eq!( + ConfigStoreRef::SECRET_FIELDS, + &[SecretField { + name: "vault", + kind: SecretKind::StoreRef, + }] + ); + } + + #[test] + fn both_secret_kinds_are_collected_in_source_order() { + assert_eq!( + ConfigBothKinds::SECRET_FIELDS, + &[ + SecretField { + name: "api_token", + kind: SecretKind::KeyInDefault, + }, + SecretField { + name: "vault", + kind: SecretKind::StoreRef, + }, + ] + ); + } + + #[test] + fn trybuild_compile_fail_fixtures() { + let cases = trybuild::TestCases::new(); + cases.compile_fail("tests/ui/secret_*.rs"); + } +} diff --git a/crates/edgezero-macros/tests/ui/secret_bogus_kind.rs b/crates/edgezero-macros/tests/ui/secret_bogus_kind.rs new file mode 100644 index 00000000..22ff3ad5 --- /dev/null +++ b/crates/edgezero-macros/tests/ui/secret_bogus_kind.rs @@ -0,0 +1,10 @@ +//! `#[secret(...)]` accepts only `store_ref`; any other argument is a +//! compile error. + +#[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] +struct Config { + #[secret(bogus)] + api_token: String, +} + +fn main() {} diff --git a/crates/edgezero-macros/tests/ui/secret_bogus_kind.stderr b/crates/edgezero-macros/tests/ui/secret_bogus_kind.stderr new file mode 100644 index 00000000..553529c9 --- /dev/null +++ b/crates/edgezero-macros/tests/ui/secret_bogus_kind.stderr @@ -0,0 +1,5 @@ +error: `#[secret(...)]` accepts only `store_ref` (e.g. `#[secret(store_ref)]`) + --> tests/ui/secret_bogus_kind.rs:6:14 + | +6 | #[secret(bogus)] + | ^^^^^ diff --git a/crates/edgezero-macros/tests/ui/secret_on_non_scalar.rs b/crates/edgezero-macros/tests/ui/secret_on_non_scalar.rs new file mode 100644 index 00000000..c13e39f9 --- /dev/null +++ b/crates/edgezero-macros/tests/ui/secret_on_non_scalar.rs @@ -0,0 +1,10 @@ +//! `#[secret]` must annotate a scalar string field; a non-scalar type +//! (e.g. `Vec`) is a compile error. + +#[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] +struct Config { + #[secret] + api_tokens: Vec, +} + +fn main() {} diff --git a/crates/edgezero-macros/tests/ui/secret_on_non_scalar.stderr b/crates/edgezero-macros/tests/ui/secret_on_non_scalar.stderr new file mode 100644 index 00000000..817d8c55 --- /dev/null +++ b/crates/edgezero-macros/tests/ui/secret_on_non_scalar.stderr @@ -0,0 +1,5 @@ +error: `#[secret]` / `#[secret(store_ref)]` may only annotate a scalar string field (e.g. `String`) + --> tests/ui/secret_on_non_scalar.rs:7:17 + | +7 | api_tokens: Vec, + | ^^^^^^^^^^^ diff --git a/crates/edgezero-macros/tests/ui/secret_with_serde_container_rename_all.rs b/crates/edgezero-macros/tests/ui/secret_with_serde_container_rename_all.rs new file mode 100644 index 00000000..a50d90fa --- /dev/null +++ b/crates/edgezero-macros/tests/ui/secret_with_serde_container_rename_all.rs @@ -0,0 +1,14 @@ +//! Container-level `#[serde(rename_all = ...)]` on a struct that has a +//! `#[secret]` field must be rejected: the renamer would translate the +//! TOML key to `api-token` while `SECRET_FIELDS` keeps reporting +//! `api_token`, silently desyncing the typed `config validate` secret +//! checks and the Spin collision check. + +#[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] +#[serde(rename_all = "kebab-case")] +struct ConfigWithRenameAll { + #[secret] + api_token: String, +} + +fn main() {} diff --git a/crates/edgezero-macros/tests/ui/secret_with_serde_container_rename_all.stderr b/crates/edgezero-macros/tests/ui/secret_with_serde_container_rename_all.stderr new file mode 100644 index 00000000..c94cb25d --- /dev/null +++ b/crates/edgezero-macros/tests/ui/secret_with_serde_container_rename_all.stderr @@ -0,0 +1,5 @@ +error: `#[derive(AppConfig)]` rejects `#[serde(rename_all = ...)]` on structs with `#[secret]` fields: SECRET_FIELDS uses Rust field names verbatim, so a container rename would silently desync `config validate` from runtime deserialisation + --> tests/ui/secret_with_serde_container_rename_all.rs:8:1 + | +8 | #[serde(rename_all = "kebab-case")] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/crates/edgezero-macros/tests/ui/secret_with_serde_flatten.rs b/crates/edgezero-macros/tests/ui/secret_with_serde_flatten.rs new file mode 100644 index 00000000..713d949a --- /dev/null +++ b/crates/edgezero-macros/tests/ui/secret_with_serde_flatten.rs @@ -0,0 +1,10 @@ +//! `#[secret]` is incompatible with `#[serde(flatten)]`. + +#[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] +struct Config { + #[secret] + #[serde(flatten)] + api_token: String, +} + +fn main() {} diff --git a/crates/edgezero-macros/tests/ui/secret_with_serde_flatten.stderr b/crates/edgezero-macros/tests/ui/secret_with_serde_flatten.stderr new file mode 100644 index 00000000..90e8c374 --- /dev/null +++ b/crates/edgezero-macros/tests/ui/secret_with_serde_flatten.stderr @@ -0,0 +1,5 @@ +error: `#[secret]` is incompatible with `#[serde(flatten)]` — the derive emits the Rust field name verbatim and config validate / push round-trip it via TOML + --> tests/ui/secret_with_serde_flatten.rs:6:5 + | +6 | #[serde(flatten)] + | ^^^^^^^^^^^^^^^^^ diff --git a/crates/edgezero-macros/tests/ui/secret_with_serde_rename.rs b/crates/edgezero-macros/tests/ui/secret_with_serde_rename.rs new file mode 100644 index 00000000..be9a25ab --- /dev/null +++ b/crates/edgezero-macros/tests/ui/secret_with_serde_rename.rs @@ -0,0 +1,10 @@ +//! `#[secret]` is incompatible with `#[serde(rename)]`. + +#[derive(serde::Deserialize, validator::Validate, edgezero_core::AppConfig)] +struct Config { + #[secret] + #[serde(rename = "token")] + api_token: String, +} + +fn main() {} diff --git a/crates/edgezero-macros/tests/ui/secret_with_serde_rename.stderr b/crates/edgezero-macros/tests/ui/secret_with_serde_rename.stderr new file mode 100644 index 00000000..0fb8a0b5 --- /dev/null +++ b/crates/edgezero-macros/tests/ui/secret_with_serde_rename.stderr @@ -0,0 +1,5 @@ +error: `#[secret]` is incompatible with `#[serde(rename)]` — the derive emits the Rust field name verbatim and config validate / push round-trip it via TOML + --> tests/ui/secret_with_serde_rename.rs:6:5 + | +6 | #[serde(rename = "token")] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/crates/edgezero-macros/tests/ui/secret_with_serde_skip_serializing_if.rs b/crates/edgezero-macros/tests/ui/secret_with_serde_skip_serializing_if.rs new file mode 100644 index 00000000..b0c088b1 --- /dev/null +++ b/crates/edgezero-macros/tests/ui/secret_with_serde_skip_serializing_if.rs @@ -0,0 +1,16 @@ +//! `#[serde(skip_serializing_if = "...")]` conditionally omits the +//! field from serialisation. Combined with `#[secret]`, that would +//! make `config push` (which reads `SECRET_FIELDS`, then serialises +//! the typed struct) drop the secret key under the condition — +//! desyncing the on-the-wire shape from the SECRET_FIELDS invariant +//! relies on. Reject at compile time. + +#[derive(serde::Deserialize, serde::Serialize, validator::Validate, edgezero_core::AppConfig)] +#[serde(deny_unknown_fields)] +struct ConfigWithSkipSerializingIf { + #[secret] + #[serde(skip_serializing_if = "String::is_empty")] + api_token: String, +} + +fn main() {} diff --git a/crates/edgezero-macros/tests/ui/secret_with_serde_skip_serializing_if.stderr b/crates/edgezero-macros/tests/ui/secret_with_serde_skip_serializing_if.stderr new file mode 100644 index 00000000..5e905343 --- /dev/null +++ b/crates/edgezero-macros/tests/ui/secret_with_serde_skip_serializing_if.stderr @@ -0,0 +1,5 @@ +error: `#[secret]` is incompatible with `#[serde(skip_serializing_if)]` — the derive emits the Rust field name verbatim and config validate / push round-trip it via TOML + --> tests/ui/secret_with_serde_skip_serializing_if.rs:12:5 + | +12 | #[serde(skip_serializing_if = "String::is_empty")] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/docs/.gitignore b/docs/.gitignore index 57a09c39..097c2293 100644 --- a/docs/.gitignore +++ b/docs/.gitignore @@ -1,3 +1,4 @@ node_modules .vitepress/dist .vitepress/cache +.vitepress/.temp diff --git a/docs/.prettierignore b/docs/.prettierignore index 94aa6e0c..2879ebbd 100644 --- a/docs/.prettierignore +++ b/docs/.prettierignore @@ -1,3 +1,11 @@ .vitepress/cache .vitepress/dist node_modules + +# Internal design docs (specs + plans) — mirror VitePress's srcExclude. +# Prettier's continuation-line indent rules mangle the wrapped prose in +# these handwritten documents (e.g. lines starting with `[[...]]` get +# treated as link references), so leave them un-reformatted. They sit +# under `docs/` only because the path is convenient for note-taking; +# they're gitignored and not part of the published site. +superpowers diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 492496c6..f56ff1f1 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -5,6 +5,12 @@ export default defineConfig({ title: 'EdgeZero', description: 'Production-ready toolkit for portable edge HTTP workloads', base: '/edgezero/', + // `superpowers/` holds internal design docs (specs + plans) that are not + // part of the published site. They sit in `docs/` so the doc tooling + // (prettier, eslint) covers them, but VitePress should skip them: the + // raw spec text contains literal `{{ … }}` interpolations inside inline + // code that Vue's compiler would otherwise try to evaluate. + srcExclude: ['superpowers/**'], themeConfig: { // https://vitepress.dev/reference/default-theme-config nav: [ @@ -40,6 +46,7 @@ export default defineConfig({ { text: 'Overview', link: '/guide/adapters/overview' }, { text: 'Fastly Compute', link: '/guide/adapters/fastly' }, { text: 'Cloudflare Workers', link: '/guide/adapters/cloudflare' }, + { text: 'Fermyon Spin', link: '/guide/adapters/spin' }, { text: 'Axum (Native)', link: '/guide/adapters/axum' }, ], }, @@ -51,6 +58,11 @@ export default defineConfig({ link: '/guide/configuration', }, { text: 'CLI Reference', link: '/guide/cli-reference' }, + { text: 'CLI Walkthrough', link: '/guide/cli-walkthrough' }, + { + text: 'Manifest Store Migration', + link: '/guide/manifest-store-migration', + }, ], }, ], diff --git a/docs/eslint.config.js b/docs/eslint.config.js index 50481a76..d991fe3e 100644 --- a/docs/eslint.config.js +++ b/docs/eslint.config.js @@ -3,7 +3,12 @@ import tseslint from 'typescript-eslint' export default [ { - ignores: ['.vitepress/cache/**', '.vitepress/dist/**', 'node_modules/**'], + ignores: [ + '.vitepress/cache/**', + '.vitepress/dist/**', + '.vitepress/.temp/**', + 'node_modules/**', + ], }, js.configs.recommended, ...tseslint.configs.recommended, diff --git a/docs/guide/adapters/axum.md b/docs/guide/adapters/axum.md index fd3b47c8..27db1295 100644 --- a/docs/guide/adapters/axum.md +++ b/docs/guide/adapters/axum.md @@ -29,23 +29,24 @@ The Axum entrypoint wires the adapter: ```rust use my_app_core::App; -fn main() { - if let Err(err) = edgezero_adapter_axum::run_app::(include_str!("../../../edgezero.toml")) { - eprintln!("axum adapter failed: {err}"); - std::process::exit(1); - } +fn main() -> anyhow::Result<()> { + edgezero_adapter_axum::run_app::() } ``` -`run_app` installs `simple_logger`, builds the app, and wires the local config store from -`[stores.config]` automatically. +`run_app` installs `simple_logger`, builds the app, and reads bind / +store / logging config at runtime from `EDGEZERO__*` environment +variables (see [the migration guide](../manifest-store-migration.md)). +The portable store metadata baked into `App` by the `app!` macro +drives which logical stores are exposed; no `edgezero.toml` needs to +be loaded by the runtime. ## Development Server -The `edgezero dev` command uses the Axum adapter: +Run your project locally on the Axum adapter: ```bash -edgezero dev +edgezero serve --adapter axum ``` This starts a server at `http://127.0.0.1:8787` with standard logging to stdout. @@ -137,23 +138,44 @@ cargo test -p my-app-adapter-axum ## Config Store -For local development, the Axum adapter only reads environment variables for keys declared in -`[stores.config.defaults]`, then falls back to those defaults in `edgezero.toml`: +For local development, each declared `[stores.config]` id resolves to a +local-file config store backed by `.edgezero/local-config-.json`. +The portable manifest carries no inline defaults — the +pre-rewrite `[stores.config.defaults]` table is gone (see +[the migration guide](../manifest-store-migration.md)). ```toml [stores.config] -name = "app_config" +ids = ["app_config"] +# default = "app_config" # required when ids.len() > 1 +``` -[stores.config.defaults] -"greeting" = "hello from config store" -"feature.new_checkout" = "false" -"service.timeout_ms" = "" +```jsonc +// .edgezero/local-config-app_config.json +{ + "greeting": "hello from config store", + "feature.new_checkout": "false", + "service.timeout_ms": "1500", +} +``` + +Handlers access stores via the `Config` extractor or `ctx.config_store(id)`: + +```rust +async fn handler(config: Config) -> Result { + let store = config.default().ok_or_else(|| EdgeError::service_unavailable("no default config"))?; + let greeting = store.get("greeting").await?.unwrap_or_default(); + // … +} ``` -Handlers access the injected store through `ctx.config_store()`. Environment variables take -precedence over manifest defaults. If a key should be overrideable from env without carrying a real -default value, declare it with an empty-string placeholder. Do not pass raw user input straight to -`ctx.config_store()?.get(...)` in production handlers; validate or allowlist keys first. +Do not pass raw user input straight to `store.get(…)` in production +handlers; validate or allowlist keys first. Seed the per-id JSON +files with `edgezero config push --adapter axum` (or +` config push --adapter axum` for the typed flow with +`#[secret]` stripping), which writes the same +`.edgezero/local-config-.json` files the runtime reads — +no shell-out, no server to authenticate against. ## Container Deployment @@ -183,7 +205,7 @@ The runtime currently binds to `127.0.0.1:8787` regardless of the `axum.toml` po A typical development workflow: -1. **Start dev server**: `edgezero dev` +1. **Run locally**: `edgezero serve --adapter axum` 2. **Make changes** to handlers in `my-app-core` 3. **Test locally** with curl or browser 4. **Run tests**: `cargo test` diff --git a/docs/guide/adapters/cloudflare.md b/docs/guide/adapters/cloudflare.md index ccfd576c..c22e99e6 100644 --- a/docs/guide/adapters/cloudflare.md +++ b/docs/guide/adapters/cloudflare.md @@ -44,15 +44,20 @@ use worker::*; #[event(fetch)] pub async fn main(req: Request, env: Env, ctx: Context) -> Result { - edgezero_adapter_cloudflare::run_app::(include_str!("../../edgezero.toml"), req, env, ctx).await + edgezero_adapter_cloudflare::run_app::(req, env, ctx).await } ``` -`run_app` reads config-store metadata generated by `edgezero_core::app!` and injects the configured -Cloudflare binding automatically. If you implement `Hooks` manually, pass the manifest source string directly to `run_app`. +`run_app` reads the portable store metadata baked into `App` by the +`app!` macro and the `EDGEZERO__*` env vars exposed on the worker +`Env` (Workers cannot enumerate `Env`, so the canonical key set is +derived from the baked store ids and queried individually). Per-id +`KV` / `Config` / `Secret` registries are built and injected into +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 -config-store metadata. Prefer `run_app` or `dispatch_with_config` for normal use. +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`. @@ -149,24 +154,30 @@ Access in handlers via the Cloudflare context or environment bindings. ## Config Store -Cloudflare does not expose a Fastly-style mutable config-store product, so EdgeZero maps -`[stores.config]` to a single JSON string binding in `wrangler.toml [vars]`: +Cloudflare does not expose a Fastly-style mutable config-store product, so each +declared `[stores.config]` id maps to a **KV namespace binding**. Reads are +asynchronous (`worker::kv::KvStore::get(key).text().await`). ```toml # edgezero.toml [stores.config] -name = "app_config" +ids = ["app_config"] +# default = "app_config" # required when ids.len() > 1 ``` ```toml # wrangler.toml -[vars] -app_config = '{"greeting":"hello from config store","feature.new_checkout":"false"}' +[[kv_namespaces]] +binding = "app_config" +id = "abc123…" ``` -At runtime the adapter parses that JSON object and injects it as `ctx.config_store()`. If the -configured binding is missing or contains invalid JSON, the adapter logs a warning and skips -config-store injection for that request. +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 +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 Storage diff --git a/docs/guide/adapters/fastly.md b/docs/guide/adapters/fastly.md index 4db5621a..59d34c7d 100644 --- a/docs/guide/adapters/fastly.md +++ b/docs/guide/adapters/fastly.md @@ -45,15 +45,19 @@ use my_app_core::App; #[fastly::main] fn main(req: fastly::Request) -> Result { - edgezero_adapter_fastly::run_app::(include_str!("../../../edgezero.toml"), req) + edgezero_adapter_fastly::run_app::(req) } ``` -`run_app` reads logging and config-store settings from `edgezero.toml`, builds the app, and injects -the configured Fastly Config Store into request extensions automatically. +`run_app` reads logging and store config at runtime from `EDGEZERO__*` +environment variables (see +[the migration guide](../manifest-store-migration.md)) and builds +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 -config-store metadata. Prefer `run_app` or `dispatch_with_config` for normal use. +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`. @@ -138,15 +142,17 @@ Fastly logging is wired when you call `init_logger` (or `run_app`); otherwise no ## Config Store -Fastly uses a native Config Store resource link for runtime configuration. Declare the logical store -name in `edgezero.toml`: +Fastly uses a native Config Store resource link for runtime configuration. Declare logical config +ids in `edgezero.toml`; each id opens its own platform store via +`EDGEZERO__STORES__CONFIG____NAME` (default = the logical id): ```toml [stores.config] -name = "app_config" +ids = ["app_config"] +# default = "app_config" # required when ids.len() > 1 ``` -For local Viceroy testing, mirror that binding in `fastly.toml`: +For local Viceroy testing, mirror the platform name in `fastly.toml`: ```toml [local_server.config_stores.app_config] @@ -156,8 +162,19 @@ format = "inline-toml" greeting = "hello from config store" ``` -Handlers can then read values through `ctx.config_store()`. If the configured store link is missing, -the adapter logs a warning and continues without injecting a config-store handle. +Handlers read values through the `Config` extractor or `ctx.config_store(id)`: + +```rust +async fn handler(config: Config) -> Result { + let store = config.named("app_config").ok_or_else(|| EdgeError::service_unavailable("no `app_config`"))?; + let greeting = store.get("greeting").await?.unwrap_or_default(); + // … +} +``` + +If a configured store link is missing, the adapter logs a one-time warning +and drops that id from the registry. Migrating from `name`/`adapters.*`? +See [the migration guide](../manifest-store-migration.md). ## Context Access diff --git a/docs/guide/adapters/overview.md b/docs/guide/adapters/overview.md index 58354db2..08745634 100644 --- a/docs/guide/adapters/overview.md +++ b/docs/guide/adapters/overview.md @@ -41,9 +41,16 @@ Adapters surface a `dispatch` function that bridges from the provider event loop This helper is what demo entrypoints and adapters call when wiring their platform-specific main functions. -## Config Store Resolution +## Store Registry Resolution -When wiring adapters, Fastly and Cloudflare check `Hooks::config_store()` first to allow custom overrides, and then fall back to the manifest. However, the Axum adapter resolves the config store exclusively from `edgezero.toml` defaults (`[stores.config.defaults]`) and currently ignores custom `Hooks::config_store()` implementations. +All four adapters resolve KV, config, and secret stores from the portable +`Hooks::stores()` metadata baked by the `app!` macro plus `EDGEZERO__*` +environment variables (see [the migration guide](../manifest-store-migration.md) +for the schema change). Each adapter builds a per-request +`StoreRegistry` keyed by logical id; handlers reach a bound store via +the id-keyed `Kv` / `Secrets` / `Config` extractors or the matching +`ctx.kv_store(id)` / `ctx.config_store(id)` / `ctx.secret_store(id)` +accessors. The pre-rewrite `Hooks::config_store()` hook is gone. ## Proxy Integration @@ -115,4 +122,5 @@ Adapters that fulfil these steps can be dropped into the EdgeZero CLI without re | ---------------------------------------- | ------------------- | ------------------------ | ------ | | [Fastly](/guide/adapters/fastly) | Fastly Compute@Edge | `wasm32-wasip1` | Stable | | [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 | diff --git a/docs/guide/adapters/spin.md b/docs/guide/adapters/spin.md new file mode 100644 index 00000000..35909500 --- /dev/null +++ b/docs/guide/adapters/spin.md @@ -0,0 +1,257 @@ +# Fermyon Spin + +Run EdgeZero applications on [Fermyon Spin](https://spinframework.dev/), +a WebAssembly-first application platform with a `wasm32-wasip2` target and +component-scoped KV / variable stores. + +## Prerequisites + +- Rust toolchain with `wasm32-wasip2` target (`rustup target add wasm32-wasip2`) +- Spin CLI ([install](https://spinframework.dev/install)) + +## Project Setup + +When scaffolding with `edgezero new my-app`, the Spin adapter includes: + +``` +crates/my-app-adapter-spin/ +├── Cargo.toml +├── spin.toml +└── src/ + └── lib.rs +``` + +### Entrypoint + +The Spin entrypoint wires the adapter via `#[http_service]`: + +```rust +use spin_sdk::{http::IntoResponse, http::Request, http_service}; +use my_app_core::App; + +#[http_service] +async fn handle(req: Request) -> anyhow::Result { + edgezero_adapter_spin::run_app::(req).await +} +``` + +`run_app` reads the portable store metadata baked into `App` by the `app!` +macro plus `EDGEZERO__*` environment variables; it does not require an +`edgezero.toml` to be present at runtime. + +## Building + +Build the Spin component: + +```bash +# Using the CLI +edgezero build --adapter spin + +# Or directly +cargo build --target wasm32-wasip2 --release -p my-app-adapter-spin +``` + +## Local Development + +```bash +# Using the CLI +edgezero serve --adapter spin + +# Or directly +spin up --from crates/my-app-adapter-spin +``` + +## Deployment + +```bash +# Using the CLI +edgezero deploy --adapter spin + +# Or directly +spin deploy --from crates/my-app-adapter-spin +``` + +## KV Storage + +Spin KV is **label-backed and multi-store** — each logical id in +`[stores.kv].ids` maps to a Spin store label declared in `spin.toml`. +Override the label per id with `EDGEZERO__STORES__KV____NAME`; with the +variable unset the label defaults to the logical id. + +```toml +# edgezero.toml +[stores.kv] +ids = ["sessions", "cache"] +default = "sessions" +``` + +```toml +# spin.toml +[component.my-app] +key_value_stores = ["sessions", "cache"] +``` + +Two Spin-specific KV constraints (see §6.7 of the design spec for the +full rationale): + +- **TTL is unsupported.** Spin's `key_value::Store::set` accepts no + expiry. `put_bytes_with_ttl` returns + `KvError::Unsupported { operation: "put_bytes_with_ttl" }` (mapped to + HTTP 501); never silently strips the TTL. +- **Listing is capped.** `Store::get_keys()` is unbounded, so the + adapter materialises the key list, filters by prefix, sorts, and pages + client-side. A `max_list_keys` cap (default `1000`, override via + `EDGEZERO__STORES__KV____MAX_LIST_KEYS`) guards against runaway + lists and yields `KvError::LimitExceeded` (HTTP 503) when exceeded. + +## Config Store + +Spin config is **KV-backed and multi-store** — each logical id in +`[stores.config].ids` opens a separate `spin_sdk::key_value::Store` at +runtime. The store accepts arbitrary UTF-8 keys, so the canonical dotted +key (`service.timeout_ms`) is read back verbatim — no key translation. +Override the label per id with `EDGEZERO__STORES__CONFIG____NAME`; +with the variable unset the label defaults to the logical id. + +```toml +# edgezero.toml +[stores.config] +ids = ["app_config", "feature_flags"] +default = "app_config" +``` + +```toml +# spin.toml — declare every label in the component's `key_value_stores` +[component.my-app] +key_value_stores = ["app_config", "feature_flags"] +``` + +```toml +# runtime-config.toml — register each custom label with a backend +# (the default `default` label is auto-provided by Spin; everything +# else needs an entry here, or `spin up` errors with +# "unknown key_value_stores label "). +[key_value_store.app_config] +type = "spin" + +[key_value_store.feature_flags] +type = "spin" +``` + +`edgezero new --adapter spin` scaffolds both files; `edgezero serve +--adapter spin` runs `spin up --runtime-config-file runtime-config.toml` +so locally-declared labels resolve to the SQLite-backed Spin KV +implementation. For production, swap `type = "spin"` for a managed +backend (`type = "azure_cosmos"`, `type = "redis"`, …) per the +[Spin runtime-config docs](https://spinframework.dev/v3/dynamic-configuration#key-value-store-runtime-configuration). + +`provision` writes the `[component.].key_value_stores` array for +you (it does NOT touch `runtime-config.toml` — keep that one +hand-edited). + +### Seeding the store + +`edgezero config push --adapter spin` reads `runtime-config.toml` and +dispatches to the right per-backend writer — no embedded HTTP endpoint, +no token to manage. Resolution order: + +1. **`--local` set**: forces SQLite-direct against + `/.spin/sqlite_key_value.db` (Spin's local KV file). + Useful for poking values in your local dev loop without + authenticating against Fermyon Cloud. Even under `--local`, every + non-`default` label MUST be declared in `runtime-config.toml` + (see point 4 below) — without the stanza, `spin up` errors with + `unknown key_value_stores label ` and the file you just + wrote is unreadable from the running app, so the dispatcher + refuses the push and tells you exactly which stanza to add. +2. **Manifest's `deploy` command targets Fermyon Cloud** (auto-detected + from `[adapters.spin.commands].deploy` containing `spin deploy` or + `spin cloud deploy`): one batched shellout per ≤96 KiB chunk of + `spin cloud key-value set --app --label
__…__` (uppercase, with `-` in +the app name replaced by `_`, segments joined by a double-underscore). +The overlay only applies to keys **already present in the file** — +it can't introduce new ones — and the existing TOML value's type +drives how the env string is coerced (`"true"` / `"false"` for +`bool`, parsed integers for numeric fields, etc.). + +```sh +# Override the nested service.timeout_ms key: +MY_APP__SERVICE__TIMEOUT_MS=2500 \ + cargo run -p my-app-adapter-axum +``` + +The env-segment translation is uppercase-only — it does **not** +substitute `-` for `_`, so dashed and underscored TOML keys remain +distinct env segments. The only way two siblings collapse is when +they differ only in letter case (e.g. `greeting_a` and `GREETING_A`, +both uppercasing to `GREETING_A`). That case is rejected as an +`EnvOverlay` error before any override is applied, so a +misconfiguration leaves the file values intact. ## Adapters Section @@ -325,7 +433,7 @@ value = "https://api.example.com" name = "API_KEY" [stores.secrets] -name = "EDGEZERO_SECRETS" +ids = ["default"] [adapters.fastly.adapter] crate = "crates/my-app-adapter-fastly" @@ -374,15 +482,24 @@ serve = "cargo run -p my-app-adapter-axum" Axum bind-address precedence is: -1. `EDGEZERO_HOST` / `EDGEZERO_PORT` -2. `edgezero.toml` `[adapters.axum.adapter]` `host` / `port` -3. `axum.toml` `[adapter]` `host` / `port` when launching through the Axum adapter CLI wrapper +1. `EDGEZERO__ADAPTER__HOST` / `EDGEZERO__ADAPTER__PORT` (canonical; + read directly by the runtime). The pre-rewrite + `EDGEZERO_HOST` / `EDGEZERO_PORT` shim is gone — rename any CI + scripts or local overrides to the canonical double-underscore + form. +2. `edgezero.toml` `[adapters.axum.adapter]` `host` / `port` (the CLI + translates these into `EDGEZERO__ADAPTER__HOST` / `EDGEZERO__ADAPTER__PORT` + when spawning the subprocess; if a canonical env var is already set, + it wins) +3. `axum.toml` `[adapter]` `host` / `port` when launching through the + Axum adapter CLI wrapper 4. default `127.0.0.1:8787` Example override: ```sh -EDGEZERO_HOST=0.0.0.0 EDGEZERO_PORT=3000 cargo run -p my-app-adapter-axum +EDGEZERO__ADAPTER__HOST=0.0.0.0 EDGEZERO__ADAPTER__PORT=3000 \ + cargo run -p my-app-adapter-axum ``` ## Using the Manifest @@ -403,7 +520,7 @@ The macro: - Parses HTTP triggers - Generates route registration - Wires middleware from the manifest -- Generates config-store metadata from `[stores.config]` when present +- Bakes portable store metadata (`Hooks::stores()`) from `[stores.kv]`, `[stores.config]`, and `[stores.secrets]` when present - Creates the `App` struct that implements `Hooks` (use `App::build_app()`) ### ManifestLoader diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index 9befc2a2..ad9d5e73 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -7,6 +7,7 @@ This guide walks you through creating your first EdgeZero application. - Rust toolchain (stable; see `.tool-versions` in the repo) - For Fastly: `wasm32-wasip1` target and the Fastly CLI - For Cloudflare: `wasm32-unknown-unknown` target and Wrangler +- For Spin: `wasm32-wasip2` target and the [Spin CLI](https://spinframework.dev/) ## Installation @@ -27,18 +28,21 @@ cd my-app This generates a workspace with: -- `crates/my-app-core` - Your shared handlers and routing logic +- `crates/my-app-core` - Your shared handlers, routing logic, and the typed `MyAppConfig` struct in `src/config.rs` +- `crates/my-app-cli` - Your project's own CLI binary, built on the `edgezero-cli` library - `crates/my-app-adapter-fastly` - Fastly Compute entrypoint - `crates/my-app-adapter-cloudflare` - Cloudflare Workers entrypoint - `crates/my-app-adapter-axum` - Native Axum entrypoint +- `crates/my-app-adapter-spin` - Fermyon Spin entrypoint - `edgezero.toml` - Manifest describing routes, middleware, and adapter config +- `my-app.toml` - Typed application config matching the `MyAppConfig` struct (see [Application config](/guide/configuration#application-config)) -## Start the Dev Server +## Run Your App Locally -Run the local Axum-powered development server: +Run your generated app on the native Axum adapter: ```bash -edgezero dev +edgezero serve --adapter axum ``` Your app is now running at `http://127.0.0.1:8787`. Try the generated endpoints: @@ -64,12 +68,17 @@ A scaffolded project looks like this: my-app/ ├── Cargo.toml # Workspace manifest ├── edgezero.toml # EdgeZero configuration +├── my-app.toml # Typed application config (loaded into MyAppConfig) ├── crates/ │ ├── my-app-core/ │ │ ├── Cargo.toml │ │ └── src/ │ │ ├── lib.rs # App definition with edgezero_core::app! +│ │ ├── config.rs # MyAppConfig with #[derive(AppConfig)] │ │ └── handlers.rs # Your route handlers +│ ├── my-app-cli/ +│ │ ├── Cargo.toml +│ │ └── src/main.rs # Your project's CLI, built on edgezero-cli │ ├── my-app-adapter-fastly/ │ │ ├── Cargo.toml │ │ ├── fastly.toml @@ -78,9 +87,13 @@ my-app/ │ │ ├── Cargo.toml │ │ ├── wrangler.toml │ │ └── src/main.rs -│ └── my-app-adapter-axum/ +│ ├── my-app-adapter-axum/ +│ │ ├── Cargo.toml +│ │ ├── axum.toml +│ │ └── src/main.rs +│ └── my-app-adapter-spin/ │ ├── Cargo.toml -│ ├── axum.toml +│ ├── spin.toml │ └── src/main.rs ``` diff --git a/docs/guide/kv.md b/docs/guide/kv.md index 8d7cb329..c468aac5 100644 --- a/docs/guide/kv.md +++ b/docs/guide/kv.md @@ -18,7 +18,11 @@ struct VisitData { } #[action] -async fn visit_counter(Kv(store): Kv) -> Result { +async fn visit_counter(kv: Kv) -> Result { + let store = kv + .default() + .ok_or_else(|| EdgeError::service_unavailable("no default kv configured"))?; + // Read-modify-write helper (Note: not atomic!) let data = store .read_modify_write("visits", VisitData::default(), |mut d| { @@ -33,31 +37,48 @@ async fn visit_counter(Kv(store): Kv) -> Result { ## Usage -### 1. Configure the Store Name +### 1. Declare logical KV store ids -In your `edgezero.toml`: +In your `edgezero.toml` — declare one or more logical ids (the portable +fact "this app uses a KV store called `sessions`"). Platform names are +resolved at runtime from `EDGEZERO__STORES__KV____NAME`; with the +variable unset, the platform name defaults to the logical id. ```toml [stores.kv] -name = "EDGEZERO_KV" # Default name for all adapters +ids = ["sessions", "cache"] +default = "sessions" # required when ids.len() > 1 ``` +For a single-store app the `default` field is optional and resolves to +`ids[0]`. Migrating from the pre-rewrite `name` / `[stores.kv.adapters.*]` +form? See [the migration guide](./manifest-store-migration.md). + ### 2. Access the Store -You can access the store using the `Kv` extractor (recommended) or via `RequestContext`. +Use the id-keyed `Kv` extractor (recommended) or `RequestContext` accessors. -**Using Extractor:** +**Using the extractor — pick a store by id at the call site:** ```rust -async fn handler(Kv(store): Kv) { ... } +async fn handler(kv: Kv) -> Result { + let sessions = kv + .named("sessions") + .ok_or_else(|| EdgeError::service_unavailable("no `sessions` kv"))?; + // — or, for the single-store common case — + let default = kv + .default() + .ok_or_else(|| EdgeError::service_unavailable("no default kv"))?; + // … +} ``` -**Using Context:** +**Using context:** ```rust async fn handler(ctx: RequestContext) { - let store = ctx.kv_handle().expect("kv configured"); - ... + let store = ctx.kv_store("sessions").expect("kv `sessions` configured"); + // or: ctx.kv_store_default() } ``` @@ -86,43 +107,37 @@ Use it only when approximate values are acceptable (e.g. visit counters, feature For strict correctness, use a transactional data store. ::: -Key listing is paginated by design. This avoids buffering an unbounded number of keys in memory and matches the underlying provider APIs. The Spin adapter returns `KvError::Validation` for key listing because Spin's current `Store::get_keys()` API is unbounded. +Key listing is paginated by design. This avoids buffering an unbounded number of keys in memory and matches the underlying provider APIs. The Spin adapter materialises `Store::get_keys()` and pages client-side; a `max_list_keys` cap (configurable via `EDGEZERO__STORES__KV____MAX_LIST_KEYS`, default `1000`) guards against runaway lists and yields `KvError::LimitExceeded` when exceeded. ## Platform Specifics ### Local Development -- **Axum**: Uses a persistent `redb` embedded database stored under `.edgezero/`. The default store name uses `.edgezero/kv.redb`; custom store names get their own derived file. Data persists across restarts (add `.edgezero/` to your `.gitignore`). -- **Fastly (Viceroy)**: Requires a `[local_server.kv_stores]` entry in `fastly.toml`. +- **Axum**: Uses a persistent `redb` embedded database stored under `.edgezero/`. Each declared KV id gets its own derived file; data persists across restarts (add `.edgezero/` to your `.gitignore`). +- **Fastly (Viceroy)**: Requires a `[local_server.kv_stores]` and `[setup.kv_stores]` entry per declared KV id. `edgezero provision --adapter fastly` writes both blocks for you; the example below assumes a `sessions` id. ```toml - [[local_server.kv_stores.EDGEZERO_KV]] + [[local_server.kv_stores.sessions]] key = "__init__" data = "" - [setup.kv_stores.EDGEZERO_KV] + [setup.kv_stores.sessions] description = "Application KV store" ``` -- **Cloudflare (Workerd)**: Requires a KV namespace and a binding in `wrangler.toml`. - 1. Create the namespace (run once per environment): - - ```sh - wrangler kv namespace create EDGEZERO_KV - wrangler kv namespace create EDGEZERO_KV --preview - ``` + Override the platform name per environment via + `EDGEZERO__STORES__KV__SESSIONS__NAME=`; provision honours + the override when it writes the setup blocks. - Each command prints an `id` — copy them into `wrangler.toml`: +- **Cloudflare (Workerd)**: `edgezero provision --adapter cloudflare` creates the namespace and appends the `[[kv_namespaces]]` binding using the env-resolved platform name (`EDGEZERO__STORES__KV____NAME` or the logical id by default). The example below shows what provision writes for a `sessions` id with no override: - 2. Add the binding to `wrangler.toml`: - ```toml - [[kv_namespaces]] - binding = "EDGEZERO_KV" - id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" # from step 1 - preview_id = "yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy" # from step 1 --preview - ``` + ```toml + [[kv_namespaces]] + binding = "sessions" + id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" # filled by provision + ``` - The `binding` name MUST match the store name configured in `edgezero.toml` (default: `"EDGEZERO_KV"`). + The `binding` name MUST match what the runtime opens — by default the logical id, otherwise the env override. - **Spin**: Requires a `key_value_stores` label in `spin.toml`. @@ -131,17 +146,16 @@ Key listing is paginated by design. This avoids buffering an unbounded number of key_value_stores = ["default"] ``` - The label MUST match the store name configured in `edgezero.toml`, or the Spin-specific override. Spin's local runtime auto-provisions the `"default"` label; custom labels require a Spin runtime config or cloud link. + The label MUST match what `EDGEZERO__STORES__KV____NAME` resolves to (or the logical id when the variable is unset). Spin's local runtime auto-provisions the `"default"` label; custom labels require a Spin runtime config or cloud link. Example: ```toml [stores.kv] - name = "EDGEZERO_KV" - - [stores.kv.adapters.spin] - name = "default" + ids = ["sessions"] + # No platform name in the manifest — set EDGEZERO__STORES__KV__SESSIONS__NAME=default + # at run time (or leave unset to bind the label "sessions"). ``` - `edgezero_adapter_spin::run_app` reads `edgezero.toml` and opens the resolved Spin label. Low-level manual dispatch helpers do not read the manifest. + `edgezero_adapter_spin::run_app` reads baked `[stores.*]` metadata + `EDGEZERO__*` env vars and opens the resolved Spin label per id. Low-level manual dispatch helpers (`dispatch`, `dispatch_with_kv_label`) bypass the env-config path. ### Consistency @@ -149,7 +163,7 @@ Both Fastly and Cloudflare KV stores are **eventually consistent**. - A value written at one edge location may not be immediately visible at another. - `read_modify_write()` is **not atomic**. Concurrent updates to the same key may result in lost writes. -- **TTL**: `put_with_ttl` enforces a minimum of **60 seconds** and a maximum of **1 year** before delegating to an adapter. Spin KV does not support TTL, so the Spin adapter returns `KvError::Validation` without writing the value. +- **TTL**: `put_with_ttl` enforces a minimum of **60 seconds** and a maximum of **1 year** before delegating to an adapter. Spin KV does not support TTL, so the Spin adapter returns `KvError::Unsupported { operation: "put_bytes_with_ttl" }` without writing the value. ## Limits & Validation diff --git a/docs/guide/manifest-store-migration.md b/docs/guide/manifest-store-migration.md new file mode 100644 index 00000000..6ce065c0 --- /dev/null +++ b/docs/guide/manifest-store-migration.md @@ -0,0 +1,171 @@ +# Migrating to the portable store schema + +Stage 2 of the CLI-extensions work rewrites `edgezero.toml`'s +`[stores.*]` sections to a portable, non-adapter-specific shape and +moves all adapter-specific runtime knobs to `EDGEZERO__*` environment +variables. This page is referenced by the loader's hard-error message +when it encounters a pre-rewrite manifest; follow it to bring an old +manifest forward. + +## TL;DR + +```toml +# Before (any of these is now a hard load error) +[stores.kv] +name = "EDGEZERO_KV" # ← removed +[stores.kv.adapters.spin] # ← removed (whole subtable) +name = "EDGEZERO_KV" +[stores.config.defaults] # ← removed +greeting = "hello" + +# After +[stores.kv] +ids = ["sessions", "cache"] +default = "sessions" # required when ids.len() > 1 +[stores.config] +ids = ["app_config"] # default optional with a single id +[stores.secrets] +ids = ["default"] +``` + +Platform names, tuning, bind host/port, and logging level are read at +runtime from `EDGEZERO__*` environment variables. An adapter binary +runs with **zero env vars set** — each logical id is used as its own +platform name. + +## What changed and why + +`edgezero.toml` is now portable: it declares what the app _is_, not +how any particular platform runs it. The old per-adapter store and +runtime tables (`[stores.*.adapters.*]`, `[adapters..adapter] +host`, etc.) coupled the manifest to a specific deployment shape; +keeping them required the manifest to be recompiled every time you +moved between environments. + +The new shape lets one manifest cover dev, staging, and production for +the same workload. Per-environment differences (which Cloudflare KV +namespace ID maps to the `sessions` store, what host axum binds to, +what log level the worker uses) live in the environment, not the file. + +## Field-by-field + +### `[stores.]` + +| Old | New | +| ----------------------------------------- | ----------------------------------------------------------------------------------------- | +| `name = "EDGEZERO_KV"` | `ids = ["edgezero_kv"]` (or whatever logical id your code uses) | +| `enabled = true` | (gone — the kind is enabled by being declared at all) | +| `[stores..adapters.] name` | `EDGEZERO__STORES______NAME` env var at run time (`` is the upper-case id) | +| `[stores.config.defaults]` | (gone — the local axum config store now reads `.edgezero/local-config-.json` instead) | + +The portable manifest accepts only `ids` (non-empty) and `default` +(required when `ids.len() > 1`; with a single id it resolves to that +id automatically). Both are validated at load time. + +### Capability matrix + +Each (adapter, kind) pair is one of two capabilities (full table in +the spec, §6.6): + +| Adapter | KV | Config | Secrets | +| ---------- | ---------------- | -------------------- | ----------------------- | +| axum | Multi (local) | Multi (local files) | Single (env vars) | +| cloudflare | Multi (KV ns) | Multi (KV ns) | Single (worker secrets) | +| fastly | Multi (KV store) | Multi (config store) | Multi (secret store) | +| spin | Multi (KV label) | Multi (KV label) | Single (flat variables) | + +- **Multi**: each logical id resolves to its own platform store. +- **Single**: every logical id maps to the same flat store; per-id + `NAME` variables are ignored. Declaring more than one id for a + `Single` (adapter, kind) pair is caught by `config validate` (§10). + +### Runtime environment variables + +`__` (double underscore) separates segments. Absent variables fall +back to their listed defaults. + +| Variable | Role | Default | +| --------------------------------------- | ---------------------------------------------------------- | --------------- | +| `EDGEZERO__STORES______NAME` | platform name for logical store `` | the logical id | +| `EDGEZERO__STORES______` | free-form per-adapter tuning (e.g. spin's `MAX_LIST_KEYS`) | — | +| `EDGEZERO__ADAPTER__HOST` | bind host (axum) | `127.0.0.1` | +| `EDGEZERO__ADAPTER__PORT` | bind port (axum) | `8787` | +| `EDGEZERO__LOGGING__LEVEL` | log level | adapter default | + +`` ∈ `KV` / `CONFIG` / `SECRETS`; `` is the upper-case logical id. + +## What this means for handler code + +`Hooks::config_store()` is gone; the `app!` macro now bakes the +portable store registry into `Hooks::stores()` for all three kinds. + +The `Kv` / `Secrets` / `Config` extractors are id-keyed: + +```rust +#[action] +pub async fn handler(kv: Kv, secrets: Secrets) -> Result { + let sessions = kv.named("sessions") + .ok_or_else(|| EdgeError::service_unavailable("no `sessions` kv"))?; + let default_secrets = secrets.default() + .ok_or_else(|| EdgeError::service_unavailable("no default secrets"))?; + // … +} +``` + +`RequestContext` mirrors the same shape: +`ctx.kv_store(id)` / `ctx.kv_store_default()` (and the same for +`config_store` / `secret_store`). The pre-rewrite no-arg accessors +(`ctx.kv_handle()`, `ctx.config_handle()`, `ctx.secret_handle()`) +are **gone** — Stage 10.1 enforced the spec's "no backward +compatibility with the pre-rewrite runtime store API" promise. +Migrating handler code is mechanical: replace each +`ctx.kv_handle()` with `ctx.kv_store_default()`, +`ctx.config_handle()` with `ctx.config_store_default()`, and +`ctx.secret_handle()` with `ctx.secret_store_default()` (the +last one returns a `BoundSecretStore` whose `get_bytes(key)` is +single-arg — the platform store name is bound by the +dispatcher, not passed at the call site). + +Adapter setup code still has `with_*_handle` / +`dispatch_with_*_handle` convenience constructors that take a +single bare handle. Internally each dispatcher synthesises a +one-id `KvRegistry` / `ConfigRegistry` / `SecretRegistry` +under the conventional `"default"` id from that handle before +the request reaches the router — so the registry-aware +accessors and the `Kv` / `Config` / `Secrets` extractors +resolve uniformly regardless of which constructor wired the +store. + +## What about local config-store seeding? + +The pre-rewrite `[stores.config.defaults]` table seeded the axum +config store from the manifest. That table is gone. The axum config +store now reads `.edgezero/local-config-.json` (one file per +declared config id). Use the `edgezero config push --adapter axum` +command (spec §13, [CLI reference](./cli-reference#edgezero-config-push)) +to write that file from your typed `.toml` app-config — or +hand-edit the JSON directly when you just need a quick fixture for +local testing. + +## Cloudflare config store: `[vars]` → KV namespace + +The Cloudflare config store used to read one `[vars]` string binding +containing a JSON object. It now reads from a **KV namespace** binding +asynchronously. To migrate, replace each `[vars] app_config = '{ … }'` +entry with a KV namespace binding: + +```toml +# wrangler.toml — before +[vars] +app_config = '{"greeting":"hello","feature.new_checkout":"false"}' + +# wrangler.toml — after +[[kv_namespaces]] +binding = "app_config" +id = "abc123…" +``` + +Populate the namespace via `wrangler kv:key put`. The binding name +becomes the platform name resolved by +`EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME` (with the default being +the literal id `app_config`). diff --git a/docs/guide/roadmap.md b/docs/guide/roadmap.md index e5ce1b8f..6b92ac1f 100644 --- a/docs/guide/roadmap.md +++ b/docs/guide/roadmap.md @@ -8,7 +8,9 @@ shift as the roadmap evolves. - Tooling parity: extend `edgezero-cli` with template/plugin style commands (similar to Spin templates) to streamline new app scaffolds and provider-specific wiring. - CLI parity backlog: add `edgezero --list-adapters`, standardize exit codes, search up for - `edgezero.toml`, respect `RUST_LOG` for dev output, and bake in hot reload for `edgezero dev`. + `edgezero.toml`, respect `RUST_LOG` for dev output, and bake in hot reload for + `edgezero serve --adapter axum` (the local dev path; the standalone `dev` subcommand was + reserved for a future dev-workflow command, see [CLI reference](./cli-reference#edgezero-demo)). - Adapter behavior matrix: document which adapters buffer bodies, which preserve streaming, and where proxy headers/automatic decompression apply so expectations match runtime behavior. - Example coverage: add focused guides for `axum.toml`, manifest `description` fields, logging diff --git a/docs/superpowers/plans/2026-05-20-cli-extensions.md b/docs/superpowers/plans/2026-05-20-cli-extensions.md new file mode 100644 index 00000000..1e172bd3 --- /dev/null +++ b/docs/superpowers/plans/2026-05-20-cli-extensions.md @@ -0,0 +1,963 @@ +# EdgeZero CLI Extensions Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Turn `edgezero-cli` into an extensible library, rewrite the manifest store schema and runtime to a multi-store model, add `auth` / `provision` / `config validate` / `config push` commands, and update `app-demo` to exercise it all across axum / cloudflare / fastly / spin. + +**Architecture:** One PR, eight sequential stages. Stage 1 extracts the CLI library substrate. Stage 2 is an atomic manifest + runtime rewrite (hard cutoff — no backward compatibility). Stages 3–7 add app-config and the four commands. Stage 8 makes `app-demo` the full-capability showcase and audits docs. + +**Tech Stack:** Rust 1.95 (edition 2021), `clap` (derive), `serde` / `toml` / `serde_json`, `validator`, `async-trait` (`?Send`, WASM-safe), `handlebars` (templates), proc-macros (`edgezero-macros`), VitePress docs. + +**Spec:** `docs/superpowers/specs/2026-05-19-cli-extensions-design.md` — read it first. Section references (§) below point into it. + +--- + +## Preconditions (do before stage 2) + +- [x] **PR #253 (`feat/spin-store-support`) is merged into the working branch.** Landed via the `chore/strict-clippy` merge — `crates/edgezero-adapter-spin/src/` now has `config_store.rs` / `key_value_store.rs` / `secret_store.rs`. Stage 2 wires `SpinKvStore` / `SpinConfigStore` / `SpinSecretStore` into the multi-store runtime. +- [ ] Working on branch `feature/extensible-cli` (stacked on `chore/strict-clippy` / PR #257). The spec and plan live in `docs/superpowers/`, which is gitignored — keep using `git add -f` for spec/plan files only. + +## Status + +- **Stage 1 — DONE.** Landed as `1d582dd` (extensible `edgezero-cli` + library + generator + `app-demo-cli`) plus follow-up `06f4b72` + (`demo` is example-only; `serve --adapter axum` runs the axum + adapter). §7 below is kept for reference — do **not** re-do it. +- **Stage 2 — DONE on `feature/extensible-cli`.** Landed across the + commit chain rooted at `f5bd432` (Task 2.1, portable store schema) + through the post-review fixes at `8942ec2` (Spin component field, + bind-vs-environment precedence, axum doc API drift). Substrate + shipped: portable `[stores.]` schema + hard-cutoff for the + legacy `[stores.] name` / `[adapters..stores.*]` / + `[adapters..adapter] ` fields (§§ 8.1, 8.3, plus + follow-ups); `EDGEZERO__*` env-config layer; `app!` macro bakes + portable store metadata into `Hooks::stores()`; `run_app` drops + `manifest_src` on all four adapters; async `ConfigStore`; `KvError` + gains `Unsupported` / `LimitExceeded` with `EdgeError` mappings; + per-id `KvRegistry` / `ConfigRegistry` / `SecretRegistry`; `Kv` / + `Secrets` / `Config` extractors reshape to `default()` / `named()`; + `BoundSecretStore` captures the per-id platform store name (Fastly + multi-secret wired end-to-end); axum config store reads + `.edgezero/local-config-.json` per id; Spin KV pagination and + dotted-key translation; cloudflare config-store rewrite from `[vars]` + JSON-string to KV namespace; `app-demo` and the generator template + ship matching manifests + per-platform bindings; manifest-store + migration guide published; all five CI gates + the opt-in + generated-project compile check + docs lint/format/build green. +- **Stages 3 + 4 — shipped** on `feature/extensible-cli`. Typed + `.toml` app-config + `#[derive(AppConfig)]` + env-var + overlay land in Stage 3; `config validate` (raw + typed flavours + dispatched via an `AdapterCheck` trait) lands in Stage 4. The + reference `app-demo-cli config validate --strict` and raw + `edgezero config validate --strict` both exit 0 against the + in-tree fixture. +- **Stage 5 — shipped.** `auth login/logout/status --adapter ` + dispatches via `AdapterAction::Auth{Login,Logout,Status}`; each + adapter crate owns its implementation in `Adapter::execute`. + Per-project overrides via + `[adapters..commands].auth-{login,logout,status}` in + `edgezero.toml`. Earlier `CommandRunner`/`MockCommandRunner` + sketch retired (see Stage 5 below). +- **Stage 6 — shipped.** `provision --adapter ` dispatches + via a new `Adapter::provision` trait method (NOT + `AdapterAction` — the surface needs typed `ProvisionStores` and + a paths/dry-run signature that doesn't fit `AdapterAction`'s + `&[String]` shape). Landed as one-adapter-per-commit: + `9a0369b` (trait + axum no-op + CLI delegate + stubs for the + other three), `d905e42` (cloudflare `wrangler kv namespace + create` + `wrangler.toml` `[[kv_namespaces]]` writeback), + `79a54b6` (fastly `fastly -store create` + + `[setup.*]`/`[local_server.*]` `fastly.toml` writeback), + `0933440` (spin pure `spin.toml` editing — appends KV labels + to the resolved `[component.].key_value_stores` array). +- **Stage 7 — shipped.** `config push` adds the symmetric + write-side counterpart to `config validate`, also dispatched + via a new `Adapter::push_config_entries` trait method (same + rationale as `provision`). Landed as one-adapter-per-commit: + `bc0a705` (trait + axum impl + CLI raw + typed entry points + + stubs), `74d596a` (cloudflare `wrangler kv bulk put` against + the namespace id read from `wrangler.toml`), `d852f3f` (fastly + `fastly config-store list --json` then + `fastly config-store-entry create` per entry), `57c7eb3` (spin + pure `spin.toml` editing — writes both `[variables].` and + `[component..variables].` with `.→__` lowercase key + translation). The typed flow strips both `#[secret]` and + `#[secret(store_ref)]` top-level fields before pushing (spec + §13). +- **Stage 8 — shipped.** Plan task 8.1 split across three + commits (`3d3f87c`, `9fdd1f4`, `26fddcc`) — manual Spin + secret-variable declarations in + `app-demo-adapter-spin/spin.toml`, three typed-CLI + integration tests in `app-demo-cli/tests/config_flow.rs`, + and the handler rewiring to `Kv::named("sessions")` / + `Kv::named("cache")` with a registry-aware + `context_with_kv` test helper. Plan task 8.2 (generator + CLI template emits the full seven-command Cmd enum) shipped + as `a4f7c81`. The e2e roundtrip + (push → `AxumConfigStore::from_path` → handler) shipped as + `45aef3d`; full HTTP-subprocess lifecycle is intentionally + deferred — the data-contract roundtrip covers what app-demo + needs without the subprocess machinery. Plan task 8.3 (CI + wiring for `cd examples/app-demo && cargo test` plus + fmt/clippy gates) shipped as `7d01061`. Plan task 8.4 + (`cli-walkthrough.md` + doc audit + sidebar update + a + silent-broken VitePress build fix for the `{{ }}` + interpolation in cli-reference.md) shipped as `a3b7a89`. +- **Stage 9 — shipped (review followups).** A staff-engineer + review of the post-Stage-8 branch found five gaps; each + landed as its own commit so review traceability stays + linear. `55fe91b` (Stage 9.1) wired `run_shared_checks` into + both raw and typed `config push` with `strict: true` + synthesised on the validate args — the typed push had been + loading config, running typed secret checks, and dispatching + without running the shared adapter / capability-completeness + / handler-path checks the spec promised. `b531f5a` (Stage + 9.2) fixed Spin secret-value validation: the runtime + `SpinSecretStore::get_bytes` lowercases keys before + `variables::get`, but the validator was case-preserving, so + `#[secret]` value `"GREETING"` against config key `greeting` + silently passed and dashed values like `"api-token"` were + caught only at runtime — `validate_typed_secrets` now mirrors + the runtime canonicalisation exactly and also runs + `is_valid_spin_key` on each secret value. `2cc85d1` (Stage + 9.3) introduced the runtime store-API hard-cutoff at the + fallback layer: `StoreRegistry::single_id` helper + dispatch- + boundary synthesis in all four adapters + extractor and + `RequestContext::*_store*` fallback removal. `6592918` + (Stage 9.4) hardened Spin dry-run assertions to verify the + translated keys, both spin.toml tables, and that + `SECRET_FIELDS` stripping reaches the adapter preview. + `8ad9040` (Stage 9.5) refreshed the PR template, run_tests.sh, + and the migration guide for the new gates and the shipped + `config push` command. +- **Stage 10 — shipped (second-review followups).** A second + pass flagged that Stage 9.3 had only closed the + silent-masking fallback; the public legacy + `RequestContext::config_handle()` / `kv_handle()` / + `secret_handle()` accessors plus the bare-handle insertion + into request extensions still existed, contradicting the + spec's "no backward compatibility" promise. + `b1b5dca` (Stage 10.1) removed those three methods, stopped + inserting bare handles into request extensions in all four + dispatchers, and migrated 9 dev-server callers + 3 axum + service tests + 4 contract-test handlers (cloudflare / + fastly / spin) to the registry-aware accessors. The + axum `with_*_handle` setup APIs stay public but route + through the one-id-registry synthesis path internally. + + Subsequent dispatch-API consolidation: the per-store + `dispatch_with_*` variant fan-out on fastly + cloudflare + collapsed into a single `FastlyService` / `CloudflareService` + builder. Per-request store wiring uses the fluent form + `Service::new(&app).with_kv("name").require_kv() + .with_config("name").with_secrets().dispatch(req[, env, ctx])`. + The manifest-driven `run_app` remains the recommended + entrypoint and now internally builds a Service. + +## Codebase facts this plan relies on + +(Reflects branch state after Stage 2 shipped on +`feature/extensible-cli`. The pre-Stage-1 / pre-Stage-2 shape that +earlier revisions of this plan referenced is gone — code below is the +substrate Stage 3 builds on.) + +- `edgezero-cli` is a **library + binary**: + - `crates/edgezero-cli/src/lib.rs` is the public API; downstream + binaries depend on it. Each command is exposed as a + `(Args, run_)` pair (`BuildArgs` / `run_build`, etc.). + - `*Args` structs derive `clap::Args` + `Default` and are + `#[non_exhaustive]`; live under `edgezero_cli::args`. + - The `edgezero` binary is a thin wrapper that delegates to those + `run_*` functions; the `cli` feature gates the binary build (deps + on `clap`). + - Adapter discovery is link-time via the `edgezero-adapter` registry; + `build.rs` reads `Cargo.toml` to figure out which optional + `edgezero-adapter-*` deps are enabled and emits + `linked_adapters.rs`. +- `ConfigStore::get` is **async** (`#[async_trait(?Send)]`), with all + four adapter impls — `AxumConfigStore` (local-file backed), + `FastlyConfigStore`, `CloudflareConfigStore` (KV-namespace backed, + was `[vars]` JSON-string), `SpinConfigStore`. `KvStore` and + `SecretStore` are already async. +- `KvError` carries `Unsupported { operation }` and + `LimitExceeded { message }` variants in addition to the legacy + `Internal` / `NotFound` / `Serialization` / `Unavailable` / + `Validation`. Both new variants map to 5xx-class `EdgeError`s. +- Handle types remain `KvHandle` / `ConfigStoreHandle` / `SecretHandle`. + Stage 2 added `BoundKvStore = KvHandle` and + `BoundConfigStore = ConfigStoreHandle` aliases, plus a real + `BoundSecretStore { handle: SecretHandle, store_name: String }` + that captures the per-id platform store name (so the registry's + `EDGEZERO__STORES__SECRETS____NAME` binding actually flows + through to lookups). +- `StoreRegistry { by_id: BTreeMap, default_id: String }` + lives at `crates/edgezero-core/src/store_registry.rs` with + `KvRegistry` / `ConfigRegistry` / `SecretRegistry` aliases. `new` + panics in both debug and release when `default_id` is missing; + builders that skip failed-to-open backends use the safe + `from_parts(by_id, default_id) -> Option`. +- `RequestContext` accessors are **id-keyed**: + `kv_store(id)` / `kv_store_default()`, + `config_store(id)` / `config_store_default()`, + `secret_store(id)` / `secret_store_default()`. The pre-rewrite + singular accessors (`kv_handle()` / `config_handle()` / + `secret_handle()`) are GONE (Stage 10.1 hard-cutoff). The + setup APIs (`with_kv_handle`, etc.) still accept a single + handle but synthesise a one-id `StoreRegistry` keyed under + `"default"` at the dispatch boundary -- the id-keyed accessors + only consult registries, never a bare handle in extensions. +- `Kv` / `Secrets` / `Config` extractors expose `.default()` / + `.named(id)` returning the matching `Bound*Store`. The legacy + destructure pattern (`Kv(store): Kv`) is gone. +- The portable manifest model (`crates/edgezero-core/src/manifest.rs`): + - `[stores.]` carries only `ids` + `default`; pre-rewrite + fields (`name`, `enabled`, `[stores..adapters.*]`, + `[stores.config.defaults]`) are a hard load error pointing at + `docs/guide/manifest-store-migration.md`. + - `[adapters.]` retains `adapter` / `build` / `commands` / + `logging`; any other sub-table is a hard load error. + `[adapters..adapter]` declares `component` / `crate` / `host` / + `manifest` / `port`; any other field is a hard load error. + - `app!` macro bakes the portable store registry into + `Hooks::stores()` at compile time (no runtime manifest load). +- `run_app::()` takes **no `manifest_src`** on any adapter + (axum / fastly / cloudflare / spin). Adapter-specific runtime + config — bind host/port, store platform names, store tuning, log + level — comes from `EDGEZERO__*` env vars + (`crates/edgezero-core/src/env_config.rs`). The Stage 2 CLI + translates `[adapters..adapter] host`/`port` into + `EDGEZERO__ADAPTER__HOST/PORT` on the subprocess env (with the + documented precedence parent env > manifest `[environment.variables]` + > `[adapters..adapter]` bind hint). +- Axum KV is `PersistentKvStore` (redb-backed). Each declared + `[stores.kv]` id resolves to its own file: the default id keeps + `.edgezero/kv.redb`; other ids get `.edgezero/kv--.redb` + where the file name is derived from the platform name from + `EDGEZERO__STORES__KV____NAME` (or the id default). +- Axum config is `AxumConfigStore::from_local_file(id)` reading + `.edgezero/local-config-.json` per declared id (a flat + `string -> string` JSON object). Missing file → empty store + (permissive); malformed → `ConfigStoreError::Unavailable` and the + id is dropped from the registry with a warn log. `config push` + (Stage 7) will write that file; Stage 3 / typed app config feed + into the same path. +- Axum secrets is `EnvSecretStore` (env-var lookup). `Single` for + secrets, so every declared id maps to the same env-backed store. +- Spin KV is `SpinKvStore` (`max_list_keys` cap honored; + `put_bytes_with_ttl` returns `KvError::Unsupported`; listing past + the cap returns `KvError::LimitExceeded`). Spin config is + `SpinConfigStore` (single flat-variable store; `.`→`__` key + translation). Spin secrets is `SpinSecretStore` (single flat- + variable store). +- Cloudflare config is **KV-namespace backed**, not `[vars]` + JSON-string — `CloudflareConfigStore::from_env(&worker::Env, binding_name)` + opens a KV namespace and `get(key)` is async. +- `examples/app-demo` is a **separate workspace**, excluded from the + root workspace. CI now runs `cd examples/app-demo && cargo test + --workspace --all-targets` as a dedicated job (see `format.yml` / + `test.yml`); previous revisions of this plan noted it was uncovered, + which is no longer true. The opt-in + `cargo test -p edgezero-cli --test generated_project_builds -- --ignored` + scaffolds a new workspace from the templates and runs `cargo check` + on it — Stage 3's generator-template changes must keep that test + green. +- CI: `.github/workflows/test.yml` and `format.yml` plus the docs + ESLint/Prettier job. The exact gate commands are the five below. + +## The full gate + +Wherever a task says **"run the full gate"**, it means these exact +commands — the project's documented CI gates (`CLAUDE.md` "CI Gates" + +`.github/workflows/`). Do not substitute `--all-features` for the +feature list, or drop `--all-targets`; match CI exactly so the plan +validates the same surface CI does. + +```sh +cargo fmt --all -- --check +cargo clippy --workspace --all-targets --all-features -- -D warnings +cargo test --workspace --all-targets +cargo check --workspace --all-targets --features "fastly cloudflare spin" +cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin +``` + +Plus, where the task touches adapter runtime or `app-demo`: the +per-adapter wasm `--test contract` runs (Task 2.6), +`cd examples/app-demo && cargo test`, and — for doc changes — the docs +ESLint/Prettier job. Each stage's final task runs the full gate before +its `git commit`. + +## File structure (created / modified across the 8 stages) + +``` +crates/edgezero-cli/ + Cargo.toml # M: lib target implicit via src/lib.rs; new deps + src/lib.rs # C (stage 1): public API + src/main.rs # M (stage 1): thin wrapper; M (4-7): dispatch arms for new commands + src/args.rs # M: standalone *Args structs; M (4-7): new *Args + Command enum variants + src/demo_server.rs # M (stage 1): renamed from dev_server.rs + # (stage 5 originally planned a `src/runner.rs` — retired in + # favour of per-adapter `Adapter::execute` dispatch.) + src/auth.rs # C (stage 5) + src/provision.rs # C (stage 6) + src/config.rs # C (stage 7): validate + push + src/generator.rs # M (stages 1, 3): scaffold -cli, .toml + src/templates/cli/ # C (stage 1); M (stage 8): full command set + src/templates/app/ # C (stage 3) + src/templates/root/edgezero.toml.hbs # M (stage 2): new store schema + src/templates/core/src/config.rs.hbs # C (stage 3) + tests/lib_consumer.rs # C (stage 1) +crates/edgezero-core/src/ + manifest.rs # M (stage 2): store schema rewrite + capability rules + config_store.rs # M (stage 2): async trait + key_value_store.rs # M (stage 2): KvError::Unsupported + LimitExceeded + secret_store.rs # M (stage 2): bound-handle wrapper + context.rs # M (stage 2): id-keyed Bound*Store accessors + extractor.rs # M (stage 2): Kv/Secrets/Config default()/named() + app.rs # M (stage 2): Hooks + id-keyed ConfigStoreMetadata (Hooks lives in app.rs, no separate hooks.rs) + app_config.rs # C (stage 3) +crates/edgezero-macros/src/ + lib.rs # M (stage 3): AppConfig derive export + app_config.rs # C (stage 3): derive impl + app.rs # M (stage 2): emit id-keyed metadata +crates/edgezero-adapter-{axum,cloudflare,fastly,spin}/src/ + {config_store,key_value_store,secret_store}.rs # M (stage 2): multi-store registries +examples/app-demo/ + Cargo.toml # M (stage 1): add app-demo-cli member + edgezero.toml # M (stage 2): new schema + app-demo.toml # C (stage 3) + crates/app-demo-cli/ # C (stage 1, extended 4-8) + crates/app-demo-core/src/config.rs # C (stage 3) + crates/app-demo-core/src/handlers.rs # M (stages 2, 8) +docs/guide/ # M: many pages per §6.12 +docs/guide/manifest-store-migration.md # C (stage 2) +docs/guide/cli-walkthrough.md # C (stage 8) +docs/.vitepress/config.mts # M (stages 2, 8): sidebar +``` + +--- + +# Stage 1 — Extensible `edgezero-cli` library + generator + `app-demo-cli` skeleton ✅ DONE (`1d582dd`, `06f4b72`) + +Spec §7. No PR #253 dependency. Goal: `edgezero-cli` becomes lib + bin; the `demo` subcommand replaces `dev`; the generator scaffolds `-cli`; a handwritten `app-demo-cli` exists. + +### Task 1.1: Promote `Command` variant fields into standalone `*Args` structs + +**Files:** + +- Modify: `crates/edgezero-cli/src/args.rs` + +- [ ] **Step 1: Write failing test** in `args.rs` `#[cfg(test)] mod tests` — assert `BuildArgs`, `DeployArgs`, `ServeArgs` exist, are `Default`, and parse: + +```rust +#[test] +fn build_args_default_and_mutate() { + let mut a = BuildArgs::default(); + a.adapter = "fastly".to_string(); + assert_eq!(a.adapter, "fastly"); +} +``` + +- [ ] **Step 2: Run** `cargo test -p edgezero-cli args::tests::build_args_default_and_mutate` — expect FAIL (`BuildArgs` not found). + +- [ ] **Step 3: Implement.** Add `#[derive(clap::Args, Debug, Default)] #[non_exhaustive]` structs `BuildArgs { adapter: String, adapter_args: Vec }`, `DeployArgs { adapter: String, adapter_args: Vec }`, `ServeArgs { adapter: String }` carrying the exact `#[arg(...)]` attributes currently inline in the `Command` enum variants. Keep `NewArgs` as-is (already standalone). Rewrite `Command` to: `Build(BuildArgs)`, `Deploy(DeployArgs)`, `Demo`, `New(NewArgs)`, `Serve(ServeArgs)`. Note: `Demo` is the renamed `Dev` (see Task 1.3). + +- [ ] **Step 4: Run** `cargo test -p edgezero-cli args::` — expect PASS. Update the existing `parses_build_command_with_passthrough_args` test to destructure `Command::Build(BuildArgs { adapter, adapter_args })`. + +- [ ] **Step 5: Commit** is deferred — stage 1 lands as one commit after Task 1.7. Stage progress only. + +### Task 1.2: Create `lib.rs`, move handlers, rewrite `main.rs` + +**Files:** + +- Create: `crates/edgezero-cli/src/lib.rs` +- Modify: `crates/edgezero-cli/src/main.rs` + +- [ ] **Step 1:** Create `lib.rs` under `#![cfg(feature = "cli")]`-style gating consistent with the crate. Declare the private modules (`mod adapter; mod args; mod generator; mod scaffold; #[cfg(feature = "edgezero-adapter-axum")] mod demo_server;`). Move `init_cli_logger`, `load_manifest_optional`, `ensure_adapter_defined`, `store_bindings_message`, `log_store_bindings`, and the handler bodies from `main.rs`. Rename `handle_build`→`run_build`, `handle_deploy`→`run_deploy`, `handle_serve`→`run_serve`; add `run_new` wrapping `generator::generate_new`; `run_demo` (Task 1.3). `pub use args::{Args, BuildArgs, Command, DeployArgs, NewArgs, ServeArgs};`. Public signatures: `pub fn run_build(args: &BuildArgs) -> Result<(), String>` etc. + +- [ ] **Step 2:** Move the `#[cfg(test)] mod tests` from `main.rs` into `lib.rs` unchanged (they test the moved fns). + +- [ ] **Step 3:** Rewrite `main.rs` to ~25 lines: `use edgezero_cli::{...}; fn main() { edgezero_cli::init_cli_logger(); match Args::parse().cmd { Command::Build(a) => exit_on_err(edgezero_cli::run_build(&a)), ... Command::Demo => exit_on_err(edgezero_cli::run_demo()), ... } }`. Keep the `#[cfg(not(feature = "cli"))]` fallback `main`. + +- [ ] **Step 4: Run** `cargo test -p edgezero-cli` — expect PASS (all relocated tests green). + +- [ ] **Step 5: Run** `cargo build -p edgezero-cli` and `./target/debug/edgezero --help` — expect four subcommands (`build`, `deploy`, `new`, `serve`); `demo` is gated behind the `demo-example` feature. + +### Task 1.3: Rename `dev` → `demo` + +**Files:** + +- Modify: `crates/edgezero-cli/src/args.rs`, `crates/edgezero-cli/src/main.rs`, `crates/edgezero-cli/src/lib.rs` +- Rename: `crates/edgezero-cli/src/dev_server.rs` → `crates/edgezero-cli/src/demo_server.rs` + +- [ ] **Step 1:** `git mv crates/edgezero-cli/src/dev_server.rs crates/edgezero-cli/src/demo_server.rs`. Inside it, rename `pub fn run_dev()` → `pub fn run_demo() -> Result<(), String>` — change the return type: `Ok(())` on graceful shutdown, `Err(String)` on bind failure. Update internal references. + +- [ ] **Step 2:** In `args.rs`, the `Command` enum variant is `Demo` (done in Task 1.1). In `lib.rs` declare `#[cfg(feature = "edgezero-adapter-axum")] mod demo_server;` and `pub use demo_server::run_demo;` (feature-gated). Add the non-axum fallback: `run_demo` errors "built without edgezero-adapter-axum". + +- [ ] **Step 3:** Update `CLAUDE.md`'s `cargo run -p edgezero-cli --features dev-example -- dev` reference is doc-only — leave the `dev-example` feature name as-is (out of scope) but the invocation becomes `-- demo`. (Doc fix happens in Task 1.7.) + +- [ ] **Step 4: Run** `cargo test -p edgezero-cli && cargo build -p edgezero-cli` — expect PASS; with `--features demo-example` built in, `./target/debug/edgezero demo --help` works. + +### Task 1.4: Extend the generator to scaffold `-cli` + +**Files:** + +- Modify: `crates/edgezero-cli/src/generator.rs`, `crates/edgezero-cli/src/scaffold.rs` +- Create: `crates/edgezero-cli/src/templates/cli/Cargo.toml.hbs`, `crates/edgezero-cli/src/templates/cli/src/main.rs.hbs` +- Modify: `crates/edgezero-cli/src/templates/root/Cargo.toml.hbs` + +- [ ] **Step 1: Write failing test** in `generator.rs` tests: `generate_new` into a `tempfile::TempDir` produces `crates/-cli/Cargo.toml` and `crates/-cli/src/main.rs`, and the root `Cargo.toml` `members` list contains `crates/-cli`. + +- [ ] **Step 2: Run** the test — expect FAIL. + +- [ ] **Step 3: Implement.** Add `templates/cli/Cargo.toml.hbs` (package `{{name}}-cli`, depends on `edgezero-cli` with default features, `clap` derive, `log`). Add `templates/cli/src/main.rs.hbs` — the canonical downstream pattern: a `clap::Parser` `Args` with a `Cmd` `Subcommand` enum listing the four downstream built-ins (`Build(BuildArgs)`, `Deploy(DeployArgs)`, `New(NewArgs)`, `Serve(ServeArgs)`), `main` dispatching to `edgezero_cli::run_*`. Register the new templates in `scaffold.rs::register_templates`. In `generator.rs`, render the cli crate and append `crates/{{name}}-cli` to the root `Cargo.toml` members. + +- [ ] **Step 4: Run** the generator test — expect PASS. + +- [ ] **Step 5: Manual check:** generate into an explicit fresh temp dir and build it — do **not** assume the project lands in CWD. Example: + +```bash +TMP="$(mktemp -d)" +cargo run -p edgezero-cli -- new throwaway --dir "$TMP" +# cd into the generated project root (confirm the exact path the generator +# prints — `--dir` is "the directory to create the app in"): +cd "$TMP"/* 2>/dev/null || cd "$TMP" +cargo check --workspace +cd - && rm -rf "$TMP" +``` + +Expected: `cargo check --workspace` in the generated project succeeds. + +### Task 1.5: Add the handwritten `app-demo-cli` crate + +**Files:** + +- Create: `examples/app-demo/crates/app-demo-cli/Cargo.toml`, `examples/app-demo/crates/app-demo-cli/src/main.rs`, `examples/app-demo/crates/app-demo-cli/tests/help.rs` +- Modify: `examples/app-demo/Cargo.toml` + +- [ ] **Step 1:** Add `"crates/app-demo-cli"` to `examples/app-demo/Cargo.toml` `members`. Add `edgezero-cli = { path = "../../crates/edgezero-cli" }` to that workspace's `[workspace.dependencies]` — the path is relative to the workspace manifest (`examples/app-demo/Cargo.toml`), matching the existing `edgezero-core = { path = "../../crates/edgezero-core" }` line. + +- [ ] **Step 2:** Write `app-demo-cli/Cargo.toml` — `name = "app-demo-cli"`, `publish = false`, `[lints] workspace = true`, deps `edgezero-cli = { workspace = true }`, `clap = { version = "4", features = ["derive"] }`, `log = { workspace = true }`. + +- [ ] **Step 3:** Write `app-demo-cli/src/main.rs` mirroring the generated `templates/cli/src/main.rs.hbs` pattern — the four downstream built-ins, no custom subcommands yet. `#[command(name = "app-demo-cli", about = "app-demo edge CLI")]`. + +- [ ] **Step 4:** Write `tests/help.rs`: `Args::try_parse_from(["app-demo-cli", "--help"])` returns the clap help error (not a panic). Since `Args` is private to `main.rs`, instead spawn the built binary: `assert_cmd`-style or `std::process::Command::new(env!("CARGO_BIN_EXE_app-demo-cli")).arg("--help")` exits 0 and stdout contains `build`, `deploy`, `new`, `serve`. + +- [ ] **Step 5: Run** `cd examples/app-demo && cargo test -p app-demo-cli` — expect PASS. + +### Task 1.6: External-consumer integration test + +**Files:** + +- Create: `crates/edgezero-cli/tests/lib_consumer.rs` + +- [ ] **Step 1: Write the test:** `use edgezero_cli::{BuildArgs, run_build};` — construct `let mut a = BuildArgs::default(); a.adapter = "fastly".into();`, write a minimal `edgezero.toml` into a `tempfile::TempDir`, set `EDGEZERO_MANIFEST`, call `run_build(&a)`, assert `Ok` (mirror the existing `handle_build_executes_manifest_command` test's manifest fixture). + + **Env-mutation guard (required).** `EDGEZERO_MANIFEST` is process-global; concurrent tests mutating it flake. Two rules: (a) restore the variable with an RAII guard — copy the `EnvOverride` struct from `edgezero-cli`'s existing `main.rs`/`lib.rs` tests (it saves the prior value in `new` and restores it in `Drop`); (b) keep `tests/lib_consumer.rs` to **exactly one** `#[test]`, so there is no in-binary parallelism on the env var. If a second env-touching test is ever added to this file, gate both with a shared `std::sync::Mutex` guard (the same `manifest_guard()` pattern the crate's unit tests use) — do not rely on `--test-threads=1`. + +- [ ] **Step 2: Run** `cargo test -p edgezero-cli --test lib_consumer` — expect PASS. This proves the public API is usable from outside the crate. + +### Task 1.7: Stage-1 documentation + commit + +**Files:** + +- Modify: `docs/guide/cli-reference.md`, `docs/guide/getting-started.md`, `CLAUDE.md` + +- [ ] **Step 1:** In `cli-reference.md` rename `dev` → `demo` and add a short "Building your own CLI" section pointing at the `edgezero-cli` library + the `-cli` scaffold. In `getting-started.md` note that `edgezero new` now also scaffolds `-cli`. In `CLAUDE.md` change the `dev` invocation example to `demo`. + +- [ ] **Step 2: Run the full gate** (the five commands in "The full gate" above) plus `cd examples/app-demo && cargo test`. All green. + +- [ ] **Step 3: Commit:** + +```bash +git add crates/edgezero-cli examples/app-demo docs/guide/cli-reference.md docs/guide/getting-started.md CLAUDE.md +git commit -m "Extensible edgezero-cli library + generator + app-demo-cli; rename dev->demo" +``` + +--- + +# Stage 2 — Manifest + runtime rewrite (atomic, all four adapters) + +Spec §8, §6.6, §6.7, §6.9. This is the largest stage and the review hotspot. Hard cutoff — legacy store schema is removed outright. + +## Design inputs added post-review — resolve in the Stage 2 design pass + +Two requirements surfaced after Stage 1 review. They revise the manifest +model and **must be reconciled with the §8 multi-store design before +implementing** — do not bolt them on piecemeal: + +- **A downstream binary must build without an `edgezero.toml` present.** + Manifest/store config reaches the runtime through the `App` / `Hooks` + type — macro-baked when `app!` is used, programmatic defaults otherwise — + never a runtime `include_str!` of a manifest file. `run_app` must not + hard-require a manifest file to exist at compile time. (Today every + adapter entrypoint does `include_str!("../../../edgezero.toml")`, which + breaks any downstream project that builds its `App` without a manifest.) +- **`edgezero.toml` defines only non-adapter-specific (portable) config.** + Routes, app metadata, logical store declarations, and env-var + declarations live in `edgezero.toml`; adapter-specific config lives in + the adapter layer (per-adapter manifests / adapter crate config), not the + shared manifest. + +### Task 2.1: Portable manifest schema + +**Files:** `crates/edgezero-core/src/manifest.rs` (+ `manifest_definitions.rs`) + +Rewrite `ManifestStores` to the §6.6 portable schema: `[stores.]` +carries only `ids` (non-empty) and `default` (required when +`ids.len() > 1`, else `ids[0]`). Remove the `[adapters.*]` store and +runtime tables from the manifest model. Pre-rewrite fields +(`[stores.] name`, `[stores.config.defaults]`, +`[adapters.*.stores.*]`) → hard load error pointing at +`docs/guide/manifest-store-migration.md`. + +- [ ] Tests: round-trip; non-empty ids; default required when >1 id; + legacy manifest → hard error with migration message. +- [ ] Full gate. + +### Task 2.2: `EDGEZERO__*` environment-config layer + +**Files:** `crates/edgezero-core/src/env_config.rs` (new) + +Parse `EDGEZERO__`-prefixed env vars (`__` = key-path separator) into an +adapter runtime-config value: per-store `NAME` + free-form tuning, bind +host/port, logging level. Absent vars resolve to the §6.6 defaults (a +store's platform name defaults to its logical id). + +- [ ] Tests: nesting, defaults, store-name resolution; zero-env case. +- [ ] Full gate. + +### Task 2.3: `app!` macro bakes portable config into `Hooks` + +**Files:** `crates/edgezero-macros/src/app.rs`, `crates/edgezero-core/src/app.rs` + +The `app!` macro reads `edgezero.toml` at compile time and codegens the +logical store registry + id-keyed `ConfigStoreMetadata` into the +generated `App` / `Hooks` type, alongside routing. `Hooks` exposes the +portable store config. The macro and manifest stay optional — an `App` +built without the macro supplies empty defaults, so a downstream binary +compiles with no `edgezero.toml`. + +- [ ] Tests: `app!` macro metadata-registry test. +- [ ] Full gate. + +### Task 2.4: `run_app::()` drops `manifest_src` (all four adapters) + +**Files:** `run_app` in each adapter crate; the four entrypoint templates; `edgezero-cli/src/demo_server.rs` + +`run_app` takes no manifest string. It reads portable config from `A` +and layers `EDGEZERO__*` env config (Task 2.2) for adapter-specific +values. Remove every `include_str!("edgezero.toml")`; update the four +adapter entrypoint templates and `demo_server.rs`. + +- [ ] Tests: `run_app` builds and runs with no manifest file / zero env. +- [ ] Full gate. + +### Task 2.5: Async `ConfigStore`, `KvError` variants, bound handles, id-keyed context + +**Files:** `config_store.rs`, `key_value_store.rs`, `secret_store.rs`, `context.rs`, `error.rs` + +`ConfigStore::get` → `async` (`#[async_trait(?Send)]`). Add +`KvError::Unsupported` and `KvError::LimitExceeded` with 5xx-class +`EdgeError` mappings. Add `BoundKvStore` / `BoundConfigStore` / +`BoundSecretStore` and a `StoreRegistry`; `RequestContext` accessors +become id-keyed with `_default()` helpers. + +- [ ] Tests: async config round-trip; new `KvError` mappings; registry. +- [ ] Full gate. + +### Task 2.6: Adapter store registries — all four adapters + +**Files:** `{config_store,key_value_store,secret_store}.rs` in each adapter crate + +Each adapter builds a `StoreRegistry` keyed by logical id, platform +names from `EDGEZERO__STORES__*`. axum: local KV + local-file config + +env secrets. cloudflare: KV registry, config `[vars]`→KV async, worker +secrets. fastly: KV / config / secret registries. spin: `SpinKvStore` +(labels from env, `max_list_keys`), `SpinConfigStore` (`.`→`__`), +`SpinSecretStore`. + +- [ ] Tests: id-keyed contract factories ×4; cross-adapter named KV; + cloudflare config-from-KV; spin `.`→`__`; spin TTL → `Unsupported`; + spin listing-cap pagination. +- [ ] Full gate incl. per-adapter wasm `--test contract`. + +### Task 2.7: `Kv` / `Secrets` / `Config` extractors + +**Files:** `crates/edgezero-core/src/extractor.rs` + +Refactor `Kv` / `Secrets` to `default()` / `named()`; add the `Config` +extractor (§6.9). + +- [ ] Tests: extractor tests for all three. +- [ ] Full gate. + +### Task 2.8: Migrate `app-demo`, templates, docs + +**Files:** `examples/app-demo/edgezero.toml` + handlers + adapter run config; `templates/root/edgezero.toml.hbs`; `docs/guide/manifest-store-migration.md`; affected `docs/guide/` pages + +Rewrite `examples/app-demo/edgezero.toml` and +`templates/root/edgezero.toml.hbs` to the portable schema (≥2 KV ids, +one config id, one secrets id). Migrate app-demo handlers for the +store-accessor change only. Publish `manifest-store-migration.md`; +update affected `docs/guide/` pages. + +- [ ] Full gate + `cd examples/app-demo && cargo test` + docs CI. + +### Task 2.9: Stage-2 ship gate + commit + +- [ ] Run the full gate (all five CI gates + per-adapter wasm contract + tests + `examples/app-demo` + the `generated_project_builds` + opt-in test). +- [ ] Verify an adapter binary builds and runs with no `edgezero.toml` + and zero env vars (defaults). +- [ ] Commit. + +--- + +# Stage 3 — App-config schema, derive macro, env-overlay loader + +Spec §9, §6.7, §6.8, §6.10. + +### Task 3.1: `edgezero-core::app_config` module + +**Files:** + +- Create: `crates/edgezero-core/src/app_config.rs`; Modify: `crates/edgezero-core/src/lib.rs` + +- [ ] **Step 1: Write failing tests:** valid `.toml` loads; missing file, bad TOML, validator failure each produce a distinct `AppConfigError`. + +- [ ] **Step 2: Run** — FAIL. + +- [ ] **Step 3: Implement** per §4. Types: `AppConfigMeta` trait with `const SECRET_FIELDS: &'static [SecretField]`; `SecretField { name, kind }`; `SecretKind { KeyInDefault, StoreRef }`; `AppConfigError`; `AppConfigLoadOptions { env_overlay: bool }` with `Default` = `{ env_overlay: true }`. + + Loader API — **one consistent shape, no hidden bool param.** The simple functions apply the env overlay (the default); the `_with_options` variants take `AppConfigLoadOptions` explicitly: + - `load_app_config(path, app_name) -> Result` — overlay on. + - `load_app_config_with_options(path, app_name, opts: &AppConfigLoadOptions) -> Result`. + - `load_app_config_raw(path, app_name) -> Result` — overlay on. + - `load_app_config_raw_with_options(path, app_name, opts: &AppConfigLoadOptions) -> Result`. + + The simple functions delegate to the `_with_options` form with `AppConfigLoadOptions::default()`. `--no-env` (Tasks 4.1 / 7.1) calls the `_with_options` variant with `env_overlay: false`. `load_app_config*` parses the file's top-level table, applies the env overlay when `opts.env_overlay`, then (typed) deserializes + `validate()`. `pub mod app_config;` in `lib.rs`. + +- [ ] **Step 4: Run** — PASS. + +### Task 3.2: `AppConfig` derive macro + +**Files:** + +- Create: `crates/edgezero-macros/src/app_config.rs`; Modify: `crates/edgezero-macros/src/lib.rs`, `crates/edgezero-core/src/lib.rs` + +**Macro availability — chosen route: re-export through `edgezero-core`.** +`edgezero-core` already re-exports the `action` and `app` proc-macros +from `edgezero-macros` (handlers do `use edgezero_core::action`). +`AppConfig` follows the _same_ route: the derive is defined in +`edgezero-macros` and **re-exported from `edgezero-core`** so consumers +write `use edgezero_core::AppConfig`. Consequence: a crate that derives +`AppConfig` needs **only `edgezero-core`** as a dependency for the +macro — no direct `edgezero-macros` dependency. (`#[derive(Validate)]` +and `#[validate(...)]` still need the `validator` crate directly — see +Task 3.4 / 3.5.) + +- [ ] **Step 1a: Add the `trybuild` dev-dependency.** Compile-fail tests need `trybuild`; `crates/edgezero-macros/Cargo.toml` currently has only `tempfile` under `[dev-dependencies]`. Add `trybuild = "1"` to `[dev-dependencies]` there (and to `[workspace.dependencies]` in the root `Cargo.toml` if the workspace pins dev-deps centrally — check first and follow the existing convention). + +- [ ] **Step 1b: Write macro tests** in `crates/edgezero-macros/tests/app_config_derive.rs`: empty `SECRET_FIELDS` with no annotation; one `KeyInDefault` from `#[secret]`; one `StoreRef` from `#[secret(store_ref)]`; both kinds. Add a `trybuild` compile-fail harness — `let t = trybuild::TestCases::new(); t.compile_fail("tests/ui/*.rs");` — with one `tests/ui/*.rs` fixture per rejected case: `#[secret]` + `#[serde(flatten)]`, `#[secret]` + `#[serde(rename)]`, `#[secret(bogus)]`, `#[secret]` on a non-scalar field. Each fixture has a matching `.stderr` golden file (generate with `TRYBUILD=overwrite` once the `compile_error!` messages are final). + +- [ ] **Step 2: Run** — FAIL. + +- [ ] **Step 3: Implement.** `#[proc_macro_derive(AppConfig, attributes(secret))]` in `edgezero-macros/src/lib.rs` delegating to `app_config::derive`. The impl scans fields for `#[secret]` / `#[secret(store_ref)]`, enforces the §6.7 constraints with `compile_error!`, and emits `impl ::edgezero_core::app_config::AppConfigMeta` with the `SECRET_FIELDS` array (Rust field name verbatim). **Also re-export it from `edgezero-core/src/lib.rs`** — `pub use edgezero_macros::AppConfig;` — next to the existing `action` / `app` re-exports, so downstream code uses `edgezero_core::AppConfig`. + +- [ ] **Step 4: Run** — PASS. + +### Task 3.3: Env-overlay resolution + +**Files:** + +- Modify: `crates/edgezero-core/src/app_config.rs` + +- [ ] **Step 1: Write tests:** `APP_DEMO__GREETING` overrides a top-level key; `APP_DEMO__SERVICE__TIMEOUT_MS` overrides a nested key; type coercion against the existing TOML value; a non-parseable value errors; two sibling keys mapping to the same env segment errors; `load_app_config_with_options` with `AppConfigLoadOptions { env_overlay: false }` skips the overlay entirely. + +- [ ] **Step 2: Run** — FAIL. + +- [ ] **Step 3: Implement** per §6.10: walk the parsed root table; for each existing key compute `__
__…__` (uppercase, `-`→`_`, `__` separators); look up the env var; coerce to the existing value's type; reject ambiguous sibling mappings. + +- [ ] **Step 4: Run** — PASS. + +### Task 3.4: Generator templates for app-config + +**Files:** + +- Create: `crates/edgezero-cli/src/templates/app/.toml.hbs`, `crates/edgezero-cli/src/templates/core/src/config.rs.hbs` +- Modify: `crates/edgezero-cli/src/templates/core/Cargo.toml.hbs`, `crates/edgezero-cli/src/generator.rs`, `scaffold.rs` + +- [ ] **Step 1: Add a `NameUpperCamel` key to the generator Handlebars context.** The config templates name the struct `{{NameUpperCamel}}Config` (e.g. `my-app` → `MyAppConfig`), and the CLI template in stage 8 reuses the same key. The generator's Handlebars data today exposes only `name`, `proj_core`, `proj_core_mod`, `proj_mod` (`generator.rs`). + + Derivation — **must yield a valid Rust type identifier** (the result is used as `{{NameUpperCamel}}Config`, a `struct` name): + 1. Start from the **sanitized** crate name (reuse `sanitize_crate_name` from `scaffold.rs`, so it stays consistent with the crate name). + 2. Split on `-` and `_`; drop empty segments (this naturally absorbs a leading `_` that `sanitize_crate_name` may have inserted). + 3. Upper-case the first character of each segment, lower-case the rest; join. + 4. **If the result is empty, or its first character is not an ASCII letter** (e.g. the project name started with a digit, giving something like `123App`), prefix it with `App`. A Rust type name cannot begin with a digit. + + Insert the result under the context key `NameUpperCamel`. Add a unit test covering: `my-app` → `MyApp`; `foo` → `Foo`; `a_b-c` → `ABC`; `_foo` → `Foo` (empty leading segment dropped); `123-app` → `App123App` (digit-leading → `App` prefix). This key lands here in stage 3 because `config.rs.hbs` is its first consumer; stage 8's `templates/cli/` reuses it. + +- [ ] **Step 2:** `app/.toml.hbs` — top-level keys (`greeting`, `api_token`, etc.) and a nested `[service]` table; no `[config]` wrapper. `core/src/config.rs.hbs` — `{{NameUpperCamel}}Config` with `#[derive(serde::Deserialize, serde::Serialize, validator::Validate, edgezero_core::AppConfig)]` + `#[serde(deny_unknown_fields)]`, a `greeting` field, a nested `service: ServiceConfig` field carrying `#[validate(nested)]`, **one plain `#[secret]` field**, and a commented-out `#[secret(store_ref)]` example (§6.8 — the generated template does not include `store_ref` live). + +- [ ] **Step 3: Update `templates/core/Cargo.toml.hbs` deps + the workspace-dep seed.** The generated config struct needs `validator` (for `#[derive(Validate)]` / `#[validate(...)]`) and `serde` with the `derive` feature. The `AppConfig` derive comes via the `edgezero-core` re-export (Task 3.2) — the core template already depends on `edgezero-core`, so **no `edgezero-macros` dependency is added**. Add `validator = { workspace = true }` to `templates/core/Cargo.toml.hbs` (it currently lacks it); confirm `serde` is present with `features = ["derive"]`. Because the generated project is itself a workspace, a `workspace = true` dep only resolves if the generated **root** `Cargo.toml` lists it: add `validator` to the generator's workspace-dependency seed (the `seed_workspace_dependencies` function / data in `generator.rs` — confirm the exact name by reading the file; it seeds the generated root `[workspace.dependencies]` and does **not** include `validator` today). Match whatever version-pin the seed already uses for `serde` etc. + +- [ ] **Step 4:** Render both new templates in `generate_new`; register them in `scaffold.rs`. + +- [ ] **Step 5: Write/extend the generator test** to assert `.toml` and `-core/src/config.rs` are produced, the struct name is `{{NameUpperCamel}}Config` for the test project name, **and** that the generated `-core` builds (the seeded `validator` dep resolves and `edgezero_core::AppConfig` is in scope) — `cargo check -p -core` in the scaffolded project. + +- [ ] **Step 6: Run** the generator test — PASS. + +### Task 3.5: `app-demo` app-config + commit + +**Files:** + +- Create: `examples/app-demo/app-demo.toml`, `examples/app-demo/crates/app-demo-core/src/config.rs` +- Modify: `examples/app-demo/crates/app-demo-core/src/lib.rs`, `examples/app-demo/crates/app-demo-core/Cargo.toml` (verify deps), `docs/guide/configuration.md`, `getting-started.md` + +- [ ] **Step 1:** Write `app-demo.toml` — top-level `greeting`, `api_token` (a `#[secret]` value), `vault` (a `#[secret(store_ref)]` value = the single secrets id); a `[feature]` sub-table containing `new_checkout` (mirrors the dotted config-store key `feature.new_checkout` the handler reads, and the per-adapter `feature__new_checkout` Spin seed); a `[service]` table with `timeout_ms`. No `[config]` wrapper. Write `app-demo-core/src/config.rs` — `AppDemoConfig` with the §6.8 shape (nested `FeatureConfig` + `ServiceConfig` carrying `#[validate(nested)]`, one `#[secret]`, one `#[secret(store_ref)]`), deriving `serde::{Deserialize, Serialize}`, `validator::Validate`, `edgezero_core::AppConfig`. Export it from `lib.rs`. **Verify `app-demo-core/Cargo.toml` deps:** it must have `edgezero-core` (for the `AppConfig` re-export), `validator`, and `serde` with `derive`. `app-demo-core` already depends on all three today — confirm and add any that are somehow missing. No `edgezero-macros` dependency is needed (macro comes via the `edgezero-core` re-export, Task 3.2). + +- [ ] **Step 2: Write a round-trip test** in `app-demo-core`: `load_app_config::` against `app-demo.toml` succeeds; `AppDemoConfig::SECRET_FIELDS` has the expected two entries; an env var overrides the nested value. + +- [ ] **Step 3:** Update `configuration.md` (app-config file + env overlay) and `getting-started.md` (generator now emits `.toml`). + +- [ ] **Step 4: Run** the full gate. **Commit:** `git commit -m "App-config schema, #[derive(AppConfig)] macro, env-overlay loader"` + +--- + +# Stage 4 — `config validate` command + +Spec §10. New: `ConfigValidateArgs`, `run_config_validate`, `run_config_validate_typed`. + +### Task 4.1: `config validate` implementation + +**Files:** + +- Modify: `crates/edgezero-cli/src/args.rs` (add `ConfigValidateArgs` + a `ConfigCmd` subcommand enum), `crates/edgezero-cli/src/lib.rs` +- Create: `crates/edgezero-cli/src/config.rs` + +- [ ] **Step 1: Write failing tests** with fixtures for each failure mode (§10): valid passes; bad TOML; unknown field (struct with `deny_unknown_fields`); type mismatch; validator-rule failure; empty `#[secret]`; `#[secret(store_ref)]` value not in `[stores.secrets].ids`; missing per-adapter mapping; the three Spin checks (key syntax, collision — typed-only, component discovery). + +- [ ] **Step 2: Run** — FAIL. + +- [ ] **Step 3: Implement** `ConfigValidateArgs { manifest, app_config, strict, no_env }` (`#[derive(clap::Args, Default, Debug)] #[non_exhaustive]`). `run_config_validate` (raw) and `run_config_validate_typed` in `config.rs`. Raw does TOML + manifest checks + Spin key-syntax + component discovery; typed adds deserialize + `validate()` + secret checks + the collision check. Both run manifest `ManifestLoader` validation; `--strict` adds capability completeness + handler-path checks. + +- [ ] **Step 4: Run** — PASS. + +### Task 4.2: Wire `config` into the default `edgezero` binary + +**Files:** + +- Modify: `crates/edgezero-cli/src/args.rs` (`Command` enum), `crates/edgezero-cli/src/main.rs` + +The spec (§1, §8) requires the new subcommands to be available on the +**default `edgezero` binary**, not only on `app-demo-cli`. The default +binary has no app-config struct, so it uses the **raw** functions. + +- [ ] **Step 1:** Add `Config(ConfigCmd)` to the default `edgezero-cli` `Command` enum in `args.rs` (the same `ConfigCmd` subcommand enum from Task 4.1; `ConfigCmd::Validate(ConfigValidateArgs)` for now, `Push` added in stage 7). + +- [ ] **Step 2:** Add the dispatch arm in `main.rs`: `Command::Config(ConfigCmd::Validate(a)) => exit_on_err(edgezero_cli::run_config_validate(&a))` — the **raw** validator (the default binary has no `C`). + +- [ ] **Step 3: Write a test** (in `args.rs` or an integration test): `Args::try_parse_from(["edgezero", "config", "validate", "--strict"])` parses to `Command::Config(ConfigCmd::Validate(_))`; and `cargo run -p edgezero-cli -- --help` lists `config`. + +- [ ] **Step 4: Run** `cargo test -p edgezero-cli && cargo build -p edgezero-cli && ./target/debug/edgezero config validate --help` — expect PASS / the subcommand help. + +### Task 4.3: Wire `app-demo-cli config validate` + docs + commit + +**Files:** + +- Modify: `examples/app-demo/crates/app-demo-cli/Cargo.toml`, `examples/app-demo/crates/app-demo-cli/src/main.rs`, `docs/guide/cli-reference.md` + +- [ ] **Step 1: Add the `app-demo-core` dependency.** `app-demo-cli` is about to reference `AppDemoConfig`, which lives in `app-demo-core` (created in stage 3, Task 3.5). Its `Cargo.toml` so far has only `edgezero-cli` / `clap` / `log` (Task 1.5). Add `app-demo-core = { path = "../app-demo-core" }` to `app-demo-cli/Cargo.toml` (path dep within the `examples/app-demo` workspace). + +- [ ] **Step 2:** Add a `Config(ConfigCmd)` arm to `app-demo-cli`'s `Cmd` enum with `ConfigCmd { Validate(ConfigValidateArgs) }` (push added in stage 7). `use app_demo_core::AppDemoConfig;` and dispatch `Validate` to `edgezero_cli::run_config_validate_typed::` — the **typed** validator (`app-demo-cli` knows `AppDemoConfig`). + +- [ ] **Step 3:** Document `config validate` in `cli-reference.md` — note the default `edgezero` binary runs the raw validator, downstream CLIs the typed one. + +- [ ] **Step 4: Run** the full gate; `cd examples/app-demo && cargo run -p app-demo-cli -- config validate --strict` exits 0; `./target/debug/edgezero config validate --strict` (raw path) also exits 0 against a fixture. **Commit:** `git commit -m "config validate command (raw + typed)"` + +--- + +# Stage 5 — `auth` command (adapter-trait dispatch) + +Spec §11, §6.1. + +### Task 5.1: Extend `AdapterAction` with the auth variants + +The original sketch placed a `CommandRunner` indirection inside +`edgezero-cli`. That duplicated the adapter-name knowledge `build` / +`deploy` / `serve` deliberately keep out of the CLI — they read +commands from the manifest first, then fall back to the adapter +crate's `Adapter::execute`. Auth follows the same path. + +**Files:** + +- Modify: `crates/edgezero-adapter/src/registry.rs` (`AdapterAction` enum) +- Modify: each `crates/edgezero-adapter-*/src/cli.rs` (`Adapter::execute` match) +- Modify: `crates/edgezero-core/src/manifest.rs` (`ManifestAdapterCommands` fields) +- Modify: `crates/edgezero-cli/src/adapter.rs` (`Action` enum + `manifest_command` lookup) + +- [ ] **Step 1:** Extend `AdapterAction` with `AuthLogin` / `AuthLogout` / `AuthStatus`. +- [ ] **Step 2:** Each `edgezero-adapter-*/src/cli.rs` adds match arms for the new variants and implements its own dispatch (cloudflare shells to `wrangler login/logout/whoami`, fastly to `fastly profile create/delete/list`, spin to `spin cloud login/logout/info`, axum no-ops). +- [ ] **Step 3:** Extend `ManifestAdapterCommands` with `auth_login` / `auth_logout` / `auth_status` (serde-renamed to `auth-login` / `auth-logout` / `auth-status` on disk), and `edgezero-cli/src/adapter.rs::manifest_command` to look them up. +- [ ] **Step 4: Run** — workspace compiles, no auth dispatch yet. + +### Task 5.2: `auth` command + docs + commit + +**Files:** + +- Modify: `crates/edgezero-cli/src/args.rs` (`AuthArgs`, `AuthSub`), `lib.rs` +- Create: `crates/edgezero-cli/src/auth.rs` +- Modify: `examples/app-demo/crates/app-demo-cli/src/main.rs`, `docs/guide/cli-reference.md` + +- [ ] **Step 1: Write tests** mirroring the existing `run_build_executes_manifest_command` pattern: configure `[adapters.fastly.commands].auth-login = "echo logged in"` (etc.) in a fixture manifest, call `run_auth(&AuthArgs { sub: AuthSub::Login { adapter: "fastly" } })`, assert success. Add an "unknown adapter errors" case. + +- [ ] **Step 2: Run** — FAIL (no `run_auth` yet). + +- [ ] **Step 3: Implement.** `AuthArgs { sub: AuthSub }` — `#[derive(clap::Args, Debug)] #[non_exhaustive]`, **no `Default`** (§6.11). `AuthSub { Login{adapter}, Logout{adapter}, Status{adapter} }`. `crates/edgezero-cli/src/auth.rs::run_auth` is a five-line delegate to `adapter::execute(name, Action::Auth{Login,Logout,Status}, manifest, &[])`. No `CommandRunner`; no `MockCommandRunner`; no hard-coded `(adapter, sub) → (program, args)` table in the CLI crate. + +- [ ] **Step 4: Run** — PASS. Document `auth` in `cli-reference.md` (built-ins + per-project override via `[adapters..commands].auth-{login,logout,status}`). + +- [ ] **Step 5: Wire both binaries.** Add `Auth(AuthArgs)` to the **default `edgezero-cli` `Command` enum** (`args.rs`) and a dispatch arm in `main.rs`: `Command::Auth(a) => exit_on_err(edgezero_cli::run_auth(&a))`. Also add `Auth(AuthArgs)` to `app-demo-cli`'s `Cmd` enum and dispatch it to `run_auth`. Write a test that `Args::try_parse_from(["edgezero", "auth", "login", "--adapter", "cloudflare"])` parses and that `edgezero --help` lists `auth`. + +- [ ] **Step 6: Run** the full gate; `./target/debug/edgezero auth --help` shows the `login`/`logout`/`status` subcommands. **Commit:** `git commit -m "auth command (adapter-trait dispatch, no hardcoded table)"` + +--- + +# Stage 6 — `provision` command + +Spec §12, §13 (Fastly contract). + +### Task 6.1: `provision` implementation + +**Files:** + +- Modify: `crates/edgezero-cli/src/args.rs` (`ProvisionArgs`), `lib.rs` +- Create: `crates/edgezero-cli/src/provision.rs` + +- [ ] **Step 1: Write tests** following Stage 5's pattern: each adapter crate's tests own the per-(adapter, kind) writeback assertions (temp-fixture writeback for `wrangler.toml`, `fastly.toml`, and the Spin `key_value_stores` array in `spin.toml`; axum no-op). The CLI test asserts `run_provision` dispatches to the right adapter and that `--dry-run` short-circuits without spawning. + +- [ ] **Step 2: Run** — FAIL. + +- [ ] **Step 3: Implement** `ProvisionArgs { manifest, adapter, dry_run }`. Extend `AdapterAction` with a `Provision` variant (or a small `ProvisionKind` payload if per-store-kind dispatch is needed). Each adapter crate's `Adapter::execute` implements its own §12 behaviour: axum no-op; cloudflare `wrangler kv namespace create` + `wrangler.toml` `[[kv_namespaces]]` writeback; fastly `fastly -store create` + `[setup.*]`/`[local_server.*]` `fastly.toml` writeback; spin KV-label `spin.toml` writeback only (component resolved per §6.7). CLI's `provision.rs` is a thin args→action delegate to `adapter::execute`, same shape as `auth.rs`. + +- [ ] **Step 4: Run** — PASS. Document `provision` in `cli-reference.md`. + +- [ ] **Step 5: Wire both binaries.** Add `Provision(ProvisionArgs)` to the **default `edgezero-cli` `Command` enum** (`args.rs`) and a dispatch arm in `main.rs`: `Command::Provision(a) => exit_on_err(edgezero_cli::run_provision(&a))`. Also add `Provision(ProvisionArgs)` to `app-demo-cli`'s `Cmd` enum, dispatched to `run_provision`. Write a test that `Args::try_parse_from(["edgezero", "provision", "--adapter", "cloudflare", "--dry-run"])` parses and that `edgezero --help` lists `provision`. + +- [ ] **Step 6: Run** the full gate; `./target/debug/edgezero provision --adapter cloudflare --dry-run` runs. **Commit:** `git commit -m "provision command (cloudflare/fastly/spin writeback, axum no-op)"` + +--- + +# Stage 7 — `config push` command + +Spec §13, §6.4, §6.5. + +### Task 7.1: `config push` implementation + +**Files:** + +- Modify: `crates/edgezero-cli/src/args.rs` (`ConfigPushArgs`, extend `ConfigCmd`), `lib.rs`, `crates/edgezero-cli/src/config.rs` + +- [ ] **Step 1: Write tests:** typed + raw; per-adapter mock-runner/fixture with golden payloads; secret fields absent; missing native-manifest id (cloudflare) → clear error; Spin `.`→`__` translation; Spin writes both `spin.toml` tables; Spin component-resolution failure errors; `--store` selection; `--dry-run` invokes nothing; the §13 "validate passes, push serialization fails" cases; the Spin `spin.toml` golden test (strongest-first validation ladder, §13). + +- [ ] **Step 2: Run** — FAIL. + +- [ ] **Step 3: Implement** `ConfigPushArgs { manifest, adapter, store, app_config, no_env, dry_run }`. `run_config_push` / `run_config_push_typed`: strict pre-flight validation, load app-config, flatten + serialize per §6.4/§6.5 (skip `SECRET_FIELDS`), resolve target id, push per the §13 per-adapter table (axum local JSON file; cloudflare `wrangler kv bulk put`; fastly `config-store-entry create`; spin both `spin.toml` tables). + +- [ ] **Step 4: Run** — PASS. + +### Task 7.2: Wire `config push` into both binaries + docs + commit + +**Files:** + +- Modify: `crates/edgezero-cli/src/args.rs` (`ConfigCmd`), `crates/edgezero-cli/src/main.rs`, `examples/app-demo/crates/app-demo-cli/src/main.rs`, `docs/guide/cli-reference.md`, `configuration.md` + +- [ ] **Step 1: Default `edgezero` binary.** Extend the `ConfigCmd` enum (defined in Task 4.1, used by the default `Command::Config` arm from Task 4.2) with `Push(ConfigPushArgs)`. Add the dispatch arm in `main.rs`: `Command::Config(ConfigCmd::Push(a)) => exit_on_err(edgezero_cli::run_config_push(&a))` — the **raw** push. + +- [ ] **Step 2: `app-demo-cli`.** Extend `app-demo-cli`'s `ConfigCmd` with `Push(ConfigPushArgs)`; dispatch to `run_config_push_typed::` — the **typed** push. + +- [ ] **Step 3:** Write a test that `Args::try_parse_from(["edgezero", "config", "push", "--adapter", "axum"])` parses to `Command::Config(ConfigCmd::Push(_))` and that `edgezero config --help` lists both `validate` and `push`. + +- [ ] **Step 4:** Document `config push` in `cli-reference.md` (note raw vs typed per binary); cross-reference from `configuration.md`. + +- [ ] **Step 5: Run** the full gate. **Commit:** `git commit -m "config push command (per-adapter, secret-skipping, env overlay)"` + +--- + +# Stage 8 — `app-demo` integration polish + docs audit + +Spec §15, §6.12. + +### Task 8.1: Full `app-demo` capability exercise + +**Files:** + +- Modify: `examples/app-demo/crates/app-demo-cli/src/main.rs`, `examples/app-demo/crates/app-demo-core/src/handlers.rs`, `examples/app-demo/edgezero.toml`, `examples/app-demo/app-demo.toml`, `examples/app-demo/crates/app-demo-adapter-spin/spin.toml` + +- [ ] **Step 1:** Confirm `app-demo-cli`'s `Cmd` has the four downstream built-ins + `Auth` + `Provision` + `Config(Validate|Push)`. Ensure handlers exercise: two named KV ids (`sessions`, `cache`) via `Kv::named`; async `config_store_default().get("greeting")`; the nested `service.timeout_ms`; both secret forms. Add the manual Spin secret-variable declarations to `app-demo-adapter-spin/spin.toml` (`secret = true`, bound under `[component..variables]`). + +- [ ] **Step 2: Write integration tests** in `app-demo`: `config validate --strict` exits 0; `config push --adapter axum` writes `.edgezero/local-config-app_config.json` and a running demo server returns `greeting` on `/config/greeting`; `config push --adapter spin --dry-run` **prints** the would-be `__`-encoded keys and the would-be content of both `spin.toml` tables — and the test asserts the on-disk `spin.toml` is **unchanged** (dry-run never mutates); an env-override test asserts `APP_DEMO__SERVICE__TIMEOUT_MS` takes effect. + + **Demo-server lifecycle (required, to keep the e2e test non-flaky):** + - **Port:** do not hard-code `8787`. Bind an ephemeral port — either bind `127.0.0.1:0` and read back the assigned port, or pick a free port in the test and pass it to the server. Concurrent CI jobs must not collide. + - **Readiness:** after spawning the server, poll `GET /` (or a health route) with a short retry loop — e.g. up to ~50 attempts, 100ms apart (~5s budget) — and only proceed once a request succeeds. Never use a bare `sleep`. + - **Teardown:** spawn the server as a child process and kill it in an RAII guard (a struct that holds the `Child` and calls `.kill()` + `.wait()` in `Drop`), so it is reaped even when an assertion fails or panics. Also clean up the `.edgezero/local-config-*.json` files the test wrote. + +- [ ] **Step 3: Run** `cd examples/app-demo && cargo test` — PASS. + +### Task 8.2: Upgrade the generated `-cli` template to the full command set + +**Files:** + +- Modify: `crates/edgezero-cli/src/templates/cli/Cargo.toml.hbs`, `crates/edgezero-cli/src/templates/cli/src/main.rs.hbs`, `crates/edgezero-cli/src/generator.rs` (tests) + +Stage 1 created the `-cli` template with only the four downstream +built-ins (`auth` / `provision` / `config` did not exist yet). Now that +stages 4–7 have landed them, a freshly-scaffolded project must expose +the full command surface (spec §1: downstream CLIs reuse the +post-effort built-ins). + +- [ ] **Step 1: Add the core-crate dependency to the CLI template.** The full-command template references the typed config functions with `{{NameUpperCamel}}Config`, which lives in the generated `{{name}}-core` crate. The `templates/cli/Cargo.toml.hbs` from stage 1 depends only on `edgezero-cli` / `clap` / `log` — add `{{name}}-core = { path = "../{{name}}-core" }` (path dep within the generated workspace). Without this the scaffolded CLI will not compile. + +- [ ] **Step 2:** Update `templates/cli/src/main.rs.hbs` so the generated `Cmd` enum lists **all seven** commands: `Build`, `Deploy`, `New`, `Serve`, `Auth`, `Provision`, `Config(ConfigCmd { Validate, Push })`. Dispatch `build/deploy/new/serve/auth/provision` to the raw `edgezero_cli::run_*`. The `use` statement must reference the core crate's **Rust module name**, not the package name — use `use {{proj_core_mod}}::{{NameUpperCamel}}Config;` (the generator already exposes `proj_core_mod`, the hyphen-to-underscore module form; `{{name}}_core` would render `my-app_core` for `my-app`, which is invalid Rust). Dispatch the `Config` arm to the **typed** `run_config_validate_typed::<{{NameUpperCamel}}Config>` / `run_config_push_typed::<{{NameUpperCamel}}Config>` — a generated project has its own core config struct (from the Task 3.4 `config.rs.hbs` template), so the scaffold wires the typed path, matching how `app-demo-cli` does it. + +- [ ] **Step 3:** Extend the generator structure test (from Task 1.4 / 3.4): the scaffolded `-cli/Cargo.toml` depends on `-core`; `-cli/src/main.rs` contains `Auth`, `Provision`, and `Config` variants and references the typed config functions with the project's config type. + +- [ ] **Step 4: Run** the generator tests, then `cargo run -p edgezero-cli -- new --dir …` and `cargo check --workspace` in the generated project — the scaffolded CLI builds with all seven commands **and** resolves `{{NameUpperCamel}}Config` from its core crate. + +### Task 8.3: CI wiring for the `app-demo` loop + +**Files:** + +- Modify: `.github/workflows/test.yml` (or `scripts/run_tests.sh`) + +- [x] **Step 1:** CI now builds `app-demo` via a dedicated `cd examples/app-demo && cargo test --workspace --all-targets` step in `test.yml`, plus a parallel `cargo fmt`/`cargo clippy` pass in `format.yml`. The end-to-end axum loop is expressed **as a Rust integration test inside `app-demo`** (Task 8.1 `app-demo` integration test) rather than as raw shell in the workflow — the Rust test already owns ephemeral-port binding, the readiness poll, and RAII teardown (Task 8.1 step 2). The CI job then just needs `cargo test`; it does not hand-roll `start server / curl / kill` in YAML, which is where shell-based e2e steps go flaky. Kept off the wasm matrix — axum only, no live external calls. + +- [ ] **Step 2:** If any loop step must stay as a shell step in the workflow (e.g. invoking the built `app-demo-cli` binary), it must still: select a free port (not a hard-coded one), poll readiness before curl-ing, and `kill` the server in a `trap`/`always()` cleanup so a failed assertion never leaves an orphan process. Mirror the Task 8.1 lifecycle rules. + +- [ ] **Step 3: Run** the workflow logic locally to confirm the loop passes and leaves no orphan processes or `.edgezero/` artifacts. + +### Task 8.4: Walkthrough doc + documentation audit + commit + +**Files:** + +- Create: `docs/guide/cli-walkthrough.md`; Modify: `docs/.vitepress/config.mts`, any pages still stale + +- [ ] **Step 1:** Write `docs/guide/cli-walkthrough.md` — the full `myapp` loop (`new`, `auth`, `provision`, `config validate`, `config push`, `deploy`, `demo`), an env-override example, all four adapters, the manual Spin secret-variable `spin.toml` entries, the explicit `[adapters.spin.adapter].component` form. Add it + `manifest-store-migration.md` to the `config.mts` sidebar. + +- [ ] **Step 2: Documentation audit** (§6.12): `grep -rn` the `docs/` tree for stale references — old `[stores.*]` keys (`stores.config.defaults`, `[stores.kv] name`), the `dev` subcommand, the old singular store API (`config_store()` with no arg, `kv_handle`, `secret_handle`). Fix every hit. Confirm every page in the §6.12 table was updated and every page is in the sidebar. + +- [ ] **Step 3: Run the full gate** (the five commands in "The full gate" above), plus all three per-adapter wasm `--test contract` runs (Task 2.7 step 6), `cd examples/app-demo && cargo test`, and the docs ESLint/Prettier job. All green. + +- [ ] **Step 4: Commit:** `git commit -m "app-demo full-capability showcase + documentation audit"` + +--- + +## Self-review notes + +- **Spec coverage:** §7→C1, §8/§6.6/§6.7/§6.9→C2, §9/§6.8/§6.10→C3, §10→C4, §11/§6.1→C5, §12→C6, §13/§6.4/§6.5→C7, §15/§6.12→C8. §6.3 (feature gates) is honored throughout. §6.11 (`Default` on `*Args`) is in Tasks 1.1, 4.1, 5.2, 6.1, 7.1. §6.12 docs are in every stage's final task. +- **Precondition:** PR #253 is a hard precondition for stage 2 — called out at the top and in the stage-2 header. +- **Bisectability:** each stage ends with a green-gate step before its commit step; stage 1 needs no PR #253; stage 2's axum config tests seed the JSON fixture directly (Task 2.7 step 1 — "absent ⇒ empty"; tests write the file). +- **Known drift risk:** stages 3–8's exact code depends on the `Bound*Store` / `StoreRegistry` shapes finalized in stage 2. Re-read stage 2's actual output before executing each later stage; adjust signatures to match. +- **`app-demo` in CI:** Task 8.3 adds the missing CI wiring — the spec's §15 ship gate assumed CI exercises `app-demo`, which it does not today. diff --git a/docs/superpowers/plans/2026-06-01-spin-kv-backed-config.md b/docs/superpowers/plans/2026-06-01-spin-kv-backed-config.md new file mode 100644 index 00000000..1e69d796 --- /dev/null +++ b/docs/superpowers/plans/2026-06-01-spin-kv-backed-config.md @@ -0,0 +1,1632 @@ +# Plan: Move Spin Config Store onto KV + +**Status:** v12 — REVISION after tenth reviewer pass. Ready for +execution. **Reviewer green-lighted start.** + +**Goal:** Back `SpinConfigStore` with the Spin KV API (`spin_sdk::key_value`) +instead of Spin variables (`spin_sdk::variables`). Bring Spin's config +surface into structural parity with Cloudflare (KV-backed) and Fastly +(Config Store-backed), so `config push` writes through a real per-store +backend on all three cloud adapters. + +## v12 changelog + +Round-10 reviewer gave the verdict "yes, we can start" and +flagged 1 Low + 1 Nit. Both fixed: + +- **L1 (Stage 4/5 should explicitly REPLACE stale Spin-variable + tests)** — fixed. The current tests assert translated keys + - `[variables]` + `[component..variables]` writes at + `examples/app-demo/crates/app-demo-cli/tests/config_flow.rs:257` + and `crates/edgezero-adapter-spin/src/cli.rs:1846`. The plan + implied replacement via Task 4.6 (dry-run shape) and + Task 5.1 (drop variables writes) but didn't say so explicitly. + Added Task 4.7 and Task 5.5 to spell out the test rewrite: + delete the translated-key / two-table assertions; add seed + URL / JSON-body / no-POST-on-dry-run / status-code coverage. +- **Nit (reworded "backward-compatible" around run_app return)** — + fixed. The migration is hard-cutoff; "backward-compatible" + wording suggested legacy Spin-variable support was being + preserved (it isn't). Reworded throughout to + "source-compatible with the generated scaffold handler + signature" — narrower, accurate. + +## v11 changelog + +Round-9 reviewer flagged 1 Medium + 1 Low against v10. Both real +and fixed: + +- **M1 (unused `IntoResponse` import after run_app signature change)** + — fixed. Today `crates/edgezero-adapter-spin/src/lib.rs` imports + `spin_sdk::http::{IntoResponse, Request as SpinRequest, Response +as SpinResponse}` because `run_app` returns + `impl spin_sdk::http::IntoResponse`. After Task 3.5 changes the + return to `SpinFullResponse`, `IntoResponse` is no longer + referenced and the wasm-clippy `-D warnings` gate would fail on + `unused_imports`. Added an explicit substep to Task 3.5: drop + `IntoResponse` from the import line. Documented in the Scope + section under `src/lib.rs` too. +- **L1 (Stage 8 smoke test not executable as written)** — fixed. + `spin up` is foreground/long-running; the v10 step list + couldn't be pasted into a script. Stage 8 now provides a real + shell snippet that backgrounds `spin up`, polls + `127.0.0.1:3000` with `curl --silent --fail` until ready (5s + timeout, fails the test cleanly), runs `config push --local`, + asserts the curl, and cleans up the spin process in a `trap` + so a failed assertion never leaves an orphan listener on + port 3000. + +## v10 changelog + +Round-8 reviewer flagged 1 High against v9. Real and fixed: + +- **H1 (seed branch Result type mismatch)** — fixed. In v9, + `handle_seed_request_spin` returned bare `SpinFullResponse` but + `run_app_with_seeder`'s seed branch was returning that value + while the fall-through `run_app::(req).await` returns + `anyhow::Result`. Mismatched arm types in + the `if/else` would not compile. + + **Resolution**: change `handle_seed_request_spin` to return + `anyhow::Result` so both arms produce the + same type. As a side benefit this drops the `.expect("static- +shaped seed response")` from v9's D10 example, which was a + latent panic in a request handler. Internal failures + (`into_core_request`, `from_core_response`) now propagate via + `?` and surface as runtime errors instead of panics. Updated + in D10, Scope (lib.rs), and Task 3.5. + +## v9 changelog + +Round-7 reviewer flagged 2 High + 1 Medium against v8. All three +are real and fixed: + +- **H1 (`#[non_exhaustive]` + struct-literal across crates)** — + settled in [D8 update](#d8-push-context-schema). Rust rejects + struct-literal construction of a `#[non_exhaustive]` type from + outside its defining crate. Added a builder API: + `AdapterPushContext::new()` (returns the default), plus + `with_seed_url` / `with_seed_token` / `with_local` chained + setters. The CLI's `dispatch_push` builds via the builder + pattern, never the struct literal. `#[non_exhaustive]` stays so + future field additions don't break out-of-tree adapter + implementers (who only RECEIVE it via the trait method anyway). +- **H2 (`run_app_with_seeder` return-type mismatch with `run_app`)** — + settled. Today `run_app` returns + `anyhow::Result`; the opaque return type + can't be implicitly converted to a concrete `SpinFullResponse`, + so `run_app_with_seeder`'s fallthrough `run_app::(req).await` + wouldn't compile. **Resolution: change `run_app` to return + `anyhow::Result`** (the concrete type already + publicly aliased in `lib.rs`). This is **source-compatible with + the generated scaffold handler signature** (NOT a legacy-Spin- + variable carve-out — this migration is still hard-cutoff). The + existing template handler signature + `async fn handle(req: Request) -> anyhow::Result` + keeps compiling because `SpinFullResponse: IntoResponse`, so the + scaffold doesn't need re-running. Both `run_app` and + `run_app_with_seeder` now return the same concrete type, and + the fallthrough is a direct return. + Documented in D9 + Scope + Task 3.5. +- **M1 (D12 401 message omits short-token case)** — settled in + [D12 update](#d12-blocking-http-client). The 401 arm's message + now spells out all four fail-closed reasons (unset / blank / + whitespace-only / shorter than 16 bytes) so an operator who + set a 4-character placeholder doesn't waste time debugging the + wrong side. + +## v8 changelog + +Round-7 reviewer flagged 1 High + 1 Medium + 1 Low against v7. +Triage: + +- **H1 (D1 `label` field unused)** — **already fixed in v7 on + disk.** The reviewer was reading a stale snapshot. Line 329 of + the v7 file matches `SpinConfigBackend::Spin { label, store }` + and the error messages include `store \`{label}\`:`. No change + in v8. +- **M1 (Stage 3.5 stale)** — **already fixed in v7 on disk.** + Same stale-snapshot issue. Task 3.5 in v7 spells out + `anyhow::Result`, the template body swap, and + "unset / blank / shorter than 16 bytes" fail-closed behavior. + No change in v8. +- **L1 (D10 prose test list out-of-sync with Task 3.2)** — + **real.** Fixed in v8. D10's narrative list expanded to match + Task 3.2's full row set, grouped by surface (auth / + request-shape / store-resolution / write). Added a + "keep-in-sync" note so the two lists can't drift again. + +## v7 changelog + +Round-6 reviewer flagged 1 High + 3 Medium against v6. All addressed: + +- **H1 (Stage 8 smoke test would 401 itself)** — fixed. `test-token` + is 10 bytes and falls below v6's 16-byte floor, so the smoke test + would hit the fail-closed 401 path before any real KV write + happens. Replaced with `test-token-1234567890` (21 bytes) in both + the `spin up` env and the `app-demo-cli config push` env. +- **M1 (Stage 3 doesn't pin the 16-byte rule with a test)** — + fixed. Added explicit test rows to Task 3.2 covering + short-server-token paths: token unset → 401; token blank / + whitespace-only → 401; token 15 bytes → 401 (just under the + floor); token 16 bytes (offered correct on the wire) → 204 (just + at the floor). Task 3.5 explicitly references the floor check + when resolving `EDGEZERO__ADAPTERS__SPIN__SEED_TOKEN`. +- **M2 (`run_app_with_seeder` return shape mismatch with template)** — + fixed. Spec'd as `anyhow::Result` to mirror + the existing `run_app` shape and the scaffold template handler. + Operators can switch from `run_app::(req).await` to + `run_app_with_seeder::(req).await` with no signature change + on the `#[http_service]` handler. +- **M/L (`label` unused in `SpinConfigBackend::Spin`)** — fixed. + D1's `get` impl now uses `&self.label` in the unavailable error + messages so the field is read (no `-D warnings` dead-code + failure) AND so error logs name which platform store fired the + error — useful when the operator has multiple config stores. + +## v6 changelog + +Round-5 reviewer flagged 2 Medium + 2 Low + 1 Medium/Low against v5. +All addressed: + +- **M1 (Stage 1 acceptance vs Task 2.5)** — fixed. The Stage 1 + acceptance line previously said `config_store_contract_tests!` + must pass on host + wasm32-wasip2. Task 2.5 (v4 fix) correctly + scoped wasm KV out. Stage 1 now matches: "host-side + `config_store_contract_tests!` against the `InMemory` backend; + real KV write/read coverage lives in the Stage 8 `spin up` smoke + test". +- **M2 (token min-length still open)** — settled. **Q2 closed YES: + enforce a 16-byte minimum token at handler startup.** Below 16 + bytes (or unset/blank/whitespace-only) → fail-closed; every + request to the seed route returns 401. Cheap to implement, + prevents the worst accidental misconfiguration. D9 status table + updated to spell this out. Removed from open questions. +- **M/L (Cargo.toml scope checklist stale)** — fixed. The scope + line previously listed only `reqwest`; updated to mirror D11's + full set: `reqwest` (optional under `cli`), and non-optional + `serde` / `serde_json` / `subtle`. +- **L1 (Task 4.4 stale status list)** — fixed. The "Surface 401 / + 403 / 404 / 422" wording is replaced with "surface every D9 + status (400 / 401 / 403 / 404 / 405 / 415 / 422)" matching D12. +- **L2 (test backend uses `from_utf8_lossy`)** — fixed. The + `InMemory` config-store backend now uses strict UTF-8 (matches + production behavior). Added a doc comment + a "non-utf8 value + → unavailable" test to the contract-test fixture so the + divergence couldn't reappear. + +## v5 changelog + +Round-4 reviewer flagged 1 High + 4 Medium + 1 Low against v4. All +addressed: + +- **H1 (stale `build_config_registry` snippet)** — settled in + [Scope: edgezero-adapter-spin](#cratesedgezero-adapter-spin-the-heavy-crate) + and [Stage 2 Task 2.4](#stage-2--runtime-backend-swap--registry-rewrite). + Updated to async/error-propagating signature: returns + `anyhow::Result>`, awaits + `SpinConfigStore::open(...).await?` per id. The + `dispatch_with_registries` snippet shows + `build_config_registry(config_meta, env).await?`. +- **M1 (`PushContext` naming collision)** — settled. The trait-level + type is now **`AdapterPushContext`**; the CLI's internal + `PushContext` (config.rs:42) keeps its name. Updated everywhere + the new type is mentioned (D8, D12, Scope, Stages). +- **M2 (dispatch_push signature gap)** — settled in + [D8 update](#d8-push-context-schema). `load_push_context` now + resolves the `AdapterPushContext` upstream (it already takes + `&ConfigPushArgs` and reads `env` for store resolution; adding + the seed_url/token/local resolution there is natural). The + resolved `AdapterPushContext` is stashed in the CLI's + internal `PushContext` and `dispatch_push` reads it from there — + no signature change required on `dispatch_push` itself. +- **M3 (stale D9 wording about `subtle` gating)** — fixed. D9's + "gated under the spin feature" line removed; cross-reference to + D11 ("non-optional dep") added. +- **M4 (in-memory store key shape)** — settled in + [D1](#d1-backend-spin-kv-via-spin_sdkkey_valuestore) and + [Scope](#cratesedgezero-adapter-spin-the-heavy-crate). The + `InMemory` test backend is keyed plain `String → Bytes`. Removed + the conflicting "(label, key)" mention in the Scope section and + Task 2.2. The contract-test macro exercises one store at a time, + so plain `key → bytes` is enough. The handler-side + `InMemorySeedWriter` (D10) is the only place that needs to + distinguish stores — that one stays keyed `(label, key)` because + it serves multi-store seed requests. +- **L1 (version labels stale)** — fixed throughout: Stage 1 task + text now says "Move this plan into specs"; the open-questions + header is "(round 5)"; the settled-section header keeps "round 2" + as the historical pointer for when those decisions were taken. + +## v4 changelog + +Round-3 reviewer flagged 4 High + 2 Medium + 1 Low against v3. All +addressed: + +- **H1 (SpinConfigStore won't host-compile)** — settled in + [D1 update](#d1-backend-spin-kv-via-spin_sdkkey_valuestore). + Restored the cfg-gated backend enum pattern (matching the existing + shape in `config_store.rs`). Wasm variant holds the opened + `key_value::Store`; `InMemory` test variant holds a `BTreeMap`. + Construction is async on wasm, sync in tests. The trait `get` + dispatches on the variant. +- **H2 (`subtle` can't be wasm-only if core is host-tested)** — + settled in [D11 update](#d11-dependency-gating). Move `subtle` + out of the `spin` feature into a non-optional dependency. It's + tiny and compiles on both host and wasm; the host tests can + reach `subtle::ConstantTimeEq` without enabling `spin`. +- **H3 (JSON deps missing from scope)** — settled in + [D11 update](#d11-dependency-gating). Add `serde` + `serde_json` + as non-optional dependencies on `edgezero-adapter-spin`. Both + are already workspace deps; both compile on host AND wasm. CLI + POST body, seed handler core parser, and the migration story + all need them. +- **H4 (`--local` could fall back to manifest prod URL)** — + settled in [D3 update](#d3-config-push---local-for-spin) and + [D8 update](#d8-push-context-schema). `--local` short-circuits + the manifest fallback completely. New `PushContext::local: bool` + field. Resolution chain when `local = true`: `--seed-url` CLI + flag → `EDGEZERO__ADAPTERS__SPIN__LOCAL_SEED_URL` env → builtin + default `http://127.0.0.1:3000/__edgezero/config/seed`. NEVER + reads the manifest's prod `seed_url`. +- **M1 (Stage 2.5 overclaims wasm contract)** — settled. CI's spin + wasm matrix runs `wasmtime run`, which doesn't host Spin KV. + Task 2.5 now: host-side `config_store_contract_tests!` against + the `InMemory` backend. Real KV write/read coverage moves to the + end-to-end smoke test in Stage 8 that requires `spin up`. +- **M2 (CLI error mapping incomplete)** — settled in + [D12 update](#d12-blocking-http-client). The CLI match now + covers every intentional status: 400, 401, 403, 404, 405, 415, 422. Each gets a specific message. +- **L1 (`cargo tree | grep '^reqwest'` may miss prefixed entries)** + — settled in [Stage 8 update](#stage-8--verify-gate). Replace + with `cargo tree -i reqwest -p edgezero-adapter-spin --features +spin --target wasm32-wasip2` which errors when `reqwest` is not + in the tree at all (the desired outcome). Pair check uses the + same form for `subtle` (which MUST resolve). + +## v3 changelog + +Round-2 reviewer flagged 4 High + 2 Medium + 1 Low against v2. All +addressed: + +- **H1 (sync trait vs async reqwest)** — settled in + [D12](#d12-blocking-http-client). Use `reqwest::blocking::Client` + so the existing sync `Adapter::push_config_entries*` trait shape + is preserved. Workspace `reqwest` gets the `blocking` + `json` + features added. No runtime needs to be threaded through the + dispatcher. +- **H2 (`subtle` gated to wrong feature)** — settled. The token + comparison runs in the wasm **seed handler**, not in the host + CLI. Move `subtle` from `cli` to the `spin` feature in + `edgezero-adapter-spin/Cargo.toml`. D9 updated to reflect. +- **H3 (store validation vs env-remapped platform names)** — + settled in [D9 update](#d9-seed-handler-security). The seed + handler validates the body's `store` field against the set of + env-resolved **platform** labels (computed from + `A::stores().config` × `EnvConfig::store_name("config", id)`), + not the logical ids. Operators can run with + `EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME=prod-config` and + push a body `{"store": "prod-config", ...}` — the validation + passes because that's the correct platform label. +- **H4 (host-testable seed signature)** — settled in + [D10 update](#d10-testable-seed-writer). Split the handler into + two layers: a host-compilable `handle_seed_request_core` that + takes `edgezero_core::http::Request` / returns + `edgezero_core::http::Response`, and a thin wasm wrapper that + translates Spin types ↔ core types and lives under the wasm + cfg gate. Unit tests target the core layer. +- **M1 (open-on-every-get)** — settled in [D1 update](#d1-backend-spin-kv-via-spin_sdkkey_valuestore). + `SpinConfigStore` holds the opened `key_value::Store` handle. + Construction is async, so `build_config_registry` becomes async + too (called from `dispatch_with_registries`, already async). + Missing `key_value_stores` declaration surfaces at registry + build time, not on first config read. +- **M2 (manifest `seed_url` is open but assumed)** — settled. + `[adapters.spin.commands].seed_url` IS a supported source. + Moved from open questions to settled. Resolution order codified + in D8. +- **L1 (`cargo tree | grep reqwest` exit-code semantics)** — + fixed in Stage 8: use `! cargo tree … | grep -q reqwest` so + the step fails ONLY when reqwest leaks into the wasm tree. + +## v2 changelog + +Reviewer flagged 4 High + 3 Medium + 1 Low against v1. All addressed: + +- **H1 (per-id config registry)** — added Stage 2 Task 2.4: rewrite + `build_config_registry` in `request.rs` to open one + `spin_sdk::key_value::Store` per declared id using + `env.store_name("config", id)` — mirroring the existing + `build_kv_registry`. The old "one shared handle cloned for every id" + shape goes away with Single→Multi. +- **H2 (seed URL/token transport schema)** — settled in new + [D8](#d8-push-context-schema). Adds `PushContext` to the + `push_config_entries*` trait signature, threads adapter command + metadata through `dispatch_push`, and gives `ConfigPushArgs` two + new CLI args (`--seed-url`, `--seed-token`) plus env fallbacks. +- **H3 (config-key validation)** — settled in + [D1.5](#d15-validator-relaxation). `validate_app_config_keys` + becomes a no-op for spin (KV accepts arbitrary key bytes). Existing + uppercase / dash / start-char tests are deleted; new tests pin + "any UTF-8 key passes". +- **H4 (seed handler security spec)** — settled in + [D9](#d9-seed-handler-security). POST-only, fail-closed on missing + or blank token, explicit status code table, and scaffolding is + opt-in (`run_app_with_seeder` is what the scaffold uses; existing + `run_app` is unchanged so downstream apps can opt out). +- **M1 (scaffold spin.toml key_value_stores)** — Stage 5 Task 5.4 + added: generator `spin.toml.hbs` declares + `key_value_stores = ["app_config"]` by default. `provision` + remains the safe path for already-scaffolded projects. +- **M2 (testable seed handler)** — settled in + [D10](#d10-testable-seed-writer). Introduces `trait SeedWriter` so + unit tests inject a fake; production uses a `SpinKvSeedWriter` + that calls the hostcall. +- **M3 (HTTP client gating)** — settled in + [D11](#d11-http-client-feature-gating). `reqwest` becomes a + `cli`-feature-only dep on `edgezero-adapter-spin` (native-only); + confirmed not pulled into the wasm target. Plan lists the exact + Cargo.toml edits. +- **L1 (legacy flag)** — settled. **No `--legacy-spin-variables` + flag.** Hard-cutoff matches the rest of the rewrite's posture. + Removed from open questions. + +Three remaining open questions for round 2 — see [Open questions](#open-questions-round-2). + +## Why + +Today `SpinConfigStore` wraps `spin_sdk::variables`. That has four +practical costs: + +1. **No dynamic config.** Spin variables are baked into `spin.toml` + at build time and override-able only via `SPIN_VARIABLE_` + env vars or `spin up --env`. Pushing a new value mid-run requires + a redeploy. +2. **Shared namespace with secrets.** `SpinSecretStore::get_bytes` + ALSO reads `spin_sdk::variables`, so config keys and `#[secret]` + values share the same flat namespace. We carry an explicit + collision-check in `validate_typed_secrets` to compensate + (`cli.rs:425-449`). +3. **Single-capable.** Spin is forced into the `single_store_kinds` + spec axis for config (one flat variable namespace per app) while + Cloudflare and Fastly are Multi. Operators can't have e.g. + `app_config` + `tenant_overrides` as two separate Spin stores. +4. **No platform parity.** `config push --adapter spin` edits + `spin.toml`; the other two cloud adapters shell out to a + platform-native bulk-write CLI (`fastly config-store-entry create` + / `wrangler kv bulk put`). The mental model split is real. + +KV-backed config fixes all four. + +## Design decisions + +### D1. Backend: Spin KV via `spin_sdk::key_value::Store` + +Runtime change in `crates/edgezero-adapter-spin/src/config_store.rs`: + +**v4**: keep the existing **cfg-gated backend enum** pattern from +today's `config_store.rs` so the file compiles on host (for tests) +without dragging in `spin_sdk` types. The wasm variant holds the +opened `key_value::Store`; the `InMemory` test variant holds a +`BTreeMap` (was `HashMap` in the +variables-backed impl). Construction is async on wasm, sync in +tests; the trait method dispatches on the variant. + +```rust +pub struct SpinConfigStore { + inner: SpinConfigBackend, +} + +enum SpinConfigBackend { + #[cfg(all(feature = "spin", target_arch = "wasm32"))] + Spin { + label: String, + store: spin_sdk::key_value::Store, // opened ONCE at dispatch + }, + #[cfg(test)] + InMemory(BTreeMap), + /// Never constructed; keeps the enum inhabited outside production Spin and tests. + #[cfg(not(any(all(feature = "spin", target_arch = "wasm32"), test)))] + _Uninhabited(std::convert::Infallible), +} + +impl SpinConfigStore { + /// Open the platform store once. Called from + /// `build_config_registry` during dispatch setup. Wasm-only; + /// tests use `from_entries`. + #[cfg(all(feature = "spin", target_arch = "wasm32"))] + pub async fn open(label: String) -> Result { + let store = spin_sdk::key_value::Store::open(&label).await + .map_err(|err| ConfigStoreError::unavailable(format!("open `{label}`: {err}")))?; + Ok(Self { inner: SpinConfigBackend::Spin { label, store } }) + } + + #[cfg(test)] + fn from_entries(entries: impl IntoIterator) -> Self { + Self { inner: SpinConfigBackend::InMemory(entries.into_iter().collect()) } + } +} + +#[async_trait(?Send)] +impl ConfigStore for SpinConfigStore { + async fn get(&self, key: &str) -> Result, ConfigStoreError> { + match &self.inner { + #[cfg(all(feature = "spin", target_arch = "wasm32"))] + SpinConfigBackend::Spin { label, store } => { + // v7 (round-6 M/L): use `label` in error wording so + // (a) the field isn't dead-code under -D warnings, + // (b) the operator running multi-store sees which + // platform store fired the failure. + match store.get(key).await { + Ok(Some(bytes)) => String::from_utf8(bytes).map(Some).map_err(|err| { + ConfigStoreError::unavailable(format!( + "store `{label}`: non-utf8 value for `{key}`: {err}" + )) + }), + Ok(None) => Ok(None), + Err(err) => Err(ConfigStoreError::unavailable(format!( + "store `{label}`: {err}" + ))), + } + } + #[cfg(test)] + SpinConfigBackend::InMemory(map) => match map.get(key) { + Some(bytes) => String::from_utf8(bytes.to_vec()).map(Some).map_err(|err| { + // v6 fix (L2): strict UTF-8 to match the wasm + // backend's behaviour. `from_utf8_lossy` would + // hide a divergence between test and prod. + ConfigStoreError::unavailable(format!("non-utf8 value for `{key}`: {err}")) + }), + None => Ok(None), + }, + #[cfg(not(any(all(feature = "spin", target_arch = "wasm32"), test)))] + SpinConfigBackend::_Uninhabited(never) => match *never {}, + } + } +} +``` + +Drops the `.→__` translation (KV accepts arbitrary key bytes). + +### D1.5. Validator relaxation + +Reviewer (H3): the existing `validate_app_config_keys` enforces Spin +variable syntax (lowercase, `^[a-z][a-z0-9_]*$` after `.→__`). With +KV-backed config, none of that applies — KV stores accept arbitrary +key bytes. + +Concrete change in `crates/edgezero-adapter-spin/src/cli.rs`: + +- `validate_app_config_keys`: collapses to `Ok(())`. The function stays + in place (trait shape) but no longer rejects anything. +- `translate_key_for_spin`: deleted. Callers (push, validator) read + keys verbatim. +- `is_valid_spin_key` / `spin_key_rule_violation`: stay — still used + by `validate_typed_secrets` for `#[secret]` value validation + (secrets still live in variables; see D7). +- Tests deleted (Stage 6 Task 6.1): + - `validate_app_config_keys_*` tests covering uppercase rejection, + dash rejection, leading-digit rejection, etc. +- Tests added (Stage 6 Task 6.2): + - `validate_app_config_keys_accepts_any_utf8` (covers `Greeting`, + `feature-flag`, `1numeric_start`, `with.dots`, `with spaces`). + +### D2. Push: HTTP POST to a seeding handler + +Spin has no `spin kv put` CLI subcommand and no bulk-write hostcall +reachable from outside the wasm runtime. Two options ruled out: + +- **Write Spin's SQLite KV file directly** — Spin doesn't guarantee + schema stability across versions. Brittle. +- **Wait for upstream `spin kv` CLI** — months of latency at best. + +So: the adapter ships a small **seeding handler** that +`app-demo-cli config push --adapter spin` HTTP-POSTs. + +### D3. `config push --local` for Spin + +With D2, `--local` and the default push both HTTP-POST to the +seeding handler, but the URL resolution chains are **strictly +disjoint** — `--local` never falls back to the manifest's prod URL. +This protects an operator who forgets to start `spin up` locally +from accidentally pushing to production. + +**Without `--local`** (prod push), `seed_url` resolves in order: + +1. `--seed-url` CLI arg. +2. `EDGEZERO__ADAPTERS__SPIN__SEED_URL` env. +3. `[adapters.spin.commands].seed_url` in `edgezero.toml`. + +Errors with a clear message if none are set. + +**With `--local`** (local push), `seed_url` resolves in order: + +1. `--seed-url` CLI arg (explicit operator override always wins). +2. `EDGEZERO__ADAPTERS__SPIN__LOCAL_SEED_URL` env (separate from + the prod env var — operators who set both don't accidentally + leak prod URL into local pushes). +3. Builtin default `http://127.0.0.1:3000/__edgezero/config/seed`. + +The manifest's `[adapters.spin.commands].seed_url` is **never read** +when `--local` is set. The dispatcher needs to know about +`args.local` before building `AdapterPushContext` — see D8. + +### D4. Provision: declare the KV store in `spin.toml` + +`provision --adapter spin` already edits `spin.toml`. Extension: for +each declared `[stores.config].id`, append the env-resolved platform +name to the component's `key_value_stores = [...]` list. Idempotent +on existing entries. Same pattern as the existing KV provision flow. + +### D5. Capability: Spin becomes Multi for config + +Drop `"config"` from `Spin::single_store_kinds` (currently +`&["config", "secrets"]` → `&["secrets"]`). Strict validation no +longer rejects `[stores.config].ids.len() > 1` for spin. + +### D6. Collision check goes away + +`validate_typed_secrets` currently builds a Spin variable name set of +`{flattened config keys} ∪ {#[secret] values}` and errors on +duplicates. With config off the variables namespace, the +intersection is empty by construction. Delete the check + spec/doc +text that explains it. + +### D7. Secrets stay on variables (unchanged) + +`SpinSecretStore` continues to use `spin_sdk::variables`. The +single-flat-namespace constraint applies only to secrets now. +`#[secret]` values still get the lowercase-only translation; the +runtime check stays. + +### D8. Push context schema + +Reviewer (H2): the v1 plan said "no CLI-side changes" but then +required the Spin adapter to read seed URL/token from somewhere the +trait signature doesn't expose. Fixed by introducing +`AdapterPushContext` (v5: renamed from v4's `PushContext` to avoid +collision with the CLI's internal `PushContext` struct at +[config.rs:42]). + +Changes to `crates/edgezero-adapter/src/registry.rs`: + +```rust +#[derive(Debug, Clone, Default)] +#[non_exhaustive] +pub struct AdapterPushContext<'a> { + /// Already-resolved seed URL. Caller (CLI dispatch) follows the + /// resolution chain for prod or local per D3 and produces the + /// final string here. `None` means "no URL was set anywhere + /// in the resolution chain" -- the adapter errors loudly. + pub seed_url: Option<&'a str>, + /// Already-resolved seed token. + pub seed_token: Option<&'a str>, + /// `true` when the operator passed `--local`. Adapters that + /// have a separate local-emulator path use this to pick the + /// right writeback target; adapters where local == default + /// can ignore it. + pub local: bool, +} + +impl<'a> AdapterPushContext<'a> { + /// Construct a default context: no seed URL / token, prod (not + /// local). v9 (round-7 H1): Rust rejects struct-literal + /// construction of `#[non_exhaustive]` types from outside the + /// defining crate, so the CLI MUST build via this constructor + /// and the `with_*` setters below. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + #[must_use] + pub fn with_seed_url(mut self, url: &'a str) -> Self { + self.seed_url = Some(url); + self + } + + #[must_use] + pub fn with_seed_token(mut self, token: &'a str) -> Self { + self.seed_token = Some(token); + self + } + + #[must_use] + pub fn with_local(mut self, local: bool) -> Self { + self.local = local; + self + } +} + +fn push_config_entries( + &self, + manifest_root: &Path, + adapter_manifest_path: Option<&str>, + component_selector: Option<&str>, + store: &ResolvedStoreId, + entries: &[(String, String)], + push_ctx: &AdapterPushContext<'_>, // NEW + dry_run: bool, +) -> Result, String> { ... } +``` + +`AdapterPushContext` is non-exhaustive so we can grow it later +without breaking downstream adapters that RECEIVE it via the +trait method. The CLI (which CONSTRUCTS it) is in-tree and uses +the builder API, so the `#[non_exhaustive]` constraint is +honoured at the source-code level. Same shape on +`push_config_entries_local`. + +Changes to `crates/edgezero-cli/src/args.rs`: + +```rust +pub struct ConfigPushArgs { + /* … existing fields … */ + /// Seed URL for adapters that push via HTTP (currently spin). + /// Resolution: this flag → `EDGEZERO__ADAPTERS____SEED_URL` + /// → `[adapters..commands].seed_url`. + #[arg(long)] + pub seed_url: Option, + /// Seed token for adapters that push via HTTP (currently spin). + /// Resolution: this flag → `EDGEZERO__ADAPTERS____SEED_TOKEN`. + /// Never read from `edgezero.toml` (don't put secrets in the + /// manifest). + #[arg(long)] + pub seed_token: Option, +} +``` + +Manifest schema: `ManifestAdapterCommands` (currently lives in +`crates/edgezero-core/src/manifest.rs`) gains an optional +`seed_url: Option` field. Already covered by `#[non_exhaustive]`, +so additive. + +Changes to `crates/edgezero-cli/src/config.rs`: + +The CLI's internal `PushContext` struct (config.rs:42) gains a +field carrying the resolved adapter context: + +```rust +struct PushContext { + // … existing fields … + /// Resolved by `load_push_context` from CLI args + env + + /// manifest per D3's prod/local chains. Stashed here so + /// `dispatch_push` can pass it through to the trait method + /// without re-reading args / env. Owned strings (not + /// borrows) so the lifetime story stays simple. + adapter_push_ctx: ResolvedAdapterPushContext, +} + +struct ResolvedAdapterPushContext { + seed_url: Option, + seed_token: Option, + local: bool, +} +``` + +`load_push_context(args: &ConfigPushArgs)` (which already takes +`&ConfigPushArgs` and reads `env` for store resolution) gains the +resolution logic per D3's disjoint chains: + +```rust +fn load_push_context(args: &ConfigPushArgs) -> Result { + // … existing manifest + store resolution … + + let env = EnvConfig::from_env(); + let name = &args.adapter; + + let seed_url = args.seed_url.clone().or_else(|| { + if args.local { + // D3 local chain: env → builtin default. Manifest NEVER consulted. + env.get(&["adapters", name, "local_seed_url"]) + .map(str::to_owned) + .or_else(|| Some("http://127.0.0.1:3000/__edgezero/config/seed".to_owned())) + } else { + // D3 prod chain: env → manifest. + env.get(&["adapters", name, "seed_url"]).map(str::to_owned) + .or_else(|| manifest.adapters.get(name) + .and_then(|cfg| cfg.adapter.commands.seed_url.clone())) + } + }); + + let seed_token = args.seed_token.clone() + .or_else(|| env.get(&["adapters", name, "seed_token"]).map(str::to_owned)); + // Manifest never consulted for tokens, even on the prod chain. + + Ok(PushContext { + // … existing fields … + adapter_push_ctx: ResolvedAdapterPushContext { + seed_url, seed_token, local: args.local, + }, + }) +} +``` + +`dispatch_push` (unchanged signature) just borrows from the +already-resolved context when building the `AdapterPushContext` +to hand the trait method: + +```rust +fn dispatch_push(ctx: &PushContext, entries: &[(String, String)], + dry_run: bool, local: bool) -> Result<(), String> { + let r = &ctx.adapter_push_ctx; + // v9 (round-7 H1): build via the builder, NOT a struct literal — + // AdapterPushContext is #[non_exhaustive] and external crates + // can't use struct-literal construction. + let mut push_ctx = AdapterPushContext::new().with_local(r.local); + if let Some(url) = r.seed_url.as_deref() { + push_ctx = push_ctx.with_seed_url(url); + } + if let Some(token) = r.seed_token.as_deref() { + push_ctx = push_ctx.with_seed_token(token); + } + let lines = if local { + ctx.adapter.push_config_entries_local(/* … */, &push_ctx, dry_run)? + } else { + ctx.adapter.push_config_entries(/* … */, &push_ctx, dry_run)? + }; + // … existing logging … +} +``` + +For non-Spin adapters this is constructed but unused — costs nothing. + +This change is **breaking** for any out-of-tree adapter that +implements `Adapter::push_config_entries*` (no in-tree adapter +outside the four ships today). Document in the next release notes. + +### D9. Seed handler security + +Reviewer (H4): pin the security contract before code. + +**Route**: `/__edgezero/config/seed`. Single fixed path, not +configurable per app — keeps every Spin deploy's seeding surface +predictable for ops scripts. + +**Method**: POST only. GET/PUT/DELETE/HEAD/OPTIONS/PATCH → 405. + +**Headers**: + +- `x-edgezero-seed: ` — REQUIRED. Compared constant-time + against `EDGEZERO__ADAPTERS__SPIN__SEED_TOKEN`. +- `content-type: application/json` — REQUIRED. Anything else → 415. + +**Body shape** (validated against this schema): + +```json +{ + "store": "app_config", + "entries": [ + { "key": "greeting", "value": "hello" }, + { "key": "service.timeout_ms", "value": "1500" } + ] +} +``` + +The `store` field is the **platform label** (what `Store::open(name)` +needs), not the logical id. The handler builds the set of accepted +labels from `A::stores().config` × `EnvConfig::store_name("config", id)` +— so an operator running with +`EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME=prod-config` pushes +`{"store": "prod-config", …}` and the validation passes. A body +mentioning the logical id `"app_config"` in that environment is +correctly rejected (404). + +The CLI does the resolution before POSTing — `dispatch_push` already +resolves the platform label via `env.store_name("config", id)`, so +the body the CLI emits matches what the handler expects. + +**Status code table**: + +| Code | Condition | +| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| 204 | Success. Body empty. | +| 400 | Malformed JSON, missing `store`, missing/empty `entries`, or any `key`/`value` not a string. | +| 401 | `x-edgezero-seed` header missing, or `EDGEZERO__ADAPTERS__SPIN__SEED_TOKEN` env unset/blank/whitespace-only/shorter than 16 bytes (fail-closed). | +| 403 | `x-edgezero-seed` header present but does not match the env token. | +| 404 | `store` does not match any env-resolved platform label for a declared `[stores.config].id`. | +| 405 | Non-POST method. | +| 415 | `content-type` not `application/json`. | +| 422 | KV store open / set hostcall returned an error mid-write (partial-write — see body for the failed key). | + +**Fail-closed contract**: if `EDGEZERO__ADAPTERS__SPIN__SEED_TOKEN` +is unset, blank, whitespace-only, OR **shorter than 16 bytes** +(v6 — round-5 Q2 settled), EVERY request to the seed route returns +401 — even with no `x-edgezero-seed` header. We never default a +token, never accept "no token = no auth", and never accept a +short-enough token to brute-force in a reasonable time. An operator +who forgot to set the token, or set a 4-character placeholder, gets +a clean error rather than an open writeable endpoint. + +**Why 16 bytes**: at 8 bits/byte that's 128 bits of token surface. +Even a single-shot guess against a constant-time compare has +~2^-128 odds; rate-limiting from the Spin runtime kills any +practical brute-force. Below 16 bytes the operator is almost +certainly using a placeholder ("dev", "test123") that doesn't +belong in production OR local. + +**Token comparison**: `subtle::ConstantTimeEq` (workspace dep, +non-optional on the spin adapter per [D11](#d11-dependency-gating) +— v4's "gated under `spin` feature" was wrong; the host +unit tests for `handle_seed_request_core` need to reach this type +without enabling `--features spin`). Prevents timing-oracle +leakage of the token prefix. + +**Logging**: log auth failures at `warn` level with the source IP +(via `spin-client-addr` header) but NEVER the offered token. + +**Opt-in vs always-scaffolded**: scaffold-side OPT-IN — the +generator emits `run_app_with_seeder` for new projects, but +`run_app` (no seeding route) stays available for projects that +explicitly opt out by switching the entrypoint. Existing +deployments keep `run_app` and aren't affected. + +### D10. Testable seed writer + +Reviewer (M2): the v1 plan called for unit tests on the seed handler +but `spin_sdk::key_value` is wasm-runtime-bound. Solution: trait + +fake. + +**v3**: split the handler into two layers so tests compile on the +host without dragging in `spin_sdk` types. The core layer is +host-compilable; the wasm wrapper translates Spin types to/from +`edgezero_core::http::{Request, Response}`. + +`crates/edgezero-adapter-spin/src/seed.rs`: + +```rust +// ---- Core layer (host-compilable) --------------------------------- + +#[async_trait(?Send)] +pub(crate) trait SeedWriter { + async fn write(&self, store: &str, key: &str, value: &str) -> Result<(), SeedError>; +} + +/// Host-compilable seed handler core. Takes a core HTTP `Request` +/// (body already buffered into `Body::Once`) and returns a core HTTP +/// `Response`. Parsing, auth, status-code routing, and the writer +/// dispatch all live here. NO spin_sdk references. +pub(crate) async fn handle_seed_request_core( + req: &edgezero_core::http::Request, + writer: &W, + valid_token: Option<&str>, // None → fail-closed (401) + known_platform_labels: &[String], // env-resolved labels per H3 +) -> edgezero_core::http::Response { ... } + +#[cfg(test)] +pub(crate) struct InMemorySeedWriter { + pub(crate) entries: Mutex>, // (label, key) → value +} + +// ---- Wasm wrapper (spin-runtime only) ----------------------------- + +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +pub(crate) struct SpinKvSeedWriter; + +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +#[async_trait(?Send)] +impl SeedWriter for SpinKvSeedWriter { + async fn write(&self, store: &str, key: &str, value: &str) -> Result<(), SeedError> { + let kv = spin_sdk::key_value::Store::open(store).await?; + kv.set(key, value.as_bytes()).await?; + Ok(()) + } +} + +/// Thin wasm wrapper: Spin `Request` → core `Request` → core handler +/// → core `Response` → Spin `Response`. Lives where the existing +/// `into_core_request` / `from_core_response` helpers do. +/// +/// v10 (round-8 H1): returns `anyhow::Result` so +/// it matches `run_app`'s shape (allows `?` at the call site in +/// `run_app_with_seeder` instead of a `.expect()` panic). +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +pub(crate) async fn handle_seed_request_spin( + req: spin_sdk::http::Request, + writer: &SpinKvSeedWriter, + valid_token: Option<&str>, + known_platform_labels: &[String], +) -> anyhow::Result { + let core_req = crate::request::into_core_request(req).await?; + let core_resp = handle_seed_request_core(&core_req, writer, + valid_token, known_platform_labels).await; + Ok(crate::response::from_core_response(core_resp).await?) +} +``` + +Host-compilable unit tests (live in `seed.rs`'s `#[cfg(test)] mod +tests`). The full row set lives in Task 3.2 — keep this list in +sync if either side moves: + +- **Auth surface (v6 16-byte floor + fail-closed)**: + - Token unset (env missing) → 401. + - Token blank (`""`) → 401. + - Token whitespace-only (`" "`) → 401. + - Token 15 bytes (just under the floor) → 401, even when the + client offers the matching token on the wire. + - Token exactly 16 bytes + matching wire token → 204 + (just-at-the-floor sentinel). + - Token 16 bytes + missing `x-edgezero-seed` → 401. + - Token 16 bytes + wrong `x-edgezero-seed` → 403. +- **Request-shape surface**: + - Non-POST method → 405. + - `content-type` not `application/json` → 415. + - Malformed JSON → 400. + - Missing `store` / `entries` / non-string values → 400. +- **Store-resolution surface**: + - Unknown store (no env-resolved label matches) → 404. +- **Write surface**: + - `SeedWriter::write` errors mid-stream → 422 (body names the + failed key). + - Happy path → 204 + `InMemorySeedWriter` recorded all entries. + +### D11. Dependency gating + +Three new deps. Different gates for different reasons: + +| Dep | Gate | Why | +| ---------------------- | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `reqwest` | `cli` feature (host-only) | Pulls `tokio` + TLS — would explode the wasm bundle and fail to compile on `wasm32-wasip2`. Only the host CLI uses it. | +| `subtle` | **non-optional** (host + wasm) | Used by the seed handler core (wasm) AND by its host-compilable unit tests (D10). Reviewer H2: can't be `spin`-gated when host tests reach `ConstantTimeEq` without `--features spin`. Tiny dep; compiles cleanly on both targets. | +| `serde` + `serde_json` | **non-optional** (host + wasm) | Reviewer H3: seed core parses JSON (wasm), CLI builds JSON body (host), `--features cli` body type derives `Serialize` / `Deserialize`. Both already workspace deps; both compile on host AND wasm. | + +Concrete `Cargo.toml` change on `crates/edgezero-adapter-spin`: + +```toml +[features] +spin = [ + "dep:spin-sdk", +] +cli = [ + "dep:edgezero-adapter", + "edgezero-adapter/cli", + "dep:ctor", + "dep:reqwest", # NEW (host HTTP push) + "dep:toml", + "dep:toml_edit", + "dep:walkdir", +] + +[dependencies] +# … existing entries … +reqwest = { workspace = true, optional = true } +serde = { workspace = true } # NEW; non-optional +serde_json = { workspace = true } # NEW; non-optional +subtle = { workspace = true } # NEW; non-optional +``` + +**Why subtle is not optional**: gating it under `spin` would hide +it from the host build, but the host unit tests for +`handle_seed_request_core` (D10) need to construct `subtle::Choice` +and friends. Making it non-optional is the simplest correct +answer; the dep is ~5 KB compiled. + +**Why serde/serde_json are not optional**: similarly, the core +seed handler runs JSON parsing on both wasm (production) and host +(tests). The Cargo features model can't express "available in +wasm under `spin` AND in host under `cfg(test)`" cleanly — making +it always-on does the right thing. + +Verification step (added to Stage 8 gate): use `cargo tree -i` +which errors when the dep is not in the tree at all (per L1). Two +checks: + +```sh +# reqwest MUST NOT be in the wasm tree. +# `cargo tree -i ` exits non-zero when isn't a dep -- +# which is the success case here. Invert with `!`: +! cargo tree -i reqwest -p edgezero-adapter-spin \ + --features spin --target wasm32-wasip2 2>/dev/null + +# subtle / serde_json MUST be in the wasm tree. +# `cargo tree -i ` succeeds when the dep IS present: +cargo tree -i subtle -p edgezero-adapter-spin \ + --features spin --target wasm32-wasip2 +cargo tree -i serde_json -p edgezero-adapter-spin \ + --features spin --target wasm32-wasip2 +``` + +### D12. Blocking HTTP client + +Reviewer (H1): the existing `Adapter::push_config_entries*` trait +methods are SYNCHRONOUS. `reqwest::Client::post` is async. Two +options: + +- **(a) `reqwest::blocking`** — keeps the sync trait shape. Needs + `blocking` + `json` features on the workspace `reqwest`. +- **(b) Async trait + runtime in dispatcher** — clean but bigger + blast radius (every adapter impl signature changes; CLI gets a + tokio dep). + +**Resolution: (a).** Workspace `Cargo.toml` change: + +```toml +reqwest = { version = "0.13", default-features = false, + features = ["rustls", "blocking", "json"] } +``` + +Spin's `push_config_entries`: + +```rust +let client = reqwest::blocking::Client::new(); +let response = client + .post(&seed_url) + .header("x-edgezero-seed", token) + .json(&body) // serde-derived; `json` feature + .send() + .map_err(|err| match err.is_connect() { + true => format!("seed POST to {seed_url} failed: connection refused. Is the Spin app running?"), + false => format!("seed POST to {seed_url} failed: {err}"), + })?; +// Map every status the handler intentionally emits (D9 status table). +match response.status().as_u16() { + 204 => Ok(vec![format!( + "pushed {} entries to seed handler at {seed_url}", + entries.len() + )]), + 400 => Err(format!( + "seed handler rejected (400 Bad Request): {}. Check CLI version / store id.", + response.text().unwrap_or_default() + )), + 401 => Err(format!( + "seed handler rejected (401 Unauthorized). Fail-closed reasons (D9): \ + server-side `EDGEZERO__ADAPTERS__SPIN__SEED_TOKEN` is unset, blank, \ + whitespace-only, or shorter than 16 bytes; OR your client-side \ + `--seed-token` / `EDGEZERO__ADAPTERS__SPIN__SEED_TOKEN` is missing. \ + Check the server's env first -- a 4-character placeholder triggers \ + this even when the wire token matches." + )), + 403 => Err(format!( + "seed handler rejected (403 Forbidden): x-edgezero-seed mismatch. \ + Check that the token on the client matches the server's \ + EDGEZERO__ADAPTERS__SPIN__SEED_TOKEN" + )), + 404 => Err(format!( + "seed handler rejected (404 Not Found): store `{}` is not a recognised platform label. \ + Check `[stores.config].ids` and any EDGEZERO__STORES__CONFIG____NAME overrides", + store.platform + )), + 405 => Err(format!( + "seed handler rejected (405 Method Not Allowed). \ + This usually means a transparent proxy rewrote the POST -- check intermediaries" + )), + 415 => Err(format!( + "seed handler rejected (415 Unsupported Media Type). \ + Internal: the CLI should always set content-type: application/json" + )), + 422 => Err(format!( + "seed handler rejected (422 Unprocessable): KV write failed mid-stream: {}", + response.text().unwrap_or_default() + )), + other => Err(format!( + "seed handler returned unexpected status {other}: {}", + response.text().unwrap_or_default() + )), +} +``` + +The blocking client is fine for a CLI binary; it spins up its own +single-thread tokio runtime under the hood. No external runtime +needed. + +## Migration story (hard-cutoff) + +Existing Spin deployments break on upgrade. No legacy flag. + +- Apps that read config via `ctx.config_store_default()` keep working + unchanged after a `config push --adapter spin` against the new + backend. +- Apps that read config via `spin_sdk::variables::get(...)` directly + break. They must either (a) move to the EdgeZero abstraction, or + (b) keep their values in `[variables]` and stop using EdgeZero's + config store for those keys. +- Existing `spin.toml` files that declare config keys in + `[variables]` need a one-time migration: the values move from + `[variables].` (and `[component..variables].`) to + the KV store via `config push --adapter spin`. After confirming + the values land in KV, the operator manually removes the + now-orphaned `[variables].` entries. + +Migration guide section title: "Spin: variables → KV for config +(2026-Q3)". + +## Scope (files touched) + +### crates/edgezero-adapter-spin (the heavy crate) + +- `src/config_store.rs` — rewrite `SpinConfigStore` per + [D1](#d1-backend-spin-kv-via-spin_sdkkey_valuestore). Cfg-gated + backend enum: wasm variant holds the opened + `key_value::Store`; the `InMemory` test variant is keyed + plain `String → bytes::Bytes` (one store at a time — that's all + the contract-test macro exercises). Drop `translate_key`. +- `src/request.rs` — rewrite `build_config_registry` as **async** + per H1 (v5: returns `anyhow::Result` so registry-build errors + propagate up the dispatcher): + ```rust + async fn build_config_registry( + meta: Option, + env: &EnvConfig, + ) -> anyhow::Result> { + let Some(meta) = meta else { return Ok(None); }; + let mut by_id = BTreeMap::new(); + for id in meta.ids { + let label = env.store_name("config", id); // per-id env resolution + let store = SpinConfigStore::open(label).await + .map_err(|err| anyhow::anyhow!( + "open config store for id `{id}`: {err}" + ))?; + by_id.insert((*id).to_owned(), + ConfigStoreHandle::new(Arc::new(store))); + } + Ok(StoreRegistry::from_parts(by_id, meta.default.to_owned())) + } + ``` + And in `dispatch_with_registries`: + ```rust + let config_registry = build_config_registry(config_meta, env).await?; + ``` + Mirrors `build_kv_registry`'s existing async + Result shape. +- `src/cli.rs` — + - `push_config_entries`: HTTP POST against `seed_url` (resolved + from `AdapterPushContext` via D8). Body is the D9 schema. + Uses `reqwest` (D11/D12). Surfaces every status code from D9 + with clear messages (D12). + - `push_config_entries_local`: defaults `seed_url` to + `http://127.0.0.1:3000/__edgezero/config/seed` if + `AdapterPushContext` didn't supply one. Otherwise identical. + - `provision`: emit `key_value_stores = [...]` entries per D4. + Drop the `[variables]` / `[component..variables]` + config-declaration writes (the migration guide tells operators + to remove existing ones). + - `validate_app_config_keys`: no-op per D1.5. Delete + `translate_key_for_spin`. + - `validate_typed_secrets`: delete the collision-check block per + D6. Keep the secret-name format check. + - `single_store_kinds`: returns `&["secrets"]`. +- `src/seed.rs` — NEW. `SeedWriter` trait + `SpinKvSeedWriter` + + `handle_seed_request`. ~200 LoC + tests. +- `src/lib.rs` — `pub mod seed;`. Plus two functions sharing + the same concrete return type (v9 round-7 H2 fix — `run_app`'s + old `impl IntoResponse` opaque return type made the fall-through + uninvocable from `run_app_with_seeder`). **v11 round-9 M1**: + drop `IntoResponse` from the + `use spin_sdk::http::{IntoResponse, Request as SpinRequest, +Response as SpinResponse}` import line — once `run_app` returns + `SpinFullResponse`, `IntoResponse` is no longer referenced and + the wasm-clippy gate would fail on `unused_imports`. + + ```rust + pub async fn run_app(req: SpinRequest) + -> anyhow::Result { /* existing body */ } + + pub async fn run_app_with_seeder(req: SpinRequest) + -> anyhow::Result { + // Route /__edgezero/config/seed to the seed handler, else + // fall through to run_app::. v10 (round-8 H1): + // handle_seed_request_spin now also returns + // anyhow::Result, so both arms are + // type-compatible. + if req.uri().path() == "/__edgezero/config/seed" { + handle_seed_request_spin(req, &SpinKvSeedWriter, …).await + } else { + run_app::(req).await + } + } + ``` + + Changing `run_app` from `impl IntoResponse` → `SpinFullResponse` + is **source-compatible with the generated scaffold handler + signature** (NOT a Spin-variable backwards-compat carve-out — + this migration stays hard-cutoff). `SpinFullResponse: IntoResponse`, + so the existing + `async fn handle(req: Request) -> anyhow::Result` + template signature keeps accepting the value through type + coercion — no need to regenerate already-scaffolded projects. + Token resolved from + `EnvConfig::get(&["adapters", "spin", "seed_token"])`; if unset + / blank / shorter than 16 bytes (D9), every request hitting the + seed route returns 401 (fail-closed). + +- `src/templates/src/lib.rs.hbs` — scaffold uses + `run_app_with_seeder` per + [D9 opt-in scaffolding](#d9-seed-handler-security). +- `src/templates/spin.toml.hbs` — add + `key_value_stores = ["app_config"]` to the default + `[component.*]` block per M1. Scaffolded projects work with + `config push --adapter spin --local` out of the box. +- `Cargo.toml` — per D11: `reqwest` optional under `cli` feature + (host HTTP push); `serde`, `serde_json`, `subtle` non-optional + (used by both the wasm seed handler core and its host-compilable + unit tests, so feature-gating would break the test layer). + +### crates/edgezero-adapter (the trait) + +- `src/registry.rs` — `AdapterPushContext` struct + threaded + through `push_config_entries` / `push_config_entries_local` + per D8. + +### crates/edgezero-core + +- `src/manifest.rs` — `ManifestAdapterCommands::seed_url: +Option` per D8 (additive; `#[non_exhaustive]` already in + place). + +### crates/edgezero-cli + +- `src/args.rs` — `ConfigPushArgs::seed_url` / `seed_token` per D8. +- `src/config.rs` — per D8: `load_push_context` resolves the + `ResolvedAdapterPushContext` (owned `String`s) and stashes it + on the CLI's `PushContext`. `dispatch_push` constructs the + borrowing `AdapterPushContext<'_>` from it and hands that to + the trait method. Update the `push_args` test fixture. + +### examples/app-demo + +- `crates/app-demo-adapter-spin/src/lib.rs` — switch + `run_app` → `run_app_with_seeder`. +- `crates/app-demo-adapter-spin/spin.toml` — add `app_config` to + `key_value_stores = [...]`. Remove `[variables].greeting` / + `feature__new_checkout` / `service__timeout_ms` (now in KV). +- `edgezero.toml` — `[adapters.spin.commands].seed_url = +"http://127.0.0.1:3000/__edgezero/config/seed"` so contributors + don't need to set the env var locally. + +### Workspace + +- `Cargo.toml` — three changes: + - `reqwest`: add `blocking` + `json` features to the existing + workspace declaration so the CLI's sync push (D12) works: + `reqwest = { version = "0.13", default-features = false, +features = ["rustls", "blocking", "json"] }`. + - `subtle`: NEW workspace dep for constant-time token + comparison: `subtle = "2"` (non-optional per D11; used by + both the wasm seed handler core and its host tests). + - `serde` / `serde_json`: already workspace deps; just declared + as non-optional on `edgezero-adapter-spin` per D11. + +### docs + +- `guide/adapters/spin.md` — rewrite config-store section: + KV-backed, no `.→__` translation, no collision check. New + seed-handler section explaining the security model + token + rotation guidance. +- `guide/manifest-store-migration.md` — new section "Spin: + variables → KV for config". +- `guide/cli-walkthrough.md` — update the Spin row in the + `config push` section. Add a `config push --adapter spin --local` + example that mirrors the Fastly one. +- `guide/cli-reference.md` — document `--seed-url` / + `--seed-token` on `config push`. + +## Stages + +### Stage 1 — Spec promotion + tracking issue + +- [ ] Move this plan into + `docs/superpowers/specs/2026-06-01-spin-kv-config.md`. +- [ ] Open a tracking issue with the acceptance criteria + (matches Task 2.5 + Stage 8 — wasm KV hostcalls aren't + reachable under the CI wasm matrix's `wasmtime run`, so + real KV coverage lives in the `spin up` smoke test): - host-side `config_store_contract_tests!` passes against + the `InMemory` backend; - the wasm32-wasip2 contract test compiles + runs (no live + KV hostcalls — those are runtime-bound); - collision check gone; - provision writes the right `key_value_stores`; - seed handler hits all status codes from D9's table; - `app-demo` works end-to-end under `spin up` with real + KV writes via `config push --adapter spin --local`. + +### Stage 2 — Runtime backend swap + registry rewrite + +- [ ] **Task 2.1**: Rewrite `SpinConfigStore` per D1. +- [ ] **Task 2.2** (M4 fix): `InMemory` test backend is keyed + plain `String → bytes::Bytes`. (One store per + `config_store_contract_tests!` invocation — no need to track + labels at this layer. The multi-store seed-handler test + fixture `InMemorySeedWriter` IS the place that tracks + `(label, key)`; see D10.) **v6**: `get` uses strict + `String::from_utf8` (NOT `from_utf8_lossy`) to match the + wasm backend's error path. New contract-test case + `non_utf8_value_returns_unavailable` documents the + behaviour and prevents future divergence. +- [ ] **Task 2.3**: Delete `translate_key_for_spin` and its callers + inside `config_store.rs`. +- [ ] **Task 2.4** (H1 + M1): Rewrite `build_config_registry` in + `request.rs` as **async**. Per declared id, await + `SpinConfigStore::open(env.store_name("config", id))` so the + `key_value::Store` handle is opened ONCE at dispatch setup + and cached in `SpinConfigStore`. Thread `&env` to + `dispatch_with_registries`'s config branch. Missing + `key_value_stores = [...]` surfaces as a registry-build + error, not a first-read error. +- [ ] **Task 2.5** (M1 update): `config_store_contract_tests!` + against the `InMemory` backend on the **host** target. Real + KV write/read coverage CANNOT live in the wasm contract test + — CI runs that via plain `wasmtime run`, which does not host + Spin's KV hostcalls. Real coverage moves to the Stage 8 + end-to-end smoke test (which requires `spin up`). + +### Stage 3 — Seed handler + testable writer + +- [ ] **Task 3.1** (D10 split): `crates/edgezero-adapter-spin/src/seed.rs`. + Build the host-compilable core: `SeedWriter` trait, + `InMemorySeedWriter`, `handle_seed_request_core(req: &Request, + …) -> Response` using `edgezero_core::http` types only. NO + `spin_sdk` references in the core layer. +- [ ] **Task 3.2**: Host unit tests against `InMemorySeedWriter` + covering every row of the D9 status code table PLUS the + v6 short-token fail-closed cases (M1 fix). Required test + rows: - Token unset (env var missing) → 401. - Token blank ("") → 401. - Token whitespace-only (" ") → 401. - Token 15 bytes (one under the floor) → 401, EVEN when + the client offers the matching token on the wire. - Token exactly 16 bytes + matching wire token → 204. - Token 16 bytes + missing wire header → 401. - Token 16 bytes + wrong wire token → 403. - Non-POST method → 405. - `content-type` not `application/json` → 415. - Malformed JSON → 400. - Missing `store` / `entries` / non-string values → 400. - Unknown store (no env-resolved label matches) → 404. - `SeedWriter::write` errors mid-stream → 422. - Happy path → 204 + `InMemorySeedWriter` recorded all + entries. +- [ ] **Task 3.3** (H3): Token comparison uses + `subtle::ConstantTimeEq`. The `known_platform_labels` arg is + computed by the caller (the wasm wrapper / lib.rs) from + `A::stores().config` × `env.store_name("config", id)`. +- [ ] **Task 3.4** (D10 wrapper, wasm-gated): Thin + `rust + pub(crate) async fn handle_seed_request_spin( + req: spin_sdk::http::Request, + writer: &SpinKvSeedWriter, + valid_token: Option<&str>, + known_platform_labels: &[String], + ) -> anyhow::Result + ` + that translates Spin `Request` → `edgezero_core::http::Request` + via `into_core_request` (uses `?`), calls the core handler, + translates back via `from_core_response` (uses `?`). v10 + (round-8 H1): returns `anyhow::Result` so + `run_app_with_seeder`'s seed branch is type-compatible with + the fall-through `run_app::` branch. NO `.expect()` panic + in the request path. +- [ ] **Task 3.5** (M2 + v9 round-7 H2 + v10 round-8 H1 + v11 + round-9 M1): 1. Change `run_app`'s signature from + `anyhow::Result` to + `anyhow::Result` (concrete type already + publicly aliased). **Source-compatible with the generated + scaffold handler signature** (NOT a Spin-variable + carve-out — this migration stays hard-cutoff): + `SpinFullResponse: IntoResponse`, so the template + `async fn handle(...) -> anyhow::Result` + keeps compiling without re-scaffolding. + 1a. Drop `IntoResponse` from the + `use spin_sdk::http::{...}` import in `src/lib.rs` — once + `run_app` no longer returns `impl IntoResponse`, the + import is unused and the wasm-clippy `-D warnings` gate + fails on `unused_imports`. 2. Add `run_app_with_seeder` with the SAME return shape: + `rust + pub async fn run_app_with_seeder(req: SpinRequest) + -> anyhow::Result + ` + Routes `/__edgezero/config/seed` to + `handle_seed_request_spin(req, &SpinKvSeedWriter, …).await` + (returns `anyhow::Result` per Task 3.4) + and falls through to `run_app::(req).await`. Both + arms produce `anyhow::Result` so the + `if/else` typechecks and either result propagates via + the outer `?` at the handler call site. 3. Scaffold template handler stays + `async fn handle(req: Request) -> anyhow::Result` + with the body swapped from + `edgezero_adapter_spin::run_app::(req).await` to + `edgezero_adapter_spin::run_app_with_seeder::(req).await`. 4. Token resolved from `EnvConfig::get(&["adapters", "spin", + "seed_token"])`; if unset / blank / shorter than 16 bytes + (D9), every request hitting the seed route returns 401 + (fail-closed). + +### Stage 4 — CLI push rewrite + +- [ ] **Task 4.1** (D8): Add `AdapterPushContext` to the trait + (renamed from v4's `PushContext` to avoid colliding with + the CLI's internal `PushContext`). Update all four existing + impls to take it (no-ops for fastly/cloudflare/axum; spin + reads from it). +- [ ] **Task 4.2**: Add `seed_url` / `seed_token` to + `ConfigPushArgs`. Update the `push_args` test fixture and the + `app-demo-cli/tests/config_flow.rs` helper. +- [ ] **Task 4.3**: Rewrite `load_push_context` to resolve the + `ResolvedAdapterPushContext` (D3's disjoint prod/local + chains per D8). `dispatch_push` converts to the + borrow-shaped `AdapterPushContext<'_>` at call time. +- [ ] **Task 4.4** (D12): Implement spin `push_config_entries` via + `reqwest::blocking::Client::post`. The CLI must resolve the + body's `store` field to the **platform label** (via + `env.store_name("config", id)`), per H3. JSON body per D9. + Surface every status from D9's table — 400 / 401 / 403 / + 404 / 405 / 415 / 422 — per D12's match block. Handle + connection-refused with a specific hint ("is the spin app + running?"). +- [ ] **Task 4.5**: Implement spin `push_config_entries_local`. + Defaults `seed_url` to local. Otherwise delegates to the + Task 4.4 impl. +- [ ] **Task 4.6**: `--dry-run` prints the planned URL + entries + without POSTing. Tests for the dry-run shape. +- [ ] **Task 4.7** (v12 round-10 L1): **Delete and replace stale + Spin-variable push tests.** Today's push tests in + `examples/app-demo/crates/app-demo-cli/tests/config_flow.rs` + (around line 257) and `crates/edgezero-adapter-spin/src/cli.rs` + (around line 1846) assert: - dotted-key → underscore translation - `[variables].` writes - `[component..variables].` writes + Under KV-backed push these assertions are wrong (variables + table is no longer touched). Delete them; add coverage for + the new contract: - Push body contains the resolved platform-label `store` + (with and without `EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME=…` + override). - Push body's `entries` array is the flattened typed + `AppDemoConfig` minus `#[secret]` / `#[secret(store_ref)]` + (mirrors the existing config-flow assertions, just on the + body shape instead of the manifest edit). - `--dry-run` produces NO POST (verify via a mock seed + endpoint that records hits). - Each D9 status code surfaces as the matching D12 error + string (covers 400 / 401 / 403 / 404 / 405 / 415 / 422 + happy 204). + +### Stage 5 — Provision + scaffold + manifest updates + +- [ ] **Task 5.1**: Drop `[variables]` / + `[component..variables]` config-key writes from spin's + `provision`. +- [ ] **Task 5.2**: For each `[stores.config].id`, append the + platform name to the component's `key_value_stores = [...]`. + Idempotent. New `provision_writes_config_kv_store_entry` + test. +- [ ] **Task 5.3**: `single_store_kinds` returns `&["secrets"]`. +- [ ] **Task 5.4** (M1): Generator `spin.toml.hbs` declares + `key_value_stores = ["app_config"]` by default. Add a test + in `generated_project_builds.rs` that checks the rendered + spin.toml contains the entry. +- [ ] **Task 5.5** (v12 round-10 L1): **Delete stale + provision-side variable-write assertions** that pair with + the Stage 4.7 deletions. Concrete sites in + `crates/edgezero-adapter-spin/src/cli.rs` (around line 1846) + currently assert the provision step emits `[variables]` / + `[component..variables]` blocks for declared config + ids. Under D4 those writes are gone. Replace with assertions + that: - For each `[stores.config].id`, the platform label appears + in the component's `key_value_stores = [...]` (Task 5.2's + change). - `[variables]` / `[component..variables]` are NOT + touched for config ids (regression guard so a future + change doesn't silently revive the old path). - Existing `[variables]` entries for `#[secret]` fields + (Task 6.2 keeps these) are preserved. + +### Stage 6 — Validator changes + +- [ ] **Task 6.1** (H3): Delete uppercase/dash/leading-digit tests + on `validate_app_config_keys`. Replace with + `validate_app_config_keys_accepts_any_utf8`. +- [ ] **Task 6.2**: Delete `validate_typed_secrets`'s + collision-check block per D6. Keep the secret-name format + check (it still validates `#[secret]` values against Spin + variable rules). +- [ ] **Task 6.3**: Update strict-completeness tests: + `[stores.config].ids.len() > 1` now PASSES for spin. + +### Stage 7 — Docs + app-demo migration + +- [ ] **Task 7.1**: Rewrite `docs/guide/adapters/spin.md` config + section. Add seed-handler section with the D9 security table. +- [ ] **Task 7.2**: Add the migration section to + `docs/guide/manifest-store-migration.md`. +- [ ] **Task 7.3**: Update `docs/guide/cli-walkthrough.md` Spin row + add `--adapter spin --local` example. +- [ ] **Task 7.4**: Update `docs/guide/cli-reference.md` for + `--seed-url` / `--seed-token`. +- [ ] **Task 7.5**: app-demo migration in ONE commit (per + resolved Q5): switch entrypoint to `run_app_with_seeder`, + update `spin.toml`, set `seed_url` in `edgezero.toml`. + +### Stage 8 — Verify gate + +- [ ] Full gate: cargo fmt, host clippy --workspace, workspace + tests, all three adapter wasm-clippy gates, docs + lint/format/build. +- [ ] Spin wasm contract test under wasmtime (wasm32-wasip2). +- [ ] **Wasm dep gating checks** (D11, fixed per L1 — use + `cargo tree -i` which errors when the dep is absent). + ``sh + # reqwest MUST NOT leak into the wasm tree. `cargo tree -i` + # errors when reqwest isn't a dep; invert with `!`: + ! cargo tree -i reqwest -p edgezero-adapter-spin \ + --features spin --target wasm32-wasip2 2>/dev/null + # subtle / serde_json MUST be in the wasm tree. + cargo tree -i subtle -p edgezero-adapter-spin \ + --features spin --target wasm32-wasip2 + cargo tree -i serde_json -p edgezero-adapter-spin \ + --features spin --target wasm32-wasip2 + `` +- [ ] **End-to-end smoke test** in `examples/app-demo` (v11 + round-9 L1: shell-form, backgrounded, port-wait + trap + cleanup so the test can actually be run in CI / pasted + into a shell). + + ```sh + #!/usr/bin/env bash + set -euo pipefail + + readonly TOKEN="test-token-1234567890" + readonly PORT=3000 + readonly URL="http://127.0.0.1:${PORT}" + export EDGEZERO__ADAPTERS__SPIN__SEED_TOKEN="$TOKEN" + + cd examples/app-demo + + # 1. Build the wasm so `spin up` has something to serve. + (cd crates/app-demo-adapter-spin && \ + cargo build --target wasm32-wasip2 --release \ + -p app-demo-adapter-spin) + + # 2. Background `spin up` and arrange to kill it on exit. + (cd crates/app-demo-adapter-spin && spin up --listen "127.0.0.1:${PORT}") \ + &> /tmp/edgezero-spin-smoke.log & + readonly SPIN_PID=$! + trap 'kill $SPIN_PID 2>/dev/null || true; wait $SPIN_PID 2>/dev/null || true' \ + EXIT INT TERM + + # 3. Wait up to 10s for the listener (Spin warm-up + KV + # backend init). 20 × 0.5s = 10s. Fail clean on timeout. + for _ in $(seq 1 20); do + if curl --silent --fail --max-time 1 "${URL}/" \ + > /dev/null 2>&1; then + break + fi + sleep 0.5 + done + if ! curl --silent --fail --max-time 1 "${URL}/" \ + > /dev/null 2>&1; then + echo "spin up did not bind ${URL} within 10s" >&2 + tail -n 100 /tmp/edgezero-spin-smoke.log >&2 + exit 1 + fi + + # 4. Push config to the LOCAL endpoint. The token env var + # is inherited from the parent shell (line 5). + cargo run -p app-demo-cli --quiet -- \ + config push --adapter spin --local + + # 5. Assert the pushed value flows through to the handler. + readonly GOT="$(curl --silent --fail "${URL}/config/greeting")" + readonly WANT="hello from app-demo" + if [[ "$GOT" != "$WANT" ]]; then + echo "smoke test FAILED: got=${GOT@Q} want=${WANT@Q}" >&2 + exit 1 + fi + echo "smoke test PASSED: GET /config/greeting → ${GOT@Q}" + # trap kills SPIN_PID on exit. + ``` + + The token value (`test-token-1234567890`, 21 bytes) clears + the v6 16-byte floor on BOTH sides (server `spin up` + inherits the var; CLI `config push` inherits the var). + The `trap` ensures no orphan `spin up` lingers on port 3000 + if the assertion fails — important for re-runnability. + +## Open questions + +None outstanding. All round-2/3/5 questions are settled. See the +"Settled" section below for the historical decisions. + +## Settled + +- **Q1 (round 2) → YES**: `[adapters.spin.commands].seed_url` IS a + valid source (third in the resolution order after CLI flag and + env). `seed_token` stays env/CLI only — never manifest. +- **Q2 (round 5) → YES, 16-byte floor**: The seed handler rejects + tokens shorter than 16 bytes at startup with a fail-closed 401 + on every request. See D9 "Fail-closed contract" for rationale. +- **Q3 (round 2) → ONE COMMIT**: Stage 7.5 ships + `run_app_with_seeder` switch + `spin.toml` KV declaration + + `edgezero.toml` seed_url together for atomic reversibility. + +## Estimated scope (v4) + +- **Code**: 14 files modified, 1 new (`seed.rs`), ~820 LoC impl + - ~430 LoC tests. (Up from v3 — D1's cfg-gated backend enum, + the H4 disjoint local resolution chain in `dispatch_push`, and + the extra D12 status-code arms add ~70 LoC; H2/H3 non-optional + dep moves are zero-LoC on the runtime side.) +- **Docs**: 4 files modified, ~100 LoC prose. +- **Migration**: hard-cutoff (resolved per L1). +- **Time**: 2 focused days assuming no surprises in the spin + hostcall surface. + +## Risks (v2 additions) + +- **`PushContext` is a breaking trait change for any out-of-tree + adapter**. Document in release notes; no in-tree adapter outside + the four ships today. +- **`reqwest` adds ~3 MB to the host CLI binary**. Acceptable for + a dev tool; flag if it ever becomes a problem. +- **Token enforcement in CI**: the end-to-end smoke test needs the + `EDGEZERO__ADAPTERS__SPIN__SEED_TOKEN` env var to flow into both + `spin up` and `app-demo-cli`. Test harness sets it once. diff --git a/docs/superpowers/plans/2026-06-04-spin-per-backend-push.md b/docs/superpowers/plans/2026-06-04-spin-per-backend-push.md new file mode 100644 index 00000000..a04781a9 --- /dev/null +++ b/docs/superpowers/plans/2026-06-04-spin-per-backend-push.md @@ -0,0 +1,402 @@ +# Plan: Replace Spin Seed Handler with Per-Backend Writers + +**Status:** v1 — drafted 2026-06-04 in response to PR-thread security +concern that exposing `/__edgezero/config/seed` on every deployed Spin +app creates a permanent internet-facing attack surface owned by +EdgeZero, even with the Pass 1-7 hardening we just landed (16-byte +constant-time token, 256 KiB body cap, 1000-entry/64 KiB caps, +fail-closed token-first ordering). + +**Goal:** Get `config push --adapter spin` off our embedded HTTP +endpoint and onto the runtime backend's own protocol — matching the +pattern Cloudflare (`wrangler kv bulk put`) and Fastly +(`fastly config-store-entry create`) already use. Delete the seed +handler from prod entirely; the only writers are (a) Spin's own SQLite +backend file for local dev and (b) `spin cloud key-value set` for +Fermyon-hosted prod. + +## Why this PR, not a follow-up + +The seed handler is currently default-on in the scaffold +(`run_app_with_seeder`). Every project generated by `edgezero new +--adapter spin` would ship the endpoint to prod. We can't ship that +default and clean it up later; the right move is to land the +deletion + replacement in the same PR that introduced the migration. + +## Design + +### Architecture (current → target) + +``` +BEFORE (Pass 1-7): + config push --adapter spin + └─> HTTP POST https:///__edgezero/config/seed + └─> run_app_with_seeder intercepts before app router + └─> seed::SpinKvSeedWriter.set(label, key, value) + └─> spin_sdk::key_value::Store::set (inside wasm) + +AFTER (this plan): + config push --adapter spin + └─> parse runtime-config.toml next to spin.toml + └─> dispatch on backend type: + ┌─ type = "spin" → rusqlite-direct write to .spin/sqlite_key_value.db + ├─ Fermyon Cloud → shell `spin cloud key-value set` per entry + │ (auto-detected from `[adapters.spin.commands].deploy` + │ containing `spin deploy` or `spin cloud deploy`) + ├─ type = "redis" → error: "use `redis-cli SET` directly" + └─ type = "azure" → error: "use `az cosmosdb` directly" +``` + +Per-backend writers mirror what Cloudflare (Wrangler) and Fastly +(Fastly CLI) already do. No internet-facing endpoint owned by us; no +embedded HTTP write surface in the deployed wasm component; no token +rotation; no `--seed-url`/`--seed-token` chain to defend. + +### SQLite-direct writer for `type = "spin"` + +Spin's `key-value-spin` crate +([`crates/key-value-spin/src/store.rs`](https://github.com/spinframework/spin/blob/main/crates/key-value-spin/src/store.rs)) +uses one table with this exact schema: + +```sql +CREATE TABLE IF NOT EXISTS spin_key_value ( + store TEXT NOT NULL, + key TEXT NOT NULL, + value BLOB NOT NULL, + PRIMARY KEY (store, key) +) +``` + +Spin's `SET` statement is: + +```sql +INSERT INTO spin_key_value (store, key, value) VALUES ($1, $2, $3) +ON CONFLICT(store, key) DO UPDATE SET value=$3 +``` + +Our writer uses the SAME `CREATE TABLE IF NOT EXISTS` and the SAME +`INSERT … ON CONFLICT` statement. The file lives at +`/.spin/sqlite_key_value.db` by default (Spin's +hard-coded default for `DatabaseLocation::Path`); operators can +override per-label via `[key_value_store.
__…__`. `` is +`[app].name` uppercased with `-`→`_`. `__` separates every nesting +level; a single `_` is literal. + +**Deterministic, ambiguity-rejecting matching.** Each config key is +transformed to its env-segment form (uppercase, `_` left as-is) and +compared exactly. Two sibling keys mapping to the same segment is an +`AppConfigError`. + +**Type coercion.** The env string is parsed against the existing TOML +value's type; parse failure → `AppConfigError`. + +**Scope.** `config validate` and `config push` both see env-resolved +values; `--no-env` disables the overlay. `--no-env` is implemented by +calling `load_app_config_with_options` (§4) with +`AppConfigLoadOptions { env_overlay: false }`; the default (no flag) +uses the simple `load_app_config` form (overlay on). The axum demo +server (the `demo` subcommand) resolves via the same path. + +Note the deliberate consistency: the env separator (`__`) is the same +as the Spin config-key separator (§6.4/§6.7). + +### 6.12 `Default` on `*Args` + +Non-subcommand `*Args` derive `Default` (external construction despite +`#[non_exhaustive]`). Subcommand-wrapping `AuthArgs` does not (a +defaulted required subcommand could leak into a real auth path); +external tests construct it via `clap::Parser::try_parse_from`. + +### 6.13 Documentation updates (definition-of-done for every stage) + +This effort changes the manifest schema, the runtime store API, the +CLI surface, and the `dev`→`demo` subcommand. The VitePress docs site +under `docs/guide/` has existing pages describing all of these, which +go stale. **Updating documentation is part of every stage's +definition-of-done** — a stage that changes user-facing behaviour +updates the affected `docs/guide/` pages _in the same stage_, so the +PR never has a docs-lag window. The docs CI (ESLint + Prettier on +`docs/`) must pass. + +Affected existing pages and the stage that owns each update: + +| Page | What changes | Stage | +| ----------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | +| `docs/guide/cli-reference.md` | `dev`→`demo` rename; `edgezero-cli` as a library; new `auth` / `provision` / `config` commands | 1, 5, 6, 7 | +| `docs/guide/configuration.md` | new `[stores]` logical-id schema + per-adapter mapping + capability rules; removal of `[stores.config.defaults]`; the `.toml` app-config file + env overlay | 2, 3 | +| `docs/guide/kv.md` | multi-store model, `ctx.kv_store(id)` / bound handles, `Kv` extractor `default()`/`named()` | 2 | +| `docs/guide/handlers.md` | extractor refactor; async `ConfigStore`; reading config/secrets by logical id | 2 | +| `docs/guide/getting-started.md` | generator now scaffolds `-cli` and `.toml` | 1, 3 | +| `docs/guide/adapters/cloudflare.md` | config store moves `[vars]` → KV | 2 | +| `docs/guide/adapters/overview.md` + Spin adapter docs | Spin store semantics (KV labels, flat-variable config/secrets) | 2 | +| `docs/guide/architecture.md` | light review — store/adapter description | 2 | + +New pages (created in their owning stage): + +- `docs/guide/manifest-store-migration.md` — stage 2 (how to migrate a + pre-rewrite `edgezero.toml`). +- `docs/guide/cli-walkthrough.md` — stage 8 (full `myapp` loop). + +Stage 8 additionally performs a **documentation audit**: grep the +`docs/` tree for stale references (old manifest store keys, the `dev` +subcommand, the old single-store runtime API) and confirm none remain; +verify every page is listed in the `docs/.vitepress/config.mts` +sidebar. The audit is a checklist item in stage 8's ship gate. + +--- + +## 7. Sub-project 1 — Extensible `edgezero-cli` library + generator + `app-demo-cli` skeleton + +**Goal:** establish the substrate. + +**Source changes:** promote `Command` variant fields into +`#[derive(clap::Args)]` structs (`#[non_exhaustive]`, `Default` per +§6.11); add `lib.rs` with `run_*` handlers; shrink `main.rs`; move +existing tests to `lib.rs`; extend the generator to scaffold +`crates/-cli`; add the handwritten `examples/app-demo/crates/ +app-demo-cli` parallel. + +The `dev` subcommand is renamed to **`demo`** — it runs the example +app locally on axum, which is a demo workflow, not a dev workflow; the +name `dev` is reserved for a future dev-workflow command. Stage 1 +renames the CLI's `dev_server` module to `demo_server`, the public +function `run_dev` to `run_demo`, and the `Command::Dev` variant to +`Command::Demo`. `run_demo` returns `Result<(), String>` (consistent +with the other `run_*` functions) — `Ok(())` on graceful shutdown, +`Err(String)` on startup failure (e.g. port bind). It is **not** +`-> !` — the demo server is allowed to return. The current +`dev_server::run_dev()` returns `()`; stage 1 adjusts that boundary. +(The `edgezero-adapter-axum` crate's own internal `dev_server` module +is not user-facing and is left as-is.) + +**Tests:** existing tests pass post-relocation; `tests/lib_consumer.rs`; +`app-demo-cli/tests/help.rs`; generator structure test. + +**Ship gate:** existing `edgezero` commands keep the same flags; +`app-demo-cli --help` shows the four downstream built-ins (`build`, `deploy`, `new`, `serve`); `edgezero new +throwaway-app && cargo check --workspace` succeeds. + +## 8. Sub-project 2 — Manifest + runtime rewrite (atomic, all four adapters) + +**Goal:** the big atomic sub-project. The manifest becomes portable and +non-adapter-specific (§6.6), adapter config moves to `EDGEZERO__*` +environment variables, and the runtime store API is rewritten. With a +hard cutoff these ship together as one stage (stage 2 of the +eight-stage PR). + +**Scope:** + +- **Manifest → portable schema:** rewrite `ManifestStores` to the §6.6 + portable schema — `[stores.]` carries only logical `ids` / + `default`. The `[adapters.*]` store/runtime tables are removed. + Legacy fields are a hard load error. +- **`EDGEZERO__*` env-config layer:** a new `edgezero-core` module + parses `EDGEZERO__`-prefixed environment variables (`__` nesting) + into adapter runtime config — store platform names + tuning, bind + host/port, logging. Absent variables fall back to defaults (§6.6). +- **No compiled-in manifest:** `run_app` drops its `manifest_src` + parameter on all four adapters. The `app!` macro bakes the portable + config (routes + logical store registry) into the `App` / `Hooks` + type; `run_app::()` reads it from `A` and layers `EDGEZERO__*` env + config on top. `include_str!("edgezero.toml")` is removed everywhere. +- **`ConfigStore` async:** `get` becomes `async` + (`#[async_trait(?Send)]`). +- **New `KvError` variants:** add `KvError::Unsupported` (Spin TTL + writes, §6.7) and `KvError::LimitExceeded` (Spin listing past + `max_list_keys`, §6.7), each with a 5xx-class `EdgeError` mapping. +- **Bound handles:** `BoundKvStore` / `BoundConfigStore` / + `BoundSecretStore`; `RequestContext` accessors id-keyed, with + `_default()` helpers. +- **Static metadata:** `Hooks` / `ConfigStoreMetadata` rewritten to + id-keyed metadata; `app!` macro emits them from the portable schema. +- **Adapter store rewrites — ALL FOUR adapters:** each builds a + `StoreRegistry` keyed by logical id, platform names resolved from + `EDGEZERO__STORES__*` (or the id default): + - **axum:** local KV registry; config from + `.edgezero/local-config-.json` (§15); secrets from env vars. + - **cloudflare:** KV registry; **config rewritten `[vars]` → KV** + with async reads; secrets from worker secrets. + - **fastly:** KV / config / secret store registries. + - **spin:** wire `SpinKvStore` (label registry, `max_list_keys` + respected), `SpinConfigStore` (single flat-variable store, `.`→`__` + key translation), `SpinSecretStore` (single flat-variable store) + into the registry; KV labels come from + `EDGEZERO__STORES__KV____NAME`, not hardcoded defaults. +- **Extractors:** `Kv` / `Secrets` refactored to `default()` / + `named()`; `Config` extractor added. +- **`[stores.config.defaults]` removed** (hard error). Replaced by the + axum config-store file flow (§15). The axum dev-server config seeding + is removed. +- **Migrate in-tree:** `examples/app-demo/edgezero.toml` rewritten to + the portable schema (≥2 KV ids `sessions`+`cache`; one config id; + one secrets id). The app-demo adapter crates' `EDGEZERO__*` env + config lives in their run configuration. `app-demo` handlers are + migrated **only for the store-accessor change** — `ctx.kv_store(id)` + / `config_store` / the refactored `Kv` / `Secrets` / `Config` + extractors. Stage 2 does **not** introduce `AppDemoConfig` or any + typed-app-config handler work: that lands in stage 3 (§9). This keeps + stage 2 independently buildable. +- **`docs/guide/manifest-store-migration.md`** published. + +**Tests:** manifest round-trip + validation (non-empty ids; default +required when `ids.len() > 1`; pre-rewrite manifest → hard error with +migration message); `EDGEZERO__*` env-layer parsing (nesting, defaults, +store-name resolution); `run_app` builds and runs with no manifest file +and zero env vars; id-keyed contract-test factories across all four +adapters; cross-adapter named-KV test; Cloudflare config-from-KV async +round-trip; Spin config `.`→`__` translation test; **Spin TTL write +returns `KvError::Unsupported`** (contract test); Spin KV listing-cap +pagination test; `Kv`/`Secrets`/`Config` extractor tests; `app!` macro +metadata registry test. + +**Bisectability — config seeding before `config push` exists.** Stage +2 removes `[stores.config.defaults]` and makes the axum config store +read `.edgezero/local-config-.json`, but `config push` (which +_writes_ that file) does not land until stage 7, and `edgezero demo`'s +auto-regeneration of the file depends on the stage-3 loader and the +stage-7 resolve-and-write step. So between stage 2 and stage 7: + +- The axum config store's backing-file **contract** is what stage 2 + establishes; stage 2 does not need anything to _produce_ the file. +- Stage 2's axum config-store tests **write the JSON fixture file + directly** in test setup (a temp-dir fixture) — they exercise the + read path without depending on `config push`. +- `app-demo`'s stage-2 state: if no fixture file is present the axum + config store is empty (the documented "absent → empty" behaviour). + Any stage-2 `app-demo` test that asserts a config value seeds the + fixture file itself. The full `config push` → running-demo-server + read-back end-to-end test lands in stage 8. + +This keeps stage 2 independently buildable and testable. + +**Ship gate:** multi-store handlers work on axum, cloudflare, fastly, +and spin; async config reads work; an adapter binary builds and runs +with no `edgezero.toml` and zero env vars (falling back to defaults); +all five CI gates green (including the wasm32 spin gate). + +## 9. Sub-project 3 — App-config schema, derive macro, env-overlay loader + +**Goal:** the `.toml` format, `#[derive(AppConfig)]`, and the +generic loader with env-var overlay (§6.10). + +**Source changes:** `edgezero-core::app_config`; `edgezero-macros` +`AppConfig` derive + `#[proc_macro_derive]` export; generator +templates for `.toml` (with a nested `[service]` table at the +root — no `[config]` wrapper) and `-core/src/config.rs` (with +`#[serde(deny_unknown_fields)]`); `examples/app-demo/app-demo.toml` + +- `app-demo-core/src/config.rs`. + +**Generated template vs the `app-demo` example — deliberately +different.** The **generated** `-core/src/config.rs` (what +`edgezero new` scaffolds) is the _common-case_ starting point: a +`greeting` field, a nested `[service]` table (to exercise the env +overlay), and a single plain `#[secret]` field as the common +secret pattern. It does **not** include `#[secret(store_ref)]` — +`store_ref` only buys multiple secret stores on a Fastly-only project +(§6.8), so putting it in every fresh scaffold would teach the edge +case as the default. A commented line in the template shows how to add +`#[secret(store_ref)]` if needed. The **`app-demo` example** is the +opposite: it deliberately exercises _everything_, so its +`app-demo-core/src/config.rs` includes a nested section, one +`#[secret]`, **and** one `#[secret(store_ref)]` — `app-demo` is the +full-capability showcase, not a representative new project. + +**Tests:** `load_app_config` (valid, missing file, bad TOML, +validator failure); env-overlay tests (top-level, nested `__`, type +coercion, parse failure, ambiguous key → error, `--no-env`); +round-trip for `AppDemoConfig`; macro tests for all §6.8 +compile-error constraints. + +**Ship gate:** `AppDemoConfig::SECRET_FIELDS` matches; `load_app_config` +succeeds; `APP_DEMO__SERVICE__TIMEOUT_MS` overrides the nested value +in a test. + +## 10. Sub-project 4 — `config validate` command + +```rust +#[derive(clap::Args, Default, Debug)] +#[non_exhaustive] +pub struct ConfigValidateArgs { + #[arg(long, default_value = "edgezero.toml")] pub manifest: PathBuf, + #[arg(long)] pub app_config: Option, + #[arg(long)] pub strict: bool, + #[arg(long)] pub no_env: bool, +} +``` + +Bound: `DeserializeOwned + Validate + AppConfigMeta` (no `Serialize`). + +App-config validation: TOML syntax; deserialises into `C`; types; +`validator` rules; unknown fields rejected when `C` opts in; +`#[secret]` non-empty; `#[secret(store_ref)]` in +`[stores.secrets].ids`. **When `spin` is in the adapter set**, three +additional Spin checks (all per §6.7): + +1. every flattened config key, `.`→`__` translated, matches + `^[a-z][a-z0-9_]*$` — **typed and raw** (both flavours have the + config keys); +2. the effective Spin variable name set — {flattened config keys} ∪ + {`#[secret]` field values}, after `.`→`__` translation — has no + duplicate (config/secret namespace collision check). **Typed + only** — `#[secret]` fields are identified via + `AppConfigMeta::SECRET_FIELDS`, which the raw flavour does not + have. `run_config_validate` (raw) cannot tell which keys are + secrets, so it performs check 1 and check 3 but **not** check 2; + its diagnostics say so. The collision check is therefore guaranteed + only for the typed path, which is the one downstream CLIs wire up; +3. Spin component discovery resolves (exactly one `[component.*]` in + `spin.toml`, or an explicit, matching `[adapters.spin.adapter] +.component`) — **typed and raw** (manifest-based, no struct + needed). + +Manifest: `ManifestLoader` checks; under `--strict`, capability-aware +completeness and well-formed handler paths. + +**Tests:** dedicated fixtures per failure mode incl. all three Spin +checks above (key-syntax, collision, component discovery); env-overlay +on/off. + +**Ship gate:** `app-demo-cli config validate --strict` exits 0; +corrupted fixtures fail with expected messages. + +## 11. Sub-project 5 — `auth` command (adapter-trait dispatch) + +```rust +#[derive(clap::Args, Debug)] // NO Default — §6.11 +#[non_exhaustive] +pub struct AuthArgs { #[command(subcommand)] pub sub: AuthSub } + +#[derive(clap::Subcommand, Debug)] +pub enum AuthSub { + Login { #[arg(long)] adapter: String }, + Logout { #[arg(long)] adapter: String }, + Status { #[arg(long)] adapter: String }, +} +``` + +UX: `auth login --adapter cloudflare`. Dispatch follows the same +path as `build` / `deploy` / `serve`: `AdapterAction::AuthLogin` / +`AuthLogout` / `AuthStatus` extend the existing +`edgezero_adapter::registry::AdapterAction` enum, and each +`edgezero-adapter-*` crate implements the variants in its own +`Adapter::execute` impl (shell out, HTTP call, or no-op — the CLI +doesn't care). Per-project override via +`[adapters..commands].auth-{login,logout,status}` in +`edgezero.toml`, same precedence as `build` / `deploy` / `serve`. + +Built-ins (each in its adapter crate): + +- axum: no-op (no remote auth surface). +- cloudflare: `wrangler login/logout/whoami`. +- fastly: `fastly profile create/delete/list`. +- spin: `spin cloud login/logout/info`. + +The standalone `CommandRunner` indirection originally sketched here +was dropped: each adapter chooses its own implementation mechanism +and is responsible for its own testability. The CLI's `auth.rs` is +a five-line args-to-action delegate to `adapter::execute`. + +**Tests:** the orchestration test mirrors `build`/`deploy`/`serve` — +configure `[adapters..commands].auth-login = "echo logged in"` +in a fixture manifest and assert dispatch succeeds. The real native +CLIs are not exercised in CI (§13). + +## 12. Sub-project 6 — `provision` command + +```rust +#[derive(clap::Args, Default, Debug)] +#[non_exhaustive] +pub struct ProvisionArgs { + #[arg(long, default_value = "edgezero.toml")] pub manifest: PathBuf, + #[arg(long)] pub adapter: String, + #[arg(long)] pub dry_run: bool, +} +``` + +Iterate every id in `[stores.].ids`. Per-adapter behaviour: + +**axum** — no remote resources. `provision --adapter axum` is an +explicit no-op: it prints, for each store, "axum store `` is local +(KV in-memory; config in `.edgezero/local-config-.json`; secrets +from env vars) — nothing to provision." Exit 0. + +**cloudflare** — for KV and config ids: `wrangler kv namespace create +`; parse the namespace id from stdout; patch `wrangler.toml` +`[[kv_namespaces]] binding = ""`, `id = ""`. Secrets: +no-op (worker secrets are runtime-managed via `wrangler secret put`). + +**fastly** — for each id: `fastly -store create --name=`; +ensure `fastly.toml` contains `[setup._stores.]` and +`[local_server._stores.]` table entries (keyed by the +resource-link name = our `name`). Store IDs are not persisted; `config +push` resolves them on demand (§13). + +**spin** — no remote `create` step (Spin KV stores and variables are +provisioned by the Spin runtime / Fermyon at deploy). `provision +--adapter spin` performs **KV-label `spin.toml` writeback only**: + +- KV: ensure each KV label (resolved from + `EDGEZERO__STORES__KV____NAME`, defaulting to the logical id) + appears in the resolved component's `key_value_stores` array field + (`key_value_stores = [...]` under `[component.]`). +- **Config and secret variables are NOT handled by `provision`.** The + manifest only carries store _ids_, not app-config field keys or + secret key names — `provision` cannot know which Spin variables to + declare. Config-variable declaration is done by `config push +--adapter spin` (which loads `.toml` and therefore knows the + keys; see §13). Secret-variable declaration is **manual** — the + developer declares Spin secret variables in `spin.toml` themselves + (§6.7); the CLI never writes secret variables. + +Component resolution for the KV writeback follows §6.7's rule. No +shell-out for Spin — it is pure manifest editing. + +`--dry-run` prints the would-be commands and would-be manifest +edits without performing them. + +**Tests:** each adapter crate owns its per-(adapter, kind) writeback +tests (temp-fixture writeback for `wrangler.toml`, `fastly.toml`, +and the Spin `key_value_stores` array in `spin.toml`; axum no-op +output asserted). The CLI's orchestration test asserts dispatch +and `--dry-run` short-circuits without invoking the adapter; +`--dry-run` performs nothing. + +## 13. Sub-project 7 — `config push` command + +```rust +#[derive(clap::Args, Default, Debug)] +#[non_exhaustive] +pub struct ConfigPushArgs { + #[arg(long, default_value = "edgezero.toml")] pub manifest: PathBuf, + #[arg(long)] pub adapter: String, + #[arg(long)] pub store: Option, // logical config id; default resolved + #[arg(long)] pub app_config: Option, + #[arg(long)] pub no_env: bool, + #[arg(long)] pub dry_run: bool, +} +``` + +Bound: `DeserializeOwned + Validate + Serialize + AppConfigMeta`. + +**Behaviour:** strict pre-flight validation; load app-config (env +overlay unless `--no-env`); flatten + serialise per §6.4/§6.5 (skip +`SECRET_FIELDS`); resolve target id (`--store` or resolved default). +Push is **split by adapter** — there is no single "resource-ID" model: + +| Adapter | Push behaviour | +| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| axum | Write resolved values to `.edgezero/local-config-.json` (the file the axum config store reads, §15). No runner call. | +| cloudflare | Read the namespace id from `wrangler.toml` (error "did you run `provision`?" if absent); `wrangler kv bulk put --namespace-id=`. Keys in dotted form. | +| fastly | Resolve the store id on demand: `fastly config-store list --json`, match by ``; per key `fastly config-store-entry create --store-id= --key= --value=` (`--stdin` for large values). Keys in dotted form. | +| spin | Declare + set each config value as a Spin variable, writing **both** `spin.toml` tables (see below). Keys in `.`→`__` lowercase form (§6.7). No remote call — live Fermyon Cloud variable push is out of scope (§2). | + +**Spin `config push` writes two `spin.toml` tables.** A Spin variable +is not readable by a component unless it is both _declared_ and +_bound_. `config push --adapter spin` therefore writes: + +1. `[variables].` — the application-level variable declaration, + with `default = ""`. +2. `[component..variables].` — the component binding, + ` = "{{ }}"`, surfacing the application variable into the + component. Without this, the component cannot read the variable. + +If the component-bindings table is missing entries for keys this push +needs and `config push` cannot resolve the component (§6.7), it +errors rather than writing a half-configured manifest. The component +is resolved per §6.7's discovery rule. Config-variable _declaration_ +lives here (not in `provision`) because only `config push` loads +`.toml` and thus knows the keys. Secret variables remain manual +(§6.7) — `config push` skips `SECRET_FIELDS` and never writes secret +variables. + +**Tests:** typed + raw; per-adapter mock-runner / fixture with golden +payloads; `#[secret]` / `#[secret(store_ref)]` absent from payload; +missing native-manifest id (cloudflare) → clear error; Spin key +`.`→`__` translation asserted; Spin writeback updates **both** +`[variables]` and `[component..variables]`; Spin push errors +when the component cannot be resolved; `--store` selection; `--dry-run` +performs nothing; env-overlay on vs `--no-env`. **Explicit "validate +passes, push serialization fails" cases:** non-object typed config, +unsupported compound shape, `skip_serializing_if`, `Option::None`, +`#[serde(flatten)]` on a non-secret field. + +**Spin `spin.toml` golden test.** A golden-file test captures the +generated `spin.toml` after a Spin `config push` and asserts: every +written variable name matches `^[a-z][a-z0-9_]*$` (§6.7); the +generated manifest **parses** (round-trips through the same TOML / +Spin-manifest parser the runtime uses), so the `^[a-z][a-z0-9_]*$` +rule cannot silently drift from Spin's actual manifest behaviour. + +**Validation strength, strongest first:** the test uses the strongest +check available in its environment. (1) If the `spin` CLI is present +(the wasm32 spin CI job already installs it), the test runs Spin's own +manifest validation against the generated file — this is authoritative +and catches semantic errors a plain TOML parse cannot. (2) Else if +`spin_sdk` exposes a manifest-validation entry point, it calls that. +(3) Otherwise it falls back to `toml` parsing + the variable-name +regex. The regex is the **floor**, not the ceiling — the +implementation prefers real Spin validation wherever it is reachable +and treats the TOML-only fallback as the weakest acceptable check. +The golden file is regenerated only on an intentional format change. + +**Ship gate:** `app-demo-cli config push --adapter cloudflare +--dry-run` and `--adapter spin --dry-run` each show the expected +output; secret fields absent; Spin keys `__`-encoded. + +## 14. (reserved — sub-project numbering uses the `#` column in §16) + +## 15. Sub-project 8 — `app-demo` integration polish (all four adapters) + +**Goal:** `app-demo` demonstrates the **full** feature set in CI across +all four adapters. + +- **Extensible CLI:** `app-demo-cli` with the four downstream built-ins plus + `Auth`, `Provision`, `Config` (`Validate` / `Push`); the `Config` + arm wired to the **typed** functions with `AppDemoConfig`. +- **Multi-store manifest + runtime:** `edgezero.toml` declares 2 KV ids + (`sessions`, `cache`), one config id, one secrets id, with per-adapter + mappings for **all four** adapters (Spin KV labels included). The + Spin capability rule is satisfied (one config id, one secrets id). +- **Multi-store runtime:** handlers read both `sessions` and `cache` + via the `Kv` extractor's `named()`. +- **Async config:** a handler does + `ctx.config_store_default()?.get("greeting").await?`. +- **Nested config + Spin key encoding:** `AppDemoConfig.service. +timeout_ms` is read at runtime; the Spin path proves `.`→`__` + translation. +- **Env-var override:** an integration test sets + `APP_DEMO__SERVICE__TIMEOUT_MS` and asserts the override. +- **Secrets:** one `#[secret]` (`api_token`) and one + `#[secret(store_ref)]` (`vault`); a handler reads each. `app-demo` + targets all four adapters, so `[stores.secrets].ids` has exactly one + id (§6.6 capability rule) and the `vault` field's value **is** that + single secrets id — the walkthrough doc explicitly shows + `#[secret(store_ref)]` resolving to the one declared id for an + all-four-adapter app (§6.8). `app-demo`'s `spin.toml` **manually + declares** its Spin secret variables (with `secret = true`, bound + under `[component..variables]`), demonstrating the §6.7 + manual-secret rule. The `app-demo-core` handler keeps its + `#[secret(store_ref)]` runtime key clear of every config key so the + Spin flat namespace does not collide. +- **Spin component:** `app-demo`'s `spin.toml` is single-component, so + component discovery resolves implicitly; the walkthrough doc also + shows the explicit `[adapters.spin.adapter].component` form. +- **`config validate` / `config push`:** CI runs `config validate +--strict` (exit 0 — including the three Spin checks of §10) then + `config push --adapter axum` and reads the value back through a + running axum demo server on `/config/greeting`. `config push + --adapter spin --dry-run` is asserted to **print** the would-be + `__`-encoded keys and the would-be content of **both** `spin.toml` + tables — and the on-disk `spin.toml` is asserted **unchanged** + (dry-run never mutates). The non-dry-run Spin push writing both + tables is covered by stage 7's tests, not the dry-run assertion. +- **`auth` / `provision`:** dispatch tests in `edgezero-cli` use + fixture manifests with `auth-login = "echo logged in"` (etc.) and + assert that `adapter::execute` is reached for the right + `AdapterAction`. The actual native-CLI invocation and any manifest + writeback live in each adapter crate's own tests (temp-fixture + writeback for `wrangler.toml`, `fastly.toml`, and the Spin + `key_value_stores` array in `spin.toml`). Spin `provision` is + asserted to write only the `key_value_stores` array, not + variables. + +**Axum config store backing.** The axum config store is backed by +`.edgezero/local-config-.json` (gitignored). `config push +--adapter axum` writes it from `.toml` (env overlay applied); +the axum config store reads the same file; `edgezero demo` regenerates +it at startup. If absent, the axum config store is empty. + +**Docs:** create `docs/guide/cli-walkthrough.md` (full `myapp` loop — +`new`, `auth`, `provision`, `config validate`, `config push`, `deploy`, +the `demo` subcommand, an env-override example, all four adapters, +including the manual Spin secret-variable `spin.toml` entries and the +explicit `[adapters.spin.adapter].component` form). Update +`docs/.vitepress/config.mts` so the sidebar lists `cli-walkthrough.md` +and `manifest-store-migration.md`. + +**Documentation audit (§6.12).** Stage 8 finishes with a docs audit: +grep `docs/` for stale references — old `[stores.*]` manifest keys, +the `dev` subcommand, the pre-rewrite single-store runtime API — and +confirm none remain; confirm every page in §6.12's table was updated +by its owning stage; confirm the docs CI (ESLint + Prettier) passes. + +**Ship gate:** CI runs the full loop on axum end-to-end; manifest / +runtime behaviour for cloudflare, fastly, and spin is covered by +contract + mock tests; the documentation audit passes with zero stale +references. + +--- + +## 16. Implementation order and milestones + +The whole effort is **a single pull request containing eight stages**, +one per sub-project, applied in this order: + +| Stage | § | Title | Risk | +| ----- | --- | ------------------------------------------------------ | ---- | +| 1 | §7 | Extensible lib + scaffold | M | +| 2 | §8 | Manifest + runtime rewrite (atomic, all four adapters) | H | +| 3 | §9 | App-config schema + derive macro + env-overlay loader | M | +| 4 | §10 | `config validate` | L | +| 5 | §11 | `auth` (adapter-trait dispatch) | M | +| 6 | §12 | `provision` | H | +| 7 | §13 | `config push` | M | +| 8 | §15 | `app-demo` polish (all four adapters) + docs audit | M | + +Every stage also updates the `docs/guide/` pages it makes stale +(§6.12) — documentation is part of each stage's definition-of-done, +not a deferred afterthought. Stage 8 closes with a documentation +audit. + +**CI and bisectability.** CI gates the PR as a whole on its head +commit; all four gates (`fmt`, `clippy -D warnings`, `cargo test`, +feature `cargo check`) plus the wasm32 spin gate must pass there. Each +of the eight stages should nonetheless compile and pass tests on its +own so the history stays bisectable — stage boundaries are chosen so +that each is a self-contained, buildable increment. Stage 2 is the one +unavoidably large stage (the atomic manifest+runtime rewrite); the +other seven are individually small. + +**Review note.** Because this is one PR, the reviewer sees all eight +stages together. The PR description should list the eight stages and +point at this spec. Reviewing stage-by-stage is recommended. +**Stage 2 is the review hotspot** — the atomic manifest+runtime +rewrite is intentionally large (the hard cutoff leaves no smaller +coherent unit), so it warrants the most reviewer attention. Its +per-adapter contract tests (§8) are the primary mitigation and should +be reviewed alongside the code. + +**Highest-risk:** stage 2 — atomic manifest+runtime rewrite touching the +schema, `ConfigStore` (async), **all four** adapters' store impls, the +Cloudflare `[vars]`→KV swap, Spin store wiring, `Hooks` / +`ConfigStoreMetadata` / `app!`, and the extractors, in one stage. +Large by necessity under the hard-cutoff decision. Mitigated by +per-adapter contract tests and `app-demo` as the in-tree canary. +Stage 6 (`provision`) — shell-out + multi-file native-manifest +writeback across four adapters (`wrangler.toml`, `fastly.toml`, +`spin.toml`). + +## 17. Risks and trade-offs + +- **Hard manifest cutoff:** a pre-rewrite `edgezero.toml` fails to + load with a migration-guide error. All in-tree projects migrated in + stage 2; external projects migrate once. +- **Large atomic stage (stage 2):** unavoidable without a + compatibility layer, which the hard-cutoff decision rejects. It is + one stage, not one PR — the PR carries all eight. +- **Async `ConfigStore` cascade:** `get` becomes async across the + trait and **all four** adapter impls, handlers, and the `Config` + extractor. `#[async_trait(?Send)]` keeps WASM compatibility. +- **Cloudflare `[vars]`→KV swap:** deployed workers migrate once. +- **Spin model asymmetry:** Spin config/secrets are a single flat + variable namespace; multi-config/multi-secret projects cannot target + Spin. The capability matrix (§6.6) enforces this at validate time + with a clear error. Spin config keys are `__`-encoded lowercase. +- **Spin config is build-time:** `config push --adapter spin` writes + static `spin.toml` variables; changing them needs a redeploy. Live + Spin variable providers are out of scope (§2). +- **Spin secret variables are manual:** the CLI never declares Spin + secret variables (their key names are not reliably knowable, §6.7). + A project targeting Spin must declare them in `spin.toml` by hand; + the walkthrough doc covers this. `#[secret(store_ref)]` is the + awkward case on Spin (single flat secret namespace, code-local + keys) — supported, but the developer owns the `spin.toml` entries. +- **Spin KV TTL / listing-cap:** stage 2 adds two new `KvError` + variants — `Unsupported` (Spin TTL writes) and `LimitExceeded` + (Spin listing past `max_list_keys`) — both 5xx-class in their + `EdgeError` mapping. Spin TTL writes return `Unsupported` + deterministically (not silent); the Spin listing path returns + `LimitExceeded`, replacing PR #253's `KvError::Validation` for that + case. Both are settled in this spec, not left open. +- **Spin component discovery:** writing `[component..*]` tables + needs the component id; single-component `spin.toml` resolves + implicitly, multi-component requires `[adapters.spin.adapter] +.component`. `config validate --strict` surfaces a failure early. +- **Env overlay surprising `config push`:** `--no-env` is the escape + hatch. +- **Shell-out + ID-writeback fragility:** current platform syntax + pinned; golden parser tests; `--dry-run` available. +- **Extractor breaking change:** `Kv(handle)` → `kv.default()`; only + in-tree consumer is `app-demo`. +- **API stability:** non-subcommand `*Args` are `#[non_exhaustive]` + + `Default`; `AuthArgs` without `Default`. + +## 18. What this spec does not cover + +- Anthropic credentials, edge DNS / TLS, observability / metrics. +- Per-environment config _files_ (env-var override is in scope). +- Restructuring `app-demo-core` handlers beyond what §15 requires. +- `edgezero-core` changes beyond `app_config`, the rewritten + `manifest` / `RequestContext` / `Hooks` / `ConfigStore` (async) / + extractor / `ConfigStoreMetadata` / `app!` surface, and the + Cloudflare adapter config backend. +- A migration _tool_; migration is manual via the published guide. +- Dynamic Spin variable providers (Fermyon Cloud variable push, Vault). + +When all eight sub-projects ship, `edgezero new myapp` produces a +workspace with `myapp-cli`, a typed `MyappConfig` +(`#[derive(AppConfig)]`, `#[serde(deny_unknown_fields)]`, optional +`#[secret]` / `#[secret(store_ref)]`), a `myapp.toml`, and an +`edgezero.toml` using the new logical-store schema with capability- +correct store declarations. The developer authenticates, provisions, +validates, pushes config (with optional env overrides), and deploys. +At runtime the service reads config (async) and secrets by logical id +across all four adapters. `app-demo` demonstrates every capability in +CI. diff --git a/docs/superpowers/specs/2026-06-01-spin-kv-config.md b/docs/superpowers/specs/2026-06-01-spin-kv-config.md new file mode 100644 index 00000000..1e69d796 --- /dev/null +++ b/docs/superpowers/specs/2026-06-01-spin-kv-config.md @@ -0,0 +1,1632 @@ +# Plan: Move Spin Config Store onto KV + +**Status:** v12 — REVISION after tenth reviewer pass. Ready for +execution. **Reviewer green-lighted start.** + +**Goal:** Back `SpinConfigStore` with the Spin KV API (`spin_sdk::key_value`) +instead of Spin variables (`spin_sdk::variables`). Bring Spin's config +surface into structural parity with Cloudflare (KV-backed) and Fastly +(Config Store-backed), so `config push` writes through a real per-store +backend on all three cloud adapters. + +## v12 changelog + +Round-10 reviewer gave the verdict "yes, we can start" and +flagged 1 Low + 1 Nit. Both fixed: + +- **L1 (Stage 4/5 should explicitly REPLACE stale Spin-variable + tests)** — fixed. The current tests assert translated keys + - `[variables]` + `[component..variables]` writes at + `examples/app-demo/crates/app-demo-cli/tests/config_flow.rs:257` + and `crates/edgezero-adapter-spin/src/cli.rs:1846`. The plan + implied replacement via Task 4.6 (dry-run shape) and + Task 5.1 (drop variables writes) but didn't say so explicitly. + Added Task 4.7 and Task 5.5 to spell out the test rewrite: + delete the translated-key / two-table assertions; add seed + URL / JSON-body / no-POST-on-dry-run / status-code coverage. +- **Nit (reworded "backward-compatible" around run_app return)** — + fixed. The migration is hard-cutoff; "backward-compatible" + wording suggested legacy Spin-variable support was being + preserved (it isn't). Reworded throughout to + "source-compatible with the generated scaffold handler + signature" — narrower, accurate. + +## v11 changelog + +Round-9 reviewer flagged 1 Medium + 1 Low against v10. Both real +and fixed: + +- **M1 (unused `IntoResponse` import after run_app signature change)** + — fixed. Today `crates/edgezero-adapter-spin/src/lib.rs` imports + `spin_sdk::http::{IntoResponse, Request as SpinRequest, Response +as SpinResponse}` because `run_app` returns + `impl spin_sdk::http::IntoResponse`. After Task 3.5 changes the + return to `SpinFullResponse`, `IntoResponse` is no longer + referenced and the wasm-clippy `-D warnings` gate would fail on + `unused_imports`. Added an explicit substep to Task 3.5: drop + `IntoResponse` from the import line. Documented in the Scope + section under `src/lib.rs` too. +- **L1 (Stage 8 smoke test not executable as written)** — fixed. + `spin up` is foreground/long-running; the v10 step list + couldn't be pasted into a script. Stage 8 now provides a real + shell snippet that backgrounds `spin up`, polls + `127.0.0.1:3000` with `curl --silent --fail` until ready (5s + timeout, fails the test cleanly), runs `config push --local`, + asserts the curl, and cleans up the spin process in a `trap` + so a failed assertion never leaves an orphan listener on + port 3000. + +## v10 changelog + +Round-8 reviewer flagged 1 High against v9. Real and fixed: + +- **H1 (seed branch Result type mismatch)** — fixed. In v9, + `handle_seed_request_spin` returned bare `SpinFullResponse` but + `run_app_with_seeder`'s seed branch was returning that value + while the fall-through `run_app::(req).await` returns + `anyhow::Result`. Mismatched arm types in + the `if/else` would not compile. + + **Resolution**: change `handle_seed_request_spin` to return + `anyhow::Result` so both arms produce the + same type. As a side benefit this drops the `.expect("static- +shaped seed response")` from v9's D10 example, which was a + latent panic in a request handler. Internal failures + (`into_core_request`, `from_core_response`) now propagate via + `?` and surface as runtime errors instead of panics. Updated + in D10, Scope (lib.rs), and Task 3.5. + +## v9 changelog + +Round-7 reviewer flagged 2 High + 1 Medium against v8. All three +are real and fixed: + +- **H1 (`#[non_exhaustive]` + struct-literal across crates)** — + settled in [D8 update](#d8-push-context-schema). Rust rejects + struct-literal construction of a `#[non_exhaustive]` type from + outside its defining crate. Added a builder API: + `AdapterPushContext::new()` (returns the default), plus + `with_seed_url` / `with_seed_token` / `with_local` chained + setters. The CLI's `dispatch_push` builds via the builder + pattern, never the struct literal. `#[non_exhaustive]` stays so + future field additions don't break out-of-tree adapter + implementers (who only RECEIVE it via the trait method anyway). +- **H2 (`run_app_with_seeder` return-type mismatch with `run_app`)** — + settled. Today `run_app` returns + `anyhow::Result`; the opaque return type + can't be implicitly converted to a concrete `SpinFullResponse`, + so `run_app_with_seeder`'s fallthrough `run_app::(req).await` + wouldn't compile. **Resolution: change `run_app` to return + `anyhow::Result`** (the concrete type already + publicly aliased in `lib.rs`). This is **source-compatible with + the generated scaffold handler signature** (NOT a legacy-Spin- + variable carve-out — this migration is still hard-cutoff). The + existing template handler signature + `async fn handle(req: Request) -> anyhow::Result` + keeps compiling because `SpinFullResponse: IntoResponse`, so the + scaffold doesn't need re-running. Both `run_app` and + `run_app_with_seeder` now return the same concrete type, and + the fallthrough is a direct return. + Documented in D9 + Scope + Task 3.5. +- **M1 (D12 401 message omits short-token case)** — settled in + [D12 update](#d12-blocking-http-client). The 401 arm's message + now spells out all four fail-closed reasons (unset / blank / + whitespace-only / shorter than 16 bytes) so an operator who + set a 4-character placeholder doesn't waste time debugging the + wrong side. + +## v8 changelog + +Round-7 reviewer flagged 1 High + 1 Medium + 1 Low against v7. +Triage: + +- **H1 (D1 `label` field unused)** — **already fixed in v7 on + disk.** The reviewer was reading a stale snapshot. Line 329 of + the v7 file matches `SpinConfigBackend::Spin { label, store }` + and the error messages include `store \`{label}\`:`. No change + in v8. +- **M1 (Stage 3.5 stale)** — **already fixed in v7 on disk.** + Same stale-snapshot issue. Task 3.5 in v7 spells out + `anyhow::Result`, the template body swap, and + "unset / blank / shorter than 16 bytes" fail-closed behavior. + No change in v8. +- **L1 (D10 prose test list out-of-sync with Task 3.2)** — + **real.** Fixed in v8. D10's narrative list expanded to match + Task 3.2's full row set, grouped by surface (auth / + request-shape / store-resolution / write). Added a + "keep-in-sync" note so the two lists can't drift again. + +## v7 changelog + +Round-6 reviewer flagged 1 High + 3 Medium against v6. All addressed: + +- **H1 (Stage 8 smoke test would 401 itself)** — fixed. `test-token` + is 10 bytes and falls below v6's 16-byte floor, so the smoke test + would hit the fail-closed 401 path before any real KV write + happens. Replaced with `test-token-1234567890` (21 bytes) in both + the `spin up` env and the `app-demo-cli config push` env. +- **M1 (Stage 3 doesn't pin the 16-byte rule with a test)** — + fixed. Added explicit test rows to Task 3.2 covering + short-server-token paths: token unset → 401; token blank / + whitespace-only → 401; token 15 bytes → 401 (just under the + floor); token 16 bytes (offered correct on the wire) → 204 (just + at the floor). Task 3.5 explicitly references the floor check + when resolving `EDGEZERO__ADAPTERS__SPIN__SEED_TOKEN`. +- **M2 (`run_app_with_seeder` return shape mismatch with template)** — + fixed. Spec'd as `anyhow::Result` to mirror + the existing `run_app` shape and the scaffold template handler. + Operators can switch from `run_app::(req).await` to + `run_app_with_seeder::(req).await` with no signature change + on the `#[http_service]` handler. +- **M/L (`label` unused in `SpinConfigBackend::Spin`)** — fixed. + D1's `get` impl now uses `&self.label` in the unavailable error + messages so the field is read (no `-D warnings` dead-code + failure) AND so error logs name which platform store fired the + error — useful when the operator has multiple config stores. + +## v6 changelog + +Round-5 reviewer flagged 2 Medium + 2 Low + 1 Medium/Low against v5. +All addressed: + +- **M1 (Stage 1 acceptance vs Task 2.5)** — fixed. The Stage 1 + acceptance line previously said `config_store_contract_tests!` + must pass on host + wasm32-wasip2. Task 2.5 (v4 fix) correctly + scoped wasm KV out. Stage 1 now matches: "host-side + `config_store_contract_tests!` against the `InMemory` backend; + real KV write/read coverage lives in the Stage 8 `spin up` smoke + test". +- **M2 (token min-length still open)** — settled. **Q2 closed YES: + enforce a 16-byte minimum token at handler startup.** Below 16 + bytes (or unset/blank/whitespace-only) → fail-closed; every + request to the seed route returns 401. Cheap to implement, + prevents the worst accidental misconfiguration. D9 status table + updated to spell this out. Removed from open questions. +- **M/L (Cargo.toml scope checklist stale)** — fixed. The scope + line previously listed only `reqwest`; updated to mirror D11's + full set: `reqwest` (optional under `cli`), and non-optional + `serde` / `serde_json` / `subtle`. +- **L1 (Task 4.4 stale status list)** — fixed. The "Surface 401 / + 403 / 404 / 422" wording is replaced with "surface every D9 + status (400 / 401 / 403 / 404 / 405 / 415 / 422)" matching D12. +- **L2 (test backend uses `from_utf8_lossy`)** — fixed. The + `InMemory` config-store backend now uses strict UTF-8 (matches + production behavior). Added a doc comment + a "non-utf8 value + → unavailable" test to the contract-test fixture so the + divergence couldn't reappear. + +## v5 changelog + +Round-4 reviewer flagged 1 High + 4 Medium + 1 Low against v4. All +addressed: + +- **H1 (stale `build_config_registry` snippet)** — settled in + [Scope: edgezero-adapter-spin](#cratesedgezero-adapter-spin-the-heavy-crate) + and [Stage 2 Task 2.4](#stage-2--runtime-backend-swap--registry-rewrite). + Updated to async/error-propagating signature: returns + `anyhow::Result>`, awaits + `SpinConfigStore::open(...).await?` per id. The + `dispatch_with_registries` snippet shows + `build_config_registry(config_meta, env).await?`. +- **M1 (`PushContext` naming collision)** — settled. The trait-level + type is now **`AdapterPushContext`**; the CLI's internal + `PushContext` (config.rs:42) keeps its name. Updated everywhere + the new type is mentioned (D8, D12, Scope, Stages). +- **M2 (dispatch_push signature gap)** — settled in + [D8 update](#d8-push-context-schema). `load_push_context` now + resolves the `AdapterPushContext` upstream (it already takes + `&ConfigPushArgs` and reads `env` for store resolution; adding + the seed_url/token/local resolution there is natural). The + resolved `AdapterPushContext` is stashed in the CLI's + internal `PushContext` and `dispatch_push` reads it from there — + no signature change required on `dispatch_push` itself. +- **M3 (stale D9 wording about `subtle` gating)** — fixed. D9's + "gated under the spin feature" line removed; cross-reference to + D11 ("non-optional dep") added. +- **M4 (in-memory store key shape)** — settled in + [D1](#d1-backend-spin-kv-via-spin_sdkkey_valuestore) and + [Scope](#cratesedgezero-adapter-spin-the-heavy-crate). The + `InMemory` test backend is keyed plain `String → Bytes`. Removed + the conflicting "(label, key)" mention in the Scope section and + Task 2.2. The contract-test macro exercises one store at a time, + so plain `key → bytes` is enough. The handler-side + `InMemorySeedWriter` (D10) is the only place that needs to + distinguish stores — that one stays keyed `(label, key)` because + it serves multi-store seed requests. +- **L1 (version labels stale)** — fixed throughout: Stage 1 task + text now says "Move this plan into specs"; the open-questions + header is "(round 5)"; the settled-section header keeps "round 2" + as the historical pointer for when those decisions were taken. + +## v4 changelog + +Round-3 reviewer flagged 4 High + 2 Medium + 1 Low against v3. All +addressed: + +- **H1 (SpinConfigStore won't host-compile)** — settled in + [D1 update](#d1-backend-spin-kv-via-spin_sdkkey_valuestore). + Restored the cfg-gated backend enum pattern (matching the existing + shape in `config_store.rs`). Wasm variant holds the opened + `key_value::Store`; `InMemory` test variant holds a `BTreeMap`. + Construction is async on wasm, sync in tests. The trait `get` + dispatches on the variant. +- **H2 (`subtle` can't be wasm-only if core is host-tested)** — + settled in [D11 update](#d11-dependency-gating). Move `subtle` + out of the `spin` feature into a non-optional dependency. It's + tiny and compiles on both host and wasm; the host tests can + reach `subtle::ConstantTimeEq` without enabling `spin`. +- **H3 (JSON deps missing from scope)** — settled in + [D11 update](#d11-dependency-gating). Add `serde` + `serde_json` + as non-optional dependencies on `edgezero-adapter-spin`. Both + are already workspace deps; both compile on host AND wasm. CLI + POST body, seed handler core parser, and the migration story + all need them. +- **H4 (`--local` could fall back to manifest prod URL)** — + settled in [D3 update](#d3-config-push---local-for-spin) and + [D8 update](#d8-push-context-schema). `--local` short-circuits + the manifest fallback completely. New `PushContext::local: bool` + field. Resolution chain when `local = true`: `--seed-url` CLI + flag → `EDGEZERO__ADAPTERS__SPIN__LOCAL_SEED_URL` env → builtin + default `http://127.0.0.1:3000/__edgezero/config/seed`. NEVER + reads the manifest's prod `seed_url`. +- **M1 (Stage 2.5 overclaims wasm contract)** — settled. CI's spin + wasm matrix runs `wasmtime run`, which doesn't host Spin KV. + Task 2.5 now: host-side `config_store_contract_tests!` against + the `InMemory` backend. Real KV write/read coverage moves to the + end-to-end smoke test in Stage 8 that requires `spin up`. +- **M2 (CLI error mapping incomplete)** — settled in + [D12 update](#d12-blocking-http-client). The CLI match now + covers every intentional status: 400, 401, 403, 404, 405, 415, 422. Each gets a specific message. +- **L1 (`cargo tree | grep '^reqwest'` may miss prefixed entries)** + — settled in [Stage 8 update](#stage-8--verify-gate). Replace + with `cargo tree -i reqwest -p edgezero-adapter-spin --features +spin --target wasm32-wasip2` which errors when `reqwest` is not + in the tree at all (the desired outcome). Pair check uses the + same form for `subtle` (which MUST resolve). + +## v3 changelog + +Round-2 reviewer flagged 4 High + 2 Medium + 1 Low against v2. All +addressed: + +- **H1 (sync trait vs async reqwest)** — settled in + [D12](#d12-blocking-http-client). Use `reqwest::blocking::Client` + so the existing sync `Adapter::push_config_entries*` trait shape + is preserved. Workspace `reqwest` gets the `blocking` + `json` + features added. No runtime needs to be threaded through the + dispatcher. +- **H2 (`subtle` gated to wrong feature)** — settled. The token + comparison runs in the wasm **seed handler**, not in the host + CLI. Move `subtle` from `cli` to the `spin` feature in + `edgezero-adapter-spin/Cargo.toml`. D9 updated to reflect. +- **H3 (store validation vs env-remapped platform names)** — + settled in [D9 update](#d9-seed-handler-security). The seed + handler validates the body's `store` field against the set of + env-resolved **platform** labels (computed from + `A::stores().config` × `EnvConfig::store_name("config", id)`), + not the logical ids. Operators can run with + `EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME=prod-config` and + push a body `{"store": "prod-config", ...}` — the validation + passes because that's the correct platform label. +- **H4 (host-testable seed signature)** — settled in + [D10 update](#d10-testable-seed-writer). Split the handler into + two layers: a host-compilable `handle_seed_request_core` that + takes `edgezero_core::http::Request` / returns + `edgezero_core::http::Response`, and a thin wasm wrapper that + translates Spin types ↔ core types and lives under the wasm + cfg gate. Unit tests target the core layer. +- **M1 (open-on-every-get)** — settled in [D1 update](#d1-backend-spin-kv-via-spin_sdkkey_valuestore). + `SpinConfigStore` holds the opened `key_value::Store` handle. + Construction is async, so `build_config_registry` becomes async + too (called from `dispatch_with_registries`, already async). + Missing `key_value_stores` declaration surfaces at registry + build time, not on first config read. +- **M2 (manifest `seed_url` is open but assumed)** — settled. + `[adapters.spin.commands].seed_url` IS a supported source. + Moved from open questions to settled. Resolution order codified + in D8. +- **L1 (`cargo tree | grep reqwest` exit-code semantics)** — + fixed in Stage 8: use `! cargo tree … | grep -q reqwest` so + the step fails ONLY when reqwest leaks into the wasm tree. + +## v2 changelog + +Reviewer flagged 4 High + 3 Medium + 1 Low against v1. All addressed: + +- **H1 (per-id config registry)** — added Stage 2 Task 2.4: rewrite + `build_config_registry` in `request.rs` to open one + `spin_sdk::key_value::Store` per declared id using + `env.store_name("config", id)` — mirroring the existing + `build_kv_registry`. The old "one shared handle cloned for every id" + shape goes away with Single→Multi. +- **H2 (seed URL/token transport schema)** — settled in new + [D8](#d8-push-context-schema). Adds `PushContext` to the + `push_config_entries*` trait signature, threads adapter command + metadata through `dispatch_push`, and gives `ConfigPushArgs` two + new CLI args (`--seed-url`, `--seed-token`) plus env fallbacks. +- **H3 (config-key validation)** — settled in + [D1.5](#d15-validator-relaxation). `validate_app_config_keys` + becomes a no-op for spin (KV accepts arbitrary key bytes). Existing + uppercase / dash / start-char tests are deleted; new tests pin + "any UTF-8 key passes". +- **H4 (seed handler security spec)** — settled in + [D9](#d9-seed-handler-security). POST-only, fail-closed on missing + or blank token, explicit status code table, and scaffolding is + opt-in (`run_app_with_seeder` is what the scaffold uses; existing + `run_app` is unchanged so downstream apps can opt out). +- **M1 (scaffold spin.toml key_value_stores)** — Stage 5 Task 5.4 + added: generator `spin.toml.hbs` declares + `key_value_stores = ["app_config"]` by default. `provision` + remains the safe path for already-scaffolded projects. +- **M2 (testable seed handler)** — settled in + [D10](#d10-testable-seed-writer). Introduces `trait SeedWriter` so + unit tests inject a fake; production uses a `SpinKvSeedWriter` + that calls the hostcall. +- **M3 (HTTP client gating)** — settled in + [D11](#d11-http-client-feature-gating). `reqwest` becomes a + `cli`-feature-only dep on `edgezero-adapter-spin` (native-only); + confirmed not pulled into the wasm target. Plan lists the exact + Cargo.toml edits. +- **L1 (legacy flag)** — settled. **No `--legacy-spin-variables` + flag.** Hard-cutoff matches the rest of the rewrite's posture. + Removed from open questions. + +Three remaining open questions for round 2 — see [Open questions](#open-questions-round-2). + +## Why + +Today `SpinConfigStore` wraps `spin_sdk::variables`. That has four +practical costs: + +1. **No dynamic config.** Spin variables are baked into `spin.toml` + at build time and override-able only via `SPIN_VARIABLE_` + env vars or `spin up --env`. Pushing a new value mid-run requires + a redeploy. +2. **Shared namespace with secrets.** `SpinSecretStore::get_bytes` + ALSO reads `spin_sdk::variables`, so config keys and `#[secret]` + values share the same flat namespace. We carry an explicit + collision-check in `validate_typed_secrets` to compensate + (`cli.rs:425-449`). +3. **Single-capable.** Spin is forced into the `single_store_kinds` + spec axis for config (one flat variable namespace per app) while + Cloudflare and Fastly are Multi. Operators can't have e.g. + `app_config` + `tenant_overrides` as two separate Spin stores. +4. **No platform parity.** `config push --adapter spin` edits + `spin.toml`; the other two cloud adapters shell out to a + platform-native bulk-write CLI (`fastly config-store-entry create` + / `wrangler kv bulk put`). The mental model split is real. + +KV-backed config fixes all four. + +## Design decisions + +### D1. Backend: Spin KV via `spin_sdk::key_value::Store` + +Runtime change in `crates/edgezero-adapter-spin/src/config_store.rs`: + +**v4**: keep the existing **cfg-gated backend enum** pattern from +today's `config_store.rs` so the file compiles on host (for tests) +without dragging in `spin_sdk` types. The wasm variant holds the +opened `key_value::Store`; the `InMemory` test variant holds a +`BTreeMap` (was `HashMap` in the +variables-backed impl). Construction is async on wasm, sync in +tests; the trait method dispatches on the variant. + +```rust +pub struct SpinConfigStore { + inner: SpinConfigBackend, +} + +enum SpinConfigBackend { + #[cfg(all(feature = "spin", target_arch = "wasm32"))] + Spin { + label: String, + store: spin_sdk::key_value::Store, // opened ONCE at dispatch + }, + #[cfg(test)] + InMemory(BTreeMap), + /// Never constructed; keeps the enum inhabited outside production Spin and tests. + #[cfg(not(any(all(feature = "spin", target_arch = "wasm32"), test)))] + _Uninhabited(std::convert::Infallible), +} + +impl SpinConfigStore { + /// Open the platform store once. Called from + /// `build_config_registry` during dispatch setup. Wasm-only; + /// tests use `from_entries`. + #[cfg(all(feature = "spin", target_arch = "wasm32"))] + pub async fn open(label: String) -> Result { + let store = spin_sdk::key_value::Store::open(&label).await + .map_err(|err| ConfigStoreError::unavailable(format!("open `{label}`: {err}")))?; + Ok(Self { inner: SpinConfigBackend::Spin { label, store } }) + } + + #[cfg(test)] + fn from_entries(entries: impl IntoIterator) -> Self { + Self { inner: SpinConfigBackend::InMemory(entries.into_iter().collect()) } + } +} + +#[async_trait(?Send)] +impl ConfigStore for SpinConfigStore { + async fn get(&self, key: &str) -> Result, ConfigStoreError> { + match &self.inner { + #[cfg(all(feature = "spin", target_arch = "wasm32"))] + SpinConfigBackend::Spin { label, store } => { + // v7 (round-6 M/L): use `label` in error wording so + // (a) the field isn't dead-code under -D warnings, + // (b) the operator running multi-store sees which + // platform store fired the failure. + match store.get(key).await { + Ok(Some(bytes)) => String::from_utf8(bytes).map(Some).map_err(|err| { + ConfigStoreError::unavailable(format!( + "store `{label}`: non-utf8 value for `{key}`: {err}" + )) + }), + Ok(None) => Ok(None), + Err(err) => Err(ConfigStoreError::unavailable(format!( + "store `{label}`: {err}" + ))), + } + } + #[cfg(test)] + SpinConfigBackend::InMemory(map) => match map.get(key) { + Some(bytes) => String::from_utf8(bytes.to_vec()).map(Some).map_err(|err| { + // v6 fix (L2): strict UTF-8 to match the wasm + // backend's behaviour. `from_utf8_lossy` would + // hide a divergence between test and prod. + ConfigStoreError::unavailable(format!("non-utf8 value for `{key}`: {err}")) + }), + None => Ok(None), + }, + #[cfg(not(any(all(feature = "spin", target_arch = "wasm32"), test)))] + SpinConfigBackend::_Uninhabited(never) => match *never {}, + } + } +} +``` + +Drops the `.→__` translation (KV accepts arbitrary key bytes). + +### D1.5. Validator relaxation + +Reviewer (H3): the existing `validate_app_config_keys` enforces Spin +variable syntax (lowercase, `^[a-z][a-z0-9_]*$` after `.→__`). With +KV-backed config, none of that applies — KV stores accept arbitrary +key bytes. + +Concrete change in `crates/edgezero-adapter-spin/src/cli.rs`: + +- `validate_app_config_keys`: collapses to `Ok(())`. The function stays + in place (trait shape) but no longer rejects anything. +- `translate_key_for_spin`: deleted. Callers (push, validator) read + keys verbatim. +- `is_valid_spin_key` / `spin_key_rule_violation`: stay — still used + by `validate_typed_secrets` for `#[secret]` value validation + (secrets still live in variables; see D7). +- Tests deleted (Stage 6 Task 6.1): + - `validate_app_config_keys_*` tests covering uppercase rejection, + dash rejection, leading-digit rejection, etc. +- Tests added (Stage 6 Task 6.2): + - `validate_app_config_keys_accepts_any_utf8` (covers `Greeting`, + `feature-flag`, `1numeric_start`, `with.dots`, `with spaces`). + +### D2. Push: HTTP POST to a seeding handler + +Spin has no `spin kv put` CLI subcommand and no bulk-write hostcall +reachable from outside the wasm runtime. Two options ruled out: + +- **Write Spin's SQLite KV file directly** — Spin doesn't guarantee + schema stability across versions. Brittle. +- **Wait for upstream `spin kv` CLI** — months of latency at best. + +So: the adapter ships a small **seeding handler** that +`app-demo-cli config push --adapter spin` HTTP-POSTs. + +### D3. `config push --local` for Spin + +With D2, `--local` and the default push both HTTP-POST to the +seeding handler, but the URL resolution chains are **strictly +disjoint** — `--local` never falls back to the manifest's prod URL. +This protects an operator who forgets to start `spin up` locally +from accidentally pushing to production. + +**Without `--local`** (prod push), `seed_url` resolves in order: + +1. `--seed-url` CLI arg. +2. `EDGEZERO__ADAPTERS__SPIN__SEED_URL` env. +3. `[adapters.spin.commands].seed_url` in `edgezero.toml`. + +Errors with a clear message if none are set. + +**With `--local`** (local push), `seed_url` resolves in order: + +1. `--seed-url` CLI arg (explicit operator override always wins). +2. `EDGEZERO__ADAPTERS__SPIN__LOCAL_SEED_URL` env (separate from + the prod env var — operators who set both don't accidentally + leak prod URL into local pushes). +3. Builtin default `http://127.0.0.1:3000/__edgezero/config/seed`. + +The manifest's `[adapters.spin.commands].seed_url` is **never read** +when `--local` is set. The dispatcher needs to know about +`args.local` before building `AdapterPushContext` — see D8. + +### D4. Provision: declare the KV store in `spin.toml` + +`provision --adapter spin` already edits `spin.toml`. Extension: for +each declared `[stores.config].id`, append the env-resolved platform +name to the component's `key_value_stores = [...]` list. Idempotent +on existing entries. Same pattern as the existing KV provision flow. + +### D5. Capability: Spin becomes Multi for config + +Drop `"config"` from `Spin::single_store_kinds` (currently +`&["config", "secrets"]` → `&["secrets"]`). Strict validation no +longer rejects `[stores.config].ids.len() > 1` for spin. + +### D6. Collision check goes away + +`validate_typed_secrets` currently builds a Spin variable name set of +`{flattened config keys} ∪ {#[secret] values}` and errors on +duplicates. With config off the variables namespace, the +intersection is empty by construction. Delete the check + spec/doc +text that explains it. + +### D7. Secrets stay on variables (unchanged) + +`SpinSecretStore` continues to use `spin_sdk::variables`. The +single-flat-namespace constraint applies only to secrets now. +`#[secret]` values still get the lowercase-only translation; the +runtime check stays. + +### D8. Push context schema + +Reviewer (H2): the v1 plan said "no CLI-side changes" but then +required the Spin adapter to read seed URL/token from somewhere the +trait signature doesn't expose. Fixed by introducing +`AdapterPushContext` (v5: renamed from v4's `PushContext` to avoid +collision with the CLI's internal `PushContext` struct at +[config.rs:42]). + +Changes to `crates/edgezero-adapter/src/registry.rs`: + +```rust +#[derive(Debug, Clone, Default)] +#[non_exhaustive] +pub struct AdapterPushContext<'a> { + /// Already-resolved seed URL. Caller (CLI dispatch) follows the + /// resolution chain for prod or local per D3 and produces the + /// final string here. `None` means "no URL was set anywhere + /// in the resolution chain" -- the adapter errors loudly. + pub seed_url: Option<&'a str>, + /// Already-resolved seed token. + pub seed_token: Option<&'a str>, + /// `true` when the operator passed `--local`. Adapters that + /// have a separate local-emulator path use this to pick the + /// right writeback target; adapters where local == default + /// can ignore it. + pub local: bool, +} + +impl<'a> AdapterPushContext<'a> { + /// Construct a default context: no seed URL / token, prod (not + /// local). v9 (round-7 H1): Rust rejects struct-literal + /// construction of `#[non_exhaustive]` types from outside the + /// defining crate, so the CLI MUST build via this constructor + /// and the `with_*` setters below. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + #[must_use] + pub fn with_seed_url(mut self, url: &'a str) -> Self { + self.seed_url = Some(url); + self + } + + #[must_use] + pub fn with_seed_token(mut self, token: &'a str) -> Self { + self.seed_token = Some(token); + self + } + + #[must_use] + pub fn with_local(mut self, local: bool) -> Self { + self.local = local; + self + } +} + +fn push_config_entries( + &self, + manifest_root: &Path, + adapter_manifest_path: Option<&str>, + component_selector: Option<&str>, + store: &ResolvedStoreId, + entries: &[(String, String)], + push_ctx: &AdapterPushContext<'_>, // NEW + dry_run: bool, +) -> Result, String> { ... } +``` + +`AdapterPushContext` is non-exhaustive so we can grow it later +without breaking downstream adapters that RECEIVE it via the +trait method. The CLI (which CONSTRUCTS it) is in-tree and uses +the builder API, so the `#[non_exhaustive]` constraint is +honoured at the source-code level. Same shape on +`push_config_entries_local`. + +Changes to `crates/edgezero-cli/src/args.rs`: + +```rust +pub struct ConfigPushArgs { + /* … existing fields … */ + /// Seed URL for adapters that push via HTTP (currently spin). + /// Resolution: this flag → `EDGEZERO__ADAPTERS____SEED_URL` + /// → `[adapters..commands].seed_url`. + #[arg(long)] + pub seed_url: Option, + /// Seed token for adapters that push via HTTP (currently spin). + /// Resolution: this flag → `EDGEZERO__ADAPTERS____SEED_TOKEN`. + /// Never read from `edgezero.toml` (don't put secrets in the + /// manifest). + #[arg(long)] + pub seed_token: Option, +} +``` + +Manifest schema: `ManifestAdapterCommands` (currently lives in +`crates/edgezero-core/src/manifest.rs`) gains an optional +`seed_url: Option` field. Already covered by `#[non_exhaustive]`, +so additive. + +Changes to `crates/edgezero-cli/src/config.rs`: + +The CLI's internal `PushContext` struct (config.rs:42) gains a +field carrying the resolved adapter context: + +```rust +struct PushContext { + // … existing fields … + /// Resolved by `load_push_context` from CLI args + env + + /// manifest per D3's prod/local chains. Stashed here so + /// `dispatch_push` can pass it through to the trait method + /// without re-reading args / env. Owned strings (not + /// borrows) so the lifetime story stays simple. + adapter_push_ctx: ResolvedAdapterPushContext, +} + +struct ResolvedAdapterPushContext { + seed_url: Option, + seed_token: Option, + local: bool, +} +``` + +`load_push_context(args: &ConfigPushArgs)` (which already takes +`&ConfigPushArgs` and reads `env` for store resolution) gains the +resolution logic per D3's disjoint chains: + +```rust +fn load_push_context(args: &ConfigPushArgs) -> Result { + // … existing manifest + store resolution … + + let env = EnvConfig::from_env(); + let name = &args.adapter; + + let seed_url = args.seed_url.clone().or_else(|| { + if args.local { + // D3 local chain: env → builtin default. Manifest NEVER consulted. + env.get(&["adapters", name, "local_seed_url"]) + .map(str::to_owned) + .or_else(|| Some("http://127.0.0.1:3000/__edgezero/config/seed".to_owned())) + } else { + // D3 prod chain: env → manifest. + env.get(&["adapters", name, "seed_url"]).map(str::to_owned) + .or_else(|| manifest.adapters.get(name) + .and_then(|cfg| cfg.adapter.commands.seed_url.clone())) + } + }); + + let seed_token = args.seed_token.clone() + .or_else(|| env.get(&["adapters", name, "seed_token"]).map(str::to_owned)); + // Manifest never consulted for tokens, even on the prod chain. + + Ok(PushContext { + // … existing fields … + adapter_push_ctx: ResolvedAdapterPushContext { + seed_url, seed_token, local: args.local, + }, + }) +} +``` + +`dispatch_push` (unchanged signature) just borrows from the +already-resolved context when building the `AdapterPushContext` +to hand the trait method: + +```rust +fn dispatch_push(ctx: &PushContext, entries: &[(String, String)], + dry_run: bool, local: bool) -> Result<(), String> { + let r = &ctx.adapter_push_ctx; + // v9 (round-7 H1): build via the builder, NOT a struct literal — + // AdapterPushContext is #[non_exhaustive] and external crates + // can't use struct-literal construction. + let mut push_ctx = AdapterPushContext::new().with_local(r.local); + if let Some(url) = r.seed_url.as_deref() { + push_ctx = push_ctx.with_seed_url(url); + } + if let Some(token) = r.seed_token.as_deref() { + push_ctx = push_ctx.with_seed_token(token); + } + let lines = if local { + ctx.adapter.push_config_entries_local(/* … */, &push_ctx, dry_run)? + } else { + ctx.adapter.push_config_entries(/* … */, &push_ctx, dry_run)? + }; + // … existing logging … +} +``` + +For non-Spin adapters this is constructed but unused — costs nothing. + +This change is **breaking** for any out-of-tree adapter that +implements `Adapter::push_config_entries*` (no in-tree adapter +outside the four ships today). Document in the next release notes. + +### D9. Seed handler security + +Reviewer (H4): pin the security contract before code. + +**Route**: `/__edgezero/config/seed`. Single fixed path, not +configurable per app — keeps every Spin deploy's seeding surface +predictable for ops scripts. + +**Method**: POST only. GET/PUT/DELETE/HEAD/OPTIONS/PATCH → 405. + +**Headers**: + +- `x-edgezero-seed: ` — REQUIRED. Compared constant-time + against `EDGEZERO__ADAPTERS__SPIN__SEED_TOKEN`. +- `content-type: application/json` — REQUIRED. Anything else → 415. + +**Body shape** (validated against this schema): + +```json +{ + "store": "app_config", + "entries": [ + { "key": "greeting", "value": "hello" }, + { "key": "service.timeout_ms", "value": "1500" } + ] +} +``` + +The `store` field is the **platform label** (what `Store::open(name)` +needs), not the logical id. The handler builds the set of accepted +labels from `A::stores().config` × `EnvConfig::store_name("config", id)` +— so an operator running with +`EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME=prod-config` pushes +`{"store": "prod-config", …}` and the validation passes. A body +mentioning the logical id `"app_config"` in that environment is +correctly rejected (404). + +The CLI does the resolution before POSTing — `dispatch_push` already +resolves the platform label via `env.store_name("config", id)`, so +the body the CLI emits matches what the handler expects. + +**Status code table**: + +| Code | Condition | +| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| 204 | Success. Body empty. | +| 400 | Malformed JSON, missing `store`, missing/empty `entries`, or any `key`/`value` not a string. | +| 401 | `x-edgezero-seed` header missing, or `EDGEZERO__ADAPTERS__SPIN__SEED_TOKEN` env unset/blank/whitespace-only/shorter than 16 bytes (fail-closed). | +| 403 | `x-edgezero-seed` header present but does not match the env token. | +| 404 | `store` does not match any env-resolved platform label for a declared `[stores.config].id`. | +| 405 | Non-POST method. | +| 415 | `content-type` not `application/json`. | +| 422 | KV store open / set hostcall returned an error mid-write (partial-write — see body for the failed key). | + +**Fail-closed contract**: if `EDGEZERO__ADAPTERS__SPIN__SEED_TOKEN` +is unset, blank, whitespace-only, OR **shorter than 16 bytes** +(v6 — round-5 Q2 settled), EVERY request to the seed route returns +401 — even with no `x-edgezero-seed` header. We never default a +token, never accept "no token = no auth", and never accept a +short-enough token to brute-force in a reasonable time. An operator +who forgot to set the token, or set a 4-character placeholder, gets +a clean error rather than an open writeable endpoint. + +**Why 16 bytes**: at 8 bits/byte that's 128 bits of token surface. +Even a single-shot guess against a constant-time compare has +~2^-128 odds; rate-limiting from the Spin runtime kills any +practical brute-force. Below 16 bytes the operator is almost +certainly using a placeholder ("dev", "test123") that doesn't +belong in production OR local. + +**Token comparison**: `subtle::ConstantTimeEq` (workspace dep, +non-optional on the spin adapter per [D11](#d11-dependency-gating) +— v4's "gated under `spin` feature" was wrong; the host +unit tests for `handle_seed_request_core` need to reach this type +without enabling `--features spin`). Prevents timing-oracle +leakage of the token prefix. + +**Logging**: log auth failures at `warn` level with the source IP +(via `spin-client-addr` header) but NEVER the offered token. + +**Opt-in vs always-scaffolded**: scaffold-side OPT-IN — the +generator emits `run_app_with_seeder` for new projects, but +`run_app` (no seeding route) stays available for projects that +explicitly opt out by switching the entrypoint. Existing +deployments keep `run_app` and aren't affected. + +### D10. Testable seed writer + +Reviewer (M2): the v1 plan called for unit tests on the seed handler +but `spin_sdk::key_value` is wasm-runtime-bound. Solution: trait + +fake. + +**v3**: split the handler into two layers so tests compile on the +host without dragging in `spin_sdk` types. The core layer is +host-compilable; the wasm wrapper translates Spin types to/from +`edgezero_core::http::{Request, Response}`. + +`crates/edgezero-adapter-spin/src/seed.rs`: + +```rust +// ---- Core layer (host-compilable) --------------------------------- + +#[async_trait(?Send)] +pub(crate) trait SeedWriter { + async fn write(&self, store: &str, key: &str, value: &str) -> Result<(), SeedError>; +} + +/// Host-compilable seed handler core. Takes a core HTTP `Request` +/// (body already buffered into `Body::Once`) and returns a core HTTP +/// `Response`. Parsing, auth, status-code routing, and the writer +/// dispatch all live here. NO spin_sdk references. +pub(crate) async fn handle_seed_request_core( + req: &edgezero_core::http::Request, + writer: &W, + valid_token: Option<&str>, // None → fail-closed (401) + known_platform_labels: &[String], // env-resolved labels per H3 +) -> edgezero_core::http::Response { ... } + +#[cfg(test)] +pub(crate) struct InMemorySeedWriter { + pub(crate) entries: Mutex>, // (label, key) → value +} + +// ---- Wasm wrapper (spin-runtime only) ----------------------------- + +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +pub(crate) struct SpinKvSeedWriter; + +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +#[async_trait(?Send)] +impl SeedWriter for SpinKvSeedWriter { + async fn write(&self, store: &str, key: &str, value: &str) -> Result<(), SeedError> { + let kv = spin_sdk::key_value::Store::open(store).await?; + kv.set(key, value.as_bytes()).await?; + Ok(()) + } +} + +/// Thin wasm wrapper: Spin `Request` → core `Request` → core handler +/// → core `Response` → Spin `Response`. Lives where the existing +/// `into_core_request` / `from_core_response` helpers do. +/// +/// v10 (round-8 H1): returns `anyhow::Result` so +/// it matches `run_app`'s shape (allows `?` at the call site in +/// `run_app_with_seeder` instead of a `.expect()` panic). +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +pub(crate) async fn handle_seed_request_spin( + req: spin_sdk::http::Request, + writer: &SpinKvSeedWriter, + valid_token: Option<&str>, + known_platform_labels: &[String], +) -> anyhow::Result { + let core_req = crate::request::into_core_request(req).await?; + let core_resp = handle_seed_request_core(&core_req, writer, + valid_token, known_platform_labels).await; + Ok(crate::response::from_core_response(core_resp).await?) +} +``` + +Host-compilable unit tests (live in `seed.rs`'s `#[cfg(test)] mod +tests`). The full row set lives in Task 3.2 — keep this list in +sync if either side moves: + +- **Auth surface (v6 16-byte floor + fail-closed)**: + - Token unset (env missing) → 401. + - Token blank (`""`) → 401. + - Token whitespace-only (`" "`) → 401. + - Token 15 bytes (just under the floor) → 401, even when the + client offers the matching token on the wire. + - Token exactly 16 bytes + matching wire token → 204 + (just-at-the-floor sentinel). + - Token 16 bytes + missing `x-edgezero-seed` → 401. + - Token 16 bytes + wrong `x-edgezero-seed` → 403. +- **Request-shape surface**: + - Non-POST method → 405. + - `content-type` not `application/json` → 415. + - Malformed JSON → 400. + - Missing `store` / `entries` / non-string values → 400. +- **Store-resolution surface**: + - Unknown store (no env-resolved label matches) → 404. +- **Write surface**: + - `SeedWriter::write` errors mid-stream → 422 (body names the + failed key). + - Happy path → 204 + `InMemorySeedWriter` recorded all entries. + +### D11. Dependency gating + +Three new deps. Different gates for different reasons: + +| Dep | Gate | Why | +| ---------------------- | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `reqwest` | `cli` feature (host-only) | Pulls `tokio` + TLS — would explode the wasm bundle and fail to compile on `wasm32-wasip2`. Only the host CLI uses it. | +| `subtle` | **non-optional** (host + wasm) | Used by the seed handler core (wasm) AND by its host-compilable unit tests (D10). Reviewer H2: can't be `spin`-gated when host tests reach `ConstantTimeEq` without `--features spin`. Tiny dep; compiles cleanly on both targets. | +| `serde` + `serde_json` | **non-optional** (host + wasm) | Reviewer H3: seed core parses JSON (wasm), CLI builds JSON body (host), `--features cli` body type derives `Serialize` / `Deserialize`. Both already workspace deps; both compile on host AND wasm. | + +Concrete `Cargo.toml` change on `crates/edgezero-adapter-spin`: + +```toml +[features] +spin = [ + "dep:spin-sdk", +] +cli = [ + "dep:edgezero-adapter", + "edgezero-adapter/cli", + "dep:ctor", + "dep:reqwest", # NEW (host HTTP push) + "dep:toml", + "dep:toml_edit", + "dep:walkdir", +] + +[dependencies] +# … existing entries … +reqwest = { workspace = true, optional = true } +serde = { workspace = true } # NEW; non-optional +serde_json = { workspace = true } # NEW; non-optional +subtle = { workspace = true } # NEW; non-optional +``` + +**Why subtle is not optional**: gating it under `spin` would hide +it from the host build, but the host unit tests for +`handle_seed_request_core` (D10) need to construct `subtle::Choice` +and friends. Making it non-optional is the simplest correct +answer; the dep is ~5 KB compiled. + +**Why serde/serde_json are not optional**: similarly, the core +seed handler runs JSON parsing on both wasm (production) and host +(tests). The Cargo features model can't express "available in +wasm under `spin` AND in host under `cfg(test)`" cleanly — making +it always-on does the right thing. + +Verification step (added to Stage 8 gate): use `cargo tree -i` +which errors when the dep is not in the tree at all (per L1). Two +checks: + +```sh +# reqwest MUST NOT be in the wasm tree. +# `cargo tree -i ` exits non-zero when isn't a dep -- +# which is the success case here. Invert with `!`: +! cargo tree -i reqwest -p edgezero-adapter-spin \ + --features spin --target wasm32-wasip2 2>/dev/null + +# subtle / serde_json MUST be in the wasm tree. +# `cargo tree -i ` succeeds when the dep IS present: +cargo tree -i subtle -p edgezero-adapter-spin \ + --features spin --target wasm32-wasip2 +cargo tree -i serde_json -p edgezero-adapter-spin \ + --features spin --target wasm32-wasip2 +``` + +### D12. Blocking HTTP client + +Reviewer (H1): the existing `Adapter::push_config_entries*` trait +methods are SYNCHRONOUS. `reqwest::Client::post` is async. Two +options: + +- **(a) `reqwest::blocking`** — keeps the sync trait shape. Needs + `blocking` + `json` features on the workspace `reqwest`. +- **(b) Async trait + runtime in dispatcher** — clean but bigger + blast radius (every adapter impl signature changes; CLI gets a + tokio dep). + +**Resolution: (a).** Workspace `Cargo.toml` change: + +```toml +reqwest = { version = "0.13", default-features = false, + features = ["rustls", "blocking", "json"] } +``` + +Spin's `push_config_entries`: + +```rust +let client = reqwest::blocking::Client::new(); +let response = client + .post(&seed_url) + .header("x-edgezero-seed", token) + .json(&body) // serde-derived; `json` feature + .send() + .map_err(|err| match err.is_connect() { + true => format!("seed POST to {seed_url} failed: connection refused. Is the Spin app running?"), + false => format!("seed POST to {seed_url} failed: {err}"), + })?; +// Map every status the handler intentionally emits (D9 status table). +match response.status().as_u16() { + 204 => Ok(vec![format!( + "pushed {} entries to seed handler at {seed_url}", + entries.len() + )]), + 400 => Err(format!( + "seed handler rejected (400 Bad Request): {}. Check CLI version / store id.", + response.text().unwrap_or_default() + )), + 401 => Err(format!( + "seed handler rejected (401 Unauthorized). Fail-closed reasons (D9): \ + server-side `EDGEZERO__ADAPTERS__SPIN__SEED_TOKEN` is unset, blank, \ + whitespace-only, or shorter than 16 bytes; OR your client-side \ + `--seed-token` / `EDGEZERO__ADAPTERS__SPIN__SEED_TOKEN` is missing. \ + Check the server's env first -- a 4-character placeholder triggers \ + this even when the wire token matches." + )), + 403 => Err(format!( + "seed handler rejected (403 Forbidden): x-edgezero-seed mismatch. \ + Check that the token on the client matches the server's \ + EDGEZERO__ADAPTERS__SPIN__SEED_TOKEN" + )), + 404 => Err(format!( + "seed handler rejected (404 Not Found): store `{}` is not a recognised platform label. \ + Check `[stores.config].ids` and any EDGEZERO__STORES__CONFIG____NAME overrides", + store.platform + )), + 405 => Err(format!( + "seed handler rejected (405 Method Not Allowed). \ + This usually means a transparent proxy rewrote the POST -- check intermediaries" + )), + 415 => Err(format!( + "seed handler rejected (415 Unsupported Media Type). \ + Internal: the CLI should always set content-type: application/json" + )), + 422 => Err(format!( + "seed handler rejected (422 Unprocessable): KV write failed mid-stream: {}", + response.text().unwrap_or_default() + )), + other => Err(format!( + "seed handler returned unexpected status {other}: {}", + response.text().unwrap_or_default() + )), +} +``` + +The blocking client is fine for a CLI binary; it spins up its own +single-thread tokio runtime under the hood. No external runtime +needed. + +## Migration story (hard-cutoff) + +Existing Spin deployments break on upgrade. No legacy flag. + +- Apps that read config via `ctx.config_store_default()` keep working + unchanged after a `config push --adapter spin` against the new + backend. +- Apps that read config via `spin_sdk::variables::get(...)` directly + break. They must either (a) move to the EdgeZero abstraction, or + (b) keep their values in `[variables]` and stop using EdgeZero's + config store for those keys. +- Existing `spin.toml` files that declare config keys in + `[variables]` need a one-time migration: the values move from + `[variables].` (and `[component..variables].`) to + the KV store via `config push --adapter spin`. After confirming + the values land in KV, the operator manually removes the + now-orphaned `[variables].` entries. + +Migration guide section title: "Spin: variables → KV for config +(2026-Q3)". + +## Scope (files touched) + +### crates/edgezero-adapter-spin (the heavy crate) + +- `src/config_store.rs` — rewrite `SpinConfigStore` per + [D1](#d1-backend-spin-kv-via-spin_sdkkey_valuestore). Cfg-gated + backend enum: wasm variant holds the opened + `key_value::Store`; the `InMemory` test variant is keyed + plain `String → bytes::Bytes` (one store at a time — that's all + the contract-test macro exercises). Drop `translate_key`. +- `src/request.rs` — rewrite `build_config_registry` as **async** + per H1 (v5: returns `anyhow::Result` so registry-build errors + propagate up the dispatcher): + ```rust + async fn build_config_registry( + meta: Option, + env: &EnvConfig, + ) -> anyhow::Result> { + let Some(meta) = meta else { return Ok(None); }; + let mut by_id = BTreeMap::new(); + for id in meta.ids { + let label = env.store_name("config", id); // per-id env resolution + let store = SpinConfigStore::open(label).await + .map_err(|err| anyhow::anyhow!( + "open config store for id `{id}`: {err}" + ))?; + by_id.insert((*id).to_owned(), + ConfigStoreHandle::new(Arc::new(store))); + } + Ok(StoreRegistry::from_parts(by_id, meta.default.to_owned())) + } + ``` + And in `dispatch_with_registries`: + ```rust + let config_registry = build_config_registry(config_meta, env).await?; + ``` + Mirrors `build_kv_registry`'s existing async + Result shape. +- `src/cli.rs` — + - `push_config_entries`: HTTP POST against `seed_url` (resolved + from `AdapterPushContext` via D8). Body is the D9 schema. + Uses `reqwest` (D11/D12). Surfaces every status code from D9 + with clear messages (D12). + - `push_config_entries_local`: defaults `seed_url` to + `http://127.0.0.1:3000/__edgezero/config/seed` if + `AdapterPushContext` didn't supply one. Otherwise identical. + - `provision`: emit `key_value_stores = [...]` entries per D4. + Drop the `[variables]` / `[component..variables]` + config-declaration writes (the migration guide tells operators + to remove existing ones). + - `validate_app_config_keys`: no-op per D1.5. Delete + `translate_key_for_spin`. + - `validate_typed_secrets`: delete the collision-check block per + D6. Keep the secret-name format check. + - `single_store_kinds`: returns `&["secrets"]`. +- `src/seed.rs` — NEW. `SeedWriter` trait + `SpinKvSeedWriter` + + `handle_seed_request`. ~200 LoC + tests. +- `src/lib.rs` — `pub mod seed;`. Plus two functions sharing + the same concrete return type (v9 round-7 H2 fix — `run_app`'s + old `impl IntoResponse` opaque return type made the fall-through + uninvocable from `run_app_with_seeder`). **v11 round-9 M1**: + drop `IntoResponse` from the + `use spin_sdk::http::{IntoResponse, Request as SpinRequest, +Response as SpinResponse}` import line — once `run_app` returns + `SpinFullResponse`, `IntoResponse` is no longer referenced and + the wasm-clippy gate would fail on `unused_imports`. + + ```rust + pub async fn run_app(req: SpinRequest) + -> anyhow::Result { /* existing body */ } + + pub async fn run_app_with_seeder(req: SpinRequest) + -> anyhow::Result { + // Route /__edgezero/config/seed to the seed handler, else + // fall through to run_app::. v10 (round-8 H1): + // handle_seed_request_spin now also returns + // anyhow::Result, so both arms are + // type-compatible. + if req.uri().path() == "/__edgezero/config/seed" { + handle_seed_request_spin(req, &SpinKvSeedWriter, …).await + } else { + run_app::(req).await + } + } + ``` + + Changing `run_app` from `impl IntoResponse` → `SpinFullResponse` + is **source-compatible with the generated scaffold handler + signature** (NOT a Spin-variable backwards-compat carve-out — + this migration stays hard-cutoff). `SpinFullResponse: IntoResponse`, + so the existing + `async fn handle(req: Request) -> anyhow::Result` + template signature keeps accepting the value through type + coercion — no need to regenerate already-scaffolded projects. + Token resolved from + `EnvConfig::get(&["adapters", "spin", "seed_token"])`; if unset + / blank / shorter than 16 bytes (D9), every request hitting the + seed route returns 401 (fail-closed). + +- `src/templates/src/lib.rs.hbs` — scaffold uses + `run_app_with_seeder` per + [D9 opt-in scaffolding](#d9-seed-handler-security). +- `src/templates/spin.toml.hbs` — add + `key_value_stores = ["app_config"]` to the default + `[component.*]` block per M1. Scaffolded projects work with + `config push --adapter spin --local` out of the box. +- `Cargo.toml` — per D11: `reqwest` optional under `cli` feature + (host HTTP push); `serde`, `serde_json`, `subtle` non-optional + (used by both the wasm seed handler core and its host-compilable + unit tests, so feature-gating would break the test layer). + +### crates/edgezero-adapter (the trait) + +- `src/registry.rs` — `AdapterPushContext` struct + threaded + through `push_config_entries` / `push_config_entries_local` + per D8. + +### crates/edgezero-core + +- `src/manifest.rs` — `ManifestAdapterCommands::seed_url: +Option` per D8 (additive; `#[non_exhaustive]` already in + place). + +### crates/edgezero-cli + +- `src/args.rs` — `ConfigPushArgs::seed_url` / `seed_token` per D8. +- `src/config.rs` — per D8: `load_push_context` resolves the + `ResolvedAdapterPushContext` (owned `String`s) and stashes it + on the CLI's `PushContext`. `dispatch_push` constructs the + borrowing `AdapterPushContext<'_>` from it and hands that to + the trait method. Update the `push_args` test fixture. + +### examples/app-demo + +- `crates/app-demo-adapter-spin/src/lib.rs` — switch + `run_app` → `run_app_with_seeder`. +- `crates/app-demo-adapter-spin/spin.toml` — add `app_config` to + `key_value_stores = [...]`. Remove `[variables].greeting` / + `feature__new_checkout` / `service__timeout_ms` (now in KV). +- `edgezero.toml` — `[adapters.spin.commands].seed_url = +"http://127.0.0.1:3000/__edgezero/config/seed"` so contributors + don't need to set the env var locally. + +### Workspace + +- `Cargo.toml` — three changes: + - `reqwest`: add `blocking` + `json` features to the existing + workspace declaration so the CLI's sync push (D12) works: + `reqwest = { version = "0.13", default-features = false, +features = ["rustls", "blocking", "json"] }`. + - `subtle`: NEW workspace dep for constant-time token + comparison: `subtle = "2"` (non-optional per D11; used by + both the wasm seed handler core and its host tests). + - `serde` / `serde_json`: already workspace deps; just declared + as non-optional on `edgezero-adapter-spin` per D11. + +### docs + +- `guide/adapters/spin.md` — rewrite config-store section: + KV-backed, no `.→__` translation, no collision check. New + seed-handler section explaining the security model + token + rotation guidance. +- `guide/manifest-store-migration.md` — new section "Spin: + variables → KV for config". +- `guide/cli-walkthrough.md` — update the Spin row in the + `config push` section. Add a `config push --adapter spin --local` + example that mirrors the Fastly one. +- `guide/cli-reference.md` — document `--seed-url` / + `--seed-token` on `config push`. + +## Stages + +### Stage 1 — Spec promotion + tracking issue + +- [ ] Move this plan into + `docs/superpowers/specs/2026-06-01-spin-kv-config.md`. +- [ ] Open a tracking issue with the acceptance criteria + (matches Task 2.5 + Stage 8 — wasm KV hostcalls aren't + reachable under the CI wasm matrix's `wasmtime run`, so + real KV coverage lives in the `spin up` smoke test): - host-side `config_store_contract_tests!` passes against + the `InMemory` backend; - the wasm32-wasip2 contract test compiles + runs (no live + KV hostcalls — those are runtime-bound); - collision check gone; - provision writes the right `key_value_stores`; - seed handler hits all status codes from D9's table; - `app-demo` works end-to-end under `spin up` with real + KV writes via `config push --adapter spin --local`. + +### Stage 2 — Runtime backend swap + registry rewrite + +- [ ] **Task 2.1**: Rewrite `SpinConfigStore` per D1. +- [ ] **Task 2.2** (M4 fix): `InMemory` test backend is keyed + plain `String → bytes::Bytes`. (One store per + `config_store_contract_tests!` invocation — no need to track + labels at this layer. The multi-store seed-handler test + fixture `InMemorySeedWriter` IS the place that tracks + `(label, key)`; see D10.) **v6**: `get` uses strict + `String::from_utf8` (NOT `from_utf8_lossy`) to match the + wasm backend's error path. New contract-test case + `non_utf8_value_returns_unavailable` documents the + behaviour and prevents future divergence. +- [ ] **Task 2.3**: Delete `translate_key_for_spin` and its callers + inside `config_store.rs`. +- [ ] **Task 2.4** (H1 + M1): Rewrite `build_config_registry` in + `request.rs` as **async**. Per declared id, await + `SpinConfigStore::open(env.store_name("config", id))` so the + `key_value::Store` handle is opened ONCE at dispatch setup + and cached in `SpinConfigStore`. Thread `&env` to + `dispatch_with_registries`'s config branch. Missing + `key_value_stores = [...]` surfaces as a registry-build + error, not a first-read error. +- [ ] **Task 2.5** (M1 update): `config_store_contract_tests!` + against the `InMemory` backend on the **host** target. Real + KV write/read coverage CANNOT live in the wasm contract test + — CI runs that via plain `wasmtime run`, which does not host + Spin's KV hostcalls. Real coverage moves to the Stage 8 + end-to-end smoke test (which requires `spin up`). + +### Stage 3 — Seed handler + testable writer + +- [ ] **Task 3.1** (D10 split): `crates/edgezero-adapter-spin/src/seed.rs`. + Build the host-compilable core: `SeedWriter` trait, + `InMemorySeedWriter`, `handle_seed_request_core(req: &Request, + …) -> Response` using `edgezero_core::http` types only. NO + `spin_sdk` references in the core layer. +- [ ] **Task 3.2**: Host unit tests against `InMemorySeedWriter` + covering every row of the D9 status code table PLUS the + v6 short-token fail-closed cases (M1 fix). Required test + rows: - Token unset (env var missing) → 401. - Token blank ("") → 401. - Token whitespace-only (" ") → 401. - Token 15 bytes (one under the floor) → 401, EVEN when + the client offers the matching token on the wire. - Token exactly 16 bytes + matching wire token → 204. - Token 16 bytes + missing wire header → 401. - Token 16 bytes + wrong wire token → 403. - Non-POST method → 405. - `content-type` not `application/json` → 415. - Malformed JSON → 400. - Missing `store` / `entries` / non-string values → 400. - Unknown store (no env-resolved label matches) → 404. - `SeedWriter::write` errors mid-stream → 422. - Happy path → 204 + `InMemorySeedWriter` recorded all + entries. +- [ ] **Task 3.3** (H3): Token comparison uses + `subtle::ConstantTimeEq`. The `known_platform_labels` arg is + computed by the caller (the wasm wrapper / lib.rs) from + `A::stores().config` × `env.store_name("config", id)`. +- [ ] **Task 3.4** (D10 wrapper, wasm-gated): Thin + `rust + pub(crate) async fn handle_seed_request_spin( + req: spin_sdk::http::Request, + writer: &SpinKvSeedWriter, + valid_token: Option<&str>, + known_platform_labels: &[String], + ) -> anyhow::Result + ` + that translates Spin `Request` → `edgezero_core::http::Request` + via `into_core_request` (uses `?`), calls the core handler, + translates back via `from_core_response` (uses `?`). v10 + (round-8 H1): returns `anyhow::Result` so + `run_app_with_seeder`'s seed branch is type-compatible with + the fall-through `run_app::` branch. NO `.expect()` panic + in the request path. +- [ ] **Task 3.5** (M2 + v9 round-7 H2 + v10 round-8 H1 + v11 + round-9 M1): 1. Change `run_app`'s signature from + `anyhow::Result` to + `anyhow::Result` (concrete type already + publicly aliased). **Source-compatible with the generated + scaffold handler signature** (NOT a Spin-variable + carve-out — this migration stays hard-cutoff): + `SpinFullResponse: IntoResponse`, so the template + `async fn handle(...) -> anyhow::Result` + keeps compiling without re-scaffolding. + 1a. Drop `IntoResponse` from the + `use spin_sdk::http::{...}` import in `src/lib.rs` — once + `run_app` no longer returns `impl IntoResponse`, the + import is unused and the wasm-clippy `-D warnings` gate + fails on `unused_imports`. 2. Add `run_app_with_seeder` with the SAME return shape: + `rust + pub async fn run_app_with_seeder(req: SpinRequest) + -> anyhow::Result + ` + Routes `/__edgezero/config/seed` to + `handle_seed_request_spin(req, &SpinKvSeedWriter, …).await` + (returns `anyhow::Result` per Task 3.4) + and falls through to `run_app::(req).await`. Both + arms produce `anyhow::Result` so the + `if/else` typechecks and either result propagates via + the outer `?` at the handler call site. 3. Scaffold template handler stays + `async fn handle(req: Request) -> anyhow::Result` + with the body swapped from + `edgezero_adapter_spin::run_app::(req).await` to + `edgezero_adapter_spin::run_app_with_seeder::(req).await`. 4. Token resolved from `EnvConfig::get(&["adapters", "spin", + "seed_token"])`; if unset / blank / shorter than 16 bytes + (D9), every request hitting the seed route returns 401 + (fail-closed). + +### Stage 4 — CLI push rewrite + +- [ ] **Task 4.1** (D8): Add `AdapterPushContext` to the trait + (renamed from v4's `PushContext` to avoid colliding with + the CLI's internal `PushContext`). Update all four existing + impls to take it (no-ops for fastly/cloudflare/axum; spin + reads from it). +- [ ] **Task 4.2**: Add `seed_url` / `seed_token` to + `ConfigPushArgs`. Update the `push_args` test fixture and the + `app-demo-cli/tests/config_flow.rs` helper. +- [ ] **Task 4.3**: Rewrite `load_push_context` to resolve the + `ResolvedAdapterPushContext` (D3's disjoint prod/local + chains per D8). `dispatch_push` converts to the + borrow-shaped `AdapterPushContext<'_>` at call time. +- [ ] **Task 4.4** (D12): Implement spin `push_config_entries` via + `reqwest::blocking::Client::post`. The CLI must resolve the + body's `store` field to the **platform label** (via + `env.store_name("config", id)`), per H3. JSON body per D9. + Surface every status from D9's table — 400 / 401 / 403 / + 404 / 405 / 415 / 422 — per D12's match block. Handle + connection-refused with a specific hint ("is the spin app + running?"). +- [ ] **Task 4.5**: Implement spin `push_config_entries_local`. + Defaults `seed_url` to local. Otherwise delegates to the + Task 4.4 impl. +- [ ] **Task 4.6**: `--dry-run` prints the planned URL + entries + without POSTing. Tests for the dry-run shape. +- [ ] **Task 4.7** (v12 round-10 L1): **Delete and replace stale + Spin-variable push tests.** Today's push tests in + `examples/app-demo/crates/app-demo-cli/tests/config_flow.rs` + (around line 257) and `crates/edgezero-adapter-spin/src/cli.rs` + (around line 1846) assert: - dotted-key → underscore translation - `[variables].` writes - `[component..variables].` writes + Under KV-backed push these assertions are wrong (variables + table is no longer touched). Delete them; add coverage for + the new contract: - Push body contains the resolved platform-label `store` + (with and without `EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME=…` + override). - Push body's `entries` array is the flattened typed + `AppDemoConfig` minus `#[secret]` / `#[secret(store_ref)]` + (mirrors the existing config-flow assertions, just on the + body shape instead of the manifest edit). - `--dry-run` produces NO POST (verify via a mock seed + endpoint that records hits). - Each D9 status code surfaces as the matching D12 error + string (covers 400 / 401 / 403 / 404 / 405 / 415 / 422 + happy 204). + +### Stage 5 — Provision + scaffold + manifest updates + +- [ ] **Task 5.1**: Drop `[variables]` / + `[component..variables]` config-key writes from spin's + `provision`. +- [ ] **Task 5.2**: For each `[stores.config].id`, append the + platform name to the component's `key_value_stores = [...]`. + Idempotent. New `provision_writes_config_kv_store_entry` + test. +- [ ] **Task 5.3**: `single_store_kinds` returns `&["secrets"]`. +- [ ] **Task 5.4** (M1): Generator `spin.toml.hbs` declares + `key_value_stores = ["app_config"]` by default. Add a test + in `generated_project_builds.rs` that checks the rendered + spin.toml contains the entry. +- [ ] **Task 5.5** (v12 round-10 L1): **Delete stale + provision-side variable-write assertions** that pair with + the Stage 4.7 deletions. Concrete sites in + `crates/edgezero-adapter-spin/src/cli.rs` (around line 1846) + currently assert the provision step emits `[variables]` / + `[component..variables]` blocks for declared config + ids. Under D4 those writes are gone. Replace with assertions + that: - For each `[stores.config].id`, the platform label appears + in the component's `key_value_stores = [...]` (Task 5.2's + change). - `[variables]` / `[component..variables]` are NOT + touched for config ids (regression guard so a future + change doesn't silently revive the old path). - Existing `[variables]` entries for `#[secret]` fields + (Task 6.2 keeps these) are preserved. + +### Stage 6 — Validator changes + +- [ ] **Task 6.1** (H3): Delete uppercase/dash/leading-digit tests + on `validate_app_config_keys`. Replace with + `validate_app_config_keys_accepts_any_utf8`. +- [ ] **Task 6.2**: Delete `validate_typed_secrets`'s + collision-check block per D6. Keep the secret-name format + check (it still validates `#[secret]` values against Spin + variable rules). +- [ ] **Task 6.3**: Update strict-completeness tests: + `[stores.config].ids.len() > 1` now PASSES for spin. + +### Stage 7 — Docs + app-demo migration + +- [ ] **Task 7.1**: Rewrite `docs/guide/adapters/spin.md` config + section. Add seed-handler section with the D9 security table. +- [ ] **Task 7.2**: Add the migration section to + `docs/guide/manifest-store-migration.md`. +- [ ] **Task 7.3**: Update `docs/guide/cli-walkthrough.md` Spin row + add `--adapter spin --local` example. +- [ ] **Task 7.4**: Update `docs/guide/cli-reference.md` for + `--seed-url` / `--seed-token`. +- [ ] **Task 7.5**: app-demo migration in ONE commit (per + resolved Q5): switch entrypoint to `run_app_with_seeder`, + update `spin.toml`, set `seed_url` in `edgezero.toml`. + +### Stage 8 — Verify gate + +- [ ] Full gate: cargo fmt, host clippy --workspace, workspace + tests, all three adapter wasm-clippy gates, docs + lint/format/build. +- [ ] Spin wasm contract test under wasmtime (wasm32-wasip2). +- [ ] **Wasm dep gating checks** (D11, fixed per L1 — use + `cargo tree -i` which errors when the dep is absent). + ``sh + # reqwest MUST NOT leak into the wasm tree. `cargo tree -i` + # errors when reqwest isn't a dep; invert with `!`: + ! cargo tree -i reqwest -p edgezero-adapter-spin \ + --features spin --target wasm32-wasip2 2>/dev/null + # subtle / serde_json MUST be in the wasm tree. + cargo tree -i subtle -p edgezero-adapter-spin \ + --features spin --target wasm32-wasip2 + cargo tree -i serde_json -p edgezero-adapter-spin \ + --features spin --target wasm32-wasip2 + `` +- [ ] **End-to-end smoke test** in `examples/app-demo` (v11 + round-9 L1: shell-form, backgrounded, port-wait + trap + cleanup so the test can actually be run in CI / pasted + into a shell). + + ```sh + #!/usr/bin/env bash + set -euo pipefail + + readonly TOKEN="test-token-1234567890" + readonly PORT=3000 + readonly URL="http://127.0.0.1:${PORT}" + export EDGEZERO__ADAPTERS__SPIN__SEED_TOKEN="$TOKEN" + + cd examples/app-demo + + # 1. Build the wasm so `spin up` has something to serve. + (cd crates/app-demo-adapter-spin && \ + cargo build --target wasm32-wasip2 --release \ + -p app-demo-adapter-spin) + + # 2. Background `spin up` and arrange to kill it on exit. + (cd crates/app-demo-adapter-spin && spin up --listen "127.0.0.1:${PORT}") \ + &> /tmp/edgezero-spin-smoke.log & + readonly SPIN_PID=$! + trap 'kill $SPIN_PID 2>/dev/null || true; wait $SPIN_PID 2>/dev/null || true' \ + EXIT INT TERM + + # 3. Wait up to 10s for the listener (Spin warm-up + KV + # backend init). 20 × 0.5s = 10s. Fail clean on timeout. + for _ in $(seq 1 20); do + if curl --silent --fail --max-time 1 "${URL}/" \ + > /dev/null 2>&1; then + break + fi + sleep 0.5 + done + if ! curl --silent --fail --max-time 1 "${URL}/" \ + > /dev/null 2>&1; then + echo "spin up did not bind ${URL} within 10s" >&2 + tail -n 100 /tmp/edgezero-spin-smoke.log >&2 + exit 1 + fi + + # 4. Push config to the LOCAL endpoint. The token env var + # is inherited from the parent shell (line 5). + cargo run -p app-demo-cli --quiet -- \ + config push --adapter spin --local + + # 5. Assert the pushed value flows through to the handler. + readonly GOT="$(curl --silent --fail "${URL}/config/greeting")" + readonly WANT="hello from app-demo" + if [[ "$GOT" != "$WANT" ]]; then + echo "smoke test FAILED: got=${GOT@Q} want=${WANT@Q}" >&2 + exit 1 + fi + echo "smoke test PASSED: GET /config/greeting → ${GOT@Q}" + # trap kills SPIN_PID on exit. + ``` + + The token value (`test-token-1234567890`, 21 bytes) clears + the v6 16-byte floor on BOTH sides (server `spin up` + inherits the var; CLI `config push` inherits the var). + The `trap` ensures no orphan `spin up` lingers on port 3000 + if the assertion fails — important for re-runnability. + +## Open questions + +None outstanding. All round-2/3/5 questions are settled. See the +"Settled" section below for the historical decisions. + +## Settled + +- **Q1 (round 2) → YES**: `[adapters.spin.commands].seed_url` IS a + valid source (third in the resolution order after CLI flag and + env). `seed_token` stays env/CLI only — never manifest. +- **Q2 (round 5) → YES, 16-byte floor**: The seed handler rejects + tokens shorter than 16 bytes at startup with a fail-closed 401 + on every request. See D9 "Fail-closed contract" for rationale. +- **Q3 (round 2) → ONE COMMIT**: Stage 7.5 ships + `run_app_with_seeder` switch + `spin.toml` KV declaration + + `edgezero.toml` seed_url together for atomic reversibility. + +## Estimated scope (v4) + +- **Code**: 14 files modified, 1 new (`seed.rs`), ~820 LoC impl + - ~430 LoC tests. (Up from v3 — D1's cfg-gated backend enum, + the H4 disjoint local resolution chain in `dispatch_push`, and + the extra D12 status-code arms add ~70 LoC; H2/H3 non-optional + dep moves are zero-LoC on the runtime side.) +- **Docs**: 4 files modified, ~100 LoC prose. +- **Migration**: hard-cutoff (resolved per L1). +- **Time**: 2 focused days assuming no surprises in the spin + hostcall surface. + +## Risks (v2 additions) + +- **`PushContext` is a breaking trait change for any out-of-tree + adapter**. Document in release notes; no in-tree adapter outside + the four ships today. +- **`reqwest` adds ~3 MB to the host CLI binary**. Acceptable for + a dev tool; flag if it ever becomes a problem. +- **Token enforcement in CI**: the end-to-end smoke test needs the + `EDGEZERO__ADAPTERS__SPIN__SEED_TOKEN` env var to flow into both + `spin up` and `app-demo-cli`. Test harness sets it once. diff --git a/examples/app-demo/Cargo.lock b/examples/app-demo/Cargo.lock index 1dea6100..53d9dbc9 100644 --- a/examples/app-demo/Cargo.lock +++ b/examples/app-demo/Cargo.lock @@ -8,6 +8,18 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -41,6 +53,56 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.102" @@ -95,6 +157,24 @@ dependencies = [ "spin-sdk", ] +[[package]] +name = "app-demo-cli" +version = "0.1.0" +dependencies = [ + "app-demo-core", + "clap", + "edgezero-adapter", + "edgezero-adapter-axum", + "edgezero-adapter-spin", + "edgezero-cli", + "edgezero-core", + "futures", + "log", + "rusqlite", + "serde_json", + "tempfile", +] + [[package]] name = "app-demo-core" version = "0.1.0" @@ -105,6 +185,7 @@ dependencies = [ "futures", "serde", "serde_json", + "tempfile", "validator", ] @@ -253,9 +334,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" [[package]] name = "block-buffer" @@ -266,6 +347,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "brotli" version = "8.0.2" @@ -342,6 +432,46 @@ dependencies = [ "windows-link", ] +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + [[package]] name = "cmake" version = "0.1.57" @@ -351,6 +481,12 @@ dependencies = [ "cc", ] +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "colored" version = "2.2.0" @@ -432,6 +568,26 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "ctor" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d765eb1c0bda10d31e0ea185f5ee15da532d60b0912d2bd1441783439e749c5" +dependencies = [ + "link-section", + "linktime-proc-macro", +] + [[package]] name = "darling" version = "0.20.11" @@ -477,6 +633,37 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.117", +] + [[package]] name = "digest" version = "0.9.0" @@ -486,6 +673,16 @@ dependencies = [ "generic-array", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -509,6 +706,13 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "edgezero-adapter" +version = "0.1.0" +dependencies = [ + "toml", +] + [[package]] name = "edgezero-adapter-axum" version = "0.1.0" @@ -517,6 +721,8 @@ dependencies = [ "async-trait", "axum", "bytes", + "ctor", + "edgezero-adapter", "edgezero-core", "futures", "futures-util", @@ -524,11 +730,14 @@ dependencies = [ "log", "redb", "reqwest", + "serde_json", "simple_logger 5.1.0", "thiserror 2.0.18", "tokio", + "toml", "tower", "tracing", + "walkdir", ] [[package]] @@ -539,12 +748,17 @@ dependencies = [ "async-trait", "brotli", "bytes", + "ctor", + "edgezero-adapter", "edgezero-core", "flate2", "futures", "futures-util", "log", "serde_json", + "tempfile", + "toml_edit", + "walkdir", "worker", ] @@ -558,6 +772,8 @@ dependencies = [ "brotli", "bytes", "chrono", + "ctor", + "edgezero-adapter", "edgezero-core", "fastly", "fern", @@ -566,7 +782,10 @@ dependencies = [ "futures-util", "log", "log-fastly", + "serde_json", "thiserror 2.0.18", + "toml_edit", + "walkdir", ] [[package]] @@ -577,12 +796,44 @@ dependencies = [ "async-trait", "brotli", "bytes", + "ctor", + "edgezero-adapter", "edgezero-core", "flate2", "futures", "futures-util", "log", + "rusqlite", + "serde", + "serde_json", "spin-sdk", + "subtle", + "thiserror 2.0.18", + "toml", + "toml_edit", + "walkdir", +] + +[[package]] +name = "edgezero-cli" +version = "0.1.0" +dependencies = [ + "clap", + "edgezero-adapter", + "edgezero-adapter-axum", + "edgezero-adapter-cloudflare", + "edgezero-adapter-fastly", + "edgezero-adapter-spin", + "edgezero-core", + "futures", + "handlebars", + "log", + "serde", + "serde_json", + "simple_logger 5.1.0", + "thiserror 2.0.18", + "toml", + "validator", ] [[package]] @@ -657,6 +908,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastly" version = "0.12.0" @@ -678,7 +941,7 @@ dependencies = [ "serde_json", "serde_repr", "serde_urlencoded", - "sha2", + "sha2 0.9.9", "smallvec", "thiserror 1.0.69", "time", @@ -716,9 +979,15 @@ dependencies = [ "fastly-shared", "http", "wasip2", - "wit-bindgen", + "wit-bindgen 0.51.0", ] +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + [[package]] name = "fern" version = "0.7.1" @@ -752,9 +1021,9 @@ checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] name = "foldhash" -version = "0.1.5" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" [[package]] name = "form_urlencoded" @@ -878,7 +1147,7 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "wasi 0.11.1+wasi-snapshot-preview1", + "wasi", "wasm-bindgen", ] @@ -896,20 +1165,48 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "handlebars" +version = "6.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43ccdfe15a81ab0a8af639e90254227c9a46afd9c5f5b6ec7efaa345c4b0f00" +dependencies = [ + "derive_builder", + "log", + "num-order", + "pest", + "pest_derive", + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "hashbrown" -version = "0.15.5" +version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" dependencies = [ - "foldhash", + "ahash", ] [[package]] name = "hashbrown" -version = "0.16.1" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] [[package]] name = "heck" @@ -1163,12 +1460,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.13.0" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown 0.17.1", "serde", "serde_core", ] @@ -1189,6 +1486,12 @@ dependencies = [ "serde", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itertools" version = "0.13.0" @@ -1266,6 +1569,35 @@ version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "link-section" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d1e908a416d6e9f725743b84a36feea40c4c131e805fbc26d61f9f451f36080" + +[[package]] +name = "linktime-proc-macro" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a44cd706ff0d503ee32b2071166510ca27e281228de10cd3aa8d35ff94560f81" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.1" @@ -1342,7 +1674,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", - "wasi 0.11.1+wasi-snapshot-preview1", + "wasi", "windows-sys 0.61.2", ] @@ -1352,6 +1684,21 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +[[package]] +name = "num-modular" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17bb261bf36fa7d83f4c294f834e91256769097b3cb505d44831e0a179ac647f" + +[[package]] +name = "num-order" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537b596b97c40fcf8056d153049eb22f481c17ebce72a513ec9286e4986d1bb6" +dependencies = [ + "num-modular", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1376,6 +1723,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "opaque-debug" version = "0.3.1" @@ -1394,6 +1747,49 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pest" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pest_meta" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +dependencies = [ + "pest", + "sha2 0.10.9", +] + [[package]] name = "pin-project" version = "1.1.11" @@ -1426,6 +1822,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + [[package]] name = "potential_utf" version = "0.1.4" @@ -1637,7 +2039,9 @@ checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" dependencies = [ "base64", "bytes", + "futures-channel", "futures-core", + "futures-util", "http", "http-body", "http-body-util", @@ -1652,6 +2056,8 @@ dependencies = [ "rustls", "rustls-pki-types", "rustls-platform-verifier", + "serde", + "serde_json", "sync_wrapper", "tokio", "tokio-rustls", @@ -1679,13 +2085,17 @@ dependencies = [ ] [[package]] -name = "routefinder" -version = "0.5.4" +name = "rusqlite" +version = "0.32.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0971d3c8943a6267d6bd0d782fdc4afa7593e7381a92a3df950ff58897e066b5" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" dependencies = [ - "smartcow", - "smartstring", + "bitflags 2.11.1", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", ] [[package]] @@ -1694,6 +2104,19 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.37" @@ -1805,7 +2228,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "core-foundation", "core-foundation-sys", "libc", @@ -1931,13 +2354,24 @@ version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" dependencies = [ - "block-buffer", + "block-buffer 0.9.0", "cfg-if", "cpufeatures", - "digest", + "digest 0.9.0", "opaque-debug", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest 0.10.7", +] + [[package]] name = "shlex" version = "1.3.0" @@ -1996,26 +2430,6 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" -[[package]] -name = "smartcow" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "656fcb1c1fca8c4655372134ce87d8afdf5ec5949ebabe8d314be0141d8b5da2" -dependencies = [ - "smartstring", -] - -[[package]] -name = "smartstring" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fb72c633efbaa2dd666986505016c32c3044395ceaf881518399d2f4127ee29" -dependencies = [ - "autocfg", - "static_assertions", - "version_check", -] - [[package]] name = "socket2" version = "0.6.3" @@ -2026,25 +2440,12 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "spin-executor" -version = "5.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bba409d00af758cd5de128da4a801e891af0545138f66a688f025f6d4e33870b" -dependencies = [ - "futures", - "once_cell", - "wasi 0.13.1+wasi-0.2.0", -] - [[package]] name = "spin-macro" -version = "5.2.0" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f959f16928e3c023468e41da9ebb77442e2ce22315e8dab11508fe76b3567ee1" +checksum = "11e483b94d5bcfac493caf0427fa875063e3e8604d0466a4ab491ec200a42857" dependencies = [ - "anyhow", - "bytes", "proc-macro2", "quote", "syn 1.0.109", @@ -2052,24 +2453,19 @@ dependencies = [ [[package]] name = "spin-sdk" -version = "5.2.0" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8951c7c4ab7f87f332d497789eeed9631c8116988b628b4851eb2fa999ead019" +checksum = "4fd2abac3eb2ee249c2241ab87f7b1287f36172c8cc1ea815c19c85e41ede44d" dependencies = [ "anyhow", - "async-trait", "bytes", - "chrono", - "form_urlencoded", "futures", "http", - "once_cell", - "routefinder", - "spin-executor", + "http-body", + "http-body-util", "spin-macro", "thiserror 2.0.18", - "wasi 0.13.1+wasi-0.2.0", - "wit-bindgen", + "wasip3", ] [[package]] @@ -2078,12 +2474,6 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" -[[package]] -name = "static_assertions" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" - [[package]] name = "strsim" version = "0.11.1" @@ -2159,6 +2549,19 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -2303,10 +2706,19 @@ dependencies = [ "indexmap", "serde_core", "serde_spanned", - "toml_datetime", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "toml_writer", - "winnow", + "winnow 1.0.0", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", ] [[package]] @@ -2318,13 +2730,26 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.23.10+spec-1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +dependencies = [ + "indexmap", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + [[package]] name = "toml_parser" version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow", + "winnow 1.0.0", ] [[package]] @@ -2355,7 +2780,7 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "bytes", "futures-util", "http", @@ -2423,6 +2848,12 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -2459,6 +2890,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "validator" version = "0.20.0" @@ -2489,6 +2926,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" @@ -2521,21 +2964,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] -name = "wasi" -version = "0.13.1+wasi-0.2.0" +name = "wasip2" +version = "1.0.2+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f43d1c36145feb89a3e61aa0ba3e582d976a8ab77f1474aa0adb80800fe0cf8" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" dependencies = [ - "wit-bindgen-rt", + "wit-bindgen 0.51.0", ] [[package]] -name = "wasip2" -version = "1.0.2+wasi-0.2.9" +name = "wasip3" +version = "0.6.0+wasi-0.3.0-rc-2026-03-15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +checksum = "ed83456dd6a0b8581998c0365e4651fa2997e5093b49243b7f35391afaa7a3d9" dependencies = [ - "wit-bindgen", + "bytes", + "http", + "http-body", + "thiserror 2.0.18", + "wit-bindgen 0.57.1", ] [[package]] @@ -2595,9 +3042,9 @@ dependencies = [ [[package]] name = "wasm-encoder" -version = "0.244.0" +version = "0.247.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +checksum = "30b6733b8b91d010a6ac5b0fb237dc46a19650bc4c67db66857e2e787d437204" dependencies = [ "leb128fmt", "wasmparser", @@ -2605,9 +3052,9 @@ dependencies = [ [[package]] name = "wasm-metadata" -version = "0.244.0" +version = "0.247.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +checksum = "665fe59e56cc9b419ca6fcca56673e3421d1a5011e3b65caf6b726fd9e041d10" dependencies = [ "anyhow", "indexmap", @@ -2630,12 +3077,12 @@ dependencies = [ [[package]] name = "wasmparser" -version = "0.244.0" +version = "0.247.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +checksum = "8e6fb4c2bee46c5ea4d40f8cdb5c131725cd976718ec56f1c8e82fbde5fa2a80" dependencies = [ - "bitflags 2.11.0", - "hashbrown 0.15.5", + "bitflags 2.11.1", + "hashbrown 0.17.1", "indexmap", "semver", ] @@ -3034,6 +3481,15 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "winnow" version = "1.0.0" @@ -3046,35 +3502,36 @@ version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" dependencies = [ - "bitflags 2.11.0", - "wit-bindgen-rust-macro", + "bitflags 2.11.1", ] [[package]] -name = "wit-bindgen-core" -version = "0.51.0" +name = "wit-bindgen" +version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" dependencies = [ - "anyhow", - "heck", - "wit-parser", + "bitflags 2.11.1", + "futures", + "wit-bindgen-rust-macro", ] [[package]] -name = "wit-bindgen-rt" -version = "0.24.0" +name = "wit-bindgen-core" +version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0780cf7046630ed70f689a098cd8d56c5c3b22f2a7379bbdb088879963ff96" +checksum = "02dee27a2dc20d1008016c742ec9fc6ea498492994ba3750be7454cbc97ff04c" dependencies = [ - "bitflags 2.11.0", + "anyhow", + "heck", + "wit-parser", ] [[package]] name = "wit-bindgen-rust" -version = "0.51.0" +version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +checksum = "b5007dae772945b7a5003d69d90a3a4a78929d41f19d004e980c4259a6af4484" dependencies = [ "anyhow", "heck", @@ -3088,9 +3545,9 @@ dependencies = [ [[package]] name = "wit-bindgen-rust-macro" -version = "0.51.0" +version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +checksum = "af9237d678e3513ad24e96fe98beacdc0db6405284ba2a2400418cf0d42caa89" dependencies = [ "anyhow", "prettyplease", @@ -3103,12 +3560,12 @@ dependencies = [ [[package]] name = "wit-component" -version = "0.244.0" +version = "0.247.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +checksum = "9d567162a6b9843080e5e0053f696623ff694bae8ae017c9ec536d1873bbe3d8" dependencies = [ "anyhow", - "bitflags 2.11.0", + "bitflags 2.11.1", "indexmap", "log", "serde", @@ -3122,11 +3579,12 @@ dependencies = [ [[package]] name = "wit-parser" -version = "0.244.0" +version = "0.247.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +checksum = "8ffe4064318cdf3c08cb99343b44c039fcefe61ccdf58aa9975285f13d74d1fc" dependencies = [ "anyhow", + "hashbrown 0.17.1", "id-arena", "indexmap", "log", diff --git a/examples/app-demo/Cargo.toml b/examples/app-demo/Cargo.toml index ba14fbd8..c608b82e 100644 --- a/examples/app-demo/Cargo.toml +++ b/examples/app-demo/Cargo.toml @@ -1,6 +1,7 @@ [workspace] members = [ "crates/app-demo-core", + "crates/app-demo-cli", "crates/app-demo-adapter-axum", "crates/app-demo-adapter-cloudflare", "crates/app-demo-adapter-fastly", @@ -16,20 +17,28 @@ anyhow = "1" async-trait = "0.1" axum = "0.8" bytes = "1" +clap = { version = "4", features = ["derive"] } +edgezero-adapter = { path = "../../crates/edgezero-adapter" } edgezero-adapter-axum = { path = "../../crates/edgezero-adapter-axum" } edgezero-adapter-cloudflare = { path = "../../crates/edgezero-adapter-cloudflare" } edgezero-adapter-fastly = { path = "../../crates/edgezero-adapter-fastly" } edgezero-adapter-spin = { path = "../../crates/edgezero-adapter-spin" } +edgezero-cli = { path = "../../crates/edgezero-cli" } edgezero-core = { path = "../../crates/edgezero-core" } -spin-sdk = { version = "5.2", default-features = false } +spin-sdk = { version = "6", default-features = false } fastly = "0.12" futures = { version = "0.3", default-features = false, features = ["std", "executor"] } log = "0.4" once_cell = "1" +# `bundled` so the demo workspace also vendors SQLite source. Used by +# the `app-demo-cli` integration test for `config push --adapter spin` +# (which writes through our SQLite-direct backend writer). +rusqlite = { version = "0.32", default-features = false, features = ["bundled"] } serde = { version = "1", features = ["derive"] } serde_json = "1" validator = { version = "0.20", features = ["derive"] } simple_logger = "4" +tempfile = "3" tokio = { version = "1", features = ["macros", "rt-multi-thread"] } tracing = "0.1" worker = { version = "0.8", default-features = false, features = ["http"] } diff --git a/examples/app-demo/app-demo.toml b/examples/app-demo/app-demo.toml new file mode 100644 index 00000000..695458fe --- /dev/null +++ b/examples/app-demo/app-demo.toml @@ -0,0 +1,34 @@ +# `app-demo.toml` — typed application config for the `app-demo` example. +# +# The file's top-level table maps 1:1 to the `AppDemoConfig` struct in +# `crates/app-demo-core/src/config.rs`. There is no `[config]` +# wrapper. +# +# Env-var overlay: every key here can be overridden at runtime by +# `APP_DEMO__
__…__` (the prefix is the project name +# uppercased with `-`→`_`; nested sections are joined by `__`) as +# long as the key already exists below. Example: +# `APP_DEMO__SERVICE__TIMEOUT_MS=2500` overrides the +# `[service] timeout_ms` field below. + +# `api_token` is the *key* inside the resolved default secret store +# (see `[stores.secrets]` in `edgezero.toml`). The handler resolves it +# via `ctx.secret_store_default()?.require_str(&cfg.api_token)`. +api_token = "demo_api_token" +greeting = "hello from app-demo" +# `vault` is a `#[secret(store_ref)]` value — the logical id of a +# secret store declared in `[stores.secrets].ids`. The app-demo +# manifest declares a single id, `"default"`. +vault = "default" + +# Nested so `config push` writes the dotted key +# `feature.new_checkout` — matching the handler that reads +# `feature.new_checkout` from the config store and the per-adapter +# seeds in `fastly.toml`/`spin.toml`. Spin's config store is now +# KV-backed and stores dotted keys verbatim, so no translation is +# applied: the runtime reads back exactly `feature.new_checkout`. +[feature] +new_checkout = false + +[service] +timeout_ms = 1500 diff --git a/examples/app-demo/crates/app-demo-adapter-axum/src/main.rs b/examples/app-demo/crates/app-demo-adapter-axum/src/main.rs index 0741f27f..93d6ee64 100644 --- a/examples/app-demo/crates/app-demo-adapter-axum/src/main.rs +++ b/examples/app-demo/crates/app-demo-adapter-axum/src/main.rs @@ -2,5 +2,5 @@ use app_demo_core::App; use edgezero_adapter_axum::dev_server::run_app; fn main() -> anyhow::Result<()> { - run_app::(include_str!("../../../edgezero.toml")) + run_app::() } diff --git a/examples/app-demo/crates/app-demo-adapter-cloudflare/src/lib.rs b/examples/app-demo/crates/app-demo-adapter-cloudflare/src/lib.rs index ba2dd50a..9fa6af12 100644 --- a/examples/app-demo/crates/app-demo-adapter-cloudflare/src/lib.rs +++ b/examples/app-demo/crates/app-demo-adapter-cloudflare/src/lib.rs @@ -14,11 +14,5 @@ use worker::{event, Context, Env, Request, Response, Result}; #[event(fetch)] #[inline] pub async fn main(req: Request, env: Env, ctx: Context) -> Result { - edgezero_adapter_cloudflare::run_app::( - include_str!("../../../edgezero.toml"), - req, - env, - ctx, - ) - .await + edgezero_adapter_cloudflare::run_app::(req, env, ctx).await } diff --git a/examples/app-demo/crates/app-demo-adapter-cloudflare/wrangler.toml b/examples/app-demo/crates/app-demo-adapter-cloudflare/wrangler.toml index 9877065f..6840f654 100644 --- a/examples/app-demo/crates/app-demo-adapter-cloudflare/wrangler.toml +++ b/examples/app-demo/crates/app-demo-adapter-cloudflare/wrangler.toml @@ -5,15 +5,27 @@ compatibility_date = "2023-05-01" [build] command = "worker-build --release" -# Config store as a single JSON string var, keyed by the binding name from edgezero.toml. -# CloudflareConfigStore parses this at startup into a HashMap, enabling arbitrary key names. -[vars] -app_config = '{"greeting":"hello from config store","feature.new_checkout":"false","service.timeout_ms":"1500"}' +# KV namespace bindings, one per logical store id from `edgezero.toml`. +# `wrangler dev` auto-provisions local KV namespaces for each binding; +# `id` values are placeholders for production — replace with the output of +# `wrangler kv namespace create ` per environment. +# +# Each binding name matches the logical id by default; override with +# `EDGEZERO__STORES______NAME=` at runtime if you need +# to remap a namespace per environment. -# KV namespace binding — used by KV demo handlers. -# For local dev (`wrangler dev`), this creates a local KV store automatically. -# For production, replace `id` with the output of: -# wrangler kv:namespace create EDGEZERO_KV +# `[stores.kv].ids = ["sessions", "cache"]` [[kv_namespaces]] -binding = "EDGEZERO_KV" +binding = "sessions" +id = "local-dev-placeholder" + +[[kv_namespaces]] +binding = "cache" +id = "local-dev-placeholder" + +# `[stores.config].ids = ["app_config"]` — config is KV-backed on Cloudflare +#. Seed values via `wrangler kv key put` against this namespace; +# the pre-rewrite `[vars] app_config = '{ … }'` form is gone. +[[kv_namespaces]] +binding = "app_config" id = "local-dev-placeholder" diff --git a/examples/app-demo/crates/app-demo-adapter-fastly/fastly.toml b/examples/app-demo/crates/app-demo-adapter-fastly/fastly.toml index 330a20c6..bbb2beff 100644 --- a/examples/app-demo/crates/app-demo-adapter-fastly/fastly.toml +++ b/examples/app-demo/crates/app-demo-adapter-fastly/fastly.toml @@ -8,7 +8,9 @@ service_id = "" [local_server] # Config store entries for local Viceroy testing. -# Mirrors [stores.config.defaults] in edgezero.toml so smoke tests pass on all adapters. +# The platform name matches the logical id from edgezero.toml +# (`[stores.config].ids = ["app_config"]`); override at runtime with +# `EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME=` if you need to remap. [local_server.config_stores.app_config] format = "inline-toml" @@ -17,28 +19,39 @@ greeting = "hello from config store" "feature.new_checkout" = "false" "service.timeout_ms" = "1500" +# KV stores, one per logical id from `[stores.kv].ids = ["sessions", "cache"]`. +# The platform store names match the logical ids by default; override per-id +# via `EDGEZERO__STORES__KV____NAME` (e.g. `…__SESSIONS__NAME=prod-store`). [local_server.kv_stores] -[[local_server.kv_stores.EDGEZERO_KV]] -# We use a dummy key to initialize the store. -# 'data' provides inline content (empty string here). -# 'path' would load content from a file (e.g. path="./README.md"), but we don't need that. +[[local_server.kv_stores.sessions]] +# Dummy `__init__` key keeps the store materialised under Viceroy without seeding data. key = "__init__" data = "" +[[local_server.kv_stores.cache]] +key = "__init__" +data = "" + +# Secret store. The platform name matches the logical id from edgezero.toml +# (`[stores.secrets].ids = ["default"]`) so `BoundSecretStore` resolves to +# this store with no env override. To remap, set +# `EDGEZERO__STORES__SECRETS__DEFAULT__NAME=`. [local_server.secret_stores] -[[local_server.secret_stores.EDGEZERO_SECRETS]] +[[local_server.secret_stores.default]] key = "SMOKE_SECRET" env = "SMOKE_SECRET" [setup] [setup.kv_stores] -[setup.kv_stores.EDGEZERO_KV] -description = "KV store for EdgeZero demo" +[setup.kv_stores.sessions] +description = "KV store for EdgeZero demo (sessions)" +[setup.kv_stores.cache] +description = "KV store for EdgeZero demo (cache)" [setup.secret_stores] -[setup.secret_stores.EDGEZERO_SECRETS] +[setup.secret_stores.default] description = "Secret store for EdgeZero demo" diff --git a/examples/app-demo/crates/app-demo-adapter-fastly/src/main.rs b/examples/app-demo/crates/app-demo-adapter-fastly/src/main.rs index 8f6ad39b..b8ba7515 100644 --- a/examples/app-demo/crates/app-demo-adapter-fastly/src/main.rs +++ b/examples/app-demo/crates/app-demo-adapter-fastly/src/main.rs @@ -10,7 +10,7 @@ use fastly::{Error, Request, Response}; #[cfg(target_arch = "wasm32")] #[fastly::main] pub fn main(req: Request) -> Result { - edgezero_adapter_fastly::run_app::(include_str!("../../../edgezero.toml"), req) + edgezero_adapter_fastly::run_app::(req) } #[cfg(not(target_arch = "wasm32"))] diff --git a/examples/app-demo/crates/app-demo-adapter-spin/runtime-config.toml b/examples/app-demo/crates/app-demo-adapter-spin/runtime-config.toml new file mode 100644 index 00000000..bf3431b7 --- /dev/null +++ b/examples/app-demo/crates/app-demo-adapter-spin/runtime-config.toml @@ -0,0 +1,21 @@ +# Spin runtime configuration for app-demo — declares the KV +# labels the component is allowed to open at runtime. Each +# label uses the default SQLite-backed Spin KV backend, which +# persists to `.spin/sqlite_key_value.db` next to this file. +# +# Custom labels (anything other than `default`) require a +# declaration here; without one, `spin up` errors with +# "unknown key_value_stores label ". `app_config` is the +# KV-backed config store; `sessions` and `cache` are the KV +# labels app-demo declares in `edgezero.toml`. Add a stanza +# below for every additional `[stores.kv]` / `[stores.config]` +# id you wire up. + +[key_value_store.app_config] +type = "spin" + +[key_value_store.sessions] +type = "spin" + +[key_value_store.cache] +type = "spin" diff --git a/examples/app-demo/crates/app-demo-adapter-spin/spin.toml b/examples/app-demo/crates/app-demo-adapter-spin/spin.toml index e0bf005e..54b9a1b7 100644 --- a/examples/app-demo/crates/app-demo-adapter-spin/spin.toml +++ b/examples/app-demo/crates/app-demo-adapter-spin/spin.toml @@ -5,12 +5,22 @@ name = "app-demo-adapter-spin" version = "0.1.0" # Application-level variable declarations. -# Spin variable names are lowercase; set overrides at runtime via -# SPIN_VARIABLE_=value or `spin up --env KEY=value`. +# +# As of the Spin KV-config migration, app-config keys live in the +# KV-backed `app_config` store (see `[component.app-demo].key_value_stores` +# below) — `[variables]` here is now SECRETS-ONLY. +# +# `api_token` is the `#[secret]` field from `AppDemoConfig`; its value +# resolves through Spin's flat variable namespace, so it must be declared +# with `secret = true` for the wasm component to read it. `vault` is +# `#[secret(store_ref)]` — the value is a runtime store id, not material +# to keep secret, but bound the same way for consistency with the +# `AppDemoConfig` surface. `smoke_secret` keeps an empty default so the +# server starts without a value set; pass +# `SPIN_VARIABLE_SMOKE_SECRET=` when running smoke_test_secrets.sh. [variables] -greeting = { default = "hello from config store" } -# smoke_secret has an empty default so the server starts without a value set. -# Pass SPIN_VARIABLE_SMOKE_SECRET= when running smoke_test_secrets.sh. +api_token = { required = true, secret = true } +vault = { default = "default" } smoke_secret = { default = "" } # Component name is shortened for brevity; scaffolded projects use the full @@ -20,15 +30,20 @@ route = "/..." component = "app-demo" [component.app-demo] -source = "../../target/wasm32-wasip1/release/app_demo_adapter_spin.wasm" +source = "../../target/wasm32-wasip2/release/app_demo_adapter_spin.wasm" allowed_outbound_hosts = ["https://*:*"] -# KV store label must match [stores.kv.adapters.spin] in edgezero.toml. -key_value_stores = ["default"] +# Each label is the platform name of a `[stores.kv]` / `[stores.config]` +# id from `edgezero.toml`. `app_config` is the KV-backed config store the +# adapter opens on demand; `sessions` and `cache` are the KV labels. +# Override per-id via `EDGEZERO__STORES______NAME=