feat: provision --local + gitignored adapter manifests + hardened path safety - #287
Open
aram356 wants to merge 131 commits into
Open
feat: provision --local + gitignored adapter manifests + hardened path safety#287aram356 wants to merge 131 commits into
aram356 wants to merge 131 commits into
Conversation
Empty tracking commit for the implementation work tracked in: docs/superpowers/plans/2026-06-27-provision-local.md Issues: - Epic: <epic-issue-url> - Per-section sub-issues linked from the epic. This PR opens as a DRAFT and stays draft until Section 1 lands its first real commit. Each section opens as its own follow-up PR that lands here before the umbrella merges to main.
This was referenced Jun 29, 2026
run_shared_checks iterates every declared adapter and dispatches validate_adapter_manifest, which for Spin does fs::read_to_string(manifest_root.join(rel)). With the containment guard sitting after run_shared_checks, a manifest declaring [adapters.spin.adapter].manifest = "../outside/spin.toml" could trigger a filesystem read outside the project root before the guard rejected it — a spec violation of §"Path containment (MUST)" which requires the helper run BEFORE any manifest-path use. Fix by relocating the check to fire immediately after load_push_context, and looping over every declared adapter (not just ctx.adapter) since run_shared_checks reads all of them. Also close Task 7's Minor about the duplicate adapter_entry call by removing the now-redundant per-adapter guard block. Regression test: config_push_local_rejects_parent_traversal_in_ sibling_spin_adapter declares a poisoned Spin adapter alongside the pushed axum adapter, and asserts the error names the containment violation (not Spin's "failed to read spin manifest" message that would surface under the old ordering). Also tighten copy_tree's else-branch to explicitly gate on is_regular_file() rather than "everything non-dir non-symlink", add a Unix symlink-skip test, and drop a stale #[expect(dead_code)] on ValidationContext::manifest_path that now has real callers.
…to feature/provision-local-impl
The bare-cwd variant of the accept-test (--manifest edgezero.toml)
previously wrote the manifest to a tempdir and set EDGEZERO_MANIFEST,
but run_provision reads args.manifest directly (no env fallback).
The test therefore failed on manifest load ("failed to load
edgezero.toml") and its negative !contains(path-safety-markers)
assertion vacuously passed — no actual coverage of the
`args.manifest.parent() == ""` fallback.
Fix by adding a CwdGuard RAII helper that chdirs into the tempdir
under the manifest_guard() serialisation lock and restores the
previous cwd on drop. Both accept-tests now also assert positively
that the error is the (true, true) dispatch stub ("local dry-run
staging lands in Task 10/11"), proving the manifest loaded AND
path-safety passed AND we reached the dispatch matrix. Drop the
now-unnecessary EnvOverride from both tests.
Reviewer: reviewer of Task 9 pushed this as a Low ahead of Task 10
because run_with_staging depends on manifest-root/cwd correctness.
prk-Jr
self-requested a review
July 14, 2026 06:21
…al-impl # Conflicts: # crates/edgezero-adapter-axum/src/cli/run.rs # crates/edgezero-adapter-cloudflare/src/cli.rs # crates/edgezero-adapter-fastly/src/cli.rs # crates/edgezero-adapter-spin/src/cli.rs # crates/edgezero-adapter-spin/src/cli/push_sqlite.rs # crates/edgezero-cli/Cargo.toml # crates/edgezero-cli/src/adapter.rs # crates/edgezero-cli/src/config.rs # crates/edgezero-cli/src/generator.rs # crates/edgezero-cli/src/provision.rs
`push_config_entries` built its starting map with a `_ => BTreeMap::new()` catch-all over `fs::read_to_string`, so every read failure -- not just NotFound -- silently produced an empty map. The `fs::write` that follows then replaced the file with only the current push's entries, dropping every sibling blob the operator had already pushed (e.g. losing `app_config` when pushing `app_config_staging`). Only two cases legitimately start empty: the file does not exist, or it exists and is blank. Invalid UTF-8, permission-denied and transient I/O errors now propagate with an error that names the file and says the push was refused rather than overwriting it. Regression test seeds an unreadable (invalid UTF-8) local-config file and asserts the push errors AND leaves the file byte-identical.
Blocking #1 -- symlink escapes in gitignored local state. `.edgezero/` and the env files are gitignored, so a symlink planted in either never surfaces in review, and both write paths followed it: - `env_file::append_lines_dedup_with_header` used `path.exists()` + `fs::write`, which follow a symlinked final component. A planted `.env -> ~/.ssh/authorized_keys` had provision write attacker-chosen `KEY=value` lines into the target, and `set_restrictive_mode` then chmod 0600'd the victim's file. A DANGLING link is worse: `fs::write` creates the target. Now rejected via `symlink_metadata`, which does not follow links. - `ProvisionLock::acquire` ran `create_dir_all` + `OpenOptions::create` on `.edgezero/provision.lock`, taking an flock on -- and holding a writable descriptor to -- a file outside the tree. - The dry-run staging copy vetted symlinked entries *inside* the tree it walked but not the root it was handed, so a symlinked `.edgezero` would have it copy e.g. `~/.aws` into the operator-visible staging dir. `path_safety::reject_symlink_components` already implemented the bounded component walk for manifest-declared paths; it is now pub(crate) with a field-agnostic message and reused by the two CLI sites. `env_file` lives in `edgezero-adapter` and has no project root to bound a walk against, so it guards its final component only -- the parent chain remains the resolving caller's job. Blocking #5 -- lossy derivations collapsing two values onto one target. Two independent axes, both silent and both serving the wrong value: - Fastly derives each secret's Viceroy env var as `key.to_ascii_uppercase()`, and `upsert_secret_store_entry` dedups on the exact key -- so `api_token` and `API_TOKEN` produced two separate `fastly.toml` rows that BOTH read `$API_TOKEN`. Now caught by `validate_typed_secrets`, which was a no-op stub. Cloudflare's stub is correct and stays: it never derives anything from the secret key. - Cloudflare AND Fastly both upper-case `store.logical` into `EDGEZERO__STORES__<KIND>__<LOGICAL>__NAME`. TOML keys are case-sensitive, so `[stores.kv.myStore]` and `[stores.kv.MYSTORE]` are two real stores emitting one variable, and env_file's dedup silently dropped the loser -- leaving that store pointed at the other's platform name. Guarded once in `ProvisionStores`, called by both. Kind is part of the variable name, so cross-kind id reuse still works. Tests cover each escape and each collision, asserting the victim file is byte-identical / the link target was never created, plus the negative cases (exact duplicate ids, same id across kinds, one key in two stores) so the guards don't over-reject.
`fastly.toml` is gitignored (per-machine); `edgezero.toml` is committed
(shared). The two provision paths move `service_id` in OPPOSITE
directions -- local pins the tracked id INTO fastly.toml, cloud captures
fastly.toml's id back OUT into the tracked block -- and nothing
arbitrated between them: the CLI hard-coded `deployed: None` on the
cloud arm, so the adapter could not see the tracked value, and
`merge_deployed_into_manifest` does a plain `insert`.
The read-back therefore always won. A developer whose gitignored
fastly.toml was left over from another service ran `edgezero provision`
and silently rewrote the id the whole team deploys against -- with no
diff to review, since the file that sourced it is not in git.
The read-back is not gratuitous, though: it is the intended bootstrap
(`fastly compute deploy` creates the service and writes its id into
fastly.toml, provision records it for the team), so "tracked always
wins" would break first-time capture. Thread the tracked state into the
cloud arm and arbitrate on it instead:
- tracked absent -> capture (the bootstrap case, unchanged)
- tracked == fastly.toml -> no writeback; returning the identical value
would report a no-op edit and render a
dry-run diff on every cloud run
- tracked != fastly.toml -> error naming both ids and both remedies
The conflict arm errors rather than picking a side because neither is
trustworthy automatically: the local file may be stale, or the service
may have been legitimately re-created. The operator decides.
The two existing read-back tests already passed `deployed: None`, so
they keep passing and now pin the bootstrap case precisely; new tests
cover the match and conflict arms.
The CLI runs `build`/`deploy`/`serve`/`auth` two ways: if the manifest sets `[adapters.<name>.commands].<action>` it spawns that shell command with the project root as cwd and the resolved child env (bind hints, `[environment.variables]`, the provision-written `.env` overlay); if the command is unset it falls back to the registered adapter's `execute`. The fallback received only the action and passthrough args, so everything the shell path applies was dropped: a `serve` there started the app with none of its `.env` secrets and resolved its manifest from the process cwd instead of the project root (blocking #3). It is reachable whenever a hand-written manifest omits `commands.serve` (scaffolded projects always set it). `Adapter::execute` now takes an `AdapterExecContext` carrying the manifest root + fully-resolved child env. Each adapter's `run::{build, deploy,serve}` seeds manifest discovery from `ctx.cwd()` (via the new `cli_support::discovery_base`) and applies `ctx.env()` to the spawned vendor CLI; the `auth` arms shell out to a globally-scoped login and ignore it. An empty context is inert, so the shell path and every existing call are unchanged. Env precedence + the required-secrets assertion were the shell path's private `apply_environment`; both moved into a shared `build_child_env` so the two paths cannot drift. That move also fixes two adjacent bugs: - #7: required `[environment.secrets]` were checked against the parent env BEFORE the `.env` overlay was applied, so a secret supplied only by the provision-written `.env` was falsely reported missing and `serve` refused to start. The check now sees the same resolved set the child will. - #9 (Spin half): the fallback `spin up` omitted `--runtime-config-file`, which the shell path passes, so local KV bindings provision wrote were absent. `run::serve` now passes it when `runtime-config.toml` exists. Trait doubles and call sites updated; tests cover the context apply, the inert default, overlay-as-secret-source, and the precedence arms.
Spec-alignment (findings #1, #6, #7): synthesised manifests now match the spec's normative baselines exactly, verified by per-adapter exact-content tests. - Cloudflare drops the `[build]` table; Fastly drops the empty `authors` array; Spin drops the `[component.<id>.build]` table. - Spin `allowed_outbound_hosts` becomes opt-in via `[adapters.spin.adapter].allowed_outbound_hosts`, defaulting to Spin's deny-all baseline. Both `edgezero new` and clean-clone read the same manifest, so the emitted file stays byte-identical. - Fastly cloud `provision` no longer auto-captures `service_id` from the gitignored, per-machine `fastly.toml`. Per the v1 contract the writeback is a documented one-time manual copy after `fastly compute deploy`; auto-capturing it let a stale local file overwrite the team's committed id. Correctness (findings #2, #3, #4, #8): - Cloudflare cloud `provision` reconciles namespace ids against the tracked `[adapters.cloudflare.deployed]` block: a local id that disagrees aborts with a conflict error instead of silently overwriting the committed one, and a fresh clone whose wrangler.toml lacks the id has it restored rather than creating a duplicate remote namespace. - Fastly typed-secret provisioning seeds the Viceroy `[[local_server.secret_stores.<name>]]` table under the resolved PLATFORM name (`TypedSecretEntry.platform`), the name the runtime opens, instead of the raw logical id -- an env override no longer points the runtime at a store the seed never created. - Symlink guards extended to the remaining provision/push write sites: `write_baseline_to_disk`, Axum's local-config JSON, and Spin's local KV SQLite db. `env_file::reject_symlinked_target` is now the shared final-component check. - Deployed-field ownership is enforced in core `Manifest` validation (canonical adapter map), so reduced-feature builds and non-CLI readers reject a field placed under an adapter that does not own it, not just the CLI's registry-based check. Also removes planning/review provenance markers (PR-round, Task, Stage, Phase, review-priority) from code comments, and corrects the CHANGELOG lock-path reference.
Closes the two remaining review mediums. Push→provision coverage (spec §"Push after provision leaves provision artifacts intact"): each adapter now has a concrete `push_after_provision_preserves_*` test that seeds an operator secret into the provision-written file, runs `config push --local`, and asserts that file is byte-for-byte intact: - Axum: `.edgezero/.env` (push writes local-config JSON). - Cloudflare: `.dev.vars` (push shells `wrangler kv bulk put` via the fake-wrangler shim -- no network). - Fastly: `[[local_server.secret_stores.<id>]]` in fastly.toml (push writes `[local_server.config_stores.*]`). - Spin: the Spin-side `.env` (push writes the local KV SQLite db). Migration docs: the generated README and getting-started/spin guides told operators to run the not-yet-installed project CLI on a fresh clone and to hand-edit the generated per-adapter binding. Both contradict the workflow -- the CLI is built from source (`cargo run -p <cli> --`) and provision owns the adapter binding. Corrected the README template, the getting-started and spin adapter guides, and the cli-walkthrough preamble, and fixed the remaining `.edgezero-provision.lock` -> `.edgezero/provision.lock` reference. CHANGELOG updated to note the coverage gap is closed.
The spec's rerun-to-refresh workflow says an operator who changes `[adapters.spin.adapter].component` out of phase with an already-synthesised `spin.toml` re-runs `provision --local` to refresh. But provision rejected it: `resolve_component_id` errored when the selector matched no existing component, and `validate_adapter_manifest` (which provision runs first) rejected it before provision could update anything -- so the manifest could never be refreshed. For a single-component `spin.toml`, provision now renames the sole `[component.<old>]` table to the selector and repoints `[[trigger.http]].component` at it (Spin's loader rejects a trigger that names a component with no matching block, so both edits happen together). Validation allows the transient mismatch for the single-component case so provision can proceed. A non-matching selector against MULTIPLE components stays a hard error -- provision can't infer which to rename. Tests: base `provision` and `provision_typed` both rename the sole component and repoint the trigger; validation allows the single- component refresh but still rejects the ambiguous multi-component case; the CLI `config validate` test is updated to match.
Round-11 review, blockers and mediums: - Spin `read_sqlite_entry` opened the db read-write and ran `CREATE TABLE`, so a nominally read-only `config diff --local` / push preflight dirtied the file. It now opens READ_ONLY and runs no DDL; a schema-less db reads as `MissingStore`. - Spin's local-KV path guarded only the final db component. A symlinked INTERMEDIATE (`.spin`) would let `create_dir_all` / open escape the project tree. New `env_file::reject_symlink_components` walks every component from the manifest root down; applied on both the write and the read resolution. - Cloudflare cloud provision no longer promotes an unverified namespace id read from the gitignored per-machine `wrangler.toml` into tracked deployed state. Durable ids come from `wrangler kv namespace create` output only; a local id that matches tracked is a no-op, one that conflicts errors, and one with no tracked entry is reported, never silently made the team source of truth. - `config validate` stays strict on a Spin component-selector mismatch (reports the inconsistency); the single-component refresh is now a provision-only transition via a new `allow_component_refresh` flag on `validate_adapter_manifest`, so provision can rename while the static check does not weaken. - Fastly cloud dry-run checks the `[setup.*]` skip BEFORE reporting, so a dry-run models the real run instead of claiming "would create" for a store that already exists. - Docs: Fastly/Cloudflare primitive snippets match the synthesised baselines; Fastly documents the one-time `service_id` copy; the migration guide gains the guarded `git rm --cached` untracking step.
…push tests
Round-11 review, remaining findings:
- The registry-fallback `execute` rediscovered each adapter's
per-platform manifest by scanning the workspace, which can pick the
wrong manifest in a nested / multi-app layout or follow a symlink off
the validated tree. `AdapterExecContext` now carries the declared,
root-resolved `[adapters.<name>.adapter].manifest`, and every
adapter's `run::{build,deploy,serve}` uses it verbatim (via
`cli_support::declared_or_discovered_manifest`) instead of scanning
whenever a manifest was loaded.
- Managed TOML value updates used `Table::insert`, which replaces the
whole item and drops a trailing inline comment on the line. Cloudflare
`id`/`preview_id`, Fastly root `service_id`, and the
`[adapters.<name>.deployed]` writeback now update the value in place
and clone the existing decor, honouring the byte-preserving merge
contract.
- The push-after-provision contract tests manually constructed the
secret file, so a provision-output-shape regression could slip past
them. All four (Axum/Cloudflare/Fastly/Spin) now run the real
`provision_typed` to write the file, let the operator fill in a value,
then push and assert it survives -- exercising the actual composition.
Blockers: - Apply the adapter declared-path safety guard to `build` and `deploy`, not just `serve`, via a shared `assert_adapter_declared_paths_safe`. - Anchor `wrangler kv namespace create` to the resolved wrangler.toml with `--config` so it can't act on an unrelated config. - Stop losing namespace ids when a later store fails mid-provision: `ProvisionOutcome` now carries `error`, per-store work moved into `provision_one_kv_store`, and the id is recorded before the writeback so a create-success/upsert-failure still checkpoints. The CLI persists `deployed` first, then surfaces the error. - Refresh the Spin component selector even when no KV/config stores are declared, so a secrets-only app doesn't leave a manifest that fails strict validate. - Reject distinct (store, key) pairs that collide on the same uppercased Fastly env var, including the same key across two stores. - Fix the migration untracking snippet: match adapter manifests and `.dev.vars` at the repo root as well as subdirectories (a `**/` pathspec skips root files), and keep the pipeline NUL-delimited. Also: - `config push --local` for Fastly now only upserts keys into a contents table provision already created, instead of fabricating the manifest structure it doesn't own. - Refuse Fastly chunk keys that exceed the 256-character Config Store key limit rather than emitting keys the platform rejects. - Treat a present-but-non-string `path`/`url`/`type` in Spin's runtime-config as malformed instead of silently falling back to the default backend, and preserve inline-comment decor when the component rename repoints a trigger. - Drop the legacy `.crate`/root fallback when resolving Spin's serve `.env`; it contradicts the required-`.manifest` rule and could read the wrong directory. - Assert the local dry-run actually succeeds for every adapter, and cover staged-tempdir path rewriting directly against `render_dry_run_report`. - Seed the Fastly and Cloudflare config stores in the config smoke before booting the emulator, and correct the adapter-manifest baselines in the docs (the synthesised `name` is the adapter crate's package name) plus the fresh-clone CLI invocations.
Blockers: - Refuse registry-fallback dispatch when a loaded manifest declares an adapter without `[adapters.<name>.adapter].manifest`. Previously the path guard was a no-op and the exec context carried no manifest, so every adapter fell back to a recursive, symlink-following workspace scan that could select an unrelated or out-of-tree project. - Thread the resolved `.crate` root through `AdapterExecContext`. The Cloudflare, Fastly, and Spin build paths assumed `Cargo.toml` sat beside the platform manifest, so a nested `crates/server/config/ spin.toml` resolved `config/Cargo.toml` and failed the build. - Refuse Fastly cloud provisioning when fastly.toml is absent. On a clean clone it creates the remote stores first, then materialises a `[setup.*]`-only manifest that `fastly compute build` rejects, leaving the stores orphaned. Cloud dispatch also now receives the tracked deployed state, so the resource-link remediation still fires when only `[adapters.fastly.deployed].service_id` knows the service is deployed. - Validate a chunk pointer's declared lengths before allocating. `envelope_len` is untrusted store data and reached `String::with_capacity` directly, so a corrupt pointer aborted the runtime instead of returning an integrity error. Also: - Preserve `ProvisionOutcome::error` across baseline prepending and the typed dry-run merge; both rebuilt the outcome from status lines and deployed state alone, reporting a failed local provision as success. - Enforce the 256-character Config Store key limit on the direct-value path, not just on chunk keys. - Make Fastly's dry-runs model the real operation: local push now probes the provision-owned contents table read-only, and cloud provisioning no longer claims it would create an `edgezero_runtime_env` store whose setup block already exists. - Build `axum.toml` through `toml_edit` instead of raw interpolation, and reject typed secret keys that cannot round-trip through a `<key>=` line (`partner=token` previously emitted `partner=token=`). - Point the config smoke at `/config/typed`, which reflects the single BlobEnvelope `config push` writes; the per-key assertions only passed against hand-seeded pre-cutover emulator state. - Save and restore `.dev.vars` in the secret and key-override smokes rather than truncating and deleting it: provision writes only empty placeholders, so it is not regenerable. - Drive clean-clone manifest regeneration through the typed provision entry point a generated CLI actually calls, and drop the unused `ConfigPushSuppressions` compatibility stub.
Blockers: - Split the typed dry-run helper so `run_local_dry_run_typed` clears the workspace `too_many_lines` clippy lint (the `-D warnings` CI gate was failing), and thread the staged provision's partial-failure error out so a dry-run over a broken provision exits non-zero. - Derive the axum nested `crate_dir` lexically instead of via `canonicalize()`: the old code required the manifest's parent dir to exist, so on a fresh clone it stayed relative while the crate root went absolute, `strip_prefix` failed, and it emitted "." where a nested manifest needs "..". - Anchor the adapters' `cargo build` at the crate root (`current_dir`) so dispatching through an absolute `EDGEZERO_MANIFEST` from outside the project no longer picks up the caller's `.cargo/config.toml` or resolves relative args against the wrong directory. - Make `resource_link_note` treat the tracked `[adapters.fastly.deployed].service_id` as the durable authority: prefer it over the gitignored fastly.toml and refuse on conflict, rather than recommending a link to whatever the local file names. Smoke fixes: - Config smoke: pass `--yes` on every `config push` (was prompting / failing without a TTY) and seed the mandatory `demo_api_token` secret per adapter so `/config/typed`'s secret walk resolves. - Secrets smoke: inject the `SMOKE_SECRET` declaration into the generated fastly.toml / spin.toml before boot (provision only declares typed secrets), so the Fastly and Spin arms resolve it on a clean clone. - Override smoke: stop appending a duplicate `demo_api_token` secret-store entry (warm-up already wrote it) and back up fastly.toml, which boot mutates in place. Also: - Refresh the app-demo Cargo.lock for axum's new `toml_edit` dep so `--locked` passes. - Require `[adapters.<name>.adapter].crate` alongside `.manifest` on the registry-fallback path, and propagate the untyped dry-run's error. - Emit the generated-CLI secret-placeholder follow-up after a bundled `provision --local`, and report Spin provisioning per touched file instead of one line naming only spin.toml. - Correct the spec (no `.hbs` templates back the adapter manifests) and the plan (keep the `.dev.vars` backup; only manifest backups are obsolete).
Blockers:
- Reject absolute / `..` SQLite `path`s and symlinked runtime-config.toml
in Spin local writes, so `--local` state can't escape the project tree.
- Refuse a Spin `--local` push whose label declares a non-SQLite backend
(redis / azure_cosmos / unknown) instead of seeding a database `spin up`
never reads.
- Edit inline-table deployed state via `TableLike`, so a valid
`deployed = { kv_namespaces = { … } }` manifest can be checkpointed
instead of failing writeback and stranding a created cloud resource.
- Reconcile the Fastly `service_id` (tracked vs local, with conflict
refusal) in PREFLIGHT, before any store is created or `[setup]` written,
and so dry-run surfaces the conflict too.
- Guard the axum serve `.env` chain with `reject_symlink_components`: a
symlinked `.edgezero` / `.env` can no longer inject env into the child.
- Enforce adapter-declaration consistency on the manifest
`commands.<action>` shell path too, so a half-declared adapter that
provision and the registry fallback reject can't slip through
build/deploy/serve.
Smoke:
- Back up fastly.toml before the oversized override section mutates it,
and run `restore_backups` even when `stop_server` returns non-zero
under `set -e` (it previously short-circuited the restore).
- Thread the authoritative `.crate` path into `synthesise_baseline_manifest` so a nested package can't be mis-selected: adapters now derive the crate name from the declared crate root, falling back to the ancestor Cargo.toml search only when it's undeclared. - Resolve a relative `CARGO_TARGET_DIR` against the crate dir cargo built in (the build runs with `current_dir(crate_dir)`), not the CLI's process cwd, so Cloudflare/Fastly artifact discovery finds the wasm. - Emit the commented `__KEY` hint for a CONFIG store added on Fastly's additive provision path, so incremental provisioning converges to the same shape a clean provision produces. - Dedup batched env-file entries against each other, not just the file, so duplicate typed-secret keys can't leave a trailing blank-valued placeholder that wins at load. - Repoint EVERY Spin trigger type on a component rename, not just `[[trigger.http]]`, so a redis/other trigger can't keep a stale ref. - Narrow the config-push lock: release it across the interactive consent prompt (re-acquired for the write, which rechecks remote state) so a human at the prompt can't block peers; and take the lock in `run_deploy` since `fastly compute deploy` writes `service_id` into fastly.toml. - Report provision writes by what actually landed: Axum and Spin no longer count deduped-away candidates as writes (`append_lines_dedup` now returns whether it wrote). - CI: add `--locked` to the app-demo test, and assert the adapter manifests + `.dev.vars` are actually gitignored (`git check-ignore`), not merely currently untracked.
Blockers:
- Edit inline `adapters = { … }` state via TableLike at the TOP level
too, so a fully-inline adapters tree can be checkpointed instead of
rejected after remote creation.
- Preflight a missing wrangler.toml in Cloudflare cloud provision, before
any `wrangler kv namespace create`, matching Fastly -- a clean clone no
longer orphans namespaces behind a manifest that never declared them.
- Thread the manifest deploy command into `config diff`'s push context
(as push already does) so a Fermyon Cloud Spin config reports the
Cloud-unsupported signal instead of reading local SQLite.
- Reject a Spin `--local` SQLite db that resolves OUTSIDE the crate even
when the `path` string is clean-relative: `--runtime-config` can point
the anchoring directory off-tree (e.g. `/tmp`).
- Guard the axum serve `.edgezero` chain UNCONDITIONALLY, not only when
`.env` exists -- the config store reads `local-config-*.json` there at
request time, so a symlinked `.edgezero` must be refused with no `.env`.
- Back up fastly.toml (and `.dev.vars`) BEFORE the config smoke's warm-up
and push mutate them, and install the cleanup trap first, so a run
never leaves a developer's tree changed.
Also:
- Resolve `CARGO_TARGET_DIR` for artifact lookup from the same ctx env
the build used (not just the process env), so a manifest-set custom
target dir is found.
- Make Spin's local read/diff reject Redis/Azure/unknown backends via the
same helper the write path uses, instead of diffing stale default
SQLite.
- Stop taking the non-reentrant provision lock in `run_deploy`: the
deploy command is arbitrary and may itself invoke provision/config
push in a child process, which would self-deadlock.
- Anchor axum `crate_dir` on the declared `.crate` (like the crate name),
so a nested package can't produce an internally inconsistent axum.toml.
- Align the spec gitignore example and the plan's axum step with the
current contract: axum.toml is a provision-generated, gitignored
manifest.
Provisioning and push: - Fastly cloud provision preflights the entire [setup] writeback shape before any `*-store create`, so a malformed-but-valid manifest aborts before orphaning a remote store (and dry-run models the real outcome). - Cloudflare cloud provision preflights every store's deterministic id-conflict and writeback-shape checks before the first `wrangler` call, so a later store's knowable conflict can't strand a namespace an earlier store already created. - Spin build artifact discovery reads CARGO_TARGET_DIR from the ctx env the build actually used, not just the process env. - A declared `[adapters.<name>.adapter].crate` with an unreadable Cargo.toml is now a hard error rather than a silent fallback to ancestor discovery. - Idempotent env-file provisioning repairs loose (0644) permissions to 0600 even when there is nothing to append; dry-run leaves mode alone. - A missing `fastly` CLI is surfaced as an error instead of being misreported as a missing config store. - Cloudflare config-push dry-run fails on a malformed wrangler.toml instead of masking it as an <unresolved> namespace; an absent or placeholder binding stays a lenient preview. Axum: - Fallback execution validates the resolved crate dir against the authoritative declared crate (or the workspace root), rejecting conflicts and symlink escapes. Smoke scripts: - Back up developer-local emulator state before warm-up provisioning mutates it, restore directories and files in reverse order, and distinguish an absent file from an existing empty one. Docs: - Reconcile the provision-local spec, scaffold README, and Spin guide with the hard-cutoff model: the synthesiser is the single writer for every adapter manifest, hand-edits are preserved on re-run, and `edgezero new` takes no --adapter flag.
Build/artifact discovery: - CF, Fastly, and Spin resolve cargo's effective target dir the way the build did (`--target-dir` arg > CARGO_TARGET_DIR > `.cargo/config.toml` `[build] target-dir`) and refuse to fall back to a conventional path when an override is set, so a custom-target build can't package a stale default-target artifact. Shared resolver added in cli_support. Provision / push correctness: - Cloudflare `config push` / `config diff` anchor wrangler at the DECLARED manifest via `--config`, so a non-default filename beside a plain wrangler.toml can't target the wrong account / namespace. - Fastly cloud provision preflights every managed `[setup.*_stores.<name>]` child (not just the collection tables), so a malformed scalar entry can't be silently skipped after an earlier store was created remotely. - Fastly local diff surfaces a malformed scalar `contents` as an error instead of a missing key; remote reads require a config-store / entry marker before classifying a "not found" as missing, so an unrelated failure (e.g. a missing profile) is no longer misreported as absent. - A Spin local SQLite `path` is validated against the CRATE ROOT with lexical `..` folding, so a nested manifest's `../.spin/db` that stays in-crate is accepted while a real escape is still refused. - Spin validation and provisioning now agree on inline component tables: both reject an inline `[component]` up front with a clear message. - `deploy` holds the provision lock and advertises it to the deploy subprocess via the child's own environment, so a vendor `service_id` writeback can't race a concurrent provision/push; a nested provision the deploy command invokes borrows the lock instead of self-dead-locking. - Local dry-run previews a permission-only repair (0644 -> 0600) that the content-only diff previously hid. Runtime / adapter: - Axum fallback lets the resolved context env (`.edgezero/.env` + manifest `[environment]`) set HOST/PORT unchanged instead of losing to axum.toml. - Read/serve/diff paths reject a symlinked final component the write side already refuses (Axum config store + diff, Spin serve + runtime-config). Smoke scripts: - Centralised fail-closed backup/restore in scripts/lib/smoke_backup.sh (with a standalone unit test): a failed capture aborts before any mutation instead of writing an empty/partial backup over the original, and a failed restore keeps the backup for recovery. Docs: - Reconcile the remaining spec paragraph with the no-template hard cutoff.
…closed
Build / artifact discovery:
- Cloudflare `config push` / `diff` pass wrangler an ABSOLUTE `--config`
path; the previous manifest-root-relative path doubled under the
command's changed cwd (`crates/cf/wrangler.toml` -> `crates/cf/crates/cf/
wrangler.toml`), targeting the wrong project.
- The cargo target-dir resolver also honours `CARGO_BUILD_TARGET_DIR` and a
`--config build.target-dir=…` build arg, so those redirects no longer
fall back to a stale conventional artifact.
- Spin's native build refreshes the conventional `target/` path that the
synthesised `spin.toml` `source` references, so `spin up` / `spin deploy`
don't read a stale module after a custom-target build.
Provision / push correctness:
- The provision lock's inherited advertisement is now VERIFIED: a matching
`EDGEZERO_PROVISION_LOCK` only lets a nested provision borrow after a
non-blocking lock attempt proves a real lock is held; a forged / stale
advertisement takes the real lock instead of skipping serialization.
- Provider not-found classification excludes auth/config failures ("API
key/token not found", "profile not found", 403, unauthorized) across
Fastly + Cloudflare read + chunk paths, so a credentials error is
surfaced instead of masked as an absent store/key.
- Fastly local diff distinguishes a malformed INTERMEDIATE node
(`local_server` / `config_stores` / the store scalar) from an absent one
-- the former is now an error, not MissingStore.
- Fastly cloud provision re-emits the resource-link remediation on the
skip path (already-declared store on a deployed service), so an operator
who missed the first run can still finish linking.
- The typed local dry-run stops before typed provision when the base
partially fails, mirroring real execution instead of previewing writes
the real command won't perform.
Read-side symlink boundaries:
- Axum diff/read rejects a symlink at ANY component down to the JSON
(a symlinked `.edgezero`), not just the final file.
- Spin's read-path symlink guard walks from the CRATE ROOT, covering a
nested manifest whose db resolves above the manifest's own dir.
Smoke scripts:
- `restore_backups` stages into a sibling temp and atomically swaps, so a
failed restore never deletes the live path, and it returns non-zero on
failure (the earlier version deleted first and returned success).
- The config and KV smokes back up emulator state (`.edgezero` /
`.wrangler` / `.spin`) that pushes overwrite.
Docs / gates:
- Reconcile a remaining spec paragraph with the no-template hard cutoff and
extend the no-legacy-read gate to scan generated `*.rs.hbs` templates.
Provision lock: - Borrowers of a deploy's advertised lock now hold a SEPARATE sibling lock (`provision.borrow.lock`) so two provisions the same deploy spawns still serialise against each other instead of both running lock-free and concurrently rewriting the same manifests / env files. The parent's main lock still gives tree-level exclusion; a forged advertisement still takes the real lock (proven via a non-blocking attempt). Build / artifact discovery: - The cargo target-dir resolver matches cargo more closely: the LAST `--target-dir` / `--config build.target-dir=` wins, a `--config <file>` is read for its `[build] target-dir`, and the `.cargo/config.toml` walk covers every ancestor to the filesystem root plus `$CARGO_HOME`. - The generated Spin `commands.build` pins `--target-dir target` so a `CARGO_TARGET_DIR=custom` build can't leave `spin.toml`'s `source` pointing at a stale conventional artifact (the shell build bypasses the native source-refresh). Smoke scripts: - Backup capture REFUSES a symlink (matching the provision-side policy) rather than following it and losing link identity / mutating the target. - Cleanup kills the server AND its descendants (workerd/spin) and waits for the port to free BEFORE restoring, so a survivor can't flush state over the restore; INT/TERM now run cleanup then EXIT so an interrupt can't resume the smoke and re-mutate after restoration. The config and KV smokes back up emulator state too. Migration / cutoff enforcement: - The migration matcher, runbook, and CI gate now also untrack the provision-written secret-bearing env files (Spin's `<crate>/.env`, Axum's `.edgezero/.env`), not just `.dev.vars`. - A generator test forbids any scaffold template from reclaiming a provision-owned adapter manifest (`axum.toml` / `spin.toml` / ...). Dry-run: - A partial-failure error surfaced from the staged provision is sanitised (staged tempdir path -> project path), matching the status-line rewrite, so no raw staging path leaks to the operator.
Provision lock (crates/edgezero-cli): - Authenticate the deploy lock advertisement with a per-holder token written into the lock file. A nested provision borrows only when the advertised token matches the file token, so a stale/leaked/forged advertisement can no longer bypass an unrelated holder -- it serialises on the real lock instead. - Make the lock advertisement (path + token) OVERRIDE inherited env in the child overlay so a nested deploy advertises its own lock rather than an ancestor's, closing a nested-deploy self-deadlock. Cargo target resolution (edgezero-adapter cli_support): - Parse inline `--config` payloads as TOML so a spaced `build.target-dir = "x"` is honoured, not dropped. - Follow config `include` directives when reading `[build] target-dir`. - Resolve a config-file-relative `target-dir` against cargo's real base (the parent of the directory containing the file), matching where the artifact actually lands. Smoke cleanup (scripts): - Only kill port holders when the smoke actually launched a server, and refuse to launch when the port is already held, so a pre-launch failure never SIGKILLs an unrelated service. Generated commands: - Omit the Axum shell `commands` block so build/serve route through the axum.toml-aware registry adapter instead of a raw `cargo -p` override. - Point Spin serve/deploy at the declared manifest (and its parent for the runtime config) so a nested spin.toml resolves. Cloudflare: - Check auth/config failures before the missing-store/missing-key mapping so an auth error mentioning "binding" isn't misread as a missing store. - Reject typed secret keys in the reserved `EDGEZERO__STORES__` namespace, which would otherwise dedup against generated store overlays. Dry-run: - Rewrite Fastly "pinned" and Cloudflare "kv binding" status lines to the conditional so a preview no longer reads as a completed write. Docs: - Correct the CLI walkthrough: provision declares typed Spin `#[secret]` variables; only code-local store_ref keys need manual declaration.
Provision lock:
- Fail the acquire if the per-holder token can't be persisted to the lock
file. Previously a failed write was ignored while the token was still
advertised, so a nested provision could not authenticate the borrow and
dead-locked on the parent's lock.
Cargo target resolution (edgezero-adapter cli_support):
- Follow `include` from inline `--config` payloads too, and let a LATER
include override an earlier one (cargo's merge order).
- Detect inline `--config` TOML by parsing (accepts quoted dotted keys),
not a hand-rolled key scan.
- Honour a ctx-provided CARGO_HOME when locating the global config.
Axum:
- Anchor the dev server's KV `.edgezero` at the project root (matching the
config store and provision), and refuse a symlinked `.edgezero`, so a
registry `serve` (cwd = crate) no longer strands KV state in the crate
dir or follows a link out of the tree.
- Reject typed secret keys in the reserved `EDGEZERO__STORES__` namespace,
which would dedup against generated store overlays in `.edgezero/.env`.
Spin:
- Omit the generated shell `commands` block so build/serve/deploy route
through the registry adapter, which reads the declared spin.toml
dynamically and refreshes the conventional artifact -- a static override
baked the manifest path and diverged from provision on a nested move.
Fastly:
- Reserve the internal `edgezero_runtime_env` store name: a user store
resolving to it is refused before any write / account mutation.
- On a broken stdin write to `fastly`, reap the child and surface its
stderr instead of returning a bare pipe error.
Cloudflare:
- Refuse a malformed tracked namespace id instead of restoring garbage and
reporting success.
- Map wrangler's "No KV Namespaces configured!" to MissingStore.
Dry-run:
- Rewrite Axum "ensured" and the Fastly local store status lines to the
conditional so a preview never reads as a completed write.
Smoke scripts:
- Route the config-key-override smoke's teardown through the ownership-
guarded shared helper and detect port occupancy via lsof, so a
pre-launch failure never SIGKILLs an unrelated service.
Docs:
- Fix the VitePress production build (the `{{ }}` example moved back into a
fenced block) and correct the Spin secret workflow: provision --local
declares typed `#[secret]` variables; the migration guide uses
SPIN_VARIABLE_.
CI:
- Gate the smoke scripts with ShellCheck and run the smoke backup helper
unit tests.
Cargo target resolution (edgezero-adapter):
- Accept array-of-tables `include = [{ path = "..." }]` entries.
- Prefer the extensionless `.cargo/config` over `.cargo/config.toml` when
both exist, matching cargo (verified against cargo 1.95).
Cloudflare:
- Validate tracked namespace ids in LOCAL provision too (cloud already
did) -- a malformed id no longer seeds wrangler.toml.
- Require an absence qualifier alongside "binding" before mapping a
wrangler error to MissingStore, so an invalid-binding / malformed-
manifest error surfaces instead of reading as an absent store.
- Reserve the whole `EDGEZERO__` namespace for typed secrets.
Axum:
- Reserve the whole `EDGEZERO__` namespace for typed secrets (not just
`EDGEZERO__STORES__`), so a secret can't shadow adapter/logging config.
Fastly:
- Reject the reserved `edgezero_runtime_env` store in the config
read/write dispatch, not only during provision, so a runtime env
overlay can't route app config into the internal override store.
- Correct the Config Store key limit to 255 (was 256).
Spin:
- Pass `--from <declared-manifest>` on deploy and serve so a non-standard
manifest filename is honoured.
- Refresh the component's DECLARED `source` (matched by this crate's wasm
filename) in addition to the conventional target, so a custom source
path never serves a stale module.
Smoke scripts:
- Tie the port teardown to processes proven to be descendants of the
wrapper (captured before teardown); an unrelated process that grabs the
freed port is never killed.
- Replace the fragile `[^[]*` runtime-env block regex with a line-based
section removal that survives brackets in comments/values.
Docs:
- Correct the manifest-store-migration host claim, the direct `spin up`
command (runtime-config + .env), and the Spin secret .env path.
Spin cloud detection: - Add an explicit `[adapters.spin.adapter].cloud = true` flag threaded through the push context. The hard-cutoff manifest omits the `[adapters.spin.commands]` block, so the old "sniff commands.deploy for 'spin cloud deploy'" heuristic could never fire on generated projects -- cloud `config push`/`diff` silently fell through to local SQLite. Cloud now routes on the flag (or the legacy deploy-command sniff for hand-written manifests). Provision lock (composed-deploy deadlock): - A deploy only SPAWNS provisions; it writes no provision files itself, so a borrowing deploy now takes NO sibling lock. Holding the single shared sibling lock across the deploy subprocess dead-locked a nested (grand)child borrower that then contended on the same file -- a three-level composed deploy hung. Co-sibling provisions still serialise, because each of them takes the sibling lock via `acquire`.
A wasip2 Spin component reads its own sandboxed WASI env (`std::env`); it does NOT inherit the `spin` host process environment. Provision writes the `EDGEZERO__*` store overlays (`__NAME`, per-environment `__KEY`) into the spin crate's `.env`, and `edgezero serve` loaded them into the host process -- but Spin never forwarded them into the guest, so every store silently fell back to its logical id and a `__KEY` override had no effect. - `run::serve` now forwards every `EDGEZERO__*` var to the guest via `spin up --env KEY=VALUE` (secrets still travel as Spin variables; other host env stays out of the sandbox). Extracted `guest_env_forwards` with a unit test. - The config-key-override smoke's Spin row passes the `__KEY` override via `spin up --env` instead of a host env var; verified live against Spin 4.0.2 (staging blob with the override, default without). - Corrected the spec's Spin environment model and the adapter guide to document that store overrides must reach the guest through `--env`, not the host environment.
…to feature/provision-local-impl
The dependabot syn 2->3 update refreshed the ROOT Cargo.lock but not the excluded `examples/app-demo` workspace's lock, so its CI step (`cargo test --workspace --all-targets --locked`) failed with 'cannot update the lock file ... --locked was passed'. Regenerate app-demo's lock (adds syn 3.0.3) so it matches its manifests; all app-demo tests pass under --locked.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds
edgezero provision --local, folds all five adapter manifests into the gitignored-generated model, and hardens the surrounding CLI (path safety, env redaction, adapter-scoped env-file load) acrossprovision,config push,config diff, andserve.Source artifacts
docs/superpowers/specs/2026-06-23-provision-local.mddocs/superpowers/plans/2026-06-27-provision-local.md— 43 tasks across 9 sectionsThe 9 plan sections all landed on this branch (rather than as separate sub-PRs merging in); each closes on merge below.
What ships
provision --local. NewProvisionMode::Localarm threads throughAdapter::provision. Local mode:toml_edit::DocumentMut(CLI-owned bootstrap runs before validation).EDGEZERO__STORES__<KIND>__<ID>__NAME=…) so the runtime resolves stores at startup..edgezero/.env,.dev.vars,<spin_crate>/.env).Dry-run stages a real
fs::copyinto atempfile::TempDirand diffs the result back. The tree stays byte-identical either way.Typed provision (
run_provision_typed::<C>). Generated<app>-clibinaries dispatch the typed variant, which runs the base flow then additionally emits per-secret placeholder lines derived fromAppConfig's#[secret]/#[secret(store_ref)]fields (Axum:<key>=; Cloudflare:<key>=""in.dev.vars; Fastly:[[local_server.secret_stores.<store_id>]]; Spin: lowercased[variables]+SPIN_VARIABLE_<NAME>=in.env). The bundlededgezerobinary intentionally does not emit placeholders — it has no downstreamAppConfigtype.All five adapter manifests are provision-generated and gitignored.
axum.tomljoinedwrangler.toml/fastly.toml/spin.toml/runtime-config.tomlin the 2026-07 amendment; the scaffold.hbstemplate foraxum.tomlwas removed so scaffold-time provision is the single writer. A CI grep gate enforces the whole set.Path safety. New
path_safety::{assert_provision_paths_safe, assert_provision_paths_contained}helpers guard every CLI entry point that joinsmanifest_rootwith an operator-declared adapter path. Cloud dispatch runs the "safe" variant (absolute-path rejection +..traversal rejection);--localruns the stricter "contained" variant that additionally requires BOTH[adapters.<name>.adapter].manifestAND[adapters.<name>.adapter].crateto be declared, with the manifest resolving inside the crate dir. Wired intorun_provision,run_provision_typed,run_config_push_typed,run_config_diff_typed, andrun_serve.Renamed / nested adapter crate support.
cli_support::read_adapter_crate_namewalks upward from the manifest's parent to the first Cargo.toml insidemanifest_rootand reads[package].name. All four bundled adapter synthesisers use this to name generated manifests correctly under operator renames ([adapters.axum.adapter].crate = "crates/server"→crate = "server"inaxum.toml; Cargo packagespin-server→ wasm pathspin_server.wasm). Nested manifests likecrates/server/config/spin.tomlresolve correctly too.Spin component / crate decoupling.
synthesise_spin_tomlnow takes two distinct identities —crate_name(from Cargo.toml, drives[application].nameand the wasm source basename) andcomponent(from[adapters.spin.adapter].component, drives[[trigger.http]].componentand the[component.<id>]table key). Fixes the pre-2026-07 conflation where setting a component selector would silently mispoint the wasm artifact.Env-value redaction in dry-run.
.envand.dev.varsbodies are rewritten asKEY=<redacted>before diffing so operator secrets never surface in dry-run output. CommentedKEY=valuelines (adapter provisioners emit these as# EDGEZERO__STORES__…__KEY=placeholders, and operators stash real values behind#for later use) get the same treatment; pure comments and blank lines pass through so structural drift stays readable.Adapter-scoped env-file load in
run_serve. Axum reads<manifest_root>/.edgezero/.env; Spin reads.envnext to the resolvedspin.toml(matches where provision writes it — a nestedmanifest = "crates/spin/config/spin.toml"correctly resolves tocrates/spin/config/.env). Both run through the path-safety guard before the read. Contract test spawns a real serve child, inspects its inherited environment via a file the child writes, and asserts the marker propagates.Section breakdown
Each section closes on merge:
ManifestAdapterDeployedschema + writeback (Tasks 14–16, 16b–16c)Post-plan additions (from external review iterations)
Multiple review passes drove refinements past the original plan:
.gitignore+ CI gate + spec-plan-guide docs updated.read_adapter_crate_nameupward walk added to support nested manifests (v5 review).[application].nameand wasm basename decoupled from the component selector (v5 review)..manifestAND.crate;run_config_diff_typedgets the guard (previously read-only justified skipping);run_servegets the guard on the Spin env-file load.# KEY=valuelines (v6/v7 reviews).run_manifest_shape_gates(addsvalidate_deployed_field_ownershipalongside capability + handler-path checks).run_servederives.envpath from[adapters.spin.adapter].manifest.parent()(was.crate— broke on nested manifests). Contract test observes the spawned child's env (v8 review).#[expect(...)]restructure sweep acrossedgezero-cli,edgezero-core/manifest.rs(Deserialize impls), and adaptercli/mod.rsfiles (removed via explicit trait method overrides,ConfigPushSuppressionssub-struct,streammodule, structural code fixes rather than workspace-levelallows).CI gates (all pass locally)
cargo fmt --all -- --checkcargo clippy --workspace --all-targets --all-features -- -D warningscargo test --workspace --all-targetscargo check --workspace --all-targets --features "fastly cloudflare spin"cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spinexamples/app-demoworkspace testscargo test -p edgezero-cli --test generated_project_builds -- --ignored)Test plan
provision_local_*cases + Spin's env-label alignment quartet (Section 9)run_serve,run_config_diff_typed,run_config_push_typed,run_provisionMigration notes
<app>-cli provision --adapter <name> --localafter cloning to regenerate the five adapter manifests (.gitignorecovers all of them plus.dev.vars).[adapters.<name>.adapter].manifestand[adapters.<name>.adapter].crateare now both required forprovision --localandconfig push/diff --local. Every scaffolded project already sets both. Cloud dispatch remains permissive.run_provision_typed::<AppConfig>(not the untypedrun_provision) so#[secret]fields reach the adapters'provision_typedimpls.