diff --git a/.github/actions/deploy-core/tests/assert-production-deploy.sh b/.github/actions/deploy-core/tests/assert-production-deploy.sh index 83727360..4d7893fc 100755 --- a/.github/actions/deploy-core/tests/assert-production-deploy.sh +++ b/.github/actions/deploy-core/tests/assert-production-deploy.sh @@ -50,7 +50,7 @@ main() { local expected for expected in \ 'token=dummy-token' \ - 'service-id=dummy-service' \ + 'service-id=dummyservice' \ 'endpoint=CLEARED' \ 'home=CLEARED' \ 'action-token-carrier=CLEARED' \ diff --git a/.github/actions/deploy-core/tests/assert-rollback-calls.sh b/.github/actions/deploy-core/tests/assert-rollback-calls.sh index a7b55748..53a273d8 100755 --- a/.github/actions/deploy-core/tests/assert-rollback-calls.sh +++ b/.github/actions/deploy-core/tests/assert-rollback-calls.sh @@ -22,7 +22,7 @@ main() { local log="${FAKE_CALL_LOG:?FAKE_CALL_LOG is required}" local staged="${EDGEZERO__TEST__STAGED_VERSION:?EDGEZERO__TEST__STAGED_VERSION is required}" local rolled_back_to="${EDGEZERO__TEST__ROLLED_BACK_TO:-}" - local api='https://api\.fastly\.com/service/dummy-service' + local api='https://api\.fastly\.com/service/dummyservice' # The EXPLICIT rollback-to the smoke passes to the production rollback (it rolls # back FROM the active version 40 TO 39; the best-effort staleness guard requires the # rolled-back-from version to still be active). diff --git a/.github/actions/deploy-core/tests/assert-staged-calls.sh b/.github/actions/deploy-core/tests/assert-staged-calls.sh index f8a9144b..10539e2d 100755 --- a/.github/actions/deploy-core/tests/assert-staged-calls.sh +++ b/.github/actions/deploy-core/tests/assert-staged-calls.sh @@ -45,16 +45,16 @@ assert_comment_precedes_stage() { fail "the comment was applied after staging; it must precede it" } -# The staging twin must MIRROR production's runtime overrides: the non-config -# override (LOG_LEVEL) is copied verbatim and the config selector is redirected -# to `_staging`, both written into the twin (STAGESEL1) before the -# relink. Without the mirror the staged version would lose production's adapter / -# logging overrides. +# The staging twin must MIRROR this service's production runtime overrides: the +# scoped logging level is copied verbatim and the scoped config selector is +# redirected to `_staging`, both written into the twin (STAGESEL1) +# before the relink. Without the mirror the staged version would lose its +# production logging override. assert_twin_mirrors_production() { local log="$1" - grep -qE '^fastly config-store-entry update .*--store-id=STAGESEL1 .*--key=EDGEZERO__ADAPTER__FASTLY__LOG_LEVEL' "$log" || + grep -qE '^fastly config-store-entry update .*--store-id=STAGESEL1 .*--key=EDGEZERO__SERVICES__dummyservice__LOGGING__LEVEL' "$log" || fail "production's non-config override was not mirrored into the staging twin" - grep -qE '^fastly config-store-entry update .*--store-id=STAGESEL1 .*--key=EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY' "$log" || + grep -qE '^fastly config-store-entry update .*--store-id=STAGESEL1 .*--key=EDGEZERO__SERVICES__dummyservice__STORES__CONFIG__APP_CONFIG__KEY' "$log" || fail "the config selector was not written into the staging twin" # The mirror must land before the relink points the draft at the twin. diff --git a/.github/actions/deploy-core/tests/assert-staging-probe.sh b/.github/actions/deploy-core/tests/assert-staging-probe.sh index 9c996ae0..957e8de7 100755 --- a/.github/actions/deploy-core/tests/assert-staging-probe.sh +++ b/.github/actions/deploy-core/tests/assert-staging-probe.sh @@ -25,7 +25,7 @@ main() { local healthy="${EDGEZERO__TEST__HEALTHY:-}" local status_code="${EDGEZERO__TEST__STATUS_CODE:-}" - grep -qE "^GET https://api\.fastly\.com/service/dummy-service/version/$staged/domain\?include=staging_ips\$" "$log" || + grep -qE "^GET https://api\.fastly\.com/service/dummyservice/version/$staged/domain\?include=staging_ips\$" "$log" || fail "the staging-IP lookup was never performed for version $staged" grep -qE '^PROBE .*--connect-to ::151\.101\.2\.10:443 .*https://staging\.example\.com/' "$log" || diff --git a/.github/actions/deploy-core/tests/assert-stale-rollback-refused.sh b/.github/actions/deploy-core/tests/assert-stale-rollback-refused.sh index 9a5d3908..76365345 100755 --- a/.github/actions/deploy-core/tests/assert-stale-rollback-refused.sh +++ b/.github/actions/deploy-core/tests/assert-stale-rollback-refused.sh @@ -30,7 +30,7 @@ main() { local state="${FAKE_ACTIVE_VERSION_FILE:?FAKE_ACTIVE_VERSION_FILE is required}" local outcome="${EDGEZERO__TEST__OUTCOME:?EDGEZERO__TEST__OUTCOME is required}" local snapshot="${EDGEZERO__TEST__LOG_SNAPSHOT:?EDGEZERO__TEST__LOG_SNAPSHOT is required}" - local api='https://api\.fastly\.com/service/dummy-service' + local api='https://api\.fastly\.com/service/dummyservice' [[ "$outcome" == "failure" ]] || fail "a stale production rollback must fail (the rolled-back-from version is no longer active), got outcome=$outcome" diff --git a/.github/actions/deploy-core/tests/make-fake-fastly-env.sh b/.github/actions/deploy-core/tests/make-fake-fastly-env.sh index e1373a7b..e10af721 100755 --- a/.github/actions/deploy-core/tests/make-fake-fastly-env.sh +++ b/.github/actions/deploy-core/tests/make-fake-fastly-env.sh @@ -45,16 +45,16 @@ case "\${1:-} \${2:-}" in "compute update") # A realistic success line: the version parser is fail-closed and will # refuse to stage if it cannot read a version out of this output. - echo "SUCCESS: Updated package (service dummy-service, version 42)" + echo "SUCCESS: Updated package (service dummyservice, version 42)" ;; - "compute deploy") echo "SUCCESS: Deployed package (service dummy-service, version 43)" ;; + "compute deploy") echo "SUCCESS: Deployed package (service dummyservice, version 43)" ;; "service-version update") echo "Updated version comment" ;; "service-version stage") echo "Staged version" ;; # An app WITH config selection: the app config store, the production selector # store edgezero_runtime_env (so a staged deploy relinks rather than skipping), # and its staging twin (the store the relink points at). config push resolves a # store id by name from this list, reads the current entry to diff, then upserts. - "config-store list") echo '[{"id":"STOREID1","name":"app_config"},{"id":"ENVSEL1","name":"edgezero_runtime_env"},{"id":"STAGESEL1","name":"edgezero_runtime_env_staging_dummy-service"}]' ;; + "config-store list") echo '[{"id":"STOREID1","name":"app_config"},{"id":"ENVSEL1","name":"edgezero_runtime_env"},{"id":"STAGESEL1","name":"edgezero_runtime_env_staging_dummyservice"}]' ;; # A cloned draft inherits the active version's links; the staged deploy drops # this one and re-links the staging store under the same name. "resource-link list") echo '[{"id":"LINK_ENV","name":"edgezero_runtime_env"}]' ;; @@ -69,10 +69,10 @@ case "\${1:-} \${2:-}" in ;; "config-store-entry list") # A staged deploy MIRRORS the production selector store into the staging twin. - # Production (ENVSEL1) carries a non-config override the twin must copy - # verbatim; the twin (STAGESEL1) starts empty. + # Production (ENVSEL1) carries this service's scoped logging override, which + # the twin must copy verbatim; the twin (STAGESEL1) starts empty. case "\$*" in - *--store-id=ENVSEL1*) echo '[{"item_key":"EDGEZERO__ADAPTER__FASTLY__LOG_LEVEL","item_value":"debug"}]' ;; + *--store-id=ENVSEL1*) echo '[{"item_key":"EDGEZERO__SERVICES__dummyservice__LOGGING__LEVEL","item_value":"debug"}]' ;; *) echo '[]' ;; esac ;; diff --git a/.github/actions/deploy-core/tests/run.sh b/.github/actions/deploy-core/tests/run.sh index 6345b924..afd3aea4 100755 --- a/.github/actions/deploy-core/tests/run.sh +++ b/.github/actions/deploy-core/tests/run.sh @@ -414,7 +414,7 @@ test_wrapper_validate() { run_dfl() { env EDGEZERO__APP__CLI__ARTIFACT_PRESENT="${A:-true}" \ EDGEZERO__FASTLY__API_TOKEN_PRESENT="${T:-true}" \ - EDGEZERO__FASTLY__SERVICE_ID="${S-svc_1}" \ + EDGEZERO__FASTLY__SERVICE_ID="${S-svc1}" \ EDGEZERO__ADAPTER=fastly EDGEZERO__RUNNER__OS=Linux EDGEZERO__RUNNER__ARCH=X64 \ EDGEZERO__ACTION__STATE_DIR="$WORK_DIR/dfl-state" \ GITHUB_OUTPUT="$WORK_DIR/dfl-out.txt" \ @@ -424,6 +424,8 @@ test_wrapper_validate() { A=false assert_fails "deploy-fastly: missing artifact is rejected" run_dfl T=false assert_fails "deploy-fastly: missing token (by presence) is rejected" run_dfl S='bad id!' assert_fails "deploy-fastly: malformed service-id is rejected" run_dfl + S='svc_1' assert_fails "deploy-fastly: service-id with underscore is rejected" run_dfl + S='svc-1' assert_fails "deploy-fastly: service-id with hyphen is rejected" run_dfl S='' assert_fails "deploy-fastly: empty service-id is rejected" run_dfl # config-push-fastly: artifact + token presence, deploy-to fail-closed. diff --git a/.github/actions/deploy-fastly/scripts/deploy.sh b/.github/actions/deploy-fastly/scripts/deploy.sh index 9a13e38c..101d48e6 100755 --- a/.github/actions/deploy-fastly/scripts/deploy.sh +++ b/.github/actions/deploy-fastly/scripts/deploy.sh @@ -28,7 +28,7 @@ main() { local service_id="${EDGEZERO__FASTLY__SERVICE_ID:-}" require_input fastly-api-token "$token" - require_input_matching fastly-service-id "$service_id" '^[A-Za-z0-9_-]+$' + require_input_matching fastly-service-id "$service_id" '^[A-Za-z0-9]+$' require_cmd jq EDGEZERO__PROVIDER__ENV=$(jq -n --arg t "$token" --arg s "$service_id" \ diff --git a/.github/actions/deploy-fastly/scripts/validate.sh b/.github/actions/deploy-fastly/scripts/validate.sh index 47e0c353..38013f47 100755 --- a/.github/actions/deploy-fastly/scripts/validate.sh +++ b/.github/actions/deploy-fastly/scripts/validate.sh @@ -28,7 +28,7 @@ main() { # run, so the CLI we then execute with credentials would be arbitrary. require_present app-cli-artifact "${EDGEZERO__APP__CLI__ARTIFACT_PRESENT:-}" require_present fastly-api-token "${EDGEZERO__FASTLY__API_TOKEN_PRESENT:-}" - require_input_matching fastly-service-id "${EDGEZERO__FASTLY__SERVICE_ID:-}" '^[A-Za-z0-9_-]+$' + require_input_matching fastly-service-id "${EDGEZERO__FASTLY__SERVICE_ID:-}" '^[A-Za-z0-9]+$' # Provider-neutral validation (adapter, booleans, JSON-array args, the # allowlist). It also rejects a 'deploy-to' that is neither production nor diff --git a/.github/actions/healthcheck-fastly/scripts/healthcheck.sh b/.github/actions/healthcheck-fastly/scripts/healthcheck.sh index c752879c..b98d59c1 100755 --- a/.github/actions/healthcheck-fastly/scripts/healthcheck.sh +++ b/.github/actions/healthcheck-fastly/scripts/healthcheck.sh @@ -32,7 +32,7 @@ validate_inputs() { require_linux_x86_64 # `required: true` in action metadata does not fail an omitted input, so the # only real guard against probing with an empty service/version is this one. - require_input_matching fastly-service-id "${EDGEZERO__LIFECYCLE__SERVICE_ID:-}" '^[A-Za-z0-9_-]+$' + require_input_matching fastly-service-id "${EDGEZERO__LIFECYCLE__SERVICE_ID:-}" '^[A-Za-z0-9]+$' require_input_matching fastly-version "${EDGEZERO__LIFECYCLE__VERSION:-}" '^[0-9]+$' require_input_matching domain "${EDGEZERO__LIFECYCLE__DOMAIN:-}" '^[A-Za-z0-9._-]+$' # The path is appended to https:// as one curl argument (the CLI diff --git a/.github/actions/rollback-fastly/scripts/rollback.sh b/.github/actions/rollback-fastly/scripts/rollback.sh index 0e47772e..bebf3c93 100755 --- a/.github/actions/rollback-fastly/scripts/rollback.sh +++ b/.github/actions/rollback-fastly/scripts/rollback.sh @@ -25,7 +25,7 @@ source "$SCRIPT_DIR/../../deploy-core/scripts/common.sh" validate_inputs() { require_linux_x86_64 - require_input_matching fastly-service-id "${EDGEZERO__LIFECYCLE__SERVICE_ID:-}" '^[A-Za-z0-9_-]+$' + require_input_matching fastly-service-id "${EDGEZERO__LIFECYCLE__SERVICE_ID:-}" '^[A-Za-z0-9]+$' require_input_matching fastly-version "${EDGEZERO__LIFECYCLE__VERSION:-}" '^[0-9]+$' require_input fastly-api-token "${FASTLY_API_TOKEN:-}" # A typo in deploy-to must never silently roll back production. diff --git a/.github/workflows/deploy-action.yml b/.github/workflows/deploy-action.yml index ac450a26..eff8262d 100644 --- a/.github/workflows/deploy-action.yml +++ b/.github/workflows/deploy-action.yml @@ -179,7 +179,7 @@ jobs: app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} working-directory: fixture-app fastly-api-token: dummy-token - fastly-service-id: dummy-service + fastly-service-id: dummyservice deploy-args: '["--comment","smoke"]' - name: Assert production deploy, version threading, and credential boundary @@ -197,7 +197,7 @@ jobs: with: app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} fastly-api-token: dummy-token - fastly-service-id: dummy-service + fastly-service-id: dummyservice fastly-version: ${{ steps.deploy.outputs['fastly-version'] }} rollback-to: ${{ steps.deploy.outputs['previous-version'] }} deploy-to: production @@ -254,7 +254,7 @@ jobs: app-cli-artifact: edgezero-cli-handoff working-directory: fixture-app fastly-api-token: dummy-token - fastly-service-id: dummy-service + fastly-service-id: dummyservice - name: Assert the cross-job handoff produced a real production deploy env: EDGEZERO__TEST__FASTLY_VERSION: ${{ steps.deploy.outputs['fastly-version'] }} @@ -290,7 +290,7 @@ jobs: app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} working-directory: fixture-app fastly-api-token: dummy-token - fastly-service-id: dummy-service + fastly-service-id: dummyservice build-mode: always cache: true - name: Capture the seeded marker, then delete target/ @@ -301,7 +301,7 @@ jobs: app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} working-directory: fixture-app fastly-api-token: dummy-token - fastly-service-id: dummy-service + fastly-service-id: dummyservice build-mode: always cache: true - name: Assert the marker was restored from cache (not rebuilt) @@ -318,7 +318,7 @@ jobs: app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} working-directory: fixture-app fastly-api-token: dummy-token - fastly-service-id: dummy-service + fastly-service-id: dummyservice build-mode: never cache: true - name: Assert the cache was NOT restored under build-mode never @@ -359,7 +359,7 @@ jobs: app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} working-directory: fixture-app fastly-api-token: dummy-token - fastly-service-id: dummy-service + fastly-service-id: dummyservice - name: Assert the deploy failed but signalled a possible mutation env: EDGEZERO__TEST__DEPLOY_OUTCOME: ${{ steps.deploy.outcome }} @@ -377,7 +377,7 @@ jobs: id: recover env: FASTLY_API_TOKEN: dummy-token - FASTLY_SERVICE_ID: dummy-service + FASTLY_SERVICE_ID: dummyservice run: .github/actions/deploy-core/tests/recovery-active-version.sh recover-cli - name: Roll back to the captured previous version, keyed on the recovered live version id: rollback @@ -385,7 +385,7 @@ jobs: with: app-cli-artifact: edgezero-cli-recovery fastly-api-token: dummy-token - fastly-service-id: dummy-service + fastly-service-id: dummyservice fastly-version: ${{ steps.recover.outputs.version }} # Thread the deploy's OWN previous-version output (captured pre-mutation), # exactly as the guide's recovery documents — not a hardcoded value. @@ -521,7 +521,7 @@ jobs: app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} working-directory: fixture-app fastly-api-token: dummy-token - fastly-service-id: dummy-service + fastly-service-id: dummyservice deploy-args: '["--comment","staged smoke"]' deploy-to: staging @@ -539,7 +539,7 @@ jobs: domain: staging.example.com fastly-version: ${{ steps.stage.outputs['fastly-version'] }} fastly-api-token: dummy-token - fastly-service-id: dummy-service + fastly-service-id: dummyservice retry: "1" retry-delay: "1" @@ -560,7 +560,7 @@ jobs: domain: staging.example.com fastly-version: ${{ steps.stage.outputs['fastly-version'] }} fastly-api-token: dummy-token - fastly-service-id: dummy-service + fastly-service-id: dummyservice retry: "1" retry-delay: "1" env: @@ -588,7 +588,7 @@ jobs: deploy-to: production domain: staging.example.com fastly-version: ${{ steps.stage.outputs['fastly-version'] }} - fastly-service-id: dummy-service + fastly-service-id: dummyservice retry: "1" retry-delay: "1" @@ -609,7 +609,7 @@ jobs: deploy-to: staging domain: staging.example.com fastly-version: ${{ steps.stage.outputs['fastly-version'] }} - fastly-service-id: dummy-service + fastly-service-id: dummyservice retry: "1" retry-delay: "1" @@ -627,7 +627,7 @@ jobs: deploy-to: staging fastly-version: ${{ steps.stage.outputs['fastly-version'] }} fastly-api-token: dummy-token - fastly-service-id: dummy-service + fastly-service-id: dummyservice - name: Roll back production id: prod-rollback @@ -643,7 +643,7 @@ jobs: fastly-version: "40" rollback-to: "39" fastly-api-token: dummy-token - fastly-service-id: dummy-service + fastly-service-id: dummyservice - name: Assert rollback verbs, paths, and version threading env: @@ -662,7 +662,7 @@ jobs: deploy-to: production fastly-version: ${{ steps.stage.outputs['fastly-version'] }} fastly-api-token: dummy-token - fastly-service-id: dummy-service + fastly-service-id: dummyservice - name: Assert the targetless production rollback was refused env: @@ -691,7 +691,7 @@ jobs: fastly-version: "40" rollback-to: "38" fastly-api-token: dummy-token - fastly-service-id: dummy-service + fastly-service-id: dummyservice - name: Assert the stale rollback was refused and activated nothing env: diff --git a/crates/edgezero-adapter-fastly/src/cli.rs b/crates/edgezero-adapter-fastly/src/cli.rs index 51ee8325..b29a43ec 100644 --- a/crates/edgezero-adapter-fastly/src/cli.rs +++ b/crates/edgezero-adapter-fastly/src/cli.rs @@ -1,6 +1,7 @@ use std::cell::{Cell, RefCell}; use std::collections::{BTreeMap, HashMap, HashSet}; use std::env; +use std::ffi::OsString; use std::fmt::Write as _; use std::fs; use std::io::{ErrorKind, Write as _}; @@ -20,6 +21,7 @@ use crate::chunked_config::{ prior_chunk_keys, resolve_fastly_config_value_typed, sha256_hex, value_announces_our_kind, value_is_future_format, value_is_inert_foreign, verify_writer_split_layout, }; +use crate::service_scoped_runtime_env_key; use ctor::ctor; use edgezero_adapter::cli_support::{ find_manifest_upwards, find_workspace_root, path_distance, read_package_name, run_native_cli, @@ -349,6 +351,19 @@ struct TempFileGuard { path: Option, } +struct EntryCommitFailure { + committed: Vec, + error: String, + failed_key: String, + not_attempted: Vec, + total: usize, +} + +struct RuntimeStoreNameReconciliation { + deletes: Vec, + upserts: Vec<(String, 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 @@ -464,6 +479,9 @@ impl Adapter for FastlyCliAdapter { ); }; let fastly_path = manifest_root.join(rel); + let manifest_dir = fastly_path.parent().unwrap_or(manifest_root); + let runtime_env_service_id = + provision_runtime_env_service_id_for_stores(&fastly_path, stores)?; let mut out = Vec::new(); for (kind, ids) in [ @@ -494,7 +512,7 @@ impl Adapter for FastlyCliAdapter { )); continue; } - create_fastly_store(kind, name)?; + create_fastly_store_in(kind, name, manifest_dir)?; // If the platform store was created but the // writeback fails, remote state and the local // manifest are out of sync. Re-running `provision` @@ -555,7 +573,7 @@ impl Adapter for FastlyCliAdapter { 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)?; + create_fastly_store_in(runtime_env_kind, runtime_env_name, manifest_dir)?; append_fastly_setup(&fastly_path, runtime_env_kind, runtime_env_name).map_err( |err| { format!( @@ -577,8 +595,12 @@ impl Adapter for FastlyCliAdapter { // make production serve staged config. Staged versions get their own // selector via `edgezero_runtime_env_staging`, wired automatically by // a staged deploy; nothing here should be edited to stage config. + let production_selector_key = runtime_env_key_for( + runtime_env_service_id.as_deref().unwrap_or(""), + "app_config", + ); let mut line = format!( - "created fastly {runtime_env_kind}-store `{runtime_env_name}` (EdgeZero runtime override store, read by the ACTIVE version); appended setup tables to {}\n It already selects each store's default key, so no edit is needed for a normal setup.\n To point PRODUCTION at a different key (e.g. a renamed store), and only then:\n fastly config-store-entry update --store-id= --key=EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY --value= --upsert\n Do NOT set a `_staging` key here: staged config is isolated by a per-service `{RUNTIME_ENV_STAGING_STORE_PREFIX}_` store, which a staged deploy creates and links automatically.", + "created fastly {runtime_env_kind}-store `{runtime_env_name}` (EdgeZero runtime override store, read by the ACTIVE version); appended setup tables to {}\n Provision writes service-scoped non-default store-name mappings below. Config stores still select their logical id as the default key.\n To point PRODUCTION at a different config key, and only then:\n fastly config-store-entry update --store-id= --key={production_selector_key} --value= --upsert\n Do NOT set a `_staging` key here: staged config is isolated by a per-service `{RUNTIME_ENV_STAGING_STORE_PREFIX}_` store, which a staged deploy creates and links automatically.", fastly_path.display() ); if let Some(note) = post_create_note { @@ -590,6 +612,13 @@ impl Adapter for FastlyCliAdapter { // Already declared; nothing to do. } + out.extend(persist_runtime_env_store_name_entries( + stores, + runtime_env_service_id.as_deref(), + dry_run, + manifest_dir, + )?); + // The STAGING twin of the runtime-override store is created and // populated entirely by a staged deploy (see // `relink_runtime_env_for_staging` → `mirror_production_to_staging`), so @@ -1460,19 +1489,20 @@ fn classify_resolved_read( /// # 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> { +fn create_fastly_store_in(kind: &str, name: &str, cwd: &Path) -> Result<(), String> { let subcommand = format!("{kind}-store"); let name_arg = format!("--name={name}"); - let output = Command::new("fastly") + let mut command = Command::new("fastly"); + command .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}") - } - })?; + .current_dir(cwd); + let output = command.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(()); } @@ -1549,18 +1579,67 @@ fn read_fastly_service_id(path: &Path) -> Result, String> { 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). +/// Resolve the service namespace provision uses for account-wide runtime-env +/// entries. A manifest id and environment id must agree so Fastly CLI project +/// context cannot write mappings owned by a different service. +fn provision_runtime_env_service_id(path: &Path) -> Result, String> { + resolve_provision_runtime_env_service_id(path, env::var_os(FASTLY_SERVICE_ID_ENV)) +} + +fn resolve_provision_runtime_env_service_id( + path: &Path, + env_value: Option, +) -> Result, String> { + let manifest_id = read_fastly_service_id(path)?; + let env_id = match env_value { + None => None, + Some(value) => Some( + value + .into_string() + .map_err(|_value| format!("{FASTLY_SERVICE_ID_ENV} must contain valid UTF-8"))?, + ), + }; + + if let Some(service_id) = manifest_id.as_deref() { + validate_service_id(service_id)?; + } + if let Some(service_id) = env_id.as_deref() { + validate_service_id(service_id)?; + } + match (manifest_id, env_id) { + (Some(manifest), Some(environment)) if manifest != environment => Err(format!( + "Fastly service id mismatch: {} declares `{manifest}` but {FASTLY_SERVICE_ID_ENV} is `{environment}`; refusing to write runtime mappings across service namespaces", + path.display() + )), + (Some(manifest), _) => Ok(Some(manifest)), + (None, Some(environment)) => Ok(Some(environment)), + (None, None) => Ok(None), + } +} + +fn provision_runtime_env_service_id_for_stores( + path: &Path, + stores: &ProvisionStores<'_>, +) -> Result, String> { + let service_id = provision_runtime_env_service_id(path)?; + if has_non_default_store_name_mappings(stores) && service_id.is_none() { + return Err(format!( + "cannot persist non-default Fastly store-name mappings without a service namespace: set top-level `service_id` in {} or set {FASTLY_SERVICE_ID_ENV}", + path.display() + )); + } + Ok(service_id) +} + +/// If fastly.toml declares `service_id` or `FASTLY_SERVICE_ID` selects one, +/// the next `fastly compute deploy` targets an existing service and skips +/// `[setup]`. Any store created by provision then needs a separate resource +/// link. This helper returns that remediation or `None` before a service has +/// been selected. fn resource_link_note(path: &Path, kind: &str, name: &str) -> Result, String> { - let note = read_fastly_service_id(path)?.map(|svc_id| { + let note = provision_runtime_env_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`)." + " Fastly service id resolves to `{svc_id}`, so `[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) @@ -3169,48 +3248,90 @@ fn chunk_key_generation_any(key: &str) -> Option { chunk_key_generation(root, key) } -/// 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. The FAILED entry's outcome is UNKNOWN — Fastly may have -/// committed it before returning the error — so the message does not -/// claim a clean boundary; it directs the operator to re-run the whole -/// idempotent push rather than hand-resume from a supposed cut point. -fn push_entries_with_committer( +/// Drive the common sequential commit mechanics while leaving recovery policy +/// to the operation that owns the writes. +fn commit_entries_with_committer( entries: &[(String, String)], mut committer: F, -) -> Result +) -> 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}` while committing {committed} of {total} entries.\n \ - The failed entry's outcome is UNKNOWN: Fastly may have committed it before the error \ - (a timeout can arrive after the write lands), including when it is the root pointer.\n \ - Recovery: re-run the SAME `config push`. It is idempotent -- chunk keys are content-addressed \ - and writes use `--upsert` -- so entries already written are rewritten harmlessly and any \ - missing ones are filled. Do NOT hand-delete the failed key.\n \ - Already written (a retry rewrites them): {pushed:?}\n \ - Failed: `{key}` (outcome unknown) -- {err}\n \ - Not attempted: {remaining:?}", - committed = pushed.len(), - total = entries.len(), - )); + let mut written_keys = Vec::with_capacity(entries.len()); + for (index, (key, value)) in entries.iter().enumerate() { + if let Err(error) = committer(key, value) { + return Err(EntryCommitFailure { + committed: written_keys, + error, + failed_key: key.clone(), + not_attempted: entries + .iter() + .skip(index.saturating_add(1)) + .map(|(remaining_key, _)| remaining_key.clone()) + .collect(), + total: entries.len(), + }); } - pushed.push(key.clone()); + written_keys.push(key.clone()); } - Ok(pushed.len()) + Ok(written_keys.len()) +} + +/// Commit config-push entries and retain its chunk-aware retry guidance. +fn push_entries_with_committer( + entries: &[(String, String)], + committer: F, +) -> Result +where + F: FnMut(&str, &str) -> Result<(), String>, +{ + commit_entries_with_committer(entries, committer).map_err(|failure| { + format!( + "fastly push failed at entry `{failed_key}` while committing {committed} of {total} entries.\n \ + The failed entry's outcome is UNKNOWN: Fastly may have committed it before the error \ + (a timeout can arrive after the write lands), including when it is the root pointer.\n \ + Recovery: re-run the SAME `config push`. It is idempotent -- chunk keys are content-addressed \ + and writes use `--upsert` -- so entries already written are rewritten harmlessly and any \ + missing ones are filled. Do NOT hand-delete the failed key.\n \ + Already written (a retry rewrites them): {already_written:?}\n \ + Failed: `{failed_key}` (outcome unknown) -- {error}\n \ + Not attempted: {not_attempted:?}", + failed_key = failure.failed_key, + committed = failure.committed.len(), + total = failure.total, + already_written = failure.committed, + error = failure.error, + not_attempted = failure.not_attempted, + ) + }) +} + +/// Commit runtime store-name mappings with provision-specific recovery advice. +fn push_runtime_store_name_entries_with_committer( + entries: &[(String, String)], + committer: F, +) -> Result +where + F: FnMut(&str, &str) -> Result<(), String>, +{ + commit_entries_with_committer(entries, committer).map_err(|failure| { + format!( + "fastly provision failed while writing runtime store-name mapping `{failed_key}` after committing {committed} of {total} mappings.\n \ + The failed mapping's outcome is UNKNOWN: Fastly may have committed it before the error.\n \ + Recovery: re-run the SAME `edgezero provision --adapter fastly` command with the same \ + `EDGEZERO__STORES__*__NAME` environment. Mapping writes use `--upsert`, so mappings \ + already written are rewritten harmlessly and missing ones are filled.\n \ + Already written (a retry rewrites them): {already_written:?}\n \ + Failed: `{failed_key}` (outcome unknown) -- {error}\n \ + Not attempted: {not_attempted:?}", + failed_key = failure.failed_key, + committed = failure.committed.len(), + total = failure.total, + already_written = failure.committed, + error = failure.error, + not_attempted = failure.not_attempted, + ) + }) } /// Shell `fastly config-store-entry update --upsert --stdin` with @@ -3238,17 +3359,39 @@ where /// 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> { + create_config_store_entry_with_cwd(store_id, key, value, None) +} + +fn create_config_store_entry_in( + store_id: &str, + key: &str, + value: &str, + cwd: &Path, +) -> Result<(), String> { + create_config_store_entry_with_cwd(store_id, key, value, Some(cwd)) +} + +fn create_config_store_entry_with_cwd( + store_id: &str, + key: &str, + value: &str, + cwd: Option<&Path>, +) -> 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", - ]) + let mut command = Command::new("fastly"); + command.args([ + "config-store-entry", + "update", + store_arg.as_str(), + key_arg.as_str(), + "--upsert", + "--stdin", + ]); + if let Some(command_cwd) = cwd { + command.current_dir(command_cwd); + } + let mut child = command .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -3407,9 +3550,9 @@ fn parse_config_store_entries(stdout: &str) -> Result, Str /// `fastly config-store-entry delete --store-id= --key=`, run in the /// app manifest directory. Distinct from the `config gc` `delete_config_store_entry` -/// (which runs in the process cwd with redacted diagnostics); staging reconciliation -/// must run `fastly` in `cwd` so it resolves the right service context. -fn delete_staging_config_store_entry(store_id: &str, key: &str, cwd: &Path) -> Result<(), String> { +/// (which runs in the process cwd with redacted diagnostics); runtime-env +/// reconciliation must run `fastly` in `cwd` so it resolves the right service context. +fn delete_config_store_entry_in(store_id: &str, key: &str, cwd: &Path) -> Result<(), String> { run_fastly_status( &[ "config-store-entry".to_owned(), @@ -3424,10 +3567,10 @@ fn delete_staging_config_store_entry(store_id: &str, key: &str, cwd: &Path) -> R /// Compute the staging selector store's entries from production's, given the /// declared config-store logical ids. /// -/// The twin is a faithful MIRROR of production's runtime overrides — adapter -/// host, logging level, `__NAME` redirects — with exactly one transform: every -/// declared config store's selector key (`EDGEZERO__STORES__CONFIG____KEY`) -/// points at `_staging`, the key `config push --staging` writes. A +/// The twin is a faithful mirror of this service's production runtime +/// overrides, with exactly one transform: every declared config store's +/// service-scoped selector points at +/// `_staging`, the key `config push --staging` writes. A /// declared store gets that selector even when production has no explicit entry /// for it (production relies on the runtime's default = the logical id; staging /// must NOT inherit that default, or it would read production's key). @@ -3435,20 +3578,24 @@ fn delete_staging_config_store_entry(store_id: &str, key: &str, cwd: &Path) -> R /// Pure so the transform is unit-testable without the fastly CLI. fn staging_entries_from_production( production: &[(String, String)], + service_id: &str, config_logical_ids: &[String], ) -> Vec<(String, String)> { - // selector key -> staging value, one per declared config store. + let service_prefix = service_scoped_runtime_env_key(service_id, "EDGEZERO__"); + // Scoped selector key -> staging value, one per declared config store. let selectors: Vec<(String, String)> = config_logical_ids .iter() - .map(|id| (runtime_env_key_for(id), format!("{id}_staging"))) + .map(|id| (runtime_env_key_for(service_id, id), format!("{id}_staging"))) .collect(); - let is_selector = |key: &str| selectors.iter().any(|(sel, _)| sel == key); + let is_selector = |key: &str| selectors.iter().any(|(selector, _)| selector == key); - // Copy every non-selector production override verbatim; selectors are - // supplied from `selectors` below (whether or not production carried one). + // Copy only current-service production overrides. Legacy unscoped entries + // have no safe owner, and another service's namespace does not belong in + // this per-service staging twin. Selectors are supplied below whether or + // not production carried one. let mut out: Vec<(String, String)> = production .iter() - .filter(|(key, _)| !is_selector(key)) + .filter(|(key, _)| key.starts_with(&service_prefix) && !is_selector(key)) .cloned() .collect(); out.extend(selectors); @@ -3466,15 +3613,15 @@ fn staging_selector_store_name(service_id: &str) -> String { format!("{RUNTIME_ENV_STAGING_STORE_PREFIX}_{service_id}") } -fn ensure_staging_selector_store(store_name: &str) -> Result { - match classify_remote_config_store(store_name)? { +fn ensure_staging_selector_store(store_name: &str, cwd: &Path) -> Result { + match classify_remote_config_store_in(store_name, cwd)? { ConfigStoreLookup::Found(id) => Ok(id), ConfigStoreLookup::NotFound => { - create_fastly_store("config", store_name)?; + create_fastly_store_in("config", store_name, cwd)?; // resolve_remote_config_store_id now yields a typed absence; we just // created the store, so a None here is fail-closed (the listing did // not reflect our own create), not a genuine absence. - resolve_remote_config_store_id(store_name) + resolve_remote_config_store_id_in(store_name, cwd) .map_err(|err| { format!( "created fastly config-store `{store_name}` but could not resolve its id: {err}" @@ -3492,9 +3639,8 @@ fn ensure_staging_selector_store(store_name: &str) -> Result { } } -/// Reconcile the staging twin so it MIRRORS production's runtime overrides -/// (`production`) with only the config selectors redirected to -/// `_staging`. +/// Reconcile the staging twin so it mirrors the current service's production +/// overrides, with only its config selectors redirected to `_staging`. /// /// Upserts the full desired set FIRST, then deletes twin entries production no /// longer has (so a removed override does not linger and diverge staging from @@ -3517,38 +3663,221 @@ fn ensure_staging_selector_store(store_name: &str) -> Result { fn mirror_production_to_staging( production: &[(String, String)], staging_id: &str, + service_id: &str, config_logical_ids: &[String], cwd: &Path, ) -> Result<(), String> { - let desired = staging_entries_from_production(production, config_logical_ids); + let desired = staging_entries_from_production(production, service_id, config_logical_ids); for (key, value) in &desired { - create_config_store_entry(staging_id, key, value)?; + create_config_store_entry_in(staging_id, key, value, cwd)?; } let current = read_config_store_entries(staging_id, cwd)?; for (key, _) in ¤t { if !desired.iter().any(|(dk, _)| dk == key) { - delete_staging_config_store_entry(staging_id, key, cwd)?; + delete_config_store_entry_in(staging_id, key, cwd)?; } } Ok(()) } -/// The runtime-override entry naming the config-store KEY for logical store -/// `id` — `EDGEZERO__STORES__CONFIG____KEY`. -/// -/// Must match what the runtime reads: `EnvConfig::from_vars` strips the -/// `EDGEZERO__` prefix, splits on `__`, and lowercases each segment, and -/// `store_key("config", id)` looks up `["stores", "config", id, "key"]`. So the -/// entry name is the id uppercased. A near-miss here is silent — the runtime -/// would just fall back to the id and read production config. -fn runtime_env_key_for(logical_id: &str) -> String { +fn canonical_runtime_store_name_key(kind: &str, logical: &str) -> String { + format!( + "EDGEZERO__STORES__{kind}__{}__NAME", + logical.to_ascii_uppercase() + ) +} + +fn runtime_store_name_key(service_id: &str, kind: &str, logical: &str) -> String { + service_scoped_runtime_env_key(service_id, &canonical_runtime_store_name_key(kind, logical)) +} + +fn has_declared_stores(stores: &ProvisionStores<'_>) -> bool { + !stores.config.is_empty() || !stores.kv.is_empty() || !stores.secrets.is_empty() +} + +fn has_non_default_store_name_mappings(stores: &ProvisionStores<'_>) -> bool { + [stores.config, stores.kv, stores.secrets] + .into_iter() + .flatten() + .any(|store| store.logical != store.platform) +} + +/// Return the service-scoped runtime entries required when logical store ids +/// map to different Fastly resource names. +fn runtime_env_store_name_entries( + stores: &ProvisionStores<'_>, + service_id: &str, +) -> Vec<(String, String)> { + let mut entries = Vec::new(); + for (kind, ids) in [ + ("CONFIG", stores.config), + ("KV", stores.kv), + ("SECRETS", stores.secrets), + ] { + for store in ids { + if store.logical == store.platform { + continue; + } + entries.push(( + runtime_store_name_key(service_id, kind, &store.logical), + store.platform.clone(), + )); + } + } + entries +} + +fn runtime_env_store_name_keys(stores: &ProvisionStores<'_>, service_id: &str) -> Vec { + let mut keys = Vec::new(); + for (kind, ids) in [ + ("CONFIG", stores.config), + ("KV", stores.kv), + ("SECRETS", stores.secrets), + ] { + keys.extend( + ids.iter() + .map(|store| runtime_store_name_key(service_id, kind, &store.logical)), + ); + } + keys +} + +/// Compute the minimal changes needed for store-name mappings owned by this +/// Fastly service and the logical ids the app currently declares. Legacy +/// unscoped entries, other service namespaces, undeclared ids, and unrelated +/// runtime settings are preserved. +fn runtime_store_name_reconciliation( + stores: &ProvisionStores<'_>, + service_id: &str, + current: &[(String, String)], +) -> RuntimeStoreNameReconciliation { + let desired = runtime_env_store_name_entries(stores, service_id); + let declared = runtime_env_store_name_keys(stores, service_id); + + let mut upserts = desired + .iter() + .filter(|(key, value)| { + current + .iter() + .find(|(current_key, _)| current_key == key) + .is_none_or(|(_, current_value)| current_value != value) + }) + .cloned() + .collect::>(); + let mut deletes = current + .iter() + .filter(|(key, _)| { + declared.iter().any(|declared_key| declared_key == key) + && !desired.iter().any(|(desired_key, _)| desired_key == key) + }) + .map(|(key, _)| key.clone()) + .collect::>(); + upserts.sort_by(|left, right| left.0.cmp(&right.0)); + deletes.sort(); + + RuntimeStoreNameReconciliation { deletes, upserts } +} + +fn persist_runtime_env_store_name_entries( + stores: &ProvisionStores<'_>, + service_id_hint: Option<&str>, + dry_run: bool, + cwd: &Path, +) -> Result, String> { + if !has_declared_stores(stores) { + return Ok(Vec::new()); + } + let Some(service_id) = service_id_hint else { + if has_non_default_store_name_mappings(stores) { + return Err(format!( + "cannot persist non-default Fastly store-name mappings without top-level `service_id` or {FASTLY_SERVICE_ID_ENV}" + )); + } + return Ok(vec![ + "no Fastly service id and no non-default store-name mappings; skipping runtime-env reconciliation" + .to_owned(), + ]); + }; + let entries = runtime_env_store_name_entries(stores, service_id); + let declared = runtime_env_store_name_keys(stores, service_id); + if dry_run { + let mut out = entries + .iter() + .map(|(key, value)| { + format!( + "would upsert `{key}={value}` into fastly config-store `{RUNTIME_ENV_STORE_NAME}`" + ) + }) + .collect::>(); + out.extend( + declared + .iter() + .filter(|key| !entries.iter().any(|(entry_key, _)| entry_key == *key)) + .map(|key| { + format!( + "would remove `{key}` from fastly config-store `{RUNTIME_ENV_STORE_NAME}` if a stale mapping is present" + ) + }), + ); + return Ok(out); + } + + let Some(runtime_env_store_id) = + resolve_remote_config_store_id_in(RUNTIME_ENV_STORE_NAME, cwd)? + else { + if entries.is_empty() { + return Ok(vec![format!( + "fastly config-store `{RUNTIME_ENV_STORE_NAME}` not found; no non-default store-name mappings to write for service `{service_id}`, skipping reconciliation" + )]); + } + return Err(format!( + "cannot write non-default store-name mappings for service `{service_id}`: fastly config-store `{RUNTIME_ENV_STORE_NAME}` does not exist remotely even though its setup block is declared. Create it with `fastly config-store create --name={RUNTIME_ENV_STORE_NAME}` (and link it to an existing service when needed), then re-run provision" + )); + }; + let current = read_config_store_entries(&runtime_env_store_id, cwd)?; + let reconciliation = runtime_store_name_reconciliation(stores, service_id, ¤t); + if reconciliation.upserts.is_empty() && reconciliation.deletes.is_empty() { + return Ok(Vec::new()); + } + + push_runtime_store_name_entries_with_committer(&reconciliation.upserts, |key, value| { + create_config_store_entry_in(&runtime_env_store_id, key, value, cwd) + })?; + for key in &reconciliation.deletes { + delete_config_store_entry_in(&runtime_env_store_id, key, cwd).map_err(|error| { + format!( + "fastly provision failed while deleting stale runtime store-name mapping `{key}`.\n \ + The delete's outcome is UNKNOWN: Fastly may have committed it before the error, \ + and earlier mapping upserts may already have committed.\n \ + Recovery: re-run the SAME `edgezero provision --adapter fastly` command with the same \ + `EDGEZERO__STORES__*__NAME` environment. Reconciliation rereads the current store and \ + is idempotent, so it will safely finish any remaining work.\n \ + Failed: `{key}` (outcome unknown) -- {error}" + ) + })?; + } + Ok(vec![format!( + "reconciled store-name mappings for service `{service_id}` in fastly config-store `{RUNTIME_ENV_STORE_NAME}`: upserted {}, removed {} stale mapping(s)", + reconciliation.upserts.len(), + reconciliation.deletes.len() + )]) +} + +fn canonical_runtime_env_key_for(logical_id: &str) -> String { format!( "EDGEZERO__STORES__CONFIG__{}__KEY", logical_id.to_ascii_uppercase() ) } +/// The service-scoped runtime-override entry naming the config-store key for a +/// logical store. The runtime converts this stored key back to canonical +/// `EDGEZERO__STORES__CONFIG____KEY` before building `EnvConfig`. +fn runtime_env_key_for(service_id: &str, logical_id: &str) -> String { + service_scoped_runtime_env_key(service_id, &canonical_runtime_env_key_for(logical_id)) +} + /// Find the id of the resource link published under `link_name` in /// `fastly resource-link list --json` output. /// @@ -3701,7 +4030,23 @@ fn shape_summary(value: &serde_json::Value) -> &'static str { /// spawn/list failure, schema drift) stays `Err` -- callers that read for a diff /// must not treat an operational failure as "store absent" and overwrite. fn resolve_remote_config_store_id(name: &str) -> Result, String> { - match classify_remote_config_store(name)? { + resolve_remote_config_store_id_with_cwd(name, None) +} + +fn resolve_remote_config_store_id_in(name: &str, cwd: &Path) -> Result, String> { + resolve_remote_config_store_id_with_cwd(name, Some(cwd)) +} + +fn resolve_remote_config_store_id_with_cwd( + name: &str, + cwd: Option<&Path>, +) -> Result, String> { + let lookup = if let Some(command_cwd) = cwd { + classify_remote_config_store_in(name, command_cwd)? + } else { + classify_remote_config_store(name)? + }; + match lookup { ConfigStoreLookup::Found(id) => Ok(Some(id)), ConfigStoreLookup::NotFound => Ok(None), ConfigStoreLookup::SchemaDrift(detail) => Err(format!( @@ -3719,16 +4064,29 @@ fn resolve_remote_config_store_id(name: &str) -> Result, String> /// `Err` is only for a failure to OBTAIN an answer; a successful listing that /// simply doesn't contain `name` is `Ok(ConfigStoreLookup::NotFound)`. fn classify_remote_config_store(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}") - } - })?; + classify_remote_config_store_with_cwd(name, None) +} + +fn classify_remote_config_store_in(name: &str, cwd: &Path) -> Result { + classify_remote_config_store_with_cwd(name, Some(cwd)) +} + +fn classify_remote_config_store_with_cwd( + name: &str, + cwd: Option<&Path>, +) -> Result { + let mut command = Command::new("fastly"); + command.args(["config-store", "list", "--json"]); + if let Some(command_cwd) = cwd { + command.current_dir(command_cwd); + } + let output = command.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: {}", @@ -4544,20 +4902,21 @@ fn curl_quote(value: &str) -> String { } /// Validate an operator-supplied Fastly service id before it is -/// interpolated into an API URL. Fastly service ids are opaque -/// alphanumeric handles; constrain to `^[A-Za-z0-9_-]+$` so a value -/// carrying a quote / newline / space (which could inject curl options -/// via the `--config` file) is rejected with a clear error. +/// interpolated into an API URL or runtime-env key. Fastly service ids are +/// opaque alphanumeric handles, so constrain them to `^[A-Za-z0-9]+$`. +/// Values carrying a quote, newline, or space could inject curl options via +/// the `--config` file. fn validate_service_id(id: &str) -> Result<(), String> { - if !id.is_empty() - && id - .chars() - .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-') - { + if id.contains("__") { + return Err(format!( + "invalid service id {id:?}: `__` is the runtime-env namespace delimiter" + )); + } + if !id.is_empty() && id.chars().all(|ch| ch.is_ascii_alphanumeric()) { Ok(()) } else { Err(format!( - "invalid service id {id:?}: expected only ASCII letters, digits, `_`, or `-`" + "invalid service id {id:?}: expected only ASCII letters or digits" )) } } @@ -4847,7 +5206,7 @@ fn relink_runtime_env_for_staging( // isolated. There is simply nothing to mirror — the twin gets only the // derived `_staging` selectors, and the staged draft is relinked to // it so it reads staged config while production keeps its default key. - let production = match classify_remote_config_store(RUNTIME_ENV_STORE_NAME)? { + let production = match classify_remote_config_store_in(RUNTIME_ENV_STORE_NAME, manifest_dir)? { ConfigStoreLookup::Found(id) => read_config_store_entries(&id, manifest_dir)?, ConfigStoreLookup::NotFound => Vec::new(), ConfigStoreLookup::SchemaDrift(detail) => { @@ -4862,10 +5221,11 @@ fn relink_runtime_env_for_staging( // THIS draft at the twin. Create the twin on demand so a staged deploy never // depends on a prior provision having created it. let staging_store_name = staging_selector_store_name(service_id); - let staging_store_id = ensure_staging_selector_store(&staging_store_name)?; + let staging_store_id = ensure_staging_selector_store(&staging_store_name, manifest_dir)?; mirror_production_to_staging( &production, &staging_store_id, + service_id, config_logical_ids, manifest_dir, )?; @@ -5209,9 +5569,11 @@ fn rollback(args: &[String]) -> Result<(), String> { mod tests { use super::*; use edgezero_adapter::cli_support::read_package_name; + use edgezero_core::app::{StoreMetadata, StoresMetadata}; + use edgezero_core::env_config::EnvConfig; #[cfg(unix)] use edgezero_core::test_env::{EnvOverride, PathPrepend}; - use std::collections::HashSet; + use std::collections::{BTreeMap, HashSet}; #[cfg(unix)] use std::sync::Mutex; @@ -5291,8 +5653,8 @@ mod tests { #[test] fn resolve_service_id_prefers_flag() { - let args = vec!["--service-id".to_owned(), "SVC_FROM_ARG".to_owned()]; - assert_eq!(resolve_service_id(&args).unwrap(), "SVC_FROM_ARG"); + let args = vec!["--service-id".to_owned(), "SVCFROMARG".to_owned()]; + assert_eq!(resolve_service_id(&args).unwrap(), "SVCFROMARG"); } // ── `compute update` passthrough filtering (`--comment`) ───────── @@ -5528,9 +5890,24 @@ mod tests { } #[test] - fn validate_service_id_accepts_opaque_handles() { + fn validate_service_id_accepts_fastly_handle() { validate_service_id("SU1Z0isxPaozGVKXdv0eY").expect("alphanumeric handle"); - validate_service_id("abc_DEF-123").expect("underscore + dash handle"); + } + + #[test] + fn validate_service_id_rejects_non_alphanumeric_characters() { + validate_service_id("SVC1_").expect_err("trailing underscore"); + validate_service_id("SVC-1").expect_err("hyphen"); + } + + #[test] + fn validate_service_id_rejects_runtime_env_namespace_delimiter() { + let err = validate_service_id("SVC__OTHER") + .expect_err("the runtime-env namespace delimiter must be unambiguous"); + assert!( + err.contains("namespace delimiter"), + "error explains the reserved delimiter: {err}" + ); } #[test] @@ -6597,7 +6974,7 @@ build = \"cargo build --release\" 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"); + fs::write(&path, "name = \"demo\"\nservice_id = \"SVC1\"\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]); @@ -6609,10 +6986,10 @@ build = \"cargo build --release\" let out = FastlyCliAdapter .provision(dir.path(), Some("fastly.toml"), None, &stores, true) .expect("dry-run succeeds"); - // 1 KV + 1 config + 1 secret + runtime-env = 4 status lines. The staging - // twin is created and populated by a staged deploy, NOT by provision, so - // it does not appear here. - assert_eq!(out.len(), 4, "dry-run rows: {out:?}"); + // 1 KV + 1 config + 1 secret + runtime-env + 3 possible stale-mapping + // removals = 7 status lines. The staging twin is created and populated by + // a staged deploy, NOT by provision, so it does not appear here. + assert_eq!(out.len(), 7, "dry-run rows: {out:?}"); 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`")); @@ -6620,6 +6997,11 @@ build = \"cargo build --release\" out[3].contains("would run `fastly config-store create --name=edgezero_runtime_env`"), "runtime-env store row: {out:?}", ); + assert!( + out.iter() + .any(|row| row.contains("EDGEZERO__SERVICES__SVC1__STORES__KV__SESSIONS__NAME")), + "dry-run reports possible stale mapping cleanup: {out:?}", + ); assert!( !out.iter() .any(|row| row.contains("edgezero_runtime_env_staging")), @@ -6627,62 +7009,411 @@ build = \"cargo build --release\" ); // Manifest untouched. let after = fs::read_to_string(&path).expect("read"); - assert_eq!(after, "name = \"demo\"\n", "dry-run mutated fastly.toml"); + assert_eq!( + after, "name = \"demo\"\nservice_id = \"SVC1\"\n", + "dry-run mutated fastly.toml" + ); } #[test] - fn provision_errors_when_adapter_manifest_path_missing() { + fn provision_dry_run_reports_non_default_store_name_mapping() { let dir = tempdir().expect("tempdir"); - let kv_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_KV_ID]); + let path = dir.path().join("fastly.toml"); + fs::write(&path, "name = \"demo\"\nservice_id = \"SVC1\"\n").expect("write"); + let secret_ids = vec![ResolvedStoreId::new("default", "production_secrets")]; let stores = ProvisionStores { config: &[], - kv: &kv_ids, + kv: &[], + secrets: &secret_ids, + }; + + let out = FastlyCliAdapter + .provision(dir.path(), Some("fastly.toml"), None, &stores, true) + .expect("dry-run succeeds"); + + assert!(out.iter().any(|line| { + line.contains( + "EDGEZERO__SERVICES__SVC1__STORES__SECRETS__DEFAULT__NAME=production_secrets", + ) + })); + } + + #[cfg(unix)] + #[test] + fn provision_non_default_mapping_requires_service_id_before_fastly_mutation() { + let _lock = path_mutation_guard().lock().expect("guard"); + let _service_id = EnvOverride::remove(FASTLY_SERVICE_ID_ENV); + let dir = tempdir().expect("tempdir"); + fs::write(dir.path().join("fastly.toml"), "name = \"demo\"\n").expect("write"); + let kv = vec![ResolvedStoreId::new("sessions", "production_sessions")]; + let stores = ProvisionStores { + config: &[], + kv: &kv, secrets: &[], }; + let err = FastlyCliAdapter - .provision(dir.path(), None, None, &stores, true) - .expect_err("missing adapter manifest path must error"); + .provision(dir.path(), Some("fastly.toml"), None, &stores, true) + .expect_err("a non-default mapping needs an unambiguous service namespace"); + assert!( - err.contains("fastly.toml"), - "error names what's missing: {err}" + err.contains("service_id"), + "error names the missing identity: {err}" + ); + assert!( + err.contains(FASTLY_SERVICE_ID_ENV), + "error gives the environment fallback: {err}" ); } + #[cfg(unix)] #[test] - fn provision_with_no_declared_stores_says_so() { + fn provision_default_mappings_skip_an_absent_runtime_env_store() { + let _lock = path_mutation_guard().lock().expect("guard"); 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", + "[setup.kv_stores.sessions]\n\ + [setup.config_stores.edgezero_runtime_env]\n", ) .expect("write"); + let kv = vec![ResolvedStoreId::from_logical("sessions")]; let stores = ProvisionStores { config: &[], - kv: &[], + kv: &kv, secrets: &[], }; + // This fake lists only `app_config`, so `edgezero_runtime_env` is + // genuinely absent remotely even though its setup block is committed. + let fake = fake_fastly_returning("", "", 0); + let _path = PathPrepend::new(fake.path()); + 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"]); + .expect("default mappings need no remote runtime-env store"); + + assert!( + out.iter() + .any(|line| line.contains("no non-default store-name mappings")), + "provision explains why reconciliation was skipped: {out:?}" + ); } + #[cfg(unix)] + #[test] + fn provision_non_default_mapping_requires_a_runtime_env_store() { + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write( + &path, + "service_id = \"SVC1\"\n\ + [setup.kv_stores.production_sessions]\n\ + [setup.config_stores.edgezero_runtime_env]\n", + ) + .expect("write"); + let kv = vec![ResolvedStoreId::new("sessions", "production_sessions")]; + let stores = ProvisionStores { + config: &[], + kv: &kv, + secrets: &[], + }; + let fake = fake_fastly_returning("", "", 0); + let _path = PathPrepend::new(fake.path()); + + let err = FastlyCliAdapter + .provision(dir.path(), Some("fastly.toml"), None, &stores, false) + .expect_err("a required mapping cannot be written without the runtime-env store"); + + assert!( + err.contains("edgezero_runtime_env"), + "missing store is named: {err}" + ); + assert!( + !err.contains("did you run `edgezero provision"), + "provision must not recommend the command already running: {err}" + ); + assert!( + err.contains("fastly config-store create --name=edgezero_runtime_env"), + "missing-store recovery gives an actionable create command: {err}" + ); + } + + #[cfg(unix)] + #[test] + fn provision_creates_declared_store_in_fastly_manifest_directory() { + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + let adapter_dir = dir.path().join("adapters/fastly"); + fs::create_dir_all(&adapter_dir).expect("adapter dir"); + let path = adapter_dir.join("fastly.toml"); + fs::write(&path, "[setup.config_stores.edgezero_runtime_env]\n").expect("write"); + let kv = vec![ResolvedStoreId::from_logical("sessions")]; + let stores = ProvisionStores { + config: &[], + kv: &kv, + secrets: &[], + }; + let oplog = dir.path().join("oplog.txt"); + let fake = fake_fastly_runtime_mapping(&[], &oplog); + let _path = PathPrepend::new(fake.path()); + + FastlyCliAdapter + .provision( + dir.path(), + Some("adapters/fastly/fastly.toml"), + None, + &stores, + false, + ) + .expect("provision succeeds"); + + let log = fs::read_to_string(&oplog).expect("oplog"); + let manifest_dir = fs::canonicalize(&adapter_dir).expect("canonical manifest dir"); + assert!( + log.contains(&format!( + "kv-store-create name=--name=sessions cwd={}", + manifest_dir.display() + )), + "declared store creation uses the Fastly manifest directory: {log}" + ); + assert!( + fs::read_to_string(path) + .expect("manifest") + .contains("[setup.kv_stores.sessions]"), + "declared store setup block is written" + ); + } + + #[cfg(unix)] + #[test] + fn provision_reconciles_runtime_store_name_mappings() { + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write( + &path, + "service_id = \"SVCA\"\n\ + [setup.kv_stores.production_sessions]\n\ + [setup.secret_stores.default]\n", + ) + .expect("write"); + let kv = vec![ResolvedStoreId::new("sessions", "production_sessions")]; + let secrets = vec![ResolvedStoreId::from_logical("default")]; + let stores = ProvisionStores { + config: &[], + kv: &kv, + secrets: &secrets, + }; + let current = vec![ + ( + "EDGEZERO__SERVICES__SVCA__STORES__KV__SESSIONS__NAME".to_owned(), + "old_sessions".to_owned(), + ), + ( + "EDGEZERO__SERVICES__SVCA__STORES__SECRETS__DEFAULT__NAME".to_owned(), + "old_secrets".to_owned(), + ), + ( + "EDGEZERO__SERVICES__SVCB__STORES__KV__SESSIONS__NAME".to_owned(), + "service_b_sessions".to_owned(), + ), + ( + "EDGEZERO__STORES__KV__SESSIONS__NAME".to_owned(), + "legacy_sessions".to_owned(), + ), + ("EDGEZERO__LOGGING__LEVEL".to_owned(), "debug".to_owned()), + ]; + let oplog = dir.path().join("oplog.txt"); + let fake = fake_fastly_runtime_mapping(¤t, &oplog); + let _path = PathPrepend::new(fake.path()); + + let out = FastlyCliAdapter + .provision(dir.path(), Some("fastly.toml"), None, &stores, false) + .expect("mapping reconciliation succeeds"); + let log = fs::read_to_string(&oplog).expect("oplog"); + let manifest_dir = fs::canonicalize(dir.path()).expect("canonical manifest dir"); + + assert!( + log.contains(&format!("store-create cwd={}", manifest_dir.display())), + "runtime-env store creation runs in the manifest directory: {log}" + ); + assert!( + log.contains(&format!("store-list cwd={}", manifest_dir.display())), + "runtime-env store lookup runs in the manifest directory: {log}" + ); + assert!( + log.contains(&format!( + "update EDGEZERO__SERVICES__SVCA__STORES__KV__SESSIONS__NAME=production_sessions cwd={}", + manifest_dir.display() + )), + "changed non-default mapping is upserted in the manifest directory: {log}" + ); + assert!( + log.contains(&format!( + "delete EDGEZERO__SERVICES__SVCA__STORES__SECRETS__DEFAULT__NAME cwd={}", + manifest_dir.display() + )), + "stale mapping is removed in the manifest directory: {log}" + ); + assert!( + !log.contains("delete EDGEZERO__SERVICES__SVCB__STORES__KV__SESSIONS__NAME") + && !log.contains("delete EDGEZERO__STORES__KV__SESSIONS__NAME") + && !log.contains("EDGEZERO__LOGGING__LEVEL="), + "other services, legacy mappings, and unrelated runtime entries are preserved: {log}" + ); + assert!( + out.iter() + .any(|line| line.contains("upserted 1, removed 1")), + "status reports both mutations: {out:?}" + ); + } + + #[cfg(unix)] + #[test] + fn provision_mapping_failure_recommends_provision_recovery() { + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write( + &path, + "service_id = \"SVC1\"\n\ + [setup.kv_stores.production_sessions]\n\ + [setup.config_stores.edgezero_runtime_env]\n", + ) + .expect("write"); + let kv = vec![ResolvedStoreId::new("sessions", "production_sessions")]; + let stores = ProvisionStores { + config: &[], + kv: &kv, + secrets: &[], + }; + let oplog = dir.path().join("oplog.txt"); + let fake = fake_fastly_runtime_mapping_with_update_exit(&[], &oplog, 1); + let _path = PathPrepend::new(fake.path()); + + let err = FastlyCliAdapter + .provision(dir.path(), Some("fastly.toml"), None, &stores, false) + .expect_err("mapping update fails"); + + assert!( + err.contains("UNKNOWN"), + "failed write outcome is explicit: {err}" + ); + assert!( + err.contains("edgezero provision --adapter fastly"), + "recovery names the command to retry: {err}" + ); + assert!( + !err.contains("config push"), + "wrong command is not recommended: {err}" + ); + assert!( + !err.contains("chunk") && !err.contains("root pointer"), + "mapping recovery contains no blob-specific guidance: {err}" + ); + } + + #[cfg(unix)] + #[test] + fn provision_delete_failure_recommends_provision_recovery() { + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write( + &path, + "service_id = \"SVC1\"\n\ + [setup.kv_stores.production_sessions]\n\ + [setup.secret_stores.default]\n\ + [setup.config_stores.edgezero_runtime_env]\n", + ) + .expect("write"); + let kv = vec![ResolvedStoreId::new("sessions", "production_sessions")]; + let secrets = vec![ResolvedStoreId::from_logical("default")]; + let stores = ProvisionStores { + config: &[], + kv: &kv, + secrets: &secrets, + }; + let current = vec![( + "EDGEZERO__SERVICES__SVC1__STORES__SECRETS__DEFAULT__NAME".to_owned(), + "old_secrets".to_owned(), + )]; + let oplog = dir.path().join("oplog.txt"); + let fake = fake_fastly_runtime_mapping_with_exits(¤t, &oplog, 0, 1); + let _path = PathPrepend::new(fake.path()); + + let err = FastlyCliAdapter + .provision(dir.path(), Some("fastly.toml"), None, &stores, false) + .expect_err("stale mapping delete fails"); + + assert!(err.contains("UNKNOWN"), "delete outcome is explicit: {err}"); + assert!( + err.contains("edgezero provision --adapter fastly") && err.contains("idempotent"), + "recovery names the safe retry: {err}" + ); + let log = fs::read_to_string(&oplog).expect("oplog"); + assert!( + log.contains("update EDGEZERO__SERVICES__SVC1__STORES__KV__SESSIONS__NAME") + && log.contains("delete EDGEZERO__SERVICES__SVC1__STORES__SECRETS__DEFAULT__NAME"), + "the failure follows a committed upsert: {log}" + ); + } + + #[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"]); + } + + #[cfg(unix)] #[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. + fn provision_skips_store_creation_when_setup_block_already_present() { + // Re-running provision skips resource creation but still reads the + // runtime-env store to reconcile a mapping that may have been removed. + let _lock = path_mutation_guard().lock().expect("guard"); 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\ + "service_id = \"SVC1\"\n\ + [setup.kv_stores.sessions]\n[local_server.kv_stores.sessions]\n\ [setup.config_stores.edgezero_runtime_env]\n", ) .expect("write"); @@ -6692,11 +7423,44 @@ build = \"cargo build --release\" kv: &kv_ids, secrets: &[], }; + let oplog = dir.path().join("oplog.txt"); + let fake = fake_fastly_runtime_mapping(&[], &oplog); + let _path = PathPrepend::new(fake.path()); + let out = FastlyCliAdapter .provision(dir.path(), Some("fastly.toml"), None, &stores, false) - .expect("skip path succeeds without invoking fastly"); + .expect("skip path succeeds"); assert_eq!(out.len(), 1); assert!(out[0].contains("already declared"), "got: {out:?}"); + let manifest_dir = fs::canonicalize(dir.path()).expect("canonical manifest dir"); + assert_eq!( + fs::read_to_string(oplog).expect("oplog"), + format!("store-list cwd={0}\nlist cwd={0}\n", manifest_dir.display()), + "runtime mapping is inspected in the manifest directory without mutation" + ); + } + + #[test] + fn provision_service_namespace_uses_env_and_rejects_manifest_mismatch() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write(&path, "name = \"demo\"\n").expect("write"); + + assert_eq!( + resolve_provision_runtime_env_service_id(&path, Some("SVCENV".into())) + .expect("env fallback"), + Some("SVCENV".to_owned()) + ); + + fs::write(&path, "name = \"demo\"\nservice_id = \"SVCMANIFEST\"\n") + .expect("write manifest service id"); + let err = resolve_provision_runtime_env_service_id(&path, Some("SVCENV".into())) + .expect_err("two target service ids must not select different namespaces"); + assert!(err.contains("mismatch"), "mismatch is explicit: {err}"); + assert!( + err.contains("SVCMANIFEST") && err.contains("SVCENV"), + "both conflicting ids are named: {err}" + ); } /// When `fastly.toml` declares `service_id`, the next @@ -6722,7 +7486,7 @@ build = \"cargo build --release\" .expect("read service_id") .expect("note present when service_id set"); assert!( - note.contains("service_id = \"abc123svc\""), + note.contains("service id resolves to `abc123svc`"), "note quotes the service id: {note}" ); assert!( @@ -7208,6 +7972,84 @@ build = \"cargo build --release\" // ---------- read_config_entry (fake fastly, remote shell-out) ---------- + /// Build a fake `fastly` for live runtime store-name reconciliation. + /// The current runtime-env entries are listed verbatim and every update or + /// delete is recorded in `oplog`. + #[cfg(unix)] + fn fake_fastly_runtime_mapping( + current: &[(String, String)], + oplog: &Path, + ) -> tempfile::TempDir { + fake_fastly_runtime_mapping_with_update_exit(current, oplog, 0) + } + + #[cfg(unix)] + fn fake_fastly_runtime_mapping_with_update_exit( + current: &[(String, String)], + oplog: &Path, + update_exit: i32, + ) -> tempfile::TempDir { + fake_fastly_runtime_mapping_with_exits(current, oplog, update_exit, 0) + } + + #[cfg(unix)] + fn fake_fastly_runtime_mapping_with_exits( + current: &[(String, String)], + oplog: &Path, + update_exit: i32, + delete_exit: i32, + ) -> tempfile::TempDir { + use std::os::unix::fs::PermissionsExt as _; + + let dir = tempdir().expect("tempdir"); + let store_list = dir.path().join("stores.json"); + let entry_list = dir.path().join("entries.json"); + fs::write( + &store_list, + format!(r#"[{{"name":"{RUNTIME_ENV_STORE_NAME}","id":"runtime-env-123"}}]"#), + ) + .expect("store list"); + let entries = current + .iter() + .map(|(key, value)| { + serde_json::json!({ + "item_key": key, + "item_value": value, + }) + }) + .collect::>(); + fs::write( + &entry_list, + serde_json::to_string(&entries).expect("entry list json"), + ) + .expect("entry list"); + + let script = format!( + r#"#!/bin/sh +if [ "$1" = "config-store" ] && [ "$2" = "create" ]; then printf 'store-create cwd=%s\n' "$PWD" >> '{oplog}'; exit 0; fi +if [ "$1" = "kv-store" ] && [ "$2" = "create" ]; then printf 'kv-store-create name=%s cwd=%s\n' "$3" "$PWD" >> '{oplog}'; exit 0; fi +if [ "$1" = "config-store" ]; then printf 'store-list cwd=%s\n' "$PWD" >> '{oplog}'; cat '{stores}'; exit 0; fi +sub="$2" +key="" +for arg in "$@"; do case "$arg" in --key=*) key="${{arg#--key=}}";; esac; done +if [ "$sub" = "list" ]; then printf 'list cwd=%s\n' "$PWD" >> '{oplog}'; cat '{entries}'; exit 0; fi +if [ "$sub" = "update" ]; then value=$(cat); printf 'update %s=%s cwd=%s\n' "$key" "$value" "$PWD" >> '{oplog}'; exit {update_exit}; fi +if [ "$sub" = "delete" ]; then printf 'delete %s cwd=%s\n' "$key" "$PWD" >> '{oplog}'; exit {delete_exit}; fi +echo 'unexpected fastly invocation' >&2 +exit 1 +"#, + stores = store_list.display(), + entries = entry_list.display(), + oplog = oplog.display(), + ); + let script_path = dir.path().join("fastly"); + fs::write(&script_path, script).expect("script"); + let mut perms = fs::metadata(&script_path).expect("meta").permissions(); + perms.set_mode(0o755); + fs::set_permissions(&script_path, perms).expect("chmod"); + dir + } + /// 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`. @@ -8877,9 +9719,11 @@ echo 'unexpected' >&2; exit 1 printf '%s\\n' '{update_stdout}'\n\ elif [ \"$1\" = \"config-store\" ] && [ \"$2\" = \"list\" ]; then\n \ printf '%s\\n' '[{{\"id\":\"ENVSEL1\",\"name\":\"edgezero_runtime_env\"}},{{\"id\":\"STAGEID1\",\"name\":\"edgezero_runtime_env_staging_SVC1\"}}]'\n\ + elif [ \"$1\" = \"config-store-entry\" ] && [ \"$2\" = \"update\" ]; then\n \ + cat >/dev/null\n\ elif [ \"$1\" = \"config-store-entry\" ] && [ \"$2\" = \"list\" ]; then\n \ case \"$*\" in\n \ - *--store-id=ENVSEL1*) printf '%s\\n' '[{{\"item_key\":\"EDGEZERO__ADAPTER__FASTLY__LOG_LEVEL\",\"item_value\":\"debug\"}}]' ;;\n \ + *--store-id=ENVSEL1*) printf '%s\\n' '[{{\"item_key\":\"EDGEZERO__SERVICES__SVC1__LOGGING__LEVEL\",\"item_value\":\"debug\"}}]' ;;\n \ *) printf '%s\\n' '[]' ;;\n \ esac\n\ elif [ \"$1\" = \"resource-link\" ] && [ \"$2\" = \"list\" ]; then\n \ @@ -8901,6 +9745,15 @@ echo 'unexpected' >&2; exit 1 fn run_deploy_staged_with_fake( update_stdout: &str, extra: &[&str], + ) -> (Result<(), String>, Vec) { + run_deploy_staged_with_fake_and_env(update_stdout, extra, None) + } + + #[cfg(unix)] + fn run_deploy_staged_with_fake_and_env( + update_stdout: &str, + extra: &[&str], + store_name_override: Option<(&str, &str)>, ) -> (Result<(), String>, Vec) { let _lock = path_mutation_guard().lock().expect("guard"); let (fake, record) = fake_fastly_recorder(update_stdout); @@ -8909,10 +9762,11 @@ echo 'unexpected' >&2; exit 1 let manifest = app.path().join("fastly.toml"); fs::write(&manifest, "name = \"app\"\n").expect("write fastly.toml"); - // RAII: set the token for the call, restore it on drop. Uses the shared - // guard (edition-2024 wraps the env mutation's `unsafe` and holds the - // lock we already took above). + // RAII: set the variables for the call, then restore them on drop. The + // shared guard serializes every process-environment mutation in tests. let _token = EnvOverride::set(FASTLY_API_TOKEN_ENV, "test-token"); + let _store_name_override = + store_name_override.map(|(key, value)| EnvOverride::set(key, value)); let mut args = vec![ "--service-id".to_owned(), "SVC1".to_owned(), @@ -10209,81 +11063,158 @@ echo 'unexpected' >&2; exit 1 } #[test] - fn runtime_env_key_matches_what_the_runtime_reads() { - use edgezero_core::env_config::EnvConfig; + fn runtime_env_store_name_entries_include_only_non_default_scoped_mappings() { + let config = vec![ResolvedStoreId::from_logical("app_config")]; + let kv = vec![ResolvedStoreId::new("sessions", "production_sessions")]; + let secrets = vec![ResolvedStoreId::new("default", "production_secrets")]; + let stores = ProvisionStores { + config: &config, + kv: &kv, + secrets: &secrets, + }; - // EnvConfig::from_vars strips `EDGEZERO__`, splits on `__`, lowercases; - // store_key("config", id) looks up ["stores","config",id,"key"]. So the - // entry name is the id uppercased. A near-miss is SILENT: the runtime - // would fall back to the id and read production config. + let entries = runtime_env_store_name_entries(&stores, "SVCA"); assert_eq!( - runtime_env_key_for("app_config"), - "EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY" + entries, + vec![ + ( + "EDGEZERO__SERVICES__SVCA__STORES__KV__SESSIONS__NAME".to_owned(), + "production_sessions".to_owned(), + ), + ( + "EDGEZERO__SERVICES__SVCA__STORES__SECRETS__DEFAULT__NAME".to_owned(), + "production_secrets".to_owned(), + ), + ] + ); + } + + #[test] + fn runtime_dictionary_uses_only_the_current_service_namespace() { + let stores = StoresMetadata { + config: Some(StoreMetadata { + default: "app_config", + ids: &["app_config"], + }), + kv: Some(StoreMetadata { + default: "sessions", + ids: &["sessions"], + }), + secrets: None, + }; + let scoped_sessions = + service_scoped_runtime_env_key("SVCA", "EDGEZERO__STORES__KV__SESSIONS__NAME"); + let values = BTreeMap::from([ + (scoped_sessions.clone(), "service_a_sessions".to_owned()), + ( + service_scoped_runtime_env_key("SVCB", "EDGEZERO__STORES__KV__SESSIONS__NAME"), + "service_b_sessions".to_owned(), + ), + ( + "EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME".to_owned(), + "legacy_config".to_owned(), + ), + ( + "EDGEZERO__STORES__KV__SESSIONS__NAME".to_owned(), + "legacy_sessions".to_owned(), + ), + ]); + + assert_eq!( + scoped_sessions, + "EDGEZERO__SERVICES__SVCA__STORES__KV__SESSIONS__NAME" ); + let vars = + crate::runtime_env_vars_for_service(stores, "SVCA", |key| values.get(key).cloned()); + let env = EnvConfig::from_vars(vars); + + assert_eq!(env.store_name("kv", "sessions"), "service_a_sessions"); + assert_eq!(env.store_name("config", "app_config"), "app_config"); + assert_ne!(env.store_name("kv", "sessions"), "service_b_sessions"); - // Prove it against the real reader rather than restating the format. - let cfg = EnvConfig::from_vars([( - runtime_env_key_for("app_config"), - "app_config_staging".to_owned(), - )]); + let default_service_vars = + crate::runtime_env_vars_for_service(stores, "SVCDEFAULT", |key| { + values.get(key).cloned() + }); + let default_service_env = EnvConfig::from_vars(default_service_vars); + assert_eq!(default_service_env.store_name("kv", "sessions"), "sessions"); assert_eq!( - cfg.store_key("config", "app_config"), - "app_config_staging", - "the entry provision writes must be the one the runtime reads" + default_service_env.store_name("config", "app_config"), + "app_config" ); } #[test] - fn staging_entries_from_production_mirrors_and_overrides() { - // Production carries a non-config override, an explicit config selector, - // and a __NAME redirect. The twin must copy the non-config entries - // verbatim and redirect EVERY declared config store to `_staging` - // — including one production has no explicit entry for (it relies on the - // runtime default; the twin must NOT inherit that default). + fn runtime_env_key_is_scoped_for_the_runtime_reader() { + assert_eq!( + canonical_runtime_env_key_for("app_config"), + "EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY" + ); + assert_eq!( + runtime_env_key_for("SVCA", "app_config"), + "EDGEZERO__SERVICES__SVCA__STORES__CONFIG__APP_CONFIG__KEY" + ); + } + + #[test] + fn staging_entries_from_production_mirrors_only_current_service_entries() { + // Production carries an unscoped legacy override, this service's + // explicit selector and name mapping, and another service's mapping. + // The per-service twin keeps only current-service values, replacing + // every declared selector with its scoped staging value. let production = vec![ ( "EDGEZERO__ADAPTER__FASTLY__LOG_LEVEL".to_owned(), "debug".to_owned(), ), ( - "EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY".to_owned(), + "EDGEZERO__SERVICES__SVC1__STORES__CONFIG__APP_CONFIG__KEY".to_owned(), "custom_prod_key".to_owned(), ), ( - "EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME".to_owned(), + "EDGEZERO__SERVICES__SVC1__STORES__CONFIG__APP_CONFIG__NAME".to_owned(), "app_config".to_owned(), ), + ( + "EDGEZERO__SERVICES__SVC2__STORES__SECRETS__DEFAULT__NAME".to_owned(), + "other_service_secrets".to_owned(), + ), ]; let out = staging_entries_from_production( &production, + "SVC1", &["app_config".to_owned(), "feature_flags".to_owned()], ); - // Non-selector overrides copied verbatim. - assert!(out.contains(&( - "EDGEZERO__ADAPTER__FASTLY__LOG_LEVEL".to_owned(), - "debug".to_owned() - ))); + assert!( + !out.iter() + .any(|(key, _)| key == "EDGEZERO__ADAPTER__FASTLY__LOG_LEVEL"), + "legacy unscoped entries are not part of a service-owned twin: {out:?}" + ); assert!(out.contains(&( - "EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME".to_owned(), + "EDGEZERO__SERVICES__SVC1__STORES__CONFIG__APP_CONFIG__NAME".to_owned(), "app_config".to_owned() ))); - // The selector production HAD is overridden to `_staging`, NOT - // production's custom value. assert!(out.contains(&( - "EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY".to_owned(), + "EDGEZERO__SERVICES__SVC1__STORES__CONFIG__APP_CONFIG__KEY".to_owned(), "app_config_staging".to_owned() ))); assert!(!out.iter().any(|(_, value)| value == "custom_prod_key")); - // The declared store production LACKED a selector for still gets one. assert!(out.contains(&( - "EDGEZERO__STORES__CONFIG__FEATURE_FLAGS__KEY".to_owned(), + "EDGEZERO__SERVICES__SVC1__STORES__CONFIG__FEATURE_FLAGS__KEY".to_owned(), "feature_flags_staging".to_owned() ))); - // Exactly one entry per selector key (no duplicate from the copy path). + assert!( + !out.iter().any(|(key, value)| { + key.contains("__SVC2__") || value == "other_service_secrets" + }), + "another service's scoped entries must not enter this twin: {out:?}" + ); assert_eq!( out.iter() - .filter(|(key, _)| key == "EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY") + .filter(|(key, _)| { + key == "EDGEZERO__SERVICES__SVC1__STORES__CONFIG__APP_CONFIG__KEY" + }) .count(), 1 ); @@ -10313,6 +11244,28 @@ echo 'unexpected' >&2; exit 1 assert_eq!(find_resource_link_id("not json", "x"), None); } + #[cfg(unix)] + #[test] + fn deploy_staged_ignores_ambient_store_name_overrides() { + let (result, argv) = run_deploy_staged_with_fake_and_env( + "SUCCESS: Updated package (service SVC1, version 7)", + &["--edgezero-staging-config=app_config"], + Some(( + "EDGEZERO__STORES__SECRETS__DEFAULT__NAME", + "ambient_secrets", + )), + ); + result.expect("staged deploy succeeds"); + + assert!( + !argv.iter().any(|line| { + line.contains("EDGEZERO__STORES__SECRETS__DEFAULT__NAME") + || line.contains("ambient_secrets") + }), + "staging must mirror persisted production mappings, not ambient process env: {argv:?}" + ); + } + #[cfg(unix)] #[test] fn deploy_staged_points_the_draft_at_the_staging_selector_store() { @@ -10332,13 +11285,13 @@ echo 'unexpected' >&2; exit 1 // `app_config_staging` via stdin) into the staging store. assert!( argv.iter().any(|line| line.starts_with( - "config-store-entry update --store-id=STAGEID1 --key=EDGEZERO__ADAPTER__FASTLY__LOG_LEVEL" + "config-store-entry update --store-id=STAGEID1 --key=EDGEZERO__SERVICES__SVC1__LOGGING__LEVEL" )), "production's non-config override must be mirrored into the twin: {argv:?}" ); assert!( argv.iter().any(|line| line.starts_with( - "config-store-entry update --store-id=STAGEID1 --key=EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY" + "config-store-entry update --store-id=STAGEID1 --key=EDGEZERO__SERVICES__SVC1__STORES__CONFIG__APP_CONFIG__KEY" )), "the config selector must be written into the twin: {argv:?}" ); @@ -10450,6 +11403,8 @@ echo 'unexpected' >&2; exit 1 else\n \ printf '%s\\n' '[{{\"id\":\"ENVSEL1\",\"name\":\"edgezero_runtime_env\"}}]'\n \ fi\n\ + elif [ \"$1\" = \"config-store-entry\" ] && [ \"$2\" = \"update\" ]; then\n \ + cat >/dev/null\n\ elif [ \"$1\" = \"config-store-entry\" ] && [ \"$2\" = \"list\" ]; then\n \ printf '%s\\n' '[]'\n\ elif [ \"$1\" = \"resource-link\" ] && [ \"$2\" = \"list\" ]; then\n \ @@ -10516,6 +11471,8 @@ echo 'unexpected' >&2; exit 1 else\n \ printf '%s\\n' '[]'\n \ fi\n\ + elif [ \"$1\" = \"config-store-entry\" ] && [ \"$2\" = \"update\" ]; then\n \ + cat >/dev/null\n\ elif [ \"$1\" = \"config-store-entry\" ] && [ \"$2\" = \"list\" ]; then\n \ printf '%s\\n' '[]'\n\ elif [ \"$1\" = \"resource-link\" ] && [ \"$2\" = \"list\" ]; then\n \ @@ -10547,7 +11504,7 @@ echo 'unexpected' >&2; exit 1 let argv = fs::read_to_string(&record).unwrap_or_default(); assert!( argv.lines().any(|line| line.starts_with( - "config-store-entry update --store-id=STAGEID1 --key=EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY" + "config-store-entry update --store-id=STAGEID1 --key=EDGEZERO__SERVICES__SVC1__STORES__CONFIG__APP_CONFIG__KEY" )), "the staging selector must be written even with no production store: {argv}" ); diff --git a/crates/edgezero-adapter-fastly/src/lib.rs b/crates/edgezero-adapter-fastly/src/lib.rs index daec2ca0..36161a35 100644 --- a/crates/edgezero-adapter-fastly/src/lib.rs +++ b/crates/edgezero-adapter-fastly/src/lib.rs @@ -34,6 +34,11 @@ use edgezero_core::env_config::EnvConfig; use edgezero_core::http::Extensions; #[cfg(any(feature = "fastly", test))] use edgezero_core::manifest::ResolvedLoggingConfig; +#[cfg(feature = "fastly")] +use fastly::compute_runtime::service_id; + +#[cfg(any(feature = "cli", feature = "fastly", test))] +const RUNTIME_ENV_PREFIX: &str = "EDGEZERO__"; /// Name of the Fastly Config Store the runtime opens for `EDGEZERO__*` /// overrides. @@ -99,6 +104,19 @@ impl From<&EnvConfig> for FastlyLogging { } } +/// Prefix a canonical `EDGEZERO__*` key with its owning Fastly service. +/// +/// The shared `edgezero_runtime_env` Config Store is account-wide. Service +/// scoping prevents two linked services that declare the same logical store id +/// from overwriting one another's runtime mappings. +#[cfg(any(feature = "cli", feature = "fastly", test))] +fn service_scoped_runtime_env_key(service_id: &str, canonical_key: &str) -> String { + let suffix = canonical_key + .strip_prefix(RUNTIME_ENV_PREFIX) + .unwrap_or(canonical_key); + format!("{RUNTIME_ENV_PREFIX}SERVICES__{service_id}__{suffix}") +} + /// # Errors /// Returns [`logger::InitLoggerError::Build`] if the underlying logger /// builder rejects its inputs (e.g. an empty endpoint), or @@ -173,49 +191,26 @@ where /// Fastly Config Store. /// /// Compute@Edge has no process env, so the `EDGEZERO__*` runtime overrides -/// (logging settings, per-store platform names, the config-store `__KEY` -/// selector) come from a Config Store the operator pre-populates: locally via -/// `fastly.toml`'s `[local_server.config_stores.edgezero_runtime_env]` block, -/// remotely via a `fastly config-store` named `edgezero_runtime_env`. +/// come from the Config Store. The function reads a fixed allowlist: adapter +/// host and port, logging settings, `__NAME` entries for declared stores, and +/// `__KEY` entries for declared config stores. /// -/// If the store is missing or empty, returns an empty `EnvConfig` and the rest -/// of the runtime uses its baked-in defaults. +/// Each lookup uses the current Fastly service's +/// `EDGEZERO__SERVICES____*` key. Legacy unscoped entries are not +/// read because they have no safe owner when this Config Store is linked to more +/// than one service. The returned [`EnvConfig`] contains canonical unscoped keys. /// /// [`run_app`] and [`run_app_with_request_extensions`] call this themselves. /// [`run_app_with_config`] does NOT, and neither does a hand-built -/// [`FastlyService`](request::FastlyService): a custom entry point on either of -/// those paths must call this explicitly, or staged and overridden store -/// selectors silently fall back to baked defaults. +/// [`FastlyService`](request::FastlyService). A custom entry point on either path +/// must call this explicitly. /// /// The `stores` argument must name the app's logical store ids. A handwritten -/// [`Hooks`] impl inherits the default `stores()`, which is EMPTY -/// ([`StoresMetadata::default`]), and empty metadata derives no -/// `EDGEZERO__STORES__*` keys at all — every selector override silently never -/// resolves. Such an impl must override `stores()`, or pass explicit -/// [`StoresMetadata`] here. -/// -/// ```rust,ignore -/// use edgezero_adapter_fastly::{FastlyLogging, init_logger, runtime_env_config}; -/// use edgezero_adapter_fastly::request::dispatch_with_registries; -/// use edgezero_core::app::{StoreMetadata, StoresMetadata}; +/// [`Hooks`] impl inherits the empty [`StoresMetadata::default`] and must +/// override `stores()` or pass explicit metadata here. /// -/// let stores = StoresMetadata { -/// config: Some(StoreMetadata { -/// default: "app_config", -/// ids: &["app_config"], -/// }), -/// ..StoresMetadata::default() -/// }; -/// let env = runtime_env_config(stores); -/// let logging = FastlyLogging::from(&env); -/// if logging.use_fastly_logger { -/// let endpoint = logging.endpoint.as_deref().unwrap_or("stdout"); -/// init_logger(endpoint, logging.level, logging.echo_stdout)?; -/// } -/// let app = MyApp::build_app(); -/// let _response = -/// dispatch_with_registries(&app, req, stores, &env, |_req, _extensions| {})?; -/// ``` +/// If the store cannot be opened, the function logs a warning and returns an +/// empty [`EnvConfig`]. Callers then use their baked-in adapter and store defaults. #[cfg(feature = "fastly")] #[must_use] #[inline] @@ -239,22 +234,33 @@ pub fn runtime_env_config(stores: StoresMetadata) -> EnvConfig { ); return EnvConfig::from_vars(empty::<(String, String)>()); }; - let vars = runtime_env_keys(stores) - .into_iter() - .filter_map(|key| dict.get(&key).map(|value| (key, value))); + let current_service_id = service_id(); + let vars = runtime_env_vars_for_service(stores, current_service_id, |key| dict.get(key)); EnvConfig::from_vars(vars) } +#[cfg(any(feature = "fastly", test))] +fn runtime_env_vars_for_service( + stores: StoresMetadata, + service_id: &str, + mut get: F, +) -> Vec<(String, String)> +where + F: FnMut(&str) -> Option, +{ + runtime_env_keys(stores) + .into_iter() + .filter_map(|canonical_key| { + let scoped_key = service_scoped_runtime_env_key(service_id, &canonical_key); + get(&scoped_key).map(|value| (canonical_key, value)) + }) + .collect() +} + /// The `EDGEZERO__*` keys resolved from the store into the [`EnvConfig`]: the /// fixed adapter and logging settings, plus a `__NAME` selector for every /// declared store id and a `__KEY` selector for config-store ids only. -/// -/// The Fastly runtime itself consumes only the logging level / endpoint and the -/// per-store selectors; the rest are resolved so downstream readers can fetch -/// them from the returned `EnvConfig`. -// The `test` arm is load-bearing: the crate's default features exclude -// `fastly`, so gating on the feature alone would keep this helper and the test -// pinning its key-derivation rules out of a plain `cargo test --workspace`. +// The `test` arm keeps the key derivation tests in default workspace tests. #[cfg(any(feature = "fastly", test))] fn runtime_env_keys(stores: StoresMetadata) -> Vec { let mut keys: Vec = vec![ diff --git a/crates/edgezero-cli/src/config.rs b/crates/edgezero-cli/src/config.rs index 079069d7..97a16f7c 100644 --- a/crates/edgezero-cli/src/config.rs +++ b/crates/edgezero-cli/src/config.rs @@ -1633,6 +1633,44 @@ pub(crate) fn reject_merged_id_collisions( Ok(()) } +fn collect_secret_leaf<'raw>( + node: &'raw Value, + field: &SecretField, + name: &str, + rendered: &str, + optional_segment: bool, + out: &mut Vec>, +) -> Result<(), String> { + let parent = node + .as_table() + .ok_or_else(|| format!("expected a table containing `{name}` at `{rendered}`"))?; + let leaf_label = if rendered.is_empty() { + name.to_owned() + } else { + format!("{rendered}.{name}") + }; + match parent.get(name).and_then(Value::as_str) { + Some(value) => { + let store_ref_value = match field.kind { + SecretKind::KeyInNamedStore { store_ref_field } => { + parent.get(store_ref_field).and_then(Value::as_str) + } + SecretKind::KeyInDefault | SecretKind::StoreRef => None, + }; + out.push(ResolvedTomlLeaf { + label: leaf_label, + store_ref_value, + value, + }); + Ok(()) + } + None if (field.optional || optional_segment) && parent.get(name).is_none() => Ok(()), + None => Err(format!( + "`#[secret]` field `{leaf_label}` is missing or not a string" + )), + } +} + /// Collect every concrete secret leaf a `SecretField` resolves to in the /// raw app-config TOML, navigating `Field` (table descent) and `ArrayEach` /// (per-element) segments. `label` uses concrete `[n]` indices and, for a @@ -1652,36 +1690,26 @@ fn collect_secret_leaves<'raw>( ) -> Result<(), String> { match remaining.split_first() { Some((SecretPathSegment::Field(name), [])) => { - let parent = node.as_table().ok_or_else(|| { - format!("expected a table containing `{name}` at `{rendered}`") - })?; - let leaf_label = if rendered.is_empty() { + collect_secret_leaf(node, field, name, rendered, false, out) + } + Some((SecretPathSegment::OptionalField(name), [])) => { + collect_secret_leaf(node, field, name, rendered, true, out) + } + Some((SecretPathSegment::Field(name), rest)) => { + let table = node + .as_table() + .ok_or_else(|| format!("expected a table at `{rendered}`"))?; + let next_rendered = if rendered.is_empty() { name.to_string() } else { format!("{rendered}.{name}") }; - match parent.get(name.as_ref()).and_then(Value::as_str) { - Some(value) => { - let store_ref_value = match field.kind { - SecretKind::KeyInNamedStore { store_ref_field } => { - parent.get(store_ref_field).and_then(Value::as_str) - } - SecretKind::KeyInDefault | SecretKind::StoreRef => None, - }; - out.push(ResolvedTomlLeaf { - label: leaf_label, - store_ref_value, - value, - }); - Ok(()) - } - None if field.optional && parent.get(name.as_ref()).is_none() => Ok(()), - None => Err(format!( - "`#[secret]` field `{leaf_label}` is missing or not a string" - )), + match table.get(name.as_ref()) { + Some(child) => walk(child, field, rest, &next_rendered, out), + None => Err(format!("missing `{next_rendered}`")), } } - Some((SecretPathSegment::Field(name), rest)) => { + Some((SecretPathSegment::OptionalField(name), rest)) => { let table = node .as_table() .ok_or_else(|| format!("expected a table at `{rendered}`"))?; @@ -1690,13 +1718,9 @@ fn collect_secret_leaves<'raw>( } else { format!("{rendered}.{name}") }; - // Intermediates are always required — `field.optional` reflects - // only the leaf, and the derive never nests through `Option`. This - // matches the runtime walk (`resolve_secret_field`) so `config - // validate` catches exactly what the runtime would reject. match table.get(name.as_ref()) { Some(child) => walk(child, field, rest, &next_rendered, out), - None => Err(format!("missing `{next_rendered}`")), + None => Ok(()), } } Some((SecretPathSegment::ArrayEach, rest)) => { @@ -1709,6 +1733,10 @@ fn collect_secret_leaves<'raw>( } Ok(()) } + Some((_unsupported, _)) => Err(format!( + "unsupported secret path segment in `{}`", + field.dotted_path() + )), None => Ok(()), } } @@ -2828,12 +2856,49 @@ other = "x" assert!(leaves.is_empty(), "absent optional leaf yields nothing"); } + #[test] + fn collect_secret_leaves_skips_absent_optional_intermediate() { + let raw: Value = toml::from_str("[integrations]\n").expect("toml"); + let field = SecretField { + kind: SecretKind::KeyInDefault, + path: vec![ + SecretPathSegment::Field(Cow::Borrowed("integrations")), + SecretPathSegment::OptionalField(Cow::Borrowed("datadome")), + SecretPathSegment::Field(Cow::Borrowed("webhook_key")), + ], + optional: true, + }; + let leaves = collect_secret_leaves(&raw, &field) + .expect("absent optional intermediate should be skipped"); + assert!( + leaves.is_empty(), + "absent optional intermediate yields nothing" + ); + } + + #[test] + fn collect_secret_leaves_rejects_scalar_parent_of_optional_intermediate() { + let raw: Value = toml::from_str("integrations = \"not-a-table\"\n").expect("toml"); + let field = SecretField { + kind: SecretKind::KeyInDefault, + path: vec![ + SecretPathSegment::Field(Cow::Borrowed("integrations")), + SecretPathSegment::OptionalField(Cow::Borrowed("datadome")), + SecretPathSegment::Field(Cow::Borrowed("webhook_key")), + ], + optional: true, + }; + + let err = collect_secret_leaves(&raw, &field) + .expect_err("a present optional intermediate must have a table parent"); + assert!( + err.contains("integrations"), + "collector error names the malformed parent: {err}" + ); + } + #[test] fn collect_secret_leaves_errors_on_missing_required_intermediate() { - // A missing INTERMEDIATE (the `integrations` table) is an error even - // when the leaf is optional — `optional` reflects only the leaf, and - // intermediates are structurally required. Locks alignment with the - // runtime walk (`resolve_secret_field`). let raw: Value = toml::from_str("other = \"x\"\n").expect("toml"); let field = SecretField { kind: SecretKind::KeyInDefault, diff --git a/crates/edgezero-cli/src/templates/root/README.md.hbs b/crates/edgezero-cli/src/templates/root/README.md.hbs index 2e2d1baa..810a010b 100644 --- a/crates/edgezero-cli/src/templates/root/README.md.hbs +++ b/crates/edgezero-cli/src/templates/root/README.md.hbs @@ -53,10 +53,11 @@ adapter binding in the per-adapter manifest) before running `config push`. Use `--key ` for per-environment overrides (staging / canary): the same `{{name}}.toml` can land under multiple keys, and the runtime -picks one via `EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY=`. -Where you set that override depends on the adapter -- Axum reads it -from process env, Cloudflare from worker vars, Spin from application -variables, and Fastly from a dedicated `edgezero_runtime_env` Config -Store that `edgezero provision --adapter fastly` creates. See the -[blob app-config migration guide](https://stackpop.github.io/edgezero/guide/blob-app-config-migration) +picks one via the canonical `EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY=` +setting. Axum reads that setting from process env, Cloudflare from worker +vars, and Spin from application variables. Fastly stores the equivalent +setting under the service-scoped key +`EDGEZERO__SERVICES____STORES__CONFIG__APP_CONFIG__KEY` in the +`edgezero_runtime_env` Config Store; an unscoped key in that store is ignored. +See the [blob app-config migration guide](https://stackpop.github.io/edgezero/guide/blob-app-config-migration) for the per-adapter steps and the full operator runbook. diff --git a/crates/edgezero-core/src/app_config.rs b/crates/edgezero-core/src/app_config.rs index 509e5684..6cf96235 100644 --- a/crates/edgezero-core/src/app_config.rs +++ b/crates/edgezero-core/src/app_config.rs @@ -32,11 +32,17 @@ use validator::{Validate, ValidationErrors}; /// One segment of a [`SecretField`] path. #[derive(Clone, Debug, Eq, PartialEq)] +#[non_exhaustive] pub enum SecretPathSegment { /// Every element of an array/`Vec` at this position. ArrayEach, /// An object key — a Rust field name, verbatim (no `serde(rename)`). Field(Cow<'static, str>), + /// An optional field that skips the rest of this secret path when absent or null. + /// + /// Available to hand-written [`AppConfigMeta`] implementations. The + /// `AppConfig` derive does not currently emit optional intermediate fields. + OptionalField(Cow<'static, str>), } /// One field's worth of secret-annotation metadata. @@ -64,7 +70,7 @@ impl SecretField { let mut out = String::new(); for segment in &self.path { match segment { - SecretPathSegment::Field(name) => { + SecretPathSegment::Field(name) | SecretPathSegment::OptionalField(name) => { if !out.is_empty() { out.push('.'); } @@ -324,10 +330,13 @@ fn prune_secret_leaf(errors: &mut ValidationErrors, path: &[SecretPathSegment]) let Some((head, rest)) = path.split_first() else { return; }; - let SecretPathSegment::Field(name) = head else { - // `ArrayEach` only appears immediately after a `Field` (the root is - // always a struct), so it is consumed by the peek below, never a head. - return; + let name = match head { + SecretPathSegment::Field(name) | SecretPathSegment::OptionalField(name) => name, + SecretPathSegment::ArrayEach => { + // `ArrayEach` only appears immediately after a field (the root is + // always a struct), so it is consumed by the peek below, never a head. + return; + } }; // Leaf reached: drop the validator error keyed by this field name. @@ -1554,7 +1563,7 @@ greeting = "hello" kind: SecretKind::KeyInDefault, path: vec![ Field(Cow::Borrowed("integrations")), - Field(Cow::Borrowed("datadome")), + OptionalField(Cow::Borrowed("datadome")), Field(Cow::Borrowed("server_side_key")), ], optional: false, diff --git a/crates/edgezero-core/src/extractor.rs b/crates/edgezero-core/src/extractor.rs index 7cbb4a85..11902205 100644 --- a/crates/edgezero-core/src/extractor.rs +++ b/crates/edgezero-core/src/extractor.rs @@ -999,15 +999,24 @@ fn resolve_secret_field<'walk>( ) -> Pin> + 'walk>> { Box::pin(async move { match remaining.split_first() { - // Leaf reached: `node` is the PARENT object; the last Field is the key. + // Leaf reached: `node` is the PARENT object; the last field is the key. Some((SecretPathSegment::Field(name), [])) => { resolve_leaf(ctx, node, field, name.as_ref(), &rendered).await } - // Descend into an object key. Intermediates are ALWAYS required — - // `field.optional` reflects only the LEAF (`Option`), and the - // derive never nests through `Option`/`Box`, so a missing/null parent - // is a stale blob. (Skipping it here would let the whole subtree pass - // silently and only fail later with a vaguer serde error.) + Some((SecretPathSegment::OptionalField(name), [])) => { + if node.as_object().is_some_and(|parent| { + matches!( + parent.get(name.as_ref()), + None | Some(serde_json::Value::Null) + ) + }) { + return Ok(()); + } + resolve_leaf(ctx, node, field, name.as_ref(), &rendered).await + } + // Required intermediates still reject stale blobs. Optional + // intermediates are represented explicitly below rather than by the + // leaf's `field.optional` flag. Some((SecretPathSegment::Field(name), rest)) => { let next_rendered = join_field(&rendered, name.as_ref()); match node.get_mut(name.as_ref()) { @@ -1020,8 +1029,23 @@ fn resolve_secret_field<'walk>( } } } + Some((SecretPathSegment::OptionalField(name), rest)) => { + let Some(parent) = node.as_object_mut() else { + return Err(EdgeError::config_out_of_date( + format!("expected an object at `{rendered}`"), + rendered, + )); + }; + let next_rendered = join_field(&rendered, name.as_ref()); + match parent.get_mut(name.as_ref()) { + None | Some(serde_json::Value::Null) => Ok(()), + Some(child) => { + resolve_secret_field(ctx, child, field, rest, next_rendered).await + } + } + } // Iterate every array element. The array itself is a required - // intermediate (see above), so a non-array is always an error. + // intermediate unless its containing field was optional above. Some((SecretPathSegment::ArrayEach, rest)) => { let Some(items) = node.as_array_mut() else { return Err(EdgeError::config_out_of_date( @@ -1059,9 +1083,9 @@ async fn resolve_leaf( rendered_parent: &str, ) -> Result<(), EdgeError> { // `StoreRef` is filtered out in `secret_walk` before any descent, so it - // never reaches here. The leaf's parent is a required intermediate, so a - // non-object parent is always an error — only the leaf key below honors - // `field.optional`. + // never reaches here. Traversal handles optional intermediates; once a leaf + // is reached, its present parent must be an object and only the leaf key + // below honors `field.optional`. let leaf_path = join_field(rendered_parent, key); let Some(parent_obj) = parent.as_object_mut() else { @@ -1394,7 +1418,8 @@ mod tests { } } - // Optional leaf behind required intermediates: integrations.datadome.webhook_key + // Optional leaf behind one required and one optional intermediate: + // integrations.datadome.webhook_key struct OptionalNestedCfg; impl AppConfigMeta for OptionalNestedCfg { fn secret_fields() -> Vec { @@ -1402,7 +1427,7 @@ mod tests { kind: SecretKind::KeyInDefault, path: vec![ SecretPathSegment::Field(Cow::Borrowed("integrations")), - SecretPathSegment::Field(Cow::Borrowed("datadome")), + SecretPathSegment::OptionalField(Cow::Borrowed("datadome")), SecretPathSegment::Field(Cow::Borrowed("webhook_key")), ], optional: true, @@ -1410,6 +1435,22 @@ mod tests { } } + // Optional terminal segment supplied by hand-written metadata: + // integrations.webhook_key + struct TerminalOptionalCfg; + impl AppConfigMeta for TerminalOptionalCfg { + fn secret_fields() -> Vec { + vec![SecretField { + kind: SecretKind::KeyInDefault, + path: vec![ + SecretPathSegment::Field(Cow::Borrowed("integrations")), + SecretPathSegment::OptionalField(Cow::Borrowed("webhook_key")), + ], + optional: false, + }] + } + } + fn ctx(body: Body, params: PathParams) -> RequestContext { let request = request_builder() .method(Method::POST) @@ -2861,9 +2902,56 @@ mod tests { ); } + #[test] + fn secret_walk_skips_absent_optional_intermediate() { + let ctx = ctx_with_default_secret_store("unused", "unused"); + let mut data = serde_json::json!({ "integrations": {} }); + block_on(secret_walk::(&ctx, &mut data)) + .expect("absent optional intermediate is fine"); + } + + #[test] + fn secret_walk_skips_null_optional_intermediate() { + let ctx = ctx_with_default_secret_store("unused", "unused"); + let mut data = serde_json::json!({ "integrations": { "datadome": null } }); + block_on(secret_walk::(&ctx, &mut data)) + .expect("null optional intermediate is fine"); + } + + #[test] + fn secret_walk_rejects_scalar_parent_of_optional_intermediate() { + let ctx = ctx_with_default_secret_store("unused", "unused"); + let mut data = serde_json::json!({ "integrations": "not-an-object" }); + let err = block_on(secret_walk::(&ctx, &mut data)) + .expect_err("a present optional intermediate must have an object parent"); + + assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE); + assert!( + err.to_string().contains("integrations"), + "error names the malformed parent: {err}" + ); + let EdgeError::ConfigOutOfDate { field_path, .. } = &err else { + panic!("malformed optional parent must be ConfigOutOfDate: {err:?}"); + }; + assert_eq!(field_path, "integrations"); + } + + #[test] + fn secret_walk_rejects_scalar_parent_of_terminal_optional_field() { + let ctx = ctx_with_default_secret_store("unused", "unused"); + let mut data = serde_json::json!({ "integrations": "not-an-object" }); + let err = block_on(secret_walk::(&ctx, &mut data)) + .expect_err("a terminal optional field must have an object parent"); + + assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE); + let EdgeError::ConfigOutOfDate { field_path, .. } = &err else { + panic!("malformed optional parent must be ConfigOutOfDate: {err:?}"); + }; + assert_eq!(field_path, "integrations.webhook_key"); + } + #[test] fn secret_walk_present_intermediate_absent_optional_leaf_is_ok() { - // The mirror case: intermediates present, optional leaf absent -> skip. let ctx = ctx_with_default_secret_store("unused", "unused"); let mut data = serde_json::json!({ "integrations": { "datadome": {} } }); block_on(secret_walk::(&ctx, &mut data)) diff --git a/docs/guide/adapters/fastly.md b/docs/guide/adapters/fastly.md index f8d25739..da0185e9 100644 --- a/docs/guide/adapters/fastly.md +++ b/docs/guide/adapters/fastly.md @@ -190,6 +190,29 @@ Fastly uses a native Config Store resource link for runtime configuration. Decla ids in `edgezero.toml`; each id opens its own platform store via `EDGEZERO__STORES__CONFIG____NAME` (default = the logical id): +Because `edgezero_runtime_env` is an account-wide Fastly resource, its stored +keys are scoped by the current service ID: + +```text +EDGEZERO__SERVICES____STORES__CONFIG____NAME +EDGEZERO__SERVICES____STORES__CONFIG____KEY +``` + +The runtime obtains `` from Fastly and translates these entries back +to the portable `EDGEZERO__STORES__*` form. Legacy unscoped entries are ignored +because they have no safe owner when the Config Store is linked to multiple +services. Re-run `edgezero provision --adapter fastly` to write scoped `__NAME` +entries, and rewrite any manually managed adapter, logging, or `__KEY` entries +under the service prefix. Provision writes only the selected service's +namespace; a non-default store-name mapping therefore requires top-level +`service_id` in `fastly.toml` or `FASTLY_SERVICE_ID`. If both are set, they must +match. + +Viceroy reports `0000000000000000000000` as its local service ID. Entries in a +local `[local_server.config_stores.edgezero_runtime_env.contents]` block must +therefore use `EDGEZERO__SERVICES__0000000000000000000000__...`, not the +production service ID or the unscoped canonical key. + ```toml [stores.config] ids = ["app_config"] diff --git a/docs/guide/blob-app-config-migration.md b/docs/guide/blob-app-config-migration.md index f83af3ae..6fdea5d0 100644 --- a/docs/guide/blob-app-config-migration.md +++ b/docs/guide/blob-app-config-migration.md @@ -246,21 +246,33 @@ provisioning: # Look up the platform store id (matches by name). fastly config-store list --json | jq -r '.[] | select(.name=="edgezero_runtime_env") | .id' -# Set the override. +# Set the override for one service. Config Store keys are case-sensitive. fastly config-store-entry update \ --store-id= \ - --key=EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY \ + --key=EDGEZERO__SERVICES____STORES__CONFIG__APP_CONFIG__KEY \ --value=app_config_staging \ --upsert ``` -Locally (Viceroy), the store lives in fastly.toml's -`[local_server.config_stores.edgezero_runtime_env]` block. If the -store is missing at runtime, EdgeZero logs a one-line warning to -Fastly logs (`Fastly Config Store 'edgezero_runtime_env' not found; -EDGEZERO__* runtime overrides will use baked-in defaults`) and falls -back to the binding's default id -- so the runtime keeps serving, but -your per-environment override is silently inactive until you provision. +Fastly runtime overrides are service-scoped because the Config Store can be +linked to multiple services. Legacy unscoped `EDGEZERO__STORES__...` entries are +not read; migrate manually managed entries by rewriting them under the service +prefix shown above. Provisioning a non-default store-name mapping requires +`service_id` in `fastly.toml` or `FASTLY_SERVICE_ID` so the command cannot write +into an ambiguous namespace. If both are set, they must match. + +Locally, Viceroy reports the fixed service ID +`0000000000000000000000`, regardless of the deployment `service_id` in +`fastly.toml`. Put local overrides under that namespace: + +```toml +[local_server.config_stores.edgezero_runtime_env.contents] +EDGEZERO__SERVICES__0000000000000000000000__STORES__CONFIG__APP_CONFIG__KEY = "app_config_staging" +``` + +If the local `edgezero_runtime_env` store is missing, EdgeZero logs a one-line +warning and falls back to the binding's default id. The runtime keeps serving, +but the per-environment override is inactive. ### Drift detection in CI diff --git a/docs/guide/cli-reference.md b/docs/guide/cli-reference.md index 65aaef4c..779c38e7 100644 --- a/docs/guide/cli-reference.md +++ b/docs/guide/cli-reference.md @@ -323,8 +323,9 @@ flags and exits `2` with a pointer to the typed CLI — it cannot push (see key is _derived_ from the store's logical id and is mutually exclusive with `--key` (an explicit staging key would be written where no staged version reads, so the combination is refused). A staged deploy points the staged version's - `edgezero_runtime_env` link at this key via `EDGEZERO__STORES__CONFIG____KEY` - in its staging selector store (see [the blob migration guide](./blob-app-config-migration.md#per-environment-key-override)). + `edgezero_runtime_env` link at this key via the service-scoped + `EDGEZERO__SERVICES____STORES__CONFIG____KEY` entry in its + staging selector store (see [the blob migration guide](./blob-app-config-migration.md#per-environment-key-override)). - `--no-env` — skip the `__…__` env-var overlay when loading the app config. By default the loader reads the overlay so the push sends the same values the runtime would. - `--local` — push into the adapter's local-emulator state instead of the live platform. Fastly edits `[local_server.config_stores]` in `fastly.toml` (Viceroy reads it on startup); Cloudflare runs `wrangler kv bulk put --local` so writes land in `.wrangler/state`; Spin forces SQLite-direct against `/.spin/sqlite_key_value.db` even when the manifest's deploy command targets Fermyon Cloud (the runtime-config `[key_value_store.