From 0f924d7ce6cf244bb281047756552f4643887826 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Mon, 29 Jun 2026 14:26:39 -0700 Subject: [PATCH 01/71] provision --local implementation: tracking branch Empty tracking commit for the implementation work tracked in: docs/superpowers/plans/2026-06-27-provision-local.md Issues: - Epic: - 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. From 1ffe5a92d1cf100f5c8b9eaf561e611f4feb54da Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Mon, 29 Jun 2026 15:09:09 -0700 Subject: [PATCH 02/71] Add ProvisionMode + ProvisionArgs.local for provision --local --- crates/edgezero-adapter/src/registry.rs | 11 +++++++++ crates/edgezero-cli/src/args.rs | 31 +++++++++++++++++++++++++ crates/edgezero-cli/src/provision.rs | 12 ++++++++++ 3 files changed, 54 insertions(+) diff --git a/crates/edgezero-adapter/src/registry.rs b/crates/edgezero-adapter/src/registry.rs index 6a24ce87..d52ece17 100644 --- a/crates/edgezero-adapter/src/registry.rs +++ b/crates/edgezero-adapter/src/registry.rs @@ -22,6 +22,17 @@ pub enum AdapterAction { Serve, } +/// Provision dispatch mode. `Cloud` keeps today's cloud-CLI shell-out +/// behaviour; `Local` writes adapter-local emulator state (no cloud +/// calls). Threaded through `Adapter::provision` so each adapter +/// branches once at the top of its impl. See spec §"CLI / trait +/// surface". +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProvisionMode { + Cloud, + Local, +} + /// A single declared store id, paired with the platform name the /// runtime will resolve via `EDGEZERO__STORES______NAME`. /// diff --git a/crates/edgezero-cli/src/args.rs b/crates/edgezero-cli/src/args.rs index 65f033d6..4a2ad85e 100644 --- a/crates/edgezero-cli/src/args.rs +++ b/crates/edgezero-cli/src/args.rs @@ -172,6 +172,12 @@ pub struct ProvisionArgs { /// without performing them. #[arg(long)] pub dry_run: bool, + /// Switch the flow from cloud-SDK shell-outs to local-file writes. + /// Adapter-local manifests, env files, and runtime-config TOML are + /// synthesised or merged in place; no cloud CLIs are invoked. See + /// spec §"CLI" for the full mode contract. + #[arg(long)] + pub local: bool, /// Path to the manifest (default: `edgezero.toml`). #[arg(long, default_value = "edgezero.toml")] pub manifest: PathBuf, @@ -191,6 +197,7 @@ impl Default for ProvisionArgs { Self { adapter: String::new(), dry_run: false, + local: false, manifest: default_manifest_path(), } } @@ -665,6 +672,30 @@ mod tests { .expect_err("`provision` without --adapter must error"); } + #[test] + fn provision_args_local_flag_defaults_false() { + use clap::Parser; + #[derive(Parser)] + struct Cli { + #[command(flatten)] + args: ProvisionArgs, + } + let cli = Cli::try_parse_from(["bin", "--adapter", "spin"]).unwrap(); + assert!(!cli.args.local); + } + + #[test] + fn provision_args_local_flag_parses() { + use clap::Parser; + #[derive(Parser)] + struct Cli { + #[command(flatten)] + args: ProvisionArgs, + } + let cli = Cli::try_parse_from(["bin", "--adapter", "spin", "--local"]).unwrap(); + assert!(cli.args.local); + } + // ── config push / diff stub tests (12.8 + 12.11) ────────────────── /// Bundled binary: bare `config push` parses to the stub variant. diff --git a/crates/edgezero-cli/src/provision.rs b/crates/edgezero-cli/src/provision.rs index 13faf70e..9011dbad 100644 --- a/crates/edgezero-cli/src/provision.rs +++ b/crates/edgezero-cli/src/provision.rs @@ -167,6 +167,7 @@ mod tests { run_provision(&ProvisionArgs { adapter: "axum".to_owned(), dry_run: false, + local: false, manifest: manifest_path.clone(), }) .expect("axum provision exits 0 (no remote resources)"); @@ -184,6 +185,7 @@ mod tests { run_provision(&ProvisionArgs { adapter: "axum".to_owned(), dry_run: true, + local: false, manifest: manifest_path.clone(), }) .expect("axum dry-run also exits 0"); @@ -201,6 +203,7 @@ mod tests { let err = run_provision(&ProvisionArgs { adapter: "wat".to_owned(), dry_run: false, + local: false, manifest: manifest_path.clone(), }) .expect_err("unknown adapter must error"); @@ -230,6 +233,7 @@ mod tests { run_provision(&ProvisionArgs { adapter: "spin".to_owned(), dry_run: true, + local: false, manifest: manifest_path.clone(), }) .expect("spin dry-run dispatches cleanly"); @@ -268,6 +272,7 @@ adapters = ["axum"] let err = run_provision(&ProvisionArgs { adapter: "axum".to_owned(), dry_run: true, + local: false, manifest: manifest_path.clone(), }) .expect_err("malformed handler must error before dispatch"); @@ -298,6 +303,7 @@ adapters = ["axum"] let err = run_provision(&ProvisionArgs { adapter: "spin".to_owned(), dry_run: true, + local: false, manifest: manifest_path.clone(), }) .expect_err("zero-component spin.toml must error pre-dispatch"); @@ -349,6 +355,7 @@ default = "default" let err = run_provision(&ProvisionArgs { adapter: "spin".to_owned(), dry_run: true, + local: false, manifest: manifest_path.clone(), }) .expect_err("Single-capability violation must error"); @@ -398,6 +405,7 @@ ids = ["default"] run_provision(&ProvisionArgs { adapter: "spin".to_owned(), dry_run: true, + local: false, manifest: manifest_path.clone(), }) .expect("multi-config dispatch must succeed under KV-backed config"); @@ -451,6 +459,7 @@ ids = ["default"] let err = run_provision(&ProvisionArgs { adapter: "spin".to_owned(), dry_run: true, + local: false, manifest: manifest_path.clone(), }) .expect_err("env-overlay platform-label collision must fail provision"); @@ -480,6 +489,7 @@ ids = ["default"] run_provision(&ProvisionArgs { adapter: "spin".to_owned(), dry_run: true, + local: false, manifest: manifest_path.clone(), }) .expect("single-id case dispatches cleanly"); @@ -501,6 +511,7 @@ ids = ["default"] run_provision(&ProvisionArgs { adapter: "cloudflare".to_owned(), dry_run: true, + local: false, manifest: manifest_path.clone(), }) .expect("cloudflare dry-run dispatches cleanly"); @@ -521,6 +532,7 @@ ids = ["default"] run_provision(&ProvisionArgs { adapter: "fastly".to_owned(), dry_run: true, + local: false, manifest: manifest_path.clone(), }) .expect("fastly dry-run dispatches cleanly"); From feb607e17e53262cd76298a7f15403c58990a027 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Mon, 29 Jun 2026 15:26:19 -0700 Subject: [PATCH 03/71] Add neutral ProvisionOutcome + AdapterDeployedState types + crate-root re-exports --- crates/edgezero-adapter/src/lib.rs | 15 +++++++++ crates/edgezero-adapter/src/registry.rs | 43 ++++++++++++++++++++++++- 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/crates/edgezero-adapter/src/lib.rs b/crates/edgezero-adapter/src/lib.rs index 607548d2..9404fc28 100644 --- a/crates/edgezero-adapter/src/lib.rs +++ b/crates/edgezero-adapter/src/lib.rs @@ -1,6 +1,21 @@ +#![expect( + clippy::pub_use, + reason = "crate-root re-exports for external callers; adapters + CLI read \ + `edgezero_adapter::TypeName` instead of `edgezero_adapter::registry::TypeName`" +)] + pub mod registry; pub mod scaffold; #[cfg(feature = "cli")] pub mod cli_support; + +// Re-exports so adapters + the CLI can write +// `edgezero_adapter::TypeName` instead of +// `edgezero_adapter::registry::TypeName`. Mirrors the surface +// adapters already touch via `registry::*` imports today. +pub use registry::{ + get_adapter, Adapter, AdapterDeployedState, ProvisionMode, ProvisionOutcome, ProvisionStores, + ResolvedStoreId, TypedSecretEntry, +}; diff --git a/crates/edgezero-adapter/src/registry.rs b/crates/edgezero-adapter/src/registry.rs index d52ece17..059e6ef4 100644 --- a/crates/edgezero-adapter/src/registry.rs +++ b/crates/edgezero-adapter/src/registry.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use std::path::Path; use std::sync::{LazyLock, PoisonError, RwLock}; @@ -33,6 +33,28 @@ pub enum ProvisionMode { Local, } +/// Adapter-emitted deployed identifiers. Kept neutral (string-keyed +/// maps only) so `edgezero-adapter` stays dep-free of +/// `edgezero-core` -- the CLI maps this into the strongly typed +/// `ManifestAdapterDeployed` shape when writing `edgezero.toml`. +/// See spec §"Writeback ownership". +#[derive(Debug, Default, Clone)] +pub struct AdapterDeployedState { + pub fields: BTreeMap, + pub sub_tables: BTreeMap>, +} + +/// Return value of `Adapter::provision` (and `provision_typed`). +/// `status_lines` are operator-facing; `deployed`, when `Some`, +/// records the cloud-returned identifiers the CLI persists into +/// `edgezero.toml`'s `[adapters..deployed]` block. Local +/// provision returns `deployed: None`. +#[derive(Debug, Default, Clone)] +pub struct ProvisionOutcome { + pub deployed: Option, + pub status_lines: Vec, +} + /// A single declared store id, paired with the platform name the /// runtime will resolve via `EDGEZERO__STORES______NAME`. /// @@ -637,4 +659,23 @@ mod tests { "expected Unsupported variant from default local impl" ); } + + #[test] + fn provision_outcome_default_is_empty() { + let outcome = ProvisionOutcome::default(); + assert!(outcome.status_lines.is_empty()); + assert!(outcome.deployed.is_none()); + } + + #[test] + fn adapter_deployed_state_round_trips_via_btreemap() { + use std::collections::BTreeMap; + let mut state = AdapterDeployedState::default(); + state.fields.insert("service_id".into(), "SVC1".into()); + let mut kv = BTreeMap::new(); + kv.insert("sessions".into(), "abc123".into()); + state.sub_tables.insert("kv_namespaces".into(), kv); + assert_eq!(state.fields["service_id"], "SVC1"); + assert_eq!(state.sub_tables["kv_namespaces"]["sessions"], "abc123"); + } } From 5896a8e5a5a44058d526a74090f2eb0433166409 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Mon, 29 Jun 2026 18:30:03 -0700 Subject: [PATCH 04/71] Thread ProvisionMode + ProvisionOutcome through Adapter::provision --- crates/edgezero-adapter-axum/src/cli.rs | 17 ++- crates/edgezero-adapter-cloudflare/src/cli.rs | 110 ++++++++++++---- crates/edgezero-adapter-fastly/src/cli.rs | 83 +++++++++--- crates/edgezero-adapter-spin/src/cli.rs | 118 +++++++++++++++--- crates/edgezero-adapter/src/registry.rs | 46 +++++-- crates/edgezero-cli/src/provision.rs | 7 +- 6 files changed, 303 insertions(+), 78 deletions(-) diff --git a/crates/edgezero-adapter-axum/src/cli.rs b/crates/edgezero-adapter-axum/src/cli.rs index 75caf585..8d61f724 100644 --- a/crates/edgezero-adapter-axum/src/cli.rs +++ b/crates/edgezero-adapter-axum/src/cli.rs @@ -11,8 +11,8 @@ use edgezero_adapter::cli_support::{ find_manifest_upwards, find_workspace_root, path_distance, read_package_name, }; use edgezero_adapter::registry::{ - register_adapter, Adapter, AdapterAction, AdapterPushContext, ProvisionStores, ReadConfigEntry, - ResolvedStoreId, + register_adapter, Adapter, AdapterAction, AdapterDeployedState, AdapterPushContext, + ProvisionMode, ProvisionOutcome, ProvisionStores, ReadConfigEntry, ResolvedStoreId, }; use edgezero_adapter::scaffold::{ register_adapter_blueprint, AdapterBlueprint, AdapterFileSpec, CommandTemplates, @@ -162,8 +162,14 @@ impl Adapter for AxumCliAdapter { _adapter_manifest_path: Option<&str>, _component_selector: Option<&str>, stores: &ProvisionStores<'_>, + _deployed: Option<&AdapterDeployedState>, + mode: ProvisionMode, _dry_run: bool, - ) -> Result, String> { + ) -> Result { + match mode { + ProvisionMode::Cloud => {} + ProvisionMode::Local => return Err("local mode lands in Section 5".to_owned()), + } //: 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 @@ -200,7 +206,10 @@ impl Adapter for AxumCliAdapter { if out.is_empty() { out.push("axum has no declared stores to provision".to_owned()); } - Ok(out) + Ok(ProvisionOutcome { + status_lines: out, + deployed: None, + }) } fn push_config_entries( diff --git a/crates/edgezero-adapter-cloudflare/src/cli.rs b/crates/edgezero-adapter-cloudflare/src/cli.rs index f858021c..b15bb62c 100644 --- a/crates/edgezero-adapter-cloudflare/src/cli.rs +++ b/crates/edgezero-adapter-cloudflare/src/cli.rs @@ -10,8 +10,8 @@ use edgezero_adapter::cli_support::{ find_manifest_upwards, find_workspace_root, path_distance, read_package_name, run_native_cli, }; use edgezero_adapter::registry::{ - register_adapter, Adapter, AdapterAction, AdapterPushContext, ProvisionStores, ReadConfigEntry, - ResolvedStoreId, + register_adapter, Adapter, AdapterAction, AdapterDeployedState, AdapterPushContext, + ProvisionMode, ProvisionOutcome, ProvisionStores, ReadConfigEntry, ResolvedStoreId, }; use edgezero_adapter::scaffold::{ register_adapter_blueprint, AdapterBlueprint, AdapterFileSpec, CommandTemplates, @@ -190,8 +190,14 @@ impl Adapter for CloudflareCliAdapter { adapter_manifest_path: Option<&str>, _component_selector: Option<&str>, stores: &ProvisionStores<'_>, + _deployed: Option<&AdapterDeployedState>, + mode: ProvisionMode, dry_run: bool, - ) -> Result, String> { + ) -> Result { + match mode { + ProvisionMode::Cloud => {} + ProvisionMode::Local => return Err("local mode lands in Section 5".to_owned()), + } //: 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. @@ -284,7 +290,10 @@ impl Adapter for CloudflareCliAdapter { if out.is_empty() { out.push("cloudflare has no declared stores to provision".to_owned()); } - Ok(out) + Ok(ProvisionOutcome { + status_lines: out, + deployed: None, + }) } fn push_config_entries( @@ -1572,14 +1581,22 @@ id = "00112233445566778899aabbccddeeff" secrets: &secret_ids, }; let out = CloudflareCliAdapter - .provision(dir.path(), Some("wrangler.toml"), None, &stores, true) + .provision( + dir.path(), + Some("wrangler.toml"), + None, + &stores, + None, + ProvisionMode::Cloud, + 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`")); + assert_eq!(out.status_lines.len(), 4); + assert!(out.status_lines[0].contains("would run `wrangler kv namespace create sessions`")); + assert!(out.status_lines[1].contains("would run `wrangler kv namespace create cache`")); + assert!(out.status_lines[2].contains("would run `wrangler kv namespace create app_config`")); + assert!(out.status_lines[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"); @@ -1603,19 +1620,27 @@ id = "00112233445566778899aabbccddeeff" secrets: &[], }; let out = CloudflareCliAdapter - .provision(dir.path(), Some("wrangler.toml"), None, &stores, true) + .provision( + dir.path(), + Some("wrangler.toml"), + None, + &stores, + None, + ProvisionMode::Cloud, + true, + ) .expect("dry-run succeeds"); - assert_eq!(out.len(), 1); + assert_eq!(out.status_lines.len(), 1); assert!( - out[0].contains("wrangler kv namespace create prod_config"), + out.status_lines[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\""), + out.status_lines[0].contains("binding = \"prod_config\""), "dry-run writes platform name as the binding: {out:?}" ); assert!( - out[0].contains("logical id `app_config`"), + out.status_lines[0].contains("logical id `app_config`"), "logical id is preserved for operator wording: {out:?}" ); } @@ -1630,7 +1655,15 @@ id = "00112233445566778899aabbccddeeff" secrets: &[], }; let err = CloudflareCliAdapter - .provision(dir.path(), None, None, &stores, true) + .provision( + dir.path(), + None, + None, + &stores, + None, + ProvisionMode::Cloud, + true, + ) .expect_err("missing adapter manifest path must error"); assert!( err.contains("wrangler.toml"), @@ -1653,12 +1686,20 @@ id = "00112233445566778899aabbccddeeff" secrets: &[], }; let out = CloudflareCliAdapter - .provision(dir.path(), Some("wrangler.toml"), None, &stores, true) + .provision( + dir.path(), + Some("wrangler.toml"), + None, + &stores, + None, + ProvisionMode::Cloud, + true, + ) .expect("dry-run succeeds"); - assert_eq!(out.len(), 1); + assert_eq!(out.status_lines.len(), 1); assert!( - out[0].contains("already provisioned") - && out[0].contains("00112233445566778899aabbccddeeff"), + out.status_lines[0].contains("already provisioned") + && out.status_lines[0].contains("00112233445566778899aabbccddeeff"), "skip line names the existing id: {out:?}" ); let after = fs::read_to_string(&path).expect("read"); @@ -1686,11 +1727,19 @@ id = "00112233445566778899aabbccddeeff" secrets: &[], }; let out = CloudflareCliAdapter - .provision(dir.path(), Some("wrangler.toml"), None, &stores, true) + .provision( + dir.path(), + Some("wrangler.toml"), + None, + &stores, + None, + ProvisionMode::Cloud, + true, + ) .expect("dry-run succeeds"); - assert_eq!(out.len(), 1); + assert_eq!(out.status_lines.len(), 1); assert!( - out[0].contains("would run `wrangler kv namespace create sessions`"), + out.status_lines[0].contains("would run `wrangler kv namespace create sessions`"), "placeholder id is treated as unprovisioned: {out:?}" ); } @@ -1705,9 +1754,20 @@ id = "00112233445566778899aabbccddeeff" secrets: &[], }; let out = CloudflareCliAdapter - .provision(dir.path(), Some("wrangler.toml"), None, &stores, false) + .provision( + dir.path(), + Some("wrangler.toml"), + None, + &stores, + None, + ProvisionMode::Cloud, + false, + ) .expect("no-store provision is fine"); - assert_eq!(out, vec!["cloudflare has no declared stores to provision"]); + assert_eq!( + out.status_lines, + vec!["cloudflare has no declared stores to provision"] + ); } // ---------- find_namespace_id ---------- diff --git a/crates/edgezero-adapter-fastly/src/cli.rs b/crates/edgezero-adapter-fastly/src/cli.rs index a3de1cba..aef252fd 100644 --- a/crates/edgezero-adapter-fastly/src/cli.rs +++ b/crates/edgezero-adapter-fastly/src/cli.rs @@ -11,8 +11,8 @@ use edgezero_adapter::cli_support::{ find_manifest_upwards, find_workspace_root, path_distance, read_package_name, run_native_cli, }; use edgezero_adapter::registry::{ - register_adapter, Adapter, AdapterAction, AdapterPushContext, ProvisionStores, ReadConfigEntry, - ResolvedStoreId, + register_adapter, Adapter, AdapterAction, AdapterDeployedState, AdapterPushContext, + ProvisionMode, ProvisionOutcome, ProvisionStores, ReadConfigEntry, ResolvedStoreId, }; use edgezero_adapter::scaffold::{ register_adapter_blueprint, AdapterBlueprint, AdapterFileSpec, CommandTemplates, @@ -204,8 +204,14 @@ impl Adapter for FastlyCliAdapter { adapter_manifest_path: Option<&str>, _component_selector: Option<&str>, stores: &ProvisionStores<'_>, + _deployed: Option<&AdapterDeployedState>, + mode: ProvisionMode, dry_run: bool, - ) -> Result, String> { + ) -> Result { + match mode { + ProvisionMode::Cloud => {} + ProvisionMode::Local => return Err("local mode lands in Section 5".to_owned()), + } // 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 @@ -343,7 +349,10 @@ impl Adapter for FastlyCliAdapter { if out.is_empty() { out.push("fastly has no declared stores to provision".to_owned()); } - Ok(out) + Ok(ProvisionOutcome { + status_lines: out, + deployed: None, + }) } fn push_config_entries( @@ -1957,15 +1966,27 @@ build = \"cargo build --release\" secrets: &secret_ids, }; let out = FastlyCliAdapter - .provision(dir.path(), Some("fastly.toml"), None, &stores, true) + .provision( + dir.path(), + Some("fastly.toml"), + None, + &stores, + None, + ProvisionMode::Cloud, + true, + ) .expect("dry-run succeeds"); // 1 KV + 1 config + 1 secret + 1 runtime-env = 4 status lines. - assert_eq!(out.len(), 4); - 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`")); + assert_eq!(out.status_lines.len(), 4); + assert!(out.status_lines[0].contains("would run `fastly kv-store create --name=sessions`")); + assert!(out.status_lines[1] + .contains("would run `fastly config-store create --name=app_config`")); assert!( - out[3].contains("would run `fastly config-store create --name=edgezero_runtime_env`"), + out.status_lines[2].contains("would run `fastly secret-store create --name=default`") + ); + assert!( + out.status_lines[3] + .contains("would run `fastly config-store create --name=edgezero_runtime_env`"), "runtime-env store row: {out:?}", ); // Manifest untouched. @@ -1983,7 +2004,15 @@ build = \"cargo build --release\" secrets: &[], }; let err = FastlyCliAdapter - .provision(dir.path(), None, None, &stores, true) + .provision( + dir.path(), + None, + None, + &stores, + None, + ProvisionMode::Cloud, + true, + ) .expect_err("missing adapter manifest path must error"); assert!( err.contains("fastly.toml"), @@ -2009,9 +2038,20 @@ build = \"cargo build --release\" secrets: &[], }; let out = FastlyCliAdapter - .provision(dir.path(), Some("fastly.toml"), None, &stores, false) + .provision( + dir.path(), + Some("fastly.toml"), + None, + &stores, + None, + ProvisionMode::Cloud, + false, + ) .expect("no-store provision is fine"); - assert_eq!(out, vec!["fastly has no declared stores to provision"]); + assert_eq!( + out.status_lines, + vec!["fastly has no declared stores to provision"] + ); } #[test] @@ -2036,10 +2076,21 @@ build = \"cargo build --release\" secrets: &[], }; let out = FastlyCliAdapter - .provision(dir.path(), Some("fastly.toml"), None, &stores, false) + .provision( + dir.path(), + Some("fastly.toml"), + None, + &stores, + None, + ProvisionMode::Cloud, + false, + ) .expect("skip path succeeds without invoking fastly"); - assert_eq!(out.len(), 1); - assert!(out[0].contains("already declared"), "got: {out:?}"); + assert_eq!(out.status_lines.len(), 1); + assert!( + out.status_lines[0].contains("already declared"), + "got: {out:?}" + ); } /// When `fastly.toml` declares `service_id`, the next diff --git a/crates/edgezero-adapter-spin/src/cli.rs b/crates/edgezero-adapter-spin/src/cli.rs index 11c1d6a2..e3f08573 100644 --- a/crates/edgezero-adapter-spin/src/cli.rs +++ b/crates/edgezero-adapter-spin/src/cli.rs @@ -17,8 +17,9 @@ use edgezero_adapter::cli_support::{ find_manifest_upwards, find_workspace_root, path_distance, read_package_name, run_native_cli, }; use edgezero_adapter::registry::{ - register_adapter, Adapter, AdapterAction, AdapterPushContext, ProvisionStores, ReadConfigEntry, - ResolvedStoreId, TypedSecretEntry, + register_adapter, Adapter, AdapterAction, AdapterDeployedState, AdapterPushContext, + ProvisionMode, ProvisionOutcome, ProvisionStores, ReadConfigEntry, ResolvedStoreId, + TypedSecretEntry, }; use edgezero_adapter::scaffold::{ register_adapter_blueprint, AdapterBlueprint, AdapterFileSpec, CommandTemplates, @@ -181,8 +182,14 @@ impl Adapter for SpinCliAdapter { adapter_manifest_path: Option<&str>, component_selector: Option<&str>, stores: &ProvisionStores<'_>, + _deployed: Option<&AdapterDeployedState>, + mode: ProvisionMode, dry_run: bool, - ) -> Result, String> { + ) -> Result { + match mode { + ProvisionMode::Cloud => {} + ProvisionMode::Local => return Err("local mode lands in Section 5".to_owned()), + } //: 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 @@ -251,7 +258,10 @@ impl Adapter for SpinCliAdapter { if out.is_empty() { out.push("spin has no declared stores to provision".to_owned()); } - Ok(out) + Ok(ProvisionOutcome { + status_lines: out, + deployed: None, + }) } fn push_config_entries( @@ -1384,6 +1394,18 @@ mod tests { fn name(&self) -> &'static str { "stub" } + fn provision( + &self, + _manifest_root: &Path, + _adapter_manifest_path: Option<&str>, + _component_selector: Option<&str>, + _stores: &ProvisionStores<'_>, + _deployed: Option<&AdapterDeployedState>, + _mode: ProvisionMode, + _dry_run: bool, + ) -> Result { + Ok(ProvisionOutcome::default()) + } } let entries = [ TypedSecretEntry::new("default", "one", "Demo_Token"), @@ -1649,11 +1671,19 @@ mod tests { secrets: &[], }; let out = SpinCliAdapter - .provision(dir.path(), Some("spin.toml"), None, &stores, true) + .provision( + dir.path(), + Some("spin.toml"), + None, + &stores, + None, + ProvisionMode::Cloud, + true, + ) .expect("dry-run succeeds"); - assert_eq!(out.len(), 2); - assert!(out[0].contains("would ensure KV label `sessions`")); - assert!(out[1].contains("would ensure KV label `cache`")); + assert_eq!(out.status_lines.len(), 2); + assert!(out.status_lines[0].contains("would ensure KV label `sessions`")); + assert!(out.status_lines[1].contains("would ensure KV label `cache`")); let after = fs::read_to_string(&path).expect("read back"); assert_eq!(after, original, "dry-run mutated spin.toml"); } @@ -1680,10 +1710,19 @@ mod tests { secrets: &[], }; let out = SpinCliAdapter - .provision(dir.path(), Some("spin.toml"), None, &stores, false) + .provision( + dir.path(), + Some("spin.toml"), + None, + &stores, + None, + ProvisionMode::Cloud, + false, + ) .expect("real-run succeeds"); assert!( - out[0].contains("`prod_sessions`") && out[0].contains("`sessions`"), + out.status_lines[0].contains("`prod_sessions`") + && out.status_lines[0].contains("`sessions`"), "status line names BOTH the platform label and the logical id: {out:?}" ); @@ -1712,10 +1751,21 @@ mod tests { secrets: &[], }; let out = SpinCliAdapter - .provision(dir.path(), Some("spin.toml"), None, &stores, false) + .provision( + dir.path(), + Some("spin.toml"), + None, + &stores, + None, + ProvisionMode::Cloud, + false, + ) .expect("real run succeeds"); - assert_eq!(out.len(), 1); - assert!(out[0].contains("added KV label `sessions`"), "got: {out:?}"); + assert_eq!(out.status_lines.len(), 1); + assert!( + out.status_lines[0].contains("added KV label `sessions`"), + "got: {out:?}" + ); let after = fs::read_to_string(dir.path().join("spin.toml")).expect("read back"); assert!( after.contains("\"sessions\""), @@ -1733,7 +1783,15 @@ mod tests { secrets: &[], }; let err = SpinCliAdapter - .provision(dir.path(), None, None, &stores, true) + .provision( + dir.path(), + None, + None, + &stores, + None, + ProvisionMode::Cloud, + true, + ) .expect_err("missing adapter manifest path must error"); assert!( err.contains("spin.toml"), @@ -1760,15 +1818,24 @@ mod tests { secrets: &secret_ids, }; let out = SpinCliAdapter - .provision(dir.path(), Some("spin.toml"), None, &stores, false) + .provision( + dir.path(), + Some("spin.toml"), + None, + &stores, + None, + ProvisionMode::Cloud, + false, + ) .expect("config + secrets provision succeeds"); - assert_eq!(out.len(), 2); + assert_eq!(out.status_lines.len(), 2); assert!( - out[0].contains("config label") && out[0].contains("key_value_stores"), + out.status_lines[0].contains("config label") + && out.status_lines[0].contains("key_value_stores"), "config row reports KV-array write: {out:?}" ); assert!( - out[1].contains("manual"), + out.status_lines[1].contains("manual"), "secret row still flags manual declaration: {out:?}" ); @@ -1792,9 +1859,20 @@ mod tests { secrets: &[], }; let out = SpinCliAdapter - .provision(dir.path(), Some("spin.toml"), None, &stores, false) + .provision( + dir.path(), + Some("spin.toml"), + None, + &stores, + None, + ProvisionMode::Cloud, + false, + ) .expect("no-store provision is fine"); - assert_eq!(out, vec!["spin has no declared stores to provision"]); + assert_eq!( + out.status_lines, + vec!["spin has no declared stores to provision"] + ); } // ---------- dispatch_push matrix ---------- diff --git a/crates/edgezero-adapter/src/registry.rs b/crates/edgezero-adapter/src/registry.rs index 059e6ef4..5ff00960 100644 --- a/crates/edgezero-adapter/src/registry.rs +++ b/crates/edgezero-adapter/src/registry.rs @@ -304,24 +304,35 @@ pub trait Adapter: Sync + Send { /// (`wrangler.toml`, `fastly.toml`, `spin.toml`) relative to /// the root. `stores` carries the declared ids per kind. /// - /// Default: no-op (returns an empty `Vec`) so adapters that - /// don't own any platform resources don't need to override. + /// `deployed` carries the adapter's previously-persisted + /// deployed identifiers (e.g. Cloudflare KV namespace ids, + /// Fastly service id). Local-arm impls consult it for + /// precedence rules (spec §"CLI / trait surface"); cloud-arm + /// impls pass `None` — they produce, not consume, the deployed + /// state. `mode` selects cloud vs. local emulator paths + /// (spec §"CLI / trait surface", §"Writeback ownership"). + /// + /// No default impl is provided — every adapter must update + /// explicitly so the compiler flags any missed call sites. /// /// # Errors /// Returns a human-readable error string if any platform /// invocation or manifest edit fails. `dry_run` impls should /// describe what they *would* do without performing it. - #[inline] + #[expect( + clippy::too_many_arguments, + reason = "provision needs the manifest root, adapter manifest path, component selector, resolved stores, previously-deployed state (for local-arm precedence), dispatch mode (cloud vs local), and dry-run flag — 8 args. Each is distinct; an aggregate struct would be a larger ergonomic regression for adapter implementers." + )] fn provision( &self, - _manifest_root: &Path, - _adapter_manifest_path: Option<&str>, - _component_selector: Option<&str>, - _stores: &ProvisionStores<'_>, - _dry_run: bool, - ) -> Result, String> { - Ok(Vec::new()) - } + manifest_root: &Path, + adapter_manifest_path: Option<&str>, + component_selector: Option<&str>, + stores: &ProvisionStores<'_>, + deployed: Option<&AdapterDeployedState>, + mode: ProvisionMode, + dry_run: bool, + ) -> Result; /// Push config entries into the platform's config store backing /// `store_id`. Returns a list of human-readable status lines the @@ -596,6 +607,19 @@ mod tests { fn name(&self) -> &'static str { self.name } + + fn provision( + &self, + _manifest_root: &Path, + _adapter_manifest_path: Option<&str>, + _component_selector: Option<&str>, + _stores: &ProvisionStores<'_>, + _deployed: Option<&AdapterDeployedState>, + _mode: ProvisionMode, + _dry_run: bool, + ) -> Result { + Ok(ProvisionOutcome::default()) + } } fn reset() { diff --git a/crates/edgezero-cli/src/provision.rs b/crates/edgezero-cli/src/provision.rs index 9011dbad..4484b8dc 100644 --- a/crates/edgezero-cli/src/provision.rs +++ b/crates/edgezero-cli/src/provision.rs @@ -114,20 +114,23 @@ pub fn run_provision(args: &ProvisionArgs) -> Result<(), String> { secrets: &secret_ids, }; - let lines = adapter.provision( + let outcome = adapter.provision( manifest_root, adapter_cfg.adapter.manifest.as_deref(), adapter_cfg.adapter.component.as_deref(), &stores, + None, // cloud arm doesn't consume deployed state; it produces it + adapter_registry::ProvisionMode::Cloud, args.dry_run, )?; if args.dry_run { log::info!("[edgezero] provision --dry-run for `{}`:", args.adapter); } - for line in lines { + for line in outcome.status_lines { log::info!("{line}"); } + // outcome.deployed wiring lands in Task 16. Ok(()) } From 63f9a4d965228e57bbbedadd5a997764124e7465 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Mon, 29 Jun 2026 20:13:01 -0700 Subject: [PATCH 05/71] Add Adapter::provision_typed trait method with default no-op --- crates/edgezero-adapter/src/registry.rs | 47 +++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/crates/edgezero-adapter/src/registry.rs b/crates/edgezero-adapter/src/registry.rs index 5ff00960..84418466 100644 --- a/crates/edgezero-adapter/src/registry.rs +++ b/crates/edgezero-adapter/src/registry.rs @@ -334,6 +334,37 @@ pub trait Adapter: Sync + Send { dry_run: bool, ) -> Result; + /// Typed-secret companion to `provision`. Runs ONLY in local mode + /// (`mode == Local`); cloud mode is a no-op by spec §"CLI / trait + /// surface". The CLI dispatches this AFTER `provision` on the same + /// `manifest_root`, so per-store bindings are already in place; this + /// method only adds adapter-specific per-secret placeholders sourced + /// from `C::SECRET_FIELDS` (the generic CLI walks them; bundled + /// `edgezero` cannot). + /// + /// The default impl is a no-op so existing adapters compile + /// untouched while the per-adapter overrides land in Section 5. + /// + /// # Errors + /// The default impl never errors. Adapter overrides may return + /// human-readable error strings if local placeholder setup fails. + #[inline] + #[expect( + clippy::elidable_lifetime_names, + reason = "lifetime name 'entry explicitly documents the secret entry lifetime for clarity" + )] + fn provision_typed<'entry>( + &self, + _manifest_root: &Path, + _adapter_manifest_path: Option<&str>, + _component_selector: Option<&str>, + _typed_secrets: &[TypedSecretEntry<'entry>], + _mode: ProvisionMode, + _dry_run: bool, + ) -> Result { + Ok(ProvisionOutcome::default()) + } + /// Push config entries into the platform's config store backing /// `store_id`. Returns a list of human-readable status lines the /// CLI logs verbatim. @@ -702,4 +733,20 @@ mod tests { assert_eq!(state.fields["service_id"], "SVC1"); assert_eq!(state.sub_tables["kv_namespaces"]["sessions"], "abc123"); } + + #[test] + fn provision_typed_default_impl_returns_empty_outcome() { + let outcome = FIRST + .provision_typed( + Path::new("/tmp"), + None, + None, + &[], + ProvisionMode::Local, + true, + ) + .unwrap(); + assert!(outcome.status_lines.is_empty()); + assert!(outcome.deployed.is_none()); + } } From 51216035d149148843bfe5200e36a87e32979b8d Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Tue, 30 Jun 2026 00:46:13 -0700 Subject: [PATCH 06/71] Extract run_typed_preflight; route validate/push/diff through it --- crates/edgezero-cli/src/config.rs | 157 +++++++++++++++++++++++++----- 1 file changed, 130 insertions(+), 27 deletions(-) diff --git a/crates/edgezero-cli/src/config.rs b/crates/edgezero-cli/src/config.rs index 4ca508bd..1660615b 100644 --- a/crates/edgezero-cli/src/config.rs +++ b/crates/edgezero-cli/src/config.rs @@ -74,7 +74,7 @@ struct ResolvedAdapterPushContext { } /// Pre-loaded state shared by the raw and typed flows. -struct ValidationContext { +pub(crate) struct ValidationContext { /// Resolved app-config TOML path. Either the explicit /// `--app-config`, or `.toml` next to the manifest. app_config_path: PathBuf, @@ -96,9 +96,53 @@ struct ValidationContext { } impl ValidationContext { - fn manifest(&self) -> &Manifest { + // Accessors below are pub(crate) API for the provision flow. They + // are not called in production code in this crate yet so the + // dead_code lint fires for the lib target; we gate the suppression + // on `not(test)` so the expect is only active where the lint fires + // (production lib), and is absent when tests are compiled (where + // the methods are used and the expect would be unfulfilled). + #[cfg_attr( + not(test), + expect( + dead_code, + reason = "pub(crate) API surface for the provision flow; not yet called in production code in this crate" + ) + )] + pub(crate) fn app_config_path(&self) -> &Path { + &self.app_config_path + } + + #[cfg_attr( + not(test), + expect( + dead_code, + reason = "pub(crate) API surface for the provision flow; not yet called in production code in this crate" + ) + )] + pub(crate) fn app_name(&self) -> &str { + &self.app_name + } + + pub(crate) fn manifest(&self) -> &Manifest { self.manifest_loader.manifest() } + + #[expect( + dead_code, + reason = "pub(crate) API surface for the provision flow; not yet called anywhere in this crate" + )] + pub(crate) fn manifest_path(&self) -> &Path { + &self.manifest_path + } + + #[expect( + dead_code, + reason = "pub(crate) API surface for the provision flow; not yet called anywhere in this crate" + )] + pub(crate) fn raw_config(&self) -> &toml::Value { + &self.raw_config + } } // ------------------------------------------------------------------- @@ -213,8 +257,7 @@ where app_config::validate_excluding_secrets(&typed) .map_err(|err| format!("typed app-config failed validation: {err}"))?; - typed_secret_checks(&typed, &ctx)?; - run_adapter_typed_checks::(&ctx)?; + run_typed_preflight(&typed, &ctx)?; log::info!( "[edgezero] config validate (typed): {} + {} OK{}", @@ -281,8 +324,7 @@ where .map_err(|err| format_app_config_error(&err))?; app_config::validate_excluding_secrets(&typed) .map_err(|err| format!("typed app-config failed validation: {err}"))?; - typed_secret_checks(&typed, &ctx.validation)?; - run_adapter_typed_checks::(&ctx.validation)?; + run_typed_preflight(&typed, &ctx.validation)?; // Resolve adapter paths. let (manifest_root, adapter_manifest_path, component_selector, push_ctx) = @@ -412,8 +454,7 @@ where .map_err(|err| format_app_config_error(&err))?; app_config::validate_excluding_secrets(&typed) .map_err(|err| format!("local validation failed: {err}"))?; - typed_secret_checks(&typed, &ctx)?; - run_adapter_typed_checks::(&ctx)?; + run_typed_preflight(&typed, &ctx)?; // Build the local envelope. let local_data: serde_json::Value = serde_json::to_value(&typed) @@ -1125,9 +1166,14 @@ fn generated_at_rfc3339() -> String { chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true) } -fn load_validation_context(args: &ConfigValidateArgs) -> Result { - let manifest_loader = ManifestLoader::from_path(&args.manifest) - .map_err(|err| format!("failed to load {}: {err}", args.manifest.display()))?; +pub(crate) fn load_validation_context_with_options( + manifest_path: &Path, + app_config_override: Option<&Path>, + strict: bool, + env_overlay: bool, +) -> Result { + let manifest_loader = ManifestLoader::from_path(manifest_path) + .map_err(|err| format!("failed to load {}: {err}", manifest_path.display()))?; // Spec: every project carries a `[app].name`. Without it we // can't compute the env-overlay prefix or resolve the default @@ -1135,18 +1181,19 @@ fn load_validation_context(args: &ConfigValidateArgs) -> Result` to drive deserialise + // validator; we keep this copy for shared checks (e.g. Spin // `[component.*]` discovery) that don't need `C`. let mut opts = AppConfigLoadOptions::default(); - opts.env_overlay = !args.no_env; + opts.env_overlay = env_overlay; let raw_config = app_config::load_app_config_raw_with_options(&app_config_path, &app_name, &opts) .map_err(|err| format_app_config_error(&err))?; @@ -1154,20 +1201,29 @@ fn load_validation_context(args: &ConfigValidateArgs) -> Result Result { + load_validation_context_with_options( + &args.manifest, + args.app_config.as_deref(), + args.strict, + !args.no_env, + ) +} + +fn resolve_app_config_path_primitive( + explicit: Option<&Path>, manifest_path: &Path, app_name: &str, ) -> PathBuf { - if let Some(explicit) = &args.app_config { - return explicit.clone(); + if let Some(path) = explicit { + return path.to_path_buf(); } let manifest_dir = manifest_path .parent() @@ -1179,6 +1235,18 @@ fn resolve_app_config_path( ) } +#[expect( + dead_code, + reason = "thin wrapper kept for call-site clarity; production callers use the primitive directly" +)] +fn resolve_app_config_path( + args: &ConfigValidateArgs, + manifest_path: &Path, + app_name: &str, +) -> PathBuf { + resolve_app_config_path_primitive(args.app_config.as_deref(), manifest_path, app_name) +} + fn run_shared_checks(ctx: &ValidationContext) -> Result<(), String> { run_adapter_shared_checks(ctx)?; if ctx.args_strict { @@ -1287,12 +1355,9 @@ pub(crate) fn reject_merged_id_collisions( Ok(()) } -/// Typed-only adapter dispatch: feed each adapter the `#[secret]` -/// (`KeyInDefault` and `KeyInNamedStore` — `StoreRef` values are -/// runtime store ids, not flat-namespace candidates) so adapters -/// whose secret store has a flat-namespace constraint (Spin) can -/// detect within-secrets collisions. -fn run_adapter_typed_checks(ctx: &ValidationContext) -> Result<(), String> { +pub(crate) fn build_typed_secret_entries<'ctx, C: AppConfigMeta>( + ctx: &'ctx ValidationContext, +) -> Result>, String> { let raw_table = ctx .raw_config .as_table() @@ -1304,7 +1369,7 @@ fn run_adapter_typed_checks(ctx: &ValidationContext) -> Result .secrets .as_ref() .map(StoreDeclaration::default_id); - let mut entries: Vec> = Vec::new(); + let mut entries: Vec> = Vec::new(); for field in C::SECRET_FIELDS { match field.kind { SecretKind::KeyInDefault => { @@ -1323,6 +1388,25 @@ fn run_adapter_typed_checks(ctx: &ValidationContext) -> Result SecretKind::StoreRef => {} } } + Ok(entries) +} + +pub(crate) fn run_typed_preflight( + typed: &C, + ctx: &ValidationContext, +) -> Result<(), String> { + typed_secret_checks(typed, ctx)?; + run_adapter_typed_checks::(ctx)?; + Ok(()) +} + +/// Typed-only adapter dispatch: feed each adapter the `#[secret]` +/// (`KeyInDefault` and `KeyInNamedStore` — `StoreRef` values are +/// runtime store ids, not flat-namespace candidates) so adapters +/// whose secret store has a flat-namespace constraint (Spin) can +/// detect within-secrets collisions. +fn run_adapter_typed_checks(ctx: &ValidationContext) -> Result<(), String> { + let entries = build_typed_secret_entries::(ctx)?; for name in ctx.manifest().adapters.keys() { if let Some(adapter) = adapter_registry::get_adapter(name) { @@ -3362,4 +3446,23 @@ ids = ["default"] "error names the empty secret field: {err}" ); } + + #[test] + fn run_typed_preflight_smoke_passes_for_valid_typed_config() { + let _lock = manifest_guard().lock().unwrap(); + let (_dir, manifest, _) = setup_project(VALID_MANIFEST, FIXTURE_APP_CONFIG); + let ctx = load_validation_context_with_options(&manifest, None, false, false).unwrap(); + let load_opts = { + let mut load_opts = AppConfigLoadOptions::default(); + load_opts.env_overlay = false; + load_opts + }; + let typed: FixtureConfig = app_config::deserialize_app_config_with_options( + ctx.app_config_path(), + ctx.app_name(), + &load_opts, + ) + .unwrap(); + run_typed_preflight(&typed, &ctx).unwrap(); + } } From a8e54e1e6b9fbc266c7ab9f670f514939958c3d5 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Tue, 30 Jun 2026 11:08:33 -0700 Subject: [PATCH 07/71] Task 5 fix: promote resolve_app_config_path_primitive to pub(crate) per brief --- crates/edgezero-cli/src/config.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/edgezero-cli/src/config.rs b/crates/edgezero-cli/src/config.rs index 1660615b..f9616505 100644 --- a/crates/edgezero-cli/src/config.rs +++ b/crates/edgezero-cli/src/config.rs @@ -1217,7 +1217,7 @@ fn load_validation_context(args: &ConfigValidateArgs) -> Result, manifest_path: &Path, app_name: &str, From d184c8fe63f7ab0bef3821c70ead865054ff72ad Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Tue, 30 Jun 2026 15:10:58 -0700 Subject: [PATCH 08/71] Add path_safety module with containment helper --- crates/edgezero-cli/src/lib.rs | 2 + crates/edgezero-cli/src/path_safety.rs | 218 +++++++++++++++++++++++++ 2 files changed, 220 insertions(+) create mode 100644 crates/edgezero-cli/src/path_safety.rs diff --git a/crates/edgezero-cli/src/lib.rs b/crates/edgezero-cli/src/lib.rs index 925722d1..c1ae0e7d 100644 --- a/crates/edgezero-cli/src/lib.rs +++ b/crates/edgezero-cli/src/lib.rs @@ -32,6 +32,8 @@ mod diff; #[cfg(feature = "cli")] mod generator; #[cfg(feature = "cli")] +mod path_safety; +#[cfg(feature = "cli")] mod provision; #[cfg(feature = "cli")] mod scaffold; diff --git a/crates/edgezero-cli/src/path_safety.rs b/crates/edgezero-cli/src/path_safety.rs new file mode 100644 index 00000000..96a48599 --- /dev/null +++ b/crates/edgezero-cli/src/path_safety.rs @@ -0,0 +1,218 @@ +//! Path containment for CLI entry points that resolve +//! manifest-declared paths and let adapters write files through +//! them. See spec §"Path containment (MUST)". + +use std::path::{Component, Path, PathBuf}; + +/// Reject absolute paths and `..` traversal for the +/// `[adapters..adapter].manifest` and `.crate` strings, then +/// assert: +/// 1. each path resolves under the project root (defence in depth); +/// 2. when both `.crate` and `.manifest` are set, the manifest +/// path resolves under the crate path -- the spec's +/// stronger promise that local provision never creates +/// files outside the adapter crate or its gitignored +/// local-state dirs. +/// +/// Callers SHOULD pass the absolute manifest-loader root when +/// they have it, but the helper defensively normalises so a +/// relative `args.manifest.parent()` ("" or "examples/app-demo") +/// compares correctly. +#[cfg_attr( + not(test), + expect( + dead_code, + reason = "consumed by provision.rs (Task 8) and config push arm (Task 7)" + ) +)] +pub(crate) fn assert_provision_paths_contained( + project_root: &Path, + adapter_manifest_path: Option<&str>, + adapter_crate_path: Option<&str>, +) -> Result<(), String> { + // Treat "" as ".": Path::parent() returns "" for a bare + // `--manifest edgezero.toml`, and Path::new("").join(...) does + // NOT prepend anything, so starts_with would fail silently. + let root_raw = if project_root.as_os_str().is_empty() { + Path::new(".") + } else { + project_root + }; + let root = lexical_normalize(root_raw); + // When `root` normalises to "." (caller passed "" or "." -- + // a bare `--manifest edgezero.toml` or an explicit + // cwd-relative path), the joined-vs-root `starts_with` + // check is structurally broken: `lexical_normalize` strips + // the leading `./` from the join, leaving e.g. + // `crates/cf/wrangler.toml` -- which does NOT start with + // ".". Skip Step 1's containment check in that case; the + // absolute + `..` rejection below already guarantees the + // candidate sits under cwd, and Step 2 (manifest-inside- + // crate) compares two paths that BOTH go through the same + // normalisation so the leading-dot strip cancels out + // there. The relative-root test fixtures + // (`accepts_relative_root_default`, + // `accepts_empty_root_string_as_dot`) only pass with this + // short-circuit in place. + let do_step1_starts_with = root != Path::new("."); + + // Step 1: each path is project-relative + no `..` + (when + // root is concretely-rooted) resolves under the project root. + for (label, maybe_raw) in [ + ("[adapters..adapter].manifest", adapter_manifest_path), + ("[adapters..adapter].crate", adapter_crate_path), + ] { + let Some(raw) = maybe_raw else { continue }; + let candidate = Path::new(raw); + if candidate.is_absolute() { + return Err(format!( + "{label} must be a project-relative path; got absolute `{raw}`" + )); + } + if candidate + .components() + .any(|comp| matches!(comp, Component::ParentDir)) + { + return Err(format!( + "{label} must not contain `..` traversal; got `{raw}`" + )); + } + if do_step1_starts_with { + let normalized = lexical_normalize(&root.join(candidate)); + if !normalized.starts_with(&root) { + return Err(format!( + "{label} resolves outside project root `{}`: `{}`", + root.display(), + normalized.display() + )); + } + } + } + + // Step 2: when both are set, manifest MUST sit inside the + // adapter crate dir. Closes the spec's stronger promise -- + // without this, crate = "crates/cf" + manifest = + // "tmp/wrangler.toml" would pass step 1 but write to a path + // outside the adapter crate. + if let (Some(crate_raw), Some(manifest_raw)) = (adapter_crate_path, adapter_manifest_path) { + let crate_resolved = lexical_normalize(&root.join(Path::new(crate_raw))); + let manifest_resolved = lexical_normalize(&root.join(Path::new(manifest_raw))); + if !manifest_resolved.starts_with(&crate_resolved) { + return Err(format!( + "[adapters..adapter].manifest `{manifest_raw}` must \ + resolve inside [adapters..adapter].crate `{crate_raw}`; \ + resolved manifest path `{}` is not under crate path `{}`", + manifest_resolved.display(), + crate_resolved.display() + )); + } + } + Ok(()) +} + +/// Lexically normalise: collapse `.` components and pass `..` +/// through unchanged (caller already rejected `..`). No +/// `fs::canonicalize` -- paths may not exist on first-run +/// bootstrap, and canonicalising would resolve operator-set +/// symlinks on the project root. +pub(crate) fn lexical_normalize(path: &Path) -> PathBuf { + let mut out = PathBuf::new(); + for comp in path.components() { + match comp { + Component::CurDir => {} + Component::Prefix(_) + | Component::RootDir + | Component::ParentDir + | Component::Normal(_) => out.push(comp.as_os_str()), + } + } + if out.as_os_str().is_empty() { + out.push("."); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + + #[test] + fn rejects_absolute_manifest_path() { + let err = + assert_provision_paths_contained(Path::new("."), Some("/etc/wrangler.toml"), None) + .unwrap_err(); + assert!(err.contains("must be a project-relative path"), "{err}"); + } + + #[test] + fn rejects_parent_traversal_in_manifest_path() { + let err = + assert_provision_paths_contained(Path::new("."), Some("../outside/spin.toml"), None) + .unwrap_err(); + assert!(err.contains("must not contain `..` traversal"), "{err}"); + } + + #[test] + fn rejects_parent_traversal_in_crate_path() { + let err = + assert_provision_paths_contained(Path::new("."), None, Some("../escape")).unwrap_err(); + assert!(err.contains("must not contain `..` traversal"), "{err}"); + } + + #[test] + fn accepts_relative_root_default() { + assert_provision_paths_contained( + Path::new("."), + Some("crates/edgezero-adapter-spin/spin.toml"), + Some("crates/edgezero-adapter-spin"), + ) + .unwrap(); + } + + #[test] + fn accepts_nested_relative_root() { + assert_provision_paths_contained( + Path::new("examples/app-demo"), + Some("crates/app-demo-adapter-spin/spin.toml"), + Some("crates/app-demo-adapter-spin"), + ) + .unwrap(); + } + + #[test] + fn accepts_empty_root_string_as_dot() { + // args.manifest.parent() returns "" for a bare `--manifest edgezero.toml`. + assert_provision_paths_contained( + Path::new(""), + Some("crates/edgezero-adapter-spin/spin.toml"), + None, + ) + .unwrap(); + } + + #[test] + fn rejects_manifest_outside_adapter_crate() { + // Crate = "crates/cf", but manifest = "tmp/wrangler.toml" + // (sibling of the crate, NOT inside it). Step 1 passes + // (both under project root); step 2 must catch the + // crate-vs-manifest mismatch. + let err = assert_provision_paths_contained( + Path::new("."), + Some("tmp/wrangler.toml"), + Some("crates/cf"), + ) + .unwrap_err(); + assert!(err.contains("must resolve inside"), "{err}"); + } + + #[test] + fn accepts_manifest_under_adapter_crate() { + assert_provision_paths_contained( + Path::new("."), + Some("crates/cf/wrangler.toml"), + Some("crates/cf"), + ) + .unwrap(); + } +} From a347422faca84e8986581cc68e16eda450f8a504 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 1 Jul 2026 00:55:36 -0700 Subject: [PATCH 09/71] Wire path_safety into config push --local --- crates/edgezero-cli/src/config.rs | 82 ++++++++++++++++++++++++++ crates/edgezero-cli/src/path_safety.rs | 7 --- 2 files changed, 82 insertions(+), 7 deletions(-) diff --git a/crates/edgezero-cli/src/config.rs b/crates/edgezero-cli/src/config.rs index f9616505..0b3a7839 100644 --- a/crates/edgezero-cli/src/config.rs +++ b/crates/edgezero-cli/src/config.rs @@ -21,6 +21,7 @@ use crate::args::{ConfigDiffArgs, ConfigPushArgs, ConfigValidateArgs, DiffFormat}; use crate::diff::{collect_changes, render_json, render_structured}; use crate::ensure_adapter_defined; +use crate::path_safety::assert_provision_paths_contained; use edgezero_adapter::registry::{ self as adapter_registry, ReadConfigEntry, ResolvedStoreId, TypedSecretEntry, }; @@ -336,6 +337,21 @@ where push_ctx: &push_ctx, }; + // Path containment check: reject `..` traversal and absolute paths + // in the manifest-declared adapter paths before any adapter dispatch. + if args.local { + let adapter_crate_path = ctx + .validation + .manifest() + .adapter_entry(ctx.adapter.name()) + .and_then(|(_, cfg)| cfg.adapter.crate_path.clone()); + assert_provision_paths_contained( + manifest_root, + adapter_manifest_path.as_deref(), + adapter_crate_path.as_deref(), + )?; + } + // Build envelope. // Honour --key override (5.4): if the caller supplied an explicit key, // use it; otherwise fall back to the manifest's resolved logical store id. @@ -2777,6 +2793,72 @@ default = "one" ); } + // ---------- push --local path containment ---------- + + #[test] + fn config_push_local_rejects_parent_traversal_in_adapter_manifest() { + let manifest_bad = r#" +[app] +name = "demo-app" + +[adapters.axum.adapter] +crate = "crates/demo-axum" +manifest = "../outside/axum.toml" + +[adapters.axum.commands] +build = "echo" +deploy = "echo" +serve = "echo" + +[stores.config] +ids = ["app_config"] + +[stores.secrets] +ids = ["default"] +"#; + let (_dir, manifest, _) = setup_project(manifest_bad, FIXTURE_APP_CONFIG); + let mut args = push_args(&manifest, "axum"); + args.local = true; + let err = run_config_push_typed::(&args) + .expect_err("parent traversal in adapter manifest must be rejected"); + assert!( + err.contains("must not contain `..` traversal"), + "error must name the traversal violation: {err}" + ); + } + + #[test] + fn config_push_local_rejects_absolute_adapter_manifest() { + let manifest_bad = r#" +[app] +name = "demo-app" + +[adapters.axum.adapter] +crate = "crates/demo-axum" +manifest = "/tmp/some.toml" + +[adapters.axum.commands] +build = "echo" +deploy = "echo" +serve = "echo" + +[stores.config] +ids = ["app_config"] + +[stores.secrets] +ids = ["default"] +"#; + let (_dir, manifest, _) = setup_project(manifest_bad, FIXTURE_APP_CONFIG); + let mut args = push_args(&manifest, "axum"); + args.local = true; + let err = run_config_push_typed::(&args) + .expect_err("absolute adapter manifest path must be rejected"); + assert!( + err.contains("must be a project-relative path"), + "error must name the absolute-path violation: {err}" + ); + } + // ------------------------------------------------------------------- // run_config_push_typed — 8.2 consent rules + diff // ------------------------------------------------------------------- diff --git a/crates/edgezero-cli/src/path_safety.rs b/crates/edgezero-cli/src/path_safety.rs index 96a48599..21a5c487 100644 --- a/crates/edgezero-cli/src/path_safety.rs +++ b/crates/edgezero-cli/src/path_safety.rs @@ -18,13 +18,6 @@ use std::path::{Component, Path, PathBuf}; /// they have it, but the helper defensively normalises so a /// relative `args.manifest.parent()` ("" or "examples/app-demo") /// compares correctly. -#[cfg_attr( - not(test), - expect( - dead_code, - reason = "consumed by provision.rs (Task 8) and config push arm (Task 7)" - ) -)] pub(crate) fn assert_provision_paths_contained( project_root: &Path, adapter_manifest_path: Option<&str>, From d8d6622d676b3243b9a0651d8f0404971a968a08 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 1 Jul 2026 01:08:19 -0700 Subject: [PATCH 10/71] Wire path_safety + ProvisionMode into run_provision --- crates/edgezero-cli/src/provision.rs | 246 ++++++++++++++++++++++++++- 1 file changed, 245 insertions(+), 1 deletion(-) diff --git a/crates/edgezero-cli/src/provision.rs b/crates/edgezero-cli/src/provision.rs index 4484b8dc..337dc50f 100644 --- a/crates/edgezero-cli/src/provision.rs +++ b/crates/edgezero-cli/src/provision.rs @@ -15,6 +15,7 @@ use crate::config::{ enforce_single_store_capability, reject_merged_id_collisions, strict_handler_paths, }; use crate::ensure_adapter_defined; +use crate::path_safety::assert_provision_paths_contained; use edgezero_adapter::registry::{self as adapter_registry, ProvisionStores, ResolvedStoreId}; use edgezero_core::env_config::EnvConfig; use edgezero_core::manifest::{ManifestLoader, StoreDeclaration}; @@ -42,6 +43,33 @@ pub fn run_provision(args: &ProvisionArgs) -> Result<(), String> { ) })?; + // Path containment: reject `..` traversal and absolute paths in + // the manifest-declared adapter paths before any adapter dispatch + // or file resolution. Mirrors the `config push --local` guard + // (Task 7); the same helper closes the spec's "local provision + // never writes outside the adapter crate" promise. Cloud mode + // still targets remote SDKs so containment isn't load-bearing; + // gating on `args.local` also preserves the existing cloud + // fixtures where `manifest` lives at project root outside `crate`. + if args.local { + let manifest_root_for_check = args + .manifest + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + assert_provision_paths_contained( + manifest_root_for_check, + adapter_cfg.adapter.manifest.as_deref(), + adapter_cfg.adapter.crate_path.as_deref(), + )?; + } + + let mode = if args.local { + adapter_registry::ProvisionMode::Local + } else { + adapter_registry::ProvisionMode::Cloud + }; + // Linked in this build? Adapters are feature-gated; a release // built without `--features cloudflare` won't have it // registered even if the manifest declares it. @@ -120,7 +148,7 @@ pub fn run_provision(args: &ProvisionArgs) -> Result<(), String> { adapter_cfg.adapter.component.as_deref(), &stores, None, // cloud arm doesn't consume deployed state; it produces it - adapter_registry::ProvisionMode::Cloud, + mode, args.dry_run, )?; @@ -156,6 +184,7 @@ mod tests { use crate::args::ProvisionArgs; use crate::test_support::{manifest_guard, EnvOverride, PROVISION_MANIFEST}; use std::fs; + use std::path::PathBuf; use tempfile::TempDir; #[test] @@ -540,4 +569,219 @@ ids = ["default"] }) .expect("fastly dry-run dispatches cleanly"); } + + // ---------- provision --local path containment ---------- + + #[test] + fn provision_local_rejects_parent_traversal_in_adapter_manifest() { + let _lock = manifest_guard().lock().expect("manifest guard"); + let temp = TempDir::new().expect("temp dir"); + let manifest_path = temp.path().join("edgezero.toml"); + let manifest_body = r#" +[app] +name = "demo-app" + +[adapters.spin.adapter] +crate = "crates/demo-spin" +manifest = "../outside/spin.toml" +[adapters.spin.commands] +build = "echo" +deploy = "echo" +serve = "echo" + +[stores.config] +ids = ["app_config"] + +[stores.secrets] +ids = ["default"] +"#; + fs::write(&manifest_path, manifest_body).expect("write manifest"); + let manifest_str = manifest_path.to_string_lossy().into_owned(); + let _env = EnvOverride::set("EDGEZERO_MANIFEST", &manifest_str); + // Canary: the traversal target resolves to `/outside/spin.toml`. + // If path-safety fires before dispatch, this path must not exist after. + let outside_dir = temp + .path() + .parent() + .expect("tempdir has parent") + .join("outside"); + assert!(!outside_dir.exists(), "sentinel: outside/ absent pre-call"); + + let err = run_provision(&ProvisionArgs { + adapter: "spin".to_owned(), + dry_run: true, + local: true, + manifest: manifest_path.clone(), + }) + .expect_err("parent traversal in adapter manifest must be rejected"); + assert!( + err.contains("must not contain `..` traversal"), + "error must name the traversal violation: {err}" + ); + assert!( + !outside_dir.exists(), + "sentinel: outside/ still absent after call (dispatch did not fire)" + ); + } + + #[test] + fn provision_local_rejects_absolute_adapter_manifest() { + let _lock = manifest_guard().lock().expect("manifest guard"); + let temp = TempDir::new().expect("temp dir"); + let manifest_path = temp.path().join("edgezero.toml"); + // Use a path inside a fresh tempdir subtree so we can prove + // it stays absent even though nothing outside the test would + // reasonably poke it: /tmp/some.toml would be a shared name. + let outside_root = TempDir::new().expect("outside temp dir"); + let outside_abs = outside_root.path().join("some.toml"); + let manifest_body = format!( + r#" +[app] +name = "demo-app" + +[adapters.spin.adapter] +crate = "crates/demo-spin" +manifest = "{}" +[adapters.spin.commands] +build = "echo" +deploy = "echo" +serve = "echo" + +[stores.config] +ids = ["app_config"] + +[stores.secrets] +ids = ["default"] +"#, + outside_abs.display() + ); + fs::write(&manifest_path, &manifest_body).expect("write manifest"); + let manifest_str = manifest_path.to_string_lossy().into_owned(); + let _env = EnvOverride::set("EDGEZERO_MANIFEST", &manifest_str); + assert!( + !outside_abs.exists(), + "sentinel: absolute path absent pre-call" + ); + + let err = run_provision(&ProvisionArgs { + adapter: "spin".to_owned(), + dry_run: true, + local: true, + manifest: manifest_path.clone(), + }) + .expect_err("absolute adapter manifest path must be rejected"); + assert!( + err.contains("must be a project-relative path"), + "error must name the absolute-path violation: {err}" + ); + assert!( + !outside_abs.exists(), + "sentinel: absolute path still absent after call" + ); + } + + #[test] + fn provision_local_rejects_parent_traversal_in_adapter_crate() { + let _lock = manifest_guard().lock().expect("manifest guard"); + let temp = TempDir::new().expect("temp dir"); + let manifest_path = temp.path().join("edgezero.toml"); + let manifest_body = r#" +[app] +name = "demo-app" + +[adapters.axum.adapter] +crate = "../escape" +[adapters.axum.commands] +build = "echo" +deploy = "echo" +serve = "echo" + +[stores.config] +ids = ["app_config"] + +[stores.secrets] +ids = ["default"] +"#; + fs::write(&manifest_path, manifest_body).expect("write manifest"); + let manifest_str = manifest_path.to_string_lossy().into_owned(); + let _env = EnvOverride::set("EDGEZERO_MANIFEST", &manifest_str); + let escape_dir = temp + .path() + .parent() + .expect("tempdir has parent") + .join("escape"); + assert!(!escape_dir.exists(), "sentinel: escape/ absent pre-call"); + + let err = run_provision(&ProvisionArgs { + adapter: "axum".to_owned(), + dry_run: true, + local: true, + manifest: manifest_path.clone(), + }) + .expect_err("parent traversal in adapter crate must be rejected"); + assert!( + err.contains("must not contain `..` traversal"), + "error must name the traversal violation: {err}" + ); + assert!( + !escape_dir.exists(), + "sentinel: escape/ still absent after call" + ); + } + + #[test] + fn provision_local_accepts_relative_manifest_root_default() { + // Bare `--manifest edgezero.toml` — `args.manifest.parent()` + // returns "". Path-safety must not reject the well-formed + // adapter paths in this fixture; the axum adapter itself + // then errors from Section 5 because local mode isn't wired + // yet, but that error is NOT a path-safety error. + let _lock = manifest_guard().lock().expect("manifest guard"); + let temp = TempDir::new().expect("temp dir"); + let manifest_path = temp.path().join("edgezero.toml"); + fs::write(&manifest_path, PROVISION_MANIFEST).expect("write manifest"); + let manifest_str = manifest_path.to_string_lossy().into_owned(); + let _env = EnvOverride::set("EDGEZERO_MANIFEST", &manifest_str); + + let err = run_provision(&ProvisionArgs { + adapter: "axum".to_owned(), + dry_run: true, + local: true, + manifest: PathBuf::from("edgezero.toml"), + }) + .expect_err("local dispatch reaches adapter (Section 5 stub errors)"); + assert!( + !err.contains("must not contain `..` traversal") + && !err.contains("must be a project-relative path") + && !err.contains("resolves outside project root"), + "path-safety must not fire for a valid fixture: {err}" + ); + } + + #[test] + fn provision_local_accepts_relative_manifest_root_nested() { + // Nested `--manifest /edgezero.toml` — parent is + // the tempdir path. Same shape as above: path-safety passes, + // the axum adapter errors from its Section 5 stub. + let _lock = manifest_guard().lock().expect("manifest guard"); + let temp = TempDir::new().expect("temp dir"); + let manifest_path = temp.path().join("edgezero.toml"); + fs::write(&manifest_path, PROVISION_MANIFEST).expect("write manifest"); + let manifest_str = manifest_path.to_string_lossy().into_owned(); + let _env = EnvOverride::set("EDGEZERO_MANIFEST", &manifest_str); + + let err = run_provision(&ProvisionArgs { + adapter: "axum".to_owned(), + dry_run: true, + local: true, + manifest: manifest_path.clone(), + }) + .expect_err("local dispatch reaches adapter (Section 5 stub errors)"); + assert!( + !err.contains("must not contain `..` traversal") + && !err.contains("must be a project-relative path") + && !err.contains("resolves outside project root"), + "path-safety must not fire for a valid fixture: {err}" + ); + } } From 7074ac795cf0fdf0301643933366c1e1ec1123fa Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 1 Jul 2026 09:19:53 -0700 Subject: [PATCH 11/71] Add Adapter::synthesise_baseline_manifest + CLI bootstrap before validate --- crates/edgezero-adapter/src/registry.rs | 33 +- crates/edgezero-cli/src/provision.rs | 410 +++++++++++++++++++++--- 2 files changed, 393 insertions(+), 50 deletions(-) diff --git a/crates/edgezero-adapter/src/registry.rs b/crates/edgezero-adapter/src/registry.rs index 84418466..ae23187c 100644 --- a/crates/edgezero-adapter/src/registry.rs +++ b/crates/edgezero-adapter/src/registry.rs @@ -1,5 +1,5 @@ use std::collections::{BTreeMap, HashMap}; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::sync::{LazyLock, PoisonError, RwLock}; static REGISTRY: LazyLock>> = @@ -510,6 +510,37 @@ pub trait Adapter: Sync + Send { &[] } + /// First-run bootstrap synthesiser, called by the CLI ONLY when + /// `mode == Local` AND the adapter manifest (or related local + /// files like `runtime-config.toml`) is absent. Returns + /// `Ok(Vec::new())` for adapters that own no synthesised local + /// state (e.g. Axum — `axum.toml` stays tracked). + /// + /// Each `(relative_path, contents)` tuple is written by the CLI + /// under `manifest_root` BEFORE `validate_adapter_manifest` + /// runs, so a clean clone can pass validation. + /// + /// **Boundary contract (MUST):** signature uses only `std` + + /// types defined IN this crate. Adapters that need values from + /// the parent manifest receive them through the neutral + /// `Option<&AdapterDeployedState>` argument — the CLI translates + /// from `&Manifest` to `AdapterDeployedState` at the call site. + /// + /// # Errors + /// The default impl never errors. Adapter overrides may return + /// human-readable error strings if baseline synthesis fails. + #[inline] + fn synthesise_baseline_manifest( + &self, + _manifest_root: &Path, + _adapter_manifest_path: Option<&str>, + _component_selector: Option<&str>, + _app_name: &str, + _deployed: Option<&AdapterDeployedState>, + ) -> Result, String> { + Ok(Vec::new()) + } + /// Adapter-specific manifest check — e.g. Spin's /// `[component.*]` discovery in `spin.toml`. The adapter /// resolves its own per-adapter manifest path relative to diff --git a/crates/edgezero-cli/src/provision.rs b/crates/edgezero-cli/src/provision.rs index 337dc50f..3169f84b 100644 --- a/crates/edgezero-cli/src/provision.rs +++ b/crates/edgezero-cli/src/provision.rs @@ -8,7 +8,8 @@ //! each `edgezero-adapter-*` crate's `Adapter::provision` impl, not //! here. -use std::path::Path; +use std::fs; +use std::path::{Path, PathBuf}; use crate::args::ProvisionArgs; use crate::config::{ @@ -17,8 +18,9 @@ use crate::config::{ use crate::ensure_adapter_defined; use crate::path_safety::assert_provision_paths_contained; use edgezero_adapter::registry::{self as adapter_registry, ProvisionStores, ResolvedStoreId}; +use edgezero_adapter::AdapterDeployedState; use edgezero_core::env_config::EnvConfig; -use edgezero_core::manifest::{ManifestLoader, StoreDeclaration}; +use edgezero_core::manifest::{Manifest, ManifestAdapter, ManifestLoader, StoreDeclaration}; /// # Errors /// @@ -64,12 +66,6 @@ pub fn run_provision(args: &ProvisionArgs) -> Result<(), String> { )?; } - let mode = if args.local { - adapter_registry::ProvisionMode::Local - } else { - adapter_registry::ProvisionMode::Cloud - }; - // Linked in this build? Adapters are feature-gated; a release // built without `--features cloudflare` won't have it // registered even if the manifest declares it. @@ -108,50 +104,63 @@ pub fn run_provision(args: &ProvisionArgs) -> Result<(), String> { .parent() .filter(|parent| !parent.as_os_str().is_empty()) .unwrap_or_else(|| Path::new(".")); - adapter.validate_adapter_manifest( - manifest_root, - adapter_cfg.adapter.manifest.as_deref(), - adapter_cfg.adapter.component.as_deref(), - )?; - - // Resolve each logical store id to its platform name via the - // same `EDGEZERO__STORES______NAME` env overlay the - // runtime reads. Provision writes the PLATFORM name into the - // per-platform manifest (wrangler.toml, spin.toml, - // fastly.toml); the logical id stays available for status-line - // wording so operators see what they declared even when the - // env override redirected the create. - let env_config = EnvConfig::from_env(); - - // Same env-resolved merged-id collision check `config validate` - // runs. Without it, `provision --adapter spin --dry-run` would - // happily ack a manifest where (e.g.) [stores.kv].sessions and - // [stores.config].app_config both resolve to platform label - // `shared` via the env overlay -- both writes would silently - // land on the same Spin KV store at runtime. Catches BOTH - // logical-id collisions and env-resolved platform-label - // collisions across merged kinds. - reject_merged_id_collisions(&args.adapter, adapter, manifest, &env_config)?; - let config_ids = resolve_kind(manifest.stores.config.as_ref(), &env_config, "config"); - let kv_ids = resolve_kind(manifest.stores.kv.as_ref(), &env_config, "kv"); - let secret_ids = resolve_kind(manifest.stores.secrets.as_ref(), &env_config, "secrets"); - let stores = ProvisionStores { - config: &config_ids, - kv: &kv_ids, - secrets: &secret_ids, + // Fallback to "" when [app].name is unset: today's synthesiser + // default is a no-op so the value isn't consulted; per-adapter + // overrides (Tasks 17/21/24) that DO use it treat empty as the + // "operator hasn't set app.name yet" case. + let app_name = manifest.app.name.clone().unwrap_or_default(); + + // Translate the manifest's deployed block into the neutral + // `AdapterDeployedState` for the synthesiser call site. Task 14 + // adds the typed struct that makes this a real translation; + // today it's always `None`. + let deployed = deployed_state_for(manifest, &args.adapter); + + let outcome = match (args.local, args.dry_run) { + (false, dry_run) => { + // Cloud: no synthesis. Validate + build stores against the + // real worktree, dispatch with mode=Cloud, deployed=None. + validate_and_dispatch( + adapter, + manifest, + adapter_cfg, + manifest_root, + &args.adapter, + None, + adapter_registry::ProvisionMode::Cloud, + dry_run, + )? + } + (true, false) => { + // Local real-write: synthesise baseline INSIDE this arm + // so cloud never touches it. Write baseline to the + // worktree, then validate + build stores + dispatch. + let baseline_pairs = adapter.synthesise_baseline_manifest( + manifest_root, + adapter_cfg.adapter.manifest.as_deref(), + adapter_cfg.adapter.component.as_deref(), + &app_name, + deployed.as_ref(), + )?; + write_baseline_to_disk(manifest_root, &baseline_pairs)?; + validate_and_dispatch( + adapter, + manifest, + adapter_cfg, + manifest_root, + &args.adapter, + deployed.as_ref(), + adapter_registry::ProvisionMode::Local, + false, + )? + } + (true, true) => { + // Local dry-run: staging harness lands in Task 10/11. + return Err("local dry-run staging lands in Task 10/11".to_owned()); + } }; - let outcome = adapter.provision( - manifest_root, - adapter_cfg.adapter.manifest.as_deref(), - adapter_cfg.adapter.component.as_deref(), - &stores, - None, // cloud arm doesn't consume deployed state; it produces it - mode, - args.dry_run, - )?; - if args.dry_run { log::info!("[edgezero] provision --dry-run for `{}`:", args.adapter); } @@ -178,15 +187,202 @@ fn resolve_kind( }) } +/// Write each `(rel, contents)` baseline pair under `root`, skipping +/// files that already exist. Preserves operator content and earlier +/// synthesis output. Used for worktree writes (real-write local) and +/// tempdir writes (dry-run staging, Task 10+) — the only difference +/// is which root is passed in. +fn write_baseline_to_disk(root: &Path, pairs: &[(PathBuf, String)]) -> Result<(), String> { + for (rel_path, contents) in pairs { + let abs = root.join(rel_path); + if abs.exists() { + continue; + } + if let Some(parent) = abs.parent() { + fs::create_dir_all(parent) + .map_err(|err| format!("create {}: {err}", parent.display()))?; + } + fs::write(&abs, contents).map_err(|err| format!("write {}: {err}", abs.display()))?; + } + Ok(()) +} + +/// Translate the parent manifest's deployed block for `canonical_adapter_name` +/// into the neutral `AdapterDeployedState` shape. Task 14 introduces the typed +/// `ManifestAdapterDeployed` struct; until that lands this returns `None` +/// unconditionally. The synthesiser call path already receives +/// `Option<&AdapterDeployedState>` — just always `None` today. Section 4 +/// fills in the real translation. +fn deployed_state_for( + _manifest: &Manifest, + _canonical_adapter_name: &str, +) -> Option { + None +} + +/// Shared validate + env-overlay + collision-check + resolve-stores + +/// dispatch tail for both cloud and local live-mode arms. Baseline +/// synthesis (local only) fires BEFORE this helper — the tail after +/// synthesis is identical between the two arms, so factoring it here +/// keeps `run_provision` under the module's function-length lint AND +/// removes the copy-paste risk of two arms drifting out of sync. +#[expect( + clippy::too_many_arguments, + reason = "the shared tail needs adapter, manifest, adapter cfg, manifest root, adapter name, deployed state, mode, and dry-run — same 8 argument shape as `Adapter::provision` itself, whose lint annotation applies for the same reason" +)] +fn validate_and_dispatch( + adapter: &'static dyn adapter_registry::Adapter, + manifest: &Manifest, + adapter_cfg: &ManifestAdapter, + manifest_root: &Path, + adapter_name: &str, + deployed: Option<&AdapterDeployedState>, + mode: adapter_registry::ProvisionMode, + dry_run: bool, +) -> Result { + adapter.validate_adapter_manifest( + manifest_root, + adapter_cfg.adapter.manifest.as_deref(), + adapter_cfg.adapter.component.as_deref(), + )?; + let env_config = EnvConfig::from_env(); + reject_merged_id_collisions(adapter_name, adapter, manifest, &env_config)?; + let config_ids = resolve_kind(manifest.stores.config.as_ref(), &env_config, "config"); + let kv_ids = resolve_kind(manifest.stores.kv.as_ref(), &env_config, "kv"); + let secret_ids = resolve_kind(manifest.stores.secrets.as_ref(), &env_config, "secrets"); + let stores = ProvisionStores { + config: &config_ids, + kv: &kv_ids, + secrets: &secret_ids, + }; + adapter.provision( + manifest_root, + adapter_cfg.adapter.manifest.as_deref(), + adapter_cfg.adapter.component.as_deref(), + &stores, + deployed, + mode, + dry_run, + ) +} + #[cfg(test)] mod tests { use super::*; use crate::args::ProvisionArgs; use crate::test_support::{manifest_guard, EnvOverride, PROVISION_MANIFEST}; + use edgezero_adapter::registry::{ + register_adapter, Adapter, AdapterAction, ProvisionMode, ProvisionOutcome, + }; use std::fs; use std::path::PathBuf; + use std::sync::atomic::{AtomicBool, Ordering}; use tempfile::TempDir; + // ----- fixtures for CLI-owned first-run bootstrap synthesis ----- + // + // A distinct fake adapter (`__test_bootstrap_fake__`) is + // registered per-test into the global adapter registry via the + // public `register_adapter` helper. The `manifest_guard()` mutex + // already serialises tests that touch the registry, so a + // second registration under the same name from a concurrent + // test cannot race. Observability is via two module-scope + // `AtomicBool` flags — `SYNTH_CALLED` for the synthesiser call + // and `VALIDATE_SAW_FILE` for the downstream + // `validate_adapter_manifest` invariant. + // + // The fake echoes `adapter_manifest_path` back as the + // synthesised file's relative path, mirroring the Spin override + // that lands at Task 24 — the file must land at + // `/`, NOT at a hard-coded + // path. + + const FAKE_MANIFEST_BODY: &str = r#" +[app] +name = "demo-app" + +[adapters.__test_bootstrap_fake__.adapter] +crate = "crates/spin" +manifest = "crates/spin/spin.toml" + +[adapters.__test_bootstrap_fake__.commands] +build = "echo" +deploy = "echo" +serve = "echo" +"#; + + static FAKE_ADAPTER: FakeBootstrapAdapter = FakeBootstrapAdapter; + static SYNTH_CALLED: AtomicBool = AtomicBool::new(false); + static VALIDATE_SAW_FILE: AtomicBool = AtomicBool::new(false); + + struct FakeBootstrapAdapter; + + #[expect( + clippy::missing_trait_methods, + reason = "the fake only exercises name/provision/synthesise_baseline_manifest/validate_adapter_manifest; every other trait method inherits its default (no-op or Unsupported)" + )] + impl Adapter for FakeBootstrapAdapter { + fn execute(&self, _action: AdapterAction, _args: &[String]) -> Result<(), String> { + Ok(()) + } + + fn name(&self) -> &'static str { + "__test_bootstrap_fake__" + } + + fn provision( + &self, + _manifest_root: &Path, + _adapter_manifest_path: Option<&str>, + _component_selector: Option<&str>, + _stores: &ProvisionStores<'_>, + _deployed: Option<&AdapterDeployedState>, + _mode: ProvisionMode, + _dry_run: bool, + ) -> Result { + Ok(ProvisionOutcome::default()) + } + + fn synthesise_baseline_manifest( + &self, + _manifest_root: &Path, + adapter_manifest_path: Option<&str>, + _component_selector: Option<&str>, + _app_name: &str, + _deployed: Option<&AdapterDeployedState>, + ) -> Result, String> { + SYNTH_CALLED.store(true, Ordering::SeqCst); + let rel = adapter_manifest_path.unwrap_or("spin.toml").to_owned(); + Ok(vec![(PathBuf::from(rel), "# stub\n".to_owned())]) + } + + fn validate_adapter_manifest( + &self, + manifest_root: &Path, + adapter_manifest_path: Option<&str>, + _component_selector: Option<&str>, + ) -> Result<(), String> { + // The synthesised file MUST exist by the time validate + // runs — that's the invariant this whole task guards. + let rel = adapter_manifest_path.unwrap_or("spin.toml"); + let abs = manifest_root.join(rel); + if abs.exists() { + VALIDATE_SAW_FILE.store(true, Ordering::SeqCst); + Ok(()) + } else { + Err(format!( + "fake validate: {} missing at validate time", + abs.display() + )) + } + } + } + + fn reset_fake_state() { + SYNTH_CALLED.store(false, Ordering::SeqCst); + VALIDATE_SAW_FILE.store(false, Ordering::SeqCst); + } + #[test] fn run_provision_axum_prints_local_only_notes_for_each_store() { let _lock = manifest_guard().lock().expect("manifest guard"); @@ -784,4 +980,120 @@ ids = ["default"] "path-safety must not fire for a valid fixture: {err}" ); } + + // ---------- CLI-owned first-run bootstrap synthesis ---------- + + #[test] + fn provision_local_synthesises_missing_adapter_manifest_before_validation() { + let _lock = manifest_guard().lock().expect("manifest guard"); + let temp = TempDir::new().expect("temp dir"); + // Fixture: crates/spin/ exists but spin.toml does NOT. + fs::create_dir_all(temp.path().join("crates/spin")).expect("create adapter crate dir"); + let manifest_path = temp.path().join("edgezero.toml"); + fs::write(&manifest_path, FAKE_MANIFEST_BODY).expect("write manifest"); + let manifest_str = manifest_path.to_string_lossy().into_owned(); + let _env = EnvOverride::set("EDGEZERO_MANIFEST", &manifest_str); + + reset_fake_state(); + register_adapter(&FAKE_ADAPTER); + + run_provision(&ProvisionArgs { + adapter: "__test_bootstrap_fake__".to_owned(), + dry_run: false, + local: true, + manifest: manifest_path.clone(), + }) + .expect("local provision synthesises baseline then validates"); + + assert!( + SYNTH_CALLED.load(Ordering::SeqCst), + "synthesiser must fire in local mode" + ); + assert!( + VALIDATE_SAW_FILE.load(Ordering::SeqCst), + "validate must see synthesised file" + ); + let synth_path = temp.path().join("crates/spin/spin.toml"); + let synth = fs::read_to_string(&synth_path) + .expect("synthesised file lands under the configured adapter manifest path"); + assert!( + synth.contains("# stub"), + "synthesised file contains the fake payload: {synth}" + ); + // Regression guard: the synthesised file must NOT land at + // /spin.toml — that path is what the earlier "hard-code + // spin.toml" shape produced. + assert!( + !temp.path().join("spin.toml").exists(), + "sentinel: synthesis must not write to a hard-coded root-level path" + ); + } + + #[test] + fn provision_local_bootstrap_is_a_no_op_when_manifest_already_present() { + let _lock = manifest_guard().lock().expect("manifest guard"); + let temp = TempDir::new().expect("temp dir"); + fs::create_dir_all(temp.path().join("crates/spin")).expect("create adapter crate dir"); + // Operator-authored content that must survive. + let operator_body = "# operator-authored\n"; + fs::write(temp.path().join("crates/spin/spin.toml"), operator_body) + .expect("write operator manifest"); + let manifest_path = temp.path().join("edgezero.toml"); + fs::write(&manifest_path, FAKE_MANIFEST_BODY).expect("write manifest"); + let manifest_str = manifest_path.to_string_lossy().into_owned(); + let _env = EnvOverride::set("EDGEZERO_MANIFEST", &manifest_str); + + reset_fake_state(); + register_adapter(&FAKE_ADAPTER); + + run_provision(&ProvisionArgs { + adapter: "__test_bootstrap_fake__".to_owned(), + dry_run: false, + local: true, + manifest: manifest_path.clone(), + }) + .expect("provision passes when manifest already exists"); + + // The synthesiser fires, but write_baseline_to_disk skips + // existing files, so operator content survives byte-for-byte. + let after = fs::read_to_string(temp.path().join("crates/spin/spin.toml")) + .expect("existing spin.toml still readable"); + assert_eq!( + after, operator_body, + "existing operator-authored file must remain byte-for-byte unchanged" + ); + } + + #[test] + fn provision_cloud_never_runs_bootstrap_synthesis() { + let _lock = manifest_guard().lock().expect("manifest guard"); + let temp = TempDir::new().expect("temp dir"); + // crates/spin/ exists but spin.toml does NOT — validation must + // therefore fail in cloud mode because bootstrap synthesis is + // NOT invoked to fill the gap. + fs::create_dir_all(temp.path().join("crates/spin")).expect("create adapter crate dir"); + let manifest_path = temp.path().join("edgezero.toml"); + fs::write(&manifest_path, FAKE_MANIFEST_BODY).expect("write manifest"); + let manifest_str = manifest_path.to_string_lossy().into_owned(); + let _env = EnvOverride::set("EDGEZERO_MANIFEST", &manifest_str); + + reset_fake_state(); + register_adapter(&FAKE_ADAPTER); + + let err = run_provision(&ProvisionArgs { + adapter: "__test_bootstrap_fake__".to_owned(), + dry_run: false, + local: false, + manifest: manifest_path.clone(), + }) + .expect_err("cloud mode with missing adapter manifest must error at validate"); + assert!( + err.contains("missing at validate time"), + "error surfaces the missing-manifest failure: {err}" + ); + assert!( + !SYNTH_CALLED.load(Ordering::SeqCst), + "synthesiser must NOT fire in cloud mode" + ); + } } From a0bec7ab69ff3374e1aafa76a1da1a391cd92e59 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 1 Jul 2026 09:55:51 -0700 Subject: [PATCH 12/71] Add copy_tree helper; promote tempfile + toml_edit to CLI runtime deps --- Cargo.lock | 1 + crates/edgezero-cli/Cargo.toml | 5 +-- crates/edgezero-cli/src/copy_tree.rs | 64 ++++++++++++++++++++++++++++ crates/edgezero-cli/src/lib.rs | 5 +++ 4 files changed, 72 insertions(+), 3 deletions(-) create mode 100644 crates/edgezero-cli/src/copy_tree.rs diff --git a/Cargo.lock b/Cargo.lock index dcbb7529..82194bd3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -783,6 +783,7 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "toml", + "toml_edit", "validator", "walkdir", ] diff --git a/crates/edgezero-cli/Cargo.toml b/crates/edgezero-cli/Cargo.toml index 97967055..9e96a0c4 100644 --- a/crates/edgezero-cli/Cargo.toml +++ b/crates/edgezero-cli/Cargo.toml @@ -34,8 +34,10 @@ serde = { workspace = true } similar = { workspace = true } simple_logger = { workspace = true } serde_json = { workspace = true} +tempfile = { workspace = true } thiserror = { workspace = true } toml = { workspace = true } +toml_edit = { workspace = true } validator = { workspace = true } proc-macro2 = { workspace = true, features = ["span-locations"], optional = true } syn = { workspace = true, optional = true } @@ -44,9 +46,6 @@ walkdir = { workspace = true, optional = true } [build-dependencies] toml = { workspace = true } -[dev-dependencies] -tempfile = { workspace = true } - [features] default = [ "cli", diff --git a/crates/edgezero-cli/src/copy_tree.rs b/crates/edgezero-cli/src/copy_tree.rs new file mode 100644 index 00000000..29d9fc4b --- /dev/null +++ b/crates/edgezero-cli/src/copy_tree.rs @@ -0,0 +1,64 @@ +//! Small internal recursive directory copy used by `provision +//! --local --dry-run` to stage mutable adapter paths. No new +//! workspace dep — built on `std::fs` only. Preserves regular +//! files and re-creates directories; symlinks and special files +//! are out of scope per spec §"Dry-run". + +use std::fs; +use std::io; +use std::path::Path; + +pub(crate) fn copy_dir_recursive(src: &Path, dst: &Path) -> io::Result<()> { + fs::create_dir_all(dst)?; + for read_result in fs::read_dir(src)? { + let entry = read_result?; + let file_type = entry.file_type()?; + let src_path = entry.path(); + let dst_path = dst.join(entry.file_name()); + if file_type.is_dir() { + copy_dir_recursive(&src_path, &dst_path)?; + } else if file_type.is_symlink() { + // Symlinks intentionally skipped per spec §"Dry-run". + } else { + // Regular files (and any other non-dir, non-symlink entry) + // get copied. Special files won't appear in normal adapter + // source trees; if one does, `fs::copy` will surface its + // own error rather than silently drop it. + fs::copy(&src_path, &dst_path)?; + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + #[test] + fn copies_nested_files_and_dirs() { + let src = TempDir::new().unwrap(); + fs::create_dir_all(src.path().join("a/b")).unwrap(); + fs::write(src.path().join("a/top.toml"), "x = 1").unwrap(); + fs::write(src.path().join("a/b/nested.toml"), "y = 2").unwrap(); + + let dst = TempDir::new().unwrap(); + copy_dir_recursive(src.path(), dst.path()).unwrap(); + + assert_eq!( + fs::read_to_string(dst.path().join("a/top.toml")).unwrap(), + "x = 1" + ); + assert_eq!( + fs::read_to_string(dst.path().join("a/b/nested.toml")).unwrap(), + "y = 2" + ); + } + + #[test] + fn missing_src_returns_error() { + let dst = TempDir::new().unwrap(); + assert!(copy_dir_recursive(Path::new("/nonexistent"), dst.path()).is_err()); + } +} diff --git a/crates/edgezero-cli/src/lib.rs b/crates/edgezero-cli/src/lib.rs index c1ae0e7d..c13be57f 100644 --- a/crates/edgezero-cli/src/lib.rs +++ b/crates/edgezero-cli/src/lib.rs @@ -25,6 +25,11 @@ mod adapter; mod auth; #[cfg(feature = "cli")] mod config; +// Gated on `test` for now: the only callers today live in the module's +// own test suite. Task 10's `run_with_staging` will drop the gate when +// it adds the first production caller. +#[cfg(all(test, feature = "cli"))] +mod copy_tree; #[cfg(all(feature = "cli", feature = "demo-example"))] mod demo_server; #[cfg(feature = "cli")] From ba3f891202decacd19a1b8c4b13904d5f0cb64c7 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 1 Jul 2026 12:51:41 -0700 Subject: [PATCH 13/71] Move config push --local containment guard above run_shared_checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/edgezero-cli/src/config.rs | 100 ++++++++++++++++++++++----- crates/edgezero-cli/src/copy_tree.rs | 47 +++++++++++-- 2 files changed, 121 insertions(+), 26 deletions(-) diff --git a/crates/edgezero-cli/src/config.rs b/crates/edgezero-cli/src/config.rs index 0b3a7839..461d8c10 100644 --- a/crates/edgezero-cli/src/config.rs +++ b/crates/edgezero-cli/src/config.rs @@ -129,10 +129,6 @@ impl ValidationContext { self.manifest_loader.manifest() } - #[expect( - dead_code, - reason = "pub(crate) API surface for the provision flow; not yet called anywhere in this crate" - )] pub(crate) fn manifest_path(&self) -> &Path { &self.manifest_path } @@ -314,6 +310,33 @@ where { // Pre-flight: load + validate. let ctx = load_push_context(args)?; + + // Path containment: reject `..` traversal and absolute paths in + // every declared adapter's manifest / crate strings BEFORE + // `run_shared_checks` dispatches per-adapter validation. Spin's + // `validate_adapter_manifest` does `fs::read_to_string(manifest_root + // .join(adapter_manifest_path))`, so a malicious path resolves + // outside the project unless we reject it here first. The spec + // ("Path containment (MUST)") requires the helper run BEFORE any + // manifest-path use — that means before shared checks, not just + // before adapter dispatch. Loop over every adapter because shared + // checks iterate every adapter, not just `ctx.adapter`. + if args.local { + let manifest_root_for_check = ctx + .validation + .manifest_path() + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + for adapter_cfg in ctx.validation.manifest().adapters.values() { + assert_provision_paths_contained( + manifest_root_for_check, + adapter_cfg.adapter.manifest.as_deref(), + adapter_cfg.adapter.crate_path.as_deref(), + )?; + } + } + run_shared_checks(&ctx.validation)?; let mut opts = AppConfigLoadOptions::default(); opts.env_overlay = !args.no_env; @@ -337,21 +360,6 @@ where push_ctx: &push_ctx, }; - // Path containment check: reject `..` traversal and absolute paths - // in the manifest-declared adapter paths before any adapter dispatch. - if args.local { - let adapter_crate_path = ctx - .validation - .manifest() - .adapter_entry(ctx.adapter.name()) - .and_then(|(_, cfg)| cfg.adapter.crate_path.clone()); - assert_provision_paths_contained( - manifest_root, - adapter_manifest_path.as_deref(), - adapter_crate_path.as_deref(), - )?; - } - // Build envelope. // Honour --key override (5.4): if the caller supplied an explicit key, // use it; otherwise fall back to the manifest's resolved logical store id. @@ -2859,6 +2867,60 @@ ids = ["default"] ); } + /// Regression: the containment guard MUST fire before + /// `run_shared_checks`, which iterates every declared adapter and + /// calls `validate_adapter_manifest`. Spin's implementation does + /// `fs::read_to_string(manifest_root.join(rel))` — an ordering bug + /// would surface as a Spin "failed to read spin manifest" error + /// rather than the containment error. This test declares the + /// pushed adapter as axum but adds a poisoned `[adapters.spin]` + /// entry with a traversal path, and asserts the error names the + /// containment violation (proving the guard fired first). + #[test] + fn config_push_local_rejects_parent_traversal_in_sibling_spin_adapter() { + let manifest_bad = r#" +[app] +name = "demo-app" + +[adapters.axum.adapter] +crate = "crates/demo-axum" + +[adapters.axum.commands] +build = "echo" +deploy = "echo" +serve = "echo" + +[adapters.spin.adapter] +crate = "crates/demo-spin" +manifest = "../outside/spin.toml" +component = "demo" + +[adapters.spin.commands] +build = "echo" +deploy = "echo" +serve = "echo" + +[stores.config] +ids = ["app_config"] + +[stores.secrets] +ids = ["default"] +"#; + let (_dir, manifest, _) = setup_project(manifest_bad, FIXTURE_APP_CONFIG); + let mut args = push_args(&manifest, "axum"); + args.local = true; + let err = run_config_push_typed::(&args) + .expect_err("sibling adapter with traversal path must be rejected"); + assert!( + err.contains("must not contain `..` traversal"), + "guard must fire before Spin's validate_adapter_manifest reads the poisoned path: {err}" + ); + assert!( + !err.contains("failed to read spin manifest"), + "if this substring appears, the Spin fs::read escaped before the containment guard: {err}" + ); + } + // ------------------------------------------------------------------- // run_config_push_typed — 8.2 consent rules + diff // ------------------------------------------------------------------- diff --git a/crates/edgezero-cli/src/copy_tree.rs b/crates/edgezero-cli/src/copy_tree.rs index 29d9fc4b..e8b4a018 100644 --- a/crates/edgezero-cli/src/copy_tree.rs +++ b/crates/edgezero-cli/src/copy_tree.rs @@ -5,9 +5,24 @@ //! are out of scope per spec §"Dry-run". use std::fs; +use std::fs::FileType; use std::io; use std::path::Path; +/// True only for regular files (not directories, symlinks, fifos, +/// sockets, block/character devices). Regular-files-only IS the +/// spec §"Dry-run" semantic — clippy's warning that callers "often +/// forget `is_file()` excludes symlinks" is inverted for us: we +/// WANT that exclusion. Wrapping at one call site keeps +/// `copy_dir_recursive` free of the suppression. +#[expect( + clippy::filetype_is_file, + reason = "spec §\"Dry-run\": regular-files-only copy semantics — symlink/special-file exclusion is the intent, not a bug" +)] +fn is_regular_file(file_type: FileType) -> bool { + file_type.is_file() +} + pub(crate) fn copy_dir_recursive(src: &Path, dst: &Path) -> io::Result<()> { fs::create_dir_all(dst)?; for read_result in fs::read_dir(src)? { @@ -17,14 +32,13 @@ pub(crate) fn copy_dir_recursive(src: &Path, dst: &Path) -> io::Result<()> { let dst_path = dst.join(entry.file_name()); if file_type.is_dir() { copy_dir_recursive(&src_path, &dst_path)?; - } else if file_type.is_symlink() { - // Symlinks intentionally skipped per spec §"Dry-run". - } else { - // Regular files (and any other non-dir, non-symlink entry) - // get copied. Special files won't appear in normal adapter - // source trees; if one does, `fs::copy` will surface its - // own error rather than silently drop it. + } else if is_regular_file(file_type) { fs::copy(&src_path, &dst_path)?; + } else { + // Symlinks and special files (fifos, sockets, block/char + // devices) are intentionally skipped per spec §"Dry-run" + // — dry-run must not follow symlinks off the staged tree, + // and adapter source trees shouldn't contain special files. } } Ok(()) @@ -61,4 +75,23 @@ mod tests { let dst = TempDir::new().unwrap(); assert!(copy_dir_recursive(Path::new("/nonexistent"), dst.path()).is_err()); } + + #[test] + #[cfg(unix)] + fn skips_symlinks_and_only_copies_regular_files() { + use std::os::unix::fs::symlink; + + let src = TempDir::new().unwrap(); + fs::write(src.path().join("real.toml"), "keep").unwrap(); + symlink(src.path().join("real.toml"), src.path().join("link.toml")).unwrap(); + + let dst = TempDir::new().unwrap(); + copy_dir_recursive(src.path(), dst.path()).unwrap(); + + assert!(dst.path().join("real.toml").exists()); + assert!( + !dst.path().join("link.toml").exists(), + "symlink must not be reproduced under the staged tree" + ); + } } From 1617bb86bfd51462147aab27fee26d52c2573428 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 1 Jul 2026 17:05:53 -0700 Subject: [PATCH 14/71] Make provision_local accept-tests actually exercise their code paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/edgezero-cli/src/provision.rs | 69 +++++++++++++++++++++------- 1 file changed, 53 insertions(+), 16 deletions(-) diff --git a/crates/edgezero-cli/src/provision.rs b/crates/edgezero-cli/src/provision.rs index 3169f84b..5375dac2 100644 --- a/crates/edgezero-cli/src/provision.rs +++ b/crates/edgezero-cli/src/provision.rs @@ -274,8 +274,10 @@ mod tests { use edgezero_adapter::registry::{ register_adapter, Adapter, AdapterAction, ProvisionMode, ProvisionOutcome, }; + use std::env; use std::fs; - use std::path::PathBuf; + use std::io; + use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; use tempfile::TempDir; @@ -315,8 +317,33 @@ serve = "echo" static SYNTH_CALLED: AtomicBool = AtomicBool::new(false); static VALIDATE_SAW_FILE: AtomicBool = AtomicBool::new(false); + /// RAII guard: on `set`, chdir into `new_cwd`; on drop, restore + /// the previous cwd. Callers MUST hold `manifest_guard()` while + /// this is live — process cwd is global state and can only be + /// mutated safely under that serialisation lock. + struct CwdGuard(PathBuf); + struct FakeBootstrapAdapter; + impl CwdGuard { + fn set(new_cwd: &Path) -> io::Result { + let prev = env::current_dir()?; + env::set_current_dir(new_cwd)?; + Ok(Self(prev)) + } + } + + impl Drop for CwdGuard { + fn drop(&mut self) { + // Best-effort cwd restore during unwind or normal drop. + // A failure here is unrecoverable at the drop site; the + // manifest_guard() lock the caller holds is released + // regardless, so the next test acquiring it will + // set_current_dir explicitly if it needs to. + drop(env::set_current_dir(&self.0)); + } + } + #[expect( clippy::missing_trait_methods, reason = "the fake only exercises name/provision/synthesise_baseline_manifest/validate_adapter_manifest; every other trait method inherits its default (no-op or Unsupported)" @@ -928,16 +955,16 @@ ids = ["default"] #[test] fn provision_local_accepts_relative_manifest_root_default() { // Bare `--manifest edgezero.toml` — `args.manifest.parent()` - // returns "". Path-safety must not reject the well-formed - // adapter paths in this fixture; the axum adapter itself - // then errors from Section 5 because local mode isn't wired - // yet, but that error is NOT a path-safety error. + // returns "", triggering the `.unwrap_or_else(|| Path::new("."))` + // fallback. To reach that fallback we must actually load + // edgezero.toml relative to cwd, so chdir into a tempdir + // that holds a valid fixture. The `_cwd` RAII guard restores + // the previous cwd on drop; `_lock` serialises all cwd + env + // manipulation. let _lock = manifest_guard().lock().expect("manifest guard"); let temp = TempDir::new().expect("temp dir"); - let manifest_path = temp.path().join("edgezero.toml"); - fs::write(&manifest_path, PROVISION_MANIFEST).expect("write manifest"); - let manifest_str = manifest_path.to_string_lossy().into_owned(); - let _env = EnvOverride::set("EDGEZERO_MANIFEST", &manifest_str); + fs::write(temp.path().join("edgezero.toml"), PROVISION_MANIFEST).expect("write manifest"); + let _cwd = CwdGuard::set(temp.path()).expect("chdir into tempdir"); let err = run_provision(&ProvisionArgs { adapter: "axum".to_owned(), @@ -945,7 +972,15 @@ ids = ["default"] local: true, manifest: PathBuf::from("edgezero.toml"), }) - .expect_err("local dispatch reaches adapter (Section 5 stub errors)"); + .expect_err("must reach the (true, true) dispatch stub"); + // Positive assertion: the (true, true) arm's stub error + // proves the manifest loaded AND path-safety passed. Without + // this, a manifest-load failure would silently satisfy the + // negative assertions below and give false-positive coverage. + assert!( + err.contains("local dry-run staging lands in Task 10/11"), + "must reach dispatch matrix, not fail on manifest load: {err}" + ); assert!( !err.contains("must not contain `..` traversal") && !err.contains("must be a project-relative path") @@ -956,15 +991,13 @@ ids = ["default"] #[test] fn provision_local_accepts_relative_manifest_root_nested() { - // Nested `--manifest /edgezero.toml` — parent is - // the tempdir path. Same shape as above: path-safety passes, - // the axum adapter errors from its Section 5 stub. + // Nested `--manifest /edgezero.toml` — parent is the + // tempdir path (non-empty), exercising the standard + // `args.manifest.parent()` code path. let _lock = manifest_guard().lock().expect("manifest guard"); let temp = TempDir::new().expect("temp dir"); let manifest_path = temp.path().join("edgezero.toml"); fs::write(&manifest_path, PROVISION_MANIFEST).expect("write manifest"); - let manifest_str = manifest_path.to_string_lossy().into_owned(); - let _env = EnvOverride::set("EDGEZERO_MANIFEST", &manifest_str); let err = run_provision(&ProvisionArgs { adapter: "axum".to_owned(), @@ -972,7 +1005,11 @@ ids = ["default"] local: true, manifest: manifest_path.clone(), }) - .expect_err("local dispatch reaches adapter (Section 5 stub errors)"); + .expect_err("must reach the (true, true) dispatch stub"); + assert!( + err.contains("local dry-run staging lands in Task 10/11"), + "must reach dispatch matrix, not fail on manifest load: {err}" + ); assert!( !err.contains("must not contain `..` traversal") && !err.contains("must be a project-relative path") From 12d7129c7c288052a05ce414f816334efccd8f40 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 1 Jul 2026 17:44:46 -0700 Subject: [PATCH 15/71] Add run_with_staging tempdir helper for dry-run staging --- crates/edgezero-cli/src/provision.rs | 134 +++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) diff --git a/crates/edgezero-cli/src/provision.rs b/crates/edgezero-cli/src/provision.rs index 5375dac2..09f76340 100644 --- a/crates/edgezero-cli/src/provision.rs +++ b/crates/edgezero-cli/src/provision.rs @@ -266,6 +266,80 @@ fn validate_and_dispatch( ) } +/// Stage a real recursive copy of the adapter crate dir AND the +/// `.edgezero/` dir (if present) under a fresh `TempDir`, then invoke +/// `body` with the staged paths. The original project worktree is +/// never mutated. Caller is responsible for diffing the staged tree +/// against the project tree before the returned `TempDir` drops. See +/// spec §"Dry-run". +/// +/// Gated on `#[cfg(test)]` for now: the only callers are the +/// same-file tests. Task 11 lifts this gate (and `lib.rs`'s +/// `mod copy_tree;` gate) together when the `(true, true)` dispatch +/// arm gains a real caller. +#[cfg(test)] +pub(crate) fn run_with_staging( + project_root: &Path, + adapter_crate_rel: &Path, + body: F, +) -> Result<(R, tempfile::TempDir), String> +where + F: FnOnce(&Path, &Path) -> Result, +{ + use crate::copy_tree::copy_dir_recursive; + + let tempdir = tempfile::TempDir::new() + .map_err(|err| format!("failed to create staging tempdir: {err}"))?; + let staged_root = tempdir.path(); + + // Copy `edgezero.toml` (read-only input). Symlinking would be + // tempting as an optimisation, but for the default + // `--manifest edgezero.toml` shape `project_root` is "." and + // `project_root.join("edgezero.toml")` is `./edgezero.toml`. + // Unix `symlink(src, dst)` interprets a relative `src` as + // relative to `dst`'s parent — so + // `staged_root/edgezero.toml -> ./edgezero.toml` resolves back + // to `staged_root/edgezero.toml` itself, a broken self-loop. + // Copying is small and correct. + let edgezero_toml = project_root.join("edgezero.toml"); + if edgezero_toml.exists() { + let staged_edgezero = staged_root.join("edgezero.toml"); + if let Some(parent) = staged_edgezero.parent() { + fs::create_dir_all(parent) + .map_err(|err| format!("failed to create staged parent dir: {err}"))?; + } + fs::copy(&edgezero_toml, &staged_edgezero) + .map_err(|err| format!("failed to stage edgezero.toml: {err}"))?; + } + + // Real-copy the adapter crate dir (mutable). `adapter_crate_rel` + // is project-relative (e.g. "crates/cf" or "."), so no + // `strip_prefix` is needed — the earlier draft that computed + // `crate_rel` via `strip_prefix(project_root)` silently failed for + // the default `project_root == "."` shape. + let src_crate = project_root.join(adapter_crate_rel); + let staged_crate = staged_root.join(adapter_crate_rel); + copy_dir_recursive(&src_crate, &staged_crate) + .map_err(|err| format!("failed to stage adapter crate dir: {err}"))?; + + // Real-copy `.edgezero/` if present; otherwise create empty. Some + // adapters own `.edgezero/local-config-*.json` state files (axum); + // staging must preserve them, and their absence in a green-clone + // case must still yield a mountable dir. + let dot_edgezero = project_root.join(".edgezero"); + let staged_dot = staged_root.join(".edgezero"); + if dot_edgezero.exists() { + copy_dir_recursive(&dot_edgezero, &staged_dot) + .map_err(|err| format!("failed to stage .edgezero/: {err}"))?; + } else { + fs::create_dir_all(&staged_dot) + .map_err(|err| format!("failed to create staged .edgezero/: {err}"))?; + } + + let result = body(staged_root, &staged_crate)?; + Ok((result, tempdir)) +} + #[cfg(test)] mod tests { use super::*; @@ -1133,4 +1207,64 @@ ids = ["default"] "synthesiser must NOT fire in cloud mode" ); } + + // ---------- run_with_staging dry-run helper ---------- + + #[test] + fn run_with_staging_drops_tempdir_after_body() { + let project = tempfile::TempDir::new().unwrap(); + fs::write(project.path().join("edgezero.toml"), "x").unwrap(); + let adapter_crate_rel = Path::new("crates/sample"); + let adapter_crate_abs = project.path().join(adapter_crate_rel); + fs::create_dir_all(&adapter_crate_abs).unwrap(); + fs::write(adapter_crate_abs.join("manifest.toml"), "y").unwrap(); + + let staged_paths = run_with_staging( + project.path(), + adapter_crate_rel, + |staged_root, staged_crate| Ok((staged_root.to_path_buf(), staged_crate.to_path_buf())), + ) + .unwrap(); + let (staged_root, staged_crate) = staged_paths.0; + // After staging the original project tree is byte-identical: + assert_eq!( + fs::read_to_string(project.path().join("edgezero.toml")).unwrap(), + "x" + ); + // Staged copies existed during body execution: + assert!(staged_root.is_absolute()); + assert!(staged_crate.starts_with(&staged_root)); + } + + #[test] + fn run_with_staging_copies_edgezero_toml_into_staged_root() { + // Regression for the relative-source-symlink bug AND the + // strip_prefix bug (fixed by switching to project-RELATIVE + // crate paths). Reads staged_root/edgezero.toml INSIDE the + // closure and asserts the bytes match. Uses an ABSOLUTE + // project_root to avoid mutating process cwd — the + // strip_prefix bug is not about relative project_root + // resolution itself, it's about the staging helper computing + // crate_rel incorrectly. + let project = tempfile::TempDir::new().unwrap(); + fs::write(project.path().join("edgezero.toml"), "real-project-bytes\n").unwrap(); + let adapter_crate_rel = Path::new("crates/sample"); + fs::create_dir_all(project.path().join(adapter_crate_rel)).unwrap(); + fs::write( + project.path().join(adapter_crate_rel).join("manifest.toml"), + "x", + ) + .unwrap(); + + let observed = run_with_staging( + project.path(), + adapter_crate_rel, + |staged_root, _staged_crate| { + fs::read_to_string(staged_root.join("edgezero.toml")) + .map_err(|err| format!("read staged edgezero.toml: {err}")) + }, + ) + .unwrap(); + assert_eq!(observed.0, "real-project-bytes\n"); + } } From 42c198e2506ca55d61989d72ad595654f63f2166 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 1 Jul 2026 18:02:26 -0700 Subject: [PATCH 16/71] Wire mode x dry-run dispatch matrix into run_provision --- crates/edgezero-cli/src/lib.rs | 5 +- crates/edgezero-cli/src/provision.rs | 315 +++++++++++++++++++++++++-- 2 files changed, 295 insertions(+), 25 deletions(-) diff --git a/crates/edgezero-cli/src/lib.rs b/crates/edgezero-cli/src/lib.rs index c13be57f..6608eabe 100644 --- a/crates/edgezero-cli/src/lib.rs +++ b/crates/edgezero-cli/src/lib.rs @@ -25,10 +25,7 @@ mod adapter; mod auth; #[cfg(feature = "cli")] mod config; -// Gated on `test` for now: the only callers today live in the module's -// own test suite. Task 10's `run_with_staging` will drop the gate when -// it adds the first production caller. -#[cfg(all(test, feature = "cli"))] +#[cfg(feature = "cli")] mod copy_tree; #[cfg(all(feature = "cli", feature = "demo-example"))] mod demo_server; diff --git a/crates/edgezero-cli/src/provision.rs b/crates/edgezero-cli/src/provision.rs index 09f76340..6d083a1f 100644 --- a/crates/edgezero-cli/src/provision.rs +++ b/crates/edgezero-cli/src/provision.rs @@ -15,6 +15,7 @@ use crate::args::ProvisionArgs; use crate::config::{ enforce_single_store_capability, reject_merged_id_collisions, strict_handler_paths, }; +use crate::copy_tree::copy_dir_recursive; use crate::ensure_adapter_defined; use crate::path_safety::assert_provision_paths_contained; use edgezero_adapter::registry::{self as adapter_registry, ProvisionStores, ResolvedStoreId}; @@ -22,6 +23,27 @@ use edgezero_adapter::AdapterDeployedState; use edgezero_core::env_config::EnvConfig; use edgezero_core::manifest::{Manifest, ManifestAdapter, ManifestLoader, StoreDeclaration}; +/// Owned counterpart to the borrowed `ProvisionStores<'_>`. Used by +/// dispatch arms that need to build resolved store ids per-root +/// (e.g. inside the `run_with_staging` closure where a borrowed +/// return would dangle when the `Vec` locals dropped). Task 29 +/// (typed provision) consumes this too. +pub(crate) struct OwnedProvisionStores { + pub config: Vec, + pub kv: Vec, + pub secrets: Vec, +} + +impl OwnedProvisionStores { + pub(crate) fn as_refs(&self) -> ProvisionStores<'_> { + ProvisionStores { + config: &self.config, + kv: &self.kv, + secrets: &self.secrets, + } + } +} + /// # Errors /// /// Returns an error string if the manifest can't be loaded, the @@ -155,10 +177,15 @@ pub fn run_provision(args: &ProvisionArgs) -> Result<(), String> { false, )? } - (true, true) => { - // Local dry-run: staging harness lands in Task 10/11. - return Err("local dry-run staging lands in Task 10/11".to_owned()); - } + (true, true) => run_local_dry_run( + adapter, + manifest, + adapter_cfg, + manifest_root, + args, + &app_name, + deployed.as_ref(), + )?, }; if args.dry_run { @@ -266,6 +293,91 @@ fn validate_and_dispatch( ) } +/// Same store-construction pattern `validate_and_dispatch` runs +/// inline, but returns the owned form so the caller can hold it +/// across a closure and `.as_refs()` immediately before dispatch. +/// Used by the `(true, true)` arm today; Task 29 (typed provision) +/// consumes it too. +/// +/// The `_root` parameter is unused today — reserved for future +/// per-root state (e.g. reading a synthesised sidecar file). +/// +/// `adapter` is `&'static dyn` to match `validate_and_dispatch` and +/// `reject_merged_id_collisions`, both of which take `'static` +/// trait objects because the registry only hands out `&'static` +/// references. +fn build_stores_against( + _root: &Path, + args: &ProvisionArgs, + adapter: &'static dyn adapter_registry::Adapter, + manifest: &Manifest, +) -> Result { + let env_config = EnvConfig::from_env(); + reject_merged_id_collisions(&args.adapter, adapter, manifest, &env_config)?; + Ok(OwnedProvisionStores { + config: resolve_kind(manifest.stores.config.as_ref(), &env_config, "config"), + kv: resolve_kind(manifest.stores.kv.as_ref(), &env_config, "kv"), + secrets: resolve_kind(manifest.stores.secrets.as_ref(), &env_config, "secrets"), + }) +} + +/// The `(true, true)` dispatch arm: synthesise the baseline manifest +/// (bytes only — no I/O against the real worktree), then stage the +/// adapter crate + `.edgezero/` + `edgezero.toml` into a tempdir and +/// run validate + build stores + dispatch entirely against the staged +/// root. The real worktree is never mutated. +/// +/// The synthesiser runs against the REAL `manifest_root` because it +/// produces bytes only; every subsequent filesystem-touching call +/// (`write_baseline_to_disk`, `validate_adapter_manifest`, +/// `build_stores_against`, `adapter.provision`) receives +/// `staged_root` from inside the `run_with_staging` closure. +fn run_local_dry_run( + adapter: &'static dyn adapter_registry::Adapter, + manifest: &Manifest, + adapter_cfg: &ManifestAdapter, + manifest_root: &Path, + args: &ProvisionArgs, + app_name: &str, + deployed: Option<&AdapterDeployedState>, +) -> Result { + let baseline_pairs = adapter.synthesise_baseline_manifest( + manifest_root, + adapter_cfg.adapter.manifest.as_deref(), + adapter_cfg.adapter.component.as_deref(), + app_name, + deployed, + )?; + let adapter_crate_rel = adapter_cfg + .adapter + .crate_path + .as_deref() + .map_or_else(|| Path::new("."), Path::new); + let (outcome, _tempdir) = run_with_staging( + manifest_root, + adapter_crate_rel, + |staged_root, _staged_crate| { + write_baseline_to_disk(staged_root, &baseline_pairs)?; + adapter.validate_adapter_manifest( + staged_root, + adapter_cfg.adapter.manifest.as_deref(), + adapter_cfg.adapter.component.as_deref(), + )?; + let owned_stores = build_stores_against(staged_root, args, adapter, manifest)?; + adapter.provision( + staged_root, + adapter_cfg.adapter.manifest.as_deref(), + adapter_cfg.adapter.component.as_deref(), + &owned_stores.as_refs(), + deployed, + adapter_registry::ProvisionMode::Local, + true, + ) + }, + )?; + Ok(outcome) +} + /// Stage a real recursive copy of the adapter crate dir AND the /// `.edgezero/` dir (if present) under a fresh `TempDir`, then invoke /// `body` with the staged paths. The original project worktree is @@ -273,11 +385,9 @@ fn validate_and_dispatch( /// against the project tree before the returned `TempDir` drops. See /// spec §"Dry-run". /// -/// Gated on `#[cfg(test)]` for now: the only callers are the -/// same-file tests. Task 11 lifts this gate (and `lib.rs`'s -/// `mod copy_tree;` gate) together when the `(true, true)` dispatch -/// arm gains a real caller. -#[cfg(test)] +/// Called by the `(true, true)` arm of the `run_provision` dispatch +/// matrix — local dry-run stages everything into a tempdir and +/// discards it after `body` completes. pub(crate) fn run_with_staging( project_root: &Path, adapter_crate_rel: &Path, @@ -286,8 +396,6 @@ pub(crate) fn run_with_staging( where F: FnOnce(&Path, &Path) -> Result, { - use crate::copy_tree::copy_dir_recursive; - let tempdir = tempfile::TempDir::new() .map_err(|err| format!("failed to create staging tempdir: {err}"))?; let staged_root = tempdir.path(); @@ -388,6 +496,7 @@ serve = "echo" "#; static FAKE_ADAPTER: FakeBootstrapAdapter = FakeBootstrapAdapter; + static RECORDED_DRY_RUN: AtomicBool = AtomicBool::new(false); static SYNTH_CALLED: AtomicBool = AtomicBool::new(false); static VALIDATE_SAW_FILE: AtomicBool = AtomicBool::new(false); @@ -439,8 +548,9 @@ serve = "echo" _stores: &ProvisionStores<'_>, _deployed: Option<&AdapterDeployedState>, _mode: ProvisionMode, - _dry_run: bool, + dry_run: bool, ) -> Result { + RECORDED_DRY_RUN.store(dry_run, Ordering::SeqCst); Ok(ProvisionOutcome::default()) } @@ -480,10 +590,44 @@ serve = "echo" } fn reset_fake_state() { + RECORDED_DRY_RUN.store(false, Ordering::SeqCst); SYNTH_CALLED.store(false, Ordering::SeqCst); VALIDATE_SAW_FILE.store(false, Ordering::SeqCst); } + /// Walks the tree at `root` and returns a sorted `Vec<(relative + /// path, content bytes)>`. Two calls yield equal `Vec`s iff the + /// tree is byte-identical. Used by the dry-run cleanliness + /// assertion — any staging leak that writes into the worktree + /// flips one of the pairs. + fn snapshot_dir(root: &Path) -> Vec<(PathBuf, Vec)> { + let mut out = Vec::new(); + snapshot_walk(root, root, &mut out).expect("snapshot walk"); + out.sort_by(|left, right| left.0.cmp(&right.0)); + out + } + + fn snapshot_walk(base: &Path, dir: &Path, out: &mut Vec<(PathBuf, Vec)>) -> io::Result<()> { + for read_result in fs::read_dir(dir)? { + let entry = read_result?; + let file_type = entry.file_type()?; + let path = entry.path(); + if file_type.is_dir() { + snapshot_walk(base, &path, out)?; + } else if !file_type.is_symlink() { + // Regular files only — symlinks are intentionally + // skipped so the snapshot mirrors `copy_dir_recursive`'s + // regular-files-only semantics. + let rel = path.strip_prefix(base).unwrap_or(&path).to_path_buf(); + let content = fs::read(&path)?; + out.push((rel, content)); + } else { + // Symlink — skip. + } + } + Ok(()) + } + #[test] fn run_provision_axum_prints_local_only_notes_for_each_store() { let _lock = manifest_guard().lock().expect("manifest guard"); @@ -1038,6 +1182,12 @@ ids = ["default"] let _lock = manifest_guard().lock().expect("manifest guard"); let temp = TempDir::new().expect("temp dir"); fs::write(temp.path().join("edgezero.toml"), PROVISION_MANIFEST).expect("write manifest"); + // Task 11 wires the (true, true) arm through `run_with_staging`, + // which recursively copies the adapter crate dir into the + // staging tempdir. The fixture must pre-create the crate dir + // referenced by PROVISION_MANIFEST or staging errors before + // dispatch reaches the axum Section-5 stub. + fs::create_dir_all(temp.path().join("crates/demo-axum")).expect("create adapter crate dir"); let _cwd = CwdGuard::set(temp.path()).expect("chdir into tempdir"); let err = run_provision(&ProvisionArgs { @@ -1046,14 +1196,15 @@ ids = ["default"] local: true, manifest: PathBuf::from("edgezero.toml"), }) - .expect_err("must reach the (true, true) dispatch stub"); - // Positive assertion: the (true, true) arm's stub error - // proves the manifest loaded AND path-safety passed. Without - // this, a manifest-load failure would silently satisfy the + .expect_err("axum's Section-5 stub errs from inside the staged dispatch"); + // Positive assertion: reaching axum's Section-5 stub proves + // the manifest loaded, path-safety passed, AND `run_with_staging` + // routed the closure through validate + build_stores + provision. + // Without this, an earlier failure would silently satisfy the // negative assertions below and give false-positive coverage. assert!( - err.contains("local dry-run staging lands in Task 10/11"), - "must reach dispatch matrix, not fail on manifest load: {err}" + err.contains("local mode lands in Section 5"), + "must reach axum's Section-5 stub through staging: {err}" ); assert!( !err.contains("must not contain `..` traversal") @@ -1072,6 +1223,7 @@ ids = ["default"] let temp = TempDir::new().expect("temp dir"); let manifest_path = temp.path().join("edgezero.toml"); fs::write(&manifest_path, PROVISION_MANIFEST).expect("write manifest"); + fs::create_dir_all(temp.path().join("crates/demo-axum")).expect("create adapter crate dir"); let err = run_provision(&ProvisionArgs { adapter: "axum".to_owned(), @@ -1079,10 +1231,10 @@ ids = ["default"] local: true, manifest: manifest_path.clone(), }) - .expect_err("must reach the (true, true) dispatch stub"); + .expect_err("axum's Section-5 stub errs from inside the staged dispatch"); assert!( - err.contains("local dry-run staging lands in Task 10/11"), - "must reach dispatch matrix, not fail on manifest load: {err}" + err.contains("local mode lands in Section 5"), + "must reach axum's Section-5 stub through staging: {err}" ); assert!( !err.contains("must not contain `..` traversal") @@ -1267,4 +1419,125 @@ ids = ["default"] .unwrap(); assert_eq!(observed.0, "real-project-bytes\n"); } + + // ---------- (local, dry-run) dispatch matrix ---------- + + #[test] + fn provision_local_dry_run_leaves_worktree_clean() { + // Snapshot the tempdir contents (relative path → content + // bytes) before the call. Run run_provision with local=true, + // dry_run=true. The axum adapter's Section-5 stub will Err + // — that's fine. The core claim is: after the call, EVERY + // file in the tempdir is byte-identical to its pre-call + // snapshot. Any staging leak (a file written into the + // worktree instead of the tempdir) would flip the assertion. + let _lock = manifest_guard().lock().expect("manifest guard"); + let temp = TempDir::new().unwrap(); + let manifest_path = temp.path().join("edgezero.toml"); + fs::write(&manifest_path, PROVISION_MANIFEST).unwrap(); + // Pre-create the axum adapter crate dir + a canary file so + // the staging copy has real content to work with. This also + // proves the crate dir itself is not clobbered. + let axum_crate = temp.path().join("crates/demo-axum"); + fs::create_dir_all(&axum_crate).unwrap(); + fs::write(axum_crate.join("Cargo.toml"), "# stub").unwrap(); + + let before = snapshot_dir(temp.path()); + + // Ignore the Result — axum's Section-5 stub Errs today; the + // core assertion is that the worktree is unchanged either way. + // Explicit type annotation quiets `let_underscore_untyped` + // and `let_underscore_must_use` — the Result is genuinely + // irrelevant to the assertion below. + let _result: Result<(), String> = run_provision(&ProvisionArgs { + adapter: "axum".to_owned(), + dry_run: true, + local: true, + manifest: manifest_path, + }); + + let after = snapshot_dir(temp.path()); + assert_eq!( + before, after, + "dry-run must leave the worktree byte-identical" + ); + } + + #[test] + fn provision_local_no_dry_run_writes_to_worktree() { + // Non-dry-run local mode DOES write. axum can't demonstrate + // that until Section 5 lands, so use the fake adapter — its + // synthesise_baseline_manifest returns a stub file at the + // configured manifest path. In (true, false) mode, the CLI + // calls write_baseline_to_disk which materialises that file + // into the worktree before validate_adapter_manifest runs. + let _lock = manifest_guard().lock().expect("manifest guard"); + reset_fake_state(); + register_adapter(&FAKE_ADAPTER); + let temp = TempDir::new().unwrap(); + let manifest_path = temp.path().join("edgezero.toml"); + fs::write(&manifest_path, FAKE_MANIFEST_BODY).unwrap(); + // Pre-create the crate dir the fake references so the + // manifest validates. + fs::create_dir_all(temp.path().join("crates/spin")).unwrap(); + let synthesised = temp.path().join("crates/spin/spin.toml"); + assert!( + !synthesised.exists(), + "pre-condition: synthesised file absent" + ); + + run_provision(&ProvisionArgs { + adapter: "__test_bootstrap_fake__".to_owned(), + dry_run: false, + local: true, + manifest: manifest_path, + }) + .expect("(true, false) arm should succeed with the fake adapter"); + + assert!( + synthesised.exists(), + "(true, false) arm must write synthesised baseline into the worktree" + ); + let bytes = fs::read_to_string(&synthesised).expect("read synthesised file"); + assert!( + bytes.contains("# stub"), + "content should match fake's synthesiser output" + ); + } + + #[test] + fn provision_cloud_dry_run_passes_dry_run_true_to_adapter() { + // Cloud dry-run must not synthesise (Task 8b covers that) and + // must pass dry_run=true down to the adapter. Use the fake and + // read back RECORDED_DRY_RUN to confirm the boolean rode + // through the dispatch matrix untouched. + let _lock = manifest_guard().lock().expect("manifest guard"); + reset_fake_state(); + register_adapter(&FAKE_ADAPTER); + let temp = TempDir::new().unwrap(); + let manifest_path = temp.path().join("edgezero.toml"); + fs::write(&manifest_path, FAKE_MANIFEST_BODY).unwrap(); + // Cloud validates the real worktree, so the fake's synthesised + // file must already be present or validate_adapter_manifest + // will Err before dispatch. + fs::create_dir_all(temp.path().join("crates/spin")).unwrap(); + fs::write(temp.path().join("crates/spin/spin.toml"), "# stub\n").unwrap(); + + run_provision(&ProvisionArgs { + adapter: "__test_bootstrap_fake__".to_owned(), + dry_run: true, + local: false, + manifest: manifest_path, + }) + .expect("cloud dry-run should succeed with fake adapter"); + + assert!( + RECORDED_DRY_RUN.load(Ordering::SeqCst), + "adapter.provision must have been called with dry_run = true" + ); + assert!( + !SYNTH_CALLED.load(Ordering::SeqCst), + "cloud must never invoke synthesise_baseline_manifest" + ); + } } From cd87c24d5eddbecb2a5e84906342867590d427f7 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 1 Jul 2026 19:38:05 -0700 Subject: [PATCH 17/71] Add dry-run allow-list diff + would-write status rewriting --- crates/edgezero-cli/src/provision.rs | 341 ++++++++++++++++++++++++++- 1 file changed, 340 insertions(+), 1 deletion(-) diff --git a/crates/edgezero-cli/src/provision.rs b/crates/edgezero-cli/src/provision.rs index 6d083a1f..fdc3a7dd 100644 --- a/crates/edgezero-cli/src/provision.rs +++ b/crates/edgezero-cli/src/provision.rs @@ -11,6 +11,8 @@ use std::fs; use std::path::{Path, PathBuf}; +use similar::{ChangeTag, TextDiff}; + use crate::args::ProvisionArgs; use crate::config::{ enforce_single_store_capability, reject_merged_id_collisions, strict_handler_paths, @@ -44,6 +46,23 @@ impl OwnedProvisionStores { } } +/// Resolved per-adapter allow-list inputs the dry-run driver diffs. +/// Built by the CLI from the resolved adapter manifest path (NOT a +/// static filename) so nested paths like +/// `crates/cf/config/wrangler.toml` resolve correctly. Spec §"Per- +/// adapter local state" defines membership per adapter: +/// - Axum: project-root `.edgezero/.env` only. +/// - Cloudflare: resolved `wrangler.toml` + sibling `.dev.vars`. +/// - Fastly: resolved `fastly.toml`. +/// - Spin: resolved `spin.toml` + sibling `runtime-config.toml` + +/// sibling `.env`. +/// +/// `axum.toml` is NOT in this list (it stays tracked). +pub(crate) struct DryRunAllowList { + /// (`project_path`, `staged_path`) pairs the driver diffs. + pub pairs: Vec<(PathBuf, PathBuf)>, +} + /// # Errors /// /// Returns an error string if the manifest can't be loaded, the @@ -321,6 +340,138 @@ fn build_stores_against( }) } +/// Build the allow-list from the resolved adapter manifest path. +/// `adapter_manifest_abs` is the absolute path the adapter would +/// write to (`staged_root.join(adapter_manifest_rel)`); the helper +/// computes its sibling paths and the corresponding project-tree +/// twins by prefix-swapping `staged_root` → `project_root`. +/// +/// **Case contract:** callers MUST lowercase the adapter name +/// before passing it in. The manifest's canonical spelling (e.g. +/// `Fastly`) does NOT match the match arms. +pub(crate) fn build_dry_run_allow_list( + project_root: &Path, + staged_root: &Path, + adapter_lower: &str, + adapter_manifest_abs: &Path, +) -> DryRunAllowList { + let project_manifest = adapter_manifest_abs.strip_prefix(staged_root).map_or_else( + |_| adapter_manifest_abs.to_path_buf(), + |rel| project_root.join(rel), + ); + let manifest_parent_staged = adapter_manifest_abs + .parent() + .unwrap_or(staged_root) + .to_path_buf(); + let manifest_parent_project = project_manifest + .parent() + .unwrap_or(project_root) + .to_path_buf(); + let mut pairs: Vec<(PathBuf, PathBuf)> = Vec::new(); + match adapter_lower { + "axum" => { + pairs.push(( + project_root.join(".edgezero/.env"), + staged_root.join(".edgezero/.env"), + )); + } + "cloudflare" => { + pairs.push((project_manifest.clone(), adapter_manifest_abs.to_path_buf())); + pairs.push(( + manifest_parent_project.join(".dev.vars"), + manifest_parent_staged.join(".dev.vars"), + )); + } + "fastly" => { + pairs.push((project_manifest, adapter_manifest_abs.to_path_buf())); + } + "spin" => { + pairs.push((project_manifest.clone(), adapter_manifest_abs.to_path_buf())); + pairs.push(( + manifest_parent_project.join("runtime-config.toml"), + manifest_parent_staged.join("runtime-config.toml"), + )); + pairs.push(( + manifest_parent_project.join(".env"), + manifest_parent_staged.join(".env"), + )); + } + _ => {} + } + DryRunAllowList { pairs } +} + +/// Per-adapter default manifest filename. Fallback for when +/// `[adapters..adapter].manifest` is unset. Mirrors each +/// adapter crate's default. +pub(crate) fn default_adapter_manifest_for(adapter_lower: &str) -> &'static str { + match adapter_lower { + "cloudflare" => "wrangler.toml", + "fastly" => "fastly.toml", + "spin" => "spin.toml", + _ => "", // axum has no per-adapter manifest in the allow-list + } +} + +/// Render the dry-run report: rewritten status lines + per-file +/// unified diff. Status-line rewriting (`wrote X` → `would write X`) +/// uses only the (`project_root`, `staged_root`) prefix swap plus a +/// verb-prefix table. +pub(crate) fn render_dry_run_report( + project_root: &Path, + staged_root: &Path, + allow_list: &DryRunAllowList, + outcome: &adapter_registry::ProvisionOutcome, +) -> String { + let mut out = String::new(); + + // Status lines: rewrite staged-tempdir paths back to project- + // relative AND prefix each verb with "would ". + for line in &outcome.status_lines { + let rewritten = line.replace( + staged_root.to_string_lossy().as_ref(), + project_root.to_string_lossy().as_ref(), + ); + let with_verb = rewritten + .replacen("wrote ", "would write ", 1) + .replacen("created ", "would create ", 1) + .replacen("appended ", "would append ", 1); + out.push_str(&with_verb); + out.push('\n'); + } + + // Per-file diff section: caller-provided pairs already resolved + // (project_path, staged_path). + for (proj_path, staged_path) in &allow_list.pairs { + if !staged_path.exists() { + continue; + } + let new = fs::read_to_string(staged_path).unwrap_or_default(); + let old = fs::read_to_string(proj_path).unwrap_or_default(); + if old == new { + continue; + } + let diff = TextDiff::from_lines(&old, &new); + out.push('\n'); + out.push_str("--- "); + out.push_str(&proj_path.display().to_string()); + out.push('\n'); + out.push_str("+++ "); + out.push_str(&proj_path.display().to_string()); + out.push('\n'); + for change in diff.iter_all_changes() { + let sign = match change.tag() { + ChangeTag::Delete => "-", + ChangeTag::Insert => "+", + ChangeTag::Equal => " ", + }; + out.push_str(sign); + out.push_str(&change.to_string()); + } + } + out +} + /// The `(true, true)` dispatch arm: synthesise the baseline manifest /// (bytes only — no I/O against the real worktree), then stage the /// adapter crate + `.edgezero/` + `edgezero.toml` into a tempdir and @@ -353,7 +504,7 @@ fn run_local_dry_run( .crate_path .as_deref() .map_or_else(|| Path::new("."), Path::new); - let (outcome, _tempdir) = run_with_staging( + let (outcome, tempdir) = run_with_staging( manifest_root, adapter_crate_rel, |staged_root, _staged_crate| { @@ -375,6 +526,28 @@ fn run_local_dry_run( ) }, )?; + + let staged_root = tempdir.path(); + let adapter_lower = args.adapter.to_lowercase(); + let adapter_manifest_rel = adapter_cfg + .adapter + .manifest + .as_deref() + .unwrap_or_else(|| default_adapter_manifest_for(&adapter_lower)); + let adapter_manifest_staged = staged_root.join(adapter_manifest_rel); + let allow_list = build_dry_run_allow_list( + manifest_root, + staged_root, + &adapter_lower, + &adapter_manifest_staged, + ); + let report = render_dry_run_report(manifest_root, staged_root, &allow_list, &outcome); + // Only emit the report if it's non-empty (avoids extraneous blank + // log lines when the adapter status_lines are empty and no + // allow-list file differs). + if !report.is_empty() { + log::info!("{report}"); + } Ok(outcome) } @@ -1505,6 +1678,172 @@ ids = ["default"] ); } + // ---------- dry-run allow-list + report rendering ---------- + + #[test] + fn dry_run_status_lines_use_would_write_verb() { + // Direct fn-under-test: call render_dry_run_report against a + // synthetic ProvisionOutcome whose status_lines exercise all + // three verbs the rewriter handles. + let _lock = manifest_guard().lock().expect("manifest guard"); + reset_fake_state(); + register_adapter(&FAKE_ADAPTER); + let temp = TempDir::new().expect("temp dir"); + let manifest_path = temp.path().join("edgezero.toml"); + fs::write(&manifest_path, FAKE_MANIFEST_BODY).expect("write manifest"); + fs::create_dir_all(temp.path().join("crates/spin")).expect("create crate dir"); + + let outcome = ProvisionOutcome { + status_lines: vec![ + "wrote crates/spin/spin.toml".to_owned(), + "created .edgezero/.env".to_owned(), + "appended crates/spin/.env".to_owned(), + ], + ..ProvisionOutcome::default() + }; + let allow_list = DryRunAllowList { pairs: vec![] }; + let report = render_dry_run_report(temp.path(), temp.path(), &allow_list, &outcome); + assert!( + report.contains("would write crates/spin/spin.toml"), + "verb-rewriting must turn 'wrote' into 'would write': {report}" + ); + assert!( + report.contains("would create .edgezero/.env"), + "verb-rewriting must turn 'created' into 'would create': {report}" + ); + assert!( + report.contains("would append crates/spin/.env"), + "verb-rewriting must turn 'appended' into 'would append': {report}" + ); + // Negative: raw verbs must not survive + assert!(!report.contains("\nwrote "), "raw 'wrote' must be gone"); + assert!(!report.contains("\ncreated "), "raw 'created' must be gone"); + assert!( + !report.contains("\nappended "), + "raw 'appended' must be gone" + ); + } + + #[test] + fn dry_run_diff_covers_all_allowlist_paths() { + // Table-driven: for each adapter, build a fixture where the + // allow-listed files exist in the staged tree, then call + // render_dry_run_report and assert the printed diff section + // mentions each expected path and excludes non-listed paths. + let project = TempDir::new().expect("project"); + let staged = TempDir::new().expect("staged"); + + // Cloudflare fixture: wrangler.toml + sibling .dev.vars + fs::write(staged.path().join("wrangler.toml"), "name = \"cf\"\n").unwrap(); + fs::write(staged.path().join(".dev.vars"), "SECRET=abc\n").unwrap(); + let cf_allow = build_dry_run_allow_list( + project.path(), + staged.path(), + "cloudflare", + &staged.path().join("wrangler.toml"), + ); + let cf_report = render_dry_run_report( + project.path(), + staged.path(), + &cf_allow, + &ProvisionOutcome::default(), + ); + assert!( + cf_report.contains("wrangler.toml"), + "cloudflare must diff wrangler.toml: {cf_report}" + ); + assert!( + cf_report.contains(".dev.vars"), + "cloudflare must diff .dev.vars: {cf_report}" + ); + // Negative: axum-only paths must not appear + assert!( + !cf_report.contains("axum.toml"), + "axum.toml must not appear in cloudflare diff" + ); + + // Axum fixture: .edgezero/.env only + let axum_project = TempDir::new().expect("axum project"); + let axum_staged = TempDir::new().expect("axum staged"); + fs::create_dir_all(axum_staged.path().join(".edgezero")).unwrap(); + fs::write(axum_staged.path().join(".edgezero/.env"), "K=V\n").unwrap(); + let axum_allow = build_dry_run_allow_list( + axum_project.path(), + axum_staged.path(), + "axum", + &axum_staged.path().join("axum.toml"), // adapter manifest not used for axum + ); + let axum_report = render_dry_run_report( + axum_project.path(), + axum_staged.path(), + &axum_allow, + &ProvisionOutcome::default(), + ); + assert!( + axum_report.contains(".edgezero/.env"), + "axum must diff .edgezero/.env: {axum_report}" + ); + // Negative: cloudflare-only paths + assert!( + !axum_report.contains("wrangler.toml"), + "wrangler.toml must not appear in axum diff" + ); + } + + #[test] + fn dry_run_diff_handles_manifest_in_subdir_of_adapter_crate() { + // Fixture: manifest in a SUB-directory of the adapter crate. + // [adapters.cloudflare.adapter] + // crate = "crates/cf" + // manifest = "crates/cf/config/wrangler.toml" + // The static-name allow-list would compute pair location as + // `crates/cf/wrangler.toml` — WRONG. Both sides absent → + // silent no-diff. + let project = TempDir::new().expect("project"); + let staged = TempDir::new().expect("staged"); + fs::create_dir_all(staged.path().join("crates/cf/config")).unwrap(); + fs::write( + staged.path().join("crates/cf/config/wrangler.toml"), + "name = \"cf-nested\"\n", + ) + .unwrap(); + fs::write( + staged.path().join("crates/cf/config/.dev.vars"), + "SECRET=abc\n", + ) + .unwrap(); + + let allow = build_dry_run_allow_list( + project.path(), + staged.path(), + "cloudflare", + &staged.path().join("crates/cf/config/wrangler.toml"), + ); + let report = render_dry_run_report( + project.path(), + staged.path(), + &allow, + &ProvisionOutcome::default(), + ); + + // Positive: nested paths present + assert!( + report.contains("crates/cf/config/wrangler.toml"), + "nested wrangler.toml must appear in the diff: {report}" + ); + assert!( + report.contains("crates/cf/config/.dev.vars"), + "nested .dev.vars (sibling of the resolved manifest) must appear: {report}" + ); + // Negative: the WRONG (static-name) location must NOT appear. + // A regression to the old shape would silently write + // `--- crates/cf/wrangler.toml` here. + assert!( + !report.contains("--- crates/cf/wrangler.toml"), + "diff must not reference the wrong (adapter-crate-relative) location: {report}" + ); + } + #[test] fn provision_cloud_dry_run_passes_dry_run_true_to_adapter() { // Cloud dry-run must not synthesise (Task 8b covers that) and From 66ab4585fd5acf3abbee81dc066ca93e264391fb Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 1 Jul 2026 19:53:05 -0700 Subject: [PATCH 18/71] Add provision_local_dry_run worktree-clean + no-tempdir-leak test (ignored until Section 5) --- crates/edgezero-cli/src/provision.rs | 76 ++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/crates/edgezero-cli/src/provision.rs b/crates/edgezero-cli/src/provision.rs index fdc3a7dd..72ded685 100644 --- a/crates/edgezero-cli/src/provision.rs +++ b/crates/edgezero-cli/src/provision.rs @@ -1879,4 +1879,80 @@ ids = ["default"] "cloud must never invoke synthesise_baseline_manifest" ); } + + // ---------- Section-5 lock-in: dry-run cleanliness against the real + // in-tree fixture, across every adapter. Ignored until Section 5's + // per-adapter local writers land (Tasks 17-28); today the adapters' + // Local-mode `provision` impls return + // `Err("local mode lands in Section 5")` before touching disk, so + // the assertions can't yet drive real behavior. This test defines + // the contract now so the eventual implementation doesn't drift. + // + // Contract A (worktree byte-identical after dry-run) is asserted + // via the existing `snapshot_dir` helper. + // + // Contract B (no tempdir path leakage into stdout) is left as a + // `TODO(section-5)` comment: the CLI uses `log::info!` for status + // lines, but `log::set_logger` is a process-wide one-shot and + // installing a capturing logger here would race the other tests + // that share the crate's default logger initialization. Adding a + // per-thread capture shim would require workspace-scope churn + // that this task explicitly declines. When Section 5 lands, a + // follow-up task can retrofit either a subprocess-based capture + // or a `tracing`-subscriber swap. + #[test] + #[ignore = "re-enable after Section 5 lands per-adapter local provision"] + fn provision_local_dry_run_worktree_clean_and_no_tempdir_paths_in_stdout() { + let _lock = manifest_guard().lock().expect("manifest guard"); + + // Resolve the repo root from the crate's manifest dir: + // `/crates/edgezero-cli` → ``. `CARGO_MANIFEST_DIR` + // is always set for `cargo test`. + let manifest_dir = env::var("CARGO_MANIFEST_DIR") + .map(PathBuf::from) + .expect("CARGO_MANIFEST_DIR must be set under cargo test"); + let repo_root = manifest_dir + .parent() + .and_then(Path::parent) + .expect("resolve repo root from CARGO_MANIFEST_DIR") + .to_path_buf(); + let manifest_path = repo_root.join("examples/app-demo/edgezero.toml"); + let app_demo_root = manifest_path.parent().expect("app-demo dir").to_path_buf(); + assert!( + manifest_path.exists(), + "fixture missing: {}", + manifest_path.display() + ); + + for adapter in ["cloudflare", "fastly", "spin", "axum"] { + let before = snapshot_dir(&app_demo_root); + + // Ignore the Result — today's stub adapters return + // `Err("local mode lands in Section 5")`. Contract A is + // the "was the worktree modified?" claim, and it holds + // regardless of whether the adapter succeeded. Explicit + // type annotation quiets `let_underscore_untyped` / + // `let_underscore_must_use`. + let _result: Result<(), String> = run_provision(&ProvisionArgs { + adapter: (*adapter).to_owned(), + dry_run: true, + local: true, + manifest: manifest_path.clone(), + }); + + let after = snapshot_dir(&app_demo_root); + assert_eq!( + before, after, + "adapter {adapter}: dry-run must leave the worktree byte-identical" + ); + + // TODO(section-5): assert no tempdir path leakage in + // stdout via captured log. The `log::info!` output from + // `render_dry_run_report` should never contain + // `/var/folders/`, `/private/var/folders/`, or `/tmp/` + // — only project-relative paths under the manifest + // root. Capture strategy TBD (see the module comment + // above this test). + } + } } From 2b3e70028b6a16218d588db7804e07ce0daeb72c Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 1 Jul 2026 20:11:03 -0700 Subject: [PATCH 19/71] Add ManifestAdapterDeployed struct + deployed field on ManifestAdapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deployed block is parsed as a shared schema — the manifest parser never branches on the adapter name inside `[adapters..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. --- crates/edgezero-core/src/manifest.rs | 100 +++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/crates/edgezero-core/src/manifest.rs b/crates/edgezero-core/src/manifest.rs index c7746530..8089d7a9 100644 --- a/crates/edgezero-core/src/manifest.rs +++ b/crates/edgezero-core/src/manifest.rs @@ -354,6 +354,13 @@ pub struct ManifestAdapter { #[serde(default)] #[validate(nested)] pub commands: ManifestAdapterCommands, + /// Deploy-time identifiers returned by cloud CLIs and persisted in + /// `edgezero.toml` so teammates' `provision --local` can regenerate + /// adapter manifests with real ids. See spec §"Where durable + /// identifiers live". + #[serde(default)] + #[validate(nested)] + pub deployed: Option, /// 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 @@ -365,6 +372,27 @@ pub struct ManifestAdapter { pub logging: ManifestLoggingConfig, } +/// Deploy-time identifiers returned by cloud CLIs and persisted +/// in `edgezero.toml` so teammates' `provision --local` can +/// regenerate adapter manifests with real ids. See spec +/// §"Where durable identifiers live". +#[derive(Debug, Default, Deserialize, Validate)] +#[serde(deny_unknown_fields)] +pub struct ManifestAdapterDeployed { + /// Primary namespace ids, keyed by logical + /// `[stores.kv]` / `[stores.config]` id (Cloudflare only). + #[serde(default)] + pub kv_namespaces: BTreeMap, + /// Preview-namespace ids, keyed by the SAME logical id. + /// Separate map so a legal store id like `sessions_preview` + /// cannot collide with a sibling-suffix convention. + #[serde(default)] + pub preview_kv_namespaces: BTreeMap, + /// Fastly compute service id returned by `fastly compute deploy`. + #[serde(default)] + pub service_id: Option, +} + #[derive(Debug, Default, Deserialize, Validate)] #[non_exhaustive] #[validate(schema(function = "validate_manifest_adapter_definition"))] @@ -1548,6 +1576,78 @@ manifest = "fastly.toml" assert_eq!(adapter.adapter.manifest.as_deref(), Some("fastly.toml")); } + // The manifest parser treats `[adapters..deployed]` as a + // shared schema — it doesn't branch on the adapter name. These + // tests name the fixture adapter `demo` so their coverage is + // read as "the shared struct captures each field kind" rather + // than "the parser knows about a specific adapter." Section 6 + // is where individual adapters read the field subset they use. + + #[test] + fn adapter_deployed_block_captures_kv_namespace_maps() { + let toml = r#" + [app] + name = "demo" + + [adapters.demo] + [adapters.demo.adapter] + crate = "crates/x" + manifest = "crates/x/manifest.toml" + [adapters.demo.deployed] + kv_namespaces.sessions = "abc123" + preview_kv_namespaces.sessions = "abc123_preview" + "#; + let manifest: Manifest = toml::from_str(toml).unwrap(); + manifest.validate().unwrap(); + let deployed = manifest.adapters["demo"].deployed.as_ref().unwrap(); + assert_eq!(deployed.kv_namespaces["sessions"], "abc123"); + assert_eq!(deployed.preview_kv_namespaces["sessions"], "abc123_preview"); + assert!(deployed.service_id.is_none()); + } + + #[test] + fn adapter_deployed_block_captures_service_id() { + let toml = r#" + [app] + name = "demo" + + [adapters.demo] + [adapters.demo.adapter] + crate = "crates/x" + manifest = "crates/x/manifest.toml" + [adapters.demo.deployed] + service_id = "SVC1" + "#; + let manifest: Manifest = toml::from_str(toml).unwrap(); + manifest.validate().unwrap(); + assert_eq!( + manifest.adapters["demo"] + .deployed + .as_ref() + .unwrap() + .service_id + .as_deref(), + Some("SVC1") + ); + } + + #[test] + fn adapter_deployed_block_rejects_unknown_field() { + let toml = r#" + [app] + name = "demo" + + [adapters.demo] + [adapters.demo.adapter] + crate = "x" + manifest = "x/manifest.toml" + [adapters.demo.deployed] + typo_field = "x" + "#; + let err = toml::from_str::(toml).unwrap_err(); + assert!(err.to_string().contains("unknown field"), "{err}"); + } + // Empty/minimal manifest tests #[test] fn empty_manifest_has_defaults() { From 5531f72be35e9cbc83ef98397ad123e778a870b0 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 1 Jul 2026 20:23:35 -0700 Subject: [PATCH 20/71] Fix local dry-run to dispatch adapters with dry_run = false MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/edgezero-cli/src/provision.rs | 53 +++++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/crates/edgezero-cli/src/provision.rs b/crates/edgezero-cli/src/provision.rs index 72ded685..a639e6b0 100644 --- a/crates/edgezero-cli/src/provision.rs +++ b/crates/edgezero-cli/src/provision.rs @@ -515,6 +515,15 @@ fn run_local_dry_run( adapter_cfg.adapter.component.as_deref(), )?; let owned_stores = build_stores_against(staged_root, args, adapter, manifest)?; + // Spec §"Dry-run" step 3: pass `dry_run = false` to the + // adapter even though `args.dry_run == true`. The tempdir + // IS the dry-run mechanism — the adapter takes its real + // write branch against the staged tree so operators can + // preview the actual files that would land. If we passed + // `true`, adapters would early-return from their dry-run + // branches (cloudflare cli.rs:263, spin cli.rs:223) and + // leave the staged tree empty of the content the diff + // report is supposed to show. adapter.provision( staged_root, adapter_cfg.adapter.manifest.as_deref(), @@ -522,7 +531,7 @@ fn run_local_dry_run( &owned_stores.as_refs(), deployed, adapter_registry::ProvisionMode::Local, - true, + false, ) }, )?; @@ -1880,6 +1889,48 @@ ids = ["default"] ); } + #[test] + fn provision_local_dry_run_passes_dry_run_false_to_adapter() { + // Spec §"Dry-run" step 3: local dry-run stages a tempdir and + // dispatches with `dry_run = false` so adapters take their + // real-write branches against the staged tree. If the CLI + // hardcoded `true` here, adapters would early-return from + // their dry-run branches (cloudflare cli.rs:263, spin + // cli.rs:223) and leave the staged tree empty — the diff + // report would then miss the very files operators want to + // preview. + let _lock = manifest_guard().lock().expect("manifest guard"); + reset_fake_state(); + register_adapter(&FAKE_ADAPTER); + let temp = TempDir::new().unwrap(); + let manifest_path = temp.path().join("edgezero.toml"); + fs::write(&manifest_path, FAKE_MANIFEST_BODY).unwrap(); + // Local dry-run stages into a tempdir, so crates/spin/ must + // exist under the source tree for run_with_staging to copy + // it. The fake's synthesiser writes spin.toml INSIDE the + // staged tree, so we do NOT need to pre-create spin.toml + // here. + fs::create_dir_all(temp.path().join("crates/spin")).unwrap(); + + run_provision(&ProvisionArgs { + adapter: "__test_bootstrap_fake__".to_owned(), + dry_run: true, + local: true, + manifest: manifest_path, + }) + .expect("local dry-run should succeed with fake adapter"); + + assert!( + !RECORDED_DRY_RUN.load(Ordering::SeqCst), + "adapter.provision must be called with dry_run = false: \ + the tempdir IS the dry-run mechanism, not the boolean" + ); + assert!( + SYNTH_CALLED.load(Ordering::SeqCst), + "local dry-run must invoke synthesise_baseline_manifest" + ); + } + // ---------- Section-5 lock-in: dry-run cleanliness against the real // in-tree fixture, across every adapter. Ignored until Section 5's // per-adapter local writers land (Tasks 17-28); today the adapters' From 6033e728d71b45e68bae8f14aa9806bb647ef113 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 1 Jul 2026 20:53:43 -0700 Subject: [PATCH 21/71] Add adapter-owned deployed_fields + Manifest-level cross-check --- crates/edgezero-adapter-cloudflare/src/cli.rs | 4 + crates/edgezero-adapter-fastly/src/cli.rs | 4 + crates/edgezero-adapter/src/registry.rs | 14 +++ crates/edgezero-cli/src/config.rs | 37 ++++++++ crates/edgezero-cli/src/provision.rs | 90 +++++++++++++++++++ crates/edgezero-core/src/manifest.rs | 66 ++++++++++++++ 6 files changed, 215 insertions(+) diff --git a/crates/edgezero-adapter-cloudflare/src/cli.rs b/crates/edgezero-adapter-cloudflare/src/cli.rs index b15bb62c..b3477e2d 100644 --- a/crates/edgezero-adapter-cloudflare/src/cli.rs +++ b/crates/edgezero-adapter-cloudflare/src/cli.rs @@ -136,6 +136,10 @@ struct CloudflareCliAdapter; reason = "cloudflare has no validate_app_config_keys / validate_adapter_manifest / validate_typed_secrets requirements; those three trait defaults are intentionally inherited. `read_config_entry` and `read_config_entry_local` are both overridden below (wrangler kv key get --remote / --local). `single_store_kinds` IS overridden below (returns `&[\"secrets\"]`)." )] impl Adapter for CloudflareCliAdapter { + fn deployed_fields(&self) -> &'static [&'static str] { + &["kv_namespaces", "preview_kv_namespaces"] + } + fn execute(&self, action: AdapterAction, args: &[String]) -> Result<(), String> { match action { // `wrangler` is the native sign-in surface for Cloudflare diff --git a/crates/edgezero-adapter-fastly/src/cli.rs b/crates/edgezero-adapter-fastly/src/cli.rs index aef252fd..33c2fd99 100644 --- a/crates/edgezero-adapter-fastly/src/cli.rs +++ b/crates/edgezero-adapter-fastly/src/cli.rs @@ -169,6 +169,10 @@ enum ConfigStoreLookup { reason = "see the explanatory block comment immediately above; fastly's no-op defaults for the three validate_* hooks are intentional and documented. `read_config_entry` and `read_config_entry_local` are both overridden below. `single_store_kinds` IS overridden below (returns `&[]`)." )] impl Adapter for FastlyCliAdapter { + fn deployed_fields(&self) -> &'static [&'static str] { + &["service_id"] + } + fn execute(&self, action: AdapterAction, args: &[String]) -> Result<(), String> { match action { // `fastly profile {create|delete|list}` is the native diff --git a/crates/edgezero-adapter/src/registry.rs b/crates/edgezero-adapter/src/registry.rs index ae23187c..61f2ddb8 100644 --- a/crates/edgezero-adapter/src/registry.rs +++ b/crates/edgezero-adapter/src/registry.rs @@ -256,6 +256,20 @@ pub enum ReadConfigEntry { /// of `edgezero-core`. Defaults are no-ops; adapters override what /// they actually need. pub trait Adapter: Sync + Send { + /// Names of the `ManifestAdapterDeployed` fields this adapter + /// reads at provision time. Manifest-level cross-check + /// (`validate_deployed_field_ownership` in the CLI) rejects + /// `[adapters..deployed]` blocks whose populated fields + /// aren't in this list — catching operator typos and writeback + /// bugs before they corrupt the deployed state at next provision. + /// + /// Default is `&[]` — adapters that don't persist deployed state + /// (spin, axum today) inherit it. + #[inline] + fn deployed_fields(&self) -> &'static [&'static str] { + &[] + } + /// Execute the requested action with optional adapter-specific args. /// /// `args` is a stringly-typed pass-through for arguments meant diff --git a/crates/edgezero-cli/src/config.rs b/crates/edgezero-cli/src/config.rs index 461d8c10..d0275dfe 100644 --- a/crates/edgezero-cli/src/config.rs +++ b/crates/edgezero-cli/src/config.rs @@ -1273,6 +1273,7 @@ fn resolve_app_config_path( fn run_shared_checks(ctx: &ValidationContext) -> Result<(), String> { run_adapter_shared_checks(ctx)?; + validate_deployed_field_ownership(ctx.manifest())?; if ctx.args_strict { strict_capability_completeness(ctx.manifest())?; strict_handler_paths(ctx.manifest())?; @@ -1280,6 +1281,42 @@ fn run_shared_checks(ctx: &ValidationContext) -> Result<(), String> { Ok(()) } +/// Cross-check: every populated field in a `[adapters..deployed]` +/// block must be owned by the registered adapter for that name. If +/// the adapter isn't registered in this build (feature disabled or +/// typo in the section name), skip the check — `ensure_adapter_defined` +/// surfaces the missing adapter separately. +/// +/// Runs at manifest-shape validation time via `run_shared_checks` +/// so `config validate --strict`, `provision`, `config push --local`, +/// and `config diff` all see the same rejection. +pub(crate) fn validate_deployed_field_ownership(manifest: &Manifest) -> Result<(), String> { + for (name, adapter_cfg) in &manifest.adapters { + let Some(deployed) = adapter_cfg.deployed.as_ref() else { + continue; + }; + let populated = deployed.populated_fields(); + if populated.is_empty() { + continue; + } + let Some(adapter) = adapter_registry::get_adapter(name) else { + // Not registered in this build; skip. Typo-detection + // is `ensure_adapter_defined`'s job. + continue; + }; + let owned = adapter.deployed_fields(); + for field in &populated { + if !owned.contains(field) { + return Err(format!( + "[adapters.{name}.deployed].{field}: field is not owned by the `{name}` adapter (owned fields: [{}])", + owned.join(", ") + )); + } + } + } + Ok(()) +} + // ------------------------------------------------------------------- // Adapter dispatch — defer per-adapter rules to each adapter crate's // `Adapter` trait impl. No `if adapter == "spin"` branches here. diff --git a/crates/edgezero-cli/src/provision.rs b/crates/edgezero-cli/src/provision.rs index a639e6b0..6bc25d8f 100644 --- a/crates/edgezero-cli/src/provision.rs +++ b/crates/edgezero-cli/src/provision.rs @@ -644,6 +644,7 @@ mod tests { use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; use tempfile::TempDir; + use validator::Validate as _; // ----- fixtures for CLI-owned first-run bootstrap synthesis ----- // @@ -678,6 +679,7 @@ serve = "echo" "#; static FAKE_ADAPTER: FakeBootstrapAdapter = FakeBootstrapAdapter; + static NO_FIELDS_FAKE_ADAPTER: NoFieldsFakeAdapter = NoFieldsFakeAdapter; static RECORDED_DRY_RUN: AtomicBool = AtomicBool::new(false); static SYNTH_CALLED: AtomicBool = AtomicBool::new(false); static VALIDATE_SAW_FILE: AtomicBool = AtomicBool::new(false); @@ -690,6 +692,8 @@ serve = "echo" struct FakeBootstrapAdapter; + struct NoFieldsFakeAdapter; + impl CwdGuard { fn set(new_cwd: &Path) -> io::Result { let prev = env::current_dir()?; @@ -714,6 +718,10 @@ serve = "echo" reason = "the fake only exercises name/provision/synthesise_baseline_manifest/validate_adapter_manifest; every other trait method inherits its default (no-op or Unsupported)" )] impl Adapter for FakeBootstrapAdapter { + fn deployed_fields(&self) -> &'static [&'static str] { + &["service_id"] + } + fn execute(&self, _action: AdapterAction, _args: &[String]) -> Result<(), String> { Ok(()) } @@ -771,6 +779,33 @@ serve = "echo" } } + #[expect( + clippy::missing_trait_methods, + reason = "the no-fields fake exercises deployed_fields default; every other trait method inherits its default (no-op or Unsupported)" + )] + impl Adapter for NoFieldsFakeAdapter { + fn execute(&self, _action: AdapterAction, _args: &[String]) -> Result<(), String> { + Ok(()) + } + + fn name(&self) -> &'static str { + "__test_no_fields_fake__" + } + + fn provision( + &self, + _manifest_root: &Path, + _adapter_manifest_path: Option<&str>, + _component_selector: Option<&str>, + _stores: &ProvisionStores<'_>, + _deployed: Option<&AdapterDeployedState>, + _mode: ProvisionMode, + _dry_run: bool, + ) -> Result { + Ok(ProvisionOutcome::default()) + } + } + fn reset_fake_state() { RECORDED_DRY_RUN.store(false, Ordering::SeqCst); SYNTH_CALLED.store(false, Ordering::SeqCst); @@ -2006,4 +2041,59 @@ ids = ["default"] // above this test). } } + + #[test] + fn validate_deployed_field_ownership_accepts_declared_field() { + // Fake registers itself as owning `service_id`. A manifest + // with [adapters.__test_bootstrap_fake__.deployed] service_id + // = "..." must validate cleanly. + use crate::config::validate_deployed_field_ownership; + let _lock = manifest_guard().lock().expect("manifest guard"); + reset_fake_state(); + register_adapter(&FAKE_ADAPTER); + + let toml_body = r#" + [app] + name = "demo" + [adapters.__test_bootstrap_fake__] + [adapters.__test_bootstrap_fake__.adapter] + crate = "crates/spin" + manifest = "crates/spin/spin.toml" + [adapters.__test_bootstrap_fake__.deployed] + service_id = "SVC1" + "#; + let manifest: Manifest = toml::from_str(toml_body).unwrap(); + manifest.validate().unwrap(); + validate_deployed_field_ownership(&manifest) + .expect("fake owns service_id -- must validate cleanly"); + } + + #[test] + fn validate_deployed_field_ownership_rejects_undeclared_field() { + // A different fake that owns NO deployed fields. A manifest + // with service_id under its section must be rejected. + use crate::config::validate_deployed_field_ownership; + let _lock = manifest_guard().lock().expect("manifest guard"); + reset_fake_state(); + register_adapter(&NO_FIELDS_FAKE_ADAPTER); + + let toml_body = r#" + [app] + name = "demo" + [adapters.__test_no_fields_fake__] + [adapters.__test_no_fields_fake__.adapter] + crate = "crates/spin" + manifest = "crates/spin/spin.toml" + [adapters.__test_no_fields_fake__.deployed] + service_id = "SVC1" + "#; + let manifest: Manifest = toml::from_str(toml_body).unwrap(); + manifest.validate().unwrap(); + let err = validate_deployed_field_ownership(&manifest) + .expect_err("adapter declares no fields -- must reject"); + assert!( + err.contains("service_id") && err.contains("__test_no_fields_fake__"), + "error must name the offending field and adapter: {err}" + ); + } } diff --git a/crates/edgezero-core/src/manifest.rs b/crates/edgezero-core/src/manifest.rs index 8089d7a9..6235d764 100644 --- a/crates/edgezero-core/src/manifest.rs +++ b/crates/edgezero-core/src/manifest.rs @@ -393,6 +393,30 @@ pub struct ManifestAdapterDeployed { pub service_id: Option, } +impl ManifestAdapterDeployed { + /// Return the names of fields that are populated (non-empty + /// map, or `Some` value). Used by the CLI to cross-check + /// against `Adapter::deployed_fields` — but the mapping of + /// field-to-adapter lives in the adapter crates, NOT here. + /// Adding a new field to this struct requires adding a matching + /// arm below. + #[inline] + #[must_use] + pub fn populated_fields(&self) -> Vec<&'static str> { + let mut out = Vec::new(); + if !self.kv_namespaces.is_empty() { + out.push("kv_namespaces"); + } + if !self.preview_kv_namespaces.is_empty() { + out.push("preview_kv_namespaces"); + } + if self.service_id.is_some() { + out.push("service_id"); + } + out + } +} + #[derive(Debug, Default, Deserialize, Validate)] #[non_exhaustive] #[validate(schema(function = "validate_manifest_adapter_definition"))] @@ -2143,4 +2167,46 @@ default = "feature__flags" .err() .expect("double-underscore store id must fail validation"); } + + #[test] + fn deployed_populated_fields_reports_service_id_when_set() { + let deployed = ManifestAdapterDeployed { + service_id: Some("SVC1".to_owned()), + ..ManifestAdapterDeployed::default() + }; + assert_eq!(deployed.populated_fields(), vec!["service_id"]); + } + + #[test] + fn deployed_populated_fields_reports_kv_maps_when_non_empty() { + let mut deployed = ManifestAdapterDeployed::default(); + deployed + .kv_namespaces + .insert("sessions".to_owned(), "abc".to_owned()); + assert_eq!(deployed.populated_fields(), vec!["kv_namespaces"]); + } + + #[test] + fn deployed_populated_fields_empty_when_all_defaults() { + let deployed = ManifestAdapterDeployed::default(); + assert!(deployed.populated_fields().is_empty()); + } + + #[test] + fn deployed_populated_fields_reports_all_when_all_set() { + let mut deployed = ManifestAdapterDeployed { + service_id: Some("SVC1".to_owned()), + ..ManifestAdapterDeployed::default() + }; + deployed + .kv_namespaces + .insert("sessions".to_owned(), "abc".to_owned()); + deployed + .preview_kv_namespaces + .insert("sessions".to_owned(), "abc_preview".to_owned()); + assert_eq!( + deployed.populated_fields(), + vec!["kv_namespaces", "preview_kv_namespaces", "service_id"] + ); + } } From af4baa967ceb7e41835fe894b4f64882ff291e3b Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 1 Jul 2026 21:00:02 -0700 Subject: [PATCH 22/71] Task 15 followup: refresh #[expect] reasons + document case-handling 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. --- crates/edgezero-cli/src/config.rs | 7 +++++++ crates/edgezero-cli/src/provision.rs | 4 ++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/crates/edgezero-cli/src/config.rs b/crates/edgezero-cli/src/config.rs index d0275dfe..049369e3 100644 --- a/crates/edgezero-cli/src/config.rs +++ b/crates/edgezero-cli/src/config.rs @@ -1287,6 +1287,13 @@ fn run_shared_checks(ctx: &ValidationContext) -> Result<(), String> { /// typo in the section name), skip the check — `ensure_adapter_defined` /// surfaces the missing adapter separately. /// +/// **Case handling:** `adapter_registry::get_adapter` normalises the +/// lookup key to `to_ascii_lowercase()` at registration and lookup +/// time, so operator spellings like `[adapters.Fastly.deployed]`, +/// `[adapters.FASTLY.deployed]`, and `[adapters.fastly.deployed]` +/// all resolve to the same registered adapter and cross-check +/// against the same `deployed_fields()` list. +/// /// Runs at manifest-shape validation time via `run_shared_checks` /// so `config validate --strict`, `provision`, `config push --local`, /// and `config diff` all see the same rejection. diff --git a/crates/edgezero-cli/src/provision.rs b/crates/edgezero-cli/src/provision.rs index 6bc25d8f..715e61d1 100644 --- a/crates/edgezero-cli/src/provision.rs +++ b/crates/edgezero-cli/src/provision.rs @@ -715,7 +715,7 @@ serve = "echo" #[expect( clippy::missing_trait_methods, - reason = "the fake only exercises name/provision/synthesise_baseline_manifest/validate_adapter_manifest; every other trait method inherits its default (no-op or Unsupported)" + reason = "the fake overrides name/deployed_fields/provision/synthesise_baseline_manifest/validate_adapter_manifest; every other trait method inherits its default (no-op or Unsupported)" )] impl Adapter for FakeBootstrapAdapter { fn deployed_fields(&self) -> &'static [&'static str] { @@ -781,7 +781,7 @@ serve = "echo" #[expect( clippy::missing_trait_methods, - reason = "the no-fields fake exercises deployed_fields default; every other trait method inherits its default (no-op or Unsupported)" + reason = "the no-fields fake overrides execute/name/provision (required by the trait) and inherits every defaulted method — including deployed_fields, whose default `&[]` is the intent this fake exercises" )] impl Adapter for NoFieldsFakeAdapter { fn execute(&self, _action: AdapterAction, _args: &[String]) -> Result<(), String> { From b3466c1e36457203681aef2168db876f0ff561e6 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 1 Jul 2026 21:07:48 -0700 Subject: [PATCH 23/71] Add toml_edit-based [adapters..deployed] writeback --- crates/edgezero-cli/src/provision.rs | 203 ++++++++++++++++++++++++++- 1 file changed, 202 insertions(+), 1 deletion(-) diff --git a/crates/edgezero-cli/src/provision.rs b/crates/edgezero-cli/src/provision.rs index 715e61d1..e943d41e 100644 --- a/crates/edgezero-cli/src/provision.rs +++ b/crates/edgezero-cli/src/provision.rs @@ -12,6 +12,7 @@ use std::fs; use std::path::{Path, PathBuf}; use similar::{ChangeTag, TextDiff}; +use toml_edit::{table, value, DocumentMut}; use crate::args::ProvisionArgs; use crate::config::{ @@ -213,7 +214,17 @@ pub fn run_provision(args: &ProvisionArgs) -> Result<(), String> { for line in outcome.status_lines { log::info!("{line}"); } - // outcome.deployed wiring lands in Task 16. + if let Some(deployed_writeback) = outcome.deployed.as_ref() { + let (canonical_adapter_key, _) = manifest + .adapter_entry(&args.adapter) + .ok_or_else(|| format!("adapter `{}` vanished from manifest", args.adapter))?; + merge_deployed_into_manifest( + &args.manifest, + canonical_adapter_key, + deployed_writeback, + args.dry_run, + )?; + } Ok(()) } @@ -266,6 +277,82 @@ fn deployed_state_for( None } +/// Merge `state` into `[adapters..deployed]` inside +/// `manifest_path`, preserving all sibling content and adjacent +/// operator comments via `toml_edit`. `adapter_name` MUST be the +/// canonical operator-spelled key (result of +/// `manifest.adapter_entry(...)`); passing the raw `args.adapter` +/// risks creating a parallel lowercased `[adapters.cloudflare.deployed]` +/// beside an operator-spelled `[adapters.Cloudflare]` table. +/// +/// `state.fields` become scalar leaves; `state.sub_tables` become +/// nested `[]` sub-tables under `.deployed`. When +/// `dry_run` is true the helper builds the doc in memory then +/// returns without writing — used by callers who want the write +/// gated on the same `--dry-run` semantic as the surrounding +/// dispatch. +pub(crate) fn merge_deployed_into_manifest( + manifest_path: &Path, + adapter_name: &str, + state: &adapter_registry::AdapterDeployedState, + dry_run: bool, +) -> Result<(), String> { + let raw = fs::read_to_string(manifest_path) + .map_err(|err| format!("read {}: {err}", manifest_path.display()))?; + let mut doc: DocumentMut = raw + .parse() + .map_err(|err| format!("parse {}: {err}", manifest_path.display()))?; + + // `entry(...).or_insert_with(table)` avoids the `IndexMut` lint + // (`clippy::indexing_slicing`) that fires on `doc["adapters"]`. + // If a sibling exists but isn't a table, we bail cleanly instead + // of clobbering it — mirrors the fastly adapter's editor pattern. + let adapters_item = doc.entry("adapters").or_insert_with(table); + let adapters_tbl = adapters_item.as_table_mut().ok_or_else(|| { + format!( + "{}: `adapters` exists but is not a table; refusing to edit in place", + manifest_path.display() + ) + })?; + let named_item = adapters_tbl.entry(adapter_name).or_insert_with(table); + let named_tbl = named_item.as_table_mut().ok_or_else(|| { + format!( + "{}: `adapters.{adapter_name}` exists but is not a table; refusing to edit in place", + manifest_path.display() + ) + })?; + let deployed_item = named_tbl.entry("deployed").or_insert_with(table); + let deployed_tbl = deployed_item.as_table_mut().ok_or_else(|| { + format!( + "{}: `adapters.{adapter_name}.deployed` exists but is not a table; refusing to edit in place", + manifest_path.display() + ) + })?; + + for (key, val) in &state.fields { + deployed_tbl.insert(key, value(val.clone())); + } + for (sub_name, sub_map) in &state.sub_tables { + let sub_item = deployed_tbl.entry(sub_name).or_insert_with(table); + let sub_tbl = sub_item.as_table_mut().ok_or_else(|| { + format!( + "{}: `adapters.{adapter_name}.deployed.{sub_name}` exists but is not a table; refusing to edit in place", + manifest_path.display() + ) + })?; + for (key, val) in sub_map { + sub_tbl.insert(key, value(val.clone())); + } + } + + if dry_run { + return Ok(()); + } + fs::write(manifest_path, doc.to_string()) + .map_err(|err| format!("write {}: {err}", manifest_path.display()))?; + Ok(()) +} + /// Shared validate + env-overlay + collision-check + resolve-stores + /// dispatch tail for both cloud and local live-mode arms. Baseline /// synthesis (local only) fires BEFORE this helper — the tail after @@ -638,6 +725,7 @@ mod tests { use edgezero_adapter::registry::{ register_adapter, Adapter, AdapterAction, ProvisionMode, ProvisionOutcome, }; + use std::collections::BTreeMap; use std::env; use std::fs; use std::io; @@ -2096,4 +2184,117 @@ ids = ["default"] "error must name the offending field and adapter: {err}" ); } + + // ---------- merge_deployed_into_manifest ---------- + + #[test] + fn merge_deployed_round_trips_cloudflare_namespaces_with_canonical_key() { + // Fixture declares mixed-case [adapters.Cloudflare]. Merger + // MUST use the canonical operator-spelled key — not a + // lowercased sibling — otherwise a parallel + // [adapters.cloudflare.deployed] table would appear beside + // the operator's [adapters.Cloudflare] one. + let temp = TempDir::new().unwrap(); + let manifest_path = temp.path().join("edgezero.toml"); + fs::write( + &manifest_path, + r#" +[app] +name = "demo" + +[adapters.Cloudflare] +[adapters.Cloudflare.adapter] +crate = "crates/cf" +manifest = "crates/cf/wrangler.toml" +"#, + ) + .unwrap(); + + let mut state = AdapterDeployedState::default(); + let mut kv = BTreeMap::new(); + kv.insert("sessions".to_owned(), "abc123".to_owned()); + state.sub_tables.insert("kv_namespaces".to_owned(), kv); + + // Canonical key is "Cloudflare" (as written in the manifest). + merge_deployed_into_manifest(&manifest_path, "Cloudflare", &state, false).unwrap(); + + let raw = fs::read_to_string(&manifest_path).unwrap(); + // Must land under the operator's spelling; NO lowercased sibling. + assert!( + raw.contains("[adapters.Cloudflare.deployed"), + "must land under operator spelling: {raw}" + ); + assert!( + !raw.contains("[adapters.cloudflare.deployed"), + "must NOT create a lowercased parallel: {raw}" + ); + // Value present. + assert!( + raw.contains("sessions = \"abc123\""), + "kv id must round-trip: {raw}" + ); + } + + #[test] + fn merge_deployed_preserves_adjacent_operator_comments() { + // Non-touched adapter sections must survive byte-for-byte, + // including their comments. + let temp = TempDir::new().unwrap(); + let manifest_path = temp.path().join("edgezero.toml"); + let source = r#" +[app] +name = "demo" + +# operator note about spin ordering +[adapters.spin] +[adapters.spin.adapter] +crate = "crates/spin" +manifest = "crates/spin/spin.toml" + +[adapters.cloudflare] +[adapters.cloudflare.adapter] +crate = "crates/cf" +manifest = "crates/cf/wrangler.toml" +"#; + fs::write(&manifest_path, source).unwrap(); + + let mut state = AdapterDeployedState::default(); + state + .fields + .insert("service_id".to_owned(), "SVC1".to_owned()); + merge_deployed_into_manifest(&manifest_path, "cloudflare", &state, false).unwrap(); + + let raw = fs::read_to_string(&manifest_path).unwrap(); + assert!( + raw.contains("# operator note about spin ordering"), + "operator comment must survive writeback: {raw}" + ); + } + + #[test] + fn merge_deployed_dry_run_does_not_mutate_file() { + let temp = TempDir::new().unwrap(); + let manifest_path = temp.path().join("edgezero.toml"); + let source = r#" +[app] +name = "demo" + +[adapters.cloudflare] +[adapters.cloudflare.adapter] +crate = "crates/cf" +manifest = "crates/cf/wrangler.toml" +"#; + fs::write(&manifest_path, source).unwrap(); + let before = fs::read_to_string(&manifest_path).unwrap(); + + let mut state = AdapterDeployedState::default(); + let mut kv = BTreeMap::new(); + kv.insert("sessions".to_owned(), "abc".to_owned()); + state.sub_tables.insert("kv_namespaces".to_owned(), kv); + + merge_deployed_into_manifest(&manifest_path, "cloudflare", &state, true).unwrap(); + + let after = fs::read_to_string(&manifest_path).unwrap(); + assert_eq!(before, after, "dry-run must leave file byte-identical"); + } } From 277f614960075ca84ec5a933e126abc1ad52ae81 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 1 Jul 2026 21:14:19 -0700 Subject: [PATCH 24/71] Cloudflare: cloud provision returns created namespace ids via deployed --- crates/edgezero-adapter-cloudflare/src/cli.rs | 129 +++++++++++++++++- 1 file changed, 127 insertions(+), 2 deletions(-) diff --git a/crates/edgezero-adapter-cloudflare/src/cli.rs b/crates/edgezero-adapter-cloudflare/src/cli.rs index b3477e2d..f2acd53a 100644 --- a/crates/edgezero-adapter-cloudflare/src/cli.rs +++ b/crates/edgezero-adapter-cloudflare/src/cli.rs @@ -1,4 +1,4 @@ -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; use std::env; use std::fs; use std::io::ErrorKind; @@ -214,6 +214,16 @@ impl Adapter for CloudflareCliAdapter { let wrangler_path = manifest_root.join(rel); let mut out = Vec::new(); + // Track logical -> namespace_id for freshly-created namespaces + // so the CLI's writeback can persist them under + // `[adapters.cloudflare.deployed].kv_namespaces.`. + // Keyed by LOGICAL id so teammates' env overlays (which + // change the platform binding name) still resolve the same + // mapping on their side. Only populated in the non-dry-run + // create branch below -- dry-runs and idempotency skips + // contribute nothing (no real wrangler invocation, no id to + // record). + let mut created_kv_ns: BTreeMap = BTreeMap::new(); for store in stores.kv.iter().chain(stores.config.iter()) { let logical = &store.logical; // The Cloudflare KV binding name is what the runtime @@ -283,6 +293,13 @@ impl Adapter for CloudflareCliAdapter { "created KV namespace `{binding}` (logical id `{logical}`, namespace id={namespace_id}); written to {}", wrangler_path.display() )); + // Record under the LOGICAL id, not the platform binding. + // Teammates' `provision --local` re-resolves logical -> + // platform via THEIR env overlay and reads the namespace + // id back via the same logical key -- keying by + // `binding` (platform) would break that lookup when + // the overlays diverge. + created_kv_ns.insert(logical.clone(), namespace_id); } for store in stores.secrets { let logical = &store.logical; @@ -294,9 +311,26 @@ impl Adapter for CloudflareCliAdapter { if out.is_empty() { out.push("cloudflare has no declared stores to provision".to_owned()); } + // dry_run branch above `continue`s BEFORE reaching + // `create_kv_namespace`, so `created_kv_ns` stays empty for + // dry-runs -- `deployed` collapses to `None` and the CLI + // writeback is a no-op. An idempotent skip (binding already + // present with a real id) similarly doesn't repopulate the + // map, since the existing id is already recorded in the + // operator's `[adapters.cloudflare.deployed]` block from a + // prior run. + let deployed = if created_kv_ns.is_empty() { + None + } else { + let mut state = AdapterDeployedState::default(); + state + .sub_tables + .insert("kv_namespaces".to_owned(), created_kv_ns); + Some(state) + }; Ok(ProvisionOutcome { status_lines: out, - deployed: None, + deployed, }) } @@ -1772,6 +1806,97 @@ id = "00112233445566778899aabbccddeeff" out.status_lines, vec!["cloudflare has no declared stores to provision"] ); + // No wrangler was invoked (no stores) => no id to record. + assert!( + out.deployed.is_none(), + "no-store provision has nothing to write back: {:?}", + out.deployed + ); + } + + #[cfg(unix)] + #[test] + fn cloudflare_cloud_provision_returns_created_namespace_ids() { + // Non-dry-run Cloud provision must populate + // `deployed.sub_tables["kv_namespaces"]` keyed by LOGICAL id + // (not the platform binding name). Task 16's CLI writeback + // then lands them under `[adapters.cloudflare.deployed]`. + // + // Uses the same wrangler-fake shim pattern as the + // read_config_entry tests: a shell script on PATH prints the + // Wrangler-3 `[[kv_namespaces]] / id = "..."` block that + // `extract_namespace_id` parses. + let _lock = path_mutation_guard().lock().expect("guard"); + let project_dir = tempdir().expect("tempdir"); + write_wrangler(project_dir.path(), "name = \"demo\"\n"); + let stdout = "[[kv_namespaces]]\nbinding = \"ignored-by-parser\"\nid = \"00112233445566778899aabbccddeeff\"\n"; + let fake = fake_wrangler_returning(stdout, "", 0); + let _path = PathPrepend::new(fake.path()); + + let kv_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_KV_ID]); + let stores = ProvisionStores { + config: &[], + kv: &kv_ids, + secrets: &[], + }; + let out = CloudflareCliAdapter + .provision( + project_dir.path(), + Some("wrangler.toml"), + None, + &stores, + None, + ProvisionMode::Cloud, + false, + ) + .expect("cloud provision succeeds against fake wrangler"); + let deployed = out + .deployed + .expect("cloud provision with creates populates deployed"); + let kv = deployed + .sub_tables + .get("kv_namespaces") + .expect("deployed carries kv_namespaces sub-table"); + // Key MUST be the LOGICAL id -- teammates' env overlays + // change the platform binding, but the logical id is + // env-overlay-independent. + assert_eq!( + kv.get(TEST_KV_ID).map(String::as_str), + Some("00112233445566778899aabbccddeeff"), + "kv_namespaces keyed by logical id `{TEST_KV_ID}`: {kv:?}" + ); + } + + #[test] + fn cloudflare_cloud_provision_dry_run_returns_none_deployed() { + // Cloud dry-run means no real `wrangler kv namespace create` + // invocation happened -- no real id to record. `deployed` + // must be `None` so the CLI writeback is a no-op. + let dir = tempdir().expect("tempdir"); + write_wrangler(dir.path(), "name = \"demo\"\n"); + let kv_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_KV_ID]); + let config_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_CONFIG_ID]); + let stores = ProvisionStores { + config: &config_ids, + kv: &kv_ids, + secrets: &[], + }; + let out = CloudflareCliAdapter + .provision( + dir.path(), + Some("wrangler.toml"), + None, + &stores, + None, + ProvisionMode::Cloud, + true, + ) + .expect("dry-run succeeds"); + assert!( + out.deployed.is_none(), + "dry-run must not populate deployed (no wrangler ran): {:?}", + out.deployed + ); } // ---------- find_namespace_id ---------- From d7d6b1a80230f94a7805fb821d0d5e9528437d2c Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 1 Jul 2026 21:21:37 -0700 Subject: [PATCH 25/71] Add edgezero_adapter::env_file::append_lines_dedup (used by Section 5) --- crates/edgezero-adapter/src/env_file.rs | 170 ++++++++++++++++++++++++ crates/edgezero-adapter/src/lib.rs | 2 + 2 files changed, 172 insertions(+) create mode 100644 crates/edgezero-adapter/src/env_file.rs diff --git a/crates/edgezero-adapter/src/env_file.rs b/crates/edgezero-adapter/src/env_file.rs new file mode 100644 index 00000000..0acde2d7 --- /dev/null +++ b/crates/edgezero-adapter/src/env_file.rs @@ -0,0 +1,170 @@ +//! Line-oriented env-file dedup shared by all adapters that +//! write provision-owned `.env` / `.dev.vars` files. Key- +//! normalised: a line whose key matches an existing commented +//! OR uncommented entry is skipped. See spec §"Merge mechanics" +//! → "Line-oriented". + +use std::collections::BTreeSet; +use std::fs; +use std::path::Path; + +/// Append each `=` line iff its normalised key does +/// NOT already appear in the file (commented OR uncommented). +/// Existing lines are preserved byte-for-byte. Creates the file +/// (and parent dirs) when absent. +/// +/// # Errors +/// Returns an error string when the file cannot be read, when +/// the parent directory cannot be created, or when the write +/// fails. +#[inline] +pub fn append_lines_dedup(path: &Path, new_lines: &[String], dry_run: bool) -> Result<(), String> { + let mut existing = String::new(); + if path.exists() { + existing = + fs::read_to_string(path).map_err(|err| format!("read {}: {err}", path.display()))?; + } + let existing_keys: BTreeSet = existing.lines().filter_map(normalised_key).collect(); + + let mut to_append = String::new(); + for line in new_lines { + let Some(key) = normalised_key(line) else { + continue; + }; + if existing_keys.contains(&key) { + continue; + } + to_append.push_str(line); + if !line.ends_with('\n') { + to_append.push('\n'); + } + } + if to_append.is_empty() || dry_run { + return Ok(()); + } + if let Some(parent) = path.parent() { + if !parent.as_os_str().is_empty() { + fs::create_dir_all(parent) + .map_err(|err| format!("create {}: {err}", parent.display()))?; + } + } + let mut combined = existing; + if !combined.is_empty() && !combined.ends_with('\n') { + combined.push('\n'); + } + combined.push_str(&to_append); + fs::write(path, combined).map_err(|err| format!("write {}: {err}", path.display()))?; + Ok(()) +} + +/// Strip at most ONE leading `#` + adjacent whitespace, then +/// parse `=` and return the trimmed key. Returns +/// `None` for blank lines and comment-only lines. +/// +/// Single-`#` semantics matter: `## KEY=value` (double hash — +/// the markdown-style heading shape some operators use as +/// section separators inside `.env` files) is NOT treated as +/// a commented `KEY=value` line; it returns `Some("# KEY")` +/// (with the second `#` embedded in the key) so dedup does NOT +/// collapse `## KEY=v` and `KEY=v` into each other. +pub(crate) fn normalised_key(line: &str) -> Option { + let trimmed = line.trim_start(); + // Strip exactly ONE leading `#`, then any whitespace that + // follows it — `# KEY=value`, `#KEY=value`, and `KEY=value` + // all normalise to the same key; `## KEY` does NOT. + let after_hash = trimmed.strip_prefix('#').unwrap_or(trimmed); + let stripped = after_hash.trim_start(); + let (raw_key, _) = stripped.split_once('=')?; + let key = raw_key.trim(); + if key.is_empty() { + None + } else { + Some(key.to_owned()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + #[test] + fn appends_new_lines_and_skips_existing_keys() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join(".env"); + fs::write(&path, "AAA=existing\n").unwrap(); + append_lines_dedup(&path, &["AAA=NEW".to_owned(), "BBB=NEW".to_owned()], false).unwrap(); + let after = fs::read_to_string(&path).unwrap(); + // AAA stays at the operator value; BBB appended. + assert!(after.contains("AAA=existing")); + assert!(after.contains("BBB=NEW")); + assert!(!after.contains("AAA=NEW")); + } + + #[test] + fn dedup_treats_commented_and_uncommented_form_as_same_key() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join(".env"); + // Operator already uncommented + edited the override line. + fs::write(&path, "EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY=staging\n").unwrap(); + // Re-provision would otherwise re-add the commented form. + append_lines_dedup( + &path, + &["# EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY=app_config".to_owned()], + false, + ) + .unwrap(); + let after = fs::read_to_string(&path).unwrap(); + let occurrences = after + .lines() + .filter(|line| { + normalised_key(line).as_deref() == Some("EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY") + }) + .count(); + assert_eq!( + occurrences, 1, + "commented override must NOT reappear: {after}" + ); + } + + #[test] + fn dry_run_makes_no_write() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join(".env"); + fs::write(&path, "KEEP=me\n").unwrap(); + let before = fs::metadata(&path).unwrap().modified().unwrap(); + append_lines_dedup(&path, &["NEW=x".to_owned()], true).unwrap(); + let after = fs::metadata(&path).unwrap().modified().unwrap(); + assert_eq!(before, after); + } + + #[test] + fn normalised_key_strips_at_most_one_leading_hash() { + // Uncommented and single-hash forms dedup against each other: + assert_eq!(normalised_key("KEY=v"), Some("KEY".into())); + assert_eq!(normalised_key("#KEY=v"), Some("KEY".into())); + assert_eq!(normalised_key("# KEY=v"), Some("KEY".into())); + assert_eq!(normalised_key(" # KEY=v"), Some("KEY".into())); + + // Double-hash leaves the second `#` in the key → DIFFERENT + // normalised key. Operator section separators using `## …` + // stay intact. + assert_eq!(normalised_key("## KEY=v"), Some("# KEY".into())); + + // Comment-only lines return None. + assert_eq!(normalised_key("# comment"), None); + assert_eq!(normalised_key("### header"), None); + assert_eq!(normalised_key(""), None); + } + + #[test] + fn creates_file_when_absent() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("nested/subdir/.env"); + assert!(!path.exists()); + append_lines_dedup(&path, &["NEW=x".to_owned()], false).unwrap(); + assert!(path.exists()); + assert_eq!(fs::read_to_string(&path).unwrap(), "NEW=x\n"); + } +} diff --git a/crates/edgezero-adapter/src/lib.rs b/crates/edgezero-adapter/src/lib.rs index 9404fc28..28f53e0c 100644 --- a/crates/edgezero-adapter/src/lib.rs +++ b/crates/edgezero-adapter/src/lib.rs @@ -4,6 +4,8 @@ `edgezero_adapter::TypeName` instead of `edgezero_adapter::registry::TypeName`" )] +pub mod env_file; + pub mod registry; pub mod scaffold; From 9c21d15271fbcc6263827c8d6c2830a552c3ae60 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 1 Jul 2026 21:43:42 -0700 Subject: [PATCH 26/71] Wire deployed_state_for translator + validate_deployed_field_ownership into run_provision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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..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. --- crates/edgezero-cli/src/provision.rs | 283 ++++++++++++++++++++++++--- 1 file changed, 251 insertions(+), 32 deletions(-) diff --git a/crates/edgezero-cli/src/provision.rs b/crates/edgezero-cli/src/provision.rs index e943d41e..9e2b45d8 100644 --- a/crates/edgezero-cli/src/provision.rs +++ b/crates/edgezero-cli/src/provision.rs @@ -17,6 +17,7 @@ use toml_edit::{table, value, DocumentMut}; use crate::args::ProvisionArgs; use crate::config::{ enforce_single_store_capability, reject_merged_id_collisions, strict_handler_paths, + validate_deployed_field_ownership, }; use crate::copy_tree::copy_dir_recursive; use crate::ensure_adapter_defined; @@ -64,6 +65,27 @@ pub(crate) struct DryRunAllowList { pub pairs: Vec<(PathBuf, PathBuf)>, } +/// # Errors +/// +/// Manifest-shape gates run before the dispatch matrix: capability +/// gate, handler-path shape, and deployed-field ownership. The +/// ownership check exists here for parity with `run_shared_checks` in +/// the config path, so `config validate` / `push` / `diff` and +/// `provision` all reject the same manifests. Extracted from +/// `run_provision` to keep that fn under the workspace `too_many_lines` +/// lint; no behaviour change. +/// +/// # Errors +/// +/// Returns the first check's error string when any of the three gates +/// rejects the manifest. +fn run_manifest_shape_gates(manifest: &Manifest, adapter_name: &str) -> Result<(), String> { + enforce_single_store_capability(manifest, adapter_name)?; + strict_handler_paths(manifest)?; + validate_deployed_field_ownership(manifest)?; + Ok(()) +} + /// # Errors /// /// Returns an error string if the manifest can't be loaded, the @@ -119,28 +141,8 @@ pub fn run_provision(args: &ProvisionArgs) -> Result<(), String> { ) })?; - // Capability gate: mirror the strict `config validate` check for - // THIS adapter only. Without it, `provision --adapter spin` - // happily accepts a manifest with two config ids and dispatches - // to a backend that has no way to model multiple stores -- the - // failure only surfaces at runtime as a confusing "wrong store" - // miss. The check is unconditional (no --strict gate) because - // it's not stylistic: the platform genuinely cannot honour the - // declaration. - enforce_single_store_capability(manifest, &args.adapter)?; - - // Manifest-shape gate: provision is the most expensive - // operation in the CLI (it can create real Cloudflare / Fastly - // resources), so a malformed handler path or a broken - // adapter manifest should fail HERE rather than after the - // remote create succeeded. `strict_handler_paths` is cheap - // and unconditional in `config validate --strict`; we run it - // unconditionally here for the same reason as the capability - // check above. The per-adapter `validate_adapter_manifest` - // hook (Spin's `[component.*]` discovery, etc.) is the other - // half of the strict-validate preflight; it's adapter-specific - // so we call it only for the targeted adapter. - strict_handler_paths(manifest)?; + run_manifest_shape_gates(manifest, &args.adapter)?; + let manifest_root = args .manifest .parent() @@ -264,17 +266,47 @@ fn write_baseline_to_disk(root: &Path, pairs: &[(PathBuf, String)]) -> Result<() Ok(()) } -/// Translate the parent manifest's deployed block for `canonical_adapter_name` -/// into the neutral `AdapterDeployedState` shape. Task 14 introduces the typed -/// `ManifestAdapterDeployed` struct; until that lands this returns `None` -/// unconditionally. The synthesiser call path already receives -/// `Option<&AdapterDeployedState>` — just always `None` today. Section 4 -/// fills in the real translation. +/// Translate the parent manifest's `[adapters..deployed]` block +/// into the neutral `AdapterDeployedState` shape adapters consume via +/// `synthesise_baseline_manifest` and `provision`. Field mapping: +/// - `service_id` (scalar) → `state.fields["service_id"]`. +/// - `kv_namespaces` (map) → `state.sub_tables["kv_namespaces"]`. +/// - `preview_kv_namespaces` (map) → `state.sub_tables["preview_kv_namespaces"]`. +/// +/// Returns `None` when the adapter has no `deployed` block OR when every +/// field is empty — synthesise / provision impls treat `None` the same as +/// an empty state, so building an empty `AdapterDeployedState` would be +/// wasteful. The lookup is case-insensitive via `manifest.adapter_entry`, +/// matching how `[adapters.Fastly]` and `[adapters.fastly]` resolve to +/// the same declaration. fn deployed_state_for( - _manifest: &Manifest, - _canonical_adapter_name: &str, + manifest: &Manifest, + canonical_adapter_name: &str, ) -> Option { - None + let (_, adapter_cfg) = manifest.adapter_entry(canonical_adapter_name)?; + let deployed = adapter_cfg.deployed.as_ref()?; + let mut state = AdapterDeployedState::default(); + if let Some(service_id) = deployed.service_id.as_ref() { + state + .fields + .insert("service_id".to_owned(), service_id.clone()); + } + if !deployed.kv_namespaces.is_empty() { + state + .sub_tables + .insert("kv_namespaces".to_owned(), deployed.kv_namespaces.clone()); + } + if !deployed.preview_kv_namespaces.is_empty() { + state.sub_tables.insert( + "preview_kv_namespaces".to_owned(), + deployed.preview_kv_namespaces.clone(), + ); + } + if state.fields.is_empty() && state.sub_tables.is_empty() { + None + } else { + Some(state) + } } /// Merge `state` into `[adapters..deployed]` inside @@ -731,6 +763,7 @@ mod tests { use std::io; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::Mutex; use tempfile::TempDir; use validator::Validate as _; @@ -769,6 +802,12 @@ serve = "echo" static FAKE_ADAPTER: FakeBootstrapAdapter = FakeBootstrapAdapter; static NO_FIELDS_FAKE_ADAPTER: NoFieldsFakeAdapter = NoFieldsFakeAdapter; static RECORDED_DRY_RUN: AtomicBool = AtomicBool::new(false); + // Captures the `deployed` argument the CLI passes into + // `FakeBootstrapAdapter::synthesise_baseline_manifest`. Used by + // Section-4 tests that assert `deployed_state_for` translated a + // real `[adapters..deployed]` block and threaded it + // through — not left it silently `None`. + static RECORDED_SYNTH_DEPLOYED: Mutex> = Mutex::new(None); static SYNTH_CALLED: AtomicBool = AtomicBool::new(false); static VALIDATE_SAW_FILE: AtomicBool = AtomicBool::new(false); @@ -838,9 +877,12 @@ serve = "echo" adapter_manifest_path: Option<&str>, _component_selector: Option<&str>, _app_name: &str, - _deployed: Option<&AdapterDeployedState>, + deployed: Option<&AdapterDeployedState>, ) -> Result, String> { SYNTH_CALLED.store(true, Ordering::SeqCst); + if let Ok(mut slot) = RECORDED_SYNTH_DEPLOYED.lock() { + *slot = deployed.cloned(); + } let rel = adapter_manifest_path.unwrap_or("spin.toml").to_owned(); Ok(vec![(PathBuf::from(rel), "# stub\n".to_owned())]) } @@ -896,6 +938,9 @@ serve = "echo" fn reset_fake_state() { RECORDED_DRY_RUN.store(false, Ordering::SeqCst); + if let Ok(mut slot) = RECORDED_SYNTH_DEPLOYED.lock() { + *slot = None; + } SYNTH_CALLED.store(false, Ordering::SeqCst); VALIDATE_SAW_FILE.store(false, Ordering::SeqCst); } @@ -2185,6 +2230,180 @@ ids = ["default"] ); } + // ---------- deployed_state_for + run_provision wiring ---------- + + #[test] + fn deployed_state_for_translates_all_field_kinds() { + // Manifest with a `demo` adapter carrying every deployed-field + // kind: scalar service_id, kv_namespaces map, and + // preview_kv_namespaces map. The translator must land them at + // the neutral positions each adapter reads from — scalar + // fields under `state.fields`, maps under `state.sub_tables`. + let toml_body = r#" +[app] +name = "demo" + +[adapters.demo] +[adapters.demo.adapter] +crate = "crates/x" +manifest = "crates/x/m.toml" + +[adapters.demo.deployed] +service_id = "SVC1" +kv_namespaces.sessions = "abc123" +preview_kv_namespaces.sessions = "abc123_preview" +"#; + let manifest: Manifest = toml::from_str(toml_body).unwrap(); + let state = + deployed_state_for(&manifest, "demo").expect("populated deployed must be Some(state)"); + assert_eq!( + state.fields.get("service_id").map(String::as_str), + Some("SVC1") + ); + assert_eq!( + state + .sub_tables + .get("kv_namespaces") + .and_then(|map| map.get("sessions")) + .map(String::as_str), + Some("abc123") + ); + assert_eq!( + state + .sub_tables + .get("preview_kv_namespaces") + .and_then(|map| map.get("sessions")) + .map(String::as_str), + Some("abc123_preview") + ); + } + + #[test] + fn deployed_state_for_returns_none_when_all_fields_empty() { + // Adapter has NO deployed block: translator returns None so + // synthesise / provision impls see the same signal they did + // in the pre-Task-14 world (empty state = None). + let toml_body = r#" +[app] +name = "demo" + +[adapters.demo] +[adapters.demo.adapter] +crate = "crates/x" +manifest = "crates/x/m.toml" +"#; + let manifest: Manifest = toml::from_str(toml_body).unwrap(); + assert!(deployed_state_for(&manifest, "demo").is_none()); + } + + #[test] + fn provision_local_threads_deployed_state_into_synthesiser() { + // Regression: `deployed_state_for` was left returning None + // through the whole of Section 4. Result: real deployed IDs + // in edgezero.toml never reached the adapter's + // synthesise_baseline_manifest call, defeating the "teammates + // regenerate local manifests from tracked durable IDs" spec + // promise. This test asserts the CLI reads + // `[adapters..deployed]` and passes it through. + let _lock = manifest_guard().lock().expect("manifest guard"); + reset_fake_state(); + register_adapter(&FAKE_ADAPTER); + let temp = TempDir::new().unwrap(); + let manifest_path = temp.path().join("edgezero.toml"); + let manifest_body = r#" +[app] +name = "demo" + +[adapters.__test_bootstrap_fake__] +[adapters.__test_bootstrap_fake__.adapter] +crate = "crates/spin" +manifest = "crates/spin/spin.toml" + +[adapters.__test_bootstrap_fake__.commands] +build = "echo" +deploy = "echo" +serve = "echo" + +[adapters.__test_bootstrap_fake__.deployed] +service_id = "SVC1" +"#; + fs::write(&manifest_path, manifest_body).unwrap(); + fs::create_dir_all(temp.path().join("crates/spin")).unwrap(); + + run_provision(&ProvisionArgs { + adapter: "__test_bootstrap_fake__".to_owned(), + dry_run: false, + local: true, + manifest: manifest_path, + }) + .expect("local real-write should reach the fake's synthesiser"); + + assert!(SYNTH_CALLED.load(Ordering::SeqCst)); + let observed = RECORDED_SYNTH_DEPLOYED + .lock() + .expect("recorded deployed slot poisoned") + .clone() + .expect("synthesiser must have received Some(state)"); + assert_eq!( + observed.fields.get("service_id").map(String::as_str), + Some("SVC1"), + "manifest's `[adapters.*.deployed] service_id` must reach the adapter: {observed:?}" + ); + } + + #[test] + fn provision_rejects_deployed_block_with_field_adapter_does_not_own() { + // The ownership check exists in run_shared_checks (config + // validate + push + diff pick it up), but until this patch + // run_provision did NOT call it. That gap let + // `edgezero provision` accept manifests that `edgezero config + // validate` correctly rejected. Regression test: register + // NoFieldsFakeAdapter (owns nothing per deployed_fields()), + // put a service_id under its deployed block, and assert + // run_provision Errs with the ownership violation before + // reaching the dispatch matrix. + let _lock = manifest_guard().lock().expect("manifest guard"); + reset_fake_state(); + register_adapter(&NO_FIELDS_FAKE_ADAPTER); + let temp = TempDir::new().unwrap(); + let manifest_path = temp.path().join("edgezero.toml"); + let manifest_body = r#" +[app] +name = "demo" + +[adapters.__test_no_fields_fake__] +[adapters.__test_no_fields_fake__.adapter] +crate = "crates/x" +manifest = "crates/x/m.toml" + +[adapters.__test_no_fields_fake__.commands] +build = "echo" +deploy = "echo" +serve = "echo" + +[adapters.__test_no_fields_fake__.deployed] +service_id = "SVC1" +"#; + fs::write(&manifest_path, manifest_body).unwrap(); + fs::create_dir_all(temp.path().join("crates/x")).unwrap(); + + let err = run_provision(&ProvisionArgs { + adapter: "__test_no_fields_fake__".to_owned(), + dry_run: true, + local: false, + manifest: manifest_path, + }) + .expect_err("adapter owns no deployed fields: service_id must be rejected"); + assert!( + err.contains("service_id") && err.contains("__test_no_fields_fake__"), + "error must name offending field + adapter: {err}" + ); + assert!( + !SYNTH_CALLED.load(Ordering::SeqCst), + "ownership check must fire before synthesise_baseline_manifest" + ); + } + // ---------- merge_deployed_into_manifest ---------- #[test] From fac82ab1b8f69d5da78050fca21b432744321490 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 1 Jul 2026 21:50:55 -0700 Subject: [PATCH 27/71] Clean up misleading adapter-named tests in provision.rs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/edgezero-cli/src/provision.rs | 148 ++++++++------------------- 1 file changed, 40 insertions(+), 108 deletions(-) diff --git a/crates/edgezero-cli/src/provision.rs b/crates/edgezero-cli/src/provision.rs index 9e2b45d8..00de4797 100644 --- a/crates/edgezero-cli/src/provision.rs +++ b/crates/edgezero-cli/src/provision.rs @@ -979,7 +979,18 @@ serve = "echo" } #[test] - fn run_provision_axum_prints_local_only_notes_for_each_store() { + fn run_provision_cloud_non_dry_run_succeeds_when_adapter_is_side_effect_free() { + // Cloud non-dry-run smoke: the CLI dispatch matrix reaches + // the adapter and exits 0 when the adapter's Cloud arm has no + // side effects to perform. Uses axum because its Cloud + // provision is a no-op; any adapter with an empty Cloud arm + // would fit — the assertion is about the CLI's dispatch, + // not axum-specific behavior. Stronger dispatch-shape + // coverage (which dry_run value the adapter observes, which + // arm of the (local, dry_run) matrix runs) is in the + // fake-based tests: provision_cloud_dry_run_passes_dry_run + // _true_to_adapter, provision_local_no_dry_run_writes_to + // _worktree, provision_local_dry_run_leaves_worktree_clean. let _lock = manifest_guard().lock().expect("manifest guard"); let temp = TempDir::new().expect("temp dir"); let manifest_path = temp.path().join("edgezero.toml"); @@ -993,25 +1004,7 @@ serve = "echo" local: false, manifest: manifest_path.clone(), }) - .expect("axum provision exits 0 (no remote resources)"); - } - - #[test] - fn run_provision_axum_dry_run_is_also_a_no_op() { - let _lock = manifest_guard().lock().expect("manifest guard"); - let temp = TempDir::new().expect("temp dir"); - let manifest_path = temp.path().join("edgezero.toml"); - fs::write(&manifest_path, PROVISION_MANIFEST).expect("write manifest"); - let manifest_str = manifest_path.to_string_lossy().into_owned(); - let _env = EnvOverride::set("EDGEZERO_MANIFEST", &manifest_str); - - run_provision(&ProvisionArgs { - adapter: "axum".to_owned(), - dry_run: true, - local: false, - manifest: manifest_path.clone(), - }) - .expect("axum dry-run also exits 0"); + .expect("side-effect-free adapter cloud provision exits 0"); } #[test] @@ -1036,32 +1029,6 @@ serve = "echo" ); } - #[test] - fn run_provision_spin_dry_run_dispatches_to_adapter() { - // Dry-run path doesn't edit spin.toml, so CI can exercise - // dispatch by writing a single-component spin.toml the - // resolver can locate. - let _lock = manifest_guard().lock().expect("manifest guard"); - let temp = TempDir::new().expect("temp dir"); - let manifest_path = temp.path().join("edgezero.toml"); - fs::write(&manifest_path, PROVISION_MANIFEST).expect("write manifest"); - fs::write( - temp.path().join("spin.toml"), - "spin_manifest_version = 2\n[application]\nname = \"x\"\nversion = \"0\"\n[component.demo]\nsource = \"demo.wasm\"\n", - ) - .expect("write spin.toml"); - let manifest_str = manifest_path.to_string_lossy().into_owned(); - let _env = EnvOverride::set("EDGEZERO_MANIFEST", &manifest_str); - - run_provision(&ProvisionArgs { - adapter: "spin".to_owned(), - dry_run: true, - local: false, - manifest: manifest_path.clone(), - }) - .expect("spin dry-run dispatches cleanly"); - } - #[test] fn run_provision_rejects_malformed_handler_path_before_dispatching() { // Provision is the most expensive operation in the CLI -- @@ -1107,9 +1074,13 @@ adapters = ["axum"] #[test] fn run_provision_spin_rejects_malformed_adapter_manifest_before_dispatching() { - // The adapter-specific `validate_adapter_manifest` hook - // also gates provision now -- a spin.toml with zero - // components must error before we touch any remote. + // CLI-logic test: `run_provision` MUST call + // `adapter.validate_adapter_manifest` before dispatch, and + // MUST surface its error. Spin is the illustrative example + // because its `validate_adapter_manifest` actually validates + // (a spin.toml with zero components errors); axum's is a + // no-op so it can't drive this assertion. The check itself + // is CLI-side, not Spin-specific. let _lock = manifest_guard().lock().expect("manifest guard"); let temp = TempDir::new().expect("temp dir"); let manifest_path = temp.path().join("edgezero.toml"); @@ -1138,11 +1109,14 @@ adapters = ["axum"] #[test] fn run_provision_spin_rejects_multi_secret_ids_via_capability_gate() { - // Stage 5: Spin moved `config` to KV (multi-capable). Secrets - // remain Single-capable until we ship native secret support, - // so a manifest declaring two secret ids must still trip the - // gate before dispatching to the spin adapter dry-run. This - // test pins parity with `config validate --strict`. + // CLI-logic test: the capability gate + // (`enforce_single_store_capability`) reads the adapter's + // `merged_id_kinds()` and rejects manifests declaring more + // than one id in a single-capable kind. Spin is the + // illustrative example because secrets remain Single-capable + // there while `config` moved to KV; any adapter with a + // Single-capable kind + a manifest exceeding that limit would + // fit. Pins parity with `config validate --strict`. let _lock = manifest_guard().lock().expect("manifest guard"); let temp = TempDir::new().expect("temp dir"); let manifest_path = temp.path().join("edgezero.toml"); @@ -1190,10 +1164,11 @@ default = "default" #[test] fn run_provision_spin_accepts_multi_config_ids_since_kv_migration() { - // Stage 5: config is KV-backed for Spin, so multiple config - // ids no longer trip enforce_single_store_capability. The - // dispatch reaches the adapter dry-run and reports one - // `key_value_stores` write per id. + // CLI-logic test: the capability gate accepts multiple ids + // when the adapter's `merged_id_kinds()` includes the kind. + // Spin is the illustrative example because its `config` kind + // is KV-backed (multi-capable) post-migration; any adapter + // with a multi-capable kind would fit. let _lock = manifest_guard().lock().expect("manifest guard"); let temp = TempDir::new().expect("temp dir"); let manifest_path = temp.path().join("edgezero.toml"); @@ -1236,13 +1211,13 @@ ids = ["default"] #[test] fn run_provision_spin_rejects_env_overlay_platform_label_collision_across_kv_and_config() { - // M1: provision must run the same merged-id collision check - // `config validate` runs. Without it, `provision --adapter - // spin --dry-run` happily acks a manifest where distinct - // logical ids `[stores.kv].sessions` and - // `[stores.config].app_config` BOTH resolve to platform - // label `shared` via the env overlay -- both writes would - // silently land on the same Spin KV store at runtime. + // CLI-logic test: `reject_merged_id_collisions` catches + // env-overlay collisions where distinct logical ids across + // merged kinds resolve to the same platform label. Spin is + // the illustrative example because it merges kv + config + // into a single KV backend (any adapter whose + // `merged_id_kinds()` covers 2+ kinds would fit). Pins parity + // with the same check `config validate` runs. let _lock = manifest_guard().lock().expect("manifest guard"); let temp = TempDir::new().expect("temp dir"); let manifest_path = temp.path().join("edgezero.toml"); @@ -1318,49 +1293,6 @@ ids = ["default"] .expect("single-id case dispatches cleanly"); } - #[test] - fn run_provision_cloudflare_dry_run_dispatches_to_adapter() { - // Dry-run path doesn't shell out to wrangler, so CI can - // exercise dispatch without wrangler installed. - let _lock = manifest_guard().lock().expect("manifest guard"); - let temp = TempDir::new().expect("temp dir"); - let manifest_path = temp.path().join("edgezero.toml"); - fs::write(&manifest_path, PROVISION_MANIFEST).expect("write manifest"); - fs::write(temp.path().join("wrangler.toml"), "name = \"demo\"\n") - .expect("write wrangler.toml"); - let manifest_str = manifest_path.to_string_lossy().into_owned(); - let _env = EnvOverride::set("EDGEZERO_MANIFEST", &manifest_str); - - run_provision(&ProvisionArgs { - adapter: "cloudflare".to_owned(), - dry_run: true, - local: false, - manifest: manifest_path.clone(), - }) - .expect("cloudflare dry-run dispatches cleanly"); - } - - #[test] - fn run_provision_fastly_dry_run_dispatches_to_adapter() { - // Dry-run path doesn't shell out to fastly, so CI can - // exercise dispatch without fastly installed. - let _lock = manifest_guard().lock().expect("manifest guard"); - let temp = TempDir::new().expect("temp dir"); - let manifest_path = temp.path().join("edgezero.toml"); - fs::write(&manifest_path, PROVISION_MANIFEST).expect("write manifest"); - fs::write(temp.path().join("fastly.toml"), "name = \"demo\"\n").expect("write fastly.toml"); - let manifest_str = manifest_path.to_string_lossy().into_owned(); - let _env = EnvOverride::set("EDGEZERO_MANIFEST", &manifest_str); - - run_provision(&ProvisionArgs { - adapter: "fastly".to_owned(), - dry_run: true, - local: false, - manifest: manifest_path.clone(), - }) - .expect("fastly dry-run dispatches cleanly"); - } - // ---------- provision --local path containment ---------- #[test] From 1e1e20677ccb76c75d3b3cf15f8a909988fd92d3 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 1 Jul 2026 22:32:39 -0700 Subject: [PATCH 28/71] Fix three review findings: dry-run leak, baseline containment, deployed schema check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/edgezero-cli/src/provision.rs | 231 ++++++++++++++++++++++++++- 1 file changed, 224 insertions(+), 7 deletions(-) diff --git a/crates/edgezero-cli/src/provision.rs b/crates/edgezero-cli/src/provision.rs index 00de4797..4db4b0e8 100644 --- a/crates/edgezero-cli/src/provision.rs +++ b/crates/edgezero-cli/src/provision.rs @@ -9,7 +9,7 @@ //! here. use std::fs; -use std::path::{Path, PathBuf}; +use std::path::{Component, Path, PathBuf}; use similar::{ChangeTag, TextDiff}; use toml_edit::{table, value, DocumentMut}; @@ -224,6 +224,7 @@ pub fn run_provision(args: &ProvisionArgs) -> Result<(), String> { &args.manifest, canonical_adapter_key, deployed_writeback, + adapter.deployed_fields(), args.dry_run, )?; } @@ -249,10 +250,32 @@ fn resolve_kind( /// Write each `(rel, contents)` baseline pair under `root`, skipping /// files that already exist. Preserves operator content and earlier /// synthesis output. Used for worktree writes (real-write local) and -/// tempdir writes (dry-run staging, Task 10+) — the only difference -/// is which root is passed in. +/// tempdir writes (dry-run staging) — the only difference is which +/// root is passed in. +/// +/// **Path containment**: each `rel_path` MUST be relative and MUST NOT +/// contain a `..` component. Adapter-returned baseline paths are trusted +/// less than manifest-declared paths (which `assert_provision_paths +/// _contained` gates upstream); a buggy or hostile synthesiser +/// returning an absolute path or `../../etc/passwd` would otherwise +/// escape the project tree. Reject before `root.join()`. fn write_baseline_to_disk(root: &Path, pairs: &[(PathBuf, String)]) -> Result<(), String> { for (rel_path, contents) in pairs { + if rel_path.is_absolute() { + return Err(format!( + "baseline path must be project-relative, got `{}`", + rel_path.display() + )); + } + if rel_path + .components() + .any(|component| matches!(component, Component::ParentDir)) + { + return Err(format!( + "baseline path must not contain `..` traversal: `{}`", + rel_path.display() + )); + } let abs = root.join(rel_path); if abs.exists() { continue; @@ -323,12 +346,57 @@ fn deployed_state_for( /// returns without writing — used by callers who want the write /// gated on the same `--dry-run` semantic as the surrounding /// dispatch. +/// +/// **Adapter-emitted schema check**: every key in `state.fields` and +/// `state.sub_tables` MUST be in the known `ManifestAdapterDeployed` +/// schema AND in `owned_fields`. `validate_deployed_field_ownership` +/// gates operator-written manifests before dispatch; this gate does +/// the same for adapter-emitted output before writing back. Without +/// it, a buggy adapter's `AdapterDeployedState` could persist +/// unknown or non-owned keys into `edgezero.toml`, breaking future +/// manifest loads. pub(crate) fn merge_deployed_into_manifest( manifest_path: &Path, adapter_name: &str, state: &adapter_registry::AdapterDeployedState, + owned_fields: &[&str], dry_run: bool, ) -> Result<(), String> { + // The canonical `ManifestAdapterDeployed` schema. If the struct + // gains a field, add it here too — the check is the ONLY defense + // against adapter-emitted unknown fields corrupting the writeback. + const KNOWN_SCALAR_FIELDS: &[&str] = &["service_id"]; + const KNOWN_SUB_TABLE_FIELDS: &[&str] = &["kv_namespaces", "preview_kv_namespaces"]; + + for key in state.fields.keys() { + if !KNOWN_SCALAR_FIELDS.contains(&key.as_str()) { + return Err(format!( + "adapter `{adapter_name}` returned unknown deployed field `{key}` (known scalar fields: [{}])", + KNOWN_SCALAR_FIELDS.join(", ") + )); + } + if !owned_fields.contains(&key.as_str()) { + return Err(format!( + "adapter `{adapter_name}` returned deployed field `{key}` it does not own (owned: [{}])", + owned_fields.join(", ") + )); + } + } + for key in state.sub_tables.keys() { + if !KNOWN_SUB_TABLE_FIELDS.contains(&key.as_str()) { + return Err(format!( + "adapter `{adapter_name}` returned unknown deployed sub-table `{key}` (known sub-tables: [{}])", + KNOWN_SUB_TABLE_FIELDS.join(", ") + )); + } + if !owned_fields.contains(&key.as_str()) { + return Err(format!( + "adapter `{adapter_name}` returned deployed sub-table `{key}` it does not own (owned: [{}])", + owned_fields.join(", ") + )); + } + } + let raw = fs::read_to_string(manifest_path) .map_err(|err| format!("read {}: {err}", manifest_path.display()))?; let mut doc: DocumentMut = raw @@ -676,7 +744,21 @@ fn run_local_dry_run( if !report.is_empty() { log::info!("{report}"); } - Ok(outcome) + // Clear status_lines: the sanitized report already includes the + // rewritten "would write ..." lines with staged-tempdir paths + // swapped back to project-relative form. If we returned them + // untouched, `run_provision`'s trailing `for line in + // outcome.status_lines` loop would re-log the raw versions — + // leaking `/var/folders/…` tempdir paths to operators. Spec §"Dry- + // run": stdout must NEVER contain raw tempdir paths. The + // `deployed` payload is intentionally kept: cloud writeback under + // (false, _) uses it, and local dry-run today always sees `None` + // there (adapters populate `deployed` only when writing real + // cloud state). + Ok(adapter_registry::ProvisionOutcome { + status_lines: Vec::new(), + deployed: outcome.deployed, + }) } /// Stage a real recursive copy of the adapter crate dir AND the @@ -2367,7 +2449,16 @@ manifest = "crates/cf/wrangler.toml" state.sub_tables.insert("kv_namespaces".to_owned(), kv); // Canonical key is "Cloudflare" (as written in the manifest). - merge_deployed_into_manifest(&manifest_path, "Cloudflare", &state, false).unwrap(); + // owned_fields = &["kv_namespaces", "preview_kv_namespaces"] matches + // Cloudflare's real deployed_fields() surface. + merge_deployed_into_manifest( + &manifest_path, + "Cloudflare", + &state, + &["kv_namespaces", "preview_kv_namespaces"], + false, + ) + .unwrap(); let raw = fs::read_to_string(&manifest_path).unwrap(); // Must land under the operator's spelling; NO lowercased sibling. @@ -2413,7 +2504,19 @@ manifest = "crates/cf/wrangler.toml" state .fields .insert("service_id".to_owned(), "SVC1".to_owned()); - merge_deployed_into_manifest(&manifest_path, "cloudflare", &state, false).unwrap(); + // owned_fields for the cloudflare-shaped comment test needs + // to include service_id (the Fastly-only field the test uses + // here) — this is a unit test of the toml_edit writeback, + // not the ownership gate. Passing a superset keeps the test's + // focus on comment preservation. + merge_deployed_into_manifest( + &manifest_path, + "cloudflare", + &state, + &["service_id", "kv_namespaces", "preview_kv_namespaces"], + false, + ) + .unwrap(); let raw = fs::read_to_string(&manifest_path).unwrap(); assert!( @@ -2443,9 +2546,123 @@ manifest = "crates/cf/wrangler.toml" kv.insert("sessions".to_owned(), "abc".to_owned()); state.sub_tables.insert("kv_namespaces".to_owned(), kv); - merge_deployed_into_manifest(&manifest_path, "cloudflare", &state, true).unwrap(); + merge_deployed_into_manifest( + &manifest_path, + "cloudflare", + &state, + &["kv_namespaces", "preview_kv_namespaces"], + true, + ) + .unwrap(); let after = fs::read_to_string(&manifest_path).unwrap(); assert_eq!(before, after, "dry-run must leave file byte-identical"); } + + #[test] + fn merge_deployed_rejects_adapter_emitted_unknown_field() { + // A buggy adapter returning a deployed key that isn't in the + // `ManifestAdapterDeployed` schema must be rejected BEFORE we + // write anything to edgezero.toml. Otherwise the manifest + // would fail future loads via `deny_unknown_fields`. + let temp = TempDir::new().unwrap(); + let manifest_path = temp.path().join("edgezero.toml"); + fs::write(&manifest_path, "[app]\nname = \"demo\"\n").unwrap(); + + let mut state = AdapterDeployedState::default(); + state + .fields + .insert("nonsense_key".to_owned(), "x".to_owned()); + + let err = merge_deployed_into_manifest( + &manifest_path, + "cloudflare", + &state, + &["service_id", "kv_namespaces", "preview_kv_namespaces"], + false, + ) + .expect_err("unknown deployed field must be rejected before writeback"); + assert!( + err.contains("nonsense_key") && err.contains("unknown"), + "error must name the offending field: {err}" + ); + // File must be untouched — write never happened. + assert_eq!( + fs::read_to_string(&manifest_path).unwrap(), + "[app]\nname = \"demo\"\n" + ); + } + + #[test] + fn merge_deployed_rejects_adapter_emitted_non_owned_field() { + // A buggy adapter that emits a known deployed field it does + // NOT own must be rejected. Symmetric to + // `validate_deployed_field_ownership`, which gates operator- + // written manifests before dispatch. + let temp = TempDir::new().unwrap(); + let manifest_path = temp.path().join("edgezero.toml"); + fs::write(&manifest_path, "[app]\nname = \"demo\"\n").unwrap(); + + let mut state = AdapterDeployedState::default(); + state + .fields + .insert("service_id".to_owned(), "SVC1".to_owned()); + + // Cloudflare owns kv_namespaces + preview_kv_namespaces, NOT + // service_id. A Cloudflare adapter emitting service_id is a + // bug the writeback must catch. + let err = merge_deployed_into_manifest( + &manifest_path, + "cloudflare", + &state, + &["kv_namespaces", "preview_kv_namespaces"], + false, + ) + .expect_err("known-but-non-owned deployed field must be rejected"); + assert!( + err.contains("service_id") && err.contains("does not own"), + "error must name the offending field: {err}" + ); + } + + // ---------- write_baseline_to_disk containment ---------- + + #[test] + fn write_baseline_rejects_absolute_path() { + // An adapter's `synthesise_baseline_manifest` returning an + // absolute path would escape the project tree via + // `root.join(abs)` (Rust replaces `root` when the joined + // path is absolute). + let temp = TempDir::new().unwrap(); + let pairs = vec![(PathBuf::from("/tmp/x.toml"), "content".to_owned())]; + let err = write_baseline_to_disk(temp.path(), &pairs) + .expect_err("absolute baseline path must be rejected"); + assert!( + err.contains("project-relative") && err.contains("/tmp/x.toml"), + "error must name the violation + offending path: {err}" + ); + assert!( + !temp.path().join("tmp/x.toml").exists(), + "no file must have been written" + ); + } + + #[test] + fn write_baseline_rejects_parent_traversal() { + // `../` in the adapter-returned rel path would let a buggy + // synthesiser write outside the staged root or the project + // crate. Reject before touching disk. + let temp = TempDir::new().unwrap(); + let pairs = vec![(PathBuf::from("../outside.toml"), "content".to_owned())]; + let err = write_baseline_to_disk(temp.path(), &pairs) + .expect_err("`..` traversal in baseline path must be rejected"); + assert!( + err.contains("`..` traversal") && err.contains("../outside.toml"), + "error must name the violation + offending path: {err}" + ); + assert!( + !temp.path().parent().unwrap().join("outside.toml").exists(), + "no file must have been written outside the root" + ); + } } From c640931fa9daa63b6be8ec85d5a930975206f189 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:38:48 -0700 Subject: [PATCH 29/71] Cloudflare: primitive synthesiser for wrangler.toml + bootstrap override --- crates/edgezero-adapter-cloudflare/src/cli.rs | 63 ++++++++++++++++++- 1 file changed, 62 insertions(+), 1 deletion(-) diff --git a/crates/edgezero-adapter-cloudflare/src/cli.rs b/crates/edgezero-adapter-cloudflare/src/cli.rs index f2acd53a..dda47776 100644 --- a/crates/edgezero-adapter-cloudflare/src/cli.rs +++ b/crates/edgezero-adapter-cloudflare/src/cli.rs @@ -133,7 +133,7 @@ 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. `read_config_entry` and `read_config_entry_local` are both overridden below (wrangler kv key get --remote / --local). `single_store_kinds` IS overridden below (returns `&[\"secrets\"]`)." + reason = "cloudflare has no validate_app_config_keys / validate_adapter_manifest / validate_typed_secrets requirements; those three trait defaults are intentionally inherited. `read_config_entry` and `read_config_entry_local` are both overridden below (wrangler kv key get --remote / --local). `single_store_kinds` IS overridden below (returns `&[\"secrets\"]`). `synthesise_baseline_manifest` IS overridden below (emits a baseline `wrangler.toml` for the Task 8b clean-clone bootstrap)." )] impl Adapter for CloudflareCliAdapter { fn deployed_fields(&self) -> &'static [&'static str] { @@ -568,6 +568,19 @@ impl Adapter for CloudflareCliAdapter { // Secrets is a single flat bag). &["secrets"] } + + fn synthesise_baseline_manifest( + &self, + _manifest_root: &Path, + adapter_manifest_path: Option<&str>, + _component_selector: Option<&str>, + app_name: &str, + _deployed: Option<&AdapterDeployedState>, + ) -> Result, String> { + let rel = + adapter_manifest_path.map_or_else(|| PathBuf::from("wrangler.toml"), PathBuf::from); + Ok(vec![(rel, synthesise_wrangler_toml(app_name))]) + } } /// Shell out to `wrangler kv namespace create `, capture @@ -1174,6 +1187,28 @@ pub fn serve(extra_args: &[String]) -> Result<(), String> { Ok(()) } +/// Synthesised baseline `wrangler.toml` for clean clones. Built via +/// `toml_edit::DocumentMut` (NOT raw `format!`) so any legal +/// `[app].name` — including names with TOML-significant characters +/// like `"`, `\`, or newlines — is escaped correctly. Manifest +/// validation today only length-bounds the name; raw interpolation +/// would produce invalid TOML for legal inputs. +pub(crate) fn synthesise_wrangler_toml(app_name: &str) -> String { + use toml_edit::{value, DocumentMut}; + + let mut doc = DocumentMut::new(); + doc.decor_mut().set_prefix("# edgezero-provision: v1\n"); + // `Table::insert` returns the previous value (if any). We build a + // fresh document from `DocumentMut::new()`, so nothing to displace + // -- but the return is discarded intentionally. Using `insert` + // instead of `doc["..."] = ...` sidesteps `clippy::indexing_slicing` + // (the index form panics if the key is missing; `insert` doesn't). + doc.insert("name", value(app_name)); + doc.insert("main", value("build/worker/shim.mjs")); + doc.insert("compatibility_date", value("2024-01-01")); + doc.to_string() +} + #[cfg(test)] mod tests { use super::*; @@ -2362,4 +2397,30 @@ id = "00112233445566778899aabbccddeeff" Ok(_) => panic!("expected Err when adapter_manifest_path is None"), } } + + // ---------- synthesise_wrangler_toml ---------- + + #[test] + fn synthesises_minimal_wrangler_toml_with_header() { + let out = synthesise_wrangler_toml("demo"); + assert!(out.starts_with("# edgezero-provision: v1")); + assert!(out.contains(r#"name = "demo""#)); + assert!(out.contains(r#"main = "build/worker/shim.mjs""#)); + assert!(out.contains("compatibility_date = ")); + } + + #[test] + fn synthesise_wrangler_toml_escapes_pathological_app_names() { + for name in [ + r#"has"quote"#, + r"has\backslash", + "has\nnewline", + "has = equals", + ] { + let out = synthesise_wrangler_toml(name); + // Re-parsing must succeed AND round-trip the name. + let doc: toml_edit::DocumentMut = out.parse().unwrap(); + assert_eq!(doc["name"].as_str(), Some(name), "input: {name:?}"); + } + } } From 91205a6cf128fbd6cd37f918e91e1a894d00f454 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:48:36 -0700 Subject: [PATCH 30/71] Cloudflare: local-mode provision emits [[kv_namespaces]] bindings (deployed precedence) --- crates/edgezero-adapter-cloudflare/src/cli.rs | 479 +++++++++++++++++- 1 file changed, 475 insertions(+), 4 deletions(-) diff --git a/crates/edgezero-adapter-cloudflare/src/cli.rs b/crates/edgezero-adapter-cloudflare/src/cli.rs index dda47776..037d8af1 100644 --- a/crates/edgezero-adapter-cloudflare/src/cli.rs +++ b/crates/edgezero-adapter-cloudflare/src/cli.rs @@ -194,13 +194,21 @@ impl Adapter for CloudflareCliAdapter { adapter_manifest_path: Option<&str>, _component_selector: Option<&str>, stores: &ProvisionStores<'_>, - _deployed: Option<&AdapterDeployedState>, + deployed: Option<&AdapterDeployedState>, mode: ProvisionMode, dry_run: bool, ) -> Result { match mode { ProvisionMode::Cloud => {} - ProvisionMode::Local => return Err("local mode lands in Section 5".to_owned()), + ProvisionMode::Local => { + return provision_local( + manifest_root, + adapter_manifest_path, + stores, + deployed, + dry_run, + ); + } } //: KV ids and config ids both back to Cloudflare KV // namespaces. Secrets are runtime-managed via @@ -319,7 +327,7 @@ impl Adapter for CloudflareCliAdapter { // map, since the existing id is already recorded in the // operator's `[adapters.cloudflare.deployed]` block from a // prior run. - let deployed = if created_kv_ns.is_empty() { + let created_deployed = if created_kv_ns.is_empty() { None } else { let mut state = AdapterDeployedState::default(); @@ -330,7 +338,7 @@ impl Adapter for CloudflareCliAdapter { }; Ok(ProvisionOutcome { status_lines: out, - deployed, + deployed: created_deployed, }) } @@ -894,6 +902,163 @@ fn upsert_kv_namespace(path: &Path, binding: &str, id: &str) -> Result<(), Strin Ok(()) } +/// Local-mode provision arm: rewrite `[[kv_namespaces]]` entries in +/// the adapter's `wrangler.toml` for every declared KV / config +/// store, applying the deployed-precedence rule. +/// +/// Precedence for the `id` cell of each entry: +/// 1. `deployed.sub_tables["kv_namespaces"][store.logical]` — the +/// cloud-side id recorded from a prior Cloud provision. +/// 2. The existing local `id` on a `[[kv_namespaces]]` entry whose +/// `binding` matches `store.platform`. Preserves operator-set +/// ids on file-based (no-cloud) setups. +/// 3. `format!("", store.logical)`. +/// +/// `preview_id` is written ONLY from +/// `deployed.sub_tables["preview_kv_namespaces"][store.logical]`; it +/// is never synthesised (matches the Cloud arm at `cli.rs:821`, which +/// also omits `preview_id` unless the operator provides one). +/// +/// **Lookups use `store.logical`** (env-overlay-independent, stable +/// across machines); **TOML cells use `store.platform`** (env-overlay +/// resolved binding name teammates can vary via +/// `EDGEZERO__STORES______NAME`). +/// +/// Assumes `wrangler.toml` already exists at the resolved path +/// (Task 8b's CLI bootstrap writes it before provision runs); if it +/// is missing, returns an error naming the path rather than silently +/// re-synthesising, since the adapter trait does not receive an +/// `app_name` to synthesise with. +fn provision_local( + manifest_root: &Path, + adapter_manifest_path: Option<&str>, + stores: &ProvisionStores<'_>, + deployed: Option<&AdapterDeployedState>, + dry_run: bool, +) -> Result { + use toml_edit::DocumentMut; + + let wrangler_rel = adapter_manifest_path.unwrap_or("wrangler.toml"); + let wrangler_path = manifest_root.join(wrangler_rel); + if !wrangler_path.exists() { + return Err(format!( + "expected wrangler.toml at {} (Task 8b's CLI bootstrap should have written it before provision ran)", + wrangler_path.display() + )); + } + let raw = fs::read_to_string(&wrangler_path) + .map_err(|err| format!("failed to read {}: {err}", wrangler_path.display()))?; + let mut doc: DocumentMut = raw + .parse() + .map_err(|err| format!("failed to parse {}: {err}", wrangler_path.display()))?; + + let mut status_lines: Vec = Vec::new(); + for store in stores.kv.iter().chain(stores.config.iter()) { + // Lookups use LOGICAL id. + let deployed_id = deployed + .and_then(|state| state.sub_tables.get("kv_namespaces")) + .and_then(|kv| kv.get(&store.logical)) + .map(String::as_str); + let deployed_preview = deployed + .and_then(|state| state.sub_tables.get("preview_kv_namespaces")) + .and_then(|kv| kv.get(&store.logical)) + .map(String::as_str); + let placeholder = format!("", store.logical); + + // TOML cells use PLATFORM binding. + let resolved_id = upsert_kv_namespace_entry( + &mut doc, + &wrangler_path, + &store.platform, + deployed_id, + deployed_preview, + &placeholder, + )?; + status_lines.push(format!( + "cloudflare: kv binding `{}` -> id `{}` (logical id `{}`)", + store.platform, resolved_id, store.logical, + )); + } + + if !dry_run { + fs::write(&wrangler_path, doc.to_string()) + .map_err(|err| format!("failed to write {}: {err}", wrangler_path.display()))?; + } + + Ok(ProvisionOutcome { + status_lines, + deployed: None, + }) +} + +/// In-memory upsert of a single `[[kv_namespaces]]` entry inside +/// `doc`, matched by `binding = platform`. Precedence for the +/// resolved id and `preview_id` is documented on [`provision_local`]. +/// +/// Returns the id cell as written so the caller can name it in the +/// operator-facing status line. +/// +/// Errors if `kv_namespaces` exists but is not an array-of-tables -- +/// symmetric with [`upsert_kv_namespace`]'s check. Missing +/// `kv_namespaces` is created as an empty array-of-tables and the +/// new entry appended. +fn upsert_kv_namespace_entry( + doc: &mut toml_edit::DocumentMut, + path: &Path, + platform: &str, + deployed_id: Option<&str>, + deployed_preview: Option<&str>, + placeholder: &str, +) -> Result { + use toml_edit::{value, ArrayOfTables, Item, Table}; + + let entry = doc + .entry("kv_namespaces") + .or_insert_with(|| Item::ArrayOfTables(ArrayOfTables::new())); + let arr = 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 + .iter() + .position(|table| table.get("binding").and_then(Item::as_str) == Some(platform)); + let resolved_id = if let Some(idx) = existing_idx { + // Existing entry: replace id from deployed if present, + // otherwise leave existing id in place (operator-set / + // prior placeholder). Only fall back to a fresh placeholder + // if the existing entry has NO id at all. + let existing_id = arr + .get(idx) + .and_then(|table| table.get("id").and_then(Item::as_str).map(str::to_owned)); + let resolved = deployed_id + .map(str::to_owned) + .or(existing_id) + .unwrap_or_else(|| placeholder.to_owned()); + if let Some(table) = arr.get_mut(idx) { + table.insert("id", value(&resolved)); + if let Some(preview) = deployed_preview { + table.insert("preview_id", value(preview)); + } + } + resolved + } else { + // No matching entry: append a new `[[kv_namespaces]]` table. + let resolved = deployed_id.unwrap_or(placeholder).to_owned(); + let mut new_table = Table::new(); + new_table.insert("binding", value(platform)); + new_table.insert("id", value(&resolved)); + if let Some(preview) = deployed_preview { + new_table.insert("preview_id", value(preview)); + } + arr.push(new_table); + resolved + }; + Ok(resolved_id) +} + /// Render the entries as the `[{"key": "...", "value": "..."}, …]` /// JSON wrangler expects for `kv bulk put`. Under the blob model the /// CLI hands this writer one logical `(root_key, envelope_json)` entry; @@ -2423,4 +2588,310 @@ id = "00112233445566778899aabbccddeeff" assert_eq!(doc["name"].as_str(), Some(name), "input: {name:?}"); } } + + // ---------- provision (Local mode) ---------- + + /// Build an `AdapterDeployedState` with a single + /// `kv_namespaces. = ` mapping. Keeps the + /// per-test fixture terse. + fn deployed_kv(logical: &str, namespace_id: &str) -> AdapterDeployedState { + let mut kv = BTreeMap::new(); + kv.insert(logical.to_owned(), namespace_id.to_owned()); + let mut state = AdapterDeployedState::default(); + state.sub_tables.insert("kv_namespaces".to_owned(), kv); + state + } + + #[test] + fn cloudflare_local_provision_emits_bindings_with_placeholders_when_no_deployed() { + // [stores.kv].ids = ["sessions"], no deployed block. + // Expect the freshly-written entry to carry the placeholder id, + // and NOT emit a preview_id at all (deployed lookup only). + let dir = tempdir().expect("tempdir"); + let path = write_wrangler(dir.path(), &synthesise_wrangler_toml("demo")); + 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, + None, + ProvisionMode::Local, + false, + ) + .expect("local provision succeeds"); + assert!( + out.deployed.is_none(), + "local provision must not repopulate deployed: {:?}", + out.deployed + ); + let after = fs::read_to_string(&path).expect("read"); + assert!( + after.contains("[[kv_namespaces]]"), + "array-of-tables header emitted: {after}" + ); + assert!( + after.contains("binding = \"sessions\""), + "binding named after logical (no env overlay): {after}" + ); + assert!( + after.contains("id = \"\""), + "placeholder id derived from logical: {after}" + ); + assert!( + !after.contains("preview_id"), + "preview_id must NOT be synthesised without deployed data: {after}" + ); + } + + #[test] + fn cloudflare_local_provision_uses_deployed_namespace_id_when_set() { + // Deployed carries `kv_namespaces.sessions = "abc123"`. + // Expect the id cell in wrangler.toml to be "abc123" (deployed + // wins over placeholder). + let dir = tempdir().expect("tempdir"); + let path = write_wrangler(dir.path(), &synthesise_wrangler_toml("demo")); + let kv_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_KV_ID]); + let stores = ProvisionStores { + config: &[], + kv: &kv_ids, + secrets: &[], + }; + let state = deployed_kv(TEST_KV_ID, "abc123"); + let out = CloudflareCliAdapter + .provision( + dir.path(), + Some("wrangler.toml"), + None, + &stores, + Some(&state), + ProvisionMode::Local, + false, + ) + .expect("local provision succeeds"); + assert!(out.deployed.is_none()); + let after = fs::read_to_string(&path).expect("read"); + assert!( + after.contains("id = \"abc123\""), + "deployed id wins over placeholder: {after}" + ); + assert!( + !after.contains(""), + "no placeholder emitted when deployed provides an id: {after}" + ); + } + + #[test] + fn cloudflare_local_provision_preserves_sibling_operator_keys() { + // Operator hand-added `usage_model = "bundled"` on the + // [[kv_namespaces]] table. Provision must overwrite `id` from + // deployed but leave `usage_model` untouched. + let dir = tempdir().expect("tempdir"); + let path = write_wrangler( + dir.path(), + "name = \"demo\"\n[[kv_namespaces]]\nbinding = \"sessions\"\nid = \"operator-set\"\nusage_model = \"bundled\"\n", + ); + let kv_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_KV_ID]); + let stores = ProvisionStores { + config: &[], + kv: &kv_ids, + secrets: &[], + }; + let state = deployed_kv(TEST_KV_ID, "from-cloud"); + CloudflareCliAdapter + .provision( + dir.path(), + Some("wrangler.toml"), + None, + &stores, + Some(&state), + ProvisionMode::Local, + false, + ) + .expect("local provision succeeds"); + let after = fs::read_to_string(&path).expect("read"); + assert!( + after.contains("id = \"from-cloud\""), + "deployed id wins over existing local id: {after}" + ); + assert!( + after.contains("usage_model = \"bundled\""), + "operator sibling key preserved: {after}" + ); + assert_eq!( + after.matches("binding = \"sessions\"").count(), + 1, + "no duplicate binding entry: {after}" + ); + } + + #[test] + fn cloudflare_local_provision_falls_back_to_existing_local_id_when_no_deployed() { + // No deployed. Existing local id = "operator-set" is + // preserved (precedence: deployed -> existing -> placeholder). + let dir = tempdir().expect("tempdir"); + let path = write_wrangler( + dir.path(), + "name = \"demo\"\n[[kv_namespaces]]\nbinding = \"sessions\"\nid = \"operator-set\"\n", + ); + let kv_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_KV_ID]); + let stores = ProvisionStores { + config: &[], + kv: &kv_ids, + secrets: &[], + }; + CloudflareCliAdapter + .provision( + dir.path(), + Some("wrangler.toml"), + None, + &stores, + None, + ProvisionMode::Local, + false, + ) + .expect("local provision succeeds"); + let after = fs::read_to_string(&path).expect("read"); + assert!( + after.contains("id = \"operator-set\""), + "existing local id preserved when no deployed: {after}" + ); + assert!( + !after.contains(""), + "no placeholder emitted when existing id is present: {after}" + ); + } + + #[test] + fn cloudflare_local_provision_resolves_nested_adapter_manifest_path() { + // Mirrors the app-demo layout: adapter_manifest_path = + // "crates/cf/wrangler.toml". Pre-seed the nested file (Task + // 8b's CLI bootstrap does this before provision runs). + // Assert the upsert lands in the nested file and NOT in a + // sibling wrangler.toml at manifest_root. + let dir = tempdir().expect("tempdir"); + let nested_dir = dir.path().join("crates").join("cf"); + fs::create_dir_all(&nested_dir).expect("mkdir nested"); + let nested_path = nested_dir.join("wrangler.toml"); + fs::write(&nested_path, synthesise_wrangler_toml("demo")).expect("seed nested"); + let kv_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_KV_ID]); + let stores = ProvisionStores { + config: &[], + kv: &kv_ids, + secrets: &[], + }; + CloudflareCliAdapter + .provision( + dir.path(), + Some("crates/cf/wrangler.toml"), + None, + &stores, + None, + ProvisionMode::Local, + false, + ) + .expect("local provision succeeds"); + let after = fs::read_to_string(&nested_path).expect("read nested"); + assert!( + after.contains("binding = \"sessions\""), + "upsert landed in nested wrangler.toml: {after}" + ); + assert!( + after.contains("id = \"\""), + "placeholder id written into nested wrangler.toml: {after}" + ); + // A sibling wrangler.toml at manifest_root must NOT have + // been created. + assert!( + !dir.path().join("wrangler.toml").exists(), + "no sibling wrangler.toml at manifest_root: {}", + dir.path().display() + ); + } + + #[test] + fn cloudflare_local_provision_errors_if_manifest_absent() { + // Same nested path, but no pre-seed. The adapter trait + // doesn't receive app_name -- provision cannot synthesise + // the manifest itself; that's Task 8b's job. + 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(), + Some("crates/cf/wrangler.toml"), + None, + &stores, + None, + ProvisionMode::Local, + false, + ) + .expect_err("missing wrangler.toml must error"); + assert!( + err.contains("crates/cf/wrangler.toml") || err.contains("crates\\cf\\wrangler.toml"), + "error names the missing path: {err}" + ); + assert!( + err.contains("wrangler.toml"), + "error mentions wrangler.toml: {err}" + ); + } + + #[test] + fn cloudflare_local_provision_writes_platform_binding_looks_up_deployed_by_logical() { + // Env-overlay round-trip. Simulates + // EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME=prod_config + // via ResolvedStoreId::new(logical, platform). + // + // Deployed is keyed by LOGICAL ("app_config"); the binding + // cell in wrangler.toml must be PLATFORM ("prod_config"). + // Bug that collapses the split would either write + // binding = "app_config" (wrong: platform ignored) + // OR fail to find the deployed id (wrong: lookup used + // platform instead of logical). + let dir = tempdir().expect("tempdir"); + let path = write_wrangler(dir.path(), &synthesise_wrangler_toml("demo")); + let config_ids = vec![ResolvedStoreId::new(TEST_CONFIG_ID, "prod_config")]; + let stores = ProvisionStores { + config: &config_ids, + kv: &[], + secrets: &[], + }; + let state = deployed_kv(TEST_CONFIG_ID, "abc123"); + CloudflareCliAdapter + .provision( + dir.path(), + Some("wrangler.toml"), + None, + &stores, + Some(&state), + ProvisionMode::Local, + false, + ) + .expect("local provision succeeds"); + let after = fs::read_to_string(&path).expect("read"); + assert!( + after.contains("binding = \"prod_config\""), + "binding cell uses PLATFORM name: {after}" + ); + assert!( + !after.contains("binding = \"app_config\""), + "logical id must NOT leak into the binding cell: {after}" + ); + assert!( + after.contains("id = \"abc123\""), + "deployed id resolved via LOGICAL lookup: {after}" + ); + } } From da4a2b91938b31bef7ee8e0cc902eb9f4da7d2c0 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:56:28 -0700 Subject: [PATCH 31/71] Cloudflare: local-mode provision writes .dev.vars __NAME / __KEY lines --- crates/edgezero-adapter-cloudflare/src/cli.rs | 198 ++++++++++++++++++ 1 file changed, 198 insertions(+) diff --git a/crates/edgezero-adapter-cloudflare/src/cli.rs b/crates/edgezero-adapter-cloudflare/src/cli.rs index 037d8af1..61b661dc 100644 --- a/crates/edgezero-adapter-cloudflare/src/cli.rs +++ b/crates/edgezero-adapter-cloudflare/src/cli.rs @@ -9,6 +9,7 @@ use ctor::ctor; use edgezero_adapter::cli_support::{ find_manifest_upwards, find_workspace_root, path_distance, read_package_name, run_native_cli, }; +use edgezero_adapter::env_file::append_lines_dedup; use edgezero_adapter::registry::{ register_adapter, Adapter, AdapterAction, AdapterDeployedState, AdapterPushContext, ProvisionMode, ProvisionOutcome, ProvisionStores, ReadConfigEntry, ResolvedStoreId, @@ -985,12 +986,66 @@ fn provision_local( .map_err(|err| format!("failed to write {}: {err}", wrangler_path.display()))?; } + // `.dev.vars` lives NEXT TO the resolved wrangler.toml so + // `wrangler dev` picks it up automatically for nested layouts + // (e.g. `adapter_manifest_path = "crates/cf/wrangler.toml"`). + let dev_vars_path = wrangler_path + .parent() + .unwrap_or(manifest_root) + .join(".dev.vars"); + let dev_vars_lines = build_dev_vars_lines(stores); + append_lines_dedup(&dev_vars_path, &dev_vars_lines, dry_run) + .map_err(|err| format!("write {}: {err}", dev_vars_path.display()))?; + status_lines.push(format!( + "cloudflare: wrote {} .dev.vars entries to {}", + dev_vars_lines.len(), + dev_vars_path.display() + )); + Ok(ProvisionOutcome { status_lines, deployed: None, }) } +/// Build the `.dev.vars` line set emitted by [`provision_local`]. +/// +/// One `EDGEZERO__STORES______NAME=""` +/// entry per declared store (KV / CONFIG / SECRETS). CONFIG stores +/// additionally get a **commented** `__KEY` placeholder — Cloudflare +/// has no way to preview the KEY overlay at provision time, so we +/// hint the shape and let the operator uncomment + fill it in. +/// +/// Dedup responsibility is delegated to +/// [`edgezero_adapter::env_file::append_lines_dedup`]: because the +/// commented and uncommented forms normalise to the same key, an +/// operator who already uncommented + edited a KEY line survives a +/// re-run of provision — the commented placeholder is not re-added. +fn build_dev_vars_lines(stores: &ProvisionStores<'_>) -> Vec { + let mut lines: Vec = Vec::new(); + for (kind, kind_stores) in [ + ("KV", stores.kv), + ("CONFIG", stores.config), + ("SECRETS", stores.secrets), + ] { + for store in kind_stores { + let logical_upper = store.logical.to_ascii_uppercase(); + let platform = &store.platform; + lines.push(format!( + r#"EDGEZERO__STORES__{kind}__{logical_upper}__NAME="{platform}""# + )); + } + } + for store in stores.config { + let logical_upper = store.logical.to_ascii_uppercase(); + let logical = &store.logical; + lines.push(format!( + r#"# EDGEZERO__STORES__CONFIG__{logical_upper}__KEY="""# + )); + } + lines +} + /// In-memory upsert of a single `[[kv_namespaces]]` entry inside /// `doc`, matched by `binding = platform`. Precedence for the /// resolved id and `preview_id` is documented on [`provision_local`]. @@ -2894,4 +2949,147 @@ id = "00112233445566778899aabbccddeeff" "deployed id resolved via LOGICAL lookup: {after}" ); } + + // ---------- provision (Local mode) — .dev.vars emission ---------- + + #[test] + fn cloudflare_local_provision_writes_dev_vars_name_lines() { + // Fixture: [stores.config].ids = ["app_config"], + // [stores.kv].ids = ["sessions"]. No .dev.vars pre-existing. + // Provision must land the file next to wrangler.toml with a + // __NAME line per store and a commented __KEY placeholder for + // the config store. + let dir = tempdir().expect("tempdir"); + write_wrangler(dir.path(), &synthesise_wrangler_toml("demo")); + let kv_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_KV_ID]); + let config_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_CONFIG_ID]); + let stores = ProvisionStores { + config: &config_ids, + kv: &kv_ids, + secrets: &[], + }; + CloudflareCliAdapter + .provision( + dir.path(), + Some("wrangler.toml"), + None, + &stores, + None, + ProvisionMode::Local, + false, + ) + .expect("local provision succeeds"); + let dev_vars = fs::read_to_string(dir.path().join(".dev.vars")).expect("read .dev.vars"); + assert!( + dev_vars.contains(r#"EDGEZERO__STORES__KV__SESSIONS__NAME="sessions""#), + "KV __NAME line present: {dev_vars}" + ); + assert!( + dev_vars.contains(r#"EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME="app_config""#), + "CONFIG __NAME line present: {dev_vars}" + ); + assert!( + dev_vars.contains( + r#"# EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY="""# + ), + "commented CONFIG __KEY placeholder present: {dev_vars}" + ); + } + + #[test] + fn cloudflare_local_provision_dev_vars_dedup_respects_commented_overrides() { + // Operator has already uncommented + edited the KEY line. + // Re-running provision must NOT re-add the commented + // placeholder — normalised_key collapses commented and + // uncommented forms, so the operator's value survives. + let dir = tempdir().expect("tempdir"); + write_wrangler(dir.path(), &synthesise_wrangler_toml("demo")); + let dev_vars_path = dir.path().join(".dev.vars"); + fs::write( + &dev_vars_path, + "EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY=\"real_staging\"\n", + ) + .expect("seed .dev.vars"); + + let config_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_CONFIG_ID]); + let stores = ProvisionStores { + config: &config_ids, + kv: &[], + secrets: &[], + }; + CloudflareCliAdapter + .provision( + dir.path(), + Some("wrangler.toml"), + None, + &stores, + None, + ProvisionMode::Local, + false, + ) + .expect("local provision succeeds"); + let dev_vars = fs::read_to_string(&dev_vars_path).expect("read .dev.vars"); + assert!( + dev_vars.contains(r#"EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY="real_staging""#), + "operator's uncommented KEY line survives: {dev_vars}" + ); + assert!( + !dev_vars.contains(""), + "commented placeholder must NOT be re-added: {dev_vars}" + ); + // Exactly one line whose normalised key matches the KEY + // env-var name. The uncommented one wins. + let key_lines = dev_vars + .lines() + .filter(|line| { + let after_hash = line.trim_start().strip_prefix('#').unwrap_or(line); + after_hash + .trim_start() + .starts_with("EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY=") + }) + .count(); + assert_eq!( + key_lines, 1, + "exactly one KEY line remains after dedup: {dev_vars}" + ); + } + + #[test] + fn cloudflare_local_provision_dev_vars_uses_platform_name_when_env_overlay_active() { + // Env-overlay round-trip. Simulates + // EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME=prod_config + // via ResolvedStoreId::new(logical, platform). The emitted + // __NAME line's VALUE must be the env-resolved platform + // (`prod_config`); the ENV-VAR KEY must still use the + // LOGICAL id in upper-case (`APP_CONFIG`) so the runtime's + // env-overlay lookup finds it. + let dir = tempdir().expect("tempdir"); + write_wrangler(dir.path(), &synthesise_wrangler_toml("demo")); + let config_ids = vec![ResolvedStoreId::new(TEST_CONFIG_ID, "prod_config")]; + let stores = ProvisionStores { + config: &config_ids, + kv: &[], + secrets: &[], + }; + CloudflareCliAdapter + .provision( + dir.path(), + Some("wrangler.toml"), + None, + &stores, + None, + ProvisionMode::Local, + false, + ) + .expect("local provision succeeds"); + let dev_vars = fs::read_to_string(dir.path().join(".dev.vars")).expect("read .dev.vars"); + assert!( + dev_vars.contains(r#"EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME="prod_config""#), + "value uses PLATFORM name, env-var key uses LOGICAL: {dev_vars}" + ); + assert!( + !dev_vars.contains("EDGEZERO__STORES__CONFIG__PROD_CONFIG__NAME="), + "platform name must NOT leak into the env-var key: {dev_vars}" + ); + } } From 140728e5b318763f618ec0aa4a4f805079030f2e Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 2 Jul 2026 14:24:01 -0700 Subject: [PATCH 32/71] Cloudflare: provision_typed appends secret placeholders to .dev.vars --- crates/edgezero-adapter-cloudflare/src/cli.rs | 218 +++++++++++++++++- 1 file changed, 217 insertions(+), 1 deletion(-) diff --git a/crates/edgezero-adapter-cloudflare/src/cli.rs b/crates/edgezero-adapter-cloudflare/src/cli.rs index 61b661dc..23f18a66 100644 --- a/crates/edgezero-adapter-cloudflare/src/cli.rs +++ b/crates/edgezero-adapter-cloudflare/src/cli.rs @@ -13,6 +13,7 @@ use edgezero_adapter::env_file::append_lines_dedup; use edgezero_adapter::registry::{ register_adapter, Adapter, AdapterAction, AdapterDeployedState, AdapterPushContext, ProvisionMode, ProvisionOutcome, ProvisionStores, ReadConfigEntry, ResolvedStoreId, + TypedSecretEntry, }; use edgezero_adapter::scaffold::{ register_adapter_blueprint, AdapterBlueprint, AdapterFileSpec, CommandTemplates, @@ -134,7 +135,7 @@ 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. `read_config_entry` and `read_config_entry_local` are both overridden below (wrangler kv key get --remote / --local). `single_store_kinds` IS overridden below (returns `&[\"secrets\"]`). `synthesise_baseline_manifest` IS overridden below (emits a baseline `wrangler.toml` for the Task 8b clean-clone bootstrap)." + reason = "cloudflare has no validate_app_config_keys / validate_adapter_manifest / validate_typed_secrets requirements; those three trait defaults are intentionally inherited. `read_config_entry` and `read_config_entry_local` are both overridden below (wrangler kv key get --remote / --local). `single_store_kinds` IS overridden below (returns `&[\"secrets\"]`). `synthesise_baseline_manifest` IS overridden below (emits a baseline `wrangler.toml` for the Task 8b clean-clone bootstrap). `provision_typed` IS overridden below (appends `=\"\"` secret placeholders to `.dev.vars` in Local mode; Cloud is a no-op — `wrangler secret put` is the remote path)." )] impl Adapter for CloudflareCliAdapter { fn deployed_fields(&self) -> &'static [&'static str] { @@ -343,6 +344,53 @@ impl Adapter for CloudflareCliAdapter { }) } + fn provision_typed( + &self, + manifest_root: &Path, + adapter_manifest_path: Option<&str>, + _component_selector: Option<&str>, + typed_secrets: &[TypedSecretEntry<'_>], + mode: ProvisionMode, + dry_run: bool, + ) -> Result { + // Cloud is a no-op: `wrangler secret put` is the tool for + // remote secret upload. `provision_typed` handles ONLY the + // local preview writeback — a `=""` placeholder + // per typed field, appended to the SAME `.dev.vars` file + // `provision_local` seeds with `EDGEZERO__STORES__…__NAME` / + // `__KEY` overlays. + if !matches!(mode, ProvisionMode::Local) { + return Ok(ProvisionOutcome::default()); + } + // Anchor `.dev.vars` on the RESOLVED wrangler.toml path so + // nested layouts (e.g. `adapter_manifest_path = + // "crates/app-demo-adapter-cloudflare/wrangler.toml"`) land + // the file in the same crate dir wrangler dev reads from, + // NOT at `manifest_root/.dev.vars`. Mirrors the placement + // `provision_local` uses for the __NAME / __KEY lines. + let wrangler_rel = adapter_manifest_path.unwrap_or("wrangler.toml"); + let wrangler_path = manifest_root.join(wrangler_rel); + let dev_vars_path = wrangler_path + .parent() + .unwrap_or(manifest_root) + .join(".dev.vars"); + let lines: Vec = typed_secrets + .iter() + .map(|entry| format!(r#"{}="""#, entry.key_value)) + .collect(); + append_lines_dedup(&dev_vars_path, &lines, dry_run) + .map_err(|err| format!("write {}: {err}", dev_vars_path.display()))?; + let status_lines = vec![format!( + "cloudflare: wrote {} secret placeholders to {}", + typed_secrets.len(), + dev_vars_path.display() + )]; + Ok(ProvisionOutcome { + status_lines, + deployed: None, + }) + } + fn push_config_entries( &self, manifest_root: &Path, @@ -3092,4 +3140,172 @@ id = "00112233445566778899aabbccddeeff" "platform name must NOT leak into the env-var key: {dev_vars}" ); } + + // ---------- provision_typed (Local mode) — secret placeholders ---------- + + #[test] + fn cloudflare_provision_typed_appends_secret_placeholders_to_dev_vars() { + // Fixture: nested wrangler.toml layout matching app-demo. + // provision_typed writes `=""` per entry into the + // `.dev.vars` NEXT TO the wrangler manifest (append_lines_dedup + // creates parent dirs, so no pre-seed of the wrangler.toml is + // required for this test). + let dir = tempdir().expect("tempdir"); + let entries = [TypedSecretEntry::new( + TEST_SECRET_ID, + "api_token", + "demo_api_token", + )]; + let outcome = CloudflareCliAdapter + .provision_typed( + dir.path(), + Some("crates/cf/wrangler.toml"), + None, + &entries, + ProvisionMode::Local, + false, + ) + .expect("provision_typed succeeds"); + let dev_vars_path = dir.path().join("crates/cf/.dev.vars"); + assert!( + dev_vars_path.exists(), + ".dev.vars exists at nested path: {}", + dev_vars_path.display() + ); + let dev_vars = fs::read_to_string(&dev_vars_path).expect("read .dev.vars"); + assert!( + dev_vars.contains(r#"demo_api_token="""#), + "placeholder line present: {dev_vars}" + ); + assert!( + outcome + .status_lines + .iter() + .any(|line| line.contains(&dev_vars_path.display().to_string())), + "status line names the .dev.vars path: {:?}", + outcome.status_lines + ); + assert!( + outcome.deployed.is_none(), + "local provision_typed returns no deployed state" + ); + } + + #[test] + fn cloudflare_provision_typed_dev_vars_lands_next_to_wrangler_toml() { + // Locks the `wrangler_path.parent().join(".dev.vars")` + // anchor against drift: with `adapter_manifest_path = + // "crates/cf/wrangler.toml"`, `.dev.vars` MUST land at + // `temp/crates/cf/.dev.vars` and NOT at `temp/.dev.vars`. + let dir = tempdir().expect("tempdir"); + let entries = [TypedSecretEntry::new( + TEST_SECRET_ID, + "api_token", + "demo_api_token", + )]; + CloudflareCliAdapter + .provision_typed( + dir.path(), + Some("crates/cf/wrangler.toml"), + None, + &entries, + ProvisionMode::Local, + false, + ) + .expect("provision_typed succeeds"); + assert!( + dir.path().join("crates/cf/.dev.vars").exists(), + ".dev.vars anchored on wrangler.toml parent" + ); + assert!( + !dir.path().join(".dev.vars").exists(), + "root-level .dev.vars must NOT be written" + ); + } + + #[test] + fn cloudflare_provision_typed_cloud_mode_is_a_no_op() { + // Cloud is a no-op: `wrangler secret put` is the remote + // path. Empty outcome, no `.dev.vars` written anywhere. + let dir = tempdir().expect("tempdir"); + let entries = [TypedSecretEntry::new( + TEST_SECRET_ID, + "api_token", + "demo_api_token", + )]; + let outcome = CloudflareCliAdapter + .provision_typed( + dir.path(), + Some("crates/cf/wrangler.toml"), + None, + &entries, + ProvisionMode::Cloud, + false, + ) + .expect("provision_typed Cloud succeeds"); + assert!( + outcome.status_lines.is_empty(), + "cloud mode emits no status lines: {:?}", + outcome.status_lines + ); + assert!( + outcome.deployed.is_none(), + "cloud mode returns no deployed state" + ); + assert!( + !dir.path().join("crates/cf/.dev.vars").exists(), + "cloud mode must NOT touch .dev.vars" + ); + assert!( + !dir.path().join(".dev.vars").exists(), + "cloud mode must NOT touch .dev.vars at manifest_root either" + ); + } + + #[test] + fn cloudflare_provision_typed_deduplicates_against_existing_dev_vars() { + // Operator has already filled in the real value. Re-running + // provision_typed must NOT clobber it with the empty + // placeholder — append_lines_dedup collapses keys. + let dir = tempdir().expect("tempdir"); + let dev_vars_dir = dir.path().join("crates/cf"); + fs::create_dir_all(&dev_vars_dir).expect("mkdir nested"); + let dev_vars_path = dev_vars_dir.join(".dev.vars"); + fs::write(&dev_vars_path, "demo_api_token=\"already_set\"\n").expect("seed .dev.vars"); + let entries = [TypedSecretEntry::new( + TEST_SECRET_ID, + "api_token", + "demo_api_token", + )]; + CloudflareCliAdapter + .provision_typed( + dir.path(), + Some("crates/cf/wrangler.toml"), + None, + &entries, + ProvisionMode::Local, + false, + ) + .expect("provision_typed succeeds"); + let dev_vars = fs::read_to_string(&dev_vars_path).expect("read .dev.vars"); + assert!( + dev_vars.contains(r#"demo_api_token="already_set""#), + "operator's real value survives: {dev_vars}" + ); + assert!( + !dev_vars.contains(r#"demo_api_token="""#), + "empty-value placeholder must NOT be appended: {dev_vars}" + ); + let token_lines = dev_vars + .lines() + .filter(|line| { + let after_hash = line.trim_start().strip_prefix('#').unwrap_or(line); + after_hash.trim_start().starts_with("demo_api_token=") + }) + .count(); + assert_eq!( + token_lines, 1, + "exactly one demo_api_token line remains: {dev_vars}" + ); + } } From 84f89080963ab58065387626fc52cfc55828f4a3 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 2 Jul 2026 14:34:14 -0700 Subject: [PATCH 33/71] Fastly: primitive synthesiser for fastly.toml + bootstrap override --- crates/edgezero-adapter-fastly/src/cli.rs | 117 +++++++++++++++++++++- 1 file changed, 116 insertions(+), 1 deletion(-) diff --git a/crates/edgezero-adapter-fastly/src/cli.rs b/crates/edgezero-adapter-fastly/src/cli.rs index 33c2fd99..f8e8e5b2 100644 --- a/crates/edgezero-adapter-fastly/src/cli.rs +++ b/crates/edgezero-adapter-fastly/src/cli.rs @@ -166,7 +166,7 @@ enum ConfigStoreLookup { // `&[]` 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. `read_config_entry` and `read_config_entry_local` are both overridden below. `single_store_kinds` IS overridden below (returns `&[]`)." + reason = "see the explanatory block comment immediately above; fastly's no-op defaults for the three validate_* hooks are intentional and documented. `read_config_entry` and `read_config_entry_local` are both overridden below. `single_store_kinds` IS overridden below (returns `&[]`). `synthesise_baseline_manifest` IS overridden below (emits a baseline `fastly.toml` for the Task 8b clean-clone bootstrap, threading `[adapters.fastly.deployed].service_id` through when present)." )] impl Adapter for FastlyCliAdapter { fn deployed_fields(&self) -> &'static [&'static str] { @@ -667,6 +667,29 @@ impl Adapter for FastlyCliAdapter { // unlike spin's flat-namespace single-store model. &[] } + + fn synthesise_baseline_manifest( + &self, + _manifest_root: &Path, + adapter_manifest_path: Option<&str>, + _component_selector: Option<&str>, + app_name: &str, + deployed: Option<&AdapterDeployedState>, + ) -> Result, String> { + // The CLI's `deployed_state_for` translator (Task 8b) copies + // `[adapters.fastly.deployed].service_id` into + // `deployed.fields["service_id"]` before calling this override, + // so the adapter reads the flat field bag and never links to + // `edgezero-core`. + let deployed_service_id = deployed + .and_then(|state| state.fields.get("service_id")) + .map(String::as_str); + let rel = adapter_manifest_path.map_or_else(|| PathBuf::from("fastly.toml"), PathBuf::from); + Ok(vec![( + rel, + synthesise_fastly_toml(app_name, deployed_service_id), + )]) + } } /// Fetch a single entry value from a remote Fastly Config Store entry by @@ -1376,6 +1399,50 @@ fn register_ctor() { register(); } +/// Synthesised baseline `fastly.toml` for clean clones. Built via +/// `toml_edit::DocumentMut` (NOT raw `format!`) so any legal +/// `[app].name` — including names with TOML-significant characters +/// like `"`, `\`, or newlines — is escaped correctly. Manifest +/// validation today only length-bounds the name; raw interpolation +/// would produce invalid TOML for legal inputs. +/// +/// `service_id` from `[adapters.fastly.deployed]` is threaded +/// through as `Option<&str>`; when `None` the key is OMITTED so the +/// operator's first `fastly compute deploy` populates it (per spec +/// §"Writeback ownership" — we deliberately don't emit +/// `service_id = ""`). +pub(crate) fn synthesise_fastly_toml(app_name: &str, service_id: Option<&str>) -> String { + use toml_edit::{value, DocumentMut, Item, Table}; + + let mut doc = DocumentMut::new(); + doc.decor_mut().set_prefix("# edgezero-provision: v1\n"); + // `Table::insert` returns the previous value (if any). We build a + // fresh document from `DocumentMut::new()`, so nothing to displace + // -- but the return is discarded intentionally. Using `insert` + // instead of `doc["..."] = ...` sidesteps `clippy::indexing_slicing` + // (the index form panics if the key is missing; `insert` doesn't). + doc.insert("manifest_version", value(3)); + doc.insert("name", value(app_name)); + doc.insert("language", value("rust")); + if let Some(sid) = service_id { + doc.insert("service_id", value(sid)); + } + // `[scripts]` and `[local_server]` are the standard Fastly Compute + // scaffold tables. `scripts.build` pins the cargo target so + // `fastly compute build` reproduces the wasm artifact; the empty + // `[local_server]` header is a placeholder the operator fills in + // when seeding local viceroy state (config-store contents, + // per-request backends, etc.). + let mut scripts = Table::new(); + scripts.insert( + "build", + value("cargo build --profile release --target wasm32-wasip1"), + ); + doc.insert("scripts", Item::Table(scripts)); + doc.insert("local_server", Item::Table(Table::new())); + doc.to_string() +} + /// # Errors /// Returns an error if the Fastly CLI serve command (Viceroy) fails. #[inline] @@ -3499,4 +3566,52 @@ build = \"cargo build --release\" "old envelope A's chunks must be inert -- read must NOT return A" ); } + + // ---------- synthesise_fastly_toml ---------- + + #[test] + fn synthesises_minimal_fastly_toml_with_header_and_no_service_id() { + let out = synthesise_fastly_toml("demo", None); + assert!(out.starts_with("# edgezero-provision: v1")); + assert!(out.contains("manifest_version = 3")); + assert!(out.contains(r#"name = "demo""#)); + assert!(out.contains(r#"language = "rust""#)); + assert!(out.contains("[scripts]")); + assert!(out.contains("[local_server]")); + assert!( + !out.contains("service_id"), + "no service_id key when None: {out}" + ); + } + + #[test] + fn synthesises_fastly_toml_pins_service_id_when_deployed_present() { + let out = synthesise_fastly_toml("demo", Some("SVC1")); + assert!(out.contains(r#"service_id = "SVC1""#)); + } + + #[test] + fn synthesise_fastly_toml_escapes_pathological_app_names() { + for name in [ + r#"has"quote"#, + r"has\backslash", + "has\nnewline", + "has = equals", + ] { + let out = synthesise_fastly_toml(name, None); + // Re-parsing must succeed AND round-trip the name. + let doc: toml_edit::DocumentMut = out.parse().unwrap(); + assert_eq!(doc["name"].as_str(), Some(name), "input: {name:?}"); + } + } + + #[test] + fn synthesise_fastly_toml_escapes_pathological_service_ids() { + // `fastly compute deploy` may return arbitrary strings. + for sid in [r#"has"quote"#, r"has\slash", "has\nnewline"] { + let out = synthesise_fastly_toml("demo", Some(sid)); + let doc: toml_edit::DocumentMut = out.parse().unwrap(); + assert_eq!(doc["service_id"].as_str(), Some(sid), "input: {sid:?}"); + } + } } From 706d2cd4a1a7f1aa65406ed3eb4bd70690c46539 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 2 Jul 2026 14:48:40 -0700 Subject: [PATCH 34/71] Fastly: local-mode provision writes [local_server.*] + edgezero_runtime_env --- crates/edgezero-adapter-fastly/src/cli.rs | 588 +++++++++++++++++++++- 1 file changed, 586 insertions(+), 2 deletions(-) diff --git a/crates/edgezero-adapter-fastly/src/cli.rs b/crates/edgezero-adapter-fastly/src/cli.rs index f8e8e5b2..46228fa7 100644 --- a/crates/edgezero-adapter-fastly/src/cli.rs +++ b/crates/edgezero-adapter-fastly/src/cli.rs @@ -208,13 +208,21 @@ impl Adapter for FastlyCliAdapter { adapter_manifest_path: Option<&str>, _component_selector: Option<&str>, stores: &ProvisionStores<'_>, - _deployed: Option<&AdapterDeployedState>, + deployed: Option<&AdapterDeployedState>, mode: ProvisionMode, dry_run: bool, ) -> Result { match mode { ProvisionMode::Cloud => {} - ProvisionMode::Local => return Err("local mode lands in Section 5".to_owned()), + ProvisionMode::Local => { + return provision_local( + manifest_root, + adapter_manifest_path, + stores, + deployed, + dry_run, + ); + } } // Fastly is Multi for every store kind. Each id maps 1:1 // to a Fastly resource (kv-store / config-store / @@ -1037,6 +1045,268 @@ fn write_fastly_local_config_store( Ok(()) } +/// Local-mode provision: seed Viceroy state in `fastly.toml` for the +/// declared stores + the `edgezero_runtime_env` runtime-override +/// store. NO shell-outs to `fastly` -- everything is a `toml_edit` +/// mutation, so operators can run `provision --local` without +/// authenticating. +/// +/// The manifest must already exist (Task 8b's CLI bootstrap writes it +/// via `synthesise_fastly_toml`); we deliberately don't re-synthesise +/// here because the app name isn't in scope at this call site. +/// +/// `deployed.fields.get("service_id")`, when present, is upserted to +/// the top-level `service_id` key -- spec says the deployed +/// service-id wins over anything the operator pre-seeded from a stale +/// template. When `deployed` has no `service_id` we leave any existing +/// value alone (operator's local seed is authoritative). +/// +/// All other mutations (kv-store blocks, config-store blocks, runtime +/// override block) are idempotent — re-running is a no-op. +fn provision_local( + manifest_root: &Path, + adapter_manifest_path: Option<&str>, + stores: &ProvisionStores<'_>, + deployed: Option<&AdapterDeployedState>, + dry_run: bool, +) -> Result { + use toml_edit::{value, DocumentMut}; + + let fastly_rel = adapter_manifest_path.unwrap_or("fastly.toml"); + let fastly_path = manifest_root.join(fastly_rel); + if !fastly_path.exists() { + return Err(format!( + "expected fastly.toml at {} (Task 8b's CLI bootstrap should have written it before provision ran)", + fastly_path.display() + )); + } + let raw = fs::read_to_string(&fastly_path) + .map_err(|err| format!("failed to read {}: {err}", fastly_path.display()))?; + let mut doc: DocumentMut = raw + .parse() + .map_err(|err| format!("failed to parse {}: {err}", fastly_path.display()))?; + + let mut status_lines: Vec = Vec::new(); + + // 1. Upsert top-level `service_id` from deployed. Applies to BOTH + // synthesis and MERGE paths -- operators who pre-seeded + // fastly.toml from a stale template still get the cloud- + // authoritative id pinned. No cloud authority => leave any + // existing operator-set value alone. + if let Some(sid) = deployed.and_then(|state| state.fields.get("service_id")) { + doc.insert("service_id", value(sid.as_str())); + status_lines.push(format!( + "fastly: pinned service_id = \"{sid}\" from deployed" + )); + } + + // 2. [[local_server.kv_stores.]] per KV store. + for store in stores.kv { + upsert_local_kv_store(&mut doc, &store.platform)?; + status_lines.push(format!( + "fastly: local kv_store `{}` (logical id `{}`)", + store.platform, store.logical + )); + } + + // 3. [local_server.config_stores.] + empty `.contents` + // sub-table per CONFIG store. `contents` MUST be a TOML table + // (not `contents = ""`) -- the `config push --local` writer + // edits it in place via `as_table_mut()`. + for store in stores.config { + upsert_local_config_store(&mut doc, &store.platform)?; + status_lines.push(format!( + "fastly: local config_store `{}` (logical id `{}`)", + store.platform, store.logical + )); + } + + // 4. `edgezero_runtime_env` block: __NAME lines for all kinds + + // commented __KEY placeholders for CONFIG stores. Same + // discipline as Cloudflare `.dev.vars`. + if upsert_runtime_env_config_store(&mut doc, stores)? { + status_lines.push("fastly: wrote edgezero_runtime_env block".to_owned()); + } + + if !dry_run { + fs::write(&fastly_path, doc.to_string()) + .map_err(|err| format!("failed to write {}: {err}", fastly_path.display()))?; + } + + Ok(ProvisionOutcome { + status_lines, + deployed: None, + }) +} + +/// Append `[[local_server.kv_stores.]]` with a stub +/// `key = "__init__"` / `data = ""` row to `doc`, IFF no entry with +/// that platform name already exists. Idempotent. +fn upsert_local_kv_store( + doc: &mut toml_edit::DocumentMut, + platform_name: &str, +) -> Result<(), String> { + use toml_edit::{value, ArrayOfTables, Item, Table}; + + let local_server_entry = doc + .entry("local_server") + .or_insert_with(|| Item::Table(Table::new())); + let local_server_tbl = local_server_entry.as_table_mut().ok_or_else(|| { + "`local_server` exists but is not a table; refusing to edit in place".to_owned() + })?; + let kv_stores_entry = local_server_tbl + .entry("kv_stores") + .or_insert_with(|| Item::Table(Table::new())); + let kv_stores_tbl = kv_stores_entry.as_table_mut().ok_or_else(|| { + "`local_server.kv_stores` exists but is not a table; refusing to edit in place".to_owned() + })?; + // Idempotent: skip if an array-of-tables (or anything) already + // registered for this platform name. + if kv_stores_tbl.contains_key(platform_name) { + return Ok(()); + } + let mut arr = ArrayOfTables::new(); + let mut row = Table::new(); + row.insert("key", value("__init__")); + row.insert("data", value("")); + arr.push(row); + kv_stores_tbl.insert(platform_name, Item::ArrayOfTables(arr)); + Ok(()) +} + +/// Insert `[local_server.config_stores.]` with +/// `format = "inline-toml"` and an EMPTY `contents` sub-TABLE. The +/// empty table (NOT `contents = ""`) is load-bearing: the Fastly +/// `config push --local` writer edits `contents` in place via +/// `as_table_mut()` and refuses to proceed if it isn't a table. +/// Idempotent — skip if the block already exists. +fn upsert_local_config_store( + doc: &mut toml_edit::DocumentMut, + platform_name: &str, +) -> Result<(), String> { + use toml_edit::{value, Item, Table}; + + let local_server_entry = doc + .entry("local_server") + .or_insert_with(|| Item::Table(Table::new())); + let local_server_tbl = local_server_entry.as_table_mut().ok_or_else(|| { + "`local_server` exists but is not a table; refusing to edit in place".to_owned() + })?; + 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(|| { + "`local_server.config_stores` exists but is not a table; refusing to edit in place" + .to_owned() + })?; + if config_stores_tbl.contains_key(platform_name) { + return Ok(()); + } + let mut store_tbl = Table::new(); + store_tbl.set_implicit(false); + store_tbl.insert("format", value("inline-toml")); + let mut contents_tbl = Table::new(); + contents_tbl.set_implicit(false); + store_tbl.insert("contents", Item::Table(contents_tbl)); + config_stores_tbl.insert(platform_name, Item::Table(store_tbl)); + Ok(()) +} + +/// Insert `[local_server.config_stores.edgezero_runtime_env]` with +/// `format = "inline-toml"` and a `.contents` sub-table containing: +/// - one `EDGEZERO__STORES______NAME = ""` +/// line per declared store across ALL kinds (KV / CONFIG / SECRETS); +/// - one COMMENTED `# EDGEZERO__STORES__CONFIG____KEY = +/// "_staging"` placeholder per CONFIG store, mirroring the +/// Cloudflare `.dev.vars` discipline. Fastly has no way to preview +/// the KEY overlay at provision time — commented placeholders hint +/// the shape and let the operator uncomment + fill it in. +/// +/// Idempotent — skip if the block already exists. Returns `true` when +/// the block was newly written, `false` when it was already present. +fn upsert_runtime_env_config_store( + doc: &mut toml_edit::DocumentMut, + stores: &ProvisionStores<'_>, +) -> Result { + use toml_edit::{value, Item, Table}; + + const RUNTIME_ENV_NAME: &str = "edgezero_runtime_env"; + + let local_server_entry = doc + .entry("local_server") + .or_insert_with(|| Item::Table(Table::new())); + let local_server_tbl = local_server_entry.as_table_mut().ok_or_else(|| { + "`local_server` exists but is not a table; refusing to edit in place".to_owned() + })?; + 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(|| { + "`local_server.config_stores` exists but is not a table; refusing to edit in place" + .to_owned() + })?; + if config_stores_tbl.contains_key(RUNTIME_ENV_NAME) { + return Ok(false); + } + + let mut store_tbl = Table::new(); + store_tbl.set_implicit(false); + store_tbl.insert("format", value("inline-toml")); + + let mut contents_tbl = Table::new(); + contents_tbl.set_implicit(false); + for (kind_label, kind_stores) in [ + ("KV", stores.kv), + ("CONFIG", stores.config), + ("SECRETS", stores.secrets), + ] { + for store in kind_stores { + let key = format!( + "EDGEZERO__STORES__{kind_label}__{}__NAME", + store.logical.to_ascii_uppercase() + ); + contents_tbl.insert(&key, value(store.platform.as_str())); + } + } + + // Commented `__KEY` placeholders for CONFIG stores. Toml_edit + // has no primitive for "commented key inside a table", so we + // stash the comment lines as a suffix on the last-inserted + // key/value's decor. The test asserts only presence-as-substring + // in the raw file text, so location within the block doesn't + // matter — but appending at the end keeps the __NAME contract + // uncontaminated (a re-parse still yields only real keys). + let comment_suffix: String = stores + .config + .iter() + .map(|store| { + let upper = store.logical.to_ascii_uppercase(); + let logical = store.logical.as_str(); + format!("\n# EDGEZERO__STORES__CONFIG__{upper}__KEY = \"{logical}_staging\"") + }) + .collect::>() + .concat(); + if !comment_suffix.is_empty() { + let last_key = contents_tbl.iter().last().map(|(key, _)| key.to_owned()); + if let Some(last) = last_key { + if let Some(item) = contents_tbl.get_mut(&last) { + if let Some(val) = item.as_value_mut() { + val.decor_mut().set_suffix(comment_suffix); + } + } + } else { + // Edge case: no declared stores at all (contents_tbl is + // empty). Attach the comments via the contents table's + // own decor so they survive serialisation. + contents_tbl.decor_mut().set_suffix(comment_suffix); + } + } + + store_tbl.insert("contents", Item::Table(contents_tbl)); + config_stores_tbl.insert(RUNTIME_ENV_NAME, Item::Table(store_tbl)); + Ok(true) +} + // ------------------------------------------------------------------- // `config push` helpers // ------------------------------------------------------------------- @@ -2224,6 +2494,320 @@ build = \"cargo build --release\" ); } + // ---------- provision (local mode) ---------- + + /// Local provision writes `[[local_server.kv_stores.]]` + /// and `[local_server.config_stores.]` blocks. The + /// config-store block's `contents` MUST be a TOML table (not + /// `contents = ""`), because the Fastly `config push --local` + /// writer edits it in place via `as_table_mut()`. + #[test] + fn fastly_local_provision_writes_kv_and_config_store_blocks() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write(&path, synthesise_fastly_toml("demo", None)).expect("write"); + let kv_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_KV_ID]); + let config_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_CONFIG_ID]); + let stores = ProvisionStores { + config: &config_ids, + kv: &kv_ids, + secrets: &[], + }; + FastlyCliAdapter + .provision( + dir.path(), + Some("fastly.toml"), + None, + &stores, + None, + ProvisionMode::Local, + false, + ) + .expect("local provision succeeds"); + let after = fs::read_to_string(&path).expect("read"); + // KV: array-of-tables with the stub row. + assert!( + after.contains("[[local_server.kv_stores.sessions]]"), + "kv block present: {after}" + ); + assert!( + after.contains(r#"key = "__init__""#), + "kv stub key present: {after}" + ); + assert!( + after.contains(r#"data = """#), + "kv stub data present: {after}" + ); + // CONFIG: table block plus empty contents SUB-TABLE (not + // `contents = ""`). Re-parse to confirm shape. + assert!( + after.contains("[local_server.config_stores.app_config]"), + "config-store block header present: {after}" + ); + assert!( + after.contains(r#"format = "inline-toml""#), + "config-store format key present: {after}" + ); + assert!( + after.contains("[local_server.config_stores.app_config.contents]"), + "config-store contents sub-table header present: {after}" + ); + assert!( + !after.contains(r#"contents = """#), + "contents MUST NOT be an empty string: {after}" + ); + let doc: toml_edit::DocumentMut = after.parse().expect("re-parse"); + assert!( + doc["local_server"]["config_stores"]["app_config"]["contents"] + .as_table() + .is_some(), + "contents parses as a table (required by config push --local)" + ); + } + + /// Local provision writes the `edgezero_runtime_env` runtime- + /// override block: `__NAME` lines for ALL declared kinds and + /// commented `__KEY` placeholders for CONFIG stores only. + #[test] + fn fastly_local_provision_writes_edgezero_runtime_env() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write(&path, synthesise_fastly_toml("demo", None)).expect("write"); + let kv_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_KV_ID]); + let config_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_CONFIG_ID]); + let stores = ProvisionStores { + config: &config_ids, + kv: &kv_ids, + secrets: &[], + }; + FastlyCliAdapter + .provision( + dir.path(), + Some("fastly.toml"), + None, + &stores, + None, + ProvisionMode::Local, + false, + ) + .expect("local provision succeeds"); + let after = fs::read_to_string(&path).expect("read"); + assert!( + after.contains("[local_server.config_stores.edgezero_runtime_env]"), + "runtime-env block header present: {after}" + ); + assert!( + after.contains("[local_server.config_stores.edgezero_runtime_env.contents]"), + "runtime-env contents sub-table header present: {after}" + ); + assert!( + after.contains(r#"EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME = "app_config""#), + "CONFIG __NAME line: {after}" + ); + assert!( + after.contains(r#"EDGEZERO__STORES__KV__SESSIONS__NAME = "sessions""#), + "KV __NAME line: {after}" + ); + assert!( + after.contains(r#"# EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY = "app_config_staging""#), + "commented CONFIG __KEY placeholder present: {after}" + ); + } + + /// A missing `fastly.toml` is a bug in the Task 8b bootstrap path. + /// Provision must error CLEARLY -- naming the expected path -- + /// rather than silently re-synthesising (we don't have the app + /// name in scope here). + #[test] + fn fastly_local_provision_errors_if_manifest_absent() { + let dir = tempdir().expect("tempdir"); + // Do NOT pre-seed fastly.toml. + let stores = ProvisionStores { + config: &[], + kv: &[], + secrets: &[], + }; + let err = FastlyCliAdapter + .provision( + dir.path(), + Some("fastly.toml"), + None, + &stores, + None, + ProvisionMode::Local, + false, + ) + .expect_err("missing manifest must error"); + assert!( + err.contains("fastly.toml"), + "error names the missing path: {err}" + ); + assert!( + err.contains(&dir.path().join("fastly.toml").display().to_string()), + "error contains the resolved absolute path: {err}" + ); + } + + /// Spec §"Fastly": the deployed `service_id` must be upserted + /// during BOTH synthesis AND merge. Task 21 handles synthesis + /// (first-run bootstrap); THIS lock covers the merge case where + /// the operator pre-seeded fastly.toml from a stale template + /// before a deploy happened. + #[test] + fn fastly_local_provision_upserts_deployed_service_id_into_existing_manifest() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + // Pre-seed WITHOUT service_id. + fs::write(&path, synthesise_fastly_toml("demo", None)).expect("write"); + assert!( + !fs::read_to_string(&path) + .expect("read") + .contains("service_id"), + "baseline has no service_id" + ); + let mut deployed = AdapterDeployedState::default(); + deployed + .fields + .insert("service_id".to_owned(), "SVC1".to_owned()); + let stores = ProvisionStores { + config: &[], + kv: &[], + secrets: &[], + }; + FastlyCliAdapter + .provision( + dir.path(), + Some("fastly.toml"), + None, + &stores, + Some(&deployed), + ProvisionMode::Local, + false, + ) + .expect("local provision succeeds"); + let after = fs::read_to_string(&path).expect("read"); + assert!( + after.contains(r#"service_id = "SVC1""#), + "deployed service_id pinned into merged manifest: {after}" + ); + } + + /// Inverse of the previous lock: when there's no cloud authority + /// (deployed = None), operator's local value wins. Provision must + /// NOT overwrite a `service_id` the operator set themselves. + #[test] + fn fastly_local_provision_leaves_operator_service_id_alone_when_deployed_absent() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write(&path, synthesise_fastly_toml("demo", Some("operator-set"))).expect("write"); + let stores = ProvisionStores { + config: &[], + kv: &[], + secrets: &[], + }; + FastlyCliAdapter + .provision( + dir.path(), + Some("fastly.toml"), + None, + &stores, + None, + ProvisionMode::Local, + false, + ) + .expect("local provision succeeds"); + let after = fs::read_to_string(&path).expect("read"); + assert!( + after.contains(r#"service_id = "operator-set""#), + "operator-set service_id survives when deployed absent: {after}" + ); + } + + /// `adapter_manifest_path` may be a NESTED relative path (e.g. + /// `crates/fastly/fastly.toml`). Provision must land its writes + /// in the nested file, NOT at a sibling under `manifest_root`. + #[test] + fn fastly_local_provision_resolves_nested_adapter_manifest_path() { + let dir = tempdir().expect("tempdir"); + let nested_rel = "crates/fastly/fastly.toml"; + let nested_path = dir.path().join(nested_rel); + fs::create_dir_all(nested_path.parent().expect("parent")).expect("mkdir"); + fs::write(&nested_path, synthesise_fastly_toml("demo", None)).expect("write"); + let kv_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_KV_ID]); + let stores = ProvisionStores { + config: &[], + kv: &kv_ids, + secrets: &[], + }; + FastlyCliAdapter + .provision( + dir.path(), + Some(nested_rel), + None, + &stores, + None, + ProvisionMode::Local, + false, + ) + .expect("local provision succeeds"); + let after = fs::read_to_string(&nested_path).expect("read nested"); + assert!( + after.contains("[[local_server.kv_stores.sessions]]"), + "merge lands in nested manifest: {after}" + ); + // And no sibling was created at manifest_root level. + let sibling = dir.path().join("fastly.toml"); + assert!( + !sibling.exists(), + "no sibling fastly.toml created at manifest_root" + ); + } + + /// Idempotency lock: running local provision twice on the same + /// fixture must leave the manifest bit-for-bit unchanged (mod the + /// first-run mutation). + #[test] + fn fastly_local_provision_is_idempotent_on_second_run() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write(&path, synthesise_fastly_toml("demo", None)).expect("write"); + let kv_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_KV_ID]); + let config_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_CONFIG_ID]); + let stores = ProvisionStores { + config: &config_ids, + kv: &kv_ids, + secrets: &[], + }; + FastlyCliAdapter + .provision( + dir.path(), + Some("fastly.toml"), + None, + &stores, + None, + ProvisionMode::Local, + false, + ) + .expect("first run succeeds"); + let after_first = fs::read_to_string(&path).expect("read after first"); + FastlyCliAdapter + .provision( + dir.path(), + Some("fastly.toml"), + None, + &stores, + None, + ProvisionMode::Local, + false, + ) + .expect("second run succeeds"); + let after_second = fs::read_to_string(&path).expect("read after second"); + assert_eq!( + after_first, after_second, + "second provision is a no-op -- fastly.toml must be bit-for-bit unchanged" + ); + } + // ---------- find_config_store_id ---------- #[test] From efd0be3219eab43c86c839658037bb739a396b57 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 2 Jul 2026 14:58:42 -0700 Subject: [PATCH 35/71] Fastly: provision_typed writes [[local_server.secret_stores.*]] entries --- crates/edgezero-adapter-fastly/src/cli.rs | 339 +++++++++++++++++++++- 1 file changed, 338 insertions(+), 1 deletion(-) diff --git a/crates/edgezero-adapter-fastly/src/cli.rs b/crates/edgezero-adapter-fastly/src/cli.rs index 46228fa7..581fb4c6 100644 --- a/crates/edgezero-adapter-fastly/src/cli.rs +++ b/crates/edgezero-adapter-fastly/src/cli.rs @@ -13,6 +13,7 @@ use edgezero_adapter::cli_support::{ use edgezero_adapter::registry::{ register_adapter, Adapter, AdapterAction, AdapterDeployedState, AdapterPushContext, ProvisionMode, ProvisionOutcome, ProvisionStores, ReadConfigEntry, ResolvedStoreId, + TypedSecretEntry, }; use edgezero_adapter::scaffold::{ register_adapter_blueprint, AdapterBlueprint, AdapterFileSpec, CommandTemplates, @@ -166,7 +167,7 @@ enum ConfigStoreLookup { // `&[]` 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. `read_config_entry` and `read_config_entry_local` are both overridden below. `single_store_kinds` IS overridden below (returns `&[]`). `synthesise_baseline_manifest` IS overridden below (emits a baseline `fastly.toml` for the Task 8b clean-clone bootstrap, threading `[adapters.fastly.deployed].service_id` through when present)." + reason = "see the explanatory block comment immediately above; fastly's no-op defaults for the three validate_* hooks are intentional and documented. `read_config_entry` and `read_config_entry_local` are both overridden below. `single_store_kinds` IS overridden below (returns `&[]`). `synthesise_baseline_manifest` IS overridden below (emits a baseline `fastly.toml` for the Task 8b clean-clone bootstrap, threading `[adapters.fastly.deployed].service_id` through when present). `provision_typed` IS overridden below (Local mode appends `[[local_server.secret_stores.]]` entries in `fastly.toml`; Cloud is a no-op)." )] impl Adapter for FastlyCliAdapter { fn deployed_fields(&self) -> &'static [&'static str] { @@ -367,6 +368,63 @@ impl Adapter for FastlyCliAdapter { }) } + fn provision_typed( + &self, + manifest_root: &Path, + adapter_manifest_path: Option<&str>, + _component_selector: Option<&str>, + typed_secrets: &[TypedSecretEntry<'_>], + mode: ProvisionMode, + dry_run: bool, + ) -> Result { + // Cloud secret storage uses `fastly secret-store-entry create` + // at deploy time. This local writer only seeds Viceroy's + // `[[local_server.secret_stores.]]` array-of-tables + // — cloud mode is a documented no-op. + if !matches!(mode, ProvisionMode::Local) { + return Ok(ProvisionOutcome::default()); + } + let fastly_rel = adapter_manifest_path.unwrap_or("fastly.toml"); + let fastly_path = manifest_root.join(fastly_rel); + if !fastly_path.exists() { + return Err(format!( + "expected fastly.toml at {} (Task 8b's CLI bootstrap should have written it before provision ran)", + fastly_path.display() + )); + } + let raw = fs::read_to_string(&fastly_path) + .map_err(|err| format!("failed to read {}: {err}", fastly_path.display()))?; + let mut doc: toml_edit::DocumentMut = raw + .parse() + .map_err(|err| format!("failed to parse {}: {err}", fastly_path.display()))?; + + let mut status_lines: Vec = Vec::new(); + let mut appended = 0_usize; + + for entry in typed_secrets { + let added = upsert_secret_store_entry(&mut doc, entry.store_id, entry.key_value)?; + if added { + appended = appended.saturating_add(1); + } + status_lines.push(format!( + "fastly: secret_store `{}` key `{}` (env `{}`)", + entry.store_id, + entry.key_value, + entry.key_value.to_ascii_uppercase(), + )); + } + + if !dry_run && appended > 0 { + fs::write(&fastly_path, doc.to_string()) + .map_err(|err| format!("failed to write {}: {err}", fastly_path.display()))?; + } + + Ok(ProvisionOutcome { + status_lines, + deployed: None, + }) + } + fn push_config_entries( &self, _manifest_root: &Path, @@ -1307,6 +1365,60 @@ fn upsert_runtime_env_config_store( Ok(true) } +/// Append one `[[local_server.secret_stores.]]` entry with +/// `key = ""` and `env = ""` — Fastly's +/// secret-store convention pairs the key name with the env var the +/// local runtime exposes it under. Idempotent: if the target array +/// already contains an entry with matching `key = ""` we +/// skip and leave sibling entries (including operator-adjusted `env` +/// values) alone. Returns `Ok(true)` when a new entry was appended, +/// `Ok(false)` when a matching key was already present. +/// +/// Refuses to clobber non-standard values: if the target +/// `secret_stores.` node exists but isn't an array of +/// tables, or if `local_server` / `local_server.secret_stores` +/// exist but aren't tables, the helper errors with a "refusing to +/// edit in place" diagnostic. +fn upsert_secret_store_entry( + doc: &mut toml_edit::DocumentMut, + store_id: &str, + key_value: &str, +) -> Result { + use toml_edit::{value, ArrayOfTables, Item, Table}; + + let local_server_entry = doc + .entry("local_server") + .or_insert_with(|| Item::Table(Table::new())); + let local_server_tbl = local_server_entry.as_table_mut().ok_or_else(|| { + "`local_server` exists but is not a table; refusing to edit in place".to_owned() + })?; + let secret_stores_entry = local_server_tbl + .entry("secret_stores") + .or_insert_with(|| Item::Table(Table::new())); + let secret_stores_tbl = secret_stores_entry.as_table_mut().ok_or_else(|| { + "`local_server.secret_stores` exists but is not a table; refusing to edit in place" + .to_owned() + })?; + let store_entry = secret_stores_tbl + .entry(store_id) + .or_insert_with(|| Item::ArrayOfTables(ArrayOfTables::new())); + let store_arr = store_entry.as_array_of_tables_mut().ok_or_else(|| { + format!( + "`local_server.secret_stores.{store_id}` exists but is not an array of tables; refusing to edit in place" + ) + })?; + for existing in store_arr.iter() { + if existing.get("key").and_then(|item| item.as_str()) == Some(key_value) { + return Ok(false); + } + } + let mut row = Table::new(); + row.insert("key", value(key_value)); + row.insert("env", value(key_value.to_ascii_uppercase())); + store_arr.push(row); + Ok(true) +} + // ------------------------------------------------------------------- // `config push` helpers // ------------------------------------------------------------------- @@ -2808,6 +2920,231 @@ build = \"cargo build --release\" ); } + // ---------- provision_typed (secret stores) ---------- + + /// Local `provision_typed` appends + /// `[[local_server.secret_stores.]]` entries with + /// `key = ""` and `env = ""` per + /// `TypedSecretEntry`, grouped by the entry's `store_id`. + #[test] + fn fastly_provision_typed_writes_secret_store_entries_under_resolved_store_id() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write(&path, synthesise_fastly_toml("demo", None)).expect("write"); + let entries = [ + TypedSecretEntry::new("default", "api_token", "demo_api_token"), + TypedSecretEntry::new("vendor_secrets", "vendor_key", "vendor_demo_key"), + ]; + FastlyCliAdapter + .provision_typed( + dir.path(), + Some("fastly.toml"), + None, + &entries, + ProvisionMode::Local, + false, + ) + .expect("provision_typed succeeds"); + let after = fs::read_to_string(&path).expect("read"); + assert!( + after.contains("[[local_server.secret_stores.default]]"), + "default store array-of-tables header present: {after}" + ); + assert!( + after.contains(r#"key = "demo_api_token""#), + "default store key line present: {after}" + ); + assert!( + after.contains(r#"env = "DEMO_API_TOKEN""#), + "default store env line uppercased: {after}" + ); + assert!( + after.contains("[[local_server.secret_stores.vendor_secrets]]"), + "vendor_secrets store array-of-tables header present: {after}" + ); + assert!( + after.contains(r#"key = "vendor_demo_key""#), + "vendor_secrets store key line present: {after}" + ); + assert!( + after.contains(r#"env = "VENDOR_DEMO_KEY""#), + "vendor_secrets store env line uppercased: {after}" + ); + // Confirm shape via re-parse: the per-store slot MUST be an + // ArrayOfTables (not a plain table) — Viceroy expects the + // array-of-tables form for secret-store entries. + let doc: toml_edit::DocumentMut = after.parse().expect("re-parse"); + assert!( + doc["local_server"]["secret_stores"]["default"] + .as_array_of_tables() + .is_some(), + "default is array-of-tables" + ); + assert!( + doc["local_server"]["secret_stores"]["vendor_secrets"] + .as_array_of_tables() + .is_some(), + "vendor_secrets is array-of-tables" + ); + } + + /// `adapter_manifest_path` may be a NESTED relative path. Entries + /// land in the nested `fastly.toml`, not at a sibling under + /// `manifest_root`. + #[test] + fn fastly_provision_typed_lands_in_resolved_fastly_toml_not_manifest_root() { + let dir = tempdir().expect("tempdir"); + let nested_rel = "crates/fastly/fastly.toml"; + let nested_path = dir.path().join(nested_rel); + fs::create_dir_all(nested_path.parent().expect("parent")).expect("mkdir"); + fs::write(&nested_path, synthesise_fastly_toml("demo", None)).expect("write"); + let entries = [TypedSecretEntry::new( + "default", + "api_token", + "demo_api_token", + )]; + FastlyCliAdapter + .provision_typed( + dir.path(), + Some(nested_rel), + None, + &entries, + ProvisionMode::Local, + false, + ) + .expect("provision_typed succeeds"); + let after = fs::read_to_string(&nested_path).expect("read nested"); + assert!( + after.contains("[[local_server.secret_stores.default]]"), + "entries land in nested manifest: {after}" + ); + assert!( + after.contains(r#"key = "demo_api_token""#), + "key line in nested manifest: {after}" + ); + let sibling = dir.path().join("fastly.toml"); + assert!( + !sibling.exists(), + "no sibling fastly.toml created at manifest_root" + ); + } + + /// Cloud mode is a no-op — real cloud secret storage uses + /// `fastly secret-store-entry create` at deploy time, not local + /// `.toml` writeback. Assert empty outcome + untouched manifest. + #[test] + fn fastly_provision_typed_cloud_mode_is_a_no_op() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + let baseline = synthesise_fastly_toml("demo", None); + fs::write(&path, &baseline).expect("write"); + let entries = [TypedSecretEntry::new( + "default", + "api_token", + "demo_api_token", + )]; + let outcome = FastlyCliAdapter + .provision_typed( + dir.path(), + Some("fastly.toml"), + None, + &entries, + ProvisionMode::Cloud, + false, + ) + .expect("cloud mode is a no-op, must succeed"); + assert!( + outcome.status_lines.is_empty(), + "cloud outcome status_lines empty: {:?}", + outcome.status_lines + ); + assert!(outcome.deployed.is_none(), "cloud outcome deployed is None"); + let after = fs::read_to_string(&path).expect("read"); + assert_eq!(after, baseline, "fastly.toml untouched in cloud mode"); + } + + /// Idempotency: a matching `key = ""` entry already in + /// the target array is preserved (including operator's non-matching + /// `env` override). No duplicate row is appended. + #[test] + fn fastly_provision_typed_deduplicates_matching_key() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + let mut seed = synthesise_fastly_toml("demo", None); + seed.push_str( + "\n[[local_server.secret_stores.default]]\nkey = \"demo_api_token\"\nenv = \"CUSTOM_ENV\"\n", + ); + fs::write(&path, &seed).expect("write"); + let entries = [TypedSecretEntry::new( + "default", + "api_token", + "demo_api_token", + )]; + FastlyCliAdapter + .provision_typed( + dir.path(), + Some("fastly.toml"), + None, + &entries, + ProvisionMode::Local, + false, + ) + .expect("provision_typed succeeds"); + let after = fs::read_to_string(&path).expect("read"); + // Operator's env override is preserved (not overwritten to the + // default `DEMO_API_TOKEN`). + assert!( + after.contains(r#"env = "CUSTOM_ENV""#), + "operator's env override preserved: {after}" + ); + assert!( + !after.contains(r#"env = "DEMO_API_TOKEN""#), + "adapter did NOT overwrite operator env: {after}" + ); + // Exactly one entry for the store with key = "demo_api_token". + let doc: toml_edit::DocumentMut = after.parse().expect("re-parse"); + let arr = doc["local_server"]["secret_stores"]["default"] + .as_array_of_tables() + .expect("default is array-of-tables"); + let matches: usize = arr + .iter() + .filter(|tbl| tbl.get("key").and_then(|item| item.as_str()) == Some("demo_api_token")) + .count(); + assert_eq!(matches, 1, "exactly one matching key entry: {after}"); + } + + /// Absent `fastly.toml` is a Task 8b bootstrap bug — error clearly + /// with the resolved absolute path, matching the Task 22 + /// `provision_local` error style so both flows fail the same way. + #[test] + fn fastly_provision_typed_errors_if_manifest_absent() { + let dir = tempdir().expect("tempdir"); + // Do NOT pre-seed fastly.toml. + let entries = [TypedSecretEntry::new( + "default", + "api_token", + "demo_api_token", + )]; + let err = FastlyCliAdapter + .provision_typed( + dir.path(), + Some("fastly.toml"), + None, + &entries, + ProvisionMode::Local, + false, + ) + .expect_err("missing manifest must error"); + assert!( + err.contains("fastly.toml"), + "error names the missing path: {err}" + ); + assert!( + err.contains(&dir.path().join("fastly.toml").display().to_string()), + "error contains the resolved absolute path: {err}" + ); + } + // ---------- find_config_store_id ---------- #[test] From 664259868025f6c566a026eb257ae1d7b06c5320 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:07:03 -0700 Subject: [PATCH 36/71] Spin: primitive synthesiser for spin.toml + runtime-config.toml + bootstrap override --- crates/edgezero-adapter-spin/src/cli.rs | 179 +++++++++++++++++++++++- 1 file changed, 178 insertions(+), 1 deletion(-) diff --git a/crates/edgezero-adapter-spin/src/cli.rs b/crates/edgezero-adapter-spin/src/cli.rs index e3f08573..fc8c9f9a 100644 --- a/crates/edgezero-adapter-spin/src/cli.rs +++ b/crates/edgezero-adapter-spin/src/cli.rs @@ -136,7 +136,7 @@ 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). `read_config_entry` and `read_config_entry_local` are both overridden below (four-branch SQLite-direct / Fermyon Cloud / non-Spin-backend dispatch)." + 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). `read_config_entry` and `read_config_entry_local` are both overridden below (four-branch SQLite-direct / Fermyon Cloud / non-Spin-backend dispatch). `synthesise_baseline_manifest` IS overridden below (emits a baseline `spin.toml` + a header-only `runtime-config.toml` for the clean-clone bootstrap; runtime-config.toml lands next to spin.toml so nested `adapter_manifest_path` values are honoured)." )] impl Adapter for SpinCliAdapter { fn execute(&self, action: AdapterAction, args: &[String]) -> Result<(), String> { @@ -466,6 +466,34 @@ impl Adapter for SpinCliAdapter { &["secrets"] } + fn synthesise_baseline_manifest( + &self, + _manifest_root: &Path, + adapter_manifest_path: Option<&str>, + component_selector: Option<&str>, + app_name: &str, + _deployed: Option<&AdapterDeployedState>, + ) -> Result, String> { + let spin_rel = + adapter_manifest_path.map_or_else(|| PathBuf::from("spin.toml"), PathBuf::from); + // runtime-config.toml sits next to spin.toml so a nested + // `adapter_manifest_path` (e.g. `crates/spin/spin.toml`) + // places runtime-config.toml at + // `crates/spin/runtime-config.toml`. When `spin_rel` has no + // parent (bare `spin.toml`), fall back to the workspace root. + let rc_rel = spin_rel + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .map_or_else( + || PathBuf::from("runtime-config.toml"), + |parent| parent.join("runtime-config.toml"), + ); + Ok(vec![ + (spin_rel, synthesise_spin_toml(app_name, component_selector)), + (rc_rel, synthesise_runtime_config_toml()), + ]) + } + fn validate_adapter_manifest( &self, manifest_root: &Path, @@ -1226,6 +1254,81 @@ pub fn serve(extra_args: &[String]) -> Result<(), String> { Ok(()) } +/// Header-only baseline for `runtime-config.toml`. Task 25's +/// local arm appends `[key_value_store.]` blocks on top of +/// this baseline; there is nothing to synthesise structurally at +/// bootstrap time — the header line pins the schema version so +/// later appenders know they are editing an EdgeZero-owned file. +pub(crate) fn synthesise_runtime_config_toml() -> String { + String::from("# edgezero-provision: v1\n") +} + +/// Synthesised baseline `spin.toml` for clean clones. Built via +/// `toml_edit::DocumentMut` (NOT raw `format!`) so any legal +/// `[app].name` or `[adapters.spin.adapter].component` selector +/// — including values with TOML-significant characters like `"`, +/// `\`, or newlines — is escaped correctly. +/// +/// Component-id resolution: `component.unwrap_or(app_name)`. The +/// wasm source path uses the UNDERSCORED component id because +/// Rust's Cargo output filenames convert hyphens to underscores +/// (`my-crate` → `my_crate.wasm`). +pub(crate) fn synthesise_spin_toml(app_name: &str, component: Option<&str>) -> String { + use toml_edit::{value, Array, ArrayOfTables, DocumentMut, Item, Table}; + + let component_id = component.unwrap_or(app_name); + let component_under = component_id.replace('-', "_"); + + let mut doc = DocumentMut::new(); + doc.decor_mut().set_prefix("# edgezero-provision: v1\n"); + // `Table::insert` returns the previous value (if any). We build + // a fresh document from `DocumentMut::new()`, so nothing to + // displace -- discarding the returned Option is intentional. + // Using `insert` rather than `doc["..."] = ...` sidesteps + // `clippy::indexing_slicing` (the index form panics if the key + // is missing; `insert` doesn't). + doc.insert("spin_manifest_version", value(2)); + + // [application] + let mut application = Table::new(); + application.insert("name", value(app_name)); + application.insert("version", value("0.1.0")); + doc.insert("application", Item::Table(application)); + + // [[trigger.http]] — array-of-tables so toml_edit emits the + // `[[...]]` double-bracket syntax. The `trigger` parent table + // is marked implicit so the emitter skips a bare `[trigger]` + // header (`[[trigger.http]]` already declares the container). + let mut http_trigger = Table::new(); + http_trigger.insert("route", value("/...")); + http_trigger.insert("component", value(component_id)); + let mut http_aot = ArrayOfTables::new(); + http_aot.push(http_trigger); + let mut trigger = Table::new(); + trigger.set_implicit(true); + trigger.insert("http", Item::ArrayOfTables(http_aot)); + doc.insert("trigger", Item::Table(trigger)); + + // [component.] — insert the sub-table typed so a pathological + // component id can't inject unescaped section-header syntax; the + // parent `component` table is implicit so the emitter renders + // only `[component.]` (no bare `[component]` header). + let mut comp = Table::new(); + comp.insert( + "source", + value(format!( + "../../target/wasm32-wasip2/release/{component_under}.wasm" + )), + ); + comp.insert("key_value_stores", value(Array::new())); + let mut component_section = Table::new(); + component_section.set_implicit(true); + component_section.insert(component_id, Item::Table(comp)); + doc.insert("component", Item::Table(component_section)); + + doc.to_string() +} + #[cfg(test)] mod tests { use super::*; @@ -2796,4 +2899,78 @@ mod tests { }; assert_eq!(value, "abs_val"); } + + // ---------- synthesise_spin_toml / synthesise_runtime_config_toml ---------- + + #[test] + fn synthesises_spin_toml_uses_app_name_when_component_unset() { + let out = synthesise_spin_toml("demo", None); + assert!(out.starts_with("# edgezero-provision: v1")); + assert!(out.contains("spin_manifest_version = 2")); + assert!(out.contains(r#"name = "demo""#)); + assert!(out.contains(r#"component = "demo""#)); + assert!(out.contains("[component.demo]")); + } + + #[test] + fn synthesises_spin_toml_honors_component_selector() { + let out = synthesise_spin_toml("demo", Some("worker")); + assert!(out.contains(r#"component = "worker""#)); + assert!(out.contains("[component.worker]")); + // wasm path matches the component id, not the app name: + assert!(out.contains("/release/worker.wasm")); + } + + #[test] + fn synthesises_runtime_config_toml_is_header_only() { + let out = synthesise_runtime_config_toml(); + assert_eq!(out, "# edgezero-provision: v1\n"); + } + + #[test] + fn synthesise_spin_toml_escapes_pathological_app_names() { + for name in [ + r#"has"quote"#, + r"has\backslash", + "has\nnewline", + "has = equals", + ] { + let out = synthesise_spin_toml(name, None); + let doc: toml_edit::DocumentMut = out.parse().unwrap(); + assert_eq!( + doc["application"]["name"].as_str(), + Some(name), + "app name round-trip failed for {name:?}: {out}" + ); + } + } + + #[test] + fn synthesise_spin_toml_escapes_pathological_component_id() { + // Component id flows into BOTH the trigger's `component =` + // value AND the `[component.]` table key — both must + // round-trip cleanly. + for cid in [r#"has"quote"#, r"has\backslash", "has\nnewline"] { + let out = synthesise_spin_toml("demo", Some(cid)); + let doc: toml_edit::DocumentMut = out.parse().unwrap(); + // trigger[0].component == cid + let trigger_http = doc["trigger"]["http"] + .as_array_of_tables() + .expect("trigger.http must be ArrayOfTables"); + assert_eq!(trigger_http.len(), 1); + assert_eq!( + trigger_http.get(0).unwrap()["component"].as_str(), + Some(cid), + "trigger.component round-trip failed for {cid:?}: {out}" + ); + // [component.] exists and has a `source` key + let comp = doc["component"] + .as_table() + .expect("component must be a table"); + assert!( + comp.contains_key(cid), + "component table missing key {cid:?}: {out}" + ); + } + } } From 8f6776c4caebc2ff2a6d2f87b0ad9ab705e25895 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:16:41 -0700 Subject: [PATCH 37/71] Spin: local-mode provision writes bindings + runtime-config + .env __NAME lines --- crates/edgezero-adapter-spin/src/cli.rs | 654 +++++++++++++++++++++++- 1 file changed, 653 insertions(+), 1 deletion(-) diff --git a/crates/edgezero-adapter-spin/src/cli.rs b/crates/edgezero-adapter-spin/src/cli.rs index fc8c9f9a..3e28be6c 100644 --- a/crates/edgezero-adapter-spin/src/cli.rs +++ b/crates/edgezero-adapter-spin/src/cli.rs @@ -16,6 +16,7 @@ use ctor::ctor; use edgezero_adapter::cli_support::{ find_manifest_upwards, find_workspace_root, path_distance, read_package_name, run_native_cli, }; +use edgezero_adapter::env_file::append_lines_dedup; use edgezero_adapter::registry::{ register_adapter, Adapter, AdapterAction, AdapterDeployedState, AdapterPushContext, ProvisionMode, ProvisionOutcome, ProvisionStores, ReadConfigEntry, ResolvedStoreId, @@ -188,7 +189,15 @@ impl Adapter for SpinCliAdapter { ) -> Result { match mode { ProvisionMode::Cloud => {} - ProvisionMode::Local => return Err("local mode lands in Section 5".to_owned()), + ProvisionMode::Local => { + return provision_local( + manifest_root, + adapter_manifest_path, + component_selector, + stores, + dry_run, + ); + } } //: spin provision is pure spin.toml editing — no // shell-out (Spin KV stores are provisioned by the Spin @@ -1082,6 +1091,307 @@ fn ensure_kv_label_in_component( Ok(true) } +/// Local-mode provision arm: extend `[component.].key_value_stores` +/// in `spin.toml`, append `[key_value_store.]` blocks (Spin +/// `SQLite` backend) to `runtime-config.toml`, and write +/// `EDGEZERO__STORES______NAME=` lines +/// (all kinds) plus a commented `__KEY=_staging` placeholder +/// (CONFIG only) to `.env` next to `spin.toml`. +/// +/// Both `spin.toml` and `runtime-config.toml` MUST exist at the +/// resolved paths -- Task 8b's CLI bootstrap writes both via +/// `synthesise_baseline_manifest` before provision runs. If either +/// is missing, we error clearly rather than silently re-synthesising: +/// a missing runtime-config next to a present spin.toml is a +/// programmer error worth surfacing (rather than silently mutating +/// the tree into an inconsistent state). +/// +/// **Lookups use `store.logical`** (env-overlay-independent) for the +/// env-var KEY portion (`APP_CONFIG__NAME`); **TOML cells and env-var +/// VALUES use `store.platform`** (env-overlay resolved binding name +/// teammates can vary via `EDGEZERO__STORES______NAME`). +fn provision_local( + manifest_root: &Path, + adapter_manifest_path: Option<&str>, + component_selector: Option<&str>, + stores: &ProvisionStores<'_>, + dry_run: bool, +) -> Result { + use toml_edit::DocumentMut; + + let spin_rel = adapter_manifest_path.unwrap_or("spin.toml"); + let spin_path = manifest_root.join(spin_rel); + let spin_dir = spin_path.parent().unwrap_or(manifest_root); + let rc_path = spin_dir.join("runtime-config.toml"); + let env_path = spin_dir.join(".env"); + + if !spin_path.exists() { + return Err(format!( + "expected spin.toml at {} (Task 8b's CLI bootstrap should have written it before provision ran)", + spin_path.display() + )); + } + if !rc_path.exists() { + return Err(format!( + "expected runtime-config.toml at {} next to spin.toml (Task 8b's CLI bootstrap should have written it before provision ran)", + rc_path.display() + )); + } + + // 1. spin.toml: append platform labels to [component.].key_value_stores. + let spin_raw = fs::read_to_string(&spin_path) + .map_err(|err| format!("failed to read {}: {err}", spin_path.display()))?; + let mut spin_doc: DocumentMut = spin_raw + .parse() + .map_err(|err| format!("failed to parse {}: {err}", spin_path.display()))?; + let mut spin_changed = false; + let needs_component = !stores.kv.is_empty() || !stores.config.is_empty(); + if needs_component { + let component_id = resolve_component_id(&spin_doc, component_selector, &spin_path)?; + for store in stores.kv.iter().chain(stores.config.iter()) { + if append_kv_store_to_component( + &mut spin_doc, + &component_id, + &store.platform, + &spin_path, + )? { + spin_changed = true; + } + } + } + + // 2. runtime-config.toml: append [key_value_store.] blocks. + let rc_raw = fs::read_to_string(&rc_path) + .map_err(|err| format!("failed to read {}: {err}", rc_path.display()))?; + let mut rc_doc: DocumentMut = rc_raw + .parse() + .map_err(|err| format!("failed to parse {}: {err}", rc_path.display()))?; + let mut rc_changed = false; + for store in stores.kv.iter().chain(stores.config.iter()) { + if append_key_value_store_block(&mut rc_doc, &store.platform) { + rc_changed = true; + } + } + + // 3. .env: __NAME lines (all kinds) + commented __KEY placeholders + // (CONFIG only). Dedup honours operator overrides -- an operator + // who already uncommented + edited a __KEY line does NOT get the + // commented placeholder re-added on a subsequent provision. + let env_lines = build_env_lines(stores); + + if spin_changed && !dry_run { + fs::write(&spin_path, spin_doc.to_string()) + .map_err(|err| format!("failed to write {}: {err}", spin_path.display()))?; + } + if rc_changed && !dry_run { + fs::write(&rc_path, rc_doc.to_string()) + .map_err(|err| format!("failed to write {}: {err}", rc_path.display()))?; + } + append_lines_dedup(&env_path, &env_lines, dry_run) + .map_err(|err| format!("write {}: {err}", env_path.display()))?; + + let total = stores + .kv + .len() + .saturating_add(stores.config.len()) + .saturating_add(stores.secrets.len()); + let status_lines = vec![format!( + "spin: wrote bindings + runtime-config + .env for {total} store(s) at {}", + spin_path.display() + )]; + Ok(ProvisionOutcome { + status_lines, + deployed: None, + }) +} + +/// Resolve which `[component.]` table `provision_local` writes +/// into, given a parsed `spin.toml`. Same rule as +/// [`resolve_spin_component`] and [`Adapter::validate_adapter_manifest`]: +/// - explicit `component_selector`: must match a declared component +/// id, else error; +/// - single component: implicit; +/// - multi-component without selector: error. +/// +/// Operates on a `DocumentMut` (already parsed) so `provision_local` +/// can share the single doc read with the writer. +fn resolve_component_id( + doc: &toml_edit::DocumentMut, + selector: Option<&str>, + spin_path: &Path, +) -> Result { + let component_ids: Vec = doc + .get("component") + .and_then(toml_edit::Item::as_table) + .map(|tbl| tbl.iter().map(|(key, _)| key.to_owned()).collect()) + .unwrap_or_default(); + + if component_ids.is_empty() { + return Err(format!( + "{}: no [component.*] declarations found", + spin_path.display() + )); + } + if let Some(sel) = selector { + if component_ids.iter().any(|id| id == sel) { + return Ok(sel.to_owned()); + } + return Err(format!( + "[adapters.spin.adapter].component = {:?} is not declared in {} (available: {})", + sel, + spin_path.display(), + component_ids.join(", ") + )); + } + if component_ids.len() == 1 { + return Ok(component_ids.into_iter().next().unwrap_or_default()); + } + Err(format!( + "{} declares {} components ({}) but [adapters.spin.adapter].component is unset; set one explicitly", + spin_path.display(), + component_ids.len(), + component_ids.join(", ") + )) +} + +/// In-memory variant of [`ensure_kv_label_in_component`]: append +/// `platform` to `[component.].key_value_stores` in +/// `doc`. Creates the array if absent. Returns `Ok(true)` if the +/// label was newly added, `Ok(false)` if already present. The caller +/// writes the doc back to disk once at the end of `provision_local` +/// so multiple platform labels land in a single atomic write. +fn append_kv_store_to_component( + doc: &mut toml_edit::DocumentMut, + component_id: &str, + platform: &str, + spin_path: &Path, +) -> Result { + use toml_edit::{value, Array, Value}; + + let component_root = doc.get_mut("component").ok_or_else(|| { + format!( + "{}: [component.*] tables expected but `component` key missing", + spin_path.display() + ) + })?; + let component_tbl = component_root + .as_table_mut() + .ok_or_else(|| format!("{}: `component` is not a table", spin_path.display()))?; + let target = component_tbl.get_mut(component_id).ok_or_else(|| { + format!( + "{}: [component.{component_id}] is not declared", + spin_path.display() + ) + })?; + let target_tbl = target.as_table_mut().ok_or_else(|| { + format!( + "{}: [component.{component_id}] is not a table", + spin_path.display() + ) + })?; + let entry = target_tbl + .entry("key_value_stores") + .or_insert_with(|| value(Array::new())); + let arr = entry + .as_value_mut() + .and_then(Value::as_array_mut) + .ok_or_else(|| { + format!( + "{}: [component.{component_id}].key_value_stores is not an array", + spin_path.display() + ) + })?; + if arr.iter().any(|item| item.as_str() == Some(platform)) { + return Ok(false); + } + arr.push(platform); + Ok(true) +} + +/// Append `[key_value_store.]` with `type = "spin"` + +/// `path = ".spin/sqlite_key_value.db"` to `doc` if the platform's +/// stanza is absent. Idempotent — an already-present stanza is left +/// untouched (returns `false`). All local-mode stores back to the +/// same local `SQLite` file (Spin's default local KV backend). +/// +/// The parent `[key_value_store]` table is set implicit so +/// `toml_edit` emits only `[key_value_store.]` section +/// headers, matching the shape `spin up` reads. +fn append_key_value_store_block(doc: &mut toml_edit::DocumentMut, platform: &str) -> bool { + use toml_edit::{value, Item, Table}; + + // Fast-path idempotency check: if a stanza for this platform + // already exists, no work to do. + if doc + .get("key_value_store") + .and_then(toml_edit::Item::as_table) + .is_some_and(|tbl| tbl.contains_key(platform)) + { + return false; + } + + let parent = doc.entry("key_value_store").or_insert_with(|| { + let mut parent_tbl = Table::new(); + parent_tbl.set_implicit(true); + Item::Table(parent_tbl) + }); + let Some(parent_tbl) = parent.as_table_mut() else { + // `key_value_store` exists but is not a table -- extremely + // unlikely in a Spin-managed runtime-config.toml. Return + // false so the caller sees "nothing changed" rather than + // clobbering a malformed file. + return false; + }; + let mut inner = Table::new(); + inner.insert("type", value("spin")); + inner.insert("path", value(".spin/sqlite_key_value.db")); + parent_tbl.insert(platform, Item::Table(inner)); + true +} + +/// Build the `.env` line set emitted by [`provision_local`]. +/// +/// One `EDGEZERO__STORES______NAME=` +/// entry per declared store (KV / CONFIG / SECRETS). CONFIG stores +/// additionally get a **commented** `__KEY` placeholder — Spin has +/// no way to preview the KEY overlay at provision time, so we hint +/// the shape and let the operator uncomment + edit it. +/// +/// Env-var KEY uses the LOGICAL id in uppercase so the runtime's +/// env-overlay lookup finds it regardless of teammates' platform +/// name overrides. Env-var VALUE uses the PLATFORM name so the +/// runtime opens the same Spin KV store name that `spin.toml`'s +/// `key_value_stores` array + `runtime-config.toml`'s stanza declare. +/// +/// Dedup responsibility is delegated to [`append_lines_dedup`]: the +/// commented and uncommented forms normalise to the same key, so an +/// operator who already uncommented + edited a `__KEY` line survives +/// a re-run of provision (the commented placeholder is NOT re-added). +fn build_env_lines(stores: &ProvisionStores<'_>) -> Vec { + let mut lines: Vec = Vec::new(); + for (kind, kind_stores) in [ + ("KV", stores.kv), + ("CONFIG", stores.config), + ("SECRETS", stores.secrets), + ] { + for store in kind_stores { + let logical_upper = store.logical.to_ascii_uppercase(); + let platform = &store.platform; + lines.push(format!( + "EDGEZERO__STORES__{kind}__{logical_upper}__NAME={platform}" + )); + } + } + for store in stores.config { + let logical_upper = store.logical.to_ascii_uppercase(); + let logical = &store.logical; + lines.push(format!( + "# EDGEZERO__STORES__CONFIG__{logical_upper}__KEY={logical}_staging" + )); + } + lines +} + /// # Errors /// Returns an error if the Spin CLI build command fails. #[inline] @@ -2900,6 +3210,348 @@ mod tests { assert_eq!(value, "abs_val"); } + // ---------- provision_local (Local arm) — Task 25 ---------- + + /// Seed BOTH baseline files (spin.toml + runtime-config.toml) at + /// `dir`, matching Task 24's `synthesise_baseline_manifest` output. + fn seed_baseline(dir: &Path, app_name: &str) { + fs::write(dir.join("spin.toml"), synthesise_spin_toml(app_name, None)) + .expect("seed spin.toml"); + fs::write( + dir.join("runtime-config.toml"), + synthesise_runtime_config_toml(), + ) + .expect("seed runtime-config.toml"); + } + + #[test] + fn spin_local_provision_writes_kv_bindings_and_runtime_config_blocks() { + let dir = tempdir().expect("tempdir"); + seed_baseline(dir.path(), TEST_COMPONENT_ID); + let kv_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_KV_ID]); + let config_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_CONFIG_ID]); + let stores = ProvisionStores { + config: &config_ids, + kv: &kv_ids, + secrets: &[], + }; + SpinCliAdapter + .provision( + dir.path(), + Some("spin.toml"), + None, + &stores, + None, + ProvisionMode::Local, + false, + ) + .expect("local provision succeeds"); + + let spin_after = fs::read_to_string(dir.path().join("spin.toml")).expect("read spin.toml"); + // Both platform labels (KV + config) land in + // [component.].key_value_stores. + assert!( + spin_after.contains("\"sessions\""), + "KV label in spin.toml: {spin_after}" + ); + assert!( + spin_after.contains("\"app_config\""), + "config label in spin.toml: {spin_after}" + ); + + let rc_after = fs::read_to_string(dir.path().join("runtime-config.toml")) + .expect("read runtime-config.toml"); + for label in ["sessions", "app_config"] { + assert!( + rc_after.contains(&format!("[key_value_store.{label}]")), + "runtime-config has [key_value_store.{label}]: {rc_after}" + ); + } + assert!( + rc_after.contains(r#"type = "spin""#), + "type = \"spin\": {rc_after}" + ); + assert!( + rc_after.contains(r#"path = ".spin/sqlite_key_value.db""#), + "sqlite path: {rc_after}" + ); + } + + #[test] + fn spin_local_provision_writes_env_name_lines_for_kv_config_secrets() { + let dir = tempdir().expect("tempdir"); + seed_baseline(dir.path(), TEST_COMPONENT_ID); + 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, + }; + SpinCliAdapter + .provision( + dir.path(), + Some("spin.toml"), + None, + &stores, + None, + ProvisionMode::Local, + false, + ) + .expect("local provision succeeds"); + + let env_after = fs::read_to_string(dir.path().join(".env")).expect("read .env"); + assert!( + env_after.contains("EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME=app_config"), + "config __NAME line: {env_after}" + ); + assert!( + env_after.contains("EDGEZERO__STORES__KV__SESSIONS__NAME=sessions"), + "kv __NAME line: {env_after}" + ); + assert!( + env_after.contains("EDGEZERO__STORES__SECRETS__DEFAULT__NAME=default"), + "secret __NAME line: {env_after}" + ); + assert!( + env_after.contains("# EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY=app_config_staging"), + "commented config __KEY placeholder: {env_after}" + ); + } + + #[test] + fn spin_local_provision_errors_if_spin_toml_absent() { + let dir = tempdir().expect("tempdir"); + // Do NOT seed spin.toml. runtime-config.toml alone must not + // paper over the missing spin.toml. + fs::write( + dir.path().join("runtime-config.toml"), + synthesise_runtime_config_toml(), + ) + .expect("seed runtime-config"); + + let kv_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_KV_ID]); + let stores = ProvisionStores { + config: &[], + kv: &kv_ids, + secrets: &[], + }; + let err = SpinCliAdapter + .provision( + dir.path(), + Some("spin.toml"), + None, + &stores, + None, + ProvisionMode::Local, + false, + ) + .expect_err("missing spin.toml must error"); + assert!( + err.contains("spin.toml") && err.contains(dir.path().to_str().unwrap()), + "error names the missing spin.toml path: {err}" + ); + } + + #[test] + fn spin_local_provision_errors_if_runtime_config_toml_absent() { + let dir = tempdir().expect("tempdir"); + // Seed spin.toml but NOT runtime-config.toml. Missing + // runtime-config next to a present spin.toml is a + // programmer error worth surfacing (rather than silently + // re-synthesising an inconsistent tree). + fs::write( + dir.path().join("spin.toml"), + synthesise_spin_toml(TEST_COMPONENT_ID, None), + ) + .expect("seed spin.toml"); + + let kv_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_KV_ID]); + let stores = ProvisionStores { + config: &[], + kv: &kv_ids, + secrets: &[], + }; + let err = SpinCliAdapter + .provision( + dir.path(), + Some("spin.toml"), + None, + &stores, + None, + ProvisionMode::Local, + false, + ) + .expect_err("missing runtime-config.toml must error"); + assert!( + err.contains("runtime-config.toml"), + "error names runtime-config.toml specifically: {err}" + ); + } + + #[test] + fn spin_local_provision_resolves_nested_adapter_manifest_path() { + let dir = tempdir().expect("tempdir"); + let nested = dir.path().join("crates/spin"); + fs::create_dir_all(&nested).expect("mkdir nested"); + seed_baseline(&nested, TEST_COMPONENT_ID); + + let kv_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_KV_ID]); + let stores = ProvisionStores { + config: &[], + kv: &kv_ids, + secrets: &[], + }; + SpinCliAdapter + .provision( + dir.path(), + Some("crates/spin/spin.toml"), + None, + &stores, + None, + ProvisionMode::Local, + false, + ) + .expect("nested-path local provision succeeds"); + + let spin_after = fs::read_to_string(nested.join("spin.toml")).expect("read spin.toml"); + assert!( + spin_after.contains("\"sessions\""), + "KV label lands in nested spin.toml: {spin_after}" + ); + let rc_after = + fs::read_to_string(nested.join("runtime-config.toml")).expect("read runtime-config"); + assert!( + rc_after.contains("[key_value_store.sessions]"), + "stanza lands in nested runtime-config.toml: {rc_after}" + ); + assert!( + nested.join(".env").exists(), + ".env lands next to nested spin.toml" + ); + assert!( + !dir.path().join(".env").exists(), + "root-level .env must NOT be written" + ); + } + + #[test] + fn spin_local_provision_dedup_preserves_operator_edited_env_lines() { + let dir = tempdir().expect("tempdir"); + seed_baseline(dir.path(), TEST_COMPONENT_ID); + // Operator pre-seeds an uncommented __KEY override. + fs::write( + dir.path().join(".env"), + "EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY=operator_override\n", + ) + .expect("seed .env"); + + let config_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_CONFIG_ID]); + let stores = ProvisionStores { + config: &config_ids, + kv: &[], + secrets: &[], + }; + SpinCliAdapter + .provision( + dir.path(), + Some("spin.toml"), + None, + &stores, + None, + ProvisionMode::Local, + false, + ) + .expect("local provision succeeds"); + + let env_after = fs::read_to_string(dir.path().join(".env")).expect("read .env"); + assert!( + env_after.contains("EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY=operator_override"), + "operator's uncommented __KEY line survives: {env_after}" + ); + assert!( + !env_after.contains("# EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY="), + "commented __KEY placeholder must NOT be re-added: {env_after}" + ); + // Exactly one line whose normalised key is the __KEY env-var + // name -- the uncommented operator override wins. + let key_lines = env_after + .lines() + .filter(|line| { + let after_hash = line.trim_start().strip_prefix('#').unwrap_or(line); + after_hash + .trim_start() + .starts_with("EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY=") + }) + .count(); + assert_eq!( + key_lines, 1, + "exactly one __KEY line after dedup: {env_after}" + ); + } + + #[test] + fn spin_local_provision_uses_platform_binding_when_env_overlay_active() { + // Env-overlay round-trip. Simulates + // EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME=prod_config + // via ResolvedStoreId::new(logical, platform). The env-var KEY + // must still use the LOGICAL id in upper-case (`APP_CONFIG`); + // the TOML cells + env-var VALUE use the PLATFORM name + // (`prod_config`) so the runtime opens the store name that + // spin.toml + runtime-config.toml declare. + let dir = tempdir().expect("tempdir"); + seed_baseline(dir.path(), TEST_COMPONENT_ID); + let config_ids = vec![ResolvedStoreId::new(TEST_CONFIG_ID, "prod_config")]; + let stores = ProvisionStores { + config: &config_ids, + kv: &[], + secrets: &[], + }; + SpinCliAdapter + .provision( + dir.path(), + Some("spin.toml"), + None, + &stores, + None, + ProvisionMode::Local, + false, + ) + .expect("local provision succeeds"); + + let spin_after = fs::read_to_string(dir.path().join("spin.toml")).expect("read spin.toml"); + assert!( + spin_after.contains("\"prod_config\""), + "spin.toml has platform label prod_config: {spin_after}" + ); + assert!( + !spin_after.contains("\"app_config\""), + "spin.toml must NOT have the logical id app_config: {spin_after}" + ); + + let rc_after = fs::read_to_string(dir.path().join("runtime-config.toml")) + .expect("read runtime-config"); + assert!( + rc_after.contains("[key_value_store.prod_config]"), + "runtime-config has [key_value_store.prod_config]: {rc_after}" + ); + assert!( + !rc_after.contains("[key_value_store.app_config]"), + "runtime-config must NOT have logical-named stanza: {rc_after}" + ); + + let env_after = fs::read_to_string(dir.path().join(".env")).expect("read .env"); + assert!( + env_after.contains("EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME=prod_config"), + "env-var key uses LOGICAL uppercase, value uses PLATFORM: {env_after}" + ); + assert!( + !env_after.contains("EDGEZERO__STORES__CONFIG__PROD_CONFIG__NAME="), + "platform name must NOT leak into the env-var key: {env_after}" + ); + } + // ---------- synthesise_spin_toml / synthesise_runtime_config_toml ---------- #[test] From 00157fe473fbdbaa80066308299bd203514dcccf Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:29:29 -0700 Subject: [PATCH 38/71] Spin: provision_typed writes lowercased [variables] + SPIN_VARIABLE_ placeholders --- crates/edgezero-adapter-spin/src/cli.rs | 445 +++++++++++++++++++++++- 1 file changed, 438 insertions(+), 7 deletions(-) diff --git a/crates/edgezero-adapter-spin/src/cli.rs b/crates/edgezero-adapter-spin/src/cli.rs index 3e28be6c..e37857b3 100644 --- a/crates/edgezero-adapter-spin/src/cli.rs +++ b/crates/edgezero-adapter-spin/src/cli.rs @@ -137,7 +137,7 @@ 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). `read_config_entry` and `read_config_entry_local` are both overridden below (four-branch SQLite-direct / Fermyon Cloud / non-Spin-backend dispatch). `synthesise_baseline_manifest` IS overridden below (emits a baseline `spin.toml` + a header-only `runtime-config.toml` for the clean-clone bootstrap; runtime-config.toml lands next to spin.toml so nested `adapter_manifest_path` values are honoured)." + 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). `read_config_entry` and `read_config_entry_local` are both overridden below (four-branch SQLite-direct / Fermyon Cloud / non-Spin-backend dispatch). `synthesise_baseline_manifest` IS overridden below (emits a baseline `spin.toml` + a header-only `runtime-config.toml` for the clean-clone bootstrap; runtime-config.toml lands next to spin.toml so nested `adapter_manifest_path` values are honoured). `provision_typed` IS overridden below (local mode emits lowercased `[variables]` + `[component..variables]` entries and `SPIN_VARIABLE_*` placeholders in `.env`; cloud mode is a no-op)." )] impl Adapter for SpinCliAdapter { fn execute(&self, action: AdapterAction, args: &[String]) -> Result<(), String> { @@ -273,6 +273,30 @@ impl Adapter for SpinCliAdapter { }) } + fn provision_typed( + &self, + manifest_root: &Path, + adapter_manifest_path: Option<&str>, + component_selector: Option<&str>, + typed_secrets: &[TypedSecretEntry<'_>], + mode: ProvisionMode, + dry_run: bool, + ) -> Result { + // Cloud mode is a no-op: Fermyon Cloud manages secret variables + // through its own dashboard / `spin cloud variable set` CLI, so + // there is nothing for the CLI to write from typed metadata. + if !matches!(mode, ProvisionMode::Local) { + return Ok(ProvisionOutcome::default()); + } + provision_typed_local( + manifest_root, + adapter_manifest_path, + component_selector, + typed_secrets, + dry_run, + ) + } + fn push_config_entries( &self, manifest_root: &Path, @@ -1205,16 +1229,18 @@ fn provision_local( }) } -/// Resolve which `[component.]` table `provision_local` writes -/// into, given a parsed `spin.toml`. Same rule as -/// [`resolve_spin_component`] and [`Adapter::validate_adapter_manifest`]: +/// Resolve which `[component.]` table `provision_local` / +/// `provision_typed_local` write into, given a parsed `spin.toml`. +/// Same rule as [`resolve_spin_component`] and +/// [`Adapter::validate_adapter_manifest`]: /// - explicit `component_selector`: must match a declared component /// id, else error; /// - single component: implicit; -/// - multi-component without selector: error. +/// - multi-component without selector: error naming +/// `[adapters.spin.adapter].component` and listing available ids. /// -/// Operates on a `DocumentMut` (already parsed) so `provision_local` -/// can share the single doc read with the writer. +/// Operates on a `DocumentMut` (already parsed) so the callers can +/// share the single doc read with the writer. fn resolve_component_id( doc: &toml_edit::DocumentMut, selector: Option<&str>, @@ -1254,6 +1280,169 @@ fn resolve_component_id( )) } +/// Local-mode `provision_typed` arm: for each typed secret declared +/// on the app, edit `spin.toml` to add a lowercased `[variables]` +/// entry (`{ default = "", secret = true }`) plus a +/// `[component..variables]` binding that references it via the +/// `{{ spin_var }}` template placeholder, then write a +/// `SPIN_VARIABLE_=` line into `/.env` so `spin up` +/// resolves the secret from the environment at runtime. +/// +/// Casing: Spin's schema requires lowercase variable names +/// (`^[a-z][a-z0-9_]*$`); the Spin runtime reads variables from +/// upper-cased `SPIN_VARIABLE_*` env vars. `spin_var` is the +/// canonicalised (`to_ascii_lowercase`) secret key. +/// +/// Idempotency: an existing `[variables].` entry is left +/// alone (operator override survives); the same rule applies to +/// `[component..variables].`. `.env` dedup is +/// delegated to [`append_lines_dedup`]. +fn provision_typed_local( + manifest_root: &Path, + adapter_manifest_path: Option<&str>, + component_selector: Option<&str>, + typed_secrets: &[TypedSecretEntry<'_>], + dry_run: bool, +) -> Result { + use toml_edit::DocumentMut; + + let spin_rel = adapter_manifest_path.unwrap_or("spin.toml"); + let spin_path = manifest_root.join(spin_rel); + let env_path = spin_path.parent().unwrap_or(manifest_root).join(".env"); + + if !spin_path.exists() { + return Err(format!( + "expected spin.toml at {} (Task 8b's CLI bootstrap should have written it before provision ran)", + spin_path.display() + )); + } + + let spin_raw = fs::read_to_string(&spin_path) + .map_err(|err| format!("failed to read {}: {err}", spin_path.display()))?; + let mut spin_doc: DocumentMut = spin_raw + .parse() + .map_err(|err| format!("failed to parse {}: {err}", spin_path.display()))?; + + // Resolve the component id ONCE — if unresolvable (multi-component + // with no selector, or a bad explicit selector), abort BEFORE + // touching either the .env or spin.toml on disk. + let component_id = resolve_component_id(&spin_doc, component_selector, &spin_path)?; + + let mut status_lines: Vec = Vec::with_capacity(typed_secrets.len()); + let mut env_lines: Vec = Vec::with_capacity(typed_secrets.len()); + let mut spin_changed = false; + + for entry in typed_secrets { + let spin_var = entry.key_value.to_ascii_lowercase(); + let env_var = format!("SPIN_VARIABLE_{}", spin_var.to_ascii_uppercase()); + + if upsert_variables_entry(&mut spin_doc, &spin_var, &spin_path)? { + spin_changed = true; + } + if upsert_component_variable(&mut spin_doc, &component_id, &spin_var, &spin_path)? { + spin_changed = true; + } + + env_lines.push(format!("{env_var}=")); + status_lines.push(format!( + "spin: variable `{spin_var}` on component `{component_id}` (env `{env_var}`)" + )); + } + + if spin_changed && !dry_run { + fs::write(&spin_path, spin_doc.to_string()) + .map_err(|err| format!("failed to write {}: {err}", spin_path.display()))?; + } + append_lines_dedup(&env_path, &env_lines, dry_run) + .map_err(|err| format!("write {}: {err}", env_path.display()))?; + + Ok(ProvisionOutcome { + status_lines, + deployed: None, + }) +} + +/// Insert `[variables]. = { default = "", secret = true }` +/// into `doc` if the key is absent. If a `[variables].` +/// entry already exists — commonly because the operator has +/// customised the `default` fallback or added extra metadata — +/// LEAVE it untouched so the operator override survives repeat +/// provisions. Returns `Ok(true)` if the entry was newly added, +/// `Ok(false)` if it was already present. +fn upsert_variables_entry( + doc: &mut toml_edit::DocumentMut, + spin_var: &str, + spin_path: &Path, +) -> Result { + use toml_edit::{InlineTable, Item, Table, Value}; + + let variables = doc + .entry("variables") + .or_insert_with(|| Item::Table(Table::new())); + let variables_tbl = variables + .as_table_mut() + .ok_or_else(|| format!("{}: `variables` is not a table", spin_path.display()))?; + if variables_tbl.contains_key(spin_var) { + return Ok(false); + } + let mut inline = InlineTable::new(); + inline.insert("default", Value::from("")); + inline.insert("secret", Value::from(true)); + variables_tbl.insert(spin_var, Item::Value(Value::InlineTable(inline))); + Ok(true) +} + +/// Insert `[component..variables]. = "{{ spin_var }}"` +/// (a literal placeholder string containing the template braces) +/// if the key is absent. LEAVES an existing binding alone so an +/// operator who has already wired the variable to a literal (or a +/// different template) survives a repeat provision. Returns +/// `Ok(true)` if newly added, `Ok(false)` if already present. +fn upsert_component_variable( + doc: &mut toml_edit::DocumentMut, + component_id: &str, + spin_var: &str, + spin_path: &Path, +) -> Result { + use toml_edit::{value, Item, Table}; + + let component_root = doc.get_mut("component").ok_or_else(|| { + format!( + "{}: [component.*] tables expected but `component` key missing", + spin_path.display() + ) + })?; + let component_tbl = component_root + .as_table_mut() + .ok_or_else(|| format!("{}: `component` is not a table", spin_path.display()))?; + let target = component_tbl.get_mut(component_id).ok_or_else(|| { + format!( + "{}: [component.{component_id}] is not declared", + spin_path.display() + ) + })?; + let target_tbl = target.as_table_mut().ok_or_else(|| { + format!( + "{}: [component.{component_id}] is not a table", + spin_path.display() + ) + })?; + let variables = target_tbl + .entry("variables") + .or_insert_with(|| Item::Table(Table::new())); + let variables_tbl = variables.as_table_mut().ok_or_else(|| { + format!( + "{}: [component.{component_id}.variables] is not a table", + spin_path.display() + ) + })?; + if variables_tbl.contains_key(spin_var) { + return Ok(false); + } + variables_tbl.insert(spin_var, value(format!("{{{{ {spin_var} }}}}"))); + Ok(true) +} + /// In-memory variant of [`ensure_kv_label_in_component`]: append /// `platform` to `[component.].key_value_stores` in /// `doc`. Creates the array if absent. Returns `Ok(true)` if the @@ -3552,6 +3741,248 @@ mod tests { ); } + // ---------- provision_typed (Task 26) ---------- + + #[test] + fn spin_provision_typed_writes_lowercased_variables_and_uppercased_env() { + let dir = tempdir().unwrap(); + fs::write( + dir.path().join("spin.toml"), + synthesise_spin_toml(TEST_COMPONENT_ID, None), + ) + .unwrap(); + + let entries = [TypedSecretEntry::new( + "default", + "API_TOKEN", + "Demo_API_TOKEN", + )]; + SpinCliAdapter + .provision_typed( + dir.path(), + Some("spin.toml"), + None, + &entries, + ProvisionMode::Local, + false, + ) + .expect("provision_typed local ok"); + + let spin_after = fs::read_to_string(dir.path().join("spin.toml")).unwrap(); + assert!( + spin_after.contains("[variables]"), + "root [variables] header: {spin_after}" + ); + assert!( + spin_after.contains("demo_api_token = { default = \"\", secret = true }"), + "inline variables entry with default=\"\" and secret=true: {spin_after}" + ); + assert!( + spin_after.contains("[component.demo.variables]"), + "component variables header: {spin_after}" + ); + assert!( + spin_after.contains(r#"demo_api_token = "{{ demo_api_token }}""#), + "component-level template placeholder: {spin_after}" + ); + + let env_after = fs::read_to_string(dir.path().join(".env")).unwrap(); + assert!( + env_after + .lines() + .any(|line| line == "SPIN_VARIABLE_DEMO_API_TOKEN="), + "SPIN_VARIABLE_= placeholder line: {env_after}" + ); + } + + #[test] + fn spin_provision_typed_uses_explicit_component_selector() { + let dir = tempdir().unwrap(); + // Synthesise with component_selector = "worker" so the + // [component.worker] table exists. + fs::write( + dir.path().join("spin.toml"), + synthesise_spin_toml("demo", Some("worker")), + ) + .unwrap(); + + let entries = [TypedSecretEntry::new("default", "API_TOKEN", "demo_token")]; + SpinCliAdapter + .provision_typed( + dir.path(), + Some("spin.toml"), + Some("worker"), + &entries, + ProvisionMode::Local, + false, + ) + .expect("provision_typed with selector ok"); + + let spin_after = fs::read_to_string(dir.path().join("spin.toml")).unwrap(); + assert!( + spin_after.contains("[component.worker.variables]"), + "selector target receives placeholder: {spin_after}" + ); + assert!( + !spin_after.contains("[component.demo.variables]"), + "non-selected component id must NOT receive placeholder: {spin_after}" + ); + } + + #[test] + fn spin_provision_typed_errors_when_component_ambiguous_and_no_selector() { + let dir = tempdir().unwrap(); + // Multi-component spin.toml with NO selector. + fs::write( + dir.path().join("spin.toml"), + "spin_manifest_version = 2\n\ + [application]\nname = \"x\"\nversion = \"0\"\n\ + [component.foo]\nsource = \"foo.wasm\"\n\ + [component.bar]\nsource = \"bar.wasm\"\n", + ) + .unwrap(); + + let entries = [TypedSecretEntry::new("default", "API_TOKEN", "demo_token")]; + let err = SpinCliAdapter + .provision_typed( + dir.path(), + Some("spin.toml"), + None, + &entries, + ProvisionMode::Local, + false, + ) + .expect_err("ambiguous component must error"); + assert!( + err.contains("foo") + && err.contains("bar") + && err.contains("[adapters.spin.adapter].component"), + "error names available component ids AND the config knob: {err}" + ); + } + + #[test] + fn spin_provision_typed_errors_when_selector_does_not_match_component() { + let dir = tempdir().unwrap(); + fs::write( + dir.path().join("spin.toml"), + synthesise_spin_toml(TEST_COMPONENT_ID, None), + ) + .unwrap(); + + let entries = [TypedSecretEntry::new("default", "API_TOKEN", "demo_token")]; + let err = SpinCliAdapter + .provision_typed( + dir.path(), + Some("spin.toml"), + Some("missing"), + &entries, + ProvisionMode::Local, + false, + ) + .expect_err("bad selector must error"); + assert!( + err.contains("missing"), + "error names the missing selector: {err}" + ); + } + + #[test] + fn spin_provision_typed_cloud_mode_is_a_no_op() { + let dir = tempdir().unwrap(); + // Do NOT seed spin.toml — cloud mode must return an empty + // outcome WITHOUT touching the filesystem. + let entries = [TypedSecretEntry::new("default", "API_TOKEN", "demo_token")]; + let out = SpinCliAdapter + .provision_typed( + dir.path(), + Some("spin.toml"), + None, + &entries, + ProvisionMode::Cloud, + false, + ) + .expect("cloud mode no-op ok"); + assert!( + out.status_lines.is_empty(), + "cloud mode emits no status lines: {:?}", + out.status_lines + ); + assert!( + out.deployed.is_none(), + "cloud mode carries no deployed state" + ); + assert!( + !dir.path().join("spin.toml").exists(), + "cloud mode must NOT create spin.toml" + ); + assert!( + !dir.path().join(".env").exists(), + "cloud mode must NOT create .env" + ); + } + + #[test] + fn spin_provision_typed_deduplicates_matching_variable() { + let dir = tempdir().unwrap(); + // Operator has customised `default = "custom-fallback"` — a + // repeat provision_typed must NOT clobber it. + fs::write( + dir.path().join("spin.toml"), + "spin_manifest_version = 2\n\ + [application]\nname = \"demo\"\nversion = \"0\"\n\ + [[trigger.http]]\nroute = \"/...\"\ncomponent = \"demo\"\n\ + [component.demo]\nsource = \"demo.wasm\"\n\ + [variables]\ndemo_api_token = { default = \"custom-fallback\", secret = true }\n", + ) + .unwrap(); + + let entries = [TypedSecretEntry::new( + "default", + "API_TOKEN", + "demo_api_token", + )]; + SpinCliAdapter + .provision_typed( + dir.path(), + Some("spin.toml"), + None, + &entries, + ProvisionMode::Local, + false, + ) + .expect("idempotent provision_typed ok"); + + let spin_after = fs::read_to_string(dir.path().join("spin.toml")).unwrap(); + assert!( + spin_after.contains(r#"default = "custom-fallback""#), + "operator's custom `default` value preserved: {spin_after}" + ); + } + + #[test] + fn spin_provision_typed_errors_if_spin_toml_absent() { + let dir = tempdir().unwrap(); + // Do NOT seed spin.toml. Local mode must error naming the + // missing baseline (Task 8b's CLI bootstrap should have + // written it). + let entries = [TypedSecretEntry::new("default", "API_TOKEN", "demo_token")]; + let err = SpinCliAdapter + .provision_typed( + dir.path(), + Some("spin.toml"), + None, + &entries, + ProvisionMode::Local, + false, + ) + .expect_err("missing spin.toml must error"); + assert!( + err.contains("spin.toml") && err.contains(dir.path().to_str().unwrap()), + "error names the missing spin.toml path: {err}" + ); + } + // ---------- synthesise_spin_toml / synthesise_runtime_config_toml ---------- #[test] From 84bfea9c5ee1df7c191d85a844fb4cab3f3aadd1 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:47:34 -0700 Subject: [PATCH 39/71] Axum: local-mode provision creates .edgezero/ and writes .env (no axum.toml changes) --- crates/edgezero-adapter-axum/src/cli.rs | 312 +++++++++++++++++++++++- crates/edgezero-cli/src/provision.rs | 44 ++-- 2 files changed, 323 insertions(+), 33 deletions(-) diff --git a/crates/edgezero-adapter-axum/src/cli.rs b/crates/edgezero-adapter-axum/src/cli.rs index 8d61f724..9a76b3f9 100644 --- a/crates/edgezero-adapter-axum/src/cli.rs +++ b/crates/edgezero-adapter-axum/src/cli.rs @@ -10,6 +10,7 @@ use ctor::ctor; use edgezero_adapter::cli_support::{ find_manifest_upwards, find_workspace_root, path_distance, read_package_name, }; +use edgezero_adapter::env_file::append_lines_dedup; use edgezero_adapter::registry::{ register_adapter, Adapter, AdapterAction, AdapterDeployedState, AdapterPushContext, ProvisionMode, ProvisionOutcome, ProvisionStores, ReadConfigEntry, ResolvedStoreId, @@ -158,17 +159,17 @@ impl Adapter for AxumCliAdapter { fn provision( &self, - _manifest_root: &Path, + manifest_root: &Path, _adapter_manifest_path: Option<&str>, _component_selector: Option<&str>, stores: &ProvisionStores<'_>, _deployed: Option<&AdapterDeployedState>, mode: ProvisionMode, - _dry_run: bool, + dry_run: bool, ) -> Result { match mode { ProvisionMode::Cloud => {} - ProvisionMode::Local => return Err("local mode lands in Section 5".to_owned()), + ProvisionMode::Local => return provision_local(manifest_root, stores, dry_run), } //: axum has no remote resources. Print one note per // declared store id so the operator sees the CLI heard @@ -691,6 +692,87 @@ fn read_axum_project_with_env( }) } +/// Local-mode `provision` arm. +/// +/// Axum is the odd one out: its adapter manifest (`axum.toml`) stays +/// tracked and operator-owned, so provision must NEVER edit it. The +/// only thing to synthesise is the `.edgezero/.env` file the runtime +/// reads at boot: `__NAME` lines seed the store->platform-name map +/// for every declared kind (KV / CONFIG / SECRETS), and commented +/// `__KEY` placeholders for CONFIG stores let the operator uncomment +/// them to switch to a staging blob without hand-remembering the +/// full env-var name. +/// +/// The `.edgezero/` directory anchors at `manifest_root` — Axum has +/// no adapter-specific manifest worth anchoring on (there is one, but +/// it's operator-owned and we've promised not to touch it). +/// +/// Dedup — including commented/uncommented cross-form dedup — is +/// delegated to [`append_lines_dedup`] so operator overrides survive +/// re-runs. +fn provision_local( + manifest_root: &Path, + stores: &ProvisionStores<'_>, + dry_run: bool, +) -> Result { + let dot_edgezero = manifest_root.join(".edgezero"); + if !dry_run { + fs::create_dir_all(&dot_edgezero) + .map_err(|err| format!("create {}: {err}", dot_edgezero.display()))?; + } + let env_path = dot_edgezero.join(".env"); + let env_lines = build_axum_env_lines(stores); + append_lines_dedup(&env_path, &env_lines, dry_run) + .map_err(|err| format!("write {}: {err}", env_path.display()))?; + let status_lines = vec![format!( + "axum: ensured {} + appended {} .env lines", + dot_edgezero.display(), + env_lines.len() + )]; + Ok(ProvisionOutcome { + status_lines, + deployed: None, + }) +} + +/// Build the `.env` line set emitted by [`provision_local`]. +/// +/// - One `EDGEZERO__STORES______NAME=` +/// line per store, for every kind (KV, CONFIG, SECRETS). +/// - One commented `# EDGEZERO__STORES__CONFIG____KEY=_staging` +/// placeholder per CONFIG store, so the operator can uncomment to +/// switch blobs without remembering the exact env-var name. +/// +/// Env-var KEY uses the LOGICAL id upper-cased so the runtime env +/// overlay finds it regardless of a teammate's per-store platform +/// override. Env-var VALUE uses the PLATFORM name so the runtime +/// resolves the same backend the rest of the toolchain (Cloudflare, +/// Fastly, Spin, and here the Axum local file store) points at. +fn build_axum_env_lines(stores: &ProvisionStores<'_>) -> Vec { + let mut lines: Vec = Vec::new(); + for (kind, kind_stores) in [ + ("KV", stores.kv), + ("CONFIG", stores.config), + ("SECRETS", stores.secrets), + ] { + for store in kind_stores { + let logical_upper = store.logical.to_ascii_uppercase(); + let platform = &store.platform; + lines.push(format!( + "EDGEZERO__STORES__{kind}__{logical_upper}__NAME={platform}" + )); + } + } + for store in stores.config { + let logical_upper = store.logical.to_ascii_uppercase(); + let logical = &store.logical; + lines.push(format!( + "# EDGEZERO__STORES__CONFIG__{logical_upper}__KEY={logical}_staging" + )); + } + lines +} + #[cfg(test)] mod tests { use super::*; @@ -1468,4 +1550,228 @@ mod tests { "staging key must be present: {raw}" ); } + + // ---------- provision (Local mode) ---------- + + #[test] + fn axum_local_provision_creates_dot_edgezero_dir() { + // Empty fixture — no `.edgezero/` yet, no stores declared. + // Local provision must still create the directory so the + // runtime always sees a well-known location for the `.env` + // file it reads at boot. + let dir = tempdir().unwrap(); + let stores = ProvisionStores { + config: &[], + kv: &[], + secrets: &[], + }; + AxumCliAdapter + .provision( + dir.path(), + None, + None, + &stores, + None, + ProvisionMode::Local, + false, + ) + .unwrap(); + assert!( + dir.path().join(".edgezero").is_dir(), + ".edgezero/ must exist after local provision" + ); + } + + #[test] + fn axum_local_provision_does_not_touch_axum_toml() { + // Load-bearing invariant: unlike cloudflare/fastly/spin, + // axum's manifest is operator-owned and tracked. Provision + // MUST NOT rewrite it. A regression here would silently + // start editing files the operator manages by hand. + let dir = tempdir().unwrap(); + let axum_toml = dir.path().join("axum.toml"); + let sentinel = + "[adapter]\ncrate = \"demo\"\ncrate_dir = \".\"\n# operator-owned sentinel\n"; + fs::write(&axum_toml, sentinel).unwrap(); + let config_ids = ResolvedStoreId::from_logicals(&["app_config"]); + let stores = ProvisionStores { + config: &config_ids, + kv: &[], + secrets: &[], + }; + AxumCliAdapter + .provision( + dir.path(), + Some("axum.toml"), + None, + &stores, + None, + ProvisionMode::Local, + false, + ) + .unwrap(); + let after = fs::read_to_string(&axum_toml).unwrap(); + assert_eq!(after, sentinel, "axum.toml must be byte-for-byte unchanged"); + } + + #[test] + fn axum_local_provision_writes_env_name_lines() { + // For every declared store id (all kinds), a `__NAME` line + // seeds the runtime store->platform-name map. CONFIG stores + // also get a commented `__KEY` placeholder the operator can + // uncomment to switch to a staging blob. + let dir = tempdir().unwrap(); + let config_ids = ResolvedStoreId::from_logicals(&["app_config"]); + let kv_ids = ResolvedStoreId::from_logicals(&["sessions"]); + let secret_ids = ResolvedStoreId::from_logicals(&["default"]); + let stores = ProvisionStores { + config: &config_ids, + kv: &kv_ids, + secrets: &secret_ids, + }; + AxumCliAdapter + .provision( + dir.path(), + None, + None, + &stores, + None, + ProvisionMode::Local, + false, + ) + .unwrap(); + let env = fs::read_to_string(dir.path().join(".edgezero/.env")).unwrap(); + assert!( + env.contains("EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME=app_config"), + "config __NAME line present: {env}" + ); + assert!( + env.contains("EDGEZERO__STORES__KV__SESSIONS__NAME=sessions"), + "kv __NAME line present: {env}" + ); + assert!( + env.contains("EDGEZERO__STORES__SECRETS__DEFAULT__NAME=default"), + "secrets __NAME line present: {env}" + ); + assert!( + env.contains("# EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY=app_config_staging"), + "commented __KEY placeholder present for CONFIG only: {env}" + ); + } + + #[test] + fn axum_local_provision_dedup_preserves_operator_env_overrides() { + // Operator already uncommented + edited the __KEY override. + // A re-provision must NOT re-add the commented placeholder, + // and must NOT clobber the operator's live value. + let dir = tempdir().unwrap(); + let dot_edgezero = dir.path().join(".edgezero"); + fs::create_dir_all(&dot_edgezero).unwrap(); + let env_path = dot_edgezero.join(".env"); + fs::write( + &env_path, + "EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY=operator_override\n", + ) + .unwrap(); + let config_ids = ResolvedStoreId::from_logicals(&["app_config"]); + let stores = ProvisionStores { + config: &config_ids, + kv: &[], + secrets: &[], + }; + AxumCliAdapter + .provision( + dir.path(), + None, + None, + &stores, + None, + ProvisionMode::Local, + false, + ) + .unwrap(); + let env = fs::read_to_string(&env_path).unwrap(); + assert!( + env.contains("EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY=operator_override"), + "operator override preserved: {env}" + ); + assert!( + !env.contains("# EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY="), + "commented placeholder must NOT be re-added: {env}" + ); + } + + #[test] + fn axum_local_provision_uses_platform_name_when_env_overlay_active() { + // Simulates + // EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME=prod_config + // in effect at CLI time via ResolvedStoreId::new(logical, + // platform). The emitted __NAME line's VALUE must be the + // env-resolved platform (`prod_config`); the ENV-VAR KEY + // must still use the LOGICAL id upper-cased (`APP_CONFIG`) + // so the runtime env overlay finds it. Same discipline as + // Cloudflare Task 19. + let dir = tempdir().unwrap(); + let config_ids = vec![ResolvedStoreId::new("app_config", "prod_config")]; + let stores = ProvisionStores { + config: &config_ids, + kv: &[], + secrets: &[], + }; + AxumCliAdapter + .provision( + dir.path(), + None, + None, + &stores, + None, + ProvisionMode::Local, + false, + ) + .unwrap(); + let env = fs::read_to_string(dir.path().join(".edgezero/.env")).unwrap(); + assert!( + env.contains("EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME=prod_config"), + "value uses PLATFORM, env-var key uses LOGICAL: {env}" + ); + assert!( + !env.contains("EDGEZERO__STORES__CONFIG__PROD_CONFIG__NAME="), + "platform name must NOT leak into the env-var key: {env}" + ); + } + + #[test] + fn axum_local_provision_cloud_mode_is_a_no_op() { + // Cloud mode: the pre-existing status-line-only arm stays in + // charge; nothing gets written to disk, and `.edgezero/` must + // NOT be auto-created. The load-bearing assertion here is + // the negative one — the Local arm's file work must not leak + // into Cloud mode. + let dir = tempdir().unwrap(); + let config_ids = ResolvedStoreId::from_logicals(&["app_config"]); + let stores = ProvisionStores { + config: &config_ids, + kv: &[], + secrets: &[], + }; + let outcome = AxumCliAdapter + .provision( + dir.path(), + None, + None, + &stores, + None, + ProvisionMode::Cloud, + false, + ) + .unwrap(); + assert!( + !dir.path().join(".edgezero").exists(), + "cloud mode must NOT auto-create .edgezero/" + ); + assert!( + !outcome.status_lines.is_empty(), + "cloud arm still emits informational status lines" + ); + } } diff --git a/crates/edgezero-cli/src/provision.rs b/crates/edgezero-cli/src/provision.rs index 4db4b0e8..4cfadb9f 100644 --- a/crates/edgezero-cli/src/provision.rs +++ b/crates/edgezero-cli/src/provision.rs @@ -1550,32 +1550,24 @@ ids = ["default"] // which recursively copies the adapter crate dir into the // staging tempdir. The fixture must pre-create the crate dir // referenced by PROVISION_MANIFEST or staging errors before - // dispatch reaches the axum Section-5 stub. + // dispatch reaches axum's Local arm. fs::create_dir_all(temp.path().join("crates/demo-axum")).expect("create adapter crate dir"); let _cwd = CwdGuard::set(temp.path()).expect("chdir into tempdir"); - let err = run_provision(&ProvisionArgs { + // Task 27: axum's Local arm now succeeds (writes .env into a + // `.edgezero/` under `manifest_root`). This test used to + // sentinel on the Section-5 stub's error; the equivalent + // positive signal is a status line that names axum's Local + // outcome. Reaching THAT line proves the manifest loaded, + // path-safety passed, AND `run_with_staging` routed the + // closure through validate + build_stores + provision. + run_provision(&ProvisionArgs { adapter: "axum".to_owned(), dry_run: true, local: true, manifest: PathBuf::from("edgezero.toml"), }) - .expect_err("axum's Section-5 stub errs from inside the staged dispatch"); - // Positive assertion: reaching axum's Section-5 stub proves - // the manifest loaded, path-safety passed, AND `run_with_staging` - // routed the closure through validate + build_stores + provision. - // Without this, an earlier failure would silently satisfy the - // negative assertions below and give false-positive coverage. - assert!( - err.contains("local mode lands in Section 5"), - "must reach axum's Section-5 stub through staging: {err}" - ); - assert!( - !err.contains("must not contain `..` traversal") - && !err.contains("must be a project-relative path") - && !err.contains("resolves outside project root"), - "path-safety must not fire for a valid fixture: {err}" - ); + .expect("axum's Local arm succeeds through the staged dispatch"); } #[test] @@ -1589,23 +1581,15 @@ ids = ["default"] fs::write(&manifest_path, PROVISION_MANIFEST).expect("write manifest"); fs::create_dir_all(temp.path().join("crates/demo-axum")).expect("create adapter crate dir"); - let err = run_provision(&ProvisionArgs { + // Task 27: same successful-Local-arm sentinel as the "_default" + // sibling above. + run_provision(&ProvisionArgs { adapter: "axum".to_owned(), dry_run: true, local: true, manifest: manifest_path.clone(), }) - .expect_err("axum's Section-5 stub errs from inside the staged dispatch"); - assert!( - err.contains("local mode lands in Section 5"), - "must reach axum's Section-5 stub through staging: {err}" - ); - assert!( - !err.contains("must not contain `..` traversal") - && !err.contains("must be a project-relative path") - && !err.contains("resolves outside project root"), - "path-safety must not fire for a valid fixture: {err}" - ); + .expect("axum's Local arm succeeds through the staged dispatch"); } // ---------- CLI-owned first-run bootstrap synthesis ---------- From dad90a2cabddbbbb1d928f19f9d80cb6bd8cbcea Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:54:11 -0700 Subject: [PATCH 40/71] Axum: provision_typed appends secret placeholders to .edgezero/.env --- crates/edgezero-adapter-axum/src/cli.rs | 228 +++++++++++++++++++++++- 1 file changed, 227 insertions(+), 1 deletion(-) diff --git a/crates/edgezero-adapter-axum/src/cli.rs b/crates/edgezero-adapter-axum/src/cli.rs index 9a76b3f9..3fab1a4b 100644 --- a/crates/edgezero-adapter-axum/src/cli.rs +++ b/crates/edgezero-adapter-axum/src/cli.rs @@ -14,6 +14,7 @@ use edgezero_adapter::env_file::append_lines_dedup; use edgezero_adapter::registry::{ register_adapter, Adapter, AdapterAction, AdapterDeployedState, AdapterPushContext, ProvisionMode, ProvisionOutcome, ProvisionStores, ReadConfigEntry, ResolvedStoreId, + TypedSecretEntry, }; use edgezero_adapter::scaffold::{ register_adapter_blueprint, AdapterBlueprint, AdapterFileSpec, CommandTemplates, @@ -132,7 +133,7 @@ struct EdgezeroAxumConfig { #[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. `read_config_entry` delegates to `read_config_entry_local` (axum is local-only). `single_store_kinds` IS overridden below (returns `&[\"secrets\"]`)." + reason = "axum has no validate_app_config_keys / validate_adapter_manifest / validate_typed_secrets requirements; those three trait defaults are intentionally inherited. `read_config_entry` delegates to `read_config_entry_local` (axum is local-only). `single_store_kinds` IS overridden below (returns `&[\"secrets\"]`). `provision_typed` IS overridden below (Local mode appends `=` secret placeholders to `.edgezero/.env`; Cloud is a no-op — axum has no cloud secret store)." )] impl Adapter for AxumCliAdapter { fn execute(&self, action: AdapterAction, args: &[String]) -> Result<(), String> { @@ -213,6 +214,42 @@ impl Adapter for AxumCliAdapter { }) } + fn provision_typed( + &self, + manifest_root: &Path, + _adapter_manifest_path: Option<&str>, + _component_selector: Option<&str>, + typed_secrets: &[TypedSecretEntry<'_>], + mode: ProvisionMode, + dry_run: bool, + ) -> Result { + // Axum has no cloud secret store: cloud is a documented no-op. + // Local mode appends `=` lines to `.edgezero/.env` + // (unquoted empty value — the loosest `.env` form). The + // operator fills in the actual secret by editing the file. + // `append_lines_dedup` handles parent-dir creation so + // `.edgezero/` gets auto-created on the first-run case. + if !matches!(mode, ProvisionMode::Local) { + return Ok(ProvisionOutcome::default()); + } + let env_path = manifest_root.join(".edgezero").join(".env"); + let lines: Vec = typed_secrets + .iter() + .map(|entry| format!("{}=", entry.key_value)) + .collect(); + append_lines_dedup(&env_path, &lines, dry_run) + .map_err(|err| format!("write {}: {err}", env_path.display()))?; + let status_lines = vec![format!( + "axum: wrote {} secret placeholders to {}", + typed_secrets.len(), + env_path.display() + )]; + Ok(ProvisionOutcome { + status_lines, + deployed: None, + }) + } + fn push_config_entries( &self, manifest_root: &Path, @@ -1774,4 +1811,193 @@ mod tests { "cloud arm still emits informational status lines" ); } + + // ---------- provision_typed (Local mode) — secret placeholders ---------- + + #[test] + fn axum_provision_typed_appends_secret_placeholders_to_edgezero_env() { + // Fixture: no `.edgezero/` pre-existing (append_lines_dedup + // creates it via parent-dir handling). provision_typed writes + // `=` per entry — unquoted empty value. + let dir = tempdir().unwrap(); + let entries = [TypedSecretEntry::new( + "default", + "api_token", + "demo_api_token", + )]; + let outcome = AxumCliAdapter + .provision_typed( + dir.path(), + None, + None, + &entries, + ProvisionMode::Local, + false, + ) + .unwrap(); + let env_path = dir.path().join(".edgezero/.env"); + assert!(env_path.exists(), ".env exists: {}", env_path.display()); + let env = fs::read_to_string(&env_path).unwrap(); + assert!( + env.lines().any(|line| line == "demo_api_token="), + "unquoted empty-value placeholder present: {env}" + ); + assert!( + outcome + .status_lines + .iter() + .any(|line| line.contains(&env_path.display().to_string())), + "status line names the .env path: {:?}", + outcome.status_lines + ); + assert!( + outcome.deployed.is_none(), + "local provision_typed returns no deployed state" + ); + } + + #[test] + fn axum_provision_typed_creates_dot_edgezero_if_missing() { + // No `.edgezero/` pre-existing. append_lines_dedup (Task 16c) + // creates parent dirs, so the first-run case works without an + // explicit `create_dir_all` in provision_typed. + let dir = tempdir().unwrap(); + assert!( + !dir.path().join(".edgezero").exists(), + "sanity: .edgezero/ must NOT pre-exist" + ); + let entries = [TypedSecretEntry::new( + "default", + "api_token", + "demo_api_token", + )]; + AxumCliAdapter + .provision_typed( + dir.path(), + None, + None, + &entries, + ProvisionMode::Local, + false, + ) + .unwrap(); + assert!( + dir.path().join(".edgezero").is_dir(), + ".edgezero/ auto-created via append_lines_dedup parent-dir handling" + ); + assert!( + dir.path().join(".edgezero/.env").exists(), + ".env landed inside auto-created .edgezero/" + ); + } + + #[test] + fn axum_provision_typed_cloud_mode_is_a_no_op() { + // Cloud is a no-op: axum has no cloud secret store. The load- + // bearing negative assertion is that Cloud mode must NOT + // create `.edgezero/` or `.env`. + let dir = tempdir().unwrap(); + let entries = [TypedSecretEntry::new( + "default", + "api_token", + "demo_api_token", + )]; + let outcome = AxumCliAdapter + .provision_typed( + dir.path(), + None, + None, + &entries, + ProvisionMode::Cloud, + false, + ) + .unwrap(); + assert!( + outcome.status_lines.is_empty(), + "cloud mode emits no status lines: {:?}", + outcome.status_lines + ); + assert!( + outcome.deployed.is_none(), + "cloud mode returns no deployed state" + ); + assert!( + !dir.path().join(".edgezero").exists(), + "cloud mode must NOT auto-create .edgezero/" + ); + } + + #[test] + fn axum_provision_typed_deduplicates_matching_key() { + // Operator has already filled in the real value. Re-running + // provision_typed must NOT clobber it with the empty + // placeholder — append_lines_dedup collapses keys. + let dir = tempdir().unwrap(); + let dot_edgezero = dir.path().join(".edgezero"); + fs::create_dir_all(&dot_edgezero).unwrap(); + let env_path = dot_edgezero.join(".env"); + fs::write(&env_path, "demo_api_token=operator_value\n").unwrap(); + let entries = [TypedSecretEntry::new( + "default", + "api_token", + "demo_api_token", + )]; + AxumCliAdapter + .provision_typed( + dir.path(), + None, + None, + &entries, + ProvisionMode::Local, + false, + ) + .unwrap(); + let env = fs::read_to_string(&env_path).unwrap(); + assert!( + env.contains("demo_api_token=operator_value"), + "operator's real value survives: {env}" + ); + let token_lines = env + .lines() + .filter(|line| { + let after_hash = line.trim_start().strip_prefix('#').unwrap_or(line); + after_hash.trim_start().starts_with("demo_api_token=") + }) + .count(); + assert_eq!( + token_lines, 1, + "exactly one demo_api_token line remains: {env}" + ); + } + + #[test] + fn axum_provision_typed_handles_multiple_entries() { + // Multiple TypedSecretEntry values across different store_ids. + // Every key_value must land as a `=` line, exactly + // once each. + let dir = tempdir().unwrap(); + let entries = [ + TypedSecretEntry::new("default", "api_token", "demo_api_token"), + TypedSecretEntry::new("default", "hmac_key", "demo_hmac_key"), + TypedSecretEntry::new("audit", "audit_token", "audit_secret"), + ]; + AxumCliAdapter + .provision_typed( + dir.path(), + None, + None, + &entries, + ProvisionMode::Local, + false, + ) + .unwrap(); + let env = fs::read_to_string(dir.path().join(".edgezero/.env")).unwrap(); + for expected in ["demo_api_token=", "demo_hmac_key=", "audit_secret="] { + let count = env.lines().filter(|line| *line == expected).count(); + assert_eq!( + count, 1, + "expected exactly one line `{expected}` in .env: {env}" + ); + } + } } From 8f68180155745e0123b25229e5aade934c849868 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 2 Jul 2026 16:25:24 -0700 Subject: [PATCH 41/71] Re-enable provision_local_dry_run worktree-clean test (Section 5 lock-in) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 28b: drop the `#[ignore]` on provision_local_dry_run_worktree_clean_and_no_tempdir_paths_in_stdout. Section 5's per-adapter Local writers are all landed (Tasks 17-28), so the test now has real behavior to lock — every adapter's Local arm stages into a tempdir under dry-run, leaving `examples/app-demo` byte-identical. `snapshot_dir` needed an exclusion path first: `examples/app-demo/ target/` alone is 29 GB of Rust build output, and walking it reads every byte — the test SIGKILL'd after 60 seconds on the first attempt. Added `snapshot_dir_excluding(root, excluded_dir_names)` that skips directories by name at any depth, and the test now excludes `[target, .git, .spin, .wrangler]` — build artifacts and adapter runtime state, all gitignored and outside the "did dry-run touch the worktree" question. The one-arg `snapshot_dir` stays for the fake-fixture callers where nothing needs excluding. Test now runs in 0.07 s across all four adapters. Contract B (no-tempdir-path leak in stdout via captured log) stays deferred — `log::set_logger` is a process-wide one-shot and would require workspace churn to swap the CliLogger for a capturing sink. The `TODO(section-5)` comment inside the loop and the module-level comment above the test both name the two viable retro-fit strategies (subprocess capture / `tracing`-subscriber swap) for a follow-up. Closes Section 5. --- crates/edgezero-cli/src/provision.rs | 51 ++++++++++++++++++++++------ 1 file changed, 40 insertions(+), 11 deletions(-) diff --git a/crates/edgezero-cli/src/provision.rs b/crates/edgezero-cli/src/provision.rs index 4cfadb9f..b1f245cd 100644 --- a/crates/edgezero-cli/src/provision.rs +++ b/crates/edgezero-cli/src/provision.rs @@ -1033,19 +1033,41 @@ serve = "echo" /// assertion — any staging leak that writes into the worktree /// flips one of the pairs. fn snapshot_dir(root: &Path) -> Vec<(PathBuf, Vec)> { + snapshot_dir_excluding(root, &[]) + } + + /// Same as `snapshot_dir` but skips directories whose file name + /// matches an entry in `excluded_dir_names` (at any depth). + /// Used by the app-demo dry-run test: `examples/app-demo/target` + /// alone is tens of gigabytes of Rust build output whose contents + /// are irrelevant to "did dry-run mutate the worktree" — reading + /// them into memory would time the test out with no signal gain. + fn snapshot_dir_excluding(root: &Path, excluded_dir_names: &[&str]) -> Vec<(PathBuf, Vec)> { let mut out = Vec::new(); - snapshot_walk(root, root, &mut out).expect("snapshot walk"); + snapshot_walk(root, root, excluded_dir_names, &mut out).expect("snapshot walk"); out.sort_by(|left, right| left.0.cmp(&right.0)); out } - fn snapshot_walk(base: &Path, dir: &Path, out: &mut Vec<(PathBuf, Vec)>) -> io::Result<()> { + fn snapshot_walk( + base: &Path, + dir: &Path, + excluded_dir_names: &[&str], + out: &mut Vec<(PathBuf, Vec)>, + ) -> io::Result<()> { for read_result in fs::read_dir(dir)? { let entry = read_result?; let file_type = entry.file_type()?; let path = entry.path(); if file_type.is_dir() { - snapshot_walk(base, &path, out)?; + let name = entry.file_name(); + if excluded_dir_names + .iter() + .any(|excluded| name.as_os_str() == *excluded) + { + continue; + } + snapshot_walk(base, &path, excluded_dir_names, out)?; } else if !file_type.is_symlink() { // Regular files only — symlinks are intentionally // skipped so the snapshot mirrors `copy_dir_recursive`'s @@ -2118,7 +2140,6 @@ ids = ["default"] // follow-up task can retrofit either a subprocess-based capture // or a `tracing`-subscriber swap. #[test] - #[ignore = "re-enable after Section 5 lands per-adapter local provision"] fn provision_local_dry_run_worktree_clean_and_no_tempdir_paths_in_stdout() { let _lock = manifest_guard().lock().expect("manifest guard"); @@ -2141,14 +2162,22 @@ ids = ["default"] manifest_path.display() ); + // Exclude directories that are gitignored / test-runtime-only. + // `target/` alone is tens of gigabytes of Rust build output; + // reading it would time the test out with no signal — build + // artifacts are gitignored and outside the "did dry-run touch + // the worktree" question. `.spin/` and `.wrangler/` hold + // adapter runtime state (sqlite KV files, cached tokens) that + // fluctuate under normal `run_serve` and are also gitignored. + let excluded: &[&str] = &["target", ".git", ".spin", ".wrangler"]; + for adapter in ["cloudflare", "fastly", "spin", "axum"] { - let before = snapshot_dir(&app_demo_root); + let before = snapshot_dir_excluding(&app_demo_root, excluded); - // Ignore the Result — today's stub adapters return - // `Err("local mode lands in Section 5")`. Contract A is - // the "was the worktree modified?" claim, and it holds - // regardless of whether the adapter succeeded. Explicit - // type annotation quiets `let_underscore_untyped` / + // Ignore the Result — Contract A is the "was the worktree + // modified?" claim, and it holds regardless of whether the + // adapter's Local arm returned Ok or Err. Explicit type + // annotation quiets `let_underscore_untyped` / // `let_underscore_must_use`. let _result: Result<(), String> = run_provision(&ProvisionArgs { adapter: (*adapter).to_owned(), @@ -2157,7 +2186,7 @@ ids = ["default"] manifest: manifest_path.clone(), }); - let after = snapshot_dir(&app_demo_root); + let after = snapshot_dir_excluding(&app_demo_root, excluded); assert_eq!( before, after, "adapter {adapter}: dry-run must leave the worktree byte-identical" From 324c106a8da61cb0cf2d9e46e93cd19f8eb81832 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 2 Jul 2026 22:02:07 -0700 Subject: [PATCH 42/71] Fix two Section 5 contract gaps: Spin silent success + missing header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Important — Spin local provision no longer silently succeeds on a malformed runtime-config.toml. `append_key_value_store_block` used to return `bool` and hand back `false` when `key_value_store` existed but was not a table (e.g. `key_value_store = "oops"`). The caller treated that as "nothing changed" and still wrote spin.toml + .env, leaving spin.toml referencing a store label that runtime-config.toml never declared. Spin would then fail at boot with a confusing lookup error. Changed to `Result` and now errors with the same "refusing to edit in place" pattern the Fastly and Cloudflare local arms use. Regression: spin_local_provision_errors_when_runtime_config_key_value_store_is _not_a_table asserts the error surfaces AND that spin.toml is not touched on the error path. Medium — provision-written line-oriented files now carry the `# edgezero-provision: v1` schema header. Spec §"Merge mechanics" → "Line-oriented" (line 940 of the spec) requires the header on every provision-written file so future migrations can detect the shape. `append_lines_dedup` wasn't prepending it — Axum `.edgezero/.env`, Spin `.env`, and Cloudflare `.dev.vars` all shipped without it. Added a new `append_lines_dedup_with_header(path, header, lines, dry_run)` that ensures the header is present. `normalised_key` returns `None` for comment-only lines (no `=`), so the ordinary dedup path can't self-check the header; the new fn uses trimmed- equality against existing lines to decide whether to prepend. Exported a workspace-level `EDGEZERO_PROVISION_HEADER` constant so a future spec bump touches one line. The existing `append_lines_dedup` wrapper stays for backward compatibility. 5 new env_file tests cover: first-write prepend, no-duplicate on re-run, prepend above operator-written content, trim-equality match, dry-run no-write. Updated all 6 adapter call sites (2 Cloudflare, 2 Spin, 2 Axum) to pass the header. --- crates/edgezero-adapter-axum/src/cli.rs | 13 +- crates/edgezero-adapter-cloudflare/src/cli.rs | 20 ++- crates/edgezero-adapter-spin/src/cli.rs | 103 ++++++++++-- crates/edgezero-adapter/src/env_file.rs | 156 +++++++++++++++++- 4 files changed, 267 insertions(+), 25 deletions(-) diff --git a/crates/edgezero-adapter-axum/src/cli.rs b/crates/edgezero-adapter-axum/src/cli.rs index 3fab1a4b..290730b8 100644 --- a/crates/edgezero-adapter-axum/src/cli.rs +++ b/crates/edgezero-adapter-axum/src/cli.rs @@ -10,7 +10,7 @@ use ctor::ctor; use edgezero_adapter::cli_support::{ find_manifest_upwards, find_workspace_root, path_distance, read_package_name, }; -use edgezero_adapter::env_file::append_lines_dedup; +use edgezero_adapter::env_file::{append_lines_dedup_with_header, EDGEZERO_PROVISION_HEADER}; use edgezero_adapter::registry::{ register_adapter, Adapter, AdapterAction, AdapterDeployedState, AdapterPushContext, ProvisionMode, ProvisionOutcome, ProvisionStores, ReadConfigEntry, ResolvedStoreId, @@ -237,7 +237,7 @@ impl Adapter for AxumCliAdapter { .iter() .map(|entry| format!("{}=", entry.key_value)) .collect(); - append_lines_dedup(&env_path, &lines, dry_run) + append_lines_dedup_with_header(&env_path, Some(EDGEZERO_PROVISION_HEADER), &lines, dry_run) .map_err(|err| format!("write {}: {err}", env_path.display()))?; let status_lines = vec![format!( "axum: wrote {} secret placeholders to {}", @@ -759,8 +759,13 @@ fn provision_local( } let env_path = dot_edgezero.join(".env"); let env_lines = build_axum_env_lines(stores); - append_lines_dedup(&env_path, &env_lines, dry_run) - .map_err(|err| format!("write {}: {err}", env_path.display()))?; + append_lines_dedup_with_header( + &env_path, + Some(EDGEZERO_PROVISION_HEADER), + &env_lines, + dry_run, + ) + .map_err(|err| format!("write {}: {err}", env_path.display()))?; let status_lines = vec![format!( "axum: ensured {} + appended {} .env lines", dot_edgezero.display(), diff --git a/crates/edgezero-adapter-cloudflare/src/cli.rs b/crates/edgezero-adapter-cloudflare/src/cli.rs index 23f18a66..282c2dec 100644 --- a/crates/edgezero-adapter-cloudflare/src/cli.rs +++ b/crates/edgezero-adapter-cloudflare/src/cli.rs @@ -9,7 +9,7 @@ use ctor::ctor; use edgezero_adapter::cli_support::{ find_manifest_upwards, find_workspace_root, path_distance, read_package_name, run_native_cli, }; -use edgezero_adapter::env_file::append_lines_dedup; +use edgezero_adapter::env_file::{append_lines_dedup_with_header, EDGEZERO_PROVISION_HEADER}; use edgezero_adapter::registry::{ register_adapter, Adapter, AdapterAction, AdapterDeployedState, AdapterPushContext, ProvisionMode, ProvisionOutcome, ProvisionStores, ReadConfigEntry, ResolvedStoreId, @@ -378,8 +378,13 @@ impl Adapter for CloudflareCliAdapter { .iter() .map(|entry| format!(r#"{}="""#, entry.key_value)) .collect(); - append_lines_dedup(&dev_vars_path, &lines, dry_run) - .map_err(|err| format!("write {}: {err}", dev_vars_path.display()))?; + append_lines_dedup_with_header( + &dev_vars_path, + Some(EDGEZERO_PROVISION_HEADER), + &lines, + dry_run, + ) + .map_err(|err| format!("write {}: {err}", dev_vars_path.display()))?; let status_lines = vec![format!( "cloudflare: wrote {} secret placeholders to {}", typed_secrets.len(), @@ -1042,8 +1047,13 @@ fn provision_local( .unwrap_or(manifest_root) .join(".dev.vars"); let dev_vars_lines = build_dev_vars_lines(stores); - append_lines_dedup(&dev_vars_path, &dev_vars_lines, dry_run) - .map_err(|err| format!("write {}: {err}", dev_vars_path.display()))?; + append_lines_dedup_with_header( + &dev_vars_path, + Some(EDGEZERO_PROVISION_HEADER), + &dev_vars_lines, + dry_run, + ) + .map_err(|err| format!("write {}: {err}", dev_vars_path.display()))?; status_lines.push(format!( "cloudflare: wrote {} .dev.vars entries to {}", dev_vars_lines.len(), diff --git a/crates/edgezero-adapter-spin/src/cli.rs b/crates/edgezero-adapter-spin/src/cli.rs index e37857b3..7987ea80 100644 --- a/crates/edgezero-adapter-spin/src/cli.rs +++ b/crates/edgezero-adapter-spin/src/cli.rs @@ -16,7 +16,7 @@ use ctor::ctor; use edgezero_adapter::cli_support::{ find_manifest_upwards, find_workspace_root, path_distance, read_package_name, run_native_cli, }; -use edgezero_adapter::env_file::append_lines_dedup; +use edgezero_adapter::env_file::{append_lines_dedup_with_header, EDGEZERO_PROVISION_HEADER}; use edgezero_adapter::registry::{ register_adapter, Adapter, AdapterAction, AdapterDeployedState, AdapterPushContext, ProvisionMode, ProvisionOutcome, ProvisionStores, ReadConfigEntry, ResolvedStoreId, @@ -1192,7 +1192,7 @@ fn provision_local( .map_err(|err| format!("failed to parse {}: {err}", rc_path.display()))?; let mut rc_changed = false; for store in stores.kv.iter().chain(stores.config.iter()) { - if append_key_value_store_block(&mut rc_doc, &store.platform) { + if append_key_value_store_block(&mut rc_doc, &store.platform)? { rc_changed = true; } } @@ -1211,8 +1211,13 @@ fn provision_local( fs::write(&rc_path, rc_doc.to_string()) .map_err(|err| format!("failed to write {}: {err}", rc_path.display()))?; } - append_lines_dedup(&env_path, &env_lines, dry_run) - .map_err(|err| format!("write {}: {err}", env_path.display()))?; + append_lines_dedup_with_header( + &env_path, + Some(EDGEZERO_PROVISION_HEADER), + &env_lines, + dry_run, + ) + .map_err(|err| format!("write {}: {err}", env_path.display()))?; let total = stores .kv @@ -1353,8 +1358,13 @@ fn provision_typed_local( fs::write(&spin_path, spin_doc.to_string()) .map_err(|err| format!("failed to write {}: {err}", spin_path.display()))?; } - append_lines_dedup(&env_path, &env_lines, dry_run) - .map_err(|err| format!("write {}: {err}", env_path.display()))?; + append_lines_dedup_with_header( + &env_path, + Some(EDGEZERO_PROVISION_HEADER), + &env_lines, + dry_run, + ) + .map_err(|err| format!("write {}: {err}", env_path.display()))?; Ok(ProvisionOutcome { status_lines, @@ -1506,7 +1516,10 @@ fn append_kv_store_to_component( /// The parent `[key_value_store]` table is set implicit so /// `toml_edit` emits only `[key_value_store.]` section /// headers, matching the shape `spin up` reads. -fn append_key_value_store_block(doc: &mut toml_edit::DocumentMut, platform: &str) -> bool { +fn append_key_value_store_block( + doc: &mut toml_edit::DocumentMut, + platform: &str, +) -> Result { use toml_edit::{value, Item, Table}; // Fast-path idempotency check: if a stanza for this platform @@ -1516,7 +1529,7 @@ fn append_key_value_store_block(doc: &mut toml_edit::DocumentMut, platform: &str .and_then(toml_edit::Item::as_table) .is_some_and(|tbl| tbl.contains_key(platform)) { - return false; + return Ok(false); } let parent = doc.entry("key_value_store").or_insert_with(|| { @@ -1524,18 +1537,24 @@ fn append_key_value_store_block(doc: &mut toml_edit::DocumentMut, platform: &str parent_tbl.set_implicit(true); Item::Table(parent_tbl) }); + // `key_value_store` exists but is not a table (e.g. the file has + // `key_value_store = "oops"`). Refuse to edit — mirrors the + // "refusing to edit malformed local state" pattern the Fastly and + // Cloudflare local arms use. Silently returning `Ok(false)` here + // would let the caller write `spin.toml` with a + // `key_value_stores = [""]` binding that + // `runtime-config.toml` never declares, leaving the runtime + // unable to resolve the store at boot. let Some(parent_tbl) = parent.as_table_mut() else { - // `key_value_store` exists but is not a table -- extremely - // unlikely in a Spin-managed runtime-config.toml. Return - // false so the caller sees "nothing changed" rather than - // clobbering a malformed file. - return false; + return Err(format!( + "runtime-config.toml: `key_value_store` exists but is not a table; refusing to edit in place (offending platform: `{platform}`)" + )); }; let mut inner = Table::new(); inner.insert("type", value("spin")); inner.insert("path", value(".spin/sqlite_key_value.db")); parent_tbl.insert(platform, Item::Table(inner)); - true + Ok(true) } /// Build the `.env` line set emitted by [`provision_local`]. @@ -3579,6 +3598,62 @@ mod tests { ); } + #[test] + fn spin_local_provision_errors_when_runtime_config_key_value_store_is_not_a_table() { + // Regression: if runtime-config.toml declares + // `key_value_store = "oops"` (a scalar instead of a table), + // `append_key_value_store_block` used to return `false` + // silently — the caller then wrote spin.toml with a + // `key_value_stores = ["sessions"]` binding that pointed at a + // store label runtime-config.toml never declared. Spin would + // fail to boot with a confusing lookup error. Now it errors + // at provision time, matching the "refusing to edit malformed + // local state" pattern the other adapters use. + let dir = tempdir().expect("tempdir"); + fs::write( + dir.path().join("spin.toml"), + synthesise_spin_toml(TEST_COMPONENT_ID, None), + ) + .expect("seed spin.toml"); + // Malformed runtime-config.toml: scalar where a table + // (or absence) is expected. + fs::write( + dir.path().join("runtime-config.toml"), + "# edgezero-provision: v1\nkey_value_store = \"oops\"\n", + ) + .expect("seed malformed runtime-config.toml"); + + let kv_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_KV_ID]); + let stores = ProvisionStores { + config: &[], + kv: &kv_ids, + secrets: &[], + }; + let err = SpinCliAdapter + .provision( + dir.path(), + Some("spin.toml"), + None, + &stores, + None, + ProvisionMode::Local, + false, + ) + .expect_err("malformed key_value_store must error"); + assert!( + err.contains("key_value_store") && err.contains("not a table"), + "error must name the malformed field and its shape: {err}" + ); + // Sibling `spin.toml` and `.env` must NOT be written on the + // error path — otherwise we'd corrupt the tree even if we + // errored. + let spin_after = fs::read_to_string(dir.path().join("spin.toml")).expect("read spin.toml"); + assert!( + !spin_after.contains(&format!("\"{TEST_KV_ID}\"")), + "spin.toml must NOT list the KV binding when provision errored: {spin_after}" + ); + } + #[test] fn spin_local_provision_resolves_nested_adapter_manifest_path() { let dir = tempdir().expect("tempdir"); diff --git a/crates/edgezero-adapter/src/env_file.rs b/crates/edgezero-adapter/src/env_file.rs index 0acde2d7..6b3fa819 100644 --- a/crates/edgezero-adapter/src/env_file.rs +++ b/crates/edgezero-adapter/src/env_file.rs @@ -8,6 +8,13 @@ use std::collections::BTreeSet; use std::fs; use std::path::Path; +/// Schema-version header prepended to every provision-written +/// line-oriented file (`.env`, `.dev.vars`). Matches the header +/// synthesised TOML files (`wrangler.toml`, `fastly.toml`, +/// `spin.toml`, `runtime-config.toml`) carry. Kept as a single +/// crate-level constant so a future spec bump touches one line. +pub const EDGEZERO_PROVISION_HEADER: &str = "# edgezero-provision: v1"; + /// Append each `=` line iff its normalised key does /// NOT already appear in the file (commented OR uncommented). /// Existing lines are preserved byte-for-byte. Creates the file @@ -19,6 +26,30 @@ use std::path::Path; /// fails. #[inline] pub fn append_lines_dedup(path: &Path, new_lines: &[String], dry_run: bool) -> Result<(), String> { + append_lines_dedup_with_header(path, None, new_lines, dry_run) +} + +/// Same as [`append_lines_dedup`], but also ensures the file's first +/// content line is `header`. When `Some(hdr)` and the existing file +/// does not already contain a trimmed line matching `hdr`, the header +/// is prepended to the write output. Matches the spec's schema- +/// version-header contract: each provision-written line-oriented +/// file starts with `# edgezero-provision: v1` (or the equivalent +/// version comment), and re-provision does not duplicate it. +/// +/// The header is compared to existing lines via trimmed-equality — +/// `normalised_key` returns `None` for comment-only lines like the +/// header, so the ordinary dedup path can't self-check it. +/// +/// # Errors +/// Same as [`append_lines_dedup`]. +#[inline] +pub fn append_lines_dedup_with_header( + path: &Path, + header: Option<&str>, + new_lines: &[String], + dry_run: bool, +) -> Result<(), String> { let mut existing = String::new(); if path.exists() { existing = @@ -26,6 +57,15 @@ pub fn append_lines_dedup(path: &Path, new_lines: &[String], dry_run: bool) -> R } let existing_keys: BTreeSet = existing.lines().filter_map(normalised_key).collect(); + // Header decision: prepend only when the caller asked for one AND + // the existing file has no trimmed-equal line already. Empty files + // ("" plus absent) count as "no header present" so a fresh + // provision writes it. + let header_needed = header.filter(|hdr| { + let trimmed_hdr = hdr.trim(); + !existing.lines().any(|line| line.trim() == trimmed_hdr) + }); + let mut to_append = String::new(); for line in new_lines { let Some(key) = normalised_key(line) else { @@ -39,7 +79,10 @@ pub fn append_lines_dedup(path: &Path, new_lines: &[String], dry_run: bool) -> R to_append.push('\n'); } } - if to_append.is_empty() || dry_run { + + // Nothing to do when there are neither new dedup'd lines nor a + // missing header to prepend. `dry_run` short-circuits any write. + if (to_append.is_empty() && header_needed.is_none()) || dry_run { return Ok(()); } if let Some(parent) = path.parent() { @@ -48,7 +91,15 @@ pub fn append_lines_dedup(path: &Path, new_lines: &[String], dry_run: bool) -> R .map_err(|err| format!("create {}: {err}", parent.display()))?; } } - let mut combined = existing; + + let mut combined = String::new(); + if let Some(hdr) = header_needed { + combined.push_str(hdr); + if !hdr.ends_with('\n') { + combined.push('\n'); + } + } + combined.push_str(&existing); if !combined.is_empty() && !combined.ends_with('\n') { combined.push('\n'); } @@ -167,4 +218,105 @@ mod tests { assert!(path.exists()); assert_eq!(fs::read_to_string(&path).unwrap(), "NEW=x\n"); } + + #[test] + fn header_is_prepended_on_first_write() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join(".env"); + append_lines_dedup_with_header( + &path, + Some("# edgezero-provision: v1"), + &["AAA=1".to_owned()], + false, + ) + .unwrap(); + let after = fs::read_to_string(&path).unwrap(); + assert!( + after.starts_with("# edgezero-provision: v1"), + "header must be first line: {after}" + ); + assert!(after.contains("AAA=1")); + } + + #[test] + fn header_is_not_reprepended_when_already_present() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join(".env"); + fs::write(&path, "# edgezero-provision: v1\nAAA=existing\n").unwrap(); + append_lines_dedup_with_header( + &path, + Some("# edgezero-provision: v1"), + &["BBB=NEW".to_owned()], + false, + ) + .unwrap(); + let after = fs::read_to_string(&path).unwrap(); + let header_count = after + .lines() + .filter(|line| line.trim() == "# edgezero-provision: v1") + .count(); + assert_eq!(header_count, 1, "header must appear exactly once: {after}"); + assert!(after.contains("AAA=existing")); + assert!(after.contains("BBB=NEW")); + } + + #[test] + fn header_is_prepended_when_operator_file_has_no_header() { + // Operator wrote the file by hand before provision ever ran; + // a subsequent provision must prepend the header. + let dir = TempDir::new().unwrap(); + let path = dir.path().join(".env"); + fs::write(&path, "AAA=operator_set\n").unwrap(); + append_lines_dedup_with_header( + &path, + Some("# edgezero-provision: v1"), + &["BBB=NEW".to_owned()], + false, + ) + .unwrap(); + let after = fs::read_to_string(&path).unwrap(); + assert!( + after.starts_with("# edgezero-provision: v1"), + "header must be prepended above operator content: {after}" + ); + assert!(after.contains("AAA=operator_set")); + assert!(after.contains("BBB=NEW")); + } + + #[test] + fn header_matches_ignore_leading_and_trailing_whitespace() { + // If the operator hand-indented the header, we still count + // it as present and don't add a second one. + let dir = TempDir::new().unwrap(); + let path = dir.path().join(".env"); + fs::write(&path, " # edgezero-provision: v1 \nAAA=x\n").unwrap(); + append_lines_dedup_with_header( + &path, + Some("# edgezero-provision: v1"), + &["BBB=x".to_owned()], + false, + ) + .unwrap(); + let after = fs::read_to_string(&path).unwrap(); + let header_count = after + .lines() + .filter(|line| line.trim() == "# edgezero-provision: v1") + .count(); + assert_eq!(header_count, 1, "trim-equality must dedup: {after}"); + } + + #[test] + fn header_dry_run_does_not_write() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join(".env"); + // File missing entirely — dry-run must NOT create it. + append_lines_dedup_with_header( + &path, + Some("# edgezero-provision: v1"), + &["AAA=x".to_owned()], + true, + ) + .unwrap(); + assert!(!path.exists(), "dry-run must not create file"); + } } From b00440c95abd002ecc877128a534cbd26e8b4268 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 2 Jul 2026 22:20:21 -0700 Subject: [PATCH 43/71] Fastly: additive merge for edgezero_runtime_env on re-provision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Important — re-provision after adding a store no longer skips the runtime env update. `upsert_runtime_env_config_store` used to `return Ok(false)` the moment `[local_server.config_stores.edgezero_runtime_env]` existed. On a second provision (operator added a new `[stores.*]` entry between runs, or an env-overlay changed a platform name), the block was skipped entirely — the per-store `[local_server.config_stores .]` block for the new store landed correctly, but its `EDGEZERO__STORES______NAME` line never made it into `edgezero_runtime_env.contents`, leaving the local runtime unable to resolve the store from env. Violated spec §"Merge mechanics" — "preserve operator-set values; only add what's missing". The upsert now branches on whether the block already exists: - First-write path (unchanged): build the full block, insert all managed __NAME keys, attach the commented __KEY placeholder decor. - Additive-merge path (new): open the existing block's `.contents` sub-table and insert only the managed __NAME keys not already present. Operator values and non-managed keys stay byte-for-byte. The commented __KEY decor is not rewritten on re-provision — operators may have uncommented or removed those on purpose, so clobbering them would be a bigger regression. Return semantic: `Ok(true)` if the block was newly written OR at least one key was added; `Ok(false)` if nothing changed. Regression test `fastly_local_provision_additively_merges_new_stores _into_existing_runtime_env` runs provision twice (KV-only, then KV+CONFIG) against the same fastly.toml and asserts: - KV __NAME line survives the second provision; - new CONFIG __NAME line lands inside the existing runtime-env block (not a duplicate block, not a sibling); - runtime-env block header appears exactly once. --- crates/edgezero-adapter-fastly/src/cli.rs | 173 +++++++++++++++++++--- 1 file changed, 155 insertions(+), 18 deletions(-) diff --git a/crates/edgezero-adapter-fastly/src/cli.rs b/crates/edgezero-adapter-fastly/src/cli.rs index 581fb4c6..800b1d5f 100644 --- a/crates/edgezero-adapter-fastly/src/cli.rs +++ b/crates/edgezero-adapter-fastly/src/cli.rs @@ -1270,8 +1270,8 @@ fn upsert_local_config_store( Ok(()) } -/// Insert `[local_server.config_stores.edgezero_runtime_env]` with -/// `format = "inline-toml"` and a `.contents` sub-table containing: +/// Ensure `[local_server.config_stores.edgezero_runtime_env]` exists +/// and add any missing managed keys to its `.contents` sub-table: /// - one `EDGEZERO__STORES______NAME = ""` /// line per declared store across ALL kinds (KV / CONFIG / SECRETS); /// - one COMMENTED `# EDGEZERO__STORES__CONFIG____KEY = @@ -1280,8 +1280,18 @@ fn upsert_local_config_store( /// the KEY overlay at provision time — commented placeholders hint /// the shape and let the operator uncomment + fill it in. /// -/// Idempotent — skip if the block already exists. Returns `true` when -/// the block was newly written, `false` when it was already present. +/// **Additive merge** (spec §"Merge mechanics"): on re-provision after +/// adding a store, the block already exists — we open its `.contents` +/// table and insert only the managed keys that aren't present. +/// Operator-set values and non-managed keys are left byte-for-byte. +/// The commented `__KEY` placeholder decor is only emitted on the +/// first-write path (when the block is newly created); on re-provision +/// we don't try to rewrite decor on existing keys, which would risk +/// clobbering operator edits — operators who need new __KEY hints can +/// re-run provision on an empty block or copy the shape by hand. +/// +/// Returns `true` when the block was newly written OR at least one +/// key was added; `false` when nothing changed. fn upsert_runtime_env_config_store( doc: &mut toml_edit::DocumentMut, stores: &ProvisionStores<'_>, @@ -1303,28 +1313,72 @@ fn upsert_runtime_env_config_store( "`local_server.config_stores` exists but is not a table; refusing to edit in place" .to_owned() })?; - if config_stores_tbl.contains_key(RUNTIME_ENV_NAME) { - return Ok(false); + + // Compute the full managed __NAME key set once — used both for + // first-write insertion and for additive-merge gap-fill. + let managed_keys: Vec<(String, String)> = [ + ("KV", stores.kv), + ("CONFIG", stores.config), + ("SECRETS", stores.secrets), + ] + .into_iter() + .flat_map(|(kind_label, kind_stores)| { + kind_stores.iter().map(move |store| { + ( + format!( + "EDGEZERO__STORES__{kind_label}__{}__NAME", + store.logical.to_ascii_uppercase() + ), + store.platform.clone(), + ) + }) + }) + .collect(); + + let block_existed = config_stores_tbl.contains_key(RUNTIME_ENV_NAME); + if block_existed { + // Additive merge path. Open the existing block's `.contents` + // sub-table and insert only the managed keys that aren't + // already there. Skip the commented __KEY decor rewrite — + // operator may have uncommented or removed those on purpose. + let store_entry = config_stores_tbl.get_mut(RUNTIME_ENV_NAME).ok_or_else(|| { + format!( + "`local_server.config_stores.{RUNTIME_ENV_NAME}` disappeared between contains_key and get_mut" + ) + })?; + let store_tbl = store_entry.as_table_mut().ok_or_else(|| { + format!( + "`local_server.config_stores.{RUNTIME_ENV_NAME}` exists but is not a table; refusing to edit in place" + ) + })?; + let contents_entry = store_tbl + .entry("contents") + .or_insert_with(|| Item::Table(Table::new())); + let contents_tbl = contents_entry.as_table_mut().ok_or_else(|| { + format!( + "`local_server.config_stores.{RUNTIME_ENV_NAME}.contents` exists but is not a table; refusing to edit in place" + ) + })?; + let mut added = false; + for (key, platform) in &managed_keys { + if !contents_tbl.contains_key(key) { + contents_tbl.insert(key, value(platform.as_str())); + added = true; + } + } + return Ok(added); } + // First-write path — build the whole block, including the + // commented __KEY placeholder decor. let mut store_tbl = Table::new(); store_tbl.set_implicit(false); store_tbl.insert("format", value("inline-toml")); let mut contents_tbl = Table::new(); contents_tbl.set_implicit(false); - for (kind_label, kind_stores) in [ - ("KV", stores.kv), - ("CONFIG", stores.config), - ("SECRETS", stores.secrets), - ] { - for store in kind_stores { - let key = format!( - "EDGEZERO__STORES__{kind_label}__{}__NAME", - store.logical.to_ascii_uppercase() - ); - contents_tbl.insert(&key, value(store.platform.as_str())); - } + for (key, platform) in &managed_keys { + contents_tbl.insert(key, value(platform.as_str())); } // Commented `__KEY` placeholders for CONFIG stores. Toml_edit @@ -2726,6 +2780,89 @@ build = \"cargo build --release\" ); } + /// Regression: re-provision after adding a store MUST add the new + /// store's `__NAME` line into the existing `edgezero_runtime_env` + /// block's `.contents` sub-table. Prior impl short-circuited + /// `Ok(false)` as soon as the block existed, leaving new stores + /// invisible to the local runtime. Violates spec §"Merge + /// mechanics" — "preserve operator-set values; only add what's + /// missing". + #[test] + fn fastly_local_provision_additively_merges_new_stores_into_existing_runtime_env() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write(&path, synthesise_fastly_toml("demo", None)).expect("write"); + + // First provision: only the KV store is declared. + let kv_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_KV_ID]); + FastlyCliAdapter + .provision( + dir.path(), + Some("fastly.toml"), + None, + &ProvisionStores { + config: &[], + kv: &kv_ids, + secrets: &[], + }, + None, + ProvisionMode::Local, + false, + ) + .expect("first provision succeeds"); + + let after_first = fs::read_to_string(&path).expect("read"); + assert!( + after_first.contains(r#"EDGEZERO__STORES__KV__SESSIONS__NAME = "sessions""#), + "first provision wrote the KV __NAME line: {after_first}" + ); + assert!( + !after_first.contains("APP_CONFIG__NAME"), + "first provision must NOT emit a CONFIG line for a store that wasn't declared: {after_first}" + ); + + // Second provision: operator added a CONFIG store (and the + // block from run 1 already exists). The new store's __NAME + // line MUST land inside the existing runtime-env contents + // sub-table. + let config_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_CONFIG_ID]); + FastlyCliAdapter + .provision( + dir.path(), + Some("fastly.toml"), + None, + &ProvisionStores { + config: &config_ids, + kv: &kv_ids, + secrets: &[], + }, + None, + ProvisionMode::Local, + false, + ) + .expect("second provision succeeds"); + + let after_second = fs::read_to_string(&path).expect("read"); + // Additive: original KV line preserved. + assert!( + after_second.contains(r#"EDGEZERO__STORES__KV__SESSIONS__NAME = "sessions""#), + "second provision must preserve the KV __NAME line: {after_second}" + ); + // Additive: new CONFIG line inserted into the existing block. + assert!( + after_second + .contains(r#"EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME = "app_config""#), + "second provision must ADD the new CONFIG __NAME line into the existing runtime-env block: {after_second}" + ); + // No duplicate runtime-env block header. + let block_header = "[local_server.config_stores.edgezero_runtime_env]"; + assert_eq!( + after_second.matches(block_header).count(), + 1, + "runtime-env block header must appear exactly once (no duplicate block emitted): {after_second}" + ); + } + /// A missing `fastly.toml` is a bug in the Task 8b bootstrap path. /// Provision must error CLEARLY -- naming the expected path -- /// rather than silently re-synthesising (we don't have the app From 0778ec912e74f7ddc504df50a1f1d5e73cd8027a Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 2 Jul 2026 23:09:29 -0700 Subject: [PATCH 44/71] Add run_provision_typed public entry point --- crates/edgezero-cli/src/lib.rs | 2 +- crates/edgezero-cli/src/provision.rs | 682 ++++++++++++++++++++++++++- 2 files changed, 667 insertions(+), 17 deletions(-) diff --git a/crates/edgezero-cli/src/lib.rs b/crates/edgezero-cli/src/lib.rs index 6608eabe..abb18cc0 100644 --- a/crates/edgezero-cli/src/lib.rs +++ b/crates/edgezero-cli/src/lib.rs @@ -56,7 +56,7 @@ pub use config::{ run_config_validate_typed, DiffExit, }; #[cfg(feature = "cli")] -pub use provision::run_provision; +pub use provision::{run_provision, run_provision_typed}; #[cfg(feature = "cli")] use args::{BuildArgs, DeployArgs, NewArgs, ServeArgs}; diff --git a/crates/edgezero-cli/src/provision.rs b/crates/edgezero-cli/src/provision.rs index b1f245cd..6a81e252 100644 --- a/crates/edgezero-cli/src/provision.rs +++ b/crates/edgezero-cli/src/provision.rs @@ -16,16 +16,23 @@ use toml_edit::{table, value, DocumentMut}; use crate::args::ProvisionArgs; use crate::config::{ - enforce_single_store_capability, reject_merged_id_collisions, strict_handler_paths, - validate_deployed_field_ownership, + build_typed_secret_entries, enforce_single_store_capability, + load_validation_context_with_options, reject_merged_id_collisions, + resolve_app_config_path_primitive, run_typed_preflight, strict_handler_paths, + validate_deployed_field_ownership, ValidationContext, }; use crate::copy_tree::copy_dir_recursive; use crate::ensure_adapter_defined; use crate::path_safety::assert_provision_paths_contained; -use edgezero_adapter::registry::{self as adapter_registry, ProvisionStores, ResolvedStoreId}; +use edgezero_adapter::registry::{ + self as adapter_registry, ProvisionOutcome, ProvisionStores, ResolvedStoreId, TypedSecretEntry, +}; use edgezero_adapter::AdapterDeployedState; +use edgezero_core::app_config::{self, AppConfigLoadOptions, AppConfigMeta}; use edgezero_core::env_config::EnvConfig; use edgezero_core::manifest::{Manifest, ManifestAdapter, ManifestLoader, StoreDeclaration}; +use serde::de::DeserializeOwned; +use validator::Validate; /// Owned counterpart to the borrowed `ProvisionStores<'_>`. Used by /// dispatch arms that need to build resolved store ids per-root @@ -86,6 +93,17 @@ fn run_manifest_shape_gates(manifest: &Manifest, adapter_name: &str) -> Result<( Ok(()) } +/// Resolve the project root that hosts the manifest file. Returns +/// `.` when the manifest path has an empty parent (bare +/// `--manifest edgezero.toml`), matching `run_with_staging`'s +/// project-relative expectations. +fn manifest_root_from(manifest_path: &Path) -> &Path { + manifest_path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")) +} + /// # Errors /// /// Returns an error string if the manifest can't be loaded, the @@ -118,11 +136,7 @@ pub fn run_provision(args: &ProvisionArgs) -> Result<(), String> { // gating on `args.local` also preserves the existing cloud // fixtures where `manifest` lives at project root outside `crate`. if args.local { - let manifest_root_for_check = args - .manifest - .parent() - .filter(|parent| !parent.as_os_str().is_empty()) - .unwrap_or_else(|| Path::new(".")); + let manifest_root_for_check = manifest_root_from(&args.manifest); assert_provision_paths_contained( manifest_root_for_check, adapter_cfg.adapter.manifest.as_deref(), @@ -143,11 +157,7 @@ pub fn run_provision(args: &ProvisionArgs) -> Result<(), String> { run_manifest_shape_gates(manifest, &args.adapter)?; - let manifest_root = args - .manifest - .parent() - .filter(|parent| !parent.as_os_str().is_empty()) - .unwrap_or_else(|| Path::new(".")); + let manifest_root = manifest_root_from(&args.manifest); // Fallback to "" when [app].name is unset: today's synthesiser // default is a no-op so the value isn't consulted; per-adapter @@ -231,6 +241,229 @@ pub fn run_provision(args: &ProvisionArgs) -> Result<(), String> { Ok(()) } +/// Typed-secret companion to [`run_provision`]. Only meaningful in +/// local mode — cloud provisioning short-circuits to the base +/// `run_provision` (typed handling flows through vendor CLIs like +/// `wrangler secret put` at deploy time). +/// +/// Combines `run_provision` (base) + `Adapter::provision_typed` +/// (secret placeholders) under a shared preflight. In dry-run, +/// both dispatch calls execute inside a single `run_with_staging` +/// tempdir so the typed merge sees the baseline manifest the base +/// step wrote. +/// +/// # Errors +/// +/// Returns an error string if the manifest can't be loaded, the +/// adapter isn't declared, the app-config fails validation, or any +/// adapter dispatch reports a failure. +#[inline] +pub fn run_provision_typed(args: &ProvisionArgs) -> Result<(), String> +where + C: DeserializeOwned + Validate + AppConfigMeta, +{ + // Cloud: delegate. No typed writeback in cloud mode. + if !args.local { + return run_provision(args); + } + + // 1. Validation context (env_overlay=false — provision captures + // operator-typed values, not env-overridden ones). + let ctx = load_validation_context_with_options(&args.manifest, None, false, false)?; + + // 2. Base preflight gates — must fire BEFORE any tempdir work + // so dry-run can't bypass expensive-mistake protection. + // Real-write inherits these from run_provision's own call + // below; dry-run inlines base+typed inside staging. + enforce_single_store_capability(ctx.manifest(), &args.adapter)?; + strict_handler_paths(ctx.manifest())?; + + // 3. Canonical adapter lookup (case-insensitive on the key). + // Clone the canonical spelling so the borrow from + // `adapter_entry` frees before later re-borrows of + // `ctx.manifest()` inside the staging closure. + let (canonical_borrow, adapter_cfg) = ctx + .manifest() + .adapter_entry(&args.adapter) + .ok_or_else(|| format!("adapter `{}` not declared in manifest", args.adapter))?; + let canonical_adapter_name = canonical_borrow.clone(); + let adapter_manifest_rel_owned = adapter_cfg.adapter.manifest.clone(); + let adapter_component_owned = adapter_cfg.adapter.component.clone(); + let adapter_crate_rel_owned = adapter_cfg.adapter.crate_path.clone(); + let manifest_root = manifest_root_from(&args.manifest); + + // 4. Path containment (mirrors run_provision's local-mode gate). + assert_provision_paths_contained( + manifest_root, + adapter_manifest_rel_owned.as_deref(), + adapter_crate_rel_owned.as_deref(), + )?; + + // 5. Typed deserialise + non-secret validate. Reconstruct the + // app-config path + app name from the manifest instead of + // calling `ctx.app_config_path()` / `ctx.app_name()` — those + // accessors carry a `#[cfg_attr(not(test), expect(dead_code, + // ...))]` marker that would flip to `unfulfilled_lint + // _expectations` the moment this lib code exercised them. + // `load_validation_context_with_options` already guaranteed + // `manifest.app.name` is `Some`, so `unwrap_or_default` is + // load-bearing only for the impossible-shape case. + let manifest = ctx.manifest(); + let app_name = manifest.app.name.clone().unwrap_or_default(); + let app_config_path = resolve_app_config_path_primitive(None, &args.manifest, &app_name); + let mut opts = AppConfigLoadOptions::default(); + opts.env_overlay = false; + let cfg: C = + app_config::deserialize_app_config_with_options::(&app_config_path, &app_name, &opts) + .map_err(|err| format!("app config load failed: {err}"))?; + app_config::validate_excluding_secrets(&cfg) + .map_err(|err| format!("app config validation failed: {err}"))?; + + // 6. Shared typed preflight (typed_secret_checks + per-adapter + // validate_typed_secrets). + run_typed_preflight(&cfg, &ctx)?; + + // 7. Build the TypedSecretEntry slice. + let entries = build_typed_secret_entries::(&ctx)?; + + // 8. Dispatch. + let adapter = adapter_registry::get_adapter(&canonical_adapter_name).ok_or_else(|| { + format!("adapter `{canonical_adapter_name}` is not registered in this build") + })?; + + if !args.dry_run { + // Real-write: base then typed against the live worktree. + run_provision(args)?; + let outcome = adapter.provision_typed( + manifest_root, + adapter_manifest_rel_owned.as_deref(), + adapter_component_owned.as_deref(), + &entries, + adapter_registry::ProvisionMode::Local, + false, + )?; + for line in outcome.status_lines { + log::info!("{line}"); + } + return Ok(()); + } + + let report = run_local_dry_run_typed( + adapter, + &ctx, + &canonical_adapter_name, + adapter_manifest_rel_owned.as_deref(), + adapter_component_owned.as_deref(), + adapter_crate_rel_owned.as_deref(), + args, + &entries, + manifest_root, + )?; + if !report.is_empty() { + log::info!("{report}"); + } + Ok(()) +} + +/// Dry-run arm of [`run_provision_typed`]. Extracted so the parent +/// function stays under the workspace `too_many_lines` lint. Stages +/// a tempdir that hosts BOTH `adapter.provision` (base) AND +/// `adapter.provision_typed` (secret placeholders) so the typed +/// merge sees the baseline manifest the base step wrote. The report +/// is rendered inside the closure — `staged_root` is only valid +/// until the tempdir drops. +#[expect( + clippy::too_many_arguments, + reason = "dry-run typed arm needs adapter, validation context, canonical name, adapter manifest / component / crate paths, args, entries, and manifest root — same distinct-arg shape as `validate_and_dispatch` above" +)] +fn run_local_dry_run_typed( + adapter: &'static dyn adapter_registry::Adapter, + ctx: &ValidationContext, + canonical_adapter_name: &str, + adapter_manifest_rel: Option<&str>, + adapter_component: Option<&str>, + adapter_crate_rel: Option<&str>, + args: &ProvisionArgs, + entries: &[TypedSecretEntry<'_>], + manifest_root: &Path, +) -> Result { + let adapter_crate_rel_path = adapter_crate_rel.map_or_else(|| Path::new("."), Path::new); + let deployed_state = deployed_state_for(ctx.manifest(), canonical_adapter_name); + let app_name = ctx.manifest().app.name.clone().unwrap_or_default(); + let baseline_pairs = adapter.synthesise_baseline_manifest( + manifest_root, + adapter_manifest_rel, + adapter_component, + &app_name, + deployed_state.as_ref(), + )?; + + let (report, _tempdir) = run_with_staging( + manifest_root, + adapter_crate_rel_path, + |staged_root, _staged_crate| { + write_baseline_to_disk(staged_root, &baseline_pairs)?; + adapter.validate_adapter_manifest( + staged_root, + adapter_manifest_rel, + adapter_component, + )?; + let owned_stores = build_stores_against(staged_root, args, adapter, ctx.manifest())?; + let stores = owned_stores.as_refs(); + // Spec §"Dry-run" step 3: pass `dry_run = false` — the + // tempdir IS the dry-run mechanism, not the flag. + let base = adapter.provision( + staged_root, + adapter_manifest_rel, + adapter_component, + &stores, + deployed_state.as_ref(), + adapter_registry::ProvisionMode::Local, + false, + )?; + let typed = adapter.provision_typed( + staged_root, + adapter_manifest_rel, + adapter_component, + entries, + adapter_registry::ProvisionMode::Local, + false, + )?; + let combined = ProvisionOutcome { + status_lines: base + .status_lines + .into_iter() + .chain(typed.status_lines) + .collect(), + deployed: base.deployed.or(typed.deployed), + }; + // Render INSIDE the closure — the allow-list builder / + // `default_adapter_manifest_for` both match on lowercase, + // so canonical (possibly mixed-case) names MUST be + // lowercased before dispatch. + let adapter_lower = canonical_adapter_name.to_ascii_lowercase(); + let adapter_manifest_rel_or_default = adapter_manifest_rel.map_or_else( + || default_adapter_manifest_for(&adapter_lower).to_owned(), + String::from, + ); + let adapter_manifest_abs = staged_root.join(&adapter_manifest_rel_or_default); + let allow_list = build_dry_run_allow_list( + manifest_root, + staged_root, + &adapter_lower, + &adapter_manifest_abs, + ); + Ok(render_dry_run_report( + manifest_root, + staged_root, + &allow_list, + &combined, + )) + }, + )?; + Ok(report) +} + /// Pair each declared id in `declaration` with its platform name /// via the `EDGEZERO__STORES______NAME` env overlay. /// Returns empty when the manifest doesn't declare the kind. @@ -832,13 +1065,19 @@ where } #[cfg(test)] +#[expect( + clippy::arbitrary_source_item_ordering, + clippy::default_numeric_fallback, + reason = "test module groups items by subject (typed-provision fixtures next to their tests) rather than strict item-kind buckets; validator bounds like `length(min = 1)` fall through to the annotated field's integer type" +)] mod tests { use super::*; use crate::args::ProvisionArgs; use crate::test_support::{manifest_guard, EnvOverride, PROVISION_MANIFEST}; use edgezero_adapter::registry::{ - register_adapter, Adapter, AdapterAction, ProvisionMode, ProvisionOutcome, + get_adapter, register_adapter, Adapter, AdapterAction, ProvisionMode, ProvisionOutcome, }; + use edgezero_core::app_config::{SecretField, SecretKind}; use std::collections::BTreeMap; use std::env; use std::fs; @@ -847,7 +1086,6 @@ mod tests { use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Mutex; use tempfile::TempDir; - use validator::Validate as _; // ----- fixtures for CLI-owned first-run bootstrap synthesis ----- // @@ -890,6 +1128,12 @@ serve = "echo" // real `[adapters..deployed]` block and threaded it // through — not left it silently `None`. static RECORDED_SYNTH_DEPLOYED: Mutex> = Mutex::new(None); + // Task 29: captures the `TypedSecretEntry` slice the CLI passes + // into `FakeBootstrapAdapter::provision_typed`. Recorded as + // `(store_id, field_name, key_value)` triples because the entry + // itself borrows from the `ValidationContext`'s raw config; the + // owned form outlives the closure so tests can read it back. + static RECORDED_TYPED_ENTRIES: Mutex> = Mutex::new(Vec::new()); static SYNTH_CALLED: AtomicBool = AtomicBool::new(false); static VALIDATE_SAW_FILE: AtomicBool = AtomicBool::new(false); @@ -924,7 +1168,7 @@ serve = "echo" #[expect( clippy::missing_trait_methods, - reason = "the fake overrides name/deployed_fields/provision/synthesise_baseline_manifest/validate_adapter_manifest; every other trait method inherits its default (no-op or Unsupported)" + reason = "the fake overrides name/deployed_fields/provision/provision_typed/single_store_kinds/synthesise_baseline_manifest/validate_adapter_manifest; every other trait method inherits its default (no-op or Unsupported)" )] impl Adapter for FakeBootstrapAdapter { fn deployed_fields(&self) -> &'static [&'static str] { @@ -953,6 +1197,38 @@ serve = "echo" Ok(ProvisionOutcome::default()) } + fn provision_typed( + &self, + _manifest_root: &Path, + _adapter_manifest_path: Option<&str>, + _component_selector: Option<&str>, + typed_secrets: &[TypedSecretEntry<'_>], + _mode: ProvisionMode, + _dry_run: bool, + ) -> Result { + if let Ok(mut slot) = RECORDED_TYPED_ENTRIES.lock() { + slot.clear(); + slot.extend(typed_secrets.iter().map(|entry| { + ( + entry.store_id.to_owned(), + entry.field_name.to_owned(), + entry.key_value.to_owned(), + ) + })); + } + Ok(ProvisionOutcome::default()) + } + + fn single_store_kinds(&self) -> &'static [&'static str] { + // The fake advertises `secrets` as Single-capable so the + // Task 29 capability-gate test can drive + // `enforce_single_store_capability` without leaning on a + // real adapter's registration. Existing fake fixtures + // declare zero or one secret id, so this override does + // not regress the other test cases. + &["secrets"] + } + fn synthesise_baseline_manifest( &self, _manifest_root: &Path, @@ -1023,6 +1299,9 @@ serve = "echo" if let Ok(mut slot) = RECORDED_SYNTH_DEPLOYED.lock() { *slot = None; } + if let Ok(mut slot) = RECORDED_TYPED_ENTRIES.lock() { + slot.clear(); + } SYNTH_CALLED.store(false, Ordering::SeqCst); VALIDATE_SAW_FILE.store(false, Ordering::SeqCst); } @@ -2678,4 +2957,375 @@ manifest = "crates/cf/wrangler.toml" "no file must have been written outside the root" ); } + + // ---------- run_provision_typed ---------- + // + // Task 29 wires the CLI's typed-secret companion to `run_provision`. + // The public entry cloud-short-circuits (delegates to + // `run_provision`) and only performs typed-secret handling in local + // mode. Local mode runs the shared preflight (capability + + // handler-path gates) BEFORE any staging, then dispatches + // `provision` + `provision_typed` inside the same tempdir so the + // typed merge sees the baseline manifest the base step wrote. + + /// Small `AppConfigMeta` fixture used across the + /// `run_provision_typed` tests. Mirrors the shape of production + /// configs: one non-secret field with a `validator` rule + /// (`greeting`) so the "malformed non-secret" test can trigger + /// `validate_excluding_secrets`, and one `#[secret]` field with + /// `KeyInDefault` so `build_typed_secret_entries` produces a + /// single entry against `[stores.secrets].default`. + #[derive(Debug, serde::Deserialize, serde::Serialize, validator::Validate)] + struct TypedTestConfig { + api_token: String, + #[validate(length(min = 1))] + greeting: String, + } + + impl AppConfigMeta for TypedTestConfig { + const SECRET_FIELDS: &'static [SecretField] = &[SecretField { + kind: SecretKind::KeyInDefault, + name: "api_token", + }]; + } + + const TYPED_APP_CONFIG: &str = r#" +api_token = "demo_api_token" +greeting = "hello" +"#; + + /// Manifest the local-mode typed-provision tests share. Uses the + /// fake adapter so `provision` + `provision_typed` are observable + /// via the module-scope statics. Declares a single-id secret store + /// so the fake's `single_store_kinds = &["secrets"]` capability + /// gate passes. + const TYPED_FAKE_MANIFEST_BODY: &str = r#" +[app] +name = "demo-app" + +[adapters.__test_bootstrap_fake__.adapter] +crate = "crates/spin" +manifest = "crates/spin/spin.toml" + +[adapters.__test_bootstrap_fake__.commands] +build = "echo" +deploy = "echo" +serve = "echo" + +[stores.secrets] +ids = ["default"] +"#; + + #[test] + fn run_provision_typed_cloud_short_circuits_without_loading_app_config() { + // Cloud mode has no typed writeback (Cloudflare's `wrangler + // secret put` and Fastly's compute deploy handle secrets via + // their own flows). The typed entry point MUST short-circuit + // to `run_provision` BEFORE touching the app-config file — + // otherwise a cloud call with a missing .toml would fail + // where it never used to. + // + // Fixture: valid edgezero.toml, deliberately NO `demo-app.toml`. + // Cloud short-circuit = Ok. A regression that loaded the typed + // config unconditionally would surface as an "io" / "not + // found" error naming `demo-app.toml`. + let _lock = manifest_guard().lock().expect("manifest guard"); + let temp = TempDir::new().expect("temp dir"); + let manifest_path = temp.path().join("edgezero.toml"); + fs::write(&manifest_path, PROVISION_MANIFEST).expect("write manifest"); + let manifest_str = manifest_path.to_string_lossy().into_owned(); + let _env = EnvOverride::set("EDGEZERO_MANIFEST", &manifest_str); + assert!( + !temp.path().join("demo-app.toml").exists(), + "pre-condition: no .toml" + ); + + run_provision_typed::(&ProvisionArgs { + adapter: "axum".to_owned(), + dry_run: false, + local: false, + manifest: manifest_path.clone(), + }) + .expect("cloud short-circuit must succeed without touching .toml"); + } + + #[test] + fn run_provision_typed_local_fails_loud_on_malformed_app_config() { + // Local mode runs `validate_excluding_secrets` on the typed + // config. A validator-rejected NON-secret value must surface + // as our fn's wrapped `app config validation failed: …` error. + // (Secret fields skip validators here; the check is in the + // shared `run_typed_preflight` typed_secret_checks that we + // gate on separately.) + let _lock = manifest_guard().lock().expect("manifest guard"); + reset_fake_state(); + register_adapter(&FAKE_ADAPTER); + let temp = TempDir::new().expect("temp dir"); + let manifest_path = temp.path().join("edgezero.toml"); + fs::write(&manifest_path, TYPED_FAKE_MANIFEST_BODY).expect("write manifest"); + // `greeting = ""` violates `#[validate(length(min = 1))]` on + // TypedTestConfig; `api_token` is non-empty so + // typed_secret_checks would pass on its own. + let malformed_app_config = "api_token = \"tok\"\ngreeting = \"\"\n"; + fs::write(temp.path().join("demo-app.toml"), malformed_app_config) + .expect("write app config"); + fs::create_dir_all(temp.path().join("crates/spin")).expect("create adapter crate dir"); + + let err = run_provision_typed::(&ProvisionArgs { + adapter: "__test_bootstrap_fake__".to_owned(), + dry_run: true, + local: true, + manifest: manifest_path.clone(), + }) + .expect_err("malformed non-secret value must be rejected"); + assert!( + err.contains("app config validation failed"), + "error must be the wrapped validator failure: {err}" + ); + } + + #[test] + fn run_provision_typed_local_builds_typed_secret_entries_from_raw_table() { + // The public typed entry point must feed adapters a slice of + // `TypedSecretEntry` values derived from the raw app-config + // table via `build_typed_secret_entries`. This test locks the + // raw-table → `TypedSecretEntry` translation end-to-end: the + // fake's `provision_typed` captures the slice, and we assert + // (store_id, field_name, key_value) matches what the fixture + // wrote. + let _lock = manifest_guard().lock().expect("manifest guard"); + reset_fake_state(); + register_adapter(&FAKE_ADAPTER); + let temp = TempDir::new().expect("temp dir"); + let manifest_path = temp.path().join("edgezero.toml"); + fs::write(&manifest_path, TYPED_FAKE_MANIFEST_BODY).expect("write manifest"); + fs::write(temp.path().join("demo-app.toml"), TYPED_APP_CONFIG).expect("write app config"); + fs::create_dir_all(temp.path().join("crates/spin")).expect("create adapter crate dir"); + + run_provision_typed::(&ProvisionArgs { + adapter: "__test_bootstrap_fake__".to_owned(), + dry_run: false, + local: true, + manifest: manifest_path.clone(), + }) + .expect("typed real-write path must succeed with the fake adapter"); + + let recorded = RECORDED_TYPED_ENTRIES + .lock() + .expect("recorded typed entries lock") + .clone(); + assert_eq!( + recorded, + vec![( + "default".to_owned(), + "api_token".to_owned(), + "demo_api_token".to_owned() + )], + "typed secret entry must map (default store, api_token field, demo_api_token key): {recorded:?}" + ); + } + + #[test] + fn run_provision_typed_local_dry_run_runs_capability_preflight() { + // The capability gate MUST fire BEFORE any staging so dry-run + // can't bypass expensive-mistake protection. Fake declares + // `single_store_kinds = &["secrets"]`, so a manifest with two + // secret ids trips the gate. Assert the wording matches + // `enforce_single_store_capability`'s existing message and + // that `SYNTH_CALLED` is still false (the gate short-circuited + // before the tempdir + baseline synthesis fired). + let _lock = manifest_guard().lock().expect("manifest guard"); + reset_fake_state(); + register_adapter(&FAKE_ADAPTER); + let temp = TempDir::new().expect("temp dir"); + let manifest_path = temp.path().join("edgezero.toml"); + let manifest_body = r#" +[app] +name = "demo-app" + +[adapters.__test_bootstrap_fake__.adapter] +crate = "crates/spin" +manifest = "crates/spin/spin.toml" + +[adapters.__test_bootstrap_fake__.commands] +build = "echo" +deploy = "echo" +serve = "echo" + +[stores.secrets] +ids = ["default", "other"] +default = "default" +"#; + fs::write(&manifest_path, manifest_body).expect("write manifest"); + fs::write(temp.path().join("demo-app.toml"), TYPED_APP_CONFIG).expect("write app config"); + fs::create_dir_all(temp.path().join("crates/spin")).expect("create adapter crate dir"); + + let err = run_provision_typed::(&ProvisionArgs { + adapter: "__test_bootstrap_fake__".to_owned(), + dry_run: true, + local: true, + manifest: manifest_path.clone(), + }) + .expect_err("capability gate must reject multi-id declaration"); + assert!( + err.contains("Single-capable for secrets"), + "error must match `enforce_single_store_capability`: {err}" + ); + assert!( + !SYNTH_CALLED.load(Ordering::SeqCst), + "capability gate must fire BEFORE staging (synth not called)" + ); + } + + #[test] + fn run_provision_typed_local_dry_run_runs_handler_paths_preflight() { + // Symmetric to the capability-gate test but for + // `strict_handler_paths`. A malformed trigger handler must + // reject BEFORE the tempdir + synthesis fire. Uses the fake + // adapter so we can observe `SYNTH_CALLED` staying false. + let _lock = manifest_guard().lock().expect("manifest guard"); + reset_fake_state(); + register_adapter(&FAKE_ADAPTER); + let temp = TempDir::new().expect("temp dir"); + let manifest_path = temp.path().join("edgezero.toml"); + let manifest_body = r#" +[app] +name = "demo-app" + +[adapters.__test_bootstrap_fake__.adapter] +crate = "crates/spin" +manifest = "crates/spin/spin.toml" + +[adapters.__test_bootstrap_fake__.commands] +build = "echo" +deploy = "echo" +serve = "echo" + +[stores.secrets] +ids = ["default"] + +[[triggers.http]] +path = "/" +methods = ["GET"] +handler = "not a valid path" +adapters = ["__test_bootstrap_fake__"] +"#; + fs::write(&manifest_path, manifest_body).expect("write manifest"); + fs::write(temp.path().join("demo-app.toml"), TYPED_APP_CONFIG).expect("write app config"); + fs::create_dir_all(temp.path().join("crates/spin")).expect("create adapter crate dir"); + + let err = run_provision_typed::(&ProvisionArgs { + adapter: "__test_bootstrap_fake__".to_owned(), + dry_run: true, + local: true, + manifest: manifest_path.clone(), + }) + .expect_err("handler-path gate must reject malformed handler"); + assert!( + err.contains("handler") && err.contains("Rust path"), + "error must match `strict_handler_paths`: {err}" + ); + assert!( + !SYNTH_CALLED.load(Ordering::SeqCst), + "handler-path gate must fire BEFORE staging (synth not called)" + ); + } + + #[test] + fn run_provision_typed_local_dry_run_handles_case_preserving_adapter_key() { + // The allow-list builder / `default_adapter_manifest_for` both + // match on lowercase — the CLI MUST lowercase the canonical + // adapter name (as spelled in `[adapters.]`) before + // dispatch, or nested paths like `.edgezero/.env` would never + // appear in the dry-run diff for a manifest that spells the + // adapter with a leading capital. + // + // Fixture: `[adapters.Axum]` mixed case (canonical returned + // from `adapter_entry("axum")` = "Axum"). If our code fails to + // lowercase, the axum arm in `build_dry_run_allow_list` misses, + // the allow-list stays empty, and `.edgezero/.env` never + // appears in the rendered diff. Asserting the report contains + // the `--- ` header for `.edgezero/.env` proves the lowercase + // step happened. Call `run_local_dry_run_typed` directly (not + // `run_provision_typed`) so the report is inspectable — the + // public entry only `log::info!`s it. + let _lock = manifest_guard().lock().expect("manifest guard"); + let temp = TempDir::new().expect("temp dir"); + let manifest_path = temp.path().join("edgezero.toml"); + let manifest_body = r#" +[app] +name = "demo-app" + +[adapters.Axum.adapter] +crate = "crates/demo-axum" + +[adapters.Axum.commands] +build = "echo" +deploy = "echo" +serve = "echo" + +[stores.secrets] +ids = ["default"] +"#; + fs::write(&manifest_path, manifest_body).expect("write manifest"); + fs::write(temp.path().join("demo-app.toml"), TYPED_APP_CONFIG).expect("write app config"); + fs::create_dir_all(temp.path().join("crates/demo-axum")).expect("create adapter crate dir"); + let manifest_str = manifest_path.to_string_lossy().into_owned(); + let _env = EnvOverride::set("EDGEZERO_MANIFEST", &manifest_str); + + let args = ProvisionArgs { + adapter: "axum".to_owned(), + dry_run: true, + local: true, + manifest: manifest_path.clone(), + }; + + // First: prove the public entry point returns Ok end-to-end. + run_provision_typed::(&args) + .expect("typed dry-run must succeed with a mixed-case adapter key"); + + // Second: re-run the dry-run helper directly so we can inspect + // the rendered report. Reconstruct the same context the public + // fn built — this is the CLI's own private path and the test + // module is in the same crate. + let ctx = load_validation_context_with_options(&args.manifest, None, false, false) + .expect("load validation context"); + let (canonical_borrow, adapter_cfg) = ctx + .manifest() + .adapter_entry(&args.adapter) + .expect("adapter_entry lookup"); + assert_eq!( + canonical_borrow, "Axum", + "sentinel: canonical spelling stays mixed-case" + ); + let canonical_name = canonical_borrow.clone(); + let adapter_manifest_rel = adapter_cfg.adapter.manifest.clone(); + let adapter_component = adapter_cfg.adapter.component.clone(); + let adapter_crate_rel = adapter_cfg.adapter.crate_path.clone(); + let adapter = get_adapter(&canonical_name).expect("registry lookup case-insensitive"); + let entries = build_typed_secret_entries::(&ctx) + .expect("build typed secret entries"); + let manifest_root = manifest_root_from(&args.manifest); + let report = run_local_dry_run_typed( + adapter, + &ctx, + &canonical_name, + adapter_manifest_rel.as_deref(), + adapter_component.as_deref(), + adapter_crate_rel.as_deref(), + &args, + &entries, + manifest_root, + ) + .expect("dry-run helper must succeed"); + + assert!( + report.contains("--- "), + "diff section must be present (allow-list arm matched via lowercase): {report}" + ); + assert!( + report.contains(".edgezero/.env"), + "axum's `.edgezero/.env` file must appear in the diff: {report}" + ); + } } From c729d934e828493c1d5b9aea7857b0be3d69c825 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 2 Jul 2026 23:14:10 -0700 Subject: [PATCH 45/71] Route Cmd::Provision through run_provision_typed (scaffold + app-demo) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related one-line dispatch changes so downstream CLIs actually walk their `#[secret]` fields at provision time. Task 30 — scaffold template: crates/edgezero-cli/src/templates/cli/src/main.rs.hbs Cmd::Provision arm now calls edgezero_cli::run_provision_typed::<{{NameUpperCamel}}Config>(&args) instead of edgezero_cli::run_provision(&args). Task 30b — in-tree app-demo-cli: examples/app-demo/crates/app-demo-cli/src/main.rs Same substitution with AppDemoConfig — smoke fixtures in Section 7/8 (Tasks 35-37) warm up via `app-demo-cli provision`, so leaving this on the untyped bundle would silently skip Spin's [variables] declarations, SPIN_VARIABLE_* lines, and Cloudflare's .dev.vars secret placeholders in every smoke. Also updated the existing generate_new_scaffolds_workspace_layout test in generator.rs to require the typed dispatch string (`run_provision_typed::`) instead of the untyped one, plus a negative assertion that the untyped `edgezero_cli::run_provision(&args)` call must NOT survive template regeneration. --- crates/edgezero-cli/src/generator.rs | 14 ++++++++++++-- .../edgezero-cli/src/templates/cli/src/main.rs.hbs | 2 +- examples/app-demo/crates/app-demo-cli/src/main.rs | 2 +- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/crates/edgezero-cli/src/generator.rs b/crates/edgezero-cli/src/generator.rs index eeba6883..3f94f6a1 100644 --- a/crates/edgezero-cli/src/generator.rs +++ b/crates/edgezero-cli/src/generator.rs @@ -1339,17 +1339,27 @@ mod tests { } // Typed dispatch — the whole reason a downstream CLI - // exists. Raw push/validate would defeat the point. + // exists. Raw push/validate would defeat the point. Provision + // routes through the typed variant so #[secret] fields on the + // downstream config reach adapter provision_typed impls. for call in [ "run_config_push_typed::", "run_config_validate_typed::", + "run_provision_typed::", "edgezero_cli::run_auth", - "edgezero_cli::run_provision", ] { assert!( main.contains(call), "-cli main.rs must dispatch via `{call}`: {main}" ); } + // Negative: the untyped variant must not survive template + // regeneration — Task 30's whole point was to eliminate the + // bypass where scaffolded CLIs would silently skip typed + // secret writeback. + assert!( + !main.contains("edgezero_cli::run_provision(&args)"), + "-cli main.rs must NOT call untyped run_provision: {main}" + ); } } diff --git a/crates/edgezero-cli/src/templates/cli/src/main.rs.hbs b/crates/edgezero-cli/src/templates/cli/src/main.rs.hbs index d2cb6300..e0248027 100644 --- a/crates/edgezero-cli/src/templates/cli/src/main.rs.hbs +++ b/crates/edgezero-cli/src/templates/cli/src/main.rs.hbs @@ -95,7 +95,7 @@ fn main() { } 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::Provision(args) => edgezero_cli::run_provision_typed::<{{NameUpperCamel}}Config>(&args), Cmd::Serve(args) => edgezero_cli::run_serve(&args), }; if let Err(err) = result { diff --git a/examples/app-demo/crates/app-demo-cli/src/main.rs b/examples/app-demo/crates/app-demo-cli/src/main.rs index 8fc85a37..4cf3b6ae 100644 --- a/examples/app-demo/crates/app-demo-cli/src/main.rs +++ b/examples/app-demo/crates/app-demo-cli/src/main.rs @@ -95,7 +95,7 @@ fn main() { } 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::Provision(args) => edgezero_cli::run_provision_typed::(&args), Cmd::Serve(args) => edgezero_cli::run_serve(&args), }; if let Err(err) = result { From 50455b4f06ca0eea4bf847e23f13e45fab544a72 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 2 Jul 2026 23:30:41 -0700 Subject: [PATCH 46/71] Scaffold: loop run_provision over every selected adapter --- crates/edgezero-cli/src/generator.rs | 159 ++++++++++++++++++++++++++- 1 file changed, 158 insertions(+), 1 deletion(-) diff --git a/crates/edgezero-cli/src/generator.rs b/crates/edgezero-cli/src/generator.rs index 3f94f6a1..8f4902af 100644 --- a/crates/edgezero-cli/src/generator.rs +++ b/crates/edgezero-cli/src/generator.rs @@ -1,4 +1,5 @@ -use crate::args::NewArgs; +use crate::args::{NewArgs, ProvisionArgs}; +use crate::provision::run_provision; use crate::scaffold::{ register_templates, resolve_dep_line, sanitize_crate_name, write_tmpl, ResolvedDependency, ScaffoldError, @@ -41,6 +42,19 @@ pub enum GeneratorError { /// The target output directory already exists; refusing to overwrite. #[error("directory '{}' already exists", .0.display())] OutputDirExists(PathBuf), + /// The scaffold's per-adapter local-provision step failed. Emitted + /// when [`generate_new`] calls [`crate::run_provision`] for each + /// adapter declared in the newly generated `edgezero.toml` and one + /// of those calls returns an error. Carries the failing adapter's + /// id so operators can tell WHICH adapter's synthesise / line + /// writer blew up without having to re-run the loop by hand. + /// + /// The wrapped payload is a `String` (matching `run_provision`'s + /// error type) rather than a `Box`; that lets us name + /// it as a distinct field (`reason`, not `source`) so thiserror + /// doesn't try to treat it as a nested `std::error::Error`. + #[error("scaffold provision failed for adapter `{adapter}`: {reason}")] + ProvisionFailed { adapter: String, reason: String }, /// A template under the workspace scaffold could not be rendered or /// written. Wraps [`ScaffoldError`] for context. #[error(transparent)] @@ -234,6 +248,7 @@ pub fn generate_new(args: &NewArgs) -> Result<(), GeneratorError> { let data_value = Value::Object(data_map); render_templates(&layout, &adapter_artifacts.contexts, &data_value)?; + provision_all_selected_adapters(&layout.out_dir, &adapter_artifacts.adapter_ids)?; initialize_git_repo(&layout.out_dir); log::info!( @@ -779,6 +794,48 @@ fn render_templates( Ok(()) } +/// Run `run_provision --local` once per adapter declared in the +/// newly generated project's manifest. +/// +/// This is the scaffold-time counterpart of the operator running +/// `edgezero provision --adapter --local` after the fact: it +/// drives each adapter's `synthesise_baseline_manifest` + +/// local-provision writers so a fresh `edgezero new` output has its +/// per-adapter local files populated (wrangler.toml, .dev.vars, +/// spin.toml, runtime-config.toml, spin's `.env`, axum's +/// `.edgezero/.env`, fastly's `[local_server.*]` entries) without a +/// second command. +/// +/// Uses the UNTYPED [`run_provision`] on purpose. The generator has +/// no downstream `C` type in scope — typed-secret placeholders +/// (`SPIN_VARIABLE_*` etc.) land later, when the operator first runs +/// the generated downstream CLI's `provision` (which routes through +/// `run_provision_typed`). +/// +/// `ProvisionArgs` is `#[non_exhaustive]`. We build it via +/// `Default::default()` + per-field assignment (using `clone_from` +/// where the source is a reference, per `assigning_clones`) rather +/// than struct-update syntax so a future field addition doesn't +/// silently regress the default-value contract. +fn provision_all_selected_adapters( + project_root: &Path, + adapter_ids: &[String], +) -> Result<(), GeneratorError> { + let manifest_path = project_root.join("edgezero.toml"); + for adapter_id in adapter_ids { + let mut prov_args = ProvisionArgs::default(); + prov_args.adapter.clone_from(adapter_id); + prov_args.local = true; + prov_args.dry_run = false; + prov_args.manifest.clone_from(&manifest_path); + run_provision(&prov_args).map_err(|reason| GeneratorError::ProvisionFailed { + adapter: adapter_id.clone(), + reason, + })?; + } + Ok(()) +} + fn initialize_git_repo(out_dir: &Path) { log::info!("[edgezero] initializing git repository"); match Command::new("git") @@ -1362,4 +1419,104 @@ mod tests { "-cli main.rs must NOT call untyped run_provision: {main}" ); } + + /// Task 31: after `generate_new` returns, every adapter declared + /// in the generated `edgezero.toml` must have already run its + /// local-mode `provision`, so the Cloudflare and Spin per-crate + /// files (synthesised platform manifests + `.env`-style line + /// writers) exist on disk without a second command. + #[test] + fn generate_new_provisions_cloudflare_and_spin_scaffold_artifacts() { + let temp = TempDir::new().expect("temp dir"); + + let args = NewArgs { + name: "demo-app".into(), + dir: Some(temp.path().to_string_lossy().into_owned()), + }; + + generate_new(&args).expect("scaffold succeeds"); + + let project_dir = temp.path().join("demo-app"); + let cf_crate = project_dir.join("crates/demo-app-adapter-cloudflare"); + let spin_crate = project_dir.join("crates/demo-app-adapter-spin"); + + assert!( + cf_crate.join("wrangler.toml").exists(), + "cloudflare wrangler.toml must be synthesised at scaffold time" + ); + assert!( + cf_crate.join(".dev.vars").exists(), + "cloudflare .dev.vars must be created at scaffold time (line writer)" + ); + assert!( + spin_crate.join("spin.toml").exists(), + "spin spin.toml must be synthesised at scaffold time" + ); + assert!( + spin_crate.join("runtime-config.toml").exists(), + "spin runtime-config.toml must be synthesised at scaffold time" + ); + assert!( + spin_crate.join(".env").exists(), + "spin per-crate .env must be created at scaffold time (line writer)" + ); + } + + /// Task 31: after `generate_new` returns, the axum adapter's + /// local-state directory (`.edgezero/`) and its `.env` + /// placeholder file must already exist at the project root, + /// courtesy of the scaffold-time provision loop dispatching to + /// the axum adapter's local writer. + #[test] + fn generate_new_provisions_axum_dot_edgezero_env() { + let temp = TempDir::new().expect("temp dir"); + + let args = NewArgs { + name: "demo-app".into(), + dir: Some(temp.path().to_string_lossy().into_owned()), + }; + + generate_new(&args).expect("scaffold succeeds"); + + let project_dir = temp.path().join("demo-app"); + assert!( + project_dir.join(".edgezero").is_dir(), + ".edgezero/ must exist post-scaffold when axum is in the adapter set" + ); + assert!( + project_dir.join(".edgezero/.env").exists(), + ".edgezero/.env must be seeded by axum's local provision writer" + ); + } + + /// Task 31: when any adapter's provision call fails, the error + /// bubbles out of the loop with the failing adapter's id, so + /// operators can tell WHICH adapter blew up. Exercised via a + /// direct call to the helper with a project root that has no + /// `edgezero.toml` — `run_provision`'s manifest loader fails + /// immediately and the loop wraps the error in + /// `GeneratorError::ProvisionFailed { adapter, .. }`. + #[test] + fn provision_all_selected_adapters_surfaces_adapter_name_on_failure() { + let temp = TempDir::new().expect("temp dir"); + let project_root = temp.path(); + // Deliberately do NOT create edgezero.toml — `run_provision` + // will fail at `ManifestLoader::from_path`. + let err = provision_all_selected_adapters(project_root, &["axum".to_owned()]) + .expect_err("provision must fail without a manifest"); + let GeneratorError::ProvisionFailed { adapter, reason } = &err else { + panic!("expected ProvisionFailed, got {err:?}"); + }; + assert_eq!(adapter, "axum", "must name the failing adapter"); + assert!( + !reason.is_empty(), + "wrapped reason must carry the underlying error" + ); + // Display string also carries the adapter name. + let msg = err.to_string(); + assert!( + msg.contains("axum"), + "Display must surface failing adapter name: {msg}" + ); + } } From 7e458aa9afddc93fe7848a79563f6b8c9db8cfa8 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 2 Jul 2026 23:35:14 -0700 Subject: [PATCH 47/71] Scaffold: extend .gitignore with provision-owned manifests + local state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Section 7 Task 32. Adds the four synthesised adapter manifests (`wrangler.toml`, `fastly.toml`, `spin.toml`, `runtime-config.toml`) and Cloudflare's `.dev.vars` to the scaffold's .gitignore. axum.toml stays tracked — Axum owns its manifest and it's operator-authored, not provision-generated. The generate_new_scaffolds_workspace_layout test now asserts every required entry is present AND that no active ignore rule targets `axum.toml` (comments mentioning axum.toml are allowed — the template's explanatory prose calls out the exclusion). --- crates/edgezero-cli/src/generator.rs | 33 +++++++++++++++++++ .../src/templates/root/gitignore.hbs | 30 +++++++++++++---- 2 files changed, 57 insertions(+), 6 deletions(-) diff --git a/crates/edgezero-cli/src/generator.rs b/crates/edgezero-cli/src/generator.rs index 8f4902af..343d8f12 100644 --- a/crates/edgezero-cli/src/generator.rs +++ b/crates/edgezero-cli/src/generator.rs @@ -1249,6 +1249,39 @@ mod tests { let gitignore = fs::read_to_string(project_dir.join(".gitignore")).expect("read .gitignore"); assert!(gitignore.contains("target/")); + + // Provision-owned manifests are regenerated by `provision + // --local`, so teammates must not commit somebody else's + // per-machine ids. axum.toml is intentionally excluded from + // this list — Axum owns its manifest and it stays tracked. + for entry in [ + "fastly.toml", + "spin.toml", + "wrangler.toml", + "runtime-config.toml", + ".edgezero/", + ".wrangler/", + ".spin/", + ".dev.vars", + ".env", + ] { + assert!( + gitignore.contains(entry), + ".gitignore missing provision-owned entry `{entry}`: {gitignore}" + ); + } + // `axum.toml` MAY appear in explanatory comments but must not + // survive as an ignore rule (a bare or globbed line). Match + // non-comment lines only. + let axum_toml_active_rule = gitignore.lines().any(|line| { + let trimmed = line.trim(); + !trimmed.starts_with('#') && trimmed.contains("axum.toml") + }); + assert!( + !axum_toml_active_rule, + "axum.toml must NOT be gitignored (Axum owns its manifest): {gitignore}" + ); + let clippy = fs::read_to_string(project_dir.join("clippy.toml")).expect("read clippy.toml"); assert!(clippy.contains("allow-expect-in-tests = true")); } diff --git a/crates/edgezero-cli/src/templates/root/gitignore.hbs b/crates/edgezero-cli/src/templates/root/gitignore.hbs index 938f60cc..c4484753 100644 --- a/crates/edgezero-cli/src/templates/root/gitignore.hbs +++ b/crates/edgezero-cli/src/templates/root/gitignore.hbs @@ -3,18 +3,36 @@ bin/ pkg/ target/ +# Cloudflare / Fastly / Spin adapter manifests -- regenerated by +# `edgezero provision --local` and by the scaffold generator, so +# teammates get real per-machine values (kv namespace ids, +# platform-resolved binding names, etc.) instead of committing +# somebody else's. `axum.toml` is INTENTIONALLY NOT in this list +# -- Axum owns its manifest and it stays tracked. +fastly.toml +spin.toml +wrangler.toml +runtime-config.toml + # 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. +# - Cloudflare local KV -> .wrangler/state/v3/kv/*/db.sqlite +# - Cloudflare local secrets -> .dev.vars (provision --local +# writes placeholders; operator +# fills in real values) +# - Spin local KV -> .spin/sqlite_key_value.db +# - Axum local config -> .edgezero/local-config-.json +# All are populated by `provision --local` / `config push --local` +# (or by the runtime itself on first read) and should not be +# committed. .wrangler/ .spin/ .edgezero/ +.dev.vars -# env +# env -- provision --local writes EDGEZERO__STORES__*__NAME lines +# plus commented __KEY overrides here. Operators uncomment / edit +# the KEY line to point at a preferred local variant. .env # OS From 1a631ad0517f2ccbf7ee42f98fa4afbd4c2d30cc Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 2 Jul 2026 23:36:49 -0700 Subject: [PATCH 48/71] Gitignore Cloudflare/Fastly/Spin manifests; regenerate via provision --local MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Section 7 Task 33. Untracks the four in-tree app-demo adapter manifests (`wrangler.toml`, `fastly.toml`, `spin.toml`, `runtime-config.toml`) via `git rm --cached` and adds them to the root .gitignore so subsequent operator provisions don't dirty the worktree. `axum.toml` stays tracked — Axum owns its manifest and it's operator-authored, not provision-generated. Same discipline as the scaffold's .gitignore (Task 32). `.dev.vars` is added even though the current in-tree tree doesn't track any — the regex mirrors the CI gate Task 37 will install so the two runbooks can't drift. Verified via `git ls-files | rg '(^|/)(fastly|spin|wrangler|runtime -config)\.toml$|(^|/)\.dev\.vars$'` returns empty output; worktree files are still present locally so the dev loop keeps working. --- .gitignore | 18 +++++- .../app-demo-adapter-cloudflare/wrangler.toml | 31 --------- .../app-demo-adapter-fastly/fastly.toml | 64 ------------------- .../app-demo-adapter-spin/runtime-config.toml | 21 ------ .../crates/app-demo-adapter-spin/spin.toml | 53 --------------- 5 files changed, 17 insertions(+), 170 deletions(-) delete mode 100644 examples/app-demo/crates/app-demo-adapter-cloudflare/wrangler.toml delete mode 100644 examples/app-demo/crates/app-demo-adapter-fastly/fastly.toml delete mode 100644 examples/app-demo/crates/app-demo-adapter-spin/runtime-config.toml delete mode 100644 examples/app-demo/crates/app-demo-adapter-spin/spin.toml diff --git a/.gitignore b/.gitignore index 46af4737..d4bf0190 100644 --- a/.gitignore +++ b/.gitignore @@ -14,7 +14,23 @@ target/ .spin/ .edgezero/ -# env +# Cloudflare / Fastly / Spin adapter manifests -- regenerated by +# `edgezero provision --local` (Tasks 17-28). Teammates must not +# commit each other's per-machine ids (namespace ids, platform +# binding names, etc.). axum.toml is INTENTIONALLY NOT in this +# list -- Axum owns its manifest and it stays tracked. +fastly.toml +spin.toml +wrangler.toml +runtime-config.toml + +# Cloudflare per-adapter local secret placeholders -- written by +# ` provision --adapter cloudflare --local` (Task 20). +# Operator-edited values must NEVER be committed. +.dev.vars + +# env -- `.env` is provision-owned for Spin (Task 25) and Axum +# (Task 27) too. Operator overrides live here. .env # OS diff --git a/examples/app-demo/crates/app-demo-adapter-cloudflare/wrangler.toml b/examples/app-demo/crates/app-demo-adapter-cloudflare/wrangler.toml deleted file mode 100644 index 6840f654..00000000 --- a/examples/app-demo/crates/app-demo-adapter-cloudflare/wrangler.toml +++ /dev/null @@ -1,31 +0,0 @@ -name = "app-demo-adapter-cloudflare" -main = "build/worker/shim.mjs" -compatibility_date = "2023-05-01" - -[build] -command = "worker-build --release" - -# 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. - -# `[stores.kv].ids = ["sessions", "cache"]` -[[kv_namespaces]] -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 deleted file mode 100644 index 8f6aa1c5..00000000 --- a/examples/app-demo/crates/app-demo-adapter-fastly/fastly.toml +++ /dev/null @@ -1,64 +0,0 @@ -authors = [""] -description = "" -language = "rust" -manifest_version = 3 -name = "app-demo-adapter-fastly" -service_id = "" - -[local_server] - -# Config store for local Viceroy testing. -# 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. -# -# In production the `AppConfig` extractor reads a single -# blob-envelope JSON value stored under the binding's `default_key` -# (written by `app-demo-cli config push`). The individual keys below -# are legacy seeds used only by the raw `/config/{name}` demo handler. -[local_server.config_stores.app_config] -format = "inline-toml" - -[local_server.config_stores.app_config.contents] -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.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.default]] -key = "SMOKE_SECRET" -env = "SMOKE_SECRET" - -[setup] -[setup.kv_stores] -[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.default] -description = "Secret store for EdgeZero demo" - - -[scripts] -build = "cargo build --profile release --target wasm32-wasip1" 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 deleted file mode 100644 index bf3431b7..00000000 --- a/examples/app-demo/crates/app-demo-adapter-spin/runtime-config.toml +++ /dev/null @@ -1,21 +0,0 @@ -# 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 deleted file mode 100644 index f3303699..00000000 --- a/examples/app-demo/crates/app-demo-adapter-spin/spin.toml +++ /dev/null @@ -1,53 +0,0 @@ -spin_manifest_version = 2 - -[application] -name = "app-demo-adapter-spin" -version = "0.1.0" - -# Application-level variable declarations. -# -# 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. -# -# The blob carries `api_token = "demo_api_token"` -- i.e. the field -# `api_token` HOLDS the secret-store key NAME (`demo_api_token`), per -# spec 3.3 Model A. The runtime secret walk asks Spin for that NAME, -# so the variable is declared as `demo_api_token` (default = "" so -# `spin up` starts without a provider configured; pass -# `SPIN_VARIABLE_DEMO_API_TOKEN=` for a populated run). -# -# `vault` is `#[secret(store_ref)]` -- the value is a runtime store -# id, not material to keep secret, but bound for consistency with the -# `AppDemoConfig` surface. `smoke_secret` keeps an empty default so -# `smoke_test_secrets.sh` can flip it via -# `SPIN_VARIABLE_SMOKE_SECRET=`. -[variables] -demo_api_token = { default = "", secret = true } -vault = { default = "default" } -smoke_secret = { default = "" } - -# Component name is shortened for brevity; scaffolded projects use the full -# adapter crate name (e.g. "{{proj_spin}}") via the template. -[[trigger.http]] -route = "/..." -component = "app-demo" - -[component.app-demo] -source = "../../target/wasm32-wasip2/release/app_demo_adapter_spin.wasm" -allowed_outbound_hosts = ["https://*:*"] -# 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=