Skip to content

feat: provision --local (umbrella PR)#287

Draft
aram356 wants to merge 30 commits into
mainfrom
feature/provision-local-impl
Draft

feat: provision --local (umbrella PR)#287
aram356 wants to merge 30 commits into
mainfrom
feature/provision-local-impl

Conversation

@aram356

@aram356 aram356 commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Umbrella PR for the provision --local workstream.

Closes #288.

Source artifacts

Architecture

A new ProvisionMode::Local arm threads through Adapter::provision. Local mode:

  • synthesises minimal per-adapter manifests on a clean clone via toml_edit::DocumentMut (CLI-owned bootstrap, before validation)
  • merges per-store bindings + env labels on top
  • writes adapter-specific env files (.edgezero/.env, .dev.vars, <spin_crate>/.env)
  • never shells out to cloud CLIs

Dry-run stages a real fs::copy into a tempfile::TempDir and diffs the result back. Cloudflare/Fastly/Spin manifests become gitignored generated state; Axum's axum.toml stays tracked. Generated <app-cli> runs run_provision_typed::<C> to add #[secret]-field placeholders the bundled edgezero can't see.

Execution plan

This umbrella PR opens as draft and stays draft. Each plan section gets its own follow-up PR that merges into this branch; once Sections 1–9 are all green, this PR converts to ready-for-review and merges to main.

Section breakdown

Each section tracks one sub-issue:

CI gates (from the plan's Global Constraints)

  • 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

Test plan

  • All five CI gates pass on the merged branch
  • All four smoke scripts (scripts/smoke_test_{config,kv,secrets,config_key_override}.sh) pass with warm-up
  • Per-adapter contract tests cover the four provision_local_* cases + Spin's env-label alignment quartet (Section 9)
  • Worktree is clean after smoke matrix (Task 43 step 4 — umbrella gate)

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.
@aram356 aram356 self-assigned this Jun 30, 2026
aram356 added 14 commits June 30, 2026 00:46
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.
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.
aram356 added 11 commits July 1, 2026 19:53
The deployed block is parsed as a shared schema — the manifest parser
never branches on the adapter name inside `[adapters.<name>.deployed]`.
Field membership (kv_namespaces / preview_kv_namespaces / service_id)
reflects which adapters happen to use which subset, but that mapping
lives in edgezero-adapter (Section 6's deployed_state_for), not core.

Tests name the fixture adapter `demo` and describe the field kind
they capture ("captures kv_namespace_maps", "captures service_id")
rather than the adapter that consumes it, so a future reader doesn't
mistake the parser for adapter-aware.
Spec §"Dry-run" step 3: the tempdir IS the dry-run mechanism.
Adapters take their real-write branches against the staged tree
so operators can preview the actual files that would land. My
Task 11 brief incorrectly said hardcode `true` in the (true, true)
arm; a review caught it before Section 5 landed real writers that
would have hit the wrong branches.

Adds provision_local_dry_run_passes_dry_run_false_to_adapter using
the existing FakeBootstrapAdapter + RECORDED_DRY_RUN observer.
Docstring-only cleanups flagged by Task 15 review:
- FakeBootstrapAdapter's #[expect(missing_trait_methods)] reason
  listed the four originally-overridden methods (name / provision /
  synthesise_baseline_manifest / validate_adapter_manifest) but not
  the newly-added deployed_fields override. Refreshed the list.
- NoFieldsFakeAdapter's reason previously said "every other trait
  method inherits its default (no-op or Unsupported)" without
  distinguishing the three required-but-implemented methods from
  the actual inherited defaults. Rewrote to name the required
  overrides explicitly and spell out that deployed_fields's `&[]`
  default is the intent this fake exercises.
- validate_deployed_field_ownership doc comment now records the
  case-handling invariant (adapter_registry::get_adapter normalises
  via to_ascii_lowercase, so [adapters.Fastly.deployed] resolves
  to the same registered adapter as [adapters.fastly.deployed]) so
  a future reader doesn't have to re-derive it from the registry.
…p into run_provision

Two blocking review findings:

- deployed_state_for was left as a `None`-returning stub through the
  whole of Section 4. Real deployed IDs in [adapters.<name>.deployed]
  never reached the adapter's synthesise_baseline_manifest call — the
  main Section 4 promise (teammates' `provision --local` regenerates
  local manifests from tracked durable IDs) was broken. Translator
  now maps service_id → state.fields, kv_namespaces +
  preview_kv_namespaces → state.sub_tables. Returns None only when
  every field is empty (matches pre-Task-14 signal for empty state).

- validate_deployed_field_ownership was wired into run_shared_checks
  (config validate / push / diff) but NOT into run_provision. Gap let
  `edgezero provision` accept deployed blocks that `config validate`
  correctly rejected. Now called from a new run_manifest_shape_gates
  helper that also holds the existing capability + handler-path
  checks; run_provision drops from 101 to 90 lines and stays under
  the workspace too_many_lines lint.

Four new tests: deployed_state_for_translates_all_field_kinds,
deployed_state_for_returns_none_when_all_fields_empty,
provision_local_threads_deployed_state_into_synthesiser (extends the
existing fake with a RECORDED_SYNTH_DEPLOYED observer),
provision_rejects_deployed_block_with_field_adapter_does_not_own.
Category B (deleted, redundant with fake-based Task 11 coverage):
- run_provision_axum_dry_run_is_also_a_no_op
- run_provision_spin_dry_run_dispatches_to_adapter
- run_provision_cloudflare_dry_run_dispatches_to_adapter
- run_provision_fastly_dry_run_dispatches_to_adapter

All four asserted only "run_provision reaches the adapter with the
requested dry_run value." That's strictly weaker than
provision_cloud_dry_run_passes_dry_run_true_to_adapter (which
observes RECORDED_DRY_RUN on the fake) and the corresponding local
matrix tests. Delegating "does this adapter's dry-run branch run"
to the adapter's own crate tests where relevant.

Category A (renamed + docstring):
- run_provision_axum_prints_local_only_notes_for_each_store →
  run_provision_cloud_non_dry_run_succeeds_when_adapter_is_side
  _effect_free. The old name promised assertions the test never
  made; the new name says what actually runs.
- Four `run_provision_spin_*` tests kept — they exercise CLI logic
  that requires an adapter with a specific `merged_id_kinds()`
  shape (capability gate, collision detection,
  validate_adapter_manifest actually validating). Spin is the
  illustrative example, and each test's docstring now says so.

Test count: 191 → 187 (-4). No coverage loss — every deleted test
was subsumed by a fake-based counterpart with stronger observers.
…ed schema check

High-1 — local dry-run was double-logging status lines.
`run_local_dry_run` rendered a sanitized report (staged tempdir paths
swapped back to project-relative) and logged it, then returned the
original `outcome`. `run_provision`'s trailing status-line loop would
then log the raw staged-tempdir paths again. Section 5's real
adapter writers would surface `/var/folders/…` paths on stdout.
Fix: return an outcome with `status_lines` cleared — the sanitized
report already contains the rewritten status content, so the outer
loop becomes a no-op. Full log-capture regression is still deferred
to Task 13's ignored test (log::set_logger is one-shot per process).

High-2 — `write_baseline_to_disk` bypassed path containment.
The manifest-declared paths are gated by `assert_provision_paths
_contained`, but the *baseline pairs* returned by an adapter's
`synthesise_baseline_manifest` weren't. A synthesiser returning
`/tmp/x.toml` or `../../etc/passwd` would escape via `root.join()`
(absolute) or ordinary path resolution (parent-dir components).
Fix: reject absolute paths + `Component::ParentDir` before joining.
Two regression tests: `write_baseline_rejects_absolute_path`,
`write_baseline_rejects_parent_traversal`.

Medium-3 — `merge_deployed_into_manifest` trusted adapter output blindly.
Existing manifests are gated by `validate_deployed_field_ownership`
before dispatch, but the writeback itself had no schema check — a
buggy adapter emitting an unknown key (e.g. `nonsense_key`) or a
known key it doesn't own (Cloudflare emitting `service_id`) would
persist into edgezero.toml and break future loads via
`deny_unknown_fields` on `ManifestAdapterDeployed`.
Fix: extend `merge_deployed_into_manifest` signature with
`owned_fields: &[&str]`; check every key in `state.fields` /
`state.sub_tables` against a hard-coded schema list + the owned
fields. Two regression tests:
`merge_deployed_rejects_adapter_emitted_unknown_field`,
`merge_deployed_rejects_adapter_emitted_non_owned_field`.

Call sites updated: `run_provision` passes `adapter.deployed_fields()`;
the three pre-existing merge_deployed tests pass an appropriate
superset.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Epic: provision --local implementation

1 participant