From 524b8d0936c88e3a5f53602b2eafef512f4b0d1c Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 16 Sep 2026 00:11:32 -0700 Subject: [PATCH 01/36] fix(fastly): apply canonical store selectors at deploy --- .github/actions/config-push-fastly/action.yml | 2 +- .../config-push-fastly/scripts/config-push.sh | 7 +- .../deploy-core/scripts/run-app-cli.sh | 26 + .../deploy-core/tests/assert-config-push.sh | 6 +- .../deploy-core/tests/assert-staged-calls.sh | 4 +- .../deploy-core/tests/make-fake-fastly-env.sh | 6 +- .github/actions/deploy-core/tests/run.sh | 19 +- .../actions/deploy-fastly/scripts/deploy.sh | 2 +- .../actions/deploy-fastly/scripts/validate.sh | 2 +- crates/edgezero-adapter-fastly/src/cli.rs | 1838 ++++++++--------- crates/edgezero-adapter-fastly/src/lib.rs | 47 +- crates/edgezero-adapter/src/registry.rs | 69 +- crates/edgezero-cli/src/adapter.rs | 97 +- crates/edgezero-cli/src/args.rs | 14 +- crates/edgezero-cli/src/config.rs | 13 +- crates/edgezero-cli/src/generator.rs | 2 + crates/edgezero-cli/src/lib.rs | 429 ++-- .../src/templates/root/README.md.hbs | 7 +- crates/edgezero-cli/src/test_support.rs | 9 + docs/guide/adapters/fastly.md | 49 +- docs/guide/blob-app-config-migration.md | 24 +- docs/guide/cli-reference.md | 36 +- docs/guide/configuration.md | 6 + docs/guide/deploy-github-actions.md | 67 +- docs/guide/manifest-store-migration.md | 9 +- docs/specs/edgezero-deploy-adoption-guide.md | 5 +- docs/specs/edgezero-deploy-github-action.md | 67 +- 27 files changed, 1405 insertions(+), 1457 deletions(-) diff --git a/.github/actions/config-push-fastly/action.yml b/.github/actions/config-push-fastly/action.yml index 533de968..61230de4 100644 --- a/.github/actions/config-push-fastly/action.yml +++ b/.github/actions/config-push-fastly/action.yml @@ -41,7 +41,7 @@ inputs: required: false default: "" deploy-to: - description: "'production' writes the base key; 'staging' writes the _staging variant in the same store (the key the staging selector points at)." + description: "'production' writes the base key; 'staging' writes the _staging variant in the store selected by the staging environment (the key the staging selector points at)." required: false default: production diff --git a/.github/actions/config-push-fastly/scripts/config-push.sh b/.github/actions/config-push-fastly/scripts/config-push.sh index 006c542b..5ac3afcd 100755 --- a/.github/actions/config-push-fastly/scripts/config-push.sh +++ b/.github/actions/config-push-fastly/scripts/config-push.sh @@ -11,9 +11,10 @@ set -euo pipefail # FASTLY_TOKEN can never redirect or re-auth the push. # # Staging: `deploy-to: staging` passes `--staging` to the CLI, which writes the -# `_staging` variant in the SAME store — the key the staging -# selector points a staged version at, never the production key the live service -# reads. `key` is production-only (the wrapper rejects key + staging up front). +# `_staging` variant in the environment-selected store — the +# key the staging selector points a staged version at, never the production key +# the live service reads. Production and staging may select the same or different +# physical stores. `key` is production-only (the wrapper rejects key + staging). # # Path confinement: working-directory, manifest, and app-config are # caller strings handed to a credential-bearing CLI, so each is canonicalized diff --git a/.github/actions/deploy-core/scripts/run-app-cli.sh b/.github/actions/deploy-core/scripts/run-app-cli.sh index 5ab6b0db..2c5f3791 100755 --- a/.github/actions/deploy-core/scripts/run-app-cli.sh +++ b/.github/actions/deploy-core/scripts/run-app-cli.sh @@ -196,6 +196,27 @@ scrub_action_private_env() { done < <(compgen -e) } +PUBLIC_RUNTIME_ENV_NAMES=() +PUBLIC_RUNTIME_ENV_VALUES=() +capture_public_runtime_env() { + local name + while IFS= read -r name; do + if [[ "$name" =~ ^EDGEZERO__STORES__CONFIG__[A-Z0-9_]+__(NAME|KEY)$ ]] || + [[ "$name" =~ ^EDGEZERO__STORES__(KV|SECRETS)__[A-Z0-9_]+__NAME$ ]]; then + PUBLIC_RUNTIME_ENV_NAMES+=("$name") + PUBLIC_RUNTIME_ENV_VALUES+=("${!name}") + fi + done < <(compgen -e) +} + +restore_public_runtime_env() { + local index name + for ((index = 0; index < ${#PUBLIC_RUNTIME_ENV_NAMES[@]}; index++)); do + name="${PUBLIC_RUNTIME_ENV_NAMES[$index]}" + export "$name=${PUBLIC_RUNTIME_ENV_VALUES[$index]}" + done +} + ARGV=() main() { local mode="${1:-}" @@ -217,11 +238,16 @@ main() { # Clear inherited provider aliases and export only the typed credentials. import_provider_env "${EDGEZERO__PROVIDER__ENV_CLEAR_FILE:-/dev/null}" build_deploy_argv "$cli_bin" "$adapter" + # GitHub Environment selection happens outside this action. Preserve the + # public canonical store selectors it supplied while the broad scrub below + # removes action-private EDGEZERO__ carriers. + capture_public_runtime_env ;; esac # Everything the action needed from its own env is now in locals or in ARGV. scrub_action_private_env + restore_public_runtime_env if [[ -n "$manifest" ]]; then export EDGEZERO_MANIFEST="$manifest" diff --git a/.github/actions/deploy-core/tests/assert-config-push.sh b/.github/actions/deploy-core/tests/assert-config-push.sh index dcfeaa75..aac3dc73 100755 --- a/.github/actions/deploy-core/tests/assert-config-push.sh +++ b/.github/actions/deploy-core/tests/assert-config-push.sh @@ -4,9 +4,9 @@ set -euo pipefail # Asserts one config-push-fastly invocation against the fake Fastly CLI. # # The contract that matters is the staging model: staging and production write -# DIFFERENT KEYS in the SAME store, so a staged push can never overwrite the key -# the live service is reading. This runs once per push — re-seeding the fake -# truncates the call log, so each push is asserted against its own log. +# different keys in the store selected by each environment, so a staged push can +# never overwrite the key the live service is reading. This runs once per push — +# re-seeding the fake truncates the call log, so each push is asserted separately. # # Reads (env): # FAKE_CALL_LOG required the fake fastly call log diff --git a/.github/actions/deploy-core/tests/assert-staged-calls.sh b/.github/actions/deploy-core/tests/assert-staged-calls.sh index 10539e2d..bdf568cf 100755 --- a/.github/actions/deploy-core/tests/assert-staged-calls.sh +++ b/.github/actions/deploy-core/tests/assert-staged-calls.sh @@ -52,9 +52,9 @@ assert_comment_precedes_stage() { # production logging override. assert_twin_mirrors_production() { local log="$1" - grep -qE '^fastly config-store-entry update .*--store-id=STAGESEL1 .*--key=EDGEZERO__SERVICES__dummyservice__LOGGING__LEVEL' "$log" || + grep -qE '^fastly config-store-entry update .*--store-id=STAGESEL1 .*--key=EDGEZERO__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__SERVICES__dummyservice__STORES__CONFIG__APP_CONFIG__KEY' "$log" || + grep -qE '^fastly config-store-entry update .*--store-id=STAGESEL1 .*--key=EDGEZERO__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/make-fake-fastly-env.sh b/.github/actions/deploy-core/tests/make-fake-fastly-env.sh index e10af721..3775ef3a 100755 --- a/.github/actions/deploy-core/tests/make-fake-fastly-env.sh +++ b/.github/actions/deploy-core/tests/make-fake-fastly-env.sh @@ -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 this service's scoped logging override, which - # the twin must copy verbatim; the twin (STAGESEL1) starts empty. + # Production (ENVSEL1) carries a logging override, which the twin must copy + # verbatim; the twin (STAGESEL1) starts empty. case "\$*" in - *--store-id=ENVSEL1*) echo '[{"item_key":"EDGEZERO__SERVICES__dummyservice__LOGGING__LEVEL","item_value":"debug"}]' ;; + *--store-id=ENVSEL1*) echo '[{"item_key":"EDGEZERO__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 afd3aea4..57f6c2ba 100755 --- a/.github/actions/deploy-core/tests/run.sh +++ b/.github/actions/deploy-core/tests/run.sh @@ -424,8 +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='svc_1' assert_succeeds "deploy-fastly: service-id with underscore is accepted" run_dfl + S='svc-1' assert_succeeds "deploy-fastly: service-id with hyphen is accepted" run_dfl S='' assert_fails "deploy-fastly: empty service-id is rejected" run_dfl # config-push-fastly: artifact + token presence, deploy-to fail-closed. @@ -658,6 +658,9 @@ printf 'FASTLY_API_TOKEN=%s\n' "${FASTLY_API_TOKEN:-ABSENT}" printf 'EDGEZERO__PROVIDER__ENV=%s\n' "${EDGEZERO__PROVIDER__ENV:-ABSENT}" printf 'EDGEZERO__FASTLY__API_TOKEN=%s\n' "${EDGEZERO__FASTLY__API_TOKEN:-ABSENT}" printf 'EDGEZERO__DEPLOY__ARGS_FILE=%s\n' "${EDGEZERO__DEPLOY__ARGS_FILE:-ABSENT}" +printf 'EDGEZERO__STORES__SECRETS__TRUSTED_SERVER_SECRETS__NAME=%s\n' "${EDGEZERO__STORES__SECRETS__TRUSTED_SERVER_SECRETS__NAME:-ABSENT}" +printf 'EDGEZERO__STORES__CONFIG__TRUSTED_SERVER_CONFIG__KEY=%s\n' "${EDGEZERO__STORES__CONFIG__TRUSTED_SERVER_CONFIG__KEY:-ABSENT}" +printf 'EDGEZERO__STORES__SECRETS____NAME=%s\n' "${EDGEZERO__STORES__SECRETS____NAME:-ABSENT}" printf 'EDGEZERO_MANIFEST=%s\n' "${EDGEZERO_MANIFEST:-ABSENT}" CLI chmod +x "$dir/bin/scrub-cli" @@ -671,6 +674,9 @@ CLI EDGEZERO__PROVIDER__ENV_CLEAR_FILE="$dir/clear.nul" \ EDGEZERO__PROVIDER__ENV='{"FASTLY_API_TOKEN":"s3cret"}' \ EDGEZERO__FASTLY__API_TOKEN='s3cret' \ + EDGEZERO__STORES__SECRETS__TRUSTED_SERVER_SECRETS__NAME='ts-secrets-staging' \ + EDGEZERO__STORES__CONFIG__TRUSTED_SERVER_CONFIG__KEY='trusted_server_config_staging' \ + EDGEZERO__STORES__SECRETS____NAME='must-not-survive' \ "$CORE_SCRIPTS/run-app-cli.sh" deploy 2>/dev/null ) @@ -679,6 +685,12 @@ CLI "FASTLY_API_TOKEN=s3cret" "$(grep '^FASTLY_API_TOKEN=' <<<"$out")" assert_equals "EDGEZERO_MANIFEST is delivered" \ "EDGEZERO_MANIFEST=$dir/edgezero.toml" "$(grep '^EDGEZERO_MANIFEST=' <<<"$out")" + assert_equals "the selected secret-store name is delivered" \ + "EDGEZERO__STORES__SECRETS__TRUSTED_SERVER_SECRETS__NAME=ts-secrets-staging" \ + "$(grep '^EDGEZERO__STORES__SECRETS__TRUSTED_SERVER_SECRETS__NAME=' <<<"$out")" + assert_equals "the selected config-store key is delivered" \ + "EDGEZERO__STORES__CONFIG__TRUSTED_SERVER_CONFIG__KEY=trusted_server_config_staging" \ + "$(grep '^EDGEZERO__STORES__CONFIG__TRUSTED_SERVER_CONFIG__KEY=' <<<"$out")" # What it must NEVER see: the same secret under names we never promised. assert_equals "the provider-env JSON blob does not survive" \ @@ -687,6 +699,9 @@ CLI "EDGEZERO__FASTLY__API_TOKEN=ABSENT" "$(grep '^EDGEZERO__FASTLY__API_TOKEN=' <<<"$out")" assert_equals "action-private file handles do not survive" \ "EDGEZERO__DEPLOY__ARGS_FILE=ABSENT" "$(grep '^EDGEZERO__DEPLOY__ARGS_FILE=' <<<"$out")" + assert_equals "malformed selector names do not survive" \ + "EDGEZERO__STORES__SECRETS____NAME=ABSENT" \ + "$(grep '^EDGEZERO__STORES__SECRETS____NAME=' <<<"$out")" } # --------------------------------------------------------------------------- diff --git a/.github/actions/deploy-fastly/scripts/deploy.sh b/.github/actions/deploy-fastly/scripts/deploy.sh index 101d48e6..9a13e38c 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 38013f47..47e0c353 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/crates/edgezero-adapter-fastly/src/cli.rs b/crates/edgezero-adapter-fastly/src/cli.rs index b29a43ec..4efa4f6f 100644 --- a/crates/edgezero-adapter-fastly/src/cli.rs +++ b/crates/edgezero-adapter-fastly/src/cli.rs @@ -1,7 +1,6 @@ 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 _}; @@ -21,19 +20,19 @@ 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, }; use edgezero_adapter::registry::{ - Adapter, AdapterAction, AdapterPushContext, ProvisionStores, ReadConfigEntry, ResolvedStoreId, - register_adapter, + Adapter, AdapterAction, AdapterDeployContext, AdapterPushContext, DeployStoreIds, + ProvisionStores, ReadConfigEntry, ResolvedStoreId, register_adapter, }; use edgezero_adapter::scaffold::{ AdapterBlueprint, AdapterFileSpec, CommandTemplates, DependencySpec, LoggingDefaults, ManifestSpec, ReadmeInfo, TemplateRegistration, register_adapter_blueprint, }; +use edgezero_core::env_config::EnvConfig; use walkdir::WalkDir; static FASTLY_ADAPTER: FastlyCliAdapter = FastlyCliAdapter; @@ -133,15 +132,13 @@ static FASTLY_TEMPLATE_REGISTRATIONS: &[TemplateRegistration] = &[ const FASTLY_INSTALL_HINT: &str = "install the Fastly CLI (https://www.fastly.com/documentation/reference/tools/cli/) and try again"; -/// Base name of the staging twin of [`RUNTIME_ENV_STORE_NAME`]. The actual store is -/// named PER SERVICE — [`staging_selector_store_name`] appends the service id — -/// because Fastly config stores are account-wide, versionless resources: a -/// single shared twin would let a staged deploy of service B destructively -/// overwrite the selectors a staged version of service A is reading. +/// Base name of the staging twin of [`RUNTIME_ENV_STORE_NAME`]. The actual store +/// is scoped by Fastly service id so different services cannot overwrite each +/// other's staging selectors. /// /// A staged deploy clones the active version, and a clone inherits its resource /// links — so without a second store the staged version reads production's -/// selector, and therefore production's config. Fastly resource links are +/// config selector and physical store names. Fastly resource links are /// per-version and carry an overridable NAME, so the staged draft links THIS /// store under the name `edgezero_runtime_env`. The runtime opens that name and /// gets staged config; the active version is untouched. @@ -245,6 +242,18 @@ enum ConfigStoreLookup { SchemaDrift(String), } +#[derive(Clone, Copy, Debug)] +enum FastlyServiceIdSource { + Environment, + Manifest, +} + +#[derive(Debug)] +struct SelectedFastlyService { + id: String, + source: FastlyServiceIdSource, +} + /// The reclamation plan for `config gc`: the orphan chunk entries to delete /// (with their ages) plus the counts for the summary line. Produced by /// `plan_gc_reclamation` (which owns every safety guard); consumed by @@ -364,6 +373,23 @@ struct RuntimeStoreNameReconciliation { upserts: Vec<(String, String)>, } +#[derive(Debug, Default, PartialEq, Eq)] +struct RuntimeStoreIds { + config: Vec, + kv: Vec, + secrets: Vec, +} + +impl From<&DeployStoreIds> for RuntimeStoreIds { + fn from(stores: &DeployStoreIds) -> Self { + Self { + config: stores.config.clone(), + kv: stores.kv.clone(), + secrets: stores.secrets.clone(), + } + } +} + // 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 @@ -389,6 +415,14 @@ struct RuntimeStoreNameReconciliation { reason = "see the explanatory block comment immediately above; fastly's no-op defaults for the three validate_* hooks are intentional and documented. `read_config_entry` and `read_config_entry_local` are both overridden below. `single_store_kinds` IS overridden below (returns `&[]`)." )] impl Adapter for FastlyCliAdapter { + fn deploy(&self, context: &AdapterDeployContext, args: &[String]) -> Result<(), String> { + if context.staging { + deploy_staged_with_context(context, args) + } else { + deploy_with_context(context, args) + } + } + fn execute(&self, action: AdapterAction, args: &[String]) -> Result<(), String> { match action { // `fastly profile {create|delete|list}` is the native @@ -419,6 +453,36 @@ impl Adapter for FastlyCliAdapter { } } + fn finalize_deploy( + &self, + context: &AdapterDeployContext, + command_output: Option<&str>, + ) -> Result<(), String> { + if context.staging { + return Ok(()); + } + + let stores = RuntimeStoreIds::from(&context.stores); + if !stores.config.is_empty() || !stores.kv.is_empty() || !stores.secrets.is_empty() { + let manifest_dir = resolve_deploy_manifest_dir(context)?; + reconcile_deploy_runtime_env(&stores, &EnvConfig::from_env(), &manifest_dir)?; + } + + let Some(service_id) = context.service_id.as_deref() else { + return Ok(()); + }; + validate_service_id(service_id)?; + if let Some(version) = command_output.and_then(parse_fastly_version) { + log::info!("version={version}"); + return Ok(()); + } + emit_active_version_for(service_id, true).map_err(|error| { + format!( + "deploy succeeded but the activated version could not be resolved from the deploy output or Fastly API: {error}" + ) + }) + } + fn gc_config_entries( &self, _manifest_root: &Path, @@ -480,9 +544,7 @@ 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 selected_service = effective_fastly_service_id(&fastly_path)?; let mut out = Vec::new(); for (kind, ids) in [ ("kv", stores.kv), @@ -544,7 +606,7 @@ impl Adapter for FastlyCliAdapter { // service is surprising. The instruction names // both the store-id lookup AND the link command so // the operator can audit before committing. - let post_create_note = resource_link_note(&fastly_path, kind, name)?; + let post_create_note = resource_link_note(selected_service.as_ref(), kind, name); let mut line = format!( "created fastly {kind}-store `{name}` (logical id `{logical}`); appended setup tables to {}", fastly_path.display() @@ -588,19 +650,19 @@ impl Adapter for FastlyCliAdapter { // be re-applied by the next `fastly compute deploy`, so the // runtime can't open the store. Emit the resource-link // remediation alongside the populate-keys hint. - let post_create_note = - resource_link_note(&fastly_path, runtime_env_kind, runtime_env_name)?; + let post_create_note = resource_link_note( + selected_service.as_ref(), + runtime_env_kind, + runtime_env_name, + ); // NB: this store is what the ACTIVE (production) service reads. The // example must never point it at a staging key — following that would // 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 production_selector_key = canonical_runtime_env_key_for("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 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.", + "created fastly {runtime_env_kind}-store `{runtime_env_name}` (EdgeZero runtime override store, read by the ACTIVE version); appended setup tables to {}\n Runtime store selectors are applied by deploy from its selected environment. Config stores still select their logical id as the default key.\n To point PRODUCTION at a different config key manually, 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 { @@ -612,13 +674,6 @@ 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 @@ -1579,70 +1634,65 @@ fn read_fastly_service_id(path: &Path) -> Result, String> { Ok(svc) } -/// 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() { +fn select_fastly_service_id( + manifest_id: Option, + environment_id: Option, +) -> Result, String> { + if let (Some(manifest), Some(environment)) = (&manifest_id, &environment_id) + && manifest != environment + { 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() + "fastly.toml service_id `{manifest}` conflicts with {FASTLY_SERVICE_ID_ENV} `{environment}`; make them agree before provisioning" )); } - Ok(service_id) + + let selected = match (manifest_id, environment_id) { + (Some(id), _) => Some(SelectedFastlyService { + id, + source: FastlyServiceIdSource::Manifest, + }), + (None, Some(id)) => Some(SelectedFastlyService { + id, + source: FastlyServiceIdSource::Environment, + }), + (None, None) => None, + }; + if let Some(service) = &selected { + validate_service_id(&service.id)?; + } + Ok(selected) } -/// 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 = provision_runtime_env_service_id(path)?.map(|svc_id| { +fn effective_fastly_service_id(path: &Path) -> Result, String> { + let manifest_id = read_fastly_service_id(path)?; + let environment_id = env::var(FASTLY_SERVICE_ID_ENV) + .ok() + .filter(|id| !id.is_empty()); + select_fastly_service_id(manifest_id, environment_id) +} + +/// If a service is selected through fastly.toml or `FASTLY_SERVICE_ID`, the +/// next `fastly compute deploy` targets an existing service and skips `[setup]`. +/// Any store created by provision then needs a separate resource link. +fn resource_link_note( + selected: Option<&SelectedFastlyService>, + kind: &str, + name: &str, +) -> Option { + selected.map(|service| { + let svc_id = &service.id; + let selection = match service.source { + FastlyServiceIdSource::Manifest => { + format!("fastly.toml declares `service_id = \"{svc_id}\"`") + } + FastlyServiceIdSource::Environment => { + format!("`{FASTLY_SERVICE_ID_ENV}` selects service `{svc_id}`") + } + }; format!( - " 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`)." + " {selection}, so this service is already deployed -- `[setup]` will NOT be re-run on the next `fastly compute deploy`. The store exists in the account but is NOT yet linked to the service. To finish provisioning, look up the store id with `fastly {kind}-store list --json` (match by name=`{name}`), then run:\n fastly resource-link create --service-id={svc_id} --resource-id= --version=latest --autoclone --name={name}\n (the link clones the active version so existing traffic is not affected until you `fastly service-version activate`)." ) - }); - Ok(note) + }) } /// Probe `fastly.toml` for the existence of `[setup._stores.]`. @@ -3306,34 +3356,6 @@ where }) } -/// 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 /// the value piped through stdin instead of `--value=` on /// argv. @@ -3564,12 +3586,100 @@ fn delete_config_store_entry_in(store_id: &str, key: &str, cwd: &Path) -> Result ) } +fn runtime_store_keys(stores: &RuntimeStoreIds) -> Vec { + let mut keys = Vec::new(); + for id in &stores.config { + keys.push(canonical_runtime_store_name_key("CONFIG", id)); + keys.push(canonical_runtime_env_key_for(id)); + } + for id in &stores.kv { + keys.push(canonical_runtime_store_name_key("KV", id)); + } + for id in &stores.secrets { + keys.push(canonical_runtime_store_name_key("SECRETS", id)); + } + keys +} + +fn runtime_store_entries( + stores: &RuntimeStoreIds, + environment: &EnvConfig, + staging: bool, +) -> Vec<(String, String)> { + let mut entries = Vec::new(); + for (kind, ids) in [ + ("config", &stores.config), + ("kv", &stores.kv), + ("secrets", &stores.secrets), + ] { + for id in ids { + let platform_name = environment.store_name(kind, id); + if platform_name != *id { + entries.push(( + canonical_runtime_store_name_key(&kind.to_ascii_uppercase(), id), + platform_name, + )); + } + if kind == "config" { + let key = if staging { + format!("{id}_staging") + } else { + environment.store_key(kind, id) + }; + if key != *id { + entries.push((canonical_runtime_env_key_for(id), key)); + } + } + } + } + entries.sort_by(|left, right| left.0.cmp(&right.0)); + entries +} + +fn runtime_store_reconciliation( + stores: &RuntimeStoreIds, + environment: &EnvConfig, + current: &[(String, String)], +) -> RuntimeStoreNameReconciliation { + let desired = runtime_store_entries(stores, environment, false); + let managed = runtime_store_keys(stores); + runtime_entries_reconciliation(&desired, &managed, current) +} + +fn runtime_entries_reconciliation( + desired: &[(String, String)], + managed: &[String], + current: &[(String, String)], +) -> RuntimeStoreNameReconciliation { + 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, _)| { + managed.iter().any(|managed_key| managed_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 } +} + /// Compute the staging selector store's entries from production's, given the /// declared config-store logical ids. /// /// 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 +/// overrides, with declared store names selected from the staging deployment +/// environment and every declared config selector pointed 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 @@ -3578,27 +3688,17 @@ fn delete_config_store_entry_in(store_id: &str, key: &str, cwd: &Path) -> Result /// 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], + stores: &RuntimeStoreIds, + environment: &EnvConfig, ) -> Vec<(String, String)> { - 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(service_id, id), format!("{id}_staging"))) - .collect(); - let is_selector = |key: &str| selectors.iter().any(|(selector, _)| selector == key); - - // 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 managed = runtime_store_keys(stores); let mut out: Vec<(String, String)> = production .iter() - .filter(|(key, _)| key.starts_with(&service_prefix) && !is_selector(key)) + .filter(|(key, _)| !managed.iter().any(|managed_key| managed_key == key)) .cloned() .collect(); - out.extend(selectors); + out.extend(runtime_store_entries(stores, environment, true)); + out.sort_by(|left, right| left.0.cmp(&right.0)); out } @@ -3606,9 +3706,7 @@ fn staging_entries_from_production( /// this store end to end (it is never linked on the ACTIVE version), so it does /// not depend on `provision` having created it first. Fails closed on a lookup /// FAILURE rather than blindly creating a duplicate. -/// The per-service staging twin store name — the base prefix plus the service -/// id, so concurrent staged deploys of different services on one account never -/// clobber each other's selectors. +/// The per-service staging selector store name. fn staging_selector_store_name(service_id: &str) -> String { format!("{RUNTIME_ENV_STAGING_STORE_PREFIX}_{service_id}") } @@ -3639,8 +3737,9 @@ fn ensure_staging_selector_store(store_name: &str, cwd: &Path) -> Result_staging`. +/// Reconcile the staging twin so it keeps unrelated production runtime settings +/// while applying the staging deployment environment's declared store names and +/// redirecting config selectors 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 @@ -3648,26 +3747,17 @@ fn ensure_staging_selector_store(store_name: &str, cwd: &Path) -> Result Result<(), String> { - let desired = staging_entries_from_production(production, service_id, config_logical_ids); + let desired = staging_entries_from_production(production, stores, environment); for (key, value) in &desired { create_config_store_entry_in(staging_id, key, value, cwd)?; @@ -3688,180 +3778,41 @@ fn canonical_runtime_store_name_key(kind: &str, logical: &str) -> String { ) } -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, +fn reconcile_deploy_runtime_env( + stores: &RuntimeStoreIds, + environment: &EnvConfig, 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); +) -> Result<(), String> { + if stores.config.is_empty() && stores.kv.is_empty() && stores.secrets.is_empty() { + return Ok(()); } - - let Some(runtime_env_store_id) = - resolve_remote_config_store_id_in(RUNTIME_ENV_STORE_NAME, cwd)? + let desired = runtime_store_entries(stores, environment, false); + let Some(runtime_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" - )]); + if desired.is_empty() { + return Ok(()); } 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" + "fastly deploy activated the service, but `{RUNTIME_ENV_STORE_NAME}` was not found; run `edgezero provision --adapter fastly` and retry so canonical `EDGEZERO__STORES__*` selectors can be applied" )); }; - 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()); + let current = read_config_store_entries(&runtime_store_id, cwd)?; + let reconciliation = runtime_store_reconciliation(stores, environment, ¤t); + for (key, value) in &reconciliation.upserts { + create_config_store_entry_in(&runtime_store_id, key, value, cwd).map_err(|error| { + format!( + "fastly deploy activated the service, but failed to apply runtime selector `{key}`: {error}. Re-run the same deploy; selector writes are idempotent" + ) + })?; } - - 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| { + delete_config_store_entry_in(&runtime_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}" + "fastly deploy activated the service, but failed to remove stale runtime selector `{key}`: {error}. Re-run the same deploy to finish reconciliation" ) })?; } - 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() - )]) + Ok(()) } fn canonical_runtime_env_key_for(logical_id: &str) -> String { @@ -3871,13 +3822,6 @@ fn canonical_runtime_env_key_for(logical_id: &str) -> String { ) } -/// 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. /// @@ -3904,6 +3848,47 @@ fn find_resource_link_id(stdout: &str, link_name: &str) -> Option { }) } +/// Parse the resource links attached to one service version, keyed by the alias +/// the Compute runtime opens. Staging uses both the link id (to replace a stale +/// alias) and the resource id (to avoid rewriting an already-correct link). +fn resource_links_by_name(stdout: &str) -> Result, String> { + let parsed: serde_json::Value = serde_json::from_str(stdout).map_err(|error| { + format!("failed to parse `fastly resource-link list --json` output: {error}") + })?; + let array = parsed + .as_array() + .or_else(|| parsed.get("items").and_then(serde_json::Value::as_array)) + .ok_or_else(|| { + "`fastly resource-link list --json` output is neither a bare array nor an `items` envelope" + .to_owned() + })?; + let mut links = BTreeMap::new(); + for (index, entry) in array.iter().enumerate() { + let field = |name| { + entry + .get(name) + .and_then(serde_json::Value::as_str) + .filter(|value| !value.is_empty()) + }; + let (Some(name), Some(id), Some(resource_id)) = + (field("name"), field("id"), field("resource_id")) + else { + return Err(format!( + "resource-link entry #{index} is missing a non-empty string `name`, `id`, or `resource_id`" + )); + }; + if links + .insert(name.to_owned(), (id.to_owned(), resource_id.to_owned())) + .is_some() + { + return Err(format!( + "resource-link list contains duplicate alias `{name}`; refusing to modify an ambiguous staged version" + )); + } + } + Ok(links) +} + /// Parse `fastly config-store list --json` output and return the /// platform `id` of the store whose `name` matches `name`. Accepts /// both a bare array (`[ {"id": "...", "name": "..."}, ... ]`) @@ -4055,11 +4040,35 @@ fn resolve_remote_config_store_id_with_cwd( } } -/// Look a config store up by name and return the raw [`ConfigStoreLookup`], so -/// callers can tell "the account has no such store" (`NotFound`) apart from "the -/// lookup itself failed" (`Err` — CLI missing / non-zero exit — or -/// `SchemaDrift`). A staged deploy relies on that distinction to decide whether -/// to skip config isolation (genuinely no store) or fail closed (couldn't tell). +fn resolve_remote_store_id_in( + cli_store_kind: &str, + store_name: &str, + cwd: &Path, +) -> Result { + let output = run_fastly_capture( + &[ + cli_store_kind.to_owned(), + "list".to_owned(), + "--json".to_owned(), + ], + cwd, + )?; + match find_config_store_id(&output, store_name) { + ConfigStoreLookup::Found(id) => Ok(id), + ConfigStoreLookup::NotFound => Err(format!( + "selected Fastly {cli_store_kind} `{store_name}` does not exist; provision it before staging" + )), + ConfigStoreLookup::SchemaDrift(detail) => Err(format!( + "could not parse `fastly {cli_store_kind} list --json` while resolving selected store `{store_name}`: {detail}" + )), + } +} + +/// Look a config store up by name and return the raw [`ConfigStoreLookup`], so +/// callers can tell "the account has no such store" (`NotFound`) apart from "the +/// lookup itself failed" (`Err` — CLI missing / non-zero exit — or +/// `SchemaDrift`). A staged deploy relies on that distinction to decide whether +/// to skip config isolation (genuinely no store) or fail closed (couldn't tell). /// /// `Err` is only for a failure to OBTAIN an answer; a successful listing that /// simply doesn't contain `name` is `Ok(ConfigStoreLookup::NotFound)`. @@ -4173,19 +4182,27 @@ fn build_compute_deploy_args(extra_args: &[String]) -> Vec { argv } -/// # Errors -/// Returns an error if the Fastly CLI deploy command fails. +/// Legacy direct entry point for callers using [`AdapterAction::Deploy`]. +/// `EdgeZero`'s main deploy path uses the typed [`Adapter::deploy`] hook. /// -/// Honours a CLI-threaded `--manifest-path ` (see -/// [`resolve_manifest_dir`]) so a monorepo with several Fastly apps -/// deploys the one the operator's `edgezero.toml` selected, rather than -/// whichever `fastly.toml` a bare working-directory search finds first. -/// The flag is EdgeZero-internal — `fastly compute deploy` has no such -/// flag — so it is stripped from the forwarded argv. +/// # Errors +/// Returns an error when the Fastly CLI cannot deploy the package. #[inline] pub fn deploy(extra_args: &[String]) -> Result<(), String> { - let manifest_dir = resolve_manifest_dir(extra_args)?; - let forwarded = args_without_flag_value(extra_args, "--manifest-path"); + let context = legacy_deploy_context(extra_args, false); + deploy_with_context(&context, extra_args) +} + +fn deploy_with_context( + context: &AdapterDeployContext, + extra_args: &[String], +) -> Result<(), String> { + let manifest_dir = resolve_deploy_manifest_dir(context)?; + let without_manifest = args_without_flag_value(extra_args, "--manifest-path"); + let mut forwarded = args_without_flag_value(&without_manifest, "--service-id"); + if let Some(service_id) = context.service_id.as_deref() { + forwarded.extend(["--service-id".to_owned(), service_id.to_owned()]); + } let status = Command::new("fastly") .args(build_compute_deploy_args(&forwarded)) @@ -4902,21 +4919,20 @@ fn curl_quote(value: &str) -> String { } /// Validate an operator-supplied Fastly service id before it is -/// interpolated into an API URL or runtime-env key. Fastly service ids are -/// opaque alphanumeric handles, so constrain them to `^[A-Za-z0-9]+$`. +/// interpolated into an API URL. Fastly service ids are opaque 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.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()) { + if !id.is_empty() + && id + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-') + { Ok(()) } else { Err(format!( - "invalid service id {id:?}: expected only ASCII letters or digits" + "invalid service id {id:?}: expected only ASCII letters, digits, `_`, or `-`" )) } } @@ -5010,26 +5026,29 @@ fn fastly_api_put(path: &str, token: &str) -> Result { } } -/// Resolve the directory containing the Fastly manifest for a deploy -/// (production [`deploy`] or [`deploy_staged`]). -/// -/// The CLI (`edgezero_cli::run_deploy`) resolves the `edgezero.toml` -/// manifest — honouring `EDGEZERO_MANIFEST` — and threads the -/// manifest-configured `[adapters.fastly.adapter].manifest` path in as -/// `--manifest-path `. Prefer that so a monorepo with -/// multiple Fastly apps deploys/stages the app the operator actually -/// selected, rather than whichever `fastly.toml` a bare working-directory -/// search happens to find first. Only when no `--manifest-path` is -/// threaded (e.g. a manifest that declares Fastly commands but no adapter -/// `manifest` key) do we fall back to the working-directory search. -fn resolve_manifest_dir(args: &[String]) -> Result { - if let Some(raw) = arg_value(args, "--manifest-path") { - let path = PathBuf::from(raw); +fn legacy_deploy_context(args: &[String], staging: bool) -> AdapterDeployContext { + AdapterDeployContext { + adapter_manifest_path: arg_value(args, "--manifest-path").map(PathBuf::from), + service_id: arg_value(args, "--service-id").map(str::to_owned), + stores: DeployStoreIds::default(), + staging, + } +} + +/// Resolve the directory containing the Fastly manifest selected by the +/// application manifest. Fall back to discovery for direct adapter callers. +fn resolve_deploy_manifest_dir(context: &AdapterDeployContext) -> Result { + if let Some(path) = context.adapter_manifest_path.as_deref() { return path .parent() .filter(|parent| !parent.as_os_str().is_empty()) .map(Path::to_path_buf) - .ok_or_else(|| format!("fastly manifest path {raw:?} has no parent directory")); + .ok_or_else(|| { + format!( + "fastly manifest path {} has no parent directory", + path.display() + ) + }); } let manifest = find_fastly_manifest(env::current_dir().map_err(|err| err.to_string())?.as_path())?; @@ -5043,40 +5062,33 @@ fn resolve_manifest_dir(args: &[String]) -> Result { /// build, upload to a new draft version (no activation), stage it, and /// emit `version=`. fn deploy_staged(args: &[String]) -> Result<(), String> { - let service_id = resolve_service_id(args)?; + let context = legacy_deploy_context(args, true); + deploy_staged_with_context(&context, args) +} + +fn deploy_staged_with_context( + context: &AdapterDeployContext, + args: &[String], +) -> Result<(), String> { + let service_id = context + .service_id + .clone() + .map_or_else(|| resolve_service_id(&[]), Ok)?; validate_service_id(&service_id)?; // The Fastly CLI reads FASTLY_API_TOKEN from the env; fail fast // with a clear message when it's missing rather than deep in a // `fastly compute update` error. require_token()?; - let manifest_dir_buf = resolve_manifest_dir(args)?; + let manifest_dir_buf = resolve_deploy_manifest_dir(context)?; let manifest_dir = manifest_dir_buf.as_path(); - // The CLI threads the app's declared config-store logical ids as - // `--edgezero-staging-config=` (one per store) so the staging relink - // knows which selectors to redirect — read from the app manifest, never a - // remote probe. These are EdgeZero-internal inline tokens; strip them so they - // never reach `fastly compute update`. - let config_logical_ids: Vec = args - .iter() - .filter_map(|arg| { - arg.strip_prefix("--edgezero-staging-config=") - .map(str::to_owned) - }) - .collect(); - let deploy_args: Vec = args - .iter() - .filter(|arg| !arg.starts_with("--edgezero-staging-config=")) - .cloned() - .collect(); - // Strip both the explicitly-threaded `--service-id` and the - // CLI-injected `--manifest-path` (which `fastly compute update` - // doesn't understand), then keep only the passthrough flags + let runtime_stores = RuntimeStoreIds::from(&context.stores); + // Strip legacy direct-call context flags, then keep only the passthrough flags // `compute update` actually supports. `--comment` in particular is // NOT a `compute update` flag — it is lifted out here and applied to // the version below. let extra = args_without_flag_value( - &args_without_flag_value(&deploy_args, "--service-id"), + &args_without_flag_value(args, "--service-id"), "--manifest-path", ); let passthrough = split_staged_passthrough(&extra); @@ -5146,9 +5158,17 @@ fn deploy_staged(args: &[String]) -> Result<(), String> { } // 4. Point the draft's runtime-override link at the STAGING selector store, - // so this version reads staged config and production keeps reading its - // own. Done while the version is still an editable draft. - relink_runtime_env_for_staging(&service_id, version, &config_logical_ids, manifest_dir)?; + // so this version reads the staging environment's store names and config + // key while production keeps its own. Done while the version is still an + // editable draft. + let environment = EnvConfig::from_env(); + relink_runtime_env_for_staging( + &service_id, + version, + &runtime_stores, + &environment, + manifest_dir, + )?; // 5. Mark the draft version staged (no activation). run_fastly_status( @@ -5167,7 +5187,7 @@ fn deploy_staged(args: &[String]) -> Result<(), String> { } /// Point a staged draft's `edgezero_runtime_env` link at the STAGING selector -/// store, so the staged version reads staged config. +/// store, so the staged version reads the staging environment's selectors. /// /// Why this exists: `compute update --autoclone --version=active` clones the /// active version, and a clone inherits its resource links. Without this, a @@ -5185,15 +5205,15 @@ fn deploy_staged(args: &[String]) -> Result<(), String> { fn relink_runtime_env_for_staging( service_id: &str, version: u64, - config_logical_ids: &[String], + stores: &RuntimeStoreIds, + environment: &EnvConfig, manifest_dir: &Path, ) -> Result<(), String> { - // An app that declares no config stores has no selector to isolate, so - // staging is still perfectly meaningful for it (staged CODE, no config): the - // draft keeps the inherited production link and this is a no-op. - if config_logical_ids.is_empty() { + // An app that declares no stores has no runtime selector to isolate, so the + // draft can keep the inherited production link. + if stores.config.is_empty() && stores.kv.is_empty() && stores.secrets.is_empty() { log::info!( - "app declares no config stores, so staged version {version} has no config selector to isolate; keeping the inherited runtime-env link" + "app declares no stores, so staged version {version} has no runtime selector to isolate; keeping the inherited runtime-env link" ); return Ok(()); } @@ -5216,7 +5236,7 @@ fn relink_runtime_env_for_staging( } }; - // Mirror production's runtime overrides into the PER-SERVICE staging twin, + // Mirror production's runtime overrides into the per-service staging twin, // overriding only the config selectors to `_staging`, then point // THIS draft at the twin. Create the twin on demand so a staged deploy never // depends on a prior provision having created it. @@ -5225,8 +5245,8 @@ fn relink_runtime_env_for_staging( mirror_production_to_staging( &production, &staging_store_id, - service_id, - config_logical_ids, + stores, + environment, manifest_dir, )?; @@ -5242,6 +5262,14 @@ fn relink_runtime_env_for_staging( ], manifest_dir, )?; + reconcile_staging_store_links( + service_id, + version, + stores, + environment, + &existing, + manifest_dir, + )?; if let Some(link_id) = find_resource_link_id(&existing, RUNTIME_ENV_STORE_NAME) { run_fastly_status( &[ @@ -5274,6 +5302,77 @@ fn relink_runtime_env_for_staging( Ok(()) } +/// Attach every physical store selected by the staging environment to the +/// staged service version under the same name the runtime will open. +fn reconcile_staging_store_links( + service_id: &str, + version: u64, + stores: &RuntimeStoreIds, + environment: &EnvConfig, + existing_json: &str, + manifest_dir: &Path, +) -> Result<(), String> { + let mut selected = BTreeMap::::new(); + for (runtime_kind, cli_store_kind, logical_ids) in [ + ("config", "config-store", &stores.config), + ("kv", "kv-store", &stores.kv), + ("secrets", "secret-store", &stores.secrets), + ] { + for logical_id in logical_ids { + let physical_name = environment.store_name(runtime_kind, logical_id); + if physical_name == RUNTIME_ENV_STORE_NAME { + return Err(format!( + "selected {runtime_kind} store for `{logical_id}` cannot use the reserved Fastly resource-link name `{RUNTIME_ENV_STORE_NAME}`" + )); + } + let resource_id = + resolve_remote_store_id_in(cli_store_kind, &physical_name, manifest_dir)?; + if let Some((other_kind, other_resource_id)) = selected.get(&physical_name) { + if other_resource_id != &resource_id { + return Err(format!( + "selected Fastly stores `{other_kind}` and `{runtime_kind}` both require resource-link alias `{physical_name}` but resolve to different resources" + )); + } + continue; + } + selected.insert(physical_name, (runtime_kind.to_owned(), resource_id)); + } + } + + let existing = resource_links_by_name(existing_json).map_err(|error| { + format!("cannot reconcile selected stores for staged version {version}: {error}") + })?; + for (physical_name, (_, resource_id)) in selected { + if let Some((link_id, linked_resource_id)) = existing.get(&physical_name) { + if linked_resource_id == &resource_id { + continue; + } + run_fastly_status( + &[ + "resource-link".to_owned(), + "delete".to_owned(), + format!("--service-id={service_id}"), + format!("--version={version}"), + format!("--id={link_id}"), + ], + manifest_dir, + )?; + } + run_fastly_status( + &[ + "resource-link".to_owned(), + "create".to_owned(), + format!("--service-id={service_id}"), + format!("--version={version}"), + format!("--resource-id={resource_id}"), + format!("--name={physical_name}"), + ], + manifest_dir, + )?; + } + Ok(()) +} + /// Production companion to `deploy`: resolve the active service version via the /// Fastly API and emit it as a `version=` line. /// @@ -5291,11 +5390,13 @@ fn relink_runtime_env_for_staging( fn emit_active_version(args: &[String]) -> Result<(), String> { let service_id = resolve_service_id(args)?; validate_service_id(&service_id)?; + emit_active_version_for(&service_id, arg_flag(args, "--require-active")) +} + +fn emit_active_version_for(service_id: &str, require_active: bool) -> Result<(), String> { let token = require_token()?; let json = fastly_api_get(&format!("/service/{service_id}/version"), &token)?; - if let Some(version) = - active_version_or_require(&json, arg_flag(args, "--require-active"), &service_id)? - { + if let Some(version) = active_version_or_require(&json, require_active, service_id)? { log::info!("version={version}"); } else { // Confirmed no active version (first-ever deploy), and it was not @@ -5636,18 +5737,13 @@ mod tests { } #[test] - fn resolve_manifest_dir_prefers_manifest_path_flag() { - // When the CLI threads `--manifest-path `, the - // deploy (production AND staged) must use its parent directory - // rather than a bare working-directory search (which in a - // monorepo could pick a different app's fastly.toml). - let args = vec![ - "--service-id".to_owned(), - "SVC1".to_owned(), - "--manifest-path".to_owned(), - "/repo/apps/edge/fastly.toml".to_owned(), - ]; - let dir = resolve_manifest_dir(&args).expect("resolves from --manifest-path"); + fn resolve_deploy_manifest_dir_prefers_typed_manifest_path() { + let context = AdapterDeployContext { + adapter_manifest_path: Some(PathBuf::from("/repo/apps/edge/fastly.toml")), + service_id: Some("SVC1".to_owned()), + ..AdapterDeployContext::default() + }; + let dir = resolve_deploy_manifest_dir(&context).expect("resolves from typed manifest path"); assert_eq!(dir, PathBuf::from("/repo/apps/edge")); } @@ -5895,19 +5991,10 @@ mod tests { } #[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}" - ); + fn validate_service_id_accepts_opaque_handle_punctuation() { + validate_service_id("SVC1_").expect("underscore"); + validate_service_id("SVC-1").expect("hyphen"); + validate_service_id("SVC__OTHER").expect("double underscore has no key semantics"); } #[test] @@ -6976,388 +7063,83 @@ build = \"cargo build --release\" let path = dir.path().join("fastly.toml"); 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]); - let stores = ProvisionStores { - config: &config_ids, - kv: &kv_ids, - secrets: &secret_ids, - }; - let out = FastlyCliAdapter - .provision(dir.path(), Some("fastly.toml"), None, &stores, true) - .expect("dry-run succeeds"); - // 1 KV + 1 config + 1 secret + 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`")); - assert!( - 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")), - "provision must NOT create the staging twin (a staged deploy owns it): {out:?}", - ); - // Manifest untouched. - let after = fs::read_to_string(&path).expect("read"); - assert_eq!( - after, "name = \"demo\"\nservice_id = \"SVC1\"\n", - "dry-run mutated fastly.toml" - ); - } - - #[test] - fn provision_dry_run_reports_non_default_store_name_mapping() { - let dir = tempdir().expect("tempdir"); - 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: &[], - 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(), Some("fastly.toml"), None, &stores, true) - .expect_err("a non-default mapping needs an unambiguous service namespace"); - - assert!( - 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_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"); - fs::write( - &path, - "[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, - 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("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 config_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_CONFIG_ID]); + let secret_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_SECRET_ID]); let stores = ProvisionStores { - config: &[], - kv: &kv, - secrets: &[], + config: &config_ids, + kv: &kv_ids, + secrets: &secret_ids, }; - 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}" - ); + 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. Runtime + // selectors belong to deploy, and the staging twin is also created by a + // staged deploy, so neither appears here. + assert_eq!(out.len(), 4, "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`")); assert!( - err.contains("edgezero provision --adapter fastly"), - "recovery names the command to retry: {err}" + out[3].contains("would run `fastly config-store create --name=edgezero_runtime_env`"), + "runtime-env store row: {out:?}", ); assert!( - !err.contains("config push"), - "wrong command is not recommended: {err}" + !out.iter() + .any(|row| row.contains("edgezero_runtime_env_staging")), + "provision must NOT create the staging twin (a staged deploy owns it): {out:?}", ); - assert!( - !err.contains("chunk") && !err.contains("root pointer"), - "mapping recovery contains no blob-specific guidance: {err}" + // Manifest untouched. + let after = fs::read_to_string(&path).expect("read"); + assert_eq!( + after, "name = \"demo\"\nservice_id = \"SVC1\"\n", + "dry-run mutated fastly.toml" ); } #[cfg(unix)] #[test] - fn provision_delete_failure_recommends_provision_recovery() { + fn provision_creates_declared_store_in_fastly_manifest_directory() { 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 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: &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 fake = fake_fastly_runtime_mapping(&[], &oplog); let _path = PathPrepend::new(fake.path()); - let err = FastlyCliAdapter - .provision(dir.path(), Some("fastly.toml"), None, &stores, false) - .expect_err("stale mapping delete fails"); + FastlyCliAdapter + .provision( + dir.path(), + Some("adapters/fastly/fastly.toml"), + None, + &stores, + false, + ) + .expect("provision succeeds"); - assert!(err.contains("UNKNOWN"), "delete outcome is explicit: {err}"); + let log = fs::read_to_string(&oplog).expect("oplog"); + let manifest_dir = fs::canonicalize(&adapter_dir).expect("canonical manifest dir"); assert!( - err.contains("edgezero provision --adapter fastly") && err.contains("idempotent"), - "recovery names the safe retry: {err}" + log.contains(&format!( + "kv-store-create name=--name=sessions cwd={}", + manifest_dir.display() + )), + "declared store creation uses the Fastly manifest directory: {log}" ); - 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}" + fs::read_to_string(path) + .expect("manifest") + .contains("[setup.kv_stores.sessions]"), + "declared store setup block is written" ); } @@ -7405,8 +7187,8 @@ build = \"cargo build --release\" #[cfg(unix)] #[test] 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. + // Re-running provision skips resource creation. Runtime selectors are + // applied by deploy, so provision performs no remote lookup here. let _lock = path_mutation_guard().lock().expect("guard"); let dir = tempdir().expect("tempdir"); let path = dir.path().join("fastly.toml"); @@ -7432,34 +7214,9 @@ build = \"cargo build --release\" .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}" + !oplog.exists(), + "provision must not inspect or mutate runtime selectors" ); } @@ -7482,11 +7239,12 @@ build = \"cargo build --release\" let dir = tempdir().expect("tempdir"); let path = dir.path().join("fastly.toml"); fs::write(&path, "name = \"demo\"\nservice_id = \"abc123svc\"\n").expect("write"); - let note = resource_link_note(&path, "config", "edgezero_runtime_env") - .expect("read service_id") + let selected = select_fastly_service_id(Some("abc123svc".to_owned()), None) + .expect("select service id"); + let note = resource_link_note(selected.as_ref(), "config", "edgezero_runtime_env") .expect("note present when service_id set"); assert!( - note.contains("service id resolves to `abc123svc`"), + note.contains("service_id = \"abc123svc\""), "note quotes the service id: {note}" ); assert!( @@ -7512,17 +7270,40 @@ build = \"cargo build --release\" /// guidance. #[test] fn provision_skips_resource_link_note_when_service_undeployed() { - let dir = tempdir().expect("tempdir"); - let path = dir.path().join("fastly.toml"); - fs::write(&path, "name = \"demo\"\n").expect("write"); - let note = - resource_link_note(&path, "config", "edgezero_runtime_env").expect("read service_id"); + let note = resource_link_note(None, "config", "edgezero_runtime_env"); assert!( note.is_none(), "no service_id => no resource-link prompt: {note:?}" ); } + #[test] + fn provision_uses_fastly_service_id_environment_fallback_for_link_note() { + let selected = select_fastly_service_id(None, Some("env-service".to_owned())) + .expect("select environment service id"); + let note = resource_link_note(selected.as_ref(), "secret", "trusted_server_secrets") + .expect("environment service produces a link note"); + assert!( + note.contains("`FASTLY_SERVICE_ID` selects service `env-service`") + && note.contains("--service-id=env-service") + && note.contains("secret-store list --json"), + "environment-selected service is used consistently: {note}" + ); + } + + #[test] + fn provision_rejects_conflicting_manifest_and_environment_service_ids() { + let err = select_fastly_service_id( + Some("manifest-service".to_owned()), + Some("environment-service".to_owned()), + ) + .expect_err("conflicting service ids must fail before provisioning"); + assert!( + err.contains("conflicts with FASTLY_SERVICE_ID"), + "conflict names both selectors: {err}" + ); + } + // ---------- find_config_store_id ---------- #[test] @@ -8026,6 +7807,7 @@ build = \"cargo build --release\" let script = format!( r#"#!/bin/sh +if [ "$1" = "compute" ] && [ "$2" = "deploy" ]; then printf 'compute-deploy cwd=%s\n' "$PWD" >> '{oplog}'; exit 0; fi 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 @@ -9718,16 +9500,20 @@ echo 'unexpected' >&2; exit 1 if [ \"$1\" = \"compute\" ] && [ \"$2\" = \"update\" ]; then\n \ 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\ + printf '%s\\n' '[{{\"id\":\"ENVSEL1\",\"name\":\"edgezero_runtime_env\"}},{{\"id\":\"STAGEID1\",\"name\":\"edgezero_runtime_env_staging_SVC1\"}},{{\"id\":\"CFGDEFAULT\",\"name\":\"app_config\"}},{{\"id\":\"CFGSTAGE\",\"name\":\"staging_config\"}}]'\n\ + elif [ \"$1\" = \"kv-store\" ] && [ \"$2\" = \"list\" ]; then\n \ + printf '%s\\n' '[{{\"id\":\"KVDEFAULT\",\"name\":\"sessions\"}},{{\"id\":\"KVSTAGE\",\"name\":\"staging_sessions\"}}]'\n\ + elif [ \"$1\" = \"secret-store\" ] && [ \"$2\" = \"list\" ]; then\n \ + printf '%s\\n' '[{{\"id\":\"SECRETDEFAULT\",\"name\":\"default\"}},{{\"id\":\"SECRETSTAGE\",\"name\":\"ambient_secrets\"}}]'\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__SERVICES__SVC1__LOGGING__LEVEL\",\"item_value\":\"debug\"}}]' ;;\n \ + *--store-id=ENVSEL1*) printf '%s\\n' '[{{\"item_key\":\"EDGEZERO__LOGGING__LEVEL\",\"item_value\":\"debug\"}}]' ;;\n \ *) printf '%s\\n' '[]' ;;\n \ esac\n\ elif [ \"$1\" = \"resource-link\" ] && [ \"$2\" = \"list\" ]; then\n \ - printf '%s\\n' '[{{\"id\":\"LINK1\",\"name\":\"edgezero_runtime_env\"}}]'\n\ + printf '%s\\n' '[{{\"id\":\"LINK1\",\"name\":\"edgezero_runtime_env\",\"resource_id\":\"ENVSEL1\"}},{{\"id\":\"LINKCFG\",\"name\":\"app_config\",\"resource_id\":\"CFGDEFAULT\"}},{{\"id\":\"LINKKV\",\"name\":\"sessions\",\"resource_id\":\"KVDEFAULT\"}},{{\"id\":\"LINKSECRET\",\"name\":\"default\",\"resource_id\":\"SECRETDEFAULT\"}}]'\n\ fi\n\ exit 0\n", record = record.display(), @@ -9746,14 +9532,15 @@ echo 'unexpected' >&2; exit 1 update_stdout: &str, extra: &[&str], ) -> (Result<(), String>, Vec) { - run_deploy_staged_with_fake_and_env(update_stdout, extra, None) + run_deploy_staged_with_fake_and_env(update_stdout, extra, DeployStoreIds::default(), &[]) } #[cfg(unix)] fn run_deploy_staged_with_fake_and_env( update_stdout: &str, extra: &[&str], - store_name_override: Option<(&str, &str)>, + stores: DeployStoreIds, + store_name_overrides: &[(&str, &str)], ) -> (Result<(), String>, Vec) { let _lock = path_mutation_guard().lock().expect("guard"); let (fake, record) = fake_fastly_recorder(update_stdout); @@ -9765,16 +9552,19 @@ echo 'unexpected' >&2; exit 1 // 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(), - "--manifest-path".to_owned(), - manifest.display().to_string(), - ]; + let _store_name_overrides = store_name_overrides + .iter() + .map(|(key, value)| EnvOverride::set(key, value)) + .collect::>(); + let mut args = Vec::new(); args.extend(extra.iter().map(|arg| (*arg).to_owned())); - let result = deploy_staged(&args); + let context = AdapterDeployContext { + adapter_manifest_path: Some(manifest), + service_id: Some("SVC1".to_owned()), + stores, + staging: true, + }; + let result = deploy_staged_with_context(&context, &args); let recorded = fs::read_to_string(&record).unwrap_or_default(); let lines = recorded.lines().map(str::to_owned).collect(); @@ -11063,34 +10853,7 @@ echo 'unexpected' >&2; exit 1 } #[test] - 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, - }; - - let entries = runtime_env_store_name_entries(&stores, "SVCA"); - assert_eq!( - 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() { + fn runtime_dictionary_reads_canonical_keys_without_a_service_namespace() { let stores = StoresMetadata { config: Some(StoreMetadata { default: "app_config", @@ -11102,122 +10865,98 @@ echo 'unexpected' >&2; exit 1 }), 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__KV__OTHER__NAME".to_owned(), + "unrelated_sessions".to_owned(), ), ( "EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME".to_owned(), - "legacy_config".to_owned(), + "selected_config".to_owned(), ), ( "EDGEZERO__STORES__KV__SESSIONS__NAME".to_owned(), - "legacy_sessions".to_owned(), + "selected_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 vars = crate::runtime_env_vars(stores, |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"); - - 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!( - default_service_env.store_name("config", "app_config"), - "app_config" - ); + assert_eq!(env.store_name("kv", "sessions"), "selected_sessions"); + assert_eq!(env.store_name("config", "app_config"), "selected_config"); + assert_ne!(env.store_name("kv", "sessions"), "unrelated_sessions"); } #[test] - fn runtime_env_key_is_scoped_for_the_runtime_reader() { + fn runtime_env_key_is_canonical_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. + fn staging_entries_use_selected_environment_for_all_store_kinds() { let production = vec![ + ("EDGEZERO__LOGGING__LEVEL".to_owned(), "debug".to_owned()), ( - "EDGEZERO__ADAPTER__FASTLY__LOG_LEVEL".to_owned(), - "debug".to_owned(), + "EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY".to_owned(), + "custom_prod_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(), + "production_config".to_owned(), + ), + ]; + let stores = RuntimeStoreIds { + config: vec!["app_config".to_owned(), "feature_flags".to_owned()], + kv: vec!["sessions".to_owned()], + secrets: vec!["default".to_owned()], + }; + let environment = EnvConfig::from_vars([ + ( + "EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME", + "staging_config", ), + ("EDGEZERO__STORES__KV__SESSIONS__NAME", "shared_sessions"), ( - "EDGEZERO__SERVICES__SVC1__STORES__CONFIG__APP_CONFIG__NAME".to_owned(), - "app_config".to_owned(), + "EDGEZERO__STORES__SECRETS__DEFAULT__NAME", + "staging_secrets", ), ( - "EDGEZERO__SERVICES__SVC2__STORES__SECRETS__DEFAULT__NAME".to_owned(), - "other_service_secrets".to_owned(), + "EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY", + "ignored_custom_key", ), - ]; - let out = staging_entries_from_production( - &production, - "SVC1", - &["app_config".to_owned(), "feature_flags".to_owned()], - ); + ]); + let out = staging_entries_from_production(&production, &stores, &environment); - 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__LOGGING__LEVEL".to_owned(), "debug".to_owned()))); assert!(out.contains(&( - "EDGEZERO__SERVICES__SVC1__STORES__CONFIG__APP_CONFIG__NAME".to_owned(), - "app_config".to_owned() + "EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME".to_owned(), + "staging_config".to_owned() ))); assert!(out.contains(&( - "EDGEZERO__SERVICES__SVC1__STORES__CONFIG__APP_CONFIG__KEY".to_owned(), + "EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY".to_owned(), "app_config_staging".to_owned() ))); - assert!(!out.iter().any(|(_, value)| value == "custom_prod_key")); assert!(out.contains(&( - "EDGEZERO__SERVICES__SVC1__STORES__CONFIG__FEATURE_FLAGS__KEY".to_owned(), + "EDGEZERO__STORES__CONFIG__FEATURE_FLAGS__KEY".to_owned(), "feature_flags_staging".to_owned() ))); - 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__SERVICES__SVC1__STORES__CONFIG__APP_CONFIG__KEY" - }) - .count(), - 1 - ); + assert!(out.contains(&( + "EDGEZERO__STORES__KV__SESSIONS__NAME".to_owned(), + "shared_sessions".to_owned() + ))); + assert!(out.contains(&( + "EDGEZERO__STORES__SECRETS__DEFAULT__NAME".to_owned(), + "staging_secrets".to_owned() + ))); + assert!(!out.iter().any(|(_, value)| { + value == "production_config" + || value == "custom_prod_key" + || value == "ignored_custom_key" + })); } #[test] @@ -11246,24 +10985,71 @@ echo 'unexpected' >&2; exit 1 #[cfg(unix)] #[test] - fn deploy_staged_ignores_ambient_store_name_overrides() { + fn deploy_staged_materializes_declared_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(( + &[], + DeployStoreIds { + config: vec!["app_config".to_owned()], + secrets: vec!["default".to_owned()], + ..DeployStoreIds::default() + }, + &[( "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") + argv.iter().any(|line| line.starts_with( + "config-store-entry update --store-id=STAGEID1 --key=EDGEZERO__STORES__SECRETS__DEFAULT__NAME" + )), + "staging must materialize the selected environment's declared secret store: {argv:?}" + ); + assert!( + argv.iter().any(|line| { + line == "resource-link create --service-id=SVC1 --version=7 --resource-id=SECRETSTAGE --name=ambient_secrets" }), - "staging must mirror persisted production mappings, not ambient process env: {argv:?}" + "staging must link the selected secret store into the staged version: {argv:?}" + ); + } + + #[cfg(unix)] + #[test] + fn deploy_staged_links_each_differently_selected_physical_store() { + let (result, argv) = run_deploy_staged_with_fake_and_env( + "SUCCESS: Updated package (service SVC1, version 7)", + &[], + DeployStoreIds { + config: vec!["app_config".to_owned()], + kv: vec!["sessions".to_owned()], + secrets: vec!["default".to_owned()], + }, + &[ + ( + "EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME", + "staging_config", + ), + ("EDGEZERO__STORES__KV__SESSIONS__NAME", "staging_sessions"), + ( + "EDGEZERO__STORES__SECRETS__DEFAULT__NAME", + "ambient_secrets", + ), + ], ); + result.expect("staged deploy succeeds"); + + for expected in [ + "resource-link create --service-id=SVC1 --version=7 --resource-id=CFGSTAGE --name=staging_config", + "resource-link create --service-id=SVC1 --version=7 --resource-id=KVSTAGE --name=staging_sessions", + "resource-link create --service-id=SVC1 --version=7 --resource-id=SECRETSTAGE --name=ambient_secrets", + ] { + assert!( + argv.iter().any(|line| line == expected), + "missing selected-store resource link `{expected}`: {argv:?}" + ); + } } #[cfg(unix)] @@ -11272,11 +11058,16 @@ echo 'unexpected' >&2; exit 1 // The defect this closes: a clone inherits the active version's links, // so without a relink the staged version opens production's selector // store and reads PRODUCTION config -- `config push --staging` would - // write a key nothing ever reads. The CLI threads the declared config - // store as `--edgezero-staging-config=`. - let (result, argv) = run_deploy_staged_with_fake( + // write a key nothing ever reads. The CLI supplies the declared store + // through the typed deploy context. + let (result, argv) = run_deploy_staged_with_fake_and_env( "SUCCESS: Updated package (service SVC1, version 7)", - &["--edgezero-staging-config=app_config"], + &[], + DeployStoreIds { + config: vec!["app_config".to_owned()], + ..DeployStoreIds::default() + }, + &[], ); result.expect("staged deploy must succeed"); @@ -11285,13 +11076,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__SERVICES__SVC1__LOGGING__LEVEL" + "config-store-entry update --store-id=STAGEID1 --key=EDGEZERO__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__SERVICES__SVC1__STORES__CONFIG__APP_CONFIG__KEY" + "config-store-entry update --store-id=STAGEID1 --key=EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY" )), "the config selector must be written into the twin: {argv:?}" ); @@ -11344,8 +11135,7 @@ echo 'unexpected' >&2; exit 1 fn deploy_staged_works_for_an_app_that_selects_no_config() { use std::os::unix::fs::PermissionsExt as _; - // An app declaring no config stores threads no - // `--edgezero-staging-config`, so there is no selector to isolate: + // An app declaring no config stores has no selector to isolate: // staging is still meaningful (staged CODE, no config), the draft keeps // the inherited link, and no config-store lookup happens at all. let _lock = path_mutation_guard().lock().expect("guard"); @@ -11399,16 +11189,16 @@ echo 'unexpected' >&2; exit 1 : > '{marker}'\n\ elif [ \"$1\" = \"config-store\" ] && [ \"$2\" = \"list\" ]; then\n \ if [ -f '{marker}' ]; then\n \ - printf '%s\\n' '[{{\"id\":\"ENVSEL1\",\"name\":\"edgezero_runtime_env\"}},{{\"id\":\"STAGEID1\",\"name\":\"edgezero_runtime_env_staging_SVC1\"}}]'\n \ + printf '%s\\n' '[{{\"id\":\"ENVSEL1\",\"name\":\"edgezero_runtime_env\"}},{{\"id\":\"STAGEID1\",\"name\":\"edgezero_runtime_env_staging_SVC1\"}},{{\"id\":\"CFGDEFAULT\",\"name\":\"app_config\"}}]'\n \ else\n \ - printf '%s\\n' '[{{\"id\":\"ENVSEL1\",\"name\":\"edgezero_runtime_env\"}}]'\n \ + printf '%s\\n' '[{{\"id\":\"ENVSEL1\",\"name\":\"edgezero_runtime_env\"}},{{\"id\":\"CFGDEFAULT\",\"name\":\"app_config\"}}]'\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 \ - printf '%s\\n' '[{{\"id\":\"LINK1\",\"name\":\"edgezero_runtime_env\"}}]'\n\ + printf '%s\\n' '[{{\"id\":\"LINK1\",\"name\":\"edgezero_runtime_env\",\"resource_id\":\"ENVSEL1\"}},{{\"id\":\"LINKCFG\",\"name\":\"app_config\",\"resource_id\":\"CFGDEFAULT\"}}]'\n\ fi\n\ exit 0\n", record = record.display(), @@ -11424,14 +11214,17 @@ echo 'unexpected' >&2; exit 1 fs::write(app.path().join("fastly.toml"), "name = \"app\"\n").expect("write fastly.toml"); let _token = EnvOverride::set(FASTLY_API_TOKEN_ENV, "test-token"); - deploy_staged(&[ - "--service-id".to_owned(), - "SVC1".to_owned(), - "--manifest-path".to_owned(), - app.path().join("fastly.toml").display().to_string(), - "--edgezero-staging-config=app_config".to_owned(), - ]) - .expect("staged deploy must auto-create the twin and succeed"); + let context = AdapterDeployContext { + adapter_manifest_path: Some(app.path().join("fastly.toml")), + service_id: Some("SVC1".to_owned()), + stores: DeployStoreIds { + config: vec!["app_config".to_owned()], + ..DeployStoreIds::default() + }, + staging: true, + }; + deploy_staged_with_context(&context, &[]) + .expect("staged deploy must auto-create the twin and succeed"); let argv = fs::read_to_string(&record).unwrap_or_default(); assert!( @@ -11467,16 +11260,16 @@ echo 'unexpected' >&2; exit 1 : > '{marker}'\n\ elif [ \"$1\" = \"config-store\" ] && [ \"$2\" = \"list\" ]; then\n \ if [ -f '{marker}' ]; then\n \ - printf '%s\\n' '[{{\"id\":\"STAGEID1\",\"name\":\"edgezero_runtime_env_staging_SVC1\"}}]'\n \ + printf '%s\\n' '[{{\"id\":\"STAGEID1\",\"name\":\"edgezero_runtime_env_staging_SVC1\"}},{{\"id\":\"CFGDEFAULT\",\"name\":\"app_config\"}}]'\n \ else\n \ - printf '%s\\n' '[]'\n \ + printf '%s\\n' '[{{\"id\":\"CFGDEFAULT\",\"name\":\"app_config\"}}]'\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 \ - printf '%s\\n' '[]'\n\ + printf '%s\\n' '[{{\"id\":\"LINKCFG\",\"name\":\"app_config\",\"resource_id\":\"CFGDEFAULT\"}}]'\n\ fi\n\ exit 0\n", record = record.display(), @@ -11492,19 +11285,22 @@ echo 'unexpected' >&2; exit 1 fs::write(app.path().join("fastly.toml"), "name = \"app\"\n").expect("write fastly.toml"); let _token = EnvOverride::set(FASTLY_API_TOKEN_ENV, "test-token"); - deploy_staged(&[ - "--service-id".to_owned(), - "SVC1".to_owned(), - "--manifest-path".to_owned(), - app.path().join("fastly.toml").display().to_string(), - "--edgezero-staging-config=app_config".to_owned(), - ]) - .expect("must isolate staging even with no production override store"); + let context = AdapterDeployContext { + adapter_manifest_path: Some(app.path().join("fastly.toml")), + service_id: Some("SVC1".to_owned()), + stores: DeployStoreIds { + config: vec!["app_config".to_owned()], + ..DeployStoreIds::default() + }, + staging: true, + }; + deploy_staged_with_context(&context, &[]) + .expect("must isolate staging even with no production override store"); 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__SERVICES__SVC1__STORES__CONFIG__APP_CONFIG__KEY" + "config-store-entry update --store-id=STAGEID1 --key=EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY" )), "the staging selector must be written even with no production store: {argv}" ); @@ -11541,14 +11337,17 @@ echo 'unexpected' >&2; exit 1 fs::write(app.path().join("fastly.toml"), "name = \"app\"\n").expect("write fastly.toml"); let _token = EnvOverride::set(FASTLY_API_TOKEN_ENV, "test-token"); - let err = deploy_staged(&[ - "--service-id".to_owned(), - "SVC1".to_owned(), - "--manifest-path".to_owned(), - app.path().join("fastly.toml").display().to_string(), - "--edgezero-staging-config=app_config".to_owned(), - ]) - .expect_err("an unreadable config-store listing must fail closed"); + let context = AdapterDeployContext { + adapter_manifest_path: Some(app.path().join("fastly.toml")), + service_id: Some("SVC1".to_owned()), + stores: DeployStoreIds { + config: vec!["app_config".to_owned()], + ..DeployStoreIds::default() + }, + staging: true, + }; + let err = deploy_staged_with_context(&context, &[]) + .expect_err("an unreadable config-store listing must fail closed"); assert!( err.contains("Refusing to stage") || err.contains("could not parse"), "the error must explain the refusal: {err}" @@ -11673,6 +11472,81 @@ echo 'unexpected' >&2; exit 1 ); } + #[cfg(unix)] + #[test] + fn deploy_materializes_selected_canonical_secret_store_name() { + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + let manifest = dir.path().join("fastly.toml"); + fs::write(&manifest, "name = \"app\"\n").expect("write fastly.toml"); + let oplog = dir.path().join("oplog.txt"); + let fake = fake_fastly_runtime_mapping(&[], &oplog); + let _path = PathPrepend::new(fake.path()); + let _selected = EnvOverride::set( + "EDGEZERO__STORES__SECRETS__TRUSTED_SERVER_SECRETS__NAME", + "ts_secrets_staging", + ); + + let context = AdapterDeployContext { + adapter_manifest_path: Some(manifest), + stores: DeployStoreIds { + secrets: vec!["trusted_server_secrets".to_owned()], + ..DeployStoreIds::default() + }, + ..AdapterDeployContext::default() + }; + FastlyCliAdapter + .deploy(&context, &[]) + .and_then(|()| FastlyCliAdapter.finalize_deploy(&context, None)) + .expect("deploy and runtime selector reconciliation succeed"); + + let log = fs::read_to_string(&oplog).expect("oplog"); + assert!( + log.contains("compute-deploy"), + "service was deployed: {log}" + ); + assert!( + log.contains( + "update EDGEZERO__STORES__SECRETS__TRUSTED_SERVER_SECRETS__NAME=ts_secrets_staging" + ), + "the selected canonical secret-store name is materialized after deploy: {log}" + ); + } + + #[cfg(unix)] + #[test] + fn deploy_default_store_selectors_do_not_require_runtime_env_store() { + use std::os::unix::fs::PermissionsExt as _; + + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + let script_path = dir.path().join("fastly"); + fs::write( + &script_path, + "#!/bin/sh\n\ + if [ \"$1 $2\" = \"config-store list\" ]; then echo '[]'; exit 0; fi\n\ + echo 'unexpected fastly invocation' >&2\n\ + exit 1\n", + ) + .expect("fake fastly"); + let mut permissions = fs::metadata(&script_path).expect("meta").permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&script_path, permissions).expect("chmod"); + let _path = PathPrepend::new(dir.path()); + + let stores = RuntimeStoreIds { + config: vec!["app_config".to_owned()], + kv: vec!["sessions".to_owned()], + secrets: vec!["default".to_owned()], + }; + reconcile_deploy_runtime_env( + &stores, + &EnvConfig::from_vars(Vec::<(String, String)>::new()), + dir.path(), + ) + .expect("default selectors need no runtime-env store"); + } + #[test] fn kept_roots_report_wording_counts_and_empty_store() { // Empty: a single, unambiguous "nothing retained" line and no root list. diff --git a/crates/edgezero-adapter-fastly/src/lib.rs b/crates/edgezero-adapter-fastly/src/lib.rs index 36161a35..21be95ad 100644 --- a/crates/edgezero-adapter-fastly/src/lib.rs +++ b/crates/edgezero-adapter-fastly/src/lib.rs @@ -34,12 +34,6 @@ 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. /// @@ -104,19 +98,6 @@ 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 @@ -195,10 +176,9 @@ where /// host and port, logging settings, `__NAME` entries for declared stores, and /// `__KEY` entries for declared config stores. /// -/// 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. +/// Each lookup uses the same canonical `EDGEZERO__*` name accepted by the other +/// adapters. The deploy flow copies the selected deployment environment's +/// declared store selectors into this Config Store. /// /// [`run_app`] and [`run_app_with_request_extensions`] call this themselves. /// [`run_app_with_config`] does NOT, and neither does a hand-built @@ -223,37 +203,28 @@ pub fn runtime_env_config(stores: StoresMetadata) -> EnvConfig { // EDGEZERO__* runtime overrides (spec 5.4 __KEY, spec 5.2 // __NAME) will silently fall back to baked defaults. Log // once at request time so operators can spot the gap in - // their Fastly logs and run `edgezero provision --adapter fastly` - // to create the store. + // their Fastly logs and provision the store when overrides are needed. log::warn!( "Fastly Config Store `edgezero_runtime_env` not found; \ EDGEZERO__* runtime overrides will use baked-in defaults. \ Run `edgezero provision --adapter fastly` to create the store, \ - then populate per-environment override keys with \ - `fastly config-store-entry update --upsert`." + then deploy from the selected environment to populate its \ + canonical store selectors." ); return EnvConfig::from_vars(empty::<(String, String)>()); }; - let current_service_id = service_id(); - let vars = runtime_env_vars_for_service(stores, current_service_id, |key| dict.get(key)); + let vars = runtime_env_vars(stores, |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)> +fn runtime_env_vars(stores: StoresMetadata, 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)) - }) + .filter_map(|key| get(&key).map(|value| (key, value))) .collect() } diff --git a/crates/edgezero-adapter/src/registry.rs b/crates/edgezero-adapter/src/registry.rs index a2d18c8a..4af1ab35 100644 --- a/crates/edgezero-adapter/src/registry.rs +++ b/crates/edgezero-adapter/src/registry.rs @@ -1,5 +1,5 @@ use std::collections::HashMap; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::sync::{LazyLock, PoisonError, RwLock}; static REGISTRY: LazyLock>> = @@ -41,6 +41,38 @@ pub enum AdapterAction { Serve, } +/// Logical store ids declared by the application manifest for a deploy. +/// +/// This stays platform-neutral: each adapter decides whether and how its +/// runtime needs these declarations materialized. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct DeployStoreIds { + pub config: Vec, + pub kv: Vec, + pub secrets: Vec, +} + +impl DeployStoreIds { + /// Whether the application declares no runtime stores of any kind. + #[must_use] + #[inline] + pub fn is_empty(&self) -> bool { + self.config.is_empty() && self.kv.is_empty() && self.secrets.is_empty() + } +} + +/// Structured application context for an adapter deploy. +/// +/// Native-CLI passthrough remains in the separate `args` slice. EdgeZero-owned +/// deployment data belongs here so it cannot collide with provider CLI flags. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct AdapterDeployContext { + pub adapter_manifest_path: Option, + pub service_id: Option, + pub staging: bool, + pub stores: DeployStoreIds, +} + /// A single declared store id, paired with the platform name the /// runtime will resolve via `EDGEZERO__STORES______NAME`. /// @@ -274,6 +306,24 @@ pub enum ReadConfigEntry { /// of `edgezero-core`. Defaults are no-ops; adapters override what /// they actually need. pub trait Adapter: Sync + Send { + /// Deploy with EdgeZero-owned inputs carried as typed context and only + /// provider-native passthrough in `args`. + /// + /// Adapters that do not need structured deploy data can use the default + /// dispatch to their existing `execute` implementation. + /// + /// # Errors + /// Returns an error string when the adapter deploy fails. + #[inline] + fn deploy(&self, context: &AdapterDeployContext, args: &[String]) -> Result<(), String> { + let action = if context.staging { + AdapterAction::DeployStaged + } else { + AdapterAction::Deploy + }; + self.execute(action, args) + } + /// Execute the requested action with optional adapter-specific args. /// /// `args` is a stringly-typed pass-through for arguments meant @@ -290,6 +340,23 @@ pub trait Adapter: Sync + Send { /// Returns an error string if the requested adapter action fails. fn execute(&self, action: AdapterAction, args: &[String]) -> Result<(), String>; + /// Finish a successful deploy. + /// + /// `command_output` is present when a manifest-defined deploy command ran + /// instead of the adapter's built-in deploy. Adapters can reconcile + /// provider state or emit deployment metadata from either path. + /// + /// # Errors + /// Returns an error string when provider state cannot be finalized. + #[inline] + fn finalize_deploy( + &self, + _context: &AdapterDeployContext, + _command_output: Option<&str>, + ) -> Result<(), String> { + Ok(()) + } + /// Reclaim chunk entries that no LIVE config pointer references. /// /// Deliberately NOT part of `config push`. On an eventually-consistent diff --git a/crates/edgezero-cli/src/adapter.rs b/crates/edgezero-cli/src/adapter.rs index 54dc6253..7d413d27 100644 --- a/crates/edgezero-cli/src/adapter.rs +++ b/crates/edgezero-cli/src/adapter.rs @@ -1,4 +1,4 @@ -use edgezero_adapter::registry::{self as adapter_registry, AdapterAction}; +use edgezero_adapter::registry::{self as adapter_registry, AdapterAction, AdapterDeployContext}; use edgezero_core::manifest::{Manifest, ManifestLoader, ResolvedEnvironment}; use std::env; @@ -130,16 +130,23 @@ pub fn execute( ); } - let adapter = adapter_registry::get_adapter(adapter_name).ok_or_else(|| { + let adapter = require_adapter(adapter_name, manifest_loader.is_some())?; + + adapter.execute(AdapterAction::from(action), adapter_args) +} + +fn require_adapter( + adapter_name: &str, + has_manifest: bool, +) -> Result<&'static dyn adapter_registry::Adapter, String> { + adapter_registry::get_adapter(adapter_name).ok_or_else(|| { let available = adapter_registry::registered_adapters(); if available.is_empty() { - if manifest_loader.is_none() { - format!( - "adapter `{adapter_name}` is not registered in this build. Provide an `edgezero.toml` (or set `EDGEZERO_MANIFEST`) so the CLI can load adapters, or rebuild `edgezero-cli` with the `{adapter_name}` adapter feature enabled." - ) + if has_manifest { + format!("adapter `{adapter_name}` is not registered (no adapters available)") } else { format!( - "adapter `{adapter_name}` is not registered (no adapters available)" + "adapter `{adapter_name}` is not registered in this build. Provide an `edgezero.toml` (or set `EDGEZERO_MANIFEST`) so the CLI can load adapters, or rebuild `edgezero-cli` with the `{adapter_name}` adapter feature enabled." ) } } else { @@ -149,61 +156,65 @@ pub fn execute( available.join(", ") ) } - })?; - - adapter.execute(AdapterAction::from(action), adapter_args) + }) } -/// Same dispatch as [`execute`], but when the action resolves to a -/// manifest-declared shell command the child's output is echoed AND -/// captured (see [`run_shell_tee`]) and returned as `Some(text)`. -/// -/// Returns `Ok(None)` when the action was served by the registered -/// adapter's built-in `execute` instead — that path writes straight to -/// the inherited stdio, so there is nothing for us to capture and the -/// caller must fall back to another source of truth (for Fastly deploy: -/// the Fastly API). -pub fn execute_capture( +/// Deploy through either the manifest-defined command or the registered +/// adapter, then let the adapter finalize provider state through the same typed +/// lifecycle hook. +pub fn deploy( adapter_name: &str, - action: Action, + context: &AdapterDeployContext, manifest_loader: Option<&ManifestLoader>, adapter_args: &[String], -) -> Result, String> { - if let Some(loader) = manifest_loader - && let Some(command) = manifest_command(loader.manifest(), adapter_name, action) +) -> Result<(), String> { + if !context.staging + && let Some(loader) = manifest_loader + && let Some(command) = manifest_command(loader.manifest(), adapter_name, Action::Deploy) { + // Store-aware adapters must finalize the provider state after a custom + // deploy command. Resolve that capability before the command activates + // anything so a CLI build without the adapter cannot silently skip it. + let finalizer = if context.stores.is_empty() { + adapter_registry::get_adapter(adapter_name) + } else { + Some(require_adapter(adapter_name, true)?) + }; let root = loader.manifest().root().unwrap_or_else(|| Path::new(".")); let env = loader.manifest().environment_for(adapter_name); let adapter_bind = adapter_bind_from_manifest(loader.manifest(), adapter_name); - return run_shell_tee( + let mut command_args = Vec::new(); + if let Some(service_id) = context.service_id.as_deref() { + command_args.extend(["--service-id".to_owned(), service_id.to_owned()]); + } + command_args.extend_from_slice(adapter_args); + let output = run_shell_tee( command, root, adapter_name, - action, + Action::Deploy, Some(env), adapter_bind, - adapter_args, - ) - .map(Some); + &command_args, + )?; + if let Some(adapter) = finalizer { + adapter.finalize_deploy(context, Some(&output))?; + } + return Ok(()); } - execute(adapter_name, action, manifest_loader, adapter_args)?; - Ok(None) + + let adapter = require_adapter(adapter_name, manifest_loader.is_some())?; + adapter.deploy(context, adapter_args)?; + adapter.finalize_deploy(context, None) } -/// Whether `action` for `adapter_name` resolves to a manifest-declared -/// shell command (rather than the registered adapter's built-in logic). -/// -/// Callers use this to decide whether an EdgeZero-internal directive -/// (e.g. `--manifest-path`, understood only by the built-in adapter) is -/// safe to thread into `adapter_args`: a manifest shell command receives -/// those args verbatim and would choke on a flag its own CLI lacks. -pub fn has_manifest_command( - manifest_loader: Option<&ManifestLoader>, +pub fn has_manifest_deploy_command( adapter_name: &str, - action: Action, + manifest_loader: Option<&ManifestLoader>, ) -> bool { - manifest_loader - .is_some_and(|loader| manifest_command(loader.manifest(), adapter_name, action).is_some()) + manifest_loader.is_some_and(|loader| { + manifest_command(loader.manifest(), adapter_name, Action::Deploy).is_some() + }) } fn manifest_command<'manifest>( diff --git a/crates/edgezero-cli/src/args.rs b/crates/edgezero-cli/src/args.rs index cc86e79c..0af2356e 100644 --- a/crates/edgezero-cli/src/args.rs +++ b/crates/edgezero-cli/src/args.rs @@ -468,9 +468,10 @@ pub struct ConfigDiffArgs { /// Path to the adapter's runtime configuration file. #[arg(long)] pub runtime_config: Option, - /// Diff against the staging key (`_staging`) in the same store, - /// so a staged diff compares exactly what `config push --staging` would - /// write. Mutually exclusive with `--key`, for the same reason as on push. + /// Diff against the staging key (`_staging`) in the + /// environment-selected store, so a staged diff compares exactly what + /// `config push --staging` would write. Mutually exclusive with `--key`, for + /// the same reason as on push. #[arg(long, conflicts_with = "key")] pub staging: bool, /// Logical config store id to diff against. Defaults to the @@ -561,9 +562,10 @@ pub struct ConfigPushArgs { #[arg(long)] pub runtime_config: Option, /// Push to staging: write the config under the `_staging` key - /// in the SAME store, so it never overwrites the production key the live - /// service reads. The same `--staging` verb `deploy`/`healthcheck`/`rollback` - /// use. Mutually exclusive with `--key`: the + /// in the environment-selected store, so it never overwrites the production + /// key the live service reads. Production and staging may select the same or + /// different physical stores. The same `--staging` verb + /// `deploy`/`healthcheck`/`rollback` use. Mutually exclusive with `--key`: the /// staging key is derived from the /// store's logical id because that is what the staging selector store (created /// and linked by a staged deploy) points a staged version at, so an explicit diff --git a/crates/edgezero-cli/src/config.rs b/crates/edgezero-cli/src/config.rs index 97a16f7c..91f60d36 100644 --- a/crates/edgezero-cli/src/config.rs +++ b/crates/edgezero-cli/src/config.rs @@ -1987,15 +1987,13 @@ fn format_app_config_error(err: &AppConfigError) -> String { )] mod tests { use super::*; - use crate::test_support::{EnvOverride, manifest_guard}; + use crate::test_support::{EnvOverride, manifest_guard, path_mutation_guard}; #[cfg(unix)] use edgezero_core::test_env::PathPrepend; use serde::{Deserialize, Serialize}; use std::borrow::Cow; use std::fs; - #[cfg(unix)] - use std::sync::Mutex; use tempfile::TempDir; // ---------- config gc argument gating ---------- @@ -4408,15 +4406,6 @@ ids = ["default"] // --- PATH-mutation helpers (mirrors Cloudflare adapter test pattern) --- - /// Process-wide mutex serialising PATH-mutating tests so parallel - /// test threads don't race on the `$PATH` environment variable. - #[cfg(unix)] - fn path_mutation_guard() -> &'static Mutex<()> { - use std::sync::OnceLock; - static GUARD: OnceLock> = OnceLock::new(); - GUARD.get_or_init(|| Mutex::new(())) - } - /// Build a tempdir containing a `fastly` script that APPENDS every /// invocation to `oplog` and fails. Injected via PATH so an ordering /// regression is caught as a recorded invocation instead of silently diff --git a/crates/edgezero-cli/src/generator.rs b/crates/edgezero-cli/src/generator.rs index f566e76b..9f2e0225 100644 --- a/crates/edgezero-cli/src/generator.rs +++ b/crates/edgezero-cli/src/generator.rs @@ -805,6 +805,7 @@ fn initialize_git_repo(out_dir: &Path) { #[cfg(test)] mod tests { use super::*; + use crate::test_support::path_mutation_guard; use edgezero_core::app_config::app_name_prefix; use edgezero_core::test_env::PathPrepend as PathOverride; use std::path::Path; @@ -1301,6 +1302,7 @@ mod tests { #[test] fn generate_new_scaffolds_workspace_layout() { + let _path_lock = path_mutation_guard().lock().expect("path guard"); let temp = TempDir::new().expect("temp dir"); let bin_dir = temp.path().join("bin"); write_git_stub(&bin_dir); diff --git a/crates/edgezero-cli/src/lib.rs b/crates/edgezero-cli/src/lib.rs index cbe17c49..c3ecd817 100644 --- a/crates/edgezero-cli/src/lib.rs +++ b/crates/edgezero-cli/src/lib.rs @@ -59,7 +59,9 @@ use args::{ ActiveVersionArgs, BuildArgs, DeployArgs, HealthcheckArgs, NewArgs, RollbackArgs, ServeArgs, }; #[cfg(feature = "cli")] -use edgezero_core::manifest::ManifestLoader; +use edgezero_adapter::registry::{AdapterDeployContext, DeployStoreIds}; +#[cfg(feature = "cli")] +use edgezero_core::manifest::{ManifestLoader, StoreDeclaration}; #[cfg(feature = "cli")] use std::env; #[cfg(feature = "cli")] @@ -183,196 +185,40 @@ pub fn run_deploy(args: &DeployArgs) -> Result<(), String> { let manifest = load_manifest_optional()?; ensure_adapter_defined(&args.adapter, manifest.as_ref())?; - - // Thread `--service-id` into the adapter invocation - // when provided, ahead of any operator passthrough args. Fastly - // consumes it; adapters that don't need a service id ignore it. - let action = if args.staging { - adapter::Action::DeployStaged + let manifest_stores = manifest.as_ref().map(|loader| &loader.manifest().stores); + let declared_ids = |declaration: Option<&StoreDeclaration>| { + declaration + .map(|store| store.ids.clone()) + .unwrap_or_default() + }; + let deploy_stores = DeployStoreIds { + config: declared_ids(manifest_stores.and_then(|declared| declared.config.as_ref())), + kv: declared_ids(manifest_stores.and_then(|declared| declared.kv.as_ref())), + secrets: declared_ids(manifest_stores.and_then(|declared| declared.secrets.as_ref())), + }; + let has_declared_stores = !deploy_stores.is_empty(); + let uses_manifest_command = + adapter::has_manifest_deploy_command(&args.adapter, manifest.as_ref()); + let adapter_manifest_path = if uses_manifest_command && !has_declared_stores && !args.staging { + None } else { - adapter::Action::Deploy + resolve_adapter_manifest_path(manifest.as_ref(), &args.adapter)?.map(PathBuf::from) + }; + let context = AdapterDeployContext { + adapter_manifest_path, + service_id: args.service_id.clone(), + stores: deploy_stores, + staging: args.staging, }; - let mut passthrough: Vec = Vec::new(); - // Thread the manifest-configured platform manifest path (resolved - // from `[adapters..adapter].manifest` relative to the - // `EDGEZERO_MANIFEST`-honoring manifest root) into BOTH the staged - // and the production deploy, so each targets the app the operator - // selected — not whichever `fastly.toml` a bare working-directory - // search finds first in a monorepo. The adapter falls back to a cwd - // search only when the manifest declares no adapter `manifest` key. - // - // `--manifest-path` is an EdgeZero-internal directive that only the - // built-in adapter understands, so it is threaded only when the - // action actually dispatches to the adapter. A manifest-declared - // shell `deploy` command receives the adapter args VERBATIM, and - // `fastly compute deploy` has no `--manifest-path` flag — such a - // command already runs in the manifest root and picks its own - // project directory. (Staged deploys are never manifest-declared - // commands, so they always get the flag.) - if !adapter::has_manifest_command(manifest.as_ref(), &args.adapter, action) - && let Some(manifest_path) = - resolve_adapter_manifest_path(manifest.as_ref(), &args.adapter)? - { - passthrough.push("--manifest-path".to_owned()); - passthrough.push(manifest_path); - } - if let Some(service_id) = &args.service_id { - passthrough.push("--service-id".to_owned()); - passthrough.push(service_id.clone()); - } - passthrough.extend_from_slice(&args.adapter_args); - - if args.staging { - // Thread the app's declared config-store logical ids so the staged - // relink knows which selectors to redirect to `_staging`. The - // adapter reads config usage from THIS list, never a remote probe — - // avoiding a lookup that fails open. One inline token per store; the - // adapter strips them before `fastly compute update`. - if let Some(loader) = manifest.as_ref() - && let Some(config) = loader.manifest().stores.config.as_ref() - { - for id in &config.ids { - passthrough.push(format!("--edgezero-staging-config={id}")); - } - } - // Staged deploy: clone the active version, upload the built - // package to a new draft, mark it staged, and emit the staged - // version. Never runs the manifest `deploy` - // command, which would activate production. - return adapter::execute( - &args.adapter, - adapter::Action::DeployStaged, - manifest.as_ref(), - &passthrough, - ); - } - - // Production deploy also emits the activated version - // so the deploy-fastly action can surface `fastly-version` and the - // deploy→healthcheck→rollback chain has a real version to thread. - // - // Resolution precedence (cheapest + most reliable first): - // 1. The deploy command's OWN output. We tee it (echoed live to - // the operator, captured for us) and look for a canonical - // `version=` line, then for Fastly's native phrasing - // ("... version 12"). The deploy command already knows the - // version it activated, so this needs no API round-trip and - // works under a manifest `[adapters.fastly.commands].deploy` - // override (including test fixtures with dummy credentials). - // 2. Only when the output yields nothing: the Fastly API lookup - // (`EmitVersion`), which needs a live API + a real token. - // 3. If BOTH fail: a clear `Err`. We never silently emit an empty - // version — that was the original bug. - if args.service_id.is_some() && args.adapter.eq_ignore_ascii_case("fastly") { - let captured = adapter::execute_capture( - &args.adapter, - adapter::Action::Deploy, - manifest.as_ref(), - &passthrough, - )?; - if let Some(version) = captured.as_deref().and_then(parse_deploy_version) { - log::info!("version={version}"); - return Ok(()); - } - // Fallback: resolve the version the deploy just activated via the Fastly - // API. `--require-active` makes EmitVersion FAIL (not emit an empty - // `version=`) when the API reports no active version — a deploy that - // activated a version but resolves to none is an error, never a silent - // empty-version success. - let mut emit_args = passthrough.clone(); - emit_args.push("--require-active".to_owned()); - return adapter::execute( - &args.adapter, - adapter::Action::EmitVersion, - manifest.as_ref(), - &emit_args, - ) - .map_err(|err| { - format!( - "deploy succeeded but the activated version could not be resolved: no `version=` \ - (or Fastly `version `) line in the deploy output, and the Fastly API fallback \ - failed: {err}" - ) - }); - } - - adapter::execute( + adapter::deploy( &args.adapter, - adapter::Action::Deploy, + &context, manifest.as_ref(), - &passthrough, + &args.adapter_args, ) } -/// Parse an activated service version out of a deploy command's output. -/// -/// Precedence: -/// 1. A canonical `version=` line (what a manifest -/// `[adapters.fastly.commands].deploy` override — or a CI fixture — -/// emits, and what `EdgeZero` itself prints). -/// 2. Fastly's native phrasing, e.g. -/// `SUCCESS: Deployed package (service abc, version 12)`. The LAST -/// mention wins, which is the version the deploy ended on. -/// -/// Returns `None` when neither shape is present, which sends the caller -/// to the Fastly API fallback. -#[cfg(feature = "cli")] -fn parse_deploy_version(output: &str) -> Option { - parse_canonical_version_line(output).or_else(|| parse_native_version_mention(output)) -} - -/// Last `version=` line in `output` (leading/trailing whitespace on -/// the line is ignored). -/// -/// FAIL CLOSED: the whole value after `version=` must be ASCII digits. -/// A `take_while(is_ascii_digit)` prefix scan would read `version=15.2.0` -/// as `15` and `version=12abc` as `12`, threading a WRONG version into -/// healthcheck / rollback. `None` sends the caller to the Fastly API -/// fallback (the version the deploy actually activated) instead. -#[cfg(feature = "cli")] -fn parse_canonical_version_line(output: &str) -> Option { - output.lines().rev().find_map(|line| { - let digits = line.trim().strip_prefix("version=")?; - if digits.is_empty() || !digits.chars().all(|ch| ch.is_ascii_digit()) { - return None; - } - digits.parse::().ok() - }) -} - -/// Last `, version )` mention in `output` (case-insensitive) — the -/// Fastly CLI's own success line, whose Go format string is -/// `"Deployed package (service %s, version %v)"`. -/// -/// Deliberately narrow: it previously accepted ANY digits appearing -/// after the word "version", so `Fastly CLI version 15.2.0` or -/// `... service 12345, version unchanged` parsed as a service version. -/// A misparse here emits a WRONG `version=` line, which the deploy → -/// healthcheck → rollback chain would then act on. When this returns -/// `None`, `run_deploy` falls back to the Fastly API's *active* version -/// (the version the deploy actually activated) rather than guessing. -#[cfg(feature = "cli")] -fn parse_native_version_mention(output: &str) -> Option { - let lower = output.to_ascii_lowercase(); - let mut result = None; - for (idx, _) in lower.match_indices(", version ") { - let after = idx.saturating_add(", version ".len()); - let Some(rest) = lower.get(after..) else { - continue; - }; - let digits: String = rest.chars().take_while(char::is_ascii_digit).collect(); - // The number must be closed by the success line's `)`. - if digits.is_empty() || rest.chars().nth(digits.len()) != Some(')') { - continue; - } - if let Ok(parsed) = digits.parse::() { - result = Some(parsed); - } - } - result -} - /// Resolve the absolute path of the adapter's platform manifest /// (`[adapters..adapter].manifest`), CONFINED to the loaded /// manifest's own directory. Used by the Fastly staged deploy to target @@ -679,7 +525,7 @@ fn load_manifest_optional() -> Result, String> { #[cfg(feature = "cli")] mod tests { use super::*; - use crate::test_support::{BASIC_MANIFEST, EnvOverride, manifest_guard}; + use crate::test_support::{BASIC_MANIFEST, EnvOverride, manifest_guard, path_mutation_guard}; use edgezero_core::manifest::ManifestLoader; use std::fs; use std::path::Path; @@ -736,78 +582,6 @@ mod tests { assert!(manifest.manifest().adapters.contains_key("fastly")); } - // ── deploy-output version parsing ───────────────────────────────── - - #[test] - fn parse_deploy_version_reads_canonical_line() { - // What a manifest `[adapters.fastly.commands].deploy` override - // (or a CI fixture running with dummy creds) emits. Must be - // parsed WITHOUT any Fastly API round-trip. - let output = "building...\nversion=7\ndone\n"; - assert_eq!(parse_deploy_version(output), Some(7)); - } - - #[test] - fn parse_deploy_version_reads_fastly_native_phrasing() { - let output = "SUCCESS: Deployed package (service abc123, version 12)\n"; - assert_eq!(parse_deploy_version(output), Some(12)); - } - - #[test] - fn parse_deploy_version_none_when_absent_triggers_fallback() { - // No version anywhere -> `None`, which routes run_deploy to the - // Fastly API fallback (and to a clear Err if that also fails). - let output = "Building package...\nUploading...\nAll good.\n"; - assert_eq!(parse_deploy_version(output), None); - assert_eq!(parse_deploy_version(""), None); - } - - #[test] - fn parse_deploy_version_prefers_canonical_over_native_mention() { - // A fixture that both narrates a clone AND emits the canonical - // line: the canonical line is authoritative. - let output = "Cloning version 3...\nversion=9\n"; - assert_eq!(parse_deploy_version(output), Some(9)); - } - - #[test] - fn parse_deploy_version_native_takes_last_success_line() { - let output = "SUCCESS: Deployed package (service abc, version 3)\n\ - SUCCESS: Deployed package (service abc, version 4)\n"; - assert_eq!(parse_deploy_version(output), Some(4)); - } - - #[test] - fn parse_deploy_version_rejects_confusable_mentions() { - // Loose `version ` narration is NOT a service version. Each of - // these used to parse (and would have emitted a wrong `version=` - // for healthcheck/rollback to act on). `None` routes run_deploy to - // the Fastly API's *active* version instead — the safe answer. - assert_eq!(parse_deploy_version("Fastly CLI version 15.2.0\n"), None); - assert_eq!( - parse_deploy_version("Uploaded to service 12345, version unchanged\n"), - None - ); - assert_eq!( - parse_deploy_version("Cloning version 3... created version 4\n"), - None - ); - } - - #[test] - fn parse_deploy_version_rejects_malformed_canonical_lines() { - // The canonical-line parser must be FAIL CLOSED: a prefix scan - // (`take_while(is_ascii_digit)`) read `version=15.2.0` as 15 and - // `version=12abc` as 12, threading a WRONG version into - // healthcheck / rollback. `None` routes run_deploy to the Fastly - // API fallback instead. - assert_eq!(parse_deploy_version("version=15.2.0\n"), None); - assert_eq!(parse_deploy_version("version=12abc\n"), None); - assert_eq!(parse_deploy_version("version=\n"), None); - // A well-formed line is still accepted (leading zeros included). - assert_eq!(parse_deploy_version("version=007\n"), Some(7)); - } - #[cfg(not(windows))] #[test] fn run_deploy_manifest_command_forwards_adapter_args_verbatim() { @@ -858,6 +632,149 @@ mod tests { ); } + #[cfg(not(windows))] + #[test] + fn run_staging_deploy_resolves_explicit_manifest_even_with_custom_production_command() { + let _lock = manifest_guard().lock().expect("manifest guard"); + let temp = TempDir::new().expect("temp dir"); + let manifest_path = temp.path().join("edgezero.toml"); + fs::write( + &manifest_path, + "[app]\nname = \"demo-app\"\n\n[adapters.fastly.adapter]\ncrate = \"crates/demo-fastly\"\nmanifest = \"missing/fastly.toml\"\n\n[adapters.fastly.commands]\ndeploy = \"true\"\n", + ) + .expect("write manifest"); + let manifest_str = manifest_path.to_string_lossy().into_owned(); + let _env = EnvOverride::set("EDGEZERO_MANIFEST", &manifest_str); + + let err = run_deploy(&DeployArgs { + adapter: "fastly".to_owned(), + adapter_args: Vec::new(), + service_id: Some("SVC1".to_owned()), + staging: true, + }) + .expect_err("staging must resolve the explicitly selected fastly.toml"); + + assert!( + err.contains("missing/fastly.toml") && err.contains("could not resolve"), + "staging reports the selected missing manifest before discovery: {err}" + ); + } + + #[cfg(not(windows))] + #[test] + fn run_custom_deploy_with_stores_requires_registered_adapter_before_command() { + let _lock = manifest_guard().lock().expect("manifest guard"); + let temp = TempDir::new().expect("temp dir"); + let marker = temp.path().join("deploy-ran"); + let script = temp.path().join("deploy.sh"); + fs::write( + &script, + format!("#!/bin/sh\ntouch '{}'\n", marker.display()), + ) + .expect("write deploy script"); + let manifest_path = temp.path().join("edgezero.toml"); + fs::write( + &manifest_path, + format!( + "[app]\nname = \"demo-app\"\n\n[stores.config]\nids = [\"app_config\"]\n\n[adapters.unregistered_test.adapter]\ncrate = \"crates/demo\"\n\n[adapters.unregistered_test.commands]\ndeploy = \"sh {}\"\n", + script.display() + ), + ) + .expect("write manifest"); + let manifest_str = manifest_path.to_string_lossy().into_owned(); + let _env = EnvOverride::set("EDGEZERO_MANIFEST", &manifest_str); + + let err = run_deploy(&DeployArgs { + adapter: "unregistered_test".to_owned(), + adapter_args: Vec::new(), + service_id: None, + staging: false, + }) + .expect_err("store-aware custom deploy requires its adapter finalizer"); + + assert!(err.contains("not registered"), "registration error: {err}"); + assert!( + !marker.exists(), + "the custom deploy command must not run before required finalization is available" + ); + } + + #[cfg(not(windows))] + #[test] + fn run_deploy_reconciles_fastly_selectors_after_a_manifest_command() { + use std::os::unix::fs::PermissionsExt as _; + + let _lock = manifest_guard().lock().expect("manifest guard"); + let _path_lock = path_mutation_guard().lock().expect("path guard"); + let temp = TempDir::new().expect("temp dir"); + let adapter_dir = temp.path().join("crates/demo-fastly"); + let bin_dir = temp.path().join("bin"); + fs::create_dir_all(&adapter_dir).expect("adapter dir"); + fs::create_dir_all(&bin_dir).expect("bin dir"); + fs::write(adapter_dir.join("fastly.toml"), "name = \"demo\"\n").expect("fastly manifest"); + + let deploy_script = temp.path().join("deploy.sh"); + fs::write(&deploy_script, "#!/bin/sh\necho version=42\n").expect("deploy script"); + let mut deploy_perms = fs::metadata(&deploy_script).expect("meta").permissions(); + deploy_perms.set_mode(0o755); + fs::set_permissions(&deploy_script, deploy_perms).expect("chmod deploy"); + + let operations = temp.path().join("operations.log"); + let fake_fastly = bin_dir.join("fastly"); + fs::write( + &fake_fastly, + format!( + "#!/bin/sh\n\ + if [ \"$1 $2\" = \"config-store list\" ]; then echo '[{{\"id\":\"ENV1\",\"name\":\"edgezero_runtime_env\"}}]'; exit 0; fi\n\ + if [ \"$1 $2\" = \"config-store-entry list\" ]; then echo '[]'; exit 0; fi\n\ + if [ \"$1 $2\" = \"config-store-entry update\" ]; then value=$(cat); printf '%s %s\\n' \"$*\" \"$value\" >> '{}'; exit 0; fi\n\ + exit 1\n", + operations.display() + ), + ) + .expect("fake fastly"); + let mut fastly_perms = fs::metadata(&fake_fastly).expect("meta").permissions(); + fastly_perms.set_mode(0o755); + fs::set_permissions(&fake_fastly, fastly_perms).expect("chmod fastly"); + + let manifest_path = temp.path().join("edgezero.toml"); + fs::write( + &manifest_path, + format!( + "[app]\nname = \"demo-app\"\n\n[stores.secrets]\nids = [\"trusted_server_secrets\"]\n\n[adapters.fastly.adapter]\ncrate = \"crates/demo-fastly\"\nmanifest = \"crates/demo-fastly/fastly.toml\"\n\n[adapters.fastly.commands]\ndeploy = \"{}\"\n", + deploy_script.display() + ), + ) + .expect("edgezero manifest"); + let manifest_str = manifest_path.to_string_lossy().into_owned(); + let path = format!( + "{}:{}", + bin_dir.display(), + env::var("PATH").unwrap_or_default() + ); + let _manifest = EnvOverride::set("EDGEZERO_MANIFEST", &manifest_str); + let _path = EnvOverride::set("PATH", &path); + let _selector = EnvOverride::set( + "EDGEZERO__STORES__SECRETS__TRUSTED_SERVER_SECRETS__NAME", + "ts_secrets_staging", + ); + + run_deploy(&DeployArgs { + adapter: "fastly".to_owned(), + adapter_args: vec!["--non-interactive".to_owned()], + service_id: Some("SVC1".to_owned()), + staging: false, + }) + .expect("custom deploy and selector reconciliation succeed"); + + let log = fs::read_to_string(&operations).expect("selector update recorded"); + assert!( + log.contains("--key=EDGEZERO__STORES__SECRETS__TRUSTED_SERVER_SECRETS__NAME") + && log.contains("ts_secrets_staging"), + "the selected canonical secret store is materialized after the custom deploy: {log}" + ); + } + #[test] fn run_deploy_rejects_staging_spellings_in_passthrough() { // A reserved lifecycle spelling after `--` must FAIL CLOSED before any deploy diff --git a/crates/edgezero-cli/src/templates/root/README.md.hbs b/crates/edgezero-cli/src/templates/root/README.md.hbs index 810a010b..a9616a58 100644 --- a/crates/edgezero-cli/src/templates/root/README.md.hbs +++ b/crates/edgezero-cli/src/templates/root/README.md.hbs @@ -56,8 +56,9 @@ the same `{{name}}.toml` can land under multiple keys, and the runtime 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. +setting under the canonical key +`EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY` in the `edgezero_runtime_env` +Config Store. The Fastly deploy flow materializes declared selectors from +the selected deployment environment; no service ID appears in the variable name. 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-cli/src/test_support.rs b/crates/edgezero-cli/src/test_support.rs index 19d58c21..21651e64 100644 --- a/crates/edgezero-cli/src/test_support.rs +++ b/crates/edgezero-cli/src/test_support.rs @@ -107,3 +107,12 @@ pub(crate) fn manifest_guard() -> &'static Mutex<()> { static GUARD: OnceLock> = OnceLock::new(); GUARD.get_or_init(|| Mutex::new(())) } + +/// Process-wide mutex serialising tests that mutate `PATH`. +/// +/// A separate guard in each test module is insufficient because environment +/// variables are shared by every test thread in this crate. +pub(crate) fn path_mutation_guard() -> &'static Mutex<()> { + static GUARD: OnceLock> = OnceLock::new(); + GUARD.get_or_init(|| Mutex::new(())) +} diff --git a/docs/guide/adapters/fastly.md b/docs/guide/adapters/fastly.md index da0185e9..c8689a8b 100644 --- a/docs/guide/adapters/fastly.md +++ b/docs/guide/adapters/fastly.md @@ -190,28 +190,41 @@ 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: +Fastly Compute has no process environment, so the EdgeZero deploy flow copies +the selected deployment environment's declared store selectors into the +`edgezero_runtime_env` Config Store under their canonical names: ```text -EDGEZERO__SERVICES____STORES__CONFIG____NAME -EDGEZERO__SERVICES____STORES__CONFIG____KEY +EDGEZERO__STORES__CONFIG____NAME +EDGEZERO__STORES__CONFIG____KEY +EDGEZERO__STORES__KV____NAME +EDGEZERO__STORES__SECRETS____NAME ``` -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. +There is no service ID in an environment variable name. A deployer can select a +GitHub Environment such as `ts.example.com` or `staging.ts.example.com`; each +environment sets the same canonical variable names and may choose the same or +different physical Config, KV, and Secret stores. Production deploy reconciles +those values into `edgezero_runtime_env`. A staged deploy creates a per-service +staging twin, applies the staging environment's store names, changes each +declared config selector to `_staging`, attaches every selected physical +store under the name the runtime opens, and links that twin into only the staged +Fastly version. The selected resources must already exist in the Fastly account; +staging fails instead of creating an unusable version when one is missing. + +Treat the production `edgezero_runtime_env` store as owned by one Fastly +service. Its canonical keys are live account resources, so two unrelated +services linked to the same physical store would overwrite each other's values. +Serialize deployments for the owning service. The service ID remains a Fastly +deployment input and part of the staging twin's physical resource name; it is +not part of the portable configuration contract. + +Local Viceroy entries use the same canonical keys: + +```toml +[local_server.config_stores.edgezero_runtime_env.contents] +EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY = "app_config_staging" +``` ```toml [stores.config] diff --git a/docs/guide/blob-app-config-migration.md b/docs/guide/blob-app-config-migration.md index 6fdea5d0..6ee5841a 100644 --- a/docs/guide/blob-app-config-migration.md +++ b/docs/guide/blob-app-config-migration.md @@ -246,28 +246,28 @@ 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 for one service. Config Store keys are case-sensitive. +# Set the canonical override manually. Config Store keys are case-sensitive. fastly config-store-entry update \ --store-id= \ - --key=EDGEZERO__SERVICES____STORES__CONFIG__APP_CONFIG__KEY \ + --key=EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY \ --value=app_config_staging \ --upsert ``` -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. +Normal deployments do not need this manual command. The EdgeZero Fastly deploy +flow receives canonical `EDGEZERO__STORES__...` variables from the selected +deployment environment and reconciles selectors for the stores declared in +`edgezero.toml`. Production and staging may set the same physical store name or +different names. A staged config push always writes `_staging`, and +the staged version's private selector store points at that key. The staged +deploy also attaches every selected physical Config, KV, and Secret store to the +draft; those resources must already exist in the Fastly account. -Locally, Viceroy reports the fixed service ID -`0000000000000000000000`, regardless of the deployment `service_id` in -`fastly.toml`. Put local overrides under that namespace: +Locally, Viceroy reads the same canonical key: ```toml [local_server.config_stores.edgezero_runtime_env.contents] -EDGEZERO__SERVICES__0000000000000000000000__STORES__CONFIG__APP_CONFIG__KEY = "app_config_staging" +EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY = "app_config_staging" ``` If the local `edgezero_runtime_env` store is missing, EdgeZero logs a one-line diff --git a/docs/guide/cli-reference.md b/docs/guide/cli-reference.md index 779c38e7..42abd5c6 100644 --- a/docs/guide/cli-reference.md +++ b/docs/guide/cli-reference.md @@ -318,13 +318,14 @@ flags and exits `2` with a pointer to the typed CLI — it cannot push (see - `--app-config ` — typed app-config path (default: `.toml` next to the manifest). - `--store ` — logical config-store id to push to. Defaults to `[stores.config].default` (or the only declared id when `[stores.config].ids` has length 1). - `--key ` — override the config-store key the blob is written under (spec §5.4). -- `--staging` — write the `_staging` variant in the SAME store, - so a staged push never overwrites the key the live service reads. The staging - key is _derived_ from the store's logical id and is mutually exclusive with +- `--staging` — write the `_staging` variant in the store + selected by the staging environment, so a staged push never overwrites the key + the live service reads. Production and staging may select the same or different + physical stores. The staging key is _derived_ from the 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 the service-scoped - `EDGEZERO__SERVICES____STORES__CONFIG____KEY` entry in its + `edgezero_runtime_env` link at this key via the canonical + `EDGEZERO__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.