diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 93b5ba2e..c4fa22bf 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -58,6 +58,20 @@ jobs: - name: Nested AppConfig audit run: cargo run -q --bin check_no_nested_app_config --features nested-app-config-check -- examples/app-demo crates/edgezero-cli/src/templates + # Enforce spec §"Cloudflare / Fastly / Spin manifests are gitignored" + # + §"Migration for downstream projects". The four generated adapter + # manifests (`wrangler.toml`, `fastly.toml`, `spin.toml`, + # `runtime-config.toml`) and Cloudflare's `.dev.vars` MUST NOT be + # tracked -- teammates regenerate them locally via + # `provision --local`. `axum.toml` is intentionally exempt: + # Axum owns its manifest and it stays tracked. + - name: Enforce Cloudflare/Fastly/Spin manifests and .dev.vars are not tracked + run: | + if git ls-files | grep -E '(^|/)(fastly|spin|wrangler|runtime-config)\.toml$|(^|/)\.dev\.vars$'; then + echo "::error::These adapter manifests AND Cloudflare's .dev.vars must be gitignored (spec §'Cloudflare / Fastly / Spin manifests are gitignored' + §'Migration for downstream projects'). axum.toml is the only adapter manifest that stays tracked. .dev.vars carries operator secret values and must NEVER be committed." + exit 1 + fi + - name: Run workspace tests run: cargo test --workspace --all-targets diff --git a/.gitignore b/.gitignore index 46af4737..d3534678 100644 --- a/.gitignore +++ b/.gitignore @@ -14,9 +14,32 @@ 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 +# Provision advisory lock -- created next to edgezero.toml by +# `edgezero provision` / `edgezero config push` to serialise +# concurrent invocations against the same tree. Auto-released on +# process exit; the sentinel file itself is not deleted so peers +# can share it across invocations. Per-machine, never committed. +.edgezero-provision.lock + # OS .DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..d54dec5a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,104 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Breaking changes + +- **`edgezero-adapter::Adapter::provision` trait method changed shape.** Was + `fn provision(&self, root, adapter_manifest, component, stores, dry_run) -> Result, String>` + with an `Ok(Vec::new())` default. Is now + `fn provision(&self, root, adapter_manifest, component, stores, deployed: Option<&AdapterDeployedState>, mode: ProvisionMode, dry_run) -> Result` + with **no default** — every `impl Adapter` must supply it. Any + out-of-tree adapter written against the previous shape will fail to + compile with two errors: the method's arity and its return type. To + migrate: + 1. Add a `mode: ProvisionMode` match arm and a `deployed: Option<&AdapterDeployedState>` parameter. + 2. Return `ProvisionOutcome::from_status_lines(lines)` (or + `::with_deployed(lines, deployed_state)` when the cloud arm has + an id to write back) instead of `Ok(vec![...])`. + 3. Add a fall-through arm `other => Err(...)` on the `match mode` — + `ProvisionMode` is `#[non_exhaustive]` and may gain variants. + +- **`edgezero-adapter::AdapterDeployedState`, `ProvisionOutcome`, + and `ProvisionMode` are now `#[non_exhaustive]`.** Struct-literal + construction from a downstream crate (e.g. + `ProvisionOutcome { status_lines, deployed }`) no longer compiles. + Use the new constructors: + - `ProvisionOutcome::from_status_lines(status_lines)` for local mode + (which returns `deployed: None`). + - `ProvisionOutcome::with_deployed(status_lines, deployed_state)` + for cloud mode that populates the writeback. + - `AdapterDeployedState::default()` + `.fields.insert(...)` / + `.sub_tables.insert(...)` for the deployed state. + +### Added + +- **`edgezero provision` / `edgezero config push` cross-process advisory + lock** (`.edgezero-provision.lock` alongside `edgezero.toml`). + Serialises concurrent invocations against the same tree so + read-modify-write on `.env` / `.dev.vars` / `edgezero.toml` no + longer silently drops a competing writer's edits. Dry-run skips + the lock. Auto-released on process exit; the sentinel file itself + is git-ignored per-machine and safe to delete when no invocation + is running. + +### Fixed + +- Fastly `service_id` no longer lands under `[local_server]` on + re-provision; the merged `fastly.toml` correctly carries it at the + TOML root so `fastly compute deploy` picks it up. +- Fastly cloud `provision` populates `ProvisionOutcome.deployed.service_id` + from `fastly.toml`; the writeback to `[adapters.fastly.deployed]` + in `edgezero.toml` no longer silently drops. +- Cloudflare `.dev.vars` commented `__KEY` placeholder now uses + `_staging` per spec Task 19 (was + `-key>`). +- Cross-adapter `path_mutation_guard` unification (`edgezero-cli` + test binary): scaffold + push-shim tests share the same mutex, no + more intermittent CI flakes from PATH-restore races. +- Cloud `config push` now honours the spec's path-containment MUST + (absolute path + `..` traversal rejection). The strict-local + "manifest inside adapter crate" check stays `--local`-gated so + existing cloud fixtures with root-level manifest paths keep + working. +- Provision `.dev.vars` / `.env` written 0600 on Unix so operator- + filled secret values are not world-readable. +- Provision line-oriented files reject values containing `\n` or + `\r` — a malicious env override can no longer split into a second + `KEY=VALUE` line and inject an unintended env-var. +- Dry-run report emits a **unified diff with 2-line context radius** + instead of the full pre-image; `.dev.vars` / `.env` operator + values no longer stream into CI logs. +- Adapter error paths inside `run_local_dry_run` sanitise raw + `/var/folders/.../edgezero-staging-*` tempdir paths back to the + project-relative form before surfacing. + +### Test coverage + +- End-to-end env-overlay: `EDGEZERO__STORES______NAME` + now has an integration test that drives it from process env + through `EnvConfig::store_name()` into the emitted `.edgezero/.env`. +- Case-insensitive adapter arg: `--adapter AXUM` against + `[adapters.axum]` lowercase now covered (previously only the + reverse direction was locked). +- Cloudflare `wrangler.toml` schema header preserved at line 1 after + provision merge into an operator-authored doc. +- `*.toml.hbs` scaffold templates walker asserts no `KEY = ""` + placeholder leaks past `write_baseline_to_disk`. +- Fastly root-scalar assertions (`service_id`, `[[local_server.kv_stores.sessions]]` + stub row) now reparse-then-index instead of substring-match, so + the shipped `service_id`-under-`[local_server]` bug's regression + class is locked. + +### Deprecated / renamed + +- Three tests named `provision_local_push_after_provision_preserves_*` + were renamed to `provision_typed_local_re_run_preserves_*` — + their bodies never invoked `push_config_entries` and the prior + name misled readers looking for real push→provision coverage. + Real push→provision integration coverage is still an open gap. diff --git a/Cargo.lock b/Cargo.lock index b49c91ae..9a0fc116 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -771,6 +771,7 @@ dependencies = [ "edgezero-adapter-fastly", "edgezero-adapter-spin", "edgezero-core", + "fs4", "futures", "handlebars", "log", @@ -783,6 +784,7 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "toml", + "toml_edit", "validator", "walkdir", ] @@ -1000,6 +1002,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs4" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8640e34b88f7652208ce9e88b1a37a2ae95227d84abec377ccd3c5cfeb141ed4" +dependencies = [ + "rustix", + "windows-sys 0.59.0", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -3367,6 +3379,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" diff --git a/Cargo.toml b/Cargo.toml index 1fea962c..af784e4a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,6 +42,12 @@ edgezero-cli = { path = "crates/edgezero-cli", default-features = false } fastly = "0.12" fern = "0.7" flate2 = { version = "1", features = ["rust_backend"] } +# Cross-platform advisory file locks (flock on Unix, LockFileEx on +# Windows). Used by edgezero-cli to serialise concurrent `provision` +# / `config push` invocations against the same project tree so +# read-modify-write on `.env` / `.dev.vars` / `edgezero.toml` +# doesn't silently drop a competing writer's changes. +fs4 = { version = "0.13", default-features = false, features = ["sync"] } futures = { version = "0.3", features = ["std", "executor"] } futures-util = { version = "0.3", features = ["alloc", "io"] } handlebars = "6" diff --git a/crates/edgezero-adapter-axum/src/cli/mod.rs b/crates/edgezero-adapter-axum/src/cli/mod.rs new file mode 100644 index 00000000..292d958f --- /dev/null +++ b/crates/edgezero-adapter-axum/src/cli/mod.rs @@ -0,0 +1,882 @@ +#![expect( + clippy::mod_module_files, + reason = "Workspace lint policy denies BOTH `self_named_module_files` (wants `cli/mod.rs`) and `mod_module_files` (wants `cli.rs`) -- they contradict, so any file with submodules must opt out of one. This crate's cli directory uses the `cli/mod.rs` form; allow accordingly." +)] +#![expect( + clippy::arbitrary_source_item_ordering, + reason = "submodule declarations sit between the `use` block and the rest of the file's items by Rust convention; the strict-ordering lint disagrees but no human convention puts `mod` blocks AFTER trait impls" +)] + +use std::collections::BTreeMap; +use std::fs; +use std::io; +use std::path::Path; + +use ctor::ctor; +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, + TypedSecretEntry, +}; +use edgezero_adapter::scaffold::{ + register_adapter_blueprint, AdapterBlueprint, AdapterFileSpec, CommandTemplates, + DependencySpec, LoggingDefaults, ManifestSpec, ReadmeInfo, TemplateRegistration, +}; + +mod provision_local; +mod run; + +static AXUM_TEMPLATE_REGISTRATIONS: &[TemplateRegistration] = &[ + TemplateRegistration { + name: "axum_Cargo_toml", + contents: include_str!("../templates/Cargo.toml.hbs"), + }, + TemplateRegistration { + name: "axum_src_main_rs", + contents: include_str!("../templates/src/main.rs.hbs"), + }, + TemplateRegistration { + name: "axum_axum_toml", + contents: include_str!("../templates/axum.toml.hbs"), + }, +]; + +static AXUM_FILE_SPECS: &[AdapterFileSpec] = &[ + AdapterFileSpec { + template: "axum_Cargo_toml", + output: "Cargo.toml", + }, + AdapterFileSpec { + template: "axum_src_main_rs", + output: "src/main.rs", + }, + AdapterFileSpec { + template: "axum_axum_toml", + output: "axum.toml", + }, +]; + +static AXUM_DEPENDENCIES: &[DependencySpec] = &[ + DependencySpec { + key: "dep_edgezero_core_axum", + repo_crate: "crates/edgezero-core", + fallback: "edgezero-core = { git = \"https://git@github.com/stackpop/edgezero.git\", package = \"edgezero-core\" }", + features: &[], + }, + DependencySpec { + key: "dep_edgezero_adapter_axum", + repo_crate: "crates/edgezero-adapter-axum", + fallback: + "edgezero-adapter-axum = { git = \"https://git@github.com/stackpop/edgezero.git\", package = \"edgezero-adapter-axum\", default-features = false }", + features: &["axum"], + }, +]; + +static AXUM_BLUEPRINT: AdapterBlueprint = AdapterBlueprint { + id: "axum", + display_name: "Axum", + crate_suffix: "adapter-axum", + dependency_crate: "edgezero-adapter-axum", + dependency_repo_path: "crates/edgezero-adapter-axum", + template_registrations: AXUM_TEMPLATE_REGISTRATIONS, + files: AXUM_FILE_SPECS, + extra_dirs: &["src"], + dependencies: AXUM_DEPENDENCIES, + manifest: ManifestSpec { + manifest_filename: "axum.toml", + build_target: "native", + build_profile: "dev", + build_features: &[], + }, + commands: CommandTemplates { + build: "cargo build -p {crate}", + serve: "cargo run -p {crate}", + deploy: "# configure deployment for Axum", + }, + logging: LoggingDefaults { + endpoint: None, + level: "info", + echo_stdout: Some(true), + }, + readme: ReadmeInfo { + description: "{display} adapter entrypoint.", + dev_heading: "{display} (local)", + dev_steps: &[ + "`cd {crate_dir}`", + "`cargo run` or `edgezero serve --adapter axum`", + ], + }, + run_module: "edgezero_adapter_axum", +}; + +static AXUM_ADAPTER: AxumCliAdapter = AxumCliAdapter; + +struct AxumCliAdapter; + +#[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\"]`). `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> { + match action { + // The axum adapter is the in-process native dev server — + // there is no remote auth provider to sign in/out of. + // Per spec this is an explicit no-op. + AdapterAction::AuthLogin | AdapterAction::AuthLogout | AdapterAction::AuthStatus => { + log::info!( + "[edgezero] axum has no remote auth surface; `auth` is a no-op for this adapter" + ); + Ok(()) + } + AdapterAction::Build => run::build(args), + AdapterAction::Deploy => run::deploy(args), + AdapterAction::Serve => run::serve(args), + other => Err(format!("axum adapter does not support {other:?}")), + } + } + + fn name(&self) -> &'static str { + "axum" + } + + 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 { + match mode { + ProvisionMode::Cloud => {} + ProvisionMode::Local => { + return provision_local::provision(manifest_root, stores, dry_run) + } + // ProvisionMode is #[non_exhaustive]; explicit error so a + // future mode variant doesn't quietly fall through. + other => { + return Err(format!( + "axum adapter does not implement provision mode {other:?}" + )) + } + } + //: axum has no remote resources. Print one note per + // declared store id so the operator sees the CLI heard + // them — same shape `dry_run` would have, since there is + // nothing to actually perform. + let mut out = Vec::with_capacity( + stores + .kv + .len() + .saturating_add(stores.config.len()) + .saturating_add(stores.secrets.len()), + ); + for store in stores.kv { + let logical = store.logical.as_str(); + out.push(format!( + "axum KV store `{logical}` is in-memory; nothing to provision" + )); + } + for store in stores.config { + // Axum reads `.edgezero/local-config-.json`. + // The platform name is informational here -- the env + // overlay isn't used for local file paths because the + // path encoding is the spec's canonical form. + let logical = store.logical.as_str(); + out.push(format!( + "axum config store `{logical}` reads `.edgezero/local-config-{logical}.json`; nothing to provision" + )); + } + for store in stores.secrets { + let logical = store.logical.as_str(); + out.push(format!( + "axum secret store `{logical}` reads env vars; nothing to provision" + )); + } + if out.is_empty() { + out.push("axum has no declared stores to provision".to_owned()); + } + Ok(ProvisionOutcome::from_status_lines(out)) + } + + 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_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 {}", + typed_secrets.len(), + env_path.display() + )]; + Ok(ProvisionOutcome::from_status_lines(status_lines)) + } + + fn push_config_entries( + &self, + manifest_root: &Path, + _adapter_manifest_path: Option<&str>, + _component_selector: Option<&str>, + store: &ResolvedStoreId, + entries: &[(String, String)], + _push_ctx: &AdapterPushContext<'_>, + dry_run: bool, + ) -> Result, String> { + //: axum is local-only. Push writes the same flat + // `string -> string` JSON object `AxumConfigStore` reads + // back from `.edgezero/local-config-.json`. The path + // is keyed on the LOGICAL id, not the env-resolved + // platform name -- the local file flow is the spec's + // canonical form and isn't subject to the per-store env + // overlay (which targets platform store names, not local + // file paths). + let logical = store.logical.as_str(); + let local_dir = manifest_root.join(".edgezero"); + let target = local_dir.join(format!("local-config-{logical}.json")); + if dry_run { + return Ok(vec![format!( + "would write {} entries to {}", + entries.len(), + target.display() + )]); + } + fs::create_dir_all(&local_dir) + .map_err(|err| format!("failed to create {}: {err}", local_dir.display()))?; + // Upsert into any existing map so a `config push --key + // app_config_staging` doesn't wipe a previously-pushed + // `app_config` blob (spec 12.7 requires default + staging + // to coexist for the `EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY` + // override to switch between them). The map is owned (rather + // than borrowed) so we can merge old + new without lifetime + // surgery on the slice. + let mut map: BTreeMap = match fs::read_to_string(&target) { + Ok(text) if !text.trim().is_empty() => serde_json::from_str(&text).map_err(|err| { + format!( + "failed to parse existing {}: {err} (expected a JSON object of key->envelope)", + target.display() + ) + })?, + _ => BTreeMap::new(), + }; + for (key, value) in entries { + map.insert(key.clone(), value.clone()); + } + let json = serde_json::to_string_pretty(&map) + .map_err(|err| format!("failed to serialize config to JSON: {err}"))?; + fs::write(&target, json) + .map_err(|err| format!("failed to write {}: {err}", target.display()))?; + Ok(vec![format!( + "wrote {} entries to {} ({} total keys after upsert)", + entries.len(), + target.display(), + map.len(), + )]) + } + + fn push_config_entries_local( + &self, + manifest_root: &Path, + adapter_manifest_path: Option<&str>, + component_selector: Option<&str>, + store: &ResolvedStoreId, + entries: &[(String, String)], + push_ctx: &AdapterPushContext<'_>, + dry_run: bool, + ) -> Result, String> { + // Axum is local-only: the default push already writes + // `.edgezero/local-config-.json`, which is what the + // running dev server reads. `--local` is therefore the + // same as the default; we delegate and prepend a notice + // so the operator who typed `--local` for parity with + // fastly/cloudflare knows there was nothing extra to do. + let mut lines = self.push_config_entries( + manifest_root, + adapter_manifest_path, + component_selector, + store, + entries, + push_ctx, + dry_run, + )?; + let notice = + "axum push is always local: `--local` has no separate effect (writes the same `.edgezero/local-config-.json` either way)".to_owned(); + lines.insert(0, notice); + Ok(lines) + } + + fn read_config_entry( + &self, + manifest_root: &Path, + adapter_manifest_path: Option<&str>, + component_selector: Option<&str>, + store: &ResolvedStoreId, + key: &str, + push_ctx: &AdapterPushContext<'_>, + ) -> Result { + // Axum has no "remote" — delegate to the local impl. + // The local JSON file IS the live state for the running dev server. + self.read_config_entry_local( + manifest_root, + adapter_manifest_path, + component_selector, + store, + key, + push_ctx, + ) + } + + fn read_config_entry_local( + &self, + manifest_root: &Path, + _adapter_manifest_path: Option<&str>, + _component_selector: Option<&str>, + store: &ResolvedStoreId, + key: &str, + _push_ctx: &AdapterPushContext<'_>, + ) -> Result { + // Axum reads `.edgezero/local-config-.json`. + // The path is keyed on the LOGICAL id (matching + // `push_config_entries`), not the env-resolved platform name. + let path = manifest_root + .join(".edgezero") + .join(format!("local-config-{}.json", store.logical)); + match fs::read_to_string(&path) { + Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(ReadConfigEntry::MissingStore), + Err(err) => Err(format!("failed to read {}: {err}", path.display())), + Ok(raw) => { + let map: BTreeMap = serde_json::from_str(&raw) + .map_err(|err| format!("failed to parse {}: {err}", path.display()))?; + match map.get(key) { + Some(value) => Ok(ReadConfigEntry::Present(value.clone())), + None => Ok(ReadConfigEntry::MissingKey), + } + } + } + } + + fn single_store_kinds(&self) -> &'static [&'static str] { + //: axum is Multi for KV (local file dirs) and Config + // (local JSON files), Single for Secrets (env vars). + &["secrets"] + } +} + +#[inline] +pub fn register() { + register_adapter(&AXUM_ADAPTER); + register_adapter_blueprint(&AXUM_BLUEPRINT); +} + +#[ctor(unsafe)] +fn register_ctor() { + register(); +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn adapter_name_is_axum() { + assert_eq!(AXUM_ADAPTER.name(), "axum"); + } + + #[test] + fn blueprint_has_correct_id() { + assert_eq!(AXUM_BLUEPRINT.id, "axum"); + assert_eq!(AXUM_BLUEPRINT.display_name, "Axum"); + } + + // ---------- push_config_entries ---------- + + #[test] + fn push_writes_flat_json_to_local_config_file() { + let dir = tempfile::tempdir().expect("tempdir"); + let entries = vec![ + ("greeting".to_owned(), "hello".to_owned()), + ("service.timeout_ms".to_owned(), "1500".to_owned()), + ]; + let lines = AxumCliAdapter + .push_config_entries( + dir.path(), + None, + None, + &ResolvedStoreId::from_logical("app_config"), + &entries, + &AdapterPushContext::new(), + false, + ) + .expect("push succeeds"); + assert_eq!(lines.len(), 1); + assert!( + lines[0].contains("wrote 2 entries"), + "status line names count: {lines:?}" + ); + let json_path = dir.path().join(".edgezero/local-config-app_config.json"); + let raw = fs::read_to_string(&json_path).expect("read written file"); + let parsed: serde_json::Value = serde_json::from_str(&raw).expect("valid JSON"); + assert_eq!(parsed["greeting"], "hello"); + assert_eq!(parsed["service.timeout_ms"], "1500"); + } + + #[test] + fn push_dry_run_does_not_create_local_dir_or_file() { + let dir = tempfile::tempdir().expect("tempdir"); + let entries = vec![("greeting".to_owned(), "hello".to_owned())]; + let lines = AxumCliAdapter + .push_config_entries( + dir.path(), + None, + None, + &ResolvedStoreId::from_logical("app_config"), + &entries, + &AdapterPushContext::new(), + true, + ) + .expect("dry-run succeeds"); + assert!( + lines[0].contains("would write 1 entries"), + "dry-run line: {lines:?}" + ); + assert!( + !dir.path().join(".edgezero").exists(), + ".edgezero must not exist after dry-run" + ); + } + + #[test] + fn push_creates_dot_edgezero_directory_when_missing() { + let dir = tempfile::tempdir().expect("tempdir"); + let entries = vec![("key".to_owned(), "value".to_owned())]; + AxumCliAdapter + .push_config_entries( + dir.path(), + None, + None, + &ResolvedStoreId::from_logical("x"), + &entries, + &AdapterPushContext::new(), + false, + ) + .expect("push succeeds"); + assert!(dir.path().join(".edgezero").is_dir(), ".edgezero created"); + } + + #[test] + fn push_with_empty_entries_writes_empty_json_object() { + let dir = tempfile::tempdir().expect("tempdir"); + AxumCliAdapter + .push_config_entries( + dir.path(), + None, + None, + &ResolvedStoreId::from_logical("empty"), + &[], + &AdapterPushContext::new(), + false, + ) + .expect("push succeeds even with no entries"); + let raw = fs::read_to_string(dir.path().join(".edgezero/local-config-empty.json")) + .expect("read written file"); + let parsed: serde_json::Value = serde_json::from_str(&raw).expect("valid JSON"); + assert_eq!(parsed, serde_json::json!({})); + } + + // ---------- read_config_entry / read_config_entry_local ---------- + + #[test] + fn read_config_entry_local_returns_missing_store_when_file_absent() { + let dir = tempfile::tempdir().expect("tempdir"); + let result = AxumCliAdapter + .read_config_entry_local( + dir.path(), + None, + None, + &ResolvedStoreId::from_logical("app_config"), + "greeting", + &AdapterPushContext::new(), + ) + .expect("infallible on missing file"); + assert!( + matches!(result, ReadConfigEntry::MissingStore), + "missing file => MissingStore" + ); + } + + #[test] + fn read_config_entry_local_returns_missing_key_when_key_absent() { + let dir = tempfile::tempdir().expect("tempdir"); + // Write a JSON file with one key so the store exists, but the + // requested key is not in it. + let local_dir = dir.path().join(".edgezero"); + fs::create_dir_all(&local_dir).expect("create dir"); + fs::write( + local_dir.join("local-config-app_config.json"), + r#"{"other_key": "value"}"#, + ) + .expect("write"); + let result = AxumCliAdapter + .read_config_entry_local( + dir.path(), + None, + None, + &ResolvedStoreId::from_logical("app_config"), + "greeting", + &AdapterPushContext::new(), + ) + .expect("infallible on missing key"); + assert!( + matches!(result, ReadConfigEntry::MissingKey), + "key absent => MissingKey" + ); + } + + #[test] + fn read_config_entry_local_returns_present_when_key_exists() { + let dir = tempfile::tempdir().expect("tempdir"); + let local_dir = dir.path().join(".edgezero"); + fs::create_dir_all(&local_dir).expect("create dir"); + fs::write( + local_dir.join("local-config-app_config.json"), + r#"{"greeting": "hello-axum"}"#, + ) + .expect("write"); + let result = AxumCliAdapter + .read_config_entry_local( + dir.path(), + None, + None, + &ResolvedStoreId::from_logical("app_config"), + "greeting", + &AdapterPushContext::new(), + ) + .expect("key present"); + let ReadConfigEntry::Present(value) = result else { + panic!("expected Present variant"); + }; + assert_eq!(value, "hello-axum", "value matches"); + } + + #[test] + fn read_config_entry_delegates_to_local() { + // Axum has no remote: read_config_entry and read_config_entry_local + // must return the same result for the same inputs. + let dir = tempfile::tempdir().expect("tempdir"); + let local_dir = dir.path().join(".edgezero"); + fs::create_dir_all(&local_dir).expect("create dir"); + fs::write( + local_dir.join("local-config-app_config.json"), + r#"{"greeting": "hello-axum"}"#, + ) + .expect("write"); + let store = ResolvedStoreId::from_logical("app_config"); + let ctx = AdapterPushContext::new(); + let via_local = AxumCliAdapter + .read_config_entry_local(dir.path(), None, None, &store, "greeting", &ctx) + .expect("local ok"); + let via_remote = AxumCliAdapter + .read_config_entry(dir.path(), None, None, &store, "greeting", &ctx) + .expect("remote ok"); + let ReadConfigEntry::Present(local_val) = via_local else { + panic!("expected Present from local"); + }; + let ReadConfigEntry::Present(remote_val) = via_remote else { + panic!("expected Present from remote"); + }; + assert_eq!(local_val, remote_val, "local and remote agree"); + } + + #[test] + fn read_config_entry_local_errors_on_malformed_json() { + let dir = tempfile::tempdir().expect("tempdir"); + let local_dir = dir.path().join(".edgezero"); + fs::create_dir_all(&local_dir).expect("create dir"); + fs::write( + local_dir.join("local-config-app_config.json"), + "not valid json {{{", + ) + .expect("write"); + let result = AxumCliAdapter.read_config_entry_local( + dir.path(), + None, + None, + &ResolvedStoreId::from_logical("app_config"), + "greeting", + &AdapterPushContext::new(), + ); + match result { + Err(err) => assert!( + err.contains("failed to parse"), + "error names the failure: {err}" + ), + Ok(_) => panic!("expected Err for malformed JSON"), + } + } + + /// Spec 12.7: pushing two blobs under different keys (e.g. + /// `app_config` + `app_config_staging`) must leave both keys + /// readable so the runtime + /// `EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY` override can + /// switch between them. Prior to the upsert fix the second push + /// wiped the first by wholesale-rewriting the JSON map. + #[test] + fn push_config_entries_preserves_sibling_keys() { + let dir = tempfile::tempdir().expect("tempdir"); + let store = ResolvedStoreId::from_logical("app_config"); + let ctx = AdapterPushContext::new(); + + AxumCliAdapter + .push_config_entries( + dir.path(), + None, + None, + &store, + &[("app_config".to_owned(), "{\"envelope\":\"A\"}".to_owned())], + &ctx, + false, + ) + .expect("first push"); + AxumCliAdapter + .push_config_entries( + dir.path(), + None, + None, + &store, + &[( + "app_config_staging".to_owned(), + "{\"envelope\":\"B\"}".to_owned(), + )], + &ctx, + false, + ) + .expect("second push (sibling key)"); + + let raw = fs::read_to_string(dir.path().join(".edgezero/local-config-app_config.json")) + .expect("read"); + let map: BTreeMap = serde_json::from_str(&raw).expect("parse map"); + assert_eq!( + map.get("app_config").map(String::as_str), + Some("{\"envelope\":\"A\"}"), + "default key must survive sibling push: {raw}" + ); + assert_eq!( + map.get("app_config_staging").map(String::as_str), + Some("{\"envelope\":\"B\"}"), + "staging key must be present: {raw}" + ); + } + + // ---------- 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}" + ); + } + } +} diff --git a/crates/edgezero-adapter-axum/src/cli/provision_local.rs b/crates/edgezero-adapter-axum/src/cli/provision_local.rs new file mode 100644 index 00000000..0d06eb6b --- /dev/null +++ b/crates/edgezero-adapter-axum/src/cli/provision_local.rs @@ -0,0 +1,512 @@ +use std::fs; +use std::path::Path; + +use edgezero_adapter::env_file::{append_lines_dedup_with_header, EDGEZERO_PROVISION_HEADER}; +use edgezero_adapter::registry::{ProvisionOutcome, ProvisionStores}; + +/// 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. +pub(super) fn provision( + 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_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(), + env_lines.len() + )]; + Ok(ProvisionOutcome::from_status_lines(status_lines)) +} + +/// 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::super::AxumCliAdapter; + use edgezero_adapter::registry::{ + Adapter as _, ProvisionMode, ProvisionStores, ResolvedStoreId, + }; + use std::fs; + use tempfile::tempdir; + + #[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" + ); + } + + #[test] + fn provision_local_creates_dot_edgezero_dir() { + // Empty fixture: `.edgezero/` does not pre-exist and no stores + // are declared. Local provision must still create the directory + // so the runtime has a well-known location to read the `.env` + // file from at boot. + let dir = tempdir().unwrap(); + assert!( + !dir.path().join(".edgezero").exists(), + "sanity: .edgezero/ must NOT pre-exist" + ); + 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 as a directory after local provision" + ); + } + + #[test] + fn provision_local_does_not_touch_axum_toml() { + // Load-bearing invariant: unlike cloudflare/fastly/spin, Axum's + // adapter manifest (`axum.toml`) is operator-owned and tracked. + // Provision MUST NOT synthesise, merge, or otherwise rewrite + // it. The assertion is a byte-identical comparison against a + // distinctive sentinel — a regression that silently starts + // touching axum.toml will flip this. + let dir = tempdir().unwrap(); + let axum_toml = dir.path().join("axum.toml"); + let sentinel = + b"[adapter]\ncrate = \"demo\"\ncrate_dir = \".\"\n# operator-authored do not touch\n"; + fs::write(&axum_toml, sentinel).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(), + Some("axum.toml"), + None, + &stores, + None, + ProvisionMode::Local, + false, + ) + .unwrap(); + let after = fs::read(&axum_toml).unwrap(); + assert_eq!( + after, + sentinel.to_vec(), + "axum.toml must be byte-for-byte unchanged (Axum-exception invariant)" + ); + } + + #[test] + fn provision_local_writes_env_name_lines() { + // Fixture: one store per kind. Local provision must: + // - write `.edgezero/.env` starting with the provenance + // header (Section 5 review fix — `# edgezero-provision: v1`); + // - emit one `__NAME` line per kind (KV / CONFIG / SECRETS); + // - emit a commented `__KEY` placeholder for CONFIG only. + 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.starts_with("# edgezero-provision: v1"), + ".env must start with the provenance header: {env}" + ); + 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 re_provision_preserves_operator_env_edits() { + // First provision writes the base `.edgezero/.env` (including + // the commented `__KEY` placeholder). The operator uncomments + // AND edits the line to point at their own override value. + // Re-running provision must NOT re-add the commented form and + // MUST leave the operator's uncommented line byte-identical + // (Task 16c dedup semantics — key-normalised uncommented + // form wins over any commented sibling). + let dir = tempdir().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_path = dir.path().join(".edgezero/.env"); + let first = fs::read_to_string(&env_path).unwrap(); + assert!( + first.contains("# EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY=app_config_staging"), + "first-run must seed the commented placeholder: {first}" + ); + + // Operator uncomments AND edits the value. + let operator_line = "EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY=my_local_override"; + let edited = first.replace( + "# EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY=app_config_staging", + operator_line, + ); + fs::write(&env_path, &edited).unwrap(); + + AxumCliAdapter + .provision( + dir.path(), + None, + None, + &stores, + None, + ProvisionMode::Local, + false, + ) + .unwrap(); + let after = fs::read_to_string(&env_path).unwrap(); + let matching: Vec<&str> = after + .lines() + .filter(|line| *line == operator_line) + .collect(); + assert_eq!( + matching.len(), + 1, + "operator's uncommented override line must survive byte-identical: {after}" + ); + assert!( + !after.contains("# EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY="), + "commented placeholder must NOT be re-added when uncommented form exists: {after}" + ); + } +} diff --git a/crates/edgezero-adapter-axum/src/cli.rs b/crates/edgezero-adapter-axum/src/cli/run.rs similarity index 54% rename from crates/edgezero-adapter-axum/src/cli.rs rename to crates/edgezero-adapter-axum/src/cli/run.rs index 75caf585..13cefa5c 100644 --- a/crates/edgezero-adapter-axum/src/cli.rs +++ b/crates/edgezero-adapter-axum/src/cli/run.rs @@ -1,115 +1,17 @@ -use std::collections::BTreeMap; use std::env; use std::fs; -use std::io; use std::net::{IpAddr, SocketAddr}; use std::path::{Path, PathBuf}; use std::process::Command; -use ctor::ctor; use edgezero_adapter::cli_support::{ find_manifest_upwards, find_workspace_root, path_distance, read_package_name, }; -use edgezero_adapter::registry::{ - register_adapter, Adapter, AdapterAction, AdapterPushContext, ProvisionStores, ReadConfigEntry, - ResolvedStoreId, -}; -use edgezero_adapter::scaffold::{ - register_adapter_blueprint, AdapterBlueprint, AdapterFileSpec, CommandTemplates, - DependencySpec, LoggingDefaults, ManifestSpec, ReadmeInfo, TemplateRegistration, -}; use edgezero_core::addr; use edgezero_core::manifest::ManifestLoader; use toml::Value; use walkdir::WalkDir; -static AXUM_TEMPLATE_REGISTRATIONS: &[TemplateRegistration] = &[ - TemplateRegistration { - name: "axum_Cargo_toml", - contents: include_str!("templates/Cargo.toml.hbs"), - }, - TemplateRegistration { - name: "axum_src_main_rs", - contents: include_str!("templates/src/main.rs.hbs"), - }, - TemplateRegistration { - name: "axum_axum_toml", - contents: include_str!("templates/axum.toml.hbs"), - }, -]; - -static AXUM_FILE_SPECS: &[AdapterFileSpec] = &[ - AdapterFileSpec { - template: "axum_Cargo_toml", - output: "Cargo.toml", - }, - AdapterFileSpec { - template: "axum_src_main_rs", - output: "src/main.rs", - }, - AdapterFileSpec { - template: "axum_axum_toml", - output: "axum.toml", - }, -]; - -static AXUM_DEPENDENCIES: &[DependencySpec] = &[ - DependencySpec { - key: "dep_edgezero_core_axum", - repo_crate: "crates/edgezero-core", - fallback: "edgezero-core = { git = \"https://git@github.com/stackpop/edgezero.git\", package = \"edgezero-core\" }", - features: &[], - }, - DependencySpec { - key: "dep_edgezero_adapter_axum", - repo_crate: "crates/edgezero-adapter-axum", - fallback: - "edgezero-adapter-axum = { git = \"https://git@github.com/stackpop/edgezero.git\", package = \"edgezero-adapter-axum\", default-features = false }", - features: &["axum"], - }, -]; - -static AXUM_BLUEPRINT: AdapterBlueprint = AdapterBlueprint { - id: "axum", - display_name: "Axum", - crate_suffix: "adapter-axum", - dependency_crate: "edgezero-adapter-axum", - dependency_repo_path: "crates/edgezero-adapter-axum", - template_registrations: AXUM_TEMPLATE_REGISTRATIONS, - files: AXUM_FILE_SPECS, - extra_dirs: &["src"], - dependencies: AXUM_DEPENDENCIES, - manifest: ManifestSpec { - manifest_filename: "axum.toml", - build_target: "native", - build_profile: "dev", - build_features: &[], - }, - commands: CommandTemplates { - build: "cargo build -p {crate}", - serve: "cargo run -p {crate}", - deploy: "# configure deployment for Axum", - }, - logging: LoggingDefaults { - endpoint: None, - level: "info", - echo_stdout: Some(true), - }, - readme: ReadmeInfo { - description: "{display} adapter entrypoint.", - dev_heading: "{display} (local)", - dev_steps: &[ - "`cd {crate_dir}`", - "`cargo run` or `edgezero serve --adapter axum`", - ], - }, - run_module: "edgezero_adapter_axum", -}; - -static AXUM_ADAPTER: AxumCliAdapter = AxumCliAdapter; - -struct AxumCliAdapter; - #[derive(Debug)] struct AxumProject { addr: SocketAddr, @@ -129,251 +31,17 @@ struct EdgezeroAxumConfig { port: Option, } -#[expect( - clippy::missing_trait_methods, - reason = "axum has no validate_app_config_keys / validate_adapter_manifest / validate_typed_secrets requirements; those three trait defaults are intentionally inherited. `read_config_entry` delegates to `read_config_entry_local` (axum is local-only). `single_store_kinds` IS overridden below (returns `&[\"secrets\"]`)." -)] -impl Adapter for AxumCliAdapter { - fn execute(&self, action: AdapterAction, args: &[String]) -> Result<(), String> { - match action { - // The axum adapter is the in-process native dev server — - // there is no remote auth provider to sign in/out of. - // Per spec this is an explicit no-op. - AdapterAction::AuthLogin | AdapterAction::AuthLogout | AdapterAction::AuthStatus => { - log::info!( - "[edgezero] axum has no remote auth surface; `auth` is a no-op for this adapter" - ); - Ok(()) - } - AdapterAction::Build => build(args), - AdapterAction::Deploy => deploy(args), - AdapterAction::Serve => serve(args), - other => Err(format!("axum adapter does not support {other:?}")), - } - } - - fn name(&self) -> &'static str { - "axum" - } - - fn provision( - &self, - _manifest_root: &Path, - _adapter_manifest_path: Option<&str>, - _component_selector: Option<&str>, - stores: &ProvisionStores<'_>, - _dry_run: bool, - ) -> Result, String> { - //: axum has no remote resources. Print one note per - // declared store id so the operator sees the CLI heard - // them — same shape `dry_run` would have, since there is - // nothing to actually perform. - let mut out = Vec::with_capacity( - stores - .kv - .len() - .saturating_add(stores.config.len()) - .saturating_add(stores.secrets.len()), - ); - for store in stores.kv { - let logical = store.logical.as_str(); - out.push(format!( - "axum KV store `{logical}` is in-memory; nothing to provision" - )); - } - for store in stores.config { - // Axum reads `.edgezero/local-config-.json`. - // The platform name is informational here -- the env - // overlay isn't used for local file paths because the - // path encoding is the spec's canonical form. - let logical = store.logical.as_str(); - out.push(format!( - "axum config store `{logical}` reads `.edgezero/local-config-{logical}.json`; nothing to provision" - )); - } - for store in stores.secrets { - let logical = store.logical.as_str(); - out.push(format!( - "axum secret store `{logical}` reads env vars; nothing to provision" - )); - } - if out.is_empty() { - out.push("axum has no declared stores to provision".to_owned()); - } - Ok(out) - } - - fn push_config_entries( - &self, - manifest_root: &Path, - _adapter_manifest_path: Option<&str>, - _component_selector: Option<&str>, - store: &ResolvedStoreId, - entries: &[(String, String)], - _push_ctx: &AdapterPushContext<'_>, - dry_run: bool, - ) -> Result, String> { - //: axum is local-only. Push writes the same flat - // `string -> string` JSON object `AxumConfigStore` reads - // back from `.edgezero/local-config-.json`. The path - // is keyed on the LOGICAL id, not the env-resolved - // platform name -- the local file flow is the spec's - // canonical form and isn't subject to the per-store env - // overlay (which targets platform store names, not local - // file paths). - let logical = store.logical.as_str(); - let local_dir = manifest_root.join(".edgezero"); - let target = local_dir.join(format!("local-config-{logical}.json")); - if dry_run { - return Ok(vec![format!( - "would write {} entries to {}", - entries.len(), - target.display() - )]); - } - fs::create_dir_all(&local_dir) - .map_err(|err| format!("failed to create {}: {err}", local_dir.display()))?; - // Upsert into any existing map so a `config push --key - // app_config_staging` doesn't wipe a previously-pushed - // `app_config` blob (spec 12.7 requires default + staging - // to coexist for the `EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY` - // override to switch between them). The map is owned (rather - // than borrowed) so we can merge old + new without lifetime - // surgery on the slice. - let mut map: BTreeMap = match fs::read_to_string(&target) { - Ok(text) if !text.trim().is_empty() => serde_json::from_str(&text).map_err(|err| { - format!( - "failed to parse existing {}: {err} (expected a JSON object of key->envelope)", - target.display() - ) - })?, - _ => BTreeMap::new(), - }; - for (key, value) in entries { - map.insert(key.clone(), value.clone()); - } - let json = serde_json::to_string_pretty(&map) - .map_err(|err| format!("failed to serialize config to JSON: {err}"))?; - fs::write(&target, json) - .map_err(|err| format!("failed to write {}: {err}", target.display()))?; - Ok(vec![format!( - "wrote {} entries to {} ({} total keys after upsert)", - entries.len(), - target.display(), - map.len(), - )]) - } - - fn push_config_entries_local( - &self, - manifest_root: &Path, - adapter_manifest_path: Option<&str>, - component_selector: Option<&str>, - store: &ResolvedStoreId, - entries: &[(String, String)], - push_ctx: &AdapterPushContext<'_>, - dry_run: bool, - ) -> Result, String> { - // Axum is local-only: the default push already writes - // `.edgezero/local-config-.json`, which is what the - // running dev server reads. `--local` is therefore the - // same as the default; we delegate and prepend a notice - // so the operator who typed `--local` for parity with - // fastly/cloudflare knows there was nothing extra to do. - let mut lines = self.push_config_entries( - manifest_root, - adapter_manifest_path, - component_selector, - store, - entries, - push_ctx, - dry_run, - )?; - let notice = - "axum push is always local: `--local` has no separate effect (writes the same `.edgezero/local-config-.json` either way)".to_owned(); - lines.insert(0, notice); - Ok(lines) - } - - fn read_config_entry( - &self, - manifest_root: &Path, - adapter_manifest_path: Option<&str>, - component_selector: Option<&str>, - store: &ResolvedStoreId, - key: &str, - push_ctx: &AdapterPushContext<'_>, - ) -> Result { - // Axum has no "remote" — delegate to the local impl. - // The local JSON file IS the live state for the running dev server. - self.read_config_entry_local( - manifest_root, - adapter_manifest_path, - component_selector, - store, - key, - push_ctx, - ) - } - - fn read_config_entry_local( - &self, - manifest_root: &Path, - _adapter_manifest_path: Option<&str>, - _component_selector: Option<&str>, - store: &ResolvedStoreId, - key: &str, - _push_ctx: &AdapterPushContext<'_>, - ) -> Result { - // Axum reads `.edgezero/local-config-.json`. - // The path is keyed on the LOGICAL id (matching - // `push_config_entries`), not the env-resolved platform name. - let path = manifest_root - .join(".edgezero") - .join(format!("local-config-{}.json", store.logical)); - match fs::read_to_string(&path) { - Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(ReadConfigEntry::MissingStore), - Err(err) => Err(format!("failed to read {}: {err}", path.display())), - Ok(raw) => { - let map: BTreeMap = serde_json::from_str(&raw) - .map_err(|err| format!("failed to parse {}: {err}", path.display()))?; - match map.get(key) { - Some(value) => Ok(ReadConfigEntry::Present(value.clone())), - None => Ok(ReadConfigEntry::MissingKey), - } - } - } - } - - fn single_store_kinds(&self) -> &'static [&'static str] { - //: axum is Multi for KV (local file dirs) and Config - // (local JSON files), Single for Secrets (env vars). - &["secrets"] - } -} - -#[inline] -pub fn register() { - register_adapter(&AXUM_ADAPTER); - register_adapter_blueprint(&AXUM_BLUEPRINT); -} - -#[ctor(unsafe)] -fn register_ctor() { - register(); -} - -fn build(extra_args: &[String]) -> Result<(), String> { +pub(super) fn build(extra_args: &[String]) -> Result<(), String> { let project = locate_project()?; run_cargo(&project, "build", extra_args) } -fn serve(extra_args: &[String]) -> Result<(), String> { +pub(super) fn serve(extra_args: &[String]) -> Result<(), String> { let project = locate_project()?; run_cargo(&project, "run", extra_args) } -fn deploy(_extra_args: &[String]) -> Result<(), String> { +pub(super) fn deploy(_extra_args: &[String]) -> Result<(), String> { Err("Axum adapter does not define a deploy command. Extend your workspace manifest with one if needed.".into()) } @@ -1075,11 +743,6 @@ mod tests { .contains("does not define a deploy command")); } - #[test] - fn adapter_name_is_axum() { - assert_eq!(AXUM_ADAPTER.name(), "axum"); - } - #[test] fn read_axum_project_env_overrides_config() { let dir = tempdir().unwrap(); @@ -1174,289 +837,4 @@ mod tests { assert_eq!(resolution.addr, SocketAddr::from(([127, 0, 0, 1], 3000))); assert_eq!(resolution.warnings.len(), 1); } - - #[test] - fn blueprint_has_correct_id() { - assert_eq!(AXUM_BLUEPRINT.id, "axum"); - assert_eq!(AXUM_BLUEPRINT.display_name, "Axum"); - } - - // ---------- push_config_entries ---------- - - #[test] - fn push_writes_flat_json_to_local_config_file() { - let dir = tempfile::tempdir().expect("tempdir"); - let entries = vec![ - ("greeting".to_owned(), "hello".to_owned()), - ("service.timeout_ms".to_owned(), "1500".to_owned()), - ]; - let lines = AxumCliAdapter - .push_config_entries( - dir.path(), - None, - None, - &ResolvedStoreId::from_logical("app_config"), - &entries, - &AdapterPushContext::new(), - false, - ) - .expect("push succeeds"); - assert_eq!(lines.len(), 1); - assert!( - lines[0].contains("wrote 2 entries"), - "status line names count: {lines:?}" - ); - let json_path = dir.path().join(".edgezero/local-config-app_config.json"); - let raw = fs::read_to_string(&json_path).expect("read written file"); - let parsed: serde_json::Value = serde_json::from_str(&raw).expect("valid JSON"); - assert_eq!(parsed["greeting"], "hello"); - assert_eq!(parsed["service.timeout_ms"], "1500"); - } - - #[test] - fn push_dry_run_does_not_create_local_dir_or_file() { - let dir = tempfile::tempdir().expect("tempdir"); - let entries = vec![("greeting".to_owned(), "hello".to_owned())]; - let lines = AxumCliAdapter - .push_config_entries( - dir.path(), - None, - None, - &ResolvedStoreId::from_logical("app_config"), - &entries, - &AdapterPushContext::new(), - true, - ) - .expect("dry-run succeeds"); - assert!( - lines[0].contains("would write 1 entries"), - "dry-run line: {lines:?}" - ); - assert!( - !dir.path().join(".edgezero").exists(), - ".edgezero must not exist after dry-run" - ); - } - - #[test] - fn push_creates_dot_edgezero_directory_when_missing() { - let dir = tempfile::tempdir().expect("tempdir"); - let entries = vec![("key".to_owned(), "value".to_owned())]; - AxumCliAdapter - .push_config_entries( - dir.path(), - None, - None, - &ResolvedStoreId::from_logical("x"), - &entries, - &AdapterPushContext::new(), - false, - ) - .expect("push succeeds"); - assert!(dir.path().join(".edgezero").is_dir(), ".edgezero created"); - } - - #[test] - fn push_with_empty_entries_writes_empty_json_object() { - let dir = tempfile::tempdir().expect("tempdir"); - AxumCliAdapter - .push_config_entries( - dir.path(), - None, - None, - &ResolvedStoreId::from_logical("empty"), - &[], - &AdapterPushContext::new(), - false, - ) - .expect("push succeeds even with no entries"); - let raw = fs::read_to_string(dir.path().join(".edgezero/local-config-empty.json")) - .expect("read written file"); - let parsed: serde_json::Value = serde_json::from_str(&raw).expect("valid JSON"); - assert_eq!(parsed, serde_json::json!({})); - } - - // ---------- read_config_entry / read_config_entry_local ---------- - - #[test] - fn read_config_entry_local_returns_missing_store_when_file_absent() { - let dir = tempfile::tempdir().expect("tempdir"); - let result = AxumCliAdapter - .read_config_entry_local( - dir.path(), - None, - None, - &ResolvedStoreId::from_logical("app_config"), - "greeting", - &AdapterPushContext::new(), - ) - .expect("infallible on missing file"); - assert!( - matches!(result, ReadConfigEntry::MissingStore), - "missing file => MissingStore" - ); - } - - #[test] - fn read_config_entry_local_returns_missing_key_when_key_absent() { - let dir = tempfile::tempdir().expect("tempdir"); - // Write a JSON file with one key so the store exists, but the - // requested key is not in it. - let local_dir = dir.path().join(".edgezero"); - fs::create_dir_all(&local_dir).expect("create dir"); - fs::write( - local_dir.join("local-config-app_config.json"), - r#"{"other_key": "value"}"#, - ) - .expect("write"); - let result = AxumCliAdapter - .read_config_entry_local( - dir.path(), - None, - None, - &ResolvedStoreId::from_logical("app_config"), - "greeting", - &AdapterPushContext::new(), - ) - .expect("infallible on missing key"); - assert!( - matches!(result, ReadConfigEntry::MissingKey), - "key absent => MissingKey" - ); - } - - #[test] - fn read_config_entry_local_returns_present_when_key_exists() { - let dir = tempfile::tempdir().expect("tempdir"); - let local_dir = dir.path().join(".edgezero"); - fs::create_dir_all(&local_dir).expect("create dir"); - fs::write( - local_dir.join("local-config-app_config.json"), - r#"{"greeting": "hello-axum"}"#, - ) - .expect("write"); - let result = AxumCliAdapter - .read_config_entry_local( - dir.path(), - None, - None, - &ResolvedStoreId::from_logical("app_config"), - "greeting", - &AdapterPushContext::new(), - ) - .expect("key present"); - let ReadConfigEntry::Present(value) = result else { - panic!("expected Present variant"); - }; - assert_eq!(value, "hello-axum", "value matches"); - } - - #[test] - fn read_config_entry_delegates_to_local() { - // Axum has no remote: read_config_entry and read_config_entry_local - // must return the same result for the same inputs. - let dir = tempfile::tempdir().expect("tempdir"); - let local_dir = dir.path().join(".edgezero"); - fs::create_dir_all(&local_dir).expect("create dir"); - fs::write( - local_dir.join("local-config-app_config.json"), - r#"{"greeting": "hello-axum"}"#, - ) - .expect("write"); - let store = ResolvedStoreId::from_logical("app_config"); - let ctx = AdapterPushContext::new(); - let via_local = AxumCliAdapter - .read_config_entry_local(dir.path(), None, None, &store, "greeting", &ctx) - .expect("local ok"); - let via_remote = AxumCliAdapter - .read_config_entry(dir.path(), None, None, &store, "greeting", &ctx) - .expect("remote ok"); - let ReadConfigEntry::Present(local_val) = via_local else { - panic!("expected Present from local"); - }; - let ReadConfigEntry::Present(remote_val) = via_remote else { - panic!("expected Present from remote"); - }; - assert_eq!(local_val, remote_val, "local and remote agree"); - } - - #[test] - fn read_config_entry_local_errors_on_malformed_json() { - let dir = tempfile::tempdir().expect("tempdir"); - let local_dir = dir.path().join(".edgezero"); - fs::create_dir_all(&local_dir).expect("create dir"); - fs::write( - local_dir.join("local-config-app_config.json"), - "not valid json {{{", - ) - .expect("write"); - let result = AxumCliAdapter.read_config_entry_local( - dir.path(), - None, - None, - &ResolvedStoreId::from_logical("app_config"), - "greeting", - &AdapterPushContext::new(), - ); - match result { - Err(err) => assert!( - err.contains("failed to parse"), - "error names the failure: {err}" - ), - Ok(_) => panic!("expected Err for malformed JSON"), - } - } - - /// Spec 12.7: pushing two blobs under different keys (e.g. - /// `app_config` + `app_config_staging`) must leave both keys - /// readable so the runtime - /// `EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY` override can - /// switch between them. Prior to the upsert fix the second push - /// wiped the first by wholesale-rewriting the JSON map. - #[test] - fn push_config_entries_preserves_sibling_keys() { - let dir = tempfile::tempdir().expect("tempdir"); - let store = ResolvedStoreId::from_logical("app_config"); - let ctx = AdapterPushContext::new(); - - AxumCliAdapter - .push_config_entries( - dir.path(), - None, - None, - &store, - &[("app_config".to_owned(), "{\"envelope\":\"A\"}".to_owned())], - &ctx, - false, - ) - .expect("first push"); - AxumCliAdapter - .push_config_entries( - dir.path(), - None, - None, - &store, - &[( - "app_config_staging".to_owned(), - "{\"envelope\":\"B\"}".to_owned(), - )], - &ctx, - false, - ) - .expect("second push (sibling key)"); - - let raw = fs::read_to_string(dir.path().join(".edgezero/local-config-app_config.json")) - .expect("read"); - let map: BTreeMap = serde_json::from_str(&raw).expect("parse map"); - assert_eq!( - map.get("app_config").map(String::as_str), - Some("{\"envelope\":\"A\"}"), - "default key must survive sibling push: {raw}" - ); - assert_eq!( - map.get("app_config_staging").map(String::as_str), - Some("{\"envelope\":\"B\"}"), - "staging key must be present: {raw}" - ); - } } diff --git a/crates/edgezero-adapter-cloudflare/src/cli.rs b/crates/edgezero-adapter-cloudflare/src/cli.rs deleted file mode 100644 index f858021c..00000000 --- a/crates/edgezero-adapter-cloudflare/src/cli.rs +++ /dev/null @@ -1,2176 +0,0 @@ -use std::collections::BTreeSet; -use std::env; -use std::fs; -use std::io::ErrorKind; -use std::path::{Path, PathBuf}; -use std::process::Command; - -use ctor::ctor; -use edgezero_adapter::cli_support::{ - find_manifest_upwards, find_workspace_root, path_distance, read_package_name, run_native_cli, -}; -use edgezero_adapter::registry::{ - register_adapter, Adapter, AdapterAction, AdapterPushContext, ProvisionStores, ReadConfigEntry, - ResolvedStoreId, -}; -use edgezero_adapter::scaffold::{ - register_adapter_blueprint, AdapterBlueprint, AdapterFileSpec, CommandTemplates, - DependencySpec, LoggingDefaults, ManifestSpec, ReadmeInfo, TemplateRegistration, -}; -use walkdir::WalkDir; - -static CLOUDFLARE_ADAPTER: CloudflareCliAdapter = CloudflareCliAdapter; - -static CLOUDFLARE_BLUEPRINT: AdapterBlueprint = AdapterBlueprint { - id: "cloudflare", - display_name: "Cloudflare Workers", - crate_suffix: "adapter-cloudflare", - dependency_crate: "edgezero-adapter-cloudflare", - dependency_repo_path: "crates/edgezero-adapter-cloudflare", - template_registrations: CLOUDFLARE_TEMPLATE_REGISTRATIONS, - files: CLOUDFLARE_FILE_SPECS, - extra_dirs: &["src", ".cargo"], - dependencies: CLOUDFLARE_DEPENDENCIES, - manifest: ManifestSpec { - manifest_filename: "wrangler.toml", - build_target: "wasm32-unknown-unknown", - build_profile: "release", - build_features: &["cloudflare"], - }, - commands: CommandTemplates { - build: "wrangler build --cwd {crate_dir}", - deploy: "wrangler deploy --cwd {crate_dir}", - serve: "wrangler dev --cwd {crate_dir}", - }, - logging: LoggingDefaults { - endpoint: None, - level: "info", - echo_stdout: None, - }, - readme: ReadmeInfo { - description: "{display} entrypoint.", - dev_heading: "{display} (local)", - dev_steps: &["`edgezero serve --adapter cloudflare`"], - }, - run_module: "edgezero_adapter_cloudflare", -}; - -static CLOUDFLARE_DEPENDENCIES: &[DependencySpec] = &[ - DependencySpec { - key: "dep_edgezero_core_cloudflare", - repo_crate: "crates/edgezero-core", - fallback: "edgezero-core = { git = \"https://git@github.com/stackpop/edgezero.git\", package = \"edgezero-core\", default-features = false }", - features: &[], - }, - DependencySpec { - key: "dep_edgezero_adapter_cloudflare", - repo_crate: "crates/edgezero-adapter-cloudflare", - fallback: - "edgezero-adapter-cloudflare = { git = \"https://git@github.com/stackpop/edgezero.git\", package = \"edgezero-adapter-cloudflare\", default-features = false }", - features: &[], - }, - DependencySpec { - key: "dep_edgezero_adapter_cloudflare_wasm", - repo_crate: "crates/edgezero-adapter-cloudflare", - fallback: - "edgezero-adapter-cloudflare = { git = \"https://git@github.com/stackpop/edgezero.git\", package = \"edgezero-adapter-cloudflare\", default-features = false, features = [\"cloudflare\"] }", - features: &["cloudflare"], - }, -]; - -static CLOUDFLARE_FILE_SPECS: &[AdapterFileSpec] = &[ - AdapterFileSpec { - template: "cf_Cargo_toml", - output: "Cargo.toml", - }, - AdapterFileSpec { - template: "cf_src_lib_rs", - output: "src/lib.rs", - }, - AdapterFileSpec { - template: "cf_src_main_rs", - output: "src/main.rs", - }, - AdapterFileSpec { - template: "cf_cargo_config_toml", - output: ".cargo/config.toml", - }, - AdapterFileSpec { - template: "cf_wrangler_toml", - output: "wrangler.toml", - }, -]; - -static CLOUDFLARE_TEMPLATE_REGISTRATIONS: &[TemplateRegistration] = &[ - TemplateRegistration { - name: "cf_Cargo_toml", - contents: include_str!("templates/Cargo.toml.hbs"), - }, - TemplateRegistration { - name: "cf_src_lib_rs", - contents: include_str!("templates/src/lib.rs.hbs"), - }, - TemplateRegistration { - name: "cf_src_main_rs", - contents: include_str!("templates/src/main.rs.hbs"), - }, - TemplateRegistration { - name: "cf_cargo_config_toml", - contents: include_str!("templates/.cargo/config.toml.hbs"), - }, - TemplateRegistration { - name: "cf_wrangler_toml", - contents: include_str!("templates/wrangler.toml.hbs"), - }, -]; - -const TARGET_TRIPLE: &str = "wasm32-unknown-unknown"; - -const WRANGLER_INSTALL_HINT: &str = - "install the Cloudflare CLI (`npm install -g wrangler`) and try again"; - -struct CloudflareCliAdapter; - -#[expect( - clippy::missing_trait_methods, - reason = "cloudflare has no validate_app_config_keys / validate_adapter_manifest / validate_typed_secrets requirements; those three trait defaults are intentionally inherited. `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 execute(&self, action: AdapterAction, args: &[String]) -> Result<(), String> { - match action { - // `wrangler` is the native sign-in surface for Cloudflare - // Workers. EdgeZero stores no credentials — this is a thin - // shell-out. - AdapterAction::AuthLogin => { - run_native_cli("wrangler", &["login"], WRANGLER_INSTALL_HINT) - } - AdapterAction::AuthLogout => { - run_native_cli("wrangler", &["logout"], WRANGLER_INSTALL_HINT) - } - AdapterAction::AuthStatus => { - run_native_cli("wrangler", &["whoami"], WRANGLER_INSTALL_HINT) - } - AdapterAction::Build => build(args).map(|artifact| { - log::info!( - "[edgezero] Cloudflare build artifact -> {}", - artifact.display() - ); - }), - AdapterAction::Deploy => deploy(args), - AdapterAction::Serve => serve(args), - other => Err(format!("cloudflare adapter does not support {other:?}")), - } - } - - fn merged_id_kinds(&self) -> &'static [&'static str] { - // Both KV and Config back to Worker KV namespaces via the - // same `[[kv_namespaces]] binding = ` - // wrangler.toml entry. Declaring the same logical id under - // both kinds (e.g. `[stores.kv].ids = ["x"]` AND - // `[stores.config].ids = ["x"]`) resolves to a SINGLE - // underlying KV namespace at runtime — KV writes from the - // app silently clobber config-shaped entries (and vice - // versa). Provision compounds the hazard: the second - // binding would already be present from the first kind's - // `upsert_kv_namespace` and get reported as "already - // provisioned" instead of failing the collision. - // - // CLI `config validate` rejects this collision before any - // wrangler shell-out happens. - &["kv", "config"] - } - - fn name(&self) -> &'static str { - "cloudflare" - } - - fn provision( - &self, - manifest_root: &Path, - adapter_manifest_path: Option<&str>, - _component_selector: Option<&str>, - stores: &ProvisionStores<'_>, - dry_run: bool, - ) -> Result, String> { - //: KV ids and config ids both back to Cloudflare KV - // namespaces. Secrets are runtime-managed via - // `wrangler secret put` — provision is a no-op for them. - let Some(rel) = adapter_manifest_path else { - return Err( - "[adapters.cloudflare.adapter].manifest must point at wrangler.toml for provision" - .to_owned(), - ); - }; - let wrangler_path = manifest_root.join(rel); - - let mut out = Vec::new(); - for store in stores.kv.iter().chain(stores.config.iter()) { - let logical = &store.logical; - // The Cloudflare KV binding name is what the runtime - // calls `env.kv(...)` with -- it's resolved at request - // time from `EDGEZERO__STORES______NAME` - // (default = logical id). Provision must write the - // resolved PLATFORM name into wrangler.toml, otherwise - // the runtime will look up a binding the CLI never - // created. - let binding = &store.platform; - // Idempotency check BEFORE shelling out: if a - // [[kv_namespaces]] entry with `binding = ` - // is already present and has a real namespace id, skip. - // Without this guard a re-run of provision would invoke - // `wrangler kv namespace create` again and orphan the - // previously-created namespace -- wasting account quota. - // A placeholder id (anything that isn't a 32-char - // lowercase hex string, like the - // `local-dev-placeholder` the scaffold wrangler.toml - // writes) is treated as "not yet provisioned" so the - // entry gets rewritten with the real id. - // - // We deliberately do NOT cross-check the stored id - // against Cloudflare's API (e.g. by calling `wrangler - // kv namespace list` to confirm the id still exists). - // Verifying every entry on every provision run would - // add a network round-trip per id and require parsing - // yet another wrangler subcommand output. The skip - // line names the existing id explicitly so the operator - // can verify it themselves and, if the Cloudflare-side - // namespace was deleted out-of-band, remove the stale - // entry by hand before re-running provision. - let existing = existing_real_namespace_id(&wrangler_path, binding)?; - if let Some(existing_id) = existing { - out.push(format!( - "binding `{binding}` (logical id `{logical}`) already provisioned (id={existing_id} in {}); skipping. To force a fresh namespace: delete the [[kv_namespaces]] entry for binding `{binding}` AND run `wrangler kv namespace delete --namespace-id={existing_id}` (the old remote namespace lingers otherwise), then re-run provision.", - wrangler_path.display() - )); - continue; - } - // Pre-flight the writeback shape BEFORE shelling - // `wrangler kv namespace create`. `read_namespace_id` - // tolerates both `[[kv_namespaces]]` (array-of-tables) - // and `kv_namespaces = [{ binding = "...", id = "..." }]` - // (inline-array) forms, but `upsert_kv_namespace` only - // writes back through the array-of-tables shape. Without - // this guard, an inline-array manifest passes the - // "already provisioned?" probe (because no id is - // present), the remote `create` succeeds, and then the - // upsert errors out — leaving the freshly-created - // namespace orphaned on Cloudflare with no local - // writeback to track it. - // - // Refuse early so the operator fixes the manifest shape - // BEFORE any account-side mutation. - check_kv_namespaces_writeback_shape(&wrangler_path)?; - if dry_run { - out.push(format!( - "would run `wrangler kv namespace create {binding}` and append [[kv_namespaces]] binding = \"{binding}\" to {} (logical id `{logical}`)", - wrangler_path.display() - )); - continue; - } - let namespace_id = create_kv_namespace(binding)?; - upsert_kv_namespace(&wrangler_path, binding, &namespace_id)?; - out.push(format!( - "created KV namespace `{binding}` (logical id `{logical}`, namespace id={namespace_id}); written to {}", - wrangler_path.display() - )); - } - for store in stores.secrets { - let logical = &store.logical; - let platform = &store.platform; - out.push(format!( - "cloudflare secret `{platform}` (logical id `{logical}`) is runtime-managed via `wrangler secret put`; nothing to provision" - )); - } - if out.is_empty() { - out.push("cloudflare has no declared stores to provision".to_owned()); - } - Ok(out) - } - - fn push_config_entries( - &self, - manifest_root: &Path, - adapter_manifest_path: Option<&str>, - _component_selector: Option<&str>, - store: &ResolvedStoreId, - entries: &[(String, String)], - _push_ctx: &AdapterPushContext<'_>, - dry_run: bool, - ) -> Result, String> { - // Read namespace id from wrangler.toml (matched by - // `binding = `), then `wrangler kv bulk put - // --namespace-id= --remote`. The - // CLI hands this writer one logical (root_key, envelope_json) - // entry; the bulk-put still works because it's one upsert - // per entry, and the one-entry case is degenerate. - // - // **--remote** is mandatory for the prod-push path: - // wrangler v4 defaults KV bulk-put to LOCAL storage when - // the command supports both — meaning a v4 user running - // `wrangler kv bulk put` without `--remote` would silently - // populate Miniflare state under `.wrangler/state` and - // report success while leaving the live Cloudflare - // namespace empty. Explicit `--remote` removes the - // ambiguity. - let Some(rel) = adapter_manifest_path else { - return Err( - "[adapters.cloudflare.adapter].manifest must point at wrangler.toml for config push" - .to_owned(), - ); - }; - let wrangler_path = manifest_root.join(rel); - let binding = store.platform.as_str(); - let logical = store.logical.as_str(); - // Dry-run is lenient about a missing/unresolved binding so - // operators can preview the keyset BEFORE running provision. - // Real runs still err loudly so we don't silently push to - // a non-existent namespace. - if dry_run { - let header = find_namespace_id(&wrangler_path, binding).map_or_else( - |_| format!( - "would run `wrangler kv bulk put --namespace-id= --remote` with {} entries for binding `{binding}` (logical id `{logical}`, binding not yet provisioned -- run `edgezero provision --adapter cloudflare` to resolve the namespace id)", - entries.len() - ), - |ns_id| format!( - "would run `wrangler kv bulk put --namespace-id={ns_id} --remote` with {} entries for binding `{binding}` (logical id `{logical}`)", - entries.len() - ), - ); - let mut out = vec![header]; - for (key, _) in entries { - out.push(format!(" would create entry `{key}`")); - } - return Ok(out); - } - let namespace_id = find_namespace_id(&wrangler_path, binding)?; - if entries.is_empty() { - return Ok(vec![format!( - "no config entries to push to KV namespace `{binding}` (logical id `{logical}`, id={namespace_id})" - )]); - } - let payload = bulk_payload(entries)?; - let temp = tempfile::Builder::new() - .prefix("edgezero-cf-push-") - .suffix(".json") - .tempfile() - .map_err(|err| { - format!("failed to create temp file for wrangler bulk payload: {err}") - })?; - fs::write(temp.path(), payload.as_bytes()) - .map_err(|err| format!("failed to write {}: {err}", temp.path().display()))?; - let temp_arg = temp - .path() - .to_str() - .ok_or_else(|| format!("temp file path {} is not UTF-8", temp.path().display()))?; - let namespace_arg = format!("--namespace-id={namespace_id}"); - // Run from the wrangler.toml's directory so wrangler picks - // up its `account_id` / `--env` resolution + persistence - // settings the same way `wrangler dev` / `wrangler deploy` - // do for this project. - let project_dir = wrangler_path.parent().unwrap_or(manifest_root); - let output = Command::new("wrangler") - .current_dir(project_dir) - .args([ - "kv", - "bulk", - "put", - temp_arg, - namespace_arg.as_str(), - "--remote", - ]) - .output() - .map_err(|err| { - if err.kind() == ErrorKind::NotFound { - format!("`wrangler` not found on PATH; {WRANGLER_INSTALL_HINT}") - } else { - format!("failed to spawn `wrangler`: {err}") - } - })?; - if !output.status.success() { - return Err(format!( - "`wrangler kv bulk put --remote` exited with status {}\nstderr: {}", - output.status, - String::from_utf8_lossy(&output.stderr).trim() - )); - } - Ok(vec![format!( - "pushed {} entries to KV namespace `{binding}` (logical id `{logical}`, id={namespace_id})", - entries.len() - )]) - } - - fn push_config_entries_local( - &self, - manifest_root: &Path, - adapter_manifest_path: Option<&str>, - _component_selector: Option<&str>, - store: &ResolvedStoreId, - entries: &[(String, String)], - _push_ctx: &AdapterPushContext<'_>, - dry_run: bool, - ) -> Result, String> { - // Local push: address the binding directly via - // `wrangler kv bulk put --binding --local`. - // Crucially we do NOT resolve a namespace id here — the - // scaffold ships with `local-dev-placeholder` ids, so an - // operator that hasn't run `edgezero provision` yet should - // still be able to seed `.wrangler/state` from the manifest - // (matching wrangler's own local KV docs). Wrangler stores - // local entries keyed by binding, not namespace id, so the - // follow-up `wrangler dev --local` / `edgezero serve - // --adapter cloudflare` reads them back through the same - // binding name. - let Some(rel) = adapter_manifest_path else { - return Err( - "[adapters.cloudflare.adapter].manifest must point at wrangler.toml for config push --local" - .to_owned(), - ); - }; - let wrangler_path = manifest_root.join(rel); - let project_dir = wrangler_path.parent().unwrap_or(manifest_root); - let binding = store.platform.as_str(); - let logical = store.logical.as_str(); - if dry_run { - let mut out = vec![format!( - "would run `wrangler kv bulk put --binding {binding} --local` with {} entries for binding `{binding}` (logical id `{logical}`)", - entries.len() - )]; - for (key, _) in entries { - out.push(format!(" would create local entry `{key}`")); - } - return Ok(out); - } - if entries.is_empty() { - return Ok(vec![format!( - "no config entries to push to local KV namespace `{binding}` (logical id `{logical}`)" - )]); - } - let payload = bulk_payload(entries)?; - let temp = tempfile::Builder::new() - .prefix("edgezero-cf-push-local-") - .suffix(".json") - .tempfile() - .map_err(|err| { - format!("failed to create temp file for wrangler bulk payload: {err}") - })?; - fs::write(temp.path(), payload.as_bytes()) - .map_err(|err| format!("failed to write {}: {err}", temp.path().display()))?; - let temp_arg = temp - .path() - .to_str() - .ok_or_else(|| format!("temp file path {} is not UTF-8", temp.path().display()))?; - let output = Command::new("wrangler") - .current_dir(project_dir) - .args([ - "kv", - "bulk", - "put", - temp_arg, - "--binding", - binding, - "--local", - ]) - .output() - .map_err(|err| { - if err.kind() == ErrorKind::NotFound { - format!("`wrangler` not found on PATH; {WRANGLER_INSTALL_HINT}") - } else { - format!("failed to spawn `wrangler`: {err}") - } - })?; - if !output.status.success() { - return Err(format!( - "`wrangler kv bulk put --binding {binding} --local` exited with status {}\nstderr: {}", - output.status, - String::from_utf8_lossy(&output.stderr).trim() - )); - } - Ok(vec![format!( - "pushed {} entries to local KV namespace bound as `{binding}` (logical id `{logical}`); `.wrangler/state` updated", - entries.len() - )]) - } - - fn read_config_entry( - &self, - manifest_root: &Path, - adapter_manifest_path: Option<&str>, - _component_selector: Option<&str>, - store: &ResolvedStoreId, - key: &str, - _push_ctx: &AdapterPushContext<'_>, - ) -> Result { - read_wrangler_kv_key(manifest_root, adapter_manifest_path, store, key, "--remote") - } - - fn read_config_entry_local( - &self, - manifest_root: &Path, - adapter_manifest_path: Option<&str>, - _component_selector: Option<&str>, - store: &ResolvedStoreId, - key: &str, - _push_ctx: &AdapterPushContext<'_>, - ) -> Result { - read_wrangler_kv_key(manifest_root, adapter_manifest_path, store, key, "--local") - } - - fn single_store_kinds(&self) -> &'static [&'static str] { - //: cloudflare is Multi for KV (KV namespaces) and - // Config (KV namespaces), Single for Secrets (Worker - // Secrets is a single flat bag). - &["secrets"] - } -} - -/// Shell out to `wrangler kv namespace create `, capture -/// stdout, and parse the resulting namespace id. The CLI's -/// `provision` command resolves this against the user's -/// `wrangler.toml` and writes the `[[kv_namespaces]]` entry. -/// -/// # Errors -/// Returns an error if `wrangler` isn't on `PATH`, the child fails -/// to spawn, the exit status is non-zero, or stdout doesn't -/// include a parseable `id = "..."` line. -fn create_kv_namespace(binding: &str) -> Result { - let output = Command::new("wrangler") - .args(["kv", "namespace", "create", binding]) - .output() - .map_err(|err| { - if err.kind() == ErrorKind::NotFound { - format!("`wrangler` not found on PATH; {WRANGLER_INSTALL_HINT}") - } else { - format!("failed to spawn `wrangler`: {err}") - } - })?; - if !output.status.success() { - return Err(format!( - "`wrangler kv namespace create {binding}` exited with status {}\nstderr: {}", - output.status, - String::from_utf8_lossy(&output.stderr).trim() - )); - } - let stdout = String::from_utf8_lossy(&output.stdout); - extract_namespace_id(&stdout).ok_or_else(|| { - format!( - "wrangler created `{binding}` but stdout did not include a parseable `id = \"...\"` line -- wrangler may have changed its output format; pin a known-compatible wrangler version or file an issue. Raw stdout:\n{stdout}" - ) - }) -} - -/// Pull the namespace id out of `wrangler kv namespace create` -/// stdout. Wrangler 3+ prints (something like): -/// -/// ```text -/// 🌀 Creating namespace with title "..." -/// ✨ Success! -/// Add the following to your configuration file in your kv_namespaces array: -/// [[kv_namespaces]] -/// binding = "my-kv" -/// id = "abc123..." -/// ``` -/// -/// We tolerate leading whitespace + surrounding decoration. To -/// avoid grabbing a stray informational line like -/// `id = ""` printed somewhere else in wrangler -/// output (or a hypothetical future `id = ...` line that names a -/// non-KV resource), we anchor to the `[[kv_namespaces]]` table -/// header AND require the value to be 32-char lowercase hex -/// (Cloudflare's actual namespace-id shape). The scan walks -/// lines top-down: when we see `[[kv_namespaces]]` we set a -/// scope flag; the next `id = "<32-char-hex>"` line within that -/// scope is the result. A new top-level header resets the scope. -fn extract_namespace_id(stdout: &str) -> Option { - let mut in_kv_namespaces = false; - for line in stdout.lines() { - let trimmed = line.trim(); - if trimmed == "[[kv_namespaces]]" { - in_kv_namespaces = true; - continue; - } - // Any other table header ends the scope so we don't reach - // forward into a sibling block. - if trimmed.starts_with('[') && trimmed.ends_with(']') { - in_kv_namespaces = false; - continue; - } - if !in_kv_namespaces { - continue; - } - let Some(after_id_kw) = trimmed.strip_prefix("id") else { - continue; - }; - let Some(after_eq) = after_id_kw.trim_start().strip_prefix('=') else { - continue; - }; - let Some(quoted) = after_eq.trim_start().strip_prefix('"') else { - continue; - }; - let Some((id, _)) = quoted.split_once('"') else { - continue; - }; - if is_real_namespace_id(id) { - return Some(id.to_owned()); - } - } - None -} - -/// Heuristic: is `id` a real Cloudflare KV namespace id (32-char -/// lowercase hex), as opposed to a scaffold placeholder like -/// `local-dev-placeholder`? Cloudflare's API consistently returns -/// 32-char lowercase hex, so we use that as a tight cheap signal. -/// -/// Additionally rejects hex-shape sentinels that LOOK like real -/// ids but are obviously hand-typed placeholders: anything with -/// fewer than 6 distinct hex characters (catches all-zeros, -/// all-`a`, `deadbeefdeadbeefdeadbeefdeadbeef`, etc.). A real id -/// generated by Cloudflare's API has effectively uniform random -/// hex distribution: expected distinct chars over 32 draws from -/// 16 symbols is ~14, and the dominant term P(=5 distinct) is on -/// the order of 10^-13 -- so false rejections of real ids are -/// astronomically unlikely. -fn is_real_namespace_id(id: &str) -> bool { - if id.len() != 32 { - return false; - } - if !id - .bytes() - .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) - { - return false; - } - // Distinct-byte count via a BTreeSet: 32 inserts is trivial, - // and the set form avoids the arithmetic-side-effect / - // silent-as / indexing-panic shapes the project's clippy - // profile rejects. - let distinct: BTreeSet = id.bytes().collect(); - distinct.len() >= 6 -} - -/// If `path` already declares a `[[kv_namespaces]]` entry with -/// `binding = binding` AND its `id` looks like a real Cloudflare -/// namespace id, return that id. Returns `Ok(None)` if the binding -/// is absent OR present with a placeholder id (so provision can -/// treat both cases as "needs (re-)create"). A failure to read / -/// parse the file is a hard error -- provision needs an authoritative -/// answer. -fn existing_real_namespace_id(path: &Path, binding: &str) -> Result, String> { - let Some(existing) = read_namespace_id(path, binding)? else { - return Ok(None); - }; - if is_real_namespace_id(&existing) { - Ok(Some(existing)) - } else { - Ok(None) - } -} - -/// Internal: look up `binding`'s `id` in `wrangler.toml` without -/// the "did you run provision?" error path that `find_namespace_id` -/// adds. Missing file -> `Ok(None)`. Returns the raw id whether or -/// not it looks like a real Cloudflare id. -/// -/// Errors loudly if `kv_namespaces` exists but is neither an -/// array-of-tables nor an inline-array (e.g. the operator typed -/// `kv_namespaces = "oops"`). Silently returning `None` there -/// surfaces downstream as "did you run provision?" -- misleading, -/// because the actual problem is a malformed manifest. -fn read_namespace_id(path: &Path, binding: &str) -> Result, String> { - use toml_edit::{DocumentMut, Item, Value}; - - let raw = match fs::read_to_string(path) { - Ok(raw) => raw, - Err(err) if err.kind() == ErrorKind::NotFound => return Ok(None), - Err(err) => return Err(format!("failed to read {}: {err}", path.display())), - }; - let doc: DocumentMut = raw - .parse() - .map_err(|err| format!("failed to parse {}: {err}", path.display()))?; - let id = match doc.get("kv_namespaces") { - Some(Item::ArrayOfTables(arr)) => arr.iter().find_map(|table| { - if table.get("binding").and_then(Item::as_str) == Some(binding) { - table.get("id").and_then(Item::as_str).map(str::to_owned) - } else { - None - } - }), - Some(Item::Value(Value::Array(arr))) => arr.iter().find_map(|item| { - let table = item.as_inline_table()?; - if table.get("binding").and_then(Value::as_str) == Some(binding) { - table.get("id").and_then(Value::as_str).map(str::to_owned) - } else { - None - } - }), - Some(other) => { - return Err(format!( - "{}: `kv_namespaces` exists but is neither `[[kv_namespaces]]` (array-of-tables) nor an inline array of `{{ binding, id }}` records; got TOML item of type `{}`", - path.display(), - item_kind(other) - )); - } - None => None, - }; - Ok(id) -} - -/// Refuse to provision a new namespace when `wrangler.toml`'s -/// `kv_namespaces` exists in a form that `upsert_kv_namespace` -/// can't write back to. Today that means the inline-array form -/// (`kv_namespaces = [{ binding = "...", id = "..." }]`), which -/// `read_namespace_id` tolerates but `upsert_kv_namespace`'s -/// `as_array_of_tables_mut()` rejects. Without this guard, the -/// orphan-namespace hazard documented in `upsert_kv_namespace` -/// reappears: `wrangler kv namespace create` succeeds, then -/// upsert errors out and the new namespace lingers on -/// Cloudflare with no local writeback to track it. Missing or -/// array-of-tables forms are OK. -fn check_kv_namespaces_writeback_shape(path: &Path) -> Result<(), String> { - use toml_edit::{DocumentMut, Item, Value}; - - let raw = match fs::read_to_string(path) { - Ok(text) => text, - Err(err) if err.kind() == ErrorKind::NotFound => return Ok(()), - Err(err) => return Err(format!("failed to read {}: {err}", path.display())), - }; - let doc: DocumentMut = raw - .parse() - .map_err(|err| format!("failed to parse {}: {err}", path.display()))?; - match doc.get("kv_namespaces") { - None | Some(Item::ArrayOfTables(_)) => Ok(()), - Some(Item::Value(Value::Array(_))) => Err(format!( - "{}: `kv_namespaces` is declared as an inline array (`kv_namespaces = [{{ binding = \"...\", id = \"...\" }}]`); provision can only write back through the `[[kv_namespaces]]` array-of-tables form. Convert each entry to a `[[kv_namespaces]]` block BEFORE re-running provision; otherwise a successful `wrangler kv namespace create` would leave the new namespace orphaned on Cloudflare with no local entry to track it.", - path.display() - )), - Some(other) => Err(format!( - "{}: `kv_namespaces` exists but is neither `[[kv_namespaces]]` (array-of-tables) nor an inline array of `{{ binding, id }}` records; got TOML item of type `{}`. Convert it manually before re-running provision.", - path.display(), - item_kind(other) - )), - } -} - -/// One-line label for a `toml_edit::Item` (for diagnostic -/// messages -- not a canonical TOML type description). -fn item_kind(item: &toml_edit::Item) -> &'static str { - use toml_edit::{Item, Value}; - match item { - Item::None => "none", - Item::Value(Value::String(_)) => "string", - Item::Value(Value::Integer(_)) => "integer", - Item::Value(Value::Float(_)) => "float", - Item::Value(Value::Boolean(_)) => "boolean", - Item::Value(Value::Datetime(_)) => "datetime", - Item::Value(Value::Array(_)) => "array", - Item::Value(Value::InlineTable(_)) => "inline-table", - Item::Table(_) => "table", - Item::ArrayOfTables(_) => "array-of-tables", - } -} - -/// Insert OR update the `[[kv_namespaces]]` entry for `binding`, -/// rewriting `id` if the binding already exists (e.g. provision -/// is replacing a `local-dev-placeholder`). Used by provision so -/// re-running on a scaffolded wrangler.toml replaces the placeholder -/// with the real id instead of silently skipping. -/// -/// Caveat: `toml_edit::Table::insert` replaces the value's `Item`, -/// which drops any trailing inline comment that was attached to -/// the prior `id = "..."` line (e.g. `id = "old" # delete me`). -/// Sibling fields under the same `[[kv_namespaces]]` table are -/// preserved verbatim -- only the `id` line's decor is lost. -/// -/// Concurrency: provision is NOT safe to run concurrently against -/// the same `wrangler.toml`. Two concurrent runs may both miss the -/// idempotency check, both call `wrangler kv namespace create` -/// remotely, then race the file write -- the loser's namespace -/// becomes an orphan in the Cloudflare account. `EdgeZero` does not -/// take a lockfile; operators must serialise provision themselves. -fn upsert_kv_namespace(path: &Path, binding: &str, id: &str) -> Result<(), String> { - use toml_edit::{value, ArrayOfTables, DocumentMut, Item, Table}; - - // Treat NotFound as "start with empty document" symmetrically with - // `read_namespace_id` so the orphan-namespace hazard goes away: if - // wrangler.toml is missing entirely (e.g. operator deleted it - // between scaffold and provision), the upsert that follows a - // successful `wrangler kv namespace create` would otherwise error - // out, leaving the remote namespace orphaned. - let raw = match fs::read_to_string(path) { - Ok(text) => text, - Err(err) if err.kind() == ErrorKind::NotFound => String::new(), - Err(err) => return Err(format!("failed to read {}: {err}", path.display())), - }; - let mut doc: DocumentMut = raw - .parse() - .map_err(|err| format!("failed to parse {}: {err}", path.display()))?; - - let entry = doc - .entry("kv_namespaces") - .or_insert_with(|| Item::ArrayOfTables(ArrayOfTables::new())); - let arr_of_tables = entry.as_array_of_tables_mut().ok_or_else(|| { - format!( - "{}: `kv_namespaces` exists but is not an array-of-tables (`[[kv_namespaces]]`); convert it manually before re-running provision", - path.display() - ) - })?; - - let existing_idx = arr_of_tables - .iter() - .position(|table| table.get("binding").and_then(Item::as_str) == Some(binding)); - if let Some(idx) = existing_idx { - if let Some(existing) = arr_of_tables.get_mut(idx) { - existing.insert("id", value(id)); - } - } else { - let mut new_table = Table::new(); - new_table.insert("binding", value(binding)); - new_table.insert("id", value(id)); - arr_of_tables.push(new_table); - } - - fs::write(path, doc.to_string()) - .map_err(|err| format!("failed to write {}: {err}", path.display()))?; - Ok(()) -} - -/// Render the entries as the `[{"key": "...", "value": "..."}, …]` -/// JSON wrangler expects for `kv bulk put`. Under the blob model the -/// CLI hands this writer one logical `(root_key, envelope_json)` entry; -/// Cloudflare passes the value through unchanged (the envelope is an -/// opaque string from the platform's perspective). -fn bulk_payload(entries: &[(String, String)]) -> Result { - let payload: Vec = entries - .iter() - .map(|(key, value)| serde_json::json!({ "key": key, "value": value })) - .collect(); - serde_json::to_string(&payload) - .map_err(|err| format!("failed to serialize wrangler bulk payload: {err}")) -} - -/// Read a single key from a Cloudflare KV namespace by shelling out to -/// `wrangler kv key get --binding `. -/// -/// `locality` is either `"--remote"` (live Cloudflare KV) or `"--local"` -/// (Miniflare `.wrangler/state`). The two read methods on the adapter call -/// this shared helper with the appropriate flag. -/// -/// # Mapping to `ReadConfigEntry` -/// - Success (exit 0) → `Present(stdout)`. -/// - Exit non-zero, stderr contains "not found" / "does not exist" → `MissingKey`. -/// - Exit non-zero, stderr mentions "binding" → `MissingStore` (the KV -/// namespace binding itself doesn't exist in `wrangler.toml`). -/// - Any other non-zero exit → `Err`. -fn read_wrangler_kv_key( - manifest_root: &Path, - adapter_manifest_path: Option<&str>, - store: &ResolvedStoreId, - key: &str, - locality: &str, -) -> Result { - let rel = adapter_manifest_path.ok_or_else(|| { - "[adapters.cloudflare.adapter].manifest must point at wrangler.toml for config diff" - .to_owned() - })?; - let wrangler_path = manifest_root.join(rel); - let binding = store.platform.as_str(); - let project_dir = wrangler_path.parent().unwrap_or(manifest_root); - let output = Command::new("wrangler") - .args(["kv", "key", "get", "--binding", binding, key, locality]) - .current_dir(project_dir) - .output() - .map_err(|err| { - if err.kind() == ErrorKind::NotFound { - format!("`wrangler` not found on PATH; {WRANGLER_INSTALL_HINT}") - } else { - format!("failed to spawn `wrangler`: {err}") - } - })?; - if output.status.success() { - let body = String::from_utf8(output.stdout) - .map_err(|err| format!("`wrangler kv key get` stdout is not UTF-8: {err}"))?; - // Wrangler 4.x (verified 4.64.0) returns exit 0 + stdout - // "Value not found" for a missing key instead of exit 1 + - // stderr. Detect that shape and map to MissingKey -- a - // missing key in the blob model is valid initial state - // (first push hasn't run yet), not corrupt remote state. - // Match the trimmed first line so trailing newlines or - // future variants like "Value not found.\n" still match. - let trimmed = body.trim(); - if trimmed.eq_ignore_ascii_case("value not found") - || trimmed.eq_ignore_ascii_case("value not found.") - { - return Ok(ReadConfigEntry::MissingKey); - } - return Ok(ReadConfigEntry::Present(body)); - } - let stderr = String::from_utf8_lossy(&output.stderr); - if stderr.contains("not found") || stderr.contains("does not exist") { - return Ok(ReadConfigEntry::MissingKey); - } - if stderr.contains("binding") || stderr.contains("Binding") { - return Ok(ReadConfigEntry::MissingStore); - } - Err(format!( - "`wrangler kv key get --binding {binding} {key} {locality}` exited with status {}\nstderr: {}", - output.status, - stderr.trim() - )) -} - -/// # Errors -/// Returns an error if the Cloudflare wrangler build command fails. -#[inline] -pub fn build(extra_args: &[String]) -> Result { - let manifest = - find_wrangler_manifest(env::current_dir().map_err(|err| err.to_string())?.as_path())?; - let manifest_dir = manifest - .parent() - .ok_or_else(|| "wrangler manifest has no parent directory".to_owned())?; - let cargo_manifest = manifest_dir.join("Cargo.toml"); - let crate_name = read_package_name(&cargo_manifest)?; - - let status = Command::new("cargo") - .args([ - "build", - "--release", - "--target", - TARGET_TRIPLE, - "--manifest-path", - cargo_manifest - .to_str() - .ok_or("invalid Cargo manifest path")?, - ]) - .args(extra_args) - .status() - .map_err(|err| format!("failed to run cargo build: {err}"))?; - if !status.success() { - return Err(format!("cargo build failed with status {status}")); - } - - let workspace_root = find_workspace_root(manifest_dir); - let artifact = locate_artifact(&workspace_root, manifest_dir, &crate_name)?; - let pkg_dir = workspace_root.join("pkg"); - fs::create_dir_all(&pkg_dir) - .map_err(|err| format!("failed to create {}: {err}", pkg_dir.display()))?; - let dest = pkg_dir.join(format!("{}.wasm", crate_name.replace('-', "_"))); - fs::copy(&artifact, &dest) - .map_err(|err| format!("failed to copy artifact to {}: {err}", dest.display()))?; - - Ok(dest) -} - -/// # Errors -/// Returns an error if the Cloudflare wrangler deploy command fails. -#[inline] -pub fn deploy(extra_args: &[String]) -> Result<(), String> { - let manifest = - find_wrangler_manifest(env::current_dir().map_err(|err| err.to_string())?.as_path())?; - let manifest_dir = manifest - .parent() - .ok_or_else(|| "wrangler manifest has no parent directory".to_owned())?; - let config = manifest - .to_str() - .ok_or_else(|| "invalid wrangler config path".to_owned())?; - - let status = Command::new("wrangler") - .args(["deploy", "--config", config]) - .args(extra_args) - .current_dir(manifest_dir) - .status() - .map_err(|err| format!("failed to run wrangler CLI: {err}"))?; - if !status.success() { - return Err(format!("wrangler deploy failed with status {status}")); - } - - Ok(()) -} - -/// Look up the namespace id wrangler.toml has bound to `binding`, -/// rejecting placeholder ids (anything that isn't a 32-char -/// lowercase hex Cloudflare API id). -/// -/// Accepts both `[[kv_namespaces]]` (array-of-tables, what -/// `provision` writes and wrangler's own post-create hint prints) -/// and the inline-array form. Returns Err with a "did you run -/// provision?" hint if the binding is absent OR holds a placeholder -/// like `local-dev-placeholder` — without this check `push` would -/// shell out to `wrangler kv bulk put --namespace-id=`, -/// which fails at wrangler with a less actionable error. -fn find_namespace_id(wrangler_path: &Path, binding: &str) -> Result { - // read_namespace_id returns Ok(None) for both - // missing-file AND binding-not-present; for `find_namespace_id` - // the user wants a "did you run provision?" hint in both cases, - // so collapse them into the same error message. - let raw = read_namespace_id(wrangler_path, binding)?.ok_or_else(|| { - format!( - "{}: no [[kv_namespaces]] entry with binding = {binding:?} (did you run `edgezero provision --adapter cloudflare`?)", - wrangler_path.display() - ) - })?; - if is_real_namespace_id(&raw) { - Ok(raw) - } else { - Err(format!( - "{}: binding {binding:?} has id {raw:?}, which doesn't look like a real Cloudflare KV namespace id (expected 32-char lowercase hex). This is usually a scaffold placeholder -- run `edgezero provision --adapter cloudflare` to create a real namespace and overwrite the entry.", - wrangler_path.display() - )) - } -} - -fn find_wrangler_manifest(start: &Path) -> Result { - if let Some(found) = find_manifest_upwards(start, "wrangler.toml") { - return Ok(found); - } - - let root = find_workspace_root(start); - let mut candidates: Vec = WalkDir::new(&root) - .follow_links(true) - .max_depth(8) - .into_iter() - .filter_map(Result::ok) - .map(|entry| entry.path().to_path_buf()) - .filter(|path| { - path.file_name().is_some_and(|n| n == "wrangler.toml") - && path - .parent() - .is_some_and(|dir| dir.join("Cargo.toml").exists()) - }) - .collect(); - - if candidates.is_empty() { - return Err("could not locate wrangler.toml".to_owned()); - } - - candidates.sort_by_key(|path| { - let parent = path.parent().unwrap_or(Path::new("")); - path_distance(start, parent) - }); - - Ok(candidates.remove(0)) -} - -fn locate_artifact( - workspace_root: &Path, - manifest_dir: &Path, - crate_name: &str, -) -> Result { - let release_name = format!("{}.wasm", crate_name.replace('-', "_")); - - if let Some(custom) = env::var_os("CARGO_TARGET_DIR") { - let candidate = PathBuf::from(custom) - .join(TARGET_TRIPLE) - .join("release") - .join(&release_name); - if candidate.exists() { - return Ok(candidate); - } - } - - let manifest_target = manifest_dir - .join("target") - .join(TARGET_TRIPLE) - .join("release") - .join(&release_name); - if manifest_target.exists() { - return Ok(manifest_target); - } - - let workspace_target = workspace_root - .join("target") - .join(TARGET_TRIPLE) - .join("release") - .join(&release_name); - if workspace_target.exists() { - return Ok(workspace_target); - } - - Err(format!( - "compiled artifact not found for {crate_name} (looked in manifest and workspace target directories)" - )) -} - -#[inline] -pub fn register() { - register_adapter(&CLOUDFLARE_ADAPTER); - register_adapter_blueprint(&CLOUDFLARE_BLUEPRINT); -} - -#[ctor(unsafe)] -fn register_ctor() { - register(); -} - -/// # Errors -/// Returns an error if the Cloudflare wrangler dev command fails. -#[inline] -pub fn serve(extra_args: &[String]) -> Result<(), String> { - let manifest = - find_wrangler_manifest(env::current_dir().map_err(|err| err.to_string())?.as_path())?; - let manifest_dir = manifest - .parent() - .ok_or_else(|| "wrangler manifest has no parent directory".to_owned())?; - let config = manifest - .to_str() - .ok_or_else(|| "invalid wrangler config path".to_owned())?; - - let status = Command::new("wrangler") - .args(["dev", "--config", config]) - .args(extra_args) - .current_dir(manifest_dir) - .status() - .map_err(|err| format!("failed to run wrangler CLI: {err}"))?; - if !status.success() { - return Err(format!("wrangler dev failed with status {status}")); - } - - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - #[cfg(unix)] - use std::ffi::OsString; - #[cfg(unix)] - use std::sync::Mutex; - use tempfile::tempdir; - - // Shared fixture names. Pinning these as consts (instead of - // inline `"sessions"` / `"app_config"` per call site) keeps the - // setup-vs-assertion pair in sync -- a typo in one place no - // longer silently divorces from the other, because both reference - // the same const. Also names the intent: these are the LOGICAL - // store ids the cloudflare adapter operates on, not arbitrary - // strings. - const TEST_KV_ID: &str = "sessions"; - const TEST_KV_ID_ALT: &str = "cache"; - const TEST_CONFIG_ID: &str = "app_config"; - const TEST_SECRET_ID: &str = "default"; - - /// RAII guard: prepends a directory to `$PATH` and restores the original - /// value on drop. Mirrors the `PathPrepend` used in `push_cloud.rs`. - #[cfg(unix)] - struct PathPrepend { - original: Option, - } - - #[cfg(unix)] - impl PathPrepend { - fn new(extra: &Path) -> Self { - let original = env::var_os("PATH"); - let new = match &original { - Some(prev) => { - let mut accum = OsString::from(extra); - accum.push(":"); - accum.push(prev); - accum - } - None => OsString::from(extra), - }; - env::set_var("PATH", new); - Self { original } - } - } - - #[cfg(unix)] - impl Drop for PathPrepend { - fn drop(&mut self) { - match self.original.take() { - Some(prev) => env::set_var("PATH", prev), - None => env::remove_var("PATH"), - } - } - } - - // ---------- extract_namespace_id ---------- - - #[test] - fn extract_namespace_id_parses_wrangler_3_output() { - // wrangler decorates these lines with unicode glyphs in real - // output; we drop them from the fixture to keep the source - // file ASCII-only (clippy::non_ascii_literal). The parser - // requires both the `[[kv_namespaces]]` anchor and a - // 32-char-lowercase-hex id. - let stdout = r#"Creating namespace with title "my-kv" -Success! -Add the following to your configuration file in your kv_namespaces array: -[[kv_namespaces]] -binding = "my-kv" -id = "00112233445566778899aabbccddeeff" -"#; - assert_eq!( - extract_namespace_id(stdout).as_deref(), - Some("00112233445566778899aabbccddeeff") - ); - } - - #[test] - fn extract_namespace_id_tolerates_extra_whitespace() { - let stdout = "[[kv_namespaces]]\n id = \"00112233445566778899aabbccddeeff\" \n"; - assert_eq!( - extract_namespace_id(stdout).as_deref(), - Some("00112233445566778899aabbccddeeff") - ); - } - - #[test] - fn extract_namespace_id_returns_none_on_missing_id_line() { - assert!(extract_namespace_id("nothing to see here").is_none()); - assert!(extract_namespace_id("").is_none()); - assert!( - extract_namespace_id("[[kv_namespaces]]\nid = \"\"").is_none(), - "empty value not a real id" - ); - } - - #[test] - fn extract_namespace_id_ignores_unrelated_lines_starting_with_id() { - // `identifier = "..."` doesn't match -- we strip exactly the - // prefix `id` then require `=`. Also doesn't match because - // there's no `[[kv_namespaces]]` anchor. - assert!(extract_namespace_id("[[kv_namespaces]]\nidentifier = \"x\"").is_none()); - } - - #[test] - fn extract_namespace_id_requires_kv_namespaces_anchor() { - // A bare `id = "<32-char-hex>"` line that isn't preceded by - // `[[kv_namespaces]]` must not match -- otherwise a future - // wrangler info line like `id = ""` printed - // somewhere else in stdout would be picked up as the - // namespace id and silently corrupt wrangler.toml on writeback. - let unanchored = "id = \"00112233445566778899aabbccddeeff\"\n"; - assert!(extract_namespace_id(unanchored).is_none()); - - // A different table header BEFORE the `id` line scopes us - // out of the kv-namespaces context. - let other_block = "[[d1_databases]]\nid = \"00112233445566778899aabbccddeeff\"\n"; - assert!(extract_namespace_id(other_block).is_none()); - } - - #[test] - fn extract_namespace_id_rejects_non_real_id_inside_kv_namespaces_anchor() { - // Even with the anchor, the value must look like a real - // Cloudflare id (32-char lowercase hex with the diversity - // floor). Shorter or non-hex values are skipped, not - // returned -- forces the operator to investigate stdout - // drift rather than silently writing a bogus id. - let stdout = "[[kv_namespaces]]\nbinding = \"my-kv\"\nid = \"abc123\"\n"; - assert!(extract_namespace_id(stdout).is_none()); - } - - fn write_wrangler(dir: &Path, contents: &str) -> PathBuf { - let path = dir.join("wrangler.toml"); - fs::write(&path, contents).expect("write wrangler.toml"); - path - } - - // ---------- is_real_namespace_id ---------- - - #[test] - fn is_real_namespace_id_accepts_32_char_lowercase_hex_with_sufficient_diversity() { - // 16-distinct-char fixture: maximum diversity. - assert!(is_real_namespace_id("00112233445566778899aabbccddeeff")); - // Realistic randomish fixture: 14 distinct chars. - assert!(is_real_namespace_id("4a8f3c2b9e1d5670adef2839c4b6e1f0")); - } - - #[test] - fn is_real_namespace_id_rejects_placeholder_or_short_id() { - assert!(!is_real_namespace_id("local-dev-placeholder")); - assert!(!is_real_namespace_id("abc123")); - assert!(!is_real_namespace_id("")); - } - - #[test] - fn is_real_namespace_id_rejects_uppercase_or_non_hex() { - // Uppercase rejected: Cloudflare's API returns lowercase. - assert!(!is_real_namespace_id("00112233445566778899AABBCCDDEEFF")); - // Non-hex digits rejected. - assert!(!is_real_namespace_id("z0112233445566778899aabbccddeeff")); - } - - #[test] - fn is_real_namespace_id_rejects_hex_shape_sentinels() { - // 32-char lowercase hex but obvious hand-typed placeholder: - // distinct-hex-digit count is below the diversity floor. - // Real Cloudflare ids have effectively uniform random hex, - // so collisions with this guard are astronomical. - assert!( - !is_real_namespace_id("00000000000000000000000000000000"), - "all-zeros rejected" - ); - assert!( - !is_real_namespace_id("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), - "all-a rejected" - ); - assert!( - !is_real_namespace_id("deadbeefdeadbeefdeadbeefdeadbeef"), - "deadbeef rejected (only 5 distinct chars: d,e,a,b,f)" - ); - // Boundary: a real-looking id with the diversity floor or - // more must still pass. - assert!( - is_real_namespace_id("00112233445566778899aabbccddeeff"), - "16-distinct-char fixture must still pass" - ); - // Exactly 6 distinct chars (a,b,c,d,e,f): on the boundary, - // must pass. - assert!( - is_real_namespace_id("aabbccddeeffaabbccddeeffaabbccdd"), - "6-distinct-char fixture (boundary) passes" - ); - } - - // ---------- read_namespace_id ---------- - - #[test] - fn read_namespace_id_errors_when_kv_namespaces_is_non_array_value() { - // `kv_namespaces = "oops"` is a malformed manifest. Silently - // returning None there bubbles up as "did you run provision?" - // -- a misleading error. The right surface is "manifest - // doesn't match the expected shape". - let dir = tempdir().expect("tempdir"); - let path = write_wrangler(dir.path(), "name = \"demo\"\nkv_namespaces = \"oops\"\n"); - let err = read_namespace_id(&path, TEST_CONFIG_ID) - .expect_err("non-array kv_namespaces must error"); - assert!( - err.contains("array-of-tables") || err.contains("inline array"), - "error names the expected shapes: {err}" - ); - assert!( - err.contains("string"), - "error names the offending kind: {err}" - ); - } - - // ---------- extract_namespace_id (pinning behaviour) ---------- - - #[test] - fn extract_namespace_id_returns_first_real_match_inside_kv_namespaces_anchor() { - // Pin: top-down scan, first qualifying line inside the - // `[[kv_namespaces]]` anchor wins. Real wrangler output has - // exactly one. A hypothetical future format with multiple - // qualifying lines would surface the earliest, but only - // values that look like real Cloudflare ids count. - let stdout = "[[kv_namespaces]]\n\ - id = \"00112233445566778899aabbccddeeff\"\n\ - id = \"ffeeddccbbaa99887766554433221100\"\n"; - assert_eq!( - extract_namespace_id(stdout).as_deref(), - Some("00112233445566778899aabbccddeeff") - ); - } - - // ---------- upsert_kv_namespace ---------- - - #[test] - fn upsert_kv_namespace_replaces_placeholder_id_for_existing_binding() { - let dir = tempdir().expect("tempdir"); - let path = write_wrangler( - dir.path(), - "[[kv_namespaces]]\nbinding = \"sessions\"\nid = \"local-dev-placeholder\"\n", - ); - upsert_kv_namespace(&path, TEST_KV_ID, "00112233445566778899aabbccddeeff").expect("upsert"); - let after = fs::read_to_string(&path).expect("read"); - assert!( - after.contains("id = \"00112233445566778899aabbccddeeff\""), - "placeholder replaced: {after}" - ); - assert!( - !after.contains("local-dev-placeholder"), - "placeholder removed: {after}" - ); - assert_eq!( - after.matches("binding = \"sessions\"").count(), - 1, - "no duplicate binding: {after}" - ); - } - - #[test] - fn upsert_kv_namespace_appends_when_binding_absent() { - let dir = tempdir().expect("tempdir"); - let path = write_wrangler(dir.path(), "name = \"demo\"\n"); - upsert_kv_namespace(&path, TEST_KV_ID, "00112233445566778899aabbccddeeff").expect("upsert"); - let after = fs::read_to_string(&path).expect("read"); - assert!( - after.contains("binding = \"sessions\"") - && after.contains("id = \"00112233445566778899aabbccddeeff\""), - "appended new entry: {after}" - ); - assert!( - after.contains("name = \"demo\""), - "preserved original keys: {after}" - ); - } - - #[test] - fn upsert_kv_namespace_appends_next_to_existing_entries() { - let dir = tempdir().expect("tempdir"); - let path = write_wrangler( - dir.path(), - "[[kv_namespaces]]\nbinding = \"cache\"\nid = \"old\"\n", - ); - upsert_kv_namespace(&path, TEST_KV_ID, "00112233445566778899aabbccddeeff").expect("upsert"); - let after = fs::read_to_string(&path).expect("read"); - assert!( - after.contains("binding = \"cache\"") && after.contains("id = \"old\""), - "existing entry kept: {after}" - ); - assert!( - after.contains("binding = \"sessions\""), - "new entry added: {after}" - ); - assert_eq!( - after.matches("[[kv_namespaces]]").count(), - 2, - "two entries: {after}" - ); - } - - #[test] - fn upsert_kv_namespace_preserves_top_comments() { - let dir = tempdir().expect("tempdir"); - let path = write_wrangler( - dir.path(), - "# managed by hand -- please keep this line\nname = \"my-worker\"\n", - ); - upsert_kv_namespace(&path, TEST_KV_ID, "00112233445566778899aabbccddeeff").expect("upsert"); - let after = fs::read_to_string(&path).expect("read"); - assert!( - after.contains("# managed by hand"), - "preserved comment: {after}" - ); - } - - #[test] - fn upsert_kv_namespace_preserves_sibling_fields_on_existing_entry() { - // toml_edit replaces only the `id` Item when we update it; - // sibling fields on the same `[[kv_namespaces]]` table - // (e.g. `preview_id`, custom annotations the user added) - // must survive the rewrite. Pinning this so a future - // toml_edit upgrade or a refactor can't silently drop - // operator data. - let dir = tempdir().expect("tempdir"); - let path = write_wrangler( - dir.path(), - "[[kv_namespaces]]\nbinding = \"sessions\"\nid = \"local-dev-placeholder\"\npreview_id = \"local-preview\"\ndescription = \"hand-added by ops\"\n", - ); - upsert_kv_namespace(&path, TEST_KV_ID, "00112233445566778899aabbccddeeff").expect("upsert"); - let after = fs::read_to_string(&path).expect("read"); - assert!( - after.contains("id = \"00112233445566778899aabbccddeeff\""), - "id rewritten: {after}" - ); - assert!( - after.contains("preview_id = \"local-preview\""), - "preserved preview_id: {after}" - ); - assert!( - after.contains("description = \"hand-added by ops\""), - "preserved description: {after}" - ); - } - - #[test] - fn upsert_kv_namespace_creates_file_when_wrangler_toml_missing() { - // Orphan-namespace hazard: if `wrangler kv namespace create` - // succeeds but wrangler.toml is missing at writeback time, - // erroring here would leave the remote namespace orphaned - // with no local reference. Symmetric with read_namespace_id's - // NotFound -> Ok(None) behaviour: upsert treats NotFound as - // "start with empty document" and writes the entry. - let dir = tempdir().expect("tempdir"); - let path = dir.path().join("missing.toml"); - assert!(!path.exists(), "precondition: file must not exist"); - upsert_kv_namespace(&path, TEST_KV_ID, "00112233445566778899aabbccddeeff") - .expect("missing file is permissive"); - let after = fs::read_to_string(&path).expect("file now exists"); - assert!( - after.contains("binding = \"sessions\""), - "created file with new entry: {after}" - ); - assert!( - after.contains("id = \"00112233445566778899aabbccddeeff\""), - "id written: {after}" - ); - } - - // ---------- writeback shape pre-check ---------- - - #[test] - fn check_kv_namespaces_writeback_shape_ok_when_file_missing() { - let dir = tempdir().expect("tempdir"); - let path = dir.path().join("missing.toml"); - check_kv_namespaces_writeback_shape(&path) - .expect("missing file is permissive (upsert creates it)"); - } - - #[test] - fn check_kv_namespaces_writeback_shape_ok_when_kv_namespaces_absent() { - let dir = tempdir().expect("tempdir"); - let path = dir.path().join("wrangler.toml"); - fs::write(&path, "name = \"demo\"\n").expect("write wrangler.toml"); - check_kv_namespaces_writeback_shape(&path).expect("no kv_namespaces => OK"); - } - - #[test] - fn check_kv_namespaces_writeback_shape_ok_when_array_of_tables() { - let dir = tempdir().expect("tempdir"); - let path = dir.path().join("wrangler.toml"); - fs::write( - &path, - "name = \"demo\"\n[[kv_namespaces]]\nbinding = \"sessions\"\nid = \"local-dev-placeholder\"\n", - ) - .expect("write wrangler.toml"); - check_kv_namespaces_writeback_shape(&path) - .expect("[[kv_namespaces]] is the writeback-supported shape"); - } - - #[test] - fn check_kv_namespaces_writeback_shape_rejects_inline_array_with_actionable_message() { - // Regression for the orphan-namespace hazard: pre-fix, a - // `kv_namespaces = [{ binding = "sessions" }]` manifest (no - // id present) made `read_namespace_id` return None ("not yet - // provisioned") so provision shelled `wrangler kv namespace - // create` successfully, then `upsert_kv_namespace`'s - // `as_array_of_tables_mut()` returned None and the upsert - // errored — leaving the freshly-created namespace orphaned - // on Cloudflare. The pre-flight rejects the inline-array - // shape BEFORE any account-side call. - let dir = tempdir().expect("tempdir"); - let path = dir.path().join("wrangler.toml"); - fs::write( - &path, - "name = \"demo\"\nkv_namespaces = [{ binding = \"sessions\" }]\n", - ) - .expect("write wrangler.toml"); - let err = check_kv_namespaces_writeback_shape(&path) - .expect_err("inline-array form must be rejected before provision shells out"); - assert!( - err.contains("inline array") - && err.contains("[[kv_namespaces]]") - && err.contains("orphaned"), - "error must name the inline-array form, the supported [[kv_namespaces]] form, AND the orphan hazard so the operator knows what's at stake: {err}" - ); - } - - // ---------- provision (dry-run + error path) ---------- - - #[test] - fn provision_dry_run_does_not_invoke_wrangler() { - let dir = tempdir().expect("tempdir"); - write_wrangler(dir.path(), "name = \"demo\"\n"); - let kv_ids: Vec = - ResolvedStoreId::from_logicals(&[TEST_KV_ID, TEST_KV_ID_ALT]); - let config_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_CONFIG_ID]); - let secret_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_SECRET_ID]); - let stores = ProvisionStores { - config: &config_ids, - kv: &kv_ids, - secrets: &secret_ids, - }; - let out = CloudflareCliAdapter - .provision(dir.path(), Some("wrangler.toml"), None, &stores, true) - .expect("dry-run succeeds"); - // 2 KV + 1 config + 1 secret = 4 status lines. - assert_eq!(out.len(), 4); - assert!(out[0].contains("would run `wrangler kv namespace create sessions`")); - assert!(out[1].contains("would run `wrangler kv namespace create cache`")); - assert!(out[2].contains("would run `wrangler kv namespace create app_config`")); - assert!(out[3].contains("runtime-managed via `wrangler secret put`")); - // Manifest untouched. - let after = fs::read_to_string(dir.path().join("wrangler.toml")).expect("read"); - assert_eq!(after, "name = \"demo\"\n", "dry-run mutated wrangler.toml"); - } - - #[test] - fn provision_dry_run_writes_resolved_platform_name_into_binding() { - // Regression: provision used to receive only logical ids - // and write them verbatim into wrangler.toml. With the - // platform-name flow, an operator who sets - // `EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME=prod_config` - // sees `prod_config` land as the binding name (matching what - // the runtime resolves via `env.kv(...)`), with the logical - // id still mentioned for human-facing wording. - let dir = tempdir().expect("tempdir"); - write_wrangler(dir.path(), "name = \"demo\"\n"); - let config_ids = vec![ResolvedStoreId::new(TEST_CONFIG_ID, "prod_config")]; - let stores = ProvisionStores { - config: &config_ids, - kv: &[], - secrets: &[], - }; - let out = CloudflareCliAdapter - .provision(dir.path(), Some("wrangler.toml"), None, &stores, true) - .expect("dry-run succeeds"); - assert_eq!(out.len(), 1); - assert!( - out[0].contains("wrangler kv namespace create prod_config"), - "dry-run uses platform name in the `wrangler` invocation: {out:?}" - ); - assert!( - out[0].contains("binding = \"prod_config\""), - "dry-run writes platform name as the binding: {out:?}" - ); - assert!( - out[0].contains("logical id `app_config`"), - "logical id is preserved for operator wording: {out:?}" - ); - } - - #[test] - fn provision_errors_when_adapter_manifest_path_missing() { - let dir = tempdir().expect("tempdir"); - let kv_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_KV_ID]); - let stores = ProvisionStores { - config: &[], - kv: &kv_ids, - secrets: &[], - }; - let err = CloudflareCliAdapter - .provision(dir.path(), None, None, &stores, true) - .expect_err("missing adapter manifest path must error"); - assert!( - err.contains("wrangler.toml"), - "error names what's missing: {err}" - ); - } - - #[test] - fn provision_dry_run_skips_bindings_already_provisioned_with_real_id() { - let dir = tempdir().expect("tempdir"); - // 32-char lowercase hex id == real Cloudflare namespace id. - let path = write_wrangler( - dir.path(), - "name = \"demo\"\n[[kv_namespaces]]\nbinding = \"sessions\"\nid = \"00112233445566778899aabbccddeeff\"\n", - ); - let kv_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_KV_ID]); - let stores = ProvisionStores { - config: &[], - kv: &kv_ids, - secrets: &[], - }; - let out = CloudflareCliAdapter - .provision(dir.path(), Some("wrangler.toml"), None, &stores, true) - .expect("dry-run succeeds"); - assert_eq!(out.len(), 1); - assert!( - out[0].contains("already provisioned") - && out[0].contains("00112233445566778899aabbccddeeff"), - "skip line names the existing id: {out:?}" - ); - let after = fs::read_to_string(&path).expect("read"); - assert!( - after.contains("00112233445566778899aabbccddeeff"), - "did not touch existing id: {after}" - ); - } - - #[test] - fn provision_dry_run_treats_placeholder_id_as_unprovisioned() { - // A scaffolded wrangler.toml ships with placeholder ids the - // user is expected to overwrite by running provision. - // Dry-run should report the would-be create call, NOT the - // already-provisioned skip. - let dir = tempdir().expect("tempdir"); - write_wrangler( - dir.path(), - "name = \"demo\"\n[[kv_namespaces]]\nbinding = \"sessions\"\nid = \"local-dev-placeholder\"\n", - ); - let kv_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_KV_ID]); - let stores = ProvisionStores { - config: &[], - kv: &kv_ids, - secrets: &[], - }; - let out = CloudflareCliAdapter - .provision(dir.path(), Some("wrangler.toml"), None, &stores, true) - .expect("dry-run succeeds"); - assert_eq!(out.len(), 1); - assert!( - out[0].contains("would run `wrangler kv namespace create sessions`"), - "placeholder id is treated as unprovisioned: {out:?}" - ); - } - - #[test] - fn provision_with_no_declared_stores_says_so() { - let dir = tempdir().expect("tempdir"); - write_wrangler(dir.path(), "name = \"demo\"\n"); - let stores = ProvisionStores { - config: &[], - kv: &[], - secrets: &[], - }; - let out = CloudflareCliAdapter - .provision(dir.path(), Some("wrangler.toml"), None, &stores, false) - .expect("no-store provision is fine"); - assert_eq!(out, vec!["cloudflare has no declared stores to provision"]); - } - - // ---------- find_namespace_id ---------- - - #[test] - fn find_namespace_id_reads_array_of_tables() { - let dir = tempdir().expect("tempdir"); - let path = write_wrangler( - dir.path(), - "name = \"demo\"\n[[kv_namespaces]]\nbinding = \"app_config\"\nid = \"00112233445566778899aabbccddeeff\"\n", - ); - let id = find_namespace_id(&path, TEST_CONFIG_ID).expect("found"); - assert_eq!(id, "00112233445566778899aabbccddeeff"); - } - - #[test] - fn find_namespace_id_reads_inline_array() { - let dir = tempdir().expect("tempdir"); - let path = write_wrangler( - dir.path(), - "name = \"demo\"\nkv_namespaces = [{ binding = \"app_config\", id = \"ffeeddccbbaa99887766554433221100\" }]\n", - ); - let id = find_namespace_id(&path, TEST_CONFIG_ID).expect("found"); - assert_eq!(id, "ffeeddccbbaa99887766554433221100"); - } - - #[test] - fn find_namespace_id_errors_with_provision_hint_when_binding_absent() { - let dir = tempdir().expect("tempdir"); - let path = write_wrangler( - dir.path(), - "name = \"demo\"\n[[kv_namespaces]]\nbinding = \"other\"\nid = \"00112233445566778899aabbccddeeff\"\n", - ); - let err = find_namespace_id(&path, TEST_CONFIG_ID).expect_err("missing must error"); - assert!( - err.contains(TEST_CONFIG_ID) && err.contains("provision"), - "error names the binding and points at provision: {err}" - ); - } - - #[test] - fn find_namespace_id_rejects_placeholder_id_with_provision_hint() { - // A binding with `id = "local-dev-placeholder"` (or any - // other non-32-char-hex value) is treated the same as - // a missing binding: the operator needs to run provision - // before the id is usable for `wrangler kv bulk put`. - // Without this guard, push would shell out with the - // placeholder as `--namespace-id=...` and fail at wrangler - // with a less actionable error. - let dir = tempdir().expect("tempdir"); - let path = write_wrangler( - dir.path(), - "name = \"demo\"\n[[kv_namespaces]]\nbinding = \"app_config\"\nid = \"local-dev-placeholder\"\n", - ); - let err = - find_namespace_id(&path, TEST_CONFIG_ID).expect_err("placeholder id must be rejected"); - assert!( - err.contains("local-dev-placeholder") && err.contains("provision"), - "error names the placeholder and points at provision: {err}" - ); - } - - #[test] - fn find_namespace_id_errors_with_provision_hint_when_file_missing() { - let dir = tempdir().expect("tempdir"); - let path = dir.path().join("does-not-exist.toml"); - let err = - find_namespace_id(&path, TEST_CONFIG_ID).expect_err("missing wrangler.toml must error"); - assert!( - err.contains("provision"), - "error points at provision: {err}" - ); - } - - // ---------- bulk_payload ---------- - - #[test] - fn bulk_payload_emits_wrangler_array_of_key_value_objects() { - let entries = vec![ - ("greeting".to_owned(), "hello".to_owned()), - ("service.timeout_ms".to_owned(), "1500".to_owned()), - ]; - let raw = bulk_payload(&entries).expect("payload"); - let parsed: serde_json::Value = serde_json::from_str(&raw).expect("valid JSON"); - let array = parsed.as_array().expect("array"); - assert_eq!(array.len(), 2); - assert_eq!(array[0]["key"], "greeting"); - assert_eq!(array[0]["value"], "hello"); - assert_eq!(array[1]["key"], "service.timeout_ms"); - assert_eq!(array[1]["value"], "1500"); - } - - #[test] - fn bulk_payload_with_no_entries_is_empty_array() { - let raw = bulk_payload(&[]).expect("empty payload"); - let parsed: serde_json::Value = serde_json::from_str(&raw).expect("valid JSON"); - assert_eq!(parsed, serde_json::json!([])); - } - - // ---------- push_config_entries (dry-run + error paths) ---------- - - #[test] - fn push_dry_run_resolves_namespace_id_and_does_not_invoke_wrangler() { - let dir = tempdir().expect("tempdir"); - let original = - "name = \"demo\"\n[[kv_namespaces]]\nbinding = \"app_config\"\nid = \"00112233445566778899aabbccddeeff\"\n"; - let path = write_wrangler(dir.path(), original); - let entries = vec![ - ("greeting".to_owned(), "hello".to_owned()), - ("feature.new_checkout".to_owned(), "false".to_owned()), - ]; - let out = CloudflareCliAdapter - .push_config_entries( - dir.path(), - Some("wrangler.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - &entries, - &AdapterPushContext::new(), - true, - ) - .expect("dry-run succeeds"); - // Header + per-entry preview, matching the fastly dry-run shape. - assert_eq!(out.len(), 1 + entries.len(), "header + per-entry preview"); - assert!( - out[0].contains("would run `wrangler kv bulk put") - && out[0].contains("--namespace-id=00112233445566778899aabbccddeeff"), - "dry-run header names namespace id: {out:?}" - ); - assert!( - out.iter().any(|line| line.contains("`greeting`")), - "dry-run lists `greeting`: {out:?}" - ); - assert!( - out.iter() - .any(|line| line.contains("`feature.new_checkout`")), - "dry-run lists `feature.new_checkout`: {out:?}" - ); - let after = fs::read_to_string(&path).expect("read"); - assert_eq!(after, original, "dry-run must not mutate wrangler.toml"); - } - - #[test] - fn push_dry_run_is_lenient_when_binding_not_yet_provisioned() { - let dir = tempdir().expect("tempdir"); - write_wrangler(dir.path(), "name = \"demo\"\n"); - let entries = vec![("greeting".to_owned(), "hello".to_owned())]; - let out = CloudflareCliAdapter - .push_config_entries( - dir.path(), - Some("wrangler.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - &entries, - &AdapterPushContext::new(), - true, - ) - .expect("dry-run is lenient: pre-provision preview is allowed"); - assert!( - out[0].contains("") && out[0].contains("provision"), - "dry-run header explains the namespace is unresolved and points at provision: {out:?}" - ); - assert!( - out.iter().any(|line| line.contains("`greeting`")), - "dry-run still lists the entries it would push: {out:?}" - ); - } - - #[test] - fn push_errors_when_adapter_manifest_path_missing() { - let dir = tempdir().expect("tempdir"); - let entries = vec![("k".to_owned(), "v".to_owned())]; - let err = CloudflareCliAdapter - .push_config_entries( - dir.path(), - None, - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - &entries, - &AdapterPushContext::new(), - true, - ) - .expect_err("missing adapter manifest path must error"); - assert!( - err.contains("wrangler.toml") && err.contains("config push"), - "error explains the missing manifest pointer: {err}" - ); - } - - #[test] - fn push_real_run_errors_with_provision_hint_when_binding_absent() { - // dry-run is now lenient (see - // `push_dry_run_is_lenient_when_binding_not_yet_provisioned`), - // but a real run still must err so we don't silently push - // to a non-existent namespace. - let dir = tempdir().expect("tempdir"); - write_wrangler(dir.path(), "name = \"demo\"\n"); - let entries = vec![("greeting".to_owned(), "hello".to_owned())]; - let err = CloudflareCliAdapter - .push_config_entries( - dir.path(), - Some("wrangler.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - &entries, - &AdapterPushContext::new(), - false, - ) - .expect_err("missing binding must error on real run"); - assert!( - err.contains("provision") && err.contains(TEST_CONFIG_ID), - "error points at provision: {err}" - ); - } - - #[test] - fn push_with_no_entries_reports_no_op_after_resolving_namespace() { - let dir = tempdir().expect("tempdir"); - write_wrangler( - dir.path(), - "name = \"demo\"\n[[kv_namespaces]]\nbinding = \"app_config\"\nid = \"00112233445566778899aabbccddeeff\"\n", - ); - let out = CloudflareCliAdapter - .push_config_entries( - dir.path(), - Some("wrangler.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - &[], - &AdapterPushContext::new(), - false, - ) - .expect("zero-entry push is fine"); - assert_eq!(out.len(), 1); - assert!( - out[0].contains("no config entries") - && out[0].contains("00112233445566778899aabbccddeeff"), - "status line names empty + namespace id: {out:?}" - ); - } - - // ---------- read_config_entry / read_config_entry_local (fake wrangler) ---------- - - /// Build a tempdir containing a `wrangler` script that emits fixed stdout / - /// stderr and exits with the given code. The files are written to siblings - /// of the script so shell-active chars in the payloads don't get - /// re-interpreted. - #[cfg(unix)] - fn fake_wrangler_returning( - stdout_body: &str, - stderr_body: &str, - exit_code: i32, - ) -> tempfile::TempDir { - use std::os::unix::fs::PermissionsExt as _; - let dir = tempdir().expect("tempdir"); - let script_path = dir.path().join("wrangler"); - let stdout_file = dir.path().join("stdout_payload.txt"); - let stderr_file = dir.path().join("stderr_payload.txt"); - fs::write(&stdout_file, stdout_body).expect("write stdout payload"); - fs::write(&stderr_file, stderr_body).expect("write stderr payload"); - let script = format!( - "#!/bin/sh\ncat '{stdout}'\ncat '{stderr}' >&2\nexit {code}\n", - stdout = stdout_file.display(), - stderr = stderr_file.display(), - code = exit_code, - ); - fs::write(&script_path, script).expect("write wrangler script"); - let mut perms = fs::metadata(&script_path).expect("meta").permissions(); - perms.set_mode(0o755); - fs::set_permissions(&script_path, perms).expect("chmod +x"); - dir - } - - /// Build a fake `wrangler` that logs each argv token (one per line) to - /// `out_path`, prints a single line of stdout, and exits 0. - #[cfg(unix)] - fn fake_wrangler_argv_log(out_path: &Path) -> tempfile::TempDir { - use std::os::unix::fs::PermissionsExt as _; - let dir = tempdir().expect("tempdir"); - let script_path = dir.path().join("wrangler"); - let script = format!( - "#!/bin/sh\nfor arg in \"$@\"; do printf '%s\\n' \"$arg\" >> '{out}'; done\nprintf 'val'\n", - out = out_path.display(), - ); - fs::write(&script_path, script).expect("write script"); - let mut perms = fs::metadata(&script_path).expect("meta").permissions(); - perms.set_mode(0o755); - fs::set_permissions(&script_path, perms).expect("chmod +x"); - dir - } - - /// Process-wide mutex serialising PATH-mutating tests so parallel - /// test threads don't race on the environment variable. - #[cfg(unix)] - fn path_mutation_guard() -> &'static Mutex<()> { - use std::sync::{Mutex, OnceLock}; - static GUARD: OnceLock> = OnceLock::new(); - GUARD.get_or_init(|| Mutex::new(())) - } - - #[cfg(unix)] - #[test] - fn read_remote_returns_present_on_success() { - let _lock = path_mutation_guard().lock().expect("guard"); - let project_dir = tempdir().expect("tempdir"); - write_wrangler(project_dir.path(), "name = \"demo\"\n"); - let fake = fake_wrangler_returning("hello-cloudflare", "", 0); - let _path = PathPrepend::new(fake.path()); - let result = CloudflareCliAdapter - .read_config_entry( - project_dir.path(), - Some("wrangler.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - "greeting", - &AdapterPushContext::new(), - ) - .expect("wrangler exit-0 must succeed"); - let ReadConfigEntry::Present(value) = result else { - panic!("expected Present"); - }; - assert_eq!(value, "hello-cloudflare"); - } - - #[cfg(unix)] - #[test] - fn read_remote_returns_missing_key_on_not_found_stderr() { - let _lock = path_mutation_guard().lock().expect("guard"); - let project_dir = tempdir().expect("tempdir"); - write_wrangler(project_dir.path(), "name = \"demo\"\n"); - let fake = fake_wrangler_returning("", "Error: key not found", 1); - let _path = PathPrepend::new(fake.path()); - let result = CloudflareCliAdapter - .read_config_entry( - project_dir.path(), - Some("wrangler.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - "greeting", - &AdapterPushContext::new(), - ) - .expect("not-found maps to MissingKey (not Err)"); - assert!( - matches!(result, ReadConfigEntry::MissingKey), - "not-found stderr => MissingKey" - ); - } - - /// Wrangler 4.x (verified 4.64.0) returns exit 0 + stdout - /// `"Value not found"` for a missing key instead of exit 1 + - /// stderr. The previous read path treated every exit-0 stdout - /// as a `Present` envelope, which made the next CLI step try - /// to parse `"Value not found"` as a `BlobEnvelope` and abort. - /// A missing key in the blob model is valid initial state -- - /// the first push hasn't run yet -- not corrupt remote state, - /// so it must map to `MissingKey`. - #[cfg(unix)] - #[test] - fn read_remote_returns_missing_key_on_wrangler_4_value_not_found_stdout() { - let _lock = path_mutation_guard().lock().expect("guard"); - let project_dir = tempdir().expect("tempdir"); - write_wrangler(project_dir.path(), "name = \"demo\"\n"); - let fake = fake_wrangler_returning("Value not found\n", "", 0); - let _path = PathPrepend::new(fake.path()); - let result = CloudflareCliAdapter - .read_config_entry( - project_dir.path(), - Some("wrangler.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - "greeting", - &AdapterPushContext::new(), - ) - .expect("Wrangler 4.x exit-0 'Value not found' must map to MissingKey"); - if let ReadConfigEntry::Present(body) = &result { - panic!( - "expected MissingKey on Wrangler 4.x 'Value not found' stdout; \ - got Present({body:?})", - ); - } - assert!( - matches!(result, ReadConfigEntry::MissingKey), - "Wrangler 4.x stdout='Value not found' (exit 0) must classify as MissingKey", - ); - } - - #[cfg(unix)] - #[test] - fn read_remote_returns_missing_store_on_binding_stderr() { - let _lock = path_mutation_guard().lock().expect("guard"); - let project_dir = tempdir().expect("tempdir"); - write_wrangler(project_dir.path(), "name = \"demo\"\n"); - let fake = fake_wrangler_returning("", "Error: binding APP_CONFIG is not defined", 1); - let _path = PathPrepend::new(fake.path()); - let result = CloudflareCliAdapter - .read_config_entry( - project_dir.path(), - Some("wrangler.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - "greeting", - &AdapterPushContext::new(), - ) - .expect("binding-error maps to MissingStore (not Err)"); - assert!( - matches!(result, ReadConfigEntry::MissingStore), - "binding stderr => MissingStore" - ); - } - - #[cfg(unix)] - #[test] - fn read_local_uses_local_flag() { - // Verify that read_config_entry_local passes `--local` (not `--remote`) - // to wrangler. We capture argv via a fake wrangler and check the args. - let _lock = path_mutation_guard().lock().expect("guard"); - let project_dir = tempdir().expect("tempdir"); - write_wrangler(project_dir.path(), "name = \"demo\"\n"); - let argv_log = project_dir.path().join("argv.txt"); - let fake = fake_wrangler_argv_log(&argv_log); - let _path = PathPrepend::new(fake.path()); - let result = CloudflareCliAdapter - .read_config_entry_local( - project_dir.path(), - Some("wrangler.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - "greeting", - &AdapterPushContext::new(), - ) - .expect("local read succeeds"); - assert!( - matches!(result, ReadConfigEntry::Present(_)), - "expected Present from local read" - ); - let captured = fs::read_to_string(&argv_log).expect("argv log"); - assert!( - captured.contains("--local"), - "read_local must pass --local to wrangler; got argv:\n{captured}" - ); - assert!( - !captured.contains("--remote"), - "read_local must NOT pass --remote; got argv:\n{captured}" - ); - } - - #[test] - fn read_config_entry_requires_adapter_manifest_path() { - let dir = tempdir().expect("tempdir"); - let result = CloudflareCliAdapter.read_config_entry( - dir.path(), - None, - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - "greeting", - &AdapterPushContext::new(), - ); - match result { - Err(err) => assert!( - err.contains("[adapters.cloudflare.adapter].manifest"), - "error names the missing field: {err}" - ), - Ok(_) => panic!("expected Err when adapter_manifest_path is None"), - } - } -} diff --git a/crates/edgezero-adapter-cloudflare/src/cli/mod.rs b/crates/edgezero-adapter-cloudflare/src/cli/mod.rs new file mode 100644 index 00000000..f1bad451 --- /dev/null +++ b/crates/edgezero-adapter-cloudflare/src/cli/mod.rs @@ -0,0 +1,659 @@ +#![expect( + clippy::mod_module_files, + reason = "Workspace lint policy denies BOTH `self_named_module_files` (wants `cli/mod.rs`) and `mod_module_files` (wants `cli.rs`) -- they contradict, so any file with submodules must opt out of one. This crate's cli directory uses the `cli/mod.rs` form; allow accordingly." +)] +#![expect( + clippy::arbitrary_source_item_ordering, + reason = "submodule declarations sit between the `use` block and the rest of the file's items by Rust convention; the strict-ordering lint disagrees but no human convention puts `mod` blocks AFTER trait impls" +)] + +use std::path::{Path, PathBuf}; + +use ctor::ctor; +use edgezero_adapter::cli_support::run_native_cli; +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, + TypedSecretEntry, +}; +use edgezero_adapter::scaffold::{ + register_adapter_blueprint, AdapterBlueprint, AdapterFileSpec, CommandTemplates, + DependencySpec, LoggingDefaults, ManifestSpec, ReadmeInfo, TemplateRegistration, +}; + +mod provision_cloud; +mod provision_local; +mod push_cloud; +mod run; + +static CLOUDFLARE_ADAPTER: CloudflareCliAdapter = CloudflareCliAdapter; + +static CLOUDFLARE_BLUEPRINT: AdapterBlueprint = AdapterBlueprint { + id: "cloudflare", + display_name: "Cloudflare Workers", + crate_suffix: "adapter-cloudflare", + dependency_crate: "edgezero-adapter-cloudflare", + dependency_repo_path: "crates/edgezero-adapter-cloudflare", + template_registrations: CLOUDFLARE_TEMPLATE_REGISTRATIONS, + files: CLOUDFLARE_FILE_SPECS, + extra_dirs: &["src", ".cargo"], + dependencies: CLOUDFLARE_DEPENDENCIES, + manifest: ManifestSpec { + manifest_filename: "wrangler.toml", + build_target: "wasm32-unknown-unknown", + build_profile: "release", + build_features: &["cloudflare"], + }, + commands: CommandTemplates { + build: "wrangler build --cwd {crate_dir}", + deploy: "wrangler deploy --cwd {crate_dir}", + serve: "wrangler dev --cwd {crate_dir}", + }, + logging: LoggingDefaults { + endpoint: None, + level: "info", + echo_stdout: None, + }, + readme: ReadmeInfo { + description: "{display} entrypoint.", + dev_heading: "{display} (local)", + dev_steps: &["`edgezero serve --adapter cloudflare`"], + }, + run_module: "edgezero_adapter_cloudflare", +}; + +static CLOUDFLARE_DEPENDENCIES: &[DependencySpec] = &[ + DependencySpec { + key: "dep_edgezero_core_cloudflare", + repo_crate: "crates/edgezero-core", + fallback: "edgezero-core = { git = \"https://git@github.com/stackpop/edgezero.git\", package = \"edgezero-core\", default-features = false }", + features: &[], + }, + DependencySpec { + key: "dep_edgezero_adapter_cloudflare", + repo_crate: "crates/edgezero-adapter-cloudflare", + fallback: + "edgezero-adapter-cloudflare = { git = \"https://git@github.com/stackpop/edgezero.git\", package = \"edgezero-adapter-cloudflare\", default-features = false }", + features: &[], + }, + DependencySpec { + key: "dep_edgezero_adapter_cloudflare_wasm", + repo_crate: "crates/edgezero-adapter-cloudflare", + fallback: + "edgezero-adapter-cloudflare = { git = \"https://git@github.com/stackpop/edgezero.git\", package = \"edgezero-adapter-cloudflare\", default-features = false, features = [\"cloudflare\"] }", + features: &["cloudflare"], + }, +]; + +static CLOUDFLARE_FILE_SPECS: &[AdapterFileSpec] = &[ + AdapterFileSpec { + template: "cf_Cargo_toml", + output: "Cargo.toml", + }, + AdapterFileSpec { + template: "cf_src_lib_rs", + output: "src/lib.rs", + }, + AdapterFileSpec { + template: "cf_src_main_rs", + output: "src/main.rs", + }, + AdapterFileSpec { + template: "cf_cargo_config_toml", + output: ".cargo/config.toml", + }, + AdapterFileSpec { + template: "cf_wrangler_toml", + output: "wrangler.toml", + }, +]; + +static CLOUDFLARE_TEMPLATE_REGISTRATIONS: &[TemplateRegistration] = &[ + TemplateRegistration { + name: "cf_Cargo_toml", + contents: include_str!("../templates/Cargo.toml.hbs"), + }, + TemplateRegistration { + name: "cf_src_lib_rs", + contents: include_str!("../templates/src/lib.rs.hbs"), + }, + TemplateRegistration { + name: "cf_src_main_rs", + contents: include_str!("../templates/src/main.rs.hbs"), + }, + TemplateRegistration { + name: "cf_cargo_config_toml", + contents: include_str!("../templates/.cargo/config.toml.hbs"), + }, + TemplateRegistration { + name: "cf_wrangler_toml", + contents: include_str!("../templates/wrangler.toml.hbs"), + }, +]; + +pub(super) const TARGET_TRIPLE: &str = "wasm32-unknown-unknown"; + +pub(super) const WRANGLER_INSTALL_HINT: &str = + "install the Cloudflare CLI (`npm install -g wrangler`) and try again"; + +struct CloudflareCliAdapter; + +#[expect( + clippy::missing_trait_methods, + reason = "cloudflare has no validate_app_config_keys / validate_adapter_manifest / validate_typed_secrets requirements; those three trait defaults are intentionally inherited. `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] { + &["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 + // Workers. EdgeZero stores no credentials — this is a thin + // shell-out. + AdapterAction::AuthLogin => { + run_native_cli("wrangler", &["login"], WRANGLER_INSTALL_HINT) + } + AdapterAction::AuthLogout => { + run_native_cli("wrangler", &["logout"], WRANGLER_INSTALL_HINT) + } + AdapterAction::AuthStatus => { + run_native_cli("wrangler", &["whoami"], WRANGLER_INSTALL_HINT) + } + AdapterAction::Build => run::build(args).map(|artifact| { + log::info!( + "[edgezero] Cloudflare build artifact -> {}", + artifact.display() + ); + }), + AdapterAction::Deploy => run::deploy(args), + AdapterAction::Serve => run::serve(args), + other => Err(format!("cloudflare adapter does not support {other:?}")), + } + } + + fn merged_id_kinds(&self) -> &'static [&'static str] { + // Both KV and Config back to Worker KV namespaces via the + // same `[[kv_namespaces]] binding = ` + // wrangler.toml entry. Declaring the same logical id under + // both kinds (e.g. `[stores.kv].ids = ["x"]` AND + // `[stores.config].ids = ["x"]`) resolves to a SINGLE + // underlying KV namespace at runtime — KV writes from the + // app silently clobber config-shaped entries (and vice + // versa). Provision compounds the hazard: the second + // binding would already be present from the first kind's + // `upsert_kv_namespace` and get reported as "already + // provisioned" instead of failing the collision. + // + // CLI `config validate` rejects this collision before any + // wrangler shell-out happens. + &["kv", "config"] + } + + fn name(&self) -> &'static str { + "cloudflare" + } + + fn provision( + &self, + manifest_root: &Path, + adapter_manifest_path: Option<&str>, + _component_selector: Option<&str>, + stores: &ProvisionStores<'_>, + deployed: Option<&AdapterDeployedState>, + mode: ProvisionMode, + dry_run: bool, + ) -> Result { + match mode { + ProvisionMode::Local => provision_local::provision( + manifest_root, + adapter_manifest_path, + stores, + deployed, + dry_run, + ), + ProvisionMode::Cloud => { + provision_cloud::provision(manifest_root, adapter_manifest_path, stores, dry_run) + } + // ProvisionMode is #[non_exhaustive]; a future mode variant + // gets an explicit error so we don't accidentally dispatch + // via one of the two known arms. + other => Err(format!( + "cloudflare adapter does not implement provision mode {other:?}" + )), + } + } + + 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_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(), + dev_vars_path.display() + )]; + Ok(ProvisionOutcome::from_status_lines(status_lines)) + } + + fn push_config_entries( + &self, + manifest_root: &Path, + adapter_manifest_path: Option<&str>, + _component_selector: Option<&str>, + store: &ResolvedStoreId, + entries: &[(String, String)], + _push_ctx: &AdapterPushContext<'_>, + dry_run: bool, + ) -> Result, String> { + push_cloud::write_entries( + manifest_root, + adapter_manifest_path, + store, + entries, + dry_run, + ) + } + + fn push_config_entries_local( + &self, + manifest_root: &Path, + adapter_manifest_path: Option<&str>, + _component_selector: Option<&str>, + store: &ResolvedStoreId, + entries: &[(String, String)], + _push_ctx: &AdapterPushContext<'_>, + dry_run: bool, + ) -> Result, String> { + push_cloud::write_entries_local( + manifest_root, + adapter_manifest_path, + store, + entries, + dry_run, + ) + } + + fn read_config_entry( + &self, + manifest_root: &Path, + adapter_manifest_path: Option<&str>, + _component_selector: Option<&str>, + store: &ResolvedStoreId, + key: &str, + _push_ctx: &AdapterPushContext<'_>, + ) -> Result { + push_cloud::read_wrangler_kv_key( + manifest_root, + adapter_manifest_path, + store, + key, + "--remote", + ) + } + + fn read_config_entry_local( + &self, + manifest_root: &Path, + adapter_manifest_path: Option<&str>, + _component_selector: Option<&str>, + store: &ResolvedStoreId, + key: &str, + _push_ctx: &AdapterPushContext<'_>, + ) -> Result { + push_cloud::read_wrangler_kv_key( + manifest_root, + adapter_manifest_path, + store, + key, + "--local", + ) + } + + fn single_store_kinds(&self) -> &'static [&'static str] { + //: cloudflare is Multi for KV (KV namespaces) and + // Config (KV namespaces), Single for Secrets (Worker + // Secrets is a single flat bag). + &["secrets"] + } + + 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, run::synthesise_wrangler_toml(app_name))]) + } +} + +#[inline] +pub fn register() { + register_adapter(&CLOUDFLARE_ADAPTER); + register_adapter_blueprint(&CLOUDFLARE_BLUEPRINT); +} + +#[ctor(unsafe)] +fn register_ctor() { + register(); +} + +// Shared process-wide mutex serialising PATH-mutating tests across every +// submodule test suite in this crate. Tests in `provision_local`, `provision_cloud`, +// and `push_cloud` all install shell shims via `PathPrepend` and would otherwise +// race on the environment variable. +#[cfg(all(test, unix))] +use std::sync::Mutex as PathMutationMutex; + +#[cfg(all(test, unix))] +pub(crate) fn path_mutation_guard() -> &'static PathMutationMutex<()> { + use std::sync::OnceLock; + static GUARD: OnceLock> = OnceLock::new(); + GUARD.get_or_init(|| PathMutationMutex::new(())) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::tempdir; + + // Shared fixture names. Pinning these as consts (instead of + // inline `"sessions"` / `"app_config"` per call site) keeps the + // setup-vs-assertion pair in sync -- a typo in one place no + // longer silently divorces from the other, because both reference + // the same const. Also names the intent: these are the LOGICAL + // store ids the cloudflare adapter operates on, not arbitrary + // strings. + const TEST_SECRET_ID: &str = "default"; + + // ---------- 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}" + ); + } + + /// Renamed 2026-07 (deep self-review finding P1-f): the prior + /// name (`provision_local_push_after_provision_preserves_*`) + /// promised a push→provision integration test but the body only + /// re-runs `provision_typed` twice. The real invariant this + /// locks is: re-running `provision_typed` after an operator + /// hand-edits the placeholder MUST NOT clobber the edit. That + /// is the `append_lines_dedup` contract, not the push contract. + #[test] + fn provision_typed_local_re_run_preserves_operator_edit_to_dev_vars_secret() { + // First run seeds `SECRET_KEY=""` (empty placeholder) into + // `.dev.vars`. The operator hand-edits the file to + // `SECRET_KEY="real_value_operator_set"`. A subsequent + // `provision_typed` MUST NOT overwrite the operator's value + // with the empty placeholder — append_lines_dedup collapses + // commented + uncommented forms by normalised key, so the + // uncommented real value survives byte-for-byte. + let dir = tempdir().expect("tempdir"); + let entries = [TypedSecretEntry::new( + TEST_SECRET_ID, + "api_token", + "SECRET_KEY", + )]; + CloudflareCliAdapter + .provision_typed( + dir.path(), + Some("wrangler.toml"), + None, + &entries, + ProvisionMode::Local, + false, + ) + .expect("first provision_typed writes empty placeholder"); + let dev_vars_path = dir.path().join(".dev.vars"); + let first = fs::read_to_string(&dev_vars_path).expect("read .dev.vars (first run)"); + assert!( + first.contains(r#"SECRET_KEY="""#), + "empty placeholder present after first run: {first}" + ); + // Simulate the operator's hand-edit. Rewrite just the + // SECRET_KEY line; everything else stays as provision wrote it. + let edited = first.replace( + r#"SECRET_KEY="""#, + r#"SECRET_KEY="real_value_operator_set""#, + ); + assert_ne!(edited, first, "operator edit actually mutated the file"); + fs::write(&dev_vars_path, &edited).expect("operator hand-edit"); + CloudflareCliAdapter + .provision_typed( + dir.path(), + Some("wrangler.toml"), + None, + &entries, + ProvisionMode::Local, + false, + ) + .expect("re-run provision_typed after operator edit"); + let after = fs::read_to_string(&dev_vars_path).expect("read .dev.vars (second run)"); + assert!( + after.contains(r#"SECRET_KEY="real_value_operator_set""#), + "operator's value survives byte-for-byte: {after}" + ); + assert!( + !after.contains(r#"SECRET_KEY="""#), + "empty placeholder must NOT be re-appended: {after}" + ); + // Exactly one SECRET_KEY line remains after dedup. + let key_lines = after + .lines() + .filter(|line| { + let after_hash = line.trim_start().strip_prefix('#').unwrap_or(line); + after_hash.trim_start().starts_with("SECRET_KEY=") + }) + .count(); + assert_eq!( + key_lines, 1, + "exactly one SECRET_KEY line remains after dedup: {after}" + ); + } +} diff --git a/crates/edgezero-adapter-cloudflare/src/cli/provision_cloud.rs b/crates/edgezero-adapter-cloudflare/src/cli/provision_cloud.rs new file mode 100644 index 00000000..30aec745 --- /dev/null +++ b/crates/edgezero-adapter-cloudflare/src/cli/provision_cloud.rs @@ -0,0 +1,922 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::io::ErrorKind; +use std::path::Path; +use std::process::Command; + +use edgezero_adapter::registry::{AdapterDeployedState, ProvisionOutcome, ProvisionStores}; + +use super::provision_local::{ + check_kv_namespaces_writeback_shape, existing_real_namespace_id, read_namespace_id, + upsert_kv_namespace, +}; +use super::WRANGLER_INSTALL_HINT; + +/// Cloud-mode `provision` arm: shells out to `wrangler kv namespace +/// create ` for every declared KV / config store that isn't +/// already provisioned, then writes the returned id back into +/// `wrangler.toml` via [`upsert_kv_namespace`]. Secret stores are +/// runtime-managed via `wrangler secret put` — the Cloud arm reports +/// each declared secret but performs no side effect. +pub(super) fn provision( + manifest_root: &Path, + adapter_manifest_path: Option<&str>, + stores: &ProvisionStores<'_>, + dry_run: bool, +) -> Result { + //: KV ids and config ids both back to Cloudflare KV + // namespaces. Secrets are runtime-managed via + // `wrangler secret put` — provision is a no-op for them. + let Some(rel) = adapter_manifest_path else { + return Err( + "[adapters.cloudflare.adapter].manifest must point at wrangler.toml for provision" + .to_owned(), + ); + }; + let wrangler_path = manifest_root.join(rel); + + let mut out = Vec::new(); + // 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 + // calls `env.kv(...)` with -- it's resolved at request + // time from `EDGEZERO__STORES______NAME` + // (default = logical id). Provision must write the + // resolved PLATFORM name into wrangler.toml, otherwise + // the runtime will look up a binding the CLI never + // created. + let binding = &store.platform; + // Idempotency check BEFORE shelling out: if a + // [[kv_namespaces]] entry with `binding = ` + // is already present and has a real namespace id, skip. + // Without this guard a re-run of provision would invoke + // `wrangler kv namespace create` again and orphan the + // previously-created namespace -- wasting account quota. + // A placeholder id (anything that isn't a 32-char + // lowercase hex string, like the + // `local-dev-placeholder` the scaffold wrangler.toml + // writes) is treated as "not yet provisioned" so the + // entry gets rewritten with the real id. + // + // We deliberately do NOT cross-check the stored id + // against Cloudflare's API (e.g. by calling `wrangler + // kv namespace list` to confirm the id still exists). + // Verifying every entry on every provision run would + // add a network round-trip per id and require parsing + // yet another wrangler subcommand output. The skip + // line names the existing id explicitly so the operator + // can verify it themselves and, if the Cloudflare-side + // namespace was deleted out-of-band, remove the stale + // entry by hand before re-running provision. + let existing = existing_real_namespace_id(&wrangler_path, binding)?; + if let Some(existing_id) = existing { + out.push(format!( + "binding `{binding}` (logical id `{logical}`) already provisioned (id={existing_id} in {}); skipping. To force a fresh namespace: delete the [[kv_namespaces]] entry for binding `{binding}` AND run `wrangler kv namespace delete --namespace-id={existing_id}` (the old remote namespace lingers otherwise), then re-run provision.", + wrangler_path.display() + )); + continue; + } + // Pre-flight the writeback shape BEFORE shelling + // `wrangler kv namespace create`. `read_namespace_id` + // tolerates both `[[kv_namespaces]]` (array-of-tables) + // and `kv_namespaces = [{ binding = "...", id = "..." }]` + // (inline-array) forms, but `upsert_kv_namespace` only + // writes back through the array-of-tables shape. Without + // this guard, an inline-array manifest passes the + // "already provisioned?" probe (because no id is + // present), the remote `create` succeeds, and then the + // upsert errors out — leaving the freshly-created + // namespace orphaned on Cloudflare with no local + // writeback to track it. + // + // Refuse early so the operator fixes the manifest shape + // BEFORE any account-side mutation. + check_kv_namespaces_writeback_shape(&wrangler_path)?; + if dry_run { + out.push(format!( + "would run `wrangler kv namespace create {binding}` and append [[kv_namespaces]] binding = \"{binding}\" to {} (logical id `{logical}`)", + wrangler_path.display() + )); + continue; + } + let namespace_id = create_kv_namespace(binding)?; + upsert_kv_namespace(&wrangler_path, binding, &namespace_id)?; + out.push(format!( + "created KV namespace `{binding}` (logical id `{logical}`, namespace id={namespace_id}); written to {}", + wrangler_path.display() + )); + // 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; + let platform = &store.platform; + out.push(format!( + "cloudflare secret `{platform}` (logical id `{logical}`) is runtime-managed via `wrangler secret put`; nothing to provision" + )); + } + if out.is_empty() { + out.push("cloudflare has no declared stores to provision".to_owned()); + } + // 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 created_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(match created_deployed { + Some(state) => ProvisionOutcome::with_deployed(out, state), + None => ProvisionOutcome::from_status_lines(out), + }) +} + +/// Shell out to `wrangler kv namespace create `, capture +/// stdout, and parse the resulting namespace id. The CLI's +/// `provision` command resolves this against the user's +/// `wrangler.toml` and writes the `[[kv_namespaces]]` entry. +/// +/// # Errors +/// Returns an error if `wrangler` isn't on `PATH`, the child fails +/// to spawn, the exit status is non-zero, or stdout doesn't +/// include a parseable `id = "..."` line. +fn create_kv_namespace(binding: &str) -> Result { + let output = Command::new("wrangler") + .args(["kv", "namespace", "create", binding]) + .output() + .map_err(|err| { + if err.kind() == ErrorKind::NotFound { + format!("`wrangler` not found on PATH; {WRANGLER_INSTALL_HINT}") + } else { + format!("failed to spawn `wrangler`: {err}") + } + })?; + if !output.status.success() { + return Err(format!( + "`wrangler kv namespace create {binding}` exited with status {}\nstderr: {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + )); + } + let stdout = String::from_utf8_lossy(&output.stdout); + extract_namespace_id(&stdout).ok_or_else(|| { + format!( + "wrangler created `{binding}` but stdout did not include a parseable `id = \"...\"` line -- wrangler may have changed its output format; pin a known-compatible wrangler version or file an issue. Raw stdout:\n{stdout}" + ) + }) +} + +/// Pull the namespace id out of `wrangler kv namespace create` +/// stdout. Wrangler 3+ prints (something like): +/// +/// ```text +/// 🌀 Creating namespace with title "..." +/// ✨ Success! +/// Add the following to your configuration file in your kv_namespaces array: +/// [[kv_namespaces]] +/// binding = "my-kv" +/// id = "abc123..." +/// ``` +/// +/// We tolerate leading whitespace + surrounding decoration. To +/// avoid grabbing a stray informational line like +/// `id = ""` printed somewhere else in wrangler +/// output (or a hypothetical future `id = ...` line that names a +/// non-KV resource), we anchor to the `[[kv_namespaces]]` table +/// header AND require the value to be 32-char lowercase hex +/// (Cloudflare's actual namespace-id shape). The scan walks +/// lines top-down: when we see `[[kv_namespaces]]` we set a +/// scope flag; the next `id = "<32-char-hex>"` line within that +/// scope is the result. A new top-level header resets the scope. +fn extract_namespace_id(stdout: &str) -> Option { + let mut in_kv_namespaces = false; + for line in stdout.lines() { + let trimmed = line.trim(); + if trimmed == "[[kv_namespaces]]" { + in_kv_namespaces = true; + continue; + } + // Any other table header ends the scope so we don't reach + // forward into a sibling block. + if trimmed.starts_with('[') && trimmed.ends_with(']') { + in_kv_namespaces = false; + continue; + } + if !in_kv_namespaces { + continue; + } + let Some(after_id_kw) = trimmed.strip_prefix("id") else { + continue; + }; + let Some(after_eq) = after_id_kw.trim_start().strip_prefix('=') else { + continue; + }; + let Some(quoted) = after_eq.trim_start().strip_prefix('"') else { + continue; + }; + let Some((id, _)) = quoted.split_once('"') else { + continue; + }; + if is_real_namespace_id(id) { + return Some(id.to_owned()); + } + } + None +} + +/// Heuristic: is `id` a real Cloudflare KV namespace id (32-char +/// lowercase hex), as opposed to a scaffold placeholder like +/// `local-dev-placeholder`? Cloudflare's API consistently returns +/// 32-char lowercase hex, so we use that as a tight cheap signal. +/// +/// Additionally rejects hex-shape sentinels that LOOK like real +/// ids but are obviously hand-typed placeholders: anything with +/// fewer than 6 distinct hex characters (catches all-zeros, +/// all-`a`, `deadbeefdeadbeefdeadbeefdeadbeef`, etc.). A real id +/// generated by Cloudflare's API has effectively uniform random +/// hex distribution: expected distinct chars over 32 draws from +/// 16 symbols is ~14, and the dominant term P(=5 distinct) is on +/// the order of 10^-13 -- so false rejections of real ids are +/// astronomically unlikely. +pub(super) fn is_real_namespace_id(id: &str) -> bool { + if id.len() != 32 { + return false; + } + if !id + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) + { + return false; + } + // Distinct-byte count via a BTreeSet: 32 inserts is trivial, + // and the set form avoids the arithmetic-side-effect / + // silent-as / indexing-panic shapes the project's clippy + // profile rejects. + let distinct: BTreeSet = id.bytes().collect(); + distinct.len() >= 6 +} + +/// Look up the namespace id wrangler.toml has bound to `binding`, +/// rejecting placeholder ids (anything that isn't a 32-char +/// lowercase hex Cloudflare API id). +/// +/// Accepts both `[[kv_namespaces]]` (array-of-tables, what +/// `provision` writes and wrangler's own post-create hint prints) +/// and the inline-array form. Returns Err with a "did you run +/// provision?" hint if the binding is absent OR holds a placeholder +/// like `local-dev-placeholder` — without this check `push` would +/// shell out to `wrangler kv bulk put --namespace-id=`, +/// which fails at wrangler with a less actionable error. +pub(super) fn find_namespace_id(wrangler_path: &Path, binding: &str) -> Result { + // read_namespace_id returns Ok(None) for both + // missing-file AND binding-not-present; for `find_namespace_id` + // the user wants a "did you run provision?" hint in both cases, + // so collapse them into the same error message. + let raw = read_namespace_id(wrangler_path, binding)?.ok_or_else(|| { + format!( + "{}: no [[kv_namespaces]] entry with binding = {binding:?} (did you run `edgezero provision --adapter cloudflare`?)", + wrangler_path.display() + ) + })?; + if is_real_namespace_id(&raw) { + Ok(raw) + } else { + Err(format!( + "{}: binding {binding:?} has id {raw:?}, which doesn't look like a real Cloudflare KV namespace id (expected 32-char lowercase hex). This is usually a scaffold placeholder -- run `edgezero provision --adapter cloudflare` to create a real namespace and overwrite the entry.", + wrangler_path.display() + )) + } +} + +// `create_kv_namespace` is exercised indirectly via the +// `cloudflare_cloud_provision_returns_created_namespace_ids` test +// (which installs a fake `wrangler` shim on PATH and asserts +// against the parsed namespace id). +#[cfg(test)] +mod tests { + #[cfg(unix)] + use super::super::path_mutation_guard; + use super::super::CloudflareCliAdapter; + use super::*; + use edgezero_adapter::registry::{ + Adapter as _, ProvisionMode, ProvisionStores, ResolvedStoreId, + }; + #[cfg(unix)] + use std::env; + #[cfg(unix)] + use std::ffi::OsString; + use std::fs; + use std::path::PathBuf; + use tempfile::tempdir; + + const TEST_KV_ID: &str = "sessions"; + const TEST_KV_ID_ALT: &str = "cache"; + const TEST_CONFIG_ID: &str = "app_config"; + const TEST_SECRET_ID: &str = "default"; + + #[cfg(unix)] + struct PathPrepend { + original: Option, + } + + #[cfg(unix)] + impl PathPrepend { + fn new(extra: &Path) -> Self { + let original = env::var_os("PATH"); + let new = match &original { + Some(prev) => { + let mut accum = OsString::from(extra); + accum.push(":"); + accum.push(prev); + accum + } + None => OsString::from(extra), + }; + env::set_var("PATH", new); + Self { original } + } + } + + #[cfg(unix)] + impl Drop for PathPrepend { + fn drop(&mut self) { + match self.original.take() { + Some(prev) => env::set_var("PATH", prev), + None => env::remove_var("PATH"), + } + } + } + + #[cfg(unix)] + fn fake_wrangler_returning( + stdout_body: &str, + stderr_body: &str, + exit_code: i32, + ) -> tempfile::TempDir { + use std::os::unix::fs::PermissionsExt as _; + let dir = tempdir().expect("tempdir"); + let script_path = dir.path().join("wrangler"); + let stdout_file = dir.path().join("stdout_payload.txt"); + let stderr_file = dir.path().join("stderr_payload.txt"); + fs::write(&stdout_file, stdout_body).expect("write stdout payload"); + fs::write(&stderr_file, stderr_body).expect("write stderr payload"); + let script = format!( + "#!/bin/sh\ncat '{stdout}'\ncat '{stderr}' >&2\nexit {code}\n", + stdout = stdout_file.display(), + stderr = stderr_file.display(), + code = exit_code, + ); + fs::write(&script_path, script).expect("write wrangler script"); + let mut perms = fs::metadata(&script_path).expect("meta").permissions(); + perms.set_mode(0o755); + fs::set_permissions(&script_path, perms).expect("chmod +x"); + dir + } + + fn write_wrangler(dir: &Path, contents: &str) -> PathBuf { + let path = dir.join("wrangler.toml"); + fs::write(&path, contents).expect("write wrangler.toml"); + path + } + + // ---------- extract_namespace_id ---------- + + #[test] + fn extract_namespace_id_parses_wrangler_3_output() { + // wrangler decorates these lines with unicode glyphs in real + // output; we drop them from the fixture to keep the source + // file ASCII-only (clippy::non_ascii_literal). The parser + // requires both the `[[kv_namespaces]]` anchor and a + // 32-char-lowercase-hex id. + let stdout = r#"Creating namespace with title "my-kv" +Success! +Add the following to your configuration file in your kv_namespaces array: +[[kv_namespaces]] +binding = "my-kv" +id = "00112233445566778899aabbccddeeff" +"#; + assert_eq!( + extract_namespace_id(stdout).as_deref(), + Some("00112233445566778899aabbccddeeff") + ); + } + + #[test] + fn extract_namespace_id_tolerates_extra_whitespace() { + let stdout = "[[kv_namespaces]]\n id = \"00112233445566778899aabbccddeeff\" \n"; + assert_eq!( + extract_namespace_id(stdout).as_deref(), + Some("00112233445566778899aabbccddeeff") + ); + } + + #[test] + fn extract_namespace_id_returns_none_on_missing_id_line() { + assert!(extract_namespace_id("nothing to see here").is_none()); + assert!(extract_namespace_id("").is_none()); + assert!( + extract_namespace_id("[[kv_namespaces]]\nid = \"\"").is_none(), + "empty value not a real id" + ); + } + + #[test] + fn extract_namespace_id_ignores_unrelated_lines_starting_with_id() { + // `identifier = "..."` doesn't match -- we strip exactly the + // prefix `id` then require `=`. Also doesn't match because + // there's no `[[kv_namespaces]]` anchor. + assert!(extract_namespace_id("[[kv_namespaces]]\nidentifier = \"x\"").is_none()); + } + + #[test] + fn extract_namespace_id_requires_kv_namespaces_anchor() { + // A bare `id = "<32-char-hex>"` line that isn't preceded by + // `[[kv_namespaces]]` must not match -- otherwise a future + // wrangler info line like `id = ""` printed + // somewhere else in stdout would be picked up as the + // namespace id and silently corrupt wrangler.toml on writeback. + let unanchored = "id = \"00112233445566778899aabbccddeeff\"\n"; + assert!(extract_namespace_id(unanchored).is_none()); + + // A different table header BEFORE the `id` line scopes us + // out of the kv-namespaces context. + let other_block = "[[d1_databases]]\nid = \"00112233445566778899aabbccddeeff\"\n"; + assert!(extract_namespace_id(other_block).is_none()); + } + + #[test] + fn extract_namespace_id_rejects_non_real_id_inside_kv_namespaces_anchor() { + // Even with the anchor, the value must look like a real + // Cloudflare id (32-char lowercase hex with the diversity + // floor). Shorter or non-hex values are skipped, not + // returned -- forces the operator to investigate stdout + // drift rather than silently writing a bogus id. + let stdout = "[[kv_namespaces]]\nbinding = \"my-kv\"\nid = \"abc123\"\n"; + assert!(extract_namespace_id(stdout).is_none()); + } + + #[test] + fn extract_namespace_id_returns_first_real_match_inside_kv_namespaces_anchor() { + // Pin: top-down scan, first qualifying line inside the + // `[[kv_namespaces]]` anchor wins. Real wrangler output has + // exactly one. A hypothetical future format with multiple + // qualifying lines would surface the earliest, but only + // values that look like real Cloudflare ids count. + let stdout = "[[kv_namespaces]]\n\ + id = \"00112233445566778899aabbccddeeff\"\n\ + id = \"ffeeddccbbaa99887766554433221100\"\n"; + assert_eq!( + extract_namespace_id(stdout).as_deref(), + Some("00112233445566778899aabbccddeeff") + ); + } + + // ---------- is_real_namespace_id ---------- + + #[test] + fn is_real_namespace_id_accepts_32_char_lowercase_hex_with_sufficient_diversity() { + // 16-distinct-char fixture: maximum diversity. + assert!(is_real_namespace_id("00112233445566778899aabbccddeeff")); + // Realistic randomish fixture: 14 distinct chars. + assert!(is_real_namespace_id("4a8f3c2b9e1d5670adef2839c4b6e1f0")); + } + + #[test] + fn is_real_namespace_id_rejects_placeholder_or_short_id() { + assert!(!is_real_namespace_id("local-dev-placeholder")); + assert!(!is_real_namespace_id("abc123")); + assert!(!is_real_namespace_id("")); + } + + #[test] + fn is_real_namespace_id_rejects_uppercase_or_non_hex() { + // Uppercase rejected: Cloudflare's API returns lowercase. + assert!(!is_real_namespace_id("00112233445566778899AABBCCDDEEFF")); + // Non-hex digits rejected. + assert!(!is_real_namespace_id("z0112233445566778899aabbccddeeff")); + } + + #[test] + fn is_real_namespace_id_rejects_hex_shape_sentinels() { + // 32-char lowercase hex but obvious hand-typed placeholder: + // distinct-hex-digit count is below the diversity floor. + // Real Cloudflare ids have effectively uniform random hex, + // so collisions with this guard are astronomical. + assert!( + !is_real_namespace_id("00000000000000000000000000000000"), + "all-zeros rejected" + ); + assert!( + !is_real_namespace_id("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), + "all-a rejected" + ); + assert!( + !is_real_namespace_id("deadbeefdeadbeefdeadbeefdeadbeef"), + "deadbeef rejected (only 5 distinct chars: d,e,a,b,f)" + ); + // Boundary: a real-looking id with the diversity floor or + // more must still pass. + assert!( + is_real_namespace_id("00112233445566778899aabbccddeeff"), + "16-distinct-char fixture must still pass" + ); + // Exactly 6 distinct chars (a,b,c,d,e,f): on the boundary, + // must pass. + assert!( + is_real_namespace_id("aabbccddeeffaabbccddeeffaabbccdd"), + "6-distinct-char fixture (boundary) passes" + ); + } + + // ---------- provision (dry-run + error path) ---------- + + #[test] + fn provision_dry_run_does_not_invoke_wrangler() { + let dir = tempdir().expect("tempdir"); + write_wrangler(dir.path(), "name = \"demo\"\n"); + let kv_ids: Vec = + ResolvedStoreId::from_logicals(&[TEST_KV_ID, TEST_KV_ID_ALT]); + let config_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_CONFIG_ID]); + let secret_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_SECRET_ID]); + let stores = ProvisionStores { + config: &config_ids, + kv: &kv_ids, + secrets: &secret_ids, + }; + let out = CloudflareCliAdapter + .provision( + dir.path(), + Some("wrangler.toml"), + None, + &stores, + None, + ProvisionMode::Cloud, + true, + ) + .expect("dry-run succeeds"); + // 2 KV + 1 config + 1 secret = 4 status lines. + 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"); + } + + #[test] + fn provision_dry_run_writes_resolved_platform_name_into_binding() { + // Regression: provision used to receive only logical ids + // and write them verbatim into wrangler.toml. With the + // platform-name flow, an operator who sets + // `EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME=prod_config` + // sees `prod_config` land as the binding name (matching what + // the runtime resolves via `env.kv(...)`), with the logical + // id still mentioned for human-facing wording. + let dir = tempdir().expect("tempdir"); + write_wrangler(dir.path(), "name = \"demo\"\n"); + let config_ids = vec![ResolvedStoreId::new(TEST_CONFIG_ID, "prod_config")]; + let stores = ProvisionStores { + config: &config_ids, + kv: &[], + secrets: &[], + }; + let out = CloudflareCliAdapter + .provision( + dir.path(), + Some("wrangler.toml"), + None, + &stores, + None, + ProvisionMode::Cloud, + true, + ) + .expect("dry-run succeeds"); + assert_eq!(out.status_lines.len(), 1); + assert!( + out.status_lines[0].contains("wrangler kv namespace create prod_config"), + "dry-run uses platform name in the `wrangler` invocation: {out:?}" + ); + assert!( + out.status_lines[0].contains("binding = \"prod_config\""), + "dry-run writes platform name as the binding: {out:?}" + ); + assert!( + out.status_lines[0].contains("logical id `app_config`"), + "logical id is preserved for operator wording: {out:?}" + ); + } + + #[test] + fn provision_errors_when_adapter_manifest_path_missing() { + let dir = tempdir().expect("tempdir"); + let kv_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_KV_ID]); + let stores = ProvisionStores { + config: &[], + kv: &kv_ids, + secrets: &[], + }; + let err = CloudflareCliAdapter + .provision( + dir.path(), + None, + None, + &stores, + None, + ProvisionMode::Cloud, + true, + ) + .expect_err("missing adapter manifest path must error"); + assert!( + err.contains("wrangler.toml"), + "error names what's missing: {err}" + ); + } + + #[test] + fn provision_dry_run_skips_bindings_already_provisioned_with_real_id() { + let dir = tempdir().expect("tempdir"); + // 32-char lowercase hex id == real Cloudflare namespace id. + let path = write_wrangler( + dir.path(), + "name = \"demo\"\n[[kv_namespaces]]\nbinding = \"sessions\"\nid = \"00112233445566778899aabbccddeeff\"\n", + ); + let kv_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_KV_ID]); + let stores = ProvisionStores { + config: &[], + kv: &kv_ids, + secrets: &[], + }; + let out = CloudflareCliAdapter + .provision( + dir.path(), + Some("wrangler.toml"), + None, + &stores, + None, + ProvisionMode::Cloud, + true, + ) + .expect("dry-run succeeds"); + assert_eq!(out.status_lines.len(), 1); + assert!( + 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"); + assert!( + after.contains("00112233445566778899aabbccddeeff"), + "did not touch existing id: {after}" + ); + } + + #[test] + fn provision_dry_run_treats_placeholder_id_as_unprovisioned() { + // A scaffolded wrangler.toml ships with placeholder ids the + // user is expected to overwrite by running provision. + // Dry-run should report the would-be create call, NOT the + // already-provisioned skip. + let dir = tempdir().expect("tempdir"); + write_wrangler( + dir.path(), + "name = \"demo\"\n[[kv_namespaces]]\nbinding = \"sessions\"\nid = \"local-dev-placeholder\"\n", + ); + let kv_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_KV_ID]); + let stores = ProvisionStores { + config: &[], + kv: &kv_ids, + secrets: &[], + }; + let out = CloudflareCliAdapter + .provision( + dir.path(), + Some("wrangler.toml"), + None, + &stores, + None, + ProvisionMode::Cloud, + true, + ) + .expect("dry-run succeeds"); + assert_eq!(out.status_lines.len(), 1); + assert!( + out.status_lines[0].contains("would run `wrangler kv namespace create sessions`"), + "placeholder id is treated as unprovisioned: {out:?}" + ); + } + + #[test] + fn provision_with_no_declared_stores_says_so() { + let dir = tempdir().expect("tempdir"); + write_wrangler(dir.path(), "name = \"demo\"\n"); + let stores = ProvisionStores { + config: &[], + kv: &[], + secrets: &[], + }; + let out = CloudflareCliAdapter + .provision( + dir.path(), + Some("wrangler.toml"), + None, + &stores, + None, + ProvisionMode::Cloud, + false, + ) + .expect("no-store provision is fine"); + assert_eq!( + 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 ---------- + + #[test] + fn find_namespace_id_reads_array_of_tables() { + let dir = tempdir().expect("tempdir"); + let path = write_wrangler( + dir.path(), + "name = \"demo\"\n[[kv_namespaces]]\nbinding = \"app_config\"\nid = \"00112233445566778899aabbccddeeff\"\n", + ); + let id = find_namespace_id(&path, TEST_CONFIG_ID).expect("found"); + assert_eq!(id, "00112233445566778899aabbccddeeff"); + } + + #[test] + fn find_namespace_id_reads_inline_array() { + let dir = tempdir().expect("tempdir"); + let path = write_wrangler( + dir.path(), + "name = \"demo\"\nkv_namespaces = [{ binding = \"app_config\", id = \"ffeeddccbbaa99887766554433221100\" }]\n", + ); + let id = find_namespace_id(&path, TEST_CONFIG_ID).expect("found"); + assert_eq!(id, "ffeeddccbbaa99887766554433221100"); + } + + #[test] + fn find_namespace_id_errors_with_provision_hint_when_binding_absent() { + let dir = tempdir().expect("tempdir"); + let path = write_wrangler( + dir.path(), + "name = \"demo\"\n[[kv_namespaces]]\nbinding = \"other\"\nid = \"00112233445566778899aabbccddeeff\"\n", + ); + let err = find_namespace_id(&path, TEST_CONFIG_ID).expect_err("missing must error"); + assert!( + err.contains(TEST_CONFIG_ID) && err.contains("provision"), + "error names the binding and points at provision: {err}" + ); + } + + #[test] + fn find_namespace_id_rejects_placeholder_id_with_provision_hint() { + // A binding with `id = "local-dev-placeholder"` (or any + // other non-32-char-hex value) is treated the same as + // a missing binding: the operator needs to run provision + // before the id is usable for `wrangler kv bulk put`. + // Without this guard, push would shell out with the + // placeholder as `--namespace-id=...` and fail at wrangler + // with a less actionable error. + let dir = tempdir().expect("tempdir"); + let path = write_wrangler( + dir.path(), + "name = \"demo\"\n[[kv_namespaces]]\nbinding = \"app_config\"\nid = \"local-dev-placeholder\"\n", + ); + let err = + find_namespace_id(&path, TEST_CONFIG_ID).expect_err("placeholder id must be rejected"); + assert!( + err.contains("local-dev-placeholder") && err.contains("provision"), + "error names the placeholder and points at provision: {err}" + ); + } + + #[test] + fn find_namespace_id_errors_with_provision_hint_when_file_missing() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("does-not-exist.toml"); + let err = + find_namespace_id(&path, TEST_CONFIG_ID).expect_err("missing wrangler.toml must error"); + assert!( + err.contains("provision"), + "error points at provision: {err}" + ); + } +} diff --git a/crates/edgezero-adapter-cloudflare/src/cli/provision_local.rs b/crates/edgezero-adapter-cloudflare/src/cli/provision_local.rs new file mode 100644 index 00000000..ea7d5137 --- /dev/null +++ b/crates/edgezero-adapter-cloudflare/src/cli/provision_local.rs @@ -0,0 +1,1380 @@ +use std::fs; +use std::io::ErrorKind; +use std::path::Path; + +use edgezero_adapter::env_file::{append_lines_dedup_with_header, EDGEZERO_PROVISION_HEADER}; +use edgezero_adapter::registry::{AdapterDeployedState, ProvisionOutcome, ProvisionStores}; + +use super::provision_cloud::is_real_namespace_id; + +/// If `path` already declares a `[[kv_namespaces]]` entry with +/// `binding = binding` AND its `id` looks like a real Cloudflare +/// namespace id, return that id. Returns `Ok(None)` if the binding +/// is absent OR present with a placeholder id (so provision can +/// treat both cases as "needs (re-)create"). A failure to read / +/// parse the file is a hard error -- provision needs an authoritative +/// answer. +pub(super) fn existing_real_namespace_id( + path: &Path, + binding: &str, +) -> Result, String> { + let Some(existing) = read_namespace_id(path, binding)? else { + return Ok(None); + }; + if is_real_namespace_id(&existing) { + Ok(Some(existing)) + } else { + Ok(None) + } +} + +/// Internal: look up `binding`'s `id` in `wrangler.toml` without +/// the "did you run provision?" error path that `find_namespace_id` +/// adds. Missing file -> `Ok(None)`. Returns the raw id whether or +/// not it looks like a real Cloudflare id. +/// +/// Errors loudly if `kv_namespaces` exists but is neither an +/// array-of-tables nor an inline-array (e.g. the operator typed +/// `kv_namespaces = "oops"`). Silently returning `None` there +/// surfaces downstream as "did you run provision?" -- misleading, +/// because the actual problem is a malformed manifest. +pub(super) fn read_namespace_id(path: &Path, binding: &str) -> Result, String> { + use toml_edit::{DocumentMut, Item, Value}; + + let raw = match fs::read_to_string(path) { + Ok(raw) => raw, + Err(err) if err.kind() == ErrorKind::NotFound => return Ok(None), + Err(err) => return Err(format!("failed to read {}: {err}", path.display())), + }; + let doc: DocumentMut = raw + .parse() + .map_err(|err| format!("failed to parse {}: {err}", path.display()))?; + let id = match doc.get("kv_namespaces") { + Some(Item::ArrayOfTables(arr)) => arr.iter().find_map(|table| { + if table.get("binding").and_then(Item::as_str) == Some(binding) { + table.get("id").and_then(Item::as_str).map(str::to_owned) + } else { + None + } + }), + Some(Item::Value(Value::Array(arr))) => arr.iter().find_map(|item| { + let table = item.as_inline_table()?; + if table.get("binding").and_then(Value::as_str) == Some(binding) { + table.get("id").and_then(Value::as_str).map(str::to_owned) + } else { + None + } + }), + Some(other) => { + return Err(format!( + "{}: `kv_namespaces` exists but is neither `[[kv_namespaces]]` (array-of-tables) nor an inline array of `{{ binding, id }}` records; got TOML item of type `{}`", + path.display(), + item_kind(other) + )); + } + None => None, + }; + Ok(id) +} + +/// Refuse to provision a new namespace when `wrangler.toml`'s +/// `kv_namespaces` exists in a form that `upsert_kv_namespace` +/// can't write back to. Today that means the inline-array form +/// (`kv_namespaces = [{ binding = "...", id = "..." }]`), which +/// `read_namespace_id` tolerates but `upsert_kv_namespace`'s +/// `as_array_of_tables_mut()` rejects. Without this guard, the +/// orphan-namespace hazard documented in `upsert_kv_namespace` +/// reappears: `wrangler kv namespace create` succeeds, then +/// upsert errors out and the new namespace lingers on +/// Cloudflare with no local writeback to track it. Missing or +/// array-of-tables forms are OK. +pub(super) fn check_kv_namespaces_writeback_shape(path: &Path) -> Result<(), String> { + use toml_edit::{DocumentMut, Item, Value}; + + let raw = match fs::read_to_string(path) { + Ok(text) => text, + Err(err) if err.kind() == ErrorKind::NotFound => return Ok(()), + Err(err) => return Err(format!("failed to read {}: {err}", path.display())), + }; + let doc: DocumentMut = raw + .parse() + .map_err(|err| format!("failed to parse {}: {err}", path.display()))?; + match doc.get("kv_namespaces") { + None | Some(Item::ArrayOfTables(_)) => Ok(()), + Some(Item::Value(Value::Array(_))) => Err(format!( + "{}: `kv_namespaces` is declared as an inline array (`kv_namespaces = [{{ binding = \"...\", id = \"...\" }}]`); provision can only write back through the `[[kv_namespaces]]` array-of-tables form. Convert each entry to a `[[kv_namespaces]]` block BEFORE re-running provision; otherwise a successful `wrangler kv namespace create` would leave the new namespace orphaned on Cloudflare with no local entry to track it.", + path.display() + )), + Some(other) => Err(format!( + "{}: `kv_namespaces` exists but is neither `[[kv_namespaces]]` (array-of-tables) nor an inline array of `{{ binding, id }}` records; got TOML item of type `{}`. Convert it manually before re-running provision.", + path.display(), + item_kind(other) + )), + } +} + +/// One-line label for a `toml_edit::Item` (for diagnostic +/// messages -- not a canonical TOML type description). +fn item_kind(item: &toml_edit::Item) -> &'static str { + use toml_edit::{Item, Value}; + match item { + Item::None => "none", + Item::Value(Value::String(_)) => "string", + Item::Value(Value::Integer(_)) => "integer", + Item::Value(Value::Float(_)) => "float", + Item::Value(Value::Boolean(_)) => "boolean", + Item::Value(Value::Datetime(_)) => "datetime", + Item::Value(Value::Array(_)) => "array", + Item::Value(Value::InlineTable(_)) => "inline-table", + Item::Table(_) => "table", + Item::ArrayOfTables(_) => "array-of-tables", + } +} + +/// Insert OR update the `[[kv_namespaces]]` entry for `binding`, +/// rewriting `id` if the binding already exists (e.g. provision +/// is replacing a `local-dev-placeholder`). Used by provision so +/// re-running on a scaffolded wrangler.toml replaces the placeholder +/// with the real id instead of silently skipping. +/// +/// Caveat: `toml_edit::Table::insert` replaces the value's `Item`, +/// which drops any trailing inline comment that was attached to +/// the prior `id = "..."` line (e.g. `id = "old" # delete me`). +/// Sibling fields under the same `[[kv_namespaces]]` table are +/// preserved verbatim -- only the `id` line's decor is lost. +/// +/// Concurrency: provision is NOT safe to run concurrently against +/// the same `wrangler.toml`. Two concurrent runs may both miss the +/// idempotency check, both call `wrangler kv namespace create` +/// remotely, then race the file write -- the loser's namespace +/// becomes an orphan in the Cloudflare account. `EdgeZero` does not +/// take a lockfile; operators must serialise provision themselves. +pub(super) fn upsert_kv_namespace(path: &Path, binding: &str, id: &str) -> Result<(), String> { + use toml_edit::{value, ArrayOfTables, DocumentMut, Item, Table}; + + // Treat NotFound as "start with empty document" symmetrically with + // `read_namespace_id` so the orphan-namespace hazard goes away: if + // wrangler.toml is missing entirely (e.g. operator deleted it + // between scaffold and provision), the upsert that follows a + // successful `wrangler kv namespace create` would otherwise error + // out, leaving the remote namespace orphaned. + let raw = match fs::read_to_string(path) { + Ok(text) => text, + Err(err) if err.kind() == ErrorKind::NotFound => String::new(), + Err(err) => return Err(format!("failed to read {}: {err}", path.display())), + }; + let mut doc: DocumentMut = raw + .parse() + .map_err(|err| format!("failed to parse {}: {err}", path.display()))?; + + let entry = doc + .entry("kv_namespaces") + .or_insert_with(|| Item::ArrayOfTables(ArrayOfTables::new())); + let arr_of_tables = entry.as_array_of_tables_mut().ok_or_else(|| { + format!( + "{}: `kv_namespaces` exists but is not an array-of-tables (`[[kv_namespaces]]`); convert it manually before re-running provision", + path.display() + ) + })?; + + let existing_idx = arr_of_tables + .iter() + .position(|table| table.get("binding").and_then(Item::as_str) == Some(binding)); + if let Some(idx) = existing_idx { + if let Some(existing) = arr_of_tables.get_mut(idx) { + existing.insert("id", value(id)); + } + } else { + let mut new_table = Table::new(); + new_table.insert("binding", value(binding)); + new_table.insert("id", value(id)); + arr_of_tables.push(new_table); + } + + fs::write(path, doc.to_string()) + .map_err(|err| format!("failed to write {}: {err}", path.display()))?; + Ok(()) +} + +/// 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, 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. +pub(super) fn provision( + 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()))?; + } + + // `.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_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(), + dev_vars_path.display() + )); + + Ok(ProvisionOutcome::from_status_lines(status_lines)) +} + +/// Build the `.dev.vars` line set emitted by [`provision`]. +/// +/// 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="{logical}_staging""# + )); + } + 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`]. +/// +/// 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) +} + +#[cfg(test)] +mod tests { + #[cfg(unix)] + use super::super::path_mutation_guard; + use super::super::run::synthesise_wrangler_toml; + use super::super::CloudflareCliAdapter; + use super::*; + use edgezero_adapter::env_file::EDGEZERO_PROVISION_HEADER; + use edgezero_adapter::registry::{ + Adapter as _, AdapterDeployedState, ProvisionMode, ProvisionStores, ResolvedStoreId, + }; + use std::collections::BTreeMap; + #[cfg(unix)] + use std::env; + #[cfg(unix)] + use std::ffi::OsString; + use std::path::PathBuf; + use tempfile::tempdir; + + const TEST_KV_ID: &str = "sessions"; + const TEST_CONFIG_ID: &str = "app_config"; + const TEST_SECRET_ID: &str = "default"; + + #[cfg(unix)] + struct PathPrepend { + original: Option, + } + + #[cfg(unix)] + impl PathPrepend { + fn new(extra: &Path) -> Self { + let original = env::var_os("PATH"); + let new = match &original { + Some(prev) => { + let mut accum = OsString::from(extra); + accum.push(":"); + accum.push(prev); + accum + } + None => OsString::from(extra), + }; + env::set_var("PATH", new); + Self { original } + } + } + + #[cfg(unix)] + impl Drop for PathPrepend { + fn drop(&mut self) { + match self.original.take() { + Some(prev) => env::set_var("PATH", prev), + None => env::remove_var("PATH"), + } + } + } + + /// A wrangler shim that fails loudly if invoked. Used by + /// `provision_local_zero_cloud_calls` to prove local-mode + /// provision never shells out to the real Cloudflare CLI: + /// if provision returns `Ok(_)` with THIS script on PATH, + /// the shim was NEVER called. + #[cfg(unix)] + fn fake_wrangler_panicking() -> tempfile::TempDir { + use std::os::unix::fs::PermissionsExt as _; + let dir = tempdir().expect("tempdir"); + let script_path = dir.path().join("wrangler"); + fs::write( + &script_path, + "#!/bin/sh\necho 'wrangler was called during local provision' >&2\nexit 42\n", + ) + .expect("write fake wrangler"); + let mut perms = fs::metadata(&script_path).expect("meta").permissions(); + perms.set_mode(0o755); + fs::set_permissions(&script_path, perms).expect("chmod +x"); + dir + } + + fn write_wrangler(dir: &Path, contents: &str) -> PathBuf { + let path = dir.join("wrangler.toml"); + fs::write(&path, contents).expect("write wrangler.toml"); + path + } + + /// 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 + } + + // ---------- read_namespace_id ---------- + + #[test] + fn read_namespace_id_errors_when_kv_namespaces_is_non_array_value() { + // `kv_namespaces = "oops"` is a malformed manifest. Silently + // returning None there bubbles up as "did you run provision?" + // -- a misleading error. The right surface is "manifest + // doesn't match the expected shape". + let dir = tempdir().expect("tempdir"); + let path = write_wrangler(dir.path(), "name = \"demo\"\nkv_namespaces = \"oops\"\n"); + let err = read_namespace_id(&path, TEST_CONFIG_ID) + .expect_err("non-array kv_namespaces must error"); + assert!( + err.contains("array-of-tables") || err.contains("inline array"), + "error names the expected shapes: {err}" + ); + assert!( + err.contains("string"), + "error names the offending kind: {err}" + ); + } + + // ---------- upsert_kv_namespace ---------- + + #[test] + fn upsert_kv_namespace_replaces_placeholder_id_for_existing_binding() { + let dir = tempdir().expect("tempdir"); + let path = write_wrangler( + dir.path(), + "[[kv_namespaces]]\nbinding = \"sessions\"\nid = \"local-dev-placeholder\"\n", + ); + upsert_kv_namespace(&path, TEST_KV_ID, "00112233445566778899aabbccddeeff").expect("upsert"); + let after = fs::read_to_string(&path).expect("read"); + assert!( + after.contains("id = \"00112233445566778899aabbccddeeff\""), + "placeholder replaced: {after}" + ); + assert!( + !after.contains("local-dev-placeholder"), + "placeholder removed: {after}" + ); + assert_eq!( + after.matches("binding = \"sessions\"").count(), + 1, + "no duplicate binding: {after}" + ); + } + + #[test] + fn upsert_kv_namespace_appends_when_binding_absent() { + let dir = tempdir().expect("tempdir"); + let path = write_wrangler(dir.path(), "name = \"demo\"\n"); + upsert_kv_namespace(&path, TEST_KV_ID, "00112233445566778899aabbccddeeff").expect("upsert"); + let after = fs::read_to_string(&path).expect("read"); + assert!( + after.contains("binding = \"sessions\"") + && after.contains("id = \"00112233445566778899aabbccddeeff\""), + "appended new entry: {after}" + ); + assert!( + after.contains("name = \"demo\""), + "preserved original keys: {after}" + ); + } + + #[test] + fn upsert_kv_namespace_appends_next_to_existing_entries() { + let dir = tempdir().expect("tempdir"); + let path = write_wrangler( + dir.path(), + "[[kv_namespaces]]\nbinding = \"cache\"\nid = \"old\"\n", + ); + upsert_kv_namespace(&path, TEST_KV_ID, "00112233445566778899aabbccddeeff").expect("upsert"); + let after = fs::read_to_string(&path).expect("read"); + assert!( + after.contains("binding = \"cache\"") && after.contains("id = \"old\""), + "existing entry kept: {after}" + ); + assert!( + after.contains("binding = \"sessions\""), + "new entry added: {after}" + ); + assert_eq!( + after.matches("[[kv_namespaces]]").count(), + 2, + "two entries: {after}" + ); + } + + #[test] + fn upsert_kv_namespace_preserves_top_comments() { + let dir = tempdir().expect("tempdir"); + let path = write_wrangler( + dir.path(), + "# managed by hand -- please keep this line\nname = \"my-worker\"\n", + ); + upsert_kv_namespace(&path, TEST_KV_ID, "00112233445566778899aabbccddeeff").expect("upsert"); + let after = fs::read_to_string(&path).expect("read"); + assert!( + after.contains("# managed by hand"), + "preserved comment: {after}" + ); + } + + #[test] + fn upsert_kv_namespace_preserves_sibling_fields_on_existing_entry() { + // toml_edit replaces only the `id` Item when we update it; + // sibling fields on the same `[[kv_namespaces]]` table + // (e.g. `preview_id`, custom annotations the user added) + // must survive the rewrite. Pinning this so a future + // toml_edit upgrade or a refactor can't silently drop + // operator data. + let dir = tempdir().expect("tempdir"); + let path = write_wrangler( + dir.path(), + "[[kv_namespaces]]\nbinding = \"sessions\"\nid = \"local-dev-placeholder\"\npreview_id = \"local-preview\"\ndescription = \"hand-added by ops\"\n", + ); + upsert_kv_namespace(&path, TEST_KV_ID, "00112233445566778899aabbccddeeff").expect("upsert"); + let after = fs::read_to_string(&path).expect("read"); + assert!( + after.contains("id = \"00112233445566778899aabbccddeeff\""), + "id rewritten: {after}" + ); + assert!( + after.contains("preview_id = \"local-preview\""), + "preserved preview_id: {after}" + ); + assert!( + after.contains("description = \"hand-added by ops\""), + "preserved description: {after}" + ); + } + + #[test] + fn upsert_kv_namespace_creates_file_when_wrangler_toml_missing() { + // Orphan-namespace hazard: if `wrangler kv namespace create` + // succeeds but wrangler.toml is missing at writeback time, + // erroring here would leave the remote namespace orphaned + // with no local reference. Symmetric with read_namespace_id's + // NotFound -> Ok(None) behaviour: upsert treats NotFound as + // "start with empty document" and writes the entry. + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("missing.toml"); + assert!(!path.exists(), "precondition: file must not exist"); + upsert_kv_namespace(&path, TEST_KV_ID, "00112233445566778899aabbccddeeff") + .expect("missing file is permissive"); + let after = fs::read_to_string(&path).expect("file now exists"); + assert!( + after.contains("binding = \"sessions\""), + "created file with new entry: {after}" + ); + assert!( + after.contains("id = \"00112233445566778899aabbccddeeff\""), + "id written: {after}" + ); + } + + // ---------- writeback shape pre-check ---------- + + #[test] + fn check_kv_namespaces_writeback_shape_ok_when_file_missing() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("missing.toml"); + check_kv_namespaces_writeback_shape(&path) + .expect("missing file is permissive (upsert creates it)"); + } + + #[test] + fn check_kv_namespaces_writeback_shape_ok_when_kv_namespaces_absent() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("wrangler.toml"); + fs::write(&path, "name = \"demo\"\n").expect("write wrangler.toml"); + check_kv_namespaces_writeback_shape(&path).expect("no kv_namespaces => OK"); + } + + #[test] + fn check_kv_namespaces_writeback_shape_ok_when_array_of_tables() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("wrangler.toml"); + fs::write( + &path, + "name = \"demo\"\n[[kv_namespaces]]\nbinding = \"sessions\"\nid = \"local-dev-placeholder\"\n", + ) + .expect("write wrangler.toml"); + check_kv_namespaces_writeback_shape(&path) + .expect("[[kv_namespaces]] is the writeback-supported shape"); + } + + #[test] + fn check_kv_namespaces_writeback_shape_rejects_inline_array_with_actionable_message() { + // Regression for the orphan-namespace hazard: pre-fix, a + // `kv_namespaces = [{ binding = "sessions" }]` manifest (no + // id present) made `read_namespace_id` return None ("not yet + // provisioned") so provision shelled `wrangler kv namespace + // create` successfully, then `upsert_kv_namespace`'s + // `as_array_of_tables_mut()` returned None and the upsert + // errored — leaving the freshly-created namespace orphaned + // on Cloudflare. The pre-flight rejects the inline-array + // shape BEFORE any account-side call. + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("wrangler.toml"); + fs::write( + &path, + "name = \"demo\"\nkv_namespaces = [{ binding = \"sessions\" }]\n", + ) + .expect("write wrangler.toml"); + let err = check_kv_namespaces_writeback_shape(&path) + .expect_err("inline-array form must be rejected before provision shells out"); + assert!( + err.contains("inline array") + && err.contains("[[kv_namespaces]]") + && err.contains("orphaned"), + "error must name the inline-array form, the supported [[kv_namespaces]] form, AND the orphan hazard so the operator knows what's at stake: {err}" + ); + } + + // ---------- provision (Local mode) ---------- + + #[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}" + ); + } + + // ---------- 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="app_config_staging""#), + "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(r#"# EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY="app_config_staging""#), + "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}" + ); + } + + // ---------- provision_local_ contract suite (spec §"Per-adapter test contract") ---------- + + #[test] + fn provision_local_first_run_writes_expected_files() { + // First-run fixture: empty crate dir, no wrangler.toml, no + // .dev.vars. The CLI's bootstrap layer (Task 8b's + // `write_baseline_to_disk`) normally primes wrangler.toml via + // `synthesise_baseline_manifest` BEFORE provision runs; this + // test mirrors that step directly, then calls + // `provision(Local)` on the seed. + // + // Contract: `wrangler.toml` lands at the resolved path; + // `.dev.vars` lands next to it; BOTH files carry the + // `# edgezero-provision: v1` schema header (Section 5 review + // fix); wrangler.toml has a `[[kv_namespaces]]` entry bound to + // `sessions`; `.dev.vars` has the __NAME overlay line. + let dir = tempdir().expect("tempdir"); + let wrangler_path = dir.path().join("wrangler.toml"); + fs::write(&wrangler_path, synthesise_wrangler_toml("demo")) + .expect("bootstrap wrangler.toml"); + 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("first-run local provision succeeds"); + assert!( + wrangler_path.exists(), + "wrangler.toml exists at resolved path" + ); + let dev_vars_path = dir.path().join(".dev.vars"); + assert!( + dev_vars_path.exists(), + ".dev.vars lands next to wrangler.toml: {}", + dev_vars_path.display() + ); + let wrangler = fs::read_to_string(&wrangler_path).expect("read wrangler.toml"); + assert!( + wrangler.starts_with(EDGEZERO_PROVISION_HEADER), + "wrangler.toml starts with schema header: {wrangler}" + ); + assert!( + wrangler.contains("[[kv_namespaces]]"), + "wrangler.toml has [[kv_namespaces]]: {wrangler}" + ); + assert!( + wrangler.contains("binding = \"sessions\""), + "wrangler.toml binds `sessions`: {wrangler}" + ); + let dev_vars = fs::read_to_string(&dev_vars_path).expect("read .dev.vars"); + assert!( + dev_vars.starts_with(EDGEZERO_PROVISION_HEADER), + ".dev.vars starts with schema header: {dev_vars}" + ); + assert!( + dev_vars.contains(r#"EDGEZERO__STORES__KV__SESSIONS__NAME="sessions""#), + ".dev.vars carries the __NAME overlay: {dev_vars}" + ); + } + + /// Locks the header-preservation contract for the case the sibling + /// first-run test misses. The seeded fixture there uses + /// `synthesise_wrangler_toml("demo")` which ALREADY carries the + /// header at line 1 -- a merge bug that stripped the header on + /// re-serialisation would pass `starts_with(EDGEZERO_PROVISION_HEADER)` + /// only because the seed matched, not because provision preserved it. + /// This test starts from a wrangler.toml with the schema header at + /// line 1 AND a couple of operator-added TOML lines, runs provision, + /// and asserts the header STILL sits at line 1 on the output. + #[test] + fn provision_local_preserves_schema_header_at_line_1_after_merge() { + let dir = tempdir().expect("tempdir"); + let wrangler_path = dir.path().join("wrangler.toml"); + // Seed matches the synthesiser shape, then adds an operator's + // `main =` line below the header. If provision's toml_edit + // round-trip re-orders root decor or drops the leading comment, + // the header slides down. + fs::write( + &wrangler_path, + "# edgezero-provision: v1\nname = \"demo\"\ncompatibility_date = \"2024-01-01\"\nmain = \"src/index.ts\"\n", + ) + .expect("seed wrangler.toml"); + + 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("provision must succeed on a seeded wrangler.toml with operator edits"); + + let wrangler = fs::read_to_string(&wrangler_path).expect("read wrangler.toml"); + let first_line = wrangler.lines().next().unwrap_or_default(); + assert_eq!( + first_line, "# edgezero-provision: v1", + "schema header must sit at line 1 after merge (bare `.starts_with(...)` masks a merge bug that slides the header down): {wrangler}" + ); + // Operator's line still present. + assert!( + wrangler.contains("main = \"src/index.ts\""), + "operator's `main` key must survive the merge: {wrangler}" + ); + } + + #[test] + fn provision_local_re_provision_is_byte_identical() { + // Re-running provision on an already-provisioned fixture must + // produce byte-identical wrangler.toml and .dev.vars — the + // second run is a no-op at the file level. Any drift here + // (rewriting a differently-formatted TOML, re-appending the + // header, appending a duplicate __NAME line) would surface as + // a byte mismatch. + let dir = tempdir().expect("tempdir"); + let wrangler_path = dir.path().join("wrangler.toml"); + fs::write(&wrangler_path, synthesise_wrangler_toml("demo")) + .expect("bootstrap wrangler.toml"); + 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("first local provision succeeds"); + let dev_vars_path = dir.path().join(".dev.vars"); + let wrangler_first = fs::read(&wrangler_path).expect("read wrangler.toml (first run)"); + let dev_vars_first = fs::read(&dev_vars_path).expect("read .dev.vars (first run)"); + CloudflareCliAdapter + .provision( + dir.path(), + Some("wrangler.toml"), + None, + &stores, + None, + ProvisionMode::Local, + false, + ) + .expect("second local provision succeeds"); + let wrangler_second = fs::read(&wrangler_path).expect("read wrangler.toml (second run)"); + let dev_vars_second = fs::read(&dev_vars_path).expect("read .dev.vars (second run)"); + assert_eq!( + wrangler_first, wrangler_second, + "wrangler.toml must be byte-identical across two provision runs" + ); + assert_eq!( + dev_vars_first, dev_vars_second, + ".dev.vars must be byte-identical across two provision runs" + ); + } + + #[cfg(unix)] + #[test] + fn provision_local_zero_cloud_calls() { + // Install a panicking `wrangler` shim on PATH: if ever + // invoked, it prints to stderr and exits 42, which surfaces + // as an `Err` out of any `Command::new("wrangler").output()` + // caller. `provision(Local)` MUST NOT shell out — it operates + // purely on local files (wrangler.toml + .dev.vars). A + // successful `Ok(_)` here is the proof: had a regression + // routed Local through a shell-out path, the shim would have + // failed loudly instead. + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + let wrangler_path = dir.path().join("wrangler.toml"); + fs::write(&wrangler_path, synthesise_wrangler_toml("demo")) + .expect("bootstrap wrangler.toml"); + let fake = fake_wrangler_panicking(); + let _path = PathPrepend::new(fake.path()); + + 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, + }; + CloudflareCliAdapter + .provision( + dir.path(), + Some("wrangler.toml"), + None, + &stores, + None, + ProvisionMode::Local, + false, + ) + .expect("local provision must not shell out to wrangler"); + } +} diff --git a/crates/edgezero-adapter-cloudflare/src/cli/push_cloud.rs b/crates/edgezero-adapter-cloudflare/src/cli/push_cloud.rs new file mode 100644 index 00000000..0a0ea8d7 --- /dev/null +++ b/crates/edgezero-adapter-cloudflare/src/cli/push_cloud.rs @@ -0,0 +1,740 @@ +use std::fs; +use std::io::ErrorKind; +use std::path::Path; +use std::process::Command; + +use edgezero_adapter::registry::{ReadConfigEntry, ResolvedStoreId}; + +use super::provision_cloud::find_namespace_id; +use super::WRANGLER_INSTALL_HINT; + +/// Push `entries` to the remote KV namespace bound to `store` (looked +/// up in `wrangler.toml`) via `wrangler kv bulk put +/// --namespace-id= --remote`. **--remote** is mandatory — wrangler +/// v4 defaults to LOCAL storage otherwise. +/// +/// Dry-run reports the intended invocation + per-entry preview without +/// resolving the namespace id strictly (operators can preview the +/// keyset BEFORE running provision). Real runs err loudly on unresolved +/// bindings. +pub(super) fn write_entries( + manifest_root: &Path, + adapter_manifest_path: Option<&str>, + store: &ResolvedStoreId, + entries: &[(String, String)], + dry_run: bool, +) -> Result, String> { + // Read namespace id from wrangler.toml (matched by + // `binding = `), then `wrangler kv bulk put + // --namespace-id= --remote`. The + // CLI hands this writer one logical (root_key, envelope_json) + // entry; the bulk-put still works because it's one upsert + // per entry, and the one-entry case is degenerate. + // + // **--remote** is mandatory for the prod-push path: + // wrangler v4 defaults KV bulk-put to LOCAL storage when + // the command supports both — meaning a v4 user running + // `wrangler kv bulk put` without `--remote` would silently + // populate Miniflare state under `.wrangler/state` and + // report success while leaving the live Cloudflare + // namespace empty. Explicit `--remote` removes the + // ambiguity. + let Some(rel) = adapter_manifest_path else { + return Err( + "[adapters.cloudflare.adapter].manifest must point at wrangler.toml for config push" + .to_owned(), + ); + }; + let wrangler_path = manifest_root.join(rel); + let binding = store.platform.as_str(); + let logical = store.logical.as_str(); + // Dry-run is lenient about a missing/unresolved binding so + // operators can preview the keyset BEFORE running provision. + // Real runs still err loudly so we don't silently push to + // a non-existent namespace. + if dry_run { + let header = find_namespace_id(&wrangler_path, binding).map_or_else( + |_| format!( + "would run `wrangler kv bulk put --namespace-id= --remote` with {} entries for binding `{binding}` (logical id `{logical}`, binding not yet provisioned -- run `edgezero provision --adapter cloudflare` to resolve the namespace id)", + entries.len() + ), + |ns_id| format!( + "would run `wrangler kv bulk put --namespace-id={ns_id} --remote` with {} entries for binding `{binding}` (logical id `{logical}`)", + entries.len() + ), + ); + let mut out = vec![header]; + for (key, _) in entries { + out.push(format!(" would create entry `{key}`")); + } + return Ok(out); + } + let namespace_id = find_namespace_id(&wrangler_path, binding)?; + if entries.is_empty() { + return Ok(vec![format!( + "no config entries to push to KV namespace `{binding}` (logical id `{logical}`, id={namespace_id})" + )]); + } + let payload = bulk_payload(entries)?; + let temp = tempfile::Builder::new() + .prefix("edgezero-cf-push-") + .suffix(".json") + .tempfile() + .map_err(|err| format!("failed to create temp file for wrangler bulk payload: {err}"))?; + fs::write(temp.path(), payload.as_bytes()) + .map_err(|err| format!("failed to write {}: {err}", temp.path().display()))?; + let temp_arg = temp + .path() + .to_str() + .ok_or_else(|| format!("temp file path {} is not UTF-8", temp.path().display()))?; + let namespace_arg = format!("--namespace-id={namespace_id}"); + // Run from the wrangler.toml's directory so wrangler picks + // up its `account_id` / `--env` resolution + persistence + // settings the same way `wrangler dev` / `wrangler deploy` + // do for this project. + let project_dir = wrangler_path.parent().unwrap_or(manifest_root); + let output = Command::new("wrangler") + .current_dir(project_dir) + .args([ + "kv", + "bulk", + "put", + temp_arg, + namespace_arg.as_str(), + "--remote", + ]) + .output() + .map_err(|err| { + if err.kind() == ErrorKind::NotFound { + format!("`wrangler` not found on PATH; {WRANGLER_INSTALL_HINT}") + } else { + format!("failed to spawn `wrangler`: {err}") + } + })?; + if !output.status.success() { + return Err(format!( + "`wrangler kv bulk put --remote` exited with status {}\nstderr: {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + )); + } + Ok(vec![format!( + "pushed {} entries to KV namespace `{binding}` (logical id `{logical}`, id={namespace_id})", + entries.len() + )]) +} + +/// Push `entries` to Miniflare's local KV storage via `wrangler kv +/// bulk put --binding --local`. +/// +/// Local mode does NOT resolve a namespace id — the scaffold ships +/// with `local-dev-placeholder` ids, so operators who haven't run +/// `edgezero provision` yet can still seed `.wrangler/state` from the +/// manifest. Wrangler stores local entries keyed by binding, not +/// namespace id, so `wrangler dev --local` / `edgezero serve --adapter +/// cloudflare` reads them back through the same binding name. +pub(super) fn write_entries_local( + manifest_root: &Path, + adapter_manifest_path: Option<&str>, + store: &ResolvedStoreId, + entries: &[(String, String)], + dry_run: bool, +) -> Result, String> { + // Local push: address the binding directly via + // `wrangler kv bulk put --binding --local`. + // Crucially we do NOT resolve a namespace id here — the + // scaffold ships with `local-dev-placeholder` ids, so an + // operator that hasn't run `edgezero provision` yet should + // still be able to seed `.wrangler/state` from the manifest + // (matching wrangler's own local KV docs). Wrangler stores + // local entries keyed by binding, not namespace id, so the + // follow-up `wrangler dev --local` / `edgezero serve + // --adapter cloudflare` reads them back through the same + // binding name. + let Some(rel) = adapter_manifest_path else { + return Err( + "[adapters.cloudflare.adapter].manifest must point at wrangler.toml for config push --local" + .to_owned(), + ); + }; + let wrangler_path = manifest_root.join(rel); + let project_dir = wrangler_path.parent().unwrap_or(manifest_root); + let binding = store.platform.as_str(); + let logical = store.logical.as_str(); + if dry_run { + let mut out = vec![format!( + "would run `wrangler kv bulk put --binding {binding} --local` with {} entries for binding `{binding}` (logical id `{logical}`)", + entries.len() + )]; + for (key, _) in entries { + out.push(format!(" would create local entry `{key}`")); + } + return Ok(out); + } + if entries.is_empty() { + return Ok(vec![format!( + "no config entries to push to local KV namespace `{binding}` (logical id `{logical}`)" + )]); + } + let payload = bulk_payload(entries)?; + let temp = tempfile::Builder::new() + .prefix("edgezero-cf-push-local-") + .suffix(".json") + .tempfile() + .map_err(|err| format!("failed to create temp file for wrangler bulk payload: {err}"))?; + fs::write(temp.path(), payload.as_bytes()) + .map_err(|err| format!("failed to write {}: {err}", temp.path().display()))?; + let temp_arg = temp + .path() + .to_str() + .ok_or_else(|| format!("temp file path {} is not UTF-8", temp.path().display()))?; + let output = Command::new("wrangler") + .current_dir(project_dir) + .args([ + "kv", + "bulk", + "put", + temp_arg, + "--binding", + binding, + "--local", + ]) + .output() + .map_err(|err| { + if err.kind() == ErrorKind::NotFound { + format!("`wrangler` not found on PATH; {WRANGLER_INSTALL_HINT}") + } else { + format!("failed to spawn `wrangler`: {err}") + } + })?; + if !output.status.success() { + return Err(format!( + "`wrangler kv bulk put --binding {binding} --local` exited with status {}\nstderr: {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + )); + } + Ok(vec![format!( + "pushed {} entries to local KV namespace bound as `{binding}` (logical id `{logical}`); `.wrangler/state` updated", + entries.len() + )]) +} + +/// 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; +/// Cloudflare passes the value through unchanged (the envelope is an +/// opaque string from the platform's perspective). +fn bulk_payload(entries: &[(String, String)]) -> Result { + let payload: Vec = entries + .iter() + .map(|(key, value)| serde_json::json!({ "key": key, "value": value })) + .collect(); + serde_json::to_string(&payload) + .map_err(|err| format!("failed to serialize wrangler bulk payload: {err}")) +} + +/// Read a single key from a Cloudflare KV namespace by shelling out to +/// `wrangler kv key get --binding `. +/// +/// `locality` is either `"--remote"` (live Cloudflare KV) or `"--local"` +/// (Miniflare `.wrangler/state`). The two read methods on the adapter call +/// this shared helper with the appropriate flag. +/// +/// # Mapping to `ReadConfigEntry` +/// - Success (exit 0) → `Present(stdout)`. +/// - Exit non-zero, stderr contains "not found" / "does not exist" → `MissingKey`. +/// - Exit non-zero, stderr mentions "binding" → `MissingStore` (the KV +/// namespace binding itself doesn't exist in `wrangler.toml`). +/// - Any other non-zero exit → `Err`. +pub(super) fn read_wrangler_kv_key( + manifest_root: &Path, + adapter_manifest_path: Option<&str>, + store: &ResolvedStoreId, + key: &str, + locality: &str, +) -> Result { + let rel = adapter_manifest_path.ok_or_else(|| { + "[adapters.cloudflare.adapter].manifest must point at wrangler.toml for config diff" + .to_owned() + })?; + let wrangler_path = manifest_root.join(rel); + let binding = store.platform.as_str(); + let project_dir = wrangler_path.parent().unwrap_or(manifest_root); + let output = Command::new("wrangler") + .args(["kv", "key", "get", "--binding", binding, key, locality]) + .current_dir(project_dir) + .output() + .map_err(|err| { + if err.kind() == ErrorKind::NotFound { + format!("`wrangler` not found on PATH; {WRANGLER_INSTALL_HINT}") + } else { + format!("failed to spawn `wrangler`: {err}") + } + })?; + if output.status.success() { + let body = String::from_utf8(output.stdout) + .map_err(|err| format!("`wrangler kv key get` stdout is not UTF-8: {err}"))?; + // Wrangler 4.x (verified 4.64.0) returns exit 0 + stdout + // "Value not found" for a missing key instead of exit 1 + + // stderr. Detect that shape and map to MissingKey -- a + // missing key in the blob model is valid initial state + // (first push hasn't run yet), not corrupt remote state. + // Match the trimmed first line so trailing newlines or + // future variants like "Value not found.\n" still match. + let trimmed = body.trim(); + if trimmed.eq_ignore_ascii_case("value not found") + || trimmed.eq_ignore_ascii_case("value not found.") + { + return Ok(ReadConfigEntry::MissingKey); + } + return Ok(ReadConfigEntry::Present(body)); + } + let stderr = String::from_utf8_lossy(&output.stderr); + if stderr.contains("not found") || stderr.contains("does not exist") { + return Ok(ReadConfigEntry::MissingKey); + } + if stderr.contains("binding") || stderr.contains("Binding") { + return Ok(ReadConfigEntry::MissingStore); + } + Err(format!( + "`wrangler kv key get --binding {binding} {key} {locality}` exited with status {}\nstderr: {}", + output.status, + stderr.trim() + )) +} + +#[cfg(test)] +mod tests { + #[cfg(unix)] + use super::super::path_mutation_guard; + use super::super::CloudflareCliAdapter; + use super::*; + use edgezero_adapter::registry::{ + Adapter as _, AdapterPushContext, ReadConfigEntry, ResolvedStoreId, + }; + #[cfg(unix)] + use std::env; + #[cfg(unix)] + use std::ffi::OsString; + use std::path::PathBuf; + use tempfile::tempdir; + + const TEST_CONFIG_ID: &str = "app_config"; + + #[cfg(unix)] + struct PathPrepend { + original: Option, + } + + #[cfg(unix)] + impl PathPrepend { + fn new(extra: &Path) -> Self { + let original = env::var_os("PATH"); + let new = match &original { + Some(prev) => { + let mut accum = OsString::from(extra); + accum.push(":"); + accum.push(prev); + accum + } + None => OsString::from(extra), + }; + env::set_var("PATH", new); + Self { original } + } + } + + #[cfg(unix)] + impl Drop for PathPrepend { + fn drop(&mut self) { + match self.original.take() { + Some(prev) => env::set_var("PATH", prev), + None => env::remove_var("PATH"), + } + } + } + + #[cfg(unix)] + fn fake_wrangler_returning( + stdout_body: &str, + stderr_body: &str, + exit_code: i32, + ) -> tempfile::TempDir { + use std::os::unix::fs::PermissionsExt as _; + let dir = tempdir().expect("tempdir"); + let script_path = dir.path().join("wrangler"); + let stdout_file = dir.path().join("stdout_payload.txt"); + let stderr_file = dir.path().join("stderr_payload.txt"); + fs::write(&stdout_file, stdout_body).expect("write stdout payload"); + fs::write(&stderr_file, stderr_body).expect("write stderr payload"); + let script = format!( + "#!/bin/sh\ncat '{stdout}'\ncat '{stderr}' >&2\nexit {code}\n", + stdout = stdout_file.display(), + stderr = stderr_file.display(), + code = exit_code, + ); + fs::write(&script_path, script).expect("write wrangler script"); + let mut perms = fs::metadata(&script_path).expect("meta").permissions(); + perms.set_mode(0o755); + fs::set_permissions(&script_path, perms).expect("chmod +x"); + dir + } + + #[cfg(unix)] + fn fake_wrangler_argv_log(out_path: &Path) -> tempfile::TempDir { + use std::os::unix::fs::PermissionsExt as _; + let dir = tempdir().expect("tempdir"); + let script_path = dir.path().join("wrangler"); + let script = format!( + "#!/bin/sh\nfor arg in \"$@\"; do printf '%s\\n' \"$arg\" >> '{out}'; done\nprintf 'val'\n", + out = out_path.display(), + ); + fs::write(&script_path, script).expect("write script"); + let mut perms = fs::metadata(&script_path).expect("meta").permissions(); + perms.set_mode(0o755); + fs::set_permissions(&script_path, perms).expect("chmod +x"); + dir + } + + fn write_wrangler(dir: &Path, contents: &str) -> PathBuf { + let path = dir.join("wrangler.toml"); + fs::write(&path, contents).expect("write wrangler.toml"); + path + } + + // ---------- bulk_payload ---------- + + #[test] + fn bulk_payload_emits_wrangler_array_of_key_value_objects() { + let entries = vec![ + ("greeting".to_owned(), "hello".to_owned()), + ("service.timeout_ms".to_owned(), "1500".to_owned()), + ]; + let raw = bulk_payload(&entries).expect("payload"); + let parsed: serde_json::Value = serde_json::from_str(&raw).expect("valid JSON"); + let array = parsed.as_array().expect("array"); + assert_eq!(array.len(), 2); + assert_eq!(array[0]["key"], "greeting"); + assert_eq!(array[0]["value"], "hello"); + assert_eq!(array[1]["key"], "service.timeout_ms"); + assert_eq!(array[1]["value"], "1500"); + } + + #[test] + fn bulk_payload_with_no_entries_is_empty_array() { + let raw = bulk_payload(&[]).expect("empty payload"); + let parsed: serde_json::Value = serde_json::from_str(&raw).expect("valid JSON"); + assert_eq!(parsed, serde_json::json!([])); + } + + // ---------- push_config_entries (dry-run + error paths) ---------- + + #[test] + fn push_dry_run_resolves_namespace_id_and_does_not_invoke_wrangler() { + let dir = tempdir().expect("tempdir"); + let original = + "name = \"demo\"\n[[kv_namespaces]]\nbinding = \"app_config\"\nid = \"00112233445566778899aabbccddeeff\"\n"; + let path = write_wrangler(dir.path(), original); + let entries = vec![ + ("greeting".to_owned(), "hello".to_owned()), + ("feature.new_checkout".to_owned(), "false".to_owned()), + ]; + let out = CloudflareCliAdapter + .push_config_entries( + dir.path(), + Some("wrangler.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &entries, + &AdapterPushContext::new(), + true, + ) + .expect("dry-run succeeds"); + // Header + per-entry preview, matching the fastly dry-run shape. + assert_eq!(out.len(), 1 + entries.len(), "header + per-entry preview"); + assert!( + out[0].contains("would run `wrangler kv bulk put") + && out[0].contains("--namespace-id=00112233445566778899aabbccddeeff"), + "dry-run header names namespace id: {out:?}" + ); + assert!( + out.iter().any(|line| line.contains("`greeting`")), + "dry-run lists `greeting`: {out:?}" + ); + assert!( + out.iter() + .any(|line| line.contains("`feature.new_checkout`")), + "dry-run lists `feature.new_checkout`: {out:?}" + ); + let after = fs::read_to_string(&path).expect("read"); + assert_eq!(after, original, "dry-run must not mutate wrangler.toml"); + } + + #[test] + fn push_dry_run_is_lenient_when_binding_not_yet_provisioned() { + let dir = tempdir().expect("tempdir"); + write_wrangler(dir.path(), "name = \"demo\"\n"); + let entries = vec![("greeting".to_owned(), "hello".to_owned())]; + let out = CloudflareCliAdapter + .push_config_entries( + dir.path(), + Some("wrangler.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &entries, + &AdapterPushContext::new(), + true, + ) + .expect("dry-run is lenient: pre-provision preview is allowed"); + assert!( + out[0].contains("") && out[0].contains("provision"), + "dry-run header explains the namespace is unresolved and points at provision: {out:?}" + ); + assert!( + out.iter().any(|line| line.contains("`greeting`")), + "dry-run still lists the entries it would push: {out:?}" + ); + } + + #[test] + fn push_errors_when_adapter_manifest_path_missing() { + let dir = tempdir().expect("tempdir"); + let entries = vec![("k".to_owned(), "v".to_owned())]; + let err = CloudflareCliAdapter + .push_config_entries( + dir.path(), + None, + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &entries, + &AdapterPushContext::new(), + true, + ) + .expect_err("missing adapter manifest path must error"); + assert!( + err.contains("wrangler.toml") && err.contains("config push"), + "error explains the missing manifest pointer: {err}" + ); + } + + #[test] + fn push_real_run_errors_with_provision_hint_when_binding_absent() { + // dry-run is now lenient (see + // `push_dry_run_is_lenient_when_binding_not_yet_provisioned`), + // but a real run still must err so we don't silently push + // to a non-existent namespace. + let dir = tempdir().expect("tempdir"); + write_wrangler(dir.path(), "name = \"demo\"\n"); + let entries = vec![("greeting".to_owned(), "hello".to_owned())]; + let err = CloudflareCliAdapter + .push_config_entries( + dir.path(), + Some("wrangler.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &entries, + &AdapterPushContext::new(), + false, + ) + .expect_err("missing binding must error on real run"); + assert!( + err.contains("provision") && err.contains(TEST_CONFIG_ID), + "error points at provision: {err}" + ); + } + + #[test] + fn push_with_no_entries_reports_no_op_after_resolving_namespace() { + let dir = tempdir().expect("tempdir"); + write_wrangler( + dir.path(), + "name = \"demo\"\n[[kv_namespaces]]\nbinding = \"app_config\"\nid = \"00112233445566778899aabbccddeeff\"\n", + ); + let out = CloudflareCliAdapter + .push_config_entries( + dir.path(), + Some("wrangler.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &[], + &AdapterPushContext::new(), + false, + ) + .expect("zero-entry push is fine"); + assert_eq!(out.len(), 1); + assert!( + out[0].contains("no config entries") + && out[0].contains("00112233445566778899aabbccddeeff"), + "status line names empty + namespace id: {out:?}" + ); + } + + // ---------- read_config_entry / read_config_entry_local (fake wrangler) ---------- + + #[cfg(unix)] + #[test] + fn read_remote_returns_present_on_success() { + let _lock = path_mutation_guard().lock().expect("guard"); + let project_dir = tempdir().expect("tempdir"); + write_wrangler(project_dir.path(), "name = \"demo\"\n"); + let fake = fake_wrangler_returning("hello-cloudflare", "", 0); + let _path = PathPrepend::new(fake.path()); + let result = CloudflareCliAdapter + .read_config_entry( + project_dir.path(), + Some("wrangler.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + "greeting", + &AdapterPushContext::new(), + ) + .expect("wrangler exit-0 must succeed"); + let ReadConfigEntry::Present(value) = result else { + panic!("expected Present"); + }; + assert_eq!(value, "hello-cloudflare"); + } + + #[cfg(unix)] + #[test] + fn read_remote_returns_missing_key_on_not_found_stderr() { + let _lock = path_mutation_guard().lock().expect("guard"); + let project_dir = tempdir().expect("tempdir"); + write_wrangler(project_dir.path(), "name = \"demo\"\n"); + let fake = fake_wrangler_returning("", "Error: key not found", 1); + let _path = PathPrepend::new(fake.path()); + let result = CloudflareCliAdapter + .read_config_entry( + project_dir.path(), + Some("wrangler.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + "greeting", + &AdapterPushContext::new(), + ) + .expect("not-found maps to MissingKey (not Err)"); + assert!( + matches!(result, ReadConfigEntry::MissingKey), + "not-found stderr => MissingKey" + ); + } + + /// Wrangler 4.x (verified 4.64.0) returns exit 0 + stdout + /// `"Value not found"` for a missing key instead of exit 1 + + /// stderr. The previous read path treated every exit-0 stdout + /// as a `Present` envelope, which made the next CLI step try + /// to parse `"Value not found"` as a `BlobEnvelope` and abort. + /// A missing key in the blob model is valid initial state -- + /// the first push hasn't run yet -- not corrupt remote state, + /// so it must map to `MissingKey`. + #[cfg(unix)] + #[test] + fn read_remote_returns_missing_key_on_wrangler_4_value_not_found_stdout() { + let _lock = path_mutation_guard().lock().expect("guard"); + let project_dir = tempdir().expect("tempdir"); + write_wrangler(project_dir.path(), "name = \"demo\"\n"); + let fake = fake_wrangler_returning("Value not found\n", "", 0); + let _path = PathPrepend::new(fake.path()); + let result = CloudflareCliAdapter + .read_config_entry( + project_dir.path(), + Some("wrangler.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + "greeting", + &AdapterPushContext::new(), + ) + .expect("Wrangler 4.x exit-0 'Value not found' must map to MissingKey"); + if let ReadConfigEntry::Present(body) = &result { + panic!( + "expected MissingKey on Wrangler 4.x 'Value not found' stdout; \ + got Present({body:?})", + ); + } + assert!( + matches!(result, ReadConfigEntry::MissingKey), + "Wrangler 4.x stdout='Value not found' (exit 0) must classify as MissingKey", + ); + } + + #[cfg(unix)] + #[test] + fn read_remote_returns_missing_store_on_binding_stderr() { + let _lock = path_mutation_guard().lock().expect("guard"); + let project_dir = tempdir().expect("tempdir"); + write_wrangler(project_dir.path(), "name = \"demo\"\n"); + let fake = fake_wrangler_returning("", "Error: binding APP_CONFIG is not defined", 1); + let _path = PathPrepend::new(fake.path()); + let result = CloudflareCliAdapter + .read_config_entry( + project_dir.path(), + Some("wrangler.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + "greeting", + &AdapterPushContext::new(), + ) + .expect("binding-error maps to MissingStore (not Err)"); + assert!( + matches!(result, ReadConfigEntry::MissingStore), + "binding stderr => MissingStore" + ); + } + + #[cfg(unix)] + #[test] + fn read_local_uses_local_flag() { + // Verify that read_config_entry_local passes `--local` (not `--remote`) + // to wrangler. We capture argv via a fake wrangler and check the args. + let _lock = path_mutation_guard().lock().expect("guard"); + let project_dir = tempdir().expect("tempdir"); + write_wrangler(project_dir.path(), "name = \"demo\"\n"); + let argv_log = project_dir.path().join("argv.txt"); + let fake = fake_wrangler_argv_log(&argv_log); + let _path = PathPrepend::new(fake.path()); + let result = CloudflareCliAdapter + .read_config_entry_local( + project_dir.path(), + Some("wrangler.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + "greeting", + &AdapterPushContext::new(), + ) + .expect("local read succeeds"); + assert!( + matches!(result, ReadConfigEntry::Present(_)), + "expected Present from local read" + ); + let captured = fs::read_to_string(&argv_log).expect("argv log"); + assert!( + captured.contains("--local"), + "read_local must pass --local to wrangler; got argv:\n{captured}" + ); + assert!( + !captured.contains("--remote"), + "read_local must NOT pass --remote; got argv:\n{captured}" + ); + } + + #[test] + fn read_config_entry_requires_adapter_manifest_path() { + let dir = tempdir().expect("tempdir"); + let result = CloudflareCliAdapter.read_config_entry( + dir.path(), + None, + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + "greeting", + &AdapterPushContext::new(), + ); + match result { + Err(err) => assert!( + err.contains("[adapters.cloudflare.adapter].manifest"), + "error names the missing field: {err}" + ), + Ok(_) => panic!("expected Err when adapter_manifest_path is None"), + } + } +} diff --git a/crates/edgezero-adapter-cloudflare/src/cli/run.rs b/crates/edgezero-adapter-cloudflare/src/cli/run.rs new file mode 100644 index 00000000..a2f72920 --- /dev/null +++ b/crates/edgezero-adapter-cloudflare/src/cli/run.rs @@ -0,0 +1,227 @@ +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use edgezero_adapter::cli_support::{ + find_manifest_upwards, find_workspace_root, path_distance, read_package_name, +}; +use walkdir::WalkDir; + +use super::TARGET_TRIPLE; + +/// # Errors +/// Returns an error if the Cloudflare wrangler build command fails. +pub(super) fn build(extra_args: &[String]) -> Result { + let manifest = + find_wrangler_manifest(env::current_dir().map_err(|err| err.to_string())?.as_path())?; + let manifest_dir = manifest + .parent() + .ok_or_else(|| "wrangler manifest has no parent directory".to_owned())?; + let cargo_manifest = manifest_dir.join("Cargo.toml"); + let crate_name = read_package_name(&cargo_manifest)?; + + let status = Command::new("cargo") + .args([ + "build", + "--release", + "--target", + TARGET_TRIPLE, + "--manifest-path", + cargo_manifest + .to_str() + .ok_or("invalid Cargo manifest path")?, + ]) + .args(extra_args) + .status() + .map_err(|err| format!("failed to run cargo build: {err}"))?; + if !status.success() { + return Err(format!("cargo build failed with status {status}")); + } + + let workspace_root = find_workspace_root(manifest_dir); + let artifact = locate_artifact(&workspace_root, manifest_dir, &crate_name)?; + let pkg_dir = workspace_root.join("pkg"); + fs::create_dir_all(&pkg_dir) + .map_err(|err| format!("failed to create {}: {err}", pkg_dir.display()))?; + let dest = pkg_dir.join(format!("{}.wasm", crate_name.replace('-', "_"))); + fs::copy(&artifact, &dest) + .map_err(|err| format!("failed to copy artifact to {}: {err}", dest.display()))?; + + Ok(dest) +} + +/// # Errors +/// Returns an error if the Cloudflare wrangler deploy command fails. +pub(super) fn deploy(extra_args: &[String]) -> Result<(), String> { + let manifest = + find_wrangler_manifest(env::current_dir().map_err(|err| err.to_string())?.as_path())?; + let manifest_dir = manifest + .parent() + .ok_or_else(|| "wrangler manifest has no parent directory".to_owned())?; + let config = manifest + .to_str() + .ok_or_else(|| "invalid wrangler config path".to_owned())?; + + let status = Command::new("wrangler") + .args(["deploy", "--config", config]) + .args(extra_args) + .current_dir(manifest_dir) + .status() + .map_err(|err| format!("failed to run wrangler CLI: {err}"))?; + if !status.success() { + return Err(format!("wrangler deploy failed with status {status}")); + } + + Ok(()) +} + +/// # Errors +/// Returns an error if the Cloudflare wrangler dev command fails. +pub(super) fn serve(extra_args: &[String]) -> Result<(), String> { + let manifest = + find_wrangler_manifest(env::current_dir().map_err(|err| err.to_string())?.as_path())?; + let manifest_dir = manifest + .parent() + .ok_or_else(|| "wrangler manifest has no parent directory".to_owned())?; + let config = manifest + .to_str() + .ok_or_else(|| "invalid wrangler config path".to_owned())?; + + let status = Command::new("wrangler") + .args(["dev", "--config", config]) + .args(extra_args) + .current_dir(manifest_dir) + .status() + .map_err(|err| format!("failed to run wrangler CLI: {err}"))?; + if !status.success() { + return Err(format!("wrangler dev failed with status {status}")); + } + + Ok(()) +} + +fn find_wrangler_manifest(start: &Path) -> Result { + if let Some(found) = find_manifest_upwards(start, "wrangler.toml") { + return Ok(found); + } + + let root = find_workspace_root(start); + let mut candidates: Vec = WalkDir::new(&root) + .follow_links(true) + .max_depth(8) + .into_iter() + .filter_map(Result::ok) + .map(|entry| entry.path().to_path_buf()) + .filter(|path| { + path.file_name().is_some_and(|n| n == "wrangler.toml") + && path + .parent() + .is_some_and(|dir| dir.join("Cargo.toml").exists()) + }) + .collect(); + + if candidates.is_empty() { + return Err("could not locate wrangler.toml".to_owned()); + } + + candidates.sort_by_key(|path| { + let parent = path.parent().unwrap_or(Path::new("")); + path_distance(start, parent) + }); + + Ok(candidates.remove(0)) +} + +fn locate_artifact( + workspace_root: &Path, + manifest_dir: &Path, + crate_name: &str, +) -> Result { + let release_name = format!("{}.wasm", crate_name.replace('-', "_")); + + if let Some(custom) = env::var_os("CARGO_TARGET_DIR") { + let candidate = PathBuf::from(custom) + .join(TARGET_TRIPLE) + .join("release") + .join(&release_name); + if candidate.exists() { + return Ok(candidate); + } + } + + let manifest_target = manifest_dir + .join("target") + .join(TARGET_TRIPLE) + .join("release") + .join(&release_name); + if manifest_target.exists() { + return Ok(manifest_target); + } + + let workspace_target = workspace_root + .join("target") + .join(TARGET_TRIPLE) + .join("release") + .join(&release_name); + if workspace_target.exists() { + return Ok(workspace_target); + } + + Err(format!( + "compiled artifact not found for {crate_name} (looked in manifest and workspace target directories)" + )) +} + +/// 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(super) 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::*; + + // ---------- 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:?}"); + } + } +} diff --git a/crates/edgezero-adapter-fastly/src/cli.rs b/crates/edgezero-adapter-fastly/src/cli.rs deleted file mode 100644 index a3de1cba..00000000 --- a/crates/edgezero-adapter-fastly/src/cli.rs +++ /dev/null @@ -1,3447 +0,0 @@ -use std::env; -use std::fs; -use std::io::{ErrorKind, Write as _}; -use std::path::{Path, PathBuf}; -use std::process::Command; -use std::process::Stdio; - -use crate::chunked_config::{prepare_fastly_config_entries, resolve_fastly_config_value}; -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::registry::{ - register_adapter, Adapter, AdapterAction, AdapterPushContext, ProvisionStores, ReadConfigEntry, - ResolvedStoreId, -}; -use edgezero_adapter::scaffold::{ - register_adapter_blueprint, AdapterBlueprint, AdapterFileSpec, CommandTemplates, - DependencySpec, LoggingDefaults, ManifestSpec, ReadmeInfo, TemplateRegistration, -}; -use walkdir::WalkDir; - -static FASTLY_ADAPTER: FastlyCliAdapter = FastlyCliAdapter; - -static FASTLY_BLUEPRINT: AdapterBlueprint = AdapterBlueprint { - id: "fastly", - display_name: "Fastly Compute@Edge", - crate_suffix: "adapter-fastly", - dependency_crate: "edgezero-adapter-fastly", - dependency_repo_path: "crates/edgezero-adapter-fastly", - template_registrations: FASTLY_TEMPLATE_REGISTRATIONS, - files: FASTLY_FILE_SPECS, - extra_dirs: &["src", ".cargo"], - dependencies: FASTLY_DEPENDENCIES, - manifest: ManifestSpec { - manifest_filename: "fastly.toml", - build_target: "wasm32-wasip1", - build_profile: "release", - build_features: &["fastly"], - }, - commands: CommandTemplates { - build: "fastly compute build -C {crate_dir}", - deploy: "fastly compute deploy -C {crate_dir}", - serve: "fastly compute serve -C {crate_dir}", - }, - logging: LoggingDefaults { - endpoint: Some("stdout"), - level: "info", - echo_stdout: Some(true), - }, - readme: ReadmeInfo { - description: "{display} entrypoint.", - dev_heading: "{display} (local)", - dev_steps: &["`cd {crate_dir}`", "`edgezero serve --adapter fastly`"], - }, - run_module: "edgezero_adapter_fastly", -}; - -static FASTLY_DEPENDENCIES: &[DependencySpec] = &[ - DependencySpec { - key: "dep_edgezero_core_fastly", - repo_crate: "crates/edgezero-core", - fallback: "edgezero-core = { git = \"https://git@github.com/stackpop/edgezero.git\", package = \"edgezero-core\", default-features = false }", - features: &[], - }, - DependencySpec { - key: "dep_edgezero_adapter_fastly", - repo_crate: "crates/edgezero-adapter-fastly", - fallback: - "edgezero-adapter-fastly = { git = \"https://git@github.com/stackpop/edgezero.git\", package = \"edgezero-adapter-fastly\", default-features = false }", - features: &[], - }, - DependencySpec { - key: "dep_edgezero_adapter_fastly_wasm", - repo_crate: "crates/edgezero-adapter-fastly", - fallback: - "edgezero-adapter-fastly = { git = \"https://git@github.com/stackpop/edgezero.git\", package = \"edgezero-adapter-fastly\", default-features = false, features = [\"fastly\"] }", - features: &["fastly"], - }, -]; - -static FASTLY_FILE_SPECS: &[AdapterFileSpec] = &[ - AdapterFileSpec { - template: "fastly_Cargo_toml", - output: "Cargo.toml", - }, - AdapterFileSpec { - template: "fastly_src_main_rs", - output: "src/main.rs", - }, - AdapterFileSpec { - template: "fastly_cargo_config_toml", - output: ".cargo/config.toml", - }, - AdapterFileSpec { - template: "fastly_fastly_toml", - output: "fastly.toml", - }, -]; - -static FASTLY_TEMPLATE_REGISTRATIONS: &[TemplateRegistration] = &[ - TemplateRegistration { - name: "fastly_Cargo_toml", - contents: include_str!("templates/Cargo.toml.hbs"), - }, - TemplateRegistration { - name: "fastly_src_main_rs", - contents: include_str!("templates/src/main.rs.hbs"), - }, - TemplateRegistration { - name: "fastly_cargo_config_toml", - contents: include_str!("templates/.cargo/config.toml.hbs"), - }, - TemplateRegistration { - name: "fastly_fastly_toml", - contents: include_str!("templates/fastly.toml.hbs"), - }, -]; - -const FASTLY_INSTALL_HINT: &str = - "install the Fastly CLI (https://www.fastly.com/documentation/reference/tools/cli/) and try again"; - -struct FastlyCliAdapter; - -/// Outcome of scanning `fastly config-store list --json` for a -/// platform store id by `name`. Distinguishes three cases the -/// caller wants to act on differently: -/// -/// - `Found(id)` — happy path. -/// - `NotFound` — JSON parsed cleanly and the array contains -/// entries with well-formed `name` + `id` string fields, but no -/// entry matched `name`. Operator likely needs to run -/// `provision`. -/// - `SchemaDrift(detail)` — the JSON parsed but doesn't match -/// the expected shape (no `items` envelope nor bare array, OR -/// entries are missing `name` / `id` string fields, OR the -/// bytes didn't parse as JSON at all). Likely a fastly CLI -/// version bump that changed the output schema; surface the -/// detail so the operator can pin a known-compatible version. -#[derive(Debug)] -enum ConfigStoreLookup { - Found(String), - NotFound, - SchemaDrift(String), -} - -// The three `validate_*` trait methods exist on `Adapter` because -// spin requires them (variable-name regex, `[component.*]` -// discovery, flat-namespace collision). The trait surface is typed -// generically so any future adapter with similar constraints can -// override — but fastly has no equivalent platform requirements, -// so the no-op defaults are correct: -// -// - `validate_app_config_keys`: Fastly Config Store keys accept -// alphanumeric + `-` / `_` / `.` up to 256 chars. Any reasonable -// Rust struct field name passes; no regex check needed. -// - `validate_adapter_manifest`: would require shelling out to -// `fastly compute validate` at validate-time. We keep -// `config validate` pure-Rust so it stays fast and -// tool-independent. -// - `validate_typed_secrets`: Fastly's KV / Config / Secret -// stores are independent namespaces — no spin-style flat- -// namespace collision risk to detect. -// -// `single_store_kinds` IS overridden below — explicitly returns -// `&[]` for documentation, matching the inherited default. -#[expect( - clippy::missing_trait_methods, - reason = "see the explanatory block comment immediately above; fastly's no-op defaults for the three validate_* hooks are intentional and documented. `read_config_entry` and `read_config_entry_local` are both overridden below. `single_store_kinds` IS overridden below (returns `&[]`)." -)] -impl Adapter for FastlyCliAdapter { - fn execute(&self, action: AdapterAction, args: &[String]) -> Result<(), String> { - match action { - // `fastly profile {create|delete|list}` is the native - // sign-in surface for Fastly Compute. EdgeZero stores no - // credentials — this is a thin shell-out. - AdapterAction::AuthLogin => { - run_native_cli("fastly", &["profile", "create"], FASTLY_INSTALL_HINT) - } - AdapterAction::AuthLogout => { - run_native_cli("fastly", &["profile", "delete"], FASTLY_INSTALL_HINT) - } - AdapterAction::AuthStatus => { - run_native_cli("fastly", &["profile", "list"], FASTLY_INSTALL_HINT) - } - AdapterAction::Build => { - let artifact = build(args)?; - log::info!("[edgezero] Fastly build complete -> {}", artifact.display()); - Ok(()) - } - AdapterAction::Deploy => deploy(args), - AdapterAction::Serve => serve(args), - other => Err(format!("fastly adapter does not support {other:?}")), - } - } - - fn name(&self) -> &'static str { - "fastly" - } - - fn provision( - &self, - manifest_root: &Path, - adapter_manifest_path: Option<&str>, - _component_selector: Option<&str>, - stores: &ProvisionStores<'_>, - dry_run: bool, - ) -> Result, String> { - // Fastly is Multi for every store kind. Each id maps 1:1 - // to a Fastly resource (kv-store / config-store / - // secret-store) created via the Fastly CLI; the manifest - // writeback declares the resource link for `fastly - // compute deploy` and the local viceroy server. - let Some(rel) = adapter_manifest_path else { - return Err( - "[adapters.fastly.adapter].manifest must point at fastly.toml for provision" - .to_owned(), - ); - }; - let fastly_path = manifest_root.join(rel); - - let mut out = Vec::new(); - for (kind, ids) in [ - ("kv", stores.kv), - ("config", stores.config), - ("secret", stores.secrets), - ] { - for store in ids { - // Fastly setup tables key on the resource name the - // CLI creates. The runtime resolves that same name - // via `EDGEZERO__STORES______NAME`, - // so provision must use the env-resolved PLATFORM - // name -- the logical id stays in status lines for - // human-facing wording. - let logical = store.logical.as_str(); - let name = store.platform.as_str(); - if dry_run { - out.push(format!( - "would run `fastly {kind}-store create --name={name}` and append [setup.{kind}_stores.{name}] to {} (logical id `{logical}`)", - fastly_path.display() - )); - continue; - } - if setup_block_present(&fastly_path, kind, name)? { - out.push(format!( - "fastly {kind}-store `{name}` (logical id `{logical}`) already declared in {}; skipping. To force a fresh remote: delete the [setup.{kind}_stores.{name}] block AND run `fastly {kind}-store delete --name={name}` (the old remote store lingers otherwise), then re-run provision.", - fastly_path.display() - )); - continue; - } - create_fastly_store(kind, name)?; - // If the platform store was created but the - // writeback fails, remote state and the local - // manifest are out of sync. Re-running `provision` - // would attempt to create the platform store again - // and fail with "already exists". Surface the - // recovery path explicitly so the operator isn't - // stuck. - append_fastly_setup(&fastly_path, kind, name).map_err(|err| { - format!( - "fastly {kind}-store `{name}` (logical id `{logical}`) was created remotely, but writeback to {path} failed: {err}\n To recover, either:\n 1. Manually append `[setup.{kind}_stores.{name}]` to {path} and re-run, or\n 2. Delete the orphan remote store via `fastly {kind}-store delete --name={name}` and re-run `edgezero provision --adapter fastly`.", - path = fastly_path.display() - ) - })?; - // Fastly's `[setup._stores.]` table is - // consumed ONLY when `fastly compute deploy` is - // creating a NEW service. If `service_id` is - // already present in fastly.toml, the service has - // been deployed at least once and subsequent - // deploys skip `[setup]` entirely — so the store - // exists in the account but has no resource link - // tying it to a service version, and the running - // Compute service can't open it. - // - // Detect that case and EMIT the exact one-shot - // command the operator should run to link the - // store. We deliberately don't auto-run it: the - // link cones the active version (`--autoclone`), - // and silently mutating an already-deployed - // service is surprising. The instruction names - // both the store-id lookup AND the link command so - // the operator can audit before committing. - let post_create_note = resource_link_note(&fastly_path, kind, name)?; - let mut line = format!( - "created fastly {kind}-store `{name}` (logical id `{logical}`); appended setup tables to {}", - fastly_path.display() - ); - if let Some(note) = post_create_note { - line.push('\n'); - line.push_str(¬e); - } - out.push(line); - } - } - // EdgeZero runtime overrides live in a dedicated Fastly Config - // Store named `edgezero_runtime_env`. Compute@Edge has no - // process env, so `EDGEZERO__STORES__CONFIG____KEY` and - // similar overrides have to come from a platform Config Store - // the runtime opens by name (see - // `env_config_from_runtime_dictionary` in lib.rs). Provision - // owns the store creation alongside the operator's declared - // stores so the runtime override path is wired correctly out - // of the box; if the store already appears in - // `[setup.config_stores.edgezero_runtime_env]`, skip. - let runtime_env_kind = "config"; - let runtime_env_name = "edgezero_runtime_env"; - if dry_run { - out.push(format!( - "would run `fastly {runtime_env_kind}-store create --name={runtime_env_name}` and append [setup.{runtime_env_kind}_stores.{runtime_env_name}] to {} (EdgeZero runtime override store)", - fastly_path.display() - )); - } else if !setup_block_present(&fastly_path, runtime_env_kind, runtime_env_name)? { - create_fastly_store(runtime_env_kind, runtime_env_name)?; - append_fastly_setup(&fastly_path, runtime_env_kind, runtime_env_name).map_err( - |err| { - format!( - "fastly {runtime_env_kind}-store `{runtime_env_name}` was created remotely, but writeback to {path} failed: {err}\n Recover via `fastly {runtime_env_kind}-store delete --name={runtime_env_name}` then re-run `edgezero provision --adapter fastly`.", - path = fastly_path.display() - ) - }, - )?; - // Same already-deployed-service caveat as the declared-store - // path: if `service_id` is set in fastly.toml, the - // `[setup.config_stores.edgezero_runtime_env]` table won't - // be re-applied by the next `fastly compute deploy`, so the - // runtime can't open the store. Emit the resource-link - // remediation alongside the populate-keys hint. - let post_create_note = - resource_link_note(&fastly_path, runtime_env_kind, runtime_env_name)?; - let mut line = format!( - "created fastly {runtime_env_kind}-store `{runtime_env_name}` (EdgeZero runtime override store); appended setup tables to {}\n Populate per-environment override keys with:\n fastly config-store-entry update --store-id= --key=EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY --value=app_config_staging --upsert", - fastly_path.display() - ); - if let Some(note) = post_create_note { - line.push('\n'); - line.push_str(¬e); - } - out.push(line); - } else { - // Already declared; nothing to do. - } - - if out.is_empty() { - out.push("fastly has no declared stores to provision".to_owned()); - } - Ok(out) - } - - fn push_config_entries( - &self, - _manifest_root: &Path, - _adapter_manifest_path: Option<&str>, - _component_selector: Option<&str>, - store: &ResolvedStoreId, - entries: &[(String, String)], - _push_ctx: &AdapterPushContext<'_>, - dry_run: bool, - ) -> Result, String> { - // Resolve the platform config-store id on demand via - // `fastly config-store list --json` (matched by name = - // `store.platform`), then `fastly config-store-entry update - // --store-id= --key= --upsert --stdin` per physical - // entry. Entries are logical blob-envelope entries from - // the CLI (one (key, envelope_json) per push); oversized - // Fastly values are expanded below into chunk entries plus - // a root pointer by `chunked_config::prepare_fastly_config_entries`. - let logical = store.logical.as_str(); - let name = store.platform.as_str(); - if entries.is_empty() { - return Ok(vec![format!( - "no config entries to push to fastly config-store `{name}` (logical id `{logical}`)" - )]); - } - // Expand each logical (key, envelope_json) into physical entries - // via the chunk-pointer helper. Entries ≤ 8 000 chars go through - // as a single direct entry; larger envelopes are split into - // content-addressed chunks with a root pointer written LAST. - // Collect all physical entries before any writes so pointer-too- - // large errors surface before touching the remote store. - let mut physical_entries: Vec<(String, String)> = Vec::new(); - for (key, body) in entries { - let expanded = prepare_fastly_config_entries(key, body)?; - physical_entries.extend(expanded); - } - if dry_run { - // Report intent without shelling out. One line per logical key - // noting whether it would be direct or chunked, plus chunk count. - let mut out = Vec::with_capacity(entries.len().saturating_add(1)); - out.push(format!( - "would resolve fastly config-store `{name}` (logical id `{logical}`) via `fastly config-store list --json` and push entries:" - )); - for (key, body) in entries { - let expanded = prepare_fastly_config_entries(key, body) - .unwrap_or_else(|_| vec![(key.clone(), body.clone())]); - if expanded.len() == 1 { - out.push(format!( - " would push `{key}` as direct entry ({}B)", - body.len() - )); - } else { - let chunk_count = expanded.len().saturating_sub(1); - out.push(format!( - " would push `{key}` as chunked ({chunk_count} chunks + 1 pointer, {}B total)", - body.len() - )); - } - } - return Ok(out); - } - let resolved_id = resolve_remote_config_store_id(name)?; - push_entries_with_committer(&physical_entries, |key, value| { - create_config_store_entry(&resolved_id, key, value) - })?; - Ok(vec![format!( - "pushed {} physical entries ({} logical) to fastly config-store `{name}` (logical id `{logical}`, id={resolved_id})", - physical_entries.len(), - entries.len() - )]) - } - - fn push_config_entries_local( - &self, - manifest_root: &Path, - adapter_manifest_path: Option<&str>, - _component_selector: Option<&str>, - store: &ResolvedStoreId, - entries: &[(String, String)], - _push_ctx: &AdapterPushContext<'_>, - dry_run: bool, - ) -> Result, String> { - // Local-emulator path: edit - // `[local_server.config_stores..contents]` in - // `fastly.toml`. Viceroy reads it on startup, so a - // subsequent `fastly compute serve` exposes the new values - // to the wasm component. No shell-out to the production - // Fastly CLI -- the operator may not be authenticated and - // wouldn't want a local push to touch production anyway. - let Some(rel) = adapter_manifest_path else { - return Err( - "[adapters.fastly.adapter].manifest must point at fastly.toml for config push --local" - .to_owned(), - ); - }; - let fastly_path = manifest_root.join(rel); - let logical = store.logical.as_str(); - let name = store.platform.as_str(); - if entries.is_empty() { - return Ok(vec![format!( - "no config entries to push to `[local_server.config_stores.{name}]` in {} (logical id `{logical}`)", - fastly_path.display() - )]); - } - // Expand logical entries into physical entries (chunks + pointer). - let mut physical_entries: Vec<(String, String)> = Vec::new(); - for (key, body) in entries { - let expanded = prepare_fastly_config_entries(key, body)?; - physical_entries.extend(expanded); - } - if dry_run { - let mut out = Vec::with_capacity(entries.len().saturating_add(1)); - out.push(format!( - "would edit `[local_server.config_stores.{name}.contents]` in {} (logical id `{logical}`) with entries:", - fastly_path.display(), - )); - for (key, body) in entries { - let expanded = prepare_fastly_config_entries(key, body) - .unwrap_or_else(|_| vec![(key.clone(), body.clone())]); - if expanded.len() == 1 { - out.push(format!( - " would set `{key}` as direct entry ({}B)", - body.len() - )); - } else { - let chunk_count = expanded.len().saturating_sub(1); - out.push(format!( - " would set `{key}` as chunked ({chunk_count} chunks + 1 pointer, {}B total)", - body.len() - )); - } - } - return Ok(out); - } - write_fastly_local_config_store(&fastly_path, name, &physical_entries)?; - Ok(vec![format!( - "wrote {} physical entries ({} logical) to `[local_server.config_stores.{name}.contents]` in {} (logical id `{logical}`); restart `fastly compute serve` to pick up changes", - physical_entries.len(), - entries.len(), - fastly_path.display() - )]) - } - - fn read_config_entry( - &self, - _manifest_root: &Path, - _adapter_manifest_path: Option<&str>, - _component_selector: Option<&str>, - store: &ResolvedStoreId, - key: &str, - _push_ctx: &AdapterPushContext<'_>, - ) -> Result { - // Shell out to `fastly config-store-entry describe - // --store-id= --key= --json`, resolve the store id on - // demand via `fastly config-store list --json`, then parse the - // JSON response. - let name = store.platform.as_str(); - let store_id = match resolve_remote_config_store_id(name) { - Ok(id) => id, - Err(err) => { - // "not found" from resolve means the store doesn't exist. - let lower = err.to_ascii_lowercase(); - if lower.contains("not found") - || lower.contains("did you run") - || lower.contains("no fastly config-store matches") - { - return Ok(ReadConfigEntry::MissingStore); - } - return Err(err); - } - }; - let store_arg = format!("--store-id={store_id}"); - let key_arg = format!("--key={key}"); - let output = Command::new("fastly") - .args([ - "config-store-entry", - "describe", - store_arg.as_str(), - key_arg.as_str(), - "--json", - ]) - .output() - .map_err(|err| { - if err.kind() == ErrorKind::NotFound { - format!("`fastly` not found on PATH; {FASTLY_INSTALL_HINT}") - } else { - format!("failed to spawn `fastly`: {err}") - } - })?; - if output.status.success() { - let stdout = String::from_utf8_lossy(&output.stdout); - // Parse the JSON and extract the `item_value` field. - let parsed: serde_json::Value = - serde_json::from_str(&stdout).map_err(|err| { - format!( - "failed to parse `fastly config-store-entry describe` JSON: {err}\nraw stdout: {stdout}" - ) - })?; - let value = parsed - .get("item_value") - .and_then(serde_json::Value::as_str) - .ok_or_else(|| { - format!( - "`fastly config-store-entry describe` JSON has no string `item_value` field; \ - fastly CLI may have changed its output schema. Raw stdout: {stdout}" - ) - })?; - // Resolve chunk pointers: if `value` is a direct BlobEnvelope it - // passes through unchanged; if it is a chunk pointer the chunks - // are fetched from the same store and reconstructed. - let resolved = resolve_fastly_config_value(key, value.to_owned(), |chunk_key| { - fetch_remote_config_store_entry(&store_id, chunk_key) - })?; - return Ok(ReadConfigEntry::Present(resolved)); - } - let stderr = String::from_utf8_lossy(&output.stderr); - let lower = stderr.to_ascii_lowercase(); - if lower.contains("not found") || lower.contains("does not exist") || lower.contains("404") - { - return Ok(ReadConfigEntry::MissingKey); - } - Err(format!( - "`fastly config-store-entry describe --store-id={store_id} --key={key} --json` exited with status {}\nstderr: {}", - output.status, - stderr.trim() - )) - } - - fn read_config_entry_local( - &self, - manifest_root: &Path, - adapter_manifest_path: Option<&str>, - _component_selector: Option<&str>, - store: &ResolvedStoreId, - key: &str, - _push_ctx: &AdapterPushContext<'_>, - ) -> Result { - // Read from `[local_server.config_stores..contents]` - // in fastly.toml — the same section `push_config_entries_local` writes. - let Some(rel) = adapter_manifest_path else { - return Err( - "[adapters.fastly.adapter].manifest must point at fastly.toml for config diff --local" - .to_owned(), - ); - }; - let fastly_path = manifest_root.join(rel); - let name = store.platform.as_str(); - let raw = match fs::read_to_string(&fastly_path) { - Ok(text) => text, - Err(err) if err.kind() == ErrorKind::NotFound => { - return Ok(ReadConfigEntry::MissingStore) - } - Err(err) => { - return Err(format!("failed to read {}: {err}", fastly_path.display())); - } - }; - let doc: toml_edit::DocumentMut = raw - .parse() - .map_err(|err| format!("failed to parse {}: {err}", fastly_path.display()))?; - // Probe `[local_server.config_stores.]` — if absent, the store - // has not been seeded locally yet. - let Some(contents) = doc - .get("local_server") - .and_then(|ls| ls.get("config_stores")) - .and_then(|cs| cs.get(name)) - .and_then(|store_tbl| store_tbl.get("contents")) - else { - return Ok(ReadConfigEntry::MissingStore); - }; - // The contents table is `key = "value"` pairs. - match contents.get(key) { - Some(item) => { - let value = item.as_str().ok_or_else(|| { - format!( - "`[local_server.config_stores.{name}.contents].{key}` in {} is not a string", - fastly_path.display() - ) - })?; - // Resolve chunk pointers using the same toml contents table. - let resolved = - resolve_fastly_config_value(key, value.to_owned(), |chunk_key| match contents - .get(chunk_key) - { - Some(chunk_item) => { - let chunk_val = chunk_item.as_str().ok_or_else(|| { - format!( - "chunk key `{chunk_key}` in {} is not a string", - fastly_path.display() - ) - })?; - Ok(Some(chunk_val.to_owned())) - } - None => Ok(None), - })?; - Ok(ReadConfigEntry::Present(resolved)) - } - None => Ok(ReadConfigEntry::MissingKey), - } - } - - fn single_store_kinds(&self) -> &'static [&'static str] { - // Explicit `&[]` rather than inheriting the trait default, - // so the "Multi for every store kind" intent is documented - // at the call site. Fastly KV / Config / Secrets all - // support multiple distinct platform resources per kind, - // unlike spin's flat-namespace single-store model. - &[] - } -} - -/// Fetch a single entry value from a remote Fastly Config Store entry by -/// key, using `fastly config-store-entry describe --store-id= --key= -/// --json`. Used by the chunk-pointer resolver to fan out to chunk entries. -/// -/// Returns: -/// - `Ok(Some(value))` when the entry exists. -/// - `Ok(None)` when the entry is absent (not-found / 404 / does not exist). -/// - `Err(...)` on adapter or parse errors. -/// -/// # Errors -/// Returns an error if `fastly` isn't on `PATH`, spawning fails, the JSON -/// cannot be parsed, or the CLI exits with an unexpected non-zero status. -fn fetch_remote_config_store_entry(store_id: &str, key: &str) -> Result, String> { - let store_arg = format!("--store-id={store_id}"); - let key_arg = format!("--key={key}"); - let output = Command::new("fastly") - .args([ - "config-store-entry", - "describe", - store_arg.as_str(), - key_arg.as_str(), - "--json", - ]) - .output() - .map_err(|err| { - if err.kind() == ErrorKind::NotFound { - format!("`fastly` not found on PATH; {FASTLY_INSTALL_HINT}") - } else { - format!("failed to spawn `fastly`: {err}") - } - })?; - if output.status.success() { - let stdout = String::from_utf8_lossy(&output.stdout); - let parsed: serde_json::Value = serde_json::from_str(&stdout).map_err(|err| { - format!( - "failed to parse `fastly config-store-entry describe` JSON for chunk \ - key `{key}`: {err}\nraw stdout: {stdout}" - ) - })?; - let value = parsed - .get("item_value") - .and_then(serde_json::Value::as_str) - .ok_or_else(|| { - format!( - "`fastly config-store-entry describe` JSON has no string `item_value` \ - field for chunk key `{key}`; fastly CLI may have changed its output schema. \ - Raw stdout: {stdout}" - ) - })?; - return Ok(Some(value.to_owned())); - } - let stderr = String::from_utf8_lossy(&output.stderr); - let lower = stderr.to_ascii_lowercase(); - if lower.contains("not found") || lower.contains("does not exist") || lower.contains("404") { - return Ok(None); - } - Err(format!( - "`fastly config-store-entry describe --store-id={store_id} --key={key} --json` \ - exited with status {}\nstderr: {}", - output.status, - stderr.trim() - )) -} - -/// Shell out to `fastly -store create --name=`. The -/// caller resolves `` from `EDGEZERO__STORES______NAME` -/// (falling back to the logical id), so this helper takes whatever the -/// caller hands it and does not re-translate. Returns `Ok(())` on success; -/// surfaces the CLI's stderr verbatim on failure (including the "already -/// exists" error, which is the caller's signal to fix the toml or use a -/// different name). -/// -/// # Errors -/// Returns an error if `fastly` isn't on `PATH`, the child fails to -/// spawn, or the exit status is non-zero. -fn create_fastly_store(kind: &str, name: &str) -> Result<(), String> { - let subcommand = format!("{kind}-store"); - let name_arg = format!("--name={name}"); - let output = Command::new("fastly") - .args([subcommand.as_str(), "create", name_arg.as_str()]) - .output() - .map_err(|err| { - if err.kind() == ErrorKind::NotFound { - format!("`fastly` not found on PATH; {FASTLY_INSTALL_HINT}") - } else { - format!("failed to spawn `fastly`: {err}") - } - })?; - if output.status.success() { - return Ok(()); - } - // Idempotency: the fastly CLI returns non-zero with an - // "already exists" message when a store of this name was - // created by a prior provision run. Treat that as success so - // the operator's recovery path -- "either manually append the - // setup block or delete the remote and re-run provision" -- - // doesn't get blocked. The append step is itself idempotent, - // so re-running provision after a writeback failure is the - // documented recovery and now actually works. - let stderr = String::from_utf8_lossy(&output.stderr); - if looks_like_already_exists(&stderr, kind) { - return Ok(()); - } - Err(format!( - "`fastly {subcommand} create --name={name}` exited with status {}\nstderr: {}", - output.status, - stderr.trim() - )) -} - -/// Heuristic: does the stderr blob look like a "store of this -/// kind, by this name, already exists" failure from the fastly -/// CLI? Different CLI versions phrase this slightly differently -/// ("a kv-store with that name already exists", -/// `"Conflict: duplicate kv_store name"`, etc.); we require BOTH -/// a conflict-signal keyword AND a store-kind reference so an -/// unrelated 409 ("Error: 409 Conflict on /service/...") cannot -/// be misread as idempotent success. The earlier wider heuristic -/// would have swallowed any stderr containing the word -/// "conflict" and let provision march on to writeback against a -/// nonexistent store, surfacing as a confusing deploy-time error. -fn looks_like_already_exists(stderr: &str, kind: &str) -> bool { - let lower = stderr.to_ascii_lowercase(); - let conflict_signal = lower.contains("already exists") - || (lower.contains("duplicate") && lower.contains("name")) - || lower.contains("conflict"); - if !conflict_signal { - return false; - } - // Accept the three common spellings of `-store` / - // `_store` / ` store` so a fastly CLI version - // bump that reshuffles punctuation still hits. - let dashed = format!("{kind}-store"); - let underscored = format!("{kind}_store"); - let spaced = format!("{kind} store"); - lower.contains(&dashed) || lower.contains(&underscored) || lower.contains(&spaced) -} - -/// Read the top-level `service_id` from `fastly.toml`. Returns -/// `Ok(None)` when the file is absent (scaffold state before first -/// `fastly compute deploy`) or when `service_id` is missing / -/// empty. Used by `provision` to detect when an already-deployed -/// service needs a separate resource-link step beyond `[setup]` -/// (which `compute deploy` only consumes on the FIRST deploy). -fn read_fastly_service_id(path: &Path) -> Result, String> { - let raw = match fs::read_to_string(path) { - Ok(text) => text, - Err(err) if err.kind() == ErrorKind::NotFound => return Ok(None), - Err(err) => return Err(format!("failed to read {}: {err}", path.display())), - }; - let doc: toml_edit::DocumentMut = raw - .parse() - .map_err(|err| format!("failed to parse {}: {err}", path.display()))?; - let svc = doc - .get("service_id") - .and_then(|item| item.as_str()) - .map(str::to_owned) - .filter(|svc_id| !svc_id.is_empty()); - Ok(svc) -} - -/// If fastly.toml declares `service_id`, the next -/// `fastly compute deploy` skips `[setup]` entirely (it only runs on -/// the FIRST deploy of a service). Any store created by provision -/// after that needs a separate `fastly resource-link create` to link -/// the platform store to the service version. This helper returns the -/// remediation note to surface in the provision output, or `None` -/// when the service hasn't been deployed yet (so the next -/// `compute deploy` will pick up the `[setup]` row automatically). -fn resource_link_note(path: &Path, kind: &str, name: &str) -> Result, String> { - let note = read_fastly_service_id(path)?.map(|svc_id| { - format!( - " fastly.toml declares `service_id = \"{svc_id}\"`, so this service is already deployed -- `[setup]` will NOT be re-run on the next `fastly compute deploy`. The store exists in the account but is NOT yet linked to the service. To finish provisioning, look up the store id with `fastly {kind}-store list --json` (match by name=`{name}`), then run:\n fastly resource-link create --service-id={svc_id} --resource-id= --version=latest --autoclone --name={name}\n (the link clones the active version so existing traffic is not affected until you `fastly service-version activate`)." - ) - }); - Ok(note) -} - -/// Probe `fastly.toml` for the existence of `[setup._stores.]`. -/// Treats a missing file as "not present" so the first provision call -/// can create it. -/// -/// Why only `[setup]` (no longer `[local_server]`): an empty -/// `[local_server._stores.]` table doesn't satisfy -/// fastly's local-server schema — config-stores need -/// `format = "inline-toml"` + a contents table, kv/secret stores -/// need a JSON `file = "..."` or an array of `{key, data}` entries. -/// Writing an empty table makes `fastly compute serve` skip the -/// declared store or error at startup. `provision`'s job is the -/// remote / `[setup]` half; local-server stanzas are written by -/// `edgezero config push --adapter fastly --local` -/// (config-stores only), and kv/secret local-server seeding is -/// hand-edited until we add equivalent writers for those kinds. -fn setup_block_present(path: &Path, kind: &str, id: &str) -> Result { - let raw = match fs::read_to_string(path) { - Ok(text) => text, - Err(err) if err.kind() == ErrorKind::NotFound => return Ok(false), - Err(err) => return Err(format!("failed to read {}: {err}", path.display())), - }; - let doc: toml_edit::DocumentMut = raw - .parse() - .map_err(|err| format!("failed to parse {}: {err}", path.display()))?; - let plural = format!("{kind}_stores"); - Ok(doc - .get("setup") - .and_then(|root| root.get(plural.as_str())) - .and_then(|kind_tbl| kind_tbl.get(id)) - .is_some()) -} - -/// Append `[setup._stores.]` to `fastly.toml`. Creates -/// the file (and the parent `[setup]` table) if absent. The block -/// is written as an empty table — that's what -/// `fastly compute deploy` consumes the first time it creates a -/// service: the resource-link declaration is enough, and the -/// account-level resource itself is already created in the -/// preceding `create_fastly_store` shellout. -/// -/// We DON'T write `[local_server._stores.]` here: see -/// `setup_block_present`'s doc for the schema rationale. The local- -/// server seeding moved to `config push --local` (config-stores -/// only), so provision only owns the remote / setup half. -fn append_fastly_setup(path: &Path, kind: &str, id: &str) -> Result<(), String> { - use toml_edit::{table, DocumentMut, Item}; - - let raw = match fs::read_to_string(path) { - Ok(text) => text, - Err(err) if err.kind() == ErrorKind::NotFound => String::new(), - Err(err) => return Err(format!("failed to read {}: {err}", path.display())), - }; - let mut doc: DocumentMut = raw - .parse() - .map_err(|err| format!("failed to parse {}: {err}", path.display()))?; - - let plural = format!("{kind}_stores"); - let parent_entry = doc.entry("setup").or_insert_with(table); - let parent_tbl = parent_entry.as_table_mut().ok_or_else(|| { - format!( - "{}: `setup` exists but is not a table; refusing to edit in place", - path.display() - ) - })?; - let kind_entry = parent_tbl - .entry(plural.as_str()) - .or_insert_with(|| Item::Table(toml_edit::Table::new())); - let kind_tbl = kind_entry.as_table_mut().ok_or_else(|| { - format!( - "{}: `setup.{plural}` exists but is not a table; refusing to edit in place", - path.display() - ) - })?; - if !kind_tbl.contains_key(id) { - kind_tbl.insert(id, Item::Table(toml_edit::Table::new())); - } - - fs::write(path, doc.to_string()) - .map_err(|err| format!("failed to write {}: {err}", path.display()))?; - Ok(()) -} - -/// Write the local-server config-store entries to `fastly.toml`: -/// `[local_server.config_stores.]` becomes -/// `format = "inline-toml"`, and `[local_server.config_stores..contents]` -/// gets the flat `key = "value"` pairs (overwriting any previous -/// values). Idempotent — re-running just rewrites `contents`. Other -/// blocks in `fastly.toml` (setup, scripts, the actual `[local_server]` -/// secret stores, etc.) are preserved via `toml_edit`. -fn write_fastly_local_config_store( - path: &Path, - platform_name: &str, - entries: &[(String, String)], -) -> Result<(), String> { - use toml_edit::{table, DocumentMut, Item, Table, Value}; - - let raw = match fs::read_to_string(path) { - Ok(text) => text, - Err(err) if err.kind() == ErrorKind::NotFound => String::new(), - Err(err) => return Err(format!("failed to read {}: {err}", path.display())), - }; - let mut doc: DocumentMut = raw - .parse() - .map_err(|err| format!("failed to parse {}: {err}", path.display()))?; - - let local_server_entry = doc.entry("local_server").or_insert_with(table); - let local_server_tbl = local_server_entry.as_table_mut().ok_or_else(|| { - format!( - "{}: `local_server` exists but is not a table; refusing to edit in place", - path.display() - ) - })?; - let config_stores_entry = local_server_tbl - .entry("config_stores") - .or_insert_with(|| Item::Table(Table::new())); - let config_stores_tbl = config_stores_entry.as_table_mut().ok_or_else(|| { - format!( - "{}: `local_server.config_stores` exists but is not a table; refusing to edit in place", - path.display() - ) - })?; - - // Upsert into the existing per-store contents table so a - // `config push --key app_config_staging` does NOT wipe the - // previously-pushed `app_config` blob. Spec 12.7 requires - // default + staging keys to coexist so the runtime - // EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY env var can - // switch between them. (Earlier wholesale-replace was a - // misread of the "stale entries don't linger" property: - // that applies WITHIN a key (old chunks for the same root - // become unreferenced when a new chunk-set installs a new - // pointer), NOT across sibling keys.) - let store_entry = config_stores_tbl.entry(platform_name).or_insert_with(|| { - let mut tbl = Table::new(); - tbl.insert("format", toml_edit::value("inline-toml")); - tbl.insert("contents", Item::Table(Table::new())); - Item::Table(tbl) - }); - let store_tbl = store_entry.as_table_mut().ok_or_else(|| { - format!( - "{}: `local_server.config_stores.{platform_name}` exists but is not a table; refusing to edit in place", - path.display() - ) - })?; - // Ensure the `format` key is present even on a pre-existing - // entry that omitted it. - if !store_tbl.contains_key("format") { - store_tbl.insert("format", toml_edit::value("inline-toml")); - } - 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.{platform_name}.contents` exists but is not a table; refusing to edit in place", - path.display() - ) - })?; - for (key, value) in entries { - contents_tbl.insert(key, Item::Value(Value::from(value.clone()))); - } - - fs::write(path, doc.to_string()) - .map_err(|err| format!("failed to write {}: {err}", path.display()))?; - Ok(()) -} - -// ------------------------------------------------------------------- -// `config push` helpers -// ------------------------------------------------------------------- - -/// Shell out to `fastly config-store-entry create --store-id= -/// --key= --value=` for a single entry. Surfaces fastly's -/// stderr verbatim on failure — including the "entry already -/// exists" error, which is the operator's signal to delete the -/// entry (or use `config-store-entry update` manually) before -/// re-running push. -/// Drive a sequential per-entry commit loop and produce the -/// partial-failure diagnostic when the committer fails mid-way. -/// Pure (no I/O) so the diagnostic shape is unit-testable without -/// the fastly CLI on PATH; production calls it with a closure that -/// shells out via `create_config_store_entry`. On success returns -/// the count of committed entries; on failure returns an error -/// string naming committed / failed / not-attempted keys so the -/// operator can resume from a known boundary. -fn push_entries_with_committer( - entries: &[(String, String)], - mut committer: F, -) -> Result -where - F: FnMut(&str, &str) -> Result<(), String>, -{ - let mut pushed: Vec = Vec::with_capacity(entries.len()); - for (key, value) in entries { - if let Err(err) = committer(key, value) { - let remaining: Vec<&str> = entries - .iter() - .skip(pushed.len().saturating_add(1)) - .map(|(remaining_key, _)| remaining_key.as_str()) - .collect(); - return Err(format!( - "fastly push failed at entry `{key}` after committing {committed} of {total} entries; the remaining {remaining_count} entries were NOT pushed.\n Committed (safe to skip on retry): {pushed:?}\n Failed: `{key}` — {err}\n Not attempted (re-push these): {remaining:?}", - committed = pushed.len(), - total = entries.len(), - remaining_count = remaining.len() - )); - } - pushed.push(key.clone()); - } - Ok(pushed.len()) -} - -/// Shell `fastly config-store-entry update --upsert --stdin` with -/// the value piped through stdin instead of `--value=` on -/// argv. -/// -/// Two reasons for this exact invocation: -/// -/// 1. `--upsert` (vs. the original `create` subcommand): the prior -/// `create` form errored on any key that already existed in the -/// config store, which made `config push` non-repeatable — -/// after the first push, every follow-up push triggered by a -/// config edit would fail at the first unchanged key. -/// `update --upsert` is documented as "insert or update", which -/// matches the convergent semantic the other config-push paths -/// already have (axum overwrites the JSON, cloudflare's -/// `wrangler kv bulk put` overwrites, spin's -/// `cloud key-value set` overwrites). -/// -/// 2. `--stdin` (vs. `--value=`): `--value=` exposed every -/// config entry's bytes in `ps`/`/proc//cmdline` listings -/// AND was bounded by the host's `ARG_MAX` (4 KiB to 256 KiB -/// depending on platform — easy to trip with a JSON blob). -/// `--stdin` reads the value from stdin instead — keeps value -/// bytes out of argv and lifts the size cap to whatever the OS -/// pipe buffer + the CLI's read accept (megabytes in practice). -fn create_config_store_entry(store_id: &str, key: &str, value: &str) -> Result<(), String> { - let store_arg = format!("--store-id={store_id}"); - let key_arg = format!("--key={key}"); - let mut child = Command::new("fastly") - .args([ - "config-store-entry", - "update", - store_arg.as_str(), - key_arg.as_str(), - "--upsert", - "--stdin", - ]) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .map_err(|err| { - if err.kind() == ErrorKind::NotFound { - format!("`fastly` not found on PATH; {FASTLY_INSTALL_HINT}") - } else { - format!("failed to spawn `fastly`: {err}") - } - })?; - // Move stdin OUT of child via `take` so the ChildStdin drops at - // end of scope — that closes the pipe and lets the CLI see EOF. - // `child.wait_with_output()` then consumes child cleanly. - let mut stdin = child - .stdin - .take() - .ok_or_else(|| "failed to open stdin pipe to `fastly`".to_owned())?; - stdin - .write_all(value.as_bytes()) - .map_err(|err| format!("failed to write value to `fastly` stdin: {err}"))?; - drop(stdin); - let output = child - .wait_with_output() - .map_err(|err| format!("failed to wait on `fastly`: {err}"))?; - if output.status.success() { - return Ok(()); - } - Err(format!( - "`fastly config-store-entry update --store-id={store_id} --key={key} --upsert --stdin` exited with status {}\nstderr: {}", - output.status, - String::from_utf8_lossy(&output.stderr).trim() - )) -} - -/// Parse `fastly config-store list --json` output and return the -/// platform `id` of the store whose `name` matches `name`. Accepts -/// both a bare array (`[ {"id": "...", "name": "..."}, ... ]`) -/// and an `{"items": [...]}` envelope so this stays compatible -/// across fastly CLI versions. -fn find_config_store_id(stdout: &str, name: &str) -> ConfigStoreLookup { - let parsed: serde_json::Value = match serde_json::from_str(stdout) { - Ok(value) => value, - Err(err) => { - return ConfigStoreLookup::SchemaDrift(format!("stdout did not parse as JSON: {err}")); - } - }; - let Some(array) = parsed - .as_array() - .or_else(|| parsed.get("items").and_then(serde_json::Value::as_array)) - else { - return ConfigStoreLookup::SchemaDrift(format!( - "expected a bare array `[...]` or an `{{\"items\": [...]}}` envelope; got JSON of shape `{}`", - shape_summary(&parsed) - )); - }; - let mut any_well_formed = false; - for entry in array { - let entry_name = entry.get("name").and_then(serde_json::Value::as_str); - let entry_id = entry.get("id").and_then(serde_json::Value::as_str); - if entry_name.is_some() && entry_id.is_some() { - any_well_formed = true; - } - if entry_name == Some(name) { - return entry_id.map_or_else( - || { - ConfigStoreLookup::SchemaDrift(format!( - "entry matched name `{name}` but is missing a string `id` field" - )) - }, - |id| ConfigStoreLookup::Found(id.to_owned()), - ); - } - } - if array.is_empty() || any_well_formed { - ConfigStoreLookup::NotFound - } else { - ConfigStoreLookup::SchemaDrift( - "no entry has both string `name` and `id` fields -- fastly CLI may have changed its output schema" - .to_owned(), - ) - } -} - -/// One-line type label for a `serde_json::Value` (for diagnostic -/// error messages — not a canonical JSON-schema description). -fn shape_summary(value: &serde_json::Value) -> &'static str { - match value { - serde_json::Value::Null => "null", - serde_json::Value::Bool(_) => "bool", - serde_json::Value::Number(_) => "number", - serde_json::Value::String(_) => "string", - serde_json::Value::Array(_) => "array", - serde_json::Value::Object(_) => "object", - } -} - -/// Resolve the platform config-store id on demand: shell out to -/// `fastly config-store list --json`, parse the JSON, match by -/// `name`. The provision flow doesn't persist this id, so push -/// has to re-fetch every time. -fn resolve_remote_config_store_id(name: &str) -> Result { - let output = Command::new("fastly") - .args(["config-store", "list", "--json"]) - .output() - .map_err(|err| { - if err.kind() == ErrorKind::NotFound { - format!("`fastly` not found on PATH; {FASTLY_INSTALL_HINT}") - } else { - format!("failed to spawn `fastly`: {err}") - } - })?; - if !output.status.success() { - return Err(format!( - "`fastly config-store list --json` exited with status {}\nstderr: {}", - output.status, - String::from_utf8_lossy(&output.stderr).trim() - )); - } - let stdout = String::from_utf8_lossy(&output.stdout); - match find_config_store_id(&stdout, name) { - ConfigStoreLookup::Found(id) => Ok(id), - ConfigStoreLookup::NotFound => Err(format!( - "no fastly config-store matches `{name}` (did you run `edgezero provision --adapter fastly`?)" - )), - ConfigStoreLookup::SchemaDrift(detail) => Err(format!( - "could not parse `fastly config-store list --json` output: {detail}.\n The fastly CLI may have changed its JSON schema in a recent version. Please file a bug report at https://github.com/stackpop/edgezero/issues with the fastly CLI version (`fastly version`) and the raw stdout. Workaround: pin to a known-compatible fastly CLI version." - )), - } -} - -/// # Errors -/// Returns an error if the Fastly CLI build command fails. -#[inline] -pub fn build(extra_args: &[String]) -> Result { - let manifest = - find_fastly_manifest(env::current_dir().map_err(|err| err.to_string())?.as_path())?; - let manifest_dir = manifest - .parent() - .ok_or_else(|| "fastly manifest has no parent directory".to_owned())?; - let cargo_manifest = manifest_dir.join("Cargo.toml"); - let crate_name = read_package_name(&cargo_manifest)?; - - let status = Command::new("cargo") - .args([ - "build", - "--release", - "--target", - "wasm32-wasip1", - "--manifest-path", - cargo_manifest - .to_str() - .ok_or("invalid Cargo manifest path")?, - ]) - .args(extra_args) - .status() - .map_err(|err| format!("failed to run cargo build: {err}"))?; - if !status.success() { - return Err(format!("cargo build failed with status {status}")); - } - - let workspace_root = find_workspace_root(manifest_dir); - let artifact = locate_artifact(&workspace_root, manifest_dir, &crate_name)?; - let pkg_dir = workspace_root.join("pkg"); - fs::create_dir_all(&pkg_dir) - .map_err(|err| format!("failed to create {}: {err}", pkg_dir.display()))?; - let dest = pkg_dir.join(format!("{}.wasm", crate_name.replace('-', "_"))); - fs::copy(&artifact, &dest) - .map_err(|err| format!("failed to copy artifact to {}: {err}", dest.display()))?; - - Ok(dest) -} - -/// # Errors -/// Returns an error if the Fastly CLI deploy command fails. -#[inline] -pub fn deploy(extra_args: &[String]) -> Result<(), String> { - let manifest = - find_fastly_manifest(env::current_dir().map_err(|err| err.to_string())?.as_path())?; - let manifest_dir = manifest - .parent() - .ok_or_else(|| "fastly manifest has no parent directory".to_owned())?; - - let status = Command::new("fastly") - .args(["compute", "deploy"]) - .args(extra_args) - .current_dir(manifest_dir) - .status() - .map_err(|err| format!("failed to run fastly CLI: {err}"))?; - if !status.success() { - return Err(format!("fastly compute deploy failed with status {status}")); - } - - Ok(()) -} - -fn find_fastly_manifest(start: &Path) -> Result { - if let Some(found) = find_manifest_upwards(start, "fastly.toml") { - return Ok(found); - } - - let root = find_workspace_root(start); - let mut candidates: Vec = WalkDir::new(&root) - .follow_links(true) - .max_depth(8) - .into_iter() - .filter_map(Result::ok) - .map(|entry| entry.path().to_path_buf()) - .filter(|path| { - path.file_name().is_some_and(|n| n == "fastly.toml") - && path - .parent() - .is_some_and(|dir| dir.join("Cargo.toml").exists()) - }) - .collect(); - - if candidates.is_empty() { - return Err("could not locate fastly.toml".to_owned()); - } - - candidates.sort_by_key(|path| { - let parent = path.parent().unwrap_or(Path::new("")); - path_distance(start, parent) - }); - - Ok(candidates.remove(0)) -} - -fn locate_artifact( - workspace_root: &Path, - manifest_dir: &Path, - crate_name: &str, -) -> Result { - let target_triple = "wasm32-wasip1"; - let release_name = format!("{}.wasm", crate_name.replace('-', "_")); - - if let Some(custom) = env::var_os("CARGO_TARGET_DIR") { - let candidate = PathBuf::from(custom) - .join(target_triple) - .join("release") - .join(&release_name); - if candidate.exists() { - return Ok(candidate); - } - } - - let manifest_target = manifest_dir - .join("target") - .join(target_triple) - .join("release") - .join(&release_name); - if manifest_target.exists() { - return Ok(manifest_target); - } - - let workspace_target = workspace_root - .join("target") - .join(target_triple) - .join("release") - .join(&release_name); - if workspace_target.exists() { - return Ok(workspace_target); - } - - Err(format!( - "compiled artifact not found (looked in {} and workspace target)", - manifest_dir.display() - )) -} - -#[inline] -pub fn register() { - register_adapter(&FASTLY_ADAPTER); - register_adapter_blueprint(&FASTLY_BLUEPRINT); -} - -#[ctor(unsafe)] -fn register_ctor() { - register(); -} - -/// # Errors -/// Returns an error if the Fastly CLI serve command (Viceroy) fails. -#[inline] -pub fn serve(extra_args: &[String]) -> Result<(), String> { - let manifest = - find_fastly_manifest(env::current_dir().map_err(|err| err.to_string())?.as_path())?; - let manifest_dir = manifest - .parent() - .ok_or_else(|| "fastly manifest has no parent directory".to_owned())?; - - let status = Command::new("fastly") - .args(["compute", "serve"]) - .args(extra_args) - .current_dir(manifest_dir) - .status() - .map_err(|err| format!("failed to run fastly CLI: {err}"))?; - if !status.success() { - return Err(format!("fastly compute serve failed with status {status}")); - } - - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use edgezero_adapter::cli_support::read_package_name; - #[cfg(unix)] - use std::ffi::OsString; - #[cfg(unix)] - use std::sync::Mutex; - use tempfile::tempdir; - - // Shared fixture names. Pinning these as consts (instead of - // inline `"sessions"` / `"app_config"` per call site) keeps the - // setup-vs-assertion pair in sync -- a typo in one place no - // longer silently divorces from the other, because both reference - // the same const. Also names the intent: these are the LOGICAL - // store ids the fastly adapter operates on, not arbitrary strings. - const TEST_KV_ID: &str = "sessions"; - const TEST_CONFIG_ID: &str = "app_config"; - const TEST_SECRET_ID: &str = "default"; - - /// RAII guard: prepends a directory to `$PATH` and restores the original - /// value on drop. - #[cfg(unix)] - struct PathPrepend { - original: Option, - } - - #[cfg(unix)] - impl PathPrepend { - fn new(extra: &Path) -> Self { - let original = env::var_os("PATH"); - let new_path = match &original { - Some(prev) => { - let mut accum = OsString::from(extra); - accum.push(":"); - accum.push(prev); - accum - } - None => OsString::from(extra), - }; - env::set_var("PATH", new_path); - Self { original } - } - } - - #[cfg(unix)] - impl Drop for PathPrepend { - fn drop(&mut self) { - match self.original.take() { - Some(prev) => env::set_var("PATH", prev), - None => env::remove_var("PATH"), - } - } - } - - #[test] - fn finds_closest_manifest_when_multiple_exist() { - let dir = tempdir().unwrap(); - let root = dir.path(); - fs::write(root.join("Cargo.toml"), "[workspace]").unwrap(); - - let first = root.join("crates/first"); - fs::create_dir_all(&first).unwrap(); - fs::write(first.join("Cargo.toml"), "[package]\nname=\"first\"").unwrap(); - fs::write(first.join("fastly.toml"), "name=\"first\"").unwrap(); - - let second = root.join("examples/second"); - fs::create_dir_all(&second).unwrap(); - fs::write(second.join("Cargo.toml"), "[package]\nname=\"second\"").unwrap(); - fs::write(second.join("fastly.toml"), "name=\"second\"").unwrap(); - - let found = find_fastly_manifest(&second).unwrap(); - assert_eq!(found, second.join("fastly.toml")); - } - - #[test] - fn finds_manifest_in_current_directory() { - let dir = tempdir().unwrap(); - let root = dir.path(); - fs::write(root.join("Cargo.toml"), "[workspace]").unwrap(); - fs::write(root.join("fastly.toml"), "name = \"demo\"").unwrap(); - - let manifest = find_fastly_manifest(root).expect("should find manifest"); - assert_eq!(manifest, root.join("fastly.toml")); - } - - #[test] - fn locate_artifact_considers_workspace_target() { - let dir = tempdir().unwrap(); - let workspace = dir.path(); - let manifest_dir = workspace.join("service"); - fs::create_dir_all(manifest_dir.join("target/wasm32-wasip1/release")).unwrap(); - let artifact = workspace.join("target/wasm32-wasip1/release/demo.wasm"); - fs::create_dir_all(artifact.parent().unwrap()).unwrap(); - fs::write(&artifact, "wasm").unwrap(); - - let located = locate_artifact(workspace, &manifest_dir, "demo").unwrap(); - assert_eq!(located, artifact); - } - - #[test] - fn read_package_falls_back_to_name() { - let dir = tempdir().unwrap(); - let manifest = dir.path().join("Cargo.toml"); - fs::write(&manifest, "name = \"demo\"").unwrap(); - let name = read_package_name(&manifest).unwrap(); - assert_eq!(name, "demo"); - } - - #[test] - fn read_package_prefers_package_table() { - let dir = tempdir().unwrap(); - let manifest = dir.path().join("Cargo.toml"); - fs::write(&manifest, "[package]\nname = \"demo\"\n").unwrap(); - let name = read_package_name(&manifest).unwrap(); - assert_eq!(name, "demo"); - } - - // ---------- push_entries_with_committer ---------- - - #[test] - fn push_entries_with_committer_returns_count_when_all_succeed() { - let entries = vec![ - ("a".to_owned(), "1".to_owned()), - ("b".to_owned(), "2".to_owned()), - ("c".to_owned(), "3".to_owned()), - ]; - let pushed = push_entries_with_committer(&entries, |_, _| Ok(())).expect("all succeed"); - assert_eq!(pushed, 3); - } - - #[test] - fn push_entries_with_committer_zero_entries_is_ok() { - let pushed = push_entries_with_committer(&[], |_, _| Ok(())).expect("empty is fine"); - assert_eq!(pushed, 0); - } - - #[test] - fn push_entries_with_committer_failure_surfaces_committed_failed_not_attempted() { - // Mock committer: succeed for first 2 keys, fail at third. - let entries = vec![ - ("k1".to_owned(), "v1".to_owned()), - ("k2".to_owned(), "v2".to_owned()), - ("k3".to_owned(), "v3".to_owned()), - ("k4".to_owned(), "v4".to_owned()), - ("k5".to_owned(), "v5".to_owned()), - ]; - let mut calls: usize = 0; - let err = push_entries_with_committer(&entries, |key, _| { - calls = calls.saturating_add(1); - if key == "k3" { - Err("simulated fastly stderr".to_owned()) - } else { - Ok(()) - } - }) - .expect_err("middle failure must error"); - // Committer was invoked for k1, k2, k3 and stopped. - assert_eq!(calls, 3_usize, "no retries beyond failure point"); - // Error names all three categories. - assert!(err.contains("k1") && err.contains("k2"), "committed: {err}"); - assert!( - err.contains("Failed: `k3`"), - "failed entry named exactly: {err}" - ); - assert!( - err.contains("k4") && err.contains("k5"), - "not-attempted: {err}" - ); - assert!(err.contains("simulated fastly stderr"), "inner err: {err}"); - // Counts are sane. - assert!( - err.contains("committing 2 of 5 entries"), - "committed/total count: {err}" - ); - } - - #[test] - fn push_entries_with_committer_first_entry_failure_reports_zero_committed() { - let entries = vec![ - ("only".to_owned(), "val".to_owned()), - ("never".to_owned(), "tried".to_owned()), - ]; - let err = push_entries_with_committer(&entries, |_, _| Err("nope".to_owned())) - .expect_err("first-entry failure"); - assert!(err.contains("committing 0 of 2"), "zero committed: {err}"); - assert!( - err.contains("Failed: `only`"), - "first-entry failure named: {err}" - ); - assert!( - err.contains("never"), - "second entry as not-attempted: {err}" - ); - } - - #[test] - fn push_entries_with_committer_last_entry_failure_reports_n_minus_one_committed() { - let entries = vec![ - ("a".to_owned(), "1".to_owned()), - ("b".to_owned(), "2".to_owned()), - ("c".to_owned(), "3".to_owned()), - ]; - let err = push_entries_with_committer(&entries, |key, _| { - if key == "c" { - Err("late failure".to_owned()) - } else { - Ok(()) - } - }) - .expect_err("last-entry failure"); - assert!(err.contains("committing 2 of 3"), "n-1 committed: {err}"); - assert!( - err.contains("the remaining 0 entries"), - "zero not-attempted when last fails: {err}" - ); - } - - // ---------- looks_like_already_exists ---------- - - #[test] - fn looks_like_already_exists_recognises_common_phrasings() { - // Real-shaped fastly CLI error strings (paraphrased; the - // CLI varies across versions). Each must be detected so - // create_fastly_store can treat it as idempotent success. - assert!(looks_like_already_exists( - "Error: a kv-store with that name already exists", - "kv", - )); - assert!(looks_like_already_exists( - "ERROR: Conflict (409): duplicate kv_store name", - "kv", - )); - assert!(looks_like_already_exists( - "A config-store with this name already exists", - "config", - )); - // Spaced form: some fastly CLI versions emit prose - // ("kv store"); accept it alongside the punctuated forms. - assert!(looks_like_already_exists( - "Error: kv store conflict: name already in use", - "kv", - )); - } - - #[test] - fn looks_like_already_exists_rejects_unrelated_errors() { - assert!(!looks_like_already_exists( - "Error: unauthenticated; run `fastly profile create`", - "kv", - )); - assert!(!looks_like_already_exists( - "Error: network unreachable", - "kv", - )); - assert!(!looks_like_already_exists("", "kv")); - } - - #[test] - fn looks_like_already_exists_rejects_unrelated_conflict_errors() { - // The earlier wider heuristic swallowed ANY stderr - // containing "conflict" or "already exists", which would - // misread an unrelated 409 from a different fastly - // subcommand (e.g. a service-version conflict during a - // parallel deploy) as idempotent store-create success. - // Now we require the kind context too, so unrelated - // conflicts surface as failures. - assert!( - !looks_like_already_exists( - "Error: 409 Conflict on /service/abc/version/42 -- already exists", - "kv", - ), - "service-version conflict must NOT be misread as kv-store idempotency" - ); - assert!( - !looks_like_already_exists( - "Error: invalid duplicate request; check name resolution", - "kv", - ), - "unrelated `duplicate ... name` AND-match must NOT trigger" - ); - // And the kind must match: a config-store conflict must - // not look-like-already-exists for a kv-store create call. - assert!( - !looks_like_already_exists("Error: a config-store with that name already exists", "kv",), - "wrong-kind conflict must NOT trigger" - ); - } - - // ---------- setup_block_present ---------- - - #[test] - fn setup_block_present_true_when_table_exists() { - let dir = tempdir().expect("tempdir"); - let path = dir.path().join("fastly.toml"); - fs::write( - &path, - "name = \"demo\"\n[setup.kv_stores.sessions]\n[local_server.kv_stores.sessions]\n", - ) - .expect("write"); - assert!(setup_block_present(&path, "kv", TEST_KV_ID).expect("probe")); - } - - #[test] - fn setup_block_present_false_when_id_missing() { - let dir = tempdir().expect("tempdir"); - let path = dir.path().join("fastly.toml"); - fs::write(&path, "name = \"demo\"\n[setup.kv_stores.other]\n").expect("write"); - assert!(!setup_block_present(&path, "kv", TEST_KV_ID).expect("probe")); - } - - #[test] - fn setup_block_present_false_for_missing_file() { - let dir = tempdir().expect("tempdir"); - let path = dir.path().join("does-not-exist.toml"); - assert!(!setup_block_present(&path, "kv", TEST_KV_ID).expect("probe")); - } - - #[test] - fn setup_block_present_true_when_only_setup_exists() { - // Post-F6 (PR #269 round 2): `setup_block_present` only - // checks `[setup._stores.]`. The pre-fix check - // ALSO required `[local_server._stores.]`, but - // writing an empty `[local_server.*]` table didn't match - // fastly's local-server schema (config-stores need - // `format` + contents, kv/secret stores need a JSON file - // or `{key, data}` entries). Local-server seeding moved - // to `config push --adapter fastly --local`, so probe - // only cares about `[setup]` now. - let dir = tempdir().expect("tempdir"); - let only_setup = dir.path().join("only_setup.toml"); - fs::write(&only_setup, "name = \"demo\"\n[setup.kv_stores.sessions]\n").expect("write"); - assert!( - setup_block_present(&only_setup, "kv", TEST_KV_ID).expect("probe"), - "[setup.*] alone is now sufficient: {only_setup:?}" - ); - - let only_local = dir.path().join("only_local.toml"); - fs::write( - &only_local, - "name = \"demo\"\n[local_server.kv_stores.sessions]\n", - ) - .expect("write"); - assert!( - !setup_block_present(&only_local, "kv", TEST_KV_ID).expect("probe"), - "[local_server.*] alone is NOT a provisioned-setup signal" - ); - } - - // ---------- append_fastly_setup ---------- - - #[test] - fn append_fastly_setup_creates_setup_table_in_minimal_file() { - let dir = tempdir().expect("tempdir"); - let path = dir.path().join("fastly.toml"); - fs::write(&path, "name = \"demo\"\n").expect("write"); - append_fastly_setup(&path, "kv", TEST_KV_ID).expect("append"); - let after = fs::read_to_string(&path).expect("read back"); - assert!( - after.contains("[setup.kv_stores.sessions]"), - "setup table added: {after}" - ); - // Post-F6: no `[local_server.*]` write — that empty stanza - // didn't satisfy fastly's local-server schema and made - // `fastly compute serve` error or skip the store. Local- - // server seeding is now `config push --adapter fastly - // --local`'s job. - assert!( - !after.contains("[local_server.kv_stores.sessions]"), - "[local_server.*] empty table no longer written by provision: {after}" - ); - assert!( - after.contains("name = \"demo\""), - "preserved original keys: {after}" - ); - } - - #[test] - fn append_fastly_setup_appends_alongside_existing_kind_tables() { - let dir = tempdir().expect("tempdir"); - let path = dir.path().join("fastly.toml"); - fs::write(&path, "[setup.kv_stores.cache]\n").expect("write"); - append_fastly_setup(&path, "kv", TEST_KV_ID).expect("append"); - let after = fs::read_to_string(&path).expect("read back"); - assert!( - after.contains("[setup.kv_stores.cache]"), - "existing entry kept: {after}" - ); - assert!( - after.contains("[setup.kv_stores.sessions]"), - "new entry added: {after}" - ); - } - - #[test] - fn append_fastly_setup_is_idempotent_on_duplicate_id() { - let dir = tempdir().expect("tempdir"); - let path = dir.path().join("fastly.toml"); - fs::write(&path, "[setup.kv_stores.sessions]\nfoo = \"keep\"\n").expect("write"); - append_fastly_setup(&path, "kv", TEST_KV_ID).expect("idempotent append"); - let after = fs::read_to_string(&path).expect("read back"); - assert!( - after.contains("foo = \"keep\""), - "did not stomp existing key: {after}" - ); - } - - #[test] - fn append_fastly_setup_creates_file_when_missing() { - let dir = tempdir().expect("tempdir"); - let path = dir.path().join("fastly.toml"); - // Note: no fs::write — file starts absent. - append_fastly_setup(&path, "config", TEST_CONFIG_ID).expect("create"); - let after = fs::read_to_string(&path).expect("read back"); - assert!(after.contains("[setup.config_stores.app_config]")); - assert!( - !after.contains("[local_server.config_stores.app_config]"), - "[local_server.*] no longer written by provision: {after}" - ); - } - - #[test] - fn append_fastly_setup_preserves_top_comments() { - let dir = tempdir().expect("tempdir"); - let path = dir.path().join("fastly.toml"); - fs::write( - &path, - "# managed by hand -- please keep this line\nname = \"demo\"\n", - ) - .expect("write"); - append_fastly_setup(&path, "secret", TEST_SECRET_ID).expect("append"); - let after = fs::read_to_string(&path).expect("read back"); - assert!( - after.contains("# managed by hand"), - "preserved comment: {after}" - ); - } - - // ---------- write_fastly_local_config_store (config push --local) ---------- - - #[test] - fn write_fastly_local_config_store_creates_inline_block_in_minimal_file() { - let dir = tempdir().expect("tempdir"); - let path = dir.path().join("fastly.toml"); - fs::write(&path, "name = \"demo\"\n").expect("write"); - let entries = vec![ - ("greeting".to_owned(), "hello".to_owned()), - ("service.timeout_ms".to_owned(), "1500".to_owned()), - ]; - write_fastly_local_config_store(&path, TEST_CONFIG_ID, &entries).expect("write"); - let after = fs::read_to_string(&path).expect("read back"); - assert!( - after.contains(&format!("[local_server.config_stores.{TEST_CONFIG_ID}]")), - "store table: {after}" - ); - assert!( - after.contains("format = \"inline-toml\""), - "format field: {after}" - ); - assert!( - after.contains(&format!( - "[local_server.config_stores.{TEST_CONFIG_ID}.contents]" - )), - "contents table: {after}" - ); - assert!(after.contains("greeting = \"hello\""), "key 1: {after}"); - assert!( - after.contains("\"service.timeout_ms\" = \"1500\""), - "dotted key quoted: {after}" - ); - assert!(after.contains("name = \"demo\""), "preserved: {after}"); - } - - #[test] - fn write_fastly_local_config_store_replaces_existing_block_on_re_push() { - let dir = tempdir().expect("tempdir"); - let path = dir.path().join("fastly.toml"); - fs::write(&path, "name = \"demo\"\n").expect("write"); - write_fastly_local_config_store( - &path, - TEST_CONFIG_ID, - &[("greeting".to_owned(), "stale".to_owned())], - ) - .expect("first write"); - write_fastly_local_config_store( - &path, - TEST_CONFIG_ID, - &[("greeting".to_owned(), "fresh".to_owned())], - ) - .expect("second write"); - let after = fs::read_to_string(&path).expect("read back"); - assert!(after.contains("greeting = \"fresh\""), "new value: {after}"); - assert!( - !after.contains("greeting = \"stale\""), - "stale value dropped: {after}" - ); - } - - #[test] - fn write_fastly_local_config_store_preserves_unrelated_blocks() { - let dir = tempdir().expect("tempdir"); - let path = dir.path().join("fastly.toml"); - let original = "\ -[setup.kv_stores.sessions] - -[[local_server.kv_stores.sessions]] -key = \"__init__\" -data = \"\" - -[scripts] -build = \"cargo build --release\" -"; - fs::write(&path, original).expect("write"); - write_fastly_local_config_store( - &path, - TEST_CONFIG_ID, - &[("greeting".to_owned(), "hi".to_owned())], - ) - .expect("write"); - let after = fs::read_to_string(&path).expect("read back"); - assert!( - after.contains("[setup.kv_stores.sessions]"), - "setup KV kept: {after}" - ); - assert!(after.contains("[scripts]"), "scripts table kept: {after}"); - assert!( - after.contains("build = \"cargo build --release\""), - "scripts value kept: {after}" - ); - assert!( - after.contains(&format!( - "[local_server.config_stores.{TEST_CONFIG_ID}.contents]" - )), - "new config_stores block added: {after}" - ); - } - - #[test] - fn write_fastly_local_config_store_creates_file_when_missing() { - let dir = tempdir().expect("tempdir"); - let path = dir.path().join("fastly.toml"); - // No fs::write — file absent. - write_fastly_local_config_store( - &path, - TEST_CONFIG_ID, - &[("greeting".to_owned(), "hi".to_owned())], - ) - .expect("write"); - let after = fs::read_to_string(&path).expect("read back"); - assert!(after.contains(&format!( - "[local_server.config_stores.{TEST_CONFIG_ID}.contents]" - ))); - assert!(after.contains("greeting = \"hi\"")); - } - - // ---------- provision (dry-run + error path) ---------- - - #[test] - fn provision_dry_run_does_not_invoke_fastly() { - let dir = tempdir().expect("tempdir"); - let path = dir.path().join("fastly.toml"); - fs::write(&path, "name = \"demo\"\n").expect("write"); - let kv_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_KV_ID]); - let config_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_CONFIG_ID]); - let secret_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_SECRET_ID]); - let stores = ProvisionStores { - config: &config_ids, - kv: &kv_ids, - secrets: &secret_ids, - }; - let out = FastlyCliAdapter - .provision(dir.path(), Some("fastly.toml"), None, &stores, true) - .expect("dry-run succeeds"); - // 1 KV + 1 config + 1 secret + 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!( - out[3].contains("would run `fastly config-store create --name=edgezero_runtime_env`"), - "runtime-env store row: {out:?}", - ); - // Manifest untouched. - let after = fs::read_to_string(&path).expect("read"); - assert_eq!(after, "name = \"demo\"\n", "dry-run mutated fastly.toml"); - } - - #[test] - fn provision_errors_when_adapter_manifest_path_missing() { - let dir = tempdir().expect("tempdir"); - let kv_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_KV_ID]); - let stores = ProvisionStores { - config: &[], - kv: &kv_ids, - secrets: &[], - }; - let err = FastlyCliAdapter - .provision(dir.path(), None, None, &stores, true) - .expect_err("missing adapter manifest path must error"); - assert!( - err.contains("fastly.toml"), - "error names what's missing: {err}" - ); - } - - #[test] - fn provision_with_no_declared_stores_says_so() { - let dir = tempdir().expect("tempdir"); - let path = dir.path().join("fastly.toml"); - // Pre-populate the runtime-env block so the provision flow's - // unconditional runtime-env step skips (otherwise it would - // shell out to real `fastly` to create the store). - fs::write( - &path, - "name = \"demo\"\n[setup.config_stores.edgezero_runtime_env]\n", - ) - .expect("write"); - let stores = ProvisionStores { - config: &[], - kv: &[], - secrets: &[], - }; - let out = FastlyCliAdapter - .provision(dir.path(), Some("fastly.toml"), None, &stores, false) - .expect("no-store provision is fine"); - assert_eq!(out, vec!["fastly has no declared stores to provision"]); - } - - #[test] - fn provision_skips_id_when_setup_block_already_present() { - // setup_block_present's role in the flow: re-running - // provision after the user already declared a store in - // fastly.toml must be a no-op (no shell-out to fastly). - // We can verify this in a real (non-dry-run) call because - // the skip path bypasses create_fastly_store entirely. - let dir = tempdir().expect("tempdir"); - let path = dir.path().join("fastly.toml"); - fs::write( - &path, - "[setup.kv_stores.sessions]\n[local_server.kv_stores.sessions]\n\ - [setup.config_stores.edgezero_runtime_env]\n", - ) - .expect("write"); - let kv_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_KV_ID]); - let stores = ProvisionStores { - config: &[], - kv: &kv_ids, - secrets: &[], - }; - let out = FastlyCliAdapter - .provision(dir.path(), Some("fastly.toml"), None, &stores, false) - .expect("skip path succeeds without invoking fastly"); - assert_eq!(out.len(), 1); - assert!(out[0].contains("already declared"), "got: {out:?}"); - } - - /// When `fastly.toml` declares `service_id`, the next - /// `fastly compute deploy` skips `[setup]` entirely. provision - /// must emit the `fastly resource-link create` remediation for - /// every store it creates -- including the implicit - /// `edgezero_runtime_env` store the runtime override path - /// depends on. Without this, a freshly-provisioned override - /// store would not be linked to the already-deployed service - /// and the runtime would silently fall back to baked defaults. - #[test] - fn provision_emits_resource_link_note_for_runtime_env_on_existing_service() { - // Dry-run only -- we just want to drive the resource_link_note - // helper for the runtime-env store branch. The real-create - // path can't run in tests (would shell out to `fastly`). - // The dry-run output line for runtime-env doesn't include the - // note (the helper only fires on real create), so we test the - // helper directly here. - let dir = tempdir().expect("tempdir"); - let path = dir.path().join("fastly.toml"); - fs::write(&path, "name = \"demo\"\nservice_id = \"abc123svc\"\n").expect("write"); - let note = resource_link_note(&path, "config", "edgezero_runtime_env") - .expect("read service_id") - .expect("note present when service_id set"); - assert!( - note.contains("service_id = \"abc123svc\""), - "note quotes the service id: {note}" - ); - assert!( - note.contains("fastly config-store list --json"), - "note tells operator how to find the store id: {note}" - ); - assert!( - note.contains("name=`edgezero_runtime_env`"), - "note names the runtime override store: {note}" - ); - assert!( - note.contains( - "fastly resource-link create --service-id=abc123svc --resource-id= --version=latest --autoclone --name=edgezero_runtime_env" - ), - "note carries the full resource-link command: {note}" - ); - } - - /// And the inverse: no `service_id` (a service that hasn't been - /// deployed yet) means `[setup]` will be applied on the next - /// `compute deploy`, so no manual resource-link step is needed. - /// The helper must return `None` to avoid noisy false-positive - /// guidance. - #[test] - fn provision_skips_resource_link_note_when_service_undeployed() { - let dir = tempdir().expect("tempdir"); - let path = dir.path().join("fastly.toml"); - fs::write(&path, "name = \"demo\"\n").expect("write"); - let note = - resource_link_note(&path, "config", "edgezero_runtime_env").expect("read service_id"); - assert!( - note.is_none(), - "no service_id => no resource-link prompt: {note:?}" - ); - } - - // ---------- find_config_store_id ---------- - - #[test] - fn find_config_store_id_matches_bare_array_by_name() { - let stdout = format!( - r#"[ - {{"id": "abc123", "name": "{TEST_CONFIG_ID}"}}, - {{"id": "def456", "name": "other_store"}} - ]"# - ); - match find_config_store_id(&stdout, TEST_CONFIG_ID) { - ConfigStoreLookup::Found(id) => assert_eq!(id, "abc123"), - ConfigStoreLookup::NotFound => panic!("expected Found, got NotFound"), - ConfigStoreLookup::SchemaDrift(detail) => { - panic!("expected Found, got SchemaDrift({detail})") - } - } - } - - #[test] - fn find_config_store_id_tolerates_items_envelope() { - let stdout = format!( - r#"{{"items": [ - {{"id": "xyz789", "name": "{TEST_CONFIG_ID}"}} - ]}}"# - ); - match find_config_store_id(&stdout, TEST_CONFIG_ID) { - ConfigStoreLookup::Found(id) => assert_eq!(id, "xyz789"), - ConfigStoreLookup::NotFound => panic!("expected Found, got NotFound"), - ConfigStoreLookup::SchemaDrift(detail) => { - panic!("expected Found, got SchemaDrift({detail})") - } - } - } - - #[test] - fn find_config_store_id_distinguishes_not_found_from_match_failure() { - // JSON parses cleanly, entries are well-formed - // (`name` + `id` strings present), but no entry matches - // → NotFound. Operator likely needs to run `provision`. - let stdout = r#"[{"id": "abc", "name": "other"}]"#; - assert!(matches!( - find_config_store_id(stdout, "missing"), - ConfigStoreLookup::NotFound - )); - } - - #[test] - fn find_config_store_id_flags_schema_drift_on_malformed_json() { - // Unparseable bytes are NOT a "store not found" — they're - // a "fastly CLI output format changed" signal. Operator - // needs different recovery (file a bug, pin CLI version) - // than for the "store doesn't exist yet" case. - let drift = find_config_store_id("not json", "anything"); - assert!( - matches!(drift, ConfigStoreLookup::SchemaDrift(_)), - "non-JSON stdout must be schema drift, got {drift:?}" - ); - let empty = find_config_store_id("", "anything"); - assert!( - matches!(empty, ConfigStoreLookup::SchemaDrift(_)), - "empty stdout must be schema drift, got {empty:?}" - ); - } - - #[test] - fn find_config_store_id_flags_schema_drift_when_shape_unexpected() { - // JSON parses but the top-level is neither a bare array - // nor an `{items: [...]}` envelope. - let stdout = r#"{"namespace": "fastly", "list": []}"#; - match find_config_store_id(stdout, "any") { - ConfigStoreLookup::SchemaDrift(detail) => { - assert!( - detail.contains("bare array") || detail.contains("items"), - "schema-drift detail names the expected shapes: {detail}" - ); - } - ConfigStoreLookup::Found(id) => panic!("expected SchemaDrift, got Found({id})"), - ConfigStoreLookup::NotFound => panic!("expected SchemaDrift, got NotFound"), - } - } - - #[test] - fn find_config_store_id_flags_schema_drift_when_entries_lack_name_id() { - // Array of objects but none have BOTH string `name` and - // string `id` fields — suggests schema rename (e.g. - // fastly renamed `name` → `title`). - let stdout = format!(r#"[{{"title": "{TEST_CONFIG_ID}", "uid": "abc"}}]"#); - let drift = find_config_store_id(&stdout, TEST_CONFIG_ID); - assert!( - matches!(drift, ConfigStoreLookup::SchemaDrift(_)), - "entries lacking name/id must be schema drift, got {drift:?}" - ); - } - - #[test] - fn find_config_store_id_returns_not_found_for_empty_array() { - // Empty array IS a valid "store doesn't exist yet" signal, - // not schema drift — fastly CLI legitimately returns `[]` - // when no config-stores exist. - let drift = find_config_store_id("[]", "any"); - assert!( - matches!(drift, ConfigStoreLookup::NotFound), - "empty array must be NotFound, got {drift:?}" - ); - } - - // ---------- push_config_entries (dry-run + error paths) ---------- - - #[test] - fn push_dry_run_does_not_invoke_fastly() { - let dir = tempdir().expect("tempdir"); - let entries = vec![ - ("greeting".to_owned(), "hello".to_owned()), - ("feature.new_checkout".to_owned(), "false".to_owned()), - ]; - let out = FastlyCliAdapter - .push_config_entries( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - &entries, - &AdapterPushContext::new(), - true, - ) - .expect("dry-run succeeds"); - // First line names the resolve+publish flow; subsequent lines preview - // each key the push would create (so callers can eyeball the keyset - // before running for real). - assert_eq!(out.len(), 1 + entries.len(), "header + per-entry preview"); - assert!( - out[0].contains("would resolve fastly config-store `app_config`") - && out[0].contains("push entries"), - "dry-run header describes the would-be flow: {out:?}" - ); - assert!( - out.iter().any(|line| line.contains("`greeting`")), - "dry-run lists `greeting`: {out:?}" - ); - assert!( - out.iter() - .any(|line| line.contains("`feature.new_checkout`")), - "dry-run lists `feature.new_checkout`: {out:?}" - ); - } - - #[test] - fn push_with_no_entries_reports_no_op_without_invoking_fastly() { - let dir = tempdir().expect("tempdir"); - let out = FastlyCliAdapter - .push_config_entries( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - &[], - &AdapterPushContext::new(), - false, - ) - .expect("zero-entry push is fine"); - assert_eq!(out.len(), 1); - assert!( - out[0].contains("no config entries"), - "status line names the no-op: {out:?}" - ); - } - - // ---------- read_config_entry_local ---------- - - #[test] - fn read_local_returns_missing_store_when_fastly_toml_absent() { - let dir = tempdir().expect("tempdir"); - // No fastly.toml written — file missing. - let result = FastlyCliAdapter - .read_config_entry_local( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - "greeting", - &AdapterPushContext::new(), - ) - .expect("missing file is not an error"); - assert!( - matches!(result, ReadConfigEntry::MissingStore), - "absent fastly.toml => MissingStore" - ); - } - - #[test] - fn read_local_returns_missing_store_when_no_local_server_contents() { - let dir = tempdir().expect("tempdir"); - let path = dir.path().join("fastly.toml"); - // fastly.toml exists but has no [local_server.config_stores.*] block. - fs::write(&path, "name = \"demo\"\n[setup.config_stores.app_config]\n").expect("write"); - let result = FastlyCliAdapter - .read_config_entry_local( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - "greeting", - &AdapterPushContext::new(), - ) - .expect("missing local_server block is not an error"); - assert!( - matches!(result, ReadConfigEntry::MissingStore), - "no local_server stanza => MissingStore" - ); - } - - #[test] - fn read_local_returns_missing_key_when_key_absent_from_contents() { - let dir = tempdir().expect("tempdir"); - let path = dir.path().join("fastly.toml"); - // Write a local_server block with a different key so the store exists - // but the requested key is absent. - fs::write( - &path, - format!( - "name = \"demo\"\n\ - [local_server.config_stores.{TEST_CONFIG_ID}]\n\ - format = \"inline-toml\"\n\ - [local_server.config_stores.{TEST_CONFIG_ID}.contents]\n\ - other_key = \"other_value\"\n" - ), - ) - .expect("write"); - let result = FastlyCliAdapter - .read_config_entry_local( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - "greeting", - &AdapterPushContext::new(), - ) - .expect("missing key is not an error"); - assert!( - matches!(result, ReadConfigEntry::MissingKey), - "key absent from contents => MissingKey" - ); - } - - #[test] - fn read_local_returns_present_when_key_exists_in_contents() { - use edgezero_core::blob_envelope::BlobEnvelope; - use serde_json::json; - - let dir = tempdir().expect("tempdir"); - let path = dir.path().join("fastly.toml"); - fs::write(&path, "name = \"demo\"\n").expect("write initial toml"); - - // Use a valid BlobEnvelope value — the resolver requires BlobEnvelope - // or chunk-pointer JSON; raw strings are not accepted post-chunking. - let envelope_json = serde_json::to_string(&BlobEnvelope::new( - json!({"hello": "fastly"}), - "2026-06-22T00:00:00Z".into(), - )) - .expect("serialize"); - write_fastly_local_config_store( - &path, - TEST_CONFIG_ID, - &[("greeting".to_owned(), envelope_json.clone())], - ) - .expect("setup write"); - - let result = FastlyCliAdapter - .read_config_entry_local( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - "greeting", - &AdapterPushContext::new(), - ) - .expect("key present"); - let ReadConfigEntry::Present(value) = result else { - panic!("expected Present variant"); - }; - assert_eq!(value, envelope_json, "value matches what was written"); - } - - #[test] - fn read_local_roundtrips_with_push_local() { - // Write via push_config_entries_local, then read via - // read_config_entry_local — the two must agree on the value. - use edgezero_core::blob_envelope::BlobEnvelope; - use serde_json::json; - - let dir = tempdir().expect("tempdir"); - let path = dir.path().join("fastly.toml"); - fs::write(&path, "name = \"demo\"\n").expect("write"); - - // push_config_entries_local passes the value through the chunk-pointer - // helper which stores it verbatim when ≤ 8 000 chars. The reader then - // resolves it through the same resolver that requires BlobEnvelope JSON. - let envelope_json = serde_json::to_string(&BlobEnvelope::new( - json!({"hello": "roundtrip"}), - "2026-06-22T00:00:00Z".into(), - )) - .expect("serialize"); - let entries = vec![("greeting".to_owned(), envelope_json.clone())]; - FastlyCliAdapter - .push_config_entries_local( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - &entries, - &AdapterPushContext::new(), - false, - ) - .expect("push succeeds"); - let result = FastlyCliAdapter - .read_config_entry_local( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - "greeting", - &AdapterPushContext::new(), - ) - .expect("read succeeds"); - let ReadConfigEntry::Present(value) = result else { - panic!("expected Present after push+read roundtrip"); - }; - assert_eq!(value, envelope_json, "roundtrip value matches"); - } - - #[test] - fn read_local_requires_adapter_manifest_path() { - let dir = tempdir().expect("tempdir"); - let result = FastlyCliAdapter.read_config_entry_local( - dir.path(), - None, // adapter_manifest_path missing - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - "greeting", - &AdapterPushContext::new(), - ); - match result { - Err(err) => assert!( - err.contains("[adapters.fastly.adapter].manifest"), - "error names the missing field: {err}" - ), - Ok(_) => panic!("expected Err when adapter_manifest_path is None"), - } - } - - // ---------- read_config_entry (fake fastly, remote shell-out) ---------- - - /// Build a tempdir containing a `fastly` shim script that: - /// - Responds to `config-store list --json` with a store-list JSON containing - /// `TEST_CONFIG_ID` mapped to `store-abc123`. - /// - Responds to `config-store-entry describe ...` with `stdout_body` on - /// stdout and `stderr_body` on stderr, exiting with `exit_code`. - /// - /// Payloads are written to separate sibling files so shell-active chars - /// in the content don't get re-interpreted by the script. - #[cfg(unix)] - fn fake_fastly_returning( - stdout_body: &str, - stderr_body: &str, - exit_code: i32, - ) -> tempfile::TempDir { - use std::os::unix::fs::PermissionsExt as _; - let dir = tempdir().expect("tempdir"); - let script_path = dir.path().join("fastly"); - let stdout_file = dir.path().join("stdout_payload.txt"); - let stderr_file = dir.path().join("stderr_payload.txt"); - let list_file = dir.path().join("list_payload.txt"); - // Store-list JSON: bare array with one entry matching TEST_CONFIG_ID. - let list_json = format!(r#"[{{"name":"{TEST_CONFIG_ID}","id":"store-abc123"}}]"#); - fs::write(&stdout_file, stdout_body).expect("write stdout payload"); - fs::write(&stderr_file, stderr_body).expect("write stderr payload"); - fs::write(&list_file, list_json).expect("write list payload"); - let script = format!( - "#!/bin/sh\nif [ \"$1\" = \"config-store\" ]; then\n cat '{}'\n exit 0\nfi\ncat '{}'\ncat '{}' >&2\nexit {exit_code}\n", - list_file.display(), - stdout_file.display(), - stderr_file.display(), - ); - fs::write(&script_path, script).expect("write fastly script"); - let mut perms = fs::metadata(&script_path).expect("meta").permissions(); - perms.set_mode(0o755); - fs::set_permissions(&script_path, perms).expect("chmod +x"); - dir - } - - /// Build a fake `fastly` that logs each argv token (one per line) to - /// `out_path`, handles the list call correctly, and exits 0 for both calls. - #[cfg(unix)] - fn fake_fastly_argv_log(out_path: &Path) -> tempfile::TempDir { - use edgezero_core::blob_envelope::BlobEnvelope; - use serde_json::json; - use std::os::unix::fs::PermissionsExt as _; - let dir = tempdir().expect("tempdir"); - let script_path = dir.path().join("fastly"); - let list_file = dir.path().join("list_payload.txt"); - let entry_file = dir.path().join("entry_payload.txt"); - let list_json = format!(r#"[{{"name":"{TEST_CONFIG_ID}","id":"store-abc123"}}]"#); - // item_value must be a valid BlobEnvelope JSON so the resolver accepts it. - let envelope_json = serde_json::to_string(&BlobEnvelope::new( - json!({"v": "logged"}), - "2026-06-22T00:00:00Z".into(), - )) - .expect("serialize"); - let entry_json = format!( - r#"{{"item_value":{},"store_id":"store-abc123"}}"#, - serde_json::to_string(&envelope_json).expect("escape") - ); - fs::write(&list_file, list_json).expect("write list payload"); - fs::write(&entry_file, &entry_json).expect("write entry payload"); - let script = format!( - "#!/bin/sh\nfor arg in \"$@\"; do printf '%s\\n' \"$arg\" >> '{}'; done\nif [ \"$1\" = \"config-store\" ]; then\n cat '{}'\n exit 0\nfi\ncat '{}'\nexit 0\n", - out_path.display(), - list_file.display(), - entry_file.display(), - ); - fs::write(&script_path, script).expect("write script"); - let mut perms = fs::metadata(&script_path).expect("meta").permissions(); - perms.set_mode(0o755); - fs::set_permissions(&script_path, perms).expect("chmod +x"); - dir - } - - /// Process-wide mutex serialising PATH-mutating tests so parallel - /// test threads don't race on the environment variable. - #[cfg(unix)] - fn path_mutation_guard() -> &'static Mutex<()> { - use std::sync::OnceLock; - static GUARD: OnceLock> = OnceLock::new(); - GUARD.get_or_init(|| Mutex::new(())) - } - - #[cfg(unix)] - #[test] - fn read_remote_returns_present_on_success() { - use edgezero_core::blob_envelope::BlobEnvelope; - use serde_json::json; - - let _lock = path_mutation_guard().lock().expect("guard"); - let dir = tempdir().expect("tempdir"); - // Fake fastly: list succeeds with app_config → store-abc123; - // describe returns valid JSON with item_value that is a BlobEnvelope. - let envelope = serde_json::to_string(&BlobEnvelope::new( - json!({"hello": "fastly"}), - "2026-06-22T00:00:00Z".into(), - )) - .expect("serialize"); - let entry_json = format!( - r#"{{"item_value":{},"store_id":"store-abc123"}}"#, - serde_json::to_string(&envelope).expect("escape") - ); - let fake = fake_fastly_returning(&entry_json, "", 0); - let _path = PathPrepend::new(fake.path()); - let result = FastlyCliAdapter - .read_config_entry( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - "greeting", - &AdapterPushContext::new(), - ) - .expect("fake fastly exit-0 must succeed"); - let ReadConfigEntry::Present(value) = result else { - panic!("expected Present"); - }; - assert_eq!(value, envelope); - } - - #[cfg(unix)] - #[test] - fn read_remote_returns_missing_key_on_not_found_stderr() { - let _lock = path_mutation_guard().lock().expect("guard"); - let dir = tempdir().expect("tempdir"); - // describe exits non-zero with "not found" in stderr → MissingKey. - let fake = fake_fastly_returning("", "Error: item not found", 1); - let _path = PathPrepend::new(fake.path()); - let result = FastlyCliAdapter - .read_config_entry( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - "greeting", - &AdapterPushContext::new(), - ) - .expect("not-found maps to MissingKey (not Err)"); - assert!( - matches!(result, ReadConfigEntry::MissingKey), - "not-found stderr => MissingKey" - ); - } - - /// The Fastly impl distinguishes store-not-found from key-not-found via - /// `resolve_remote_config_store_id`: when the list call exits non-zero and - /// the error string contains "not found", `read_config_entry` returns - /// `MissingStore` without ever calling the describe subcommand. - #[cfg(unix)] - #[test] - fn read_remote_returns_missing_store_on_appropriate_stderr() { - use std::os::unix::fs::PermissionsExt as _; - let _lock = path_mutation_guard().lock().expect("guard"); - let dir = tempdir().expect("tempdir"); - // Script that exits non-zero for the list call so resolve fails with - // a "not found" error, causing read_config_entry to return MissingStore. - let fake_dir = tempdir().expect("tempdir"); - let stderr_file = fake_dir.path().join("stderr_payload.txt"); - fs::write(&stderr_file, "Error: config store not found for service").expect("write stderr"); - let script_path = fake_dir.path().join("fastly"); - let script = format!( - "#!/bin/sh\ncat '{stderr}' >&2\nexit 1\n", - stderr = stderr_file.display(), - ); - fs::write(&script_path, script).expect("write script"); - let mut perms = fs::metadata(&script_path).expect("meta").permissions(); - perms.set_mode(0o755); - fs::set_permissions(&script_path, perms).expect("chmod +x"); - let _path = PathPrepend::new(fake_dir.path()); - let result = FastlyCliAdapter - .read_config_entry( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - "greeting", - &AdapterPushContext::new(), - ) - .expect("list failure with not-found maps to MissingStore (not Err)"); - assert!( - matches!(result, ReadConfigEntry::MissingStore), - "list not-found => MissingStore" - ); - } - - /// Verify that `read_config_entry` invokes - /// `fastly config-store-entry describe --store-id= --key= --json` - /// (after the resolve step that calls `fastly config-store list --json`). - #[cfg(unix)] - #[test] - fn read_remote_invokes_correct_argv() { - let _lock = path_mutation_guard().lock().expect("guard"); - let dir = tempdir().expect("tempdir"); - let argv_log = dir.path().join("argv.txt"); - let fake = fake_fastly_argv_log(&argv_log); - let _path = PathPrepend::new(fake.path()); - let result = FastlyCliAdapter - .read_config_entry( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - "greeting", - &AdapterPushContext::new(), - ) - .expect("argv-log fake must succeed"); - assert!( - matches!(result, ReadConfigEntry::Present(_)), - "expected Present from argv-log fake" - ); - let captured = fs::read_to_string(&argv_log).expect("argv log"); - // The describe call must include these args (resolve call args - // are also captured but we only assert the describe shape here). - assert!( - captured.contains("config-store-entry"), - "must invoke config-store-entry; got:\n{captured}" - ); - assert!( - captured.contains("describe"), - "must pass describe subcommand; got:\n{captured}" - ); - assert!( - captured.contains("--store-id=store-abc123"), - "must pass resolved store id; got:\n{captured}" - ); - assert!( - captured.contains("--key=greeting"), - "must pass --key=; got:\n{captured}" - ); - assert!( - captured.contains("--json"), - "must pass --json flag; got:\n{captured}" - ); - } - - // ---------- chunked push integration tests ---------- - - /// Build a valid `BlobEnvelope` JSON string of approximately `target_len` bytes. - #[cfg(unix)] - fn make_test_envelope(target_len: usize) -> String { - use edgezero_core::blob_envelope::BlobEnvelope; - use serde_json::json; - let pad = "x".repeat(target_len.saturating_add(64)); - let data = json!({ "pad": pad }); - let raw = - serde_json::to_string(&BlobEnvelope::new(data, "2026-06-22T00:00:00Z".into())).unwrap(); - if raw.len() >= target_len { - let overhead = raw.len().saturating_sub(pad.len()); - let adjusted = "x".repeat(target_len.saturating_sub(overhead)); - let data2 = json!({ "pad": adjusted }); - serde_json::to_string(&BlobEnvelope::new(data2, "2026-06-22T00:00:00Z".into())).unwrap() - } else { - raw - } - } - - /// Build a fake `fastly` script whose describe response depends on - /// the `--key=` argument: `key_responses` maps key names to JSON - /// item-value responses. Falls back to exit 1 "not found" for unknown keys. - #[cfg(unix)] - fn fake_fastly_with_key_dispatch( - _dir: &Path, - key_responses: &[(String, String)], - ) -> tempfile::TempDir { - use std::fmt::Write as _; - use std::os::unix::fs::PermissionsExt as _; - let fake_dir = tempdir().expect("tempdir"); - let list_file = fake_dir.path().join("list.json"); - let list_json = format!(r#"[{{"name":"{TEST_CONFIG_ID}","id":"store-abc123"}}]"#); - fs::write(&list_file, list_json).expect("write list"); - // Write each key response to a named file. - let mut dispatch_lines = String::new(); - for (key, response) in key_responses { - let resp_file = fake_dir.path().join(format!("resp_{key}.json")); - fs::write(&resp_file, response).expect("write resp"); - // Use exact-match: iterate argv and compare each token literally - // so that a root key like "app_config" does NOT match a chunk key - // like "app_config.__edgezero_chunks.abc.0". - writeln!( - dispatch_lines, - " for arg in \"$@\"; do if [ \"$arg\" = \"--key={key}\" ]; then cat '{}'; exit 0; fi; done", - resp_file.display() - ) - .expect("write to String is infallible"); - } - // Fallback outputs "not found" so fetch_remote_config_store_entry - // maps it to Ok(None) rather than Err. - let script = format!( - "#!/bin/sh\nif [ \"$1\" = \"config-store\" ]; then\n cat '{}'\n exit 0\nfi\n{dispatch_lines}echo 'Error: item not found' >&2\nexit 1\n", - list_file.display() - ); - let script_path = fake_dir.path().join("fastly"); - fs::write(&script_path, &script).expect("write script"); - let mut perms = fs::metadata(&script_path).expect("meta").permissions(); - perms.set_mode(0o755); - fs::set_permissions(&script_path, perms).expect("chmod"); - fake_dir - } - - #[cfg(unix)] - #[test] - fn push_config_entries_writes_direct_entry_at_exactly_8000_chars() { - use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; - let _lock = path_mutation_guard().lock().expect("guard"); - let dir = tempdir().expect("tempdir"); - let argv_log = dir.path().join("argv.txt"); - let fake = fake_fastly_argv_log(&argv_log); - let _path = PathPrepend::new(fake.path()); - - let envelope = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT); - assert_eq!(envelope.len(), FASTLY_CONFIG_ENTRY_LIMIT); - - let entries = vec![(TEST_CONFIG_ID.to_owned(), envelope)]; - let out = FastlyCliAdapter - .push_config_entries( - dir.path(), - None, - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - &entries, - &AdapterPushContext::new(), - false, - ) - .expect("push must succeed"); - // One physical entry written (direct). - let captured = fs::read_to_string(&argv_log).expect("argv log"); - assert!( - captured.contains(&format!("--key={TEST_CONFIG_ID}")), - "must write root key directly: {captured}" - ); - assert!( - out[0].contains("1 physical entries (1 logical)"), - "summary reports 1 physical entry: {out:?}" - ); - } - - #[cfg(unix)] - #[test] - fn push_config_entries_writes_chunks_and_root_pointer_for_8001_chars() { - use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; - let _lock = path_mutation_guard().lock().expect("guard"); - let dir = tempdir().expect("tempdir"); - let argv_log = dir.path().join("argv.txt"); - let fake = fake_fastly_argv_log(&argv_log); - let _path = PathPrepend::new(fake.path()); - - let envelope = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT.saturating_add(1)); - assert!(envelope.len() > FASTLY_CONFIG_ENTRY_LIMIT); - - let entries = vec![(TEST_CONFIG_ID.to_owned(), envelope)]; - let out = FastlyCliAdapter - .push_config_entries( - dir.path(), - None, - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - &entries, - &AdapterPushContext::new(), - false, - ) - .expect("push must succeed"); - let captured = fs::read_to_string(&argv_log).expect("argv log"); - // At least one chunk key must appear before the root key. - assert!( - captured.contains(".__edgezero_chunks."), - "chunk keys must be written: {captured}" - ); - // Root pointer must also be written. - assert!( - captured.contains(&format!("--key={TEST_CONFIG_ID}")), - "root pointer must be written: {captured}" - ); - // Root key must be LAST in the log (chunk lines come before it). - let root_pos = captured.rfind(&format!("--key={TEST_CONFIG_ID}")).unwrap(); - let chunk_pos = captured.find(".__edgezero_chunks.").unwrap(); - assert!( - chunk_pos < root_pos, - "chunk writes must precede root pointer write: chunk_pos={chunk_pos} root_pos={root_pos}" - ); - assert!(out[0].contains("logical"), "summary line present: {out:?}"); - } - - #[cfg(unix)] - #[test] - fn push_config_entries_dry_run_reports_direct_vs_chunked() { - use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; - let dir = tempdir().expect("tempdir"); - - let direct_envelope = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT); - let chunked_envelope = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT.saturating_add(1)); - - let entries = vec![ - ("cfg_direct".to_owned(), direct_envelope), - ("cfg_chunked".to_owned(), chunked_envelope), - ]; - let out = FastlyCliAdapter - .push_config_entries( - dir.path(), - None, - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - &entries, - &AdapterPushContext::new(), - true, // dry_run - ) - .expect("dry-run must not error"); - - // No shellout happens; output must describe intent. - let combined = out.join("\n"); - assert!( - combined.contains("would push `cfg_direct` as direct entry"), - "must report direct: {combined}" - ); - assert!( - combined.contains("would push `cfg_chunked` as chunked"), - "must report chunked: {combined}" - ); - } - - /// Spec 12.7: pushing two blobs under different root keys - /// (e.g. `app_config` + `app_config_staging`) must leave both - /// keys readable from the local fastly.toml so the runtime - /// `EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY` override can - /// switch between them. Prior to the upsert fix the second - /// push wholesale-replaced the per-store contents table. - #[cfg(unix)] - #[test] - fn push_config_entries_local_preserves_sibling_keys() { - let dir = tempdir().expect("tempdir"); - let fastly_toml = dir.path().join("fastly.toml"); - fs::write(&fastly_toml, "name = \"demo\"\n").expect("seed"); - let store = ResolvedStoreId::from_logical(TEST_CONFIG_ID); - let ctx = AdapterPushContext::new(); - - FastlyCliAdapter - .push_config_entries_local( - dir.path(), - Some("fastly.toml"), - None, - &store, - &[("app_config".to_owned(), "{\"envelope\":\"A\"}".to_owned())], - &ctx, - false, - ) - .expect("first push"); - FastlyCliAdapter - .push_config_entries_local( - dir.path(), - Some("fastly.toml"), - None, - &store, - &[( - "app_config_staging".to_owned(), - "{\"envelope\":\"B\"}".to_owned(), - )], - &ctx, - false, - ) - .expect("second push (sibling key)"); - - let raw = fs::read_to_string(&fastly_toml).expect("read"); - let doc: toml_edit::DocumentMut = raw.parse().expect("parse"); - let contents = doc - .get("local_server") - .and_then(|ls| ls.get("config_stores")) - .and_then(|cs| cs.get(TEST_CONFIG_ID)) - .and_then(|st| st.get("contents")) - .and_then(toml_edit::Item::as_table) - .expect("contents after sibling push"); - let app_config = contents - .get("app_config") - .and_then(toml_edit::Item::as_str) - .expect("default key must survive sibling push"); - assert_eq!( - app_config, "{\"envelope\":\"A\"}", - "default key value: {raw}" - ); - let staging = contents - .get("app_config_staging") - .and_then(toml_edit::Item::as_str) - .expect("staging key must be present"); - assert_eq!(staging, "{\"envelope\":\"B\"}", "staging key value: {raw}"); - } - - #[cfg(unix)] - #[test] - fn push_config_entries_local_writes_literal_dotted_chunk_keys() { - use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; - let dir = tempdir().expect("tempdir"); - let fastly_toml = dir.path().join("fastly.toml"); - fs::write(&fastly_toml, "name = \"demo\"\n").expect("write"); - - let envelope = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT.saturating_add(1)); - let entries = vec![(TEST_CONFIG_ID.to_owned(), envelope)]; - FastlyCliAdapter - .push_config_entries_local( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - &entries, - &AdapterPushContext::new(), - false, - ) - .expect("local push must succeed"); - - let after = fs::read_to_string(&fastly_toml).expect("read back"); - // Chunk keys contain '.' and must appear as quoted string keys, - // not as TOML nested tables (which would look like [table.sub]). - assert!( - after.contains(".__edgezero_chunks."), - "chunk keys written to fastly.toml: {after}" - ); - // Parse with toml_edit and confirm chunk keys are string-keyed entries. - let doc: toml_edit::DocumentMut = after.parse().expect("must parse"); - let contents = doc - .get("local_server") - .and_then(|ls| ls.get("config_stores")) - .and_then(|cs| cs.get(TEST_CONFIG_ID)) - .and_then(|st| st.get("contents")) - .expect("contents table must exist"); - // At least one chunk key must be present as a string value (not a table). - let has_chunk_string = contents.as_table().is_some_and(|tbl| { - tbl.iter() - .any(|(key, val)| key.contains(".__edgezero_chunks.") && val.as_value().is_some()) - }); - assert!( - has_chunk_string, - "chunk keys must be literal string-valued entries, not nested tables: {after}" - ); - } - - #[cfg(unix)] - #[test] - fn push_config_entries_local_dry_run_reports_chunking_and_does_not_edit_fastly_toml() { - use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; - let dir = tempdir().expect("tempdir"); - let fastly_toml = dir.path().join("fastly.toml"); - let original = "name = \"demo\"\n"; - fs::write(&fastly_toml, original).expect("write"); - - let envelope = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT.saturating_add(1)); - let entries = vec![(TEST_CONFIG_ID.to_owned(), envelope)]; - let out = FastlyCliAdapter - .push_config_entries_local( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - &entries, - &AdapterPushContext::new(), - true, // dry_run - ) - .expect("local dry-run must not error"); - - // File must be untouched. - let after = fs::read_to_string(&fastly_toml).expect("read back"); - assert_eq!(after, original, "dry-run must not edit fastly.toml"); - - // Output must describe chunking intent. - let combined = out.join("\n"); - assert!( - combined.contains("would set") && combined.contains("chunked"), - "must report chunked intent: {combined}" - ); - } - - // ---------- chunked read integration tests ---------- - - #[cfg(unix)] - #[test] - fn read_config_entry_resolves_direct_value_unchanged() { - use edgezero_core::blob_envelope::BlobEnvelope; - use serde_json::json; - let _lock = path_mutation_guard().lock().expect("guard"); - let dir = tempdir().expect("tempdir"); - - let envelope = BlobEnvelope::new(json!({"hello": "world"}), "2026-06-22T00:00:00Z".into()); - let json_str = serde_json::to_string(&envelope).unwrap(); - let item_json = format!( - r#"{{"item_value":{}}}"#, - serde_json::to_string(&json_str).unwrap() - ); - let fake = fake_fastly_returning(&item_json, "", 0); - let _path = PathPrepend::new(fake.path()); - - let result = FastlyCliAdapter - .read_config_entry( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - "cfg", - &AdapterPushContext::new(), - ) - .expect("read must succeed"); - let ReadConfigEntry::Present(value) = result else { - panic!("expected Present"); - }; - assert_eq!(value, json_str, "direct envelope passes through unchanged"); - } - - #[cfg(unix)] - #[test] - fn read_config_entry_reconstructs_chunked_envelope() { - use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; - let _lock = path_mutation_guard().lock().expect("guard"); - let dir = tempdir().expect("tempdir"); - - let envelope = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT.saturating_add(1)); - let physical = prepare_fastly_config_entries(TEST_CONFIG_ID, &envelope).unwrap(); - let (_, pointer_json) = physical.last().unwrap(); - // Build a key→response map for every physical entry. - let mut key_responses: Vec<(String, String)> = Vec::new(); - for (pk, pv) in &physical { - let resp = format!(r#"{{"item_value":{}}}"#, serde_json::to_string(pv).unwrap()); - key_responses.push((pk.clone(), resp)); - } - // The root key should return the pointer. - let ptr_resp = format!( - r#"{{"item_value":{}}}"#, - serde_json::to_string(pointer_json).unwrap() - ); - key_responses.push((TEST_CONFIG_ID.to_owned(), ptr_resp)); - - let fake = fake_fastly_with_key_dispatch(dir.path(), &key_responses); - let _path = PathPrepend::new(fake.path()); - - let result = FastlyCliAdapter - .read_config_entry( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - TEST_CONFIG_ID, - &AdapterPushContext::new(), - ) - .expect("chunked read must succeed"); - let ReadConfigEntry::Present(value) = result else { - panic!("expected Present"); - }; - assert_eq!( - value, envelope, - "reconstructed envelope must equal original" - ); - } - - #[cfg(unix)] - #[test] - fn read_config_entry_errors_on_missing_chunk() { - use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; - let _lock = path_mutation_guard().lock().expect("guard"); - let dir = tempdir().expect("tempdir"); - - let envelope = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT.saturating_add(1)); - let physical = prepare_fastly_config_entries(TEST_CONFIG_ID, &envelope).unwrap(); - let (_, pointer_json) = physical.last().unwrap(); - // Only provide the root pointer; omit chunk responses so chunk fetch returns not-found. - let ptr_resp = format!( - r#"{{"item_value":{}}}"#, - serde_json::to_string(pointer_json).unwrap() - ); - let key_responses = vec![(TEST_CONFIG_ID.to_owned(), ptr_resp)]; - let fake = fake_fastly_with_key_dispatch(dir.path(), &key_responses); - let _path = PathPrepend::new(fake.path()); - - let result = FastlyCliAdapter.read_config_entry( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - TEST_CONFIG_ID, - &AdapterPushContext::new(), - ); - let Err(err) = result else { - panic!("missing chunk must error") - }; - assert!( - err.contains("missing chunk"), - "error must mention missing chunk: {err}" - ); - } - - #[cfg(unix)] - #[test] - fn read_config_entry_errors_on_corrupt_chunk_hash() { - use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; - let _lock = path_mutation_guard().lock().expect("guard"); - let dir = tempdir().expect("tempdir"); - - let envelope = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT.saturating_add(1)); - let physical = prepare_fastly_config_entries(TEST_CONFIG_ID, &envelope).unwrap(); - let (_, pointer_json) = physical.last().unwrap(); - let mut key_responses: Vec<(String, String)> = Vec::new(); - // Corrupt first chunk's content. - let (first_chunk_key, first_chunk_val) = &physical[0]; - let corrupted: String = first_chunk_val.chars().map(|_| 'Z').collect(); - let corrupt_resp = format!( - r#"{{"item_value":{}}}"#, - serde_json::to_string(&corrupted).unwrap() - ); - key_responses.push((first_chunk_key.clone(), corrupt_resp)); - // Remaining chunks as normal. - for (pk, pv) in physical - .iter() - .take(physical.len().saturating_sub(1)) - .skip(1) - { - key_responses.push(( - pk.clone(), - format!(r#"{{"item_value":{}}}"#, serde_json::to_string(pv).unwrap()), - )); - } - key_responses.push(( - TEST_CONFIG_ID.to_owned(), - format!( - r#"{{"item_value":{}}}"#, - serde_json::to_string(pointer_json).unwrap() - ), - )); - let fake = fake_fastly_with_key_dispatch(dir.path(), &key_responses); - let _path = PathPrepend::new(fake.path()); - - let result = FastlyCliAdapter.read_config_entry( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - TEST_CONFIG_ID, - &AdapterPushContext::new(), - ); - let Err(err) = result else { - panic!("corrupt chunk must error") - }; - assert!( - err.contains("SHA mismatch") || err.contains("mismatch"), - "error must mention hash mismatch: {err}" - ); - } - - #[cfg(unix)] - #[test] - fn read_config_entry_errors_on_malformed_pointer() { - let _lock = path_mutation_guard().lock().expect("guard"); - let dir = tempdir().expect("tempdir"); - // Root value is JSON but neither a BlobEnvelope nor a valid pointer. - let bad_json = r#"{"some_field":"not a pointer or envelope"}"#; - let item_json = format!( - r#"{{"item_value":{}}}"#, - serde_json::to_string(bad_json).unwrap() - ); - let fake = fake_fastly_returning(&item_json, "", 0); - let _path = PathPrepend::new(fake.path()); - - let result = FastlyCliAdapter.read_config_entry( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - "cfg", - &AdapterPushContext::new(), - ); - let Err(err) = result else { - panic!("malformed pointer must error") - }; - assert!( - err.contains("neither a valid BlobEnvelope") || err.contains("chunk pointer"), - "error must describe parse failure: {err}" - ); - } - - // ---------- local read integration tests ---------- - - #[test] - fn read_config_entry_local_resolves_direct_value() { - use edgezero_core::blob_envelope::BlobEnvelope; - use serde_json::json; - let dir = tempdir().expect("tempdir"); - let fastly_toml = dir.path().join("fastly.toml"); - - let envelope = BlobEnvelope::new(json!({"x": 1_i32}), "2026-06-22T00:00:00Z".into()); - let json_str = serde_json::to_string(&envelope).unwrap(); - // Write directly as a single entry (not via push_config_entries_local so we - // control the exact TOML content). - write_fastly_local_config_store( - &fastly_toml, - TEST_CONFIG_ID, - &[("cfg".to_owned(), json_str.clone())], - ) - .expect("write"); - - let result = FastlyCliAdapter - .read_config_entry_local( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - "cfg", - &AdapterPushContext::new(), - ) - .expect("local read must succeed"); - let ReadConfigEntry::Present(value) = result else { - panic!("expected Present"); - }; - assert_eq!(value, json_str, "direct envelope passes through unchanged"); - } - - #[test] - fn read_config_entry_local_reconstructs_chunked_envelope() { - use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; - let dir = tempdir().expect("tempdir"); - let fastly_toml = dir.path().join("fastly.toml"); - - let envelope = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT.saturating_add(1)); - let physical = prepare_fastly_config_entries(TEST_CONFIG_ID, &envelope).unwrap(); - // Write all physical entries (chunks + pointer) to the local store. - write_fastly_local_config_store(&fastly_toml, TEST_CONFIG_ID, &physical).expect("write"); - - let result = FastlyCliAdapter - .read_config_entry_local( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - TEST_CONFIG_ID, - &AdapterPushContext::new(), - ) - .expect("local chunked read must succeed"); - let ReadConfigEntry::Present(value) = result else { - panic!("expected Present"); - }; - assert_eq!( - value, envelope, - "reconstructed envelope must equal original" - ); - } - - /// Spec 12.3 + 9.3: a second oversized push must converge the - /// runtime on the NEW envelope — chunk keys are content-addressed - /// by the full-envelope SHA, so push B writes a new chunk-set and - /// installs a new root pointer. - /// - /// The local fastly.toml writer upserts per-key (so a sibling - /// `--key app_config_staging` push leaves `app_config` intact per - /// spec 12.7). Within the SAME root key, old chunks for envelope - /// A remain in the contents table after envelope B's push — they're - /// unreferenced (the root pointer at `app_config` now names B's - /// chunks), matching the remote Fastly behaviour where the - /// per-entry `update --upsert` shell-out has no atomic-delete - /// pairing. The runtime-correctness property holds either way: a - /// read after push B follows the active pointer and reconstructs - /// envelope B, not A. - #[cfg(unix)] - #[test] - #[expect( - clippy::too_many_lines, - reason = "linear test scenario: push A, inspect, push B, inspect, read; splitting would obscure the chunk-set comparison" - )] - fn second_oversized_push_converges_runtime_on_new_envelope() { - use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; - let dir = tempdir().expect("tempdir"); - let fastly_toml = dir.path().join("fastly.toml"); - fs::write(&fastly_toml, "name = \"demo\"\n").expect("seed"); - - // First push: envelope A. Records the chunk-key set so we can - // confirm they survive the second push (no garbage collection - // in v1 — spec 9.3 + Q6). - let envelope_a = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT.saturating_add(1)); - FastlyCliAdapter - .push_config_entries_local( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - &[(TEST_CONFIG_ID.to_owned(), envelope_a.clone())], - &AdapterPushContext::new(), - false, - ) - .expect("first push must succeed"); - - let after_a = fs::read_to_string(&fastly_toml).expect("read"); - let doc_a: toml_edit::DocumentMut = after_a.parse().expect("parse"); - let contents_a = doc_a - .get("local_server") - .and_then(|ls| ls.get("config_stores")) - .and_then(|cs| cs.get(TEST_CONFIG_ID)) - .and_then(|st| st.get("contents")) - .and_then(toml_edit::Item::as_table) - .expect("contents table after push A"); - let chunks_a: Vec = contents_a - .iter() - .map(|(key, _)| key.to_owned()) - .filter(|key| key.contains(".__edgezero_chunks.")) - .collect(); - assert!( - !chunks_a.is_empty(), - "push A must have produced chunk entries: {after_a}" - ); - - // Second push: a DIFFERENT oversized envelope B. The - // content-addressed chunk keys must shift to B's sha; old - // A-chunks may remain in the table (v1 doesn't GC). Build - // envelope B with a distinct payload key so its SHA differs - // from A's even at the same total length. - let envelope_b = { - use edgezero_core::blob_envelope::BlobEnvelope; - use serde_json::json; - let data = json!({ "alt": "x".repeat(FASTLY_CONFIG_ENTRY_LIMIT) }); - serde_json::to_string(&BlobEnvelope::new(data, "2026-06-22T00:00:01Z".to_owned())) - .expect("envelope B serialises") - }; - assert_ne!(envelope_a, envelope_b, "test fixtures must differ"); - FastlyCliAdapter - .push_config_entries_local( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - &[(TEST_CONFIG_ID.to_owned(), envelope_b.clone())], - &AdapterPushContext::new(), - false, - ) - .expect("second push must succeed"); - - let after_b = fs::read_to_string(&fastly_toml).expect("read"); - let doc_b: toml_edit::DocumentMut = after_b.parse().expect("parse"); - let contents_b = doc_b - .get("local_server") - .and_then(|ls| ls.get("config_stores")) - .and_then(|cs| cs.get(TEST_CONFIG_ID)) - .and_then(|st| st.get("contents")) - .and_then(toml_edit::Item::as_table) - .expect("contents table after push B"); - let chunks_b: Vec = contents_b - .iter() - .map(|(key, _)| key.to_owned()) - .filter(|key| key.contains(".__edgezero_chunks.")) - .collect(); - assert!( - !chunks_b.is_empty(), - "push B must have produced chunk entries: {after_b}" - ); - - // Chunk keys are content-addressed by envelope SHA, so the B - // push installs a fresh chunk-set whose keys are all distinct - // from A's. Under the upsert semantic the A-chunks remain in - // the contents table (no GC in v1); B's chunks are simply added. - let new_b_chunks: Vec<&String> = chunks_b - .iter() - .filter(|key| !chunks_a.contains(*key)) - .collect(); - assert!( - !new_b_chunks.is_empty(), - "push B must have added at least one new content-addressed chunk: A-set={chunks_a:?} B-set={chunks_b:?}" - ); - // Old A-chunks remain in the table (orphan-but-present — - // matches the remote Fastly write-only-upsert semantic). - for chunk_key in &chunks_a { - assert!( - chunks_b.contains(chunk_key), - "old A-chunk `{chunk_key}` must remain in the local table after push B (v1 has no GC); B-set={chunks_b:?}" - ); - } - - // Runtime-correctness property: a fresh read after push B - // reconstructs envelope B (NOT envelope A). - let read = FastlyCliAdapter - .read_config_entry_local( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - TEST_CONFIG_ID, - &AdapterPushContext::new(), - ) - .expect("local read after push B"); - let ReadConfigEntry::Present(value) = read else { - panic!("expected Present after push B"); - }; - assert_eq!( - value, envelope_b, - "read after second push must reconstruct envelope B, not A" - ); - assert_ne!( - value, envelope_a, - "old envelope A's chunks must be inert -- read must NOT return A" - ); - } -} diff --git a/crates/edgezero-adapter-fastly/src/cli/mod.rs b/crates/edgezero-adapter-fastly/src/cli/mod.rs new file mode 100644 index 00000000..49b39a13 --- /dev/null +++ b/crates/edgezero-adapter-fastly/src/cli/mod.rs @@ -0,0 +1,378 @@ +#![expect( + clippy::mod_module_files, + reason = "Workspace lint policy denies BOTH `self_named_module_files` (wants `cli/mod.rs`) and `mod_module_files` (wants `cli.rs`) -- they contradict, so any file with submodules must opt out of one. This crate's cli directory uses the `cli/mod.rs` form; allow accordingly." +)] +#![expect( + clippy::arbitrary_source_item_ordering, + reason = "submodule declarations sit between the `use` block and the rest of the file's items by Rust convention; the strict-ordering lint disagrees but no human convention puts `mod` blocks AFTER trait impls" +)] + +use std::path::{Path, PathBuf}; + +use ctor::ctor; +use edgezero_adapter::cli_support::run_native_cli; +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, + DependencySpec, LoggingDefaults, ManifestSpec, ReadmeInfo, TemplateRegistration, +}; + +mod provision_cloud; +mod provision_local; +mod push_cloud; +mod push_local; +mod run; + +static FASTLY_ADAPTER: FastlyCliAdapter = FastlyCliAdapter; + +static FASTLY_BLUEPRINT: AdapterBlueprint = AdapterBlueprint { + id: "fastly", + display_name: "Fastly Compute@Edge", + crate_suffix: "adapter-fastly", + dependency_crate: "edgezero-adapter-fastly", + dependency_repo_path: "crates/edgezero-adapter-fastly", + template_registrations: FASTLY_TEMPLATE_REGISTRATIONS, + files: FASTLY_FILE_SPECS, + extra_dirs: &["src", ".cargo"], + dependencies: FASTLY_DEPENDENCIES, + manifest: ManifestSpec { + manifest_filename: "fastly.toml", + build_target: "wasm32-wasip1", + build_profile: "release", + build_features: &["fastly"], + }, + commands: CommandTemplates { + build: "fastly compute build -C {crate_dir}", + deploy: "fastly compute deploy -C {crate_dir}", + serve: "fastly compute serve -C {crate_dir}", + }, + logging: LoggingDefaults { + endpoint: Some("stdout"), + level: "info", + echo_stdout: Some(true), + }, + readme: ReadmeInfo { + description: "{display} entrypoint.", + dev_heading: "{display} (local)", + dev_steps: &["`cd {crate_dir}`", "`edgezero serve --adapter fastly`"], + }, + run_module: "edgezero_adapter_fastly", +}; + +static FASTLY_DEPENDENCIES: &[DependencySpec] = &[ + DependencySpec { + key: "dep_edgezero_core_fastly", + repo_crate: "crates/edgezero-core", + fallback: "edgezero-core = { git = \"https://git@github.com/stackpop/edgezero.git\", package = \"edgezero-core\", default-features = false }", + features: &[], + }, + DependencySpec { + key: "dep_edgezero_adapter_fastly", + repo_crate: "crates/edgezero-adapter-fastly", + fallback: + "edgezero-adapter-fastly = { git = \"https://git@github.com/stackpop/edgezero.git\", package = \"edgezero-adapter-fastly\", default-features = false }", + features: &[], + }, + DependencySpec { + key: "dep_edgezero_adapter_fastly_wasm", + repo_crate: "crates/edgezero-adapter-fastly", + fallback: + "edgezero-adapter-fastly = { git = \"https://git@github.com/stackpop/edgezero.git\", package = \"edgezero-adapter-fastly\", default-features = false, features = [\"fastly\"] }", + features: &["fastly"], + }, +]; + +static FASTLY_FILE_SPECS: &[AdapterFileSpec] = &[ + AdapterFileSpec { + template: "fastly_Cargo_toml", + output: "Cargo.toml", + }, + AdapterFileSpec { + template: "fastly_src_main_rs", + output: "src/main.rs", + }, + AdapterFileSpec { + template: "fastly_cargo_config_toml", + output: ".cargo/config.toml", + }, + AdapterFileSpec { + template: "fastly_fastly_toml", + output: "fastly.toml", + }, +]; + +static FASTLY_TEMPLATE_REGISTRATIONS: &[TemplateRegistration] = &[ + TemplateRegistration { + name: "fastly_Cargo_toml", + contents: include_str!("../templates/Cargo.toml.hbs"), + }, + TemplateRegistration { + name: "fastly_src_main_rs", + contents: include_str!("../templates/src/main.rs.hbs"), + }, + TemplateRegistration { + name: "fastly_cargo_config_toml", + contents: include_str!("../templates/.cargo/config.toml.hbs"), + }, + TemplateRegistration { + name: "fastly_fastly_toml", + contents: include_str!("../templates/fastly.toml.hbs"), + }, +]; + +pub(super) const FASTLY_INSTALL_HINT: &str = + "install the Fastly CLI (https://www.fastly.com/documentation/reference/tools/cli/) and try again"; + +pub(super) struct FastlyCliAdapter; + +/// Outcome of scanning `fastly config-store list --json` for a +/// platform store id by `name`. Distinguishes three cases the +/// caller wants to act on differently: +/// +/// - `Found(id)` — happy path. +/// - `NotFound` — JSON parsed cleanly and the array contains +/// entries with well-formed `name` + `id` string fields, but no +/// entry matched `name`. Operator likely needs to run +/// `provision`. +/// - `SchemaDrift(detail)` — the JSON parsed but doesn't match +/// the expected shape (no `items` envelope nor bare array, OR +/// entries are missing `name` / `id` string fields, OR the +/// bytes didn't parse as JSON at all). Likely a fastly CLI +/// version bump that changed the output schema; surface the +/// detail so the operator can pin a known-compatible version. +#[derive(Debug)] +pub(super) enum ConfigStoreLookup { + Found(String), + NotFound, + SchemaDrift(String), +} + +// The three `validate_*` trait methods exist on `Adapter` because +// spin requires them (variable-name regex, `[component.*]` +// discovery, flat-namespace collision). The trait surface is typed +// generically so any future adapter with similar constraints can +// override — but fastly has no equivalent platform requirements, +// so the no-op defaults are correct: +// +// - `validate_app_config_keys`: Fastly Config Store keys accept +// alphanumeric + `-` / `_` / `.` up to 256 chars. Any reasonable +// Rust struct field name passes; no regex check needed. +// - `validate_adapter_manifest`: would require shelling out to +// `fastly compute validate` at validate-time. We keep +// `config validate` pure-Rust so it stays fast and +// tool-independent. +// - `validate_typed_secrets`: Fastly's KV / Config / Secret +// stores are independent namespaces — no spin-style flat- +// namespace collision risk to detect. +// +// `single_store_kinds` IS overridden below — explicitly returns +// `&[]` for documentation, matching the inherited default. +#[expect( + clippy::missing_trait_methods, + reason = "see the explanatory block comment immediately above; fastly's no-op defaults for the three validate_* hooks are intentional and documented. `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] { + &["service_id"] + } + + fn execute(&self, action: AdapterAction, args: &[String]) -> Result<(), String> { + match action { + // `fastly profile {create|delete|list}` is the native + // sign-in surface for Fastly Compute. EdgeZero stores no + // credentials — this is a thin shell-out. + AdapterAction::AuthLogin => { + run_native_cli("fastly", &["profile", "create"], FASTLY_INSTALL_HINT) + } + AdapterAction::AuthLogout => { + run_native_cli("fastly", &["profile", "delete"], FASTLY_INSTALL_HINT) + } + AdapterAction::AuthStatus => { + run_native_cli("fastly", &["profile", "list"], FASTLY_INSTALL_HINT) + } + AdapterAction::Build => { + let artifact = run::build(args)?; + log::info!("[edgezero] Fastly build complete -> {}", artifact.display()); + Ok(()) + } + AdapterAction::Deploy => run::deploy(args), + AdapterAction::Serve => run::serve(args), + other => Err(format!("fastly adapter does not support {other:?}")), + } + } + + fn name(&self) -> &'static str { + "fastly" + } + + 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 { + match mode { + ProvisionMode::Local => provision_local::provision( + manifest_root, + adapter_manifest_path, + stores, + deployed, + dry_run, + ), + ProvisionMode::Cloud => { + provision_cloud::provision(manifest_root, adapter_manifest_path, stores, dry_run) + } + // ProvisionMode is #[non_exhaustive]; a future mode variant + // is an explicit error so we don't dispatch via one of the + // two known arms by accident. + other => Err(format!( + "fastly adapter does not implement provision mode {other:?}" + )), + } + } + + 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. Local mode delegates to `provision_local` + // which 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()); + } + provision_local::provision_typed( + manifest_root, + adapter_manifest_path, + typed_secrets, + dry_run, + ) + } + + fn push_config_entries( + &self, + _manifest_root: &Path, + _adapter_manifest_path: Option<&str>, + _component_selector: Option<&str>, + store: &ResolvedStoreId, + entries: &[(String, String)], + _push_ctx: &AdapterPushContext<'_>, + dry_run: bool, + ) -> Result, String> { + push_cloud::write_entries(store, entries, dry_run) + } + + fn push_config_entries_local( + &self, + manifest_root: &Path, + adapter_manifest_path: Option<&str>, + _component_selector: Option<&str>, + store: &ResolvedStoreId, + entries: &[(String, String)], + _push_ctx: &AdapterPushContext<'_>, + dry_run: bool, + ) -> Result, String> { + push_local::write_entries( + manifest_root, + adapter_manifest_path, + store, + entries, + dry_run, + ) + } + + fn read_config_entry( + &self, + _manifest_root: &Path, + _adapter_manifest_path: Option<&str>, + _component_selector: Option<&str>, + store: &ResolvedStoreId, + key: &str, + _push_ctx: &AdapterPushContext<'_>, + ) -> Result { + push_cloud::read_entry(store, key) + } + + fn read_config_entry_local( + &self, + manifest_root: &Path, + adapter_manifest_path: Option<&str>, + _component_selector: Option<&str>, + store: &ResolvedStoreId, + key: &str, + _push_ctx: &AdapterPushContext<'_>, + ) -> Result { + push_local::read_entry(manifest_root, adapter_manifest_path, store, key) + } + + fn single_store_kinds(&self) -> &'static [&'static str] { + // Explicit `&[]` rather than inheriting the trait default, + // so the "Multi for every store kind" intent is documented + // at the call site. Fastly KV / Config / Secrets all + // support multiple distinct platform resources per kind, + // unlike spin's flat-namespace single-store model. + &[] + } + + 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, + run::synthesise_fastly_toml(app_name, deployed_service_id), + )]) + } +} + +#[inline] +pub fn register() { + register_adapter(&FASTLY_ADAPTER); + register_adapter_blueprint(&FASTLY_BLUEPRINT); +} + +#[ctor(unsafe)] +fn register_ctor() { + register(); +} + +// Shared process-wide mutex serialising PATH-mutating tests across every +// submodule test suite in this crate. Tests in `provision_local`, `push_cloud`, +// etc. all install shell shims via `PathPrepend` and would otherwise race on +// the environment variable. +#[cfg(all(test, unix))] +use std::sync::Mutex as PathMutationMutex; + +#[cfg(all(test, unix))] +pub(crate) fn path_mutation_guard() -> &'static PathMutationMutex<()> { + use std::sync::OnceLock; + static GUARD: OnceLock> = OnceLock::new(); + GUARD.get_or_init(|| PathMutationMutex::new(())) +} diff --git a/crates/edgezero-adapter-fastly/src/cli/provision_cloud.rs b/crates/edgezero-adapter-fastly/src/cli/provision_cloud.rs new file mode 100644 index 00000000..c1d6e2bb --- /dev/null +++ b/crates/edgezero-adapter-fastly/src/cli/provision_cloud.rs @@ -0,0 +1,930 @@ +use std::fs; +use std::io::ErrorKind; +use std::path::Path; +use std::process::Command; + +use edgezero_adapter::registry::{AdapterDeployedState, ProvisionOutcome, ProvisionStores}; + +use super::FASTLY_INSTALL_HINT; + +/// Cloud-mode `provision`: create Fastly platform stores via +/// `fastly -store create`, then write the corresponding +/// `[setup._stores.]` block to `fastly.toml`. Also +/// creates the `edgezero_runtime_env` config-store the runtime +/// override path depends on. +/// +/// Callers in `mod.rs` gate this on `ProvisionMode::Cloud`; Local +/// mode dispatches to `provision_local::provision`. +pub(super) fn provision( + manifest_root: &Path, + adapter_manifest_path: Option<&str>, + stores: &ProvisionStores<'_>, + dry_run: bool, +) -> Result { + // Fastly is Multi for every store kind. Each id maps 1:1 + // to a Fastly resource (kv-store / config-store / + // secret-store) created via the Fastly CLI; the manifest + // writeback declares the resource link for `fastly + // compute deploy` and the local viceroy server. + let Some(rel) = adapter_manifest_path else { + return Err( + "[adapters.fastly.adapter].manifest must point at fastly.toml for provision".to_owned(), + ); + }; + let fastly_path = manifest_root.join(rel); + + let mut out = Vec::new(); + for (kind, ids) in [ + ("kv", stores.kv), + ("config", stores.config), + ("secret", stores.secrets), + ] { + for store in ids { + // Fastly setup tables key on the resource name the + // CLI creates. The runtime resolves that same name + // via `EDGEZERO__STORES______NAME`, + // so provision must use the env-resolved PLATFORM + // name -- the logical id stays in status lines for + // human-facing wording. + let logical = store.logical.as_str(); + let name = store.platform.as_str(); + if dry_run { + out.push(format!( + "would run `fastly {kind}-store create --name={name}` and append [setup.{kind}_stores.{name}] to {} (logical id `{logical}`)", + fastly_path.display() + )); + continue; + } + if setup_block_present(&fastly_path, kind, name)? { + out.push(format!( + "fastly {kind}-store `{name}` (logical id `{logical}`) already declared in {}; skipping. To force a fresh remote: delete the [setup.{kind}_stores.{name}] block AND run `fastly {kind}-store delete --name={name}` (the old remote store lingers otherwise), then re-run provision.", + fastly_path.display() + )); + continue; + } + create_fastly_store(kind, name)?; + // If the platform store was created but the + // writeback fails, remote state and the local + // manifest are out of sync. Re-running `provision` + // would attempt to create the platform store again + // and fail with "already exists". Surface the + // recovery path explicitly so the operator isn't + // stuck. + append_fastly_setup(&fastly_path, kind, name).map_err(|err| { + format!( + "fastly {kind}-store `{name}` (logical id `{logical}`) was created remotely, but writeback to {path} failed: {err}\n To recover, either:\n 1. Manually append `[setup.{kind}_stores.{name}]` to {path} and re-run, or\n 2. Delete the orphan remote store via `fastly {kind}-store delete --name={name}` and re-run `edgezero provision --adapter fastly`.", + path = fastly_path.display() + ) + })?; + // Fastly's `[setup._stores.]` table is + // consumed ONLY when `fastly compute deploy` is + // creating a NEW service. If `service_id` is + // already present in fastly.toml, the service has + // been deployed at least once and subsequent + // deploys skip `[setup]` entirely — so the store + // exists in the account but has no resource link + // tying it to a service version, and the running + // Compute service can't open it. + // + // Detect that case and EMIT the exact one-shot + // command the operator should run to link the + // store. We deliberately don't auto-run it: the + // link cones the active version (`--autoclone`), + // and silently mutating an already-deployed + // service is surprising. The instruction names + // both the store-id lookup AND the link command so + // the operator can audit before committing. + let post_create_note = resource_link_note(&fastly_path, kind, name)?; + let mut line = format!( + "created fastly {kind}-store `{name}` (logical id `{logical}`); appended setup tables to {}", + fastly_path.display() + ); + if let Some(note) = post_create_note { + line.push('\n'); + line.push_str(¬e); + } + out.push(line); + } + } + // EdgeZero runtime overrides live in a dedicated Fastly Config + // Store named `edgezero_runtime_env`. Compute@Edge has no + // process env, so `EDGEZERO__STORES__CONFIG____KEY` and + // similar overrides have to come from a platform Config Store + // the runtime opens by name (see + // `env_config_from_runtime_dictionary` in lib.rs). Provision + // owns the store creation alongside the operator's declared + // stores so the runtime override path is wired correctly out + // of the box; if the store already appears in + // `[setup.config_stores.edgezero_runtime_env]`, skip. + let runtime_env_kind = "config"; + let runtime_env_name = "edgezero_runtime_env"; + if dry_run { + out.push(format!( + "would run `fastly {runtime_env_kind}-store create --name={runtime_env_name}` and append [setup.{runtime_env_kind}_stores.{runtime_env_name}] to {} (EdgeZero runtime override store)", + fastly_path.display() + )); + } else if !setup_block_present(&fastly_path, runtime_env_kind, runtime_env_name)? { + create_fastly_store(runtime_env_kind, runtime_env_name)?; + append_fastly_setup(&fastly_path, runtime_env_kind, runtime_env_name).map_err(|err| { + format!( + "fastly {runtime_env_kind}-store `{runtime_env_name}` was created remotely, but writeback to {path} failed: {err}\n Recover via `fastly {runtime_env_kind}-store delete --name={runtime_env_name}` then re-run `edgezero provision --adapter fastly`.", + path = fastly_path.display() + ) + })?; + // Same already-deployed-service caveat as the declared-store + // path: if `service_id` is set in fastly.toml, the + // `[setup.config_stores.edgezero_runtime_env]` table won't + // be re-applied by the next `fastly compute deploy`, so the + // runtime can't open the store. Emit the resource-link + // remediation alongside the populate-keys hint. + let post_create_note = + resource_link_note(&fastly_path, runtime_env_kind, runtime_env_name)?; + let mut line = format!( + "created fastly {runtime_env_kind}-store `{runtime_env_name}` (EdgeZero runtime override store); appended setup tables to {}\n Populate per-environment override keys with:\n fastly config-store-entry update --store-id= --key=EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY --value=app_config_staging --upsert", + fastly_path.display() + ); + if let Some(note) = post_create_note { + line.push('\n'); + line.push_str(¬e); + } + out.push(line); + } else { + // Already declared; nothing to do. + } + + if out.is_empty() { + out.push("fastly has no declared stores to provision".to_owned()); + } + // Read-back the service_id from fastly.toml (if the operator has + // already run `fastly compute deploy` at least once) and thread it + // into ProvisionOutcome.deployed so the CLI's writeback path lands + // `[adapters.fastly.deployed].service_id` in `edgezero.toml`. + // deployed_fields() advertises ownership of `service_id`; without + // this population the writeback is silently dropped and the + // operator has to hand-copy from fastly.toml. Dry-run still + // populates: the CLI's `merge_deployed_into_manifest` respects + // its own dry_run flag and will only report (not write) the + // pending edgezero.toml change. + let deployed = match read_fastly_service_id(&fastly_path)? { + Some(sid) => { + let mut state = AdapterDeployedState::default(); + state.fields.insert("service_id".to_owned(), sid); + Some(state) + } + None => None, + }; + Ok(match deployed { + Some(state) => ProvisionOutcome::with_deployed(out, state), + None => ProvisionOutcome::from_status_lines(out), + }) +} + +/// Shell out to `fastly -store create --name=`. The +/// caller resolves `` from `EDGEZERO__STORES______NAME` +/// (falling back to the logical id), so this helper takes whatever the +/// caller hands it and does not re-translate. Returns `Ok(())` on success; +/// surfaces the CLI's stderr verbatim on failure (including the "already +/// exists" error, which is the caller's signal to fix the toml or use a +/// different name). +/// +/// # Errors +/// Returns an error if `fastly` isn't on `PATH`, the child fails to +/// spawn, or the exit status is non-zero. +fn create_fastly_store(kind: &str, name: &str) -> Result<(), String> { + let subcommand = format!("{kind}-store"); + let name_arg = format!("--name={name}"); + let output = Command::new("fastly") + .args([subcommand.as_str(), "create", name_arg.as_str()]) + .output() + .map_err(|err| { + if err.kind() == ErrorKind::NotFound { + format!("`fastly` not found on PATH; {FASTLY_INSTALL_HINT}") + } else { + format!("failed to spawn `fastly`: {err}") + } + })?; + if output.status.success() { + return Ok(()); + } + // Idempotency: the fastly CLI returns non-zero with an + // "already exists" message when a store of this name was + // created by a prior provision run. Treat that as success so + // the operator's recovery path -- "either manually append the + // setup block or delete the remote and re-run provision" -- + // doesn't get blocked. The append step is itself idempotent, + // so re-running provision after a writeback failure is the + // documented recovery and now actually works. + let stderr = String::from_utf8_lossy(&output.stderr); + if looks_like_already_exists(&stderr, kind) { + return Ok(()); + } + Err(format!( + "`fastly {subcommand} create --name={name}` exited with status {}\nstderr: {}", + output.status, + stderr.trim() + )) +} + +/// Heuristic: does the stderr blob look like a "store of this +/// kind, by this name, already exists" failure from the fastly +/// CLI? Different CLI versions phrase this slightly differently +/// ("a kv-store with that name already exists", +/// `"Conflict: duplicate kv_store name"`, etc.); we require BOTH +/// a conflict-signal keyword AND a store-kind reference so an +/// unrelated 409 ("Error: 409 Conflict on /service/...") cannot +/// be misread as idempotent success. The earlier wider heuristic +/// would have swallowed any stderr containing the word +/// "conflict" and let provision march on to writeback against a +/// nonexistent store, surfacing as a confusing deploy-time error. +fn looks_like_already_exists(stderr: &str, kind: &str) -> bool { + let lower = stderr.to_ascii_lowercase(); + let conflict_signal = lower.contains("already exists") + || (lower.contains("duplicate") && lower.contains("name")) + || lower.contains("conflict"); + if !conflict_signal { + return false; + } + // Accept the three common spellings of `-store` / + // `_store` / ` store` so a fastly CLI version + // bump that reshuffles punctuation still hits. + let dashed = format!("{kind}-store"); + let underscored = format!("{kind}_store"); + let spaced = format!("{kind} store"); + lower.contains(&dashed) || lower.contains(&underscored) || lower.contains(&spaced) +} + +/// Read the top-level `service_id` from `fastly.toml`. Returns +/// `Ok(None)` when the file is absent (scaffold state before first +/// `fastly compute deploy`) or when `service_id` is missing / +/// empty. Used by `provision` to detect when an already-deployed +/// service needs a separate resource-link step beyond `[setup]` +/// (which `compute deploy` only consumes on the FIRST deploy). +fn read_fastly_service_id(path: &Path) -> Result, String> { + let raw = match fs::read_to_string(path) { + Ok(text) => text, + Err(err) if err.kind() == ErrorKind::NotFound => return Ok(None), + Err(err) => return Err(format!("failed to read {}: {err}", path.display())), + }; + let doc: toml_edit::DocumentMut = raw + .parse() + .map_err(|err| format!("failed to parse {}: {err}", path.display()))?; + let svc = doc + .get("service_id") + .and_then(|item| item.as_str()) + .map(str::to_owned) + .filter(|svc_id| !svc_id.is_empty()); + Ok(svc) +} + +/// If fastly.toml declares `service_id`, the next +/// `fastly compute deploy` skips `[setup]` entirely (it only runs on +/// the FIRST deploy of a service). Any store created by provision +/// after that needs a separate `fastly resource-link create` to link +/// the platform store to the service version. This helper returns the +/// remediation note to surface in the provision output, or `None` +/// when the service hasn't been deployed yet (so the next +/// `compute deploy` will pick up the `[setup]` row automatically). +fn resource_link_note(path: &Path, kind: &str, name: &str) -> Result, String> { + let note = read_fastly_service_id(path)?.map(|svc_id| { + format!( + " fastly.toml declares `service_id = \"{svc_id}\"`, so this service is already deployed -- `[setup]` will NOT be re-run on the next `fastly compute deploy`. The store exists in the account but is NOT yet linked to the service. To finish provisioning, look up the store id with `fastly {kind}-store list --json` (match by name=`{name}`), then run:\n fastly resource-link create --service-id={svc_id} --resource-id= --version=latest --autoclone --name={name}\n (the link clones the active version so existing traffic is not affected until you `fastly service-version activate`)." + ) + }); + Ok(note) +} + +/// Probe `fastly.toml` for the existence of `[setup._stores.]`. +/// Treats a missing file as "not present" so the first provision call +/// can create it. +/// +/// Why only `[setup]` (no longer `[local_server]`): an empty +/// `[local_server._stores.]` table doesn't satisfy +/// fastly's local-server schema — config-stores need +/// `format = "inline-toml"` + a contents table, kv/secret stores +/// need a JSON `file = "..."` or an array of `{key, data}` entries. +/// Writing an empty table makes `fastly compute serve` skip the +/// declared store or error at startup. `provision`'s job is the +/// remote / `[setup]` half; local-server stanzas are written by +/// `edgezero config push --adapter fastly --local` +/// (config-stores only), and kv/secret local-server seeding is +/// hand-edited until we add equivalent writers for those kinds. +fn setup_block_present(path: &Path, kind: &str, id: &str) -> Result { + let raw = match fs::read_to_string(path) { + Ok(text) => text, + Err(err) if err.kind() == ErrorKind::NotFound => return Ok(false), + Err(err) => return Err(format!("failed to read {}: {err}", path.display())), + }; + let doc: toml_edit::DocumentMut = raw + .parse() + .map_err(|err| format!("failed to parse {}: {err}", path.display()))?; + let plural = format!("{kind}_stores"); + Ok(doc + .get("setup") + .and_then(|root| root.get(plural.as_str())) + .and_then(|kind_tbl| kind_tbl.get(id)) + .is_some()) +} + +/// Append `[setup._stores.]` to `fastly.toml`. Creates +/// the file (and the parent `[setup]` table) if absent. The block +/// is written as an empty table — that's what +/// `fastly compute deploy` consumes the first time it creates a +/// service: the resource-link declaration is enough, and the +/// account-level resource itself is already created in the +/// preceding `create_fastly_store` shellout. +/// +/// We DON'T write `[local_server._stores.]` here: see +/// `setup_block_present`'s doc for the schema rationale. The local- +/// server seeding moved to `config push --local` (config-stores +/// only), so provision only owns the remote / setup half. +fn append_fastly_setup(path: &Path, kind: &str, id: &str) -> Result<(), String> { + use toml_edit::{table, DocumentMut, Item}; + + let raw = match fs::read_to_string(path) { + Ok(text) => text, + Err(err) if err.kind() == ErrorKind::NotFound => String::new(), + Err(err) => return Err(format!("failed to read {}: {err}", path.display())), + }; + let mut doc: DocumentMut = raw + .parse() + .map_err(|err| format!("failed to parse {}: {err}", path.display()))?; + + let plural = format!("{kind}_stores"); + let parent_entry = doc.entry("setup").or_insert_with(table); + let parent_tbl = parent_entry.as_table_mut().ok_or_else(|| { + format!( + "{}: `setup` exists but is not a table; refusing to edit in place", + path.display() + ) + })?; + let kind_entry = parent_tbl + .entry(plural.as_str()) + .or_insert_with(|| Item::Table(toml_edit::Table::new())); + let kind_tbl = kind_entry.as_table_mut().ok_or_else(|| { + format!( + "{}: `setup.{plural}` exists but is not a table; refusing to edit in place", + path.display() + ) + })?; + if !kind_tbl.contains_key(id) { + kind_tbl.insert(id, Item::Table(toml_edit::Table::new())); + } + + fs::write(path, doc.to_string()) + .map_err(|err| format!("failed to write {}: {err}", path.display()))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::super::run::synthesise_fastly_toml; + use super::super::FastlyCliAdapter; + use super::*; + use edgezero_adapter::registry::{ + Adapter as _, ProvisionMode, ResolvedStoreId, TypedSecretEntry, + }; + use tempfile::tempdir; + + // Shared fixture names. + const TEST_KV_ID: &str = "sessions"; + const TEST_CONFIG_ID: &str = "app_config"; + const TEST_SECRET_ID: &str = "default"; + + // ---------- looks_like_already_exists ---------- + + #[test] + fn looks_like_already_exists_recognises_common_phrasings() { + // Real-shaped fastly CLI error strings (paraphrased; the + // CLI varies across versions). Each must be detected so + // create_fastly_store can treat it as idempotent success. + assert!(looks_like_already_exists( + "Error: a kv-store with that name already exists", + "kv", + )); + assert!(looks_like_already_exists( + "ERROR: Conflict (409): duplicate kv_store name", + "kv", + )); + assert!(looks_like_already_exists( + "A config-store with this name already exists", + "config", + )); + // Spaced form: some fastly CLI versions emit prose + // ("kv store"); accept it alongside the punctuated forms. + assert!(looks_like_already_exists( + "Error: kv store conflict: name already in use", + "kv", + )); + } + + #[test] + fn looks_like_already_exists_rejects_unrelated_errors() { + assert!(!looks_like_already_exists( + "Error: unauthenticated; run `fastly profile create`", + "kv", + )); + assert!(!looks_like_already_exists( + "Error: network unreachable", + "kv", + )); + assert!(!looks_like_already_exists("", "kv")); + } + + #[test] + fn looks_like_already_exists_rejects_unrelated_conflict_errors() { + // The earlier wider heuristic swallowed ANY stderr + // containing "conflict" or "already exists", which would + // misread an unrelated 409 from a different fastly + // subcommand (e.g. a service-version conflict during a + // parallel deploy) as idempotent store-create success. + // Now we require the kind context too, so unrelated + // conflicts surface as failures. + assert!( + !looks_like_already_exists( + "Error: 409 Conflict on /service/abc/version/42 -- already exists", + "kv", + ), + "service-version conflict must NOT be misread as kv-store idempotency" + ); + assert!( + !looks_like_already_exists( + "Error: invalid duplicate request; check name resolution", + "kv", + ), + "unrelated `duplicate ... name` AND-match must NOT trigger" + ); + // And the kind must match: a config-store conflict must + // not look-like-already-exists for a kv-store create call. + assert!( + !looks_like_already_exists("Error: a config-store with that name already exists", "kv",), + "wrong-kind conflict must NOT trigger" + ); + } + + // ---------- setup_block_present ---------- + + #[test] + fn setup_block_present_true_when_table_exists() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write( + &path, + "name = \"demo\"\n[setup.kv_stores.sessions]\n[local_server.kv_stores.sessions]\n", + ) + .expect("write"); + assert!(setup_block_present(&path, "kv", TEST_KV_ID).expect("probe")); + } + + #[test] + fn setup_block_present_false_when_id_missing() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write(&path, "name = \"demo\"\n[setup.kv_stores.other]\n").expect("write"); + assert!(!setup_block_present(&path, "kv", TEST_KV_ID).expect("probe")); + } + + #[test] + fn setup_block_present_false_for_missing_file() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("does-not-exist.toml"); + assert!(!setup_block_present(&path, "kv", TEST_KV_ID).expect("probe")); + } + + #[test] + fn setup_block_present_true_when_only_setup_exists() { + // Post-F6 (PR #269 round 2): `setup_block_present` only + // checks `[setup._stores.]`. The pre-fix check + // ALSO required `[local_server._stores.]`, but + // writing an empty `[local_server.*]` table didn't match + // fastly's local-server schema (config-stores need + // `format` + contents, kv/secret stores need a JSON file + // or `{key, data}` entries). Local-server seeding moved + // to `config push --adapter fastly --local`, so probe + // only cares about `[setup]` now. + let dir = tempdir().expect("tempdir"); + let only_setup = dir.path().join("only_setup.toml"); + fs::write(&only_setup, "name = \"demo\"\n[setup.kv_stores.sessions]\n").expect("write"); + assert!( + setup_block_present(&only_setup, "kv", TEST_KV_ID).expect("probe"), + "[setup.*] alone is now sufficient: {only_setup:?}" + ); + + let only_local = dir.path().join("only_local.toml"); + fs::write( + &only_local, + "name = \"demo\"\n[local_server.kv_stores.sessions]\n", + ) + .expect("write"); + assert!( + !setup_block_present(&only_local, "kv", TEST_KV_ID).expect("probe"), + "[local_server.*] alone is NOT a provisioned-setup signal" + ); + } + + // ---------- append_fastly_setup ---------- + + #[test] + fn append_fastly_setup_creates_setup_table_in_minimal_file() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write(&path, "name = \"demo\"\n").expect("write"); + append_fastly_setup(&path, "kv", TEST_KV_ID).expect("append"); + let after = fs::read_to_string(&path).expect("read back"); + assert!( + after.contains("[setup.kv_stores.sessions]"), + "setup table added: {after}" + ); + // Post-F6: no `[local_server.*]` write — that empty stanza + // didn't satisfy fastly's local-server schema and made + // `fastly compute serve` error or skip the store. Local- + // server seeding is now `config push --adapter fastly + // --local`'s job. + assert!( + !after.contains("[local_server.kv_stores.sessions]"), + "[local_server.*] empty table no longer written by provision: {after}" + ); + assert!( + after.contains("name = \"demo\""), + "preserved original keys: {after}" + ); + } + + #[test] + fn append_fastly_setup_appends_alongside_existing_kind_tables() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write(&path, "[setup.kv_stores.cache]\n").expect("write"); + append_fastly_setup(&path, "kv", TEST_KV_ID).expect("append"); + let after = fs::read_to_string(&path).expect("read back"); + assert!( + after.contains("[setup.kv_stores.cache]"), + "existing entry kept: {after}" + ); + assert!( + after.contains("[setup.kv_stores.sessions]"), + "new entry added: {after}" + ); + } + + #[test] + fn append_fastly_setup_is_idempotent_on_duplicate_id() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write(&path, "[setup.kv_stores.sessions]\nfoo = \"keep\"\n").expect("write"); + append_fastly_setup(&path, "kv", TEST_KV_ID).expect("idempotent append"); + let after = fs::read_to_string(&path).expect("read back"); + assert!( + after.contains("foo = \"keep\""), + "did not stomp existing key: {after}" + ); + } + + #[test] + fn append_fastly_setup_creates_file_when_missing() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + // Note: no fs::write — file starts absent. + append_fastly_setup(&path, "config", TEST_CONFIG_ID).expect("create"); + let after = fs::read_to_string(&path).expect("read back"); + assert!(after.contains("[setup.config_stores.app_config]")); + assert!( + !after.contains("[local_server.config_stores.app_config]"), + "[local_server.*] no longer written by provision: {after}" + ); + } + + #[test] + fn append_fastly_setup_preserves_top_comments() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write( + &path, + "# managed by hand -- please keep this line\nname = \"demo\"\n", + ) + .expect("write"); + append_fastly_setup(&path, "secret", TEST_SECRET_ID).expect("append"); + let after = fs::read_to_string(&path).expect("read back"); + assert!( + after.contains("# managed by hand"), + "preserved comment: {after}" + ); + } + + // ---------- provision (dry-run + error path) ---------- + + #[test] + fn provision_dry_run_does_not_invoke_fastly() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write(&path, "name = \"demo\"\n").expect("write"); + let kv_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_KV_ID]); + let config_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_CONFIG_ID]); + let secret_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_SECRET_ID]); + let stores = ProvisionStores { + config: &config_ids, + kv: &kv_ids, + secrets: &secret_ids, + }; + let out = FastlyCliAdapter + .provision( + dir.path(), + Some("fastly.toml"), + None, + &stores, + None, + ProvisionMode::Cloud, + true, + ) + .expect("dry-run succeeds"); + // 1 KV + 1 config + 1 secret + 1 runtime-env = 4 status lines. + 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.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. + let after = fs::read_to_string(&path).expect("read"); + assert_eq!(after, "name = \"demo\"\n", "dry-run mutated fastly.toml"); + } + + /// Cloud provision must populate `ProvisionOutcome.deployed` with + /// `service_id` when fastly.toml declares it. `deployed_fields()` + /// claims ownership of `service_id`; without this the writeback to + /// `[adapters.fastly.deployed]` in edgezero.toml is silently + /// dropped and the operator has to hand-copy from fastly.toml. + /// + /// Regression test: pre-`Adapters` fix, the cloud arm unconditionally + /// returned `deployed: None`. + #[test] + fn provision_populates_deployed_service_id_from_fastly_toml() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + // fastly.toml declares a service_id (as it would after a first + // successful `fastly compute deploy`). + fs::write( + &path, + "manifest_version = 3\nname = \"demo\"\nservice_id = \"SVC_ALREADY_DEPLOYED\"\n\n[local_server]\n", + ) + .expect("write"); + let stores = ProvisionStores { + config: &[], + kv: &[], + secrets: &[], + }; + let outcome = FastlyCliAdapter + .provision( + dir.path(), + Some("fastly.toml"), + None, + &stores, + None, + ProvisionMode::Cloud, + true, // dry-run avoids invoking the real fastly CLI + ) + .expect("dry-run succeeds"); + let deployed = outcome + .deployed + .as_ref() + .expect("deployed must be Some when fastly.toml declares service_id"); + assert_eq!( + deployed.fields.get("service_id").map(String::as_str), + Some("SVC_ALREADY_DEPLOYED"), + "service_id must flow into ProvisionOutcome.deployed" + ); + } + + /// Inverse: when `fastly.toml` has no `service_id` (fresh project, + /// not yet deployed), cloud provision returns `deployed: None`. + /// Nothing to write back -- the operator hasn't picked a service + /// yet. + #[test] + fn provision_returns_none_deployed_when_fastly_toml_has_no_service_id() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write( + &path, + "manifest_version = 3\nname = \"demo\"\n\n[local_server]\n", + ) + .expect("write"); + let stores = ProvisionStores { + config: &[], + kv: &[], + secrets: &[], + }; + let outcome = FastlyCliAdapter + .provision( + dir.path(), + Some("fastly.toml"), + None, + &stores, + None, + ProvisionMode::Cloud, + true, + ) + .expect("dry-run succeeds"); + assert!( + outcome.deployed.is_none(), + "no service_id in fastly.toml means deployed must be None" + ); + } + + #[test] + fn provision_errors_when_adapter_manifest_path_missing() { + let dir = tempdir().expect("tempdir"); + let kv_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_KV_ID]); + let stores = ProvisionStores { + config: &[], + kv: &kv_ids, + secrets: &[], + }; + let err = FastlyCliAdapter + .provision( + dir.path(), + None, + None, + &stores, + None, + ProvisionMode::Cloud, + true, + ) + .expect_err("missing adapter manifest path must error"); + assert!( + err.contains("fastly.toml"), + "error names what's missing: {err}" + ); + } + + #[test] + fn provision_with_no_declared_stores_says_so() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + // Pre-populate the runtime-env block so the provision flow's + // unconditional runtime-env step skips (otherwise it would + // shell out to real `fastly` to create the store). + fs::write( + &path, + "name = \"demo\"\n[setup.config_stores.edgezero_runtime_env]\n", + ) + .expect("write"); + let stores = ProvisionStores { + config: &[], + kv: &[], + secrets: &[], + }; + let out = FastlyCliAdapter + .provision( + dir.path(), + Some("fastly.toml"), + None, + &stores, + None, + ProvisionMode::Cloud, + false, + ) + .expect("no-store provision is fine"); + assert_eq!( + out.status_lines, + vec!["fastly has no declared stores to provision"] + ); + } + + #[test] + fn provision_skips_id_when_setup_block_already_present() { + // setup_block_present's role in the flow: re-running + // provision after the user already declared a store in + // fastly.toml must be a no-op (no shell-out to fastly). + // We can verify this in a real (non-dry-run) call because + // the skip path bypasses create_fastly_store entirely. + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write( + &path, + "[setup.kv_stores.sessions]\n[local_server.kv_stores.sessions]\n\ + [setup.config_stores.edgezero_runtime_env]\n", + ) + .expect("write"); + let kv_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_KV_ID]); + let stores = ProvisionStores { + config: &[], + kv: &kv_ids, + secrets: &[], + }; + let out = FastlyCliAdapter + .provision( + dir.path(), + Some("fastly.toml"), + None, + &stores, + None, + ProvisionMode::Cloud, + false, + ) + .expect("skip path succeeds without invoking fastly"); + 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 + /// `fastly compute deploy` skips `[setup]` entirely. provision + /// must emit the `fastly resource-link create` remediation for + /// every store it creates -- including the implicit + /// `edgezero_runtime_env` store the runtime override path + /// depends on. Without this, a freshly-provisioned override + /// store would not be linked to the already-deployed service + /// and the runtime would silently fall back to baked defaults. + #[test] + fn provision_emits_resource_link_note_for_runtime_env_on_existing_service() { + // Dry-run only -- we just want to drive the resource_link_note + // helper for the runtime-env store branch. The real-create + // path can't run in tests (would shell out to `fastly`). + // The dry-run output line for runtime-env doesn't include the + // note (the helper only fires on real create), so we test the + // helper directly here. + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write(&path, "name = \"demo\"\nservice_id = \"abc123svc\"\n").expect("write"); + let note = resource_link_note(&path, "config", "edgezero_runtime_env") + .expect("read service_id") + .expect("note present when service_id set"); + assert!( + note.contains("service_id = \"abc123svc\""), + "note quotes the service id: {note}" + ); + assert!( + note.contains("fastly config-store list --json"), + "note tells operator how to find the store id: {note}" + ); + assert!( + note.contains("name=`edgezero_runtime_env`"), + "note names the runtime override store: {note}" + ); + assert!( + note.contains( + "fastly resource-link create --service-id=abc123svc --resource-id= --version=latest --autoclone --name=edgezero_runtime_env" + ), + "note carries the full resource-link command: {note}" + ); + } + + /// And the inverse: no `service_id` (a service that hasn't been + /// deployed yet) means `[setup]` will be applied on the next + /// `compute deploy`, so no manual resource-link step is needed. + /// The helper must return `None` to avoid noisy false-positive + /// guidance. + #[test] + fn provision_skips_resource_link_note_when_service_undeployed() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write(&path, "name = \"demo\"\n").expect("write"); + let note = + resource_link_note(&path, "config", "edgezero_runtime_env").expect("read service_id"); + assert!( + note.is_none(), + "no service_id => no resource-link prompt: {note:?}" + ); + } + + /// 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"); + } +} diff --git a/crates/edgezero-adapter-fastly/src/cli/provision_local.rs b/crates/edgezero-adapter-fastly/src/cli/provision_local.rs new file mode 100644 index 00000000..fca27745 --- /dev/null +++ b/crates/edgezero-adapter-fastly/src/cli/provision_local.rs @@ -0,0 +1,1672 @@ +use std::fs; +use std::path::Path; + +use edgezero_adapter::registry::{ + AdapterDeployedState, ProvisionOutcome, ProvisionStores, TypedSecretEntry, +}; + +/// 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. +pub(super) fn provision( + manifest_root: &Path, + adapter_manifest_path: Option<&str>, + stores: &ProvisionStores<'_>, + deployed: Option<&AdapterDeployedState>, + dry_run: bool, +) -> Result { + use toml_edit::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. + // + // TOML root-key positioning matters here: once the parsed doc has + // any headed sub-table (`[scripts]`, `[local_server]`, …), a naive + // `doc.insert("service_id", …)` appends the scalar AFTER those + // headers, and the re-serialised file parses the value as + // `local_server.service_id`. `upsert_root_scalar_before_tables` + // preserves the "scalars before sub-tables" TOML rule regardless + // of insertion order. + if let Some(sid) = deployed.and_then(|state| state.fields.get("service_id")) { + upsert_root_scalar_before_tables(&mut doc, "service_id", 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::from_status_lines(status_lines)) +} + +/// Local-mode `provision_typed`: append `[[local_server.secret_stores.]]` +/// entries in `fastly.toml`. Cloud secret storage uses `fastly secret-store-entry +/// create` at deploy time — the caller in `mod.rs` gates this on `ProvisionMode::Local` +/// and returns `Ok(ProvisionOutcome::default())` for cloud mode. +pub(super) fn provision_typed( + manifest_root: &Path, + adapter_manifest_path: Option<&str>, + typed_secrets: &[TypedSecretEntry<'_>], + dry_run: bool, +) -> Result { + 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::from_status_lines(status_lines)) +} + +/// Upsert a scalar key at the root of `doc`, guaranteeing it lands +/// BEFORE any headed sub-table. +/// +/// TOML root-key rule: once a `[header]` opens a sub-table, every +/// subsequent `key = "value"` line is parsed as a child of that header. +/// `toml_edit::DocumentMut::insert` appends at end-of-order, so +/// inserting a root scalar after the doc has picked up any `[scripts]` +/// / `[local_server]` header from parse silently produces +/// `local_server.` on re-emit. +/// +/// If the key already exists, `insert` preserves its position (the +/// value cell is overwritten in place), so we only need the reorder +/// dance in the fresh-insert case: hoist every root-level sub-table +/// / array-of-tables out, insert the scalar, then re-attach the +/// tables in original order. Preserves comments and decor on both +/// the scalar's neighbours and the sub-tables via `toml_edit`'s +/// per-item decor tracking. +fn upsert_root_scalar_before_tables(doc: &mut toml_edit::DocumentMut, key: &str, val: &str) { + use toml_edit::value; + let table = doc.as_table_mut(); + if table.contains_key(key) { + table.insert(key, value(val)); + return; + } + let sub_table_keys: Vec = table + .iter() + .filter(|(_, item)| item.is_table() || item.is_array_of_tables()) + .map(|(name, _)| name.to_owned()) + .collect(); + let mut removed: Vec<(String, toml_edit::Item)> = Vec::with_capacity(sub_table_keys.len()); + for name in sub_table_keys { + if let Some(item) = table.remove(&name) { + removed.push((name, item)); + } + } + table.insert(key, value(val)); + for (name, item) in removed { + table.insert(&name, item); + } +} + +/// 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(()) +} + +/// 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 = +/// "_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. +/// +/// **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<'_>, +) -> 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() + })?; + + // 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 (key, platform) in &managed_keys { + contents_tbl.insert(key, value(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) +} + +/// 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) +} + +/// Write the local-server config-store entries to `fastly.toml`: +/// `[local_server.config_stores.]` becomes +/// `format = "inline-toml"`, and `[local_server.config_stores..contents]` +/// gets the flat `key = "value"` pairs (overwriting any previous +/// values). Idempotent — re-running just rewrites `contents`. Other +/// blocks in `fastly.toml` (setup, scripts, the actual `[local_server]` +/// secret stores, etc.) are preserved via `toml_edit`. +pub(super) fn write_fastly_local_config_store( + path: &Path, + platform_name: &str, + entries: &[(String, String)], +) -> Result<(), String> { + use std::io::ErrorKind; + use toml_edit::{table, DocumentMut, Item, Table, Value}; + + let raw = match fs::read_to_string(path) { + Ok(text) => text, + Err(err) if err.kind() == ErrorKind::NotFound => String::new(), + Err(err) => return Err(format!("failed to read {}: {err}", path.display())), + }; + let mut doc: DocumentMut = raw + .parse() + .map_err(|err| format!("failed to parse {}: {err}", path.display()))?; + + let local_server_entry = doc.entry("local_server").or_insert_with(table); + let local_server_tbl = local_server_entry.as_table_mut().ok_or_else(|| { + format!( + "{}: `local_server` exists but is not a table; refusing to edit in place", + path.display() + ) + })?; + let config_stores_entry = local_server_tbl + .entry("config_stores") + .or_insert_with(|| Item::Table(Table::new())); + let config_stores_tbl = config_stores_entry.as_table_mut().ok_or_else(|| { + format!( + "{}: `local_server.config_stores` exists but is not a table; refusing to edit in place", + path.display() + ) + })?; + + // Upsert into the existing per-store contents table so a + // `config push --key app_config_staging` does NOT wipe the + // previously-pushed `app_config` blob. Spec 12.7 requires + // default + staging keys to coexist so the runtime + // EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY env var can + // switch between them. (Earlier wholesale-replace was a + // misread of the "stale entries don't linger" property: + // that applies WITHIN a key (old chunks for the same root + // become unreferenced when a new chunk-set installs a new + // pointer), NOT across sibling keys.) + let store_entry = config_stores_tbl.entry(platform_name).or_insert_with(|| { + let mut tbl = Table::new(); + tbl.insert("format", toml_edit::value("inline-toml")); + tbl.insert("contents", Item::Table(Table::new())); + Item::Table(tbl) + }); + let store_tbl = store_entry.as_table_mut().ok_or_else(|| { + format!( + "{}: `local_server.config_stores.{platform_name}` exists but is not a table; refusing to edit in place", + path.display() + ) + })?; + // Ensure the `format` key is present even on a pre-existing + // entry that omitted it. + if !store_tbl.contains_key("format") { + store_tbl.insert("format", toml_edit::value("inline-toml")); + } + 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.{platform_name}.contents` exists but is not a table; refusing to edit in place", + path.display() + ) + })?; + for (key, value) in entries { + contents_tbl.insert(key, Item::Value(Value::from(value.clone()))); + } + + fs::write(path, doc.to_string()) + .map_err(|err| format!("failed to write {}: {err}", path.display()))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + #[cfg(unix)] + use super::super::path_mutation_guard; + use super::super::run::synthesise_fastly_toml; + use super::super::FastlyCliAdapter; + use super::*; + use edgezero_adapter::registry::{ + Adapter as _, ProvisionMode, ResolvedStoreId, TypedSecretEntry, + }; + #[cfg(unix)] + use std::env; + #[cfg(unix)] + use std::ffi::OsString; + use tempfile::tempdir; + + // Shared fixture names. Pinning these as consts (instead of + // inline `"sessions"` / `"app_config"` per call site) keeps the + // setup-vs-assertion pair in sync -- a typo in one place no + // longer silently divorces from the other, because both reference + // the same const. Also names the intent: these are the LOGICAL + // store ids the fastly adapter operates on, not arbitrary strings. + const TEST_KV_ID: &str = "sessions"; + const TEST_CONFIG_ID: &str = "app_config"; + + /// RAII guard: prepends a directory to `$PATH` and restores the original + /// value on drop. + #[cfg(unix)] + struct PathPrepend { + original: Option, + } + + #[cfg(unix)] + impl PathPrepend { + fn new(extra: &Path) -> Self { + let original = env::var_os("PATH"); + let new_path = match &original { + Some(prev) => { + let mut accum = OsString::from(extra); + accum.push(":"); + accum.push(prev); + accum + } + None => OsString::from(extra), + }; + env::set_var("PATH", new_path); + Self { original } + } + } + + #[cfg(unix)] + impl Drop for PathPrepend { + fn drop(&mut self) { + match self.original.take() { + Some(prev) => env::set_var("PATH", prev), + None => env::remove_var("PATH"), + } + } + } + + /// A shell script named `fastly` that exits non-zero and prints an + /// unambiguous diagnostic to stderr — installed on `$PATH` to + /// detect any (forbidden) invocation of the platform CLI during a + /// Local-mode provision. Any call fails the test with `exit 42`. + #[cfg(unix)] + fn fake_fastly_panicking() -> tempfile::TempDir { + use std::os::unix::fs::PermissionsExt as _; + let dir = tempdir().expect("tempdir"); + let script = dir.path().join("fastly"); + fs::write( + &script, + "#!/usr/bin/env bash\necho 'fastly was called during local provision' >&2\nexit 42\n", + ) + .expect("write fake fastly"); + let mut perms = fs::metadata(&script).expect("stat").permissions(); + perms.set_mode(0o755); + fs::set_permissions(&script, perms).expect("chmod +x"); + dir + } + + // ---------- 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}" + ); + // Reparse-and-index instead of `.contains("key = \"__init__\"")` + // + `.contains("data = \"\"")`: those substrings would pass for + // BOTH the correct nested-row form AND the scenario where the + // stub keys land at the doc root (same class as the shipped + // service_id bug). Lock the row on the actual + // `[[local_server.kv_stores.sessions]]` block. + let after_doc: toml_edit::DocumentMut = after.parse().expect("re-parse merged fastly.toml"); + let kv_row = after_doc + .get("local_server") + .and_then(|item| item.get("kv_stores")) + .and_then(|item| item.get("sessions")) + .and_then(toml_edit::Item::as_array_of_tables) + .and_then(|arr| arr.get(0)) + .expect("[[local_server.kv_stores.sessions]] with at least one row"); + assert_eq!( + kv_row.get("key").and_then(toml_edit::Item::as_str), + Some("__init__"), + "kv stub `key = \"__init__\"` must sit inside [[local_server.kv_stores.sessions]]: {after}" + ); + assert_eq!( + kv_row.get("data").and_then(toml_edit::Item::as_str), + Some(""), + "kv stub `data = \"\"` must sit inside [[local_server.kv_stores.sessions]]: {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}" + ); + } + + /// 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 + /// 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}" + ); + // Regression: `toml_edit::DocumentMut::insert` on a doc that + // already parsed `[local_server]` was appending `service_id` + // AFTER the header, so re-parse read it as + // `local_server.service_id` — a silent divergence that + // `.contains("service_id = \"SVC1\"")` never caught. Parse the + // re-emitted file and assert the key lives at the ROOT. + let reparsed: toml_edit::DocumentMut = + after.parse().expect("re-parse must succeed after upsert"); + assert_eq!( + reparsed.get("service_id").and_then(toml_edit::Item::as_str), + Some("SVC1"), + "service_id must live at the TOML root (not as local_server.service_id): {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" + ); + } + + // ---------- 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" + ); + } + + /// 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}" + ); + } + + // ---------- Section 9: provision_local_* contract suite ---------- + // + // Cross-adapter contract for `provision(mode=Local)`. Mirrors the + // Cloudflare/Spin/Axum suites so the four adapters share a single + // observable specification: the first run writes an expected set + // of files, re-provision is byte-identical, operator hand-edits to + // sibling entries survive a subsequent write, and Local mode never + // shells out to the platform CLI. + // + // Test #5 (additive merge of a new store into the existing + // `edgezero_runtime_env` block) is already covered by + // `fastly_local_provision_additively_merges_new_stores_into_existing_runtime_env` + // above — not re-implemented here to avoid duplicate coverage. + + /// Section 9.1 — First run: empty fixture with one KV and one + /// CONFIG store yields a `fastly.toml` with the edgezero-provision + /// header, per-kind `[local_server.*_stores.*]` blocks in their + /// expected shape, and an `edgezero_runtime_env.contents` + /// sub-table populated with `__NAME` lines for every declared + /// store. `contents` MUST remain a TABLE (spec regression guard). + #[test] + fn provision_local_first_run_writes_expected_files() { + 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"); + assert!( + path.exists(), + "fastly.toml exists after first-run provision" + ); + let after = fs::read_to_string(&path).expect("read"); + assert!( + after.starts_with("# edgezero-provision: v1"), + "manifest starts with edgezero-provision header: {after}" + ); + // KV: array-of-tables with the stub row. Reparse-and-index -- + // see the sibling test's rationale (bare `.contains(...)` + // passes for both correct-nested and shipped root-drift bug). + let after_doc: toml_edit::DocumentMut = + after.parse().expect("re-parse first-run fastly.toml"); + let kv_row = after_doc + .get("local_server") + .and_then(|item| item.get("kv_stores")) + .and_then(|item| item.get("sessions")) + .and_then(toml_edit::Item::as_array_of_tables) + .and_then(|arr| arr.get(0)) + .expect("[[local_server.kv_stores.sessions]] with at least one row"); + assert_eq!( + kv_row.get("key").and_then(toml_edit::Item::as_str), + Some("__init__"), + "kv stub `key` inside the sessions block: {after}" + ); + assert_eq!( + kv_row.get("data").and_then(toml_edit::Item::as_str), + Some(""), + "kv stub `data` inside the sessions block: {after}" + ); + // CONFIG: table block with `format = "inline-toml"` plus an + // empty `contents` SUB-TABLE (never `contents = ""`). + 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 (spec regression guard): {after}" + ); + // Runtime-env: __NAME line for every declared store. + 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__KV__SESSIONS__NAME = "sessions""#), + "KV __NAME line: {after}" + ); + assert!( + after.contains(r#"EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME = "app_config""#), + "CONFIG __NAME line: {after}" + ); + // Re-parse to confirm both `contents` slots are tables (the + // shape Viceroy + `config push --local` expect). + let doc: toml_edit::DocumentMut = after.parse().expect("re-parse"); + assert!( + doc["local_server"]["config_stores"]["app_config"]["contents"] + .as_table() + .is_some(), + "app_config.contents parses as a table" + ); + assert!( + doc["local_server"]["config_stores"]["edgezero_runtime_env"]["contents"] + .as_table() + .is_some(), + "edgezero_runtime_env.contents parses as a table" + ); + } + + /// Section 9.2 — Re-provision must be byte-identical. This is the + /// operator's contract that `provision --adapter fastly` is safe + /// to re-run: no drift in whitespace, no reordering, no re-emit of + /// entries that were already present. + #[test] + fn provision_local_re_provision_is_byte_identical() { + 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 provision 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 provision succeeds"); + let after_second = fs::read_to_string(&path).expect("read after second"); + assert_eq!( + after_first, after_second, + "second provision is byte-identical to the first" + ); + } + + /// Section 9.3 — Fastly-specific: after the base `fastly.toml` is + /// provisioned and the operator hand-edits a + /// `[[local_server.secret_stores.default]]` entry with a custom + /// `env` mapping, a subsequent `provision_typed` call that adds a + /// DIFFERENT key must land the new entry as a sibling in the same + /// array-of-tables — WITHOUT rewriting the operator's `env` + /// mapping on the pre-existing row (idempotent-append semantics). + /// + /// Fastly is the only adapter where the operator maps a secret + /// store `key` to an OS env var via the `env` field; the writer + /// MUST NOT clobber that mapping when appending new keys. + /// + /// Renamed 2026-07 (deep self-review finding P1-f): the prior + /// name `provision_local_push_after_provision_preserves_*` + /// promised a push→provision integration test but the body only + /// re-runs `provision_typed` twice; the invariant is + /// re-provision idempotency, not push semantics. + #[test] + fn provision_typed_local_re_run_preserves_operator_env_mapping_on_secret_store_entry() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + // Base manifest + the operator's hand-edited entry. The + // operator maps their secret's local `key = "custom_key"` to + // the real-world OS env var `REAL_ENV_MAPPING` — an override + // that must survive future writer runs. + let mut seed = synthesise_fastly_toml("demo", None); + seed.push_str( + "\n[[local_server.secret_stores.default]]\nkey = \"custom_key\"\nenv = \"REAL_ENV_MAPPING\"\n", + ); + fs::write(&path, &seed).expect("write"); + // A new secret arrives — a DIFFERENT key under the same store. + let entries = [TypedSecretEntry::new( + "default", + "api_token", + "different_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"); + // Operator's exact env mapping survives byte-for-byte. + assert!( + after.contains(r#"env = "REAL_ENV_MAPPING""#), + "operator's `env = \"REAL_ENV_MAPPING\"` mapping preserved verbatim: {after}" + ); + assert!( + after.contains(r#"key = "custom_key""#), + "operator's original key row still present: {after}" + ); + // The new entry lands as a sibling row with the default + // key→env uppercasing. + assert!( + after.contains(r#"key = "different_key""#), + "new key row appended: {after}" + ); + assert!( + after.contains(r#"env = "DIFFERENT_KEY""#), + "new entry defaults to `env = \"\"`: {after}" + ); + // Re-parse: the array-of-tables now holds both rows, with the + // operator's row untouched. + 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"); + assert_eq!(arr.len(), 2, "two sibling entries after append: {after}"); + let custom = arr + .iter() + .find(|tbl| tbl.get("key").and_then(|item| item.as_str()) == Some("custom_key")) + .expect("custom_key row present"); + assert_eq!( + custom.get("env").and_then(|item| item.as_str()), + Some("REAL_ENV_MAPPING"), + "custom_key row's env mapping locked to the operator's value" + ); + let different = arr + .iter() + .find(|tbl| tbl.get("key").and_then(|item| item.as_str()) == Some("different_key")) + .expect("different_key row present"); + assert_eq!( + different.get("env").and_then(|item| item.as_str()), + Some("DIFFERENT_KEY"), + "different_key row's env defaults to KEY_UPPER" + ); + } + + /// Section 9.4 — Zero cloud calls. Local-mode provision is a pure + /// file writer; it must NEVER shell out to `fastly`. Install + /// `fake_fastly_panicking()` (a script that exits 42 on any call) + /// on `$PATH` before invoking provision. If provision ever calls + /// the platform CLI, the fake short-circuits and the invocation + /// bubbles up as an error — so `Ok(...)` is the load-bearing + /// signal that no cloud call happened. + #[cfg(unix)] + #[test] + fn provision_local_zero_cloud_calls() { + let _lock = path_mutation_guard().lock().expect("guard"); + let fake = fake_fastly_panicking(); + let _path = PathPrepend::new(fake.path()); + 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 with a panicking fake fastly on PATH"); + } + + // ---------- write_fastly_local_config_store (config push --local) ---------- + // + // The writer is exported from provision_local.rs (per the split + // brief: local-server config-store writes are provision_local's + // territory). These four tests exercise the writer directly. + + #[test] + fn write_fastly_local_config_store_creates_inline_block_in_minimal_file() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write(&path, "name = \"demo\"\n").expect("write"); + let entries = vec![ + ("greeting".to_owned(), "hello".to_owned()), + ("service.timeout_ms".to_owned(), "1500".to_owned()), + ]; + write_fastly_local_config_store(&path, TEST_CONFIG_ID, &entries).expect("write"); + let after = fs::read_to_string(&path).expect("read back"); + assert!( + after.contains(&format!("[local_server.config_stores.{TEST_CONFIG_ID}]")), + "store table: {after}" + ); + assert!( + after.contains("format = \"inline-toml\""), + "format field: {after}" + ); + assert!( + after.contains(&format!( + "[local_server.config_stores.{TEST_CONFIG_ID}.contents]" + )), + "contents table: {after}" + ); + assert!(after.contains("greeting = \"hello\""), "key 1: {after}"); + assert!( + after.contains("\"service.timeout_ms\" = \"1500\""), + "dotted key quoted: {after}" + ); + assert!(after.contains("name = \"demo\""), "preserved: {after}"); + } + + #[test] + fn write_fastly_local_config_store_replaces_existing_block_on_re_push() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write(&path, "name = \"demo\"\n").expect("write"); + write_fastly_local_config_store( + &path, + TEST_CONFIG_ID, + &[("greeting".to_owned(), "stale".to_owned())], + ) + .expect("first write"); + write_fastly_local_config_store( + &path, + TEST_CONFIG_ID, + &[("greeting".to_owned(), "fresh".to_owned())], + ) + .expect("second write"); + let after = fs::read_to_string(&path).expect("read back"); + assert!(after.contains("greeting = \"fresh\""), "new value: {after}"); + assert!( + !after.contains("greeting = \"stale\""), + "stale value dropped: {after}" + ); + } + + #[test] + fn write_fastly_local_config_store_preserves_unrelated_blocks() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + let original = "\ +[setup.kv_stores.sessions] + +[[local_server.kv_stores.sessions]] +key = \"__init__\" +data = \"\" + +[scripts] +build = \"cargo build --release\" +"; + fs::write(&path, original).expect("write"); + write_fastly_local_config_store( + &path, + TEST_CONFIG_ID, + &[("greeting".to_owned(), "hi".to_owned())], + ) + .expect("write"); + let after = fs::read_to_string(&path).expect("read back"); + assert!( + after.contains("[setup.kv_stores.sessions]"), + "setup KV kept: {after}" + ); + assert!(after.contains("[scripts]"), "scripts table kept: {after}"); + assert!( + after.contains("build = \"cargo build --release\""), + "scripts value kept: {after}" + ); + assert!( + after.contains(&format!( + "[local_server.config_stores.{TEST_CONFIG_ID}.contents]" + )), + "new config_stores block added: {after}" + ); + } + + #[test] + fn write_fastly_local_config_store_creates_file_when_missing() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + // No fs::write — file absent. + write_fastly_local_config_store( + &path, + TEST_CONFIG_ID, + &[("greeting".to_owned(), "hi".to_owned())], + ) + .expect("write"); + let after = fs::read_to_string(&path).expect("read back"); + assert!(after.contains(&format!( + "[local_server.config_stores.{TEST_CONFIG_ID}.contents]" + ))); + assert!(after.contains("greeting = \"hi\"")); + } +} diff --git a/crates/edgezero-adapter-fastly/src/cli/push_cloud.rs b/crates/edgezero-adapter-fastly/src/cli/push_cloud.rs new file mode 100644 index 00000000..dcbf42aa --- /dev/null +++ b/crates/edgezero-adapter-fastly/src/cli/push_cloud.rs @@ -0,0 +1,1356 @@ +use std::io::{ErrorKind, Write as _}; +use std::process::{Command, Stdio}; + +use edgezero_adapter::registry::{ReadConfigEntry, ResolvedStoreId}; + +use crate::chunked_config::{prepare_fastly_config_entries, resolve_fastly_config_value}; + +use super::{ConfigStoreLookup, FASTLY_INSTALL_HINT}; + +/// Cloud-mode `push_config_entries`: resolve the platform config-store +/// id via `fastly config-store list --json`, then shell out per +/// physical entry to `fastly config-store-entry update --upsert --stdin`. +pub(super) fn write_entries( + store: &ResolvedStoreId, + entries: &[(String, String)], + dry_run: bool, +) -> Result, String> { + // Resolve the platform config-store id on demand via + // `fastly config-store list --json` (matched by name = + // `store.platform`), then `fastly config-store-entry update + // --store-id= --key= --upsert --stdin` per physical + // entry. Entries are logical blob-envelope entries from + // the CLI (one (key, envelope_json) per push); oversized + // Fastly values are expanded below into chunk entries plus + // a root pointer by `chunked_config::prepare_fastly_config_entries`. + let logical = store.logical.as_str(); + let name = store.platform.as_str(); + if entries.is_empty() { + return Ok(vec![format!( + "no config entries to push to fastly config-store `{name}` (logical id `{logical}`)" + )]); + } + // Expand each logical (key, envelope_json) into physical entries + // via the chunk-pointer helper. Entries ≤ 8 000 chars go through + // as a single direct entry; larger envelopes are split into + // content-addressed chunks with a root pointer written LAST. + // Collect all physical entries before any writes so pointer-too- + // large errors surface before touching the remote store. + let mut physical_entries: Vec<(String, String)> = Vec::new(); + for (key, body) in entries { + let expanded = prepare_fastly_config_entries(key, body)?; + physical_entries.extend(expanded); + } + if dry_run { + // Report intent without shelling out. One line per logical key + // noting whether it would be direct or chunked, plus chunk count. + let mut out = Vec::with_capacity(entries.len().saturating_add(1)); + out.push(format!( + "would resolve fastly config-store `{name}` (logical id `{logical}`) via `fastly config-store list --json` and push entries:" + )); + for (key, body) in entries { + let expanded = prepare_fastly_config_entries(key, body) + .unwrap_or_else(|_| vec![(key.clone(), body.clone())]); + if expanded.len() == 1 { + out.push(format!( + " would push `{key}` as direct entry ({}B)", + body.len() + )); + } else { + let chunk_count = expanded.len().saturating_sub(1); + out.push(format!( + " would push `{key}` as chunked ({chunk_count} chunks + 1 pointer, {}B total)", + body.len() + )); + } + } + return Ok(out); + } + let resolved_id = resolve_remote_config_store_id(name)?; + push_entries_with_committer(&physical_entries, |key, value| { + create_config_store_entry(&resolved_id, key, value) + })?; + Ok(vec![format!( + "pushed {} physical entries ({} logical) to fastly config-store `{name}` (logical id `{logical}`, id={resolved_id})", + physical_entries.len(), + entries.len() + )]) +} + +/// Cloud-mode `read_config_entry`: shell out to `fastly +/// config-store-entry describe --store-id= --key= --json`, +/// then resolve chunk pointers via the same store when needed. +pub(super) fn read_entry(store: &ResolvedStoreId, key: &str) -> Result { + let name = store.platform.as_str(); + let store_id = match resolve_remote_config_store_id(name) { + Ok(id) => id, + Err(err) => { + // "not found" from resolve means the store doesn't exist. + let lower = err.to_ascii_lowercase(); + if lower.contains("not found") + || lower.contains("did you run") + || lower.contains("no fastly config-store matches") + { + return Ok(ReadConfigEntry::MissingStore); + } + return Err(err); + } + }; + let store_arg = format!("--store-id={store_id}"); + let key_arg = format!("--key={key}"); + let output = Command::new("fastly") + .args([ + "config-store-entry", + "describe", + store_arg.as_str(), + key_arg.as_str(), + "--json", + ]) + .output() + .map_err(|err| { + if err.kind() == ErrorKind::NotFound { + format!("`fastly` not found on PATH; {FASTLY_INSTALL_HINT}") + } else { + format!("failed to spawn `fastly`: {err}") + } + })?; + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + // Parse the JSON and extract the `item_value` field. + let parsed: serde_json::Value = serde_json::from_str(&stdout).map_err(|err| { + format!( + "failed to parse `fastly config-store-entry describe` JSON: {err}\nraw stdout: {stdout}" + ) + })?; + let value = parsed + .get("item_value") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + format!( + "`fastly config-store-entry describe` JSON has no string `item_value` field; \ + fastly CLI may have changed its output schema. Raw stdout: {stdout}" + ) + })?; + // Resolve chunk pointers: if `value` is a direct BlobEnvelope it + // passes through unchanged; if it is a chunk pointer the chunks + // are fetched from the same store and reconstructed. + let resolved = resolve_fastly_config_value(key, value.to_owned(), |chunk_key| { + fetch_remote_config_store_entry(&store_id, chunk_key) + })?; + return Ok(ReadConfigEntry::Present(resolved)); + } + let stderr = String::from_utf8_lossy(&output.stderr); + let lower = stderr.to_ascii_lowercase(); + if lower.contains("not found") || lower.contains("does not exist") || lower.contains("404") { + return Ok(ReadConfigEntry::MissingKey); + } + Err(format!( + "`fastly config-store-entry describe --store-id={store_id} --key={key} --json` exited with status {}\nstderr: {}", + output.status, + stderr.trim() + )) +} + +/// Fetch a single entry value from a remote Fastly Config Store entry by +/// key, using `fastly config-store-entry describe --store-id= --key= +/// --json`. Used by the chunk-pointer resolver to fan out to chunk entries. +/// +/// Returns: +/// - `Ok(Some(value))` when the entry exists. +/// - `Ok(None)` when the entry is absent (not-found / 404 / does not exist). +/// - `Err(...)` on adapter or parse errors. +fn fetch_remote_config_store_entry(store_id: &str, key: &str) -> Result, String> { + let store_arg = format!("--store-id={store_id}"); + let key_arg = format!("--key={key}"); + let output = Command::new("fastly") + .args([ + "config-store-entry", + "describe", + store_arg.as_str(), + key_arg.as_str(), + "--json", + ]) + .output() + .map_err(|err| { + if err.kind() == ErrorKind::NotFound { + format!("`fastly` not found on PATH; {FASTLY_INSTALL_HINT}") + } else { + format!("failed to spawn `fastly`: {err}") + } + })?; + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + let parsed: serde_json::Value = serde_json::from_str(&stdout).map_err(|err| { + format!( + "failed to parse `fastly config-store-entry describe` JSON for chunk \ + key `{key}`: {err}\nraw stdout: {stdout}" + ) + })?; + let value = parsed + .get("item_value") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + format!( + "`fastly config-store-entry describe` JSON has no string `item_value` \ + field for chunk key `{key}`; fastly CLI may have changed its output schema. \ + Raw stdout: {stdout}" + ) + })?; + return Ok(Some(value.to_owned())); + } + let stderr = String::from_utf8_lossy(&output.stderr); + let lower = stderr.to_ascii_lowercase(); + if lower.contains("not found") || lower.contains("does not exist") || lower.contains("404") { + return Ok(None); + } + Err(format!( + "`fastly config-store-entry describe --store-id={store_id} --key={key} --json` \ + exited with status {}\nstderr: {}", + output.status, + stderr.trim() + )) +} + +// ------------------------------------------------------------------- +// `config push` helpers +// ------------------------------------------------------------------- + +/// Drive a sequential per-entry commit loop and produce the +/// partial-failure diagnostic when the committer fails mid-way. +/// Pure (no I/O) so the diagnostic shape is unit-testable without +/// the fastly CLI on PATH; production calls it with a closure that +/// shells out via `create_config_store_entry`. On success returns +/// the count of committed entries; on failure returns an error +/// string naming committed / failed / not-attempted keys so the +/// operator can resume from a known boundary. +fn push_entries_with_committer( + entries: &[(String, String)], + mut committer: F, +) -> Result +where + F: FnMut(&str, &str) -> Result<(), String>, +{ + let mut pushed: Vec = Vec::with_capacity(entries.len()); + for (key, value) in entries { + if let Err(err) = committer(key, value) { + let remaining: Vec<&str> = entries + .iter() + .skip(pushed.len().saturating_add(1)) + .map(|(remaining_key, _)| remaining_key.as_str()) + .collect(); + return Err(format!( + "fastly push failed at entry `{key}` after committing {committed} of {total} entries; the remaining {remaining_count} entries were NOT pushed.\n Committed (safe to skip on retry): {pushed:?}\n Failed: `{key}` — {err}\n Not attempted (re-push these): {remaining:?}", + committed = pushed.len(), + total = entries.len(), + remaining_count = remaining.len() + )); + } + pushed.push(key.clone()); + } + Ok(pushed.len()) +} + +/// Shell `fastly config-store-entry update --upsert --stdin` with +/// the value piped through stdin instead of `--value=` on +/// argv. +/// +/// Two reasons for this exact invocation: +/// +/// 1. `--upsert` (vs. the original `create` subcommand): the prior +/// `create` form errored on any key that already existed in the +/// config store, which made `config push` non-repeatable — +/// after the first push, every follow-up push triggered by a +/// config edit would fail at the first unchanged key. +/// `update --upsert` is documented as "insert or update", which +/// matches the convergent semantic the other config-push paths +/// already have (axum overwrites the JSON, cloudflare's +/// `wrangler kv bulk put` overwrites, spin's +/// `cloud key-value set` overwrites). +/// +/// 2. `--stdin` (vs. `--value=`): `--value=` exposed every +/// config entry's bytes in `ps`/`/proc//cmdline` listings +/// AND was bounded by the host's `ARG_MAX` (4 KiB to 256 KiB +/// depending on platform — easy to trip with a JSON blob). +/// `--stdin` reads the value from stdin instead — keeps value +/// bytes out of argv and lifts the size cap to whatever the OS +/// pipe buffer + the CLI's read accept (megabytes in practice). +fn create_config_store_entry(store_id: &str, key: &str, value: &str) -> Result<(), String> { + let store_arg = format!("--store-id={store_id}"); + let key_arg = format!("--key={key}"); + let mut child = Command::new("fastly") + .args([ + "config-store-entry", + "update", + store_arg.as_str(), + key_arg.as_str(), + "--upsert", + "--stdin", + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|err| { + if err.kind() == ErrorKind::NotFound { + format!("`fastly` not found on PATH; {FASTLY_INSTALL_HINT}") + } else { + format!("failed to spawn `fastly`: {err}") + } + })?; + // Move stdin OUT of child via `take` so the ChildStdin drops at + // end of scope — that closes the pipe and lets the CLI see EOF. + // `child.wait_with_output()` then consumes child cleanly. + let mut stdin = child + .stdin + .take() + .ok_or_else(|| "failed to open stdin pipe to `fastly`".to_owned())?; + stdin + .write_all(value.as_bytes()) + .map_err(|err| format!("failed to write value to `fastly` stdin: {err}"))?; + drop(stdin); + let output = child + .wait_with_output() + .map_err(|err| format!("failed to wait on `fastly`: {err}"))?; + if output.status.success() { + return Ok(()); + } + Err(format!( + "`fastly config-store-entry update --store-id={store_id} --key={key} --upsert --stdin` exited with status {}\nstderr: {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + )) +} + +/// Parse `fastly config-store list --json` output and return the +/// platform `id` of the store whose `name` matches `name`. Accepts +/// both a bare array (`[ {"id": "...", "name": "..."}, ... ]`) +/// and an `{"items": [...]}` envelope so this stays compatible +/// across fastly CLI versions. +fn find_config_store_id(stdout: &str, name: &str) -> ConfigStoreLookup { + let parsed: serde_json::Value = match serde_json::from_str(stdout) { + Ok(value) => value, + Err(err) => { + return ConfigStoreLookup::SchemaDrift(format!("stdout did not parse as JSON: {err}")); + } + }; + let Some(array) = parsed + .as_array() + .or_else(|| parsed.get("items").and_then(serde_json::Value::as_array)) + else { + return ConfigStoreLookup::SchemaDrift(format!( + "expected a bare array `[...]` or an `{{\"items\": [...]}}` envelope; got JSON of shape `{}`", + shape_summary(&parsed) + )); + }; + let mut any_well_formed = false; + for entry in array { + let entry_name = entry.get("name").and_then(serde_json::Value::as_str); + let entry_id = entry.get("id").and_then(serde_json::Value::as_str); + if entry_name.is_some() && entry_id.is_some() { + any_well_formed = true; + } + if entry_name == Some(name) { + return entry_id.map_or_else( + || { + ConfigStoreLookup::SchemaDrift(format!( + "entry matched name `{name}` but is missing a string `id` field" + )) + }, + |id| ConfigStoreLookup::Found(id.to_owned()), + ); + } + } + if array.is_empty() || any_well_formed { + ConfigStoreLookup::NotFound + } else { + ConfigStoreLookup::SchemaDrift( + "no entry has both string `name` and `id` fields -- fastly CLI may have changed its output schema" + .to_owned(), + ) + } +} + +/// One-line type label for a `serde_json::Value` (for diagnostic +/// error messages — not a canonical JSON-schema description). +fn shape_summary(value: &serde_json::Value) -> &'static str { + match value { + serde_json::Value::Null => "null", + serde_json::Value::Bool(_) => "bool", + serde_json::Value::Number(_) => "number", + serde_json::Value::String(_) => "string", + serde_json::Value::Array(_) => "array", + serde_json::Value::Object(_) => "object", + } +} + +/// Resolve the platform config-store id on demand: shell out to +/// `fastly config-store list --json`, parse the JSON, match by +/// `name`. The provision flow doesn't persist this id, so push +/// has to re-fetch every time. +fn resolve_remote_config_store_id(name: &str) -> Result { + let output = Command::new("fastly") + .args(["config-store", "list", "--json"]) + .output() + .map_err(|err| { + if err.kind() == ErrorKind::NotFound { + format!("`fastly` not found on PATH; {FASTLY_INSTALL_HINT}") + } else { + format!("failed to spawn `fastly`: {err}") + } + })?; + if !output.status.success() { + return Err(format!( + "`fastly config-store list --json` exited with status {}\nstderr: {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + )); + } + let stdout = String::from_utf8_lossy(&output.stdout); + match find_config_store_id(&stdout, name) { + ConfigStoreLookup::Found(id) => Ok(id), + ConfigStoreLookup::NotFound => Err(format!( + "no fastly config-store matches `{name}` (did you run `edgezero provision --adapter fastly`?)" + )), + ConfigStoreLookup::SchemaDrift(detail) => Err(format!( + "could not parse `fastly config-store list --json` output: {detail}.\n The fastly CLI may have changed its JSON schema in a recent version. Please file a bug report at https://github.com/stackpop/edgezero/issues with the fastly CLI version (`fastly version`) and the raw stdout. Workaround: pin to a known-compatible fastly CLI version." + )), + } +} + +#[cfg(test)] +mod tests { + #[cfg(unix)] + use super::super::path_mutation_guard; + use super::super::FastlyCliAdapter; + use super::*; + use edgezero_adapter::registry::{Adapter as _, AdapterPushContext}; + #[cfg(unix)] + use std::env; + #[cfg(unix)] + use std::ffi::OsString; + #[cfg(unix)] + use std::fs; + #[cfg(unix)] + use std::path::Path; + use tempfile::tempdir; + + // Shared fixture names. + const TEST_CONFIG_ID: &str = "app_config"; + + /// RAII guard: prepends a directory to `$PATH` and restores the original + /// value on drop. + #[cfg(unix)] + struct PathPrepend { + original: Option, + } + + #[cfg(unix)] + impl PathPrepend { + fn new(extra: &Path) -> Self { + let original = env::var_os("PATH"); + let new_path = match &original { + Some(prev) => { + let mut accum = OsString::from(extra); + accum.push(":"); + accum.push(prev); + accum + } + None => OsString::from(extra), + }; + env::set_var("PATH", new_path); + Self { original } + } + } + + #[cfg(unix)] + impl Drop for PathPrepend { + fn drop(&mut self) { + match self.original.take() { + Some(prev) => env::set_var("PATH", prev), + None => env::remove_var("PATH"), + } + } + } + + /// Build a tempdir containing a `fastly` shim script that: + /// - Responds to `config-store list --json` with a store-list JSON containing + /// `TEST_CONFIG_ID` mapped to `store-abc123`. + /// - Responds to `config-store-entry describe ...` with `stdout_body` on + /// stdout and `stderr_body` on stderr, exiting with `exit_code`. + #[cfg(unix)] + fn fake_fastly_returning( + stdout_body: &str, + stderr_body: &str, + exit_code: i32, + ) -> tempfile::TempDir { + use std::os::unix::fs::PermissionsExt as _; + let dir = tempdir().expect("tempdir"); + let script_path = dir.path().join("fastly"); + let stdout_file = dir.path().join("stdout_payload.txt"); + let stderr_file = dir.path().join("stderr_payload.txt"); + let list_file = dir.path().join("list_payload.txt"); + // Store-list JSON: bare array with one entry matching TEST_CONFIG_ID. + let list_json = format!(r#"[{{"name":"{TEST_CONFIG_ID}","id":"store-abc123"}}]"#); + fs::write(&stdout_file, stdout_body).expect("write stdout payload"); + fs::write(&stderr_file, stderr_body).expect("write stderr payload"); + fs::write(&list_file, list_json).expect("write list payload"); + let script = format!( + "#!/bin/sh\nif [ \"$1\" = \"config-store\" ]; then\n cat '{}'\n exit 0\nfi\ncat '{}'\ncat '{}' >&2\nexit {exit_code}\n", + list_file.display(), + stdout_file.display(), + stderr_file.display(), + ); + fs::write(&script_path, script).expect("write fastly script"); + let mut perms = fs::metadata(&script_path).expect("meta").permissions(); + perms.set_mode(0o755); + fs::set_permissions(&script_path, perms).expect("chmod +x"); + dir + } + + /// Build a fake `fastly` that logs each argv token (one per line) to + /// `out_path`, handles the list call correctly, and exits 0 for both calls. + #[cfg(unix)] + fn fake_fastly_argv_log(out_path: &Path) -> tempfile::TempDir { + use edgezero_core::blob_envelope::BlobEnvelope; + use serde_json::json; + use std::os::unix::fs::PermissionsExt as _; + let dir = tempdir().expect("tempdir"); + let script_path = dir.path().join("fastly"); + let list_file = dir.path().join("list_payload.txt"); + let entry_file = dir.path().join("entry_payload.txt"); + let list_json = format!(r#"[{{"name":"{TEST_CONFIG_ID}","id":"store-abc123"}}]"#); + // item_value must be a valid BlobEnvelope JSON so the resolver accepts it. + let envelope_json = serde_json::to_string(&BlobEnvelope::new( + json!({"v": "logged"}), + "2026-06-22T00:00:00Z".into(), + )) + .expect("serialize"); + let entry_json = format!( + r#"{{"item_value":{},"store_id":"store-abc123"}}"#, + serde_json::to_string(&envelope_json).expect("escape") + ); + fs::write(&list_file, list_json).expect("write list payload"); + fs::write(&entry_file, &entry_json).expect("write entry payload"); + let script = format!( + "#!/bin/sh\nfor arg in \"$@\"; do printf '%s\\n' \"$arg\" >> '{}'; done\nif [ \"$1\" = \"config-store\" ]; then\n cat '{}'\n exit 0\nfi\ncat '{}'\nexit 0\n", + out_path.display(), + list_file.display(), + entry_file.display(), + ); + fs::write(&script_path, script).expect("write script"); + let mut perms = fs::metadata(&script_path).expect("meta").permissions(); + perms.set_mode(0o755); + fs::set_permissions(&script_path, perms).expect("chmod +x"); + dir + } + + /// Build a valid `BlobEnvelope` JSON string of approximately `target_len` bytes. + #[cfg(unix)] + fn make_test_envelope(target_len: usize) -> String { + use edgezero_core::blob_envelope::BlobEnvelope; + use serde_json::json; + let pad = "x".repeat(target_len.saturating_add(64)); + let data = json!({ "pad": pad }); + let raw = + serde_json::to_string(&BlobEnvelope::new(data, "2026-06-22T00:00:00Z".into())).unwrap(); + if raw.len() >= target_len { + let overhead = raw.len().saturating_sub(pad.len()); + let adjusted = "x".repeat(target_len.saturating_sub(overhead)); + let data2 = json!({ "pad": adjusted }); + serde_json::to_string(&BlobEnvelope::new(data2, "2026-06-22T00:00:00Z".into())).unwrap() + } else { + raw + } + } + + /// Build a fake `fastly` script whose describe response depends on + /// the `--key=` argument: `key_responses` maps key names to JSON + /// item-value responses. Falls back to exit 1 "not found" for unknown keys. + #[cfg(unix)] + fn fake_fastly_with_key_dispatch( + _dir: &Path, + key_responses: &[(String, String)], + ) -> tempfile::TempDir { + use std::fmt::Write as _; + use std::os::unix::fs::PermissionsExt as _; + let fake_dir = tempdir().expect("tempdir"); + let list_file = fake_dir.path().join("list.json"); + let list_json = format!(r#"[{{"name":"{TEST_CONFIG_ID}","id":"store-abc123"}}]"#); + fs::write(&list_file, list_json).expect("write list"); + // Write each key response to a named file. + let mut dispatch_lines = String::new(); + for (key, response) in key_responses { + let resp_file = fake_dir.path().join(format!("resp_{key}.json")); + fs::write(&resp_file, response).expect("write resp"); + // Use exact-match: iterate argv and compare each token literally + // so that a root key like "app_config" does NOT match a chunk key + // like "app_config.__edgezero_chunks.abc.0". + writeln!( + dispatch_lines, + " for arg in \"$@\"; do if [ \"$arg\" = \"--key={key}\" ]; then cat '{}'; exit 0; fi; done", + resp_file.display() + ) + .expect("write to String is infallible"); + } + // Fallback outputs "not found" so fetch_remote_config_store_entry + // maps it to Ok(None) rather than Err. + let script = format!( + "#!/bin/sh\nif [ \"$1\" = \"config-store\" ]; then\n cat '{}'\n exit 0\nfi\n{dispatch_lines}echo 'Error: item not found' >&2\nexit 1\n", + list_file.display() + ); + let script_path = fake_dir.path().join("fastly"); + fs::write(&script_path, &script).expect("write script"); + let mut perms = fs::metadata(&script_path).expect("meta").permissions(); + perms.set_mode(0o755); + fs::set_permissions(&script_path, perms).expect("chmod"); + fake_dir + } + + // ---------- push_entries_with_committer ---------- + + #[test] + fn push_entries_with_committer_returns_count_when_all_succeed() { + let entries = vec![ + ("a".to_owned(), "1".to_owned()), + ("b".to_owned(), "2".to_owned()), + ("c".to_owned(), "3".to_owned()), + ]; + let pushed = push_entries_with_committer(&entries, |_, _| Ok(())).expect("all succeed"); + assert_eq!(pushed, 3); + } + + #[test] + fn push_entries_with_committer_zero_entries_is_ok() { + let pushed = push_entries_with_committer(&[], |_, _| Ok(())).expect("empty is fine"); + assert_eq!(pushed, 0); + } + + #[test] + fn push_entries_with_committer_failure_surfaces_committed_failed_not_attempted() { + // Mock committer: succeed for first 2 keys, fail at third. + let entries = vec![ + ("k1".to_owned(), "v1".to_owned()), + ("k2".to_owned(), "v2".to_owned()), + ("k3".to_owned(), "v3".to_owned()), + ("k4".to_owned(), "v4".to_owned()), + ("k5".to_owned(), "v5".to_owned()), + ]; + let mut calls: usize = 0; + let err = push_entries_with_committer(&entries, |key, _| { + calls = calls.saturating_add(1); + if key == "k3" { + Err("simulated fastly stderr".to_owned()) + } else { + Ok(()) + } + }) + .expect_err("middle failure must error"); + // Committer was invoked for k1, k2, k3 and stopped. + assert_eq!(calls, 3_usize, "no retries beyond failure point"); + // Error names all three categories. + assert!(err.contains("k1") && err.contains("k2"), "committed: {err}"); + assert!( + err.contains("Failed: `k3`"), + "failed entry named exactly: {err}" + ); + assert!( + err.contains("k4") && err.contains("k5"), + "not-attempted: {err}" + ); + assert!(err.contains("simulated fastly stderr"), "inner err: {err}"); + // Counts are sane. + assert!( + err.contains("committing 2 of 5 entries"), + "committed/total count: {err}" + ); + } + + #[test] + fn push_entries_with_committer_first_entry_failure_reports_zero_committed() { + let entries = vec![ + ("only".to_owned(), "val".to_owned()), + ("never".to_owned(), "tried".to_owned()), + ]; + let err = push_entries_with_committer(&entries, |_, _| Err("nope".to_owned())) + .expect_err("first-entry failure"); + assert!(err.contains("committing 0 of 2"), "zero committed: {err}"); + assert!( + err.contains("Failed: `only`"), + "first-entry failure named: {err}" + ); + assert!( + err.contains("never"), + "second entry as not-attempted: {err}" + ); + } + + #[test] + fn push_entries_with_committer_last_entry_failure_reports_n_minus_one_committed() { + let entries = vec![ + ("a".to_owned(), "1".to_owned()), + ("b".to_owned(), "2".to_owned()), + ("c".to_owned(), "3".to_owned()), + ]; + let err = push_entries_with_committer(&entries, |key, _| { + if key == "c" { + Err("late failure".to_owned()) + } else { + Ok(()) + } + }) + .expect_err("last-entry failure"); + assert!(err.contains("committing 2 of 3"), "n-1 committed: {err}"); + assert!( + err.contains("the remaining 0 entries"), + "zero not-attempted when last fails: {err}" + ); + } + + // ---------- find_config_store_id ---------- + + #[test] + fn find_config_store_id_matches_bare_array_by_name() { + let stdout = format!( + r#"[ + {{"id": "abc123", "name": "{TEST_CONFIG_ID}"}}, + {{"id": "def456", "name": "other_store"}} + ]"# + ); + match find_config_store_id(&stdout, TEST_CONFIG_ID) { + ConfigStoreLookup::Found(id) => assert_eq!(id, "abc123"), + ConfigStoreLookup::NotFound => panic!("expected Found, got NotFound"), + ConfigStoreLookup::SchemaDrift(detail) => { + panic!("expected Found, got SchemaDrift({detail})") + } + } + } + + #[test] + fn find_config_store_id_tolerates_items_envelope() { + let stdout = format!( + r#"{{"items": [ + {{"id": "xyz789", "name": "{TEST_CONFIG_ID}"}} + ]}}"# + ); + match find_config_store_id(&stdout, TEST_CONFIG_ID) { + ConfigStoreLookup::Found(id) => assert_eq!(id, "xyz789"), + ConfigStoreLookup::NotFound => panic!("expected Found, got NotFound"), + ConfigStoreLookup::SchemaDrift(detail) => { + panic!("expected Found, got SchemaDrift({detail})") + } + } + } + + #[test] + fn find_config_store_id_distinguishes_not_found_from_match_failure() { + // JSON parses cleanly, entries are well-formed + // (`name` + `id` strings present), but no entry matches + // → NotFound. Operator likely needs to run `provision`. + let stdout = r#"[{"id": "abc", "name": "other"}]"#; + assert!(matches!( + find_config_store_id(stdout, "missing"), + ConfigStoreLookup::NotFound + )); + } + + #[test] + fn find_config_store_id_flags_schema_drift_on_malformed_json() { + // Unparseable bytes are NOT a "store not found" — they're + // a "fastly CLI output format changed" signal. Operator + // needs different recovery (file a bug, pin CLI version) + // than for the "store doesn't exist yet" case. + let drift = find_config_store_id("not json", "anything"); + assert!( + matches!(drift, ConfigStoreLookup::SchemaDrift(_)), + "non-JSON stdout must be schema drift, got {drift:?}" + ); + let empty = find_config_store_id("", "anything"); + assert!( + matches!(empty, ConfigStoreLookup::SchemaDrift(_)), + "empty stdout must be schema drift, got {empty:?}" + ); + } + + #[test] + fn find_config_store_id_flags_schema_drift_when_shape_unexpected() { + // JSON parses but the top-level is neither a bare array + // nor an `{items: [...]}` envelope. + let stdout = r#"{"namespace": "fastly", "list": []}"#; + match find_config_store_id(stdout, "any") { + ConfigStoreLookup::SchemaDrift(detail) => { + assert!( + detail.contains("bare array") || detail.contains("items"), + "schema-drift detail names the expected shapes: {detail}" + ); + } + ConfigStoreLookup::Found(id) => panic!("expected SchemaDrift, got Found({id})"), + ConfigStoreLookup::NotFound => panic!("expected SchemaDrift, got NotFound"), + } + } + + #[test] + fn find_config_store_id_flags_schema_drift_when_entries_lack_name_id() { + // Array of objects but none have BOTH string `name` and + // string `id` fields — suggests schema rename (e.g. + // fastly renamed `name` → `title`). + let stdout = format!(r#"[{{"title": "{TEST_CONFIG_ID}", "uid": "abc"}}]"#); + let drift = find_config_store_id(&stdout, TEST_CONFIG_ID); + assert!( + matches!(drift, ConfigStoreLookup::SchemaDrift(_)), + "entries lacking name/id must be schema drift, got {drift:?}" + ); + } + + #[test] + fn find_config_store_id_returns_not_found_for_empty_array() { + // Empty array IS a valid "store doesn't exist yet" signal, + // not schema drift — fastly CLI legitimately returns `[]` + // when no config-stores exist. + let drift = find_config_store_id("[]", "any"); + assert!( + matches!(drift, ConfigStoreLookup::NotFound), + "empty array must be NotFound, got {drift:?}" + ); + } + + // ---------- push_config_entries (dry-run + error paths) ---------- + + #[test] + fn push_dry_run_does_not_invoke_fastly() { + let dir = tempdir().expect("tempdir"); + let entries = vec![ + ("greeting".to_owned(), "hello".to_owned()), + ("feature.new_checkout".to_owned(), "false".to_owned()), + ]; + let out = FastlyCliAdapter + .push_config_entries( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &entries, + &AdapterPushContext::new(), + true, + ) + .expect("dry-run succeeds"); + // First line names the resolve+publish flow; subsequent lines preview + // each key the push would create (so callers can eyeball the keyset + // before running for real). + assert_eq!(out.len(), 1 + entries.len(), "header + per-entry preview"); + assert!( + out[0].contains("would resolve fastly config-store `app_config`") + && out[0].contains("push entries"), + "dry-run header describes the would-be flow: {out:?}" + ); + assert!( + out.iter().any(|line| line.contains("`greeting`")), + "dry-run lists `greeting`: {out:?}" + ); + assert!( + out.iter() + .any(|line| line.contains("`feature.new_checkout`")), + "dry-run lists `feature.new_checkout`: {out:?}" + ); + } + + #[test] + fn push_with_no_entries_reports_no_op_without_invoking_fastly() { + let dir = tempdir().expect("tempdir"); + let out = FastlyCliAdapter + .push_config_entries( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &[], + &AdapterPushContext::new(), + false, + ) + .expect("zero-entry push is fine"); + assert_eq!(out.len(), 1); + assert!( + out[0].contains("no config entries"), + "status line names the no-op: {out:?}" + ); + } + + // ---------- read_config_entry (fake fastly, remote shell-out) ---------- + + #[cfg(unix)] + #[test] + fn read_remote_returns_present_on_success() { + use edgezero_core::blob_envelope::BlobEnvelope; + use serde_json::json; + + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + // Fake fastly: list succeeds with app_config → store-abc123; + // describe returns valid JSON with item_value that is a BlobEnvelope. + let envelope = serde_json::to_string(&BlobEnvelope::new( + json!({"hello": "fastly"}), + "2026-06-22T00:00:00Z".into(), + )) + .expect("serialize"); + let entry_json = format!( + r#"{{"item_value":{},"store_id":"store-abc123"}}"#, + serde_json::to_string(&envelope).expect("escape") + ); + let fake = fake_fastly_returning(&entry_json, "", 0); + let _path = PathPrepend::new(fake.path()); + let result = FastlyCliAdapter + .read_config_entry( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + "greeting", + &AdapterPushContext::new(), + ) + .expect("fake fastly exit-0 must succeed"); + let ReadConfigEntry::Present(value) = result else { + panic!("expected Present"); + }; + assert_eq!(value, envelope); + } + + #[cfg(unix)] + #[test] + fn read_remote_returns_missing_key_on_not_found_stderr() { + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + // describe exits non-zero with "not found" in stderr → MissingKey. + let fake = fake_fastly_returning("", "Error: item not found", 1); + let _path = PathPrepend::new(fake.path()); + let result = FastlyCliAdapter + .read_config_entry( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + "greeting", + &AdapterPushContext::new(), + ) + .expect("not-found maps to MissingKey (not Err)"); + assert!( + matches!(result, ReadConfigEntry::MissingKey), + "not-found stderr => MissingKey" + ); + } + + /// The Fastly impl distinguishes store-not-found from key-not-found via + /// `resolve_remote_config_store_id`: when the list call exits non-zero and + /// the error string contains "not found", `read_config_entry` returns + /// `MissingStore` without ever calling the describe subcommand. + #[cfg(unix)] + #[test] + fn read_remote_returns_missing_store_on_appropriate_stderr() { + use std::os::unix::fs::PermissionsExt as _; + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + // Script that exits non-zero for the list call so resolve fails with + // a "not found" error, causing read_config_entry to return MissingStore. + let fake_dir = tempdir().expect("tempdir"); + let stderr_file = fake_dir.path().join("stderr_payload.txt"); + fs::write(&stderr_file, "Error: config store not found for service").expect("write stderr"); + let script_path = fake_dir.path().join("fastly"); + let script = format!( + "#!/bin/sh\ncat '{stderr}' >&2\nexit 1\n", + stderr = stderr_file.display(), + ); + fs::write(&script_path, script).expect("write script"); + let mut perms = fs::metadata(&script_path).expect("meta").permissions(); + perms.set_mode(0o755); + fs::set_permissions(&script_path, perms).expect("chmod +x"); + let _path = PathPrepend::new(fake_dir.path()); + let result = FastlyCliAdapter + .read_config_entry( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + "greeting", + &AdapterPushContext::new(), + ) + .expect("list failure with not-found maps to MissingStore (not Err)"); + assert!( + matches!(result, ReadConfigEntry::MissingStore), + "list not-found => MissingStore" + ); + } + + /// Verify that `read_config_entry` invokes + /// `fastly config-store-entry describe --store-id= --key= --json` + /// (after the resolve step that calls `fastly config-store list --json`). + #[cfg(unix)] + #[test] + fn read_remote_invokes_correct_argv() { + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + let argv_log = dir.path().join("argv.txt"); + let fake = fake_fastly_argv_log(&argv_log); + let _path = PathPrepend::new(fake.path()); + let result = FastlyCliAdapter + .read_config_entry( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + "greeting", + &AdapterPushContext::new(), + ) + .expect("argv-log fake must succeed"); + assert!( + matches!(result, ReadConfigEntry::Present(_)), + "expected Present from argv-log fake" + ); + let captured = fs::read_to_string(&argv_log).expect("argv log"); + // The describe call must include these args (resolve call args + // are also captured but we only assert the describe shape here). + assert!( + captured.contains("config-store-entry"), + "must invoke config-store-entry; got:\n{captured}" + ); + assert!( + captured.contains("describe"), + "must pass describe subcommand; got:\n{captured}" + ); + assert!( + captured.contains("--store-id=store-abc123"), + "must pass resolved store id; got:\n{captured}" + ); + assert!( + captured.contains("--key=greeting"), + "must pass --key=; got:\n{captured}" + ); + assert!( + captured.contains("--json"), + "must pass --json flag; got:\n{captured}" + ); + } + + // ---------- chunked push integration tests ---------- + + #[cfg(unix)] + #[test] + fn push_config_entries_writes_direct_entry_at_exactly_8000_chars() { + use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + let argv_log = dir.path().join("argv.txt"); + let fake = fake_fastly_argv_log(&argv_log); + let _path = PathPrepend::new(fake.path()); + + let envelope = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT); + assert_eq!(envelope.len(), FASTLY_CONFIG_ENTRY_LIMIT); + + let entries = vec![(TEST_CONFIG_ID.to_owned(), envelope)]; + let out = FastlyCliAdapter + .push_config_entries( + dir.path(), + None, + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &entries, + &AdapterPushContext::new(), + false, + ) + .expect("push must succeed"); + // One physical entry written (direct). + let captured = fs::read_to_string(&argv_log).expect("argv log"); + assert!( + captured.contains(&format!("--key={TEST_CONFIG_ID}")), + "must write root key directly: {captured}" + ); + assert!( + out[0].contains("1 physical entries (1 logical)"), + "summary reports 1 physical entry: {out:?}" + ); + } + + #[cfg(unix)] + #[test] + fn push_config_entries_writes_chunks_and_root_pointer_for_8001_chars() { + use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + let argv_log = dir.path().join("argv.txt"); + let fake = fake_fastly_argv_log(&argv_log); + let _path = PathPrepend::new(fake.path()); + + let envelope = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT.saturating_add(1)); + assert!(envelope.len() > FASTLY_CONFIG_ENTRY_LIMIT); + + let entries = vec![(TEST_CONFIG_ID.to_owned(), envelope)]; + let out = FastlyCliAdapter + .push_config_entries( + dir.path(), + None, + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &entries, + &AdapterPushContext::new(), + false, + ) + .expect("push must succeed"); + let captured = fs::read_to_string(&argv_log).expect("argv log"); + // At least one chunk key must appear before the root key. + assert!( + captured.contains(".__edgezero_chunks."), + "chunk keys must be written: {captured}" + ); + // Root pointer must also be written. + assert!( + captured.contains(&format!("--key={TEST_CONFIG_ID}")), + "root pointer must be written: {captured}" + ); + // Root key must be LAST in the log (chunk lines come before it). + let root_pos = captured.rfind(&format!("--key={TEST_CONFIG_ID}")).unwrap(); + let chunk_pos = captured.find(".__edgezero_chunks.").unwrap(); + assert!( + chunk_pos < root_pos, + "chunk writes must precede root pointer write: chunk_pos={chunk_pos} root_pos={root_pos}" + ); + assert!(out[0].contains("logical"), "summary line present: {out:?}"); + } + + #[cfg(unix)] + #[test] + fn push_config_entries_dry_run_reports_direct_vs_chunked() { + use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; + let dir = tempdir().expect("tempdir"); + + let direct_envelope = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT); + let chunked_envelope = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT.saturating_add(1)); + + let entries = vec![ + ("cfg_direct".to_owned(), direct_envelope), + ("cfg_chunked".to_owned(), chunked_envelope), + ]; + let out = FastlyCliAdapter + .push_config_entries( + dir.path(), + None, + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &entries, + &AdapterPushContext::new(), + true, // dry_run + ) + .expect("dry-run must not error"); + + // No shellout happens; output must describe intent. + let combined = out.join("\n"); + assert!( + combined.contains("would push `cfg_direct` as direct entry"), + "must report direct: {combined}" + ); + assert!( + combined.contains("would push `cfg_chunked` as chunked"), + "must report chunked: {combined}" + ); + } + + // ---------- chunked read integration tests ---------- + + #[cfg(unix)] + #[test] + fn read_config_entry_resolves_direct_value_unchanged() { + use edgezero_core::blob_envelope::BlobEnvelope; + use serde_json::json; + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + + let envelope = BlobEnvelope::new(json!({"hello": "world"}), "2026-06-22T00:00:00Z".into()); + let json_str = serde_json::to_string(&envelope).unwrap(); + let item_json = format!( + r#"{{"item_value":{}}}"#, + serde_json::to_string(&json_str).unwrap() + ); + let fake = fake_fastly_returning(&item_json, "", 0); + let _path = PathPrepend::new(fake.path()); + + let result = FastlyCliAdapter + .read_config_entry( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + "cfg", + &AdapterPushContext::new(), + ) + .expect("read must succeed"); + let ReadConfigEntry::Present(value) = result else { + panic!("expected Present"); + }; + assert_eq!(value, json_str, "direct envelope passes through unchanged"); + } + + #[cfg(unix)] + #[test] + fn read_config_entry_reconstructs_chunked_envelope() { + use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + + let envelope = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT.saturating_add(1)); + let physical = prepare_fastly_config_entries(TEST_CONFIG_ID, &envelope).unwrap(); + let (_, pointer_json) = physical.last().unwrap(); + // Build a key→response map for every physical entry. + let mut key_responses: Vec<(String, String)> = Vec::new(); + for (pk, pv) in &physical { + let resp = format!(r#"{{"item_value":{}}}"#, serde_json::to_string(pv).unwrap()); + key_responses.push((pk.clone(), resp)); + } + // The root key should return the pointer. + let ptr_resp = format!( + r#"{{"item_value":{}}}"#, + serde_json::to_string(pointer_json).unwrap() + ); + key_responses.push((TEST_CONFIG_ID.to_owned(), ptr_resp)); + + let fake = fake_fastly_with_key_dispatch(dir.path(), &key_responses); + let _path = PathPrepend::new(fake.path()); + + let result = FastlyCliAdapter + .read_config_entry( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + TEST_CONFIG_ID, + &AdapterPushContext::new(), + ) + .expect("chunked read must succeed"); + let ReadConfigEntry::Present(value) = result else { + panic!("expected Present"); + }; + assert_eq!( + value, envelope, + "reconstructed envelope must equal original" + ); + } + + #[cfg(unix)] + #[test] + fn read_config_entry_errors_on_missing_chunk() { + use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + + let envelope = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT.saturating_add(1)); + let physical = prepare_fastly_config_entries(TEST_CONFIG_ID, &envelope).unwrap(); + let (_, pointer_json) = physical.last().unwrap(); + // Only provide the root pointer; omit chunk responses so chunk fetch returns not-found. + let ptr_resp = format!( + r#"{{"item_value":{}}}"#, + serde_json::to_string(pointer_json).unwrap() + ); + let key_responses = vec![(TEST_CONFIG_ID.to_owned(), ptr_resp)]; + let fake = fake_fastly_with_key_dispatch(dir.path(), &key_responses); + let _path = PathPrepend::new(fake.path()); + + let result = FastlyCliAdapter.read_config_entry( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + TEST_CONFIG_ID, + &AdapterPushContext::new(), + ); + let Err(err) = result else { + panic!("missing chunk must error") + }; + assert!( + err.contains("missing chunk"), + "error must mention missing chunk: {err}" + ); + } + + #[cfg(unix)] + #[test] + fn read_config_entry_errors_on_corrupt_chunk_hash() { + use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + + let envelope = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT.saturating_add(1)); + let physical = prepare_fastly_config_entries(TEST_CONFIG_ID, &envelope).unwrap(); + let (_, pointer_json) = physical.last().unwrap(); + let mut key_responses: Vec<(String, String)> = Vec::new(); + // Corrupt first chunk's content. + let (first_chunk_key, first_chunk_val) = &physical[0]; + let corrupted: String = first_chunk_val.chars().map(|_| 'Z').collect(); + let corrupt_resp = format!( + r#"{{"item_value":{}}}"#, + serde_json::to_string(&corrupted).unwrap() + ); + key_responses.push((first_chunk_key.clone(), corrupt_resp)); + // Remaining chunks as normal. + for (pk, pv) in physical + .iter() + .take(physical.len().saturating_sub(1)) + .skip(1) + { + key_responses.push(( + pk.clone(), + format!(r#"{{"item_value":{}}}"#, serde_json::to_string(pv).unwrap()), + )); + } + key_responses.push(( + TEST_CONFIG_ID.to_owned(), + format!( + r#"{{"item_value":{}}}"#, + serde_json::to_string(pointer_json).unwrap() + ), + )); + let fake = fake_fastly_with_key_dispatch(dir.path(), &key_responses); + let _path = PathPrepend::new(fake.path()); + + let result = FastlyCliAdapter.read_config_entry( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + TEST_CONFIG_ID, + &AdapterPushContext::new(), + ); + let Err(err) = result else { + panic!("corrupt chunk must error") + }; + assert!( + err.contains("SHA mismatch") || err.contains("mismatch"), + "error must mention hash mismatch: {err}" + ); + } + + #[cfg(unix)] + #[test] + fn read_config_entry_errors_on_malformed_pointer() { + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + // Root value is JSON but neither a BlobEnvelope nor a valid pointer. + let bad_json = r#"{"some_field":"not a pointer or envelope"}"#; + let item_json = format!( + r#"{{"item_value":{}}}"#, + serde_json::to_string(bad_json).unwrap() + ); + let fake = fake_fastly_returning(&item_json, "", 0); + let _path = PathPrepend::new(fake.path()); + + let result = FastlyCliAdapter.read_config_entry( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + "cfg", + &AdapterPushContext::new(), + ); + let Err(err) = result else { + panic!("malformed pointer must error") + }; + assert!( + err.contains("neither a valid BlobEnvelope") || err.contains("chunk pointer"), + "error must describe parse failure: {err}" + ); + } +} diff --git a/crates/edgezero-adapter-fastly/src/cli/push_local.rs b/crates/edgezero-adapter-fastly/src/cli/push_local.rs new file mode 100644 index 00000000..741ebf1e --- /dev/null +++ b/crates/edgezero-adapter-fastly/src/cli/push_local.rs @@ -0,0 +1,724 @@ +use std::fs; +use std::io::ErrorKind; +use std::path::Path; + +use edgezero_adapter::registry::{ReadConfigEntry, ResolvedStoreId}; + +use crate::chunked_config::{prepare_fastly_config_entries, resolve_fastly_config_value}; + +use super::provision_local::write_fastly_local_config_store; + +/// Local-emulator `push_config_entries_local`: edit +/// `[local_server.config_stores..contents]` in `fastly.toml`. +/// Viceroy reads it on startup, so a subsequent `fastly compute serve` +/// exposes the new values to the wasm component. No shell-out to the +/// production Fastly CLI -- the operator may not be authenticated and +/// wouldn't want a local push to touch production anyway. +pub(super) fn write_entries( + manifest_root: &Path, + adapter_manifest_path: Option<&str>, + store: &ResolvedStoreId, + entries: &[(String, String)], + dry_run: bool, +) -> Result, String> { + let Some(rel) = adapter_manifest_path else { + return Err( + "[adapters.fastly.adapter].manifest must point at fastly.toml for config push --local" + .to_owned(), + ); + }; + let fastly_path = manifest_root.join(rel); + let logical = store.logical.as_str(); + let name = store.platform.as_str(); + if entries.is_empty() { + return Ok(vec![format!( + "no config entries to push to `[local_server.config_stores.{name}]` in {} (logical id `{logical}`)", + fastly_path.display() + )]); + } + // Expand logical entries into physical entries (chunks + pointer). + let mut physical_entries: Vec<(String, String)> = Vec::new(); + for (key, body) in entries { + let expanded = prepare_fastly_config_entries(key, body)?; + physical_entries.extend(expanded); + } + if dry_run { + let mut out = Vec::with_capacity(entries.len().saturating_add(1)); + out.push(format!( + "would edit `[local_server.config_stores.{name}.contents]` in {} (logical id `{logical}`) with entries:", + fastly_path.display(), + )); + for (key, body) in entries { + let expanded = prepare_fastly_config_entries(key, body) + .unwrap_or_else(|_| vec![(key.clone(), body.clone())]); + if expanded.len() == 1 { + out.push(format!( + " would set `{key}` as direct entry ({}B)", + body.len() + )); + } else { + let chunk_count = expanded.len().saturating_sub(1); + out.push(format!( + " would set `{key}` as chunked ({chunk_count} chunks + 1 pointer, {}B total)", + body.len() + )); + } + } + return Ok(out); + } + write_fastly_local_config_store(&fastly_path, name, &physical_entries)?; + Ok(vec![format!( + "wrote {} physical entries ({} logical) to `[local_server.config_stores.{name}.contents]` in {} (logical id `{logical}`); restart `fastly compute serve` to pick up changes", + physical_entries.len(), + entries.len(), + fastly_path.display() + )]) +} + +/// Local-emulator `read_config_entry_local`: read from +/// `[local_server.config_stores..contents]` in fastly.toml +/// — the same section `push_config_entries_local` writes. +pub(super) fn read_entry( + manifest_root: &Path, + adapter_manifest_path: Option<&str>, + store: &ResolvedStoreId, + key: &str, +) -> Result { + let Some(rel) = adapter_manifest_path else { + return Err( + "[adapters.fastly.adapter].manifest must point at fastly.toml for config diff --local" + .to_owned(), + ); + }; + let fastly_path = manifest_root.join(rel); + let name = store.platform.as_str(); + let raw = match fs::read_to_string(&fastly_path) { + Ok(text) => text, + Err(err) if err.kind() == ErrorKind::NotFound => return Ok(ReadConfigEntry::MissingStore), + Err(err) => { + return Err(format!("failed to read {}: {err}", fastly_path.display())); + } + }; + let doc: toml_edit::DocumentMut = raw + .parse() + .map_err(|err| format!("failed to parse {}: {err}", fastly_path.display()))?; + // Probe `[local_server.config_stores.]` — if absent, the store + // has not been seeded locally yet. + let Some(contents) = doc + .get("local_server") + .and_then(|ls| ls.get("config_stores")) + .and_then(|cs| cs.get(name)) + .and_then(|store_tbl| store_tbl.get("contents")) + else { + return Ok(ReadConfigEntry::MissingStore); + }; + // The contents table is `key = "value"` pairs. + match contents.get(key) { + Some(item) => { + let value = item.as_str().ok_or_else(|| { + format!( + "`[local_server.config_stores.{name}.contents].{key}` in {} is not a string", + fastly_path.display() + ) + })?; + // Resolve chunk pointers using the same toml contents table. + let resolved = resolve_fastly_config_value(key, value.to_owned(), |chunk_key| { + match contents.get(chunk_key) { + Some(chunk_item) => { + let chunk_val = chunk_item.as_str().ok_or_else(|| { + format!( + "chunk key `{chunk_key}` in {} is not a string", + fastly_path.display() + ) + })?; + Ok(Some(chunk_val.to_owned())) + } + None => Ok(None), + } + })?; + Ok(ReadConfigEntry::Present(resolved)) + } + None => Ok(ReadConfigEntry::MissingKey), + } +} + +#[cfg(test)] +mod tests { + use super::super::provision_local::write_fastly_local_config_store; + use super::super::FastlyCliAdapter; + use super::*; + use edgezero_adapter::registry::{Adapter as _, AdapterPushContext, ResolvedStoreId}; + use tempfile::tempdir; + + // Shared fixture names. + const TEST_CONFIG_ID: &str = "app_config"; + + /// Build a valid `BlobEnvelope` JSON string of approximately `target_len` bytes. + fn make_test_envelope(target_len: usize) -> String { + use edgezero_core::blob_envelope::BlobEnvelope; + use serde_json::json; + let pad = "x".repeat(target_len.saturating_add(64)); + let data = json!({ "pad": pad }); + let raw = + serde_json::to_string(&BlobEnvelope::new(data, "2026-06-22T00:00:00Z".into())).unwrap(); + if raw.len() >= target_len { + let overhead = raw.len().saturating_sub(pad.len()); + let adjusted = "x".repeat(target_len.saturating_sub(overhead)); + let data2 = json!({ "pad": adjusted }); + serde_json::to_string(&BlobEnvelope::new(data2, "2026-06-22T00:00:00Z".into())).unwrap() + } else { + raw + } + } + + // ---------- read_config_entry_local ---------- + + #[test] + fn read_local_returns_missing_store_when_fastly_toml_absent() { + let dir = tempdir().expect("tempdir"); + // No fastly.toml written — file missing. + let result = FastlyCliAdapter + .read_config_entry_local( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + "greeting", + &AdapterPushContext::new(), + ) + .expect("missing file is not an error"); + assert!( + matches!(result, ReadConfigEntry::MissingStore), + "absent fastly.toml => MissingStore" + ); + } + + #[test] + fn read_local_returns_missing_store_when_no_local_server_contents() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + // fastly.toml exists but has no [local_server.config_stores.*] block. + fs::write(&path, "name = \"demo\"\n[setup.config_stores.app_config]\n").expect("write"); + let result = FastlyCliAdapter + .read_config_entry_local( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + "greeting", + &AdapterPushContext::new(), + ) + .expect("missing local_server block is not an error"); + assert!( + matches!(result, ReadConfigEntry::MissingStore), + "no local_server stanza => MissingStore" + ); + } + + #[test] + fn read_local_returns_missing_key_when_key_absent_from_contents() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + // Write a local_server block with a different key so the store exists + // but the requested key is absent. + fs::write( + &path, + format!( + "name = \"demo\"\n\ + [local_server.config_stores.{TEST_CONFIG_ID}]\n\ + format = \"inline-toml\"\n\ + [local_server.config_stores.{TEST_CONFIG_ID}.contents]\n\ + other_key = \"other_value\"\n" + ), + ) + .expect("write"); + let result = FastlyCliAdapter + .read_config_entry_local( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + "greeting", + &AdapterPushContext::new(), + ) + .expect("missing key is not an error"); + assert!( + matches!(result, ReadConfigEntry::MissingKey), + "key absent from contents => MissingKey" + ); + } + + #[test] + fn read_local_returns_present_when_key_exists_in_contents() { + use edgezero_core::blob_envelope::BlobEnvelope; + use serde_json::json; + + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write(&path, "name = \"demo\"\n").expect("write initial toml"); + + // Use a valid BlobEnvelope value — the resolver requires BlobEnvelope + // or chunk-pointer JSON; raw strings are not accepted post-chunking. + let envelope_json = serde_json::to_string(&BlobEnvelope::new( + json!({"hello": "fastly"}), + "2026-06-22T00:00:00Z".into(), + )) + .expect("serialize"); + write_fastly_local_config_store( + &path, + TEST_CONFIG_ID, + &[("greeting".to_owned(), envelope_json.clone())], + ) + .expect("setup write"); + + let result = FastlyCliAdapter + .read_config_entry_local( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + "greeting", + &AdapterPushContext::new(), + ) + .expect("key present"); + let ReadConfigEntry::Present(value) = result else { + panic!("expected Present variant"); + }; + assert_eq!(value, envelope_json, "value matches what was written"); + } + + #[test] + fn read_local_roundtrips_with_push_local() { + // Write via push_config_entries_local, then read via + // read_config_entry_local — the two must agree on the value. + use edgezero_core::blob_envelope::BlobEnvelope; + use serde_json::json; + + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write(&path, "name = \"demo\"\n").expect("write"); + + // push_config_entries_local passes the value through the chunk-pointer + // helper which stores it verbatim when ≤ 8 000 chars. The reader then + // resolves it through the same resolver that requires BlobEnvelope JSON. + let envelope_json = serde_json::to_string(&BlobEnvelope::new( + json!({"hello": "roundtrip"}), + "2026-06-22T00:00:00Z".into(), + )) + .expect("serialize"); + let entries = vec![("greeting".to_owned(), envelope_json.clone())]; + FastlyCliAdapter + .push_config_entries_local( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &entries, + &AdapterPushContext::new(), + false, + ) + .expect("push succeeds"); + let result = FastlyCliAdapter + .read_config_entry_local( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + "greeting", + &AdapterPushContext::new(), + ) + .expect("read succeeds"); + let ReadConfigEntry::Present(value) = result else { + panic!("expected Present after push+read roundtrip"); + }; + assert_eq!(value, envelope_json, "roundtrip value matches"); + } + + #[test] + fn read_local_requires_adapter_manifest_path() { + let dir = tempdir().expect("tempdir"); + let result = FastlyCliAdapter.read_config_entry_local( + dir.path(), + None, // adapter_manifest_path missing + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + "greeting", + &AdapterPushContext::new(), + ); + match result { + Err(err) => assert!( + err.contains("[adapters.fastly.adapter].manifest"), + "error names the missing field: {err}" + ), + Ok(_) => panic!("expected Err when adapter_manifest_path is None"), + } + } + + // ---------- push_config_entries_local ---------- + + /// Spec 12.7: pushing two blobs under different root keys + /// (e.g. `app_config` + `app_config_staging`) must leave both + /// keys readable from the local fastly.toml so the runtime + /// `EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY` override can + /// switch between them. Prior to the upsert fix the second + /// push wholesale-replaced the per-store contents table. + #[cfg(unix)] + #[test] + fn push_config_entries_local_preserves_sibling_keys() { + let dir = tempdir().expect("tempdir"); + let fastly_toml = dir.path().join("fastly.toml"); + fs::write(&fastly_toml, "name = \"demo\"\n").expect("seed"); + let store = ResolvedStoreId::from_logical(TEST_CONFIG_ID); + let ctx = AdapterPushContext::new(); + + FastlyCliAdapter + .push_config_entries_local( + dir.path(), + Some("fastly.toml"), + None, + &store, + &[("app_config".to_owned(), "{\"envelope\":\"A\"}".to_owned())], + &ctx, + false, + ) + .expect("first push"); + FastlyCliAdapter + .push_config_entries_local( + dir.path(), + Some("fastly.toml"), + None, + &store, + &[( + "app_config_staging".to_owned(), + "{\"envelope\":\"B\"}".to_owned(), + )], + &ctx, + false, + ) + .expect("second push (sibling key)"); + + let raw = fs::read_to_string(&fastly_toml).expect("read"); + let doc: toml_edit::DocumentMut = raw.parse().expect("parse"); + let contents = doc + .get("local_server") + .and_then(|ls| ls.get("config_stores")) + .and_then(|cs| cs.get(TEST_CONFIG_ID)) + .and_then(|st| st.get("contents")) + .and_then(toml_edit::Item::as_table) + .expect("contents after sibling push"); + let app_config = contents + .get("app_config") + .and_then(toml_edit::Item::as_str) + .expect("default key must survive sibling push"); + assert_eq!( + app_config, "{\"envelope\":\"A\"}", + "default key value: {raw}" + ); + let staging = contents + .get("app_config_staging") + .and_then(toml_edit::Item::as_str) + .expect("staging key must be present"); + assert_eq!(staging, "{\"envelope\":\"B\"}", "staging key value: {raw}"); + } + + #[cfg(unix)] + #[test] + fn push_config_entries_local_writes_literal_dotted_chunk_keys() { + use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; + let dir = tempdir().expect("tempdir"); + let fastly_toml = dir.path().join("fastly.toml"); + fs::write(&fastly_toml, "name = \"demo\"\n").expect("write"); + + let envelope = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT.saturating_add(1)); + let entries = vec![(TEST_CONFIG_ID.to_owned(), envelope)]; + FastlyCliAdapter + .push_config_entries_local( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &entries, + &AdapterPushContext::new(), + false, + ) + .expect("local push must succeed"); + + let after = fs::read_to_string(&fastly_toml).expect("read back"); + // Chunk keys contain '.' and must appear as quoted string keys, + // not as TOML nested tables (which would look like [table.sub]). + assert!( + after.contains(".__edgezero_chunks."), + "chunk keys written to fastly.toml: {after}" + ); + // Parse with toml_edit and confirm chunk keys are string-keyed entries. + let doc: toml_edit::DocumentMut = after.parse().expect("must parse"); + let contents = doc + .get("local_server") + .and_then(|ls| ls.get("config_stores")) + .and_then(|cs| cs.get(TEST_CONFIG_ID)) + .and_then(|st| st.get("contents")) + .expect("contents table must exist"); + // At least one chunk key must be present as a string value (not a table). + let has_chunk_string = contents.as_table().is_some_and(|tbl| { + tbl.iter() + .any(|(key, val)| key.contains(".__edgezero_chunks.") && val.as_value().is_some()) + }); + assert!( + has_chunk_string, + "chunk keys must be literal string-valued entries, not nested tables: {after}" + ); + } + + #[cfg(unix)] + #[test] + fn push_config_entries_local_dry_run_reports_chunking_and_does_not_edit_fastly_toml() { + use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; + let dir = tempdir().expect("tempdir"); + let fastly_toml = dir.path().join("fastly.toml"); + let original = "name = \"demo\"\n"; + fs::write(&fastly_toml, original).expect("write"); + + let envelope = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT.saturating_add(1)); + let entries = vec![(TEST_CONFIG_ID.to_owned(), envelope)]; + let out = FastlyCliAdapter + .push_config_entries_local( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &entries, + &AdapterPushContext::new(), + true, // dry_run + ) + .expect("local dry-run must not error"); + + // File must be untouched. + let after = fs::read_to_string(&fastly_toml).expect("read back"); + assert_eq!(after, original, "dry-run must not edit fastly.toml"); + + // Output must describe chunking intent. + let combined = out.join("\n"); + assert!( + combined.contains("would set") && combined.contains("chunked"), + "must report chunked intent: {combined}" + ); + } + + // ---------- local read integration tests ---------- + + #[test] + fn read_config_entry_local_resolves_direct_value() { + use edgezero_core::blob_envelope::BlobEnvelope; + use serde_json::json; + let dir = tempdir().expect("tempdir"); + let fastly_toml = dir.path().join("fastly.toml"); + + let envelope = BlobEnvelope::new(json!({"x": 1_i32}), "2026-06-22T00:00:00Z".into()); + let json_str = serde_json::to_string(&envelope).unwrap(); + // Write directly as a single entry (not via push_config_entries_local so we + // control the exact TOML content). + write_fastly_local_config_store( + &fastly_toml, + TEST_CONFIG_ID, + &[("cfg".to_owned(), json_str.clone())], + ) + .expect("write"); + + let result = FastlyCliAdapter + .read_config_entry_local( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + "cfg", + &AdapterPushContext::new(), + ) + .expect("local read must succeed"); + let ReadConfigEntry::Present(value) = result else { + panic!("expected Present"); + }; + assert_eq!(value, json_str, "direct envelope passes through unchanged"); + } + + #[test] + fn read_config_entry_local_reconstructs_chunked_envelope() { + use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; + let dir = tempdir().expect("tempdir"); + let fastly_toml = dir.path().join("fastly.toml"); + + let envelope = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT.saturating_add(1)); + let physical = prepare_fastly_config_entries(TEST_CONFIG_ID, &envelope).unwrap(); + // Write all physical entries (chunks + pointer) to the local store. + write_fastly_local_config_store(&fastly_toml, TEST_CONFIG_ID, &physical).expect("write"); + + let result = FastlyCliAdapter + .read_config_entry_local( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + TEST_CONFIG_ID, + &AdapterPushContext::new(), + ) + .expect("local chunked read must succeed"); + let ReadConfigEntry::Present(value) = result else { + panic!("expected Present"); + }; + assert_eq!( + value, envelope, + "reconstructed envelope must equal original" + ); + } + + /// Spec 12.3 + 9.3: a second oversized push must converge the + /// runtime on the NEW envelope — chunk keys are content-addressed + /// by the full-envelope SHA, so push B writes a new chunk-set and + /// installs a new root pointer. + /// + /// The local fastly.toml writer upserts per-key (so a sibling + /// `--key app_config_staging` push leaves `app_config` intact per + /// spec 12.7). Within the SAME root key, old chunks for envelope + /// A remain in the contents table after envelope B's push — they're + /// unreferenced (the root pointer at `app_config` now names B's + /// chunks), matching the remote Fastly behaviour where the + /// per-entry `update --upsert` shell-out has no atomic-delete + /// pairing. The runtime-correctness property holds either way: a + /// read after push B follows the active pointer and reconstructs + /// envelope B, not A. + #[cfg(unix)] + #[test] + #[expect( + clippy::too_many_lines, + reason = "linear test scenario: push A, inspect, push B, inspect, read; splitting would obscure the chunk-set comparison" + )] + fn second_oversized_push_converges_runtime_on_new_envelope() { + use crate::chunked_config::FASTLY_CONFIG_ENTRY_LIMIT; + let dir = tempdir().expect("tempdir"); + let fastly_toml = dir.path().join("fastly.toml"); + fs::write(&fastly_toml, "name = \"demo\"\n").expect("seed"); + + // First push: envelope A. Records the chunk-key set so we can + // confirm they survive the second push (no garbage collection + // in v1 — spec 9.3 + Q6). + let envelope_a = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT.saturating_add(1)); + FastlyCliAdapter + .push_config_entries_local( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &[(TEST_CONFIG_ID.to_owned(), envelope_a.clone())], + &AdapterPushContext::new(), + false, + ) + .expect("first push must succeed"); + + let after_a = fs::read_to_string(&fastly_toml).expect("read"); + let doc_a: toml_edit::DocumentMut = after_a.parse().expect("parse"); + let contents_a = doc_a + .get("local_server") + .and_then(|ls| ls.get("config_stores")) + .and_then(|cs| cs.get(TEST_CONFIG_ID)) + .and_then(|st| st.get("contents")) + .and_then(toml_edit::Item::as_table) + .expect("contents table after push A"); + let chunks_a: Vec = contents_a + .iter() + .map(|(key, _)| key.to_owned()) + .filter(|key| key.contains(".__edgezero_chunks.")) + .collect(); + assert!( + !chunks_a.is_empty(), + "push A must have produced chunk entries: {after_a}" + ); + + // Second push: a DIFFERENT oversized envelope B. The + // content-addressed chunk keys must shift to B's sha; old + // A-chunks may remain in the table (v1 doesn't GC). Build + // envelope B with a distinct payload key so its SHA differs + // from A's even at the same total length. + let envelope_b = { + use edgezero_core::blob_envelope::BlobEnvelope; + use serde_json::json; + let data = json!({ "alt": "x".repeat(FASTLY_CONFIG_ENTRY_LIMIT) }); + serde_json::to_string(&BlobEnvelope::new(data, "2026-06-22T00:00:01Z".to_owned())) + .expect("envelope B serialises") + }; + assert_ne!(envelope_a, envelope_b, "test fixtures must differ"); + FastlyCliAdapter + .push_config_entries_local( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &[(TEST_CONFIG_ID.to_owned(), envelope_b.clone())], + &AdapterPushContext::new(), + false, + ) + .expect("second push must succeed"); + + let after_b = fs::read_to_string(&fastly_toml).expect("read"); + let doc_b: toml_edit::DocumentMut = after_b.parse().expect("parse"); + let contents_b = doc_b + .get("local_server") + .and_then(|ls| ls.get("config_stores")) + .and_then(|cs| cs.get(TEST_CONFIG_ID)) + .and_then(|st| st.get("contents")) + .and_then(toml_edit::Item::as_table) + .expect("contents table after push B"); + let chunks_b: Vec = contents_b + .iter() + .map(|(key, _)| key.to_owned()) + .filter(|key| key.contains(".__edgezero_chunks.")) + .collect(); + assert!( + !chunks_b.is_empty(), + "push B must have produced chunk entries: {after_b}" + ); + + // Chunk keys are content-addressed by envelope SHA, so the B + // push installs a fresh chunk-set whose keys are all distinct + // from A's. Under the upsert semantic the A-chunks remain in + // the contents table (no GC in v1); B's chunks are simply added. + let new_b_chunks: Vec<&String> = chunks_b + .iter() + .filter(|key| !chunks_a.contains(*key)) + .collect(); + assert!( + !new_b_chunks.is_empty(), + "push B must have added at least one new content-addressed chunk: A-set={chunks_a:?} B-set={chunks_b:?}" + ); + // Old A-chunks remain in the table (orphan-but-present — + // matches the remote Fastly write-only-upsert semantic). + for chunk_key in &chunks_a { + assert!( + chunks_b.contains(chunk_key), + "old A-chunk `{chunk_key}` must remain in the local table after push B (v1 has no GC); B-set={chunks_b:?}" + ); + } + + // Runtime-correctness property: a fresh read after push B + // reconstructs envelope B (NOT envelope A). + let read = FastlyCliAdapter + .read_config_entry_local( + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + TEST_CONFIG_ID, + &AdapterPushContext::new(), + ) + .expect("local read after push B"); + let ReadConfigEntry::Present(value) = read else { + panic!("expected Present after push B"); + }; + assert_eq!( + value, envelope_b, + "read after second push must reconstruct envelope B, not A" + ); + assert_ne!( + value, envelope_a, + "old envelope A's chunks must be inert -- read must NOT return A" + ); + } +} diff --git a/crates/edgezero-adapter-fastly/src/cli/run.rs b/crates/edgezero-adapter-fastly/src/cli/run.rs new file mode 100644 index 00000000..f994949e --- /dev/null +++ b/crates/edgezero-adapter-fastly/src/cli/run.rs @@ -0,0 +1,353 @@ +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use edgezero_adapter::cli_support::{ + find_manifest_upwards, find_workspace_root, path_distance, read_package_name, +}; +use walkdir::WalkDir; + +/// # Errors +/// Returns an error if the Fastly CLI build command fails. +#[inline] +pub fn build(extra_args: &[String]) -> Result { + let manifest = + find_fastly_manifest(env::current_dir().map_err(|err| err.to_string())?.as_path())?; + let manifest_dir = manifest + .parent() + .ok_or_else(|| "fastly manifest has no parent directory".to_owned())?; + let cargo_manifest = manifest_dir.join("Cargo.toml"); + let crate_name = read_package_name(&cargo_manifest)?; + + let status = Command::new("cargo") + .args([ + "build", + "--release", + "--target", + "wasm32-wasip1", + "--manifest-path", + cargo_manifest + .to_str() + .ok_or("invalid Cargo manifest path")?, + ]) + .args(extra_args) + .status() + .map_err(|err| format!("failed to run cargo build: {err}"))?; + if !status.success() { + return Err(format!("cargo build failed with status {status}")); + } + + let workspace_root = find_workspace_root(manifest_dir); + let artifact = locate_artifact(&workspace_root, manifest_dir, &crate_name)?; + let pkg_dir = workspace_root.join("pkg"); + fs::create_dir_all(&pkg_dir) + .map_err(|err| format!("failed to create {}: {err}", pkg_dir.display()))?; + let dest = pkg_dir.join(format!("{}.wasm", crate_name.replace('-', "_"))); + fs::copy(&artifact, &dest) + .map_err(|err| format!("failed to copy artifact to {}: {err}", dest.display()))?; + + Ok(dest) +} + +/// # Errors +/// Returns an error if the Fastly CLI deploy command fails. +#[inline] +pub fn deploy(extra_args: &[String]) -> Result<(), String> { + let manifest = + find_fastly_manifest(env::current_dir().map_err(|err| err.to_string())?.as_path())?; + let manifest_dir = manifest + .parent() + .ok_or_else(|| "fastly manifest has no parent directory".to_owned())?; + + let status = Command::new("fastly") + .args(["compute", "deploy"]) + .args(extra_args) + .current_dir(manifest_dir) + .status() + .map_err(|err| format!("failed to run fastly CLI: {err}"))?; + if !status.success() { + return Err(format!("fastly compute deploy failed with status {status}")); + } + + Ok(()) +} + +/// # Errors +/// Returns an error if the Fastly CLI serve command (Viceroy) fails. +#[inline] +pub fn serve(extra_args: &[String]) -> Result<(), String> { + let manifest = + find_fastly_manifest(env::current_dir().map_err(|err| err.to_string())?.as_path())?; + let manifest_dir = manifest + .parent() + .ok_or_else(|| "fastly manifest has no parent directory".to_owned())?; + + let status = Command::new("fastly") + .args(["compute", "serve"]) + .args(extra_args) + .current_dir(manifest_dir) + .status() + .map_err(|err| format!("failed to run fastly CLI: {err}"))?; + if !status.success() { + return Err(format!("fastly compute serve failed with status {status}")); + } + + Ok(()) +} + +fn find_fastly_manifest(start: &Path) -> Result { + if let Some(found) = find_manifest_upwards(start, "fastly.toml") { + return Ok(found); + } + + let root = find_workspace_root(start); + let mut candidates: Vec = WalkDir::new(&root) + .follow_links(true) + .max_depth(8) + .into_iter() + .filter_map(Result::ok) + .map(|entry| entry.path().to_path_buf()) + .filter(|path| { + path.file_name().is_some_and(|n| n == "fastly.toml") + && path + .parent() + .is_some_and(|dir| dir.join("Cargo.toml").exists()) + }) + .collect(); + + if candidates.is_empty() { + return Err("could not locate fastly.toml".to_owned()); + } + + candidates.sort_by_key(|path| { + let parent = path.parent().unwrap_or(Path::new("")); + path_distance(start, parent) + }); + + Ok(candidates.remove(0)) +} + +fn locate_artifact( + workspace_root: &Path, + manifest_dir: &Path, + crate_name: &str, +) -> Result { + let target_triple = "wasm32-wasip1"; + let release_name = format!("{}.wasm", crate_name.replace('-', "_")); + + if let Some(custom) = env::var_os("CARGO_TARGET_DIR") { + let candidate = PathBuf::from(custom) + .join(target_triple) + .join("release") + .join(&release_name); + if candidate.exists() { + return Ok(candidate); + } + } + + let manifest_target = manifest_dir + .join("target") + .join(target_triple) + .join("release") + .join(&release_name); + if manifest_target.exists() { + return Ok(manifest_target); + } + + let workspace_target = workspace_root + .join("target") + .join(target_triple) + .join("release") + .join(&release_name); + if workspace_target.exists() { + return Ok(workspace_target); + } + + Err(format!( + "compiled artifact not found (looked in {} and workspace target)", + manifest_dir.display() + )) +} + +/// 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() +} + +#[cfg(test)] +mod tests { + use super::*; + use edgezero_adapter::cli_support::read_package_name; + use tempfile::tempdir; + + #[test] + fn finds_closest_manifest_when_multiple_exist() { + let dir = tempdir().unwrap(); + let root = dir.path(); + fs::write(root.join("Cargo.toml"), "[workspace]").unwrap(); + + let first = root.join("crates/first"); + fs::create_dir_all(&first).unwrap(); + fs::write(first.join("Cargo.toml"), "[package]\nname=\"first\"").unwrap(); + fs::write(first.join("fastly.toml"), "name=\"first\"").unwrap(); + + let second = root.join("examples/second"); + fs::create_dir_all(&second).unwrap(); + fs::write(second.join("Cargo.toml"), "[package]\nname=\"second\"").unwrap(); + fs::write(second.join("fastly.toml"), "name=\"second\"").unwrap(); + + let found = find_fastly_manifest(&second).unwrap(); + assert_eq!(found, second.join("fastly.toml")); + } + + #[test] + fn finds_manifest_in_current_directory() { + let dir = tempdir().unwrap(); + let root = dir.path(); + fs::write(root.join("Cargo.toml"), "[workspace]").unwrap(); + fs::write(root.join("fastly.toml"), "name = \"demo\"").unwrap(); + + let manifest = find_fastly_manifest(root).expect("should find manifest"); + assert_eq!(manifest, root.join("fastly.toml")); + } + + #[test] + fn locate_artifact_considers_workspace_target() { + let dir = tempdir().unwrap(); + let workspace = dir.path(); + let manifest_dir = workspace.join("service"); + fs::create_dir_all(manifest_dir.join("target/wasm32-wasip1/release")).unwrap(); + let artifact = workspace.join("target/wasm32-wasip1/release/demo.wasm"); + fs::create_dir_all(artifact.parent().unwrap()).unwrap(); + fs::write(&artifact, "wasm").unwrap(); + + let located = locate_artifact(workspace, &manifest_dir, "demo").unwrap(); + assert_eq!(located, artifact); + } + + #[test] + fn read_package_falls_back_to_name() { + let dir = tempdir().unwrap(); + let manifest = dir.path().join("Cargo.toml"); + fs::write(&manifest, "name = \"demo\"").unwrap(); + let name = read_package_name(&manifest).unwrap(); + assert_eq!(name, "demo"); + } + + #[test] + fn read_package_prefers_package_table() { + let dir = tempdir().unwrap(); + let manifest = dir.path().join("Cargo.toml"); + fs::write(&manifest, "[package]\nname = \"demo\"\n").unwrap(); + let name = read_package_name(&manifest).unwrap(); + assert_eq!(name, "demo"); + } + + // ---------- 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")); + // Reparse-and-index: substring `service_id = "SVC1"` passes + // for both the correct root form AND the shipped bug where + // service_id landed inside `[local_server]`. Explicitly assert + // it's at the ROOT of the doc. + let doc: toml_edit::DocumentMut = out.parse().expect("re-parse synthesised fastly.toml"); + assert_eq!( + doc.get("service_id").and_then(toml_edit::Item::as_str), + Some("SVC1"), + "service_id must live at the TOML root, not nested under a section: {out}" + ); + // Also assert no `local_server.service_id` -- that would be + // the exact silent-drift bug we're guarding against. + let local_server_carries_it = doc + .get("local_server") + .and_then(|item| item.as_table()) + .and_then(|tbl| tbl.get("service_id")) + .is_some(); + assert!( + !local_server_carries_it, + "service_id must NOT appear under `[local_server]`: {out}" + ); + } + + #[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:?}"); + } + } +} diff --git a/crates/edgezero-adapter-fastly/src/templates/fastly.toml.hbs b/crates/edgezero-adapter-fastly/src/templates/fastly.toml.hbs index e3ccb441..f3e1c645 100644 --- a/crates/edgezero-adapter-fastly/src/templates/fastly.toml.hbs +++ b/crates/edgezero-adapter-fastly/src/templates/fastly.toml.hbs @@ -1,12 +1,10 @@ +# edgezero-provision: v1 authors = [""] -description = "" language = "rust" manifest_version = 3 name = "{{proj_fastly}}" -service_id = "" [local_server] [scripts] build = "cargo build --profile release --target wasm32-wasip1" - diff --git a/crates/edgezero-adapter-spin/src/cli.rs b/crates/edgezero-adapter-spin/src/cli.rs deleted file mode 100644 index 11c1d6a2..00000000 --- a/crates/edgezero-adapter-spin/src/cli.rs +++ /dev/null @@ -1,2721 +0,0 @@ -#![expect( - clippy::self_named_module_files, - reason = "Workspace lint policy denies BOTH `self_named_module_files` (wants `cli/mod.rs`) and `mod_module_files` (wants `cli.rs`) -- they contradict, so any file with submodules must opt out of one. The repo convention is the self-named form (`cli.rs` with submodules under `cli/`); allow accordingly." -)] -#![expect( - clippy::arbitrary_source_item_ordering, - reason = "submodule declarations sit between the `use` block and the rest of the file's items by Rust convention; the strict-ordering lint disagrees but no human convention puts `mod` blocks AFTER trait impls" -)] - -use std::env; -use std::fs; -use std::path::{Path, PathBuf}; -use std::process::Command; - -use ctor::ctor; -use edgezero_adapter::cli_support::{ - find_manifest_upwards, find_workspace_root, path_distance, read_package_name, run_native_cli, -}; -use edgezero_adapter::registry::{ - register_adapter, Adapter, AdapterAction, AdapterPushContext, ProvisionStores, ReadConfigEntry, - ResolvedStoreId, TypedSecretEntry, -}; -use edgezero_adapter::scaffold::{ - register_adapter_blueprint, AdapterBlueprint, AdapterFileSpec, CommandTemplates, - DependencySpec, LoggingDefaults, ManifestSpec, ReadmeInfo, TemplateRegistration, -}; -use walkdir::WalkDir; - -mod push_cloud; -mod push_sqlite; -mod runtime_config; - -static SPIN_ADAPTER: SpinCliAdapter = SpinCliAdapter; - -static SPIN_BLUEPRINT: AdapterBlueprint = AdapterBlueprint { - id: "spin", - display_name: "Spin (Fermyon)", - crate_suffix: "adapter-spin", - dependency_crate: "edgezero-adapter-spin", - dependency_repo_path: "crates/edgezero-adapter-spin", - template_registrations: SPIN_TEMPLATE_REGISTRATIONS, - files: SPIN_FILE_SPECS, - extra_dirs: &["src"], - dependencies: SPIN_DEPENDENCIES, - manifest: ManifestSpec { - manifest_filename: "spin.toml", - build_target: "wasm32-wasip2", - build_profile: "release", - build_features: &["spin"], - }, - commands: CommandTemplates { - build: "cargo build --target wasm32-wasip2 --release -p {crate}", - deploy: "spin deploy --from {crate_dir}", - serve: "spin up --from {crate_dir} --runtime-config-file {crate_dir}/runtime-config.toml", - }, - logging: LoggingDefaults { - endpoint: None, - level: "info", - echo_stdout: None, - }, - readme: ReadmeInfo { - description: "{display} entrypoint.", - dev_heading: "{display} (local)", - dev_steps: &["`edgezero serve --adapter spin`"], - }, - run_module: "edgezero_adapter_spin", -}; - -static SPIN_DEPENDENCIES: &[DependencySpec] = &[ - DependencySpec { - key: "dep_edgezero_core_spin", - repo_crate: "crates/edgezero-core", - fallback: "edgezero-core = { git = \"https://git@github.com/stackpop/edgezero.git\", package = \"edgezero-core\", default-features = false }", - features: &[], - }, - DependencySpec { - key: "dep_edgezero_adapter_spin", - repo_crate: "crates/edgezero-adapter-spin", - fallback: - "edgezero-adapter-spin = { git = \"https://git@github.com/stackpop/edgezero.git\", package = \"edgezero-adapter-spin\", default-features = false }", - features: &[], - }, - DependencySpec { - key: "dep_edgezero_adapter_spin_wasm", - repo_crate: "crates/edgezero-adapter-spin", - fallback: - "edgezero-adapter-spin = { git = \"https://git@github.com/stackpop/edgezero.git\", package = \"edgezero-adapter-spin\", default-features = false, features = [\"spin\"] }", - features: &["spin"], - }, -]; - -static SPIN_FILE_SPECS: &[AdapterFileSpec] = &[ - AdapterFileSpec { - template: "spin_Cargo_toml", - output: "Cargo.toml", - }, - AdapterFileSpec { - template: "spin_runtime_config_toml", - output: "runtime-config.toml", - }, - AdapterFileSpec { - template: "spin_src_lib_rs", - output: "src/lib.rs", - }, - AdapterFileSpec { - template: "spin_spin_toml", - output: "spin.toml", - }, -]; - -static SPIN_TEMPLATE_REGISTRATIONS: &[TemplateRegistration] = &[ - TemplateRegistration { - name: "spin_Cargo_toml", - contents: include_str!("templates/Cargo.toml.hbs"), - }, - TemplateRegistration { - name: "spin_runtime_config_toml", - contents: include_str!("templates/runtime-config.toml.hbs"), - }, - TemplateRegistration { - name: "spin_src_lib_rs", - contents: include_str!("templates/src/lib.rs.hbs"), - }, - TemplateRegistration { - name: "spin_spin_toml", - contents: include_str!("templates/spin.toml.hbs"), - }, -]; - -const TARGET_TRIPLE: &str = "wasm32-wasip2"; - -const SPIN_INSTALL_HINT: &str = "install the Spin CLI (https://spinframework.dev/) and try again"; - -struct SpinCliAdapter; - -#[expect( - clippy::missing_trait_methods, - reason = "Stage 6: KV-backed config dropped Spin's `^[a-z][a-z0-9_]*$` key rule and the config-vs-secret collision check, so `validate_app_config_keys` falls back to the trait default `Ok(())`. `validate_typed_secrets` IS overridden below (secret-value canonicalisation + within-secrets uniqueness still apply). `validate_adapter_manifest` IS overridden below (Spin's multi-component disambiguation). `read_config_entry` and `read_config_entry_local` are both overridden below (four-branch SQLite-direct / Fermyon Cloud / non-Spin-backend dispatch)." -)] -impl Adapter for SpinCliAdapter { - fn execute(&self, action: AdapterAction, args: &[String]) -> Result<(), String> { - match action { - // `spin cloud {login|logout|info}` is the native sign-in - // surface for Fermyon Cloud. EdgeZero stores no - // credentials — this is a thin shell-out. - AdapterAction::AuthLogin => { - run_native_cli("spin", &["cloud", "login"], SPIN_INSTALL_HINT) - } - AdapterAction::AuthLogout => { - run_native_cli("spin", &["cloud", "logout"], SPIN_INSTALL_HINT) - } - AdapterAction::AuthStatus => { - run_native_cli("spin", &["cloud", "info"], SPIN_INSTALL_HINT) - } - AdapterAction::Build => { - let artifact = build(args)?; - log::info!("[edgezero] Spin build complete -> {}", artifact.display()); - Ok(()) - } - AdapterAction::Deploy => deploy(args), - AdapterAction::Serve => serve(args), - other => Err(format!("spin adapter does not support {other:?}")), - } - } - - fn merged_id_kinds(&self) -> &'static [&'static str] { - // Both KV and Config back to `spin_sdk::key_value::Store` via - // the same `provision` path; declaring the same logical id - // under both kinds resolves to one underlying store with - // silent write-collisions. CLI validate rejects. - &["kv", "config"] - } - - fn name(&self) -> &'static str { - "spin" - } - - fn provision( - &self, - manifest_root: &Path, - adapter_manifest_path: Option<&str>, - component_selector: Option<&str>, - stores: &ProvisionStores<'_>, - dry_run: bool, - ) -> Result, String> { - //: spin provision is pure spin.toml editing — no - // shell-out (Spin KV stores are provisioned by the Spin - // runtime / Fermyon at deploy). For each declared KV id - // AND each declared CONFIG id (KV-backed since Stage 5 - // of the spin-kv-config plan), append the env-resolved - // platform label to the component's `key_value_stores` - // array. Secret variables are manually declared by the - // developer in spin.toml -- secrets stay on Spin - // variables for the platform's `secret = true` flagging. - let Some(rel) = adapter_manifest_path else { - return Err( - "[adapters.spin.adapter].manifest must point at spin.toml for provision".to_owned(), - ); - }; - let spin_path = manifest_root.join(rel); - - let mut out = Vec::new(); - // Resolve the component once if either KV or config has - // anything to provision. - let needs_component = !stores.kv.is_empty() || !stores.config.is_empty(); - if needs_component { - let component_id = resolve_spin_component(&spin_path, component_selector)?; - for (kind, store) in stores - .kv - .iter() - .map(|store| ("KV", store)) - .chain(stores.config.iter().map(|store| ("config", store))) - { - let logical = store.logical.as_str(); - // The label the runtime opens is what - // `EDGEZERO__STORES______NAME` - // resolves to (default = the logical id). Provision - // writes the PLATFORM label into - // `[component.X].key_value_stores` so that both the - // KV runtime lookup AND the KV-backed config - // runtime lookup match. - let label = store.platform.as_str(); - if dry_run { - out.push(format!( - "would ensure {kind} label `{label}` (logical id `{logical}`) is in [component.{component_id}].key_value_stores in {}", - spin_path.display() - )); - continue; - } - let added = ensure_kv_label_in_component(&spin_path, &component_id, label)?; - if added { - out.push(format!( - "added {kind} label `{label}` (logical id `{logical}`) to [component.{component_id}].key_value_stores in {}", - spin_path.display() - )); - } else { - out.push(format!( - "{kind} label `{label}` (logical id `{logical}`) already present in [component.{component_id}].key_value_stores in {}; skipping", - spin_path.display() - )); - } - } - } - for store in stores.secrets { - let logical = store.logical.as_str(); - let platform = store.platform.as_str(); - out.push(format!( - "spin secret id `{logical}` (platform name `{platform}`) requires manual `[variables].* secret = true` + `[component.*.variables].*` declarations in spin.toml; nothing to do here" - )); - } - if out.is_empty() { - out.push("spin has no declared stores to provision".to_owned()); - } - Ok(out) - } - - fn push_config_entries( - &self, - manifest_root: &Path, - adapter_manifest_path: Option<&str>, - _component_selector: Option<&str>, - store: &ResolvedStoreId, - entries: &[(String, String)], - push_ctx: &AdapterPushContext<'_>, - dry_run: bool, - ) -> Result, String> { - dispatch_push( - manifest_root, - adapter_manifest_path, - store, - entries, - push_ctx, - dry_run, - ) - } - - fn push_config_entries_local( - &self, - manifest_root: &Path, - adapter_manifest_path: Option<&str>, - _component_selector: Option<&str>, - store: &ResolvedStoreId, - entries: &[(String, String)], - push_ctx: &AdapterPushContext<'_>, - dry_run: bool, - ) -> Result, String> { - // `--local` lives in `push_ctx.local`. `dispatch_push` honours - // it by suppressing the Fermyon Cloud auto-detect so the - // operator can force a SQLite-direct write even when the - // manifest's deploy command shells to `spin deploy`. - dispatch_push( - manifest_root, - adapter_manifest_path, - store, - entries, - push_ctx, - dry_run, - ) - } - - fn read_config_entry( - &self, - manifest_root: &Path, - adapter_manifest_path: Option<&str>, - component_selector: Option<&str>, - store: &ResolvedStoreId, - key: &str, - push_ctx: &AdapterPushContext<'_>, - ) -> Result { - // Four-branch dispatch mirroring `dispatch_push`: - // - // 1. `push_ctx.local` → delegate to `read_config_entry_local` - // (SQLite-direct, same as the `--local` write path). - // 2. Deploy command targets Fermyon Cloud → `Unsupported`. - // Fermyon Cloud's `spin cloud key-value list` enumerates - // STORES, not keys; there is no stable per-key get CLI in - // v1 (8.3 / 9.4 of the spec). NO shell-out. - // 3. `runtime-config.toml` declares a non-`spin` backend - // (Redis / AzureCosmos / Unknown) → error naming the backend - // and pointing at its native CLI, matching the writer at - // `cli.rs:639-650`. - // 4. Default / `type = "spin"` → SQLite-direct read. - // - // Errors from `runtime_config::read` and from - // `verify_label_declared` are PROPAGATED (not swallowed with - // `.ok()`). Silently falling through on a malformed - // runtime-config would let `config diff` report a result on a - // tree where the writer would have errored hard. - if push_ctx.local { - return self.read_config_entry_local( - manifest_root, - adapter_manifest_path, - component_selector, - store, - key, - push_ctx, - ); - } - - let spin_manifest_path = adapter_manifest_path - .map(|rel| manifest_root.join(rel)) - .ok_or_else(|| { - "[adapters.spin.adapter].manifest must point at spin.toml for config diff" - .to_owned() - })?; - let spin_manifest_dir = spin_manifest_path.parent().unwrap_or(manifest_root); - let runtime_config_path = push_ctx.runtime_config_path.map_or_else( - || spin_manifest_dir.join("runtime-config.toml"), - Path::to_path_buf, - ); - let runtime_config_dir = runtime_config_path.parent().unwrap_or(spin_manifest_dir); - let platform = store.platform.as_str(); - - // Branch 2: Fermyon Cloud auto-detect. - if push_cloud::deploy_command_targets_fermyon_cloud(push_ctx.manifest_adapter_deploy_cmd) { - return Ok(ReadConfigEntry::Unsupported( - "Spin Cloud key-value CLI exposes no `get`; remote read-back unsupported in v1", - )); - } - - // Branches 3 + 4: parse runtime-config, propagating parse errors, - // then dispatch on backend type. - let parsed = runtime_config::read(&runtime_config_path)?; - verify_label_declared(platform, &parsed, &runtime_config_path)?; - let backend = parsed.key_value_stores.get(platform); - match backend { - Some(runtime_config::KeyValueBackend::Redis { url }) => Err(format!( - "store `{platform}` is backed by `type = \"redis\"` (url: `{url}`) in {}; \ - use `redis-cli -u {url} GET ` to read entries from this store. \ - edgezero does not read from redis backends.", - runtime_config_path.display() - )), - Some(runtime_config::KeyValueBackend::AzureCosmos) => Err(format!( - "store `{platform}` is backed by `type = \"azure_cosmos\"` in {}; \ - use the Azure CosmosDB CLI to read this store. \ - edgezero does not read from azure_cosmos backends.", - runtime_config_path.display() - )), - Some(runtime_config::KeyValueBackend::Unknown { type_name }) => Err(format!( - "store `{platform}` is backed by an unrecognised type `{type_name}` in {}; \ - edgezero only reads from `type = \"spin\"` (SQLite) backends.", - runtime_config_path.display() - )), - // Branch 4: `type = "spin"` or missing stanza (default). - Some(runtime_config::KeyValueBackend::Spin { path }) => { - let db_path = push_sqlite::resolve_sqlite_path( - spin_manifest_dir, - runtime_config_dir, - path.as_deref(), - ); - read_sqlite_entry(&db_path, platform, key) - } - None => { - let db_path = - push_sqlite::resolve_sqlite_path(spin_manifest_dir, runtime_config_dir, None); - read_sqlite_entry(&db_path, platform, key) - } - } - } - - fn read_config_entry_local( - &self, - manifest_root: &Path, - adapter_manifest_path: Option<&str>, - _component_selector: Option<&str>, - store: &ResolvedStoreId, - key: &str, - push_ctx: &AdapterPushContext<'_>, - ) -> Result { - // Branch 1: `--local` forces SQLite-direct regardless of the - // runtime-config backend type or the Fermyon Cloud auto-detect. - // Mirrors the write path at `dispatch_push` branch 1 (cli.rs:572). - // - // We still enforce that any non-`default` label is declared in - // `runtime-config.toml` (same invariant as the writer) so the - // read path can't silently succeed on a tree where `spin up` - // would error with "unknown key_value_stores label X". - // - // An explicit `--runtime-config ` is honoured for path - // resolution; the backend `type` is ignored (SQLite is always - // the target for `--local`). - let spin_manifest_path = adapter_manifest_path - .map(|rel| manifest_root.join(rel)) - .ok_or_else(|| { - "[adapters.spin.adapter].manifest must point at spin.toml for config diff --local" - .to_owned() - })?; - let spin_manifest_dir = spin_manifest_path.parent().unwrap_or(manifest_root); - let runtime_config_path = push_ctx.runtime_config_path.map_or_else( - || spin_manifest_dir.join("runtime-config.toml"), - Path::to_path_buf, - ); - let runtime_config_dir = runtime_config_path.parent().unwrap_or(spin_manifest_dir); - let platform = store.platform.as_str(); - - // Parse runtime-config (propagating errors). - let parsed = runtime_config::read(&runtime_config_path)?; - verify_label_declared(platform, &parsed, &runtime_config_path)?; - - // Resolve the SQLite path: honour any explicit `path` in a - // `type = "spin"` stanza; fall back to Spin's default otherwise - // (matches the write path at dispatch_push branch 1). - let explicit_path = match parsed.key_value_stores.get(platform) { - Some(runtime_config::KeyValueBackend::Spin { path }) => path.as_deref(), - _ => None, - }; - let db_path = - push_sqlite::resolve_sqlite_path(spin_manifest_dir, runtime_config_dir, explicit_path); - read_sqlite_entry(&db_path, platform, key) - } - - fn single_store_kinds(&self) -> &'static [&'static str] { - //: Multi for KV AND Config (both label-backed via the - // Spin KV API since Stage 5 of the spin-kv-config plan). - // Single for Secrets (still flat-variable namespace). - &["secrets"] - } - - fn validate_adapter_manifest( - &self, - manifest_root: &Path, - adapter_manifest_path: Option<&str>, - component_selector: Option<&str>, - ) -> Result<(), String> { - // check 3: spin.toml must exist and either declare - // exactly one `[component.*]` or carry an explicit selector - // that matches one of the declared ids. - let Some(rel) = adapter_manifest_path else { - return Err( - "[adapters.spin.adapter].manifest must point at spin.toml for Spin component discovery".to_owned() - ); - }; - let spin_path = manifest_root.join(rel); - let raw = fs::read_to_string(&spin_path).map_err(|err| { - format!( - "failed to read spin manifest at {}: {err}", - spin_path.display() - ) - })?; - let parsed: toml::Value = toml::from_str(&raw) - .map_err(|err| format!("failed to parse {} as TOML: {err}", spin_path.display()))?; - let component_ids = collect_spin_component_ids(&parsed); - - if component_ids.is_empty() { - return Err(format!( - "{}: no [component.*] declarations found", - spin_path.display() - )); - } - - if let Some(selector) = component_selector { - if component_ids.iter().any(|id| id == selector) { - return Ok(()); - } - return Err(format!( - "[adapters.spin.adapter].component = {:?} is not declared in {} (available: {})", - selector, - spin_path.display(), - component_ids.join(", ") - )); - } - - if component_ids.len() == 1 { - return Ok(()); - } - Err(format!( - "{} declares {} components ({}) but [adapters.spin.adapter].component is unset; set one explicitly", - spin_path.display(), - component_ids.len(), - component_ids.join(", ") - )) - } - - fn validate_typed_secrets(&self, entries: &[TypedSecretEntry<'_>]) -> Result<(), String> { - use std::collections::HashMap; - // Stage 5+: KV-backed config no longer shares Spin's flat - // variable namespace, so config keys are NOT considered here - // (and the trait dropped the parameter in Stage 6+) — config - // can use arbitrary UTF-8 keys without colliding with - // `#[secret]` values. Secrets still resolve through - // `spin_sdk::variables`, so two checks remain: - // 1. each `#[secret]` value canonicalises (lowercase, no - // `.→__` — secrets don't get translated at runtime) - // to a valid Spin variable name, so invalid chars - // (dashes, digit-first) fail validation rather than - // at runtime with an opaque `InvalidName`; - // 2. no two `#[secret]` values collapse to the same - // lowercased Spin variable, since Spin's flat - // namespace cannot disambiguate them. - // Map lowercased-Spin-variable → original field name. When a - // second entry collapses to the same name, the existing entry - // tells us which field already claimed it. - let mut seen: HashMap = HashMap::with_capacity(entries.len()); - for entry in entries { - let spin_var = entry.key_value.to_ascii_lowercase(); - if !is_valid_spin_key(&spin_var) { - let reason = spin_key_rule_violation(&spin_var); - return Err(format!( - "`#[secret]` field `{field}` value `{value}` translates to Spin variable `{spin_var}`, which is not a valid Spin variable name. {reason}. Pick a `#[secret]` value that conforms.", - field = entry.field_name, - value = entry.key_value, - )); - } - if let Some(prev_field) = seen.insert(spin_var.clone(), entry.field_name) { - return Err(format!( - "Spin variable `{spin_var}` would receive values from BOTH `#[secret]` field `{prev_field}` AND `#[secret]` field `{this_field}`; Spin's flat variable namespace cannot disambiguate them. Pick distinct `#[secret]` values whose lowercased forms differ.", - this_field = entry.field_name, - )); - } - } - Ok(()) - } -} - -fn is_valid_spin_key(key: &str) -> bool { - let mut chars = key.chars(); - let Some(first) = chars.next() else { - return false; - }; - if !first.is_ascii_lowercase() { - return false; - } - chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_') -} - -/// Return a per-failure-mode diagnostic for a key that failed -/// `is_valid_spin_key`. Spin's variable-name rule -/// (`^[a-z][a-z0-9_]*$`) is one regex but the operator usually -/// wants to know WHICH bit they broke: digit-leading, uppercase, -/// or stray punctuation. Returns a short phrase to splice into -/// the caller's full error. -fn spin_key_rule_violation(key: &str) -> &'static str { - // Callers only invoke this AFTER `is_valid_spin_key` returned - // false; in production the per-char branches below exhaust the - // failure modes and the catch-all at the bottom is unreachable. - // It's kept defensively so a future regex tweak (e.g. allowing - // a new char class) doesn't crash the diagnostic helper with - // an unreachable!() before the caller can produce its error. - // - // Reachability notes for the per-mode branches: - // - `push_config_entries` translates keys via - // `translate_key_for_spin` (which lowercases) BEFORE this - // call, so the uppercase-first branch is unreachable from - // that site. It IS reachable from `validate_app_config_keys` - // and `validate_typed_secrets`, which check raw user input. - let mut chars = key.chars(); - let Some(first) = chars.next() else { - return "Spin variable names must not be empty"; - }; - if first.is_ascii_digit() { - return "Spin variable names must start with a lowercase letter, not a digit"; - } - if first.is_ascii_uppercase() { - return "Spin variable names must be lowercase (uppercase letters are not allowed)"; - } - if !first.is_ascii_lowercase() { - return "Spin variable names must start with a lowercase ASCII letter"; - } - for ch in chars { - if ch.is_ascii_uppercase() { - return "Spin variable names must be lowercase (uppercase letters are not allowed)"; - } - if !(ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_') { - return "Spin variable names may only contain lowercase letters, digits, and underscores"; - } - } - debug_assert!( - false, - "spin_key_rule_violation called with key `{key}` that satisfies the regex; check is_valid_spin_key + caller agreement" - ); - "Spin variable names must match `^[a-z][a-z0-9_]*$`" -} - -fn collect_spin_component_ids(parsed: &toml::Value) -> Vec { - parsed - .as_table() - .and_then(|root| root.get("component")) - .and_then(toml::Value::as_table) - .map(|components| components.keys().cloned().collect()) - .unwrap_or_default() -} - -/// Read `[application].name` from `spin.toml`. Required by the -/// Fermyon Cloud writer to address KV stores via the app-scoped -/// label model (`--app --label