diff --git a/.github/actions/build-app-cli/action.yml b/.github/actions/build-app-cli/action.yml index ecad412f..f73d307f 100644 --- a/.github/actions/build-app-cli/action.yml +++ b/.github/actions/build-app-cli/action.yml @@ -47,7 +47,7 @@ outputs: description: The binary name inside the artifact. value: ${{ steps.build.outputs['app-cli-bin'] }} app-cli-artifact: - description: Artifact name the deploy, healthcheck, rollback, and config-push-fastly actions consume. + description: Artifact name consumed when assembling an immutable application release. value: ${{ steps.build.outputs['app-cli-artifact'] }} runs: @@ -105,6 +105,9 @@ runs: EDGEZERO__PROJECT__WORKING_DIRECTORY: ${{ inputs['working-directory'] }} EDGEZERO__PROJECT__RUST_TOOLCHAIN: ${{ inputs['rust-toolchain'] }} EDGEZERO__APP__CLI__ARTIFACT: ${{ inputs['app-cli-artifact'] }} + # setup-rust-build-cache publishes this through GITHUB_ENV only when its + # opt-in target cache is enabled. build-app-cli confines it to RUNNER_TEMP. + EDGEZERO__RUST_CACHE__TARGET_DIR: ${{ env.EDGEZERO__RUST_CACHE__TARGET_DIR }} # This step runs app-controlled code, so it emits NO outputs through the # runner's GITHUB_OUTPUT (blanked below). It collects them into this # action-owned file; the separate "Publish CLI outputs" step re-emits them. @@ -276,9 +279,7 @@ runs: name: ${{ steps.build.outputs['app-cli-artifact'] }} path: ${{ steps.build.outputs['tarball-path'] }} if-no-files-found: error - # No forced retention: the repository/org default applies, so the artifact - # survives an environment-approval gate that delays the deploy job past a day - # (a forced `retention-days: 1` would expire the CLI before it downloads). + retention-days: 14 # Remove the per-invocation workspace (build/stage dirs, the outputs handoff) # now that the artifact is uploaded. Runs no application code. diff --git a/.github/actions/build-app-cli/scripts/build-app-cli.sh b/.github/actions/build-app-cli/scripts/build-app-cli.sh index 879cbe76..7f535837 100755 --- a/.github/actions/build-app-cli/scripts/build-app-cli.sh +++ b/.github/actions/build-app-cli/scripts/build-app-cli.sh @@ -2,7 +2,8 @@ set -euo pipefail # Compiles the CLI package the APPLICATION provides — a crate in the app's own -# workspace — into an action-owned CARGO_TARGET_DIR, then packages the binary +# workspace — into an application-scoped cached CARGO_TARGET_DIR when configured, +# or an action-owned scratch directory otherwise. It then packages the binary # plus a self-describing app-cli-meta.json into a tar so the executable bit # survives actions/upload-artifact. Never builds the EdgeZero monorepo CLI. # @@ -17,6 +18,7 @@ set -euo pipefail # RUNNER_TEMP optional action-owned scratch root (default: /tmp) # EDGEZERO__PROVIDER__ENV_CLEAR optional JSON array of provider aliases to scrub before the build (default: []) # EDGEZERO__ACTION__WORKSPACE optional per-invocation scratch root (default: mktemp under RUNNER_TEMP) +# EDGEZERO__RUST_CACHE__TARGET_DIR optional shared Cargo target directory beneath RUNNER_TEMP # EDGEZERO__ACTION__OUTPUT_FILE optional outputs handoff file, started fresh (default: GITHUB_OUTPUT) # Writes (outputs): # app-cli-package the package that was built @@ -194,6 +196,19 @@ main() { [[ -n "$action_ws" ]] || action_ws=$(mktemp -d "$runner_temp/edgezero-cli.XXXXXX") local stage_root="$action_ws/artifact" local build_target_dir="$action_ws/build" + local cached_target_dir="${EDGEZERO__RUST_CACHE__TARGET_DIR:-}" + + if [[ -n "$cached_target_dir" ]]; then + [[ -d "$runner_temp" ]] || fail "RUNNER_TEMP does not exist or is not a directory" + [[ -d "$cached_target_dir" ]] || + fail "cached Cargo target directory does not exist or is not a directory" + local runner_temp_real cached_target_real + runner_temp_real=$(canonical_path "$runner_temp") + cached_target_real=$(canonical_path "$cached_target_dir") + is_under "$runner_temp_real" "$cached_target_real" || + fail "cached Cargo target directory must resolve inside RUNNER_TEMP" + build_target_dir="$cached_target_real" + fi require_linux_x86_64 require_cmd cargo @@ -260,8 +275,11 @@ main() { [[ "$has_bin" == "true" ]] || fail "app-cli-package '$cli_package' declares no binary target named '$cli_bin'" cli_version=$(jq -r '.version' <<<"$package_json") - # Build into an action-owned target dir so the checkout stays clean. - reset_owned_dir "$build_target_dir" "$runner_temp" + # Keep restored artifacts when setup-rust-build-cache supplied the target. + # The uncached fallback remains isolated to this invocation and starts clean. + if [[ -z "$cached_target_dir" ]]; then + reset_owned_dir "$build_target_dir" "$runner_temp" + fi run_untrusted CARGO_TARGET_DIR="$build_target_dir" cargo +"$rust_toolchain" build \ --locked --release -p "$cli_package" --bin "$cli_bin" @@ -278,7 +296,7 @@ main() { >"$stage_root/app-cli-meta.json" # Fixed tarball name — never derive a path component from caller input. - local tarball="$stage_root/../edgezero-cli.tar" + local tarball="$stage_root/../app-cli.tar" tar -C "$stage_root" -cf "$tarball" "$cli_bin" app-cli-meta.json tarball=$(canonical_path "$tarball") diff --git a/.github/actions/config-push-fastly/action.yml b/.github/actions/config-push-fastly/action.yml index 533de968..c0bd0e81 100644 --- a/.github/actions/config-push-fastly/action.yml +++ b/.github/actions/config-push-fastly/action.yml @@ -1,76 +1,68 @@ name: EdgeZero config-push-fastly -description: Push a checked-out EdgeZero application's typed config to a Fastly config store using a prebuilt app CLI artifact. +description: Push publisher runtime config using the CLI and manifest from a verified immutable application release. inputs: - app-cli-artifact: - description: Name of the build-app-cli artifact to download and run. + app-release-archive: + description: Path to the pinned application release archive. + required: true + app-release-sha256: + description: Expected lowercase SHA-256 digest of the application release archive. + required: true + expected-source-revision: + description: Full source revision that release.json must record. required: true - app-cli-bin: - description: Binary name inside the artifact. Defaults to the artifact metadata. - required: false - default: "" fastly-api-token: - description: Fastly API token. Injected only into the push step. + description: Fastly API token, scoped only to provider operations. required: true working-directory: - description: Application directory relative to github.workspace (holds the manifest + typed config). + description: Directory relative to github.workspace containing a publisher-owned app-config file. required: false default: . - manifest: - description: Optional edgezero.toml path relative to working-directory. - required: false - default: "" app-config: - description: "Optional typed config file path relative to working-directory (default: resolved from the manifest). Mutually exclusive with app-config-inline." + description: Typed runtime config file relative to working-directory; exactly one config input is required. required: false default: "" app-config-inline: - description: "Optional raw typed-config content (TOML) supplied inline instead of from a checked-out file — for config that lives in a GitHub variable with no file on disk. Written to an action-owned temp file and passed to the CLI. Mutually exclusive with app-config." + description: Inline typed runtime config; exactly one config input is required. required: false default: "" no-env: - description: "When 'true', pass --no-env so the CLI does NOT overlay __…__ environment variables onto the typed config before pushing. Defaults to 'false'." + description: "When 'true', skip the typed runtime environment overlay." required: false default: "false" store: - description: "Optional logical config-store id (default: the manifest's resolved id)." + description: Optional logical Config Store ID declared by the bundled application manifest. required: false default: "" key: - description: "Optional explicit base key for a PRODUCTION push (default: the logical store id). Not allowed with deploy-to: staging, whose key is derived." + description: "Deprecated and rejected when nonempty; use EDGEZERO__STORES__CONFIG____KEY." 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: "Select the canonical environment key, with production/staging fallback when absent." required: false default: production outputs: pushed-key: - description: The key that was written (the base key, or the derived _staging variant). + description: Key written by the application CLI. value: ${{ steps.push.outputs['pushed-key'] }} store: - description: The logical config-store id the CLI resolved (always emitted, not only when the `store` input was supplied). + description: Logical Config Store ID resolved by the bundled manifest. value: ${{ steps.push.outputs.store }} mutation-attempted: - description: "'true', emitted immediately BEFORE the config-push CLI runs (so a cancel/timeout mid-mutation can preserve it; a hard runner loss can still drop it, so absence is not proof the store is unchanged, and a cancel in the tiny pre-run window is a conservative false positive). On failure, read this via `if: always()` and reconcile — do not assume the config store is unchanged." + description: "'true' when the config-push CLI was invoked." value: ${{ steps.push.outputs['mutation-attempted'] }} provider-cli-version: - description: The pinned Fastly CLI version this action installed and ran. + description: Pinned Fastly CLI version installed by this action. value: ${{ steps.install-fastly.outputs['provider-cli-version'] }} runs: using: composite steps: - # A UNIQUE per-invocation workspace root under RUNNER_TEMP, so two concurrent - # invocations in one job (e.g. `background: true`) never collide on fixed temp - # paths (CLI download, extracted tools). The cleanup step removes it. - name: Prepare action workspace id: ws shell: bash - # Runs before validation, so it scrubs like every other step: blank the - # shipped aliases and BASH_ENV/ENV (a caller's job env could otherwise point - # BASH_ENV at checkout code that runs at bash startup with a token in scope). env: BASH_ENV: "" ENV: "" @@ -97,10 +89,13 @@ runs: env: BASH_ENV: "" ENV: "" - EDGEZERO__APP__CLI__ARTIFACT_PRESENT: ${{ inputs['app-cli-artifact'] != '' && 'true' || 'false' }} + EDGEZERO__APP__RELEASE__ARCHIVE_PRESENT: ${{ inputs['app-release-archive'] != '' && 'true' || 'false' }} + EDGEZERO__APP__RELEASE__SHA256_PRESENT: ${{ inputs['app-release-sha256'] != '' && 'true' || 'false' }} EDGEZERO__FASTLY__API_TOKEN_PRESENT: ${{ inputs['fastly-api-token'] != '' && 'true' || 'false' }} EDGEZERO__DEPLOY__TO: ${{ inputs['deploy-to'] }} - EDGEZERO__CONFIG_PUSH__KEY_PRESENT: ${{ inputs.key != '' && 'true' || 'false' }} + EDGEZERO__CONFIG_PUSH__APP_CONFIG_PRESENT: ${{ inputs['app-config'] != '' && 'true' || 'false' }} + EDGEZERO__CONFIG_PUSH__APP_CONFIG_INLINE_PRESENT: ${{ inputs['app-config-inline'] != '' && 'true' || 'false' }} + EDGEZERO__CONFIG_PUSH__KEY: ${{ inputs.key }} FASTLY_API_TOKEN: "" FASTLY_SERVICE_ID: "" FASTLY_TOKEN: "" @@ -119,12 +114,18 @@ runs: FASTLY_HOME: "" run: exec "$GITHUB_ACTION_PATH/scripts/validate.sh" - - name: Download CLI artifact - uses: actions/download-artifact@v8 - with: - name: ${{ inputs['app-cli-artifact'] }} - path: ${{ steps.ws.outputs.root }}/cli-download + - name: Verify application release + id: release + shell: bash env: + BASH_ENV: "" + ENV: "" + EDGEZERO__APP__RELEASE__ARCHIVE: ${{ inputs['app-release-archive'] }} + EDGEZERO__APP__RELEASE__SHA256: ${{ inputs['app-release-sha256'] }} + EDGEZERO__APP__RELEASE__EXPECTED_SOURCE_REVISION: ${{ inputs['expected-source-revision'] }} + EDGEZERO__APP__RELEASE__EXPECTED_ADAPTER: fastly + EDGEZERO__APP__RELEASE__EXPECTED_LIFECYCLE_PROTOCOL: "1" + EDGEZERO__APP__RELEASE__ROOT: ${{ steps.ws.outputs.root }}/release FASTLY_API_TOKEN: "" FASTLY_SERVICE_ID: "" FASTLY_TOKEN: "" @@ -141,16 +142,16 @@ runs: FASTLY_CONFIG_FILE: "" FASTLY_CARGO_PROFILE: "" FASTLY_HOME: "" + run: exec "$GITHUB_ACTION_PATH/../release-core/scripts/prepare-release.sh" - - name: Extract CLI + - name: Extract application CLI id: cli shell: bash env: BASH_ENV: "" ENV: "" - EDGEZERO__APP__CLI__ARTIFACT_DIR: ${{ steps.ws.outputs.root }}/cli-download + EDGEZERO__APP__CLI__ARCHIVE: ${{ steps.release.outputs['app-cli-archive'] }} EDGEZERO__ACTION__TOOL_ROOT: ${{ steps.ws.outputs.root }}/tools - EDGEZERO__APP__CLI__BIN: ${{ inputs['app-cli-bin'] }} FASTLY_API_TOKEN: "" FASTLY_SERVICE_ID: "" FASTLY_TOKEN: "" @@ -199,27 +200,19 @@ runs: id: push shell: bash env: - # BASH_ENV/ENV are sourced at bash startup, before this script can scrub — - # blank them so a caller's job env cannot run code here with the token. BASH_ENV: "" ENV: "" - # Mint the sensitive lifecycle log under the per-invocation workspace so the - # Cleanup step removes it wholesale even if the in-process EXIT trap cannot fire. EDGEZERO__ACTION__WORKSPACE: ${{ steps.ws.outputs.root }} - EDGEZERO__APP__CLI__BIN: ${{ steps.cli.outputs['app-cli-bin'] }} EDGEZERO__APP__CLI__PATH: ${{ steps.cli.outputs['app-cli-path'] }} EDGEZERO__PROJECT__WORKING_DIRECTORY: ${{ inputs['working-directory'] }} EDGEZERO__DEPLOY__TO: ${{ inputs['deploy-to'] }} EDGEZERO__CONFIG_PUSH__STORE: ${{ inputs.store }} - EDGEZERO__CONFIG_PUSH__KEY: ${{ inputs.key }} - EDGEZERO__CONFIG_PUSH__MANIFEST: ${{ inputs.manifest }} + EDGEZERO__CONFIG_PUSH__MANIFEST: ${{ steps.release.outputs['application-manifest'] }} EDGEZERO__CONFIG_PUSH__APP_CONFIG: ${{ inputs['app-config'] }} EDGEZERO__CONFIG_PUSH__APP_CONFIG_INLINE: ${{ inputs['app-config-inline'] }} EDGEZERO__CONFIG_PUSH__NO_ENV: ${{ inputs['no-env'] }} - # Only the typed token reaches the CLI under the adapter's own convention - # (FASTLY_API_TOKEN, what `fastly config-store-entry update` reads); every - # other inherited FASTLY_* alias is blanked so none can redirect the push. - FASTLY_API_TOKEN: ${{ inputs['fastly-api-token'] }} + EDGEZERO__FASTLY__API_TOKEN: ${{ inputs['fastly-api-token'] }} + FASTLY_API_TOKEN: "" FASTLY_SERVICE_ID: "" FASTLY_TOKEN: "" FASTLY_KEY: "" diff --git a/.github/actions/config-push-fastly/scripts/config-push.sh b/.github/actions/config-push-fastly/scripts/config-push.sh index 006c542b..a8022ba3 100755 --- a/.github/actions/config-push-fastly/scripts/config-push.sh +++ b/.github/actions/config-push-fastly/scripts/config-push.sh @@ -4,45 +4,41 @@ set -euo pipefail # Pushes the application's typed config to a Fastly config store, and emits the # key that was written. # -# Like healthcheck.sh and rollback.sh (its sibling lifecycle actions), this calls -# the app CLI directly with FASTLY_API_TOKEN in the step env — the adapter's own -# convention, which `fastly config-store-entry update` reads to authenticate. The -# wrapper blanks every other FASTLY_* alias, so an inherited FASTLY_ENDPOINT or -# FASTLY_TOKEN can never redirect or re-auth the push. +# Like its sibling lifecycle actions, this passes the token to the shared runner +# as typed data. The runner clears every Fastly alias before importing only the +# validated token, so inherited endpoint or token aliases cannot redirect or +# re-authenticate 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). +# Every target uses `` as the config entry key. The selected +# environment chooses the physical store through `__NAME`; using the same name +# shares config, while different names isolate it. # -# Path confinement: working-directory, manifest, and app-config are -# caller strings handed to a credential-bearing CLI, so each is canonicalized -# (resolving symlinks) and required to stay inside the application directory -# beneath github.workspace. Absolute paths, `..` traversal, and symlink escapes -# are rejected rather than read. +# The manifest is an absolute verified member of the immutable application +# release. Publisher-owned app-config files remain confined beneath the selected +# working directory; inline config is written to an action-owned temporary file. # # Reads (env): # EDGEZERO__APP__CLI__PATH optional absolute path to the app CLI (preferred; avoids PATH shadowing) # EDGEZERO__APP__CLI__BIN optional app CLI name, used when __PATH is unset -# FASTLY_API_TOKEN required provider token (Fastly's own convention) +# EDGEZERO__FASTLY__API_TOKEN required action-private Fastly API token # EDGEZERO__PROJECT__WORKING_DIRECTORY required app dir, relative to github.workspace # GITHUB_WORKSPACE required confinement root # EDGEZERO__DEPLOY__TO optional production | staging (default: production) # EDGEZERO__CONFIG_PUSH__STORE optional logical config-store id -# EDGEZERO__CONFIG_PUSH__KEY optional explicit base key -# EDGEZERO__CONFIG_PUSH__MANIFEST optional edgezero.toml path (relative to the app dir) +# EDGEZERO__CONFIG_PUSH__KEY deprecated; nonempty is rejected +# EDGEZERO__CONFIG_PUSH__MANIFEST required verified absolute release manifest # EDGEZERO__CONFIG_PUSH__APP_CONFIG optional typed config file path (relative to the app dir) # EDGEZERO__CONFIG_PUSH__APP_CONFIG_INLINE optional raw inline typed-config content (exclusive with APP_CONFIG) # EDGEZERO__CONFIG_PUSH__NO_ENV optional 'true' to pass --no-env (skip the env overlay); default false # RUNNER_TEMP optional scratch root for the inline-config temp file (default: /tmp) # Writes (outputs): # mutation-attempted true, emitted before the CLI runs (reconcile signal) -# pushed-key the key written (base, or its _staging variant) +# pushed-key canonical environment key, or the logical ID fallback # store the logical store id the CLI resolved SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) -# shellcheck source=../../deploy-core/scripts/common.sh -source "$SCRIPT_DIR/../../deploy-core/scripts/common.sh" +# shellcheck source=../../fastly-common/scripts/common.sh +source "$SCRIPT_DIR/../../fastly-common/scripts/common.sh" # Resolve a caller-supplied file path relative to the app dir and prove it stays # inside it. Echoes the path relative to the app dir (what the CLI is given). @@ -66,16 +62,26 @@ main() { local workspace="${GITHUB_WORKSPACE:?GITHUB_WORKSPACE is required}" local deploy_to="${EDGEZERO__DEPLOY__TO:-production}" local store="${EDGEZERO__CONFIG_PUSH__STORE:-}" - local key="${EDGEZERO__CONFIG_PUSH__KEY:-}" + local deprecated_key="${EDGEZERO__CONFIG_PUSH__KEY:-}" local manifest="${EDGEZERO__CONFIG_PUSH__MANIFEST:-}" local app_config="${EDGEZERO__CONFIG_PUSH__APP_CONFIG:-}" local app_config_inline="${EDGEZERO__CONFIG_PUSH__APP_CONFIG_INLINE:-}" local no_env="${EDGEZERO__CONFIG_PUSH__NO_ENV:-false}" local inline_file="" + local token="${EDGEZERO__FASTLY__API_TOKEN:-}" - require_input fastly-api-token "${FASTLY_API_TOKEN:-}" + if [[ -n "$deprecated_key" ]]; then + fail "input 'key' is deprecated and unsupported; use EDGEZERO__STORES__CONFIG____KEY" + fi + require_input fastly-api-token "$token" + require_input application-manifest "$manifest" + [[ "$manifest" == /* && -f "$manifest" && ! -L "$manifest" ]] || + fail "the bundled application manifest must be an absolute regular file" require_cmd "$cli_bin" + cli_bin=$(command -v "$cli_bin") || fail "application CLI is unavailable" + export EDGEZERO__APP__CLI__PATH="$cli_bin" require_cmd git + require_cmd jq # A typo in deploy-to must never silently push to production. case "$deploy_to" in production | staging) ;; @@ -89,8 +95,9 @@ main() { esac # A file path and inline content name the same thing two ways; requiring # exactly one avoids a silent precedence surprise. - if [[ -n "$app_config" && -n "$app_config_inline" ]]; then - fail "inputs 'app-config' and 'app-config-inline' are mutually exclusive" + if [[ -z "$app_config" && -z "$app_config_inline" ]] || + [[ -n "$app_config" && -n "$app_config_inline" ]]; then + fail "exactly one of 'app-config' or 'app-config-inline' is required" fi # Confine the app directory to github.workspace, then every path to the app. @@ -101,19 +108,8 @@ main() { app_dir=$(canonical_path "$workspace/$working_directory") is_under "$workspace_real" "$app_dir" || fail "input 'working-directory' must resolve inside github.workspace" - if [[ -n "$manifest" ]]; then - manifest=$(confine_to_app "$manifest" "$app_dir" manifest) - elif [[ -e "$app_dir/edgezero.toml" ]]; then - # Default discovery is confined too: the CLI reads `edgezero.toml` from the - # app dir, and a committed symlink there could point its deploy/store config - # outside the app while this step holds provider credentials. - local default_manifest - default_manifest=$(canonical_path "$app_dir/edgezero.toml") - is_under "$app_dir" "$default_manifest" || - fail "the default 'edgezero.toml' resolves outside the application directory — refusing to read a manifest that escapes it" - fi - # Committed-source guard: config pushed from the CHECKED-OUT tree (a manifest or an - # app-config FILE) must come from committed source, so the store the live service + # Committed-source guard: config pushed from a checked-out app-config FILE must + # come from committed source, so the store the live service # reads always corresponds to a revision that can be reconciled later — the same # guarantee deploy gets from resolve-project.sh. Inline config is caller-supplied # CONTENT (a workflow variable), not the tree, so it is exempt. @@ -158,27 +154,30 @@ main() { fi # Build the argv through a Bash array — never eval. --yes and --no-diff make the - # push non-interactive in CI; --staging selects the `_staging` variant. - local argv=("$cli_bin" config push --adapter fastly) - if [[ -n "$manifest" ]]; then argv+=(--manifest "$manifest"); fi - if [[ -n "$app_config" ]]; then argv+=(--app-config "$app_config"); fi + # push non-interactive in CI. --staging selects the Fastly lifecycle target; + # it does not change the runtime config key. + local argv=(config push --adapter fastly --manifest "$manifest" --app-config "$app_config") if [[ -n "$store" ]]; then argv+=(--store "$store"); fi - if [[ -n "$key" ]]; then argv+=(--key "$key"); fi if [[ "$deploy_to" == "staging" ]]; then argv+=(--staging); fi if [[ "$no_env" == "true" ]]; then argv+=(--no-env); fi argv+=(--yes --no-diff) - # Enter the app dir BEFORE signalling: a directory-entry failure here means the - # CLI was never invoked, so it must NOT falsely claim a mutation was attempted. - cd "$app_dir" || fail "could not enter working-directory '$app_dir'" - # Record that a provider mutation is being ATTEMPTED before the CLI runs, so the - # signal survives a failed step (readable via `if: always()`). If the push - # succeeds but its canonical `pushed-key=`/`pushed-store=` lines are missing - # below, the caller can still reconcile the config store rather than assume the - # store is unchanged. - append_output mutation-attempted true + local action_workspace="${EDGEZERO__ACTION__WORKSPACE:-$(dirname -- "$cli_bin")}" + mkdir -p "$action_workspace" + export EDGEZERO__ACTION__WORKSPACE="$action_workspace" + local args_file="$action_workspace/config-push-argv.nul" + local clear_file="$action_workspace/fastly-provider-clear.nul" + printf '%s\0' "${argv[@]}" >"$args_file" + write_fastly_provider_clear_file "$clear_file" + EDGEZERO__PROVIDER__ENV=$(jq -n --arg token "$token" '{FASTLY_API_TOKEN:$token}') + export EDGEZERO__PROVIDER__ENV + export EDGEZERO__PROVIDER__ENV_CLEAR_FILE="$clear_file" + export EDGEZERO__APP__CLI__ARGS_FILE="$args_file" + export EDGEZERO__APP__CLI__MUTATES=true + export EDGEZERO__PROJECT__WORKING_DIRECTORY="$app_dir" + export EDGEZERO__PROJECT__MANIFEST_PATH="$manifest" local rc=0 - "${argv[@]}" 2>&1 | tee "$LIFECYCLE_LOG" || rc=$? + "$SCRIPT_DIR/../../deploy-core/scripts/run-app-cli.sh" 2>&1 | tee "$LIFECYCLE_LOG" || rc=$? if [[ "$rc" -ne 0 ]]; then fail_with "$rc" "config push failed (CLI exit $rc)" fi diff --git a/.github/actions/config-push-fastly/scripts/validate.sh b/.github/actions/config-push-fastly/scripts/validate.sh index 3d61ca96..ce14fbe6 100755 --- a/.github/actions/config-push-fastly/scripts/validate.sh +++ b/.github/actions/config-push-fastly/scripts/validate.sh @@ -5,31 +5,34 @@ set -euo pipefail # action.yml `run:`) so it is shellcheck'd and contract-tested. # # Reads (env): -# EDGEZERO__APP__CLI__ARTIFACT_PRESENT required "true" when app-cli-artifact is non-empty +# EDGEZERO__APP__RELEASE__ARCHIVE_PRESENT required release archive presence flag +# EDGEZERO__APP__RELEASE__SHA256_PRESENT required release digest presence flag # EDGEZERO__FASTLY__API_TOKEN_PRESENT required "true" when fastly-api-token is non-empty # EDGEZERO__DEPLOY__TO optional production | staging (default: production) -# EDGEZERO__CONFIG_PUSH__KEY_PRESENT optional "true" when an explicit key was supplied +# EDGEZERO__CONFIG_PUSH__KEY deprecated key input; nonempty is rejected SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) # shellcheck source=../../deploy-core/scripts/common.sh source "$SCRIPT_DIR/../../deploy-core/scripts/common.sh" main() { - require_present app-cli-artifact "${EDGEZERO__APP__CLI__ARTIFACT_PRESENT:-}" + require_present app-release-archive "${EDGEZERO__APP__RELEASE__ARCHIVE_PRESENT:-}" + require_present app-release-sha256 "${EDGEZERO__APP__RELEASE__SHA256_PRESENT:-}" require_present fastly-api-token "${EDGEZERO__FASTLY__API_TOKEN_PRESENT:-}" + if [[ -n "${EDGEZERO__CONFIG_PUSH__KEY:-}" ]]; then + fail "input 'key' is deprecated and unsupported; use EDGEZERO__STORES__CONFIG____KEY" + fi + local has_file="${EDGEZERO__CONFIG_PUSH__APP_CONFIG_PRESENT:-false}" + local has_inline="${EDGEZERO__CONFIG_PUSH__APP_CONFIG_INLINE_PRESENT:-false}" + if [[ "$has_file" == "$has_inline" ]]; then + fail "exactly one of 'app-config' or 'app-config-inline' is required" + fi local deploy_to="${EDGEZERO__DEPLOY__TO:-production}" # A typo in deploy-to must never silently push to production. case "$deploy_to" in production | staging) ;; *) fail "input 'deploy-to' must be 'production' or 'staging' (got '${EDGEZERO__DEPLOY__TO:-}')" ;; esac - # A staging push derives its key from the store's logical id (`_staging`), - # which is what the staging selector store points a staged version at. An - # explicit `key` would be written to a key nothing reads, so the CLI refuses - # the combination — reject it here with a clearer, earlier message. - if [[ "$deploy_to" == "staging" && "${EDGEZERO__CONFIG_PUSH__KEY_PRESENT:-}" == "true" ]]; then - fail "input 'key' cannot be combined with deploy-to: staging; the staging key is derived from the store's logical id (_staging). Push to production with 'key', or push staging without it." - fi } main "$@" diff --git a/.github/actions/deploy-core/scripts/cleanup.sh b/.github/actions/deploy-core/scripts/cleanup.sh index 244abff8..473d91ec 100755 --- a/.github/actions/deploy-core/scripts/cleanup.sh +++ b/.github/actions/deploy-core/scripts/cleanup.sh @@ -7,9 +7,7 @@ set -euo pipefail # This script does `rm -rf`, so it removes ONLY directories it can prove the # action owns: real paths strictly beneath RUNNER_TEMP. An inherited or # job-level value pointing at the checkout — or anywhere else on a self-hosted -# runner — is refused, not deleted. (An earlier revision removed -# `$EDGEZERO_FASTLY_HOME`, a variable nothing in the action ever set: its value -# could only ever come from the caller's environment.) +# runner — is refused, not deleted. # # Reads (env): # RUNNER_TEMP required the only root anything may be removed beneath diff --git a/.github/actions/deploy-core/scripts/common.sh b/.github/actions/deploy-core/scripts/common.sh index 7dfcef5b..c5e01158 100755 --- a/.github/actions/deploy-core/scripts/common.sh +++ b/.github/actions/deploy-core/scripts/common.sh @@ -49,6 +49,12 @@ require_cmd() { command -v "$1" >/dev/null 2>&1 || fail "required command '$1' was not found" } +require_yq_v4() { + require_cmd yq + yq --version 2>&1 | grep -qE 'mikefarah/yq.*version v?4\.' || + fail "Mike Farah yq v4 is required" +} + append_output() { local name="$1" local value="$2" @@ -198,12 +204,12 @@ require_present() { [[ "$present" == "true" ]] || fail "missing required input '$name'" } -# The Fastly provider tooling and its pinned release binary are Linux x86-64 -# only. Fail with a clear message rather than a confusing exec error later. +# The published application CLI artifact currently targets Linux x86-64. +# Fail with a clear message rather than a confusing exec error later. require_linux_x86_64() { case "$(uname -s)-$(uname -m)" in Linux-x86_64 | Linux-amd64) ;; - *) fail "the Fastly wrapper supports only Linux x86-64 runners" ;; + *) fail "the application CLI artifact supports only Linux x86-64 runners" ;; esac } @@ -279,9 +285,8 @@ assert_committed_source() { # The app CLI to invoke — the ABSOLUTE path the download step resolved, when # available, else the bare name. # -# Bare-name resolution goes through PATH, and the provider CLI install prepends -# its own directory: an app CLI legitimately named `fastly` would then resolve to -# the provider's `fastly`, not the app's. Invoking the absolute path is immune to +# Bare-name resolution goes through PATH, where provider tooling can shadow an +# application CLI with the same name. Invoking the absolute path is immune to # PATH ordering. `EDGEZERO__APP__CLI__PATH` comes from download-app-cli.sh's # `app-cli-path` output. resolve_app_cli() { diff --git a/.github/actions/deploy-core/scripts/download-app-cli.sh b/.github/actions/deploy-core/scripts/download-app-cli.sh index 244ca0ea..e9ebd8fa 100755 --- a/.github/actions/deploy-core/scripts/download-app-cli.sh +++ b/.github/actions/deploy-core/scripts/download-app-cli.sh @@ -6,10 +6,11 @@ set -euo pipefail # self-describing app-cli-meta.json. A wrapper-supplied EDGEZERO__APP__CLI__BIN # overrides the metadata's binary name. The binary's ABSOLUTE path is emitted as # `app-cli-path`; the dir is deliberately NOT added to PATH, so an app CLI named -# after a tool the action shells out to (jq, fastly, …) cannot shadow it. +# after a tool the action shells out to (jq, provider-cli, …) cannot shadow it. # # Reads (env): -# EDGEZERO__APP__CLI__ARTIFACT_DIR required dir containing the downloaded tar +# EDGEZERO__APP__CLI__ARCHIVE preferred exact verified release member +# EDGEZERO__APP__CLI__ARTIFACT_DIR legacy build-action artifact directory # EDGEZERO__APP__CLI__BIN optional override for the binary name # EDGEZERO__ACTION__TOOL_ROOT optional install dir (default: under RUNNER_TEMP) # Writes (outputs): @@ -45,7 +46,8 @@ find_cli_tarball() { } main() { - local artifact_dir="${EDGEZERO__APP__CLI__ARTIFACT_DIR:?EDGEZERO__APP__CLI__ARTIFACT_DIR is required}" + local archive="${EDGEZERO__APP__CLI__ARCHIVE:-}" + local artifact_dir="${EDGEZERO__APP__CLI__ARTIFACT_DIR:-}" local cli_bin_override="${EDGEZERO__APP__CLI__BIN:-}" local tool_root="${EDGEZERO__ACTION__TOOL_ROOT:-${RUNNER_TEMP:-/tmp}/edgezero-action-tools}" @@ -58,8 +60,15 @@ main() { mkdir -p "$tool_root/bin" local tarball - tarball=$(find_cli_tarball "$artifact_dir") - [[ -n "$tarball" ]] || fail "no CLI tar found under the downloaded artifact at '$artifact_dir'" + if [[ -n "$archive" ]]; then + [[ -f "$archive" && ! -L "$archive" ]] || fail "the release-recorded application CLI archive is not a regular file" + [[ -z "$cli_bin_override" ]] || fail "app-cli-bin cannot override the binary recorded by an application release" + tarball="$archive" + else + require_input app-cli-artifact-dir "$artifact_dir" + tarball=$(find_cli_tarball "$artifact_dir") + [[ -n "$tarball" ]] || fail "no CLI tar found under the downloaded artifact at '$artifact_dir'" + fi assert_safe_tarball "$tarball" tar -xf "$tarball" -C "$tool_root/bin" @@ -76,7 +85,7 @@ main() { chmod +x "$cli_path" # Smoke-check with a scrubbed environment: no inherited provider credential - # (FASTLY_KEY, FASTLY_AUTH_TOKEN, ...) may reach the app CLI here. + # may reach the app CLI here. env -i PATH="/usr/bin:/bin" HOME="${HOME:-/tmp}" "$cli_path" --help >/dev/null 2>&1 || fail "downloaded CLI '$cli_bin' did not run '--help'" @@ -84,7 +93,7 @@ main() { # it by the ABSOLUTE `app-cli-path` output below, and an app CLI may legitimately # be named after a tool the action itself shells out to (e.g. `jq`) — prepending # its dir would then SHADOW that system command and break later steps. - notice "using app CLI '$cli_bin' v$cli_version from artifact" + notice "using verified app CLI '$cli_bin' v$cli_version" append_output app-cli-bin "$cli_bin" # The ABSOLUTE path, so callers invoke this exact binary rather than resolving # the bare name through PATH (immune to the provider-CLI dir the installer diff --git a/.github/actions/deploy-core/scripts/run-app-cli.sh b/.github/actions/deploy-core/scripts/run-app-cli.sh index 5ab6b0db..fef0d0c5 100755 --- a/.github/actions/deploy-core/scripts/run-app-cli.sh +++ b/.github/actions/deploy-core/scripts/run-app-cli.sh @@ -1,248 +1,151 @@ #!/usr/bin/env bash set -euo pipefail -# Runs the application CLI (build or deploy) through Bash arrays — never eval. -# -# Provider-neutral: it invokes ` --adapter ` with the -# wrapper's typed deploy-flags (before `--`) and the caller's passthrough -# deploy-args (after `--`). -# -# Credential boundary (deploy mode): the wrapper never exports provider tokens -# onto the step directly. It passes EDGEZERO__PROVIDER__ENV (a JSON object of typed -# credential name -> value) plus a provider-env-clear name list. This script -# first UNSETS every clear-listed alias (removing any inherited FASTLY_* value), -# then exports only the typed values from EDGEZERO__PROVIDER__ENV — and only names -# that are declared in the clear list. So inherited endpoint/token aliases can -# never survive into the deploy. Build mode is credential-free and only clears. +# Invokes an already-verified application CLI from an exact NUL-delimited argv. +# Provider wrappers own command construction, credential aliases, and any extra +# public runtime names; this core owns confinement, environment scrubbing, +# mutation signalling, and exact exit-status propagation. # # Reads (env): -# EDGEZERO__APP__CLI__PATH optional absolute path to the app CLI (preferred; avoids PATH shadowing) -# EDGEZERO__APP__CLI__BIN optional app CLI name, used when __PATH is unset -# EDGEZERO__ADAPTER required adapter passed as --adapter -# EDGEZERO__PROJECT__WORKING_DIRECTORY required directory to run the CLI from -# EDGEZERO__PROJECT__MANIFEST_PATH optional exported as EDGEZERO_MANIFEST when set -# EDGEZERO__BUILD__ARGS_FILE optional NUL-delimited build passthrough (build) -# EDGEZERO__DEPLOY__FLAGS_FILE optional NUL-delimited typed flags (deploy) -# EDGEZERO__DEPLOY__ARGS_FILE optional NUL-delimited passthrough (deploy) -# EDGEZERO__PROVIDER__ENV_CLEAR_FILE optional NUL-delimited alias names to clear -# EDGEZERO__PROVIDER__ENV optional JSON object of typed creds (deploy) +# EDGEZERO__ACTION__WORKSPACE required trusted root containing the CLI and control files +# EDGEZERO__APP__CLI__PATH required executable regular file beneath the action workspace +# EDGEZERO__APP__CLI__ARGS_FILE required NUL-delimited argv beneath the action workspace +# EDGEZERO__APP__CLI__MUTATES required true | false +# EDGEZERO__PROJECT__WORKING_DIRECTORY optional invocation directory (default: current directory) +# EDGEZERO__PROJECT__MANIFEST_PATH optional exported to the CLI as EDGEZERO_MANIFEST +# EDGEZERO__PROVIDER__ENV_CLEAR_FILE optional NUL-delimited provider aliases allowed for import +# EDGEZERO__PROVIDER__ENV optional JSON object containing typed provider values +# EDGEZERO__PUBLIC_RUNTIME_ENV_ALLOW_FILE optional NUL-delimited provider-specific public EDGEZERO__ names # Writes (outputs): -# mutation-attempted deploy mode only: 'true', written immediately BEFORE the -# CLI runs (best-effort reconcile signal — see below; a hard -# runner loss can drop it, so absence is not proof of no-op). -# Otherwise runs the app CLI, which owns stdout/stderr and the exit status. -# EDGEZERO_MANIFEST is exported to the CLI; the whole EDGEZERO__* namespace is -# scrubbed first (see scrub_action_private_env). +# mutation-attempted true immediately before a mutating CLI invocation +# Otherwise preserves the application CLI's stdout, stderr, and exit status. SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) # shellcheck source=common.sh source "$SCRIPT_DIR/common.sh" -# --- Untrusted-build isolation (build mode only) ----------------------------- -# `build` mode runs ` build` → `cargo build`, which executes the app's AND -# every transitive dependency's `build.rs` / proc macros — untrusted code. In -# deploy-fastly this is the `build-mode: always` seed build, and it runs in the -# SAME job that later receives the provider token. A `build.rs` that appends a shim -# dir to `$GITHUB_PATH`, or `LD_PRELOAD=…` / `https_proxy=…` to `$GITHUB_ENV`, would -# have the runner apply that to the token-bearing `Capture rollback target` and -# `Deploy` steps — token exfiltration. Strip the GitHub file-command channels from -# the PROCESS IMAGE before any build runs. This must be a re-exec, not an `unset`: -# `/proc//environ` still exposes the pre-unset values to a child otherwise -# (the same reason build-app-cli re-execs its untrusted build). The sentinel arg — -# not an env var, which a caller controls — marks "already stripped". `deploy` mode -# is deliberately unchanged: it runs the app's OWN trusted CLI with the same token -# and must keep GITHUB_OUTPUT to write `mutation-attempted`. -readonly BUILD_ISOLATED_SENTINEL="__edgezero_build_isolated__" -if [[ "${BASH_SOURCE[0]}" == "${0}" && "${1:-}" == build && "${2:-}" != "$BUILD_ISOLATED_SENTINEL" ]]; then - exec env -u GITHUB_ENV -u GITHUB_PATH -u GITHUB_OUTPUT -u GITHUB_STATE \ - -u GITHUB_STEP_SUMMARY -u BASH_ENV -u ENV \ - "$0" build "$BUILD_ISOLATED_SENTINEL" "${@:2}" -fi - -# Collect a NUL-delimited file into the global COLLECTED array (portable; avoids -# Bash 4.3 namerefs, which some runners/macOS Bash 3.2 lack). COLLECTED=() +validate_nul_file() { + local file="$1" label="$2" + [[ -f "$file" && ! -L "$file" ]] || fail "$label must be a regular file" + [[ ! -s "$file" ]] || + [[ "$(tail -c 1 "$file" | od -An -tu1 | tr -d ' ')" == 0 ]] || + fail "$label must end in NUL" +} + collect_nul() { - local file="$1" + local file="$1" entry COLLECTED=() + validate_nul_file "$file" "application CLI argument file" [[ -s "$file" ]] || return 0 - local entry - while IFS= read -r -d '' entry; do - COLLECTED+=("$entry") - done <"$file" + while IFS= read -r -d '' entry; do COLLECTED+=("$entry"); done <"$file" } -# Unset each wrapper-named provider alias listed (NUL-delimited) in a file. clear_named_aliases() { - local file="$1" + local file="$1" name [[ -s "$file" ]] || return 0 - local name while IFS= read -r -d '' name; do - if [[ "$name" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then - unset "$name" || true - fi + [[ "$name" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || fail "provider clear-list contains an invalid name" + unset "$name" || true done <"$file" } -# Return 0 if appears in the NUL-delimited clear-list file. -name_in_clear_list() { +name_in_nul_file() { local wanted="$1" file="$2" name [[ -s "$file" ]] || return 1 - while IFS= read -r -d '' name; do - [[ "$name" == "$wanted" ]] && return 0 - done <"$file" + while IFS= read -r -d '' name; do [[ "$name" == "$wanted" ]] && return 0; done <"$file" return 1 } -# Clear the provider aliases, then export ONLY the typed values from -# EDGEZERO__PROVIDER__ENV whose names are declared in the clear list. jq parses the -# JSON, so values are opaque data (never interpreted by the shell). import_provider_env() { - local clear_file="$1" - local json="${EDGEZERO__PROVIDER__ENV:-}" + local clear_file="$1" json="${EDGEZERO__PROVIDER__ENV:-}" name b64 value [[ -n "$json" ]] || json='{}' clear_named_aliases "$clear_file" - require_cmd jq require_cmd base64 - printf '%s' "$json" | jq -e 'type == "object"' >/dev/null 2>&1 || - fail "EDGEZERO__PROVIDER__ENV must be a JSON object of string values" - printf '%s' "$json" | jq -e 'all(.[]; type == "string")' >/dev/null 2>&1 || - fail "every EDGEZERO__PROVIDER__ENV value must be a string" - # A NUL cannot survive the Bash boundary: `export NAME=value` truncates at the - # first NUL, so a value carrying one would be silently altered — a credential - # that is quietly wrong is worse than one that is rejected. - printf '%s' "$json" | jq -e 'all(.[]; contains("\u0000") | not)' >/dev/null 2>&1 || - fail "EDGEZERO__PROVIDER__ENV values must not contain NUL bytes" - # A trailing CR/LF cannot survive the boundary either: the `$(…)` that decodes each - # base64 value below strips trailing newlines, so a value ending in a newline would - # be silently truncated. Reject CR/LF outright — a quietly-wrong credential is worse - # than one that is rejected. - printf '%s' "$json" | jq -e 'all(.[]; (contains("\n") or contains("\r")) | not)' >/dev/null 2>&1 || - fail "EDGEZERO__PROVIDER__ENV values must not contain CR or LF bytes" - - # One "NAME BASE64VALUE" line per entry. Base64 keeps values line-safe - # (newlines, spaces, quotes cannot break the read loop) and opaque. - local name b64 value + printf '%s' "$json" | jq -e 'type == "object" and all(.[]; type == "string" and ((contains("\u0000") or contains("\n") or contains("\r")) | not))' >/dev/null 2>&1 || + fail "EDGEZERO__PROVIDER__ENV must be a JSON object of string values without NUL, CR, or LF" while read -r name b64; do - [[ "$name" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || - fail "EDGEZERO__PROVIDER__ENV name '$name' is not a valid environment variable name" - name_in_clear_list "$name" "$clear_file" || - fail "EDGEZERO__PROVIDER__ENV name '$name' must be declared in provider-env-clear" + [[ "$name" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || fail "provider environment name is invalid" + name_in_nul_file "$name" "$clear_file" || fail "provider environment name must appear in the clear-list" value=$(printf '%s' "$b64" | base64 --decode) export "$name=$value" done < <(printf '%s' "$json" | jq -r 'to_entries[] | "\(.key) \(.value | @base64)"') } -# Build the CLI argv for `build` mode into the global ARGV array. -build_build_argv() { - local cli_bin="$1" - local adapter="$2" - ARGV=("$cli_bin" build --adapter "$adapter") - # Credential-free build: defensively drop the wrapper-named aliases. - clear_named_aliases "${EDGEZERO__PROVIDER__ENV_CLEAR_FILE:-/dev/null}" - collect_nul "${EDGEZERO__BUILD__ARGS_FILE:-/dev/null}" - if ((${#COLLECTED[@]})); then - ARGV+=(-- "${COLLECTED[@]}") - fi -} - -# Build the CLI argv for `deploy` mode into the global ARGV array. -build_deploy_argv() { - local cli_bin="$1" - local adapter="$2" - ARGV=("$cli_bin" deploy --adapter "$adapter") - # Typed adapter flags (before `--`): e.g. --service-id , --staging. - collect_nul "${EDGEZERO__DEPLOY__FLAGS_FILE:-/dev/null}" - ((${#COLLECTED[@]})) && ARGV+=("${COLLECTED[@]}") - # Caller passthrough (after `--`): allowlisted deploy-args, e.g. --comment. - collect_nul "${EDGEZERO__DEPLOY__ARGS_FILE:-/dev/null}" - if ((${#COLLECTED[@]})); then - ARGV+=(-- "${COLLECTED[@]}") - fi +PUBLIC_NAMES=() +PUBLIC_VALUES=() +capture_public_env() { + local allow_file="$1" name allowed + while IFS= read -r name; do + allowed=false + if [[ "$name" == "EDGEZERO__ADAPTER__HOST" ]] || + [[ "$name" == "EDGEZERO__ADAPTER__PORT" ]] || + [[ "$name" == "EDGEZERO__LOGGING__ENDPOINT" ]] || + [[ "$name" == "EDGEZERO__LOGGING__LEVEL" ]] || + [[ "$name" =~ ^EDGEZERO__STORES__CONFIG__[A-Z0-9_]+__(NAME|KEY)$ ]] || + [[ "$name" =~ ^EDGEZERO__STORES__(KV|SECRETS)__[A-Z0-9_]+__NAME$ ]] || + name_in_nul_file "$name" "$allow_file"; then + allowed=true + fi + if [[ "$allowed" == true ]]; then + PUBLIC_NAMES+=("$name") + PUBLIC_VALUES+=("${!name}") + fi + done < <(compgen -e) } -# Unset the action's PRIVATE environment namespace before handing control to the -# application CLI. -# -# This is a credential boundary, not tidiness. The wrapper carries the typed -# token into this script twice: once as `EDGEZERO___API_TOKEN` (so the -# step's YAML can build the JSON without interpolating a secret into a `run:` -# block), and once inside `EDGEZERO__PROVIDER__ENV` itself. Both are -# secret-bearing. Without this scrub they stay exported, so the app CLI — and -# every subprocess it spawns, including a manifest `[adapters.*.commands]` shell -# command — inherits the raw token under names we never promised, and any -# `env`-dumping build script would print it. -# -# This is why every action-owned variable lives under the double-underscore -# `EDGEZERO__` prefix: the boundary is then one rule with no list to keep in sync. -# `EDGEZERO_MANIFEST` (SINGLE underscore) is deliberately outside it — that is the -# CLI's own public contract, not ours, and it is the one variable we do pass on. -# -# This is `unset`, not a re-exec: it scrubs what the CLI INHERITS, so a token never -# appears under an unpromised name or in an accidental `env` dump. It is NOT a -# process-image boundary — on Linux this shell's original environment stays -# readable via `/proc//environ`. That is acceptable here (unlike the -# untrusted build in build-app-cli/common.sh, which re-execs with `env -u`): the -# deploy runs the app's OWN trusted CLI, which already gets the same token as -# FASTLY_API_TOKEN. Deploying means trusting that CLI with the credential. -scrub_action_private_env() { - local name +scrub_private_env() { + local name index while IFS= read -r name; do - case "$name" in - EDGEZERO__*) unset "$name" || true ;; - *) ;; - esac + if [[ "$name" == EDGEZERO__* ]]; then unset "$name" || true; fi done < <(compgen -e) + for ((index = 0; index < ${#PUBLIC_NAMES[@]}; index++)); do + name="${PUBLIC_NAMES[$index]}" + export "$name=${PUBLIC_VALUES[$index]}" + done } -ARGV=() main() { - local mode="${1:-}" - case "$mode" in - build | deploy) ;; - *) fail "usage: run-app-cli.sh build|deploy" ;; - esac - - local cli_bin - cli_bin=$(resolve_app_cli) - local adapter="${EDGEZERO__ADAPTER:?EDGEZERO__ADAPTER is required}" - local working_directory="${EDGEZERO__PROJECT__WORKING_DIRECTORY:?EDGEZERO__PROJECT__WORKING_DIRECTORY is required}" + local cli="${EDGEZERO__APP__CLI__PATH:-}" + local args_file="${EDGEZERO__APP__CLI__ARGS_FILE:-}" + local mutates="${EDGEZERO__APP__CLI__MUTATES:-}" + local workspace="${EDGEZERO__ACTION__WORKSPACE:-}" + local working_directory="${EDGEZERO__PROJECT__WORKING_DIRECTORY:-$PWD}" local manifest="${EDGEZERO__PROJECT__MANIFEST_PATH:-}" - require_cmd "$cli_bin" - - case "$mode" in - build) build_build_argv "$cli_bin" "$adapter" ;; - deploy) - # 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" - ;; - esac - - # Everything the action needed from its own env is now in locals or in ARGV. - scrub_action_private_env - - if [[ -n "$manifest" ]]; then - export EDGEZERO_MANIFEST="$manifest" - else - unset EDGEZERO_MANIFEST || true - fi - + local clear_file="${EDGEZERO__PROVIDER__ENV_CLEAR_FILE:-/dev/null}" + local allow_file="${EDGEZERO__PUBLIC_RUNTIME_ENV_ALLOW_FILE:-/dev/null}" + require_input application-cli "$cli" + require_input application-cli-args-file "$args_file" + [[ -f "$cli" && ! -L "$cli" && -x "$cli" ]] || fail "application CLI must be an executable regular file" + [[ -d "$workspace" ]] || fail "action workspace must be a directory" + local workspace_real + workspace_real=$(canonical_path "$workspace") + local cli_real + cli_real=$(canonical_path "$cli") + is_under "$workspace_real" "$cli_real" || + fail "application CLI must resolve beneath the action workspace" + local file real + for file in "$args_file" "$clear_file" "$allow_file"; do + [[ "$file" == /dev/null ]] && continue + [[ -f "$file" && ! -L "$file" ]] || fail "application CLI control file must be a regular file" + real=$(canonical_path "$file") + is_under "$workspace_real" "$real" || fail "application CLI control file must resolve beneath the action workspace" + done + [[ "$clear_file" == /dev/null ]] || validate_nul_file "$clear_file" "provider clear-list" + [[ "$allow_file" == /dev/null ]] || validate_nul_file "$allow_file" "public runtime allow-list" + case "$mutates" in true | false) ;; *) fail "application CLI mutation setting must be true or false" ;; esac + [[ -d "$working_directory" ]] || fail "application CLI working directory must exist" + collect_nul "$args_file" + local -a argv=("$cli" "${COLLECTED[@]}") + import_provider_env "$clear_file" + capture_public_env "$allow_file" + scrub_private_env + if [[ -n "$manifest" ]]; then export EDGEZERO_MANIFEST="$manifest"; else unset EDGEZERO_MANIFEST || true; fi cd "$working_directory" - # Publish the reconcile signal for a MUTATING invocation HERE — after all setup - # succeeded (binary resolve, credential import, cd) and immediately before the - # CLI runs. Writing it now, from the launcher itself, means a setup failure ABOVE - # never falsely claims the CLI ran, and because it lands in GITHUB_OUTPUT before - # the mutation starts it CAN survive a cancel/timeout mid-mutation — best-effort, - # not guaranteed: a hard runner loss can still drop it, so its absence is not - # proof of no mutation (the reconcile contract is in the guide). `build` is - # credential-free and mutates nothing, so it is not signalled. - if [[ "$mode" == "deploy" ]]; then - append_output mutation-attempted true - fi - echo "[edgezero-action] running $cli_bin $mode for adapter $adapter" >&2 - "${ARGV[@]}" + [[ "$mutates" == false ]] || append_output mutation-attempted true + echo "[edgezero-action] running verified application CLI" >&2 + "${argv[@]}" } main "$@" diff --git a/.github/actions/deploy-core/scripts/validate-inputs.sh b/.github/actions/deploy-core/scripts/validate-inputs.sh index 9aa215d1..4f1e8e33 100755 --- a/.github/actions/deploy-core/scripts/validate-inputs.sh +++ b/.github/actions/deploy-core/scripts/validate-inputs.sh @@ -107,7 +107,7 @@ main() { # Well-formedness only: the CLI decides whether the adapter is supported. [[ -n "$adapter" ]] || fail "internal parameter 'adapter' is required" - [[ "$adapter" =~ ^[a-z][a-z0-9-]*$ ]] || fail "adapter '$adapter' is malformed; expected a lowercase token like 'fastly'" + [[ "$adapter" =~ ^[a-z][a-z0-9-]*$ ]] || fail "adapter '$adapter' is malformed; expected a lowercase token like 'provider'" case "$build_mode" in auto | always | never) ;; @@ -152,9 +152,8 @@ main() { # Action-owned passthrough args are prepended AFTER the allowlist check, # because they are not caller input — the wrapper supplies them to make the - # deploy safe in CI (for Fastly: `--non-interactive`, which the built-in - # deploy path adds for itself but a manifest `[adapters.fastly.commands] - # deploy = "fastly compute deploy"` override would otherwise never get, so the + # deploy safe in CI (for example `--non-interactive`, which a built-in deploy + # path may add itself but a manifest command override may omit, so the # deploy could block on a TTY prompt). They go first so a caller arg can still # override them where the provider CLI takes last-wins. local prepend_file="$state_dir/deploy-args-prepend.nul" diff --git a/.github/actions/deploy-core/tests/assert-lost-version.sh b/.github/actions/deploy-core/tests/assert-lost-version.sh deleted file mode 100755 index 2fc3fb61..00000000 --- a/.github/actions/deploy-core/tests/assert-lost-version.sh +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# The lost-version deploy must FAIL (no version to thread) yet still signal that a -# mutation may have occurred, so an operator knows to reconcile. It must also have -# actually reached the provider deploy command (the fixture records that). -# -# Reads (env): -# GITHUB_WORKSPACE -# EDGEZERO__TEST__DEPLOY_OUTCOME the deploy step's outcome -# EDGEZERO__TEST__MUTATION_ATTEMPTED the deploy's mutation-attempted output -# EDGEZERO__TEST__PREVIOUS_VERSION the deploy's previous-version output - -SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) -# shellcheck source=../scripts/common.sh -source "$SCRIPT_DIR/../scripts/common.sh" - -main() { - local workspace="${GITHUB_WORKSPACE:?GITHUB_WORKSPACE is required}" - local outcome="${EDGEZERO__TEST__DEPLOY_OUTCOME:-}" - local mutation="${EDGEZERO__TEST__MUTATION_ATTEMPTED:-}" - local previous="${EDGEZERO__TEST__PREVIOUS_VERSION:-}" - - [[ "$outcome" == "failure" ]] || - fail "the lost-version deploy should have FAILED, but its outcome was '$outcome'" - - [[ "$mutation" == "true" ]] || - fail "a failed-but-mutating deploy must still emit mutation-attempted=true, got '$mutation'" - - # The rollback target captured before the deploy must survive the failure so - # recovery can thread it — the smoke rolls back to exactly this value. - [[ "$previous" == "40" ]] || - fail "the failed deploy must still expose previous-version=40 (the captured rollback target), got '${previous:-}'" - - # The deploy really reached the provider command (which recorded the env it saw). - [[ -f "$workspace/fixture-app/env-seen.txt" ]] || - fail "the deploy never reached the app CLI's Fastly deploy command" - - notice "lost-version deploy failed as expected, with mutation-attempted=true" -} - -main "$@" diff --git a/.github/actions/deploy-core/tests/assert-production-deploy.sh b/.github/actions/deploy-core/tests/assert-production-deploy.sh deleted file mode 100755 index 4d7893fc..00000000 --- a/.github/actions/deploy-core/tests/assert-production-deploy.sh +++ /dev/null @@ -1,65 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Asserts the production deploy path end to end: build-app-cli -> deploy-fastly -> -# the app-owned CLI -> the manifest's overridden Fastly deploy command. -# -# Also asserts the provider-env credential boundary: the typed inputs reach the -# deploy, inherited aliases are CLEARED, and the action's own secret-bearing -# helper variables do NOT survive into the CLI's environment. -# -# Reads (env): -# GITHUB_WORKSPACE required checkout root (holds the smoke fixture output) -# EDGEZERO__TEST__FASTLY_VERSION required the production deploy's fastly-version output -# EDGEZERO__TEST__PREVIOUS_VERSION required the captured rollback target (previous-version) - -SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) -# shellcheck source=../scripts/common.sh -source "$SCRIPT_DIR/../scripts/common.sh" - -main() { - local workspace="${GITHUB_WORKSPACE:?GITHUB_WORKSPACE is required}" - local version_out="${EDGEZERO__TEST__FASTLY_VERSION:-}" - local previous_out="${EDGEZERO__TEST__PREVIOUS_VERSION:-}" - local env_seen="$workspace/fixture-app/env-seen.txt" - local argv="$workspace/fixture-app/deploy-argv.txt" - - [[ -f "$argv" && -f "$env_seen" ]] || - fail "the deploy never reached the app CLI's Fastly deploy command" - echo "recorded argv:" - cat "$argv" - echo "environment the deploy saw:" - cat "$env_seen" - - [[ "$version_out" == "7" ]] || - fail "expected fastly-version=7 out of the action, got '${version_out:-}'" - - # The rollback target was captured BEFORE the deploy via `active-version`: the - # fake Fastly API reports version 40 active, so previous-version must be 40. A - # non-zero active-version exit would have failed the deploy closed instead. - [[ "$previous_out" == "40" ]] || - fail "expected previous-version=40 (the captured rollback target), got '${previous_out:-}'" - - # The action supplies --non-interactive itself, so a manifest-command deploy - # (this fixture is one) cannot block on a TTY prompt in CI. - grep -qx -- '--non-interactive' "$argv" || - fail "the action-owned --non-interactive never reached the deploy command" - - # The provider-env boundary: typed values in, inherited aliases out, and none - # of the action's private secret carriers left behind. - local expected - for expected in \ - 'token=dummy-token' \ - 'service-id=dummyservice' \ - 'endpoint=CLEARED' \ - 'home=CLEARED' \ - 'action-token-carrier=CLEARED' \ - 'provider-env-json=CLEARED'; do - grep -qx -- "$expected" "$env_seen" || - fail "credential boundary violated: expected '$expected' in env-seen.txt" - done - - notice "production deploy, version threading, and the credential boundary all hold" -} - -main "$@" diff --git a/.github/actions/deploy-core/tests/assert-staged-calls.sh b/.github/actions/deploy-core/tests/assert-staged-calls.sh deleted file mode 100755 index 10539e2d..00000000 --- a/.github/actions/deploy-core/tests/assert-staged-calls.sh +++ /dev/null @@ -1,114 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Asserts the exact Fastly call sequence a STAGED deploy through the -# deploy-fastly wrapper must produce, and that the staged version threaded out -# of the action: -# * `--comment` must NOT reach `fastly compute update` (it has no such flag); -# it is applied via `fastly service-version update --comment` BEFORE the -# version is staged. -# * `--non-interactive` is supplied as an action-owned passthrough arg, so a -# manifest-command deploy cannot block on a TTY prompt in CI. -# * The staged upload clones the active version. -# -# Reads (env): -# FAKE_CALL_LOG required the fake fastly/curl call log -# EDGEZERO__TEST__STAGED_VERSION required the version the staged deploy produced - -SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) -# shellcheck source=../scripts/common.sh -source "$SCRIPT_DIR/../scripts/common.sh" - -assert_update_flags() { - local update="$1" flag - for flag in --autoclone --version=active --non-interactive --service-id; do - [[ "$update" == *"$flag"* ]] || - fail "'compute update' is missing $flag (got: $update)" - done -} - -assert_no_comment_on_update() { - local log="$1" - if grep -qE '^fastly compute update .*--comment' "$log"; then - fail "--comment was forwarded to 'compute update', which does not support it" - fi -} - -assert_comment_precedes_stage() { - local log="$1" comment_line stage_line - comment_line=$(grep -nE '^fastly service-version update .*--comment' "$log" | head -n 1 | cut -d: -f1) - stage_line=$(grep -nE '^fastly service-version stage ' "$log" | head -n 1 | cut -d: -f1) - - [[ -n "$comment_line" ]] || fail "the comment was never applied via 'service-version update'" - [[ -n "$stage_line" ]] || fail "the version was never staged" - [[ "$comment_line" -lt "$stage_line" ]] || - fail "the comment was applied after staging; it must precede it" -} - -# The staging twin must MIRROR this service's production runtime overrides: the -# scoped logging level is copied verbatim and the scoped config selector is -# redirected to `_staging`, both written into the twin (STAGESEL1) -# before the relink. Without the mirror the staged version would lose its -# production logging override. -assert_twin_mirrors_production() { - local log="$1" - grep -qE '^fastly config-store-entry update .*--store-id=STAGESEL1 .*--key=EDGEZERO__SERVICES__dummyservice__LOGGING__LEVEL' "$log" || - fail "production's non-config override was not mirrored into the staging twin" - grep -qE '^fastly config-store-entry update .*--store-id=STAGESEL1 .*--key=EDGEZERO__SERVICES__dummyservice__STORES__CONFIG__APP_CONFIG__KEY' "$log" || - fail "the config selector was not written into the staging twin" - - # The mirror must land before the relink points the draft at the twin. - local mirror_line create_line - mirror_line=$(grep -nE '^fastly config-store-entry update .*--store-id=STAGESEL1' "$log" | head -n 1 | cut -d: -f1) - create_line=$(grep -n '^fastly resource-link create ' "$log" | head -n 1 | cut -d: -f1) - if [[ -n "$mirror_line" && -n "$create_line" ]] && ((mirror_line >= create_line)); then - fail "the twin must be mirrored BEFORE the draft is relinked to it" - fi -} - -# The staged draft must be re-pointed at the STAGING selector store, or it reads -# production config and `config push --staging` writes a key nothing reads. The -# link name stays `edgezero_runtime_env` (what the runtime opens); only the store -# behind it changes. -assert_relinked_to_staging_selector() { - local log="$1" - grep -qE '^fastly resource-link delete .*--id=LINK_ENV( |$)' "$log" || - fail "the staged deploy never dropped the inherited 'edgezero_runtime_env' link" - grep -qE '^fastly resource-link create .*--resource-id=STAGESEL1 .*--name=edgezero_runtime_env( |$)' "$log" || - fail "the staged deploy never linked the staging selector store as 'edgezero_runtime_env'" - - # Both must land while the version is still an editable draft. - local create_line stage_line - create_line=$(grep -n '^fastly resource-link create ' "$log" | head -n 1 | cut -d: -f1) - stage_line=$(grep -n '^fastly service-version stage ' "$log" | head -n 1 | cut -d: -f1) - if [[ -n "$create_line" && -n "$stage_line" ]] && ((create_line >= stage_line)); then - fail "the staging relink must happen BEFORE the version is staged" - fi -} - -main() { - local log="${FAKE_CALL_LOG:?FAKE_CALL_LOG is required}" - local staged_version="${EDGEZERO__TEST__STAGED_VERSION:-}" - - echo "--- recorded fastly/curl calls:" - cat "$log" - - local update - update=$(grep -E '^fastly compute update ' "$log" | head -n 1 || true) - [[ -n "$update" ]] || fail "the staged deploy never ran 'fastly compute update'" - - assert_update_flags "$update" - assert_no_comment_on_update "$log" - assert_comment_precedes_stage "$log" - assert_twin_mirrors_production "$log" - assert_relinked_to_staging_selector "$log" - - # The staged version must thread out of deploy-fastly, or the healthcheck and - # rollback that follow have nothing to act on. - [[ "$staged_version" == "42" ]] || - fail "expected fastly-version=42 out of the staged deploy, got '${staged_version:-}'" - - notice "staged call sequence is correct and fastly-version=$staged_version threaded out" -} - -main "$@" diff --git a/.github/actions/deploy-core/tests/make-fake-fastly-env.sh b/.github/actions/deploy-core/tests/make-fake-fastly-env.sh deleted file mode 100755 index e10af721..00000000 --- a/.github/actions/deploy-core/tests/make-fake-fastly-env.sh +++ /dev/null @@ -1,289 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Installs fake `fastly` and `curl` binaries for the lifecycle smoke test, plus a -# call log the assertions read back. -# -# The fakes mirror the REAL contracts the adapter depends on, so the smoke test -# exercises the exact call shapes that matter: -# * `fastly compute update` must NOT receive --comment (it has no such flag); -# the comment goes through `fastly service-version update` BEFORE -# `service-version stage`. -# * `compute update` output must be a realistic success line, because the -# version parser is fail-closed and refuses to guess. -# * The Fastly domain API returns a SINGULAR `staging_ip` string. -# * activate/deactivate are PUT, and staging deactivate is /deactivate/staging. -# -# The fake `fastly` is packaged as a tar.gz at install-fastly.sh's cache path and -# the checked-out `versions.json` is repointed at it with a matching SHA-256, so -# install-fastly.sh VERIFIES and extracts the fake through its real -# download+checksum+extract path — never adopting a planted binary. That lets the -# staged path be exercised through the real deploy-fastly wrapper while keeping -# the installer's provenance guarantee intact. The fake `curl` goes on PATH, -# which nothing reinstalls. -# -# The fake binaries write their call log to FAKE_CALL_LOG and read FORCE_UNHEALTHY. -# These are deliberately OUTSIDE the EDGEZERO__ namespace: the app CLI scrubs -# every EDGEZERO__* var before exec, and these must survive that scrub because -# the fake fastly/curl are spawned BY the app CLI and read them there. -# -# Reads (env): GITHUB_WORKSPACE, GITHUB_PATH, GITHUB_ENV, RUNNER_TEMP. -# Writes (env): FAKE_CALL_LOG (the call-log path). - -SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) -# shellcheck source=../scripts/common.sh -source "$SCRIPT_DIR/../scripts/common.sh" - -write_fake_fastly() { - local path="$1" version="$2" - cat >"$path" <>"\$FAKE_CALL_LOG" -case "\${1:-} \${2:-}" in - "version ") echo "Fastly CLI version v$version (fake)" ;; - "compute build") echo "Built package (fixture)" ;; - "compute update") - # A realistic success line: the version parser is fail-closed and will - # refuse to stage if it cannot read a version out of this output. - echo "SUCCESS: Updated package (service dummyservice, version 42)" - ;; - "compute deploy") echo "SUCCESS: Deployed package (service dummyservice, version 43)" ;; - "service-version update") echo "Updated version comment" ;; - "service-version stage") echo "Staged version" ;; - # An app WITH config selection: the app config store, the production selector - # store edgezero_runtime_env (so a staged deploy relinks rather than skipping), - # and its staging twin (the store the relink points at). config push resolves a - # store id by name from this list, reads the current entry to diff, then upserts. - "config-store list") echo '[{"id":"STOREID1","name":"app_config"},{"id":"ENVSEL1","name":"edgezero_runtime_env"},{"id":"STAGESEL1","name":"edgezero_runtime_env_staging_dummyservice"}]' ;; - # A cloned draft inherits the active version's links; the staged deploy drops - # this one and re-links the staging store under the same name. - "resource-link list") echo '[{"id":"LINK_ENV","name":"edgezero_runtime_env"}]' ;; - "resource-link delete") echo "SUCCESS: Deleted resource link" ;; - "resource-link create") echo "SUCCESS: Created resource link" ;; - "config-store-entry describe") - # Report the key as absent so the push proceeds to a first write. The real - # CLI distinguishes "missing" from "unparseable" — returning nothing at all - # is a parse error, not an absent key. - echo "Error: config store entry not found" >&2 - exit 1 - ;; - "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. - case "\$*" in - *--store-id=ENVSEL1*) echo '[{"item_key":"EDGEZERO__SERVICES__dummyservice__LOGGING__LEVEL","item_value":"debug"}]' ;; - *) echo '[]' ;; - esac - ;; - "config-store-entry update") echo "SUCCESS: Updated config store entry" ;; - "config-store-entry delete") echo "SUCCESS: Deleted config store entry" ;; - *) - case "\${1:-}" in - version | --version) echo "Fastly CLI version v$version (fake)" ;; - # An UNHANDLED command must fail: an unexpected provider call (a new command - # the code started issuing) should break the smoke, not pass silently. - *) echo "fake fastly: unhandled command: \$*" >&2; exit 90 ;; - esac - ;; -esac -exit 0 -SHIM - chmod +x "$path" -} - -write_fake_curl() { - local path="$1" - cat >"$path" <<'SHIM' -#!/usr/bin/env bash -# install-fastly.sh downloads the (fake) archive with -# `curl … --output `. Each invocation now uses a UNIQUE per-run -# tool root (mktemp -d), so the archive is not pre-placed there — serve the -# download by copying the file:// source to the output path, keeping the real -# download+checksum+extract path intact. -_out="" -_url="" -_prev="" -for _a in "$@"; do - [ "$_prev" = "--output" ] && _out="$_a" - case "$_a" in file://*) _url="$_a" ;; esac - _prev="$_a" -done -if [ -n "$_out" ]; then - cp "${_url#file://}" "$_out" - exit 0 -fi - -# The versions this fake service has. The ACTIVE one is tracked separately (in -# FAKE_ACTIVE_VERSION_FILE) and is always considered to exist. -FAKE_KNOWN_VERSIONS="1 38 39 40 41 42" - -fake_active_version() { - local active - active=$(cat "${FAKE_ACTIVE_VERSION_FILE:-/dev/null}" 2>/dev/null || true) - printf '%s' "${active:-40}" -} - -fake_version_exists() { - local want="$1" active - active=$(fake_active_version) - case " $FAKE_KNOWN_VERSIONS $active " in - *" $want "*) return 0 ;; - *) return 1 ;; - esac -} - -# Render the version list the Fastly API would return: every known version, with -# `active: true` on exactly the current one. -fake_version_list_json() { - local active out="" sep="" n flag - active=$(fake_active_version) - local all="$FAKE_KNOWN_VERSIONS" - case " $all " in *" $active "*) ;; *) all="$all $active" ;; esac - for n in $all; do - if [ "$n" = "$active" ]; then flag=true; else flag=false; fi - out="$out$sep{\"number\":$n,\"active\":$flag}" - sep="," - done - printf '[%s]' "$out" -} - -# Two shapes: a Fastly API call via `--config -` (config on stdin), or a probe. -if [[ "$*" == *"--config"* ]]; then - config=$(cat) - url=$(printf '%s\n' "$config" | sed -nE 's/^url = "(.*)"$/\1/p') - if printf '%s\n' "$config" | grep -q '^request = "PUT"$'; then - printf 'PUT %s\n' "$url" >>"$FAKE_CALL_LOG" - case "$url" in - */version/*/activate) - activated="${url##*/version/}" - activated="${activated%%/activate}" - # A real API rejects activating a version the service does not have, so - # the fixture must too — otherwise a smoke could "succeed" against a - # version that never existed. - if ! fake_version_exists "$activated"; then - printf 'PUT-REJECTED %s (no such version)\n' "$url" >>"$FAKE_CALL_LOG" - echo 404 - exit 0 - fi - # Model reality: activating version N makes N the active version, so a - # later read (e.g. another rollback's staleness check) sees the mutation. - if [ -n "${FAKE_ACTIVE_VERSION_FILE:-}" ]; then - printf '%s\n' "$activated" >"$FAKE_ACTIVE_VERSION_FILE" - fi - ;; - esac - echo 200 - exit 0 - fi - printf 'GET %s\n' "$url" >>"$FAKE_CALL_LOG" - # fastly_api_get appends `write-out = "\n%{http_code}"`, so the real curl emits - # `\n` and the caller requires a 2xx. Mirror that: body, then a - # trailing `\n200`, with NO trailing newline after the code. - # - # The service-version list. The ACTIVE version is read from a state file so the - # smoke can model reality: it is 40 before the production deploy (rollback-target - # capture), and a deploy (or a test step) updates it. The production-rollback - # best-effort staleness guard requires the active version to equal the `--version` - # being rolled back from. Every version the fixture may activate is listed, so a - # rollback target is a version the service actually has. - if [[ "$url" == */version ]]; then - # Recovery smoke: a broken-API sentinel makes active-version resolution fail, so - # a lost-version deploy cannot recover the version. Absent otherwise, so this is - # inert for every other smoke. - if [[ -n "${FAKE_API_BREAK_FILE:-}" && -f "$FAKE_API_BREAK_FILE" ]]; then - printf 'simulated Fastly API failure\n500' - exit 0 - fi - printf '%s\n200' "$(fake_version_list_json)" - exit 0 - fi - # Domain lookup: Fastly returns a SINGULAR `staging_ip` string per domain. - printf '[{"name":"staging.example.com","staging_ip":"151.101.2.10"}]\n200' - exit 0 -fi -printf 'PROBE %s\n' "$*" >>"$FAKE_CALL_LOG" -# Record whether a provider token was in scope for this probe. A PRODUCTION -# healthcheck just curls the public domain and must receive NO token, even when -# one is inherited from the job env; a staging probe needs one (staging-IP -# resolution). The assertions read this back. -printf 'PROBE-TOKEN=%s\n' "${FASTLY_API_TOKEN:+set}" >>"$FAKE_CALL_LOG" -if [[ -n "${FORCE_UNHEALTHY:-}" ]]; then - echo 503 -else - echo 200 -fi -exit 0 -SHIM - chmod +x "$path" -} - -main() { - local workspace="${GITHUB_WORKSPACE:?GITHUB_WORKSPACE is required}" - local runner_temp="${RUNNER_TEMP:?RUNNER_TEMP is required}" - local action_dir - action_dir=$(cd -- "$SCRIPT_DIR/../../deploy-fastly" && pwd) - local path_dir="$workspace/fake-bin" - # install-fastly.sh extracts the provider CLI from a checksum-verified archive - # into `/provider-bin`, caching the archive under `downloads/`. - # Deliver the fake THROUGH that verified path (not by planting a binary), so the - # smoke exercises the real download+verify+extract and never relies on a bypass. - local downloads="$runner_temp/edgezero-action-tools/downloads" - local log="$workspace/fake-calls.log" - - mkdir -p "$path_dir" "$downloads" - : >"$log" - - local pinned - pinned=$(json_get "$action_dir/versions.json" fastly.version) - - # NOTE: the installer's "always re-extract, never adopt a pre-existing binary" - # provenance guard is no longer exercised by planting a binary here. Each action - # invocation now installs into a UNIQUE mktemp workspace, so its provider-bin is - # always fresh — there is nothing to adopt, and this fixture cannot predict the - # path to plant into. The guarantee still holds structurally: install-fastly.sh - # extracts from the checksum-verified archive on every run (see it there). - - # Package a fake `fastly` as the checksum-verified archive install-fastly.sh - # downloads. It lives at the fixed downloads path and is served to each - # invocation's unique tool root by the fake `curl`'s file:// copy above. - local stage archive sha - stage=$(mktemp -d) - write_fake_fastly "$stage/fastly" "$pinned" - archive="$downloads/fastly-$pinned-linux-amd64.tar.gz" - tar -C "$stage" -czf "$archive" fastly - sha=$(sha256_file "$archive") - - # Repoint the CHECKED-OUT versions.json (what the local action reads) at the - # fake archive with its real checksum, so install-fastly.sh verifies and - # extracts the fake. The version stays pinned, so the `.tool-versions` - # agreement check still holds. This modifies only the job's checkout, never a - # committed file — production reads the real, pinned versions.json. - local patched - patched=$(mktemp) - jq --arg url "file://$archive" --arg sha "$sha" \ - '.fastly.linux_amd64.url = $url | .fastly.linux_amd64.sha256 = $sha' \ - "$action_dir/versions.json" >"$patched" - mv "$patched" "$action_dir/versions.json" - - write_fake_curl "$path_dir/curl" - - # The active version the fake Fastly API reports, in a file so a deploy or a - # test step can update it (see the production-rollback guard). Starts at 40 — - # the version rollback-target capture sees BEFORE the first production deploy. - local active_state="$workspace/fake-active-version" - printf '40\n' >"$active_state" - - printf '%s\n' "$path_dir" >>"${GITHUB_PATH:?GITHUB_PATH is required}" - { - printf 'FAKE_CALL_LOG=%s\n' "$log" - printf 'FAKE_ACTIVE_VERSION_FILE=%s\n' "$active_state" - # The recovery smoke touches this path to break active-version resolution; it is - # not created here, so every other smoke sees a working API. - printf 'FAKE_API_BREAK_FILE=%s\n' "$workspace/fake-api-break" - } >>"${GITHUB_ENV:?GITHUB_ENV is required}" - - notice "fake fastly (v$pinned) packaged as a checksum-verified archive at $archive; fake curl on PATH" -} - -main "$@" diff --git a/.github/actions/deploy-core/tests/make-smoke-fixture.sh b/.github/actions/deploy-core/tests/make-smoke-fixture.sh deleted file mode 100755 index c1bd3619..00000000 --- a/.github/actions/deploy-core/tests/make-smoke-fixture.sh +++ /dev/null @@ -1,220 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Builds the fixture the composite smoke test deploys. -# -# This is a REAL app-owned CLI: a standalone Cargo workspace (kept out of the -# surrounding edgezero workspace) whose own crate depends on `edgezero-cli` and -# exposes deploy / healthcheck / rollback. That exercises the actual contract — -# "the application provides the CLI package" — instead of building the monorepo's -# own CLI. -# -# The Fastly deploy command is overridden by a marker script that emits -# `version=` (version threading), records the credentials it actually saw -# (provider-env boundary), and records its argv — all without contacting Fastly. -# -# Inputs (environment): GITHUB_WORKSPACE (required). - -main() { - local workspace="${GITHUB_WORKSPACE:?GITHUB_WORKSPACE is required}" - local app_dir="$workspace/fixture-app" - - mkdir -p "$app_dir/crates/fixture-app-cli/src" - cd "$app_dir" - - git init -q - git config user.email test@example.com - git config user.name Test - - # Standalone workspace: not a member of the surrounding edgezero workspace. - cat >Cargo.toml <<'TOML' -[workspace] -members = ["crates/fixture-app-cli"] -resolver = "2" -TOML - - # The app's OWN CLI crate, built on edgezero-cli (path dep into the checkout). - cat >crates/fixture-app-cli/Cargo.toml <<'TOML' -[package] -name = "fixture-app-cli" -version = "0.1.0" -edition = "2021" - -[[bin]] -name = "fixture-app-cli" -path = "src/main.rs" - -[dependencies] -edgezero-cli = { path = "../../../crates/edgezero-cli", default-features = false, features = [ - "cli", - "edgezero-adapter-fastly", -] } -clap = { version = "4", features = ["derive"] } -edgezero-core = { path = "../../../crates/edgezero-core" } -serde = { version = "1", features = ["derive"] } -validator = { version = "0.20", features = ["derive"] } -TOML - - cat >crates/fixture-app-cli/src/main.rs <<'RS' -//! Fixture app CLI: the smoke test's stand-in for an application-owned CLI. -//! -//! It wires the TYPED `config push` (not the bundled stub), because that is the -//! contract config-push-fastly depends on: only an app-owned CLI has the -//! app-config struct, so only it can push typed config. -use clap::{Parser, Subcommand}; -use edgezero_cli::args::{ - ActiveVersionArgs, BuildArgs, ConfigPushArgs, DeployArgs, HealthcheckArgs, RollbackArgs, -}; -use serde::{Deserialize, Serialize}; -use validator::Validate; - -/// The fixture's typed app config, loaded from `fixture-app.toml`. -#[derive(Debug, Deserialize, Serialize, Validate, edgezero_core::AppConfig)] -#[serde(deny_unknown_fields)] -struct FixtureAppConfig { - greeting: String, -} - -#[derive(Parser, Debug)] -#[command(name = "fixture-app-cli", version, about = "fixture app edge CLI")] -struct Args { - #[command(subcommand)] - cmd: Cmd, -} - -#[derive(Subcommand, Debug)] -enum Cmd { - #[command(subcommand)] - Config(ConfigCmd), - Build(BuildArgs), - Deploy(DeployArgs), - Healthcheck(HealthcheckArgs), - ActiveVersion(ActiveVersionArgs), - Rollback(RollbackArgs), -} - -#[derive(Subcommand, Debug)] -enum ConfigCmd { - Push(ConfigPushArgs), -} - -fn main() { - edgezero_cli::init_cli_logger(); - let result = match Args::parse().cmd { - Cmd::Config(ConfigCmd::Push(args)) => { - edgezero_cli::run_config_push_typed::(&args) - } - Cmd::Build(args) => edgezero_cli::run_build(&args), - Cmd::Deploy(args) => edgezero_cli::run_deploy(&args), - Cmd::Healthcheck(args) => edgezero_cli::run_healthcheck(&args), - Cmd::ActiveVersion(args) => edgezero_cli::run_active_version(&args), - Cmd::Rollback(args) => edgezero_cli::run_rollback(&args), - }; - if let Err(err) = result { - eprintln!("[fixture-app] {err}"); - std::process::exit(2); - } -} -RS - - # Marker "deploy" the CLI runs instead of `fastly compute deploy`. It records - # the credentials it saw and its argv, and emits a version line. - cat >fake-deploy.sh <<'SH' -#!/usr/bin/env bash -{ - printf 'token=%s\n' "${FASTLY_API_TOKEN:-MISSING}" - printf 'service-id=%s\n' "${FASTLY_SERVICE_ID:-MISSING}" - # Boundary: inherited provider aliases must have been cleared... - printf 'endpoint=%s\n' "${FASTLY_ENDPOINT:-CLEARED}" - printf 'home=%s\n' "${FASTLY_HOME:-CLEARED}" - # ...and the action's own secret-bearing helpers must NOT have survived into - # this process: they carry the raw token under names we never promised. - printf 'action-token-carrier=%s\n' "${EDGEZERO__FASTLY__API_TOKEN:-CLEARED}" - printf 'provider-env-json=%s\n' "${EDGEZERO__PROVIDER__ENV:-CLEARED}" -} >"${GITHUB_WORKSPACE}/fixture-app/env-seen.txt" -printf '%s\n' "$@" >"${GITHUB_WORKSPACE}/fixture-app/deploy-argv.txt" -# Reflect the activation in the fake Fastly API's state: this "deploy" makes -# version 7 the active one, so the production-rollback guard (which requires the -# rolled-back-from --version to still be active) sees 7 rather than the 40 that -# capture saw before this deploy. -[ -n "${FAKE_ACTIVE_VERSION_FILE:-}" ] && printf '7\n' >"${FAKE_ACTIVE_VERSION_FILE}" -# Recovery smoke: model a MUTATION whose version line is LOST. The service is now -# at 7 (activated above), but the deploy emits no parseable `version=`, so -# deploy-fastly fails while `mutation-attempted` stays true. FAKE_LOSE_VERSION is -# outside the EDGEZERO__* namespace, so it survives the pre-exec scrub. -if [ -n "${FAKE_LOSE_VERSION:-}" ]; then - # The mutation already happened (active=7 above). Now BREAK the provider API so - # the CLI's version-resolution fallback (active-version) also fails — the version - # is truly lost and deploy-fastly fails. This sentinel is created HERE, during the - # deploy, so the rollback-target capture that ran BEFORE the deploy still saw a - # working API. Recovery removes the sentinel and asks the API what is live now. - [ -n "${FAKE_API_BREAK_FILE:-}" ] && : >"${FAKE_API_BREAK_FILE}" - echo "deploy mutated the service but its version line was lost" >&2 - exit 0 -fi -echo "version=7" -SH - chmod +x fake-deploy.sh - - # A credential-free "build" the cache-seed step runs under build-mode: always. It - # does no real compile — it just populates target/ (the cache path) so there is - # something to save, and it is IDEMPOTENT: if the marker already exists (restored - # from cache) it leaves it untouched, which is how the cache smoke proves a restore - # HIT rather than a rebuild. It must run WITHOUT a provider token. - cat >fake-build.sh <<'SH' -#!/usr/bin/env bash -set -euo pipefail -[ -z "${FASTLY_API_TOKEN:-}" ] || { - echo "cache-seed build must run without a provider token" >&2 - exit 91 -} -mkdir -p target -# Idempotent: only stamp a fresh marker when one was NOT restored from the cache. -if [ ! -f target/fixture-build-marker ]; then - printf 'built-%s\n' "$RANDOM$RANDOM" >target/fixture-build-marker -fi -echo "Built package (fixture cache seed)" -SH - chmod +x fake-build.sh - - cat >edgezero.toml <<'ETOML' -[app] -name = "fixture-app" - -[adapters.fastly.commands] -build = "bash fake-build.sh" -deploy = "bash fake-deploy.sh" - -# config push resolves this logical id, then the Fastly adapter matches it by -# name against `fastly config-store list --json`. -[stores.config] -ids = ["app_config"] -default = "app_config" -ETOML - - # The typed app config `config push` reads (named from `[app].name`). - cat >fixture-app.toml <<'ATOML' -greeting = "hello from the fixture" -ATOML - - # The staged-deploy path bypasses manifest commands and drives the Fastly CLI, - # so it needs a Fastly manifest to resolve its working directory. - cat >fastly.toml <<'FTOML' -manifest_version = 3 -name = "fixture-app" -language = "rust" -FTOML - - # Ignore build output AND the fake deploy's side-effect files (env-seen.txt, - # deploy-argv.txt) so they never dirty the source and trip the committed-source - # guard. This matters for the cache smoke, which deploys TWICE: the first deploy - # writes these, and the second deploy's guard would otherwise see a dirty tree. - printf 'target/\nenv-seen.txt\ndeploy-argv.txt\n' >.gitignore - - cargo generate-lockfile - - git add -A - git commit -q -m fixture -} - -main "$@" diff --git a/.github/actions/deploy-core/tests/run.sh b/.github/actions/deploy-core/tests/run.sh index afd3aea4..c2c51173 100755 --- a/.github/actions/deploy-core/tests/run.sh +++ b/.github/actions/deploy-core/tests/run.sh @@ -3,7 +3,7 @@ set -euo pipefail # Contract tests for the EdgeZero deploy actions. # -# Pure Bash: no Python, no network, no live provider credentials. Every test +# Bash test harness with no network or live provider credentials. Every test # runs against temp dirs and fake binaries, so it is safe in CI and locally. REPO_ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../../../.." && pwd) @@ -214,128 +214,85 @@ test_cli_bin_confinement() { } # --------------------------------------------------------------------------- -# run-app-cli.sh — provider-env credential boundary +# run-app-cli.sh — provider-neutral invocation and trust boundary # --------------------------------------------------------------------------- -# A fake CLI records the FASTLY_* it actually saw; run-cli must clear inherited -# aliases and export only the declared, typed values. -test_provider_env_boundary() { - section "run-cli provider-env boundary" - - local bin_dir="$WORK_DIR/pe-bin" app_dir="$WORK_DIR/pe-app" - local seen="$WORK_DIR/pe-seen.txt" clear="$WORK_DIR/pe-clear.nul" - mkdir -p "$bin_dir" "$app_dir" - cat >"$bin_dir/fakecli" <"$cli" <<'CLI' #!/usr/bin/env bash +printf '%s\0' "$@" >"$ACTUAL_ARGV" { - printf 'TOKEN=%s\n' "\${FASTLY_API_TOKEN-unset}" - printf 'ENDPOINT=%s\n' "\${FASTLY_ENDPOINT-unset}" -} >"$seen" -EOF - chmod +x "$bin_dir/fakecli" - printf 'FASTLY_API_TOKEN\0FASTLY_ENDPOINT\0' >"$clear" - - run_deploy_pe() { - env -i PATH="$bin_dir:$PATH" \ - EDGEZERO__APP__CLI__BIN=fakecli EDGEZERO__ADAPTER=fastly \ - EDGEZERO__PROJECT__WORKING_DIRECTORY="$app_dir" \ - EDGEZERO__PROVIDER__ENV_CLEAR_FILE="$clear" \ - EDGEZERO__PROVIDER__ENV="$1" \ - FASTLY_API_TOKEN=inherited-BAD FASTLY_ENDPOINT=https://inherited.invalid \ - bash "$CORE_SCRIPTS/run-app-cli.sh" deploy - } - - if run_deploy_pe '{"FASTLY_API_TOKEN":"typed-tok"}' >/dev/null 2>&1; then - assert_equals "typed token wins; inherited endpoint cleared" \ - $'TOKEN=typed-tok\nENDPOINT=unset' "$(cat "$seen")" - else - fail "run-cli deploy (provider-env) failed to execute" - fi - - # A provider-env name not declared in provider-env-clear is rejected. - assert_fails "rejects an undeclared provider-env name" \ - run_deploy_pe '{"FASTLY_TOKEN":"x"}' -} - -# --------------------------------------------------------------------------- -# run-app-cli.sh — CLI argv construction -# --------------------------------------------------------------------------- -# Installs a fake CLI that records its argv, then asserts run-cli places typed -# deploy-flags before `--` and caller passthrough after `--`. -test_run_cli_argv() { - section "run-cli argv" - - local bin_dir="$WORK_DIR/bin" - local argv_file="$WORK_DIR/recorded-argv.txt" - local app_dir="$WORK_DIR/app" - mkdir -p "$bin_dir" "$app_dir" - - cat >"$bin_dir/fakecli" <"$argv_file" -EOF - chmod +x "$bin_dir/fakecli" - - # NUL-delimited argument files, exactly as validate-inputs would emit them. - printf -- '--service-id\0abc\0--staging\0' >"$WORK_DIR/deploy-flags.nul" - printf -- '--comment\0hello\0' >"$WORK_DIR/deploy-args.nul" - - if env -i PATH="$bin_dir:$PATH" \ - EDGEZERO__APP__CLI__BIN=fakecli \ - EDGEZERO__ADAPTER=fastly \ - EDGEZERO__PROJECT__WORKING_DIRECTORY="$app_dir" \ - EDGEZERO__DEPLOY__FLAGS_FILE="$WORK_DIR/deploy-flags.nul" \ - EDGEZERO__DEPLOY__ARGS_FILE="$WORK_DIR/deploy-args.nul" \ - bash "$CORE_SCRIPTS/run-app-cli.sh" deploy >/dev/null 2>&1; then - local expected - expected=$'deploy\n--adapter\nfastly\n--service-id\nabc\n--staging\n--\n--comment\nhello' - assert_equals "flags precede --, passthrough follows --" "$expected" "$(cat "$argv_file")" + printf 'token=%s\n' "${SYNTHETIC_TOKEN:-ABSENT}" + printf 'endpoint=%s\n' "${SYNTHETIC_ENDPOINT:-ABSENT}" + printf 'manifest=%s\n' "${EDGEZERO_MANIFEST:-ABSENT}" + printf 'selector=%s\n' "${EDGEZERO__STORES__KV__CACHE__NAME:-ABSENT}" + printf 'adapter-extra=%s\n' "${EDGEZERO__LOGGING__SYNTHETIC_MODE:-ABSENT}" + printf 'private=%s\n' "${EDGEZERO__PRIVATE:-ABSENT}" + printf 'pwd=%s\n' "$PWD" + if grep -qx 'mutation-attempted=true' "$GITHUB_OUTPUT"; then + printf 'mutation-before-cli=yes\n' else - fail "run-cli deploy failed to execute" + printf 'mutation-before-cli=no\n' fi -} - -# --------------------------------------------------------------------------- -# run-app-cli.sh — build mode isolates the untrusted build from the job's -# GitHub file-command channels, so a build.rs cannot append to $GITHUB_PATH / -# $GITHUB_ENV and reach a later token-bearing step in the same job. -# --------------------------------------------------------------------------- -test_run_cli_build_isolation() { - section "run-cli build-mode isolation" +} >"$SEEN_ENV" +exit "${SYNTHETIC_EXIT:-0}" +CLI + chmod +x "$cli" + printf '%s\0' deploy --adapter synthetic '' $'line one\nline two' '*' >"$args" + printf '%s\0' SYNTHETIC_TOKEN SYNTHETIC_ENDPOINT >"$clear" + printf '%s\0' EDGEZERO__LOGGING__SYNTHETIC_MODE >"$allow" + : >"$output" + + run_invoke() { + local provider_json="${1:-}" + [[ -n "$provider_json" ]] || provider_json='{"SYNTHETIC_TOKEN":"typed token"}' + ACTUAL_ARGV="$actual" SEEN_ENV="$seen" GITHUB_OUTPUT="$output" \ + EDGEZERO__ACTION__WORKSPACE="$dir" \ + EDGEZERO__APP__CLI__PATH="$cli" EDGEZERO__APP__CLI__ARGS_FILE="$args" \ + EDGEZERO__APP__CLI__MUTATES=true EDGEZERO__PROJECT__WORKING_DIRECTORY="$dir/work" \ + EDGEZERO__PROJECT__MANIFEST_PATH="$dir/work/edgezero.toml" \ + EDGEZERO__PROVIDER__ENV_CLEAR_FILE="$clear" \ + EDGEZERO__PUBLIC_RUNTIME_ENV_ALLOW_FILE="$allow" \ + EDGEZERO__PROVIDER__ENV="$provider_json" \ + SYNTHETIC_TOKEN=inherited SYNTHETIC_ENDPOINT=https://inherited.invalid \ + EDGEZERO__STORES__KV__CACHE__NAME=physical-cache \ + EDGEZERO__LOGGING__SYNTHETIC_MODE=enabled EDGEZERO__PRIVATE=hidden \ + SYNTHETIC_EXIT="${2:-0}" "$CORE_SCRIPTS/run-app-cli.sh" >/dev/null 2>&1 + } - local bin_dir="$WORK_DIR/bin-iso" - local app_dir="$WORK_DIR/app-iso" - local seen="$WORK_DIR/build-channels-seen.txt" - mkdir -p "$bin_dir" "$app_dir" + assert_succeeds "invokes a synthetic adapter CLI" run_invoke + assert_succeeds "preserves the exact NUL-delimited argv" cmp -s "$args" "$actual" + assert_succeeds "imports only the typed provider credential" grep -qx 'token=typed token' "$seen" + assert_succeeds "clears inherited provider aliases" grep -qx 'endpoint=ABSENT' "$seen" + assert_succeeds "exports the selected manifest" grep -qx "manifest=$dir/work/edgezero.toml" "$seen" + assert_succeeds "preserves canonical store selectors" grep -qx 'selector=physical-cache' "$seen" + assert_succeeds "preserves provider-declared public runtime names" grep -qx 'adapter-extra=enabled' "$seen" + assert_succeeds "scrubs action-private EDGEZERO variables" grep -qx 'private=ABSENT' "$seen" + assert_succeeds "runs from the requested working directory" grep -qx "pwd=$dir/work" "$seen" + assert_succeeds "publishes mutation-attempted before invoking the CLI" grep -qx 'mutation-before-cli=yes' "$seen" + + assert_fails "rejects a provider name outside the provider clear-list" \ + run_invoke '{"UNDECLARED_TOKEN":"x"}' + assert_fails "rejects a provider value containing LF" \ + run_invoke "$(jq -nc '{SYNTHETIC_TOKEN:"a\nb"}')" + printf '%s' unterminated >"$args" + assert_fails "rejects an unterminated argv file" run_invoke + printf '%s\0' deploy >"$args" + local rc=0 + run_invoke '{"SYNTHETIC_TOKEN":"ok"}' 42 || rc=$? + assert_equals "propagates the application CLI exit status" 42 "$rc" - # Stands in for ` build`: records which GitHub file-command channels are - # still visible to the (untrusted) build's environment. - cat >"$bin_dir/fakecli" <}" - printf 'GITHUB_PATH=%s\n' "\${GITHUB_PATH:-}" - printf 'GITHUB_OUTPUT=%s\n' "\${GITHUB_OUTPUT:-}" -} >"$seen" -EOF - chmod +x "$bin_dir/fakecli" - - if env -i PATH="$bin_dir:$PATH" \ - EDGEZERO__APP__CLI__BIN=fakecli \ - EDGEZERO__ADAPTER=fastly \ - EDGEZERO__PROJECT__WORKING_DIRECTORY="$app_dir" \ - GITHUB_ENV="$WORK_DIR/gh-env" \ - GITHUB_PATH="$WORK_DIR/gh-path" \ - GITHUB_OUTPUT="$WORK_DIR/gh-output" \ - bash "$CORE_SCRIPTS/run-app-cli.sh" build >/dev/null 2>&1; then - assert_equals "build strips GITHUB_ENV from the build's environment" \ - "GITHUB_ENV=" "$(grep '^GITHUB_ENV=' "$seen")" - assert_equals "build strips GITHUB_PATH from the build's environment" \ - "GITHUB_PATH=" "$(grep '^GITHUB_PATH=' "$seen")" - assert_equals "build strips GITHUB_OUTPUT from the build's environment" \ - "GITHUB_OUTPUT=" "$(grep '^GITHUB_OUTPUT=' "$seen")" - else - fail "run-cli build failed to execute" - fi + local outside="$WORK_DIR/outside-cli" + cp "$cli" "$outside" + chmod +x "$outside" + assert_fails "rejects a CLI outside the action workspace" \ + env EDGEZERO__ACTION__WORKSPACE="$dir" EDGEZERO__APP__CLI__PATH="$outside" \ + EDGEZERO__APP__CLI__ARGS_FILE="$args" EDGEZERO__APP__CLI__MUTATES=false \ + "$CORE_SCRIPTS/run-app-cli.sh" } # --------------------------------------------------------------------------- @@ -367,7 +324,7 @@ EOF chmod +x "$stage_dir/myapp-cli" printf '{"app-cli-bin":"myapp-cli","app-cli-version":"1.2.3","app-cli-package":"myapp-cli"}\n' \ >"$stage_dir/app-cli-meta.json" - tar -C "$stage_dir" -cf "$artifact_dir/edgezero-cli.tar" myapp-cli app-cli-meta.json + tar -C "$stage_dir" -cf "$artifact_dir/app-cli.tar" myapp-cli app-cli-meta.json local output_file="$WORK_DIR/download-output.txt" if env -i PATH="$PATH" \ @@ -399,6 +356,290 @@ EOF fi } +hash_file() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + else + shasum -a 256 "$1" | awk '{print $1}' + fi +} + +make_fastly_release_fixture() { + local dir="$1" + rm -rf "$dir" + mkdir -p "$dir/release/cli" "$dir/release/package" "$dir/release/adapter" "$dir/cli" + cat >"$dir/cli/app-cli" <<'CLI' +#!/usr/bin/env bash +exit 0 +CLI + chmod +x "$dir/cli/app-cli" + printf '{"app-cli-bin":"app-cli","app-cli-version":"1.2.3","app-cli-package":"app-cli"}\n' \ + >"$dir/cli/app-cli-meta.json" + tar -C "$dir/cli" -czf "$dir/release/cli/app-cli.tar.gz" app-cli app-cli-meta.json + printf 'immutable-fastly-package\n' >"$dir/release/package/app.tar.gz" + printf '[app]\nname = "demo"\n[adapters.fastly.adapter]\nmanifest = "adapter/fastly.toml"\n[adapters.spin.adapter]\nmanifest = "adapter/spin.toml"\n' \ + >"$dir/release/edgezero.toml" + printf 'manifest_version = 3\nname = "demo"\n' >"$dir/release/adapter/fastly.toml" + jq -n \ + --arg revision "$(printf 'a%.0s' {1..40})" \ + --arg cli "$(hash_file "$dir/release/cli/app-cli.tar.gz")" \ + --arg package "$(hash_file "$dir/release/package/app.tar.gz")" \ + --arg edgezero "$(hash_file "$dir/release/edgezero.toml")" \ + --arg adapter "$(hash_file "$dir/release/adapter/fastly.toml")" \ + '{format:1,lifecycle_protocol:1,source_revision:$revision,adapter:"fastly",app_cli:{path:"cli/app-cli.tar.gz",sha256:$cli},package:{path:"package/app.tar.gz",sha256:$package},manifests:{edgezero:{path:"edgezero.toml",sha256:$edgezero},adapter:{path:"adapter/fastly.toml",sha256:$adapter}}}' \ + >"$dir/release/release.json" + tar -C "$dir/release" -czf "$dir/app-release.tar.gz" \ + release.json cli/app-cli.tar.gz package/app.tar.gz edgezero.toml adapter/fastly.toml + hash_file "$dir/app-release.tar.gz" >"$dir/app-release.sha256" +} + +test_fastly_application_release() { + section "Fastly immutable application release" + local dir="$WORK_DIR/fastly-release" + local prepare="$ACTIONS_DIR/release-core/scripts/prepare-release.sh" + make_fastly_release_fixture "$dir" + local out="$dir/out.txt" root="$dir/extracted" + local expected_revision + expected_revision=$(printf 'a%.0s' {1..40}) + local EDGEZERO__APP__RELEASE__EXPECTED_SOURCE_REVISION="$expected_revision" + local EDGEZERO__APP__RELEASE__EXPECTED_ADAPTER=fastly + local EDGEZERO__APP__RELEASE__EXPECTED_LIFECYCLE_PROTOCOL=1 + export EDGEZERO__APP__RELEASE__EXPECTED_SOURCE_REVISION + export EDGEZERO__APP__RELEASE__EXPECTED_ADAPTER + export EDGEZERO__APP__RELEASE__EXPECTED_LIFECYCLE_PROTOCOL + + assert_succeeds "a strict release archive verifies and extracts" \ + env EDGEZERO__APP__RELEASE__ARCHIVE="$dir/app-release.tar.gz" \ + EDGEZERO__APP__RELEASE__SHA256="$(cat "$dir/app-release.sha256")" \ + EDGEZERO__APP__RELEASE__EXPECTED_SOURCE_REVISION="$expected_revision" \ + EDGEZERO__APP__RELEASE__ROOT="$root" GITHUB_OUTPUT="$out" bash "$prepare" + local root_real + root_real=$(realpath "$root") + assert_succeeds "release preparation emits its confined root" \ + grep -qx "release-root=$root_real" "$out" + assert_succeeds "release preparation emits the verified package digest" \ + grep -qx "package-digest=$(hash_file "$dir/release/package/app.tar.gz")" "$out" + assert_succeeds "release preparation emits the bundled application manifest" \ + grep -qx "application-manifest=$root_real/edgezero.toml" "$out" + assert_succeeds "release preparation emits the bundled adapter manifest" \ + grep -qx "adapter-manifest=$root_real/adapter/fastly.toml" "$out" + assert_succeeds "release preparation emits the exact CLI archive" \ + grep -qx "app-cli-archive=$root_real/cli/app-cli.tar.gz" "$out" + assert_succeeds "release preparation emits the pinned source revision" \ + grep -qx "source-revision=$expected_revision" "$out" + + assert_fails_with "release preparation rejects a mismatched selected source revision" \ + "does not match expected-source-revision" \ + env EDGEZERO__APP__RELEASE__ARCHIVE="$dir/app-release.tar.gz" \ + EDGEZERO__APP__RELEASE__SHA256="$(cat "$dir/app-release.sha256")" \ + EDGEZERO__APP__RELEASE__EXPECTED_SOURCE_REVISION="$(printf 'b%.0s' {1..40})" \ + EDGEZERO__APP__RELEASE__ROOT="$dir/wrong-revision" bash "$prepare" + + case "$(uname -s)-$(uname -m)" in + Linux-x86_64 | Linux-amd64) + assert_succeeds "the exact app CLI archive recorded by the release extracts" \ + env EDGEZERO__APP__CLI__ARCHIVE="$root/cli/app-cli.tar.gz" \ + EDGEZERO__ACTION__TOOL_ROOT="$dir/tools" GITHUB_OUTPUT="$dir/cli-out" \ + bash "$CORE_SCRIPTS/download-app-cli.sh" + ;; + *) skip "release-recorded application CLI extraction (non-Linux runner)" ;; + esac + + assert_fails "a missing release archive is rejected" \ + env EDGEZERO__APP__RELEASE__ARCHIVE= \ + EDGEZERO__APP__RELEASE__SHA256="$(cat "$dir/app-release.sha256")" \ + EDGEZERO__APP__RELEASE__ROOT="$dir/missing-archive" bash "$prepare" + assert_fails "a missing release digest is rejected" \ + env EDGEZERO__APP__RELEASE__ARCHIVE="$dir/app-release.tar.gz" \ + EDGEZERO__APP__RELEASE__SHA256= \ + EDGEZERO__APP__RELEASE__ROOT="$dir/missing-digest" bash "$prepare" + + assert_fails "outer release digest mismatch is rejected before extraction" \ + env EDGEZERO__APP__RELEASE__ARCHIVE="$dir/app-release.tar.gz" \ + EDGEZERO__APP__RELEASE__SHA256="$(printf '0%.0s' {1..64})" \ + EDGEZERO__APP__RELEASE__ROOT="$dir/bad-digest" bash "$prepare" + + cp -R "$dir/release" "$dir/extra-release-root" + printf 'extra\n' >"$dir/extra-release-root/extra" + tar -C "$dir/extra-release-root" -czf "$dir/extra-release.tar.gz" \ + release.json cli/app-cli.tar.gz package/app.tar.gz edgezero.toml adapter/fastly.toml extra + assert_fails "an extra release member is rejected" \ + env EDGEZERO__APP__RELEASE__ARCHIVE="$dir/extra-release.tar.gz" \ + EDGEZERO__APP__RELEASE__SHA256="$(hash_file "$dir/extra-release.tar.gz")" \ + EDGEZERO__APP__RELEASE__ROOT="$dir/extra-root" bash "$prepare" + + mkdir -p "$dir/unsafe-dir" + printf 'escape\n' >"$dir/unsafe-member" + tar -C "$dir/unsafe-dir" -czf "$dir/unsafe-release.tar.gz" ../unsafe-member + assert_fails "an unsafe traversing release member is rejected" \ + env EDGEZERO__APP__RELEASE__ARCHIVE="$dir/unsafe-release.tar.gz" \ + EDGEZERO__APP__RELEASE__SHA256="$(hash_file "$dir/unsafe-release.tar.gz")" \ + EDGEZERO__APP__RELEASE__ROOT="$dir/unsafe-root" bash "$prepare" + + make_fastly_release_fixture "$dir/link" + rm "$dir/link/release/adapter/fastly.toml" + ln -s ../edgezero.toml "$dir/link/release/adapter/fastly.toml" + tar -C "$dir/link/release" -czf "$dir/link/link.tar.gz" \ + release.json cli/app-cli.tar.gz package/app.tar.gz edgezero.toml adapter/fastly.toml + assert_fails "a symlink release member is rejected" \ + env EDGEZERO__APP__RELEASE__ARCHIVE="$dir/link/link.tar.gz" \ + EDGEZERO__APP__RELEASE__SHA256="$(hash_file "$dir/link/link.tar.gz")" \ + EDGEZERO__APP__RELEASE__ROOT="$dir/link/root" bash "$prepare" + + make_fastly_release_fixture "$dir/missing" + tar -C "$dir/missing/release" -czf "$dir/missing/missing.tar.gz" \ + release.json cli/app-cli.tar.gz package/app.tar.gz edgezero.toml + assert_fails "a recorded release member missing from the archive is rejected" \ + env EDGEZERO__APP__RELEASE__ARCHIVE="$dir/missing/missing.tar.gz" \ + EDGEZERO__APP__RELEASE__SHA256="$(hash_file "$dir/missing/missing.tar.gz")" \ + EDGEZERO__APP__RELEASE__ROOT="$dir/missing/root" bash "$prepare" + + make_fastly_release_fixture "$dir/invalid" + jq '.unexpected = true' "$dir/invalid/release/release.json" \ + >"$dir/invalid/release/release.invalid.json" + mv "$dir/invalid/release/release.invalid.json" "$dir/invalid/release/release.json" + tar -C "$dir/invalid/release" -czf "$dir/invalid/invalid.tar.gz" \ + release.json cli/app-cli.tar.gz package/app.tar.gz edgezero.toml adapter/fastly.toml + assert_fails "invalid release metadata is rejected" \ + env EDGEZERO__APP__RELEASE__ARCHIVE="$dir/invalid/invalid.tar.gz" \ + EDGEZERO__APP__RELEASE__SHA256="$(hash_file "$dir/invalid/invalid.tar.gz")" \ + EDGEZERO__APP__RELEASE__ROOT="$dir/invalid/root" bash "$prepare" + + make_fastly_release_fixture "$dir/duplicate" + awk '{ print; if ($0 ~ /"format": 1,/) print " \"format\": 1," }' \ + "$dir/duplicate/release/release.json" >"$dir/duplicate/release/release.duplicate.json" + mv "$dir/duplicate/release/release.duplicate.json" "$dir/duplicate/release/release.json" + tar -C "$dir/duplicate/release" -czf "$dir/duplicate/duplicate.tar.gz" \ + release.json cli/app-cli.tar.gz package/app.tar.gz edgezero.toml adapter/fastly.toml + assert_fails "duplicate release metadata fields are rejected" \ + env EDGEZERO__APP__RELEASE__ARCHIVE="$dir/duplicate/duplicate.tar.gz" \ + EDGEZERO__APP__RELEASE__SHA256="$(hash_file "$dir/duplicate/duplicate.tar.gz")" \ + EDGEZERO__APP__RELEASE__ROOT="$dir/duplicate/root" bash "$prepare" + + make_fastly_release_fixture "$dir/duplicate-empty-first" + jq -c . "$dir/duplicate-empty-first/release/release.json" | + sed 's/"app_cli":/"app_cli":{},"app_cli":/' \ + >"$dir/duplicate-empty-first/release/release.duplicate.json" + mv "$dir/duplicate-empty-first/release/release.duplicate.json" \ + "$dir/duplicate-empty-first/release/release.json" + tar -C "$dir/duplicate-empty-first/release" \ + -czf "$dir/duplicate-empty-first/duplicate.tar.gz" \ + release.json cli/app-cli.tar.gz package/app.tar.gz edgezero.toml adapter/fastly.toml + assert_fails_with "an empty object cannot hide a duplicate release metadata field" \ + "duplicate field" \ + env EDGEZERO__APP__RELEASE__ARCHIVE="$dir/duplicate-empty-first/duplicate.tar.gz" \ + EDGEZERO__APP__RELEASE__SHA256="$(hash_file "$dir/duplicate-empty-first/duplicate.tar.gz")" \ + EDGEZERO__APP__RELEASE__ROOT="$dir/duplicate-empty-first/root" bash "$prepare" + assert_fails "duplicate metadata is rejected before creating the release root" \ + test -e "$dir/duplicate-empty-first/root" + + make_fastly_release_fixture "$dir/duplicate-array-first" + jq -c . "$dir/duplicate-array-first/release/release.json" | + sed 's/"app_cli":/"app_cli":[],"app_cli":/' \ + >"$dir/duplicate-array-first/release/release.duplicate.json" + mv "$dir/duplicate-array-first/release/release.duplicate.json" \ + "$dir/duplicate-array-first/release/release.json" + tar -C "$dir/duplicate-array-first/release" \ + -czf "$dir/duplicate-array-first/duplicate.tar.gz" \ + release.json cli/app-cli.tar.gz package/app.tar.gz edgezero.toml adapter/fastly.toml + assert_fails_with "an empty array cannot hide a duplicate release metadata field" \ + "duplicate field" \ + env EDGEZERO__APP__RELEASE__ARCHIVE="$dir/duplicate-array-first/duplicate.tar.gz" \ + EDGEZERO__APP__RELEASE__SHA256="$(hash_file "$dir/duplicate-array-first/duplicate.tar.gz")" \ + EDGEZERO__APP__RELEASE__ROOT="$dir/duplicate-array-first/root" bash "$prepare" + + make_fastly_release_fixture "$dir/duplicate-empty-last" + jq -c . "$dir/duplicate-empty-last/release/release.json" | + sed 's/}$/,"app_cli":{}}/' \ + >"$dir/duplicate-empty-last/release/release.duplicate.json" + mv "$dir/duplicate-empty-last/release/release.duplicate.json" \ + "$dir/duplicate-empty-last/release/release.json" + tar -C "$dir/duplicate-empty-last/release" \ + -czf "$dir/duplicate-empty-last/duplicate.tar.gz" \ + release.json cli/app-cli.tar.gz package/app.tar.gz edgezero.toml adapter/fastly.toml + assert_fails_with "a trailing empty object is also rejected as a duplicate field" \ + "duplicate field" \ + env EDGEZERO__APP__RELEASE__ARCHIVE="$dir/duplicate-empty-last/duplicate.tar.gz" \ + EDGEZERO__APP__RELEASE__SHA256="$(hash_file "$dir/duplicate-empty-last/duplicate.tar.gz")" \ + EDGEZERO__APP__RELEASE__ROOT="$dir/duplicate-empty-last/root" bash "$prepare" + + make_fastly_release_fixture "$dir/float-format" + jq -c . "$dir/float-format/release/release.json" | + sed 's/"format":1/"format":1.0/' \ + >"$dir/float-format/release/release.float.json" + mv "$dir/float-format/release/release.float.json" \ + "$dir/float-format/release/release.json" + tar -C "$dir/float-format/release" -czf "$dir/float-format/float.tar.gz" \ + release.json cli/app-cli.tar.gz package/app.tar.gz edgezero.toml adapter/fastly.toml + assert_fails_with "release format must use the exact integer JSON representation" \ + "unsupported format" \ + env EDGEZERO__APP__RELEASE__ARCHIVE="$dir/float-format/float.tar.gz" \ + EDGEZERO__APP__RELEASE__SHA256="$(hash_file "$dir/float-format/float.tar.gz")" \ + EDGEZERO__APP__RELEASE__ROOT="$dir/float-format/root" bash "$prepare" + + make_fastly_release_fixture "$dir/missing-lifecycle-protocol" + jq 'del(.lifecycle_protocol)' "$dir/missing-lifecycle-protocol/release/release.json" \ + >"$dir/missing-lifecycle-protocol/release/release.invalid.json" + mv "$dir/missing-lifecycle-protocol/release/release.invalid.json" \ + "$dir/missing-lifecycle-protocol/release/release.json" + tar -C "$dir/missing-lifecycle-protocol/release" \ + -czf "$dir/missing-lifecycle-protocol/invalid.tar.gz" \ + release.json cli/app-cli.tar.gz package/app.tar.gz edgezero.toml adapter/fastly.toml + assert_fails_with "release metadata requires a lifecycle protocol" \ + "lifecycle_protocol" \ + env EDGEZERO__APP__RELEASE__ARCHIVE="$dir/missing-lifecycle-protocol/invalid.tar.gz" \ + EDGEZERO__APP__RELEASE__SHA256="$(hash_file "$dir/missing-lifecycle-protocol/invalid.tar.gz")" \ + EDGEZERO__APP__RELEASE__ROOT="$dir/missing-lifecycle-protocol/root" bash "$prepare" + + make_fastly_release_fixture "$dir/string-lifecycle-protocol" + jq '.lifecycle_protocol = "1"' "$dir/string-lifecycle-protocol/release/release.json" \ + >"$dir/string-lifecycle-protocol/release/release.invalid.json" + mv "$dir/string-lifecycle-protocol/release/release.invalid.json" \ + "$dir/string-lifecycle-protocol/release/release.json" + tar -C "$dir/string-lifecycle-protocol/release" \ + -czf "$dir/string-lifecycle-protocol/invalid.tar.gz" \ + release.json cli/app-cli.tar.gz package/app.tar.gz edgezero.toml adapter/fastly.toml + assert_fails_with "release lifecycle protocol must be an integer" \ + "lifecycle_protocol" \ + env EDGEZERO__APP__RELEASE__ARCHIVE="$dir/string-lifecycle-protocol/invalid.tar.gz" \ + EDGEZERO__APP__RELEASE__SHA256="$(hash_file "$dir/string-lifecycle-protocol/invalid.tar.gz")" \ + EDGEZERO__APP__RELEASE__ROOT="$dir/string-lifecycle-protocol/root" bash "$prepare" + + make_fastly_release_fixture "$dir/unsupported-lifecycle-protocol" + jq '.lifecycle_protocol = 2' "$dir/unsupported-lifecycle-protocol/release/release.json" \ + >"$dir/unsupported-lifecycle-protocol/release/release.invalid.json" + mv "$dir/unsupported-lifecycle-protocol/release/release.invalid.json" \ + "$dir/unsupported-lifecycle-protocol/release/release.json" + tar -C "$dir/unsupported-lifecycle-protocol/release" \ + -czf "$dir/unsupported-lifecycle-protocol/invalid.tar.gz" \ + release.json cli/app-cli.tar.gz package/app.tar.gz edgezero.toml adapter/fastly.toml + assert_fails_with "unsupported release lifecycle protocols are rejected" \ + "lifecycle protocol" \ + env EDGEZERO__APP__RELEASE__ARCHIVE="$dir/unsupported-lifecycle-protocol/invalid.tar.gz" \ + EDGEZERO__APP__RELEASE__SHA256="$(hash_file "$dir/unsupported-lifecycle-protocol/invalid.tar.gz")" \ + EDGEZERO__APP__RELEASE__ROOT="$dir/unsupported-lifecycle-protocol/root" bash "$prepare" + assert_fails "invalid lifecycle protocols are rejected before creating the release root" \ + test -e "$dir/unsupported-lifecycle-protocol/root" + + make_fastly_release_fixture "$dir/inner" + printf 'tampered\n' >>"$dir/inner/release/package/app.tar.gz" + tar -C "$dir/inner/release" -czf "$dir/inner/tampered.tar.gz" \ + release.json cli/app-cli.tar.gz package/app.tar.gz edgezero.toml adapter/fastly.toml + assert_fails "an inner release digest mismatch is rejected" \ + env EDGEZERO__APP__RELEASE__ARCHIVE="$dir/inner/tampered.tar.gz" \ + EDGEZERO__APP__RELEASE__SHA256="$(hash_file "$dir/inner/tampered.tar.gz")" \ + EDGEZERO__APP__RELEASE__ROOT="$dir/inner/root" bash "$prepare" + + make_fastly_release_fixture "$dir/cli-digest" + printf 'tampered\n' >>"$dir/cli-digest/release/cli/app-cli.tar.gz" + tar -C "$dir/cli-digest/release" -czf "$dir/cli-digest/tampered.tar.gz" \ + release.json cli/app-cli.tar.gz package/app.tar.gz edgezero.toml adapter/fastly.toml + assert_fails "an app CLI digest mismatch is rejected" \ + env EDGEZERO__APP__RELEASE__ARCHIVE="$dir/cli-digest/tampered.tar.gz" \ + EDGEZERO__APP__RELEASE__SHA256="$(hash_file "$dir/cli-digest/tampered.tar.gz")" \ + EDGEZERO__APP__RELEASE__ROOT="$dir/cli-digest/root" bash "$prepare" +} + # --------------------------------------------------------------------------- # wrapper validate.sh — the per-wrapper input validation (now scripts, not inline # YAML, so it is shellcheck'd AND testable). GitHub does not enforce @@ -407,12 +648,13 @@ EOF test_wrapper_validate() { section "wrapper validate.sh" - # deploy-fastly: artifact + token presence, service-id format, then it delegates + # deploy-fastly: immutable release + token presence, service-id format, then it delegates # to the real engine validate-inputs.sh — so the success case runs end to end # (the engine needs a supported runner + adapter). local dfl="$ACTIONS_DIR/deploy-fastly/scripts/validate.sh" run_dfl() { - env EDGEZERO__APP__CLI__ARTIFACT_PRESENT="${A:-true}" \ + env EDGEZERO__APP__RELEASE__ARCHIVE_PRESENT="${A:-true}" \ + EDGEZERO__APP__RELEASE__SHA256_PRESENT="${H:-true}" \ EDGEZERO__FASTLY__API_TOKEN_PRESENT="${T:-true}" \ EDGEZERO__FASTLY__SERVICE_ID="${S-svc1}" \ EDGEZERO__ADAPTER=fastly EDGEZERO__RUNNER__OS=Linux EDGEZERO__RUNNER__ARCH=X64 \ @@ -421,7 +663,9 @@ test_wrapper_validate() { bash "$dfl" } assert_succeeds "deploy-fastly: well-formed inputs pass" run_dfl - A=false assert_fails "deploy-fastly: missing artifact is rejected" run_dfl + S='Svc123ABC' assert_succeeds "deploy-fastly: mixed alphanumeric service-id is accepted" run_dfl + A=false assert_fails "deploy-fastly: missing release archive is rejected" run_dfl + H=false assert_fails "deploy-fastly: missing release digest 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 @@ -431,28 +675,37 @@ test_wrapper_validate() { # config-push-fastly: artifact + token presence, deploy-to fail-closed. local cpf="$ACTIONS_DIR/config-push-fastly/scripts/validate.sh" run_cpf() { - env EDGEZERO__APP__CLI__ARTIFACT_PRESENT="${A:-true}" \ + env EDGEZERO__APP__RELEASE__ARCHIVE_PRESENT="${A:-true}" \ + EDGEZERO__APP__RELEASE__SHA256_PRESENT="${H:-true}" \ EDGEZERO__FASTLY__API_TOKEN_PRESENT="${T:-true}" \ EDGEZERO__DEPLOY__TO="${D:-production}" \ - EDGEZERO__CONFIG_PUSH__KEY_PRESENT="${K:-false}" bash "$cpf" + EDGEZERO__CONFIG_PUSH__APP_CONFIG_PRESENT="${C:-true}" \ + EDGEZERO__CONFIG_PUSH__APP_CONFIG_INLINE_PRESENT="${I:-false}" \ + EDGEZERO__CONFIG_PUSH__KEY="${K:-}" bash "$cpf" } assert_succeeds "config-push: production passes" run_cpf D=staging assert_succeeds "config-push: staging passes" run_cpf D=Staging assert_fails "config-push: a deploy-to typo is rejected (no silent prod)" run_cpf - A=false assert_fails "config-push: missing artifact is rejected" run_cpf - # A staging key is derived, so an explicit key with staging is refused early. - D=production K=true assert_succeeds "config-push: an explicit key is fine for production" run_cpf - D=staging K=true assert_fails "config-push: key + staging is rejected up front" run_cpf - - # healthcheck + rollback: artifact presence only. + A=false assert_fails "config-push: missing release archive is rejected" run_cpf + H=false assert_fails "config-push: missing release digest is rejected" run_cpf + C=false I=false assert_fails "config-push: neither typed config input is rejected" run_cpf + C=true I=true assert_fails "config-push: both typed config inputs are rejected" run_cpf + K=custom-key assert_fails "config-push: deprecated key input is rejected" run_cpf + # Healthcheck + rollback require the same immutable release and alphanumeric ID. local hc="$ACTIONS_DIR/healthcheck-fastly/scripts/validate.sh" - assert_succeeds "healthcheck: present artifact passes" \ - env EDGEZERO__APP__CLI__ARTIFACT_PRESENT=true bash "$hc" - assert_fails "healthcheck: missing artifact is rejected" \ - env EDGEZERO__APP__CLI__ARTIFACT_PRESENT=false bash "$hc" + assert_succeeds "healthcheck: release and mixed alphanumeric ID pass" \ + env EDGEZERO__APP__RELEASE__ARCHIVE_PRESENT=true EDGEZERO__APP__RELEASE__SHA256_PRESENT=true \ + EDGEZERO__FASTLY__SERVICE_ID=Svc123ABC bash "$hc" + assert_fails "healthcheck: underscore service-id is rejected" \ + env EDGEZERO__APP__RELEASE__ARCHIVE_PRESENT=true EDGEZERO__APP__RELEASE__SHA256_PRESENT=true \ + EDGEZERO__FASTLY__SERVICE_ID=svc_1 bash "$hc" local rb="$ACTIONS_DIR/rollback-fastly/scripts/validate.sh" - assert_fails "rollback: missing artifact is rejected" \ - env EDGEZERO__APP__CLI__ARTIFACT_PRESENT=false bash "$rb" + assert_fails "rollback: missing release is rejected" \ + env EDGEZERO__APP__RELEASE__ARCHIVE_PRESENT=false EDGEZERO__APP__RELEASE__SHA256_PRESENT=true \ + EDGEZERO__FASTLY__SERVICE_ID=Svc123 bash "$rb" + assert_fails "rollback: hyphen service-id is rejected" \ + env EDGEZERO__APP__RELEASE__ARCHIVE_PRESENT=true EDGEZERO__APP__RELEASE__SHA256_PRESENT=true \ + EDGEZERO__FASTLY__SERVICE_ID=svc-1 bash "$rb" } # --------------------------------------------------------------------------- @@ -524,6 +777,29 @@ run_steps_missing_env_scrub() { ' "$1" } +# Print the label of every `run:` step that does not explicitly replace every +# shipped Fastly environment alias. FASTLY_API_TOKEN may be blank or populated +# from the action's typed input; every other alias must be blank. This prevents +# a caller's job-level Fastly configuration from changing provider behavior. +run_steps_missing_fastly_env_boundary() { + local file=$1 label block alias missing + while IFS= read -r label; do + block=$(step_block "$file" "$label") + grep -qE '^[[:space:]]*run:' <<<"$block" || continue + + missing=false + grep -qE '^[[:space:]]*FASTLY_API_TOKEN:' <<<"$block" || missing=true + for alias in \ + FASTLY_SERVICE_ID FASTLY_TOKEN FASTLY_KEY FASTLY_API_KEY \ + FASTLY_AUTH_TOKEN FASTLY_API_ENDPOINT FASTLY_ENDPOINT FASTLY_API_URL \ + FASTLY_PROFILE FASTLY_SERVICE_NAME FASTLY_DEBUG FASTLY_DEBUG_MODE \ + FASTLY_CONFIG_FILE FASTLY_CARGO_PROFILE FASTLY_HOME; do + grep -qE "^[[:space:]]*${alias}: \"\"[[:space:]]*$" <<<"$block" || missing=true + done + [[ "$missing" == false ]] || printf '%s\n' "$label" + done < <(sed -n 's/^ - name: //p' "$file") +} + test_workspace_step_scrub() { section "workspace steps scrub credentials" # The prepare/cleanup steps run before validation, so — like every other step — @@ -535,12 +811,24 @@ test_workspace_step_scrub() { # those channels at startup, before a step's script can scrub, so a caller's job env # could otherwise run code with a provider token in scope. local a p missing - for a in build-app-cli deploy-fastly healthcheck-fastly rollback-fastly config-push-fastly; do + for a in build-app-cli deploy-fastly healthcheck-fastly rollback-fastly config-push-fastly \ + package-application-release-fastly require-github-environment; do p="$ACTIONS_DIR/$a/action.yml" missing=$(run_steps_missing_env_scrub "$p") assert_equals "$a: every run: step blanks BASH_ENV and ENV" "" "$missing" done + + # Fastly lifecycle actions must replace the whole Fastly environment surface + # in every shell step. Typed credentials are installed only in the step that + # needs them; ambient service IDs, endpoints, profiles, and tokens stay inert. + for a in deploy-fastly healthcheck-fastly rollback-fastly config-push-fastly \ + package-application-release-fastly require-github-environment; do + p="$ACTIONS_DIR/$a/action.yml" + missing=$(run_steps_missing_fastly_env_boundary "$p") + assert_equals "$a: every run: step replaces all Fastly environment aliases" "" "$missing" + done + # The credential-scrubbing steps ADDITIONALLY blank the shipped FASTLY_API_TOKEN # alias: prepare/cleanup, and the build-app-cli compile/publish/cleanup steps, run # before (or without) the deploy's typed-credential import, so an inherited raw @@ -616,6 +904,19 @@ test_no_inline_action_scripts() { bad=$(grep -nE '^[[:space:]]*run:' "$p" | grep -vF '.sh' || true) assert_equals "$(basename "$(dirname "$p")"): every run: invokes a .sh script" "" "$bad" done + + local embedded_language=py"thon" heredoc_marker=P"Y" + local embedded_python_pattern="${embedded_language}3 .*<<|${embedded_language} .*<<|<<'?${heredoc_marker}'?" + bad=$(grep -REn --include='*.sh' -E "$embedded_python_pattern" "$ACTIONS_DIR" || true) + assert_equals "action shell scripts contain no embedded Python programs" "" "$bad" + + local command_pattern="(^|[[:space:]|;&])(${embedded_language}3?|pip3?|pipx)([[:space:]]|$)" + bad=$( + grep -REn --include='*.sh' --include='*.yml' -E "$command_pattern" \ + "$ACTIONS_DIR" "$REPO_ROOT/.github/workflows" "$REPO_ROOT/scripts/run_coverage.sh" | + grep -vE ':[0-9]+:[[:space:]]*#' || true + ) + assert_equals "action and CI tooling use only approved shell tools" "" "$bad" } test_cleanup_confinement() { @@ -644,51 +945,6 @@ test_cleanup_confinement() { assert_succeeds "no RUNNER_TEMP: removes nothing" "$CORE_SCRIPTS/cleanup.sh" } -# --------------------------------------------------------------------------- -# run-app-cli.sh — the action's private env must not survive into the app CLI -# --------------------------------------------------------------------------- -test_action_env_scrub() { - section "action-private env scrub" - local dir="$WORK_DIR/scrub" - mkdir -p "$dir/bin" - # A stand-in CLI that reports the environment it was handed. - cat >"$dir/bin/scrub-cli" <<'CLI' -#!/usr/bin/env bash -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_MANIFEST=%s\n' "${EDGEZERO_MANIFEST:-ABSENT}" -CLI - chmod +x "$dir/bin/scrub-cli" - printf 'FASTLY_API_TOKEN\0' >"$dir/clear.nul" - - local out - out=$( - PATH="$dir/bin:$PATH" \ - EDGEZERO__APP__CLI__BIN=scrub-cli EDGEZERO__ADAPTER=fastly EDGEZERO__PROJECT__WORKING_DIRECTORY="$dir" \ - EDGEZERO__PROJECT__MANIFEST_PATH="$dir/edgezero.toml" \ - EDGEZERO__PROVIDER__ENV_CLEAR_FILE="$dir/clear.nul" \ - EDGEZERO__PROVIDER__ENV='{"FASTLY_API_TOKEN":"s3cret"}' \ - EDGEZERO__FASTLY__API_TOKEN='s3cret' \ - "$CORE_SCRIPTS/run-app-cli.sh" deploy 2>/dev/null - ) - - # What the CLI IS promised. - assert_equals "the typed provider alias is delivered" \ - "FASTLY_API_TOKEN=s3cret" "$(grep '^FASTLY_API_TOKEN=' <<<"$out")" - assert_equals "EDGEZERO_MANIFEST is delivered" \ - "EDGEZERO_MANIFEST=$dir/edgezero.toml" "$(grep '^EDGEZERO_MANIFEST=' <<<"$out")" - - # What it must NEVER see: the same secret under names we never promised. - assert_equals "the provider-env JSON blob does not survive" \ - "EDGEZERO__PROVIDER__ENV=ABSENT" "$(grep '^EDGEZERO__PROVIDER__ENV=' <<<"$out")" - assert_equals "the action's token carrier does not survive" \ - "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")" -} - # --------------------------------------------------------------------------- # validate-inputs.sh — action-owned passthrough bypasses the caller allowlist # --------------------------------------------------------------------------- @@ -721,51 +977,6 @@ test_deploy_args_prepend() { # --------------------------------------------------------------------------- # common.sh — anchored version parsing, required inputs, private logs # --------------------------------------------------------------------------- -# --------------------------------------------------------------------------- -# run-app-cli.sh — provider values must survive the Bash boundary intact -# --------------------------------------------------------------------------- -# `export NAME=value` truncates at the first NUL, so a NUL-bearing credential -# would be silently altered rather than rejected. The guard must reject NUL and -# still accept ordinary values — a NUL check that also rejects spaces would break -# every real token. -test_provider_env_nul() { - section "provider-env NUL rejection" - local dir="$WORK_DIR/nul" - mkdir -p "$dir/bin" "$dir/app" - printf '#!/usr/bin/env bash\nexit 0\n' >"$dir/bin/nul-cli" - chmod +x "$dir/bin/nul-cli" - printf 'FASTLY_API_TOKEN\0' >"$dir/clear.nul" - - run_with_env() { - PATH="$dir/bin:$PATH" \ - EDGEZERO__APP__CLI__BIN=nul-cli EDGEZERO__ADAPTER=fastly \ - EDGEZERO__PROJECT__WORKING_DIRECTORY="$dir/app" \ - EDGEZERO__PROVIDER__ENV_CLEAR_FILE="$dir/clear.nul" \ - EDGEZERO__PROVIDER__ENV="$1" \ - "$CORE_SCRIPTS/run-app-cli.sh" deploy >/dev/null 2>&1 - } - - # jq builds the NUL: a raw NUL cannot survive argv, which is the whole point. - local nul_json - nul_json=$(jq -nc '{FASTLY_API_TOKEN: "abc\u0000def"}') - assert_fails "a NUL-bearing provider value is rejected" run_with_env "$nul_json" - - # A NUL check must not become a space check. - assert_succeeds "an ordinary value containing spaces is accepted" \ - run_with_env '{"FASTLY_API_TOKEN":"tok with spaces"}' - assert_succeeds "a plain token is accepted" \ - run_with_env '{"FASTLY_API_TOKEN":"abc123"}' - - # CR/LF are rejected too: the `$(base64 --decode)` step strips trailing newlines, - # so a value ending in one would be silently truncated to a wrong credential. - assert_fails "an LF-bearing provider value is rejected" \ - run_with_env "$(jq -nc '{FASTLY_API_TOKEN: "abc\ndef"}')" - assert_fails "a trailing-newline provider value is rejected" \ - run_with_env "$(jq -nc '{FASTLY_API_TOKEN: "abc\n"}')" - assert_fails "a CR-bearing provider value is rejected" \ - run_with_env "$(jq -nc '{FASTLY_API_TOKEN: "abc\rdef"}')" -} - test_lifecycle_helpers() { section "lifecycle helpers" # NB: sourced in subshells only — common.sh defines its own `fail`, which would @@ -1163,14 +1374,14 @@ test_toolchain_boundary() { } # --------------------------------------------------------------------------- -# config-push.sh — the staging key is a different key, driven by --staging +# config-push.sh — canonical KEY is selected by the deployment environment # --------------------------------------------------------------------------- # Runs config-push.sh against a fake app CLI that records its argv and emits the # canonical pushed-key line. Returns the recorded argv (one arg per line). run_config_push_argv() { local dir="$WORK_DIR/config-push" rm -rf "$dir" - mkdir -p "$dir/bin" "$dir/app" + mkdir -p "$dir/bin" "$dir/app" "$dir/release" # A fake app CLI: record every argument, then emit the contract line so the # wrapper's anchored parse succeeds. cat >"$dir/bin/fake-cli" <<'CLI' @@ -1183,13 +1394,18 @@ for a in "$@"; do if [[ "$prev" == "--app-config" ]]; then cp -f "$a" "$FAKE_ARGV_OUT.appconfig" 2>/dev/null || true; fi prev="$a" done -echo "pushed-key=app_config_staging" +runtime_key="${EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY:-}" +printf '%s' "$runtime_key" >"$FAKE_ARGV_OUT.runtime-key" +[[ -z "$runtime_key" || "$runtime_key" == app_config ]] || exit 2 +runtime_key=app_config +echo "pushed-key=$runtime_key" echo "pushed-store=app_config" CLI chmod +x "$dir/bin/fake-cli" # An in-app file every call can reference (this helper recreates $dir, so the # fixture must live here rather than being made by the caller). printf 'x\n' >"$dir/app/real.toml" + printf '[app]\nname = "demo"\n' >"$dir/release/edgezero.toml" # config-push enforces a committed-source guard, so the app dir must be a clean Git # checkout. bin/ and the argv output live in $dir, OUTSIDE $dir/app, so the fake # CLI's recorded-argv writes never dirty the app repo the guard inspects. @@ -1199,20 +1415,27 @@ CLI git -C "$dir/app" add -A git -C "$dir/app" commit -qm fixture - PATH="$dir/bin:$PATH" FAKE_ARGV_OUT="$dir/argv.txt" \ + : >"$dir/ghout" + local -a key_env=(env -u EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY) + if [[ ${CP_RUNTIME_KEY+x} == x ]]; then + key_env=(env "EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY=$CP_RUNTIME_KEY") + fi + local rc=0 + "${key_env[@]}" PATH="$dir/bin:$PATH" FAKE_ARGV_OUT="$dir/argv.txt" GITHUB_OUTPUT="$dir/ghout" \ EDGEZERO__APP__CLI__BIN=fake-cli \ - FASTLY_API_TOKEN=tok \ + EDGEZERO__FASTLY__API_TOKEN=tok \ GITHUB_WORKSPACE="$dir" \ EDGEZERO__PROJECT__WORKING_DIRECTORY=app \ EDGEZERO__DEPLOY__TO="${CP_DEPLOY_TO:-production}" \ EDGEZERO__CONFIG_PUSH__STORE="${CP_STORE:-}" \ EDGEZERO__CONFIG_PUSH__KEY="${CP_KEY:-}" \ - EDGEZERO__CONFIG_PUSH__MANIFEST="${CP_MANIFEST:-}" \ + EDGEZERO__CONFIG_PUSH__MANIFEST="$dir/release/edgezero.toml" \ EDGEZERO__CONFIG_PUSH__APP_CONFIG="${CP_APP_CONFIG:-}" \ - EDGEZERO__CONFIG_PUSH__APP_CONFIG_INLINE="${CP_APP_CONFIG_INLINE:-}" \ + EDGEZERO__CONFIG_PUSH__APP_CONFIG_INLINE="${CP_APP_CONFIG_INLINE-greeting = \"default\"}" \ EDGEZERO__CONFIG_PUSH__NO_ENV="${CP_NO_ENV:-false}" \ - "$ACTIONS_DIR/config-push-fastly/scripts/config-push.sh" >/dev/null 2>&1 + "$ACTIONS_DIR/config-push-fastly/scripts/config-push.sh" >/dev/null 2>&1 || rc=$? cat "$dir/argv.txt" 2>/dev/null + return "$rc" } # Run config-push.sh with a caller-supplied path; used for confinement checks. @@ -1220,8 +1443,9 @@ config_push_rejects_path() { local var="$1" value="$2" local dir="$WORK_DIR/config-push" env "$var=$value" PATH="$dir/bin:$PATH" FAKE_ARGV_OUT="$dir/argv.txt" \ - EDGEZERO__APP__CLI__BIN=fake-cli FASTLY_API_TOKEN=tok \ + EDGEZERO__APP__CLI__BIN=fake-cli EDGEZERO__FASTLY__API_TOKEN=tok \ GITHUB_WORKSPACE="$dir" EDGEZERO__PROJECT__WORKING_DIRECTORY=app \ + EDGEZERO__CONFIG_PUSH__MANIFEST="$dir/release/edgezero.toml" \ "$ACTIONS_DIR/config-push-fastly/scripts/config-push.sh" } @@ -1232,19 +1456,32 @@ test_config_push_argv() { local prod prod=$(run_config_push_argv) assert_equals "production drives 'config push --adapter fastly'" \ - $'config\npush\n--adapter\nfastly\n--yes\n--no-diff' "$prod" - - # Staging: same argv plus --staging (the CLI then writes _staging). + $'config\npush\n--adapter\nfastly' "$(printf '%s\n' "$prod" | head -4)" + # shellcheck disable=SC2016 # awk program, not a shell interpolation + assert_succeeds "config push always pins the bundled release manifest" \ + awk -v manifest="$WORK_DIR/config-push/release/edgezero.toml" \ + '$0 == "--manifest" { getline; found = ($0 == manifest) } END { exit !found }' <<<"$prod" + assert_succeeds "config push always passes one explicit typed config file" \ + grep -qx -- '--app-config' <<<"$prod" + + # Staging changes the publication target, not the config entry key. local staged staged=$(CP_DEPLOY_TO=staging run_config_push_argv) assert_succeeds "staging appends --staging" grep -qx -- '--staging' <<<"$staged" + assert_succeeds "staging reports the same logical key" \ + grep -qx 'pushed-key=app_config' "$WORK_DIR/config-push/ghout" assert_fails "production does NOT pass --staging" grep -qx -- '--staging' <<<"$prod" - # Typed --store / --key are threaded through when supplied. + CP_RUNTIME_KEY=publisher-selected CP_DEPLOY_TO=staging \ + assert_fails "Fastly rejects a conflicting environment KEY" run_config_push_argv + + # The managed action may select a logical store. Its deprecated key input + # fails before the application CLI can mutate a provider store. local with_store - with_store=$(CP_STORE=cfg CP_KEY=mykey run_config_push_argv) + with_store=$(CP_STORE=cfg run_config_push_argv) assert_succeeds "--store is threaded" grep -qx -- 'cfg' <<<"$with_store" - assert_succeeds "--key is threaded" grep -qx -- 'mykey' <<<"$with_store" + CP_KEY=mykey assert_fails "deprecated key input is rejected before mutation" \ + run_config_push_argv # Inline config: threaded as --app-config pointing at an action-owned temp file # that holds exactly the supplied content (no checkout file required). @@ -1260,17 +1497,20 @@ test_config_push_argv() { printf '#!/usr/bin/env bash\necho "pushed-key=release/canary"\necho "pushed-store=app_config"\n' >"$cpdir/bin/fake-cli" chmod +x "$cpdir/bin/fake-cli" : >"$cpdir/ghout" - env PATH="$cpdir/bin:$PATH" EDGEZERO__APP__CLI__BIN=fake-cli FASTLY_API_TOKEN=tok \ + env PATH="$cpdir/bin:$PATH" EDGEZERO__APP__CLI__BIN=fake-cli EDGEZERO__FASTLY__API_TOKEN=tok \ GITHUB_WORKSPACE="$cpdir" EDGEZERO__PROJECT__WORKING_DIRECTORY=app \ RUNNER_TEMP="$cpdir" GITHUB_OUTPUT="$cpdir/ghout" \ + EDGEZERO__CONFIG_PUSH__MANIFEST="$cpdir/release/edgezero.toml" \ + EDGEZERO__CONFIG_PUSH__APP_CONFIG_INLINE='x = 1' \ "$ACTIONS_DIR/config-push-fastly/scripts/config-push.sh" >/dev/null 2>&1 assert_succeeds "a pushed key containing '/' is accepted, not rejected post-write" \ grep -qx 'pushed-key=release/canary' "$cpdir/ghout" # cd into the app dir must precede the mutation-attempted signal: a directory- # entry failure means the CLI was never invoked, so it must not falsely signal. - local cp="$ACTIONS_DIR/config-push-fastly/scripts/config-push.sh" cd_line sig_line - cd_line=$(grep -n 'could not enter working-directory' "$cp" | head -1 | cut -d: -f1) + local cp="$ACTIONS_DIR/deploy-core/scripts/run-app-cli.sh" cd_line sig_line + # shellcheck disable=SC2016 # Match the literal variable reference in the launcher. + cd_line=$(grep -n 'cd "$working_directory"' "$cp" | head -1 | cut -d: -f1) sig_line=$(grep -n 'append_output mutation-attempted' "$cp" | head -1 | cut -d: -f1) assert_succeeds "config-push cd precedes the mutation-attempted signal" \ test "$cd_line" -lt "$sig_line" @@ -1284,15 +1524,25 @@ test_config_push_argv() { # A file path and inline content are mutually exclusive, and no-env must be a # boolean — both fail closed with a named diagnostic (never a silent default). assert_fails_with "app-config and app-config-inline are mutually exclusive" \ - "mutually exclusive" \ - env PATH="$WORK_DIR/config-push/bin:$PATH" EDGEZERO__APP__CLI__BIN=fake-cli FASTLY_API_TOKEN=tok \ + "exactly one" \ + env PATH="$WORK_DIR/config-push/bin:$PATH" EDGEZERO__APP__CLI__BIN=fake-cli EDGEZERO__FASTLY__API_TOKEN=tok \ GITHUB_WORKSPACE="$WORK_DIR/config-push" EDGEZERO__PROJECT__WORKING_DIRECTORY=app \ + EDGEZERO__CONFIG_PUSH__MANIFEST="$WORK_DIR/config-push/release/edgezero.toml" \ EDGEZERO__CONFIG_PUSH__APP_CONFIG=real.toml EDGEZERO__CONFIG_PUSH__APP_CONFIG_INLINE='x = 1' \ "$ACTIONS_DIR/config-push-fastly/scripts/config-push.sh" + assert_fails_with "one typed config input is required" \ + "exactly one" \ + env PATH="$WORK_DIR/config-push/bin:$PATH" EDGEZERO__APP__CLI__BIN=fake-cli EDGEZERO__FASTLY__API_TOKEN=tok \ + GITHUB_WORKSPACE="$WORK_DIR/config-push" EDGEZERO__PROJECT__WORKING_DIRECTORY=app \ + EDGEZERO__CONFIG_PUSH__MANIFEST="$WORK_DIR/config-push/release/edgezero.toml" \ + EDGEZERO__CONFIG_PUSH__APP_CONFIG='' EDGEZERO__CONFIG_PUSH__APP_CONFIG_INLINE='' \ + "$ACTIONS_DIR/config-push-fastly/scripts/config-push.sh" assert_fails_with "an invalid no-env value is rejected" \ "input 'no-env' must be" \ - env PATH="$WORK_DIR/config-push/bin:$PATH" EDGEZERO__APP__CLI__BIN=fake-cli FASTLY_API_TOKEN=tok \ + env PATH="$WORK_DIR/config-push/bin:$PATH" EDGEZERO__APP__CLI__BIN=fake-cli EDGEZERO__FASTLY__API_TOKEN=tok \ GITHUB_WORKSPACE="$WORK_DIR/config-push" EDGEZERO__PROJECT__WORKING_DIRECTORY=app \ + EDGEZERO__CONFIG_PUSH__MANIFEST="$WORK_DIR/config-push/release/edgezero.toml" \ + EDGEZERO__CONFIG_PUSH__APP_CONFIG_INLINE='x = 1' \ EDGEZERO__CONFIG_PUSH__NO_ENV=yes \ "$ACTIONS_DIR/config-push-fastly/scripts/config-push.sh" @@ -1310,7 +1560,7 @@ test_config_push_argv() { chmod +x "$cli" fi env PATH="$WORK_DIR/config-push/bin:$PATH" FAKE_ARGV_OUT="$rt/argv.txt" \ - EDGEZERO__APP__CLI__BIN=fake-cli FASTLY_API_TOKEN=tok \ + EDGEZERO__APP__CLI__BIN=fake-cli EDGEZERO__FASTLY__API_TOKEN=tok \ GITHUB_WORKSPACE="$WORK_DIR/config-push" EDGEZERO__PROJECT__WORKING_DIRECTORY=app \ RUNNER_TEMP="$rt" EDGEZERO__CONFIG_PUSH__APP_CONFIG_INLINE='a = 1' \ "$ACTIONS_DIR/config-push-fastly/scripts/config-push.sh" >/dev/null 2>&1 || true @@ -1320,65 +1570,31 @@ test_config_push_argv() { # A bad deploy-to must fail closed, never silently push to production. assert_fails "a non-{production,staging} deploy-to is rejected" \ - env EDGEZERO__APP__CLI__BIN=fake-cli FASTLY_API_TOKEN=tok \ + env EDGEZERO__APP__CLI__BIN=fake-cli EDGEZERO__FASTLY__API_TOKEN=tok \ GITHUB_WORKSPACE="$WORK_DIR/config-push" EDGEZERO__PROJECT__WORKING_DIRECTORY=app \ EDGEZERO__DEPLOY__TO=Staging \ "$ACTIONS_DIR/config-push-fastly/scripts/config-push.sh" - # Path confinement: manifest/app-config are caller strings handed to a - # credential-bearing CLI, so nothing may escape the app directory. + # The application manifest is fixed by the release. Publisher-owned config + # files remain confined to the checked-out runtime-config directory. local dir="$WORK_DIR/config-push" printf 'secret\n' >"$WORK_DIR/outside.toml" ln -sf "$WORK_DIR/outside.toml" "$dir/app/escape.toml" - assert_fails "an absolute manifest path is rejected" \ - config_push_rejects_path EDGEZERO__CONFIG_PUSH__MANIFEST "$WORK_DIR/outside.toml" - assert_fails "a traversal manifest path is rejected" \ - config_push_rejects_path EDGEZERO__CONFIG_PUSH__MANIFEST "../outside.toml" - assert_fails "a symlink escaping the app dir is rejected" \ - config_push_rejects_path EDGEZERO__CONFIG_PUSH__MANIFEST "escape.toml" assert_fails "an absolute app-config path is rejected" \ config_push_rejects_path EDGEZERO__CONFIG_PUSH__APP_CONFIG "$WORK_DIR/outside.toml" + assert_fails "a traversal app-config path is rejected" \ + config_push_rejects_path EDGEZERO__CONFIG_PUSH__APP_CONFIG "../outside.toml" + assert_fails "a symlink escaping app-config path is rejected" \ + config_push_rejects_path EDGEZERO__CONFIG_PUSH__APP_CONFIG "escape.toml" # Confinement must not over-reject: an in-app path still works. local ok - ok=$(CP_MANIFEST=real.toml run_config_push_argv || true) - assert_succeeds "an in-app manifest path is accepted and threaded" \ + ok=$(CP_APP_CONFIG=real.toml CP_APP_CONFIG_INLINE='' run_config_push_argv || true) + assert_succeeds "an in-app config path is accepted and threaded" \ grep -qx -- 'real.toml' <<<"$ok" } -# --------------------------------------------------------------------------- -# run-app-cli.sh — the CLI's exit status is the step's exit status -# --------------------------------------------------------------------------- -# A deploy that fails must fail the step. If the engine swallowed the exit code, -# a broken deploy would report success and the caller would never roll back. -test_exit_propagation() { - section "exit propagation" - local dir="$WORK_DIR/exit-prop" - mkdir -p "$dir/bin" "$dir/app" - cat >"$dir/bin/exit-cli" <<'CLI' -#!/usr/bin/env bash -exit "${FAKE_EXIT_CODE:-0}" -CLI - chmod +x "$dir/bin/exit-cli" - - run_with_exit() { - PATH="$dir/bin:$PATH" FAKE_EXIT_CODE="$1" \ - EDGEZERO__APP__CLI__BIN=exit-cli EDGEZERO__ADAPTER=fastly \ - EDGEZERO__PROJECT__WORKING_DIRECTORY="$dir/app" \ - "$CORE_SCRIPTS/run-app-cli.sh" build >/dev/null 2>&1 - } - - # NB: capture with `|| rc=$?` — a trailing `|| true` would reset $? to 0 and - # make this test vacuously pass. - local rc=0 - run_with_exit 0 || rc=$? - assert_equals "a succeeding CLI exits 0" "0" "$rc" - rc=0 - run_with_exit 42 || rc=$? - assert_equals "a failing CLI's exit code reaches the step (42, not 1)" "42" "$rc" -} - # --------------------------------------------------------------------------- # resolve-project.sh — deploys require committed source # --------------------------------------------------------------------------- @@ -1736,14 +1952,15 @@ test_action_output_contracts() { missing=$((missing + 1)) continue fi - # The named step's OWN script must emit it — not merely some other action. - # Exception: a script may DELEGATE the run to the shared run-app-cli.sh - # launcher (which emits `mutation-attempted` itself, right before it invokes - # the CLI); if the step's script calls it, that counts as emitting. + # The named step's own script must emit it, or delegate to a shared core + # script that emits it as part of the same invocation. emitted=$(grep -oE "append_output ${out_name}( |\$)" "$script" || true) if [[ -z "$emitted" ]] && grep -q 'run-app-cli\.sh' "$script"; then emitted=$(grep -oE "append_output ${out_name}( |\$)" "$CORE_SCRIPTS/run-app-cli.sh" || true) fi + if [[ -z "$emitted" ]] && grep -q 'release-core/scripts/package-release\.sh' "$script"; then + emitted=$(grep -oE "append_output ${out_name}( |\$)" "$ACTIONS_DIR/release-core/scripts/package-release.sh" || true) + fi if [[ -z "$emitted" ]]; then fail "$name_of output '$out_name' claims step '$step_id' ($(basename "$script")) emits it, but that script does not" missing=$((missing + 1)) @@ -1818,21 +2035,17 @@ EOF assert_equals "deploy-fastly public surface" "$( cat <<'EOF' -in app-cli-artifact true none -in app-cli-bin false "" -in build-args false "[]" -in build-mode false auto -in cache false "false" +in app-release-archive true none +in app-release-sha256 true none in deploy-args false "[]" in deploy-to false production +in expected-source-revision true none in fastly-api-token true none in fastly-service-id true none -in manifest false "" -in rust-toolchain false auto -in working-directory false . out app-cli-version out fastly-version out mutation-attempted +out package-digest out previous-version out provider-cli-version out source-revision @@ -1841,14 +2054,14 @@ EOF assert_equals "config-push-fastly public surface" "$( cat <<'EOF' -in app-cli-artifact true none -in app-cli-bin false "" in app-config false "" in app-config-inline false "" +in app-release-archive true none +in app-release-sha256 true none in deploy-to false production +in expected-source-revision true none in fastly-api-token true none in key false "" -in manifest false "" in no-env false "false" in store false "" in working-directory false . @@ -1861,9 +2074,10 @@ EOF assert_equals "rollback-fastly public surface" "$( cat <<'EOF' -in app-cli-artifact true none -in app-cli-bin false "" +in app-release-archive true none +in app-release-sha256 true none in deploy-to false production +in expected-source-revision true none in fastly-api-token true none in fastly-service-id true none in fastly-version true none @@ -1875,10 +2089,11 @@ EOF assert_equals "healthcheck-fastly public surface" "$( cat <<'EOF' -in app-cli-artifact true none -in app-cli-bin false "" +in app-release-archive true none +in app-release-sha256 true none in deploy-to false production in domain true none +in expected-source-revision true none in fastly-api-token false "" in fastly-service-id true none in fastly-version true none @@ -1890,6 +2105,679 @@ out healthy out status-code EOF )" "$(parse_action_surface "$ACTIONS_DIR/healthcheck-fastly/action.yml")" + + assert_equals "package-application-release-fastly public surface" "$( + cat <<'EOF' +in adapter-manifest true none +in app-cli-archive true none +in application-manifest true none +in artifact-name false application-release +in fastly-package true none +in source-revision true none +out archive-sha256 +out artifact-name +out package-sha256 +out source-revision +EOF + )" "$(parse_action_surface "$ACTIONS_DIR/package-application-release-fastly/action.yml")" + + assert_equals "require-github-environment public surface" "$( + cat <<'EOF' +in environment-name true none +in github-token true none +in repository true none +out environment-name +EOF + )" "$(parse_action_surface "$ACTIONS_DIR/require-github-environment/action.yml")" +} + +test_fastly_release_action_wiring() { + section "Fastly release action wiring" + local caller_script_dir + caller_script_dir=$(bash -c 'SCRIPT_DIR=caller-owned; source "$1"; printf "%s" "$SCRIPT_DIR"' \ + _ "$ACTIONS_DIR/fastly-common/scripts/common.sh") + assert_equals "Fastly common helpers preserve the caller's SCRIPT_DIR" \ + "caller-owned" "$caller_script_dir" + + local action + for action in deploy-fastly config-push-fastly healthcheck-fastly rollback-fastly; do + local file="$ACTIONS_DIR/$action/action.yml" + assert_succeeds "$action prepares the pinned application release" \ + grep -q 'release-core/scripts/prepare-release.sh' "$file" + assert_fails "$action never downloads a separately rebuilt CLI artifact" \ + grep -q 'actions/download-artifact' "$file" + assert_succeeds "$action extracts the CLI archive recorded by the release" \ + grep -q 'EDGEZERO__APP__CLI__ARCHIVE:' "$file" + local release_line cli_line release_step + release_line=$(grep -n 'release-core/scripts/prepare-release.sh' "$file" | cut -d: -f1) + cli_line=$(grep -n 'EDGEZERO__APP__CLI__ARCHIVE:' "$file" | cut -d: -f1) + assert_succeeds "$action verifies release metadata before extracting or invoking the app CLI" \ + test "$release_line" -lt "$cli_line" + release_step=$(step_block "$file" "Verify application release") + assert_succeeds "$action supplies the Fastly release adapter policy" \ + grep -qF 'EDGEZERO__APP__RELEASE__EXPECTED_ADAPTER: fastly' <<<"$release_step" + assert_succeeds "$action supplies lifecycle protocol 1" \ + grep -qF 'EDGEZERO__APP__RELEASE__EXPECTED_LIFECYCLE_PROTOCOL: "1"' <<<"$release_step" + assert_fails "$action cannot continue after release verification failure" \ + grep -qE '^[[:space:]]*continue-on-error:' <<<"$release_step" + done + local script + for script in \ + deploy-fastly/scripts/validate.sh deploy-fastly/scripts/deploy.sh \ + deploy-fastly/scripts/capture-previous.sh healthcheck-fastly/scripts/validate.sh \ + healthcheck-fastly/scripts/healthcheck.sh rollback-fastly/scripts/validate.sh \ + rollback-fastly/scripts/rollback.sh; do + assert_succeeds "$script uses the shared Fastly service-ID contract" \ + grep -q 'fastly-common/scripts/common.sh' "$ACTIONS_DIR/$script" + assert_succeeds "$script calls the shared Fastly service-ID helper" \ + grep -q 'require_fastly_service_id' "$ACTIONS_DIR/$script" + done + for script in \ + deploy-fastly/scripts/deploy.sh deploy-fastly/scripts/capture-previous.sh \ + config-push-fastly/scripts/config-push.sh healthcheck-fastly/scripts/healthcheck.sh \ + rollback-fastly/scripts/rollback.sh; do + assert_succeeds "$script delegates application CLI execution to the provider-neutral core" \ + grep -q 'deploy-core/scripts/run-app-cli.sh' "$ACTIONS_DIR/$script" + done + assert_fails "provider-neutral action cores contain no provider policy" \ + grep -REiq 'fastly|cloudflare|spin|axum' \ + "$ACTIONS_DIR/deploy-core/scripts" "$ACTIONS_DIR/release-core/scripts" + assert_succeeds "deploy passes the action-owned release root through the typed flag" \ + grep -q -- '--application-release' "$ACTIONS_DIR/deploy-fastly/action.yml" + assert_succeeds "config push pins the bundled application manifest" \ + grep -q 'EDGEZERO__CONFIG_PUSH__MANIFEST:.*steps.release.outputs' \ + "$ACTIONS_DIR/config-push-fastly/action.yml" + assert_fails "deploy exposes no build controls" \ + grep -Eq '^ (working-directory|manifest|rust-toolchain|build-mode|build-args|cache):' \ + "$ACTIONS_DIR/deploy-fastly/action.yml" +} + +test_release_producer_and_environment_preflight() { + section "release producer and GitHub Environment preflight" + assert_succeeds "GitHub Environment preflight rejects missing and invalid environments" \ + bash "$ACTIONS_DIR/require-github-environment/tests/run.sh" + assert_succeeds "provider-neutral release verification enforces adapter and protocol identity" \ + bash "$ACTIONS_DIR/release-core/tests/run.sh" + assert_succeeds "Fastly application release packager verifies its lifecycle protocol" \ + bash "$ACTIONS_DIR/package-application-release-fastly/tests/run.sh" +} + +test_fastly_smoke_release_contract() { + section "Fastly immutable-release composite smoke" + if ! command -v yq >/dev/null 2>&1; then + skip "Fastly immutable-release composite smoke (yq not installed)" + return 0 + fi + + local workflow="$REPO_ROOT/.github/workflows/deploy-action.yml" + local fixture="$ACTIONS_DIR/deploy-fastly/tests/make-smoke-fixture.sh" + local fake="$ACTIONS_DIR/deploy-fastly/tests/make-fake-fastly-env.sh" + local staged="$ACTIONS_DIR/deploy-fastly/tests/assert-staged-calls.sh" + local production="$ACTIONS_DIR/deploy-fastly/tests/assert-production-deploy.sh" + local lost="$ACTIONS_DIR/deploy-fastly/tests/assert-lost-version.sh" + local lifecycle_filter + lifecycle_filter='select(tag == "!!map" and (.uses == "./.github/actions/deploy-fastly" or .uses == "./.github/actions/config-push-fastly" or .uses == "./.github/actions/healthcheck-fastly" or .uses == "./.github/actions/rollback-fastly"))' + + local missing_release stale_inputs archives store_aware_digests store_aware_revisions + missing_release=$(yq eval -r \ + ".. | $lifecycle_filter | select(.with.\"app-release-archive\" == null or .with.\"app-release-sha256\" == null or .with.\"expected-source-revision\" == null) | .name" \ + "$workflow") + assert_equals "every Fastly lifecycle action receives the immutable release identity" \ + "" "$missing_release" + + stale_inputs=$(yq eval -r \ + ".. | $lifecycle_filter | .with | keys | .[] | select(. == \"app-cli-artifact\" or . == \"manifest\" or . == \"working-directory\" or . == \"rust-toolchain\" or . == \"build-mode\" or . == \"build-args\" or . == \"cache\")" \ + "$workflow" | sort -u) + assert_equals "Fastly lifecycle jobs expose no CLI, manifest, or deployer build selector" \ + "" "$stale_inputs" + + archives=$(yq eval -r ".. | $lifecycle_filter | .with.\"app-release-archive\"" \ + "$workflow" | sort -u) + store_aware_digests=$(yq eval -r \ + ".jobs | to_entries[] | select(.key != \"store-free-deploy-smoke\") | .value.steps[]? | $lifecycle_filter | .with.\"app-release-sha256\"" \ + "$workflow" | sort -u) + store_aware_revisions=$(yq eval -r \ + ".jobs | to_entries[] | select(.key != \"store-free-deploy-smoke\") | .value.steps[]? | $lifecycle_filter | .with.\"expected-source-revision\"" \ + "$workflow" | sort -u) + # shellcheck disable=SC2016 # GitHub expression is the literal contract under test. + assert_equals "every lifecycle action consumes an action-owned release archive path" \ + '${{ github.workspace }}/fixture-release/app-release.tar.gz' "$archives" + # shellcheck disable=SC2016 # GitHub expression is the literal contract under test. + assert_equals "all store-aware lifecycle cases consume the same pinned release digest" \ + '${{ needs.fixture-release.outputs.app-release-sha256 }}' "$store_aware_digests" + # shellcheck disable=SC2016 # GitHub expression is the literal contract under test. + assert_equals "all store-aware lifecycle cases consume the same source revision" \ + '${{ needs.fixture-release.outputs.source-revision }}' "$store_aware_revisions" + assert_succeeds "one fixture release is built before the deployment matrix" \ + grep -q '^ fixture-release:' "$workflow" + + local store_free_deploys store_free_digest store_free_revision store_free_source + store_free_deploys=$(yq eval -r \ + '[.jobs."store-free-deploy-smoke".steps[]? | select(.uses == "./.github/actions/deploy-fastly")] | length' \ + "$workflow") + assert_equals "the workflow executes one release-backed store-free deployment" \ + 1 "$store_free_deploys" + store_free_digest=$(yq eval -r \ + '.jobs."store-free-deploy-smoke".steps[]? | select(.uses == "./.github/actions/deploy-fastly") | .with."app-release-sha256"' \ + "$workflow") + # shellcheck disable=SC2016 # GitHub expression is the literal contract under test. + assert_equals "the store-free deployment consumes its distinct immutable release" \ + '${{ needs.store-free-release.outputs.app-release-sha256 }}' "$store_free_digest" + store_free_revision=$(yq eval -r \ + '.jobs."store-free-deploy-smoke".steps[]? | select(.uses == "./.github/actions/deploy-fastly") | .with."expected-source-revision"' \ + "$workflow") + # shellcheck disable=SC2016 # GitHub expression is the literal contract under test. + assert_equals "the store-free deployment consumes its source revision" \ + '${{ needs.store-free-release.outputs.source-revision }}' "$store_free_revision" + store_free_source=$(yq eval -r \ + '.jobs."store-free-release".steps[]? | select(.run != null) | .run' "$workflow") + assert_succeeds "the distinct store-free application release is actually assembled" \ + grep -q 'make-smoke-fixture.sh source store-free' <<<"$store_free_source" + + local install_step + install_step=$(yq eval -r \ + '.jobs.static-checks.steps[] | select(.name == "Install pinned validation binaries") | .run' \ + "$workflow") + # shellcheck disable=SC2016 # GitHub's path file is the literal workflow contract. + assert_succeeds "the pinned yq directory is handed to every later workflow step" \ + grep -Fq '>>"$GITHUB_PATH"' <<<"$install_step" + + assert_succeeds "the fixture supports an explicit store-free application mode" \ + grep -q 'store-free' "$fixture" + assert_succeeds "the fixture supports an explicit store-aware application mode" \ + grep -q 'store-aware' "$fixture" + assert_succeeds "the executable config-push smoke registers the Spin validator" \ + grep -Fq '"edgezero-adapter-spin"' "$fixture" + assert_succeeds "the smoke application references its Spin manifest" \ + grep -Fq 'manifest = "adapter/spin.toml"' "$fixture" + # shellcheck disable=SC2016 # jq source contains a literal shell variable name. + assert_succeeds "the Fastly smoke release records one selected adapter manifest" \ + grep -Fq 'adapter: {path: "adapter/fastly.toml", sha256: $fastly}' "$fixture" + assert_fails "the Fastly smoke release omits the unselected Spin manifest" \ + grep -Fq 'adapter/fastly.toml adapter/spin.toml' "$fixture" + assert_succeeds "the fake seeds active v40 with logical Config, KV, and Secret aliases" \ + grep -Fq 'LINK_CONFIG_PROD\tapp_config\tCONFIGPROD\tconfig\nLINK_KV_PROD\tcache\tKVPROD\tkv-store\nLINK_SECRET_PROD\tcredentials\tSECRETPROD\tsecret-store' "$fake" + assert_succeeds "the staged assertion checks staging resources under logical aliases" \ + grep -Fq 'alias:"app_config", resource:"CONFIGSTAGE"' "$staged" + assert_succeeds "production checks selected resources under logical aliases" \ + grep -Fq 'alias:"app_config", resource:"CONFIGPROD"' "$production" + assert_fails "the fake has no runtime descriptor key" \ + grep -Fq 'EDGEZERO__SERVICES__' "$fake" + assert_succeeds "production asserts the verified release package digest" \ + grep -q 'EDGEZERO__TEST__PACKAGE_DIGEST' "$production" + assert_succeeds "staging link-order failures show expected mutations" \ + grep -Fq 'expected resource mutations:' "$staged" + assert_succeeds "staging link-order failures show actual mutations" \ + grep -Fq 'actual resource mutations:' "$staged" + assert_succeeds "production preserves already-correct resource links" \ + grep -Fq 'must retain already-correct resource links' "$production" + assert_succeeds "failed deployment asserts its recoverable version and package digest" \ + grep -q 'EDGEZERO__TEST__FASTLY_VERSION' "$lost" + assert_succeeds "failed deployment checks its verified package digest" \ + grep -q 'EDGEZERO__TEST__PACKAGE_DIGEST' "$lost" + assert_succeeds "the executable staging smoke uses the real Fastly domain" \ + grep -Fq 'domain: app.example.com' "$workflow" + assert_succeeds "the executable staging smoke keeps a distinct GitHub Environment identifier" \ + grep -Fq 'EDGEZERO__TEST__GITHUB_ENVIRONMENT: staging.app.example.com' "$workflow" + + local legacy + for legacy in STAGESEL1 \ + EDGEZERO__SERVICES__dummyservice__STORES EDGEZERO__SERVICES__dummyservice__VERSIONS; do + assert_fails "smoke fixtures issue no legacy selector or staging-twin command ($legacy)" \ + grep -Fq -- "$legacy" "$fake" "$staged" "$production" "$lost" "$workflow" + done +} + +test_smoke_release_uses_application_revision() { + section "Fastly smoke release source revision" + local workspace="$WORK_DIR/revision-workspace" + local output="$workspace/output" + mkdir -p "$workspace/fixture-app/adapter" + git -C "$workspace" init -q + git -C "$workspace" config user.email test@example.com + git -C "$workspace" config user.name Test + printf 'harness checkout\n' >"$workspace/harness.txt" + git -C "$workspace" add harness.txt + git -C "$workspace" commit -q -m harness + + git -C "$workspace/fixture-app" init -q + git -C "$workspace/fixture-app" config user.email test@example.com + git -C "$workspace/fixture-app" config user.name Test + printf '[app]\nname = "fixture"\n[adapters.fastly.adapter]\nmanifest = "adapter/fastly.toml"\n[adapters.spin.adapter]\nmanifest = "adapter/spin.toml"\n' \ + >"$workspace/fixture-app/edgezero.toml" + printf '[package]\nname = "fixture"\n' >"$workspace/fixture-app/adapter/fastly.toml" + printf 'spin_manifest_version = 2\n[component.fixture]\nsource = "fixture.wasm"\n' \ + >"$workspace/fixture-app/adapter/spin.toml" + git -C "$workspace/fixture-app" add -A + git -C "$workspace/fixture-app" commit -q -m fixture + printf 'fixture CLI archive\n' >"$workspace/app-cli.tar" + GITHUB_WORKSPACE="$workspace" GITHUB_OUTPUT="$output" \ + bash "$ACTIONS_DIR/deploy-fastly/tests/make-smoke-fixture.sh" release "$workspace/app-cli.tar" + + local recorded protocol app_revision harness_revision + recorded=$(tar -xOzf "$workspace/fixture-release/app-release.tar.gz" release.json | + jq -er '.source_revision') + protocol=$(tar -xOzf "$workspace/fixture-release/app-release.tar.gz" release.json | + jq -er '.lifecycle_protocol') + app_revision=$(git -C "$workspace/fixture-app" rev-parse HEAD) + harness_revision=$(git -C "$workspace" rev-parse HEAD) + assert_equals "release metadata records lifecycle protocol 1" "1" "$protocol" + assert_equals "release metadata records the fixture application revision" \ + "$app_revision" "$recorded" + assert_fails "release metadata never records the harness checkout revision" \ + test "$recorded" = "$harness_revision" +} + +test_fastly_logical_link_documentation() { + section "Fastly logical resource-link deployment documentation" + local deploy="$REPO_ROOT/docs/guide/deploy-github-actions.md" + local fastly="$REPO_ROOT/docs/guide/adapters/fastly.md" + local cli="$REPO_ROOT/docs/guide/cli-reference.md" + local manifest="$REPO_ROOT/docs/guide/manifest-store-migration.md" + local blob="$REPO_ROOT/docs/guide/blob-app-config-migration.md" + local adoption="$REPO_ROOT/docs/guide/deploy-action-adoption.md" + local fastly_cli="$REPO_ROOT/crates/edgezero-adapter-fastly/src/cli.rs" + local corpus="$WORK_DIR/fastly-logical-link-docs.md" + local example_workflow="$WORK_DIR/application-deploy-workflow.yml" + local example_deploy="$WORK_DIR/application-deploy-job.yml" + local fastly_deployment="$WORK_DIR/fastly-deployment.md" + local fastly_deployment_flat="$WORK_DIR/fastly-deployment-flat.md" + local managed_args="$WORK_DIR/managed-fastly-arguments.md" + local deploy_flat="$WORK_DIR/deploy-github-actions-flat.md" + local adoption_flat="$WORK_DIR/deploy-action-adoption-flat.md" + local managed_lifecycle_comment="$WORK_DIR/managed-lifecycle-comment.txt" + cat "$deploy" "$fastly" "$cli" "$manifest" "$blob" "$adoption" >"$corpus" + + awk ' + /^```yaml$/ { fence = 1; next } + fence && /^name: Deploy Application$/ { capture = 1 } + capture && /^```$/ { exit } + capture { print } + ' "$adoption" >"$example_workflow" + awk ' + /^ deploy:$/ { capture = 1 } + capture { print } + ' "$example_workflow" >"$example_deploy" + awk ' + /^## Deployment$/ { capture = 1 } + capture && /^## Backends$/ { exit } + capture { print } + ' "$fastly" >"$fastly_deployment" + tr '\n' ' ' <"$fastly_deployment" >"$fastly_deployment_flat" + tr '\n' ' ' <"$deploy" >"$deploy_flat" + tr '\n' ' ' <"$adoption" >"$adoption_flat" + awk ' + /^### Managed Fastly argument contract$/ { capture = 1 } + capture && /^::: warning$/ { exit } + capture { print } + ' "$cli" >"$managed_args" + awk ' + /^\/\/ Fastly lifecycle$/ { capture = 1 } + capture && /^\/\/\/ Value that follows/ { exit } + capture { print } + ' "$fastly_cli" | sed 's|^//[ ]*||' | tr '\n' ' ' >"$managed_lifecycle_comment" + + # shellcheck disable=SC2016 # Documentation contract contains literal Markdown backticks. + assert_succeeds "docs describe logical aliases on Fastly version resource links" \ + grep -Fq 'Fastly version resource links bind each' "$corpus" + # shellcheck disable=SC2016 # Documentation contract contains literal Markdown backticks. + assert_succeeds "docs state the production Config Store key" \ + grep -Fq 'production, staging, and local Viceroy' "$corpus" + # shellcheck disable=SC2016 # Documentation contract contains literal Markdown backticks. + assert_succeeds "docs state the staging Config Store key" \ + grep -Fq 'all read ``' "$corpus" + assert_succeeds "docs mark runtime selectors unsupported" \ + grep -Fq 'Runtime descriptors and service-scoped selector keys are unsupported' "$corpus" + assert_fails "docs contain no service-scoped runtime selector key" \ + grep -Fq 'EDGEZERO__SERVICES____VERSIONS____ENV_V1' "$corpus" + assert_succeeds "docs state canonical environment precedence" \ + grep -Fq 'parent value > manifest variable default > logical default' "$manifest" + assert_succeeds "docs retain an optional Secret Store name-only example" \ + grep -Fq 'EDGEZERO__STORES__SECRETS__CREDENTIALS__NAME' "$manifest" + assert_fails "docs never place secret values in runtime selectors" \ + grep -Eq 'EDGEZERO__STORES__SECRETS__[^[:space:]`]*__(KEY|VALUE)' "$corpus" + + # shellcheck disable=SC2016 # GitHub expression is the literal documentation contract. + assert_succeeds "example deploy uses the validated GitHub Environment output" \ + grep -Fq 'environment: ${{ needs.preflight.outputs.environment }}' "$example_deploy" + # shellcheck disable=SC2016 # GitHub expression is the literal documentation contract. + assert_succeeds "example uses the requested domain as the real hostname" \ + grep -Fq 'domain: ${{ inputs.domain }}' "$example_deploy" + # shellcheck disable=SC2016 # GitHub expression is the literal documentation contract. + assert_fails "example never treats a hostname as a GitHub Environment name" \ + grep -Fq 'environment: ${{ inputs.domain }}' "$example_workflow" + assert_succeeds "example preflight derives the production Environment from the real hostname" \ + grep -Fq "production) environment=\"\$DOMAIN\"" "$adoption" + assert_succeeds "example preflight prefixes only the staging Environment identifier" \ + grep -Fq "staging) environment=\"staging.\$DOMAIN\"" "$adoption" + assert_succeeds "example keeps one real application domain across both targets" \ + grep -Fq 'hostname passed to healthcheck for both targets' "$adoption" + assert_succeeds "release identity is selected before the publisher environment" \ + grep -Fq 'Select the source revision and release digest' "$adoption" + assert_succeeds "deployer never checks out or rebuilds application source" \ + grep -Fq 'never checks out or rebuilds application source' "$adoption" + assert_succeeds "publisher environments cannot choose release identity" \ + grep -Fq 'cannot come from a publisher GitHub Environment' "$adoption" + assert_succeeds "one release fixes CLI, package, and manifests for every publisher and target" \ + grep -Fq 'byte-identical application CLI, Fastly package' "$adoption" + # shellcheck disable=SC2016 # Documentation contract contains literal Markdown backticks. + assert_succeeds "one release fixes the selected Fastly manifest" \ + grep -Fq '`edgezero.toml`, and Fastly manifest to every publisher' "$adoption" + assert_succeeds "Fastly releases exclude other adapter manifests" \ + grep -Fq 'adapter manifests are not part of a Fastly application release' "$adoption" + + assert_succeeds "application example is extracted as a workflow" \ + grep -Fxq 'name: Deploy Application' "$example_workflow" + # shellcheck disable=SC2016 # GitHub expression is the literal contract under test. + assert_succeeds "example preflight validates the pinned producer run" \ + grep -Fq 'RELEASE_RUN_ID: ${{ inputs.release-run-id }}' "$example_workflow" + # shellcheck disable=SC2016 # GitHub expression is the literal contract under test. + assert_succeeds "example preflight validates the pinned release digest" \ + grep -Fq 'RELEASE_SHA256: ${{ inputs.release-sha256 }}' "$example_workflow" + # shellcheck disable=SC2016 # GitHub expression is the literal contract under test. + assert_succeeds "example preflight validates the producer repository" \ + grep -Fq 'PRODUCER_REPOSITORY: ${{ inputs.producer-repository }}' "$example_workflow" + assert_succeeds "example preflight verifies the derived GitHub Environment" \ + grep -Fq '/require-github-environment@' "$example_workflow" + assert_succeeds "example deploy depends literally on preflight" \ + grep -Fxq ' needs: preflight' "$example_deploy" + assert_succeeds "deployer checkout remains allowed" \ + grep -Fq 'uses: actions/checkout@v4' "$example_deploy" + assert_succeeds "example downloads the selected immutable release" \ + grep -Fq 'Download the selected application release' "$example_deploy" + assert_succeeds "example uses GitHub's release artifact downloader" \ + grep -Fq 'uses: actions/download-artifact@v4' "$example_deploy" + # shellcheck disable=SC2016 # GitHub expression is the literal contract under test. + assert_succeeds "example pins the producer run used for artifact download" \ + grep -Fq 'run-id: ${{ needs.preflight.outputs.release-run-id }}' "$example_deploy" + # shellcheck disable=SC2016 # GitHub expressions are literal documentation contracts. + assert_succeeds "example downloads from the explicit producer repository" \ + grep -Fq 'repository: ${{ needs.preflight.outputs.producer-repository }}' "$example_deploy" + # shellcheck disable=SC2016 # GitHub expressions are literal documentation contracts. + assert_succeeds "example uses a producer-readable token for artifact download" \ + grep -Fq 'github-token: ${{ secrets.APPLICATION_RELEASE_TOKEN }}' "$example_deploy" + assert_succeeds "each lifecycle action verifies the same downloaded release" \ + grep -Fq 'each lifecycle action independently verifies the same archive, digest, and source revision' "$adoption_flat" + assert_fails "example checkout never selects application source repository or ref" \ + awk '/uses: actions\/checkout@/{checkout=1; next} checkout && /^[[:space:]]+-/{exit} checkout && /^[[:space:]]+(repository|ref):/{found=1} END{exit !found}' "$example_deploy" + assert_fails "example deploy never runs an application build" \ + grep -Eiq 'cargo build|fastly compute build|build-app-cli|app-cli-artifact' "$example_deploy" + assert_fails "example deploy never clones or checks out application source with git" \ + grep -Eiq 'git[[:space:]]+(clone|checkout)' "$example_deploy" + assert_fails "example deploy has no alternate manifest CLI or build selectors" \ + grep -Eq '^[[:space:]]+(manifest|app-cli-bin|build-mode|build-args):' "$example_deploy" + + local action + for action in deploy-fastly config-push-fastly healthcheck-fastly rollback-fastly; do + assert_equals "example invokes $action exactly once" \ + 1 "$(grep -Fc "/$action@" "$example_deploy")" + done + # shellcheck disable=SC2016 # GitHub expressions are literal documentation contracts. + assert_equals "all four example lifecycle actions use one local release archive" \ + 4 "$(grep -Fc 'app-release-archive: ${{ github.workspace }}/app-release/app-release.tar.gz' "$example_deploy")" + # shellcheck disable=SC2016 # GitHub expressions are literal documentation contracts. + assert_equals "all four example lifecycle actions use one release digest" \ + 4 "$(grep -Fc 'app-release-sha256: ${{ needs.preflight.outputs.sha256 }}' "$example_deploy")" + # shellcheck disable=SC2016 # GitHub expressions are literal documentation contracts. + assert_equals "all four example lifecycle actions verify one source revision" \ + 4 "$(grep -Fc 'expected-source-revision: ${{ needs.preflight.outputs.source-revision }}' "$example_deploy")" + assert_succeeds "example requires config reconciliation after a later failure" \ + grep -Fq 'Require config reconciliation after a later failure' "$example_deploy" + assert_succeeds "example documents config compatibility through healthcheck" \ + grep -Fq 'must remain backward-compatible with the current' "$adoption" + local runtime_name + for runtime_name in \ + EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME \ + EDGEZERO__STORES__KV__CACHE__NAME \ + EDGEZERO__STORES__SECRETS__CREDENTIALS__NAME; do + # shellcheck disable=SC2016 # GitHub expressions are literal documentation contracts. + assert_succeeds "example maps canonical runtime variable $runtime_name from vars" \ + grep -Fq "$runtime_name: \${{ vars.$runtime_name }}" "$example_deploy" + done + assert_fails "example never maps a Secret Store value or key" \ + grep -Eq 'EDGEZERO__STORES__SECRETS__[^[:space:]]+__(KEY|VALUE)' "$example_workflow" + + if command -v yq >/dev/null 2>&1; then + assert_succeeds "extracted application example parses as YAML" \ + yq eval '.' "$example_workflow" + local uses action_input + for action in deploy-fastly config-push-fastly healthcheck-fastly rollback-fastly; do + uses="stackpop/edgezero/.github/actions/$action@" + assert_equals "example has exactly one structural $action step" \ + 1 "$(yq eval "[.jobs.deploy.steps[] | select(.uses == \"$uses\")] | length" "$example_workflow")" + action_input=$(yq eval -r \ + ".jobs.deploy.steps[] | select(.uses == \"$uses\") | .with.\"app-release-archive\"" \ + "$example_workflow") + # shellcheck disable=SC2016 # GitHub expression is the literal documentation contract. + assert_equals "$action structurally uses the one local release archive" \ + '${{ github.workspace }}/app-release/app-release.tar.gz' "$action_input" + action_input=$(yq eval -r \ + ".jobs.deploy.steps[] | select(.uses == \"$uses\") | .with.\"app-release-sha256\"" \ + "$example_workflow") + # shellcheck disable=SC2016 # GitHub expression is the literal documentation contract. + assert_equals "$action structurally uses the selected release digest" \ + '${{ needs.preflight.outputs.sha256 }}' "$action_input" + action_input=$(yq eval -r \ + ".jobs.deploy.steps[] | select(.uses == \"$uses\") | .with.\"expected-source-revision\"" \ + "$example_workflow") + # shellcheck disable=SC2016 # GitHub expression is the literal documentation contract. + assert_equals "$action structurally verifies the selected source revision" \ + '${{ needs.preflight.outputs.source-revision }}' "$action_input" + done + for runtime_name in \ + EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME \ + EDGEZERO__STORES__KV__CACHE__NAME \ + EDGEZERO__STORES__SECRETS__CREDENTIALS__NAME; do + action_input=$(yq eval -r ".jobs.deploy.env.\"$runtime_name\"" "$example_workflow") + assert_equals "example structurally maps $runtime_name from its selected Environment" \ + "\${{ vars.$runtime_name }}" "$action_input" + done + else + skip "application documentation workflow structure (yq not installed)" + fi + + if command -v actionlint >/dev/null 2>&1; then + local actionlint_workflow="$WORK_DIR/application-deploy-actionlint.yml" + sed 's/@/@0123456789abcdef0123456789abcdef01234567/g' \ + "$example_workflow" >"$actionlint_workflow" + assert_succeeds "sanitized application example passes actionlint" \ + actionlint "$actionlint_workflow" + else + skip "application documentation workflow actionlint (actionlint not installed)" + fi + + assert_succeeds "CLI docs explain provider-neutral managed deployment ownership" \ + grep -Fq 'provider-neutral deployment ownership' "$cli" + assert_succeeds "CLI docs preserve unregistered manifest-command adapters" \ + grep -Fq 'adapters keep their manifest command' "$cli" + assert_succeeds "CLI docs name the immutable application release flag" \ + grep -Fq -- '--application-release' "$cli" + local flag + for flag in --service-id -s --service-name --version --autoclone --token -t --package/-p; do + assert_succeeds "CLI docs list reserved managed Fastly flag $flag" \ + grep -Fq -- "$flag" "$managed_args" + done + for flag in --comment --accept-defaults -d --auto-yes -y --debug-mode --non-interactive -i --quiet -q --verbose -v; do + assert_succeeds "CLI docs list allowed managed Fastly argument $flag" \ + grep -Fq -- "$flag" "$managed_args" + done + assert_succeeds "CLI docs use the adapter package digest output name" \ + grep -Fq 'package-sha256=' "$managed_args" + # shellcheck disable=SC2016 # Documentation contract contains literal Markdown backticks. + assert_succeeds "CLI docs explain the deploy action package output mapping" \ + grep -Fq 'maps `package-sha256` to its public `package-digest` output' "$managed_args" + # shellcheck disable=SC2016 # Documentation contract contains literal Markdown backticks. + assert_fails "CLI docs do not claim the adapter emits the action output name" \ + grep -Fq 'emits `package-digest=`' "$managed_args" + # shellcheck disable=SC2016 # Documentation contract contains literal Markdown backticks. + assert_succeeds "action wrapper deploy args are restricted to comment" \ + grep -Fq 'action wrapper accepts only `--comment`' "$deploy" + # shellcheck disable=SC2016 # Documentation contract contains literal Markdown backticks. + assert_succeeds "deploy action input table limits deploy args to at most one comment" \ + grep -Eq '^\| `deploy-args`.*At most one `--comment`' "$deploy" + # shellcheck disable=SC2016 # Documentation contract contains literal Markdown backticks. + assert_fails "deploy action input table does not claim the direct CLI allowlist" \ + grep -Eq '^\| `deploy-args`.*managed Fastly allowlist' "$deploy" + assert_succeeds "docs state the shared alphanumeric Fastly service-ID rule" \ + grep -Fq 'ASCII letters and digits only' "$corpus" + # shellcheck disable=SC2016 # Documentation contract contains literal Markdown backticks. + assert_succeeds "deploy docs expose the verified package digest" \ + grep -Fq '`package-digest`' "$deploy" + # shellcheck disable=SC2016 # Documentation contract contains literal Markdown backticks. + assert_succeeds "deploy docs retain failed-version recovery output" \ + grep -Fq 'emits `fastly-version` before later preparation failures' "$deploy_flat" + # shellcheck disable=SC2016 # Documentation contract contains literal Markdown backticks. + assert_succeeds "deploy docs scope package digest to adapter output timing" \ + grep -Fq 'only after the deploy step emits adapter `package-sha256`' "$deploy_flat" + assert_succeeds "deploy docs allow package digest to be absent on preflight failure" \ + grep -Fq 'may be absent when preflight fails' "$deploy_flat" + + # shellcheck disable=SC2016 # Shell variables are literal documentation contracts. + assert_succeeds "Fastly primary deployment uses a verified application release" \ + grep -Fq -- '--application-release "$RELEASE_ROOT"' "$fastly_deployment" + # shellcheck disable=SC2016 # Shell variables are literal documentation contracts. + assert_succeeds "Fastly primary deployment names the destination service" \ + grep -Fq -- '--service-id "$FASTLY_SERVICE_ID"' "$fastly_deployment" + assert_fails "Fastly managed deployment does not recommend direct provider deployment" \ + grep -Fq 'fastly compute deploy' "$fastly_deployment" + assert_succeeds "bare Fastly deploy is explicitly store-free production compatibility" \ + grep -Fq 'store-free production compatibility' "$fastly_deployment_flat" + + # shellcheck disable=SC2016 # GitHub expression is the literal documentation contract. + assert_fails "lifecycle examples do not reference a cross-job archive path output" \ + grep -Fq '${{ needs.release.outputs.archive }}' "$deploy" + # shellcheck disable=SC2016 # GitHub expression is the literal documentation contract. + assert_succeeds "lifecycle examples use the downloaded runner-local archive" \ + test "$(grep -Fc 'app-release-archive: ${{ github.workspace }}/app-release/app-release.tar.gz' "$deploy")" -ge 5 + # shellcheck disable=SC2016 # Awk program must remain single quoted. + assert_fails "config-push table has no duplicate Markdown separator" \ + awk 'previous && /^\|[ :|-]+\|$/ { found = 1 } { previous = ($0 ~ /^\|[ :|-]+\|$/) } END { exit !found }' "$deploy" + assert_fails "recovery never recommends removing an exact inactive version" \ + grep -Eiq '(remove|delete).{0,32}(exact|inactive).{0,32}version|(exact|inactive).{0,32}version.{0,32}(remove|delete)' "$deploy" "$adoption" + local recovery_guide recovery_flat + for recovery_guide in "$deploy" "$adoption"; do + recovery_flat="$WORK_DIR/recovery-$(basename "$recovery_guide")" + tr '\n' ' ' <"$recovery_guide" >"$recovery_flat" + assert_succeeds "$(basename "$recovery_guide") says version output does not prove current state" \ + grep -Fq 'does not prove its current Fastly state' "$recovery_flat" + assert_succeeds "$(basename "$recovery_guide") documents exact staging-state inspection" \ + grep -Eq '(reads|inspects) (that|the) exact version' "$recovery_flat" + assert_succeeds "$(basename "$recovery_guide") deactivates only a staged version" \ + grep -Eq 'deactivates (it only when staged|a staged version)' "$recovery_flat" + assert_succeeds "$(basename "$recovery_guide") treats an unpublished draft as a no-op" \ + grep -Eq 'succeeds without mutation (for|when).{0,100}unpublished (editable )?draft' "$recovery_flat" + assert_succeeds "$(basename "$recovery_guide") requires a previous production version" \ + grep -Eq '[Pp]roduction.{0,48}(requires|previous-version|previous version)' "$recovery_flat" + assert_succeeds "$(basename "$recovery_guide") refuses incompatible staging state" \ + grep -Eiq 'refuses.{0,48}incompatible state|incompatible.{0,48}refuses' "$recovery_flat" + done + assert_fails "recovery never assumes an emitted version is an inactive draft" \ + grep -Fq 'inspect and reuse that exact inactive draft' "$deploy" "$adoption" + assert_fails "recovery never labels an emitted version a recoverable draft" \ + grep -Fq 'recoverable draft' "$deploy" "$adoption" + + assert_succeeds "managed lifecycle comment verifies the immutable release" \ + grep -Fq 'verifies the immutable application release' "$managed_lifecycle_comment" + # shellcheck disable=SC2016 # Source contract contains literal Rust-doc backticks. + assert_succeeds "managed lifecycle comment uploads only the recorded package" \ + grep -Fq 'uploads its recorded package with `compute update` to an exact unreachable draft' "$managed_lifecycle_comment" + assert_succeeds "managed lifecycle comment describes exact logical-link reconciliation" \ + grep -Fq 'reconciles and reads back exact logical resource links' "$managed_lifecycle_comment" + assert_succeeds "managed lifecycle comment orders publication after verification" \ + grep -Fq 'stages or activates only after verification' "$managed_lifecycle_comment" + # shellcheck disable=SC2016 # Source contract contains literal Rust-doc backticks. + assert_succeeds "managed lifecycle comment names both adapter outputs" \ + grep -Fq 'version=` and `package-sha256=' "$managed_lifecycle_comment" + assert_succeeds "managed lifecycle comment scopes bare manifest compatibility" \ + grep -Fq 'bare store-free production manifest command is a compatibility path outside this managed lifecycle' "$managed_lifecycle_comment" + # shellcheck disable=SC2016 # Source contract contains literal Rust-doc backticks. + assert_fails "managed lifecycle comment has no obsolete build-first staging path" \ + grep -Fq 'build + `compute update --autoclone`' "$managed_lifecycle_comment" + # shellcheck disable=SC2016 # Source contract contains literal Rust-doc backticks. + assert_fails "managed lifecycle comment has no obsolete production manifest semantics" \ + grep -Fq '`fastly compute deploy` runs via the manifest' "$managed_lifecycle_comment" + + assert_fails "docs contain no staging runtime-store physical name" \ + grep -Fq 'edgezero_runtime_env_staging_' "$corpus" + # shellcheck disable=SC2016 # Documentation contract contains literal Markdown backticks. + assert_fails "docs do not prescribe the removed runtime descriptor architecture" \ + grep -Fq 'one physical `edgezero_runtime_env`' "$corpus" + assert_fails "docs contain no one-service owner guidance" \ + grep -Fq 'owned by one Fastly' "$corpus" + assert_fails "docs contain no operational staging-twin instruction" \ + grep -Eq '(creates|writes|links|applies).{0,80}(staging twin|staging-twin)' "$corpus" + assert_fails "docs contain no manual unscoped selector write" \ + grep -Fq -- '--key=EDGEZERO__STORES__' "$corpus" + assert_fails "lifecycle docs contain no obsolete separate CLI artifact input" \ + grep -Fq 'app-cli-artifact' "$deploy" "$adoption" + assert_fails "lifecycle docs contain no deployer build controls" \ + grep -Eq 'build-mode|build-args' "$deploy" "$adoption" +} + +workflow_duplicate_env_keys() { + local workflow="$1" + awk ' + function indentation(line) { + match(line, /^ */) + return RLENGTH + } + + /^[[:space:]]*($|#)/ { next } + + { + indent = indentation($0) + if (in_env && indent <= env_indent) { + in_env = 0 + delete seen + } + + if (!in_env && $0 ~ /^[ ]*env:[ ]*(#.*)?$/) { + in_env = 1 + env_indent = indent + delete seen + next + } + + if (in_env && indent == env_indent + 2 && + $0 ~ /^[ ]*[A-Za-z_][A-Za-z0-9_]*:/) { + key = $0 + sub(/^[ ]*/, "", key) + sub(/:.*/, "", key) + if (key in seen) print key + seen[key] = 1 + } + } + ' "$workflow" | sort -u +} + +workflow_has_no_duplicate_env_keys() { + local workflow="$1" duplicates + duplicates=$(workflow_duplicate_env_keys "$workflow") + if [[ -n "$duplicates" ]]; then + echo "duplicate workflow env keys: $(tr '\n' ' ' <<<"$duplicates")" >&2 + return 1 + fi +} + +test_workflow_duplicate_env_keys() { + section "workflow duplicate environment keys" + local duplicate="$WORK_DIR/duplicate-workflow-env.yml" + cat >"$duplicate" <<'YAML' +jobs: + smoke: + runs-on: ubuntu-latest + env: + STORE_NAME: first + STORE_NAME: second + steps: [] +YAML + + assert_equals "duplicate workflow env key is identified" \ + STORE_NAME "$(workflow_duplicate_env_keys "$duplicate")" + assert_fails "duplicate keys in one workflow env mapping are rejected" \ + workflow_has_no_duplicate_env_keys "$duplicate" + assert_succeeds "deploy-action workflow has no duplicate env mapping keys" \ + workflow_has_no_duplicate_env_keys "$REPO_ROOT/.github/workflows/deploy-action.yml" } test_action_pin_gate() { @@ -2024,7 +2912,7 @@ CLI PATH="$dir/bin:$PATH" \ EDGEZERO__APP__CLI__BIN=fake-cli \ EDGEZERO__FASTLY__SERVICE_ID=svc \ - FASTLY_API_TOKEN=tok \ + EDGEZERO__FASTLY__API_TOKEN=tok \ GITHUB_OUTPUT="$dir/out.txt" \ FAKE_VERSION_LINE="${FVL-version=}" FAKE_EXIT="${FE:-0}" FAKE_SILENT="${FS:-}" \ FAKE_EXTRA_LINE="${FXL:-}" \ @@ -2151,7 +3039,8 @@ test_mutation_attempted_signal() { section "mutation-attempted reconcile signal" local dir="$WORK_DIR/mutation-signal" rm -rf "$dir" - mkdir -p "$dir/bin" "$dir/app" + mkdir -p "$dir/bin" "$dir/app" "$dir/release" + printf '[app]\nname = "demo"\n' >"$dir/release/edgezero.toml" # A CLI that SUCCEEDS (exit 0) but emits no canonical line. printf '#!/usr/bin/env bash\nexit 0\n' >"$dir/bin/fake-cli" chmod +x "$dir/bin/fake-cli" @@ -2163,8 +3052,10 @@ test_mutation_attempted_signal() { # mutation-attempted=true is already written to GITHUB_OUTPUT. local out="$dir/cp-out.txt" rc=0 : >"$out" - env PATH="$dir/bin:$PATH" EDGEZERO__APP__CLI__BIN=fake-cli FASTLY_API_TOKEN=tok \ + env PATH="$dir/bin:$PATH" EDGEZERO__APP__CLI__BIN=fake-cli EDGEZERO__FASTLY__API_TOKEN=tok \ GITHUB_WORKSPACE="$dir" EDGEZERO__PROJECT__WORKING_DIRECTORY=app GITHUB_OUTPUT="$out" \ + EDGEZERO__CONFIG_PUSH__MANIFEST="$dir/release/edgezero.toml" \ + EDGEZERO__CONFIG_PUSH__APP_CONFIG_INLINE='x = 1' \ "$ACTIONS_DIR/config-push-fastly/scripts/config-push.sh" >/dev/null 2>&1 || rc=$? assert_succeeds "config-push fails on a missing canonical line" test "$rc" -ne 0 assert_succeeds "config-push still signals mutation-attempted on that failure" \ @@ -2181,7 +3072,7 @@ test_mutation_attempted_signal() { local rout="$dir/rb-out.txt" rc=0 : >"$rout" - env PATH="$dir/bin:$PATH" EDGEZERO__APP__CLI__BIN=fake-cli FASTLY_API_TOKEN=tok \ + env PATH="$dir/bin:$PATH" EDGEZERO__APP__CLI__BIN=fake-cli EDGEZERO__FASTLY__API_TOKEN=tok \ EDGEZERO__LIFECYCLE__SERVICE_ID=svc123 EDGEZERO__LIFECYCLE__VERSION=9 \ EDGEZERO__LIFECYCLE__ROLLBACK_TO=8 EDGEZERO__DEPLOY__TO=production GITHUB_OUTPUT="$rout" \ "$ACTIONS_DIR/rollback-fastly/scripts/rollback.sh" >/dev/null 2>&1 || rc=$? @@ -2192,7 +3083,7 @@ test_mutation_attempted_signal() { # A missing CLI must NOT signal a mutation — require_cmd fails before the emit. : >"$rout" rc=0 - env PATH="$dir/bin:$PATH" EDGEZERO__APP__CLI__BIN=nonexistent-cli FASTLY_API_TOKEN=tok \ + env PATH="$dir/bin:$PATH" EDGEZERO__APP__CLI__BIN=nonexistent-cli EDGEZERO__FASTLY__API_TOKEN=tok \ EDGEZERO__LIFECYCLE__SERVICE_ID=svc123 EDGEZERO__LIFECYCLE__VERSION=9 \ EDGEZERO__LIFECYCLE__ROLLBACK_TO=8 EDGEZERO__DEPLOY__TO=production GITHUB_OUTPUT="$rout" \ "$ACTIONS_DIR/rollback-fastly/scripts/rollback.sh" >/dev/null 2>&1 || rc=$? @@ -2206,7 +3097,7 @@ test_mutation_attempted_signal() { chmod +x "$dir/bin/fake-cli" : >"$rout" rc=0 - env PATH="$dir/bin:$PATH" EDGEZERO__APP__CLI__BIN=fake-cli FASTLY_API_TOKEN=tok \ + env PATH="$dir/bin:$PATH" EDGEZERO__APP__CLI__BIN=fake-cli EDGEZERO__FASTLY__API_TOKEN=tok \ EDGEZERO__LIFECYCLE__SERVICE_ID=svc123 EDGEZERO__LIFECYCLE__VERSION=9 \ EDGEZERO__LIFECYCLE__ROLLBACK_TO=8 EDGEZERO__DEPLOY__TO=production GITHUB_OUTPUT="$rout" \ "$ACTIONS_DIR/rollback-fastly/scripts/rollback.sh" >/dev/null 2>&1 || rc=$? @@ -2217,21 +3108,106 @@ test_mutation_attempted_signal() { grep -qx 'rolled-back-to=8' "$rout" } +test_build_app_cli_archive() { + section "build-app-cli archive contract" + local dir="$WORK_DIR/build-app-cli-archive" + local app_dir="$dir/workspace/app" + local action_ws="$dir/runner/invocation" + local cached_target="$dir/runner/edgezero-rust-cache/example-app/target" + mkdir -p "$app_dir" "$dir/bin" "$action_ws" "$cached_target" + touch "$cached_target/restored-cache-entry" + printf '[package]\nname = "fixture-cli"\nversion = "1.2.3"\nedition = "2021"\n' \ + >"$app_dir/Cargo.toml" + printf 'version = 3\n' >"$app_dir/Cargo.lock" + + cat >"$dir/bin/uname" <<'EOF' +#!/usr/bin/env bash +case "${1:-}" in + -s) printf 'Linux\n' ;; + -m) printf 'x86_64\n' ;; + *) exit 2 ;; +esac +EOF + cat >"$dir/bin/rustup" <<'EOF' +#!/usr/bin/env bash +exit 0 +EOF + cat >"$dir/bin/cargo" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +case " $* " in + *" metadata "*) + jq -n --arg root "$FAKE_APP_DIR" \ + '{workspace_root:$root,packages:[{name:"fixture-cli",version:"1.2.3",manifest_path:($root + "/Cargo.toml"),targets:[{kind:["bin"],name:"fixture-cli"}]}]}' + ;; + *" build "*) + mkdir -p "$CARGO_TARGET_DIR/release" + cat >"$CARGO_TARGET_DIR/release/fixture-cli" <<'CLI' +#!/usr/bin/env bash +test "${1:-}" = --help +CLI + chmod +x "$CARGO_TARGET_DIR/release/fixture-cli" + ;; + *) exit 2 ;; +esac +EOF + chmod +x "$dir/bin/uname" "$dir/bin/rustup" "$dir/bin/cargo" + + local handoff="$dir/build-outputs" published="$dir/published-outputs" + assert_succeeds "build-app-cli produces its archive with a fake toolchain" \ + env PATH="$dir/bin:$PATH" FAKE_APP_DIR="$app_dir" \ + GITHUB_WORKSPACE="$dir/workspace" RUNNER_TEMP="$dir/runner" \ + EDGEZERO__ACTION__ROOT="$REPO_ROOT" \ + EDGEZERO__ACTION__WORKSPACE="$action_ws" \ + EDGEZERO__RUST_CACHE__TARGET_DIR="$cached_target" \ + EDGEZERO__ACTION__OUTPUT_FILE="$handoff" \ + EDGEZERO__APP__CLI__PACKAGE=fixture-cli \ + EDGEZERO__APP__CLI__ARTIFACT=fixture-upload-name \ + EDGEZERO__PROJECT__WORKING_DIRECTORY=app \ + EDGEZERO__PROVIDER__ENV_CLEAR='[]' \ + bash "$ACTIONS_DIR/build-app-cli/scripts/build-app-cli.sh" + + EDGEZERO__BUILD__OUTPUTS_FILE="$handoff" \ + EDGEZERO__ACTION__WORKSPACE="$action_ws" GITHUB_OUTPUT="$published" \ + bash "$ACTIONS_DIR/build-app-cli/scripts/publish-outputs.sh" >/dev/null + + local tarball + tarball=$(sed -n 's/^tarball-path=//p' "$published") + assert_equals "published tarball-path resolves to app-cli.tar" \ + "$(realpath "$action_ws/app-cli.tar")" "$tarball" + assert_succeeds "the published app-cli.tar exists" test -f "$tarball" + assert_equals "the uploaded archive keeps the application CLI and metadata" \ + $'app-cli-meta.json\nfixture-cli' "$(tar -tf "$tarball" | sort)" + assert_succeeds "the configurable GitHub artifact name is preserved" \ + grep -qx 'app-cli-artifact=fixture-upload-name' "$published" + assert_succeeds "build-app-cli uses the restored Cargo target directory" \ + test -x "$cached_target/release/fixture-cli" + assert_succeeds "build-app-cli preserves restored Cargo target artifacts" \ + test -f "$cached_target/restored-cache-entry" + assert_succeeds "the upload step consumes the published tarball-path" \ + grep -Fq "path: \${{ steps.build.outputs['tarball-path'] }}" \ + "$ACTIONS_DIR/build-app-cli/action.yml" +} + # --------------------------------------------------------------------------- # publish-outputs.sh — the trusted output boundary of the two-step build. # --------------------------------------------------------------------------- test_publish_outputs() { section "publish-outputs (trusted output boundary)" + assert_succeeds "build-app-cli stages the uploaded archive as app-cli.tar" \ + grep -Fq "local tarball=\"\$stage_root/../app-cli.tar\"" \ + "$ACTIONS_DIR/build-app-cli/scripts/build-app-cli.sh" + local dir="$WORK_DIR/publish" rm -rf "$dir" mkdir -p "$dir/rt/ws" "$dir/rt/sibling" local pub="$ACTIONS_DIR/build-app-cli/scripts/publish-outputs.sh" local ws="$dir/rt/ws" - touch "$ws/edgezero-cli.tar" + touch "$ws/app-cli.tar" touch "$dir/rt/sibling/cli.tar" # a SIBLING invocation's file: under RUNNER_TEMP, not our workspace # The canonical path publish-outputs must emit (computed with its own helper). local expected - expected=$(bash -c "source '$ACTIONS_DIR/build-app-cli/scripts/common.sh'; canonical_path '$ws/edgezero-cli.tar'") + expected=$(bash -c "source '$ACTIONS_DIR/build-app-cli/scripts/common.sh'; canonical_path '$ws/app-cli.tar'") # A valid handoff, with a TAMPERED trailing duplicate tarball-path: first wins. { @@ -2239,7 +3215,7 @@ test_publish_outputs() { printf 'app-cli-package=my-cli\n' printf 'app-cli-bin=my-cli\n' printf 'app-cli-artifact=edgezero-cli\n' - printf 'tarball-path=%s\n' "$ws/edgezero-cli.tar" + printf 'tarball-path=%s\n' "$ws/app-cli.tar" printf 'tarball-path=/evil/hijack.tar\n' } >"$dir/outputs.env" local out="$dir/gh-output" @@ -2320,6 +3296,9 @@ test_deploy_signal_timing() { local dir="$WORK_DIR/deploy-signal" rm -rf "$dir" mkdir -p "$dir/bin" "$dir/app" "$dir/rt" + make_fastly_release_fixture "$dir/application" + local release_root="$dir/application/release" package_digest + package_digest=$(hash_file "$release_root/package/app.tar.gz") # The fake CLI records whether the signal was ALREADY in GITHUB_OUTPUT when it # ran — proving the launcher publishes it BEFORE the mutation (so a cancel # mid-mutation CAN preserve it; a hard runner loss can still drop it), not after @@ -2331,21 +3310,53 @@ if grep -qx 'mutation-attempted=true' "${GITHUB_OUTPUT:-/dev/null}" 2>/dev/null; else echo "signal-before-cli=no" >"$PROBE" fi +printf '%s\n' "$@" >"$PROBE.argv" +printf '%s\n' "${EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME:-}" >"$PROBE.selector" +release_root="" +previous="" +for arg in "$@"; do + if [[ "$previous" == "--application-release" ]]; then release_root="$arg"; fi + previous="$arg" +done +for member in cli/app-cli.tar.gz package/app.tar.gz edgezero.toml adapter/fastly.toml; do + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$release_root/$member" | awk '{ print $1 }' + else + shasum -a 256 "$release_root/$member" | awk '{ print $1 }' + fi +done >"$PROBE.bytes" +if command -v sha256sum >/dev/null 2>&1; then + digest=$(sha256sum "$release_root/package/app.tar.gz" | awk '{ print $1 }') +else + digest=$(shasum -a 256 "$release_root/package/app.tar.gz" | awk '{ print $1 }') +fi +echo "package-sha256=$digest" echo "version=42" CLI chmod +x "$dir/bin/fakecli" printf 'FASTLY_API_TOKEN\0FASTLY_SERVICE_ID\0' >"$dir/clear.nul" + printf '%s\0' --service-id svc123 --application-release "$release_root" >"$dir/flags.nul" run_deploy() { env -i PATH="$dir/bin:$PATH" RUNNER_TEMP="$dir/rt" GITHUB_OUTPUT="$dir/out" \ - PROBE="$dir/probe" \ + PROBE="$dir/probe" EXPECTED_PACKAGE_DIGEST="$package_digest" \ EDGEZERO__FASTLY__API_TOKEN=tok EDGEZERO__FASTLY__SERVICE_ID=svc123 \ + EDGEZERO__APP__RELEASE__PACKAGE_DIGEST="${3-$package_digest}" \ EDGEZERO__APP__CLI__BIN="$1" EDGEZERO__ADAPTER=fastly \ EDGEZERO__PROJECT__WORKING_DIRECTORY="$dir/app" \ + EDGEZERO__DEPLOY__FLAGS_FILE="$dir/flags.nul" \ EDGEZERO__PROVIDER__ENV_CLEAR_FILE="$dir/clear.nul" \ + EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME="${2:-publisher_a}" \ bash "$ACTIONS_DIR/deploy-fastly/scripts/deploy.sh" } + # An invalid verified-release digest fails before the mutating CLI is reached. + : >"$dir/out" + assert_fails "deploy requires a verified release package digest" \ + run_deploy fakecli publisher_a invalid + assert_fails "an invalid release package digest causes no provider mutation" \ + grep -qx 'mutation-attempted=true' "$dir/out" + # The CLI is invoked and succeeds: both signal and version are emitted. : >"$dir/out" : >"$dir/probe" @@ -2353,12 +3364,43 @@ CLI assert_succeeds "an invoked deploy signals mutation-attempted" \ grep -qx 'mutation-attempted=true' "$dir/out" assert_succeeds "an invoked deploy emits fastly-version" grep -qx 'fastly-version=42' "$dir/out" + assert_succeeds "an invoked deploy emits its verified package digest" \ + grep -qx "package-digest=$package_digest" "$dir/out" + assert_equals "deploy receives the typed release root before passthrough" \ + $'deploy\n--adapter\nfastly\n--service-id\nsvc123\n--application-release\n'"$release_root" \ + "$(cat "$dir/probe.argv")" + assert_fails "deploy receives no raw package flag" grep -Eq '^(--package|-p)$' "$dir/probe.argv" # Durability (best-effort): the signal was present BEFORE the CLI finished, so a # cancel/timeout mid-mutation CAN preserve it — though a hard runner loss can # still drop it, so its absence is not proof of no mutation. assert_equals "the signal is published before the CLI runs" \ "signal-before-cli=yes" "$(cat "$dir/probe")" + local first_selector first_digest second_selector second_digest first_release_hashes second_release_hashes first_argv + first_selector=$(cat "$dir/probe.selector") + first_digest=$(sed -n 's/^package-digest=//p' "$dir/out") + first_release_hashes=$(cat "$dir/probe.bytes") + first_argv=$(cat "$dir/probe.argv") + printf '%s\0' --service-id svc123 --staging --application-release "$release_root" >"$dir/flags.nul" + : >"$dir/out" + assert_succeeds "the same release deploys with a second publisher selector" \ + run_deploy fakecli publisher_b + second_selector=$(cat "$dir/probe.selector") + second_digest=$(sed -n 's/^package-digest=//p' "$dir/out") + second_release_hashes=$(cat "$dir/probe.bytes") + assert_equals "runtime selector A reaches the app CLI" publisher_a "$first_selector" + assert_equals "runtime selector B reaches the app CLI" publisher_b "$second_selector" + assert_fails "the first immutable-release deploy targets production" \ + grep -qx -- '--staging' <<<"$first_argv" + assert_succeeds "the second immutable-release deploy targets staging" \ + grep -qx -- '--staging' "$dir/probe.argv" + assert_equals "runtime selector changes do not change the package digest" \ + "$first_digest" "$second_digest" + assert_equals "runtime selector changes do not change CLI, package, or manifest bytes" \ + "$first_release_hashes" "$second_release_hashes" + assert_equals "all four immutable release members were compared" 4 \ + "$(printf '%s\n' "$second_release_hashes" | grep -cE '^[0-9a-f]{64}$')" + # Setup fails BEFORE invocation (the CLI binary is missing): NO false signal. : >"$dir/out" assert_fails "a deploy that never reaches the CLI fails" run_deploy nonexistent-bin @@ -2368,28 +3410,117 @@ CLI # Version parse: the app CLI tees the provider output BEFORE its canonical line, so # a conforming deploy routinely prints the SAME `version=` twice. Benign duplicates # must resolve to that one value; two DIFFERENT versions must fail closed. - printf '#!/usr/bin/env bash\necho "version=42"\necho "Deployed package (service x, version 42)"\necho "version=42"\n' >"$dir/bin/dup-cli" + printf '#!/usr/bin/env bash\necho "package-sha256=%s"\necho "version=42"\necho "Deployed package (service x, version 42)"\necho "version=42"\n' "$package_digest" >"$dir/bin/dup-cli" chmod +x "$dir/bin/dup-cli" : >"$dir/out" assert_succeeds "a deploy that prints the same version twice succeeds" run_deploy dup-cli assert_succeeds "duplicate identical version lines resolve to the one value" \ grep -qx 'fastly-version=42' "$dir/out" - printf '#!/usr/bin/env bash\necho "version=42"\necho "version=43"\n' >"$dir/bin/conflict-cli" + printf '#!/usr/bin/env bash\necho "package-sha256=%s"\necho "version=42"\necho "version=43"\n' "$package_digest" >"$dir/bin/conflict-cli" chmod +x "$dir/bin/conflict-cli" : >"$dir/out" assert_fails "conflicting version values fail closed" run_deploy conflict-cli assert_fails "no fastly-version is threaded on a conflicting deploy" \ grep -q '^fastly-version=' "$dir/out" + assert_succeeds "a valid package digest survives a conflicting version contract" \ + grep -qx "package-digest=$package_digest" "$dir/out" # A malformed `version=` line must fail closed even BESIDE a valid one — the # malformed line must be rejected before the valid values are deduplicated. - printf '#!/usr/bin/env bash\necho "version=42"\necho "version=43x"\n' >"$dir/bin/malformed-cli" + printf '#!/usr/bin/env bash\necho "package-sha256=%s"\necho "version=42"\necho "version=43x"\n' "$package_digest" >"$dir/bin/malformed-cli" chmod +x "$dir/bin/malformed-cli" : >"$dir/out" assert_fails "a malformed version line fails closed even beside a valid one" run_deploy malformed-cli assert_fails "no fastly-version is threaded when any version line is malformed" \ grep -q '^fastly-version=' "$dir/out" + assert_succeeds "a valid package digest survives a malformed version contract" \ + grep -qx "package-digest=$package_digest" "$dir/out" + + cat >"$dir/bin/missing-package-cli" <<'CLI' +#!/usr/bin/env bash +echo "version=42" +CLI + cat >"$dir/bin/mismatched-package-cli" <<'CLI' +#!/usr/bin/env bash +echo "package-sha256=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +echo "version=42" +CLI + chmod +x "$dir/bin/missing-package-cli" "$dir/bin/mismatched-package-cli" + for cli in missing-package-cli mismatched-package-cli; do + : >"$dir/out" + assert_fails "$cli fails its successful-deploy package contract" run_deploy "$cli" + assert_succeeds "$cli retains the independently valid recovery version" \ + grep -qx 'fastly-version=42' "$dir/out" + assert_fails "$cli emits no unverified package digest" \ + grep -q '^package-digest=' "$dir/out" + done + + # A managed deploy can create a recoverable draft and emit its version before a + # later operation fails. The wrapper must retain that exact version while + # preserving the provider/CLI status. Invalid output on failure stays silent. + cat >"$dir/bin/failed-valid-cli" <<'CLI' +#!/usr/bin/env bash +echo "package-sha256=$EXPECTED_PACKAGE_DIGEST" +echo "version=42" +exit 37 +CLI + cat >"$dir/bin/failed-conflict-cli" <<'CLI' +#!/usr/bin/env bash +echo "version=42" +echo "version=43" +exit 38 +CLI + cat >"$dir/bin/failed-malformed-cli" <<'CLI' +#!/usr/bin/env bash +echo "version=42x" +exit 39 +CLI + cat >"$dir/bin/failed-absent-cli" <<'CLI' +#!/usr/bin/env bash +echo "provider failed before returning a version" +exit 40 +CLI + cat >"$dir/bin/failed-output-cli" <<'CLI' +#!/usr/bin/env bash +rm -f "$GITHUB_OUTPUT" +mkdir "$GITHUB_OUTPUT" +echo "package-sha256=$EXPECTED_PACKAGE_DIGEST" +echo "version=42" +exit 41 +CLI + chmod +x "$dir/bin"/failed-*-cli + + local rc=0 + : >"$dir/out" + run_deploy failed-valid-cli >/dev/null 2>&1 || rc=$? + assert_equals "failed deploy preserves its original status after valid version parse" "37" "$rc" + assert_succeeds "failed deploy retains its recoverable version" \ + grep -qx 'fastly-version=42' "$dir/out" + assert_succeeds "failed deploy retains its verified package digest" \ + grep -qx "package-digest=$package_digest" "$dir/out" + + local cli expected + for cli in failed-conflict-cli failed-malformed-cli failed-absent-cli; do + case "$cli" in + failed-conflict-cli) expected=38 ;; + failed-malformed-cli) expected=39 ;; + failed-absent-cli) expected=40 ;; + esac + : >"$dir/out" + rc=0 + run_deploy "$cli" >/dev/null 2>&1 || rc=$? + assert_equals "$cli preserves the original failure status" "$expected" "$rc" + assert_fails "$cli emits no untrusted fastly-version" grep -q '^fastly-version=' "$dir/out" + done + + rc=0 + rm -rf "$dir/out" + : >"$dir/out" + run_deploy failed-output-cli >/dev/null 2>&1 || rc=$? + assert_equals "failed deploy preserves provider status when recovery output cannot be written" \ + 41 "$rc" + rm -rf "$dir/out" } test_recovery_version_parse() { @@ -2445,16 +3576,55 @@ sed -n "s/^version=//p" <<<"$out"' test "$rc" -ne 0 } +test_package_action_name() { + section "Fastly package action name" + local obsolete="$ACTIONS_DIR/package-fastly"'-application-release' + assert_succeeds "the package action uses operation/object/provider order" \ + test -f "$ACTIONS_DIR/package-application-release-fastly/action.yml" + assert_fails "the obsolete provider-first package action path is absent" \ + test -e "$obsolete" +} + +yaml_step_has_retention() { + awk -v want="$2" ' + /^[[:space:]]*- name:/ { + label = $0 + sub(/^[[:space:]]*- name:[[:space:]]*/, "", label) + if (in_step) exit + in_step = (label == want) + } + in_step && /^[[:space:]]+retention-days:[[:space:]]*14[[:space:]]*$/ { + found = 1 + } + END { + if (!in_step || !found) exit 1 + } + ' "$1" +} + +test_artifact_retention_policy() { + section "artifact retention policy" + local action step + while IFS='|' read -r action step; do + assert_succeeds "$step retains its artifact for 14 days" \ + yaml_step_has_retention "$action" "$step" + done < build`, so it requires the app CLI to support `build`; - # that is why caching is tied to build-mode: always rather than forced on every - # cache=true (an app may deploy via a manifest command and implement no `build`). - - name: Build (validation / cache seed) - if: ${{ steps.resolve.outputs['effective-build-mode'] == 'always' }} - shell: bash - env: - # Sourced at bash startup, before the script can scrub — blank them here too. - BASH_ENV: "" - ENV: "" - EDGEZERO__APP__CLI__BIN: ${{ steps.cli.outputs['app-cli-bin'] }} - EDGEZERO__APP__CLI__PATH: ${{ steps.cli.outputs['app-cli-path'] }} - EDGEZERO__ADAPTER: fastly - EDGEZERO__PROJECT__WORKING_DIRECTORY: ${{ steps.resolve.outputs['working-directory'] }} - EDGEZERO__PROJECT__MANIFEST_PATH: ${{ steps.resolve.outputs['manifest'] }} - EDGEZERO__BUILD__ARGS_FILE: ${{ steps.validate.outputs['build-args-file'] }} - EDGEZERO__PROVIDER__ENV_CLEAR_FILE: ${{ steps.validate.outputs['provider-env-clear-file'] }} - FASTLY_API_TOKEN: "" - FASTLY_SERVICE_ID: "" - FASTLY_TOKEN: "" - FASTLY_KEY: "" - FASTLY_API_KEY: "" - FASTLY_AUTH_TOKEN: "" - FASTLY_API_ENDPOINT: "" - FASTLY_ENDPOINT: "" - FASTLY_API_URL: "" - FASTLY_PROFILE: "" - FASTLY_SERVICE_NAME: "" - FASTLY_DEBUG: "" - FASTLY_DEBUG_MODE: "" - FASTLY_CONFIG_FILE: "" - FASTLY_CARGO_PROFILE: "" - FASTLY_HOME: "" - run: exec "$GITHUB_ACTION_PATH/../deploy-core/scripts/run-app-cli.sh" build - - # Save from the CREDENTIAL-FREE build above, BEFORE the token-bearing deploy — so - # the cached target/ can never contain a secret a build script wrote during the - # deploy. Gated on build-mode: always because that is the step that produced a - # token-free target/; with build-mode: never nothing credential-free built the - # tree, so there is nothing safe to cache and the deploy's own compile is never - # saved. - - name: Save application target cache - if: ${{ inputs.cache == 'true' && steps.resolve.outputs['effective-build-mode'] == 'always' && steps.cache-restore.outputs['cache-hit'] != 'true' }} - uses: actions/cache/save@v6 - with: - key: ${{ steps.resolve.outputs['cache-key'] }} - path: ${{ steps.resolve.outputs['cache-path'] }} - env: - FASTLY_API_TOKEN: "" - FASTLY_SERVICE_ID: "" - FASTLY_TOKEN: "" - FASTLY_KEY: "" - FASTLY_API_KEY: "" - FASTLY_AUTH_TOKEN: "" - FASTLY_API_ENDPOINT: "" - FASTLY_ENDPOINT: "" - FASTLY_API_URL: "" - FASTLY_PROFILE: "" - FASTLY_SERVICE_NAME: "" - FASTLY_DEBUG: "" - FASTLY_DEBUG_MODE: "" - FASTLY_CONFIG_FILE: "" - FASTLY_CARGO_PROFILE: "" - FASTLY_HOME: "" - - # Production only: read the version active BEFORE we deploy — the rollback - # target. Fastly cannot tell it apart from a staged version afterward, so it - # must be captured now. Skipped for a staged deploy (staging rollback - # deactivates the staged version; there is nothing to activate back to). - name: Capture rollback target id: capture if: ${{ inputs['deploy-to'] != 'staging' }} shell: bash env: - # BASH_ENV/ENV are sourced at bash startup — BEFORE this step's script can - # scrub — so a caller's job env could otherwise run code here with the token - # in scope. Blank them in the token-bearing step itself, not only the ws step. BASH_ENV: "" ENV: "" - # Mint the sensitive lifecycle log under the per-invocation workspace so the - # Cleanup step removes it wholesale even if the in-process EXIT trap cannot fire. EDGEZERO__ACTION__WORKSPACE: ${{ steps.ws.outputs.root }} - EDGEZERO__APP__CLI__BIN: ${{ steps.cli.outputs['app-cli-bin'] }} EDGEZERO__APP__CLI__PATH: ${{ steps.cli.outputs['app-cli-path'] }} EDGEZERO__FASTLY__SERVICE_ID: ${{ inputs['fastly-service-id'] }} - # Only the typed token reaches the CLI; blank every inherited alias. - FASTLY_API_TOKEN: ${{ inputs['fastly-api-token'] }} + EDGEZERO__FASTLY__API_TOKEN: ${{ inputs['fastly-api-token'] }} + FASTLY_API_TOKEN: "" FASTLY_SERVICE_ID: "" FASTLY_TOKEN: "" FASTLY_KEY: "" @@ -423,44 +232,19 @@ runs: id: deploy shell: bash env: - # BASH_ENV/ENV are sourced at bash startup, before this script can scrub — - # blank them so a caller's job env cannot run code here with the token. BASH_ENV: "" ENV: "" - # Mint the sensitive lifecycle log under the per-invocation workspace so the - # Cleanup step removes it wholesale even if the in-process EXIT trap cannot fire. EDGEZERO__ACTION__WORKSPACE: ${{ steps.ws.outputs.root }} - EDGEZERO__APP__CLI__BIN: ${{ steps.cli.outputs['app-cli-bin'] }} EDGEZERO__APP__CLI__PATH: ${{ steps.cli.outputs['app-cli-path'] }} EDGEZERO__ADAPTER: fastly - EDGEZERO__PROJECT__WORKING_DIRECTORY: ${{ steps.resolve.outputs['working-directory'] }} - EDGEZERO__PROJECT__MANIFEST_PATH: ${{ steps.resolve.outputs['manifest'] }} + EDGEZERO__PROJECT__WORKING_DIRECTORY: ${{ steps.release.outputs['release-root'] }} + EDGEZERO__PROJECT__MANIFEST_PATH: ${{ steps.release.outputs['application-manifest'] }} + EDGEZERO__APP__RELEASE__PACKAGE_DIGEST: ${{ steps.release.outputs['package-digest'] }} EDGEZERO__DEPLOY__FLAGS_FILE: ${{ steps.validate.outputs['deploy-flags-file'] }} EDGEZERO__DEPLOY__ARGS_FILE: ${{ steps.validate.outputs['deploy-args-file'] }} - # Credential boundary: pass the typed values as data (not as FASTLY_* - # aliases). run-app-cli.sh clears every provider-env-clear alias — including - # any inherited FASTLY_ENDPOINT/FASTLY_TOKEN — and then exports only these. EDGEZERO__PROVIDER__ENV_CLEAR_FILE: ${{ steps.validate.outputs['provider-env-clear-file'] }} EDGEZERO__FASTLY__API_TOKEN: ${{ inputs['fastly-api-token'] }} EDGEZERO__FASTLY__SERVICE_ID: ${{ inputs['fastly-service-id'] }} - run: exec "$GITHUB_ACTION_PATH/scripts/deploy.sh" - - - name: Write summary - if: ${{ always() }} - shell: bash - env: - BASH_ENV: "" - ENV: "" - EDGEZERO__SUMMARY__ADAPTER: fastly - EDGEZERO__SUMMARY__WORKING_DIRECTORY: ${{ steps.resolve.outputs['working-directory-relative'] }} - EDGEZERO__SUMMARY__SOURCE_REVISION: ${{ steps.resolve.outputs['source-revision'] }} - EDGEZERO__SUMMARY__MANIFEST: ${{ steps.resolve.outputs['manifest-summary'] }} - EDGEZERO__SUMMARY__RUST_TOOLCHAIN: ${{ steps.resolve.outputs['rust-toolchain'] }} - EDGEZERO__SUMMARY__TARGET: wasm32-wasip1 - EDGEZERO__SUMMARY__APP_CLI_VERSION: ${{ steps.cli.outputs['app-cli-version'] }} - EDGEZERO__SUMMARY__EFFECTIVE_BUILD_MODE: ${{ steps.resolve.outputs['effective-build-mode'] }} - EDGEZERO__SUMMARY__CACHE: ${{ inputs.cache }} - EDGEZERO__SUMMARY__RESULT: ${{ steps.deploy.outcome }} FASTLY_API_TOKEN: "" FASTLY_SERVICE_ID: "" FASTLY_TOKEN: "" @@ -477,7 +261,7 @@ runs: FASTLY_CONFIG_FILE: "" FASTLY_CARGO_PROFILE: "" FASTLY_HOME: "" - run: exec "$GITHUB_ACTION_PATH/../deploy-core/scripts/write-summary.sh" + run: exec "$GITHUB_ACTION_PATH/scripts/deploy.sh" - name: Cleanup if: ${{ always() }} @@ -486,7 +270,6 @@ runs: BASH_ENV: "" ENV: "" EDGEZERO__ACTION__WORKSPACE: ${{ steps.ws.outputs.root }} - EDGEZERO__ACTION__STATE_DIR: ${{ steps.ws.outputs.root }}/state EDGEZERO__ACTION__TOOL_ROOT: ${{ steps.ws.outputs.root }}/tools FASTLY_API_TOKEN: "" FASTLY_SERVICE_ID: "" diff --git a/.github/actions/deploy-fastly/scripts/capture-previous.sh b/.github/actions/deploy-fastly/scripts/capture-previous.sh index f9ef02df..7515e10e 100755 --- a/.github/actions/deploy-fastly/scripts/capture-previous.sh +++ b/.github/actions/deploy-fastly/scripts/capture-previous.sh @@ -28,37 +28,44 @@ set -euo pipefail # Reads (env): # EDGEZERO__APP__CLI__PATH / _BIN required the app CLI (via resolve_app_cli) # EDGEZERO__FASTLY__SERVICE_ID required the Fastly service id -# FASTLY_API_TOKEN required provider token (Fastly's own convention) +# EDGEZERO__FASTLY__API_TOKEN required action-private Fastly API token # Writes (outputs): # previous-version the active version before this deploy (may be empty) SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) -# shellcheck source=../../deploy-core/scripts/common.sh -source "$SCRIPT_DIR/../../deploy-core/scripts/common.sh" +# shellcheck source=../../fastly-common/scripts/common.sh +source "$SCRIPT_DIR/../../fastly-common/scripts/common.sh" main() { - local cli_bin service_id + local service_id cli_bin cli_bin=$(resolve_app_cli) + cli_bin=$(command -v "$cli_bin") || fail "application CLI is unavailable" + export EDGEZERO__APP__CLI__PATH="$cli_bin" service_id="${EDGEZERO__FASTLY__SERVICE_ID:?EDGEZERO__FASTLY__SERVICE_ID is required}" - require_input fastly-api-token "${FASTLY_API_TOKEN:-}" - require_cmd "$cli_bin" + require_fastly_service_id "$service_id" + local token="${EDGEZERO__FASTLY__API_TOKEN:-}" + require_input fastly-api-token "$token" + require_cmd jq - # Credential-free preflight: confirm the app CLI actually exposes - # `active-version` BEFORE invoking it with the token. A missing subcommand here - # means the CLI was built without the lifecycle commands wired — fail with a - # clear, actionable message instead of a bare clap "unrecognized subcommand". - # The token is UNSET for this probe so it truly never reaches a `--help` call; - # only the real API invocation below sees it. - if ! env -u FASTLY_API_TOKEN "$cli_bin" active-version --help >/dev/null 2>&1; then - fail "the app CLI does not expose the \`active-version\` command, which a production deploy needs to capture the rollback target. Wire \`edgezero_cli::run_active_version\` (and \`run_healthcheck\` / \`run_rollback\`) into your CLI -- see the 'Deploying from GitHub Actions' guide's required command surface." - fi + local workspace="${EDGEZERO__ACTION__WORKSPACE:-$(dirname -- "$cli_bin")}" + mkdir -p "$workspace" + export EDGEZERO__ACTION__WORKSPACE="$workspace" + local args_file="$workspace/active-version-argv.nul" + local clear_file="$workspace/fastly-provider-clear.nul" + printf '%s\0' active-version --adapter fastly --service-id "$service_id" >"$args_file" + write_fastly_provider_clear_file "$clear_file" + EDGEZERO__PROVIDER__ENV=$(jq -n --arg token "$token" '{FASTLY_API_TOKEN:$token}') + export EDGEZERO__PROVIDER__ENV + export EDGEZERO__PROVIDER__ENV_CLEAR_FILE="$clear_file" + export EDGEZERO__APP__CLI__ARGS_FILE="$args_file" + export EDGEZERO__APP__CLI__MUTATES=false new_private_log # Fail CLOSED on an operational failure: the CLI exits 0 for "no active version" # (first deploy) and non-zero only for a real failure. Capture the CLI's exit # (pipefail makes it the pipeline status; tee exits 0). local rc=0 - "$cli_bin" active-version --adapter fastly --service-id "$service_id" \ + "$SCRIPT_DIR/../../deploy-core/scripts/run-app-cli.sh" \ 2>&1 | tee "$LIFECYCLE_LOG" || rc=$? if [[ "$rc" -ne 0 ]]; then fail_with "$rc" "could not determine the active version (CLI exit $rc); refusing to deploy without a captured rollback target. A first-ever deploy (no active version) exits 0 with an empty target — a non-zero exit means an API/auth/parse failure." diff --git a/.github/actions/deploy-fastly/scripts/deploy.sh b/.github/actions/deploy-fastly/scripts/deploy.sh index 101d48e6..67a55291 100755 --- a/.github/actions/deploy-fastly/scripts/deploy.sh +++ b/.github/actions/deploy-fastly/scripts/deploy.sh @@ -18,23 +18,62 @@ set -euo pipefail # Writes (outputs): # mutation-attempted true, emitted before the CLI runs (reconcile signal) # fastly-version the deployed/staged Fastly version +# package-digest verified package SHA-256 reported by the CLI SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) -# shellcheck source=../../deploy-core/scripts/common.sh -source "$SCRIPT_DIR/../../deploy-core/scripts/common.sh" +# shellcheck source=../../fastly-common/scripts/common.sh +source "$SCRIPT_DIR/../../fastly-common/scripts/common.sh" + +PARSED_VALUE="" +parse_contract_value() { + local key="$1" pattern="$2" all_lines malformed values distinct + PARSED_VALUE="" + all_lines=$(grep -E "^${key}=" "$LIFECYCLE_LOG" || true) + [[ -n "$all_lines" ]] || return 1 + malformed=$(printf '%s\n' "$all_lines" | grep -vE "^${key}=${pattern}$" || true) + [[ -z "$malformed" ]] || return 2 + values=$(printf '%s\n' "$all_lines" | sed "s/^${key}=//" | sort -u) + distinct=$(printf '%s\n' "$values" | grep -c . || true) + [[ "$distinct" == 1 ]] || return 3 + PARSED_VALUE="$values" +} main() { local token="${EDGEZERO__FASTLY__API_TOKEN:-}" local service_id="${EDGEZERO__FASTLY__SERVICE_ID:-}" + local expected_package_digest="${EDGEZERO__APP__RELEASE__PACKAGE_DIGEST:-}" require_input fastly-api-token "$token" - require_input_matching fastly-service-id "$service_id" '^[A-Za-z0-9]+$' + require_fastly_service_id "$service_id" + [[ "$expected_package_digest" =~ ^[0-9a-f]{64}$ ]] || + fail "the verified application release package digest is missing or invalid" require_cmd jq + local cli_bin + cli_bin=$(resolve_app_cli) + cli_bin=$(command -v "$cli_bin") || fail "application CLI is unavailable" + export EDGEZERO__APP__CLI__PATH="$cli_bin" EDGEZERO__PROVIDER__ENV=$(jq -n --arg t "$token" --arg s "$service_id" \ '{FASTLY_API_TOKEN: $t, FASTLY_SERVICE_ID: $s}') export EDGEZERO__PROVIDER__ENV + local workspace="${EDGEZERO__ACTION__WORKSPACE:-$(dirname -- "${EDGEZERO__DEPLOY__FLAGS_FILE:?EDGEZERO__DEPLOY__FLAGS_FILE is required}")}" + export EDGEZERO__ACTION__WORKSPACE="$workspace" + local args_file="$workspace/deploy-argv.nul" + local allow_file="$workspace/fastly-public-runtime.nul" + printf '%s\0' deploy --adapter fastly >"$args_file" + if [[ -s "${EDGEZERO__DEPLOY__FLAGS_FILE:-/dev/null}" ]]; then + cat "$EDGEZERO__DEPLOY__FLAGS_FILE" >>"$args_file" + fi + if [[ -s "${EDGEZERO__DEPLOY__ARGS_FILE:-/dev/null}" ]]; then + printf '%s\0' -- >>"$args_file" + cat "$EDGEZERO__DEPLOY__ARGS_FILE" >>"$args_file" + fi + write_fastly_public_runtime_file "$allow_file" + export EDGEZERO__APP__CLI__ARGS_FILE="$args_file" + export EDGEZERO__APP__CLI__MUTATES=true + export EDGEZERO__PUBLIC_RUNTIME_ENV_ALLOW_FILE="$allow_file" + new_private_log # run-app-cli.sh publishes `mutation-attempted=true` itself, immediately before # it invokes the CLI — so a setup failure never falsely signals, and the signal @@ -42,39 +81,34 @@ main() { # cancel/timeout; a hard runner loss can still drop it). This wrapper only threads # the resulting version out. local rc=0 - "$SCRIPT_DIR/../../deploy-core/scripts/run-app-cli.sh" deploy 2>&1 | tee "$LIFECYCLE_LOG" || rc=$? - if [[ "$rc" -ne 0 ]]; then - fail_with "$rc" "deploy failed (CLI exit $rc or setup error before invocation)" - fi + "$SCRIPT_DIR/../../deploy-core/scripts/run-app-cli.sh" 2>&1 | tee "$LIFECYCLE_LOG" || rc=$? - # Resolve the deployed version, tolerant of it legitimately appearing more than - # once (the app CLI tees the provider output — which can itself carry a `version=` - # line — BEFORE emitting its own canonical `version=`), but FAIL CLOSED on any - # broken contract: - # 1. EVERY `^version=` line must be well-formed `version=`. A malformed - # line (`version=43x`, empty `version=`) fails closed even when a SIBLING line - # is valid — dropping the malformed one and trusting the rest would let a - # corrupt contract slip a wrong version through. - # 2. Then the well-formed lines must agree on ONE distinct value. Benign - # duplicates collapse; two DIFFERENT versions are genuine ambiguity and fail. - # 3. No `version=` line at all is a missing contract. - local all_lines malformed version_values distinct version - all_lines=$(grep -E '^version=' "$LIFECYCLE_LOG" || true) - if [[ -z "$all_lines" ]]; then - fail "deploy reported success but emitted no canonical 'version=' line, so there is no version to thread into healthcheck or rollback" - fi - malformed=$(printf '%s\n' "$all_lines" | grep -vE '^version=[0-9]+$' || true) - if [[ -n "$malformed" ]]; then - fail "deploy emitted a malformed 'version=' line ($(printf '%s' "$malformed" | tr '\n' ' ')); expected 'version='. Refusing to thread an unparseable version into healthcheck or rollback" + local version="" version_status=0 package_digest="" package_status=0 + parse_contract_value version '[0-9]+' || version_status=$? + [[ "$version_status" -ne 0 ]] || version="$PARSED_VALUE" + parse_contract_value package-sha256 '[0-9a-f]{64}' || package_status=$? + [[ "$package_status" -ne 0 ]] || package_digest="$PARSED_VALUE" + + if [[ "$package_status" -eq 0 && -n "$expected_package_digest" && "$package_digest" != "$expected_package_digest" ]]; then + package_status=4 fi - version_values=$(printf '%s\n' "$all_lines" | sort -u) - distinct=$(printf '%s\n' "$version_values" | grep -c . || true) - if [[ "$distinct" -gt 1 ]]; then - fail "deploy emitted conflicting version values ($(printf '%s' "$version_values" | tr '\n' ' ')); refusing to guess which version was deployed" + if [[ "$rc" -ne 0 ]]; then + # Recovery outputs are best-effort on an already-failed provider command. + # A broken GitHub output channel must not replace the original provider status. + set +e + [[ "$version_status" -ne 0 ]] || append_output fastly-version "$version" + [[ "$package_status" -ne 0 ]] || append_output package-digest "$package_digest" + set -e + fail_with "$rc" "deploy failed (CLI exit $rc or setup error before invocation)" fi - version="${version_values#version=}" - append_output fastly-version "$version" + # Publish every independently valid recovery value before checking the other + # post-invocation contract. A package-output defect must not hide a version + # that may already identify a mutated provider draft, and vice versa. + [[ "$version_status" -ne 0 ]] || append_output fastly-version "$version" + [[ "$package_status" -ne 0 ]] || append_output package-digest "$package_digest" + [[ "$version_status" -eq 0 ]] || fail "deploy reported success without one unambiguous canonical 'version=' line" + [[ "$package_status" -eq 0 ]] || fail "deploy reported success without the verified canonical package digest" } main "$@" diff --git a/.github/actions/deploy-fastly/scripts/validate.sh b/.github/actions/deploy-fastly/scripts/validate.sh index 38013f47..301485c2 100755 --- a/.github/actions/deploy-fastly/scripts/validate.sh +++ b/.github/actions/deploy-fastly/scripts/validate.sh @@ -13,22 +13,23 @@ set -euo pipefail # precomputed `…_PRESENT` boolean instead. # # Reads (env): -# EDGEZERO__APP__CLI__ARTIFACT_PRESENT required "true" when app-cli-artifact is non-empty +# EDGEZERO__APP__RELEASE__ARCHIVE_PRESENT required release archive presence flag +# EDGEZERO__APP__RELEASE__SHA256_PRESENT required release digest presence flag # EDGEZERO__FASTLY__API_TOKEN_PRESENT required "true" when fastly-api-token is non-empty # EDGEZERO__FASTLY__SERVICE_ID required the Fastly service id # (plus the validate-inputs.sh Reads contract, which this delegates to) SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) -# shellcheck source=../../deploy-core/scripts/common.sh -source "$SCRIPT_DIR/../../deploy-core/scripts/common.sh" +# shellcheck source=../../fastly-common/scripts/common.sh +source "$SCRIPT_DIR/../../fastly-common/scripts/common.sh" main() { - # GitHub does not enforce `required: true` on composite inputs. An empty - # artifact name makes actions/download-artifact fetch EVERY artifact in the - # run, so the CLI we then execute with credentials would be arbitrary. - require_present app-cli-artifact "${EDGEZERO__APP__CLI__ARTIFACT_PRESENT:-}" + # GitHub does not enforce `required: true` on composite inputs. Require both + # release coordinates before any application CLI or provider command runs. + require_present app-release-archive "${EDGEZERO__APP__RELEASE__ARCHIVE_PRESENT:-}" + require_present app-release-sha256 "${EDGEZERO__APP__RELEASE__SHA256_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_fastly_service_id "${EDGEZERO__FASTLY__SERVICE_ID:-}" # Provider-neutral validation (adapter, booleans, JSON-array args, the # allowlist). It also rejects a 'deploy-to' that is neither production nor diff --git a/.github/actions/deploy-core/tests/assert-config-push.sh b/.github/actions/deploy-fastly/tests/assert-config-push.sh similarity index 84% rename from .github/actions/deploy-core/tests/assert-config-push.sh rename to .github/actions/deploy-fastly/tests/assert-config-push.sh index dcfeaa75..f965b7e0 100755 --- a/.github/actions/deploy-core/tests/assert-config-push.sh +++ b/.github/actions/deploy-fastly/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. +# the same logical key in the physical store selected by each environment. This +# runs once per push; re-seeding the fake truncates the call log, so each +# environment-selected store is asserted separately. # # Reads (env): # FAKE_CALL_LOG required the fake fastly call log @@ -16,8 +16,8 @@ set -euo pipefail # EDGEZERO__TEST__REJECT_KEY optional a key that must NOT appear in the log SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) -# shellcheck source=../scripts/common.sh -source "$SCRIPT_DIR/../scripts/common.sh" +# shellcheck source=../../fastly-common/scripts/common.sh +source "$SCRIPT_DIR/../../fastly-common/scripts/common.sh" log="${FAKE_CALL_LOG:?FAKE_CALL_LOG is required}" expect_key="${EDGEZERO__TEST__EXPECT_KEY:?EDGEZERO__TEST__EXPECT_KEY is required}" @@ -44,7 +44,7 @@ grep -q 'fastly config-store list' "$log" || grep -qE "fastly config-store-entry update .*--key=${expect_key}( |$)" "$log" || fail "config push never wrote --key=$expect_key via 'fastly config-store-entry update'" -# Staging must not touch the production key (and vice versa). +# The push must not use a rejected target-derived key. if [[ -n "$reject_key" ]]; then if grep -qE "config-store-entry update .*--key=${reject_key}( |$)" "$log"; then fail "this push wrote --key=$reject_key, which it must never touch" diff --git a/.github/actions/deploy-fastly/tests/assert-lost-version.sh b/.github/actions/deploy-fastly/tests/assert-lost-version.sh new file mode 100755 index 00000000..9d3fc538 --- /dev/null +++ b/.github/actions/deploy-fastly/tests/assert-lost-version.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Asserts a failed deploy preserves every independently valid recovery output +# after Fastly accepted the package but a later provider read failed. +# +# Reads (env): +# EDGEZERO__TEST__DEPLOY_OUTCOME, EDGEZERO__TEST__MUTATION_ATTEMPTED +# EDGEZERO__TEST__PREVIOUS_VERSION, EDGEZERO__TEST__FASTLY_VERSION +# EDGEZERO__TEST__PACKAGE_DIGEST, FAKE_EXPECTED_PACKAGE_DIGEST +# FAKE_PACKAGE_DIGEST_FILE, FAKE_ACTIVE_VERSION_FILE +# Writes: none + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=../../fastly-common/scripts/common.sh +source "$SCRIPT_DIR/../../fastly-common/scripts/common.sh" + +main() { + local outcome="${EDGEZERO__TEST__DEPLOY_OUTCOME:-}" + local mutation="${EDGEZERO__TEST__MUTATION_ATTEMPTED:-}" + local previous="${EDGEZERO__TEST__PREVIOUS_VERSION:-}" + local version="${EDGEZERO__TEST__FASTLY_VERSION:-}" + local digest="${EDGEZERO__TEST__PACKAGE_DIGEST:-}" + local expected_digest="${FAKE_EXPECTED_PACKAGE_DIGEST:?FAKE_EXPECTED_PACKAGE_DIGEST is required}" + [[ "$outcome" == failure ]] || fail "the post-upload provider failure unexpectedly succeeded" + [[ "$mutation" == true && "$previous" == 40 && "$version" == 42 ]] || fail "the failed deploy did not retain recovery outputs" + [[ "$digest" == "$expected_digest" && "$(cat "$FAKE_PACKAGE_DIGEST_FILE")" == "$expected_digest" ]] || fail "the failed deploy did not retain its verified package digest" + grep -q '^fastly compute update ' "$FAKE_CALL_LOG" || fail "failure occurred before package upload" + grep -q '^fastly service resource-link list --service-id=dummyservice --version=42 --json$' "$FAKE_CALL_LOG" || fail "failure did not occur during post-upload verification" + ! grep -Eq 'service version stage|/version/42/activate' "$FAKE_CALL_LOG" || fail "the failed deployment published version 42" + ! grep -qE 'config-store-entry (create|describe)|/resources/stores/config/.*/item/' "$FAKE_CALL_LOG" || fail "the failed deployment used the removed runtime descriptor path" + notice "failed deploy retained version 42 and the verified package digest" +} +main "$@" diff --git a/.github/actions/deploy-fastly/tests/assert-production-deploy.sh b/.github/actions/deploy-fastly/tests/assert-production-deploy.sh new file mode 100755 index 00000000..3e644210 --- /dev/null +++ b/.github/actions/deploy-fastly/tests/assert-production-deploy.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Asserts a production deployment used the selected logical resource links, +# published the expected package, and retained the prior version for recovery. +# +# Reads (env): +# FAKE_CALL_LOG, FAKE_EXPECTED_PACKAGE_DIGEST, FAKE_PACKAGE_DIGEST_FILE +# EDGEZERO__TEST__FASTLY_VERSION, EDGEZERO__TEST__PREVIOUS_VERSION +# EDGEZERO__TEST__PACKAGE_DIGEST, EDGEZERO__TEST__FIXTURE_MODE +# Writes: none + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=../../fastly-common/scripts/common.sh +source "$SCRIPT_DIR/../../fastly-common/scripts/common.sh" + +main() { + local log="${FAKE_CALL_LOG:?FAKE_CALL_LOG is required}" + local version="${EDGEZERO__TEST__FASTLY_VERSION:-}" + local previous="${EDGEZERO__TEST__PREVIOUS_VERSION:-}" + local digest="${EDGEZERO__TEST__PACKAGE_DIGEST:-}" + local expected_digest="${FAKE_EXPECTED_PACKAGE_DIGEST:?FAKE_EXPECTED_PACKAGE_DIGEST is required}" + local fixture_mode="${EDGEZERO__TEST__FIXTURE_MODE:-store-aware}" + + [[ "$version" == 42 ]] || fail "expected production fastly-version=42, got '${version:-}'" + [[ "$previous" == 40 ]] || fail "expected captured previous-version=40, got '${previous:-}'" + [[ "$digest" == "$expected_digest" ]] || fail "production did not report the pinned package digest" + [[ "$(cat "$FAKE_PACKAGE_DIGEST_FILE")" == "$expected_digest" ]] || fail "production uploaded different package bytes" + grep -Fqx 'PUT https://api.fastly.com/service/dummyservice/version/40/clone' "$log" || fail "production did not explicitly clone the verified source" + grep -Eq '^fastly compute update --service-id=dummyservice --version=42 --package=[^[:space:]]+/package/app\.tar\.gz --non-interactive$' "$log" || fail "production did not update the verified clone" + grep -Eq '^fastly compute hash-files --package=[^[:space:]]+/package/app\.tar\.gz --skip-build --non-interactive --quiet$' "$log" || fail "production did not hash the pinned package" + + local expected_comment + case "$fixture_mode" in + store-aware) expected_comment='production smoke' ;; + store-free) expected_comment='store-free managed smoke' ;; + *) fail "unknown production fixture mode '$fixture_mode'" ;; + esac + grep -Fqx "fastly service version update --service-id=dummyservice --version=42 --comment $expected_comment" "$log" || fail "production did not apply the exact version comment" + + jq -Rn ' + [inputs | split("\t") | {alias: .[1], resource: .[2], type: .[3]}] | + sort_by(.type, .alias) == ([ + {alias:"app_config", resource:"CONFIGPROD", type:"config"}, + {alias:"cache", resource:"KVPROD", type:"kv-store"}, + {alias:"credentials", resource:"SECRETPROD", type:"secret-store"} + ] | sort_by(.type, .alias)) + ' <"$FAKE_LINK_DIR/version-42.tsv" | grep -qx true || fail "production links do not expose the selected resources under logical aliases" + + local mutations + mutations=$(grep -E '^fastly service resource-link (create|delete) ' "$log" || true) + [[ -z "$mutations" ]] || fail "production must retain already-correct resource links; got: $mutations" + grep -q '^PUT https://api.fastly.com/service/dummyservice/version/42/activate$' "$log" || fail "production did not activate version 42" + + local final_links_line package_line configuration_line publish_line + final_links_line=$(grep -n '^fastly service resource-link list --service-id=dummyservice --version=42 --json$' "$log" | tail -n1 | cut -d: -f1) + package_line=$(grep -n '^GET https://api.fastly.com/service/dummyservice/version/42/package$' "$log" | tail -n1 | cut -d: -f1) + configuration_line=$(grep -n '^GET https://api.fastly.com/service/dummyservice/version/42/settings$' "$log" | tail -n1 | cut -d: -f1) + publish_line=$(grep -n '^PUT https://api.fastly.com/service/dummyservice/version/42/activate$' "$log" | tail -n1 | cut -d: -f1) + for version in 40 42; do + for collection in domain backend healthcheck settings; do + [[ "$(grep -c "^GET https://api.fastly.com/service/dummyservice/version/$version/$collection$" "$log")" -eq 3 ]] || fail "production did not preserve and revalidate version $version $collection configuration" + done + [[ "$(grep -c "^GET https://api.fastly.com/service/dummyservice/version/$version/logging/" "$log")" -eq 84 ]] || fail "production did not preserve and revalidate version $version logging configuration" + for provider in pubsub logentries s3; do + [[ "$(grep -c "^GET https://api.fastly.com/service/dummyservice/version/$version/logging/$provider$" "$log")" -eq 3 ]] || fail "production did not preserve and revalidate version $version $provider logging configuration" + done + ! grep -q "^GET https://api.fastly.com/service/dummyservice/version/$version/logging/googlepubsub$" "$log" || fail "production used the invalid googlepubsub API path" + done + ! grep -q '/diff/from/' "$log" || fail "production used the unsupported Fastly Compute diff endpoint" + [[ "$final_links_line" -lt "$publish_line" && "$package_line" -lt "$publish_line" && "$configuration_line" -lt "$publish_line" ]] || fail "final link, package, and protected-configuration verification did not precede activation" + ! grep -qE 'config-store-entry (create|describe)|/resources/stores/config/.*/item/' "$log" || fail "production used the removed runtime descriptor path" + notice "production activated version 42 with logical resource links and pinned package bytes" +} +main "$@" diff --git a/.github/actions/deploy-core/tests/assert-production-probe-tokenless.sh b/.github/actions/deploy-fastly/tests/assert-production-probe-tokenless.sh similarity index 94% rename from .github/actions/deploy-core/tests/assert-production-probe-tokenless.sh rename to .github/actions/deploy-fastly/tests/assert-production-probe-tokenless.sh index dc784511..f58a3bf4 100755 --- a/.github/actions/deploy-core/tests/assert-production-probe-tokenless.sh +++ b/.github/actions/deploy-fastly/tests/assert-production-probe-tokenless.sh @@ -16,8 +16,8 @@ set -euo pipefail # EDGEZERO__TEST__HEALTHY required the healthcheck's `healthy` output SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) -# shellcheck source=../scripts/common.sh -source "$SCRIPT_DIR/../scripts/common.sh" +# shellcheck source=../../fastly-common/scripts/common.sh +source "$SCRIPT_DIR/../../fastly-common/scripts/common.sh" main() { local log="${FAKE_CALL_LOG:?FAKE_CALL_LOG is required}" diff --git a/.github/actions/deploy-core/tests/assert-recovery-rollback.sh b/.github/actions/deploy-fastly/tests/assert-recovery-rollback.sh similarity index 90% rename from .github/actions/deploy-core/tests/assert-recovery-rollback.sh rename to .github/actions/deploy-fastly/tests/assert-recovery-rollback.sh index 7c75bcbd..44b4656b 100755 --- a/.github/actions/deploy-core/tests/assert-recovery-rollback.sh +++ b/.github/actions/deploy-fastly/tests/assert-recovery-rollback.sh @@ -11,8 +11,8 @@ set -euo pipefail # EDGEZERO__TEST__ROLLED_BACK_TO rollback-fastly's rolled-back-to output SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) -# shellcheck source=../scripts/common.sh -source "$SCRIPT_DIR/../scripts/common.sh" +# shellcheck source=../../fastly-common/scripts/common.sh +source "$SCRIPT_DIR/../../fastly-common/scripts/common.sh" main() { local rolled_back_to="${EDGEZERO__TEST__ROLLED_BACK_TO:-}" diff --git a/.github/actions/deploy-core/tests/assert-rollback-calls.sh b/.github/actions/deploy-fastly/tests/assert-rollback-calls.sh similarity index 95% rename from .github/actions/deploy-core/tests/assert-rollback-calls.sh rename to .github/actions/deploy-fastly/tests/assert-rollback-calls.sh index 53a273d8..16fac487 100755 --- a/.github/actions/deploy-core/tests/assert-rollback-calls.sh +++ b/.github/actions/deploy-fastly/tests/assert-rollback-calls.sh @@ -15,8 +15,8 @@ set -euo pipefail # EDGEZERO__TEST__ROLLED_BACK_TO required the production rollback's rolled-back-to output SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) -# shellcheck source=../scripts/common.sh -source "$SCRIPT_DIR/../scripts/common.sh" +# shellcheck source=../../fastly-common/scripts/common.sh +source "$SCRIPT_DIR/../../fastly-common/scripts/common.sh" main() { local log="${FAKE_CALL_LOG:?FAKE_CALL_LOG is required}" diff --git a/.github/actions/deploy-core/tests/assert-rollback-threaded.sh b/.github/actions/deploy-fastly/tests/assert-rollback-threaded.sh similarity index 91% rename from .github/actions/deploy-core/tests/assert-rollback-threaded.sh rename to .github/actions/deploy-fastly/tests/assert-rollback-threaded.sh index 1eb96dce..92b785b2 100755 --- a/.github/actions/deploy-core/tests/assert-rollback-threaded.sh +++ b/.github/actions/deploy-fastly/tests/assert-rollback-threaded.sh @@ -11,8 +11,8 @@ set -euo pipefail # EDGEZERO__TEST__PREVIOUS_VERSION required the deploy's captured previous-version SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) -# shellcheck source=../scripts/common.sh -source "$SCRIPT_DIR/../scripts/common.sh" +# shellcheck source=../../fastly-common/scripts/common.sh +source "$SCRIPT_DIR/../../fastly-common/scripts/common.sh" main() { local rolled_back_to="${EDGEZERO__TEST__ROLLED_BACK_TO:-}" diff --git a/.github/actions/deploy-fastly/tests/assert-staged-calls.sh b/.github/actions/deploy-fastly/tests/assert-staged-calls.sh new file mode 100755 index 00000000..1f890ea9 --- /dev/null +++ b/.github/actions/deploy-fastly/tests/assert-staged-calls.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Asserts a staging deployment cloned the intended source, reconciled the +# selected resource links, uploaded the expected package, and staged the draft. +# +# Reads (env): +# FAKE_CALL_LOG, FAKE_EXPECTED_PACKAGE_DIGEST, FAKE_PACKAGE_DIGEST_FILE +# EDGEZERO__TEST__STAGED_VERSION, EDGEZERO__TEST__PACKAGE_DIGEST +# Writes: none + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=../../fastly-common/scripts/common.sh +source "$SCRIPT_DIR/../../fastly-common/scripts/common.sh" + +main() { + local log="${FAKE_CALL_LOG:?FAKE_CALL_LOG is required}" + local version="${EDGEZERO__TEST__STAGED_VERSION:-}" + local digest="${EDGEZERO__TEST__PACKAGE_DIGEST:-}" + local expected_digest="${FAKE_EXPECTED_PACKAGE_DIGEST:?FAKE_EXPECTED_PACKAGE_DIGEST is required}" + [[ "$version" == 42 ]] || fail "expected staged fastly-version=42, got '${version:-}'" + [[ "$digest" == "$expected_digest" ]] || fail "staging did not report the pinned package digest" + [[ "$(cat "$FAKE_PACKAGE_DIGEST_FILE")" == "$expected_digest" ]] || fail "staging uploaded different package bytes" + grep -Fqx 'PUT https://api.fastly.com/service/dummyservice/version/40/clone' "$log" || fail "staging did not explicitly clone the verified source" + grep -Eq '^fastly compute update --service-id=dummyservice --version=42 --package=[^[:space:]]+/package/app\.tar\.gz --non-interactive$' "$log" || fail "staging did not update the verified clone" + + jq -Rn ' + [inputs | split("\t") | {alias: .[1], resource: .[2], type: .[3]}] | + sort_by(.type, .alias) == ([ + {alias:"app_config", resource:"CONFIGSTAGE", type:"config"}, + {alias:"cache", resource:"KVSTAGE", type:"kv-store"}, + {alias:"credentials", resource:"SECRETSTAGE", type:"secret-store"} + ] | sort_by(.type, .alias)) + ' <"$FAKE_LINK_DIR/version-42.tsv" | grep -qx true || fail "staging links do not expose staging resources under logical aliases" + + local expected mutations + expected=$(cat <<'MUTATIONS' +fastly service resource-link delete --service-id=dummyservice --version=42 --id=LINK_CONFIG_PROD +fastly service resource-link delete --service-id=dummyservice --version=42 --id=LINK_KV_PROD +fastly service resource-link delete --service-id=dummyservice --version=42 --id=LINK_SECRET_PROD +fastly service resource-link create --service-id=dummyservice --version=42 --resource-id=CONFIGSTAGE --name=app_config +fastly service resource-link create --service-id=dummyservice --version=42 --resource-id=KVSTAGE --name=cache +fastly service resource-link create --service-id=dummyservice --version=42 --resource-id=SECRETSTAGE --name=credentials +MUTATIONS +) + mutations=$(grep -E '^fastly service resource-link (create|delete) ' "$log" || true) + [[ "$mutations" == "$expected" ]] || { printf 'expected resource mutations:\n%s\nactual resource mutations:\n%s\n' "$expected" "${mutations:-}" >&2; fail "staging reconciliation differed"; } + grep -q '^fastly service version stage --service-id=dummyservice --version=42$' "$log" || fail "version 42 was not staged" + + local last_create final_links package_line configuration_line stage_line + last_create=$(grep -n '^fastly service resource-link create ' "$log" | tail -n1 | cut -d: -f1) + final_links=$(grep -n '^fastly service resource-link list --service-id=dummyservice --version=42 --json$' "$log" | tail -n1 | cut -d: -f1) + package_line=$(grep -n '^GET https://api.fastly.com/service/dummyservice/version/42/package$' "$log" | tail -n1 | cut -d: -f1) + configuration_line=$(grep -n '^GET https://api.fastly.com/service/dummyservice/version/42/settings$' "$log" | tail -n1 | cut -d: -f1) + stage_line=$(grep -n '^fastly service version stage --service-id=dummyservice --version=42$' "$log" | tail -n1 | cut -d: -f1) + for version in 40 42; do + for collection in domain backend healthcheck settings; do + [[ "$(grep -c "^GET https://api.fastly.com/service/dummyservice/version/$version/$collection$" "$log")" -eq 3 ]] || fail "staging did not preserve and revalidate version $version $collection configuration" + done + [[ "$(grep -c "^GET https://api.fastly.com/service/dummyservice/version/$version/logging/" "$log")" -eq 84 ]] || fail "staging did not preserve and revalidate version $version logging configuration" + for provider in pubsub logentries s3; do + [[ "$(grep -c "^GET https://api.fastly.com/service/dummyservice/version/$version/logging/$provider$" "$log")" -eq 3 ]] || fail "staging did not preserve and revalidate version $version $provider logging configuration" + done + ! grep -q "^GET https://api.fastly.com/service/dummyservice/version/$version/logging/googlepubsub$" "$log" || fail "staging used the invalid googlepubsub API path" + done + ! grep -q '/diff/from/' "$log" || fail "staging used the unsupported Fastly Compute diff endpoint" + [[ "$last_create" -lt "$final_links" && "$final_links" -lt "$stage_line" && "$package_line" -lt "$stage_line" && "$configuration_line" -lt "$stage_line" ]] || fail "final link, package, and protected-configuration verification did not follow reconciliation and precede staging" + ! grep -qE 'config-store-entry (create|describe)|/resources/stores/config/.*/item/' "$log" || fail "staging used the removed runtime descriptor path" + notice "staged version 42 uses logical resource links and pinned package bytes" +} +main "$@" diff --git a/.github/actions/deploy-core/tests/assert-staging-probe.sh b/.github/actions/deploy-fastly/tests/assert-staging-probe.sh similarity index 84% rename from .github/actions/deploy-core/tests/assert-staging-probe.sh rename to .github/actions/deploy-fastly/tests/assert-staging-probe.sh index 957e8de7..e6d2fbb2 100755 --- a/.github/actions/deploy-core/tests/assert-staging-probe.sh +++ b/.github/actions/deploy-fastly/tests/assert-staging-probe.sh @@ -16,20 +16,23 @@ set -euo pipefail # EDGEZERO__TEST__STATUS_CODE required the healthcheck's `status-code` output SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) -# shellcheck source=../scripts/common.sh -source "$SCRIPT_DIR/../scripts/common.sh" +# shellcheck source=../../fastly-common/scripts/common.sh +source "$SCRIPT_DIR/../../fastly-common/scripts/common.sh" main() { local log="${FAKE_CALL_LOG:?FAKE_CALL_LOG is required}" local staged="${EDGEZERO__TEST__STAGED_VERSION:?EDGEZERO__TEST__STAGED_VERSION is required}" local healthy="${EDGEZERO__TEST__HEALTHY:-}" local status_code="${EDGEZERO__TEST__STATUS_CODE:-}" + local environment="${EDGEZERO__TEST__GITHUB_ENVIRONMENT:-}" grep -qE "^GET https://api\.fastly\.com/service/dummyservice/version/$staged/domain\?include=staging_ips\$" "$log" || fail "the staging-IP lookup was never performed for version $staged" - grep -qE '^PROBE .*--connect-to ::151\.101\.2\.10:443 .*https://staging\.example\.com/' "$log" || + grep -qE '^PROBE .*--connect-to ::151\.101\.2\.10:443 .*https://app\.example\.com/' "$log" || fail "the probe was not rerouted to the staging IP (was the singular staging_ip read?)" + [[ "$environment" == staging.app.example.com ]] || + fail "the smoke did not distinguish the GitHub Environment from the real Fastly domain" # The public outputs must reflect a healthy probe: the fake curl returns 200, # so a passing staged healthcheck must thread healthy=true and status-code=200. diff --git a/.github/actions/deploy-core/tests/assert-stale-rollback-refused.sh b/.github/actions/deploy-fastly/tests/assert-stale-rollback-refused.sh similarity index 96% rename from .github/actions/deploy-core/tests/assert-stale-rollback-refused.sh rename to .github/actions/deploy-fastly/tests/assert-stale-rollback-refused.sh index 76365345..989e213f 100755 --- a/.github/actions/deploy-core/tests/assert-stale-rollback-refused.sh +++ b/.github/actions/deploy-fastly/tests/assert-stale-rollback-refused.sh @@ -22,8 +22,8 @@ set -euo pipefail # EDGEZERO__TEST__LOG_SNAPSHOT required call-log line count BEFORE the rollback SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) -# shellcheck source=../scripts/common.sh -source "$SCRIPT_DIR/../scripts/common.sh" +# shellcheck source=../../fastly-common/scripts/common.sh +source "$SCRIPT_DIR/../../fastly-common/scripts/common.sh" main() { local log="${FAKE_CALL_LOG:?FAKE_CALL_LOG is required}" diff --git a/.github/actions/deploy-core/tests/assert-unhealthy-failed.sh b/.github/actions/deploy-fastly/tests/assert-unhealthy-failed.sh similarity index 100% rename from .github/actions/deploy-core/tests/assert-unhealthy-failed.sh rename to .github/actions/deploy-fastly/tests/assert-unhealthy-failed.sh diff --git a/.github/actions/deploy-fastly/tests/make-fake-fastly-env.sh b/.github/actions/deploy-fastly/tests/make-fake-fastly-env.sh new file mode 100755 index 00000000..4182c81d --- /dev/null +++ b/.github/actions/deploy-fastly/tests/make-fake-fastly-env.sh @@ -0,0 +1,294 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Installs stateful fake Fastly CLI/API surfaces for the hosted lifecycle smoke. +# The state models an active source version, typed resource links, exact selected +# Config/KV/Secret resources, provider-visible package identity, and publication. + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=../../fastly-common/scripts/common.sh +source "$SCRIPT_DIR/../../fastly-common/scripts/common.sh" + +write_fake_fastly() { + local path="$1" version="$2" + cat >"$path" <>"\$FAKE_CALL_LOG" + +arg_value() { + local prefix="\$1" arg + shift + for arg in "\$@"; do + case "\$arg" in "\$prefix"*) printf '%s' "\${arg#"\$prefix"}"; return 0;; esac + done + return 1 +} + +links_file() { printf '%s/version-%s.tsv' "\$FAKE_LINK_DIR" "\$1"; } +links_json() { + local file + file=\$(links_file "\$1") + if [[ ! -s "\$file" ]]; then printf '[]\n'; return; fi + jq -Rn '[inputs | split("\\t") | {id: .[0], name: .[1], resource_id: .[2], resource_type: .[3]}]' <"\$file" +} + +case "\${1:-} \${2:-}" in + 'version ' | '--version ') echo 'Fastly CLI version v$version (fake)' ;; + 'config-store list') + cat <<'JSON' +[{"id":"CONFIGPROD","name":"config-prod"},{"id":"CONFIGSTAGE","name":"config-stage"}] +JSON + ;; + 'service resource-link') + case "\${3:-}" in + list) + [[ "\$#" -eq 6 && "\$4" == --service-id=dummyservice && + ("\$5" == --version=40 || "\$5" == --version=42) && "\$6" == --json ]] || exit 91 + target=\$(arg_value --version= "\$@") || exit 91 + if [[ "\$target" == 42 && -n "\${FAKE_FAIL_AFTER_VERSION:-}" && -s "\$FAKE_PACKAGE_DIGEST_FILE" ]]; then + echo 'simulated post-upload resource-link readback failure' >&2 + exit 77 + fi + [[ -f "\$(links_file "\$target")" ]] || exit 91 + links_json "\$target" + ;; + delete) + [[ "\$#" -eq 6 && "\$4" == --service-id=dummyservice && "\$5" == --version=42 ]] || exit 91 + target=\$(arg_value --version= "\$@") || exit 91 + id=\$(arg_value --id= "\$@") || exit 91 + file=\$(links_file "\$target") + grep -q "^\$id"$'\\t' "\$file" || exit 91 + awk -F '\\t' -v id="\$id" '\$1 != id' "\$file" >"\$file.tmp" + mv "\$file.tmp" "\$file" + ;; + create) + [[ "\$#" -eq 7 && "\$4" == --service-id=dummyservice && "\$5" == --version=42 ]] || exit 91 + target=\$(arg_value --version= "\$@") || exit 91 + resource=\$(arg_value --resource-id= "\$@") || exit 91 + alias=\$(arg_value --name= "\$@") || exit 91 + case "\$resource/\$alias" in + CONFIGSTAGE/app_config) type=config ;; + KVSTAGE/cache) type=kv-store ;; + SECRETSTAGE/credentials) type=secret-store ;; + *) exit 91;; + esac + file=\$(links_file "\$target") + ! awk -F '\t' -v alias="\$alias" -v type="\$type" '\$2 == alias && \$4 == type { found = 1 } END { exit !found }' "\$file" || exit 91 + printf 'LINK_%s\t%s\t%s\t%s\n' "\$alias" "\$alias" "\$resource" "\$type" >>"\$file" + ;; + *) exit 90 ;; + esac + ;; + 'compute hash-files') + [[ "\$#" -eq 6 && "\$3" == --package=* && "\$4" == --skip-build && + "\$5" == --non-interactive && "\$6" == --quiet ]] || exit 92 + package=\${3#--package=} + [[ -f "\$package" && ! -L "\$package" ]] || exit 92 + printf '%0128d\n' 0 + ;; + 'compute update') + [[ "\$#" -eq 6 && "\$3" == --service-id=dummyservice && "\$4" == --version=42 && + "\$5" == --package=* && "\$6" == --non-interactive ]] || exit 92 + package=\${5#--package=} + [[ -f "\$package" && ! -L "\$package" ]] || exit 92 + grep -qx 42 "\$FAKE_VERSION_FILE" || exit 92 + digest=\$(sha256sum "\$package" | awk '{print \$1}') + printf '%s\n' "\$digest" >"\$FAKE_PACKAGE_DIGEST_FILE" + printf 'PACKAGE-SHA256 %s\n' "\$digest" >>"\$FAKE_CALL_LOG" + if [[ -n "\${FAKE_EXPECTED_PACKAGE_DIGEST:-}" && "\$digest" != "\$FAKE_EXPECTED_PACKAGE_DIGEST" ]]; then + echo 'fake fastly: immutable package digest changed' >&2 + exit 93 + fi + echo 'SUCCESS: Updated package (service dummyservice, version 42)' + ;; + 'service version') + case "\${3:-}" in + update) + [[ "\$#" -eq 7 && "\$4" == --service-id=dummyservice && "\$5" == --version=42 && + "\$6" == --comment ]] || exit 94 + case "\$7" in + 'production smoke' | 'staged smoke' | 'store-free managed smoke') ;; + *) exit 94;; + esac + ;; + stage) + [[ "\$*" == 'service version stage --service-id=dummyservice --version=42' ]] || exit 94 + grep -qx 42 "\$FAKE_VERSION_FILE" || exit 94 + [[ -s "\$FAKE_PACKAGE_DIGEST_FILE" && -f "\$FAKE_LINK_DIR/version-42.tsv" ]] || exit 94 + printf '42\n' >"\$FAKE_STAGED_VERSION_FILE" + ;; + *) exit 90 ;; + esac + ;; + 'config-store-entry describe') + echo 'fake fastly: unexpected Config Store describe' >&2 + exit 96 + ;; + 'config-store-entry update') + key=\$(arg_value --key= "\$@") || exit 91 + cat >"\$FAKE_CONFIG_PUSH_DIR/\$key" + ;; + 'config-store-entry list') echo '[]' ;; + *) echo "fake fastly: unhandled command: \$*" >&2; exit 90 ;; +esac +SHIM + chmod +x "$path" +} + +write_fake_curl() { + local path="$1" + cat >"$path" <<'SHIM' +#!/usr/bin/env bash +set -euo pipefail + +out='' +url='' +previous='' +for arg in "$@"; do + [[ "$previous" == --output ]] && out="$arg" + case "$arg" in file://*) url="$arg";; esac + previous="$arg" +done +if [[ -n "$out" ]]; then cp "${url#file://}" "$out"; exit 0; fi + +active_version() { + local active + active=$(cat "$FAKE_ACTIVE_VERSION_FILE" 2>/dev/null || true) + printf '%s' "${active:-40}" +} + +version_list() { + local active staged target=false + active=$(active_version) + staged=$(cat "$FAKE_STAGED_VERSION_FILE" 2>/dev/null || true) + [[ -f "$FAKE_VERSION_FILE" ]] && grep -qx 42 "$FAKE_VERSION_FILE" && target=true + printf '[{"number":40,"active":%s,"locked":true,"staging":false,"deployed":true,"environments":[]}' "$([[ "$active" == 40 ]] && echo true || echo false)" + if [[ "$target" == true ]]; then + if [[ "$active" == 42 ]]; then + printf ',{"number":42,"active":true,"locked":true,"staging":false,"deployed":true,"environments":[]}' + elif [[ "$staged" == 42 ]]; then + printf ',{"number":42,"active":false,"locked":true,"staging":false,"deployed":true,"environments":[{"active_version":42,"name":"staging","service_id":"dummy-staging-service"}]}' + else + printf ',{"number":42,"active":false,"locked":false,"staging":false,"deployed":false,"environments":[]}' + fi + fi + if [[ "$active" != 40 && "$active" != 42 ]]; then + printf ',{"number":%s,"active":true,"locked":true,"staging":false,"deployed":true,"environments":[]}' "$active" + fi + printf ']' +} + +if [[ "$*" == *--config* ]]; then + config=$(cat) + url=$(printf '%s\n' "$config" | sed -nE 's/^url = "(.*)"$/\1/p') + request=$(printf '%s\n' "$config" | sed -nE 's/^request = "(.*)"$/\1/p') + request=${request:-GET} + printf '%s %s\n' "$request" "$url" >>"$FAKE_CALL_LOG" + if [[ "$request" == PUT ]]; then + case "$url" in + */service/dummyservice/version/40/clone) + grep -qx 40 "$FAKE_VERSION_FILE" || { printf 'source version not prepared\n400'; exit 0; } + ! grep -qx 42 "$FAKE_VERSION_FILE" || { printf 'target version already exists\n409'; exit 0; } + printf '42\n' >>"$FAKE_VERSION_FILE" + cp "$FAKE_LINK_DIR/version-40.tsv" "$FAKE_LINK_DIR/version-42.tsv" + printf '{"service_id":"dummyservice","number":42}\n200' + exit 0 ;; + */service/dummyservice/version/42/activate) + [[ -s "$FAKE_PACKAGE_DIGEST_FILE" && -f "$FAKE_LINK_DIR/version-42.tsv" ]] || { printf 'version not prepared\n400'; exit 0; } + printf '42\n' >"$FAKE_ACTIVE_VERSION_FILE" ;; + */service/dummyservice/version/40/activate) + grep -qx 40 "$FAKE_VERSION_FILE" || { printf 'version not prepared\n400'; exit 0; } + printf '40\n' >"$FAKE_ACTIVE_VERSION_FILE" ;; + */service/dummyservice/version/39/activate) + grep -qx 39 "$FAKE_VERSION_FILE" || { printf 'version not prepared\n400'; exit 0; } + printf '39\n' >"$FAKE_ACTIVE_VERSION_FILE" ;; + */service/dummyservice/version/42/deactivate/staging) + [[ "$(cat "$FAKE_STAGED_VERSION_FILE" 2>/dev/null || true)" == 42 ]] || { printf 'version not staged\n400'; exit 0; } + : >"$FAKE_STAGED_VERSION_FILE" ;; + *) printf 'unexpected mutation\n400'; exit 0;; + esac + printf '200' + exit 0 + fi + case "$url" in + */resources/stores/kv\?limit=100) + printf '{"data":[{"id":"KVPROD","name":"cache-prod"},{"id":"KVSTAGE","name":"cache-stage"}],"meta":{"next_cursor":null}}\n200' ;; + */resources/stores/secret\?limit=100) + printf '{"data":[{"id":"SECRETPROD","name":"credentials-prod"},{"id":"SECRETSTAGE","name":"credentials-stage"}],"meta":{"next_cursor":null}}\n200' ;; + */service/dummyservice/version) printf '%s\n200' "$(version_list)" ;; + */service/dummyservice/version/42/package) + printf '{"service_id":"dummyservice","version":42,"metadata":{"files_hash":"%0128d"}}\n200' 0 ;; + */service/dummyservice/version/*/domain) + printf '[{"name":"app.example.com","service_id":"dummyservice","version":40}]\n200' ;; + */service/dummyservice/version/*/backend) + printf '[{"name":"origin","hostname":"origin.example.com","service_id":"dummyservice","version":40}]\n200' ;; + */service/dummyservice/version/*/healthcheck) + printf '[]\n200' ;; + */service/dummyservice/version/*/logging/googlepubsub) + printf 'unknown logging provider\n404' ;; + */service/dummyservice/version/*/logging/*) + printf '[]\n200' ;; + */service/dummyservice/version/*/settings) + printf '{"general.default_ttl":3600,"service_id":"dummyservice","version":40}\n200' ;; + */service/dummyservice/diff/from/*/to/*) + printf 'diff unavailable for Compute services\n500' ;; + */service/dummyservice/version/*/domain\?include=staging_ips) + printf '[{"name":"other.example.com","staging_ip":"151.101.1.10"},{"name":"app.example.com","staging_ip":"151.101.2.10"}]\n200' ;; + */resources/stores/config/*/item/*) printf 'unexpected Config Store item read\n400' ;; + *) printf 'unexpected fake API read\n404';; + esac + exit 0 +fi + +printf 'PROBE %s\n' "$*" >>"$FAKE_CALL_LOG" +printf 'PROBE-TOKEN=%s\n' "${FASTLY_API_TOKEN:+set}" >>"$FAKE_CALL_LOG" +if [[ -n "${FORCE_UNHEALTHY:-}" ]]; then echo 503; else echo 200; fi +SHIM + chmod +x "$path" +} + +main() { + local workspace="${GITHUB_WORKSPACE:?GITHUB_WORKSPACE is required}" + local runner_temp="${RUNNER_TEMP:?RUNNER_TEMP is required}" + local action_dir path_dir downloads log state pinned stage archive sha expected_digest + action_dir=$(cd -- "$SCRIPT_DIR/.." && pwd) + path_dir="$workspace/fake-bin" + downloads="$runner_temp/edgezero-action-tools/downloads" + log="$workspace/fake-calls.log" + state="$workspace/fake-fastly-state" + + mkdir -p "$path_dir" "$downloads" "$state/links" "$state/config-push" + : >"$log" + printf '40\n' >"$state/active-version" + printf '39\n40\n' >"$state/versions" + printf 'LINK_CONFIG_PROD\tapp_config\tCONFIGPROD\tconfig\nLINK_KV_PROD\tcache\tKVPROD\tkv-store\nLINK_SECRET_PROD\tcredentials\tSECRETPROD\tsecret-store\n' >"$state/links/version-40.tsv" + : >"$state/staged-version" + : >"$state/package-digest" + + pinned=$(json_get "$action_dir/versions.json" fastly.version) + stage=$(mktemp -d) + write_fake_fastly "$stage/fastly" "$pinned" + archive="$downloads/fastly-$pinned-linux-amd64.tar.gz" + tar -C "$stage" -czf "$archive" fastly + sha=$(sha256_file "$archive") + local patched + patched=$(mktemp) + jq --arg url "file://$archive" --arg sha "$sha" '.fastly.linux_amd64.url = $url | .fastly.linux_amd64.sha256 = $sha' "$action_dir/versions.json" >"$patched" + mv "$patched" "$action_dir/versions.json" + write_fake_curl "$path_dir/curl" + + expected_digest='' + [[ ! -f "$workspace/fixture-release/package.sha256" ]] || expected_digest=$(cat "$workspace/fixture-release/package.sha256") + append_env FAKE_CALL_LOG "$log" + append_env FAKE_ACTIVE_VERSION_FILE "$state/active-version" + append_env FAKE_VERSION_FILE "$state/versions" + append_env FAKE_STAGED_VERSION_FILE "$state/staged-version" + append_env FAKE_LINK_DIR "$state/links" + append_env FAKE_CONFIG_PUSH_DIR "$state/config-push" + append_env FAKE_PACKAGE_DIGEST_FILE "$state/package-digest" + append_env FAKE_EXPECTED_PACKAGE_DIGEST "$expected_digest" + append_env PATH "$path_dir:$PATH" +} + +main "$@" diff --git a/.github/actions/deploy-fastly/tests/make-smoke-fixture.sh b/.github/actions/deploy-fastly/tests/make-smoke-fixture.sh new file mode 100755 index 00000000..0ff7002f --- /dev/null +++ b/.github/actions/deploy-fastly/tests/make-smoke-fixture.sh @@ -0,0 +1,229 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Builds the application-owned CLI source fixture, then packages that CLI with +# immutable Fastly package and manifest bytes into one verified application +# release. Runtime publisher/environment choices are deliberately absent here. + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=../../fastly-common/scripts/common.sh +source "$SCRIPT_DIR/../../fastly-common/scripts/common.sh" + +write_source_fixture() { + local mode="$1" + local workspace="${GITHUB_WORKSPACE:?GITHUB_WORKSPACE is required}" + local app_dir="$workspace/fixture-app" + + case "$mode" in + store-free | store-aware) ;; + *) fail "fixture mode must be store-free or store-aware" ;; + esac + + mkdir -p "$app_dir/crates/fixture-app-cli/src" + cd "$app_dir" + + git init -q + git config user.email test@example.com + git config user.name Test + + cat >Cargo.toml <<'TOML' +[workspace] +members = ["crates/fixture-app-cli"] +resolver = "2" +TOML + + cat >crates/fixture-app-cli/Cargo.toml <<'TOML' +[package] +name = "fixture-app-cli" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "fixture-app-cli" +path = "src/main.rs" + +[dependencies] +edgezero-cli = { path = "../../../crates/edgezero-cli", default-features = false, features = [ + "cli", + "edgezero-adapter-fastly", + "edgezero-adapter-spin", +] } +clap = { version = "4", features = ["derive"] } +edgezero-core = { path = "../../../crates/edgezero-core" } +serde = { version = "1", features = ["derive"] } +validator = { version = "0.20", features = ["derive"] } +TOML + + cat >crates/fixture-app-cli/src/main.rs <<'RS' +use clap::{Parser, Subcommand}; +use edgezero_cli::args::{ + ActiveVersionArgs, BuildArgs, ConfigPushArgs, DeployArgs, HealthcheckArgs, RollbackArgs, +}; +use serde::{Deserialize, Serialize}; +use validator::Validate; + +#[derive(Debug, Deserialize, Serialize, Validate, edgezero_core::AppConfig)] +#[serde(deny_unknown_fields)] +struct FixtureAppConfig { + greeting: String, +} + +#[derive(Parser, Debug)] +#[command(name = "fixture-app-cli", version, about = "fixture app edge CLI")] +struct Args { + #[command(subcommand)] + cmd: Cmd, +} + +#[derive(Subcommand, Debug)] +enum Cmd { + #[command(subcommand)] + Config(ConfigCmd), + Build(BuildArgs), + Deploy(DeployArgs), + Healthcheck(HealthcheckArgs), + ActiveVersion(ActiveVersionArgs), + Rollback(RollbackArgs), +} + +#[derive(Subcommand, Debug)] +enum ConfigCmd { + Push(ConfigPushArgs), +} + +fn main() { + edgezero_cli::init_cli_logger(); + let result = match Args::parse().cmd { + Cmd::Config(ConfigCmd::Push(args)) => { + edgezero_cli::run_config_push_typed::(&args) + } + Cmd::Build(args) => edgezero_cli::run_build(&args), + Cmd::Deploy(args) => edgezero_cli::run_deploy(&args), + Cmd::Healthcheck(args) => edgezero_cli::run_healthcheck(&args), + Cmd::ActiveVersion(args) => edgezero_cli::run_active_version(&args), + Cmd::Rollback(args) => edgezero_cli::run_rollback(&args), + }; + if let Err(error) = result { + eprintln!("[fixture-app] {error}"); + std::process::exit(2); + } +} +RS + + cat >edgezero.toml <<'TOML' +[app] +name = "fixture-app" + +[adapters.fastly.adapter] +manifest = "adapter/fastly.toml" + +[adapters.spin.adapter] +manifest = "adapter/spin.toml" +TOML + if [[ "$mode" == store-aware ]]; then + cat >>edgezero.toml <<'TOML' + +[stores.config] +ids = ["app_config"] +default = "app_config" + +[stores.kv] +ids = ["cache"] +default = "cache" + +[stores.secrets] +ids = ["credentials"] +default = "credentials" +TOML + fi + + mkdir -p adapter + cat >adapter/fastly.toml <<'TOML' +manifest_version = 3 +name = "fixture-app" +language = "rust" +TOML + cat >adapter/spin.toml <<'TOML' +spin_manifest_version = 2 + +[application] +name = "fixture-app" +version = "0.1.0" + +[component.fixture] +source = "fixture.wasm" +TOML + + cargo generate-lockfile + git add -A + git commit -q -m fixture +} + +package_release() { + local cli_archive="$1" + local workspace="${GITHUB_WORKSPACE:?GITHUB_WORKSPACE is required}" + local app_dir="$workspace/fixture-app" + local output_dir="$workspace/fixture-release" + local stage="$output_dir/root" + + [[ -f "$cli_archive" && ! -L "$cli_archive" ]] || + fail "application CLI archive is missing or is not a regular file" + [[ -f "$app_dir/edgezero.toml" && -f "$app_dir/adapter/fastly.toml" ]] || + fail "run the source fixture mode before packaging its release" + + mkdir -p "$stage/cli" "$stage/package" "$stage/adapter" + cp "$cli_archive" "$stage/cli/app-cli.tar" + cp "$app_dir/edgezero.toml" "$stage/edgezero.toml" + cp "$app_dir/adapter/fastly.toml" "$stage/adapter/fastly.toml" + printf 'immutable fixture Fastly package\n' >"$stage/package/app.tar.gz" + + local cli_digest package_digest edgezero_digest fastly_digest revision + cli_digest=$(sha256_file "$stage/cli/app-cli.tar") + package_digest=$(sha256_file "$stage/package/app.tar.gz") + edgezero_digest=$(sha256_file "$stage/edgezero.toml") + fastly_digest=$(sha256_file "$stage/adapter/fastly.toml") + revision=$(git -C "$app_dir" rev-parse HEAD) + + jq -n \ + --arg revision "$revision" \ + --arg cli "$cli_digest" \ + --arg package "$package_digest" \ + --arg edgezero "$edgezero_digest" \ + --arg fastly "$fastly_digest" \ + '{ + format: 1, + lifecycle_protocol: 1, + source_revision: $revision, + adapter: "fastly", + app_cli: {path: "cli/app-cli.tar", sha256: $cli}, + package: {path: "package/app.tar.gz", sha256: $package}, + manifests: { + edgezero: {path: "edgezero.toml", sha256: $edgezero}, + adapter: {path: "adapter/fastly.toml", sha256: $fastly} + } + }' >"$stage/release.json" + + tar -C "$stage" -czf "$output_dir/app-release.tar.gz" \ + release.json cli/app-cli.tar package/app.tar.gz edgezero.toml \ + adapter/fastly.toml + local release_digest + release_digest=$(sha256_file "$output_dir/app-release.tar.gz") + printf '%s\n' "$release_digest" >"$output_dir/app-release.sha256" + printf '%s\n' "$package_digest" >"$output_dir/package.sha256" + append_output app-release-sha256 "$release_digest" + append_output package-digest "$package_digest" + append_output source-revision "$revision" +} + +main() { + case "${1:-source}" in + source) write_source_fixture "${2:-store-aware}" ;; + release) + [[ $# -eq 2 ]] || fail "usage: make-smoke-fixture.sh release " + package_release "$2" + ;; + *) fail "usage: make-smoke-fixture.sh source | release " ;; + esac +} + +main "$@" diff --git a/.github/actions/deploy-core/tests/recovery-active-version.sh b/.github/actions/deploy-fastly/tests/recovery-active-version.sh similarity index 100% rename from .github/actions/deploy-core/tests/recovery-active-version.sh rename to .github/actions/deploy-fastly/tests/recovery-active-version.sh diff --git a/.github/actions/fastly-common/scripts/common.sh b/.github/actions/fastly-common/scripts/common.sh new file mode 100755 index 00000000..fb5174ed --- /dev/null +++ b/.github/actions/fastly-common/scripts/common.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Shared Fastly policy for lifecycle action wrappers. Provider-neutral action +# cores source only deploy-core/common.sh and never this file. +# +# Defines: +# Fastly service-ID validation +# the complete Fastly environment-alias clear list +# Fastly-only public runtime environment names +# +# Reads/Writes: +# no environment values or outputs at source time; helper arguments identify +# action-owned files written as NUL-delimited name lists. + +FASTLY_COMMON_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +readonly FASTLY_COMMON_DIR +# shellcheck source=../../deploy-core/scripts/common.sh +source "$FASTLY_COMMON_DIR/../../deploy-core/scripts/common.sh" + +require_fastly_service_id() { + require_input_matching fastly-service-id "$1" '^[A-Za-z0-9]+$' +} + +write_fastly_provider_clear_file() { + local file="$1" + printf '%s\0' \ + FASTLY_API_TOKEN FASTLY_SERVICE_ID FASTLY_TOKEN FASTLY_KEY FASTLY_API_KEY \ + FASTLY_AUTH_TOKEN FASTLY_API_ENDPOINT FASTLY_ENDPOINT FASTLY_API_URL \ + FASTLY_PROFILE FASTLY_SERVICE_NAME FASTLY_DEBUG FASTLY_DEBUG_MODE \ + FASTLY_CONFIG_FILE FASTLY_CARGO_PROFILE FASTLY_HOME >"$file" +} + +write_fastly_public_runtime_file() { + local file="$1" + printf '%s\0' EDGEZERO__LOGGING__USE_FASTLY_LOGGER EDGEZERO__LOGGING__ECHO_STDOUT >"$file" +} diff --git a/.github/actions/healthcheck-fastly/action.yml b/.github/actions/healthcheck-fastly/action.yml index c4b0631d..8e8ce5d4 100644 --- a/.github/actions/healthcheck-fastly/action.yml +++ b/.github/actions/healthcheck-fastly/action.yml @@ -1,68 +1,64 @@ name: EdgeZero healthcheck-fastly -description: Probe a deployed Fastly version's health via the app CLI. Exits non-zero when unhealthy after retries. +description: Probe a Fastly deployment with the application CLI from a verified immutable release. inputs: - app-cli-artifact: - description: Name of the build-app-cli artifact to download and run. + app-release-archive: + description: Path to the pinned application release archive. + required: true + app-release-sha256: + description: Expected lowercase SHA-256 digest of the application release archive. + required: true + expected-source-revision: + description: Full source revision that release.json must record. required: true - app-cli-bin: - description: Binary name inside the artifact. Defaults to the artifact metadata. - required: false - default: "" - fastly-api-token: - description: "Fastly API token. Needed ONLY for staging (deploy-to=staging) IP resolution; a production probe requires no token and receives none." - required: false - default: "" fastly-service-id: - description: Fastly service ID. + description: Alphanumeric Fastly service ID. required: true fastly-version: - description: Fastly service version to check. + description: Fastly version to probe. required: true domain: - description: Domain to probe (e.g. www.example.com). + description: Deployment hostname to probe. required: true path: - description: "URL path to probe (must begin with '/'). Applies to production and staging alike — a staged probe reroutes the same URL to the resolved staging IP. Defaults to '/'." + description: URL path to probe. required: false default: "/" - deploy-to: - description: Deployment target, 'production' or 'staging'. - required: false - default: production retry: - description: Total number of probe ATTEMPTS (not additional retries); e.g. 3 means at most 3 probes. Must be >= 1. + description: Total number of probe attempts. required: false default: "3" retry-delay: - description: Seconds between attempts. + description: Seconds between probe attempts. required: false default: "5" timeout: - description: Per-attempt request timeout in seconds. Must be a positive integer (0 would disable curl's timeout). + description: Per-attempt timeout in seconds. required: false default: "10" + deploy-to: + description: Deployment target, production or staging. + required: false + default: production + fastly-api-token: + description: Fastly API token required only to resolve a staging deployment. + required: false + default: "" outputs: healthy: - description: Whether the deployment is healthy (true/false). - value: ${{ steps.check.outputs.healthy }} + description: Whether the deployment was proven healthy. + value: ${{ steps.health.outputs.healthy }} status-code: - description: HTTP status code returned. - value: ${{ steps.check.outputs['status-code'] }} + description: Last HTTP status observed by the healthcheck. + value: ${{ steps.health.outputs['status-code'] }} runs: using: composite steps: - # A UNIQUE per-invocation workspace root under RUNNER_TEMP, so two concurrent - # invocations in one job (e.g. `background: true`) never collide on fixed temp - # paths (CLI download, extracted tools). The cleanup step removes it. - name: Prepare action workspace id: ws shell: bash - # Runs before validation, so it scrubs like every other step: blank the - # shipped aliases and BASH_ENV/ENV (a caller's job env could otherwise point - # BASH_ENV at checkout code that runs at bash startup with a token in scope). env: BASH_ENV: "" ENV: "" @@ -84,17 +80,14 @@ runs: FASTLY_HOME: "" run: exec "$GITHUB_ACTION_PATH/../deploy-core/scripts/prepare-workspace.sh" - # GitHub does not enforce `required: true` on composite inputs, and an empty - # artifact name makes actions/download-artifact fetch EVERY artifact in the - # run — so the CLI we execute would be arbitrary. Check before downloading. - name: Validate inputs shell: bash env: BASH_ENV: "" ENV: "" - EDGEZERO__APP__CLI__ARTIFACT_PRESENT: ${{ inputs['app-cli-artifact'] != '' && 'true' || 'false' }} - # Non-provider step: blank inherited provider aliases (only the probe step - # below receives the token, and only when it needs one). + EDGEZERO__APP__RELEASE__ARCHIVE_PRESENT: ${{ inputs['app-release-archive'] != '' && 'true' || 'false' }} + EDGEZERO__APP__RELEASE__SHA256_PRESENT: ${{ inputs['app-release-sha256'] != '' && 'true' || 'false' }} + EDGEZERO__FASTLY__SERVICE_ID: ${{ inputs['fastly-service-id'] }} FASTLY_API_TOKEN: "" FASTLY_SERVICE_ID: "" FASTLY_TOKEN: "" @@ -113,12 +106,18 @@ runs: FASTLY_HOME: "" run: exec "$GITHUB_ACTION_PATH/scripts/validate.sh" - - name: Download CLI artifact - uses: actions/download-artifact@v8 - with: - name: ${{ inputs['app-cli-artifact'] }} - path: ${{ steps.ws.outputs.root }}/cli-download + - name: Verify application release + id: release + shell: bash env: + BASH_ENV: "" + ENV: "" + EDGEZERO__APP__RELEASE__ARCHIVE: ${{ inputs['app-release-archive'] }} + EDGEZERO__APP__RELEASE__SHA256: ${{ inputs['app-release-sha256'] }} + EDGEZERO__APP__RELEASE__EXPECTED_SOURCE_REVISION: ${{ inputs['expected-source-revision'] }} + EDGEZERO__APP__RELEASE__EXPECTED_ADAPTER: fastly + EDGEZERO__APP__RELEASE__EXPECTED_LIFECYCLE_PROTOCOL: "1" + EDGEZERO__APP__RELEASE__ROOT: ${{ steps.ws.outputs.root }}/release FASTLY_API_TOKEN: "" FASTLY_SERVICE_ID: "" FASTLY_TOKEN: "" @@ -135,26 +134,26 @@ runs: FASTLY_CONFIG_FILE: "" FASTLY_CARGO_PROFILE: "" FASTLY_HOME: "" + run: exec "$GITHUB_ACTION_PATH/../release-core/scripts/prepare-release.sh" - - name: Extract CLI + - name: Extract application CLI id: cli shell: bash env: BASH_ENV: "" ENV: "" - EDGEZERO__APP__CLI__ARTIFACT_DIR: ${{ steps.ws.outputs.root }}/cli-download + EDGEZERO__APP__CLI__ARCHIVE: ${{ steps.release.outputs['app-cli-archive'] }} EDGEZERO__ACTION__TOOL_ROOT: ${{ steps.ws.outputs.root }}/tools - EDGEZERO__APP__CLI__BIN: ${{ inputs['app-cli-bin'] }} FASTLY_API_TOKEN: "" + FASTLY_SERVICE_ID: "" FASTLY_TOKEN: "" FASTLY_KEY: "" FASTLY_API_KEY: "" FASTLY_AUTH_TOKEN: "" - FASTLY_ENDPOINT: "" FASTLY_API_ENDPOINT: "" + FASTLY_ENDPOINT: "" FASTLY_API_URL: "" FASTLY_PROFILE: "" - FASTLY_SERVICE_ID: "" FASTLY_SERVICE_NAME: "" FASTLY_DEBUG: "" FASTLY_DEBUG_MODE: "" @@ -163,40 +162,33 @@ runs: FASTLY_HOME: "" run: exec "$GITHUB_ACTION_PATH/../deploy-core/scripts/download-app-cli.sh" - - name: Health check - id: check + - name: Healthcheck + id: health shell: bash env: - # BASH_ENV/ENV are sourced at bash startup, before this script can scrub — - # blank them so a caller's job env cannot run code here with the token. BASH_ENV: "" ENV: "" - # Mint the sensitive lifecycle log under the per-invocation workspace so the - # Cleanup step removes it wholesale even if the in-process EXIT trap cannot fire. EDGEZERO__ACTION__WORKSPACE: ${{ steps.ws.outputs.root }} - EDGEZERO__APP__CLI__BIN: ${{ steps.cli.outputs['app-cli-bin'] }} EDGEZERO__APP__CLI__PATH: ${{ steps.cli.outputs['app-cli-path'] }} EDGEZERO__LIFECYCLE__SERVICE_ID: ${{ inputs['fastly-service-id'] }} EDGEZERO__LIFECYCLE__VERSION: ${{ inputs['fastly-version'] }} EDGEZERO__LIFECYCLE__DOMAIN: ${{ inputs.domain }} EDGEZERO__LIFECYCLE__PATH: ${{ inputs.path }} - EDGEZERO__DEPLOY__TO: ${{ inputs['deploy-to'] }} EDGEZERO__LIFECYCLE__RETRY: ${{ inputs.retry }} EDGEZERO__LIFECYCLE__RETRY_DELAY: ${{ inputs['retry-delay'] }} EDGEZERO__LIFECYCLE__TIMEOUT: ${{ inputs.timeout }} - # Only the typed token reaches the CLI; blank any inherited alias. - # Only a STAGING probe needs the token (staging-IP resolution). A - # production probe just curls the domain, so it receives no token. - FASTLY_API_TOKEN: ${{ inputs['deploy-to'] == 'staging' && inputs['fastly-api-token'] || '' }} + EDGEZERO__DEPLOY__TO: ${{ inputs['deploy-to'] }} + EDGEZERO__FASTLY__API_TOKEN: ${{ inputs['deploy-to'] == 'staging' && inputs['fastly-api-token'] || '' }} + FASTLY_API_TOKEN: "" + FASTLY_SERVICE_ID: "" FASTLY_TOKEN: "" FASTLY_KEY: "" FASTLY_API_KEY: "" FASTLY_AUTH_TOKEN: "" - FASTLY_ENDPOINT: "" FASTLY_API_ENDPOINT: "" + FASTLY_ENDPOINT: "" FASTLY_API_URL: "" FASTLY_PROFILE: "" - FASTLY_SERVICE_ID: "" FASTLY_SERVICE_NAME: "" FASTLY_DEBUG: "" FASTLY_DEBUG_MODE: "" diff --git a/.github/actions/healthcheck-fastly/scripts/healthcheck.sh b/.github/actions/healthcheck-fastly/scripts/healthcheck.sh index b98d59c1..3e3f7ec3 100755 --- a/.github/actions/healthcheck-fastly/scripts/healthcheck.sh +++ b/.github/actions/healthcheck-fastly/scripts/healthcheck.sh @@ -14,7 +14,7 @@ set -euo pipefail # EDGEZERO__LIFECYCLE__VERSION required version to probe # EDGEZERO__LIFECYCLE__DOMAIN required domain to probe # EDGEZERO__LIFECYCLE__PATH optional URL path to probe (default: /) -# FASTLY_API_TOKEN staging-only provider token (staging-IP resolution) +# EDGEZERO__FASTLY__API_TOKEN staging-only action-private provider token # EDGEZERO__DEPLOY__TO optional production | staging (default: production) # EDGEZERO__LIFECYCLE__RETRY optional attempts before unhealthy (default: 3) # EDGEZERO__LIFECYCLE__RETRY_DELAY optional seconds between attempts (default: 5) @@ -25,14 +25,14 @@ set -euo pipefail # Exits non-zero when the deployment is not provably healthy (the rollback gate). SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) -# shellcheck source=../../deploy-core/scripts/common.sh -source "$SCRIPT_DIR/../../deploy-core/scripts/common.sh" +# shellcheck source=../../fastly-common/scripts/common.sh +source "$SCRIPT_DIR/../../fastly-common/scripts/common.sh" validate_inputs() { require_linux_x86_64 # `required: true` in action metadata does not fail an omitted input, so the # only real guard against probing with an empty service/version is this one. - require_input_matching fastly-service-id "${EDGEZERO__LIFECYCLE__SERVICE_ID:-}" '^[A-Za-z0-9]+$' + require_fastly_service_id "${EDGEZERO__LIFECYCLE__SERVICE_ID:-}" require_input_matching fastly-version "${EDGEZERO__LIFECYCLE__VERSION:-}" '^[0-9]+$' require_input_matching domain "${EDGEZERO__LIFECYCLE__DOMAIN:-}" '^[A-Za-z0-9._-]+$' # The path is appended to https:// as one curl argument (the CLI @@ -62,7 +62,7 @@ validate_inputs() { # production probe just curls the public domain, so it needs no token — and the # wrapper passes none. if [[ "${EDGEZERO__DEPLOY__TO:-}" == "staging" ]]; then - require_input fastly-api-token "${FASTLY_API_TOKEN:-}" + require_input fastly-api-token "${EDGEZERO__FASTLY__API_TOKEN:-}" fi } @@ -80,8 +80,10 @@ main() { local cli_bin cli_bin=$(resolve_app_cli) require_cmd "$cli_bin" + cli_bin=$(command -v "$cli_bin") || fail "application CLI is unavailable" + export EDGEZERO__APP__CLI__PATH="$cli_bin" local argv=( - "$cli_bin" healthcheck + healthcheck --adapter fastly --service-id "$EDGEZERO__LIFECYCLE__SERVICE_ID" --version "$EDGEZERO__LIFECYCLE__VERSION" @@ -95,9 +97,27 @@ main() { argv+=(--staging) fi + require_cmd jq + local workspace="${EDGEZERO__ACTION__WORKSPACE:-$(dirname -- "$cli_bin")}" + mkdir -p "$workspace" + export EDGEZERO__ACTION__WORKSPACE="$workspace" + local args_file="$workspace/healthcheck-argv.nul" + local clear_file="$workspace/fastly-provider-clear.nul" + printf '%s\0' "${argv[@]}" >"$args_file" + write_fastly_provider_clear_file "$clear_file" + if [[ "$EDGEZERO__DEPLOY__TO" == "staging" ]]; then + EDGEZERO__PROVIDER__ENV=$(jq -n --arg token "${EDGEZERO__FASTLY__API_TOKEN:-}" '{FASTLY_API_TOKEN:$token}') + else + EDGEZERO__PROVIDER__ENV='{}' + fi + export EDGEZERO__PROVIDER__ENV + export EDGEZERO__PROVIDER__ENV_CLEAR_FILE="$clear_file" + export EDGEZERO__APP__CLI__ARGS_FILE="$args_file" + export EDGEZERO__APP__CLI__MUTATES=false + new_private_log local rc=0 - "${argv[@]}" 2>&1 | tee "$LIFECYCLE_LOG" || rc=$? + "$SCRIPT_DIR/../../deploy-core/scripts/run-app-cli.sh" 2>&1 | tee "$LIFECYCLE_LOG" || rc=$? local healthy status healthy=$(read_bool_line healthy "$LIFECYCLE_LOG") diff --git a/.github/actions/healthcheck-fastly/scripts/validate.sh b/.github/actions/healthcheck-fastly/scripts/validate.sh index 5edcf440..c5775d0f 100755 --- a/.github/actions/healthcheck-fastly/scripts/validate.sh +++ b/.github/actions/healthcheck-fastly/scripts/validate.sh @@ -1,16 +1,18 @@ #!/usr/bin/env bash set -euo pipefail -# Validates the healthcheck-fastly wrapper's inputs before downloading the artifact. In a script +# Validates the healthcheck-fastly wrapper's inputs before verifying the release. In a script # (not inline action.yml run: ) so it is linted and contract-tested. # # Reads (env): -# EDGEZERO__APP__CLI__ARTIFACT_PRESENT required "true" when app-cli-artifact is non-empty +# EDGEZERO__APP__RELEASE__ARCHIVE_PRESENT required release archive presence flag +# EDGEZERO__APP__RELEASE__SHA256_PRESENT required release digest presence flag SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) -# shellcheck source=../../deploy-core/scripts/common.sh -source "$SCRIPT_DIR/../../deploy-core/scripts/common.sh" +# shellcheck source=../../fastly-common/scripts/common.sh +source "$SCRIPT_DIR/../../fastly-common/scripts/common.sh" -# An empty artifact name makes actions/download-artifact fetch EVERY artifact in -# the run, so the CLI we then execute would be arbitrary. -require_present app-cli-artifact "${EDGEZERO__APP__CLI__ARTIFACT_PRESENT:-}" +# The release must be pinned before its CLI can be extracted. +require_present app-release-archive "${EDGEZERO__APP__RELEASE__ARCHIVE_PRESENT:-}" +require_present app-release-sha256 "${EDGEZERO__APP__RELEASE__SHA256_PRESENT:-}" +require_fastly_service_id "${EDGEZERO__FASTLY__SERVICE_ID:-}" diff --git a/.github/actions/package-application-release-fastly/action.yml b/.github/actions/package-application-release-fastly/action.yml new file mode 100644 index 00000000..9c590cb3 --- /dev/null +++ b/.github/actions/package-application-release-fastly/action.yml @@ -0,0 +1,149 @@ +name: EdgeZero package Fastly application release +description: Assemble and publish a verified immutable Fastly application release from prebuilt inputs. + +inputs: + app-cli-archive: + description: Path beneath github.workspace to the build-app-cli tar archive. + required: true + fastly-package: + description: Path beneath github.workspace to the prebuilt Fastly package tarball. + required: true + application-manifest: + description: Path beneath github.workspace to edgezero.toml. + required: true + adapter-manifest: + description: Path beneath github.workspace to the Fastly manifest referenced by edgezero.toml. + required: true + source-revision: + description: Full lowercase 40- or 64-character source revision. + required: true + artifact-name: + description: Name of the uploaded immutable release artifact. + required: false + default: application-release + +outputs: + artifact-name: + description: Uploaded artifact name. + value: ${{ steps.package.outputs.artifact-name }} + archive-sha256: + description: Lowercase SHA-256 of app-release.tar.gz. + value: ${{ steps.package.outputs.archive-sha256 }} + package-sha256: + description: Lowercase SHA-256 of the bundled Fastly package. + value: ${{ steps.package.outputs.package-sha256 }} + source-revision: + description: Source revision recorded in release.json. + value: ${{ steps.package.outputs.source-revision }} + +runs: + using: composite + steps: + - name: Package and verify application release + id: package + shell: bash + env: + EDGEZERO__RELEASE__APP_CLI_ARCHIVE: ${{ inputs.app-cli-archive }} + EDGEZERO__RELEASE__FASTLY_PACKAGE: ${{ inputs.fastly-package }} + EDGEZERO__RELEASE__APPLICATION_MANIFEST: ${{ inputs.application-manifest }} + EDGEZERO__RELEASE__ADAPTER_MANIFEST: ${{ inputs.adapter-manifest }} + EDGEZERO__RELEASE__SOURCE_REVISION: ${{ inputs.source-revision }} + EDGEZERO__RELEASE__ARTIFACT_NAME: ${{ inputs.artifact-name }} + BASH_ENV: "" + ENV: "" + FASTLY_API_TOKEN: "" + FASTLY_SERVICE_ID: "" + FASTLY_TOKEN: "" + FASTLY_KEY: "" + FASTLY_API_KEY: "" + FASTLY_AUTH_TOKEN: "" + FASTLY_API_ENDPOINT: "" + FASTLY_ENDPOINT: "" + FASTLY_API_URL: "" + FASTLY_PROFILE: "" + FASTLY_SERVICE_NAME: "" + FASTLY_DEBUG: "" + FASTLY_DEBUG_MODE: "" + FASTLY_CONFIG_FILE: "" + FASTLY_CARGO_PROFILE: "" + FASTLY_HOME: "" + CLOUDFLARE_API_TOKEN: "" + CLOUDFLARE_API_KEY: "" + CLOUDFLARE_ACCOUNT_ID: "" + CLOUDFLARE_EMAIL: "" + CF_API_TOKEN: "" + CF_API_KEY: "" + CF_ACCOUNT_ID: "" + SPIN_AUTH_TOKEN: "" + FERMYON_TOKEN: "" + run: exec "$GITHUB_ACTION_PATH/scripts/package-release.sh" + + - name: Upload immutable application release + uses: actions/upload-artifact@v7 + env: + BASH_ENV: "" + ENV: "" + FASTLY_API_TOKEN: "" + FASTLY_SERVICE_ID: "" + FASTLY_TOKEN: "" + FASTLY_KEY: "" + FASTLY_API_KEY: "" + FASTLY_AUTH_TOKEN: "" + FASTLY_API_ENDPOINT: "" + FASTLY_ENDPOINT: "" + FASTLY_API_URL: "" + FASTLY_PROFILE: "" + FASTLY_SERVICE_NAME: "" + FASTLY_DEBUG: "" + FASTLY_DEBUG_MODE: "" + FASTLY_CONFIG_FILE: "" + FASTLY_CARGO_PROFILE: "" + FASTLY_HOME: "" + CLOUDFLARE_API_TOKEN: "" + CLOUDFLARE_API_KEY: "" + CLOUDFLARE_ACCOUNT_ID: "" + CLOUDFLARE_EMAIL: "" + CF_API_TOKEN: "" + CF_API_KEY: "" + CF_ACCOUNT_ID: "" + SPIN_AUTH_TOKEN: "" + FERMYON_TOKEN: "" + with: + name: ${{ steps.package.outputs.artifact-name }} + path: ${{ steps.package.outputs.archive-path }} + if-no-files-found: error + retention-days: 14 + + - name: Cleanup release workspace + if: ${{ always() }} + shell: bash + env: + EDGEZERO__ACTION__WORKSPACE: ${{ steps.package.outputs.workspace-path }} + BASH_ENV: "" + ENV: "" + FASTLY_API_TOKEN: "" + FASTLY_SERVICE_ID: "" + FASTLY_TOKEN: "" + FASTLY_KEY: "" + FASTLY_API_KEY: "" + FASTLY_AUTH_TOKEN: "" + FASTLY_API_ENDPOINT: "" + FASTLY_ENDPOINT: "" + FASTLY_API_URL: "" + FASTLY_PROFILE: "" + FASTLY_SERVICE_NAME: "" + FASTLY_DEBUG: "" + FASTLY_DEBUG_MODE: "" + FASTLY_CONFIG_FILE: "" + FASTLY_CARGO_PROFILE: "" + FASTLY_HOME: "" + CLOUDFLARE_API_TOKEN: "" + CLOUDFLARE_API_KEY: "" + CLOUDFLARE_ACCOUNT_ID: "" + CLOUDFLARE_EMAIL: "" + CF_API_TOKEN: "" + CF_API_KEY: "" + CF_ACCOUNT_ID: "" + SPIN_AUTH_TOKEN: "" + FERMYON_TOKEN: "" + run: exec "$GITHUB_ACTION_PATH/../deploy-core/scripts/cleanup.sh" diff --git a/.github/actions/package-application-release-fastly/lifecycle-protocol.json b/.github/actions/package-application-release-fastly/lifecycle-protocol.json new file mode 100644 index 00000000..9f2d7d3e --- /dev/null +++ b/.github/actions/package-application-release-fastly/lifecycle-protocol.json @@ -0,0 +1,25 @@ +{ + "lifecycle_protocol": 1, + "probes": [ + { + "command": ["deploy"], + "required_flags": ["--adapter", "--service-id", "--application-release", "--staging"] + }, + { + "command": ["config", "push"], + "required_flags": ["--adapter", "--manifest", "--app-config", "--store", "--staging", "--no-env", "--yes", "--no-diff"] + }, + { + "command": ["healthcheck"], + "required_flags": ["--adapter", "--service-id", "--version", "--domain", "--path", "--retry", "--retry-delay", "--timeout", "--staging"] + }, + { + "command": ["rollback"], + "required_flags": ["--adapter", "--service-id", "--version", "--rollback-to", "--staging"] + }, + { + "command": ["active-version"], + "required_flags": ["--adapter", "--service-id"] + } + ] +} diff --git a/.github/actions/package-application-release-fastly/scripts/package-release.sh b/.github/actions/package-application-release-fastly/scripts/package-release.sh new file mode 100755 index 00000000..9ba138f6 --- /dev/null +++ b/.github/actions/package-application-release-fastly/scripts/package-release.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Supplies Fastly policy to the provider-neutral immutable release packager. +# The composite action owns all user-facing input mapping; this wrapper fixes the +# adapter identity, capability declaration, and Fastly package variable. +# +# Reads (env): +# EDGEZERO__RELEASE__FASTLY_PACKAGE required prebuilt Fastly package +# EDGEZERO__RELEASE__APP_CLI_ARCHIVE forwarded to release-core +# EDGEZERO__RELEASE__APPLICATION_MANIFEST forwarded to release-core +# EDGEZERO__RELEASE__ADAPTER_MANIFEST forwarded to release-core +# EDGEZERO__RELEASE__SOURCE_REVISION forwarded to release-core +# EDGEZERO__RELEASE__ARTIFACT_NAME forwarded to release-core +# Writes: +# the release-core package outputs unchanged + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +ACTION_DIR=$(cd -- "$SCRIPT_DIR/.." && pwd) + +export EDGEZERO__RELEASE__ADAPTER=fastly +export EDGEZERO__RELEASE__LIFECYCLE_CAPABILITIES="$ACTION_DIR/lifecycle-protocol.json" +export EDGEZERO__RELEASE__POLICY_ROOT="$ACTION_DIR" +export EDGEZERO__RELEASE__PACKAGE="${EDGEZERO__RELEASE__FASTLY_PACKAGE:-}" + +exec "$ACTION_DIR/../release-core/scripts/package-release.sh" diff --git a/.github/actions/package-application-release-fastly/tests/run.sh b/.github/actions/package-application-release-fastly/tests/run.sh new file mode 100755 index 00000000..8908d1b7 --- /dev/null +++ b/.github/actions/package-application-release-fastly/tests/run.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ "$(uname -s)" != Linux || "$(uname -m)" != x86_64 ]]; then + printf 'package release test skipped outside Linux x86-64\n' + exit 0 +fi + +ACTION_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +WORK_DIR=$(mktemp -d "${TMPDIR:-/tmp}/edgezero-package-release-test.XXXXXX") +trap 'rm -rf -- "$WORK_DIR"' EXIT +mkdir -p "$WORK_DIR/workspace/cli-root" "$WORK_DIR/runner" + +cat >"$WORK_DIR/workspace/cli-root/app-cli" <<'CLI' +#!/usr/bin/env bash +case "$*" in + --help) echo 'app-cli help' ;; + 'deploy --help') echo '--adapter --service-id --application-release --staging' ;; + 'config push --help') echo '--adapter --manifest --app-config --store --staging --no-env --yes --no-diff' ;; + 'healthcheck --help') echo '--adapter --service-id --version --domain --path --retry --retry-delay --timeout --staging' ;; + 'rollback --help') echo '--adapter --service-id --version --rollback-to --staging' ;; + 'active-version --help') echo '--adapter --service-id' ;; + *) exit 2 ;; +esac +CLI +chmod +x "$WORK_DIR/workspace/cli-root/app-cli" +cat >"$WORK_DIR/workspace/cli-root/app-cli-meta.json" <<'JSON' +{"app-cli-bin":"app-cli","app-cli-version":"1.0.0","app-cli-package":"fixture-cli"} +JSON +tar -C "$WORK_DIR/workspace/cli-root" -cf "$WORK_DIR/workspace/app-cli.tar" \ + app-cli app-cli-meta.json +printf 'package\n' >"$WORK_DIR/workspace/app.tar.gz" +cat >"$WORK_DIR/workspace/edgezero.toml" <<'TOML' +[app] +name = "fixture" +[adapters.fastly.adapter] +manifest = "fastly.toml" +[adapters.spin.adapter] +manifest = "adapters/spin/spin.toml" +TOML +printf 'manifest_version = 3\nname = "fixture"\n' >"$WORK_DIR/workspace/fastly.toml" +: >"$WORK_DIR/output" + +GITHUB_WORKSPACE="$WORK_DIR/workspace" \ + GITHUB_OUTPUT="$WORK_DIR/output" \ + RUNNER_TEMP="$WORK_DIR/runner" \ + EDGEZERO__RELEASE__APP_CLI_ARCHIVE=app-cli.tar \ + EDGEZERO__RELEASE__FASTLY_PACKAGE=app.tar.gz \ + EDGEZERO__RELEASE__APPLICATION_MANIFEST=edgezero.toml \ + EDGEZERO__RELEASE__ADAPTER_MANIFEST=fastly.toml \ + EDGEZERO__RELEASE__SOURCE_REVISION=0123456789abcdef0123456789abcdef01234567 \ + EDGEZERO__RELEASE__ARTIFACT_NAME=fixture-release \ + "$ACTION_DIR/scripts/package-release.sh" >/dev/null + +archive=$(sed -n 's/^archive-path=//p' "$WORK_DIR/output") +[[ -f "$archive" ]] +tar -xOzf "$archive" release.json | jq -e \ + '.format == 1 and .lifecycle_protocol == 1 and .adapter == "fastly" + and (.manifests.adapter.path == "fastly.toml")' >/dev/null +tar -tzf "$archive" | grep -qx 'fastly.toml' +if tar -tzf "$archive" | grep -qx 'adapters/spin/spin.toml'; then + printf 'packager included an unselected Spin manifest\n' >&2 + exit 1 +fi +if tar -tzf "$archive" | grep -qx 'adapter/fastly.toml'; then + printf 'packager relocated the declared Fastly manifest\n' >&2 + exit 1 +fi +grep -qx 'artifact-name=fixture-release' "$WORK_DIR/output" +grep -Eq '^archive-sha256=[0-9a-f]{64}$' "$WORK_DIR/output" +grep -Eq '^package-sha256=[0-9a-f]{64}$' "$WORK_DIR/output" + +mv "$WORK_DIR/workspace/fastly.toml" "$WORK_DIR/workspace/fastly.real.toml" +ln -s fastly.real.toml "$WORK_DIR/workspace/fastly.toml" +if GITHUB_WORKSPACE="$WORK_DIR/workspace" \ + GITHUB_OUTPUT="$WORK_DIR/symlink-output" \ + RUNNER_TEMP="$WORK_DIR/runner" \ + EDGEZERO__RELEASE__APP_CLI_ARCHIVE=app-cli.tar \ + EDGEZERO__RELEASE__FASTLY_PACKAGE=app.tar.gz \ + EDGEZERO__RELEASE__APPLICATION_MANIFEST=edgezero.toml \ + EDGEZERO__RELEASE__ADAPTER_MANIFEST=fastly.toml \ + EDGEZERO__RELEASE__SOURCE_REVISION=0123456789abcdef0123456789abcdef01234567 \ + EDGEZERO__RELEASE__ARTIFACT_NAME=symlink-release \ + "$ACTION_DIR/scripts/package-release.sh" >"$WORK_DIR/symlink.log" 2>&1; then + printf 'packager accepted a symlinked Fastly adapter manifest\n' >&2 + exit 1 +fi +grep -Fq "adapter-manifest must be a regular file" "$WORK_DIR/symlink.log" +rm "$WORK_DIR/workspace/fastly.toml" +mv "$WORK_DIR/workspace/fastly.real.toml" "$WORK_DIR/workspace/fastly.toml" + +perl -0pi -e 's/ --timeout//' "$WORK_DIR/workspace/cli-root/app-cli" +tar -C "$WORK_DIR/workspace/cli-root" -cf "$WORK_DIR/workspace/app-cli.tar" \ + app-cli app-cli-meta.json +if GITHUB_WORKSPACE="$WORK_DIR/workspace" \ + GITHUB_OUTPUT="$WORK_DIR/incompatible-output" \ + RUNNER_TEMP="$WORK_DIR/runner" \ + EDGEZERO__RELEASE__APP_CLI_ARCHIVE=app-cli.tar \ + EDGEZERO__RELEASE__FASTLY_PACKAGE=app.tar.gz \ + EDGEZERO__RELEASE__APPLICATION_MANIFEST=edgezero.toml \ + EDGEZERO__RELEASE__ADAPTER_MANIFEST=fastly.toml \ + EDGEZERO__RELEASE__SOURCE_REVISION=0123456789abcdef0123456789abcdef01234567 \ + EDGEZERO__RELEASE__ARTIFACT_NAME=incompatible-release \ + "$ACTION_DIR/scripts/package-release.sh" >"$WORK_DIR/incompatible.log" 2>&1; then + printf 'packager accepted a CLI without the complete lifecycle protocol\n' >&2 + exit 1 +fi +grep -Fq "healthcheck --help' lacks '--timeout'" "$WORK_DIR/incompatible.log" + +printf 'Fastly application release packaging test passed\n' diff --git a/.github/actions/release-core/scripts/package-release.sh b/.github/actions/release-core/scripts/package-release.sh new file mode 100755 index 00000000..8322d96d --- /dev/null +++ b/.github/actions/release-core/scripts/package-release.sh @@ -0,0 +1,235 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Builds and self-verifies one immutable application release. The core knows the +# release schema and archive-safety rules; the provider wrapper supplies adapter +# identity, lifecycle capability probes, and the provider package input. +# +# Reads (env): +# GITHUB_WORKSPACE required confinement root for every packaged input +# RUNNER_TEMP optional parent for the action-owned package workspace +# EDGEZERO__RELEASE__ADAPTER required lowercase adapter identity recorded in release.json +# EDGEZERO__RELEASE__LIFECYCLE_CAPABILITIES required provider-owned JSON capability declaration +# EDGEZERO__RELEASE__POLICY_ROOT required trusted root containing the capability declaration +# EDGEZERO__RELEASE__APP_CLI_ARCHIVE required build-app-cli archive beneath github.workspace +# EDGEZERO__RELEASE__PACKAGE required provider package beneath github.workspace +# EDGEZERO__RELEASE__APPLICATION_MANIFEST required edgezero.toml beneath github.workspace +# EDGEZERO__RELEASE__ADAPTER_MANIFEST required selected manifest beneath github.workspace +# EDGEZERO__RELEASE__SOURCE_REVISION required full lowercase 40- or 64-hex revision +# EDGEZERO__RELEASE__ARTIFACT_NAME optional uploaded artifact name +# Writes (outputs): +# artifact-name, archive-path, workspace-path, archive-sha256, +# package-sha256, source-revision + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=../../deploy-core/scripts/common.sh +source "$SCRIPT_DIR/../../deploy-core/scripts/common.sh" + +confined_file() { + local raw="$1" label="$2" workspace_real="$3" candidate + [[ -n "$raw" ]] || fail "$label is required" + case "$raw" in + /*) candidate="$raw" ;; + *) candidate="${GITHUB_WORKSPACE:?GITHUB_WORKSPACE is required}/$raw" ;; + esac + [[ -f "$candidate" && ! -L "$candidate" ]] || fail "$label must be a regular file" + candidate=$(canonical_path "$candidate") + is_under "$workspace_real" "$candidate" || fail "$label must resolve beneath github.workspace" + printf '%s\n' "$candidate" +} + +probe_capabilities() { + local cli="$1" capabilities="$2" protocol="$3" probe_count index help expected + probe_count=$(jq -r '.probes | length' "$capabilities") + for ((index = 0; index < probe_count; index++)); do + local -a command=() + while IFS= read -r token; do command+=("$token"); done < <(jq -r ".probes[$index].command[]" "$capabilities") + help=$(env -i PATH="/usr/bin:/bin" HOME="${HOME:-/tmp}" "$cli" "${command[@]}" --help 2>&1) || + fail "application CLI does not support '${command[*]} --help' required by lifecycle protocol $protocol" + while IFS= read -r expected; do + awk -v flag="$expected" ' + { + for (field = 1; field <= NF; field++) { + if ($field == flag || index($field, flag "=") == 1) found = 1 + } + } + END { exit !found } + ' <<<"$help" || + fail "application CLI '${command[*]} --help' lacks '$expected' required by lifecycle protocol $protocol" + done < <(jq -r ".probes[$index].required_flags[]" "$capabilities") + done +} + +package_adapter_manifest() { + local application_manifest="$1" adapter_manifest="$2" adapter="$3" stage="$4" output="$5" + local parsed application_root relative candidate referenced destination digest + parsed="$output.application.json" + yq -p toml -o json -I=0 '.' "$application_manifest" >"$parsed" 2>/dev/null || + fail "could not parse application-manifest as TOML" + jq -e --arg adapter "$adapter" ' + (.adapters | type == "object") + and ([.adapters | keys[] | ascii_downcase] as $names + | ($names | length) == ($names | unique | length)) + and ([.adapters | to_entries[] | select((.key | ascii_downcase) == $adapter)] as $selected + | ($selected | length) == 1 + and ($selected[0].value | type == "object") + and ($selected[0].value.adapter | type == "object") + and ($selected[0].value.adapter.manifest | type == "string" and length > 0)) + ' "$parsed" >/dev/null 2>&1 || + fail "application-manifest must declare one valid [adapters.$adapter.adapter] manifest" + relative=$(jq -er \ + --arg adapter "$adapter" \ + '[.adapters | to_entries[] | select((.key | ascii_downcase) == $adapter)][0].value.adapter.manifest' \ + "$parsed") || fail "could not resolve the selected adapter manifest from application-manifest" + + application_root=$(canonical_path "$(dirname -- "$application_manifest")") + [[ "$relative" =~ ^[A-Za-z0-9._/-]+$ ]] || + fail "application-manifest adapter manifest has an invalid path" + case "$relative" in + /* | *\\* | */ | ./* | *//* | release.json | edgezero.toml | cli/app-cli.tar | package/app.tar.gz) + fail "application-manifest adapter manifest path must be normalized, relative, and distinct from reserved release members" + ;; + esac + local part + local -a parts + IFS=/ read -r -a parts <<<"$relative" + for part in "${parts[@]}"; do + [[ -n "$part" && "$part" != . && "$part" != .. ]] || + fail "application-manifest adapter manifest path must be normalized and relative" + done + candidate="$application_root/$relative" + [[ ! -L "$candidate" ]] || fail "adapter manifest must not be a symlink" + [[ -f "$candidate" ]] || fail "adapter manifest is not a regular file" + referenced=$(canonical_path "$candidate") + is_under "$application_root" "$referenced" || + fail "application-manifest adapter manifest must resolve beneath the application root" + [[ "$referenced" == "$adapter_manifest" ]] || + fail "adapter-manifest must be the selected file referenced by application-manifest" + destination="$stage/$relative" + mkdir -p "$(dirname -- "$destination")" + cp "$referenced" "$destination" + digest=$(sha256_file "$destination") + jq -cn --arg path "$relative" --arg sha256 "$digest" \ + '{path:$path,sha256:$sha256}' >"$output" + rm -f "$parsed" +} + +validate_capabilities() { + local file="$1" + jq -e '.' "$file" >/dev/null 2>&1 || fail "lifecycle capability declaration is not valid JSON" + # shellcheck disable=SC2016 # $keys is a yq variable, not a shell variable + yq -p yaml -o json -I=0 ' + [.. | select(tag == "!!map") + | (keys as $keys | ($keys | length) == ($keys | unique | length))] + | all + ' "$file" 2>/dev/null | grep -qx true || + fail "lifecycle capability declaration contains a duplicate field" + jq -e ' + type == "object" + and (keys == ["lifecycle_protocol", "probes"]) + and (.lifecycle_protocol | type == "number" and floor == . and . > 0) + and (.probes | type == "array" and length > 0) + and ([.probes[] | + type == "object" + and (keys == ["command", "required_flags"]) + and (.command | type == "array" and length > 0) + and ([.command[] | type == "string" and test("^[ -~]+$") and length > 0 and . != "--help"] | all) + and (.required_flags | type == "array") + and ([.required_flags[] | type == "string" and test("^-[ -~]*$") and length > 1] | all) + and ((.required_flags | length) == (.required_flags | unique | length)) + ] | all) + and (([.probes[].command | tojson] | length) == ([.probes[].command | tojson] | unique | length)) + ' "$file" >/dev/null 2>&1 || fail "lifecycle capability declaration has an invalid schema" +} + +main() { + local workspace_real runner_temp artifact_name revision adapter policy_root capabilities protocol + workspace_real=$(canonical_path "${GITHUB_WORKSPACE:?GITHUB_WORKSPACE is required}") + runner_temp="${RUNNER_TEMP:-/tmp}" + artifact_name="${EDGEZERO__RELEASE__ARTIFACT_NAME:-application-release}" + revision="${EDGEZERO__RELEASE__SOURCE_REVISION:-}" + adapter="${EDGEZERO__RELEASE__ADAPTER:-}" + policy_root="${EDGEZERO__RELEASE__POLICY_ROOT:-}" + capabilities="${EDGEZERO__RELEASE__LIFECYCLE_CAPABILITIES:-}" + [[ "$artifact_name" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ ]] || + fail "artifact-name contains unsupported characters" + [[ "$revision" =~ ^([0-9a-f]{40}|[0-9a-f]{64})$ ]] || + fail "source-revision must be 40 or 64 lowercase hexadecimal characters" + [[ "$adapter" =~ ^[a-z][a-z0-9_-]*$ ]] || fail "release adapter is invalid" + for command in jq tar; do require_cmd "$command"; done + require_yq_v4 + [[ -d "$policy_root" ]] || fail "release policy root must be a directory" + policy_root=$(canonical_path "$policy_root") + [[ -f "$capabilities" && ! -L "$capabilities" ]] || + fail "lifecycle capability declaration must be a regular file" + capabilities=$(canonical_path "$capabilities") + is_under "$policy_root" "$capabilities" || + fail "lifecycle capability declaration must resolve beneath the policy root" + validate_capabilities "$capabilities" + protocol=$(jq -r '.lifecycle_protocol' "$capabilities") + + local cli_archive package application_manifest adapter_manifest + cli_archive=$(confined_file "${EDGEZERO__RELEASE__APP_CLI_ARCHIVE:-}" app-cli-archive "$workspace_real") + package=$(confined_file "${EDGEZERO__RELEASE__PACKAGE:-}" adapter-package "$workspace_real") + application_manifest=$(confined_file "${EDGEZERO__RELEASE__APPLICATION_MANIFEST:-}" application-manifest "$workspace_real") + adapter_manifest=$(confined_file "${EDGEZERO__RELEASE__ADAPTER_MANIFEST:-}" adapter-manifest "$workspace_real") + + local work cli_outputs cli_path + work=$(mktemp -d "$runner_temp/edgezero-package-release.XXXXXX") + trap 'rm -rf -- "$work"' EXIT + cli_outputs="$work/cli.outputs" + : >"$cli_outputs" + EDGEZERO__APP__CLI__ARCHIVE="$cli_archive" \ + EDGEZERO__ACTION__TOOL_ROOT="$work/tool" \ + GITHUB_OUTPUT="$cli_outputs" \ + "$SCRIPT_DIR/../../deploy-core/scripts/download-app-cli.sh" >/dev/null + cli_path=$(sed -n 's/^app-cli-path=//p' "$cli_outputs") + [[ -n "$cli_path" && -x "$cli_path" ]] || fail "application CLI verification emitted no executable path" + probe_capabilities "$cli_path" "$capabilities" "$protocol" + + local stage adapter_metadata adapter_path release_archive package_digest archive_digest verify_root + stage="$work/stage" + mkdir -p "$stage/cli" "$stage/package" + cp "$cli_archive" "$stage/cli/app-cli.tar" + cp "$package" "$stage/package/app.tar.gz" + cp "$application_manifest" "$stage/edgezero.toml" + adapter_metadata="$work/adapter-manifest.json" + package_adapter_manifest "$application_manifest" "$adapter_manifest" "$adapter" "$stage" "$adapter_metadata" || + fail "could not package the selected adapter manifest referenced by application-manifest" + adapter_path=$(jq -er '.path' "$adapter_metadata") + package_digest=$(sha256_file "$stage/package/app.tar.gz") + jq -n \ + --arg revision "$revision" \ + --arg adapter_name "$adapter" \ + --argjson protocol "$protocol" \ + --arg cli "$(sha256_file "$stage/cli/app-cli.tar")" \ + --arg package "$package_digest" \ + --arg edgezero "$(sha256_file "$stage/edgezero.toml")" \ + --slurpfile adapter "$adapter_metadata" \ + '{format:1,lifecycle_protocol:$protocol,source_revision:$revision,adapter:$adapter_name,app_cli:{path:"cli/app-cli.tar",sha256:$cli},package:{path:"package/app.tar.gz",sha256:$package},manifests:{edgezero:{path:"edgezero.toml",sha256:$edgezero},adapter:$adapter[0]}}' \ + >"$stage/release.json" + release_archive="$work/app-release.tar.gz" + tar -C "$stage" -czf "$release_archive" \ + release.json cli/app-cli.tar package/app.tar.gz edgezero.toml "$adapter_path" + archive_digest=$(sha256_file "$release_archive") + + verify_root="$work/verified" + EDGEZERO__APP__RELEASE__ARCHIVE="$release_archive" \ + EDGEZERO__APP__RELEASE__SHA256="$archive_digest" \ + EDGEZERO__APP__RELEASE__EXPECTED_SOURCE_REVISION="$revision" \ + EDGEZERO__APP__RELEASE__EXPECTED_ADAPTER="$adapter" \ + EDGEZERO__APP__RELEASE__EXPECTED_LIFECYCLE_PROTOCOL="$protocol" \ + EDGEZERO__APP__RELEASE__ROOT="$verify_root" \ + GITHUB_OUTPUT="$work/verify.outputs" \ + "$SCRIPT_DIR/prepare-release.sh" >/dev/null + + append_output artifact-name "$artifact_name" + append_output archive-path "$release_archive" + append_output workspace-path "$work" + append_output archive-sha256 "$archive_digest" + append_output package-sha256 "$package_digest" + append_output source-revision "$revision" + trap - EXIT +} + +main "$@" diff --git a/.github/actions/release-core/scripts/prepare-release.sh b/.github/actions/release-core/scripts/prepare-release.sh new file mode 100755 index 00000000..c4cbd35d --- /dev/null +++ b/.github/actions/release-core/scripts/prepare-release.sh @@ -0,0 +1,266 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Verifies and extracts an immutable application release before any provider +# operation. It validates the outer digest, exact archive members, member types, +# path confinement, recorded member digests, adapter/protocol identity, source +# revision, and the selected adapter-manifest reference in edgezero.toml. +# +# Reads (env): +# EDGEZERO__APP__RELEASE__ARCHIVE required release archive +# EDGEZERO__APP__RELEASE__SHA256 required expected archive digest +# EDGEZERO__APP__RELEASE__EXPECTED_SOURCE_REVISION required selected source revision +# EDGEZERO__APP__RELEASE__EXPECTED_ADAPTER required wrapper-owned adapter identity +# EDGEZERO__APP__RELEASE__EXPECTED_LIFECYCLE_PROTOCOL required wrapper-owned protocol version +# EDGEZERO__APP__RELEASE__ROOT required new extraction root +# Writes (outputs): +# release-root, app-cli-archive, application-manifest, adapter-manifest, +# package, package-digest, source-revision + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=../../deploy-core/scripts/common.sh +source "$SCRIPT_DIR/../../deploy-core/scripts/common.sh" + +validate_member_path() { + local path="$1" label="$2" part + [[ "$path" =~ ^[A-Za-z0-9._/-]+$ ]] || fail "$label has an invalid path" + case "$path" in + /* | *\\* | */ | ./* | *//* ) fail "$label has a non-normalized path" ;; + esac + IFS=/ read -r -a parts <<<"$path" + for part in "${parts[@]}"; do + [[ -n "$part" && "$part" != "." && "$part" != ".." ]] || + fail "$label has a traversing or non-normalized path" + done +} + +assert_exact_keys() { + local file="$1" filter="$2" label="$3" + jq -e "$filter" "$file" >/dev/null 2>&1 || fail "release.json has an invalid $label schema" +} + +validate_release_json_syntax() { + local file="$1" expected_protocol="$2" format_tag format_value protocol_tag protocol_value + jq -e '.' "$file" >/dev/null 2>&1 || fail "release.json is not valid JSON" + yq -p json -o json -I=0 '.' "$file" >/dev/null 2>&1 || + fail "release.json is not valid JSON" + # shellcheck disable=SC2016 # $keys is a yq variable, not a shell variable + yq -p yaml -o json -I=0 ' + [.. | select(tag == "!!map") + | (keys as $keys | ($keys | length) == ($keys | unique | length))] + | all + ' "$file" 2>/dev/null | grep -qx true || + fail "release.json contains a duplicate field" + + format_tag=$(yq -p yaml -o yaml -r '.format | tag' "$file" 2>/dev/null) || + fail "release.json has an unsupported format" + format_value=$(yq -p yaml -o yaml -r '.format | to_string' "$file" 2>/dev/null) || + fail "release.json has an unsupported format" + [[ "$format_tag" == '!!int' && "$format_value" == 1 ]] || + fail "release.json has an unsupported format" + + protocol_tag=$(yq -p yaml -o yaml -r '.lifecycle_protocol | tag' "$file" 2>/dev/null) || + fail "release.json has an invalid lifecycle_protocol" + protocol_value=$(yq -p yaml -o yaml -r '.lifecycle_protocol | to_string' "$file" 2>/dev/null) || + fail "release.json has an invalid lifecycle_protocol" + [[ "$protocol_tag" == '!!int' ]] || + fail "release.json has an invalid lifecycle_protocol" + [[ "$protocol_value" == "$expected_protocol" ]] || + fail "release.json has an unsupported lifecycle protocol" +} + +validate_manifest_reference() { + local release_json="$1" application_manifest="$2" expected_adapter="$3" parsed status=0 + parsed="$release_json.application.json" + if ! yq -p toml -o json -I=0 '.' "$application_manifest" >"$parsed" 2>/dev/null; then + rm -f "$parsed" + return 1 + fi + jq -e --arg adapter "$expected_adapter" --slurpfile application "$parsed" ' + $application[0] as $app + | ($app.adapters | type == "object") + and ([$app.adapters | keys[] | ascii_downcase] as $names + | ($names | length) == ($names | unique | length)) + and ([$app.adapters | to_entries[] + | select((.key | ascii_downcase) == $adapter)] as $selected + | ($selected | length) == 1 + and ($selected[0].value | type == "object") + and ($selected[0].value.adapter | type == "object") + and ($selected[0].value.adapter.manifest | type == "string" and length > 0) + and ($selected[0].value.adapter.manifest == .manifests.adapter.path)) + ' "$release_json" >/dev/null 2>&1 || status=$? + rm -f "$parsed" + return "$status" +} + +add_parent_dirs() { + local path="$1" prefix="" + IFS=/ read -r -a parts <<<"$path" + local i + for ((i = 0; i < ${#parts[@]} - 1; i++)); do + if [[ -z "$prefix" ]]; then prefix="${parts[$i]}"; else prefix="$prefix/${parts[$i]}"; fi + local candidate="$prefix/" existing seen=false + for existing in "${ALLOWED_DIRS[@]:-}"; do + [[ "$existing" == "$candidate" ]] && seen=true + done + [[ "$seen" == true ]] || ALLOWED_DIRS+=("$candidate") + done +} + +main() { + local archive="${EDGEZERO__APP__RELEASE__ARCHIVE:-}" + local expected_digest="${EDGEZERO__APP__RELEASE__SHA256:-}" + local expected_revision="${EDGEZERO__APP__RELEASE__EXPECTED_SOURCE_REVISION:-}" + local expected_adapter="${EDGEZERO__APP__RELEASE__EXPECTED_ADAPTER:-}" + local expected_protocol="${EDGEZERO__APP__RELEASE__EXPECTED_LIFECYCLE_PROTOCOL:-}" + local release_root="${EDGEZERO__APP__RELEASE__ROOT:-}" + require_input app-release-archive "$archive" + require_input app-release-sha256 "$expected_digest" + require_input expected-source-revision "$expected_revision" + require_input expected-adapter "$expected_adapter" + require_input expected-lifecycle-protocol "$expected_protocol" + require_input application-release-root "$release_root" + [[ "$expected_digest" =~ ^[0-9a-f]{64}$ ]] || fail "app-release-sha256 must be 64 lowercase hexadecimal characters" + [[ "$expected_revision" =~ ^([0-9a-f]{40}|[0-9a-f]{64})$ ]] || fail "expected-source-revision must be 40 or 64 lowercase hexadecimal characters" + [[ "$expected_adapter" =~ ^[a-z][a-z0-9_-]*$ ]] || fail "expected-adapter is invalid" + [[ "$expected_protocol" =~ ^[1-9][0-9]*$ ]] || fail "expected-lifecycle-protocol must be a positive integer" + [[ -f "$archive" && ! -L "$archive" ]] || fail "application release archive is missing or is not a regular file" + require_cmd jq + require_cmd tar + require_yq_v4 + + local actual_digest + actual_digest=$(sha256_file "$archive") + [[ "$actual_digest" == "$expected_digest" ]] || fail "application release archive digest mismatch" + [[ ! -e "$release_root" ]] || fail "application release root already exists" + mkdir -p "$(dirname -- "$release_root")" + + local scratch + scratch=$(mktemp -d "$(dirname -- "$release_root")/.edgezero-release.XXXXXX") + # shellcheck disable=SC2064 # expand the action-owned path while the local exists + trap "rm -rf -- '$scratch'" EXIT + + local listing + listing=$(tar -tzf "$archive") || fail "could not list application release archive" + [[ -n "$listing" ]] || fail "application release archive is empty" + if [[ -n "$(printf '%s\n' "$listing" | sort | uniq -d)" ]]; then + fail "application release archive contains duplicate members" + fi + case "$listing" in + *$'\r'* | *$'\t'*) fail "application release archive contains an invalid member name" ;; + esac + if printf '%s\n' "$listing" | grep -qE '(^/|(^|/)\.\.?(/|$)|\\|//)'; then + fail "application release archive contains an unsafe member path" + fi + + local release_json="$scratch/release.json" + tar -xOzf "$archive" release.json >"$release_json" 2>/dev/null || fail "application release archive is missing release.json" + validate_release_json_syntax "$release_json" "$expected_protocol" + + assert_exact_keys "$release_json" 'type == "object" and (keys == ["adapter","app_cli","format","lifecycle_protocol","manifests","package","source_revision"])' root + assert_exact_keys "$release_json" '.app_cli | type == "object" and (keys == ["path","sha256"])' app_cli + assert_exact_keys "$release_json" '.package | type == "object" and (keys == ["path","sha256"])' package + assert_exact_keys "$release_json" '.manifests | type == "object" and (keys == ["adapter","edgezero"])' manifests + assert_exact_keys "$release_json" '.manifests.edgezero | type == "object" and (keys == ["path","sha256"])' manifests.edgezero + assert_exact_keys "$release_json" '.manifests.adapter | type == "object" and (keys == ["path","sha256"])' manifests.adapter + jq -e --arg adapter "$expected_adapter" --argjson protocol "$expected_protocol" \ + '.format == 1 and .lifecycle_protocol == $protocol and .adapter == $adapter' \ + "$release_json" >/dev/null 2>&1 || + fail "release.json has an unsupported format, lifecycle protocol, or adapter" + + local revision + revision=$(jq -er '.source_revision | select(type == "string")' "$release_json") || fail "release.json has an invalid source_revision" + [[ "$revision" =~ ^([0-9a-f]{40}|[0-9a-f]{64})$ ]] || fail "release.json source_revision must be 40 or 64 lowercase hexadecimal characters" + [[ "$revision" == "$expected_revision" ]] || fail "release.json source_revision does not match expected-source-revision" + + local cli_path package_path edgezero_path adapter_path + local cli_digest package_digest edgezero_digest adapter_digest + cli_path=$(jq -er '.app_cli.path | select(type == "string")' "$release_json") || fail "release.json app_cli.path is invalid" + package_path=$(jq -er '.package.path | select(type == "string")' "$release_json") || fail "release.json package.path is invalid" + edgezero_path=$(jq -er '.manifests.edgezero.path | select(type == "string")' "$release_json") || fail "release.json manifests.edgezero.path is invalid" + adapter_path=$(jq -er '.manifests.adapter.path | select(type == "string")' "$release_json") || fail "release.json manifests.adapter.path is invalid" + cli_digest=$(jq -er '.app_cli.sha256 | select(type == "string")' "$release_json") || fail "release.json app_cli.sha256 is invalid" + package_digest=$(jq -er '.package.sha256 | select(type == "string")' "$release_json") || fail "release.json package.sha256 is invalid" + edgezero_digest=$(jq -er '.manifests.edgezero.sha256 | select(type == "string")' "$release_json") || fail "release.json manifests.edgezero.sha256 is invalid" + adapter_digest=$(jq -er '.manifests.adapter.sha256 | select(type == "string")' "$release_json") || fail "release.json manifests.adapter.sha256 is invalid" + + validate_member_path "$cli_path" app_cli.path + validate_member_path "$package_path" package.path + validate_member_path "$edgezero_path" manifests.edgezero.path + validate_member_path "$adapter_path" manifests.adapter.path + local digest + for digest in "$cli_digest" "$package_digest" "$edgezero_digest" "$adapter_digest"; do + [[ "$digest" =~ ^[0-9a-f]{64}$ ]] || fail "release.json contains an invalid sha256" + done + local unique_paths + unique_paths=$(printf '%s\n' "$cli_path" "$package_path" "$edgezero_path" "$adapter_path" | sort -u | wc -l | tr -d ' ') + [[ "$unique_paths" == 4 ]] || fail "release.json records duplicate member paths" + + local -a ALLOWED_FILES=(release.json "$cli_path" "$package_path" "$edgezero_path" "$adapter_path") + local -a ALLOWED_DIRS=() + add_parent_dirs "$cli_path" + add_parent_dirs "$package_path" + add_parent_dirs "$edgezero_path" + add_parent_dirs "$adapter_path" + local member verbose kind allowed + while IFS= read -r member; do + allowed="file" + local candidate + for candidate in "${ALLOWED_FILES[@]}"; do + [[ "$candidate" == "$member" ]] && allowed=regular + done + if [[ "$allowed" == regular ]]; then + verbose=$(tar -tvzf "$archive" -- "$member") || fail "could not inspect release member" + [[ "$(printf '%s\n' "$verbose" | wc -l | tr -d ' ')" == 1 ]] || fail "release member is ambiguous" + kind=${verbose:0:1} + [[ "$kind" == "-" ]] || fail "release member '$member' is not a regular file" + continue + fi + allowed=false + for candidate in "${ALLOWED_DIRS[@]:-}"; do + [[ "$candidate" == "$member" ]] && allowed=true + done + if [[ "$allowed" == true ]]; then + verbose=$(tar -tvzf "$archive" -- "$member") || fail "could not inspect release directory" + kind=${verbose:0:1} + [[ "$kind" == "d" ]] || fail "release member '$member' is not a directory" + else + fail "application release archive contains unexpected member '$member'" + fi + done <<<"$listing" + local expected_file + for expected_file in "${ALLOWED_FILES[@]}"; do + [[ $(printf '%s\n' "$listing" | grep -Fxc "$expected_file") == 1 ]] || fail "application release archive is missing '$expected_file'" + done + + rm -f "$release_json" + tar -xzf "$archive" -C "$scratch" || fail "could not extract application release archive" + local scratch_real target_real + scratch_real=$(canonical_path "$scratch") + for expected_file in "${ALLOWED_FILES[@]}"; do + [[ -f "$scratch/$expected_file" && ! -L "$scratch/$expected_file" ]] || fail "release member '$expected_file' is not a regular file" + target_real=$(canonical_path "$scratch/$expected_file") + is_under "$scratch_real" "$target_real" || fail "release member '$expected_file' escapes its root" + done + [[ "$(sha256_file "$scratch/$cli_path")" == "$cli_digest" ]] || fail "application CLI digest mismatch" + [[ "$(sha256_file "$scratch/$package_path")" == "$package_digest" ]] || fail "adapter package digest mismatch" + [[ "$(sha256_file "$scratch/$edgezero_path")" == "$edgezero_digest" ]] || fail "edgezero manifest digest mismatch" + [[ "$(sha256_file "$scratch/$adapter_path")" == "$adapter_digest" ]] || fail "adapter manifest digest mismatch" + validate_manifest_reference "$scratch/release.json" "$scratch/$edgezero_path" "$expected_adapter" || + fail "release.json adapter manifest does not match edgezero.toml" + + mv "$scratch" "$release_root" + trap - EXIT + local root_real + root_real=$(canonical_path "$release_root") + notice "verified immutable application release for adapter '$expected_adapter'" + append_output release-root "$root_real" + append_output app-cli-archive "$root_real/$cli_path" + append_output application-manifest "$root_real/$edgezero_path" + append_output adapter-manifest "$root_real/$adapter_path" + append_output package "$root_real/$package_path" + append_output package-digest "$package_digest" + append_output source-revision "$revision" +} + +main "$@" diff --git a/.github/actions/release-core/tests/run.sh b/.github/actions/release-core/tests/run.sh new file mode 100755 index 00000000..7cc8de94 --- /dev/null +++ b/.github/actions/release-core/tests/run.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +set -euo pipefail + +TEST_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +ACTION_DIR=$(cd -- "$TEST_DIR/.." && pwd) +# shellcheck source=../../deploy-core/scripts/common.sh +source "$ACTION_DIR/../deploy-core/scripts/common.sh" + +work=$(mktemp -d "${TMPDIR:-/tmp}/edgezero-release-core-test.XXXXXX") +trap 'rm -rf -- "$work"' EXIT +stage="$work/stage" +mkdir -p "$stage/cli" "$stage/package" "$stage/adapters" +printf 'cli\n' >"$stage/cli/app-cli.tar" +printf 'package\n' >"$stage/package/app.tar.gz" +cat >"$stage/edgezero.toml" <<'TOML' +[app] +name = "fixture" + +[adapters.synthetic.adapter] +manifest = "adapters/synthetic.toml" + +[adapters.unselected.adapter] +manifest = "adapters/unselected.toml" +TOML +printf 'name = "synthetic"\n' >"$stage/adapters/synthetic.toml" +revision=0123456789abcdef0123456789abcdef01234567 +jq -n \ + --arg revision "$revision" \ + --arg cli "$(sha256_file "$stage/cli/app-cli.tar")" \ + --arg package "$(sha256_file "$stage/package/app.tar.gz")" \ + --arg edgezero "$(sha256_file "$stage/edgezero.toml")" \ + --arg adapter "$(sha256_file "$stage/adapters/synthetic.toml")" \ + '{format:1,lifecycle_protocol:7,source_revision:$revision,adapter:"synthetic",app_cli:{path:"cli/app-cli.tar",sha256:$cli},package:{path:"package/app.tar.gz",sha256:$package},manifests:{edgezero:{path:"edgezero.toml",sha256:$edgezero},adapter:{path:"adapters/synthetic.toml",sha256:$adapter}}}' \ + >"$stage/release.json" +archive="$work/application-release.tar.gz" +tar -C "$stage" -czf "$archive" \ + release.json cli/app-cli.tar package/app.tar.gz edgezero.toml adapters/synthetic.toml +digest=$(sha256_file "$archive") + +verify() { + local root="$1" adapter="$2" protocol="$3" output="$4" + EDGEZERO__APP__RELEASE__ARCHIVE="$archive" \ + EDGEZERO__APP__RELEASE__SHA256="$digest" \ + EDGEZERO__APP__RELEASE__EXPECTED_SOURCE_REVISION="$revision" \ + EDGEZERO__APP__RELEASE__EXPECTED_ADAPTER="$adapter" \ + EDGEZERO__APP__RELEASE__EXPECTED_LIFECYCLE_PROTOCOL="$protocol" \ + EDGEZERO__APP__RELEASE__ROOT="$root" \ + GITHUB_OUTPUT="$output" \ + "$ACTION_DIR/scripts/prepare-release.sh" +} + +output="$work/output" +verify "$work/release" synthetic 7 "$output" >/dev/null +root_real=$(canonical_path "$work/release") +grep -qx "release-root=$root_real" "$output" +grep -qx "app-cli-archive=$root_real/cli/app-cli.tar" "$output" +grep -qx "application-manifest=$root_real/edgezero.toml" "$output" +grep -qx "adapter-manifest=$root_real/adapters/synthetic.toml" "$output" +grep -qx "package=$root_real/package/app.tar.gz" "$output" +grep -qx "source-revision=$revision" "$output" + +if verify "$work/wrong-adapter" other 7 "$work/wrong-adapter.out" >/dev/null 2>&1; then + fail "release verifier accepted an adapter mismatch" +fi +if verify "$work/wrong-protocol" synthetic 8 "$work/wrong-protocol.out" >/dev/null 2>&1; then + fail "release verifier accepted a lifecycle protocol mismatch" +fi + +if [[ "$(uname -s)" == Linux && "$(uname -m)" == x86_64 ]]; then + workspace="$work/package-workspace" + policy_root="$work/policy" + mkdir -p "$workspace/cli" "$workspace/adapters" "$policy_root" "$work/runner" + cat >"$workspace/cli/app-cli" <<'CLI' +#!/usr/bin/env bash +case "$*" in + --help) printf 'synthetic application CLI\n' ;; + 'publish --help') printf '%s\n' '--adapter --release' ;; + *) exit 2 ;; +esac +CLI + chmod +x "$workspace/cli/app-cli" + printf '%s\n' '{"app-cli-bin":"app-cli","app-cli-version":"1.0.0","app-cli-package":"fixture"}' \ + >"$workspace/cli/app-cli-meta.json" + tar -C "$workspace/cli" -cf "$workspace/app-cli.tar" app-cli app-cli-meta.json + printf 'synthetic package\n' >"$workspace/package.tar.gz" + cat >"$workspace/edgezero.toml" <<'TOML' +[app] +name = "fixture" + +[adapters.synthetic.adapter] +manifest = "adapters/synthetic.toml" + +[adapters.unselected.adapter] +manifest = "adapters/unselected.toml" +TOML + printf 'name = "synthetic"\n' >"$workspace/adapters/synthetic.toml" + printf 'name = "unselected"\n' >"$workspace/adapters/unselected.toml" + cat >"$policy_root/lifecycle-protocol.json" <<'JSON' +{ + "lifecycle_protocol": 7, + "probes": [ + {"command": ["publish"], "required_flags": ["--adapter", "--release"]} + ] +} +JSON + package_output="$work/package-output" + GITHUB_WORKSPACE="$workspace" GITHUB_OUTPUT="$package_output" RUNNER_TEMP="$work/runner" \ + EDGEZERO__RELEASE__ADAPTER=synthetic \ + EDGEZERO__RELEASE__LIFECYCLE_CAPABILITIES="$policy_root/lifecycle-protocol.json" \ + EDGEZERO__RELEASE__POLICY_ROOT="$policy_root" \ + EDGEZERO__RELEASE__APP_CLI_ARCHIVE=app-cli.tar \ + EDGEZERO__RELEASE__PACKAGE=package.tar.gz \ + EDGEZERO__RELEASE__APPLICATION_MANIFEST=edgezero.toml \ + EDGEZERO__RELEASE__ADAPTER_MANIFEST=adapters/synthetic.toml \ + EDGEZERO__RELEASE__SOURCE_REVISION="$revision" \ + EDGEZERO__RELEASE__ARTIFACT_NAME=synthetic-release \ + "$ACTION_DIR/scripts/package-release.sh" >/dev/null + packaged_archive=$(sed -n 's/^archive-path=//p' "$package_output") + tar -xOzf "$packaged_archive" release.json | jq -e \ + '.adapter == "synthetic" and .lifecycle_protocol == 7 + and .manifests.adapter.path == "adapters/synthetic.toml"' >/dev/null + tar -tzf "$packaged_archive" | grep -qx 'adapters/synthetic.toml' + if tar -tzf "$packaged_archive" | grep -qx 'adapters/unselected.toml'; then + fail "provider-neutral packager included an unselected adapter manifest" + fi +fi + +printf 'provider-neutral release contract tests passed\n' diff --git a/.github/actions/require-github-environment/action.yml b/.github/actions/require-github-environment/action.yml new file mode 100644 index 00000000..ea5f5908 --- /dev/null +++ b/.github/actions/require-github-environment/action.yml @@ -0,0 +1,57 @@ +name: EdgeZero require GitHub Environment +description: Fail closed unless an existing GitHub Environment exactly matches the requested name. + +inputs: + environment-name: + description: Exact GitHub Environment name to verify. + required: true + repository: + description: Repository in owner/name form. + required: true + github-token: + description: GitHub token with Actions read access to the repository. + required: true +outputs: + environment-name: + description: The exact verified GitHub Environment name. + value: ${{ steps.verify.outputs.environment-name }} + +runs: + using: composite + steps: + - name: Verify GitHub Environment + id: verify + shell: bash + env: + EDGEZERO__GITHUB__ENVIRONMENT: ${{ inputs.environment-name }} + EDGEZERO__GITHUB__REPOSITORY: ${{ inputs.repository }} + EDGEZERO__GITHUB__TOKEN: ${{ inputs.github-token }} + EDGEZERO__GITHUB__API_URL: ${{ github.api_url }} + BASH_ENV: "" + ENV: "" + FASTLY_API_TOKEN: "" + FASTLY_SERVICE_ID: "" + FASTLY_TOKEN: "" + FASTLY_KEY: "" + FASTLY_API_KEY: "" + FASTLY_AUTH_TOKEN: "" + FASTLY_API_ENDPOINT: "" + FASTLY_ENDPOINT: "" + FASTLY_API_URL: "" + FASTLY_PROFILE: "" + FASTLY_SERVICE_NAME: "" + FASTLY_DEBUG: "" + FASTLY_DEBUG_MODE: "" + FASTLY_CONFIG_FILE: "" + FASTLY_CARGO_PROFILE: "" + FASTLY_HOME: "" + CLOUDFLARE_API_TOKEN: "" + CLOUDFLARE_API_KEY: "" + CLOUDFLARE_ACCOUNT_ID: "" + CLOUDFLARE_EMAIL: "" + CF_API_TOKEN: "" + CF_API_KEY: "" + CF_ACCOUNT_ID: "" + SPIN_AUTH_TOKEN: "" + FERMYON_TOKEN: "" + run: exec "$GITHUB_ACTION_PATH/scripts/require-environment.sh" diff --git a/.github/actions/require-github-environment/scripts/require-environment.sh b/.github/actions/require-github-environment/scripts/require-environment.sh new file mode 100755 index 00000000..b3111cd6 --- /dev/null +++ b/.github/actions/require-github-environment/scripts/require-environment.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=../../deploy-core/scripts/common.sh +source "$SCRIPT_DIR/../../deploy-core/scripts/common.sh" + +environment_name="${EDGEZERO__GITHUB__ENVIRONMENT:-}" +repository="${EDGEZERO__GITHUB__REPOSITORY:-}" +token="${EDGEZERO__GITHUB__TOKEN:-}" +api_url="${EDGEZERO__GITHUB__API_URL:-https://api.github.com}" + +[[ -n "$environment_name" && "$environment_name" != *[$'\r\n\0']* ]] || + fail "environment-name must be non-empty and contain no control characters" +[[ "$repository" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] || + fail "repository must use owner/name form" +[[ -n "$token" && "$token" != *[$'\r\n']* ]] || + fail "github-token must be non-empty and contain no line breaks" +[[ "$api_url" =~ ^https://[^/]+(/[^[:space:]]*)?$ ]] || + fail "api-url must be an https URL" + +for command in curl jq; do + command -v "$command" >/dev/null 2>&1 || fail "required command '$command' was not found" +done + +encoded_name=$(jq -rn --arg name "$environment_name" '$name | @uri') || + fail "could not encode environment-name" + +response_file=$(mktemp "${RUNNER_TEMP:-/tmp}/edgezero-github-environment.XXXXXX") +trap 'rm -f -- "$response_file"' EXIT + +status=0 +http_code=$(printf 'Authorization: Bearer %s\n' "$token" | curl --silent --show-error \ + --output "$response_file" \ + --write-out '%{http_code}' \ + --header 'Accept: application/vnd.github+json' \ + --header @- \ + --header 'X-GitHub-Api-Version: 2022-11-28' \ + "$api_url/repos/$repository/environments/$encoded_name") || status=$? +[[ "$status" -eq 0 ]] || fail "GitHub Environment lookup failed before receiving a response" + +case "$http_code" in + 200) + jq -e --arg expected "$environment_name" \ + 'type == "object" and .name == $expected' "$response_file" >/dev/null 2>&1 || + fail "GitHub Environment lookup returned a malformed or mismatched response" + ;; + 404) fail "GitHub Environment '$environment_name' does not exist in '$repository'" ;; + *) fail "GitHub Environment lookup failed with HTTP $http_code" ;; +esac + +[[ -n "${GITHUB_OUTPUT:-}" ]] || fail "GITHUB_OUTPUT is required" +append_output environment-name "$environment_name" diff --git a/.github/actions/require-github-environment/tests/run.sh b/.github/actions/require-github-environment/tests/run.sh new file mode 100755 index 00000000..7b559539 --- /dev/null +++ b/.github/actions/require-github-environment/tests/run.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +set -euo pipefail + +ACTION_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +WORK_DIR=$(mktemp -d "${TMPDIR:-/tmp}/edgezero-environment-test.XXXXXX") +trap 'rm -rf -- "$WORK_DIR"' EXIT + +mkdir -p "$WORK_DIR/bin" +cat >"$WORK_DIR/bin/curl" <<'CURL' +#!/usr/bin/env bash +set -euo pipefail +output="" +url="" +while (($#)); do + case "$1" in + --output) output="$2"; shift 2 ;; + --write-out | --header) shift 2 ;; + --silent | --show-error) shift ;; + *) url="$1"; shift ;; + esac +done +cat >/dev/null +printf '%s' "$url" >"${FAKE_URL_OUT:?}" +case "${FAKE_RESPONSE:?}" in + success) printf '{"name":"staging.app/example.com"}\n' >"$output"; printf 200 ;; + missing) printf '{"message":"Not Found"}\n' >"$output"; printf 404 ;; + forbidden) printf '{"message":"Forbidden"}\n' >"$output"; printf 403 ;; + malformed) printf '[]\n' >"$output"; printf 200 ;; + mismatch) printf '{"name":"production"}\n' >"$output"; printf 200 ;; + network) exit 7 ;; +esac +CURL +chmod +x "$WORK_DIR/bin/curl" + +run_case() { + local response="$1" + : >"$WORK_DIR/output" + PATH="$WORK_DIR/bin:$PATH" \ + FAKE_RESPONSE="$response" \ + FAKE_URL_OUT="$WORK_DIR/url" \ + GITHUB_OUTPUT="$WORK_DIR/output" \ + RUNNER_TEMP="$WORK_DIR" \ + EDGEZERO__GITHUB__ENVIRONMENT='staging.app/example.com' \ + EDGEZERO__GITHUB__REPOSITORY='example/application' \ + EDGEZERO__GITHUB__TOKEN='test-token' \ + EDGEZERO__GITHUB__API_URL='https://api.github.test' \ + "$ACTION_DIR/scripts/require-environment.sh" >/dev/null 2>&1 +} + +run_case success +grep -qx 'environment-name=staging.app/example.com' "$WORK_DIR/output" +grep -Fqx 'https://api.github.test/repos/example/application/environments/staging.app%2Fexample.com' "$WORK_DIR/url" + +for response in missing forbidden malformed mismatch network; do + if run_case "$response"; then + printf 'expected %s response to fail\n' "$response" >&2 + exit 1 + fi +done + +if PATH="$WORK_DIR/bin:$PATH" \ + FAKE_RESPONSE=success \ + FAKE_URL_OUT="$WORK_DIR/url" \ + GITHUB_OUTPUT="$WORK_DIR/output" \ + RUNNER_TEMP="$WORK_DIR" \ + EDGEZERO__GITHUB__ENVIRONMENT=production \ + EDGEZERO__GITHUB__REPOSITORY=example/application \ + EDGEZERO__GITHUB__TOKEN=$'bad\nInjected: header' \ + EDGEZERO__GITHUB__API_URL=https://api.github.test \ + "$ACTION_DIR/scripts/require-environment.sh" >/dev/null 2>&1; then + printf 'expected a line-breaking token to fail before curl\n' >&2 + exit 1 +fi + +printf 'GitHub Environment preflight tests passed\n' diff --git a/.github/actions/rollback-fastly/action.yml b/.github/actions/rollback-fastly/action.yml index 80389ccf..95b31b4a 100644 --- a/.github/actions/rollback-fastly/action.yml +++ b/.github/actions/rollback-fastly/action.yml @@ -1,52 +1,48 @@ name: EdgeZero rollback-fastly -description: Roll back a Fastly deployment via the app CLI. Production activates the previous version; staging deactivates the staged version. +description: Roll back a Fastly deployment with the application CLI from a verified immutable release. inputs: - app-cli-artifact: - description: Name of the build-app-cli artifact to download and run. + app-release-archive: + description: Path to the pinned application release archive. + required: true + app-release-sha256: + description: Expected lowercase SHA-256 digest of the application release archive. + required: true + expected-source-revision: + description: Full source revision that release.json must record. required: true - app-cli-bin: - description: Binary name inside the artifact. Defaults to the artifact metadata. - required: false - default: "" fastly-api-token: - description: Fastly API token. + description: Fastly API token, scoped only to rollback. required: true fastly-service-id: - description: Fastly service ID. + description: Alphanumeric Fastly service ID. required: true fastly-version: - description: The current (bad) Fastly version to roll back from. + description: Current Fastly version to roll back. required: true rollback-to: - description: "Production only: the version to re-activate. Fastly cannot infer it, so wire it from deploy-fastly's previous-version output. Required when deploy-to is production; ignored for staging." + description: Production version to reactivate; unused for staging rollback. required: false default: "" deploy-to: - description: Deployment target, 'production' or 'staging'. + description: Deployment target, production or staging. required: false default: production outputs: - rolled-back-to: - description: The Fastly version that was activated (production only). - value: ${{ steps.rollback.outputs['rolled-back-to'] }} mutation-attempted: - description: "'true', emitted immediately BEFORE the rollback CLI runs (so a cancel/timeout mid-mutation can preserve it; a hard runner loss can still drop it, so absence is not proof the active version is unchanged, and a cancel in the tiny pre-run window is a conservative false positive). On failure, read this via `if: always()` and reconcile — do not assume the rollback was a no-op." + description: "'true' when the rollback CLI was invoked." value: ${{ steps.rollback.outputs['mutation-attempted'] }} + rolled-back-to: + description: Production version activated by rollback. + value: ${{ steps.rollback.outputs['rolled-back-to'] }} runs: using: composite steps: - # A UNIQUE per-invocation workspace root under RUNNER_TEMP, so two concurrent - # invocations in one job (e.g. `background: true`) never collide on fixed temp - # paths (CLI download, extracted tools). The cleanup step removes it. - name: Prepare action workspace id: ws shell: bash - # Runs before validation, so it scrubs like every other step: blank the - # shipped aliases and BASH_ENV/ENV (a caller's job env could otherwise point - # BASH_ENV at checkout code that runs at bash startup with a token in scope). env: BASH_ENV: "" ENV: "" @@ -68,17 +64,14 @@ runs: FASTLY_HOME: "" run: exec "$GITHUB_ACTION_PATH/../deploy-core/scripts/prepare-workspace.sh" - # GitHub does not enforce `required: true` on composite inputs, and an empty - # artifact name makes actions/download-artifact fetch EVERY artifact in the - # run — so the CLI we execute would be arbitrary. Check before downloading. - name: Validate inputs shell: bash env: BASH_ENV: "" ENV: "" - EDGEZERO__APP__CLI__ARTIFACT_PRESENT: ${{ inputs['app-cli-artifact'] != '' && 'true' || 'false' }} - # Non-provider step: blank inherited provider aliases (only the rollback - # step below receives the token). + EDGEZERO__APP__RELEASE__ARCHIVE_PRESENT: ${{ inputs['app-release-archive'] != '' && 'true' || 'false' }} + EDGEZERO__APP__RELEASE__SHA256_PRESENT: ${{ inputs['app-release-sha256'] != '' && 'true' || 'false' }} + EDGEZERO__FASTLY__SERVICE_ID: ${{ inputs['fastly-service-id'] }} FASTLY_API_TOKEN: "" FASTLY_SERVICE_ID: "" FASTLY_TOKEN: "" @@ -97,12 +90,18 @@ runs: FASTLY_HOME: "" run: exec "$GITHUB_ACTION_PATH/scripts/validate.sh" - - name: Download CLI artifact - uses: actions/download-artifact@v8 - with: - name: ${{ inputs['app-cli-artifact'] }} - path: ${{ steps.ws.outputs.root }}/cli-download + - name: Verify application release + id: release + shell: bash env: + BASH_ENV: "" + ENV: "" + EDGEZERO__APP__RELEASE__ARCHIVE: ${{ inputs['app-release-archive'] }} + EDGEZERO__APP__RELEASE__SHA256: ${{ inputs['app-release-sha256'] }} + EDGEZERO__APP__RELEASE__EXPECTED_SOURCE_REVISION: ${{ inputs['expected-source-revision'] }} + EDGEZERO__APP__RELEASE__EXPECTED_ADAPTER: fastly + EDGEZERO__APP__RELEASE__EXPECTED_LIFECYCLE_PROTOCOL: "1" + EDGEZERO__APP__RELEASE__ROOT: ${{ steps.ws.outputs.root }}/release FASTLY_API_TOKEN: "" FASTLY_SERVICE_ID: "" FASTLY_TOKEN: "" @@ -119,26 +118,26 @@ runs: FASTLY_CONFIG_FILE: "" FASTLY_CARGO_PROFILE: "" FASTLY_HOME: "" + run: exec "$GITHUB_ACTION_PATH/../release-core/scripts/prepare-release.sh" - - name: Extract CLI + - name: Extract application CLI id: cli shell: bash env: BASH_ENV: "" ENV: "" - EDGEZERO__APP__CLI__ARTIFACT_DIR: ${{ steps.ws.outputs.root }}/cli-download + EDGEZERO__APP__CLI__ARCHIVE: ${{ steps.release.outputs['app-cli-archive'] }} EDGEZERO__ACTION__TOOL_ROOT: ${{ steps.ws.outputs.root }}/tools - EDGEZERO__APP__CLI__BIN: ${{ inputs['app-cli-bin'] }} FASTLY_API_TOKEN: "" + FASTLY_SERVICE_ID: "" FASTLY_TOKEN: "" FASTLY_KEY: "" FASTLY_API_KEY: "" FASTLY_AUTH_TOKEN: "" - FASTLY_ENDPOINT: "" FASTLY_API_ENDPOINT: "" + FASTLY_ENDPOINT: "" FASTLY_API_URL: "" FASTLY_PROFILE: "" - FASTLY_SERVICE_ID: "" FASTLY_SERVICE_NAME: "" FASTLY_DEBUG: "" FASTLY_DEBUG_MODE: "" @@ -151,30 +150,25 @@ runs: id: rollback shell: bash env: - # BASH_ENV/ENV are sourced at bash startup, before this script can scrub — - # blank them so a caller's job env cannot run code here with the token. BASH_ENV: "" ENV: "" - # Mint the sensitive lifecycle log under the per-invocation workspace so the - # Cleanup step removes it wholesale even if the in-process EXIT trap cannot fire. EDGEZERO__ACTION__WORKSPACE: ${{ steps.ws.outputs.root }} - EDGEZERO__APP__CLI__BIN: ${{ steps.cli.outputs['app-cli-bin'] }} EDGEZERO__APP__CLI__PATH: ${{ steps.cli.outputs['app-cli-path'] }} EDGEZERO__LIFECYCLE__SERVICE_ID: ${{ inputs['fastly-service-id'] }} EDGEZERO__LIFECYCLE__VERSION: ${{ inputs['fastly-version'] }} EDGEZERO__LIFECYCLE__ROLLBACK_TO: ${{ inputs['rollback-to'] }} EDGEZERO__DEPLOY__TO: ${{ inputs['deploy-to'] }} - # Only the typed token reaches the CLI; blank any inherited alias. - FASTLY_API_TOKEN: ${{ inputs['fastly-api-token'] }} + EDGEZERO__FASTLY__API_TOKEN: ${{ inputs['fastly-api-token'] }} + FASTLY_API_TOKEN: "" + FASTLY_SERVICE_ID: "" FASTLY_TOKEN: "" FASTLY_KEY: "" FASTLY_API_KEY: "" FASTLY_AUTH_TOKEN: "" - FASTLY_ENDPOINT: "" FASTLY_API_ENDPOINT: "" + FASTLY_ENDPOINT: "" FASTLY_API_URL: "" FASTLY_PROFILE: "" - FASTLY_SERVICE_ID: "" FASTLY_SERVICE_NAME: "" FASTLY_DEBUG: "" FASTLY_DEBUG_MODE: "" diff --git a/.github/actions/rollback-fastly/scripts/rollback.sh b/.github/actions/rollback-fastly/scripts/rollback.sh index bebf3c93..7be43d2f 100755 --- a/.github/actions/rollback-fastly/scripts/rollback.sh +++ b/.github/actions/rollback-fastly/scripts/rollback.sh @@ -13,21 +13,21 @@ set -euo pipefail # EDGEZERO__LIFECYCLE__SERVICE_ID required Fastly service id # EDGEZERO__LIFECYCLE__VERSION required the current (bad) version to roll back from # EDGEZERO__LIFECYCLE__ROLLBACK_TO required (production) the version to re-activate -# FASTLY_API_TOKEN required provider token (Fastly's own convention) +# EDGEZERO__FASTLY__API_TOKEN required action-private Fastly API token # EDGEZERO__DEPLOY__TO optional production | staging (default: production) # Writes (outputs): # mutation-attempted true, emitted before the CLI runs (reconcile signal) # rolled-back-to the activated version (production only) SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) -# shellcheck source=../../deploy-core/scripts/common.sh -source "$SCRIPT_DIR/../../deploy-core/scripts/common.sh" +# shellcheck source=../../fastly-common/scripts/common.sh +source "$SCRIPT_DIR/../../fastly-common/scripts/common.sh" validate_inputs() { require_linux_x86_64 - require_input_matching fastly-service-id "${EDGEZERO__LIFECYCLE__SERVICE_ID:-}" '^[A-Za-z0-9]+$' + require_fastly_service_id "${EDGEZERO__LIFECYCLE__SERVICE_ID:-}" require_input_matching fastly-version "${EDGEZERO__LIFECYCLE__VERSION:-}" '^[0-9]+$' - require_input fastly-api-token "${FASTLY_API_TOKEN:-}" + require_input fastly-api-token "${EDGEZERO__FASTLY__API_TOKEN:-}" # A typo in deploy-to must never silently roll back production. case "${EDGEZERO__DEPLOY__TO:-}" in production) @@ -50,22 +50,32 @@ main() { local cli_bin cli_bin=$(resolve_app_cli) require_cmd "$cli_bin" - local argv=("$cli_bin" rollback --adapter fastly --service-id "$EDGEZERO__LIFECYCLE__SERVICE_ID" --version "$EDGEZERO__LIFECYCLE__VERSION") + cli_bin=$(command -v "$cli_bin") || fail "application CLI is unavailable" + export EDGEZERO__APP__CLI__PATH="$cli_bin" + local argv=(rollback --adapter fastly --service-id "$EDGEZERO__LIFECYCLE__SERVICE_ID" --version "$EDGEZERO__LIFECYCLE__VERSION") if [[ "$EDGEZERO__DEPLOY__TO" == "staging" ]]; then argv+=(--staging) else argv+=(--rollback-to "$EDGEZERO__LIFECYCLE__ROLLBACK_TO") fi + require_cmd jq + local workspace="${EDGEZERO__ACTION__WORKSPACE:-$(dirname -- "$cli_bin")}" + mkdir -p "$workspace" + export EDGEZERO__ACTION__WORKSPACE="$workspace" + local args_file="$workspace/rollback-argv.nul" + local clear_file="$workspace/fastly-provider-clear.nul" + printf '%s\0' "${argv[@]}" >"$args_file" + write_fastly_provider_clear_file "$clear_file" + EDGEZERO__PROVIDER__ENV=$(jq -n --arg token "${EDGEZERO__FASTLY__API_TOKEN:-}" '{FASTLY_API_TOKEN:$token}') + export EDGEZERO__PROVIDER__ENV + export EDGEZERO__PROVIDER__ENV_CLEAR_FILE="$clear_file" + export EDGEZERO__APP__CLI__ARGS_FILE="$args_file" + export EDGEZERO__APP__CLI__MUTATES=true + new_private_log - # Record that a provider mutation is being ATTEMPTED after setup (CLI verified) - # and immediately before the CLI runs: a setup failure never falsely signals, and - # because it lands in GITHUB_OUTPUT before the mutation starts it CAN survive a - # cancel/timeout mid-activation (best-effort — a hard runner loss can still drop - # it, so its absence is not proof of no mutation; read via `if: always()`). - append_output mutation-attempted true local rc=0 - "${argv[@]}" 2>&1 | tee "$LIFECYCLE_LOG" || rc=$? + "$SCRIPT_DIR/../../deploy-core/scripts/run-app-cli.sh" 2>&1 | tee "$LIFECYCLE_LOG" || rc=$? # Surface the CLI's exit status BEFORE writing any output, so an output-write # failure can never replace the real provider result. diff --git a/.github/actions/rollback-fastly/scripts/validate.sh b/.github/actions/rollback-fastly/scripts/validate.sh index 10657231..dd7ddd33 100755 --- a/.github/actions/rollback-fastly/scripts/validate.sh +++ b/.github/actions/rollback-fastly/scripts/validate.sh @@ -1,16 +1,18 @@ #!/usr/bin/env bash set -euo pipefail -# Validates the rollback-fastly wrapper's inputs before downloading the artifact. In a script +# Validates the rollback-fastly wrapper's inputs before verifying the release. In a script # (not inline action.yml run: ) so it is linted and contract-tested. # # Reads (env): -# EDGEZERO__APP__CLI__ARTIFACT_PRESENT required "true" when app-cli-artifact is non-empty +# EDGEZERO__APP__RELEASE__ARCHIVE_PRESENT required release archive presence flag +# EDGEZERO__APP__RELEASE__SHA256_PRESENT required release digest presence flag SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) -# shellcheck source=../../deploy-core/scripts/common.sh -source "$SCRIPT_DIR/../../deploy-core/scripts/common.sh" +# shellcheck source=../../fastly-common/scripts/common.sh +source "$SCRIPT_DIR/../../fastly-common/scripts/common.sh" -# An empty artifact name makes actions/download-artifact fetch EVERY artifact in -# the run, so the CLI we then execute would be arbitrary. -require_present app-cli-artifact "${EDGEZERO__APP__CLI__ARTIFACT_PRESENT:-}" +# The release must be pinned before its CLI can be extracted. +require_present app-release-archive "${EDGEZERO__APP__RELEASE__ARCHIVE_PRESENT:-}" +require_present app-release-sha256 "${EDGEZERO__APP__RELEASE__SHA256_PRESENT:-}" +require_fastly_service_id "${EDGEZERO__FASTLY__SERVICE_ID:-}" diff --git a/.github/actions/setup-rust-build-cache/action.yml b/.github/actions/setup-rust-build-cache/action.yml new file mode 100644 index 00000000..c30af25b --- /dev/null +++ b/.github/actions/setup-rust-build-cache/action.yml @@ -0,0 +1,55 @@ +name: EdgeZero setup Rust build cache +description: Cache application-scoped Cargo sources, target artifacts, and Rust compiler outputs for later build steps. + +inputs: + app-name: + description: Stable lowercase app name used to isolate its caches from other apps. + required: true + working-directory: + description: Rust workspace directory, relative to github.workspace, containing the Cargo.lock used for the cache key. + required: false + default: . + cache-target: + description: Restore and save the application's Cargo target directory in addition to compiler outputs. + required: false + default: "false" + +runs: + using: composite + steps: + - name: Validate Rust workspace and enable compiler caching + id: prepare + shell: bash + env: + EDGEZERO__RUST_CACHE__APP_NAME: ${{ inputs['app-name'] }} + EDGEZERO__RUST_CACHE__CACHE_TARGET: ${{ inputs['cache-target'] }} + EDGEZERO__RUST_CACHE__WORKING_DIRECTORY: ${{ inputs['working-directory'] }} + BASH_ENV: "" + ENV: "" + run: exec "$GITHUB_ACTION_PATH/scripts/prepare.sh" + + - name: Restore Cargo dependency sources + id: cargo-sources + uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + key: edgezero-app-${{ steps.prepare.outputs['cache-namespace'] }}-rust-sources-${{ runner.os }}-${{ runner.arch }}-${{ steps.prepare.outputs['cargo-lock-sha256'] }} + restore-keys: | + edgezero-app-${{ steps.prepare.outputs['cache-namespace'] }}-rust-sources-${{ runner.os }}-${{ runner.arch }}- + + - name: Restore Cargo target artifacts + if: inputs['cache-target'] == 'true' + uses: actions/cache@v5 + with: + path: ${{ steps.prepare.outputs['cargo-target-dir'] }} + key: edgezero-app-${{ steps.prepare.outputs['cache-namespace'] }}-rust-target-${{ runner.os }}-${{ runner.arch }}-${{ steps.prepare.outputs['cargo-lock-sha256'] }}-${{ github.sha }} + restore-keys: | + edgezero-app-${{ steps.prepare.outputs['cache-namespace'] }}-rust-target-${{ runner.os }}-${{ runner.arch }}-${{ steps.prepare.outputs['cargo-lock-sha256'] }}- + + - name: Set up Rust compiler cache + uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 + with: + version: v0.16.0 diff --git a/.github/actions/setup-rust-build-cache/scripts/prepare.sh b/.github/actions/setup-rust-build-cache/scripts/prepare.sh new file mode 100755 index 00000000..8554bcc6 --- /dev/null +++ b/.github/actions/setup-rust-build-cache/scripts/prepare.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Validates the application and Rust workspace selected by +# setup-rust-build-cache, binds its cache keys to the application and Cargo.lock +# bytes, and enables sccache for every later Rust build in the current GitHub +# Actions job. When target caching is enabled, it also publishes one confined, +# application-scoped CARGO_TARGET_DIR beneath RUNNER_TEMP. +# +# This script reads no application manifest and runs no application code. The +# action therefore remains independent of the package, adapter, and deployment +# that consume the compiled output. + +fail() { + printf '::error::%s\n' "$*" >&2 + exit 1 +} + +canonical_path() { + local path="$1" + realpath "$path" 2>/dev/null || fail "could not resolve path '$path'" +} + +is_under() { + local root="${1%/}" + local path="${2%/}" + [[ "$path" == "$root" || "$path" == "$root"/* ]] +} + +sha256_file() { + local file="$1" + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$file" | awk '{ print $1 }' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$file" | awk '{ print $1 }' + else + fail "required command 'sha256sum' or 'shasum' was not found" + fi +} + +main() { + local workspace="${GITHUB_WORKSPACE:?GITHUB_WORKSPACE is required}" + local output_file="${GITHUB_OUTPUT:?GITHUB_OUTPUT is required}" + local env_file="${GITHUB_ENV:?GITHUB_ENV is required}" + local app_name="${EDGEZERO__RUST_CACHE__APP_NAME-}" + local cache_target="${EDGEZERO__RUST_CACHE__CACHE_TARGET-false}" + local working_directory="${EDGEZERO__RUST_CACHE__WORKING_DIRECTORY-}" + + [[ "$app_name" =~ ^[a-z0-9][a-z0-9._-]{0,63}$ ]] || + fail "input 'app-name' must be a lowercase app identifier using only letters, digits, '.', '_', or '-'" + case "$cache_target" in + true | false) ;; + *) fail "input 'cache-target' must be 'true' or 'false'" ;; + esac + [[ -n "$working_directory" ]] || + fail "input 'working-directory' cannot be empty" + [[ "$working_directory" != /* ]] || + fail "input 'working-directory' must be relative to github.workspace" + command -v realpath >/dev/null 2>&1 || + fail "required command 'realpath' was not found" + + local workspace_real project_real lockfile + workspace_real=$(canonical_path "$workspace") + [[ -d "$workspace/$working_directory" ]] || + fail "working-directory '$working_directory' does not exist or is not a directory" + project_real=$(canonical_path "$workspace/$working_directory") + is_under "$workspace_real" "$project_real" || + fail "input 'working-directory' must resolve inside github.workspace" + + lockfile="$project_real/Cargo.lock" + [[ -f "$lockfile" && ! -L "$lockfile" ]] || + fail "working-directory '$working_directory' must contain a regular Cargo.lock file" + + local lock_sha + lock_sha=$(sha256_file "$lockfile") + [[ "$lock_sha" =~ ^[0-9a-f]{64}$ ]] || + fail "could not compute a lowercase SHA-256 for Cargo.lock" + + { + printf 'cache-namespace=%s\n' "$app_name" + printf 'cargo-lock-sha256=%s\n' "$lock_sha" + } >>"$output_file" + { + printf 'RUSTC_WRAPPER=sccache\n' + printf 'SCCACHE_GHA_ENABLED=true\n' + printf 'CARGO_INCREMENTAL=0\n' + } >>"$env_file" + + if [[ "$cache_target" == "true" ]]; then + local runner_temp="${RUNNER_TEMP:?RUNNER_TEMP is required when cache-target is true}" + [[ -d "$runner_temp" ]] || fail "RUNNER_TEMP does not exist or is not a directory" + + local runner_temp_real target_dir + runner_temp_real=$(canonical_path "$runner_temp") + target_dir="$runner_temp_real/edgezero-rust-cache/$app_name/target" + mkdir -p "$target_dir" + target_dir=$(canonical_path "$target_dir") + is_under "$runner_temp_real" "$target_dir" || + fail "Cargo target cache directory must resolve inside RUNNER_TEMP" + + printf 'cargo-target-dir=%s\n' "$target_dir" >>"$output_file" + { + printf 'CARGO_TARGET_DIR=%s\n' "$target_dir" + printf 'EDGEZERO__RUST_CACHE__TARGET_DIR=%s\n' "$target_dir" + } >>"$env_file" + fi +} + +main "$@" diff --git a/.github/actions/setup-rust-build-cache/tests/run.sh b/.github/actions/setup-rust-build-cache/tests/run.sh new file mode 100755 index 00000000..91110123 --- /dev/null +++ b/.github/actions/setup-rust-build-cache/tests/run.sh @@ -0,0 +1,150 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Contract tests for the provider-neutral Rust build-cache setup action. +# +# These tests exercise only local files and action metadata. They require no +# network, credentials, or provider CLI. + +ACTION_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +WORK_DIR=$(mktemp -d "${TMPDIR:-/tmp}/edgezero-rust-cache-test.XXXXXX") +trap 'rm -rf -- "$WORK_DIR"' EXIT + +fail() { + printf 'setup-rust-build-cache test failed: %s\n' "$*" >&2 + exit 1 +} + +assert_line() { + local file="$1" expected="$2" + grep -Fqx -- "$expected" "$file" || + fail "expected '$expected' in $file" +} + +workspace="$WORK_DIR/workspace" +runner_temp="$WORK_DIR/runner" +mkdir -p "$workspace/project" "$runner_temp" +printf '%s\n' '# generic lockfile fixture' >"$workspace/project/Cargo.lock" +: >"$WORK_DIR/output" +: >"$WORK_DIR/env" + +GITHUB_WORKSPACE="$workspace" \ + RUNNER_TEMP="$runner_temp" \ + GITHUB_OUTPUT="$WORK_DIR/output" \ + GITHUB_ENV="$WORK_DIR/env" \ + EDGEZERO__RUST_CACHE__APP_NAME=example-app \ + EDGEZERO__RUST_CACHE__CACHE_TARGET=true \ + EDGEZERO__RUST_CACHE__WORKING_DIRECTORY=project \ + "$ACTION_DIR/scripts/prepare.sh" + +if command -v sha256sum >/dev/null 2>&1; then + expected_sha=$(sha256sum "$workspace/project/Cargo.lock" | awk '{ print $1 }') +else + expected_sha=$(shasum -a 256 "$workspace/project/Cargo.lock" | awk '{ print $1 }') +fi +assert_line "$WORK_DIR/output" "cargo-lock-sha256=$expected_sha" +assert_line "$WORK_DIR/output" 'cache-namespace=example-app' +expected_target=$(realpath "$runner_temp/edgezero-rust-cache/example-app/target") +assert_line "$WORK_DIR/output" "cargo-target-dir=$expected_target" +assert_line "$WORK_DIR/env" 'RUSTC_WRAPPER=sccache' +assert_line "$WORK_DIR/env" 'SCCACHE_GHA_ENABLED=true' +assert_line "$WORK_DIR/env" 'CARGO_INCREMENTAL=0' +assert_line "$WORK_DIR/env" "CARGO_TARGET_DIR=$expected_target" +assert_line "$WORK_DIR/env" "EDGEZERO__RUST_CACHE__TARGET_DIR=$expected_target" + +: >"$WORK_DIR/output" +: >"$WORK_DIR/env" +GITHUB_WORKSPACE="$workspace" \ + RUNNER_TEMP="$runner_temp" \ + GITHUB_OUTPUT="$WORK_DIR/output" \ + GITHUB_ENV="$WORK_DIR/env" \ + EDGEZERO__RUST_CACHE__APP_NAME=example-app \ + EDGEZERO__RUST_CACHE__CACHE_TARGET=false \ + EDGEZERO__RUST_CACHE__WORKING_DIRECTORY=project \ + "$ACTION_DIR/scripts/prepare.sh" +assert_line "$WORK_DIR/output" 'cache-namespace=example-app' +if grep -Fq 'cargo-target-dir=' "$WORK_DIR/output"; then + fail "published a Cargo target directory while target caching was disabled" +fi +if grep -Eq '^(CARGO_TARGET_DIR|EDGEZERO__RUST_CACHE__TARGET_DIR)=' "$WORK_DIR/env"; then + fail "enabled Cargo target caching while target caching was disabled" +fi + +for invalid in missing ../outside /; do + : >"$WORK_DIR/output" + : >"$WORK_DIR/env" + if GITHUB_WORKSPACE="$workspace" \ + GITHUB_OUTPUT="$WORK_DIR/output" \ + GITHUB_ENV="$WORK_DIR/env" \ + EDGEZERO__RUST_CACHE__APP_NAME=example-app \ + EDGEZERO__RUST_CACHE__CACHE_TARGET=false \ + EDGEZERO__RUST_CACHE__WORKING_DIRECTORY="$invalid" \ + "$ACTION_DIR/scripts/prepare.sh" >/dev/null 2>&1; then + fail "accepted invalid working-directory '$invalid'" + fi + [[ ! -s "$WORK_DIR/output" ]] || + fail "published a cache key for invalid working-directory '$invalid'" + [[ ! -s "$WORK_DIR/env" ]] || + fail "enabled compiler caching for invalid working-directory '$invalid'" +done + +for invalid_name in '' 'Example App' '../example' 'example/app'; do + : >"$WORK_DIR/output" + : >"$WORK_DIR/env" + if GITHUB_WORKSPACE="$workspace" \ + RUNNER_TEMP="$runner_temp" \ + GITHUB_OUTPUT="$WORK_DIR/output" \ + GITHUB_ENV="$WORK_DIR/env" \ + EDGEZERO__RUST_CACHE__APP_NAME="$invalid_name" \ + EDGEZERO__RUST_CACHE__CACHE_TARGET=false \ + EDGEZERO__RUST_CACHE__WORKING_DIRECTORY=project \ + "$ACTION_DIR/scripts/prepare.sh" >/dev/null 2>&1; then + fail "accepted invalid app-name '$invalid_name'" + fi + [[ ! -s "$WORK_DIR/output" ]] || fail "published outputs for invalid app-name '$invalid_name'" + [[ ! -s "$WORK_DIR/env" ]] || fail "enabled caching for invalid app-name '$invalid_name'" +done + +: >"$WORK_DIR/output" +: >"$WORK_DIR/env" +if GITHUB_WORKSPACE="$workspace" \ + RUNNER_TEMP="$runner_temp" \ + GITHUB_OUTPUT="$WORK_DIR/output" \ + GITHUB_ENV="$WORK_DIR/env" \ + EDGEZERO__RUST_CACHE__APP_NAME=example-app \ + EDGEZERO__RUST_CACHE__CACHE_TARGET=yes \ + EDGEZERO__RUST_CACHE__WORKING_DIRECTORY=project \ + "$ACTION_DIR/scripts/prepare.sh" >/dev/null 2>&1; then + fail "accepted invalid cache-target value 'yes'" +fi +[[ ! -s "$WORK_DIR/output" ]] || fail "published outputs for invalid cache-target" +[[ ! -s "$WORK_DIR/env" ]] || fail "enabled caching for invalid cache-target" + +action="$ACTION_DIR/action.yml" +[[ -f "$action" ]] || fail "missing action.yml" +grep -Eq '^ app-name:$' "$action" || fail "action does not expose the required app-name input" +if grep -Eq '^ application-name:$' "$action"; then + fail "action still exposes the inconsistent application-name input" +fi +grep -Fq 'actions/cache@v5' "$action" || fail "Cargo source cache is not pinned to actions/cache v5" +grep -Fq 'mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba' "$action" || + fail "sccache setup action is not pinned to the reviewed v0.0.11 commit" +grep -Eq '^[[:space:]]+version:[[:space:]]+v0\.16\.0[[:space:]]*$' "$action" || + fail "sccache binary version is not pinned to v0.16.0" +tilde='~' +for path in "$tilde/.cargo/registry/index/" "$tilde/.cargo/registry/cache/" "$tilde/.cargo/git/db/"; do + grep -Fq "$path" "$action" || fail "Cargo source cache omits $path" +done +grep -Fq "steps.prepare.outputs['cargo-lock-sha256']" "$action" || + fail "Cargo source cache key is not bound to the validated Cargo.lock" +grep -Fq "steps.prepare.outputs['cache-namespace']" "$action" || + fail "cache keys are not scoped to the validated application name" +grep -Fq "steps.prepare.outputs['cargo-target-dir']" "$action" || + fail "Cargo target cache does not use the validated target directory" +grep -Fq "inputs['cache-target'] == 'true'" "$action" || + fail "Cargo target cache is not gated by the cache-target input" +github_sha_expression="\${{ github.sha }}" +grep -Fq "$github_sha_expression" "$action" || + fail "Cargo target cache primary key is not bound to the source revision" + +printf 'setup-rust-build-cache action tests passed\n' diff --git a/.github/workflows/deploy-action.yml b/.github/workflows/deploy-action.yml index eff8262d..ab72c716 100644 --- a/.github/workflows/deploy-action.yml +++ b/.github/workflows/deploy-action.yml @@ -3,17 +3,11 @@ name: Deploy actions on: pull_request: paths: - # ANY workflow or action change must start the repository-wide pin gate (and - # the smokes) — including a brand-new action directory or an unrelated - # workflow like test.yml that could introduce a floating ref. - .github/actions/** - .github/workflows/** - .github/zizmor.yml - scripts/install-actionlint.sh - scripts/install-yq.sh - # The smoke fixture is a real Cargo app built against this workspace's crate - # graph, so a change to the CLI/core/macro crates, the workspace manifest and - # lockfile, or the pinned toolchain can change what the smoke compiles. - Cargo.toml - Cargo.lock - .tool-versions @@ -23,21 +17,17 @@ on: - crates/edgezero-core/** - crates/edgezero-macros/** - docs/guide/deploy-github-actions.md - - docs/specs/** + - docs/guide/deploy-action-adoption.md + - docs/superpowers/specs/*-edgezero-deploy-*.md + - docs/superpowers/plans/*-edgezero-deploy-*.md push: branches: [main] paths: - # ANY workflow or action change must start the repository-wide pin gate (and - # the smokes) — including a brand-new action directory or an unrelated - # workflow like test.yml that could introduce a floating ref. - .github/actions/** - .github/workflows/** - .github/zizmor.yml - scripts/install-actionlint.sh - scripts/install-yq.sh - # The smoke fixture is a real Cargo app built against this workspace's crate - # graph, so a change to the CLI/core/macro crates, the workspace manifest and - # lockfile, or the pinned toolchain can change what the smoke compiles. - Cargo.toml - Cargo.lock - .tool-versions @@ -47,7 +37,9 @@ on: - crates/edgezero-core/** - crates/edgezero-macros/** - docs/guide/deploy-github-actions.md - - docs/specs/** + - docs/guide/deploy-action-adoption.md + - docs/superpowers/specs/*-edgezero-deploy-*.md + - docs/superpowers/plans/*-edgezero-deploy-*.md permissions: contents: read @@ -65,40 +57,28 @@ jobs: with: persist-credentials: false - - name: Install pinned validation binaries under RUNNER_TEMP (checksum-verified) - # Per the action's binary-isolation rule (§5.4/security principle 5), - # validation tools live under RUNNER_TEMP, not a shared PATH dir. Prepend the - # dir so these verified copies win over any runner-provided ones, and confirm - # each reports its pinned version — the check steps below also call them by - # ABSOLUTE path, so it is provably these that run. + - name: Install pinned validation binaries run: | bin="$RUNNER_TEMP/tools/bin" mkdir -p "$bin" INSTALL_DIR="$bin" scripts/install-actionlint.sh "$ACTIONLINT_VERSION" INSTALL_DIR="$bin" scripts/install-yq.sh "$YQ_VERSION" cargo install zizmor --version "$ZIZMOR_VERSION" --locked --root "$RUNNER_TEMP/tools" - echo "$bin" >>"$GITHUB_PATH" "$bin/actionlint" -version | grep -qF "$ACTIONLINT_VERSION" "$bin/yq" --version | grep -qF "version v$YQ_VERSION" "$bin/zizmor" --version | grep -qF "$ZIZMOR_VERSION" + printf '%s\n' "$bin" >>"$GITHUB_PATH" - # ShellCheck must be installed BEFORE actionlint: actionlint's `-shellcheck` - # integration silently disables itself (and the step exits 0) when shellcheck - # is not on PATH, so a `run:` defect could pass unchecked on a runner that does - # not preinstall it. - name: Install ShellCheck run: | sudo apt-get update sudo apt-get install -y shellcheck - - name: Actionlint (all workflows) - # No file args → actionlint validates every .github/workflows/*.{yml,yaml}. - # The `-shellcheck` integration runs shellcheck on each `run:` block at a - # warning floor (info-level notes in unrelated workflows are not failures). + - name: Actionlint run: | "$RUNNER_TEMP/tools/bin/actionlint" -shellcheck='shellcheck -S warning' - - name: Third-party actions pinned to a ref + - name: Third-party action pin gate run: .github/actions/deploy-core/tests/check-action-pins.sh - name: Zizmor security scan @@ -107,18 +87,19 @@ jobs: .github/workflows/deploy-action.yml \ .github/workflows/fastly-installer-check.yml \ .github/actions/build-app-cli/action.yml \ + .github/actions/setup-rust-build-cache/action.yml \ .github/actions/deploy-fastly/action.yml \ .github/actions/healthcheck-fastly/action.yml \ .github/actions/rollback-fastly/action.yml \ - .github/actions/config-push-fastly/action.yml + .github/actions/config-push-fastly/action.yml \ + .github/actions/package-application-release-fastly/action.yml \ + .github/actions/require-github-environment/action.yml - name: ShellCheck action scripts - # -e SC1091: the `source "$SCRIPT_DIR/common.sh"` path is dynamic, so - # shellcheck can't follow it from the repo root — that info finding is - # not a real defect. Everything else is checked. run: | shellcheck -e SC1091 \ .github/actions/*/scripts/*.sh \ + .github/actions/*/tests/*.sh \ .github/actions/deploy-core/tests/*.sh \ scripts/install-actionlint.sh \ scripts/install-yq.sh @@ -126,6 +107,9 @@ jobs: - name: Bash contract tests run: .github/actions/deploy-core/tests/run.sh + - name: Rust build-cache action tests + run: .github/actions/setup-rust-build-cache/tests/run.sh + - name: Validate docs run: | cd docs @@ -134,567 +118,515 @@ jobs: npm run lint npm run build - # Production path: build the app's OWN CLI, deploy through the wrapper, and - # prove the whole chain ran with the credential boundary intact. Every - # assertion lives in a script under deploy-core/tests/ so it is shellcheck'd - # and readable outside the YAML. - composite-smoke: + # Build the store-aware application once. Its production/staging matrix and + # lifecycle actions consume these exact CLI, package, and manifest bytes. + fixture-release: runs-on: ubuntu-latest - # Inherited provider aliases the deploy MUST clear (provider-env boundary). - env: - FASTLY_ENDPOINT: https://inherited.invalid - FASTLY_HOME: /nonexistent/inherited + outputs: + app-release-sha256: ${{ steps.release.outputs.app-release-sha256 }} + package-digest: ${{ steps.release.outputs.package-digest }} + source-revision: ${{ steps.release.outputs.source-revision }} steps: - uses: actions/checkout@v7 with: persist-credentials: false - # The fixture is a REAL app-owned CLI (its own crate depending on - # edgezero-cli) — the contract build-app-cli actually promises. - - name: Create fixture app (app-owned CLI) - run: .github/actions/deploy-core/tests/make-smoke-fixture.sh + - name: Create the store-aware fixture application + run: .github/actions/deploy-fastly/tests/make-smoke-fixture.sh source store-aware - - name: Build the APP's own CLI package - id: cli - uses: ./.github/actions/build-app-cli + - name: Set up Rust build cache + uses: ./.github/actions/setup-rust-build-cache with: - app-cli-package: fixture-app-cli + app-name: store-aware-fixture + cache-target: true working-directory: fixture-app - # Distinct per job: parallel jobs each upload their own artifact, so a - # download-by-name never has to disambiguate between same-named uploads. - app-cli-artifact: edgezero-cli-composite - - # The production deploy runs a manifest-command deploy (fake-deploy.sh), but - # deploy-fastly now also captures the rollback target first via a real - # `active-version` Fastly API call. Provide a fake `curl` (and `fastly`) so - # that capture resolves the active version (40) instead of hitting the real - # API. The deploy itself still exercises the manifest command. - - name: Set up fake Fastly API for rollback-target capture - run: .github/actions/deploy-core/tests/make-fake-fastly-env.sh - - - name: Deploy fixture (production) with local action - id: deploy - uses: ./.github/actions/deploy-fastly + + - name: Build the application-owned CLI once + uses: ./.github/actions/build-app-cli with: - app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} + app-cli-package: fixture-app-cli working-directory: fixture-app - fastly-api-token: dummy-token - fastly-service-id: dummyservice - deploy-args: '["--comment","smoke"]' + app-cli-artifact: fixture-app-cli - - name: Assert production deploy, version threading, and credential boundary - env: - EDGEZERO__TEST__FASTLY_VERSION: ${{ steps.deploy.outputs['fastly-version'] }} - EDGEZERO__TEST__PREVIOUS_VERSION: ${{ steps.deploy.outputs['previous-version'] }} - run: .github/actions/deploy-core/tests/assert-production-deploy.sh - - # Prove the real rollback wiring: a production rollback consumes the deploy's - # `previous-version` output as `rollback-to` (no hardcoded version), and the - # activated target threads back out as `rolled-back-to`. - - name: Roll back production using the captured previous-version - id: rollback - uses: ./.github/actions/rollback-fastly + - name: Download the one application CLI archive + uses: actions/download-artifact@v8 with: - app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} - fastly-api-token: dummy-token - fastly-service-id: dummyservice - fastly-version: ${{ steps.deploy.outputs['fastly-version'] }} - rollback-to: ${{ steps.deploy.outputs['previous-version'] }} - deploy-to: production + name: fixture-app-cli + path: fixture-cli - - name: Assert the rollback activated the captured previous-version - env: - EDGEZERO__TEST__ROLLED_BACK_TO: ${{ steps.rollback.outputs['rolled-back-to'] }} - EDGEZERO__TEST__PREVIOUS_VERSION: ${{ steps.deploy.outputs['previous-version'] }} - run: .github/actions/deploy-core/tests/assert-rollback-threaded.sh - - # Separate-job artifact handoff: build the CLI in ONE job, deploy in a DEPENDENT - # job that downloads the artifact by a LITERAL name (a `steps.*.outputs` value - # cannot cross a job boundary). This is the layout the guide recommends for keeping - # the credential entirely out of the build phase, and the only smoke that exercises - # cross-job artifact download. - handoff-build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - with: - persist-credentials: false - - name: Create fixture app (app-owned CLI) - run: .github/actions/deploy-core/tests/make-smoke-fixture.sh - # No provider credentials in this job at all: it only builds and uploads. - - name: Build the APP's own CLI package (build job) - uses: ./.github/actions/build-app-cli + - name: Assemble the immutable application release + id: release + run: .github/actions/deploy-fastly/tests/make-smoke-fixture.sh release fixture-cli/app-cli.tar + + - name: Upload the immutable application release + uses: actions/upload-artifact@v7 with: - app-cli-package: fixture-app-cli - working-directory: fixture-app - app-cli-artifact: edgezero-cli-handoff + name: edgezero-fastly-release + path: | + fixture-release/app-release.tar.gz + fixture-release/app-release.sha256 + fixture-release/package.sha256 + if-no-files-found: error + retention-days: 14 - handoff-deploy: - needs: handoff-build + # This is a separate application release because its bundled edgezero.toml + # declares no stores. It is built once and is never varied by a deployer. + store-free-release: runs-on: ubuntu-latest - # The deploy job carries the token; inherited aliases it MUST still clear. - env: - FASTLY_ENDPOINT: https://inherited.invalid - FASTLY_HOME: /nonexistent/inherited + outputs: + app-release-sha256: ${{ steps.release.outputs.app-release-sha256 }} + package-digest: ${{ steps.release.outputs.package-digest }} + source-revision: ${{ steps.release.outputs.source-revision }} steps: - uses: actions/checkout@v7 with: persist-credentials: false - # The deploy job needs the app SOURCE (working-directory); the CLI binary comes - # from the build job's artifact, downloaded inside deploy-fastly by its literal - # name — NOT a step output, which cannot cross jobs. - - name: Recreate the fixture app source - run: .github/actions/deploy-core/tests/make-smoke-fixture.sh - - name: Set up fake Fastly API for rollback-target capture - run: .github/actions/deploy-core/tests/make-fake-fastly-env.sh - - name: Deploy using the artifact built in the other job (literal name) - id: deploy - uses: ./.github/actions/deploy-fastly + + - name: Create the store-free fixture application + run: .github/actions/deploy-fastly/tests/make-smoke-fixture.sh source store-free + + - name: Set up Rust build cache + uses: ./.github/actions/setup-rust-build-cache with: - app-cli-artifact: edgezero-cli-handoff + app-name: store-free-fixture + cache-target: true working-directory: fixture-app - fastly-api-token: dummy-token - fastly-service-id: dummyservice - - name: Assert the cross-job handoff produced a real production deploy - env: - EDGEZERO__TEST__FASTLY_VERSION: ${{ steps.deploy.outputs['fastly-version'] }} - EDGEZERO__TEST__PREVIOUS_VERSION: ${{ steps.deploy.outputs['previous-version'] }} - run: .github/actions/deploy-core/tests/assert-production-deploy.sh - - # Cache POPULATION + RESTORE HIT end to end. build-mode: always runs the - # credential-free seed build that populates target/ and saves the cache; a second - # deploy in the same job restores it. Deleting target/ between the two proves the - # marker comes back from the CACHE (a real restore hit), not from disk — and the - # idempotent seed build leaves a restored marker untouched, so a rebuild would be - # caught as a different value. - cache-smoke: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - with: - persist-credentials: false - - name: Create fixture app (app-owned CLI) - run: .github/actions/deploy-core/tests/make-smoke-fixture.sh - - name: Build the APP's own CLI package - id: cli + + - name: Build the store-free application CLI once uses: ./.github/actions/build-app-cli with: app-cli-package: fixture-app-cli working-directory: fixture-app - app-cli-artifact: edgezero-cli-cache - - name: Set up fake Fastly API - run: .github/actions/deploy-core/tests/make-fake-fastly-env.sh - - name: Deploy (populate cache via the credential-free seed build) - uses: ./.github/actions/deploy-fastly - with: - app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} - working-directory: fixture-app - fastly-api-token: dummy-token - fastly-service-id: dummyservice - build-mode: always - cache: true - - name: Capture the seeded marker, then delete target/ - run: .github/actions/deploy-core/tests/cache-smoke-capture.sh - - name: Deploy again (restore cache) - uses: ./.github/actions/deploy-fastly + app-cli-artifact: fixture-app-cli-store-free + + - name: Download the store-free application CLI archive + uses: actions/download-artifact@v8 with: - app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} - working-directory: fixture-app - fastly-api-token: dummy-token - fastly-service-id: dummyservice - build-mode: always - cache: true - - name: Assert the marker was restored from cache (not rebuilt) - run: .github/actions/deploy-core/tests/cache-smoke-assert.sh - # Negative regression: build-mode: never must SKIP restore (gated on always). - # Delete target/, deploy with build-mode: never + cache: true, and assert the - # cache did NOT come back. Reintroducing the old restore condition would fail - # here instead of leaving the smoke green. - - name: Delete target/ before the build-mode never deploy - run: .github/actions/deploy-core/tests/cache-smoke-capture.sh - - name: Deploy with build-mode never (restore must be skipped) - uses: ./.github/actions/deploy-fastly + name: fixture-app-cli-store-free + path: fixture-cli + + - name: Assemble the immutable store-free application release + id: release + run: .github/actions/deploy-fastly/tests/make-smoke-fixture.sh release fixture-cli/app-cli.tar + + - name: Upload the immutable store-free application release + uses: actions/upload-artifact@v7 with: - app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} - working-directory: fixture-app - fastly-api-token: dummy-token - fastly-service-id: dummyservice - build-mode: never - cache: true - - name: Assert the cache was NOT restored under build-mode never - run: .github/actions/deploy-core/tests/cache-smoke-assert-no-restore.sh - - # Lost-version RECOVERY through the actual actions. A production deploy mutates the - # service (activates v7) but loses its version line AND breaks the API fallback, so - # deploy-fastly fails with mutation-attempted=true. The operator then recovers: - # download the CLI artifact, ask active-version what is live now (7), and roll back - # to the previously-captured version (40). Exercises artifact download, - # active-version recovery, and rollback together — the flow the guide documents. - recovery-smoke: + name: edgezero-fastly-store-free-release + path: | + fixture-release/app-release.tar.gz + fixture-release/app-release.sha256 + fixture-release/package.sha256 + if-no-files-found: error + retention-days: 14 + + production-smoke: + needs: fixture-release runs-on: ubuntu-latest - # fake-deploy reads this (only it, and only during the deploy step): it loses the - # version line and trips the API-break sentinel so the fallback also fails. env: - FAKE_LOSE_VERSION: "1" + EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME: config-prod + EDGEZERO__STORES__KV__CACHE__NAME: cache-prod + EDGEZERO__STORES__SECRETS__CREDENTIALS__NAME: credentials-prod + FASTLY_ENDPOINT: https://inherited.invalid + FASTLY_HOME: /nonexistent/inherited steps: - uses: actions/checkout@v7 with: persist-credentials: false - - name: Create fixture app (app-owned CLI) - run: .github/actions/deploy-core/tests/make-smoke-fixture.sh - - name: Build the APP's own CLI package - id: cli - uses: ./.github/actions/build-app-cli + - uses: actions/download-artifact@v8 with: - app-cli-package: fixture-app-cli - working-directory: fixture-app - app-cli-artifact: edgezero-cli-recovery - - name: Set up fake Fastly API - run: .github/actions/deploy-core/tests/make-fake-fastly-env.sh - - name: Deploy (mutates, then loses the version — must fail) + name: edgezero-fastly-release + path: fixture-release + - name: Install the stateful fake Fastly provider + run: .github/actions/deploy-fastly/tests/make-fake-fastly-env.sh + + - name: Deploy the immutable release to production id: deploy - continue-on-error: true uses: ./.github/actions/deploy-fastly with: - app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} - working-directory: fixture-app + app-release-archive: ${{ github.workspace }}/fixture-release/app-release.tar.gz + app-release-sha256: ${{ needs.fixture-release.outputs.app-release-sha256 }} + expected-source-revision: ${{ needs.fixture-release.outputs.source-revision }} fastly-api-token: dummy-token fastly-service-id: dummyservice - - name: Assert the deploy failed but signalled a possible mutation + deploy-args: '["--comment","production smoke"]' + + - name: Assert the production resource links and package env: - EDGEZERO__TEST__DEPLOY_OUTCOME: ${{ steps.deploy.outcome }} - EDGEZERO__TEST__MUTATION_ATTEMPTED: ${{ steps.deploy.outputs['mutation-attempted'] }} - # The rollback target captured BEFORE the deploy must still thread out of a - # FAILED deploy — that is what a real recovery rolls back to. - EDGEZERO__TEST__PREVIOUS_VERSION: ${{ steps.deploy.outputs['previous-version'] }} - run: .github/actions/deploy-core/tests/assert-lost-version.sh - - name: Download the CLI artifact for recovery - uses: actions/download-artifact@v8 + EDGEZERO__TEST__FASTLY_VERSION: ${{ steps.deploy.outputs.fastly-version }} + EDGEZERO__TEST__PREVIOUS_VERSION: ${{ steps.deploy.outputs.previous-version }} + EDGEZERO__TEST__PACKAGE_DIGEST: ${{ steps.deploy.outputs.package-digest }} + run: .github/actions/deploy-fastly/tests/assert-production-deploy.sh + + - name: Probe production using the same immutable release + uses: ./.github/actions/healthcheck-fastly with: - name: edgezero-cli-recovery - path: recover-cli - - name: Recover the live version from the provider (operator flow) - id: recover - env: - FASTLY_API_TOKEN: dummy-token - FASTLY_SERVICE_ID: dummyservice - run: .github/actions/deploy-core/tests/recovery-active-version.sh recover-cli - - name: Roll back to the captured previous version, keyed on the recovered live version + app-release-archive: ${{ github.workspace }}/fixture-release/app-release.tar.gz + app-release-sha256: ${{ needs.fixture-release.outputs.app-release-sha256 }} + expected-source-revision: ${{ needs.fixture-release.outputs.source-revision }} + deploy-to: production + domain: app.example.com + fastly-version: ${{ steps.deploy.outputs.fastly-version }} + fastly-service-id: dummyservice + retry: "1" + retry-delay: "1" + + - name: Roll production back using the same immutable release id: rollback uses: ./.github/actions/rollback-fastly with: - app-cli-artifact: edgezero-cli-recovery + app-release-archive: ${{ github.workspace }}/fixture-release/app-release.tar.gz + app-release-sha256: ${{ needs.fixture-release.outputs.app-release-sha256 }} + expected-source-revision: ${{ needs.fixture-release.outputs.source-revision }} fastly-api-token: dummy-token fastly-service-id: dummyservice - fastly-version: ${{ steps.recover.outputs.version }} - # Thread the deploy's OWN previous-version output (captured pre-mutation), - # exactly as the guide's recovery documents — not a hardcoded value. - rollback-to: ${{ steps.deploy.outputs['previous-version'] }} - deploy-to: production - - name: Assert recovery rolled the service back to the captured version + fastly-version: ${{ steps.deploy.outputs.fastly-version }} + rollback-to: ${{ steps.deploy.outputs.previous-version }} + + - name: Assert rollback used the captured version env: - EDGEZERO__TEST__ROLLED_BACK_TO: ${{ steps.rollback.outputs['rolled-back-to'] }} - run: .github/actions/deploy-core/tests/assert-recovery-rollback.sh - - # Config push: the real config-push-fastly wrapper against a fake `fastly`, - # proving the whole chain — artifact download, Fastly CLI install, the app - # CLI's TYPED `config push` (the bundled stub cannot do this), and the - # staging-key contract. Config push is deliberately NOT part of deploy, so it - # gets its own job. - config-push-smoke: + EDGEZERO__TEST__ROLLED_BACK_TO: ${{ steps.rollback.outputs.rolled-back-to }} + EDGEZERO__TEST__PREVIOUS_VERSION: ${{ steps.deploy.outputs.previous-version }} + run: .github/actions/deploy-fastly/tests/assert-rollback-threaded.sh + + store-free-deploy-smoke: + needs: store-free-release runs-on: ubuntu-latest - # Inherited provider aliases the push MUST clear. - env: - FASTLY_ENDPOINT: https://inherited.invalid - FASTLY_HOME: /nonexistent/inherited steps: - uses: actions/checkout@v7 with: persist-credentials: false - - - name: Create fixture app (app-owned CLI with typed config) - run: .github/actions/deploy-core/tests/make-smoke-fixture.sh - - - name: Build the APP's own CLI package - id: cli - uses: ./.github/actions/build-app-cli - with: - app-cli-package: fixture-app-cli - working-directory: fixture-app - # Distinct per job: parallel jobs each upload their own artifact, so a - # download-by-name never has to disambiguate between same-named uploads. - app-cli-artifact: edgezero-cli-config-push - - - name: Install fake fastly + curl - run: .github/actions/deploy-core/tests/make-fake-fastly-env.sh - - - name: Push config to staging - id: staged - uses: ./.github/actions/config-push-fastly + - uses: actions/download-artifact@v8 with: - app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} - working-directory: fixture-app - fastly-api-token: dummy-token - deploy-to: staging + name: edgezero-fastly-store-free-release + path: fixture-release + - name: Install the stateful fake Fastly provider + run: .github/actions/deploy-fastly/tests/make-fake-fastly-env.sh - # Asserted before the re-seed below, which truncates the call log. - - name: Assert staging wrote the _staging key and never the production key - env: - EDGEZERO__TEST__EXPECT_KEY: app_config_staging - EDGEZERO__TEST__REJECT_KEY: app_config - EDGEZERO__TEST__PUSHED_KEY: ${{ steps.staged.outputs['pushed-key'] }} - EDGEZERO__TEST__PUSHED_STORE: ${{ steps.staged.outputs.store }} - run: .github/actions/deploy-core/tests/assert-config-push.sh - - # Each action's cleanup runs with `if: always()` and removes the SHARED - # action-owned tool root, so a second tool-installing action in the same - # job reinstalls the Fastly CLI from scratch. A real job re-downloads it; - # this job re-seeds the fake instead, keeping the test hermetic (and this - # resets the call log, so each push is asserted against its own). - - name: Re-seed fake fastly (the previous push's cleanup removed the tool root) - run: .github/actions/deploy-core/tests/make-fake-fastly-env.sh - - - name: Push config to production - id: prod - uses: ./.github/actions/config-push-fastly + - name: Deploy the immutable store-free release through the adapter + id: deploy + uses: ./.github/actions/deploy-fastly with: - app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} - working-directory: fixture-app + app-release-archive: ${{ github.workspace }}/fixture-release/app-release.tar.gz + app-release-sha256: ${{ needs.store-free-release.outputs.app-release-sha256 }} + expected-source-revision: ${{ needs.store-free-release.outputs.source-revision }} fastly-api-token: dummy-token + fastly-service-id: dummyservice + deploy-args: '["--comment","store-free managed smoke"]' - - name: Assert production wrote the base key and never the staging key + - name: Assert the store-free release used managed deployment env: - EDGEZERO__TEST__EXPECT_KEY: app_config - EDGEZERO__TEST__REJECT_KEY: app_config_staging - EDGEZERO__TEST__PUSHED_KEY: ${{ steps.prod.outputs['pushed-key'] }} - EDGEZERO__TEST__PUSHED_STORE: ${{ steps.prod.outputs.store }} - run: .github/actions/deploy-core/tests/assert-config-push.sh - - # Staging path: the full lifecycle through the REAL wrappers — deploy-fastly - # with `deploy-to: staging`, then healthcheck-fastly, then rollback-fastly — against - # fake `fastly`/`curl` binaries that mirror the real contracts. - # - # The version is never hard-coded: it is threaded out of the deploy action's - # `fastly-version` output and into the two lifecycle actions, which is the - # contract an operator's workflow depends on. Because the defects a review - # found were argv/verb defects, the assertions check argv and verbs — see the - # assert-*.sh scripts for what each one regression-tests. - lifecycle-smoke: + EDGEZERO__TEST__FASTLY_VERSION: ${{ steps.deploy.outputs.fastly-version }} + EDGEZERO__TEST__FIXTURE_MODE: store-free + EDGEZERO__TEST__PREVIOUS_VERSION: ${{ steps.deploy.outputs.previous-version }} + EDGEZERO__TEST__PACKAGE_DIGEST: ${{ steps.deploy.outputs.package-digest }} + run: .github/actions/deploy-fastly/tests/assert-production-deploy.sh + + staging-smoke: + needs: fixture-release runs-on: ubuntu-latest - # Inherited provider aliases that every step MUST clear. FASTLY_API_TOKEN is - # here on purpose: a PRODUCTION healthcheck must probe with no token even when - # the job env carries one. env: - FASTLY_ENDPOINT: https://inherited.invalid - FASTLY_HOME: /nonexistent/inherited + EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME: config-stage + EDGEZERO__STORES__KV__CACHE__NAME: cache-stage + EDGEZERO__STORES__SECRETS__CREDENTIALS__NAME: credentials-stage FASTLY_API_TOKEN: inherited-must-not-reach-production-probes steps: - uses: actions/checkout@v7 with: persist-credentials: false - - - name: Create fixture app (app-owned CLI) - run: .github/actions/deploy-core/tests/make-smoke-fixture.sh - - - name: Build the APP's own CLI package - id: cli - uses: ./.github/actions/build-app-cli + - uses: actions/download-artifact@v8 with: - app-cli-package: fixture-app-cli - working-directory: fixture-app - # Distinct per job: parallel jobs each upload their own artifact, so a - # download-by-name never has to disambiguate between same-named uploads. - app-cli-artifact: edgezero-cli-lifecycle - - # Packages a fake `fastly` as a checksum-verified archive and a fake `curl` - # that serves it to each invocation's unique tool root (file:// copy). So - # install-fastly.sh runs its REAL download+verify+extract path — never - # adopting a planted binary — and the staged path runs through the real - # wrapper without contacting Fastly. - - name: Install fake fastly + curl - run: .github/actions/deploy-core/tests/make-fake-fastly-env.sh - - - name: Staged deploy through the deploy-fastly wrapper + name: edgezero-fastly-release + path: fixture-release + - name: Install the stateful fake Fastly provider + run: .github/actions/deploy-fastly/tests/make-fake-fastly-env.sh + + - name: Deploy the same immutable release to staging id: stage uses: ./.github/actions/deploy-fastly with: - app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} - working-directory: fixture-app + app-release-archive: ${{ github.workspace }}/fixture-release/app-release.tar.gz + app-release-sha256: ${{ needs.fixture-release.outputs.app-release-sha256 }} + expected-source-revision: ${{ needs.fixture-release.outputs.source-revision }} fastly-api-token: dummy-token fastly-service-id: dummyservice deploy-args: '["--comment","staged smoke"]' deploy-to: staging - - name: Assert the staged Fastly call sequence + - name: Assert staging resource links and package env: - EDGEZERO__TEST__STAGED_VERSION: ${{ steps.stage.outputs['fastly-version'] }} - run: .github/actions/deploy-core/tests/assert-staged-calls.sh + EDGEZERO__TEST__STAGED_VERSION: ${{ steps.stage.outputs.fastly-version }} + EDGEZERO__TEST__PACKAGE_DIGEST: ${{ steps.stage.outputs.package-digest }} + run: .github/actions/deploy-fastly/tests/assert-staged-calls.sh - - name: Health check the staged version + - name: Probe staging using the same immutable release id: health uses: ./.github/actions/healthcheck-fastly with: - app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} + app-release-archive: ${{ github.workspace }}/fixture-release/app-release.tar.gz + app-release-sha256: ${{ needs.fixture-release.outputs.app-release-sha256 }} + expected-source-revision: ${{ needs.fixture-release.outputs.source-revision }} deploy-to: staging - domain: staging.example.com - fastly-version: ${{ steps.stage.outputs['fastly-version'] }} + domain: app.example.com + fastly-version: ${{ steps.stage.outputs.fastly-version }} fastly-api-token: dummy-token fastly-service-id: dummyservice retry: "1" retry-delay: "1" - - name: Assert the staging IP was resolved and probed + - name: Assert the staged IP was probed env: - EDGEZERO__TEST__STAGED_VERSION: ${{ steps.stage.outputs['fastly-version'] }} + EDGEZERO__TEST__GITHUB_ENVIRONMENT: staging.app.example.com + EDGEZERO__TEST__STAGED_VERSION: ${{ steps.stage.outputs.fastly-version }} EDGEZERO__TEST__HEALTHY: ${{ steps.health.outputs.healthy }} - EDGEZERO__TEST__STATUS_CODE: ${{ steps.health.outputs['status-code'] }} - run: .github/actions/deploy-core/tests/assert-staging-probe.sh + EDGEZERO__TEST__STATUS_CODE: ${{ steps.health.outputs.status-code }} + run: .github/actions/deploy-fastly/tests/assert-staging-probe.sh - - name: Health check must FAIL when the probe is unhealthy + - name: Unhealthy staging probe fails id: unhealthy continue-on-error: true uses: ./.github/actions/healthcheck-fastly + env: + FORCE_UNHEALTHY: "1" with: - app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} + app-release-archive: ${{ github.workspace }}/fixture-release/app-release.tar.gz + app-release-sha256: ${{ needs.fixture-release.outputs.app-release-sha256 }} + expected-source-revision: ${{ needs.fixture-release.outputs.source-revision }} deploy-to: staging - domain: staging.example.com - fastly-version: ${{ steps.stage.outputs['fastly-version'] }} + domain: app.example.com + fastly-version: ${{ steps.stage.outputs.fastly-version }} fastly-api-token: dummy-token fastly-service-id: dummyservice retry: "1" retry-delay: "1" - env: - # Flips the fake probe to 503 for this step only. - FORCE_UNHEALTHY: "1" - - name: Assert the unhealthy check failed the wrapper + - name: Assert unhealthy staging probe failed env: EDGEZERO__TEST__OUTCOME: ${{ steps.unhealthy.outcome }} EDGEZERO__TEST__HEALTHY: ${{ steps.unhealthy.outputs.healthy }} - EDGEZERO__TEST__STATUS_CODE: ${{ steps.unhealthy.outputs['status-code'] }} - run: .github/actions/deploy-core/tests/assert-unhealthy-failed.sh + EDGEZERO__TEST__STATUS_CODE: ${{ steps.unhealthy.outputs.status-code }} + run: .github/actions/deploy-fastly/tests/assert-unhealthy-failed.sh - # A PRODUCTION probe needs no credential — it just curls the public domain. - # The job env carries an inherited FASTLY_API_TOKEN, so this proves the - # wrapper withholds it rather than merely not requiring it. - - name: Snapshot the call log before the production probe + - name: Snapshot calls before tokenless production probe run: printf 'PROD_PROBE_SNAPSHOT=%s\n' "$(wc -l <"$FAKE_CALL_LOG")" >>"$GITHUB_ENV" - - name: Production health check (no token supplied) - id: prod-health + - name: Probe production without a provider token + id: production-health uses: ./.github/actions/healthcheck-fastly with: - app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} + app-release-archive: ${{ github.workspace }}/fixture-release/app-release.tar.gz + app-release-sha256: ${{ needs.fixture-release.outputs.app-release-sha256 }} + expected-source-revision: ${{ needs.fixture-release.outputs.source-revision }} deploy-to: production - domain: staging.example.com - fastly-version: ${{ steps.stage.outputs['fastly-version'] }} + domain: app.example.com + fastly-version: ${{ steps.stage.outputs.fastly-version }} fastly-service-id: dummyservice retry: "1" retry-delay: "1" - - name: Assert the production probe ran without any provider token + - name: Assert production probe received no token env: EDGEZERO__TEST__LOG_SNAPSHOT: ${{ env.PROD_PROBE_SNAPSHOT }} - EDGEZERO__TEST__HEALTHY: ${{ steps.prod-health.outputs.healthy }} - run: .github/actions/deploy-core/tests/assert-production-probe-tokenless.sh + EDGEZERO__TEST__HEALTHY: ${{ steps.production-health.outputs.healthy }} + run: .github/actions/deploy-fastly/tests/assert-production-probe-tokenless.sh - # A STAGING probe genuinely needs the token (staging-IP resolution), so - # omitting it must fail fast rather than probe the wrong thing. - - name: Staging health check without a token must fail + - name: Refuse staging probe without a provider token id: staging-no-token continue-on-error: true uses: ./.github/actions/healthcheck-fastly with: - app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} + app-release-archive: ${{ github.workspace }}/fixture-release/app-release.tar.gz + app-release-sha256: ${{ needs.fixture-release.outputs.app-release-sha256 }} + expected-source-revision: ${{ needs.fixture-release.outputs.source-revision }} deploy-to: staging - domain: staging.example.com - fastly-version: ${{ steps.stage.outputs['fastly-version'] }} + domain: app.example.com + fastly-version: ${{ steps.stage.outputs.fastly-version }} fastly-service-id: dummyservice retry: "1" retry-delay: "1" - - name: Assert the tokenless staging check was refused + - name: Assert tokenless staging probe was refused env: EDGEZERO__TEST__OUTCOME: ${{ steps.staging-no-token.outcome }} run: | - [[ "${EDGEZERO__TEST__OUTCOME}" == "failure" ]] || - { echo "::error::a staging healthcheck with no token must fail, got '${EDGEZERO__TEST__OUTCOME}'"; exit 1; } + [[ "$EDGEZERO__TEST__OUTCOME" == failure ]] || { + echo "::error::tokenless staging healthcheck was not refused" + exit 1 + } - - name: Roll back the staged version + - name: Roll staging back using the same immutable release uses: ./.github/actions/rollback-fastly with: - app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} + app-release-archive: ${{ github.workspace }}/fixture-release/app-release.tar.gz + app-release-sha256: ${{ needs.fixture-release.outputs.app-release-sha256 }} + expected-source-revision: ${{ needs.fixture-release.outputs.source-revision }} deploy-to: staging - fastly-version: ${{ steps.stage.outputs['fastly-version'] }} + fastly-version: ${{ steps.stage.outputs.fastly-version }} fastly-api-token: dummy-token fastly-service-id: dummyservice - - name: Roll back production - id: prod-rollback + - name: Assert staging rollback deactivated version 42 + run: grep -q '^PUT https://api.fastly.com/service/dummyservice/version/42/deactivate/staging$' "$FAKE_CALL_LOG" + + - name: Roll active production version 40 back to 39 + id: production-rollback uses: ./.github/actions/rollback-fastly with: - app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} + app-release-archive: ${{ github.workspace }}/fixture-release/app-release.tar.gz + app-release-sha256: ${{ needs.fixture-release.outputs.app-release-sha256 }} + expected-source-revision: ${{ needs.fixture-release.outputs.source-revision }} deploy-to: production - # The fake Fastly API reports version 40 as active, and the - # best-effort staleness guard requires the rolled-back-from `--version` to - # still be active — so roll back FROM 40 TO 39. Fastly cannot infer the - # previous version, so `rollback-to` is explicit (a real caller wires - # deploy-fastly's `previous-version`; the composite-smoke proves that). fastly-version: "40" rollback-to: "39" fastly-api-token: dummy-token fastly-service-id: dummyservice - - name: Assert rollback verbs, paths, and version threading + - name: Assert rollback verbs and version threading env: - EDGEZERO__TEST__STAGED_VERSION: ${{ steps.stage.outputs['fastly-version'] }} - EDGEZERO__TEST__ROLLED_BACK_TO: ${{ steps.prod-rollback.outputs['rolled-back-to'] }} - run: .github/actions/deploy-core/tests/assert-rollback-calls.sh + EDGEZERO__TEST__STAGED_VERSION: ${{ steps.stage.outputs.fastly-version }} + EDGEZERO__TEST__ROLLED_BACK_TO: ${{ steps.production-rollback.outputs.rolled-back-to }} + run: .github/actions/deploy-fastly/tests/assert-rollback-calls.sh - # A production rollback with NO rollback-to must fail closed rather than - # guess a target — Fastly cannot infer the previously-live version. - - name: Production rollback without a target must fail + - name: Refuse a production rollback without a target id: rollback-no-target continue-on-error: true uses: ./.github/actions/rollback-fastly with: - app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} + app-release-archive: ${{ github.workspace }}/fixture-release/app-release.tar.gz + app-release-sha256: ${{ needs.fixture-release.outputs.app-release-sha256 }} + expected-source-revision: ${{ needs.fixture-release.outputs.source-revision }} deploy-to: production - fastly-version: ${{ steps.stage.outputs['fastly-version'] }} + fastly-version: "39" fastly-api-token: dummy-token fastly-service-id: dummyservice - - name: Assert the targetless production rollback was refused + - name: Assert targetless rollback was refused env: EDGEZERO__TEST__OUTCOME: ${{ steps.rollback-no-target.outcome }} run: | - [[ "${EDGEZERO__TEST__OUTCOME}" == "failure" ]] || - { echo "::error::a production rollback with no rollback-to must fail, got '${EDGEZERO__TEST__OUTCOME}'"; exit 1; } + [[ "$EDGEZERO__TEST__OUTCOME" == failure ]] || { + echo "::error::targetless production rollback was not refused" + exit 1 + } - # A STALE rollback — one whose rolled-back-from version is no longer active - # because a newer deploy landed — must be refused BEFORE it mutates anything. - - name: Simulate a newer deploy becoming active, snapshot the call log + - name: Simulate a newer active version and snapshot calls run: | printf '99\n' >"$FAKE_ACTIVE_VERSION_FILE" - # Snapshot the call log so the assertion inspects ONLY the stale - # rollback's calls (the delta), not the whole job's history. printf 'STALE_LOG_SNAPSHOT=%s\n' "$(wc -l <"$FAKE_CALL_LOG")" >>"$GITHUB_ENV" - - name: Stale production rollback must be refused + - name: Refuse stale production rollback id: stale-rollback continue-on-error: true uses: ./.github/actions/rollback-fastly with: - app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} + app-release-archive: ${{ github.workspace }}/fixture-release/app-release.tar.gz + app-release-sha256: ${{ needs.fixture-release.outputs.app-release-sha256 }} + expected-source-revision: ${{ needs.fixture-release.outputs.source-revision }} deploy-to: production - # 40 is no longer active (99 is), so this rollback is stale. - fastly-version: "40" + fastly-version: "39" rollback-to: "38" fastly-api-token: dummy-token fastly-service-id: dummyservice - - name: Assert the stale rollback was refused and activated nothing + - name: Assert stale rollback mutated nothing env: EDGEZERO__TEST__OUTCOME: ${{ steps.stale-rollback.outcome }} EDGEZERO__TEST__LOG_SNAPSHOT: ${{ env.STALE_LOG_SNAPSHOT }} - run: .github/actions/deploy-core/tests/assert-stale-rollback-refused.sh + run: .github/actions/deploy-fastly/tests/assert-stale-rollback-refused.sh + + config-push-smoke: + needs: fixture-release + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/download-artifact@v8 + with: + name: edgezero-fastly-release + path: fixture-release + - name: Install fake Fastly for publisher B + run: .github/actions/deploy-fastly/tests/make-fake-fastly-env.sh + + - name: Push publisher B staging config from the bundled manifest + id: staged + uses: ./.github/actions/config-push-fastly + env: + EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME: config-stage + with: + app-release-archive: ${{ github.workspace }}/fixture-release/app-release.tar.gz + app-release-sha256: ${{ needs.fixture-release.outputs.app-release-sha256 }} + expected-source-revision: ${{ needs.fixture-release.outputs.source-revision }} + app-config-inline: 'greeting = "publisher B"' + fastly-api-token: dummy-token + deploy-to: staging + + - name: Assert publisher B staging config + env: + EDGEZERO__TEST__EXPECT_KEY: app_config + EDGEZERO__TEST__REJECT_KEY: app_config_staging + EDGEZERO__TEST__PUSHED_KEY: ${{ steps.staged.outputs.pushed-key }} + EDGEZERO__TEST__PUSHED_STORE: ${{ steps.staged.outputs.store }} + run: .github/actions/deploy-fastly/tests/assert-config-push.sh + + - name: Reset fake Fastly for publisher A + run: .github/actions/deploy-fastly/tests/make-fake-fastly-env.sh + + - name: Push publisher A production config from the bundled manifest + id: production + uses: ./.github/actions/config-push-fastly + env: + EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME: config-prod + with: + app-release-archive: ${{ github.workspace }}/fixture-release/app-release.tar.gz + app-release-sha256: ${{ needs.fixture-release.outputs.app-release-sha256 }} + expected-source-revision: ${{ needs.fixture-release.outputs.source-revision }} + app-config-inline: 'greeting = "publisher A"' + fastly-api-token: dummy-token + + - name: Assert publisher A production config + env: + EDGEZERO__TEST__EXPECT_KEY: app_config + EDGEZERO__TEST__REJECT_KEY: app_config_staging + EDGEZERO__TEST__PUSHED_KEY: ${{ steps.production.outputs.pushed-key }} + EDGEZERO__TEST__PUSHED_STORE: ${{ steps.production.outputs.store }} + run: .github/actions/deploy-fastly/tests/assert-config-push.sh + + recovery-smoke: + needs: fixture-release + runs-on: ubuntu-latest + env: + EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME: config-prod + EDGEZERO__STORES__KV__CACHE__NAME: cache-prod + EDGEZERO__STORES__SECRETS__CREDENTIALS__NAME: credentials-prod + FAKE_FAIL_AFTER_VERSION: "1" + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/download-artifact@v8 + with: + name: edgezero-fastly-release + path: fixture-release + - name: Install the stateful fake Fastly provider + run: .github/actions/deploy-fastly/tests/make-fake-fastly-env.sh + + - name: Fail after Fastly returns the recoverable draft version + id: deploy + continue-on-error: true + uses: ./.github/actions/deploy-fastly + with: + app-release-archive: ${{ github.workspace }}/fixture-release/app-release.tar.gz + app-release-sha256: ${{ needs.fixture-release.outputs.app-release-sha256 }} + expected-source-revision: ${{ needs.fixture-release.outputs.source-revision }} + fastly-api-token: dummy-token + fastly-service-id: dummyservice + + - name: Assert failed deployment retained recovery outputs + env: + EDGEZERO__TEST__DEPLOY_OUTCOME: ${{ steps.deploy.outcome }} + EDGEZERO__TEST__MUTATION_ATTEMPTED: ${{ steps.deploy.outputs.mutation-attempted }} + EDGEZERO__TEST__PREVIOUS_VERSION: ${{ steps.deploy.outputs.previous-version }} + EDGEZERO__TEST__FASTLY_VERSION: ${{ steps.deploy.outputs.fastly-version }} + EDGEZERO__TEST__PACKAGE_DIGEST: ${{ steps.deploy.outputs.package-digest }} + run: .github/actions/deploy-fastly/tests/assert-lost-version.sh diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e629d17a..66a87ebd 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,9 +14,13 @@ concurrency: cancel-in-progress: true jobs: - test: - name: cargo test + host-tests: + name: ${{ matrix.suite }} tests runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + suite: [workspace, cli, axum, app-demo] steps: - uses: actions/checkout@v6 @@ -29,13 +33,13 @@ jobs: ~/.cargo/registry/cache/ ~/.cargo/git/db/ target/ - key: ${{ runner.os }}-cargo-test-${{ hashFiles('**/Cargo.lock') }} + key: ${{ runner.os }}-cargo-${{ matrix.suite }}-tests-${{ hashFiles('**/Cargo.lock') }} restore-keys: | - ${{ runner.os }}-cargo-test- + ${{ runner.os }}-cargo-${{ matrix.suite }}-tests- - name: Retrieve Rust version id: rust-version - run: echo "rust-version=$(grep '^rust ' .tool-versions | awk '{print $2}')" >> $GITHUB_OUTPUT + run: echo "rust-version=$(grep '^rust ' .tool-versions | awk '{print $2}')" >> "$GITHUB_OUTPUT" shell: bash - name: Set up Rust tool chain @@ -43,61 +47,59 @@ jobs: with: toolchain: ${{ steps.rust-version.outputs.rust-version }} - - name: Add wasm targets - run: rustup target add wasm32-wasip1 wasm32-wasip2 wasm32-unknown-unknown - - name: Fetch dependencies (locked) run: cargo fetch --locked - name: No placeholder pins + if: matrix.suite == 'workspace' run: ./scripts/check_no_placeholder_pins.sh - name: No legacy typed reads + if: matrix.suite == 'workspace' run: ./scripts/check_no_legacy_typed_reads.sh - name: Nested AppConfig audit + if: matrix.suite == 'cli' run: cargo run -q --bin check_no_nested_app_config --features nested-app-config-check -- examples/app-demo crates/edgezero-cli/src/templates - # The checker's own unit tests live behind `required-features = - # ["nested-app-config-check"]`, so the unfeatured - # `cargo test --workspace` step below does not compile or run them. - # Exercise them explicitly to keep the coverage closure enforced by CI. - name: Nested AppConfig checker tests + if: matrix.suite == 'cli' run: cargo test -p edgezero-cli --features nested-app-config-check --bin check_no_nested_app_config - name: Run workspace tests + if: matrix.suite == 'workspace' run: cargo test --workspace --all-targets - # The adapter CLI dispatch (build/deploy/stage/healthcheck/rollback) and - # its tests live behind the `cli` feature, which the unfeatured - # `cargo test --workspace` step above does not enable — so none of those - # tests compile or run there. Exercise them explicitly. + - name: Run EdgeZero CLI tests + if: matrix.suite == 'cli' + run: cargo test -p edgezero-cli --all-targets + - name: Adapter CLI dispatch tests - run: cargo test -p edgezero-adapter-fastly --all-targets --features cli + if: matrix.suite == 'cli' + run: >- + cargo test + -p edgezero-adapter-axum + -p edgezero-adapter-cloudflare + -p edgezero-adapter-fastly + -p edgezero-adapter-spin + --all-targets + --no-default-features + --features cli - name: Check feature compilation + if: matrix.suite == 'workspace' run: cargo check --workspace --all-targets --features "fastly cloudflare spin" - name: Verify a generated project compiles + if: matrix.suite == 'cli' run: cargo test -p edgezero-cli --test generated_project_builds -- --ignored - # `examples/app-demo` is excluded from the root workspace, so - # `cargo test --workspace` above does not cover it. Run its own - # workspace tests separately. An end-to-end push → - # AxumConfigStore → handler roundtrip in - # `app-demo-cli/tests/config_flow.rs` exists to be exercised by - # THIS step — without it, a regression in the JSON-file contract - # between `config push --adapter axum` and - # `AxumConfigStore::from_path` would not be caught by CI. - # Axum-only path, no live external calls — intentionally kept - # off the wasm matrix. - # `--locked`: app-demo is excluded from the root workspace and keeps its own - # committed lockfile, which drifts when the edgezero crates' dependencies - # change (e.g. a `syn` major bump lands in the root lock but not here). - # Without `--locked` CI silently regenerates it and MASKS that drift; with - # it, a stale app-demo lockfile fails here instead of at a consumer's - # `cargo build --locked`. + - name: Run Axum host tests + if: matrix.suite == 'axum' + run: cargo test -p edgezero-adapter-axum --all-targets --features axum + - name: Run app-demo workspace tests + if: matrix.suite == 'app-demo' working-directory: examples/app-demo run: cargo test --locked --workspace --all-targets @@ -139,7 +141,7 @@ jobs: - name: Retrieve Rust version id: rust-version - run: echo "rust-version=$(grep '^rust ' .tool-versions | awk '{print $2}')" >> $GITHUB_OUTPUT + run: echo "rust-version=$(grep '^rust ' .tool-versions | awk '{print $2}')" >> "$GITHUB_OUTPUT" shell: bash - name: Set up Rust tool chain diff --git a/Cargo.lock b/Cargo.lock index 92ca9374..881f58a8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -664,8 +664,12 @@ checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" name = "edgezero-adapter" version = "0.1.0" dependencies = [ + "serde", + "serde_json", + "sha2 0.10.9", "tempfile", "toml", + "walkdir", ] [[package]] diff --git a/crates/edgezero-adapter-fastly/src/chunked_config.rs b/crates/edgezero-adapter-fastly/src/chunked_config.rs index a505bfed..c497b9f2 100644 --- a/crates/edgezero-adapter-fastly/src/chunked_config.rs +++ b/crates/edgezero-adapter-fastly/src/chunked_config.rs @@ -2799,10 +2799,9 @@ mod tests { let entries = prepare_fastly_config_entries("app_config", &envelope).unwrap(); let (_, pointer_json) = entries.last().unwrap(); let mut pointer: FastlyChunkPointer = serde_json::from_str(pointer_json).unwrap(); - pointer.chunks[0].key = - pointer.chunks[0] - .key - .replacen("app_config", "app_config_staging", 1); + pointer.chunks[0].key = pointer.chunks[0] + .key + .replacen("app_config", "foreign_config", 1); let raw = serde_json::to_string(&pointer).unwrap(); let err = prior_chunk_keys("app_config", &raw).expect_err("foreign chunk should warn"); diff --git a/crates/edgezero-adapter-fastly/src/cli.rs b/crates/edgezero-adapter-fastly/src/cli.rs index 9bc1a063..c30b7c26 100644 --- a/crates/edgezero-adapter-fastly/src/cli.rs +++ b/crates/edgezero-adapter-fastly/src/cli.rs @@ -1,8 +1,12 @@ +#![expect( + clippy::arbitrary_source_item_ordering, + reason = "the managed deployment planning and execution state machine is kept as one cohesive block" +)] + use std::cell::{Cell, RefCell}; -use std::collections::{BTreeMap, HashMap, HashSet}; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use std::env; -use std::ffi::OsString; -use std::fmt::Write as _; +use std::fmt::{self, Write as _}; use std::fs; use std::io::{ErrorKind, Write as _}; use std::net::IpAddr; @@ -14,26 +18,26 @@ use std::process::id as process_id; use std::thread; use std::time::{Duration, SystemTime, UNIX_EPOCH}; -use crate::RUNTIME_ENV_STORE_NAME; use crate::chunked_config::{ CHUNK_KEY_INFIX, GcPointer, GcRootValue, ResolveFailure, chunk_key_generation, chunk_key_index, chunk_lengths, gc_classify_root, gc_verify_generation, prepare_fastly_config_entries, 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, DeployOwnership, + DeployStoreIds, ProvisionStores, ReadConfigEntry, ResolvedStoreId, register_adapter, }; +use edgezero_adapter::release::{VerifiedApplicationRelease, verify_application_release}; use edgezero_adapter::scaffold::{ AdapterBlueprint, AdapterFileSpec, CommandTemplates, DependencySpec, LoggingDefaults, ManifestSpec, ReadmeInfo, TemplateRegistration, register_adapter_blueprint, }; +use edgezero_core::env_config::{EnvConfig, merge_env_defaults}; use walkdir::WalkDir; static FASTLY_ADAPTER: FastlyCliAdapter = FastlyCliAdapter; @@ -133,23 +137,9 @@ 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. -/// -/// 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 -/// 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. -const RUNTIME_ENV_STAGING_STORE_PREFIX: &str = "edgezero_runtime_env_staging"; - /// Env var carrying the Fastly API token (read by the Fastly CLI and /// forwarded to the Fastly API via the `Fastly-Key` header). Part of -/// the Fastly staging lifecycle. +/// the managed Fastly deployment lifecycle. const FASTLY_API_TOKEN_ENV: &str = "FASTLY_API_TOKEN"; /// Env var carrying the default Fastly service id, used when /// `--service-id` is not passed explicitly. @@ -164,29 +154,9 @@ const FASTLY_API_MAX_TIME_SECS: u64 = 30; /// curl's exit code for an operation that exceeded `--connect-timeout`/`--max-time`. const CURL_EXIT_TIMEOUT: i32 = 28; -/// Flags `fastly compute update` accepts that take a VALUE (either -/// `--flag value` or `--flag=value`). Verified against -/// `fastly compute update --help` (Fastly CLI v15): the command's -/// `--service-id`/`-s`, `--service-name`, `--package`/`-p`, `--version`, -/// plus the global `--token`/`-t`. -const COMPUTE_UPDATE_VALUE_FLAGS: &[&str] = &[ - "--service-id", - "-s", - "--service-name", - "--package", - "-p", - "--version", - "--token", - "-t", -]; - -/// Boolean flags `fastly compute update` accepts: the command's -/// `--autoclone` plus the Fastly CLI globals. NOTE the absence of -/// `--comment` -- `compute update` does NOT support it (unlike -/// `compute deploy`), which is why an operator `--comment` is routed to -/// `service-version update` instead (see `deploy_staging`). -const COMPUTE_UPDATE_BOOL_FLAGS: &[&str] = &[ - "--autoclone", +/// Non-targeting Fastly CLI global booleans accepted by the managed deploy +/// path. Lifecycle-owned `--autoclone` is deliberately absent. +const MANAGED_DEPLOY_GLOBAL_BOOL_FLAGS: &[&str] = &[ "--accept-defaults", "-d", "--auto-yes", @@ -200,6 +170,39 @@ const COMPUTE_UPDATE_BOOL_FLAGS: &[&str] = &[ "-v", ]; +/// Version-scoped logging endpoint collections exposed by the Fastly API. +/// `service logging debug` streams endpoint errors and is not a collection. +const FASTLY_LOGGING_PROVIDER_KINDS: &[&str] = &[ + "azureblob", + "bigquery", + "cloudfiles", + "datadog", + "digitalocean", + "elasticsearch", + "ftp", + "gcs", + "pubsub", + "grafanacloudlogs", + "heroku", + "honeycomb", + "https", + "kafka", + "kinesis", + "logentries", + "loggly", + "logshuttle", + "newrelic", + "newrelicotlp", + "openstack", + "papertrail", + "s3", + "scalyr", + "sftp", + "splunk", + "sumologic", + "syslog", +]; + /// Hard-error message for a value written by a NEWER format this v1 CLI must not /// overwrite. Shared by the read path so the wording stays consistent. const FUTURE_FORMAT_READ_ERROR: &str = "the remote value uses a config format this CLI version does not recognise (a newer \ @@ -208,5844 +211,7989 @@ const FUTURE_FORMAT_READ_ERROR: &str = "the remote value uses a config format th struct FastlyCliAdapter; -/// An operator passthrough arg list split for a staged deploy (see -/// `split_staging_passthrough`). -struct StagingPassthrough { - /// The `--comment` value, applied to the version separately via - /// `fastly service-version update --comment` (`compute update` has - /// no `--comment` flag). +#[derive(Debug, Eq, PartialEq)] +struct ReleaseManagedDeployArgs { comment: Option, - /// Args `compute update` does not support; dropped with a warning - /// rather than forwarded (forwarding them makes the CLI exit - /// non-zero and fails the whole staged deploy). - dropped: Vec, - /// Args that `fastly compute update` actually supports. - forwarded: Vec, + globals: Vec, } -/// Outcome of scanning `fastly config-store list --json` for a -/// platform store id by `name`. Distinguishes three cases the -/// caller wants to act on differently: -/// -/// - `Found(id)` — happy path. -/// - `NotFound` — JSON parsed cleanly and the array contains -/// entries with well-formed `name` + `id` string fields, but no -/// entry matched `name`. Operator likely needs to run -/// `provision`. -/// - `SchemaDrift(detail)` — the JSON parsed but doesn't match -/// the expected shape (no `items` envelope nor bare array, OR -/// entries are missing `name` / `id` string fields, OR the -/// bytes didn't parse as JSON at all). Likely a fastly CLI -/// version bump that changed the output schema; surface the -/// detail so the operator can pin a known-compatible version. -#[derive(Debug)] -enum ConfigStoreLookup { - Found(String), - NotFound, - SchemaDrift(String), +struct FastlyApiToken(String); + +impl fmt::Debug for FastlyApiToken { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("FastlyApiToken([REDACTED])") + } } -/// 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 -/// `gc_fastly_config_store` (which reports and deletes). -struct GcPlan { - /// Whole generations to reclaim, each a list of `(key, age_secs)`. Grouped, - /// not flat: a generation is provable only as a UNIT (see - /// `prove_generation`), so deleting part of one destroys the very evidence - /// that licenses deleting the rest. - doomed: Vec>, - /// The root keys retained as live/protected — the config entries GC will NOT - /// delete, sorted. Surfaced so a run shows what it is KEEPING, not only what - /// it would delete, making the sweep reviewable. - kept_roots: Vec, - live_count: usize, - retained_recent: usize, - roots: usize, - /// Chunk-shaped entries we could NOT prove our writer produced, so left - /// untouched. Surfaced so an operator can see we declined to judge them. - unprovable: usize, - /// Non-fatal problems to print — see `GcClassification::warnings`. - warnings: Vec, +impl FastlyApiToken { + fn as_str(&self) -> &str { + &self.0 + } } -/// What one pass of `config gc`'s delete loop actually did. -struct GcDeleteOutcome { - /// Entries whose delete returned success. - deleted: usize, - /// Keys whose delete returned non-zero. - failed: Vec, - /// Survivors of a generation in which an earlier sibling's delete had - /// ALREADY succeeded before a later one failed. These are definitely an - /// incomplete generation now, so they can never be proved (or reclaimed) - /// again -- manual removal only. - stranded: Vec, - /// Members of a generation whose ONLY failure was on a delete with no - /// confirmed prior sibling success. A failed remote delete has UNKNOWN - /// outcome (Fastly may have committed it before returning an error), so we - /// cannot say whether the generation is still whole. A re-run reclaims it if - /// it is, or reports it as an unprovable fragment if it is not. - uncertain: Vec, +#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq)] +struct ServiceVersionRecord { + #[serde(alias = "Active")] + active: bool, + #[serde(alias = "Environments")] + environments: Vec, + #[serde(alias = "Locked")] + locked: bool, + #[serde(alias = "Number")] + number: u64, } -/// The result of classifying a store's entries for reclamation. -struct GcClassification { - /// Chunk keys a live root pointer references, each verified against its - /// content-address. Never deletable. - live: HashSet, - /// Keys whose OWN value is a runtime-readable root — a valid direct envelope - /// or a pointer — regardless of what their key looks like. Never deletable. - protected: HashSet, - /// Count of entries classified as roots, for the summary line. - roots: usize, - /// Non-fatal problems the operator should see — currently roots that are - /// not runtime-readable and so can never be reclaimed automatically. - warnings: Vec, +#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq)] +struct ServiceEnvironmentRecord { + #[serde(alias = "ServiceVersion")] + active_version: u64, + #[serde(alias = "Name")] + name: String, + #[serde(alias = "ServiceID")] + service_id: String, } -/// One `config-store-entry list` item. -/// -/// `item_value` IS captured — `config gc` must parse root pointers to learn -/// which chunks are live, and one listing avoids a `describe` per root. It is -/// the config payload: it may be read in memory but must NEVER be logged or -/// surfaced (see `redact_describe_response` / `redact_stderr`). -struct ConfigStoreItem { - created_at: String, - item_key: String, - item_value: String, +#[derive(Debug, serde::Deserialize)] +struct ComputePackageRecord { + metadata: ComputePackageMetadata, + service_id: String, + version: u64, } -/// Per-root plan for the LOCAL path's eager prune. -/// -/// Local reclamation is safe to do immediately: `fastly.toml` is a single -/// file that Viceroy reads at startup — there is no propagation window and no -/// POP that could still be serving the previous pointer. (The cloud path -/// cannot do this; see `reclaim_orphan_generations`.) -struct FastlyConfigGcPlan { - /// Exact keep-set this push writes for the root (chunk keys + root key). - new_keys: HashSet, - /// Prior chunk keys to consider deleting, or a warning to surface - /// (suspicious prior pointer) that skips GC for this root. - prior_keys: Result, String>, +#[derive(Debug, serde::Deserialize)] +struct ComputePackageMetadata { + files_hash: String, } -/// An exclusive, cross-process advisory lock covering a local `fastly.toml` -/// rewrite. Serialises concurrent pushes so their read-modify-write cycles -/// cannot interleave and lose each other's edits. -/// -/// The lock is a persistent sidecar file next to the manifest. It is never -/// unlinked — deleting it would reintroduce a create/lock race between two -/// processes each making their own lock file. Dropping the guard releases the -/// OS lock (closing the file descriptor). `File::lock` is advisory, so it only -/// coordinates other lockers, which is exactly the pushes we control. -struct ManifestLock { - _file: fs::File, - /// The REAL file the lock guards, resolved through any symlink. Callers read - /// and replace THIS path, so every alias operates on one target. - target: PathBuf, +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum VersionSource { + Active(u64), + InitialDraft(u64), + Retired(u64), + Staging(u64), } -/// Removes a staging temp file on drop unless disarmed — so every early return -/// (permission failure, write failure, rename failure) cleans up after itself. -struct TempFileGuard { - path: Option, +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum PublishTarget { + Production, + Staging, } -struct EntryCommitFailure { - committed: Vec, - error: String, - failed_key: String, - not_attempted: Vec, - total: usize, +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum StagingRollbackDecision { + Deactivate, + NoopDraft, } -struct RuntimeStoreNameReconciliation { - deletes: Vec, - upserts: Vec<(String, String)>, +#[derive(Debug)] +struct InitialDraftSnapshot { + configuration: VersionConfigurationSnapshot, + links: serde_json::Value, + metadata: serde_json::Value, + version: ServiceVersionRecord, } -// The three `validate_*` trait methods exist on `Adapter` because -// spin requires them (variable-name regex, `[component.*]` -// discovery, flat-namespace collision). The trait surface is typed -// generically so any future adapter with similar constraints can -// override — but fastly has no equivalent platform requirements, -// so the no-op defaults are correct: -// -// - `validate_app_config_keys`: Fastly Config Store keys accept -// alphanumeric + `-` / `_` / `.` up to 256 chars. Any reasonable -// Rust struct field name passes; no regex check needed. -// - `validate_adapter_manifest`: would require shelling out to -// `fastly compute validate` at validate-time. We keep -// `config validate` pure-Rust so it stays fast and -// tool-independent. -// - `validate_typed_secrets`: Fastly's KV / Config / Secret -// stores are independent namespaces — no spin-style flat- -// namespace collision risk to detect. -// -// `single_store_kinds` IS overridden below — explicitly returns -// `&[]` for documentation, matching the inherited default. -#[expect( - clippy::missing_trait_methods, - reason = "see the explanatory block comment immediately above; fastly's no-op defaults for the three validate_* hooks are intentional and documented. `read_config_entry` and `read_config_entry_local` are both overridden below. `single_store_kinds` IS overridden below (returns `&[]`)." -)] -impl Adapter for FastlyCliAdapter { - fn execute(&self, action: AdapterAction, args: &[String]) -> Result<(), String> { - match action { - // `fastly profile {create|delete|list}` is the native - // sign-in surface for Fastly Compute. EdgeZero stores no - // credentials — this is a thin shell-out. - AdapterAction::AuthLogin => { - run_native_cli("fastly", &["profile", "create"], FASTLY_INSTALL_HINT) - } - AdapterAction::AuthLogout => { - run_native_cli("fastly", &["profile", "delete"], FASTLY_INSTALL_HINT) - } - AdapterAction::AuthStatus => { - run_native_cli("fastly", &["profile", "list"], FASTLY_INSTALL_HINT) - } - AdapterAction::Build => { - let artifact = build(args)?; - log::info!("[edgezero] Fastly build complete -> {}", artifact.display()); - Ok(()) - } - AdapterAction::Deploy => deploy(args), - AdapterAction::Serve => serve(args), - // Fastly staging lifecycle. - AdapterAction::DeployStaging => deploy_staging(args), - AdapterAction::EmitVersion => emit_active_version(args), - AdapterAction::Healthcheck => healthcheck(args), - AdapterAction::Rollback => rollback(args), - other => Err(format!("fastly adapter does not support {other:?}")), +#[derive(Debug)] +struct InactiveSourceSnapshot { + links: serde_json::Value, + metadata: serde_json::Value, + version: ServiceVersionRecord, +} + +#[derive(Clone, Debug, PartialEq)] +struct LoggingProviderSnapshot { + endpoints: serde_json::Value, + kind: &'static str, +} + +#[derive(Clone, Debug, PartialEq)] +struct VersionConfigurationSnapshot { + backends: serde_json::Value, + domains: serde_json::Value, + healthchecks: serde_json::Value, + logging: Vec, + settings: serde_json::Value, +} + +#[derive(Debug)] +enum EditableVersionSource { + CloneActive { active_version: u64 }, + CloneRetired(Box), + CloneStaging(Box), + InitialDraft(Box), +} + +#[derive(Debug)] +struct ManagedDeployPlan { + arguments: ReleaseManagedDeployArgs, + links: LinkReconciliation, + package_files_hash: String, + package_sha256: String, + release: VerifiedApplicationRelease, + service_id: String, + source_configuration: VersionConfigurationSnapshot, + source_links: Vec, + target: PublishTarget, + token: FastlyApiToken, + version_source: EditableVersionSource, +} + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +enum ResourceKind { + Config, + Kv, + Secret, +} + +impl ResourceKind { + fn display_name(self) -> &'static str { + match self { + Self::Config => "Config Store", + Self::Kv => "KV Store", + Self::Secret => "Secret Store", } } - fn gc_config_entries( - &self, - _manifest_root: &Path, - _adapter_manifest_path: Option<&str>, - _component_selector: Option<&str>, - store: &ResolvedStoreId, - _push_ctx: &AdapterPushContext<'_>, - older_than_secs: u64, - dry_run: bool, - ) -> Result, String> { - gc_fastly_config_store(store.platform.as_str(), older_than_secs, dry_run) + fn runtime_name(self) -> &'static str { + match self { + Self::Config => "config", + Self::Kv => "kv", + Self::Secret => "secrets", + } } +} - fn name(&self) -> &'static str { - "fastly" - } +#[derive(Clone, Debug, Eq, PartialEq)] +struct DesiredResourceLink { + alias: String, + kind: ResourceKind, + resource_id: String, + selected_name: String, +} - fn preflight_config_write(&self, key: &str, body: &str) -> Result<(), String> { - // Reject an infeasible push here, BEFORE the CLI's remote read, so it - // fails offline rather than after a list/describe. The write path - // re-checks, so this is a strict early gate, not the only one. - // - // An empty key is writer-valid but resolver-invalid (canonical chunk - // parsing rejects an empty root); reject it before any I/O. - if key.is_empty() { - return Err( - "config key is empty; provide a store id or a non-empty `--key`".to_owned(), - ); +#[derive(Clone, Debug, Eq, PartialEq)] +struct ExistingResourceLink { + alias: String, + kind: ResourceKind, + link_id: String, + resource_id: String, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +struct LinkReconciliation { + create: Vec, + delete_link_ids: Vec, +} + +#[derive(Clone, Debug)] +struct StoreInventory { + by_name: BTreeMap, +} + +#[derive(serde::Deserialize)] +struct PaginatedStoreInventoryPage { + #[serde(alias = "Data")] + data: Vec, + #[serde(alias = "Meta")] + meta: PaginatedStoreInventoryMeta, +} + +#[derive(serde::Deserialize)] +struct PaginatedStoreInventoryRecord { + #[serde(alias = "StoreID")] + id: String, + #[serde(alias = "Name")] + name: String, +} + +#[derive(serde::Deserialize)] +struct PaginatedStoreInventoryMeta { + next_cursor: Option, +} + +#[derive(Clone, Debug)] +struct ResourceInventories { + config: StoreInventory, + kind_by_resource_id: BTreeMap, + kv: StoreInventory, + secret: StoreInventory, +} + +impl ResourceInventories { + fn for_kind(&self, kind: ResourceKind) -> &StoreInventory { + match kind { + ResourceKind::Config => &self.config, + ResourceKind::Kv => &self.kv, + ResourceKind::Secret => &self.secret, } - let entry = [(key.to_owned(), String::new())]; - reject_reserved_root_keys(&entry)?; - // Run the full chunk expansion OFFLINE (no I/O): exactly what the write - // path does, so every body-dependent feasibility failure — the root key - // over the store limit, a DERIVED chunk key over it once the value - // chunks, or a pointer that would not fit the entry limit — is caught - // here, before the remote read, instead of after it. - prepare_fastly_config_entries(key, body)?; - Ok(()) } - fn provision( - &self, - manifest_root: &Path, - adapter_manifest_path: Option<&str>, - _component_selector: Option<&str>, - stores: &ProvisionStores<'_>, - dry_run: bool, - ) -> Result, String> { - // Fastly is Multi for every store kind. Each id maps 1:1 - // to a Fastly resource (kv-store / config-store / - // secret-store) created via the Fastly CLI; the manifest - // writeback declares the resource link for `fastly - // compute deploy` and the local viceroy server. - let Some(rel) = adapter_manifest_path else { - return Err( - "[adapters.fastly.adapter].manifest must point at fastly.toml for provision" - .to_owned(), - ); - }; - let fastly_path = manifest_root.join(rel); - let manifest_dir = fastly_path.parent().unwrap_or(manifest_root); - let runtime_env_service_id = - provision_runtime_env_service_id_for_stores(&fastly_path, stores)?; - - let mut out = Vec::new(); - for (kind, ids) in [ - ("kv", stores.kv), - ("config", stores.config), - ("secret", stores.secrets), + #[cfg_attr( + not(test), + expect( + dead_code, + reason = "the pure JSON constructor is retained for clean-cutover planner tests" + ) + )] + fn from_json(raw_config: &str, raw_kv: &str, raw_secret: &str) -> Result { + let config = parse_store_inventory(ResourceKind::Config, raw_config)?; + let kv = parse_store_inventory(ResourceKind::Kv, raw_kv)?; + let secret = parse_store_inventory(ResourceKind::Secret, raw_secret)?; + Self::from_inventories(config, kv, secret) + } + + fn from_inventories( + config: StoreInventory, + kv: StoreInventory, + secret: StoreInventory, + ) -> Result { + let mut kind_by_resource_id = BTreeMap::new(); + for (kind, inventory) in [ + (ResourceKind::Config, &config), + (ResourceKind::Kv, &kv), + (ResourceKind::Secret, &secret), ] { - for store in ids { - // Fastly setup tables key on the resource name the - // CLI creates. The runtime resolves that same name - // via `EDGEZERO__STORES______NAME`, - // so provision must use the env-resolved PLATFORM - // name -- the logical id stays in status lines for - // human-facing wording. - let logical = store.logical.as_str(); - let name = store.platform.as_str(); - if dry_run { - out.push(format!( - "would run `fastly {kind}-store create --name={name}` and append [setup.{kind}_stores.{name}] to {} (logical id `{logical}`)", - fastly_path.display() - )); - continue; - } - if setup_block_present(&fastly_path, kind, name)? { - out.push(format!( - "fastly {kind}-store `{name}` (logical id `{logical}`) already declared in {}; skipping. To force a fresh remote: delete the [setup.{kind}_stores.{name}] block AND run `fastly {kind}-store delete --name={name}` (the old remote store lingers otherwise), then re-run provision.", - fastly_path.display() + for resource_id in inventory.by_name.values() { + if let Some(prior_kind) = kind_by_resource_id.insert(resource_id.clone(), kind) { + return Err(format!( + "Fastly resource id `{resource_id}` appears in both {} and {} inventories; resource kind is ambiguous", + prior_kind.display_name(), + kind.display_name() )); - continue; - } - create_fastly_store_in(kind, name, manifest_dir)?; - // If the platform store was created but the - // writeback fails, remote state and the local - // manifest are out of sync. Re-running `provision` - // would attempt to create the platform store again - // and fail with "already exists". Surface the - // recovery path explicitly so the operator isn't - // stuck. - append_fastly_setup(&fastly_path, kind, name).map_err(|err| { - format!( - "fastly {kind}-store `{name}` (logical id `{logical}`) was created remotely, but writeback to {path} failed: {err}\n To recover, either:\n 1. Manually append `[setup.{kind}_stores.{name}]` to {path} and re-run, or\n 2. Delete the orphan remote store via `fastly {kind}-store delete --name={name}` and re-run `edgezero provision --adapter fastly`.", - path = fastly_path.display() - ) - })?; - // Fastly's `[setup._stores.]` table is - // consumed ONLY when `fastly compute deploy` is - // creating a NEW service. If `service_id` is - // already present in fastly.toml, the service has - // been deployed at least once and subsequent - // deploys skip `[setup]` entirely — so the store - // exists in the account but has no resource link - // tying it to a service version, and the running - // Compute service can't open it. - // - // Detect that case and EMIT the exact one-shot - // command the operator should run to link the - // store. We deliberately don't auto-run it: the - // link cones the active version (`--autoclone`), - // and silently mutating an already-deployed - // service is surprising. The instruction names - // both the store-id lookup AND the link command so - // the operator can audit before committing. - let post_create_note = resource_link_note(&fastly_path, kind, name)?; - let mut line = format!( - "created fastly {kind}-store `{name}` (logical id `{logical}`); appended setup tables to {}", - fastly_path.display() - ); - if let Some(note) = post_create_note { - line.push('\n'); - line.push_str(¬e); } - out.push(line); } } - // EdgeZero runtime overrides live in a dedicated Fastly Config - // Store named `edgezero_runtime_env`. Compute@Edge has no - // process env, so `EDGEZERO__STORES__CONFIG____KEY` and - // similar overrides have to come from a platform Config Store - // the runtime opens by name (see `runtime_env_config` in - // lib.rs). Provision owns the store creation alongside the - // operator's declared stores so the runtime override path is - // wired correctly out of the box; if the store already appears - // in `[setup.config_stores.edgezero_runtime_env]`, skip. - let runtime_env_kind = "config"; - let runtime_env_name = RUNTIME_ENV_STORE_NAME; - if dry_run { - out.push(format!( - "would run `fastly {runtime_env_kind}-store create --name={runtime_env_name}` and append [setup.{runtime_env_kind}_stores.{runtime_env_name}] to {} (EdgeZero runtime override store)", - fastly_path.display() + Ok(Self { + config, + kind_by_resource_id, + kv, + secret, + }) + } + + fn resolve(&self, kind: ResourceKind, name: &str) -> Result { + self.for_kind(kind) + .by_name + .get(name) + .cloned() + .ok_or_else(|| { + format!( + "selected Fastly {} `{name}` does not exist in the complete provider inventory", + kind.display_name() + ) + }) + } +} + +fn parse_store_inventory(kind: ResourceKind, raw: &str) -> Result { + let parsed: serde_json::Value = serde_json::from_str(raw).map_err(|error| { + format!( + "failed to parse Fastly {} inventory as JSON: {error}", + kind.display_name() + ) + })?; + let rows = parsed.as_array().ok_or_else(|| { + format!( + "Fastly {} inventory must be one complete bare JSON array; paginated or enveloped results are not accepted", + kind.display_name() + ) + })?; + let mut records = Vec::with_capacity(rows.len()); + for (index, row) in rows.iter().enumerate() { + let object = row.as_object().ok_or_else(|| { + format!( + "Fastly {} inventory record #{index} is not an object", + kind.display_name() + ) + })?; + let field = |name| { + object + .get(name) + .and_then(serde_json::Value::as_str) + .filter(|field_value| !field_value.is_empty()) + }; + let (Some(name), Some(id)) = (field("name"), field("id")) else { + return Err(format!( + "Fastly {} inventory record #{index} requires non-empty string `name` and `id` fields", + kind.display_name() + )); + }; + records.push((name.to_owned(), id.to_owned())); + } + store_inventory_from_records(kind, records) +} + +fn store_inventory_from_records( + kind: ResourceKind, + records: impl IntoIterator, +) -> Result { + let mut by_name = BTreeMap::new(); + let mut ids = BTreeSet::new(); + for (index, (name, id)) in records.into_iter().enumerate() { + if id.is_empty() + || !id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) + { + return Err(format!( + "Fastly {} inventory record #{index} has invalid resource id", + kind.display_name() + )); + } + if name.is_empty() + || name.chars().all(char::is_whitespace) + || name.chars().any(char::is_control) + { + return Err(format!( + "Fastly {} inventory record #{index} has invalid store name", + kind.display_name() + )); + } + if by_name.insert(name.clone(), id.clone()).is_some() { + return Err(format!( + "Fastly {} inventory contains duplicate name `{name}`", + kind.display_name() )); - } else if !setup_block_present(&fastly_path, runtime_env_kind, runtime_env_name)? { - create_fastly_store_in(runtime_env_kind, runtime_env_name, manifest_dir)?; - append_fastly_setup(&fastly_path, runtime_env_kind, runtime_env_name).map_err( - |err| { - format!( - "fastly {runtime_env_kind}-store `{runtime_env_name}` was created remotely, but writeback to {path} failed: {err}\n Recover via `fastly {runtime_env_kind}-store delete --name={runtime_env_name}` then re-run `edgezero provision --adapter fastly`.", - path = fastly_path.display() - ) - }, - )?; - // Same already-deployed-service caveat as the declared-store - // path: if `service_id` is set in fastly.toml, the - // `[setup.config_stores.edgezero_runtime_env]` table won't - // be re-applied by the next `fastly compute deploy`, so the - // runtime can't open the store. Emit the resource-link - // remediation alongside the populate-keys hint. - let post_create_note = - resource_link_note(&fastly_path, runtime_env_kind, runtime_env_name)?; - // 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 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.", - fastly_path.display() - ); - if let Some(note) = post_create_note { - line.push('\n'); - line.push_str(¬e); - } - out.push(line); - } else { - // Already declared; nothing to do. } + if !ids.insert(id.clone()) { + return Err(format!( + "Fastly {} inventory contains duplicate resource id `{id}`", + kind.display_name() + )); + } + } + Ok(StoreInventory { by_name }) +} - out.extend(persist_runtime_env_store_name_entries( - stores, - runtime_env_service_id.as_deref(), - dry_run, - manifest_dir, - )?); +fn percent_encode_query_value(value: &str) -> Result { + let mut encoded = String::with_capacity(value.len()); + for byte in value.bytes() { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') { + encoded.push(char::from(byte)); + } else { + write!(encoded, "%{byte:02X}") + .map_err(|error| format!("failed to encode Fastly pagination cursor: {error}"))?; + } + } + Ok(encoded) +} - // 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 - // it always mirrors production's CURRENT overrides. Provision does not - // touch it: a twin populated here would drift the moment an operator - // edited a production override. +fn collect_paginated_store_inventory( + kind: ResourceKind, + first_path: &str, + mut fetch: impl FnMut(&str) -> Result, +) -> Result { + let mut path = first_path.to_owned(); + let mut seen_cursors = BTreeSet::new(); + let mut records = Vec::new(); + loop { + let raw = fetch(&path)?; + let page: PaginatedStoreInventoryPage = serde_json::from_str(&raw).map_err(|_error| { + format!( + "Fastly {} inventory page has an invalid paginated response shape (payload redacted)", + kind.display_name() + ) + })?; + records.extend(page.data.into_iter().map(|record| (record.name, record.id))); + let Some(cursor) = page.meta.next_cursor.filter(|cursor| !cursor.is_empty()) else { + break; + }; + if !seen_cursors.insert(cursor.clone()) { + return Err(format!( + "Fastly {} inventory repeated a pagination cursor; completeness cannot be proven", + kind.display_name() + )); + } + path = format!( + "{first_path}&cursor={}", + percent_encode_query_value(&cursor)? + ); + } + store_inventory_from_records(kind, records) +} - if out.is_empty() { - out.push("fastly has no declared stores to provision".to_owned()); +fn fetch_complete_paginated_store_inventory( + kind: ResourceKind, + endpoint: &str, + token: &str, +) -> Result { + let first_path = format!("{endpoint}?limit=100"); + collect_paginated_store_inventory(kind, &first_path, |path| fastly_api_get(path, token)) +} + +fn desired_resource_links( + stores: &RuntimeStoreIds, + environment: &EnvConfig, + inventories: &ResourceInventories, +) -> Result, String> { + let mut desired_by_identity = BTreeMap::<(ResourceKind, String), DesiredResourceLink>::new(); + + for (kind, logical_ids) in [ + (ResourceKind::Config, &stores.config), + (ResourceKind::Kv, &stores.kv), + (ResourceKind::Secret, &stores.secrets), + ] { + for logical_id in logical_ids { + let selected_name = environment + .store_name_checked(kind.runtime_name(), logical_id) + .map_err(|error| format!("invalid Fastly deploy environment: {error}"))?; + let resource_id = inventories.resolve(kind, &selected_name)?; + let desired = DesiredResourceLink { + kind, + alias: logical_id.clone(), + selected_name: selected_name.clone(), + resource_id, + }; + let identity = (kind, logical_id.clone()); + if desired_by_identity.insert(identity, desired).is_some() { + return Err(format!( + "Fastly {} logical store id `{logical_id}` is declared more than once", + kind.display_name() + )); + } } - Ok(out) } + Ok(desired_by_identity.into_values().collect()) +} - fn push_config_entries( - &self, - _manifest_root: &Path, - _adapter_manifest_path: Option<&str>, - _component_selector: Option<&str>, - store: &ResolvedStoreId, - entries: &[(String, String)], - _push_ctx: &AdapterPushContext<'_>, - dry_run: bool, - ) -> Result, String> { - // Resolve the platform config-store id on demand via - // `fastly config-store list --json` (matched by name = - // `store.platform`), then `fastly config-store-entry update - // --store-id= --key= --upsert --stdin` per physical - // entry. Entries are logical blob-envelope entries from - // the CLI (one (key, envelope_json) per push); oversized - // Fastly values are expanded below into chunk entries plus - // a root pointer by `chunked_config::prepare_fastly_config_entries`. - let logical = store.logical.as_str(); - let name = store.platform.as_str(); - if entries.is_empty() { - return Ok(vec![format!( - "no config entries to push to fastly config-store `{name}` (logical id `{logical}`)" - )]); +fn plan_link_reconciliation( + desired: &[DesiredResourceLink], + existing: &[ExistingResourceLink], + inventories: &ResourceInventories, +) -> Result { + let mut desired_identities = BTreeSet::new(); + for link in desired { + let identity = (link.kind, link.alias.as_str()); + if !desired_identities.insert(identity) { + return Err(format!( + "desired Fastly {} resource-link alias `{}` is duplicated", + link.kind.display_name(), + link.alias + )); } - // Reject reserved keys before any expansion or I/O. - reject_reserved_root_keys(entries)?; - reject_duplicate_root_keys(entries)?; - // Expand each logical root into its physical entries (chunks + pointer, or - // a single direct entry). Collecting them all first surfaces a - // pointer-too-large error before touching the remote store. A cloud push - // does NOT reclaim, so — unlike the local path — it keeps no per-root - // keep-set / root-value GC bookkeeping. - let mut physical_entries: Vec<(String, String)> = Vec::new(); - for (key, body) in entries { - let (expanded, ..) = expand_root(key, body)?; - physical_entries.extend(expanded); + } + let mut existing_by_identity = BTreeMap::new(); + let mut existing_ids = BTreeSet::new(); + for link in existing { + if !existing_ids.insert(link.link_id.as_str()) { + return Err(format!( + "Fastly resource-link inventory contains duplicate link id `{}`", + link.link_id + )); } - if dry_run { - // Report intent without shelling out. Stays fully offline: no - // store-id resolution, no remote read (so no GC count). - let mut out = Vec::with_capacity(entries.len().saturating_mul(2).saturating_add(1)); - out.push(format!( - "would resolve fastly config-store `{name}` (logical id `{logical}`) via `fastly config-store list --json` and push entries:" + match inventories.kind_by_resource_id.get(&link.resource_id) { + Some(kind) if *kind == link.kind => {} + Some(kind) => { + return Err(format!( + "Fastly resource link `{}` reports {} but resource `{}` belongs to {}", + link.alias, + link.kind.display_name(), + link.resource_id, + kind.display_name() + )); + } + // Account inventory visibility can be narrower than the resource + // links inherited by this service version. The link response is + // authoritative for an undeclared inherited link, which must + // survive reconciliation even when the token cannot list its + // physical resource. + None => {} + } + let identity = (link.kind, link.alias.as_str()); + if existing_by_identity.insert(identity, link).is_some() { + return Err(format!( + "Fastly resource-link inventory contains duplicate {} alias `{}`", + link.kind.display_name(), + link.alias )); - for (key, body) in entries { - let expanded = prepare_fastly_config_entries(key, body) - .unwrap_or_else(|_| vec![(key.clone(), body.clone())]); - if expanded.len() == 1 { - out.push(format!( - " would push `{key}` as direct entry ({}B)", - body.len() - )); - } else { - let chunk_count = expanded.len().saturating_sub(1); - out.push(format!( - " would push `{key}` as chunked ({chunk_count} chunks + 1 pointer, {}B total)", - body.len() - )); - } + } + } + + let mut create = Vec::new(); + let mut delete_link_ids = Vec::new(); + for desired_link in desired { + let identity = (desired_link.kind, desired_link.alias.as_str()); + match existing_by_identity.get(&identity) { + Some(existing_link) if existing_link.resource_id == desired_link.resource_id => {} + Some(existing_link) => { + delete_link_ids.push(existing_link.link_id.clone()); + create.push(desired_link.clone()); } - return Ok(out); + None => create.push(desired_link.clone()), } - let resolved_id = - resolve_remote_config_store_id(name)?.ok_or_else(|| no_matching_store_error(name))?; - // NOTE: a cloud push does NOT reclaim orphaned chunks. - // - // Fastly's config store is eventually consistent, so a generation may - // only be deleted once the pointer that referenced it has stopped being - // served everywhere. Fastly records no pointer-supersession time - // (`updated_at` is NOT bumped by `update --upsert` -- verified against - // the live API), offers no compare-and-swap with which to record one - // safely, and chunk `created_at` is NOT a proxy for it (a chunked -> - // direct -> direct transition leaves the old generation with no - // "successor" at all). Every attempt to synthesise that fact is unsound. - // - // So reclamation is an explicit, operator-invoked `config gc`: the - // operator supplies the one fact the platform cannot -- that the current - // config has been live long enough that nothing is serving the old - // pointers. See the spec's "Cloud reclamation". - // Preflight: refuse if a generated chunk key would clobber an existing - // root-like sibling in the remote store. Uses a completeness-strict key - // listing (value-tolerant) and describes only the rare colliding keys. - let remote_keys = list_config_store_keys(&resolved_id)?; - reject_generated_key_collisions(&physical_entries, &remote_keys, |chunk_key| { - fetch_remote_config_store_entry(&resolved_id, chunk_key).map(Some) - })?; - push_entries_with_committer(&physical_entries, |key, value| { - create_config_store_entry(&resolved_id, key, value) + } + + delete_link_ids.sort(); + delete_link_ids.dedup(); + create.sort_by(|left, right| (left.kind, &left.alias).cmp(&(right.kind, &right.alias))); + Ok(LinkReconciliation { + create, + delete_link_ids, + }) +} + +#[expect( + clippy::too_many_lines, + reason = "the read-only checkpoint intentionally assembles every validated input into one reviewable plan" +)] +fn build_managed_deploy_plan( + context: &AdapterDeployContext, + args: &[String], +) -> Result { + let arguments = parse_release_managed_deploy_args(args)?; + let target = if context.staging { + PublishTarget::Staging + } else { + PublishTarget::Production + }; + let environment = effective_deploy_environment(context)?; + let release_root = context + .application_release_root + .as_deref() + .ok_or_else(|| "managed Fastly deployment requires --application-release".to_owned())?; + let application_manifest = context + .application_manifest_path + .as_deref() + .ok_or_else(|| { + "managed Fastly deployment requires the exact loaded application manifest path" + .to_owned() })?; - Ok(vec![format!( - "pushed {} physical entries ({} logical) to fastly config-store `{name}` (logical id `{logical}`, id={resolved_id})", - physical_entries.len(), - entries.len() - )]) + let adapter_manifest = context.adapter_manifest_path.as_deref().ok_or_else(|| { + "managed Fastly deployment requires the exact referenced Fastly manifest path".to_owned() + })?; + let release = verify_application_release( + release_root, + application_manifest, + adapter_manifest, + "fastly", + 1, + )?; + let service_id = resolve_managed_plan_service_id(context, &release)?; + let token = FastlyApiToken(require_token()?); + if token.as_str().is_empty() { + return Err(format!( + "{FASTLY_API_TOKEN_ENV} must be non-empty in the environment" + )); } - fn push_config_entries_local( - &self, - manifest_root: &Path, - adapter_manifest_path: Option<&str>, - _component_selector: Option<&str>, - store: &ResolvedStoreId, - entries: &[(String, String)], - _push_ctx: &AdapterPushContext<'_>, - dry_run: bool, - ) -> Result, String> { - // Local-emulator path: edit - // `[local_server.config_stores..contents]` in - // `fastly.toml`. Viceroy reads it on startup, so a - // subsequent `fastly compute serve` exposes the new values - // to the wasm component. No shell-out to the production - // Fastly CLI -- the operator may not be authenticated and - // wouldn't want a local push to touch production anyway. - let Some(rel) = adapter_manifest_path else { - return Err( - "[adapters.fastly.adapter].manifest must point at fastly.toml for config push --local" - .to_owned(), - ); - }; - let fastly_path = manifest_root.join(rel); - let logical = store.logical.as_str(); - let name = store.platform.as_str(); - if entries.is_empty() { - return Ok(vec![format!( - "no config entries to push to `[local_server.config_stores.{name}]` in {} (logical id `{logical}`)", - fastly_path.display() - )]); - } - // Reject reserved keys before any expansion or I/O. - reject_reserved_root_keys(entries)?; - reject_duplicate_root_keys(entries)?; - // Expand each logical root once: flatten for the write, keep the - // exact per-root keep-set for GC (no prefix scan of the flattened set). - let mut physical_entries: Vec<(String, String)> = Vec::new(); - let mut gc_roots: Vec<(String, HashSet)> = Vec::with_capacity(entries.len()); - for (key, body) in entries { - let (expanded, new_keys, _new_root) = expand_root(key, body)?; - physical_entries.extend(expanded); - gc_roots.push((key.clone(), new_keys)); - } - if dry_run { - let counts = local_orphan_counts_for_dry_run(&fastly_path, name, entries); - let mut out = Vec::with_capacity(entries.len().saturating_mul(2).saturating_add(1)); - out.push(format!( - "would edit `[local_server.config_stores.{name}.contents]` in {} (logical id `{logical}`) with entries:", - fastly_path.display(), - )); - for (idx, (key, body)) in entries.iter().enumerate() { - let expanded = prepare_fastly_config_entries(key, body) - .unwrap_or_else(|_| vec![(key.clone(), body.clone())]); - if expanded.len() == 1 { - out.push(format!( - " would set `{key}` as direct entry ({}B)", - body.len() - )); - } else { - let chunk_count = expanded.len().saturating_sub(1); - out.push(format!( - " would set `{key}` as chunked ({chunk_count} chunks + 1 pointer, {}B total)", - body.len() - )); - } - match counts.get(idx).map(|(_, count)| count) { - Some(Ok(n)) => out.push(format!( - " would delete {n} orphan chunks from the previous generation of `{key}`" - )), - Some(Err(reason)) => out.push(format!( - " would delete an unknown number of orphan chunks from the previous generation of `{key}` (unknown: {reason})" - )), - None => {} - } - } - return Ok(out); - } - let warnings = - write_fastly_local_config_store(&fastly_path, name, &physical_entries, &gc_roots)?; - let mut out = vec![format!( - "wrote {} physical entries ({} logical) to `[local_server.config_stores.{name}.contents]` in {} (logical id `{logical}`); restart `fastly compute serve` to pick up changes", - physical_entries.len(), - entries.len(), - fastly_path.display() - )]; - out.extend(warnings); - Ok(out) + let stores = RuntimeStoreIds::from(&context.stores); + for logical_id in &stores.config { + let key = environment + .store_key_checked(ResourceKind::Config.runtime_name(), logical_id) + .map_err(|error| format!("invalid Fastly deploy environment: {error}"))?; + validate_fastly_config_key(logical_id, &key, target == PublishTarget::Staging, false)?; } + let cwd = release + .adapter_manifest() + .parent() + .ok_or_else(|| "verified Fastly manifest has no parent directory".to_owned())?; + let package_files_hash = compute_package_files_hash(release.package(), cwd, token.as_str())?; + + let raw_config_inventory = run_fastly_json_capture(&["config-store", "list", "--json"], cwd)?; + let config_inventory = parse_store_inventory(ResourceKind::Config, &raw_config_inventory)?; + let kv_inventory = fetch_complete_paginated_store_inventory( + ResourceKind::Kv, + "/resources/stores/kv", + token.as_str(), + )?; + let secret_inventory = fetch_complete_paginated_store_inventory( + ResourceKind::Secret, + "/resources/stores/secret", + token.as_str(), + )?; + let inventories = + ResourceInventories::from_inventories(config_inventory, kv_inventory, secret_inventory)?; + let desired_links = desired_resource_links(&stores, &environment, &inventories)?; + + let versions_raw = fastly_api_get(&format!("/service/{service_id}/version"), token.as_str())?; + let versions = parse_service_versions(&versions_raw)?; + let selected_source = select_version_source(&versions)?; + let source_version = match selected_source { + VersionSource::Active(version) + | VersionSource::InitialDraft(version) + | VersionSource::Retired(version) + | VersionSource::Staging(version) => version, + }; + let links_raw = run_fastly_json_capture( + &[ + "service", + "resource-link", + "list", + &format!("--service-id={service_id}"), + &format!("--version={source_version}"), + "--json", + ], + cwd, + )?; + let (source_links, links_snapshot) = parse_resource_links(&links_raw)?; + let source_configuration = + read_version_configuration_snapshot_for(&service_id, token.as_str(), source_version)?; - fn read_config_entry( - &self, - _manifest_root: &Path, - _adapter_manifest_path: Option<&str>, - _component_selector: Option<&str>, - store: &ResolvedStoreId, - key: &str, - _push_ctx: &AdapterPushContext<'_>, - ) -> Result { - // Shell out to `fastly config-store-entry describe - // --store-id= --key= --json`, resolve the store id on - // demand via `fastly config-store list --json`, then parse the - // JSON response. - let name = store.platform.as_str(); - // A TYPED absence: `Ok(None)` (list succeeded, no store matched) is the - // only path to MissingStore. Any operational failure stays `Err` and fails - // closed -- an incomplete read must never read as absence and authorise an - // overwrite of healthy remote state. - let Some(store_id) = resolve_remote_config_store_id(name)? else { - return Ok(ReadConfigEntry::MissingStore); - }; - let store_arg = format!("--store-id={store_id}"); - let key_arg = format!("--key={key}"); - let output = Command::new("fastly") - .args([ - "config-store-entry", - "describe", - store_arg.as_str(), - key_arg.as_str(), - "--json", - ]) - .output() - .map_err(|err| { - if err.kind() == ErrorKind::NotFound { - format!("`fastly` not found on PATH; {FASTLY_INSTALL_HINT}") - } else { - format!("failed to spawn `fastly`: {err}") - } - })?; - if output.status.success() { - let stdout = strict_stdout(output.stdout, "config-store-entry describe --json")?; - // Parse the JSON and extract the `item_value` field. - let parsed: serde_json::Value = serde_json::from_str(&stdout).map_err(|_err| { - format!( - "failed to parse `fastly config-store-entry describe` JSON (parse error \ - redacted; response: {})", - redact_describe_response(&stdout) - ) - })?; - let value = parsed - .get("item_value") - .and_then(serde_json::Value::as_str) + let links = plan_link_reconciliation(&desired_links, &source_links, &inventories)?; + + let version_source = match selected_source { + VersionSource::Active(active_version) => { + EditableVersionSource::CloneActive { active_version } + } + VersionSource::InitialDraft(draft_version) => { + let version = versions + .iter() + .find(|version| version.number == draft_version) + .cloned() .ok_or_else(|| { - format!( - "`fastly config-store-entry describe` JSON has no string `item_value` field; \ - fastly CLI may have changed its output schema. (response: {})", - redact_describe_response(&stdout) - ) + format!("selected initial draft version {draft_version} disappeared") })?; - // Resolve chunk pointers: if `value` is a direct BlobEnvelope it - // passes through unchanged; if it is a chunk pointer the chunks - // are fetched from the same store and reconstructed. - // - // A chunk describe that fails could not be FULLY read. Confirm whether - // the chunk is genuinely ABSENT against the complete store listing - // (authoritative), never the describe 404: - // - CONFIRMED absent → resolve to a repairable `Corrupt`. The blob - // spec makes persistent chunk loss repairable by re-pushing, so a - // push can overwrite to fix it. - // - present-but-unreadable, or the listing itself failed → - // `fetch_failed`: an incomplete read that must be a HARD error, - // never an overwritable value. - let store_keys: RefCell, String>>> = RefCell::new(None); - let fetch_failed: Cell = Cell::new(false); - let resolved = resolve_fastly_config_value_typed(key, value.to_owned(), |chunk_key| { - match fetch_remote_config_store_entry(&store_id, chunk_key) { - Ok(found) => Ok(Some(found)), - Err(_describe_err) => { - match confirm_key_absent_cached(&store_keys, &store_id, chunk_key) { - Ok(true) => Ok(None), // genuinely gone → repairable Corrupt - Ok(false) => { - fetch_failed.set(true); - Err("a referenced chunk is present in the store but its value \ - could not be read (incomplete read)" - .to_owned()) - } - Err(list_err) => { - fetch_failed.set(true); - Err(list_err) - } - } - } - } + let metadata = exact_version_metadata(&versions_raw, draft_version)?; + EditableVersionSource::InitialDraft(Box::new(snapshot_initial_draft( + version, + metadata, + links_snapshot, + source_configuration.clone(), + ))) + } + VersionSource::Retired(inactive_version) | VersionSource::Staging(inactive_version) => { + let version = versions + .iter() + .find(|version| version.number == inactive_version) + .cloned() + .ok_or_else(|| { + format!("selected inactive version {inactive_version} disappeared") + })?; + let metadata = exact_version_metadata(&versions_raw, inactive_version)?; + let snapshot = Box::new(InactiveSourceSnapshot { + links: links_snapshot, + metadata, + version, }); - return classify_resolved_read(resolved, value, fetch_failed.get()); - } - // The describe failed. Absence is CONFIRMED only by a complete listing - // that omits the key -- never by a describe 404, which a proxy/endpoint or - // auth failure produces just the same. A present key (or a listing that - // itself fails) is a hard error, so two such incomplete reads can never - // pass the pre-write recheck and authorise an overwrite. - if confirm_entry_absent(&store_id, key)? { - return Ok(ReadConfigEntry::MissingKey); + if matches!(selected_source, VersionSource::Retired(_)) { + EditableVersionSource::CloneRetired(snapshot) + } else { + EditableVersionSource::CloneStaging(snapshot) + } } - let stderr = String::from_utf8_lossy(&output.stderr); - Err(format!( - "`fastly config-store-entry describe --store-id={store_id} --key={key} --json` exited \ - with status {} but the key IS present in the store listing (an operational failure, \ - not absence); nothing was changed.\nstderr: {}", - output.status, - redact_stderr(&stderr) - )) + }; + let package_sha256 = release.package_sha256().to_owned(); + + Ok(ManagedDeployPlan { + arguments, + links, + package_files_hash, + package_sha256, + release, + service_id, + source_configuration, + source_links, + target, + token, + version_source, + }) +} + +fn deploy_managed_with_context( + context: &AdapterDeployContext, + args: &[String], +) -> Result<(), String> { + let plan = build_managed_deploy_plan(context, args)?; + execute_managed_deploy_plan(&plan) +} + +fn execute_managed_deploy_plan(plan: &ManagedDeployPlan) -> Result<(), String> { + execute_managed_deploy_plan_with_emit(plan, &mut |line| log::info!("{line}")) +} + +fn execute_managed_deploy_plan_with_emit( + plan: &ManagedDeployPlan, + emit: &mut dyn FnMut(&str), +) -> Result<(), String> { + let cwd = plan + .release + .adapter_manifest() + .parent() + .ok_or_else(|| "verified Fastly manifest has no parent directory".to_owned())?; + emit(&format!("package-sha256={}", plan.package_sha256)); + let version = prepare_managed_version(plan, cwd, emit)?; + + if let Some(comment) = plan.arguments.comment.as_deref() { + run_fastly_status( + &[ + "service".to_owned(), + "version".to_owned(), + "update".to_owned(), + format!("--service-id={}", plan.service_id), + format!("--version={version}"), + "--comment".to_owned(), + comment.to_owned(), + ], + cwd, + )?; } - fn read_config_entry_local( - &self, - manifest_root: &Path, - adapter_manifest_path: Option<&str>, - _component_selector: Option<&str>, - store: &ResolvedStoreId, - key: &str, - _push_ctx: &AdapterPushContext<'_>, - ) -> Result { - // Read from `[local_server.config_stores..contents]` - // in fastly.toml — the same section `push_config_entries_local` writes. - let Some(rel) = adapter_manifest_path else { - return Err( - "[adapters.fastly.adapter].manifest must point at fastly.toml for config diff --local" - .to_owned(), - ); - }; - let fastly_path = manifest_root.join(rel); - let name = store.platform.as_str(); - // A prior-state read failure must never BLOCK the command: the diff just - // cannot be computed, so it degrades to `Unsupported` ("cannot diff"). - // Downstream, a dry-run then reaches the writer's orphan-count - // degradation (spec 12.x) and a real push reaches the writer, which - // fails fatally on malformed TOML or overwrites otherwise. Erroring here - // would newly fail a dry-run that reads nothing today. - let raw = match fs::read_to_string(&fastly_path) { - Ok(text) => text, - Err(err) if err.kind() == ErrorKind::NotFound => { - return Ok(ReadConfigEntry::MissingStore); - } - Err(_err) => { - return Ok(ReadConfigEntry::Unsupported( - "local fastly.toml could not be read; cannot diff the prior value", - )); - } - }; - let Ok(doc) = raw.parse::() else { - return Ok(ReadConfigEntry::Unsupported( - "local fastly.toml is not valid TOML; cannot diff the prior value", - )); - }; - // Descend `[local_server.config_stores..contents]` level by level. - // At each level an ABSENT key means the store isn't seeded yet - // (MissingStore), but a key that is PRESENT yet not a table is malformed - // store state — distinct outcomes. Collapsing the malformed case into - // MissingStore (as a plain `.get().and_then()` chain does) would render an - // inaccurate "all values added" diff, so it degrades to "cannot diff". - // - // `descend` returns Ok(None) for absent (-> MissingStore) and - // Err(Unsupported) for present-but-not-a-table. - let descend = |parent: &'_ toml_edit::Item, - child: &str| - -> Result, ReadConfigEntry> { - match parent.get(child) { - None => Ok(None), - Some(item) if item.is_table_like() => Ok(Some(item.clone())), - Some(_) => Err(ReadConfigEntry::Unsupported( - "a local config-store parent table is not a table; cannot diff the prior value", - )), - } - }; - let root_item = toml_edit::Item::Table(doc.as_table().clone()); - let contents_item = (|| { - let Some(local_server) = descend(&root_item, "local_server")? else { - return Ok(None); - }; - let Some(config_stores) = descend(&local_server, "config_stores")? else { - return Ok(None); - }; - let Some(store_tbl) = descend(&config_stores, name)? else { - return Ok(None); - }; - descend(&store_tbl, "contents") - })(); - let contents = match contents_item { - Ok(Some(item)) => item, - Ok(None) => return Ok(ReadConfigEntry::MissingStore), - Err(unsupported) => return Ok(unsupported), - }; - // `contents` MUST be a table of `key = "value"` pairs. (Guaranteed by - // `descend` above, but re-borrow as a table to index it.) - let Some(contents_tbl) = contents.as_table_like() else { - return Ok(ReadConfigEntry::Unsupported( - "local config-store `contents` is not a table; cannot diff the prior value", - )); - }; - // The contents table is `key = "value"` pairs. - match contents_tbl.get(key) { - Some(item) => { - let Some(value) = item.as_str() else { - return Ok(ReadConfigEntry::Unsupported( - "the local prior value is not a string; cannot diff the prior value", - )); - }; - // Resolve chunk pointers using the same toml contents table. - let resolved = - resolve_fastly_config_value_typed(key, value.to_owned(), |chunk_key| { - match contents_tbl.get(chunk_key) { - Some(chunk_item) => { - let chunk_val = chunk_item.as_str().ok_or_else(|| { - format!( - "chunk key `{chunk_key}` in {} is not a string", - fastly_path.display() - ) - })?; - Ok(Some(chunk_val.to_owned())) - } - None => Ok(None), - } - }); - // Same taxonomy as the cloud read, so recovery is uniform across - // targets: a valid envelope is `Present`; a non-envelope or - // corrupt/incomplete value is `Corrupt` (the local writer's - // fail-soft then overwrites it); an unknown/future kind is a hard - // error (do not clobber a newer format). There is no - // infrastructure fetch here -- the chunks are read from the local - // TOML table -- so `fetch_failed` is always false. - classify_resolved_read(resolved, value, false) - } - None => Ok(ReadConfigEntry::MissingKey), - } + let inherited = read_version_links(&plan.service_id, version, cwd)?; + require_same_link_resources(&plan.source_links, &inherited, "inherited draft")?; + let delete_identities = planned_delete_identities(plan)?; + let expected_links = expected_final_link_resources(plan, &delete_identities)?; + let inherited_by_identity = links_by_identity(&inherited)?; + for identity in &delete_identities { + let link = inherited_by_identity.get(identity).ok_or_else(|| { + format!( + "planned stale Fastly {} resource link `{}` is absent from the draft", + identity.0.display_name(), + identity.1 + ) + })?; + run_fastly_status( + &[ + "service".to_owned(), + "resource-link".to_owned(), + "delete".to_owned(), + format!("--service-id={}", plan.service_id), + format!("--version={version}"), + format!("--id={}", link.link_id), + ], + cwd, + )?; } + for link in &plan.links.create { + run_fastly_status( + &[ + "service".to_owned(), + "resource-link".to_owned(), + "create".to_owned(), + format!("--service-id={}", plan.service_id), + format!("--version={version}"), + format!("--resource-id={}", link.resource_id), + format!("--name={}", link.alias), + ], + cwd, + )?; + } + let reconciled = read_version_links(&plan.service_id, version, cwd)?; + require_exact_link_resources(&expected_links, &reconciled, "reconciled draft")?; - fn single_store_kinds(&self) -> &'static [&'static str] { - // Explicit `&[]` rather than inheriting the trait default, - // so the "Multi for every store kind" intent is documented - // at the call site. Fastly KV / Config / Secrets all - // support multiple distinct platform resources per kind, - // unlike spin's flat-namespace single-store model. - &[] + // Capture the versioned Compute configuration only after EdgeZero has + // finished every intended mutation, then compare it again in the immediate + // publication barrier below. Package identity and resource links have + // dedicated checks alongside this snapshot. + let expected_configuration = read_version_configuration_snapshot(plan, version)?; + if expected_configuration != plan.source_configuration { + return Err(format!( + "Fastly version {version} protected Compute configuration no longer matches its source" + )); + } + + revalidate_managed_draft(plan, version, &expected_links, &expected_configuration, cwd)?; + + match plan.target { + PublishTarget::Staging => run_fastly_status( + &[ + "service".to_owned(), + "version".to_owned(), + "stage".to_owned(), + format!("--service-id={}", plan.service_id), + format!("--version={version}"), + ], + cwd, + ), + PublishTarget::Production => fastly_api_put( + &format!("/service/{}/version/{version}/activate", plan.service_id), + plan.token.as_str(), + ) + .map(|_status| ()), } } -impl ManifestLock { - fn acquire(manifest_path: &Path) -> Result { - // Key the lock on the REAL target, so a symlinked manifest and a direct - // path to the same file acquire the SAME lock rather than two different - // sidecars. Every manifest writer (config push AND provision) takes this - // lock, so their read-modify-writes serialise instead of clobbering. - let target = canonical_manifest_target(manifest_path)?; - // A hard-linked manifest cannot be safely replaced: two hard links share - // one inode but have distinct pathnames, so they key DIFFERENT sidecar - // locks (no mutual exclusion), and the atomic rename swaps in a NEW inode, - // breaking the link. We cannot detect the other names, so fail closed - // rather than silently diverge or break the link. - reject_hard_linked_manifest(&target)?; - let dir = target.parent().unwrap_or_else(|| Path::new(".")); - let file_name = target - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or("fastly.toml"); - let lock_path = dir.join(format!(".{file_name}.edgezero-lock")); - let file = fs::OpenOptions::new() - .create(true) - .read(true) - .write(true) - .truncate(false) - .open(&lock_path) - .map_err(|err| format!("failed to open lock file {}: {err}", lock_path.display()))?; - // Blocks until any other writer holding the lock releases it. - file.lock() - .map_err(|err| format!("failed to lock {}: {err}", lock_path.display()))?; - // Re-check AFTER the (possibly long) lock wait: a hard link created while - // we blocked would not have been visible to the pre-lock check above. The - // replacement path re-checks once more immediately before the rename. - reject_hard_linked_manifest(&target)?; - Ok(Self { - _file: file, - target, - }) +struct CapturedFastlyCommand { + combined: String, + status: String, + success: bool, +} + +fn prepare_managed_version( + plan: &ManagedDeployPlan, + cwd: &Path, + emit: &mut dyn FnMut(&str), +) -> Result { + let package = plan.release.package().to_str().ok_or_else(|| { + "verified Fastly package path is not valid UTF-8 and cannot be passed to the Fastly CLI" + .to_owned() + })?; + let version = match &plan.version_source { + EditableVersionSource::CloneActive { active_version } => { + revalidate_active_source(plan, *active_version, cwd, None)?; + clone_managed_source(plan, *active_version, cwd, emit)? + } + EditableVersionSource::CloneRetired(snapshot) + | EditableVersionSource::CloneStaging(snapshot) => { + revalidate_inactive_source(plan, snapshot, cwd, None)?; + require_source_configuration(plan, snapshot.version.number)?; + clone_managed_source(plan, snapshot.version.number, cwd, emit)? + } + EditableVersionSource::InitialDraft(snapshot) => { + revalidate_initial_draft_before_update(plan, snapshot, cwd)?; + let version = snapshot.version.number; + emit(&format!("version={version}")); + version + } + }; + let mut update = vec![ + "compute".to_owned(), + "update".to_owned(), + format!("--service-id={}", plan.service_id), + format!("--version={version}"), + ]; + update.push(format!("--package={package}")); + update.extend(plan.arguments.globals.iter().cloned()); + if !has_non_interactive(&plan.arguments.globals) { + update.push("--non-interactive".to_owned()); } - /// The real file this lock guards. Callers read and replace THIS path. - fn target(&self) -> &Path { - &self.target + let outcome = run_fastly_capture_outcome(&update, cwd)?; + let reported_version = parse_fastly_version(&outcome.combined); + if !outcome.success { + let redacted_output = outcome.combined.replace(plan.token.as_str(), "[REDACTED]"); + return Err(format!( + "`fastly {}` exited with status {}\n{}", + update.join(" "), + outcome.status, + redacted_output.trim() + )); } -} -impl TempFileGuard { - fn disarm(&mut self) { - self.path = None; + if let Some(reported) = reported_version + && reported != version + { + return Err(format!( + "Fastly updated version {reported}, but the verified draft was version {version}" + )); } + Ok(version) } -impl Drop for TempFileGuard { - fn drop(&mut self) { - if let Some(path) = &self.path { - let _cleanup = fs::remove_file(path); +fn clone_managed_source( + plan: &ManagedDeployPlan, + source_version: u64, + cwd: &Path, + emit: &mut dyn FnMut(&str), +) -> Result { + let raw = fastly_api_put_capture( + &format!( + "/service/{}/version/{source_version}/clone", + plan.service_id + ), + plan.token.as_str(), + )?; + let version = parse_cloned_version(&raw, &plan.service_id, source_version)?; + emit(&format!("version={version}")); + + match &plan.version_source { + EditableVersionSource::CloneActive { active_version } => { + revalidate_active_source(plan, *active_version, cwd, Some(version))?; + } + EditableVersionSource::CloneRetired(snapshot) + | EditableVersionSource::CloneStaging(snapshot) => { + revalidate_inactive_source(plan, snapshot, cwd, Some(version))?; + require_source_configuration(plan, snapshot.version.number)?; + } + EditableVersionSource::InitialDraft(_) => { + return Err("internal managed deploy clone source mismatch".to_owned()); } } + + let versions_raw = fastly_api_get( + &format!("/service/{}/version", plan.service_id), + plan.token.as_str(), + )?; + let versions = parse_service_versions(&versions_raw)?; + let draft = versions + .iter() + .find(|candidate| candidate.number == version) + .ok_or_else(|| format!("cloned Fastly draft version {version} is absent"))?; + if draft.active || draft.locked || !draft.environments.is_empty() { + return Err(format!( + "cloned Fastly version {version} is not an unpublished editable draft" + )); + } + let cloned_links = read_version_links(&plan.service_id, version, cwd)?; + require_same_link_resources(&plan.source_links, &cloned_links, "fresh clone")?; + let cloned_configuration = read_version_configuration_snapshot(plan, version)?; + if cloned_configuration != plan.source_configuration { + return Err(format!( + "cloned Fastly version {version} does not match the preflight source configuration" + )); + } + Ok(version) } -/// Resolve a manifest path to the REAL file every alias shares, so a symlink and -/// a direct path lock and replace the SAME target. An existing file (or symlink) -/// canonicalizes directly; a not-yet-created file canonicalizes via its parent -/// so a fresh `fastly.toml` still keys on a stable location. -/// -/// FAILS CLOSED on an ambiguous chain: a symlink whose target cannot be read, or -/// a chain too deep / cyclic, returns `Err` rather than falling back to a writable -/// path that could replace an intermediate link. -fn canonical_manifest_target(path: &Path) -> Result { - // Follow the WHOLE symlink chain to the final target -- each hop may itself be - // a dangling symlink (fastly.toml -> middle.toml -> missing.toml). We write at - // the final target, preserving every intermediate link, and a direct writer to - // that same target keys on the same lock. - let mut current = path.to_owned(); - // Bounded to avoid spinning on a symlink cycle (canonicalize would ELOOP). - for _ in 0..40_u32 { - // Fully resolvable => the real existing file. - if let Ok(real) = fs::canonicalize(¤t) { - return Ok(real); - } - // Otherwise, if this hop is a symlink, follow one link and continue. - match fs::symlink_metadata(¤t) { - Ok(meta) if meta.file_type().is_symlink() => match fs::read_link(¤t) { - Ok(link) => { - current = if link.is_absolute() { - link - } else { - // A relative link resolves against the DIRECTORY holding it. - current - .parent() - .unwrap_or_else(|| Path::new(".")) - .join(link) - }; - } - // A symlink we cannot read: refuse rather than guess a target. - Err(err) => { - return Err(format!( - "could not read the manifest symlink `{}` ({err}); refusing to write", - current.display() - )); - } - }, - // Not a symlink -- a plain not-yet-created file, or the final dangling - // target: this is where the write should land. - _ => return Ok(canonicalize_parent_join(¤t)), - } - } - // Exhausted the hop budget: a cyclic or absurdly deep chain. Fail closed. - Err(format!( - "the manifest symlink chain starting at `{}` is too deep or cyclic; refusing to write", - path.display() - )) -} - -/// Canonicalize `path`'s PARENT (which should exist) and rejoin the file name, -/// so a not-yet-created file still resolves to a stable absolute location. -fn canonicalize_parent_join(path: &Path) -> PathBuf { - let parent = match path.parent() { - Some(parent) if !parent.as_os_str().is_empty() => parent, - _ => Path::new("."), - }; - let file_name = path.file_name().unwrap_or(path.as_os_str()); - match fs::canonicalize(parent) { - Ok(real_parent) => real_parent.join(file_name), - Err(_) => path.to_owned(), - } -} - -/// Refuse to operate on a manifest that has MORE THAN ONE hard link. Such a file -/// cannot be replaced safely: the atomic rename installs a new inode (breaking -/// the link), and the path-based lock cannot serialise writers arriving via the -/// other names. Fail closed with a fix. A not-yet-created file, or a filesystem -/// that does not report a link count, is left alone. -/// -/// The link count is read via the platform `MetadataExt` -- `nlink()` on Unix, -/// `number_of_links()` on Windows (both stable, no extra deps) -- so Windows -/// hard-link aliases are caught too, not just Unix ones. On any other target the -/// count is unknown and the file is left alone. -fn reject_hard_linked_manifest(target: &Path) -> Result<(), String> { - #[cfg(unix)] - let link_count: Option = { - use std::os::unix::fs::MetadataExt as _; - fs::metadata(target).ok().map(|meta| meta.nlink()) - }; - #[cfg(windows)] - let link_count: Option = { - use std::os::windows::fs::MetadataExt as _; - fs::metadata(target) - .ok() - .and_then(|meta| meta.number_of_links()) - .map(u64::from) - }; - #[cfg(not(any(unix, windows)))] - let link_count: Option = None; - - if let Some(count) = link_count - && count > 1 - { - return Err(format!( - "{} has multiple hard links (link count {count}); refusing to replace it -- an atomic \ - rename would break the link and concurrent writers via the other names could \ - diverge. Remove the extra hard link(s), or use a symlink instead.", - target.display(), - )); +fn parse_cloned_version(raw: &str, service_id: &str, source_version: u64) -> Result { + let value: serde_json::Value = serde_json::from_str(raw) + .map_err(|_error| "Fastly clone response is malformed".to_owned())?; + let object = value + .as_object() + .ok_or_else(|| "Fastly clone response must be one JSON object".to_owned())?; + let cloned_service = object + .get("service_id") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| "Fastly clone response has no service_id".to_owned())?; + let version = object + .get("number") + .and_then(serde_json::Value::as_u64) + .ok_or_else(|| "Fastly clone response has no numeric version".to_owned())?; + if cloned_service != service_id || version == source_version { + return Err( + "Fastly clone response does not identify a new version for the requested service" + .to_owned(), + ); } - Ok(()) + Ok(version) } -/// Fetch a single entry value from a remote Fastly Config Store entry by -/// key, using `fastly config-store-entry describe --store-id= --key= -/// --json`. Used by the chunk-pointer resolver to fan out to chunk entries. -/// -/// `Ok(value)` when the entry exists; `Err` on ANY failure, INCLUDING a -/// not-found. Absence is NOT decided here (a describe 404 is not proof) -- the -/// caller confirms it against the complete store listing. -/// -/// # Errors -/// Returns an error if `fastly` isn't on `PATH`, spawning fails, the JSON -/// cannot be parsed, or the CLI exits with a non-zero status (not-found included). -fn fetch_remote_config_store_entry(store_id: &str, key: &str) -> Result { - let store_arg = format!("--store-id={store_id}"); - let key_arg = format!("--key={key}"); +fn run_fastly_capture_outcome( + fastly_args: &[String], + cwd: &Path, +) -> Result { let output = Command::new("fastly") - .args([ - "config-store-entry", - "describe", - store_arg.as_str(), - key_arg.as_str(), - "--json", - ]) + .args(fastly_args) + .current_dir(cwd) .output() - .map_err(|err| { - if err.kind() == ErrorKind::NotFound { + .map_err(|error| { + if error.kind() == ErrorKind::NotFound { format!("`fastly` not found on PATH; {FASTLY_INSTALL_HINT}") } else { - format!("failed to spawn `fastly`: {err}") + format!("failed to run fastly CLI: {error}") } })?; - if output.status.success() { - let stdout = strict_stdout(output.stdout, "config-store-entry describe --json")?; - let parsed: serde_json::Value = serde_json::from_str(&stdout).map_err(|_err| { - format!( - "failed to parse `fastly config-store-entry describe` JSON for key \ - `{key}` (parse error redacted; response: {})", - redact_describe_response(&stdout) - ) - })?; - let value = parsed - .get("item_value") - .and_then(serde_json::Value::as_str) - .ok_or_else(|| { - format!( - "`fastly config-store-entry describe` JSON has no string `item_value` \ - field for key `{key}`; fastly CLI may have changed its output schema. \ - (response: {})", - redact_describe_response(&stdout) - ) - })?; - return Ok(value.to_owned()); + let mut combined = String::from_utf8_lossy(&output.stdout).into_owned(); + combined.push_str(&String::from_utf8_lossy(&output.stderr)); + Ok(CapturedFastlyCommand { + combined, + status: output.status.to_string(), + success: output.status.success(), + }) +} + +fn is_canonical_sha512_hex(value: &str) -> bool { + value.len() == 128 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +fn parse_package_files_hash_output(output: &str) -> Result { + let hashes = output + .lines() + .map(str::trim) + .filter(|line| is_canonical_sha512_hex(line)) + .collect::>(); + if hashes.len() != 1 { + return Err( + "Fastly package hash command did not return one unambiguous SHA-512 files hash" + .to_owned(), + ); } - // `Err` on ANY non-success, INCLUDING a not-found. A describe 404 alone is not - // proof of absence -- a proxy/endpoint 404, an auth 404, or a gateway error - // all look the same -- so the caller CONFIRMS a genuine absence against the - // complete store listing rather than trusting this stderr. - let stderr = String::from_utf8_lossy(&output.stderr); - Err(format!( - "`fastly config-store-entry describe --store-id={store_id} --key={key} --json` \ - exited with status {}\nstderr: {}", - output.status, - redact_stderr(&stderr) - )) + hashes.into_iter().next().map(str::to_owned).ok_or_else(|| { + "Fastly package hash command did not return one unambiguous SHA-512 files hash".to_owned() + }) } -/// The COMPLETE set of item keys in a store, via `config-store-entry list`. -/// -/// Absence is CONFIRMED against this, never against a describe 404: the listing -/// is completeness-strict (fails closed on a paginated / non-bare-array view and -/// on a duplicate key), so a key's absence from it is authoritative. Tolerant of -/// empty item VALUES -- only keys are needed to confirm presence. -fn list_config_store_keys(store_id: &str) -> Result, String> { - let store_arg = format!("--store-id={store_id}"); - let output = Command::new("fastly") - .args(["config-store-entry", "list", store_arg.as_str(), "--json"]) - .output() - .map_err(|err| { - if err.kind() == ErrorKind::NotFound { - format!("`fastly` not found on PATH; {FASTLY_INSTALL_HINT}") - } else { - format!("failed to spawn `fastly`: {err}") - } - })?; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); +fn compute_package_files_hash(package: &Path, cwd: &Path, token: &str) -> Result { + let package_path = package.to_str().ok_or_else(|| { + "verified Fastly package path is not valid UTF-8 and cannot be hashed by the Fastly CLI" + .to_owned() + })?; + let outcome = run_fastly_capture_outcome( + &[ + "compute".to_owned(), + "hash-files".to_owned(), + format!("--package={package_path}"), + "--skip-build".to_owned(), + "--non-interactive".to_owned(), + "--quiet".to_owned(), + ], + cwd, + )?; + if !outcome.success { + let redacted = outcome.combined.replace(token, "[REDACTED]"); return Err(format!( - "`fastly config-store-entry list --store-id={store_id} --json` exited with status {}\nstderr: {}", - output.status, - redact_stderr(&stderr) + "Fastly package hash command exited with status {}\n{}", + outcome.status, + redacted.trim() )); } - let stdout = strict_stdout(output.stdout, "config-store-entry list --json")?; - let parsed: serde_json::Value = serde_json::from_str(&stdout).map_err(|_err| { - format!( - "failed to parse `fastly config-store-entry list` JSON (parse error redacted; \ - response: {})", - redact_describe_response(&stdout) - ) - })?; - let array = parsed.as_array().ok_or_else(|| { - format!( - "refusing to confirm absence: `fastly config-store-entry list --json` did not return a \ - bare array (response: {}). A paginated or partial view could hide a present key and \ - turn it into a false absence that authorises an overwrite.", - redact_describe_response(&stdout) - ) - })?; - let mut keys = HashSet::with_capacity(array.len()); - for (idx, entry) in array.iter().enumerate() { - let key = entry - .get("item_key") - .and_then(serde_json::Value::as_str) - .ok_or_else(|| { - format!( - "`fastly config-store-entry list` entry #{idx} is missing a string `item_key`; \ - refusing to confirm absence on an unreadable listing" - ) - })?; - if key.is_empty() { + parse_package_files_hash_output(&outcome.combined) +} + +fn parse_package_metadata_files_hash( + raw: &str, + expected_service_id: &str, + expected_version: u64, +) -> Result { + let package: ComputePackageRecord = serde_json::from_str(raw) + .map_err(|_error| "Fastly package metadata response is malformed".to_owned())?; + if package.service_id != expected_service_id || package.version != expected_version { + return Err( + "Fastly package metadata does not identify the requested service version".to_owned(), + ); + } + if !is_canonical_sha512_hex(&package.metadata.files_hash) { + return Err("Fastly package metadata contains an invalid files hash".to_owned()); + } + Ok(package.metadata.files_hash) +} + +fn read_version_links( + service_id: &str, + version: u64, + cwd: &Path, +) -> Result, String> { + read_version_links_with_snapshot(service_id, version, cwd).map(|(links, _snapshot)| links) +} + +fn read_version_links_with_snapshot( + service_id: &str, + version: u64, + cwd: &Path, +) -> Result<(Vec, serde_json::Value), String> { + let service_arg = format!("--service-id={service_id}"); + let version_arg = format!("--version={version}"); + let raw = run_fastly_json_capture( + &[ + "service", + "resource-link", + "list", + &service_arg, + &version_arg, + "--json", + ], + cwd, + )?; + parse_resource_links(&raw) +} + +type ResourceLinkIdentity = (ResourceKind, String); + +fn links_by_identity( + links: &[ExistingResourceLink], +) -> Result, String> { + let mut by_identity = BTreeMap::new(); + let mut ids = BTreeSet::new(); + for link in links { + if !ids.insert(link.link_id.as_str()) { return Err(format!( - "`fastly config-store-entry list` entry #{idx} has an empty `item_key`; refusing \ - to confirm absence on an unreadable listing" + "Fastly resource-link inventory contains duplicate link id `{}`", + link.link_id )); } - if !keys.insert(key.to_owned()) { + let identity = (link.kind, link.alias.clone()); + if by_identity.insert(identity, link.clone()).is_some() { return Err(format!( - "`fastly config-store-entry list` returned duplicate key `{key}`; refusing to \ - confirm absence on an ambiguous listing" + "Fastly resource-link inventory contains duplicate {} alias `{}`", + link.kind.display_name(), + link.alias )); } } - Ok(keys) + Ok(by_identity) } -/// Confirm `key` is ABSENT from the store via a complete listing (authoritative). -/// `Ok(true)` = the listing succeeded and omits the key. `Ok(false)` = the key IS -/// present (so a describe failure on it was operational, not absence). `Err` = the -/// listing itself failed. All three fail closed for the caller: only `Ok(true)` -/// is a genuine absence. -fn confirm_entry_absent(store_id: &str, key: &str) -> Result { - Ok(!list_config_store_keys(store_id)?.contains(key)) +fn link_resource_map( + links: &[ExistingResourceLink], +) -> Result, String> { + Ok(links_by_identity(links)? + .into_iter() + .map(|(identity, link)| (identity, link.resource_id)) + .collect()) } -/// Cached form of [`confirm_entry_absent`] for chunk fetches: lists the store at -/// most ONCE per read (a whole lost generation would otherwise list per chunk). -fn confirm_key_absent_cached( - cache: &RefCell, String>>>, - store_id: &str, - key: &str, -) -> Result { - let mut slot = cache.borrow_mut(); - if slot.is_none() { - *slot = Some(list_config_store_keys(store_id)); - } - match slot.as_ref() { - Some(Ok(keys)) => Ok(!keys.contains(key)), - Some(Err(err)) => Err(err.clone()), - // Unreachable: populated just above. Fail closed rather than unwrap. - None => Err("internal error: store listing cache was not populated".to_owned()), - } +fn require_same_link_resources( + expected: &[ExistingResourceLink], + actual: &[ExistingResourceLink], + label: &str, +) -> Result<(), String> { + let expected_resources = link_resource_map(expected)?; + require_exact_link_resources(&expected_resources, actual, label) } -/// Convert `fastly` stdout to a `String`, FAILING CLOSED on invalid UTF-8 rather -/// than substituting U+FFFD. A lossy replacement inside a JSON string could -/// mutate a stored root value or chunk and yield parseable-but-WRONG data on a -/// path that drives an overwrite or a deletion, violating the exact-read -/// invariant. Diagnostics only ever see redacted output, so stderr stays lossy. -fn strict_stdout(stdout: Vec, command: &str) -> Result { - String::from_utf8(stdout).map_err(|_err| { - format!( - "`fastly {command}` returned non-UTF-8 output; refusing to act on it -- a lossy \ - conversion could mutate a stored value. Nothing was changed." - ) - }) +fn require_exact_link_resources( + expected: &BTreeMap, + actual: &[ExistingResourceLink], + label: &str, +) -> Result<(), String> { + let actual_resources = link_resource_map(actual)?; + if actual_resources == *expected { + Ok(()) + } else { + Err(format!( + "Fastly {label} resource links changed unexpectedly; refusing to publish" + )) + } } -/// Does `body` parse AND integrity-verify as a `BlobEnvelope`? -/// -/// The typed-config key must hold a valid envelope. A resolved chunk pointer -/// already reconstructs and verifies one; a DIRECT or foreign value is checked -/// here. A value that is not a verifying envelope (invalid JSON, missing fields, -/// or a SHA mismatch) is corrupt FOR THE PUSH -- something to overwrite, not to -/// diff against. -fn body_is_valid_envelope(body: &str) -> bool { - use edgezero_core::blob_envelope::BlobEnvelope; - serde_json::from_str::(body).is_ok_and(|envelope| envelope.verify().is_ok()) +fn planned_delete_identities( + plan: &ManagedDeployPlan, +) -> Result, String> { + let by_id = plan + .source_links + .iter() + .map(|link| (link.link_id.as_str(), (link.kind, link.alias.as_str()))) + .collect::>(); + plan.links + .delete_link_ids + .iter() + .map(|id| { + by_id + .get(id.as_str()) + .map(|(kind, alias)| (*kind, (*alias).to_owned())) + .ok_or_else(|| { + format!("planned Fastly resource-link deletion `{id}` has no source link") + }) + }) + .collect() } -/// Map a `resolve_fastly_config_value` result to a read outcome, distinguishing -/// the cases that must NOT be treated as overwritable corruption: -/// -/// - a FUTURE format (unknown/newer `edgezero_kind`, or a bumped envelope/pointer -/// `version`) → a hard error: overwriting a newer format with this v1 CLI would -/// lose it. Checked FIRST. Detected two ways: on the raw stored value (a direct -/// future envelope, or a future pointer version), AND via a typed -/// [`ResolveFailure::FutureFormat`] from the resolver -- the ONLY signal for a -/// newer INNER envelope reassembled from v1 chunks, which the raw value alone -/// cannot reveal. -/// - a resolve error where a chunk FETCH failed for infrastructure reasons -/// (`fetch_failed`) → a hard error: the read was incomplete, so a push must not -/// overwrite healthy remote state. -/// - `Ok(body)` that verifies as an envelope → `Present`. -/// - `Ok(body)` that is NOT a valid envelope (a malformed direct value, a SHA -/// mismatch, a foreign non-envelope) → `Corrupt` (repairable by overwrite). -/// - any other resolve error (bad/missing chunk, malformed pointer) → `Corrupt`. -fn classify_resolved_read( - resolved: Result, - raw_value: &str, - fetch_failed: bool, -) -> Result { - // A newer format is refused BEFORE anything else: on the raw value (direct - // future envelope or future pointer version) OR when the resolver typed the - // failure as a newer format (a future inner envelope only knowable after the - // chunks are reassembled). Overwriting a newer format with this v1 CLI would - // lose it. - if value_is_future_format(raw_value) - || resolved - .as_ref() - .err() - .is_some_and(ResolveFailure::is_future_format) - { - return Err(FUTURE_FORMAT_READ_ERROR.to_owned()); +fn expected_final_link_resources( + plan: &ManagedDeployPlan, + delete_identities: &BTreeSet, +) -> Result, String> { + let mut expected = link_resource_map(&plan.source_links)?; + for identity in delete_identities { + if expected.remove(identity).is_none() { + return Err(format!( + "planned stale Fastly {} resource-link alias `{}` has no source link", + identity.0.display_name(), + identity.1 + )); + } } - match resolved { - // An INFRASTRUCTURE fetch failure: the read was incomplete, so a push must - // not overwrite. The resolver's message is already redacted (it names only - // a chunk POSITION, never a value), so surface it for diagnostics. - Err(err) if fetch_failed => Err(format!( - "a chunk fetch failed while reading the remote value ({}); the remote was not fully \ - read, so nothing was changed. Fix connectivity/auth and retry.", - err.into_message() - )), - Ok(body) if body_is_valid_envelope(&body) => Ok(ReadConfigEntry::Present(body)), - Ok(_) => Ok(ReadConfigEntry::Corrupt( - "remote value is not a valid config envelope; a push will overwrite it", - )), - // A confirmed-absent chunk, a hash mismatch, or a malformed pointer: the - // value was fully read and is provably unusable, so a push repairs it. - Err(_) => Ok(ReadConfigEntry::Corrupt( - "remote prior value could not be resolved (corrupt or incomplete chunk state); a push \ - will overwrite it", - )), + for link in &plan.links.create { + let identity = (link.kind, link.alias.clone()); + if expected + .insert(identity, link.resource_id.clone()) + .is_some() + { + return Err(format!( + "planned Fastly {} resource-link creation `{}` collides with an inherited identity", + link.kind.display_name(), + link.alias + )); + } } + Ok(expected) } -/// Shell out to `fastly -store create --name=`. The -/// caller resolves `` from `EDGEZERO__STORES______NAME` -/// (falling back to the logical id), so this helper takes whatever the -/// caller hands it and does not re-translate. Returns `Ok(())` on success; -/// surfaces the CLI's stderr verbatim on failure (including the "already -/// exists" error, which is the caller's signal to fix the toml or use a -/// different name). -/// -/// # Errors -/// Returns an error if `fastly` isn't on `PATH`, the child fails to -/// spawn, or the exit status is non-zero. -fn create_fastly_store_in(kind: &str, name: &str, cwd: &Path) -> Result<(), String> { - let subcommand = format!("{kind}-store"); - let name_arg = format!("--name={name}"); - let mut command = Command::new("fastly"); - command - .args([subcommand.as_str(), "create", name_arg.as_str()]) - .current_dir(cwd); - let output = command.output().map_err(|err| { - if err.kind() == ErrorKind::NotFound { - format!("`fastly` not found on PATH; {FASTLY_INSTALL_HINT}") - } else { - format!("failed to spawn `fastly`: {err}") - } - })?; - if output.status.success() { - return Ok(()); +fn revalidate_initial_draft_before_update( + plan: &ManagedDeployPlan, + snapshot: &InitialDraftSnapshot, + cwd: &Path, +) -> Result<(), String> { + let versions_raw = fastly_api_get( + &format!("/service/{}/version", plan.service_id), + plan.token.as_str(), + )?; + let versions = parse_service_versions(&versions_raw)?; + if select_version_source(&versions)? != VersionSource::InitialDraft(snapshot.version.number) { + return Err("Fastly initial draft selection changed after preflight".to_owned()); } - // Idempotency: the fastly CLI returns non-zero with an - // "already exists" message when a store of this name was - // created by a prior provision run. Treat that as success so - // the operator's recovery path -- "either manually append the - // setup block or delete the remote and re-run provision" -- - // doesn't get blocked. The append step is itself idempotent, - // so re-running provision after a writeback failure is the - // documented recovery and now actually works. - let stderr = String::from_utf8_lossy(&output.stderr); - if looks_like_already_exists(&stderr, kind) { - return Ok(()); + let current = versions + .iter() + .find(|version| version.number == snapshot.version.number) + .ok_or_else(|| "Fastly initial draft disappeared after preflight".to_owned())?; + if current != &snapshot.version + || exact_version_metadata(&versions_raw, snapshot.version.number)? != snapshot.metadata + { + return Err("Fastly initial draft metadata changed after preflight".to_owned()); } - Err(format!( - "`fastly {subcommand} create --name={name}` exited with status {}\nstderr: {}", - output.status, - stderr.trim() - )) -} - -/// Heuristic: does the stderr blob look like a "store of this -/// kind, by this name, already exists" failure from the fastly -/// CLI? Different CLI versions phrase this slightly differently -/// ("a kv-store with that name already exists", -/// `"Conflict: duplicate kv_store name"`, etc.); we require BOTH -/// a conflict-signal keyword AND a store-kind reference so an -/// unrelated 409 ("Error: 409 Conflict on /service/...") cannot -/// be misread as idempotent success. The earlier wider heuristic -/// would have swallowed any stderr containing the word -/// "conflict" and let provision march on to writeback against a -/// nonexistent store, surfacing as a confusing deploy-time error. -fn looks_like_already_exists(stderr: &str, kind: &str) -> bool { - let lower = stderr.to_ascii_lowercase(); - let conflict_signal = lower.contains("already exists") - || (lower.contains("duplicate") && lower.contains("name")) - || lower.contains("conflict"); - if !conflict_signal { - return false; + let service_id = &plan.service_id; + let version = snapshot.version.number; + let (_current_links, links_value) = read_version_links_with_snapshot(service_id, version, cwd)?; + if links_value != snapshot.links { + return Err("Fastly initial draft links changed after preflight".to_owned()); } - // Accept the three common spellings of `-store` / - // `_store` / ` store` so a fastly CLI version - // bump that reshuffles punctuation still hits. - let dashed = format!("{kind}-store"); - let underscored = format!("{kind}_store"); - let spaced = format!("{kind} store"); - lower.contains(&dashed) || lower.contains(&underscored) || lower.contains(&spaced) + require_protected_initial_snapshot(plan, snapshot)?; + Ok(()) } -/// Read the top-level `service_id` from `fastly.toml`. Returns -/// `Ok(None)` when the file is absent (scaffold state before first -/// `fastly compute deploy`) or when `service_id` is missing / -/// empty. Used by `provision` to detect when an already-deployed -/// service needs a separate resource-link step beyond `[setup]` -/// (which `compute deploy` only consumes on the FIRST deploy). -fn read_fastly_service_id(path: &Path) -> Result, String> { - let raw = match fs::read_to_string(path) { - Ok(text) => text, - Err(err) if err.kind() == ErrorKind::NotFound => return Ok(None), - Err(err) => return Err(format!("failed to read {}: {err}", path.display())), - }; - let doc: toml_edit::DocumentMut = raw.parse().map_err(|_err| { - format!( - "failed to parse {} as TOML (details redacted: the error can quote a stored value)", - path.display() - ) - })?; - let svc = doc - .get("service_id") - .and_then(|item| item.as_str()) - .map(str::to_owned) - .filter(|svc_id| !svc_id.is_empty()); - Ok(svc) +fn revalidate_active_source( + plan: &ManagedDeployPlan, + active_version: u64, + cwd: &Path, + excluded_target: Option, +) -> Result<(), String> { + let versions_raw = fastly_api_get( + &format!("/service/{}/version", plan.service_id), + plan.token.as_str(), + )?; + let versions = parse_service_versions(&versions_raw)?; + let source_versions = versions + .iter() + .filter(|version| Some(version.number) != excluded_target) + .cloned() + .collect::>(); + if select_version_source(&source_versions)? != VersionSource::Active(active_version) { + return Err("Fastly active source selection changed after preflight".to_owned()); + } + let source = source_versions + .iter() + .find(|version| version.number == active_version) + .ok_or_else(|| "Fastly active source disappeared after preflight".to_owned())?; + if !source.locked { + return Err("Fastly active source is unexpectedly editable".to_owned()); + } + let current_links = read_version_links(&plan.service_id, active_version, cwd)?; + require_same_link_resources(&plan.source_links, ¤t_links, "active source")?; + require_source_configuration(plan, active_version) } -/// 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 require_source_configuration( + plan: &ManagedDeployPlan, + source_version: u64, +) -> Result<(), String> { + let current = read_version_configuration_snapshot(plan, source_version)?; + if current == plan.source_configuration { + Ok(()) + } else { + Err(format!( + "Fastly source version {source_version} protected Compute configuration changed after preflight" + )) + } } -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"))?, - ), +fn revalidate_inactive_source( + plan: &ManagedDeployPlan, + snapshot: &InactiveSourceSnapshot, + cwd: &Path, + excluded_target: Option, +) -> Result<(), String> { + let versions_raw = fastly_api_get( + &format!("/service/{}/version", plan.service_id), + plan.token.as_str(), + )?; + let versions = parse_service_versions(&versions_raw)?; + let source_versions = versions + .iter() + .filter(|version| Some(version.number) != excluded_target) + .cloned() + .collect::>(); + let expected = match &plan.version_source { + EditableVersionSource::CloneRetired(_) => VersionSource::Retired(snapshot.version.number), + EditableVersionSource::CloneStaging(_) => VersionSource::Staging(snapshot.version.number), + EditableVersionSource::CloneActive { .. } | EditableVersionSource::InitialDraft(_) => { + return Err("internal managed deploy inactive source mismatch".to_owned()); + } }; - - if let Some(service_id) = manifest_id.as_deref() { - validate_service_id(service_id)?; + if select_version_source(&source_versions)? != expected { + return Err("Fastly inactive source selection changed after preflight".to_owned()); } - if let Some(service_id) = env_id.as_deref() { - validate_service_id(service_id)?; + let current = versions + .iter() + .find(|version| version.number == snapshot.version.number) + .ok_or_else(|| "Fastly inactive source disappeared after preflight".to_owned())?; + if current != &snapshot.version + || exact_version_metadata(&versions_raw, snapshot.version.number)? != snapshot.metadata + { + return Err("Fastly inactive source metadata changed after preflight".to_owned()); } - 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), + let (_current_links, links_value) = + read_version_links_with_snapshot(&plan.service_id, snapshot.version.number, cwd)?; + if links_value != snapshot.links { + return Err("Fastly inactive source links changed after preflight".to_owned()); } + Ok(()) } -fn provision_runtime_env_service_id_for_stores( - path: &Path, - stores: &ProvisionStores<'_>, -) -> Result, String> { - let service_id = provision_runtime_env_service_id(path)?; - if has_non_default_store_name_mappings(stores) && service_id.is_none() { - return Err(format!( - "cannot persist non-default Fastly store-name mappings without a service namespace: set top-level `service_id` in {} or set {FASTLY_SERVICE_ID_ENV}", - path.display() - )); +fn require_protected_initial_snapshot( + plan: &ManagedDeployPlan, + snapshot: &InitialDraftSnapshot, +) -> Result<(), String> { + let current = read_version_configuration_snapshot(plan, snapshot.version.number)?; + if current == snapshot.configuration { + Ok(()) + } else { + Err("Fastly initial draft protected configuration changed after preflight".to_owned()) } - Ok(service_id) -} - -/// If fastly.toml declares `service_id` or `FASTLY_SERVICE_ID` selects one, -/// the next `fastly compute deploy` targets an existing service and skips -/// `[setup]`. Any store created by provision then needs a separate resource -/// link. This helper returns that remediation or `None` before a service has -/// been selected. -fn resource_link_note(path: &Path, kind: &str, name: &str) -> Result, String> { - let note = provision_runtime_env_service_id(path)?.map(|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`)." - ) - }); - Ok(note) } -/// Probe `fastly.toml` for the existence of `[setup._stores.]`. -/// Treats a missing file as "not present" so the first provision call -/// can create it. -/// -/// Why only `[setup]` (no longer `[local_server]`): an empty -/// `[local_server._stores.]` table doesn't satisfy -/// fastly's local-server schema — config-stores need -/// `format = "inline-toml"` + a contents table, kv/secret stores -/// need a JSON `file = "..."` or an array of `{key, data}` entries. -/// Writing an empty table makes `fastly compute serve` skip the -/// declared store or error at startup. `provision`'s job is the -/// remote / `[setup]` half; local-server stanzas are written by -/// `edgezero config push --adapter fastly --local` -/// (config-stores only), and kv/secret local-server seeding is -/// hand-edited until we add equivalent writers for those kinds. -fn setup_block_present(path: &Path, kind: &str, id: &str) -> Result { - let raw = match fs::read_to_string(path) { - Ok(text) => text, - Err(err) if err.kind() == ErrorKind::NotFound => return Ok(false), - Err(err) => return Err(format!("failed to read {}: {err}", path.display())), - }; - let doc: toml_edit::DocumentMut = raw.parse().map_err(|_err| { - format!( - "failed to parse {} as TOML (details redacted: the error can quote a stored value)", - path.display() - ) - })?; - let plural = format!("{kind}_stores"); - Ok(doc - .get("setup") - .and_then(|root| root.get(plural.as_str())) - .and_then(|kind_tbl| kind_tbl.get(id)) - .is_some()) +fn revalidate_managed_draft( + plan: &ManagedDeployPlan, + version: u64, + expected_links: &BTreeMap, + expected_configuration: &VersionConfigurationSnapshot, + cwd: &Path, +) -> Result<(), String> { + let versions_raw = fastly_api_get( + &format!("/service/{}/version", plan.service_id), + plan.token.as_str(), + )?; + let versions = parse_service_versions(&versions_raw)?; + let draft = versions + .iter() + .find(|candidate| candidate.number == version) + .ok_or_else(|| format!("Fastly draft version {version} disappeared before publication"))?; + if draft.active || draft.locked || !draft.environments.is_empty() { + return Err(format!( + "Fastly version {version} is no longer an unpublished editable draft" + )); + } + let active_versions = versions + .iter() + .filter(|candidate| candidate.active) + .map(|candidate| candidate.number) + .collect::>(); + if !matches!( + plan.version_source, + EditableVersionSource::CloneActive { .. } + ) && !active_versions.is_empty() + { + return Err("Fastly active version appeared before publication".to_owned()); + } + match &plan.version_source { + EditableVersionSource::CloneActive { active_version } => { + if active_versions != [*active_version] { + return Err("Fastly active version changed before publication".to_owned()); + } + } + EditableVersionSource::CloneRetired(snapshot) => { + if version == snapshot.version.number { + return Err("managed Fastly retired clone version did not advance".to_owned()); + } + revalidate_inactive_source(plan, snapshot, cwd, Some(version))?; + } + EditableVersionSource::CloneStaging(snapshot) => { + if version == snapshot.version.number { + return Err("managed Fastly staged clone version did not advance".to_owned()); + } + revalidate_inactive_source(plan, snapshot, cwd, Some(version))?; + } + EditableVersionSource::InitialDraft(snapshot) => { + if version != snapshot.version.number { + return Err("managed Fastly initial draft version changed".to_owned()); + } + } + } + let links = read_version_links(&plan.service_id, version, cwd)?; + require_exact_link_resources(expected_links, &links, "final draft")?; + let package_raw = fastly_api_get( + &format!("/service/{}/version/{version}/package", plan.service_id), + plan.token.as_str(), + )?; + let package_files_hash = + parse_package_metadata_files_hash(&package_raw, &plan.service_id, version)?; + if package_files_hash != plan.package_files_hash { + return Err(format!( + "Fastly version {version} package identity changed before publication" + )); + } + let current_configuration = read_version_configuration_snapshot(plan, version)?; + if ¤t_configuration != expected_configuration { + return Err(format!( + "Fastly version {version} protected Compute configuration changed before publication" + )); + } + Ok(()) } -/// Append `[setup._stores.]` to `fastly.toml`. Creates -/// the file (and the parent `[setup]` table) if absent. The block -/// is written as an empty table — that's what -/// `fastly compute deploy` consumes the first time it creates a -/// service: the resource-link declaration is enough, and the -/// account-level resource itself is already created in the -/// preceding `create_fastly_store` shellout. -/// -/// We DON'T write `[local_server._stores.]` here: see -/// `setup_block_present`'s doc for the schema rationale. The local- -/// server seeding moved to `config push --local` (config-stores -/// only), so provision only owns the remote / setup half. -fn append_fastly_setup(path: &Path, kind: &str, id: &str) -> Result<(), String> { - use toml_edit::{DocumentMut, Item, table}; +fn read_version_configuration_snapshot( + plan: &ManagedDeployPlan, + version: u64, +) -> Result { + read_version_configuration_snapshot_for(&plan.service_id, plan.token.as_str(), version) +} - // Provision writes the SAME manifest as `config push --local`; take the same - // lock so a concurrent provision and push serialise instead of clobbering - // each other's edit, and operate on the real target the lock resolved. - let lock = ManifestLock::acquire(path)?; - let target = lock.target(); +fn read_version_configuration_snapshot_for( + service_id: &str, + token: &str, + version: u64, +) -> Result { + let base = format!("/service/{service_id}/version/{version}"); + let domains = parse_snapshot_array( + "domains", + &fastly_api_get(&format!("{base}/domain"), token)?, + )?; + let backends = parse_snapshot_array( + "backends", + &fastly_api_get(&format!("{base}/backend"), token)?, + )?; + let healthchecks = parse_snapshot_array( + "health checks", + &fastly_api_get(&format!("{base}/healthcheck"), token)?, + )?; + let logging = snapshot_logging_providers(&base, token)?; + let settings = parse_snapshot_object( + "settings", + &fastly_api_get(&format!("{base}/settings"), token)?, + )?; + Ok(VersionConfigurationSnapshot { + backends, + domains, + healthchecks, + logging, + settings, + }) +} - let raw = match fs::read_to_string(target) { - Ok(text) => text, - Err(err) if err.kind() == ErrorKind::NotFound => String::new(), - Err(err) => return Err(format!("failed to read {}: {err}", target.display())), - }; - let mut doc: DocumentMut = raw.parse().map_err(|_err| { - format!( - "failed to parse {} as TOML (details redacted: the error can quote a stored value)", - target.display() - ) - })?; +fn resolve_managed_plan_service_id( + context: &AdapterDeployContext, + release: &VerifiedApplicationRelease, +) -> Result { + if let Some(service_id) = context.service_id.as_deref() { + validate_service_id(service_id)?; + return Ok(service_id.to_owned()); + } + effective_fastly_service_id(release.adapter_manifest())? + .map(|selected| selected.id) + .ok_or_else(|| { + format!( + "managed Fastly deployment requires a service id in typed context, the verified Fastly manifest, or {FASTLY_SERVICE_ID_ENV}" + ) + }) +} - let plural = format!("{kind}_stores"); - let parent_entry = doc.entry("setup").or_insert_with(table); - let parent_tbl = parent_entry.as_table_mut().ok_or_else(|| { - format!( - "{}: `setup` exists but is not a table; refusing to edit in place", - path.display() - ) - })?; - let kind_entry = parent_tbl - .entry(plural.as_str()) - .or_insert_with(|| Item::Table(toml_edit::Table::new())); - let kind_tbl = kind_entry.as_table_mut().ok_or_else(|| { - format!( - "{}: `setup.{plural}` exists but is not a table; refusing to edit in place", - path.display() - ) - })?; - if !kind_tbl.contains_key(id) { - kind_tbl.insert(id, Item::Table(toml_edit::Table::new())); +fn run_fastly_json_capture(args: &[&str], cwd: &Path) -> Result { + let output = Command::new("fastly") + .args(args) + .current_dir(cwd) + .output() + .map_err(|error| { + if error.kind() == ErrorKind::NotFound { + format!("`fastly` not found on PATH; {FASTLY_INSTALL_HINT}") + } else { + format!("failed to run read-only Fastly command: {error}") + } + })?; + if !output.status.success() { + return Err(format!( + "read-only `fastly {}` exited with status {}\nstderr: {}", + args.join(" "), + output.status, + redact_stderr(&String::from_utf8_lossy(&output.stderr)) + )); } + strict_stdout(output.stdout, "read-only Fastly JSON command") +} - atomically_replace_file(target, &raw, &doc.to_string())?; - Ok(()) +fn parse_resource_links( + raw: &str, +) -> Result<(Vec, serde_json::Value), String> { + let snapshot: serde_json::Value = serde_json::from_str(raw) + .map_err(|error| format!("failed to parse Fastly resource-link inventory: {error}"))?; + let rows = snapshot.as_array().ok_or_else(|| { + "Fastly resource-link inventory must be one complete bare JSON array".to_owned() + })?; + let mut links = Vec::with_capacity(rows.len()); + for (index, row) in rows.iter().enumerate() { + let object = row.as_object().ok_or_else(|| { + format!("Fastly resource-link inventory record #{index} is not an object") + })?; + let required = |field: &str| { + object + .get(field) + .and_then(serde_json::Value::as_str) + .filter(|value| !value.is_empty()) + .map(str::to_owned) + .ok_or_else(|| { + format!( + "Fastly resource-link inventory record #{index} requires non-empty string `{field}`" + ) + }) + }; + let resource_type = required("resource_type")?; + let kind = match resource_type.as_str() { + "config" => ResourceKind::Config, + "kv-store" => ResourceKind::Kv, + "secret-store" => ResourceKind::Secret, + _ => { + return Err(format!( + "Fastly resource-link inventory record #{index} has unknown `resource_type` `{resource_type}`" + )); + } + }; + links.push(ExistingResourceLink { + link_id: required("id")?, + alias: required("name")?, + kind, + resource_id: required("resource_id")?, + }); + } + Ok((links, snapshot)) } -/// Write the local-server config-store entries to `fastly.toml`: -/// `[local_server.config_stores.]` becomes -/// `format = "inline-toml"`, and `[local_server.config_stores..contents]` -/// gets the flat `key = "value"` pairs (overwriting any previous -/// values). Idempotent — re-running just rewrites `contents`. Other -/// blocks in `fastly.toml` (setup, scripts, the actual `[local_server]` -/// secret stores, etc.) are preserved via `toml_edit`. -/// Refuse before writing if any GENERATED chunk key would clobber an existing -/// value that is itself ROOT-LIKE (announces our `edgezero_kind`, is a newer -/// format, or classifies as a valid root) or that has a NESTED generation beneath -/// it. Chunk keys are content-addressed, so such a collision is pathological, but -/// overwriting one would destroy live or foreign config -- so fail closed. -/// -/// Logical ROOT keys are excluded here; overwriting a root is governed by the -/// downgrade/future guards. `sibling_keys` is the complete set of existing store -/// keys (for the nested-generation check); `existing_value_at` fetches the value -/// at a colliding key (only called for keys already present). -fn reject_generated_key_collisions( - entries: &[(String, String)], - sibling_keys: &HashSet, - mut existing_value_at: impl FnMut(&str) -> Result, String>, -) -> Result<(), String> { - for (key, _) in entries { - if !key.contains(CHUNK_KEY_INFIX) { - continue; // a logical root; the root-overwrite guards cover it - } - let has_nested_generation = sibling_keys - .iter() - .any(|other| other != key && chunk_key_generation(key, other).is_some()); - let clobbers_root_like = sibling_keys.contains(key) - && existing_value_at(key)?.is_some_and(|value| { - value_announces_our_kind(&value) - || value_is_future_format(&value) - || gc_classify_root(key, &value).is_ok() - }); - if has_nested_generation || clobbers_root_like { - return Err(format!( - "refusing to push: the generated chunk key `{key}` already holds a value that is \ - itself a root (or has a nested generation beneath it); overwriting it could \ - destroy live or foreign config. Nothing was changed." - )); - } +fn snapshot_initial_draft( + version: ServiceVersionRecord, + metadata: serde_json::Value, + links: serde_json::Value, + configuration: VersionConfigurationSnapshot, +) -> InitialDraftSnapshot { + InitialDraftSnapshot { + configuration, + links, + metadata, + version, } - Ok(()) } -/// [`reject_generated_key_collisions`] against a local `contents` table. -fn reject_local_generated_key_collisions( - contents_tbl: &toml_edit::Table, - entries: &[(String, String)], -) -> Result<(), String> { - let sibling_keys: HashSet = contents_tbl +fn snapshot_logging_providers( + version_base: &str, + token: &str, +) -> Result, String> { + FASTLY_LOGGING_PROVIDER_KINDS .iter() - .map(|(existing_key, _)| existing_key.to_owned()) - .collect(); - reject_generated_key_collisions(entries, &sibling_keys, |chunk_key| { - Ok(contents_tbl - .get(chunk_key) - .and_then(toml_edit::Item::as_str) - .map(str::to_owned)) - }) + .map(|&kind| { + let endpoints = parse_snapshot_array( + &format!("logging/{kind}"), + &fastly_api_get(&format!("{version_base}/logging/{kind}"), token)?, + )?; + Ok(LoggingProviderSnapshot { endpoints, kind }) + }) + .collect() } -/// Ensure a local config-store entry is `format = "inline-toml"` -- the only -/// format compatible with the inline `contents` this writer emits. -/// -/// REFUSES an existing non-inline store rather than converting it. A -/// `format = "json"` / `"file"` store points at an EXTERNAL file that this writer -/// cannot safely rewrite: leaving `file` in place produces a manifest Viceroy -/// rejects ("unrecognized key 'file'"), and removing it would silently discard -/// the sibling entries that file holds (this writer only inserts the pushed -/// root). Migration is the operator's explicit choice, not a silent side effect. -fn ensure_inline_toml_format( - store_tbl: &mut toml_edit::Table, - platform_name: &str, -) -> Result<(), String> { - let existing = store_tbl.get("format").and_then(toml_edit::Item::as_str); - match existing { - Some("inline-toml") => Ok(()), - Some(other) => Err(format!( - "refusing to push: `local_server.config_stores.{platform_name}` uses `format = \ - \"{other}\"` (an external-file store), which is incompatible with the inline \ - `contents` this command writes. Converting it here would either produce a manifest \ - the local server rejects or silently discard the sibling entries the external file \ - holds. Migrate the store to `format = \"inline-toml\"` (or a fresh store id) yourself, \ - then re-run. Nothing was changed." - )), - None => { - // A brand-new or format-less entry: this writer owns it, so stamp the - // inline format it is about to fill. - store_tbl.insert("format", toml_edit::value("inline-toml")); - Ok(()) - } +fn exact_version_metadata(raw: &str, number: u64) -> Result { + let value: serde_json::Value = serde_json::from_str(raw) + .map_err(|error| format!("failed to preserve Fastly version metadata: {error}"))?; + value + .as_array() + .and_then(|versions| { + versions.iter().find(|version| { + version.get("number").and_then(serde_json::Value::as_u64) == Some(number) + }) + }) + .cloned() + .ok_or_else(|| format!("selected Fastly version {number} has no exact metadata record")) +} + +fn parse_snapshot_array(label: &str, raw: &str) -> Result { + let mut value: serde_json::Value = serde_json::from_str(raw) + .map_err(|error| format!("failed to parse Fastly {label} snapshot: {error}"))?; + let rows = value + .as_array_mut() + .ok_or_else(|| format!("Fastly {label} snapshot must be a complete JSON array"))?; + for (index, row) in rows.iter_mut().enumerate() { + let object = row + .as_object_mut() + .ok_or_else(|| format!("Fastly {label} snapshot record #{index} must be an object"))?; + remove_snapshot_metadata(object); + row.sort_all_objects(); + } + rows.sort_by_cached_key(ToString::to_string); + Ok(value) +} + +fn remove_snapshot_metadata(object: &mut serde_json::Map) { + for field in [ + "created_at", + "locked", + "service_id", + "updated_at", + "version", + ] { + object.remove(field); } } -/// TOCTOU guard for the LOCAL writer: refuse to overwrite a root that now holds a -/// NEWER format, classified HERE under the write lock. The generic push's -/// pre-push future-format check ran BEFORE the lock, so a newer writer could have -/// installed a v2 value in between; without this the old writer would clobber it. -/// -/// The raw value alone does not reveal a future INNER envelope hidden behind a -/// valid v1 pointer -- that is only knowable after reconstruction. So each root -/// that is one of our pointers is RESOLVED against the locked `contents` table -/// (its chunks live there too); a typed `FutureFormat` from the resolver is -/// refused just like a raw future value. -fn reject_future_local_roots( - contents_tbl: &toml_edit::Table, - gc_roots: &[(String, HashSet)], -) -> Result<(), String> { - for (root_key, _) in gc_roots { - let Some(existing) = contents_tbl.get(root_key).and_then(toml_edit::Item::as_str) else { - continue; - }; - // Raw check: a direct future envelope, a future pointer version, or an - // unknown `edgezero_kind`. - let mut is_future = value_is_future_format(existing); - if !is_future { - // Resolve against the locked contents to catch a future INNER envelope - // behind a valid v1 pointer. Only `FutureFormat` blocks the write; a - // corrupt/incomplete v1 prior stays overwritable. - let resolved = resolve_fastly_config_value_typed(root_key, existing.to_owned(), |ck| { - Ok(contents_tbl - .get(ck) - .and_then(toml_edit::Item::as_str) - .map(str::to_owned)) - }); - is_future = matches!(resolved, Err(err) if err.is_future_format()); - } - if is_future { - return Err(format!( - "refusing to overwrite `{root_key}`: the local store now holds a value in a newer \ - format this CLI does not recognise (installed since the pre-push check). Upgrade \ - the CLI rather than clobber a newer format. Nothing was changed." - )); - } - } - Ok(()) +fn parse_snapshot_object(label: &str, raw: &str) -> Result { + let mut value: serde_json::Value = serde_json::from_str(raw) + .map_err(|error| format!("failed to parse Fastly {label} snapshot: {error}"))?; + let object = value + .as_object_mut() + .ok_or_else(|| format!("Fastly {label} snapshot must be a complete JSON object"))?; + remove_snapshot_metadata(object); + value.sort_all_objects(); + Ok(value) } -fn write_fastly_local_config_store( - path: &Path, - platform_name: &str, - entries: &[(String, String)], - gc_roots: &[(String, HashSet)], -) -> Result, String> { - use toml_edit::{DocumentMut, Item, Table, Value, table}; - - // Hold a cross-process advisory lock for the WHOLE read-modify-write. Two - // concurrent local pushes would otherwise both read the file, each apply - // their own edit, and the later rename would discard the earlier push's - // change. Serialising here makes each push read what the previous one wrote - // and build on it, so both edits survive. Released when `_lock` drops. - let lock = ManifestLock::acquire(path)?; - // Read and replace the REAL target the lock guards, so a symlinked manifest - // and a direct path never diverge between the read, the compare, and the - // rename. - let target = lock.target(); - - let raw = match fs::read_to_string(target) { - Ok(text) => text, - Err(err) if err.kind() == ErrorKind::NotFound => String::new(), - Err(err) => return Err(format!("failed to read {}: {err}", target.display())), - }; - // Redacted: `toml_edit`'s parse error quotes the offending source LINE, which - // in a config-store `contents` table is a stored (possibly secret-bearing) - // value. The diff read redacts the same failure; the writer must too. - let mut doc: DocumentMut = raw.parse().map_err(|_err| { - format!( - "failed to parse {} as TOML (details redacted: the error can quote a stored value)", - target.display() - ) - })?; +/// Outcome of scanning `fastly config-store list --json` for a +/// platform store id by `name`. Distinguishes three cases the +/// caller wants to act on differently: +/// +/// - `Found(id)` — happy path. +/// - `NotFound` — JSON parsed cleanly and the array contains +/// entries with well-formed `name` + `id` string fields, but no +/// entry matched `name`. Operator likely needs to run +/// `provision`. +/// - `SchemaDrift(detail)` — the JSON parsed but doesn't match +/// the expected shape (no `items` envelope nor bare array, OR +/// entries are missing `name` / `id` string fields, OR the +/// bytes didn't parse as JSON at all). Likely a fastly CLI +/// version bump that changed the output schema; surface the +/// detail so the operator can pin a known-compatible version. +#[derive(Debug)] +enum ConfigStoreLookup { + Found(String), + NotFound, + SchemaDrift(String), +} - let local_server_entry = doc.entry("local_server").or_insert_with(table); - let local_server_tbl = local_server_entry.as_table_mut().ok_or_else(|| { - format!( - "{}: `local_server` exists but is not a table; refusing to edit in place", - path.display() - ) - })?; - let config_stores_entry = local_server_tbl - .entry("config_stores") - .or_insert_with(|| Item::Table(Table::new())); - let config_stores_tbl = config_stores_entry.as_table_mut().ok_or_else(|| { - format!( - "{}: `local_server.config_stores` exists but is not a table; refusing to edit in place", - path.display() - ) - })?; +#[derive(Clone, Copy, Debug)] +enum FastlyServiceIdSource { + Environment, + Manifest, +} - // Upsert into the existing per-store contents table so a - // `config push --key app_config_staging` does NOT wipe the - // previously-pushed `app_config` blob. The - // default + staging keys must coexist so the runtime - // EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY env var can - // switch between them. (Earlier wholesale-replace was a - // misread of the "stale entries don't linger" property: - // that applies WITHIN a key (old chunks for the same root - // become unreferenced when a new chunk-set installs a new - // pointer), NOT across sibling keys.) - let store_entry = config_stores_tbl.entry(platform_name).or_insert_with(|| { - let mut tbl = Table::new(); - tbl.insert("format", toml_edit::value("inline-toml")); - tbl.insert("contents", Item::Table(Table::new())); - Item::Table(tbl) - }); - let store_tbl = store_entry.as_table_mut().ok_or_else(|| { - format!( - "{}: `local_server.config_stores.{platform_name}` exists but is not a table; refusing to edit in place", - path.display() - ) - })?; - ensure_inline_toml_format(store_tbl, platform_name)?; - let contents_entry = store_tbl - .entry("contents") - .or_insert_with(|| Item::Table(Table::new())); - let contents_tbl = contents_entry.as_table_mut().ok_or_else(|| { - format!( - "{}: `local_server.config_stores.{platform_name}.contents` exists but is not a table; refusing to edit in place", - path.display() - ) - })?; - reject_future_local_roots(contents_tbl, gc_roots)?; - reject_local_generated_key_collisions(contents_tbl, entries)?; - // Snapshot prior chunk keys per GC root BEFORE the upsert, using the - // exact keep-set the caller computed for each root (no prefix scan). - let mut plans: Vec = Vec::with_capacity(gc_roots.len()); - for (root_key, new_keys) in gc_roots { - let prior_keys = contents_tbl - .get(root_key) - .and_then(toml_edit::Item::as_str) - .map_or_else(|| Ok(Vec::new()), |value| prior_chunk_keys(root_key, value)); - plans.push(FastlyConfigGcPlan { - new_keys: new_keys.clone(), - prior_keys, - }); - } +#[derive(Debug)] +struct SelectedFastlyService { + id: String, + source: FastlyServiceIdSource, +} - // Upsert the new physical entries. - for (key, value) in entries { - contents_tbl.insert(key, Item::Value(Value::from(value.clone()))); - } +/// 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 +/// `gc_fastly_config_store` (which reports and deletes). +struct GcPlan { + /// Whole generations to reclaim, each a list of `(key, age_secs)`. Grouped, + /// not flat: a generation is provable only as a UNIT (see + /// `prove_generation`), so deleting part of one destroys the very evidence + /// that licenses deleting the rest. + doomed: Vec>, + /// The root keys retained as live/protected — the config entries GC will NOT + /// delete, sorted. Surfaced so a run shows what it is KEEPING, not only what + /// it would delete, making the sweep reviewable. + kept_roots: Vec, + live_count: usize, + retained_recent: usize, + roots: usize, + /// Chunk-shaped entries we could NOT prove our writer produced, so left + /// untouched. Surfaced so an operator can see we declined to judge them. + unprovable: usize, + /// Non-fatal problems to print — see `GcClassification::warnings`. + warnings: Vec, +} - // Prune orphans in the same in-memory rewrite; a suspicious prior - // pointer (Err) warns and deletes nothing. - let mut warnings = Vec::new(); - for plan in &plans { - match orphan_chunk_keys(plan) { - Ok(orphans) => { - for key in orphans { - // Never remove an orphan that is itself protected -- a - // runtime-readable root, a value claiming our `edgezero_kind` - // namespace or written by a NEWER format, or a nested root with - // canonical chunks beneath it (deleting which would orphan that - // nested generation). Only a raw leaf PAYLOAD prunes. Shared - // with the dry-run count via `is_prunable_leaf`, so the preview - // can never disagree with what is removed here. - if !is_prunable_leaf(contents_tbl, &key) { - warnings.push(format!( - "warning: kept `{key}` -- it is a runtime-readable root, claims the \ - `edgezero_kind` namespace, or is a nested root with chunks beneath it; \ - not a prunable chunk payload" - )); - continue; - } - contents_tbl.remove(&key); - } - } - Err(err) => warnings.push(format!("warning: {err}")), - } - } +/// What one pass of `config gc`'s delete loop actually did. +struct GcDeleteOutcome { + /// Entries whose delete returned success. + deleted: usize, + /// Keys whose delete returned non-zero. + failed: Vec, + /// Survivors of a generation in which an earlier sibling's delete had + /// ALREADY succeeded before a later one failed. These are definitely an + /// incomplete generation now, so they can never be proved (or reclaimed) + /// again -- manual removal only. + stranded: Vec, + /// Members of a generation whose ONLY failure was on a delete with no + /// confirmed prior sibling success. A failed remote delete has UNKNOWN + /// outcome (Fastly may have committed it before returning an error), so we + /// cannot say whether the generation is still whole. A re-run reclaims it if + /// it is, or reports it as an unprovable fragment if it is not. + uncertain: Vec, +} - atomically_replace_file(target, &raw, &doc.to_string())?; - Ok(warnings) +/// The result of classifying a store's entries for reclamation. +struct GcClassification { + /// Chunk keys a live root pointer references, each verified against its + /// content-address. Never deletable. + live: HashSet, + /// Keys whose OWN value is a runtime-readable root — a valid direct envelope + /// or a pointer — regardless of what their key looks like. Never deletable. + protected: HashSet, + /// Count of entries classified as roots, for the summary line. + roots: usize, + /// Non-fatal problems the operator should see — currently roots that are + /// not runtime-readable and so can never be reclaimed automatically. + warnings: Vec, } -/// Replace an already-canonical `target`'s contents ATOMICALLY. Callers pass -/// [`ManifestLock::target`] and hold the lock across the surrounding -/// read-modify-write, so this is not racing another writer; the re-read + compare -/// is a defence-in-depth corruption check, not the concurrency guard. +/// One `config-store-entry list` item. /// -/// In order: +/// `item_value` IS captured — `config gc` must parse root pointers to learn +/// which chunks are live, and one listing avoids a `describe` per root. It is +/// the config payload: it may be read in memory but must NEVER be logged or +/// surfaced (see `redact_describe_response` / `redact_stderr`). +struct ConfigStoreItem { + created_at: String, + item_key: String, + item_value: String, +} + +/// Per-root plan for the LOCAL path's eager prune. /// -/// 1. Re-read `target` and require it to still hold the bytes this rewrite -/// started from (`expected_before`). A mismatch means something OUTSIDE our -/// writers mutated it, so fail rather than overwrite. -/// 2. Create a FRESH temp file in the target's directory with `create_new` -/// (`O_EXCL`): this never follows a file or symlink someone pre-planted at the -/// temp path, and successive names avoid collisions. The rename stays within -/// one directory so it cannot cross a filesystem boundary. -/// 3. Copy the target's existing permissions onto the temp BEFORE writing, so the -/// config bytes are never briefly readable under wider permissions than the -/// manifest allows, then write, then `rename` over the target. `rename` is -/// atomic on POSIX, so a concurrent reader sees either the old file or the new. -/// -/// A [`TempFileGuard`] removes the temp on any failure after it is created. -fn atomically_replace_file( - target: &Path, - expected_before: &str, - contents: &str, -) -> Result<(), String> { - let current = match fs::read_to_string(target) { - Ok(text) => text, - Err(err) if err.kind() == ErrorKind::NotFound => String::new(), - Err(err) => return Err(format!("failed to re-read {}: {err}", target.display())), - }; - if current != expected_before { - return Err(format!( - "{} changed on disk while this write was preparing its rewrite; nothing was written. \ - Re-run to pick up the other change.", - target.display() - )); - } - - let dir = target.parent().unwrap_or_else(|| Path::new(".")); - let file_name = target - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or("fastly.toml"); - // Create a staging file that CANNOT be an attacker's pre-planted symlink: - // `create_new` fails if the path already exists (regular file or symlink), so - // we retry successive names until we own a fresh inode. - let mut attempt = 0_u32; - let (tmp_path, mut tmp_file) = loop { - let candidate = dir.join(format!( - ".{file_name}.edgezero-{}-{attempt}.tmp", - process_id() - )); - match fs::OpenOptions::new() - .create_new(true) - .write(true) - .open(&candidate) - { - Ok(file) => break (candidate, file), - Err(err) if err.kind() == ErrorKind::AlreadyExists => { - attempt = attempt.saturating_add(1); - if attempt > 1_024 { - return Err(format!( - "could not create a staging temp file next to {}", - target.display() - )); - } - } - Err(err) => return Err(format!("failed to create staging temp file: {err}")), - } - }; - let mut guard = TempFileGuard { - path: Some(tmp_path.clone()), - }; - - // Match the target's permissions BEFORE writing any bytes, so config content - // never lands under wider permissions than the manifest already had. A brand - // NEW manifest (NotFound) keeps the create default -- nothing to preserve -- - // but any OTHER metadata error means the target EXISTS yet we cannot read its - // mode, so we must NOT silently widen: fail rather than guess. - match fs::metadata(target) { - Ok(meta) => tmp_file - .set_permissions(meta.permissions()) - .map_err(|err| format!("failed to set permissions on the staging temp file: {err}"))?, - Err(err) if err.kind() == ErrorKind::NotFound => {} - Err(err) => { - return Err(format!( - "failed to read the permissions of {} (refusing to widen access): {err}", - target.display() - )); - } - } - tmp_file - .write_all(contents.as_bytes()) - .map_err(|err| format!("failed to write the staging temp file: {err}"))?; - // Flush to disk BEFORE the rename. A writeback error (ENOSPC/EIO) must surface - // HERE, while the known-good manifest is still untouched -- NOT be swallowed - // so the command "succeeds" after installing content that never reached disk. - // The guard removes the temp on this error. - tmp_file - .sync_all() - .map_err(|err| format!("failed to flush the staging temp file to disk: {err}"))?; - drop(tmp_file); - - // Re-check the hard-link count IMMEDIATELY before the rename. The lock-acquire - // check ran before this write blocked on the lock, and a hard link created - // during that wait (or since) would survive the byte comparison above only for - // the rename to break the alias. This is the last moment we can fail closed. - reject_hard_linked_manifest(target)?; +/// Local reclamation is safe to do immediately: `fastly.toml` is a single +/// file that Viceroy reads at startup — there is no propagation window and no +/// POP that could still be serving the previous pointer. (The cloud path +/// cannot do this; see `reclaim_orphan_generations`.) +struct FastlyConfigGcPlan { + /// Exact keep-set this push writes for the root (chunk keys + root key). + new_keys: HashSet, + /// Prior chunk keys to consider deleting, or a warning to surface + /// (suspicious prior pointer) that skips GC for this root. + prior_keys: Result, String>, +} - fs::rename(&tmp_path, target) - .map_err(|err| format!("failed to replace {}: {err}", target.display()))?; - guard.disarm(); - // Sync the containing directory so the rename entry itself survives a crash. - // Best-effort: opening a directory as a file is not portable (Windows), and - // the critical durability -- the file's contents -- is already flushed above. - if let Ok(dir_handle) = fs::File::open(dir) { - let _dir_sync = dir_handle.sync_all(); - } - Ok(()) +/// An exclusive, cross-process advisory lock covering a local `fastly.toml` +/// rewrite. Serialises concurrent pushes so their read-modify-write cycles +/// cannot interleave and lose each other's edits. +/// +/// The lock is a persistent sidecar file next to the manifest. It is never +/// unlinked — deleting it would reintroduce a create/lock race between two +/// processes each making their own lock file. Dropping the guard releases the +/// OS lock (closing the file descriptor). `File::lock` is advisory, so it only +/// coordinates other lockers, which is exactly the pushes we control. +struct ManifestLock { + _file: fs::File, + /// The REAL file the lock guards, resolved through any symlink. Callers read + /// and replace THIS path, so every alias operates on one target. + target: PathBuf, } -// ------------------------------------------------------------------- -// chunk GC helpers (Stage 7 re-push reclamation) -// ------------------------------------------------------------------- +/// Removes a staging temp file on drop unless disarmed — so every early return +/// (permission failure, write failure, rename failure) cleans up after itself. +struct TempFileGuard { + path: Option, +} -/// Expand ONE logical `(root_key, body)` into its physical entries, the -/// exact keep-set for that root, and the value written at the root key. -/// No cross-root prefix scanning (a free-form `--key` can't mislead it). -#[expect( - clippy::type_complexity, - reason = "one-off internal return; a named type would not aid readability" -)] -fn expand_root( - root_key: &str, - body: &str, -) -> Result<(Vec<(String, String)>, HashSet, String), String> { - let expanded = prepare_fastly_config_entries(root_key, body)?; - let new_keys: HashSet = expanded.iter().map(|(key, _)| key.clone()).collect(); - // prepare_* always emits the root entry LAST (root pointer or direct - // value). Make the invariant explicit rather than silently defaulting. - let new_root_value = expanded - .last() - .map(|(_, value)| value.clone()) - .ok_or_else(|| format!("internal: no physical entries produced for root `{root_key}`"))?; - Ok((expanded, new_keys, new_root_value)) +struct EntryCommitFailure { + committed: Vec, + error: String, + failed_key: String, + not_attempted: Vec, + total: usize, } -/// Orphans = prior chunk keys not in the new keep-set. Propagates a -/// suspicious-pointer `Err` so the caller can warn and skip GC. -fn orphan_chunk_keys(plan: &FastlyConfigGcPlan) -> Result, String> { - match &plan.prior_keys { - Ok(prior) => Ok(prior - .iter() - .filter(|key| !plan.new_keys.contains(*key)) - .cloned() - .collect()), - Err(err) => Err(err.clone()), - } +#[derive(Debug, Default, PartialEq, Eq)] +struct RuntimeStoreIds { + config: Vec, + kv: Vec, + secrets: Vec, } -/// Reject logical keys that collide with the reserved chunk namespace. -/// `--key` is free-form, so this is enforced at the Fastly adapter -/// boundary: such a key would let a push write into another key's chunk -/// space, and could not be reclaimed correctly. -fn reject_reserved_root_keys(entries: &[(String, String)]) -> Result<(), String> { - for (key, _) in entries { - if key.contains(CHUNK_KEY_INFIX) { - return Err(format!( - "config key `{key}` contains the reserved infix `{CHUNK_KEY_INFIX}`, which collides with Fastly chunk storage; choose a different config key (or --key override)" - )); +impl From<&DeployStoreIds> for RuntimeStoreIds { + fn from(stores: &DeployStoreIds) -> Self { + Self { + config: stores.config.clone(), + kv: stores.kv.clone(), + secrets: stores.secrets.clone(), } } - Ok(()) } -/// Unix epoch seconds. Push-time only (the `cli` feature is native). -fn unix_now_secs() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map_or(0, |elapsed| elapsed.as_secs()) +fn owns_managed_deploy(context: &AdapterDeployContext) -> bool { + context.application_release_root.is_some() || context.staging || !context.stores.is_empty() } -/// Reject a batch that names the same logical root key more than once. -/// -/// The adapter trait takes an entry slice and does not enforce uniqueness, -/// but GC builds one plan per entry and snapshots every plan against the -/// SAME prior generation. With `[(root, A), (root, B)]` the last tuple wins -/// the upsert (root = B), yet A's plan would still reclaim `prior - A_keys` -/// — which includes B's freshly-written chunks — leaving the final pointer -/// referencing missing chunks. Rejecting is safer than silently coalescing: -/// a duplicated key is a caller bug, and picking a winner would hide it. -fn reject_duplicate_root_keys(entries: &[(String, String)]) -> Result<(), String> { - let mut seen: HashSet<&str> = HashSet::with_capacity(entries.len()); - for (key, _) in entries { - if !seen.insert(key.as_str()) { - return Err(format!( - "config key `{key}` appears more than once in a single push; each logical key must be pushed exactly once" - )); +// The three `validate_*` trait methods exist on `Adapter` because +// spin requires them (variable-name regex, `[component.*]` +// discovery, flat-namespace collision). The trait surface is typed +// generically so any future adapter with similar constraints can +// override — but fastly has no equivalent platform requirements, +// so the no-op defaults are correct: +// +// - `validate_app_config_keys`: Fastly Config Store keys accept +// alphanumeric + `-` / `_` / `.` up to 256 chars. Any reasonable +// Rust struct field name passes; no regex check needed. +// - `validate_adapter_manifest`: would require shelling out to +// `fastly compute validate` at validate-time. We keep +// `config validate` pure-Rust so it stays fast and +// tool-independent. +// - `validate_typed_secrets`: Fastly's KV / Config / Secret +// stores are independent namespaces — no spin-style flat- +// namespace collision risk to detect. +// +// `single_store_kinds` IS overridden below — explicitly returns +// `&[]` for documentation, matching the inherited default. +#[expect( + clippy::missing_trait_methods, + reason = "see the explanatory block comment immediately above; fastly's no-op defaults for the three validate_* hooks are intentional and documented. `read_config_entry` and `read_config_entry_local` are both overridden below. `single_store_kinds` IS overridden below (returns `&[]`)." +)] +impl Adapter for FastlyCliAdapter { + fn deploy(&self, context: &AdapterDeployContext, args: &[String]) -> Result<(), String> { + if owns_managed_deploy(context) { + deploy_managed_with_context(context, args) + } else { + validate_effective_deploy_service_id(context)?; + scan_reserved_deploy_args(args)?; + deploy_with_context(context, args) } } - Ok(()) -} -/// Best-effort per-root orphan count for `config push --local --dry-run`. -/// Navigate to `[local_server.config_stores..contents]` for the -/// dry-run counter. `Ok(None)` when any level is absent (no prior state); -/// `Err` when a level is present but the wrong type — prior state the real -/// writer would reject, so the count must degrade to "unknown", not 0. -fn local_contents_table<'doc>( - doc: &'doc toml_edit::DocumentMut, - platform_name: &str, -) -> Result, String> { - let malformed = || "could not read prior state".to_owned(); - let Some(server_item) = doc.get("local_server") else { - return Ok(None); - }; - let Some(server) = server_item.as_table() else { - return Err(malformed()); - }; - let Some(stores_item) = server.get("config_stores") else { - return Ok(None); - }; - let Some(stores) = stores_item.as_table() else { - return Err(malformed()); - }; - let Some(store_item) = stores.get(platform_name) else { - return Ok(None); - }; - let Some(store) = store_item.as_table() else { - return Err(malformed()); - }; - let Some(contents_item) = store.get("contents") else { - return Ok(None); - }; - contents_item - .as_table() - .map_or_else(|| Err(malformed()), |table| Ok(Some(table))) -} - -/// Reads the current `fastly.toml` (offline) and, for each logical -/// `(root_key, body)`, counts `prior_chunk_keys(root, old) - new_keys` -/// where `new_keys` is the root's OWN expansion. Never fails the dry-run: -/// on a missing file / no prior pointer / direct prior value it reports -/// `Ok(0)`; on unreadable or malformed prior state it reports `Err(reason)` -/// which the caller renders as an "unknown" line. -/// Is `key` a plain, prunable chunk PAYLOAD in `contents`? `false` for a value -/// that must be KEPT: a runtime-readable root, a value claiming our -/// `edgezero_kind` namespace or written by a newer format, or a NESTED root (a -/// key with a canonical chunk beneath it). Only a raw leaf payload prunes. -/// -/// The single source of truth shared by the real prune (`write_fastly_local_ -/// config_store`) and the dry-run count, so the previewed number can never drift -/// from what `--yes` actually removes. (The dry-run reads the PRE-upsert table and -/// the prune the POST-upsert one, but a generated key with a nested generation is -/// already refused by `reject_generated_key_collisions`, so that asymmetry cannot -/// change the verdict.) -fn is_prunable_leaf(contents: &toml_edit::Table, key: &str) -> bool { - let value_protected = contents - .get(key) - .and_then(toml_edit::Item::as_str) - .is_some_and(|text| { - value_announces_our_kind(text) - || value_is_future_format(text) - || gc_classify_root(key, text).is_ok() - }); - let has_nested = contents - .iter() - .any(|(other, _)| other != key && chunk_key_generation(key, other).is_some()); - !(value_protected || has_nested) -} - -fn local_orphan_counts_for_dry_run( - path: &Path, - platform_name: &str, - entries: &[(String, String)], -) -> Vec<(String, Result)> { - use toml_edit::DocumentMut; + fn execute(&self, action: AdapterAction, args: &[String]) -> Result<(), String> { + match action { + // `fastly profile {create|delete|list}` is the native + // sign-in surface for Fastly Compute. EdgeZero stores no + // credentials — this is a thin shell-out. + AdapterAction::AuthLogin => { + run_native_cli("fastly", &["profile", "create"], FASTLY_INSTALL_HINT) + } + AdapterAction::AuthLogout => { + run_native_cli("fastly", &["profile", "delete"], FASTLY_INSTALL_HINT) + } + AdapterAction::AuthStatus => { + run_native_cli("fastly", &["profile", "list"], FASTLY_INSTALL_HINT) + } + AdapterAction::Build => { + let artifact = build(args)?; + log::info!("[edgezero] Fastly build complete -> {}", artifact.display()); + Ok(()) + } + AdapterAction::Deploy => deploy(args), + AdapterAction::Serve => serve(args), + AdapterAction::DeployStaging => Err( + "Fastly staging requires typed deploy context and --application-release".to_owned(), + ), + AdapterAction::EmitVersion => emit_active_version(args), + AdapterAction::Healthcheck => healthcheck(args), + AdapterAction::Rollback => rollback(args), + other => Err(format!("fastly adapter does not support {other:?}")), + } + } - // Parse the current file once (best-effort). Absent file => no prior. - let parsed: Result, String> = match fs::read_to_string(path) { - Ok(text) => text - .parse::() - .map(Some) - .map_err(|_err| "could not read prior state".to_owned()), - Err(err) if err.kind() == ErrorKind::NotFound => Ok(None), - Err(_) => Err("could not read prior state".to_owned()), - }; + fn finalize_deploy( + &self, + context: &AdapterDeployContext, + command_output: Option<&str>, + ) -> Result<(), String> { + if context.staging { + return Ok(()); + } - entries - .iter() - .map(|(root_key, body)| { - let new_keys = match expand_root(root_key, body) { - Ok((_, keys, _)) => keys, - Err(err) => return (root_key.clone(), Err(err)), - }; - let count = match &parsed { - Err(reason) => Err(reason.clone()), - Ok(None) => Ok(0), - Ok(Some(doc)) => match local_contents_table(doc, platform_name) { - Err(reason) => Err(reason), - Ok(None) => Ok(0), - Ok(Some(contents)) => match contents.get(root_key) { - None => Ok(0), // no prior value for this root - Some(item) => match item.as_str() { - None => Err("could not read prior state".to_owned()), - Some(raw) => match prior_chunk_keys(root_key, raw) { - Ok(prior) => Ok(prior - .iter() - .filter(|key| !new_keys.contains(*key)) - // Count only what the real prune would remove: - // it must still be PRESENT (an absent key is a - // no-op remove, not a deletion) AND a prunable - // leaf by the SAME predicate the prune uses. - .filter(|key| { - contents.get(key.as_str()).is_some() - && is_prunable_leaf(contents, key) - }) - .count()), - Err(_) => Err("suspicious prior pointer".to_owned()), - }, - }, - }, - }, - }; - (root_key.clone(), count) + 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) { + let token = require_token()?; + verify_version_active( + service_id, + version, + &token, + "after manifest-command deployment", + )?; + 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}" + ) }) - .collect() -} - -// ------------------------------------------------------------------- -// `config push` helpers -// ------------------------------------------------------------------- + } -/// Run `fastly config-store-entry list --store-id= --json` and return each -/// item's `item_key`, `item_value`, and `created_at`. -/// -/// The item VALUE is KEPT (not discarded): `config gc` classifies each root by -/// its value (`gc_classify_root`) and reconstructs live generations from the -/// chunk values, so all three fields are required. The value is used internally -/// only and is NEVER echoed into a diagnostic — parse failures redact it via -/// `redact_describe_response`. -fn list_config_store_entries(store_id: &str) -> Result, String> { - let store_arg = format!("--store-id={store_id}"); - let output = Command::new("fastly") - .args(["config-store-entry", "list", store_arg.as_str(), "--json"]) - .output() - .map_err(|err| { - if err.kind() == ErrorKind::NotFound { - format!("`fastly` not found on PATH; {FASTLY_INSTALL_HINT}") - } else { - format!("failed to spawn `fastly`: {err}") - } - })?; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!( - "`fastly config-store-entry list --store-id={store_id} --json` exited with status {}\nstderr: {}", - output.status, - redact_stderr(&stderr) - )); + fn gc_config_entries( + &self, + _manifest_root: &Path, + _adapter_manifest_path: Option<&str>, + _component_selector: Option<&str>, + store: &ResolvedStoreId, + _push_ctx: &AdapterPushContext<'_>, + older_than_secs: u64, + dry_run: bool, + ) -> Result, String> { + gc_fastly_config_store(store.platform.as_str(), older_than_secs, dry_run) } - let stdout = strict_stdout(output.stdout, "config-store-entry list --json")?; - let parsed: serde_json::Value = serde_json::from_str(&stdout).map_err(|_err| { - format!( - "failed to parse `fastly config-store-entry list` JSON (parse error redacted; \ - response: {})", - redact_describe_response(&stdout) - ) - })?; - // A BARE ARRAY ONLY. The installed Fastly CLI returns the complete store as - // a top-level array with no cursor/paging flags. Any other shape (e.g. an - // `{"items":[...], ...}` envelope) may carry pagination metadata we do not - // follow -- and a page that omitted a ROOT while listing its chunks would - // make live chunks look orphaned. The completeness guard cannot see a root - // that isn't there, so we refuse rather than reclaim from a partial view. - let array = parsed.as_array().ok_or_else(|| { - format!( - "refusing to reclaim: `fastly config-store-entry list --json` did not return a bare \ - array (response: {}). This build only supports an unpaginated listing; a partial view \ - could hide a root and orphan its live chunks. Nothing was deleted.", - redact_describe_response(&stdout) - ) - })?; - // FAIL CLOSED on any malformed entry. A missing/non-string field on a - // reclamation input must NEVER be silently skipped or defaulted to empty: - // skipping a root hides the chunks it references (they'd look orphaned and - // get deleted while live), and an empty `item_value` makes a real root - // parse as "references nothing" — same catastrophe. If we can't read the - // listing exactly, we delete nothing. - let mut items = Vec::with_capacity(array.len()); - for (idx, entry) in array.iter().enumerate() { - // Name the offending KEY, not just the index: `item_key` is readable even - // when another field is empty, so the operator can see WHICH entry to fix. - let key_hint = entry - .get("item_key") - .and_then(serde_json::Value::as_str) - .filter(|key| !key.is_empty()) - .map_or_else(|| format!("#{idx}"), |key| format!("`{key}`")); - let field = |name: &str| -> Result { - let raw = entry - .get(name) - .and_then(serde_json::Value::as_str) - .ok_or_else(|| { - format!( - "`fastly config-store-entry list` entry {key_hint} is missing a string \ - `{name}` field; refusing to reclaim (nothing deleted)" - ) - })?; - // An EMPTY field is as dangerous as a missing one: an empty root value - // would classify as "references nothing" and orphan its live chunks. - // Reject it here rather than reason about it later -- but say what to - // look at, since a legitimate empty-valued sibling is otherwise a - // whole-store block with no obvious cause. - if raw.is_empty() { - return Err(format!( - "`fastly config-store-entry list` entry {key_hint} has an empty `{name}` field; \ - refusing to reclaim (nothing deleted). If this is a legitimate empty-valued \ - entry, remove it or give it a value before running `config gc`." - )); - } - Ok(raw.to_owned()) - }; - items.push(ConfigStoreItem { - created_at: field("created_at")?, - item_key: field("item_key")?, - item_value: field("item_value")?, - }); + + fn name(&self) -> &'static str { + "fastly" } - // DUPLICATE KEYS => fail closed. A key must appear once; a store cannot - // really hold two entries under one key, so duplicate rows mean we are not - // reading the store we think we are (a merged/paginated view, or a CLI - // change). Left alone, the last row silently wins for BOTH the live-set - // lookup and `created_at`, so conflicting rows could age a recent key into - // eligibility and schedule the same key for two deletes. - let mut seen: HashSet<&str> = HashSet::with_capacity(items.len()); - if let Some(duplicate) = items - .iter() - .find(|item| !seen.insert(item.item_key.as_str())) - { - return Err(format!( - "refusing to reclaim: `fastly config-store-entry list` returned key `{}` more than \ - once. A key is unique in a config store, so this listing does not describe one \ - consistent view of it (nothing was deleted).", - duplicate.item_key - )); + fn preflight_config_write(&self, key: &str, body: &str) -> Result<(), String> { + // Reject an infeasible push here, BEFORE the CLI's remote read, so it + // fails offline rather than after a list/describe. The write path + // re-checks, so this is a strict early gate, not the only one. + // + // An empty key is writer-valid but resolver-invalid (canonical chunk + // parsing rejects an empty root); reject it before any I/O. + if key.is_empty() { + return Err( + "config key is empty; provide a store id or a non-empty `--key`".to_owned(), + ); + } + let entry = [(key.to_owned(), String::new())]; + reject_reserved_root_keys(&entry)?; + // Run the full chunk expansion OFFLINE (no I/O): exactly what the write + // path does, so every body-dependent feasibility failure — the root key + // over the store limit, a DERIVED chunk key over it once the value + // chunks, or a pointer that would not fit the entry limit — is caught + // here, before the remote read, instead of after it. + prepare_fastly_config_entries(key, body)?; + Ok(()) } - Ok(items) -} - -/// RFC 3339 (`2026-07-13T03:27:42Z`) -> unix seconds, rounded UP on any fraction. -/// -/// `timestamp()` FLOORS the sub-second part, and the current time the age gate -/// compares against is also floored. A creation floored DOWN makes a key look -/// OLDER: a true age of 59.002s (created `...:42.998Z`) would compute as 60s and -/// pass a 60s `--older-than` almost a full second early. Rounding creation UP -/// keeps the computed age conservative -- a key never ages into deletion early. -fn parse_rfc3339_secs(raw: &str) -> Option { - let stamp = chrono::DateTime::parse_from_rfc3339(raw).ok()?; - let secs = stamp.timestamp(); - let rounded_up = if stamp.timestamp_subsec_nanos() > 0 { - secs.checked_add(1)? - } else { - secs - }; - u64::try_from(rounded_up).ok() -} - -/// Report what a sweep is KEEPING, not only what it would delete, so the run is -/// reviewable: each RETAINED root by key, plus the referenced-chunk total those -/// roots hold (already summarised). A root listed here is never a delete -/// candidate. -/// -/// "Retained"/"referenced", not "live": the set also includes a root that is -/// PROTECTED but not runtime-readable (e.g. one that fails the writer split check -/// and is warned about separately). Its chunks are conservatively protected, not -/// runtime-live, so `live_count` here is a count of REFERENCED chunks. -fn append_kept_roots_report(out: &mut Vec, kept_roots: &[String], live_count: usize) { - if kept_roots.is_empty() { - out.push("keeping 0 retained root(s)".to_owned()); - return; - } - out.push(format!( - "keeping {} retained root(s) ({live_count} referenced chunk(s) held by them):", - kept_roots.len() - )); - for key in kept_roots { - out.push(format!(" keeping `{key}`")); + fn validate_config_key_for_target( + &self, + logical_store_id: &str, + key: &str, + staging: bool, + local: bool, + ) -> Result<(), String> { + validate_fastly_config_key(logical_store_id, key, staging, local) } -} -/// `config gc` for Fastly: delete chunk entries that no LIVE root pointer -/// references and that are older than the operator's `older_than_secs`. -/// -/// Why this is a separate, operator-invoked command rather than part of `config -/// push`: see `Adapter::gc_config_entries`. The operator's `--older-than` is the -/// safety assertion the platform cannot make. A dry-run prints exactly which -/// keys would go, with ages, so the assertion is reviewable. -/// -/// Fails CLOSED: if the listing is unreadable, or a root's value cannot be -/// classified, nothing is deleted. -fn gc_fastly_config_store( - store_name: &str, - older_than_secs: u64, - dry_run: bool, -) -> Result, String> { - // THE destructive boundary enforces its own precondition. The CLI rejects a - // zero window too, but `gc_config_entries` is a public trait method any - // caller can reach directly -- a safety rule that lives only in the CLI is - // not a safety rule. A zero window asserts nothing: it makes every orphan - // eligible, including one superseded a second ago whose pointer POPs are - // still serving. (A dry-run may preview at zero; it deletes nothing.) - if !dry_run && older_than_secs == 0 { - return Err( - "refusing to reclaim: a destructive `config gc` requires a non-zero `--older-than` \ - window. Zero asserts nothing -- it would make every orphan eligible, including \ - chunks a pointer POPs are still serving. Nothing was deleted." - .to_owned(), - ); + fn preflight_deploy( + &self, + context: &AdapterDeployContext, + args: &[String], + ) -> Result { + validate_effective_deploy_service_id(context)?; + scan_reserved_deploy_args(args)?; + if owns_managed_deploy(context) { + Ok(DeployOwnership::AdapterManaged) + } else { + Ok(DeployOwnership::ManifestCommand) + } } - let resolved_id = resolve_remote_config_store_id(store_name)? - .ok_or_else(|| no_matching_store_error(store_name))?; - let items = list_config_store_entries(&resolved_id)?; - let plan = plan_gc_reclamation(&items, unix_now_secs(), older_than_secs)?; - let GcPlan { - doomed, - kept_roots, - live_count, - retained_recent, - roots, - unprovable, - warnings, - } = plan; - let doomed_count: usize = doomed.iter().map(Vec::len).sum(); - let mut out = vec![format!( - "fastly config-store `{store_name}` (id={resolved_id}): {} entries, {roots} root(s), {live_count} referenced chunk(s), {doomed_count} orphan(s) in {} generation(s) older than {older_than_secs}s, {retained_recent} orphan(s) too recent", - items.len(), - doomed.len(), - )]; - out.extend(warnings); - append_kept_roots_report(&mut out, &kept_roots, live_count); - if unprovable > 0 { - // NEVER silent: these entries look like chunk keys but we could not - // prove our writer produced them, so we left them alone. Say so, or the - // summary reads as "everything reclaimable was reclaimed". - out.push(format!( - " {unprovable} chunk-shaped entr(ies) left untouched: they are not byte-identical to what this writer would produce (wrong content-address, a split this writer would not choose, an incomplete generation, or a count it would never emit), so EdgeZero cannot claim them" - )); - } - if doomed_count == 0 { - out.push("nothing to reclaim".to_owned()); - return Ok(out); - } - if dry_run { - // A dry-run only PLANS: list every candidate and stop. Nothing is - // attempted, so there is no confirmed/failed/skipped distinction yet. - for (key, age) in doomed.iter().flatten() { - out.push(format!(" would delete `{key}` (age {age}s)")); + fn provision( + &self, + manifest_root: &Path, + adapter_manifest_path: Option<&str>, + _component_selector: Option<&str>, + stores: &ProvisionStores<'_>, + dry_run: bool, + ) -> Result, String> { + // Fastly is Multi for every store kind. Each id maps 1:1 + // to a Fastly resource (kv-store / config-store / + // secret-store) created via the Fastly CLI; the manifest + // writeback declares the resource link for `fastly + // compute deploy` and the local viceroy server. + let Some(rel) = adapter_manifest_path else { + return Err( + "[adapters.fastly.adapter].manifest must point at fastly.toml for provision" + .to_owned(), + ); + }; + let fastly_path = manifest_root.join(rel); + let manifest_dir = fastly_path.parent().unwrap_or(manifest_root); + let selected_service = effective_fastly_service_id(&fastly_path)?; + let mut out = Vec::new(); + for (kind, ids) in [ + ("kv", stores.kv), + ("config", stores.config), + ("secret", stores.secrets), + ] { + for store in ids { + // Fastly setup tables key on the resource name the + // CLI creates. The runtime resolves that same name + // via `EDGEZERO__STORES______NAME`, + // so provision must use the env-resolved PLATFORM + // name -- the logical id stays in status lines for + // human-facing wording. + let logical = store.logical.as_str(); + let name = store.platform.as_str(); + if dry_run { + out.push(format!( + "would run `fastly {kind}-store create --name={name}` and append [setup.{kind}_stores.{name}] to {} (logical id `{logical}`)", + fastly_path.display() + )); + continue; + } + if setup_block_present(&fastly_path, kind, name)? { + out.push(format!( + "fastly {kind}-store `{name}` (logical id `{logical}`) already declared in {}; skipping. To force a fresh remote: delete the [setup.{kind}_stores.{name}] block AND run `fastly {kind}-store delete --name={name}` (the old remote store lingers otherwise), then re-run provision.", + fastly_path.display() + )); + continue; + } + create_fastly_store_in(kind, name, manifest_dir)?; + // If the platform store was created but the + // writeback fails, remote state and the local + // manifest are out of sync. Re-running `provision` + // would attempt to create the platform store again + // and fail with "already exists". Surface the + // recovery path explicitly so the operator isn't + // stuck. + append_fastly_setup(&fastly_path, kind, name).map_err(|err| { + format!( + "fastly {kind}-store `{name}` (logical id `{logical}`) was created remotely, but writeback to {path} failed: {err}\n To recover, either:\n 1. Manually append `[setup.{kind}_stores.{name}]` to {path} and re-run, or\n 2. Delete the orphan remote store via `fastly {kind}-store delete --name={name}` and re-run `edgezero provision --adapter fastly`.", + path = fastly_path.display() + ) + })?; + // Fastly's `[setup._stores.]` table is + // consumed ONLY when `fastly compute deploy` is + // creating a NEW service. If `service_id` is + // already present in fastly.toml, the service has + // been deployed at least once and subsequent + // deploys skip `[setup]` entirely — so the store + // exists in the account but has no resource link + // tying it to a service version, and the running + // Compute service can't open it. + // + // Detect that case and EMIT the exact one-shot + // command the operator should run to link the + // store. We deliberately don't auto-run it: the + // link cones the active version (`--autoclone`), + // and silently mutating an already-deployed + // service is surprising. The instruction names + // both the store-id lookup AND the link command so + // the operator can audit before committing. + let post_create_note = resource_link_note(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() + ); + if let Some(note) = post_create_note { + line.push('\n'); + line.push_str(¬e); + } + out.push(line); + } } - // `--yes` ALWAYS requires an explicit non-zero `--older-than` (a - // destructive run must not guess the window), so the apply instruction - // names both -- "re-run with --yes" alone would be rejected. - out.push(format!( - "dry-run: {doomed_count} orphan chunk(s) planned for deletion; re-run with \ - `--yes --older-than ` (a non-zero window is required) to apply" - )); - return Ok(out); + if out.is_empty() { + out.push("fastly has no declared stores to provision".to_owned()); + } + Ok(out) } - // Real run: `doomed_count` is the PLANNED count. Do NOT pre-print each key as - // "deleting" -- execution stops at a generation's first failure, so some - // planned keys are never attempted. `execute_gc_deletes` reports the real - // per-key outcome (deleted / FAILED / skipped) as it happens. - out.push(format!( + + fn push_config_entries( + &self, + _manifest_root: &Path, + _adapter_manifest_path: Option<&str>, + _component_selector: Option<&str>, + store: &ResolvedStoreId, + entries: &[(String, String)], + _push_ctx: &AdapterPushContext<'_>, + dry_run: bool, + ) -> Result, String> { + // Resolve the platform config-store id on demand via + // `fastly config-store list --json` (matched by name = + // `store.platform`), then `fastly config-store-entry update + // --store-id= --key= --upsert --stdin` per physical + // entry. Entries are logical blob-envelope entries from + // the CLI (one (key, envelope_json) per push); oversized + // Fastly values are expanded below into chunk entries plus + // a root pointer by `chunked_config::prepare_fastly_config_entries`. + let logical = store.logical.as_str(); + let name = store.platform.as_str(); + if entries.is_empty() { + return Ok(vec![format!( + "no config entries to push to fastly config-store `{name}` (logical id `{logical}`)" + )]); + } + // Reject reserved keys before any expansion or I/O. + reject_reserved_root_keys(entries)?; + reject_duplicate_root_keys(entries)?; + // Expand each logical root into its physical entries (chunks + pointer, or + // a single direct entry). Collecting them all first surfaces a + // pointer-too-large error before touching the remote store. A cloud push + // does NOT reclaim, so — unlike the local path — it keeps no per-root + // keep-set / root-value GC bookkeeping. + let mut physical_entries: Vec<(String, String)> = Vec::new(); + for (key, body) in entries { + let (expanded, ..) = expand_root(key, body)?; + physical_entries.extend(expanded); + } + if dry_run { + // Report intent without shelling out. Stays fully offline: no + // store-id resolution, no remote read (so no GC count). + let mut out = Vec::with_capacity(entries.len().saturating_mul(2).saturating_add(1)); + out.push(format!( + "would resolve fastly config-store `{name}` (logical id `{logical}`) via `fastly config-store list --json` and push entries:" + )); + for (key, body) in entries { + let expanded = prepare_fastly_config_entries(key, body) + .unwrap_or_else(|_| vec![(key.clone(), body.clone())]); + if expanded.len() == 1 { + out.push(format!( + " would push `{key}` as direct entry ({}B)", + body.len() + )); + } else { + let chunk_count = expanded.len().saturating_sub(1); + out.push(format!( + " would push `{key}` as chunked ({chunk_count} chunks + 1 pointer, {}B total)", + body.len() + )); + } + } + return Ok(out); + } + let resolved_id = + resolve_remote_config_store_id(name)?.ok_or_else(|| no_matching_store_error(name))?; + // NOTE: a cloud push does NOT reclaim orphaned chunks. + // + // Fastly's config store is eventually consistent, so a generation may + // only be deleted once the pointer that referenced it has stopped being + // served everywhere. Fastly records no pointer-supersession time + // (`updated_at` is NOT bumped by `update --upsert` -- verified against + // the live API), offers no compare-and-swap with which to record one + // safely, and chunk `created_at` is NOT a proxy for it (a chunked -> + // direct -> direct transition leaves the old generation with no + // "successor" at all). Every attempt to synthesise that fact is unsound. + // + // So reclamation is an explicit, operator-invoked `config gc`: the + // operator supplies the one fact the platform cannot -- that the current + // config has been live long enough that nothing is serving the old + // pointers. See the spec's "Cloud reclamation". + // Preflight: refuse if a generated chunk key would clobber an existing + // root-like sibling in the remote store. Uses a completeness-strict key + // listing (value-tolerant) and describes only the rare colliding keys. + let remote_keys = list_config_store_keys(&resolved_id)?; + reject_generated_key_collisions(&physical_entries, &remote_keys, |chunk_key| { + fetch_remote_config_store_entry(&resolved_id, chunk_key).map(Some) + })?; + push_entries_with_committer(&physical_entries, |key, value| { + create_config_store_entry(&resolved_id, key, value) + })?; + Ok(vec![format!( + "pushed {} physical entries ({} logical) to fastly config-store `{name}` (logical id `{logical}`, id={resolved_id})", + physical_entries.len(), + entries.len() + )]) + } + + fn push_config_entries_local( + &self, + manifest_root: &Path, + adapter_manifest_path: Option<&str>, + _component_selector: Option<&str>, + store: &ResolvedStoreId, + entries: &[(String, String)], + _push_ctx: &AdapterPushContext<'_>, + dry_run: bool, + ) -> Result, String> { + // Local-emulator path: edit + // `[local_server.config_stores..contents]` in + // `fastly.toml`. Viceroy reads it on startup, so a + // subsequent `fastly compute serve` exposes the new values + // to the wasm component. No shell-out to the production + // Fastly CLI -- the operator may not be authenticated and + // wouldn't want a local push to touch production anyway. + let Some(rel) = adapter_manifest_path else { + return Err( + "[adapters.fastly.adapter].manifest must point at fastly.toml for config push --local" + .to_owned(), + ); + }; + let fastly_path = manifest_root.join(rel); + let logical = store.logical.as_str(); + let name = store.logical.as_str(); + if entries.is_empty() { + return Ok(vec![format!( + "no config entries to push to `[local_server.config_stores.{name}]` in {} (logical id `{logical}`)", + fastly_path.display() + )]); + } + // Reject reserved keys before any expansion or I/O. + reject_reserved_root_keys(entries)?; + reject_duplicate_root_keys(entries)?; + // Expand each logical root once: flatten for the write, keep the + // exact per-root keep-set for GC (no prefix scan of the flattened set). + let mut physical_entries: Vec<(String, String)> = Vec::new(); + let mut gc_roots: Vec<(String, HashSet)> = Vec::with_capacity(entries.len()); + for (key, body) in entries { + let (expanded, new_keys, _new_root) = expand_root(key, body)?; + physical_entries.extend(expanded); + gc_roots.push((key.clone(), new_keys)); + } + if dry_run { + let counts = local_orphan_counts_for_dry_run(&fastly_path, name, entries); + let mut out = Vec::with_capacity(entries.len().saturating_mul(2).saturating_add(1)); + out.push(format!( + "would edit `[local_server.config_stores.{name}.contents]` in {} (logical id `{logical}`) with entries:", + fastly_path.display(), + )); + for (idx, (key, body)) in entries.iter().enumerate() { + let expanded = prepare_fastly_config_entries(key, body) + .unwrap_or_else(|_| vec![(key.clone(), body.clone())]); + if expanded.len() == 1 { + out.push(format!( + " would set `{key}` as direct entry ({}B)", + body.len() + )); + } else { + let chunk_count = expanded.len().saturating_sub(1); + out.push(format!( + " would set `{key}` as chunked ({chunk_count} chunks + 1 pointer, {}B total)", + body.len() + )); + } + match counts.get(idx).map(|(_, count)| count) { + Some(Ok(n)) => out.push(format!( + " would delete {n} orphan chunks from the previous generation of `{key}`" + )), + Some(Err(reason)) => out.push(format!( + " would delete an unknown number of orphan chunks from the previous generation of `{key}` (unknown: {reason})" + )), + None => {} + } + } + return Ok(out); + } + let warnings = + write_fastly_local_config_store(&fastly_path, name, &physical_entries, &gc_roots)?; + let mut out = vec![format!( + "wrote {} physical entries ({} logical) to `[local_server.config_stores.{name}.contents]` in {} (logical id `{logical}`); restart `fastly compute serve` to pick up changes", + physical_entries.len(), + entries.len(), + fastly_path.display() + )]; + out.extend(warnings); + Ok(out) + } + + fn read_config_entry( + &self, + _manifest_root: &Path, + _adapter_manifest_path: Option<&str>, + _component_selector: Option<&str>, + store: &ResolvedStoreId, + key: &str, + _push_ctx: &AdapterPushContext<'_>, + ) -> Result { + // Shell out to `fastly config-store-entry describe + // --store-id= --key= --json`, resolve the store id on + // demand via `fastly config-store list --json`, then parse the + // JSON response. + let name = store.platform.as_str(); + // A TYPED absence: `Ok(None)` (list succeeded, no store matched) is the + // only path to MissingStore. Any operational failure stays `Err` and fails + // closed -- an incomplete read must never read as absence and authorise an + // overwrite of healthy remote state. + let Some(store_id) = resolve_remote_config_store_id(name)? else { + return Ok(ReadConfigEntry::MissingStore); + }; + let store_arg = format!("--store-id={store_id}"); + let key_arg = format!("--key={key}"); + let output = Command::new("fastly") + .args([ + "config-store-entry", + "describe", + store_arg.as_str(), + key_arg.as_str(), + "--json", + ]) + .output() + .map_err(|err| { + if err.kind() == ErrorKind::NotFound { + format!("`fastly` not found on PATH; {FASTLY_INSTALL_HINT}") + } else { + format!("failed to spawn `fastly`: {err}") + } + })?; + if output.status.success() { + let stdout = strict_stdout(output.stdout, "config-store-entry describe --json")?; + // Parse the JSON and extract the `item_value` field. + let parsed: serde_json::Value = serde_json::from_str(&stdout).map_err(|_err| { + format!( + "failed to parse `fastly config-store-entry describe` JSON (parse error \ + redacted; response: {})", + redact_describe_response(&stdout) + ) + })?; + let value = parsed + .get("item_value") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + format!( + "`fastly config-store-entry describe` JSON has no string `item_value` field; \ + fastly CLI may have changed its output schema. (response: {})", + redact_describe_response(&stdout) + ) + })?; + // Resolve chunk pointers: if `value` is a direct BlobEnvelope it + // passes through unchanged; if it is a chunk pointer the chunks + // are fetched from the same store and reconstructed. + // + // A chunk describe that fails could not be FULLY read. Confirm whether + // the chunk is genuinely ABSENT against the complete store listing + // (authoritative), never the describe 404: + // - CONFIRMED absent → resolve to a repairable `Corrupt`. The blob + // spec makes persistent chunk loss repairable by re-pushing, so a + // push can overwrite to fix it. + // - present-but-unreadable, or the listing itself failed → + // `fetch_failed`: an incomplete read that must be a HARD error, + // never an overwritable value. + let store_keys: RefCell, String>>> = RefCell::new(None); + let fetch_failed: Cell = Cell::new(false); + let resolved = resolve_fastly_config_value_typed(key, value.to_owned(), |chunk_key| { + match fetch_remote_config_store_entry(&store_id, chunk_key) { + Ok(found) => Ok(Some(found)), + Err(_describe_err) => { + match confirm_key_absent_cached(&store_keys, &store_id, chunk_key) { + Ok(true) => Ok(None), // genuinely gone → repairable Corrupt + Ok(false) => { + fetch_failed.set(true); + Err("a referenced chunk is present in the store but its value \ + could not be read (incomplete read)" + .to_owned()) + } + Err(list_err) => { + fetch_failed.set(true); + Err(list_err) + } + } + } + } + }); + return classify_resolved_read(resolved, value, fetch_failed.get()); + } + // The describe failed. Absence is CONFIRMED only by a complete listing + // that omits the key -- never by a describe 404, which a proxy/endpoint or + // auth failure produces just the same. A present key (or a listing that + // itself fails) is a hard error, so two such incomplete reads can never + // pass the pre-write recheck and authorise an overwrite. + if confirm_entry_absent(&store_id, key)? { + return Ok(ReadConfigEntry::MissingKey); + } + let stderr = String::from_utf8_lossy(&output.stderr); + Err(format!( + "`fastly config-store-entry describe --store-id={store_id} --key={key} --json` exited \ + with status {} but the key IS present in the store listing (an operational failure, \ + not absence); nothing was changed.\nstderr: {}", + output.status, + redact_stderr(&stderr) + )) + } + + fn read_config_entry_local( + &self, + manifest_root: &Path, + adapter_manifest_path: Option<&str>, + _component_selector: Option<&str>, + store: &ResolvedStoreId, + key: &str, + _push_ctx: &AdapterPushContext<'_>, + ) -> Result { + // Read from `[local_server.config_stores..contents]` + // in fastly.toml — the same section `push_config_entries_local` writes. + let Some(rel) = adapter_manifest_path else { + return Err( + "[adapters.fastly.adapter].manifest must point at fastly.toml for config diff --local" + .to_owned(), + ); + }; + let fastly_path = manifest_root.join(rel); + let name = store.logical.as_str(); + // A prior-state read failure must never BLOCK the command: the diff just + // cannot be computed, so it degrades to `Unsupported` ("cannot diff"). + // Downstream, a dry-run then reaches the writer's orphan-count + // degradation (spec 12.x) and a real push reaches the writer, which + // fails fatally on malformed TOML or overwrites otherwise. Erroring here + // would newly fail a dry-run that reads nothing today. + let raw = match fs::read_to_string(&fastly_path) { + Ok(text) => text, + Err(err) if err.kind() == ErrorKind::NotFound => { + return Ok(ReadConfigEntry::MissingStore); + } + Err(_err) => { + return Ok(ReadConfigEntry::Unsupported( + "local fastly.toml could not be read; cannot diff the prior value", + )); + } + }; + let Ok(doc) = raw.parse::() else { + return Ok(ReadConfigEntry::Unsupported( + "local fastly.toml is not valid TOML; cannot diff the prior value", + )); + }; + // Descend `[local_server.config_stores..contents]` level by level. + // At each level an ABSENT key means the store isn't seeded yet + // (MissingStore), but a key that is PRESENT yet not a table is malformed + // store state — distinct outcomes. Collapsing the malformed case into + // MissingStore (as a plain `.get().and_then()` chain does) would render an + // inaccurate "all values added" diff, so it degrades to "cannot diff". + // + // `descend` returns Ok(None) for absent (-> MissingStore) and + // Err(Unsupported) for present-but-not-a-table. + let descend = |parent: &'_ toml_edit::Item, + child: &str| + -> Result, ReadConfigEntry> { + match parent.get(child) { + None => Ok(None), + Some(item) if item.is_table_like() => Ok(Some(item.clone())), + Some(_) => Err(ReadConfigEntry::Unsupported( + "a local config-store parent table is not a table; cannot diff the prior value", + )), + } + }; + let root_item = toml_edit::Item::Table(doc.as_table().clone()); + let contents_item = (|| { + let Some(local_server) = descend(&root_item, "local_server")? else { + return Ok(None); + }; + let Some(config_stores) = descend(&local_server, "config_stores")? else { + return Ok(None); + }; + let Some(store_tbl) = descend(&config_stores, name)? else { + return Ok(None); + }; + descend(&store_tbl, "contents") + })(); + let contents = match contents_item { + Ok(Some(item)) => item, + Ok(None) => return Ok(ReadConfigEntry::MissingStore), + Err(unsupported) => return Ok(unsupported), + }; + // `contents` MUST be a table of `key = "value"` pairs. (Guaranteed by + // `descend` above, but re-borrow as a table to index it.) + let Some(contents_tbl) = contents.as_table_like() else { + return Ok(ReadConfigEntry::Unsupported( + "local config-store `contents` is not a table; cannot diff the prior value", + )); + }; + // The contents table is `key = "value"` pairs. + match contents_tbl.get(key) { + Some(item) => { + let Some(value) = item.as_str() else { + return Ok(ReadConfigEntry::Unsupported( + "the local prior value is not a string; cannot diff the prior value", + )); + }; + // Resolve chunk pointers using the same toml contents table. + let resolved = + resolve_fastly_config_value_typed(key, value.to_owned(), |chunk_key| { + match contents_tbl.get(chunk_key) { + Some(chunk_item) => { + let chunk_val = chunk_item.as_str().ok_or_else(|| { + format!( + "chunk key `{chunk_key}` in {} is not a string", + fastly_path.display() + ) + })?; + Ok(Some(chunk_val.to_owned())) + } + None => Ok(None), + } + }); + // Same taxonomy as the cloud read, so recovery is uniform across + // targets: a valid envelope is `Present`; a non-envelope or + // corrupt/incomplete value is `Corrupt` (the local writer's + // fail-soft then overwrites it); an unknown/future kind is a hard + // error (do not clobber a newer format). There is no + // infrastructure fetch here -- the chunks are read from the local + // TOML table -- so `fetch_failed` is always false. + classify_resolved_read(resolved, value, false) + } + None => Ok(ReadConfigEntry::MissingKey), + } + } + + fn single_store_kinds(&self) -> &'static [&'static str] { + // Explicit `&[]` rather than inheriting the trait default, + // so the "Multi for every store kind" intent is documented + // at the call site. Fastly KV / Config / Secrets all + // support multiple distinct platform resources per kind, + // unlike spin's flat-namespace single-store model. + &[] + } +} + +impl ManifestLock { + fn acquire(manifest_path: &Path) -> Result { + // Key the lock on the REAL target, so a symlinked manifest and a direct + // path to the same file acquire the SAME lock rather than two different + // sidecars. Every manifest writer (config push AND provision) takes this + // lock, so their read-modify-writes serialise instead of clobbering. + let target = canonical_manifest_target(manifest_path)?; + // A hard-linked manifest cannot be safely replaced: two hard links share + // one inode but have distinct pathnames, so they key DIFFERENT sidecar + // locks (no mutual exclusion), and the atomic rename swaps in a NEW inode, + // breaking the link. We cannot detect the other names, so fail closed + // rather than silently diverge or break the link. + reject_hard_linked_manifest(&target)?; + let dir = target.parent().unwrap_or_else(|| Path::new(".")); + let file_name = target + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("fastly.toml"); + let lock_path = dir.join(format!(".{file_name}.edgezero-lock")); + let file = fs::OpenOptions::new() + .create(true) + .read(true) + .write(true) + .truncate(false) + .open(&lock_path) + .map_err(|err| format!("failed to open lock file {}: {err}", lock_path.display()))?; + // Blocks until any other writer holding the lock releases it. + file.lock() + .map_err(|err| format!("failed to lock {}: {err}", lock_path.display()))?; + // Re-check AFTER the (possibly long) lock wait: a hard link created while + // we blocked would not have been visible to the pre-lock check above. The + // replacement path re-checks once more immediately before the rename. + reject_hard_linked_manifest(&target)?; + Ok(Self { + _file: file, + target, + }) + } + + /// The real file this lock guards. Callers read and replace THIS path. + fn target(&self) -> &Path { + &self.target + } +} + +impl TempFileGuard { + fn disarm(&mut self) { + self.path = None; + } +} + +impl Drop for TempFileGuard { + fn drop(&mut self) { + if let Some(path) = &self.path { + let _cleanup = fs::remove_file(path); + } + } +} + +/// Resolve a manifest path to the REAL file every alias shares, so a symlink and +/// a direct path lock and replace the SAME target. An existing file (or symlink) +/// canonicalizes directly; a not-yet-created file canonicalizes via its parent +/// so a fresh `fastly.toml` still keys on a stable location. +/// +/// FAILS CLOSED on an ambiguous chain: a symlink whose target cannot be read, or +/// a chain too deep / cyclic, returns `Err` rather than falling back to a writable +/// path that could replace an intermediate link. +fn canonical_manifest_target(path: &Path) -> Result { + // Follow the WHOLE symlink chain to the final target -- each hop may itself be + // a dangling symlink (fastly.toml -> middle.toml -> missing.toml). We write at + // the final target, preserving every intermediate link, and a direct writer to + // that same target keys on the same lock. + let mut current = path.to_owned(); + // Bounded to avoid spinning on a symlink cycle (canonicalize would ELOOP). + for _ in 0..40_u32 { + // Fully resolvable => the real existing file. + if let Ok(real) = fs::canonicalize(¤t) { + return Ok(real); + } + // Otherwise, if this hop is a symlink, follow one link and continue. + match fs::symlink_metadata(¤t) { + Ok(meta) if meta.file_type().is_symlink() => match fs::read_link(¤t) { + Ok(link) => { + current = if link.is_absolute() { + link + } else { + // A relative link resolves against the DIRECTORY holding it. + current + .parent() + .unwrap_or_else(|| Path::new(".")) + .join(link) + }; + } + // A symlink we cannot read: refuse rather than guess a target. + Err(err) => { + return Err(format!( + "could not read the manifest symlink `{}` ({err}); refusing to write", + current.display() + )); + } + }, + // Not a symlink -- a plain not-yet-created file, or the final dangling + // target: this is where the write should land. + _ => return Ok(canonicalize_parent_join(¤t)), + } + } + // Exhausted the hop budget: a cyclic or absurdly deep chain. Fail closed. + Err(format!( + "the manifest symlink chain starting at `{}` is too deep or cyclic; refusing to write", + path.display() + )) +} + +/// Canonicalize `path`'s PARENT (which should exist) and rejoin the file name, +/// so a not-yet-created file still resolves to a stable absolute location. +fn canonicalize_parent_join(path: &Path) -> PathBuf { + let parent = match path.parent() { + Some(parent) if !parent.as_os_str().is_empty() => parent, + _ => Path::new("."), + }; + let file_name = path.file_name().unwrap_or(path.as_os_str()); + match fs::canonicalize(parent) { + Ok(real_parent) => real_parent.join(file_name), + Err(_) => path.to_owned(), + } +} + +/// Refuse to operate on a manifest that has MORE THAN ONE hard link. Such a file +/// cannot be replaced safely: the atomic rename installs a new inode (breaking +/// the link), and the path-based lock cannot serialise writers arriving via the +/// other names. Fail closed with a fix. A not-yet-created file, or a filesystem +/// that does not report a link count, is left alone. +/// +/// The link count is read via the platform `MetadataExt` -- `nlink()` on Unix, +/// `number_of_links()` on Windows (both stable, no extra deps) -- so Windows +/// hard-link aliases are caught too, not just Unix ones. On any other target the +/// count is unknown and the file is left alone. +fn reject_hard_linked_manifest(target: &Path) -> Result<(), String> { + #[cfg(unix)] + let link_count: Option = { + use std::os::unix::fs::MetadataExt as _; + fs::metadata(target).ok().map(|meta| meta.nlink()) + }; + #[cfg(windows)] + let link_count: Option = { + use std::os::windows::fs::MetadataExt as _; + fs::metadata(target) + .ok() + .and_then(|meta| meta.number_of_links()) + .map(u64::from) + }; + #[cfg(not(any(unix, windows)))] + let link_count: Option = None; + + if let Some(count) = link_count + && count > 1 + { + return Err(format!( + "{} has multiple hard links (link count {count}); refusing to replace it -- an atomic \ + rename would break the link and concurrent writers via the other names could \ + diverge. Remove the extra hard link(s), or use a symlink instead.", + target.display(), + )); + } + Ok(()) +} + +/// Fetch a single entry value from a remote Fastly Config Store entry by +/// key, using `fastly config-store-entry describe --store-id= --key= +/// --json`. Used by the chunk-pointer resolver to fan out to chunk entries. +/// +/// `Ok(value)` when the entry exists; `Err` on ANY failure, INCLUDING a +/// not-found. Absence is NOT decided here (a describe 404 is not proof) -- the +/// caller confirms it against the complete store listing. +/// +/// # Errors +/// Returns an error if `fastly` isn't on `PATH`, spawning fails, the JSON +/// cannot be parsed, or the CLI exits with a non-zero status (not-found included). +fn fetch_remote_config_store_entry(store_id: &str, key: &str) -> Result { + let store_arg = format!("--store-id={store_id}"); + let key_arg = format!("--key={key}"); + let output = Command::new("fastly") + .args([ + "config-store-entry", + "describe", + store_arg.as_str(), + key_arg.as_str(), + "--json", + ]) + .output() + .map_err(|err| { + if err.kind() == ErrorKind::NotFound { + format!("`fastly` not found on PATH; {FASTLY_INSTALL_HINT}") + } else { + format!("failed to spawn `fastly`: {err}") + } + })?; + if output.status.success() { + let stdout = strict_stdout(output.stdout, "config-store-entry describe --json")?; + let parsed: serde_json::Value = serde_json::from_str(&stdout).map_err(|_err| { + format!( + "failed to parse `fastly config-store-entry describe` JSON for key \ + `{key}` (parse error redacted; response: {})", + redact_describe_response(&stdout) + ) + })?; + let value = parsed + .get("item_value") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + format!( + "`fastly config-store-entry describe` JSON has no string `item_value` \ + field for key `{key}`; fastly CLI may have changed its output schema. \ + (response: {})", + redact_describe_response(&stdout) + ) + })?; + return Ok(value.to_owned()); + } + // `Err` on ANY non-success, INCLUDING a not-found. A describe 404 alone is not + // proof of absence -- a proxy/endpoint 404, an auth 404, or a gateway error + // all look the same -- so the caller CONFIRMS a genuine absence against the + // complete store listing rather than trusting this stderr. + let stderr = String::from_utf8_lossy(&output.stderr); + Err(format!( + "`fastly config-store-entry describe --store-id={store_id} --key={key} --json` \ + exited with status {}\nstderr: {}", + output.status, + redact_stderr(&stderr) + )) +} + +/// The COMPLETE set of item keys in a store, via `config-store-entry list`. +/// +/// Absence is CONFIRMED against this, never against a describe 404: the listing +/// is completeness-strict (fails closed on a paginated / non-bare-array view and +/// on a duplicate key), so a key's absence from it is authoritative. Tolerant of +/// empty item VALUES -- only keys are needed to confirm presence. +fn list_config_store_keys(store_id: &str) -> Result, String> { + let store_arg = format!("--store-id={store_id}"); + let output = Command::new("fastly") + .args(["config-store-entry", "list", store_arg.as_str(), "--json"]) + .output() + .map_err(|err| { + if err.kind() == ErrorKind::NotFound { + format!("`fastly` not found on PATH; {FASTLY_INSTALL_HINT}") + } else { + format!("failed to spawn `fastly`: {err}") + } + })?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!( + "`fastly config-store-entry list --store-id={store_id} --json` exited with status {}\nstderr: {}", + output.status, + redact_stderr(&stderr) + )); + } + let stdout = strict_stdout(output.stdout, "config-store-entry list --json")?; + let parsed: serde_json::Value = serde_json::from_str(&stdout).map_err(|_err| { + format!( + "failed to parse `fastly config-store-entry list` JSON (parse error redacted; \ + response: {})", + redact_describe_response(&stdout) + ) + })?; + let array = parsed.as_array().ok_or_else(|| { + format!( + "refusing to confirm absence: `fastly config-store-entry list --json` did not return a \ + bare array (response: {}). A paginated or partial view could hide a present key and \ + turn it into a false absence that authorises an overwrite.", + redact_describe_response(&stdout) + ) + })?; + let mut keys = HashSet::with_capacity(array.len()); + for (idx, entry) in array.iter().enumerate() { + let key = entry + .get("item_key") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + format!( + "`fastly config-store-entry list` entry #{idx} is missing a string `item_key`; \ + refusing to confirm absence on an unreadable listing" + ) + })?; + if key.is_empty() { + return Err(format!( + "`fastly config-store-entry list` entry #{idx} has an empty `item_key`; refusing \ + to confirm absence on an unreadable listing" + )); + } + if !keys.insert(key.to_owned()) { + return Err(format!( + "`fastly config-store-entry list` returned duplicate key `{key}`; refusing to \ + confirm absence on an ambiguous listing" + )); + } + } + Ok(keys) +} + +/// Confirm `key` is ABSENT from the store via a complete listing (authoritative). +/// `Ok(true)` = the listing succeeded and omits the key. `Ok(false)` = the key IS +/// present (so a describe failure on it was operational, not absence). `Err` = the +/// listing itself failed. All three fail closed for the caller: only `Ok(true)` +/// is a genuine absence. +fn confirm_entry_absent(store_id: &str, key: &str) -> Result { + Ok(!list_config_store_keys(store_id)?.contains(key)) +} + +/// Cached form of [`confirm_entry_absent`] for chunk fetches: lists the store at +/// most ONCE per read (a whole lost generation would otherwise list per chunk). +fn confirm_key_absent_cached( + cache: &RefCell, String>>>, + store_id: &str, + key: &str, +) -> Result { + let mut slot = cache.borrow_mut(); + if slot.is_none() { + *slot = Some(list_config_store_keys(store_id)); + } + match slot.as_ref() { + Some(Ok(keys)) => Ok(!keys.contains(key)), + Some(Err(err)) => Err(err.clone()), + // Unreachable: populated just above. Fail closed rather than unwrap. + None => Err("internal error: store listing cache was not populated".to_owned()), + } +} + +/// Convert `fastly` stdout to a `String`, FAILING CLOSED on invalid UTF-8 rather +/// than substituting U+FFFD. A lossy replacement inside a JSON string could +/// mutate a stored root value or chunk and yield parseable-but-WRONG data on a +/// path that drives an overwrite or a deletion, violating the exact-read +/// invariant. Diagnostics only ever see redacted output, so stderr stays lossy. +fn strict_stdout(stdout: Vec, command: &str) -> Result { + String::from_utf8(stdout).map_err(|_err| { + format!( + "`fastly {command}` returned non-UTF-8 output; refusing to act on it -- a lossy \ + conversion could mutate a stored value. Nothing was changed." + ) + }) +} + +/// Does `body` parse AND integrity-verify as a `BlobEnvelope`? +/// +/// The typed-config key must hold a valid envelope. A resolved chunk pointer +/// already reconstructs and verifies one; a DIRECT or foreign value is checked +/// here. A value that is not a verifying envelope (invalid JSON, missing fields, +/// or a SHA mismatch) is corrupt FOR THE PUSH -- something to overwrite, not to +/// diff against. +fn body_is_valid_envelope(body: &str) -> bool { + use edgezero_core::blob_envelope::BlobEnvelope; + serde_json::from_str::(body).is_ok_and(|envelope| envelope.verify().is_ok()) +} + +/// Map a `resolve_fastly_config_value` result to a read outcome, distinguishing +/// the cases that must NOT be treated as overwritable corruption: +/// +/// - a FUTURE format (unknown/newer `edgezero_kind`, or a bumped envelope/pointer +/// `version`) → a hard error: overwriting a newer format with this v1 CLI would +/// lose it. Checked FIRST. Detected two ways: on the raw stored value (a direct +/// future envelope, or a future pointer version), AND via a typed +/// [`ResolveFailure::FutureFormat`] from the resolver -- the ONLY signal for a +/// newer INNER envelope reassembled from v1 chunks, which the raw value alone +/// cannot reveal. +/// - a resolve error where a chunk FETCH failed for infrastructure reasons +/// (`fetch_failed`) → a hard error: the read was incomplete, so a push must not +/// overwrite healthy remote state. +/// - `Ok(body)` that verifies as an envelope → `Present`. +/// - `Ok(body)` that is NOT a valid envelope (a malformed direct value, a SHA +/// mismatch, a foreign non-envelope) → `Corrupt` (repairable by overwrite). +/// - any other resolve error (bad/missing chunk, malformed pointer) → `Corrupt`. +fn classify_resolved_read( + resolved: Result, + raw_value: &str, + fetch_failed: bool, +) -> Result { + // A newer format is refused BEFORE anything else: on the raw value (direct + // future envelope or future pointer version) OR when the resolver typed the + // failure as a newer format (a future inner envelope only knowable after the + // chunks are reassembled). Overwriting a newer format with this v1 CLI would + // lose it. + if value_is_future_format(raw_value) + || resolved + .as_ref() + .err() + .is_some_and(ResolveFailure::is_future_format) + { + return Err(FUTURE_FORMAT_READ_ERROR.to_owned()); + } + match resolved { + // An INFRASTRUCTURE fetch failure: the read was incomplete, so a push must + // not overwrite. The resolver's message is already redacted (it names only + // a chunk POSITION, never a value), so surface it for diagnostics. + Err(err) if fetch_failed => Err(format!( + "a chunk fetch failed while reading the remote value ({}); the remote was not fully \ + read, so nothing was changed. Fix connectivity/auth and retry.", + err.into_message() + )), + Ok(body) if body_is_valid_envelope(&body) => Ok(ReadConfigEntry::Present(body)), + Ok(_) => Ok(ReadConfigEntry::Corrupt( + "remote value is not a valid config envelope; a push will overwrite it", + )), + // A confirmed-absent chunk, a hash mismatch, or a malformed pointer: the + // value was fully read and is provably unusable, so a push repairs it. + Err(_) => Ok(ReadConfigEntry::Corrupt( + "remote prior value could not be resolved (corrupt or incomplete chunk state); a push \ + will overwrite it", + )), + } +} + +/// Shell out to `fastly -store create --name=`. The +/// caller resolves `` from `EDGEZERO__STORES______NAME` +/// (falling back to the logical id), so this helper takes whatever the +/// caller hands it and does not re-translate. Returns `Ok(())` on success; +/// surfaces the CLI's stderr verbatim on failure (including the "already +/// exists" error, which is the caller's signal to fix the toml or use a +/// different name). +/// +/// # Errors +/// Returns an error if `fastly` isn't on `PATH`, the child fails to +/// spawn, or the exit status is non-zero. +fn create_fastly_store_in(kind: &str, name: &str, cwd: &Path) -> Result<(), String> { + let subcommand = format!("{kind}-store"); + let name_arg = format!("--name={name}"); + let mut command = Command::new("fastly"); + command + .args([subcommand.as_str(), "create", name_arg.as_str()]) + .current_dir(cwd); + let output = command.output().map_err(|err| { + if err.kind() == ErrorKind::NotFound { + format!("`fastly` not found on PATH; {FASTLY_INSTALL_HINT}") + } else { + format!("failed to spawn `fastly`: {err}") + } + })?; + if output.status.success() { + return Ok(()); + } + // Idempotency: the fastly CLI returns non-zero with an + // "already exists" message when a store of this name was + // created by a prior provision run. Treat that as success so + // the operator's recovery path -- "either manually append the + // setup block or delete the remote and re-run provision" -- + // doesn't get blocked. The append step is itself idempotent, + // so re-running provision after a writeback failure is the + // documented recovery and now actually works. + let stderr = String::from_utf8_lossy(&output.stderr); + if looks_like_already_exists(&stderr, kind) { + return Ok(()); + } + Err(format!( + "`fastly {subcommand} create --name={name}` exited with status {}\nstderr: {}", + output.status, + stderr.trim() + )) +} + +/// Heuristic: does the stderr blob look like a "store of this +/// kind, by this name, already exists" failure from the fastly +/// CLI? Different CLI versions phrase this slightly differently +/// ("a kv-store with that name already exists", +/// `"Conflict: duplicate kv_store name"`, etc.); we require BOTH +/// a conflict-signal keyword AND a store-kind reference so an +/// unrelated 409 ("Error: 409 Conflict on /service/...") cannot +/// be misread as idempotent success. The earlier wider heuristic +/// would have swallowed any stderr containing the word +/// "conflict" and let provision march on to writeback against a +/// nonexistent store, surfacing as a confusing deploy-time error. +fn looks_like_already_exists(stderr: &str, kind: &str) -> bool { + let lower = stderr.to_ascii_lowercase(); + let conflict_signal = lower.contains("already exists") + || (lower.contains("duplicate") && lower.contains("name")) + || lower.contains("conflict"); + if !conflict_signal { + return false; + } + // Accept the three common spellings of `-store` / + // `_store` / ` store` so a fastly CLI version + // bump that reshuffles punctuation still hits. + let dashed = format!("{kind}-store"); + let underscored = format!("{kind}_store"); + let spaced = format!("{kind} store"); + lower.contains(&dashed) || lower.contains(&underscored) || lower.contains(&spaced) +} + +/// Read the top-level `service_id` from `fastly.toml`. Returns +/// `Ok(None)` when the file is absent (scaffold state before first +/// `fastly compute deploy`) or when `service_id` is missing. Used by +/// `provision` to detect when an already-deployed +/// service needs a separate resource-link step beyond `[setup]` +/// (which `compute deploy` only consumes on the FIRST deploy). +fn read_fastly_service_id(path: &Path) -> Result, String> { + let raw = match fs::read_to_string(path) { + Ok(text) => text, + Err(err) if err.kind() == ErrorKind::NotFound => return Ok(None), + Err(err) => return Err(format!("failed to read {}: {err}", path.display())), + }; + let doc: toml_edit::DocumentMut = raw.parse().map_err(|_err| { + format!( + "failed to parse {} as TOML (details redacted: the error can quote a stored value)", + path.display() + ) + })?; + let svc = doc + .get("service_id") + .and_then(|item| item.as_str()) + .map(str::to_owned); + Ok(svc) +} + +fn select_fastly_service_id( + manifest_id: Option, + environment_id: Option, +) -> Result, String> { + if let Some(manifest) = manifest_id.as_deref() { + validate_service_id(manifest)?; + } + if let Some(environment) = environment_id.as_deref() { + validate_service_id(environment)?; + } + if let (Some(manifest), Some(environment)) = (&manifest_id, &environment_id) + && manifest != environment + { + return Err(format!( + "fastly.toml service_id `{manifest}` conflicts with {FASTLY_SERVICE_ID_ENV} `{environment}`; make them agree before provisioning" + )); + } + + 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, + }; + Ok(selected) +} + +fn effective_fastly_service_id(path: &Path) -> Result, String> { + let manifest_id = read_fastly_service_id(path)?; + let environment_id = match env::var(FASTLY_SERVICE_ID_ENV) { + Ok(id) => Some(id), + Err(env::VarError::NotPresent) => None, + Err(env::VarError::NotUnicode(_)) => { + return Err(format!( + "invalid service id from {FASTLY_SERVICE_ID_ENV}: expected ASCII letters and digits only" + )); + } + }; + 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!( + " {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 service 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`)." + ) + }) +} + +/// Probe `fastly.toml` for the existence of `[setup._stores.]`. +/// Treats a missing file as "not present" so the first provision call +/// can create it. +/// +/// Why only `[setup]` (no longer `[local_server]`): an empty +/// `[local_server._stores.]` table doesn't satisfy +/// fastly's local-server schema — config-stores need +/// `format = "inline-toml"` + a contents table, kv/secret stores +/// need a JSON `file = "..."` or an array of `{key, data}` entries. +/// Writing an empty table makes `fastly compute serve` skip the +/// declared store or error at startup. `provision`'s job is the +/// remote / `[setup]` half; local-server stanzas are written by +/// `edgezero config push --adapter fastly --local` +/// (config-stores only), and kv/secret local-server seeding is +/// hand-edited until we add equivalent writers for those kinds. +fn setup_block_present(path: &Path, kind: &str, id: &str) -> Result { + let raw = match fs::read_to_string(path) { + Ok(text) => text, + Err(err) if err.kind() == ErrorKind::NotFound => return Ok(false), + Err(err) => return Err(format!("failed to read {}: {err}", path.display())), + }; + let doc: toml_edit::DocumentMut = raw.parse().map_err(|_err| { + format!( + "failed to parse {} as TOML (details redacted: the error can quote a stored value)", + path.display() + ) + })?; + let plural = format!("{kind}_stores"); + Ok(doc + .get("setup") + .and_then(|root| root.get(plural.as_str())) + .and_then(|kind_tbl| kind_tbl.get(id)) + .is_some()) +} + +/// Append `[setup._stores.]` to `fastly.toml`. Creates +/// the file (and the parent `[setup]` table) if absent. The block +/// is written as an empty table — that's what +/// `fastly compute deploy` consumes the first time it creates a +/// service: the resource-link declaration is enough, and the +/// account-level resource itself is already created in the +/// preceding `create_fastly_store` shellout. +/// +/// We DON'T write `[local_server._stores.]` here: see +/// `setup_block_present`'s doc for the schema rationale. The local- +/// server seeding moved to `config push --local` (config-stores +/// only), so provision only owns the remote / setup half. +fn append_fastly_setup(path: &Path, kind: &str, id: &str) -> Result<(), String> { + use toml_edit::{DocumentMut, Item, table}; + + // Provision writes the SAME manifest as `config push --local`; take the same + // lock so a concurrent provision and push serialise instead of clobbering + // each other's edit, and operate on the real target the lock resolved. + let lock = ManifestLock::acquire(path)?; + let target = lock.target(); + + let raw = match fs::read_to_string(target) { + Ok(text) => text, + Err(err) if err.kind() == ErrorKind::NotFound => String::new(), + Err(err) => return Err(format!("failed to read {}: {err}", target.display())), + }; + let mut doc: DocumentMut = raw.parse().map_err(|_err| { + format!( + "failed to parse {} as TOML (details redacted: the error can quote a stored value)", + target.display() + ) + })?; + + let plural = format!("{kind}_stores"); + let parent_entry = doc.entry("setup").or_insert_with(table); + let parent_tbl = parent_entry.as_table_mut().ok_or_else(|| { + format!( + "{}: `setup` exists but is not a table; refusing to edit in place", + path.display() + ) + })?; + let kind_entry = parent_tbl + .entry(plural.as_str()) + .or_insert_with(|| Item::Table(toml_edit::Table::new())); + let kind_tbl = kind_entry.as_table_mut().ok_or_else(|| { + format!( + "{}: `setup.{plural}` exists but is not a table; refusing to edit in place", + path.display() + ) + })?; + if !kind_tbl.contains_key(id) { + kind_tbl.insert(id, Item::Table(toml_edit::Table::new())); + } + + atomically_replace_file(target, &raw, &doc.to_string())?; + Ok(()) +} + +/// Write the local-server config-store entries to `fastly.toml`: +/// `[local_server.config_stores.]` becomes +/// `format = "inline-toml"`, and `[local_server.config_stores..contents]` +/// gets the flat `key = "value"` pairs (overwriting any previous +/// values). Idempotent — re-running just rewrites `contents`. Other +/// blocks in `fastly.toml` (setup, scripts, the actual `[local_server]` +/// secret stores, etc.) are preserved via `toml_edit`. +/// Refuse before writing if any GENERATED chunk key would clobber an existing +/// value that is itself ROOT-LIKE (announces our `edgezero_kind`, is a newer +/// format, or classifies as a valid root) or that has a NESTED generation beneath +/// it. Chunk keys are content-addressed, so such a collision is pathological, but +/// overwriting one would destroy live or foreign config -- so fail closed. +/// +/// Logical ROOT keys are excluded here; overwriting a root is governed by the +/// downgrade/future guards. `sibling_keys` is the complete set of existing store +/// keys (for the nested-generation check); `existing_value_at` fetches the value +/// at a colliding key (only called for keys already present). +fn reject_generated_key_collisions( + entries: &[(String, String)], + sibling_keys: &HashSet, + mut existing_value_at: impl FnMut(&str) -> Result, String>, +) -> Result<(), String> { + for (key, _) in entries { + if !key.contains(CHUNK_KEY_INFIX) { + continue; // a logical root; the root-overwrite guards cover it + } + let has_nested_generation = sibling_keys + .iter() + .any(|other| other != key && chunk_key_generation(key, other).is_some()); + let clobbers_root_like = sibling_keys.contains(key) + && existing_value_at(key)?.is_some_and(|value| { + value_announces_our_kind(&value) + || value_is_future_format(&value) + || gc_classify_root(key, &value).is_ok() + }); + if has_nested_generation || clobbers_root_like { + return Err(format!( + "refusing to push: the generated chunk key `{key}` already holds a value that is \ + itself a root (or has a nested generation beneath it); overwriting it could \ + destroy live or foreign config. Nothing was changed." + )); + } + } + Ok(()) +} + +/// [`reject_generated_key_collisions`] against a local `contents` table. +fn reject_local_generated_key_collisions( + contents_tbl: &toml_edit::Table, + entries: &[(String, String)], +) -> Result<(), String> { + let sibling_keys: HashSet = contents_tbl + .iter() + .map(|(existing_key, _)| existing_key.to_owned()) + .collect(); + reject_generated_key_collisions(entries, &sibling_keys, |chunk_key| { + Ok(contents_tbl + .get(chunk_key) + .and_then(toml_edit::Item::as_str) + .map(str::to_owned)) + }) +} + +/// Ensure a local config-store entry is `format = "inline-toml"` -- the only +/// format compatible with the inline `contents` this writer emits. +/// +/// REFUSES an existing non-inline store rather than converting it. A +/// `format = "json"` / `"file"` store points at an EXTERNAL file that this writer +/// cannot safely rewrite: leaving `file` in place produces a manifest Viceroy +/// rejects ("unrecognized key 'file'"), and removing it would silently discard +/// the sibling entries that file holds (this writer only inserts the pushed +/// root). Migration is the operator's explicit choice, not a silent side effect. +fn ensure_inline_toml_format( + store_tbl: &mut toml_edit::Table, + platform_name: &str, +) -> Result<(), String> { + let existing = store_tbl.get("format").and_then(toml_edit::Item::as_str); + match existing { + Some("inline-toml") => Ok(()), + Some(other) => Err(format!( + "refusing to push: `local_server.config_stores.{platform_name}` uses `format = \ + \"{other}\"` (an external-file store), which is incompatible with the inline \ + `contents` this command writes. Converting it here would either produce a manifest \ + the local server rejects or silently discard the sibling entries the external file \ + holds. Migrate the store to `format = \"inline-toml\"` (or a fresh store id) yourself, \ + then re-run. Nothing was changed." + )), + None => { + // A brand-new or format-less entry: this writer owns it, so stamp the + // inline format it is about to fill. + store_tbl.insert("format", toml_edit::value("inline-toml")); + Ok(()) + } + } +} + +/// TOCTOU guard for the LOCAL writer: refuse to overwrite a root that now holds a +/// NEWER format, classified HERE under the write lock. The generic push's +/// pre-push future-format check ran BEFORE the lock, so a newer writer could have +/// installed a v2 value in between; without this the old writer would clobber it. +/// +/// The raw value alone does not reveal a future INNER envelope hidden behind a +/// valid v1 pointer -- that is only knowable after reconstruction. So each root +/// that is one of our pointers is RESOLVED against the locked `contents` table +/// (its chunks live there too); a typed `FutureFormat` from the resolver is +/// refused just like a raw future value. +fn reject_future_local_roots( + contents_tbl: &toml_edit::Table, + gc_roots: &[(String, HashSet)], +) -> Result<(), String> { + for (root_key, _) in gc_roots { + let Some(existing) = contents_tbl.get(root_key).and_then(toml_edit::Item::as_str) else { + continue; + }; + // Raw check: a direct future envelope, a future pointer version, or an + // unknown `edgezero_kind`. + let mut is_future = value_is_future_format(existing); + if !is_future { + // Resolve against the locked contents to catch a future INNER envelope + // behind a valid v1 pointer. Only `FutureFormat` blocks the write; a + // corrupt/incomplete v1 prior stays overwritable. + let resolved = resolve_fastly_config_value_typed(root_key, existing.to_owned(), |ck| { + Ok(contents_tbl + .get(ck) + .and_then(toml_edit::Item::as_str) + .map(str::to_owned)) + }); + is_future = matches!(resolved, Err(err) if err.is_future_format()); + } + if is_future { + return Err(format!( + "refusing to overwrite `{root_key}`: the local store now holds a value in a newer \ + format this CLI does not recognise (installed since the pre-push check). Upgrade \ + the CLI rather than clobber a newer format. Nothing was changed." + )); + } + } + Ok(()) +} + +fn write_fastly_local_config_store( + path: &Path, + platform_name: &str, + entries: &[(String, String)], + gc_roots: &[(String, HashSet)], +) -> Result, String> { + use toml_edit::{DocumentMut, Item, Table, Value, table}; + + // Hold a cross-process advisory lock for the WHOLE read-modify-write. Two + // concurrent local pushes would otherwise both read the file, each apply + // their own edit, and the later rename would discard the earlier push's + // change. Serialising here makes each push read what the previous one wrote + // and build on it, so both edits survive. Released when `_lock` drops. + let lock = ManifestLock::acquire(path)?; + // Read and replace the REAL target the lock guards, so a symlinked manifest + // and a direct path never diverge between the read, the compare, and the + // rename. + let target = lock.target(); + + let raw = match fs::read_to_string(target) { + Ok(text) => text, + Err(err) if err.kind() == ErrorKind::NotFound => String::new(), + Err(err) => return Err(format!("failed to read {}: {err}", target.display())), + }; + // Redacted: `toml_edit`'s parse error quotes the offending source LINE, which + // in a config-store `contents` table is a stored (possibly secret-bearing) + // value. The diff read redacts the same failure; the writer must too. + let mut doc: DocumentMut = raw.parse().map_err(|_err| { + format!( + "failed to parse {} as TOML (details redacted: the error can quote a stored value)", + target.display() + ) + })?; + + let local_server_entry = doc.entry("local_server").or_insert_with(table); + let local_server_tbl = local_server_entry.as_table_mut().ok_or_else(|| { + format!( + "{}: `local_server` exists but is not a table; refusing to edit in place", + path.display() + ) + })?; + let config_stores_entry = local_server_tbl + .entry("config_stores") + .or_insert_with(|| Item::Table(Table::new())); + let config_stores_tbl = config_stores_entry.as_table_mut().ok_or_else(|| { + format!( + "{}: `local_server.config_stores` exists but is not a table; refusing to edit in place", + path.display() + ) + })?; + + // Upsert into the existing per-store contents table so writing one root + // key does not wipe an unrelated sibling. Earlier wholesale replacement + // misread the "stale entries don't linger" property: that applies within + // one key, where old chunks become unreferenced after a new pointer is + // installed, not across sibling keys. + let store_entry = config_stores_tbl.entry(platform_name).or_insert_with(|| { + let mut tbl = Table::new(); + tbl.insert("format", toml_edit::value("inline-toml")); + tbl.insert("contents", Item::Table(Table::new())); + Item::Table(tbl) + }); + let store_tbl = store_entry.as_table_mut().ok_or_else(|| { + format!( + "{}: `local_server.config_stores.{platform_name}` exists but is not a table; refusing to edit in place", + path.display() + ) + })?; + ensure_inline_toml_format(store_tbl, platform_name)?; + let contents_entry = store_tbl + .entry("contents") + .or_insert_with(|| Item::Table(Table::new())); + let contents_tbl = contents_entry.as_table_mut().ok_or_else(|| { + format!( + "{}: `local_server.config_stores.{platform_name}.contents` exists but is not a table; refusing to edit in place", + path.display() + ) + })?; + reject_future_local_roots(contents_tbl, gc_roots)?; + reject_local_generated_key_collisions(contents_tbl, entries)?; + // Snapshot prior chunk keys per GC root BEFORE the upsert, using the + // exact keep-set the caller computed for each root (no prefix scan). + let mut plans: Vec = Vec::with_capacity(gc_roots.len()); + for (root_key, new_keys) in gc_roots { + let prior_keys = contents_tbl + .get(root_key) + .and_then(toml_edit::Item::as_str) + .map_or_else(|| Ok(Vec::new()), |value| prior_chunk_keys(root_key, value)); + plans.push(FastlyConfigGcPlan { + new_keys: new_keys.clone(), + prior_keys, + }); + } + + // Upsert the new physical entries. + for (key, value) in entries { + contents_tbl.insert(key, Item::Value(Value::from(value.clone()))); + } + + // Prune orphans in the same in-memory rewrite; a suspicious prior + // pointer (Err) warns and deletes nothing. + let mut warnings = Vec::new(); + for plan in &plans { + match orphan_chunk_keys(plan) { + Ok(orphans) => { + for key in orphans { + // Never remove an orphan that is itself protected -- a + // runtime-readable root, a value claiming our `edgezero_kind` + // namespace or written by a NEWER format, or a nested root with + // canonical chunks beneath it (deleting which would orphan that + // nested generation). Only a raw leaf PAYLOAD prunes. Shared + // with the dry-run count via `is_prunable_leaf`, so the preview + // can never disagree with what is removed here. + if !is_prunable_leaf(contents_tbl, &key) { + warnings.push(format!( + "warning: kept `{key}` -- it is a runtime-readable root, claims the \ + `edgezero_kind` namespace, or is a nested root with chunks beneath it; \ + not a prunable chunk payload" + )); + continue; + } + contents_tbl.remove(&key); + } + } + Err(err) => warnings.push(format!("warning: {err}")), + } + } + + atomically_replace_file(target, &raw, &doc.to_string())?; + Ok(warnings) +} + +/// Replace an already-canonical `target`'s contents ATOMICALLY. Callers pass +/// [`ManifestLock::target`] and hold the lock across the surrounding +/// read-modify-write, so this is not racing another writer; the re-read + compare +/// is a defence-in-depth corruption check, not the concurrency guard. +/// +/// In order: +/// +/// 1. Re-read `target` and require it to still hold the bytes this rewrite +/// started from (`expected_before`). A mismatch means something OUTSIDE our +/// writers mutated it, so fail rather than overwrite. +/// 2. Create a FRESH temp file in the target's directory with `create_new` +/// (`O_EXCL`): this never follows a file or symlink someone pre-planted at the +/// temp path, and successive names avoid collisions. The rename stays within +/// one directory so it cannot cross a filesystem boundary. +/// 3. Copy the target's existing permissions onto the temp BEFORE writing, so the +/// config bytes are never briefly readable under wider permissions than the +/// manifest allows, then write, then `rename` over the target. `rename` is +/// atomic on POSIX, so a concurrent reader sees either the old file or the new. +/// +/// A [`TempFileGuard`] removes the temp on any failure after it is created. +fn atomically_replace_file( + target: &Path, + expected_before: &str, + contents: &str, +) -> Result<(), String> { + let current = match fs::read_to_string(target) { + Ok(text) => text, + Err(err) if err.kind() == ErrorKind::NotFound => String::new(), + Err(err) => return Err(format!("failed to re-read {}: {err}", target.display())), + }; + if current != expected_before { + return Err(format!( + "{} changed on disk while this write was preparing its rewrite; nothing was written. \ + Re-run to pick up the other change.", + target.display() + )); + } + + let dir = target.parent().unwrap_or_else(|| Path::new(".")); + let file_name = target + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("fastly.toml"); + // Create a staging file that CANNOT be an attacker's pre-planted symlink: + // `create_new` fails if the path already exists (regular file or symlink), so + // we retry successive names until we own a fresh inode. + let mut attempt = 0_u32; + let (tmp_path, mut tmp_file) = loop { + let candidate = dir.join(format!( + ".{file_name}.edgezero-{}-{attempt}.tmp", + process_id() + )); + match fs::OpenOptions::new() + .create_new(true) + .write(true) + .open(&candidate) + { + Ok(file) => break (candidate, file), + Err(err) if err.kind() == ErrorKind::AlreadyExists => { + attempt = attempt.saturating_add(1); + if attempt > 1_024 { + return Err(format!( + "could not create a staging temp file next to {}", + target.display() + )); + } + } + Err(err) => return Err(format!("failed to create staging temp file: {err}")), + } + }; + let mut guard = TempFileGuard { + path: Some(tmp_path.clone()), + }; + + // Match the target's permissions BEFORE writing any bytes, so config content + // never lands under wider permissions than the manifest already had. A brand + // NEW manifest (NotFound) keeps the create default -- nothing to preserve -- + // but any OTHER metadata error means the target EXISTS yet we cannot read its + // mode, so we must NOT silently widen: fail rather than guess. + match fs::metadata(target) { + Ok(meta) => tmp_file + .set_permissions(meta.permissions()) + .map_err(|err| format!("failed to set permissions on the staging temp file: {err}"))?, + Err(err) if err.kind() == ErrorKind::NotFound => {} + Err(err) => { + return Err(format!( + "failed to read the permissions of {} (refusing to widen access): {err}", + target.display() + )); + } + } + tmp_file + .write_all(contents.as_bytes()) + .map_err(|err| format!("failed to write the staging temp file: {err}"))?; + // Flush to disk BEFORE the rename. A writeback error (ENOSPC/EIO) must surface + // HERE, while the known-good manifest is still untouched -- NOT be swallowed + // so the command "succeeds" after installing content that never reached disk. + // The guard removes the temp on this error. + tmp_file + .sync_all() + .map_err(|err| format!("failed to flush the staging temp file to disk: {err}"))?; + drop(tmp_file); + + // Re-check the hard-link count IMMEDIATELY before the rename. The lock-acquire + // check ran before this write blocked on the lock, and a hard link created + // during that wait (or since) would survive the byte comparison above only for + // the rename to break the alias. This is the last moment we can fail closed. + reject_hard_linked_manifest(target)?; + + fs::rename(&tmp_path, target) + .map_err(|err| format!("failed to replace {}: {err}", target.display()))?; + guard.disarm(); + // Sync the containing directory so the rename entry itself survives a crash. + // Best-effort: opening a directory as a file is not portable (Windows), and + // the critical durability -- the file's contents -- is already flushed above. + if let Ok(dir_handle) = fs::File::open(dir) { + let _dir_sync = dir_handle.sync_all(); + } + Ok(()) +} + +// ------------------------------------------------------------------- +// chunk GC helpers (Stage 7 re-push reclamation) +// ------------------------------------------------------------------- + +/// Expand ONE logical `(root_key, body)` into its physical entries, the +/// exact keep-set for that root, and the value written at the root key. +/// No cross-root prefix scanning (a free-form `--key` can't mislead it). +#[expect( + clippy::type_complexity, + reason = "one-off internal return; a named type would not aid readability" +)] +fn expand_root( + root_key: &str, + body: &str, +) -> Result<(Vec<(String, String)>, HashSet, String), String> { + let expanded = prepare_fastly_config_entries(root_key, body)?; + let new_keys: HashSet = expanded.iter().map(|(key, _)| key.clone()).collect(); + // prepare_* always emits the root entry LAST (root pointer or direct + // value). Make the invariant explicit rather than silently defaulting. + let new_root_value = expanded + .last() + .map(|(_, value)| value.clone()) + .ok_or_else(|| format!("internal: no physical entries produced for root `{root_key}`"))?; + Ok((expanded, new_keys, new_root_value)) +} + +/// Orphans = prior chunk keys not in the new keep-set. Propagates a +/// suspicious-pointer `Err` so the caller can warn and skip GC. +fn orphan_chunk_keys(plan: &FastlyConfigGcPlan) -> Result, String> { + match &plan.prior_keys { + Ok(prior) => Ok(prior + .iter() + .filter(|key| !plan.new_keys.contains(*key)) + .cloned() + .collect()), + Err(err) => Err(err.clone()), + } +} + +/// Reject logical keys that collide with the reserved chunk namespace. +/// `--key` is free-form, so this is enforced at the Fastly adapter +/// boundary: such a key would let a push write into another key's chunk +/// space, and could not be reclaimed correctly. +fn reject_reserved_root_keys(entries: &[(String, String)]) -> Result<(), String> { + for (key, _) in entries { + if key.contains(CHUNK_KEY_INFIX) { + return Err(format!( + "config key `{key}` contains the reserved infix `{CHUNK_KEY_INFIX}`, which collides with Fastly chunk storage; choose a different config key (or --key override)" + )); + } + } + Ok(()) +} + +/// Unix epoch seconds. Push-time only (the `cli` feature is native). +fn unix_now_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |elapsed| elapsed.as_secs()) +} + +/// Reject a batch that names the same logical root key more than once. +/// +/// The adapter trait takes an entry slice and does not enforce uniqueness, +/// but GC builds one plan per entry and snapshots every plan against the +/// SAME prior generation. With `[(root, A), (root, B)]` the last tuple wins +/// the upsert (root = B), yet A's plan would still reclaim `prior - A_keys` +/// — which includes B's freshly-written chunks — leaving the final pointer +/// referencing missing chunks. Rejecting is safer than silently coalescing: +/// a duplicated key is a caller bug, and picking a winner would hide it. +fn reject_duplicate_root_keys(entries: &[(String, String)]) -> Result<(), String> { + let mut seen: HashSet<&str> = HashSet::with_capacity(entries.len()); + for (key, _) in entries { + if !seen.insert(key.as_str()) { + return Err(format!( + "config key `{key}` appears more than once in a single push; each logical key must be pushed exactly once" + )); + } + } + Ok(()) +} + +/// Best-effort per-root orphan count for `config push --local --dry-run`. +/// Navigate to `[local_server.config_stores..contents]` for the +/// dry-run counter. `Ok(None)` when any level is absent (no prior state); +/// `Err` when a level is present but the wrong type — prior state the real +/// writer would reject, so the count must degrade to "unknown", not 0. +fn local_contents_table<'doc>( + doc: &'doc toml_edit::DocumentMut, + platform_name: &str, +) -> Result, String> { + let malformed = || "could not read prior state".to_owned(); + let Some(server_item) = doc.get("local_server") else { + return Ok(None); + }; + let Some(server) = server_item.as_table() else { + return Err(malformed()); + }; + let Some(stores_item) = server.get("config_stores") else { + return Ok(None); + }; + let Some(stores) = stores_item.as_table() else { + return Err(malformed()); + }; + let Some(store_item) = stores.get(platform_name) else { + return Ok(None); + }; + let Some(store) = store_item.as_table() else { + return Err(malformed()); + }; + let Some(contents_item) = store.get("contents") else { + return Ok(None); + }; + contents_item + .as_table() + .map_or_else(|| Err(malformed()), |table| Ok(Some(table))) +} + +/// Reads the current `fastly.toml` (offline) and, for each logical +/// `(root_key, body)`, counts `prior_chunk_keys(root, old) - new_keys` +/// where `new_keys` is the root's OWN expansion. Never fails the dry-run: +/// on a missing file / no prior pointer / direct prior value it reports +/// `Ok(0)`; on unreadable or malformed prior state it reports `Err(reason)` +/// which the caller renders as an "unknown" line. +/// Is `key` a plain, prunable chunk PAYLOAD in `contents`? `false` for a value +/// that must be KEPT: a runtime-readable root, a value claiming our +/// `edgezero_kind` namespace or written by a newer format, or a NESTED root (a +/// key with a canonical chunk beneath it). Only a raw leaf payload prunes. +/// +/// The single source of truth shared by the real prune (`write_fastly_local_ +/// config_store`) and the dry-run count, so the previewed number can never drift +/// from what `--yes` actually removes. (The dry-run reads the PRE-upsert table and +/// the prune the POST-upsert one, but a generated key with a nested generation is +/// already refused by `reject_generated_key_collisions`, so that asymmetry cannot +/// change the verdict.) +fn is_prunable_leaf(contents: &toml_edit::Table, key: &str) -> bool { + let value_protected = contents + .get(key) + .and_then(toml_edit::Item::as_str) + .is_some_and(|text| { + value_announces_our_kind(text) + || value_is_future_format(text) + || gc_classify_root(key, text).is_ok() + }); + let has_nested = contents + .iter() + .any(|(other, _)| other != key && chunk_key_generation(key, other).is_some()); + !(value_protected || has_nested) +} + +fn local_orphan_counts_for_dry_run( + path: &Path, + platform_name: &str, + entries: &[(String, String)], +) -> Vec<(String, Result)> { + use toml_edit::DocumentMut; + + // Parse the current file once (best-effort). Absent file => no prior. + let parsed: Result, String> = match fs::read_to_string(path) { + Ok(text) => text + .parse::() + .map(Some) + .map_err(|_err| "could not read prior state".to_owned()), + Err(err) if err.kind() == ErrorKind::NotFound => Ok(None), + Err(_) => Err("could not read prior state".to_owned()), + }; + + entries + .iter() + .map(|(root_key, body)| { + let new_keys = match expand_root(root_key, body) { + Ok((_, keys, _)) => keys, + Err(err) => return (root_key.clone(), Err(err)), + }; + let count = match &parsed { + Err(reason) => Err(reason.clone()), + Ok(None) => Ok(0), + Ok(Some(doc)) => match local_contents_table(doc, platform_name) { + Err(reason) => Err(reason), + Ok(None) => Ok(0), + Ok(Some(contents)) => match contents.get(root_key) { + None => Ok(0), // no prior value for this root + Some(item) => match item.as_str() { + None => Err("could not read prior state".to_owned()), + Some(raw) => match prior_chunk_keys(root_key, raw) { + Ok(prior) => Ok(prior + .iter() + .filter(|key| !new_keys.contains(*key)) + // Count only what the real prune would remove: + // it must still be PRESENT (an absent key is a + // no-op remove, not a deletion) AND a prunable + // leaf by the SAME predicate the prune uses. + .filter(|key| { + contents.get(key.as_str()).is_some() + && is_prunable_leaf(contents, key) + }) + .count()), + Err(_) => Err("suspicious prior pointer".to_owned()), + }, + }, + }, + }, + }; + (root_key.clone(), count) + }) + .collect() +} + +// ------------------------------------------------------------------- +// `config push` helpers +// ------------------------------------------------------------------- + +/// Run `fastly config-store-entry list --store-id= --json` and return each +/// item's `item_key`, `item_value`, and `created_at`. +/// +/// The item VALUE is KEPT (not discarded): `config gc` classifies each root by +/// its value (`gc_classify_root`) and reconstructs live generations from the +/// chunk values, so all three fields are required. The value is used internally +/// only and is NEVER echoed into a diagnostic — parse failures redact it via +/// `redact_describe_response`. +fn list_config_store_entries(store_id: &str) -> Result, String> { + let store_arg = format!("--store-id={store_id}"); + let output = Command::new("fastly") + .args(["config-store-entry", "list", store_arg.as_str(), "--json"]) + .output() + .map_err(|err| { + if err.kind() == ErrorKind::NotFound { + format!("`fastly` not found on PATH; {FASTLY_INSTALL_HINT}") + } else { + format!("failed to spawn `fastly`: {err}") + } + })?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!( + "`fastly config-store-entry list --store-id={store_id} --json` exited with status {}\nstderr: {}", + output.status, + redact_stderr(&stderr) + )); + } + let stdout = strict_stdout(output.stdout, "config-store-entry list --json")?; + let parsed: serde_json::Value = serde_json::from_str(&stdout).map_err(|_err| { + format!( + "failed to parse `fastly config-store-entry list` JSON (parse error redacted; \ + response: {})", + redact_describe_response(&stdout) + ) + })?; + // A BARE ARRAY ONLY. The installed Fastly CLI returns the complete store as + // a top-level array with no cursor/paging flags. Any other shape (e.g. an + // `{"items":[...], ...}` envelope) may carry pagination metadata we do not + // follow -- and a page that omitted a ROOT while listing its chunks would + // make live chunks look orphaned. The completeness guard cannot see a root + // that isn't there, so we refuse rather than reclaim from a partial view. + let array = parsed.as_array().ok_or_else(|| { + format!( + "refusing to reclaim: `fastly config-store-entry list --json` did not return a bare \ + array (response: {}). This build only supports an unpaginated listing; a partial view \ + could hide a root and orphan its live chunks. Nothing was deleted.", + redact_describe_response(&stdout) + ) + })?; + // FAIL CLOSED on any malformed entry. A missing/non-string field on a + // reclamation input must NEVER be silently skipped or defaulted to empty: + // skipping a root hides the chunks it references (they'd look orphaned and + // get deleted while live), and an empty `item_value` makes a real root + // parse as "references nothing" — same catastrophe. If we can't read the + // listing exactly, we delete nothing. + let mut items = Vec::with_capacity(array.len()); + for (idx, entry) in array.iter().enumerate() { + // Name the offending KEY, not just the index: `item_key` is readable even + // when another field is empty, so the operator can see WHICH entry to fix. + let key_hint = entry + .get("item_key") + .and_then(serde_json::Value::as_str) + .filter(|key| !key.is_empty()) + .map_or_else(|| format!("#{idx}"), |key| format!("`{key}`")); + let field = |name: &str| -> Result { + let raw = entry + .get(name) + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + format!( + "`fastly config-store-entry list` entry {key_hint} is missing a string \ + `{name}` field; refusing to reclaim (nothing deleted)" + ) + })?; + // An EMPTY field is as dangerous as a missing one: an empty root value + // would classify as "references nothing" and orphan its live chunks. + // Reject it here rather than reason about it later -- but say what to + // look at, since a legitimate empty-valued sibling is otherwise a + // whole-store block with no obvious cause. + if raw.is_empty() { + return Err(format!( + "`fastly config-store-entry list` entry {key_hint} has an empty `{name}` field; \ + refusing to reclaim (nothing deleted). If this is a legitimate empty-valued \ + entry, remove it or give it a value before running `config gc`." + )); + } + Ok(raw.to_owned()) + }; + items.push(ConfigStoreItem { + created_at: field("created_at")?, + item_key: field("item_key")?, + item_value: field("item_value")?, + }); + } + + // DUPLICATE KEYS => fail closed. A key must appear once; a store cannot + // really hold two entries under one key, so duplicate rows mean we are not + // reading the store we think we are (a merged/paginated view, or a CLI + // change). Left alone, the last row silently wins for BOTH the live-set + // lookup and `created_at`, so conflicting rows could age a recent key into + // eligibility and schedule the same key for two deletes. + let mut seen: HashSet<&str> = HashSet::with_capacity(items.len()); + if let Some(duplicate) = items + .iter() + .find(|item| !seen.insert(item.item_key.as_str())) + { + return Err(format!( + "refusing to reclaim: `fastly config-store-entry list` returned key `{}` more than \ + once. A key is unique in a config store, so this listing does not describe one \ + consistent view of it (nothing was deleted).", + duplicate.item_key + )); + } + + Ok(items) +} + +/// RFC 3339 (`2026-07-13T03:27:42Z`) -> unix seconds, rounded UP on any fraction. +/// +/// `timestamp()` FLOORS the sub-second part, and the current time the age gate +/// compares against is also floored. A creation floored DOWN makes a key look +/// OLDER: a true age of 59.002s (created `...:42.998Z`) would compute as 60s and +/// pass a 60s `--older-than` almost a full second early. Rounding creation UP +/// keeps the computed age conservative -- a key never ages into deletion early. +fn parse_rfc3339_secs(raw: &str) -> Option { + let stamp = chrono::DateTime::parse_from_rfc3339(raw).ok()?; + let secs = stamp.timestamp(); + let rounded_up = if stamp.timestamp_subsec_nanos() > 0 { + secs.checked_add(1)? + } else { + secs + }; + u64::try_from(rounded_up).ok() +} + +/// Report what a sweep is KEEPING, not only what it would delete, so the run is +/// reviewable: each RETAINED root by key, plus the referenced-chunk total those +/// roots hold (already summarised). A root listed here is never a delete +/// candidate. +/// +/// "Retained"/"referenced", not "live": the set also includes a root that is +/// PROTECTED but not runtime-readable (e.g. one that fails the writer split check +/// and is warned about separately). Its chunks are conservatively protected, not +/// runtime-live, so `live_count` here is a count of REFERENCED chunks. +fn append_kept_roots_report(out: &mut Vec, kept_roots: &[String], live_count: usize) { + if kept_roots.is_empty() { + out.push("keeping 0 retained root(s)".to_owned()); + return; + } + out.push(format!( + "keeping {} retained root(s) ({live_count} referenced chunk(s) held by them):", + kept_roots.len() + )); + for key in kept_roots { + out.push(format!(" keeping `{key}`")); + } +} + +/// `config gc` for Fastly: delete chunk entries that no LIVE root pointer +/// references and that are older than the operator's `older_than_secs`. +/// +/// Why this is a separate, operator-invoked command rather than part of `config +/// push`: see `Adapter::gc_config_entries`. The operator's `--older-than` is the +/// safety assertion the platform cannot make. A dry-run prints exactly which +/// keys would go, with ages, so the assertion is reviewable. +/// +/// Fails CLOSED: if the listing is unreadable, or a root's value cannot be +/// classified, nothing is deleted. +fn gc_fastly_config_store( + store_name: &str, + older_than_secs: u64, + dry_run: bool, +) -> Result, String> { + // THE destructive boundary enforces its own precondition. The CLI rejects a + // zero window too, but `gc_config_entries` is a public trait method any + // caller can reach directly -- a safety rule that lives only in the CLI is + // not a safety rule. A zero window asserts nothing: it makes every orphan + // eligible, including one superseded a second ago whose pointer POPs are + // still serving. (A dry-run may preview at zero; it deletes nothing.) + if !dry_run && older_than_secs == 0 { + return Err( + "refusing to reclaim: a destructive `config gc` requires a non-zero `--older-than` \ + window. Zero asserts nothing -- it would make every orphan eligible, including \ + chunks a pointer POPs are still serving. Nothing was deleted." + .to_owned(), + ); + } + let resolved_id = resolve_remote_config_store_id(store_name)? + .ok_or_else(|| no_matching_store_error(store_name))?; + let items = list_config_store_entries(&resolved_id)?; + let plan = plan_gc_reclamation(&items, unix_now_secs(), older_than_secs)?; + let GcPlan { + doomed, + kept_roots, + live_count, + retained_recent, + roots, + unprovable, + warnings, + } = plan; + + let doomed_count: usize = doomed.iter().map(Vec::len).sum(); + let mut out = vec![format!( + "fastly config-store `{store_name}` (id={resolved_id}): {} entries, {roots} root(s), {live_count} referenced chunk(s), {doomed_count} orphan(s) in {} generation(s) older than {older_than_secs}s, {retained_recent} orphan(s) too recent", + items.len(), + doomed.len(), + )]; + out.extend(warnings); + append_kept_roots_report(&mut out, &kept_roots, live_count); + if unprovable > 0 { + // NEVER silent: these entries look like chunk keys but we could not + // prove our writer produced them, so we left them alone. Say so, or the + // summary reads as "everything reclaimable was reclaimed". + out.push(format!( + " {unprovable} chunk-shaped entr(ies) left untouched: they are not byte-identical to what this writer would produce (wrong content-address, a split this writer would not choose, an incomplete generation, or a count it would never emit), so EdgeZero cannot claim them" + )); + } + if doomed_count == 0 { + out.push("nothing to reclaim".to_owned()); + return Ok(out); + } + if dry_run { + // A dry-run only PLANS: list every candidate and stop. Nothing is + // attempted, so there is no confirmed/failed/skipped distinction yet. + for (key, age) in doomed.iter().flatten() { + out.push(format!(" would delete `{key}` (age {age}s)")); + } + // `--yes` ALWAYS requires an explicit non-zero `--older-than` (a + // destructive run must not guess the window), so the apply instruction + // names both -- "re-run with --yes" alone would be rejected. + out.push(format!( + "dry-run: {doomed_count} orphan chunk(s) planned for deletion; re-run with \ + `--yes --older-than ` (a non-zero window is required) to apply" + )); + return Ok(out); + } + // Real run: `doomed_count` is the PLANNED count. Do NOT pre-print each key as + // "deleting" -- execution stops at a generation's first failure, so some + // planned keys are never attempted. `execute_gc_deletes` reports the real + // per-key outcome (deleted / FAILED / skipped) as it happens. + out.push(format!( "reclaiming {doomed_count} planned orphan chunk(s) across {} generation(s)", doomed.len() )); - let GcDeleteOutcome { - deleted, - failed, - stranded, - uncertain, - } = execute_gc_deletes(&resolved_id, &doomed, &mut out); - out.push(format!( - "reclaimed {deleted} of {doomed_count} orphan chunk entries" - )); - if failed.is_empty() { - return Ok(out); + let GcDeleteOutcome { + deleted, + failed, + stranded, + uncertain, + } = execute_gc_deletes(&resolved_id, &doomed, &mut out); + out.push(format!( + "reclaimed {deleted} of {doomed_count} orphan chunk entries" + )); + if failed.is_empty() { + return Ok(out); + } + // Partial/total failure must be a non-zero exit so automation can see it. + let mut diagnostic = format!( + "{}\nconfig gc: {} of {doomed_count} deletes FAILED ({})", + out.join("\n"), + failed.len(), + failed.join(", ") + ); + // A generation whose only failure was on an unconfirmed delete: the outcome + // is UNKNOWN (Fastly may have committed it), so a re-run is worth trying but + // may find a fragment. + if !uncertain.is_empty() { + write!( + diagnostic, + ".\nNOTE: a failed remote delete has an unknown outcome -- Fastly may have applied it \ + before returning an error. Re-run `config gc`: it reclaims each affected generation \ + if it is still whole, or reports it as an unprovable fragment (\"left untouched\") if \ + a delete did commit. If reported as a fragment, remove the survivors by hand:\n{}", + recovery_commands(&resolved_id, &uncertain) + ) + .map_err(|err| format!("failed to format the gc diagnostic: {err}"))?; + } + // A generation with a CONFIRMED prior delete: definitely a fragment now. + if !stranded.is_empty() { + write!( + diagnostic, + ".\nWARNING: {} entr(ies) are now an INCOMPLETE generation because a sibling was \ + already deleted before the failure: {}. `config gc` proves a generation by \ + reassembling it, so it can no longer prove these and will never reclaim them -- \ + re-running will NOT help. They are inert (no pointer references them). Remove them \ + by hand once you are satisfied they are unreferenced:\n{}", + stranded.len(), + stranded.join(", "), + recovery_commands(&resolved_id, &stranded), + ) + .map_err(|err| format!("failed to format the gc diagnostic: {err}"))?; + } + Err(diagnostic) +} + +/// Render copy-pasteable `fastly config-store-entry delete` commands, one per +/// key, with EVERY interpolated value single-quoted for POSIX shells. +/// +/// Root keys are free-form (`--key `), and a chunk key preserves its +/// root, so a key can contain `$(...)`, spaces, or `;`. Pasting an unquoted +/// command could execute or misparse it, so this is not cosmetic. +/// +/// The escaping is POSIX/bash (Linux/macOS). A leading note makes that explicit, +/// because Windows `cmd` and PowerShell quote differently — an operator on those +/// shells must adapt the quoting rather than paste verbatim. +fn recovery_commands(store_id: &str, keys: &[String]) -> String { + let commands = keys + .iter() + .map(|key| { + format!( + " fastly config-store-entry delete --store-id={} --key={} --auto-yes", + shell_single_quote(store_id), + shell_single_quote(key), + ) + }) + .collect::>() + .join("\n"); + format!( + " # POSIX/bash (Linux/macOS). On Windows cmd/PowerShell the quoting \ + differs -- adapt it for your shell.\n{commands}" + ) +} + +/// Single-quote a value for a POSIX shell: wrap in `'...'` and rewrite each +/// embedded `'` as `'\''`. Inside single quotes every other byte -- `$`, spaces, +/// `;`, `$(...)`, backticks -- is literal, so this neutralises any hostile key. +fn shell_single_quote(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\\''")) +} + +/// Delete each doomed generation, stopping a generation at its FIRST failure. +/// +/// A generation is provable only as a whole (`prove_generation` reassembles it), +/// so a half-deleted one can never be proved again: the next run sees a fragment, +/// cannot verify it, and correctly refuses to touch it — forever. Ploughing on +/// after a failure is therefore the one thing that turns a possibly-recoverable +/// error into permanent, unreclaimable litter. +/// +/// A failed remote delete has an UNKNOWN outcome — Fastly may commit it before +/// returning an error — so nothing here is promised as cleanly retryable. The +/// caller distinguishes two cases: a failure with a CONFIRMED prior sibling +/// delete strands the survivors for good (manual recovery), and a failure with +/// no confirmed prior delete leaves the generation in an UNCERTAIN state (a +/// re-run may reclaim it, or surface it as an unprovable fragment). Generations +/// are independent, so a failure in one does not stop the others. +fn execute_gc_deletes( + resolved_id: &str, + doomed: &[Vec<(String, u64)>], + out: &mut Vec, +) -> GcDeleteOutcome { + let mut outcome = GcDeleteOutcome { + deleted: 0, + failed: Vec::new(), + stranded: Vec::new(), + uncertain: Vec::new(), + }; + for generation in doomed { + let mut deleted_here: Vec<&str> = Vec::new(); + for (key, _) in generation { + match delete_config_store_entry(resolved_id, key) { + Ok(()) => { + outcome.deleted = outcome.deleted.saturating_add(1); + deleted_here.push(key.as_str()); + // CONFIRMED gone, per key, as it happens. + out.push(format!(" deleted `{key}`")); + } + Err(err) => { + out.push(format!(" FAILED to delete `{key}` ({err})")); + outcome.failed.push(key.clone()); + // Everything in this generation we have NOT confirmed deleted + // -- the failed key itself, plus the ones we never reached. + let unconfirmed: Vec = generation + .iter() + .map(|(member, _)| member.clone()) + .filter(|member| !deleted_here.contains(&member.as_str())) + .collect(); + // Distinguish the ones we NEVER ATTEMPTED (after the stop) + // from the failed key itself, so the report is not read as + // "all of these were tried and failed". + for skipped in unconfirmed.iter().filter(|member| *member != key) { + out.push(format!( + " skipped `{skipped}` (not attempted: this generation's delete stopped at the failure above)" + )); + } + if deleted_here.is_empty() { + // No sibling is CONFIRMED gone. The failed delete's + // outcome is unknown: if it did not commit, the + // generation is whole and a re-run reclaims it; if it + // did, the re-run finds a fragment and reports it. Either + // way we must not claim clean retryability. + outcome.uncertain.extend(unconfirmed); + } else { + // A sibling is CONFIRMED gone, so this generation is + // definitely a fragment no future run can prove. + outcome.stranded.extend(unconfirmed); + } + break; // stop THIS generation; the others are independent + } + } + } + } + outcome +} + +/// Classify a store's entries: the live chunk set, the protected root keys, and +/// the root count. +/// +/// Root-vs-chunk is decided by VALUE, not key shape. The runtime resolver reads +/// whatever value sits at a key, so ANY entry whose value is a valid direct +/// envelope or a chunk pointer is a runtime-readable root and must never be +/// deleted — even at a chunk-shaped key. Two ways that happens: +/// +/// - a pointer parked at a chunk-shaped key makes its references LIVE; +/// - a value that is itself a valid direct envelope (e.g. a small envelope whose +/// first 7 000-byte chunk is the whole envelope plus trailing whitespace, and +/// so still parses and verifies) is a root in its own right. +/// +/// Only a value that is NEITHER — a raw envelope fragment, which does not parse — +/// is a delete candidate. In normal operation a chunk payload is exactly such a +/// fragment, so this protects the pathological cases at no cost to real GC. +fn classify_store_entries( + items: &[ConfigStoreItem], + value_by_key: &HashMap<&str, &str>, +) -> Result { + let mut live: HashSet = HashSet::new(); + let mut protected: HashSet = HashSet::new(); + let mut roots = 0_usize; + let mut warnings: Vec = Vec::new(); + for item in items { + let is_chunk_shaped = chunk_key_generation_any(&item.item_key).is_some(); + let classified = match gc_classify_root(&item.item_key, &item.item_value) { + Ok(classified) => classified, + // A chunk-shaped key whose value we cannot classify is a genuine + // chunk fragment (a candidate) ONLY if BOTH hold: + // - the value ANNOUNCES no kind. A real chunk payload is a raw + // envelope fragment (no `edgezero_kind`); anything that DOES claim + // our namespace -- a parked pointer, an unknown/future kind -- is + // root-like or suspicious and must fail closed below. + // - NOTHING is nested beneath this key. A truncated/corrupt pointer + // at a chunk-shaped key is ALSO an unparseable fragment, but if it + // is a nested ROOT with its own generation, those nested chunks are + // proven independently and would be deleted while their (unreadable) + // root can no longer name them -- silent loss of a whole nested + // generation. If any canonical chunk of THIS key exists, treat the + // key as an unreadable nested root and FAIL CLOSED. A real leaf + // payload never has nested chunks, so normal GC is unaffected. + Err(_) + if is_chunk_shaped + && !value_announces_our_kind(&item.item_value) + && !value_is_future_format(&item.item_value) + && !items.iter().any(|other| { + other.item_key != item.item_key + && chunk_key_generation(&item.item_key, &other.item_key).is_some() + }) => + { + continue; // a leaf chunk payload: a delete candidate + } + // A definitively FOREIGN entry at an ORDINARY key — a plain string + // like `greeting = "hello"`, a scalar, or a complete JSON object + // without our discriminator. The runtime returns it verbatim and it + // references no chunks, so protect it as a zero-reference root. + // Aborting here would let one ordinary sibling block reclamation of + // every generation in the store. + // + // Three guards keep this from ever masking corruption: + // - the value must be provably inert (NOT a malformed object that + // could be a truncated/corrupt pointer, NOT a value claiming our + // namespace) -- otherwise we might orphan chunks a broken root + // still references; + // - it must NOT be a future format. A direct envelope from a newer + // writer classifies as `Foreign` (no `edgezero_kind`), so without + // this guard it would be waved through as a zero-reference root -- + // yet a newer format may reference chunks under a scheme this build + // cannot read, and GC would plan them for deletion. Fail closed; + // - the KEY must be outside our reserved `.__edgezero_chunks.` + // namespace. A non-canonical key that still lives in that + // namespace is not an ordinary sibling; we cannot say what it is, + // so it fails closed below rather than being waved through. + Err(_) + if value_is_inert_foreign(&item.item_value) + && !value_is_future_format(&item.item_value) + && !item.item_key.contains(CHUNK_KEY_INFIX) => + { + roots = roots.saturating_add(1); + protected.insert(item.item_key.clone()); + continue; + } + Err(err) => { + return Err(format!( + "refusing to reclaim: could not classify root `{}` ({err}); nothing was deleted", + item.item_key + )); + } + }; + // A runtime-readable root, wherever it lives: never a delete candidate. + roots = roots.saturating_add(1); + protected.insert(item.item_key.clone()); + let GcRootValue::Chunked(pointer) = classified else { + continue; // A direct envelope references no chunks. + }; + // The pointer's METADATA is self-consistent by here. That is not proof + // that it honestly describes its generation: a pointer can drop its last + // chunk ref AND restate `envelope_len` as the remaining sum, and every + // metadata check still passes while the dropped chunk silently leaves + // the live set and becomes deletable. So reassemble what it references + // and hold the bytes against its content-address. + let assembled = assemble_pointer_chunks(&item.item_key, &pointer, value_by_key)?; + // The reassembled value may be a NEWER inner format (a bumped envelope + // version, or an unknown `edgezero_kind`) that `BlobEnvelope` deserialize + // silently ignores. Such a format can reference ADDITIONAL generations this + // build cannot see, so trusting only the outer pointer's chunks as the live + // set would let GC delete those as orphans. The runtime resolver rejects + // this case; GC must too. Fail closed. + if value_is_future_format(&assembled) { + return Err(format!( + "refusing to reclaim: root `{}` reconstructs to a value in a newer format this \ + build does not recognise. It may reference generations this build cannot see, so \ + treating its outer chunks as the whole live set could delete live data. Nothing \ + was deleted.", + item.item_key + )); + } + gc_verify_generation(&pointer.envelope_sha256, &assembled).map_err(|err| { + format!( + "refusing to reclaim: root `{}` names a chunk set that does not reconstruct the \ + envelope it claims ({err}). Its chunk list is therefore not a trustworthy live \ + set, and treating it as one could delete a live chunk. Nothing was deleted.", + item.item_key + ) + })?; + // Same exact-split predicate the RUNTIME resolver applies. The content + // checks above only prove the bytes; a pointer whose boundaries are not + // the ones this writer emits reassembles correctly here but is REJECTED + // at runtime -- so GC would otherwise call it a healthy live root while + // the guest 500s on it, and its generation can never satisfy + // `prove_generation` either, making it permanently unreclaimable. + // + // We still protect it (fail-closed: never delete on a judgement we are + // unsure of), but we no longer call it healthy silently -- the operator + // gets told it is unreadable and will not be reclaimed automatically. + if let Err(err) = + verify_writer_split_layout(&item.item_key, &assembled, &chunk_lengths(&pointer.chunks)) + { + warnings.push(format!( + "warning: root `{}` is NOT runtime-readable ({err}). Its chunks are kept, but this \ + generation can never be proven writer-produced, so `config gc` will never reclaim \ + it. Re-run `config push` for this key to rewrite it, then re-run `config gc`.", + item.item_key + )); + } + live.extend(pointer.chunks.into_iter().map(|chunk| chunk.key)); + } + Ok(GcClassification { + live, + protected, + roots, + warnings, + }) +} + +/// The reclamation plan for one store: which orphan chunk entries to delete, and +/// the counts for the summary line. Deriving it is where every safety guard +/// lives, so it is fail-closed throughout — any unreadable/incomplete state +/// returns `Err` and the caller deletes nothing. +/// +/// The organising idea is that **content-addressing makes a chunk set +/// self-proving**: a chunk key embeds the SHA-256 of the whole envelope it +/// belongs to, so reassembling a generation either reproduces the +/// content-address its own keys name, or it does not. Every destructive decision +/// here rests on that hash — never on what the store's metadata claims about +/// itself, which is exactly what an inconsistent store gets wrong. +fn plan_gc_reclamation( + items: &[ConfigStoreItem], + now: u64, + older_than_secs: u64, +) -> Result { + let mut value_by_key: HashMap<&str, &str> = HashMap::with_capacity(items.len()); + let mut created_by_key: HashMap<&str, u64> = HashMap::with_capacity(items.len()); + for item in items { + let Some(created) = parse_rfc3339_secs(&item.created_at) else { + // Unparseable timestamp anywhere in the listing -> fail closed. On a + // DELETE path we will not guess an age. + return Err(format!( + "refusing to reclaim: entry `{}` has an unreadable `created_at`; nothing was deleted", + item.item_key + )); + }; + created_by_key.insert(item.item_key.as_str(), created); + value_by_key.insert(item.item_key.as_str(), item.item_value.as_str()); + } + + // ---- 1. Classify entries: live chunks, protected roots, root count ---- + let GcClassification { + live, + protected, + roots, + warnings, + } = classify_store_entries(items, &value_by_key)?; + + // ---- 2. Per-root live-config age (best-effort; see the guard below) ---- + // rsplit_once (the LAST infix): a chunk of a chunk-shaped root nests the infix + // twice, and its root is everything before the LAST one. Splitting on the + // first would attribute a nested chunk's age to the wrong (outer) root. + let root_live_since: HashMap<&str, u64> = live.iter().fold(HashMap::new(), |mut acc, key| { + if let Some((root, _)) = key.rsplit_once(CHUNK_KEY_INFIX) { + let created = *created_by_key.get(key.as_str()).unwrap_or(&0); + let slot = acc.entry(root).or_insert(0); + *slot = (*slot).max(created); + } + acc + }); + + // ---- 3. Candidates, grouped by GENERATION and proven writer-produced ---- + // A per-key decision cannot be safe: an entry is only ours if the whole + // generation it belongs to reassembles to the content-address its keys name. + // So group first, prove second, and delete whole generations or none -- a + // partial delete would leave a corrupt generation behind. + let mut groups: BTreeMap<(&str, String), Vec<&ConfigStoreItem>> = BTreeMap::new(); + for item in items { + if live.contains(&item.item_key) { + continue; + } + // A key whose own value is a runtime-readable root is never a candidate, + // even when its key is chunk-shaped (a valid direct envelope can sit at + // one). Excluding it here also means any real chunk sharing that + // generation drops to an incomplete group, which prove_generation then + // leaves untouched — safe: we leak rather than delete a possible root. + if protected.contains(&item.item_key) { + continue; + } + // rsplit_once (the LAST infix): the same nested-chunk correctness the + // live-set scan and classification use — a chunk of a chunk-shaped root + // is grouped under THAT root, not the outer one, so nested orphans are + // grouped (and thus reclaimed or reported), not silently dropped. + let Some((root, _)) = item.item_key.rsplit_once(CHUNK_KEY_INFIX) else { + continue; // a root + }; + let Some(generation) = chunk_key_generation(root, &item.item_key) else { + continue; // chunk-shaped but NOT canonical => never a key we emit + }; + groups.entry((root, generation)).or_default().push(item); + } + + let mut doomed: Vec> = Vec::new(); + let mut retained_recent = 0_usize; + let mut unprovable = 0_usize; + for ((root, generation), mut group) in groups { + if prove_generation(root, &generation, &group).is_err() { + // We cannot prove we wrote this, so we do not touch it. It may be an + // ordinary entry that merely LOOKS like a chunk key (a store can + // predate this feature or be shared, and push-time reserved-key + // rejection cannot protect what already exists), or a half-written + // generation. Skipped rather than fatal: one foreign entry must not + // block reclamation of the store forever. Reported in the summary. + unprovable = unprovable.saturating_add(group.len()); + continue; + } + + // Age the generation as a UNIT, by its youngest member: deleting a + // generation is one decision, so its most restrictive age governs. + let group_age = group + .iter() + .map(|item| { + now.saturating_sub(*created_by_key.get(item.item_key.as_str()).unwrap_or(&0)) + }) + .min() + .unwrap_or(0); + // BOTH ages must clear the operator's window; neither substitutes for + // the other, so take the more restrictive (the MINIMUM). + // + // - The chunks' OWN age is mandatory: a generation written seconds ago + // is inside the propagation window whatever its root looks like (e.g. + // a concurrent push wrote it and has not committed its pointer yet), + // so an old-looking root must never license deleting it. + // - The root's live-config age (when known) is an EXTRA restriction: it + // catches an old generation superseded recently, which its own age + // cannot see. + let effective_age = root_live_since.get(root).map_or(group_age, |live_since| { + group_age.min(now.saturating_sub(*live_since)) + }); + if effective_age < older_than_secs { + retained_recent = retained_recent.saturating_add(group.len()); + continue; + } + // Delete in canonical chunk-INDEX order (`.0`, `.1`, ...), NOT the remote + // listing order. Deletion stops at a generation's first failure, so a + // reordered listing would otherwise change the preview order and which + // siblings get stranded; sorting makes both deterministic. Every member is + // a canonical chunk of `root` (it passed the grouping filter), so + // `chunk_key_index` is `Some`; `None` sorts last defensively. + group.sort_by_key(|item| chunk_key_index(root, &item.item_key).unwrap_or(usize::MAX)); + doomed.push( + group + .iter() + .map(|item| { + let age = now + .saturating_sub(*created_by_key.get(item.item_key.as_str()).unwrap_or(&0)); + (item.item_key.clone(), age) + }) + .collect(), + ); + } + + let mut kept_roots: Vec = protected.into_iter().collect(); + kept_roots.sort(); + + Ok(GcPlan { + doomed, + kept_roots, + live_count: live.len(), + retained_recent, + roots, + unprovable, + warnings, + }) +} + +/// Reassemble the chunks a live pointer references, in index order, checking each +/// against the pointer's own per-chunk `len`/`sha256` along the way. +/// +/// Fails closed when a referenced key is absent from the listing. This subsumes +/// the old standalone completeness guard: an incomplete or paginated listing +/// cannot produce the bytes, so it can never reach a passing verification. +fn assemble_pointer_chunks( + root_key: &str, + pointer: &GcPointer, + value_by_key: &HashMap<&str, &str>, +) -> Result { + // NOT `with_capacity(pointer.envelope_len)`: that length is untrusted stored + // metadata. `validate_pointer_chunks` bounds it, but this is a destructive + // path -- do not reserve from a number the store supplied when growing from + // the bytes we actually read costs nothing. + let mut assembled = String::new(); + // The chunk KEY is pointer-controlled (a malformed pointer can carry any + // string there), so diagnostics name a POSITION, not the key. `root_key` is + // the operator's own logical entry key and is named for context, as the rest + // of the GC diagnostics do. + for (position, chunk) in pointer.chunks.iter().enumerate() { + let Some(value) = value_by_key.get(chunk.key.as_str()) else { + return Err(format!( + "refusing to reclaim: root `{root_key}` references chunk {position}, which is \ + absent from the store listing (the listing may be incomplete/paginated, or the \ + store is already inconsistent); nothing was deleted" + )); + }; + if value.len() != chunk.len { + return Err(format!( + "refusing to reclaim: root `{root_key}` says chunk {position} is {} bytes but the \ + store holds {}; nothing was deleted", + chunk.len, + value.len() + )); + } + if sha256_hex(value.as_bytes()) != chunk.sha256 { + return Err(format!( + "refusing to reclaim: the stored value of chunk {position} does not match the \ + SHA-256 that root `{root_key}` records for it; nothing was deleted" + )); + } + assembled.push_str(value); + } + if assembled.len() != pointer.envelope_len { + return Err(format!( + "refusing to reclaim: root `{root_key}` declares an envelope of {} bytes but its \ + chunks reassemble to {}; nothing was deleted", + pointer.envelope_len, + assembled.len() + )); + } + Ok(assembled) +} + +/// Is this candidate generation byte-identical to what THIS writer would have +/// produced for the bytes it contains? +/// +/// The gate on every delete. `group` is every listed entry sharing one +/// `(root, generation)`. +/// +/// **What this proves, precisely.** We reassemble the group in index order and +/// re-run `prepare_fastly_config_entries` over the result. If the writer, given +/// those exact bytes, would emit exactly these keys and these values, the entries +/// are indistinguishable from our own output: same direct-vs-chunked threshold, +/// same UTF-8-safe 7 000-byte boundaries, same content-addressed keys, same +/// count. A lone chunk fails automatically (an envelope small enough to store +/// directly round-trips to a single ROOT-keyed entry, and a large one to >= 2 +/// chunks), as does any set split at boundaries we would not choose. +/// +/// **What this does NOT prove: authorship.** Content-addressing is not a +/// signature. A foreign writer can pick envelope E, compute `H = sha256(E)`, +/// split E exactly as we would, and store the parts under our reserved +/// `.__edgezero_chunks.` namespace; that group is byte-identical to ours and we +/// will reclaim it. No preimage attack is needed, and no check over the stored +/// bytes alone can separate the two — telling them apart needs trusted +/// generation metadata or an authenticated marker, and the store offers neither +/// (any writer with store access could forge either). +/// +/// We accept that residual: the namespace is reserved by convention, push-time +/// validation rejects logical keys inside it, and anything passing this gate is +/// a faithful reproduction of our format. The spec documents it as a limitation +/// rather than claiming a guarantee we cannot make. +fn prove_generation( + root: &str, + generation: &str, + group: &[&ConfigStoreItem], +) -> Result<(), String> { + let mut ordered: Vec<(usize, &str)> = Vec::with_capacity(group.len()); + for item in group { + let index = item + .item_key + .rsplit_once('.') + .and_then(|(_, index)| index.parse::().ok()) + .ok_or_else(|| format!("`{}` has no readable index", item.item_key))?; + ordered.push((index, item.item_value.as_str())); + } + ordered.sort_by_key(|&(index, _)| index); + for (position, &(index, _)) in ordered.iter().enumerate() { + if index != position { + return Err(format!( + "indexes are not dense 0..n-1 (found {index} at position {position})" + )); + } + } + let assembled: String = ordered.iter().map(|&(_, value)| value).collect(); + + // 1. The bytes must be the generation the keys name, and a real envelope. + gc_verify_generation(generation, &assembled)?; + + // 2. ...and the writer, given those bytes, must produce EXACTLY these + // entries. This is what pins the split boundaries and the chunked-vs- + // direct threshold, so a set assembled by anything that does not + // reproduce our writer's output byte-for-byte is left alone. + let expected = prepare_fastly_config_entries(root, &assembled) + .map_err(|err| format!("this writer could not re-derive the generation ({err})"))?; + let Some(expected_chunks) = expected.get(..expected.len().saturating_sub(1)) else { + return Err("this writer produced no chunk entries for these bytes".to_owned()); + }; + if expected_chunks.is_empty() { + // The envelope fits directly, so the writer would never have chunked it: + // whatever these entries are, they are not ours. + return Err( + "these bytes fit the entry limit, so this writer would have stored them directly \ + rather than in chunks" + .to_owned(), + ); + } + if expected_chunks.len() != ordered.len() { + return Err(format!( + "this writer would split these bytes into {} chunk(s), not {}", + expected_chunks.len(), + ordered.len() + )); + } + for ((expected_key, expected_value), item) in + expected_chunks.iter().zip(group_in_index_order(group)) + { + if *expected_key != item.item_key { + return Err(format!( + "this writer would not have produced the key `{}`", + item.item_key + )); + } + if *expected_value != item.item_value { + return Err(format!( + "the stored value of `{}` is not the chunk this writer would have written at that \ + index", + item.item_key + )); + } + } + Ok(()) +} + +/// `group` sorted by chunk index, so it lines up with the writer's output order. +fn group_in_index_order<'item>(group: &[&'item ConfigStoreItem]) -> Vec<&'item ConfigStoreItem> { + let mut ordered: Vec<&ConfigStoreItem> = group.to_vec(); + ordered.sort_by_key(|item| { + item.item_key + .rsplit_once('.') + .and_then(|(_, index)| index.parse::().ok()) + .unwrap_or(usize::MAX) + }); + ordered +} + +/// Is this key a chunk key of ANY root? (`config gc` scans the whole store, so +/// it cannot scope to one root up front.) Validates the canonical shape. +fn chunk_key_generation_any(key: &str) -> Option { + // Split on the LAST infix, not the first: a chunk of a root that ITSELF + // contains the infix (a pointer parked at a chunk-shaped key with self-scoped + // chunks) has the infix twice, and its chunk suffix is after the LAST one. + // Splitting on the first would misread the doubly-nested chunk as a + // non-chunk, get it classified as an unclassifiable root, and abort the whole + // store's GC. For an ordinary single-infix key the root has no infix, so the + // last infix IS the first — this only changes the nested case. + let (root, _rest) = key.rsplit_once(CHUNK_KEY_INFIX)?; + chunk_key_generation(root, key) +} + +/// Drive the common sequential commit mechanics while leaving recovery policy +/// to the operation that owns the writes. +fn commit_entries_with_committer( + entries: &[(String, String)], + mut committer: F, +) -> Result +where + F: FnMut(&str, &str) -> Result<(), String>, +{ + let mut written_keys = Vec::with_capacity(entries.len()); + for (index, (key, value)) in entries.iter().enumerate() { + if let Err(error) = committer(key, value) { + return Err(EntryCommitFailure { + committed: written_keys, + error, + failed_key: key.clone(), + not_attempted: entries + .iter() + .skip(index.saturating_add(1)) + .map(|(remaining_key, _)| remaining_key.clone()) + .collect(), + total: entries.len(), + }); + } + written_keys.push(key.clone()); } - // Partial/total failure must be a non-zero exit so automation can see it. - let mut diagnostic = format!( - "{}\nconfig gc: {} of {doomed_count} deletes FAILED ({})", - out.join("\n"), - failed.len(), - failed.join(", ") - ); - // A generation whose only failure was on an unconfirmed delete: the outcome - // is UNKNOWN (Fastly may have committed it), so a re-run is worth trying but - // may find a fragment. - if !uncertain.is_empty() { - write!( - diagnostic, - ".\nNOTE: a failed remote delete has an unknown outcome -- Fastly may have applied it \ - before returning an error. Re-run `config gc`: it reclaims each affected generation \ - if it is still whole, or reports it as an unprovable fragment (\"left untouched\") if \ - a delete did commit. If reported as a fragment, remove the survivors by hand:\n{}", - recovery_commands(&resolved_id, &uncertain) + Ok(written_keys.len()) +} + +/// Commit config-push entries and retain its chunk-aware retry guidance. +fn push_entries_with_committer( + entries: &[(String, String)], + committer: F, +) -> Result +where + F: FnMut(&str, &str) -> Result<(), String>, +{ + commit_entries_with_committer(entries, committer).map_err(|failure| { + format!( + "fastly push failed at entry `{failed_key}` while committing {committed} of {total} entries.\n \ + The failed entry's outcome is UNKNOWN: Fastly may have committed it before the error \ + (a timeout can arrive after the write lands), including when it is the root pointer.\n \ + Recovery: re-run the SAME `config push`. It is idempotent -- chunk keys are content-addressed \ + and writes use `--upsert` -- so entries already written are rewritten harmlessly and any \ + missing ones are filled. Do NOT hand-delete the failed key.\n \ + Already written (a retry rewrites them): {already_written:?}\n \ + Failed: `{failed_key}` (outcome unknown) -- {error}\n \ + Not attempted: {not_attempted:?}", + failed_key = failure.failed_key, + committed = failure.committed.len(), + total = failure.total, + already_written = failure.committed, + error = failure.error, + not_attempted = failure.not_attempted, ) - .map_err(|err| format!("failed to format the gc diagnostic: {err}"))?; + }) +} + +/// Shell `fastly config-store-entry update --upsert --stdin` with +/// the value piped through stdin instead of `--value=` on +/// argv. +/// +/// Two reasons for this exact invocation: +/// +/// 1. `--upsert` (vs. the original `create` subcommand): the prior +/// `create` form errored on any key that already existed in the +/// config store, which made `config push` non-repeatable — +/// after the first push, every follow-up push triggered by a +/// config edit would fail at the first unchanged key. +/// `update --upsert` is documented as "insert or update", which +/// matches the convergent semantic the other config-push paths +/// already have (axum overwrites the JSON, cloudflare's +/// `wrangler kv bulk put` overwrites, spin's +/// `cloud key-value set` overwrites). +/// +/// 2. `--stdin` (vs. `--value=`): `--value=` exposed every +/// config entry's bytes in `ps`/`/proc//cmdline` listings +/// AND was bounded by the host's `ARG_MAX` (4 KiB to 256 KiB +/// depending on platform — easy to trip with a JSON blob). +/// `--stdin` reads the value from stdin instead — keeps value +/// bytes out of argv and lifts the size cap to whatever the OS +/// pipe buffer + the CLI's read accept (megabytes in practice). +fn create_config_store_entry(store_id: &str, key: &str, value: &str) -> Result<(), String> { + create_config_store_entry_with_cwd(store_id, key, value, None) +} + +fn create_config_store_entry_with_cwd( + store_id: &str, + key: &str, + value: &str, + cwd: Option<&Path>, +) -> Result<(), String> { + let store_arg = format!("--store-id={store_id}"); + let key_arg = format!("--key={key}"); + let mut command = Command::new("fastly"); + command.args([ + "config-store-entry", + "update", + store_arg.as_str(), + key_arg.as_str(), + "--upsert", + "--stdin", + ]); + if let Some(command_cwd) = cwd { + command.current_dir(command_cwd); + } + let mut child = command + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|err| { + if err.kind() == ErrorKind::NotFound { + format!("`fastly` not found on PATH; {FASTLY_INSTALL_HINT}") + } else { + format!("failed to spawn `fastly`: {err}") + } + })?; + // Take stdin OUT of the child and hand it to a helper that writes the value + // and drops the handle on return — closing the pipe so the CLI sees EOF. + // Dropping on scope-exit rather than via an explicit `drop()` keeps this + // valid on targets where `ChildStdin` is a non-Drop stub. + // `child.wait_with_output()` then consumes child cleanly. + let stdin = child + .stdin + .take() + .ok_or_else(|| "failed to open stdin pipe to `fastly`".to_owned())?; + write_value_to_fastly_stdin(stdin, value)?; + let output = child + .wait_with_output() + .map_err(|err| format!("failed to wait on `fastly`: {err}"))?; + if output.status.success() { + return Ok(()); + } + Err(format!( + "`fastly config-store-entry update --store-id={store_id} --key={key} --upsert --stdin` exited with status {}\nstderr: {}", + output.status, + redact_stderr(&String::from_utf8_lossy(&output.stderr)) + )) +} + +/// Write `value` to the child's stdin, then drop the handle as it falls out of +/// scope on return — closing the pipe so the `fastly` CLI sees EOF. Taking +/// `stdin` by value gives a natural scope-end drop rather than an explicit +/// `drop()`, which also keeps this valid on targets where `ChildStdin` is a +/// non-Drop stub. +fn write_value_to_fastly_stdin(mut stdin: ChildStdin, value: &str) -> Result<(), String> { + stdin + .write_all(value.as_bytes()) + .map_err(|err| format!("failed to write value to `fastly` stdin: {err}")) +} + +fn delete_config_store_entry(store_id: &str, key: &str) -> Result<(), String> { + let store_arg = format!("--store-id={store_id}"); + let key_arg = format!("--key={key}"); + let output = Command::new("fastly") + .args([ + "config-store-entry", + "delete", + store_arg.as_str(), + key_arg.as_str(), + "--auto-yes", + ]) + .output() + .map_err(|err| { + if err.kind() == ErrorKind::NotFound { + format!("`fastly` not found on PATH; {FASTLY_INSTALL_HINT}") + } else { + format!("failed to spawn `fastly`: {err}") + } + })?; + if output.status.success() { + return Ok(()); } - // A generation with a CONFIRMED prior delete: definitely a fragment now. - if !stranded.is_empty() { - write!( - diagnostic, - ".\nWARNING: {} entr(ies) are now an INCOMPLETE generation because a sibling was \ - already deleted before the failure: {}. `config gc` proves a generation by \ - reassembling it, so it can no longer prove these and will never reclaim them -- \ - re-running will NOT help. They are inert (no pointer references them). Remove them \ - by hand once you are satisfied they are unreferenced:\n{}", - stranded.len(), - stranded.join(", "), - recovery_commands(&resolved_id, &stranded), - ) - .map_err(|err| format!("failed to format the gc diagnostic: {err}"))?; + // EVERY non-zero delete is a failure -- no "already gone" special case. + // Pattern-matching stderr for "not found"/"404" cannot reliably tell "this + // key is already gone" from "the store does not exist", an auth failure, or + // a 500: messages like `config store abc does not exist while deleting key + // ` name the key AND say "does not exist". Reporting those as a + // successful reclamation is strictly worse than a retry, and a retry is + // free: `config gc` re-lists the store, so a key that really is gone simply + // will not appear as a candidate next run. + // Redact stderr: a Fastly error can quote the entry value back, which on the + // delete path would put a stored config value into CI logs. + let stderr = String::from_utf8_lossy(&output.stderr); + Err(format!( + "`fastly config-store-entry delete --store-id={store_id} --key={key} --auto-yes` exited with status {}\n{}", + output.status, + redact_stderr(&stderr) + )) +} + +/// Parse `fastly config-store list --json` output and return the +/// platform `id` of the store whose `name` matches `name`. Accepts +/// both a bare array (`[ {"id": "...", "name": "..."}, ... ]`) +/// and an `{"items": [...]}` envelope so this stays compatible +/// across fastly CLI versions. +fn find_config_store_id(stdout: &str, name: &str) -> ConfigStoreLookup { + let parsed: serde_json::Value = match serde_json::from_str(stdout) { + Ok(value) => value, + Err(err) => { + return ConfigStoreLookup::SchemaDrift(format!("stdout did not parse as JSON: {err}")); + } + }; + let Some(array) = parsed + .as_array() + .or_else(|| parsed.get("items").and_then(serde_json::Value::as_array)) + else { + return ConfigStoreLookup::SchemaDrift(format!( + "expected a bare array `[...]` or an `{{\"items\": [...]}}` envelope; got JSON of shape `{}`", + shape_summary(&parsed) + )); + }; + // FAIL CLOSED on any malformed or duplicate row: a `NotFound` here becomes a + // MissingStore that AUTHORISES an overwrite, so a listing we cannot read + // exactly must never look like a definite absence. A malformed row could BE + // the requested store (its unreadable `name` might have matched), and a + // duplicate name means we are not reading the store we think we are. Every row + // must carry a non-empty string `name` and `id`, and names must be unique. + let mut seen_names = HashSet::with_capacity(array.len()); + let mut found: Option = None; + for (idx, entry) in array.iter().enumerate() { + let name_field = entry + .get("name") + .and_then(serde_json::Value::as_str) + .filter(|value| !value.is_empty()); + let id_field = entry + .get("id") + .and_then(serde_json::Value::as_str) + .filter(|value| !value.is_empty()); + let (Some(entry_name), Some(entry_id)) = (name_field, id_field) else { + return ConfigStoreLookup::SchemaDrift(format!( + "store-list entry #{idx} is missing a non-empty string `name` or `id`; refusing to \ + treat a store as absent on a listing this build cannot read exactly" + )); + }; + if !seen_names.insert(entry_name.to_owned()) { + return ConfigStoreLookup::SchemaDrift(format!( + "store-list has a duplicate `name` (`{entry_name}`); refusing to resolve a store id \ + on an ambiguous listing" + )); + } + if entry_name == name { + found = Some(entry_id.to_owned()); + } } - Err(diagnostic) + found.map_or(ConfigStoreLookup::NotFound, ConfigStoreLookup::Found) } -/// Render copy-pasteable `fastly config-store-entry delete` commands, one per -/// key, with EVERY interpolated value single-quoted for POSIX shells. +/// Summarise a `fastly ... describe` response for diagnostics WITHOUT +/// leaking its contents. /// -/// Root keys are free-form (`--key `), and a chunk key preserves its -/// root, so a key can contain `$(...)`, spaces, or `;`. Pasting an unquoted -/// command could execute or misparse it, so this is not cosmetic. +/// The response body is the stored config value. App config may hold +/// credentials, internal endpoints, or security policy, and this adapter +/// performs no secret stripping — while CLI status lines are logged +/// verbatim and CI logs are commonly retained and shared. So a schema-drift +/// diagnostic must never echo the payload: report only its size and its +/// top-level *shape* (field names for an object, type otherwise), never a +/// value. +fn redact_describe_response(stdout: &str) -> String { + let len = stdout.len(); + serde_json::from_str::(stdout).map_or_else( + |_err| format!("{len} bytes, not valid JSON"), + |value| match value { + serde_json::Value::Object(map) => { + // Object KEYS are stored/provider-controlled data (a wrong-shape + // response could be `{"": ...}`), so only the COUNT is + // reported, never the key names. + format!("{len} bytes, JSON object with {} field(s)", map.len()) + } + other @ (serde_json::Value::Null + | serde_json::Value::Bool(_) + | serde_json::Value::Number(_) + | serde_json::Value::String(_) + | serde_json::Value::Array(_)) => { + format!("{len} bytes, JSON {}", shape_summary(&other)) + } + }, + ) +} + +/// Summarise a failing `fastly` invocation's stderr WITHOUT echoing it. /// -/// The escaping is POSIX/bash (Linux/macOS). A leading note makes that explicit, -/// because Windows `cmd` and PowerShell quote differently — an operator on those -/// shells must adapt the quoting rather than paste verbatim. -fn recovery_commands(store_id: &str, keys: &[String]) -> String { - let commands = keys - .iter() - .map(|key| { - format!( - " fastly config-store-entry delete --store-id={} --key={} --auto-yes", - shell_single_quote(store_id), - shell_single_quote(key), - ) - }) - .collect::>() - .join("\n"); +/// The `describe` and `update --stdin` paths carry the stored config value, so +/// a Fastly error that quotes the payload back would put credentials straight +/// into CI logs — the same exposure as the stdout leak, via the failure branch. +/// Not-found *classification* still inspects stderr internally; only the +/// user-facing string is redacted. +fn redact_stderr(stderr: &str) -> String { + let len = stderr.trim().len(); format!( - " # POSIX/bash (Linux/macOS). On Windows cmd/PowerShell the quoting \ - differs -- adapt it for your shell.\n{commands}" + "{len} bytes suppressed (may echo the stored config value); re-run the `fastly` command directly to inspect it" ) } -/// Single-quote a value for a POSIX shell: wrap in `'...'` and rewrite each -/// embedded `'` as `'\''`. Inside single quotes every other byte -- `$`, spaces, -/// `;`, `$(...)`, backticks -- is literal, so this neutralises any hostile key. -fn shell_single_quote(value: &str) -> String { - format!("'{}'", value.replace('\'', "'\\''")) +/// One-line type label for a `serde_json::Value` (for diagnostic +/// error messages — not a canonical JSON-schema description). +fn shape_summary(value: &serde_json::Value) -> &'static str { + match value { + serde_json::Value::Null => "null", + serde_json::Value::Bool(_) => "bool", + serde_json::Value::Number(_) => "number", + serde_json::Value::String(_) => "string", + serde_json::Value::Array(_) => "array", + serde_json::Value::Object(_) => "object", + } } -/// Delete each doomed generation, stopping a generation at its FIRST failure. -/// -/// A generation is provable only as a whole (`prove_generation` reassembles it), -/// so a half-deleted one can never be proved again: the next run sees a fragment, -/// cannot verify it, and correctly refuses to touch it — forever. Ploughing on -/// after a failure is therefore the one thing that turns a possibly-recoverable -/// error into permanent, unreclaimable litter. +/// Resolve the platform config-store id on demand: shell out to +/// `fastly config-store list --json`, parse the JSON, match by +/// `name`. The provision flow doesn't persist this id, so push +/// has to re-fetch every time. /// -/// A failed remote delete has an UNKNOWN outcome — Fastly may commit it before -/// returning an error — so nothing here is promised as cleanly retryable. The -/// caller distinguishes two cases: a failure with a CONFIRMED prior sibling -/// delete strands the survivors for good (manual recovery), and a failure with -/// no confirmed prior delete leaves the generation in an UNCERTAIN state (a -/// re-run may reclaim it, or surface it as an unprovable fragment). Generations -/// are independent, so a failure in one does not stop the others. -fn execute_gc_deletes( - resolved_id: &str, - doomed: &[Vec<(String, u64)>], - out: &mut Vec, -) -> GcDeleteOutcome { - let mut outcome = GcDeleteOutcome { - deleted: 0, - failed: Vec::new(), - stranded: Vec::new(), - uncertain: Vec::new(), - }; - for generation in doomed { - let mut deleted_here: Vec<&str> = Vec::new(); - for (key, _) in generation { - match delete_config_store_entry(resolved_id, key) { - Ok(()) => { - outcome.deleted = outcome.deleted.saturating_add(1); - deleted_here.push(key.as_str()); - // CONFIRMED gone, per key, as it happens. - out.push(format!(" deleted `{key}`")); - } - Err(err) => { - out.push(format!(" FAILED to delete `{key}` ({err})")); - outcome.failed.push(key.clone()); - // Everything in this generation we have NOT confirmed deleted - // -- the failed key itself, plus the ones we never reached. - let unconfirmed: Vec = generation - .iter() - .map(|(member, _)| member.clone()) - .filter(|member| !deleted_here.contains(&member.as_str())) - .collect(); - // Distinguish the ones we NEVER ATTEMPTED (after the stop) - // from the failed key itself, so the report is not read as - // "all of these were tried and failed". - for skipped in unconfirmed.iter().filter(|member| *member != key) { - out.push(format!( - " skipped `{skipped}` (not attempted: this generation's delete stopped at the failure above)" - )); - } - if deleted_here.is_empty() { - // No sibling is CONFIRMED gone. The failed delete's - // outcome is unknown: if it did not commit, the - // generation is whole and a re-run reclaims it; if it - // did, the re-run finds a fragment and reports it. Either - // way we must not claim clean retryability. - outcome.uncertain.extend(unconfirmed); - } else { - // A sibling is CONFIRMED gone, so this generation is - // definitely a fragment no future run can prove. - outcome.stranded.extend(unconfirmed); - } - break; // stop THIS generation; the others are independent - } - } - } +/// Returns a TYPED absence: `Ok(None)` ONLY when the list call SUCCEEDS and no +/// store matches (a genuine absence). An operational failure (missing binary, +/// spawn/list failure, schema drift) stays `Err` -- callers that read for a diff +/// must not treat an operational failure as "store absent" and overwrite. +fn resolve_remote_config_store_id(name: &str) -> Result, String> { + match classify_remote_config_store(name)? { + ConfigStoreLookup::Found(id) => Ok(Some(id)), + ConfigStoreLookup::NotFound => Ok(None), + ConfigStoreLookup::SchemaDrift(detail) => Err(format!( + "could not parse `fastly config-store list --json` output: {detail}.\n The fastly CLI may have changed its JSON schema in a recent version. Please file a bug report at https://github.com/stackpop/edgezero/issues with the fastly CLI version (`fastly version`) and the raw stdout. Workaround: pin to a known-compatible fastly CLI version." + )), } - outcome } -/// Classify a store's entries: the live chunk set, the protected root keys, and -/// the root count. -/// -/// Root-vs-chunk is decided by VALUE, not key shape. The runtime resolver reads -/// whatever value sits at a key, so ANY entry whose value is a valid direct -/// envelope or a chunk pointer is a runtime-readable root and must never be -/// deleted — even at a chunk-shaped key. Two ways that happens: -/// -/// - a pointer parked at a chunk-shaped key makes its references LIVE; -/// - a value that is itself a valid direct envelope (e.g. a small envelope whose -/// first 7 000-byte chunk is the whole envelope plus trailing whitespace, and -/// so still parses and verifies) is a root in its own right. +/// 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). /// -/// Only a value that is NEITHER — a raw envelope fragment, which does not parse — -/// is a delete candidate. In normal operation a chunk payload is exactly such a -/// fragment, so this protects the pathological cases at no cost to real GC. -fn classify_store_entries( - items: &[ConfigStoreItem], - value_by_key: &HashMap<&str, &str>, -) -> Result { - let mut live: HashSet = HashSet::new(); - let mut protected: HashSet = HashSet::new(); - let mut roots = 0_usize; - let mut warnings: Vec = Vec::new(); - for item in items { - let is_chunk_shaped = chunk_key_generation_any(&item.item_key).is_some(); - let classified = match gc_classify_root(&item.item_key, &item.item_value) { - Ok(classified) => classified, - // A chunk-shaped key whose value we cannot classify is a genuine - // chunk fragment (a candidate) ONLY if BOTH hold: - // - the value ANNOUNCES no kind. A real chunk payload is a raw - // envelope fragment (no `edgezero_kind`); anything that DOES claim - // our namespace -- a parked pointer, an unknown/future kind -- is - // root-like or suspicious and must fail closed below. - // - NOTHING is nested beneath this key. A truncated/corrupt pointer - // at a chunk-shaped key is ALSO an unparseable fragment, but if it - // is a nested ROOT with its own generation, those nested chunks are - // proven independently and would be deleted while their (unreadable) - // root can no longer name them -- silent loss of a whole nested - // generation. If any canonical chunk of THIS key exists, treat the - // key as an unreadable nested root and FAIL CLOSED. A real leaf - // payload never has nested chunks, so normal GC is unaffected. - Err(_) - if is_chunk_shaped - && !value_announces_our_kind(&item.item_value) - && !value_is_future_format(&item.item_value) - && !items.iter().any(|other| { - other.item_key != item.item_key - && chunk_key_generation(&item.item_key, &other.item_key).is_some() - }) => - { - continue; // a leaf chunk payload: a delete candidate - } - // A definitively FOREIGN entry at an ORDINARY key — a plain string - // like `greeting = "hello"`, a scalar, or a complete JSON object - // without our discriminator. The runtime returns it verbatim and it - // references no chunks, so protect it as a zero-reference root. - // Aborting here would let one ordinary sibling block reclamation of - // every generation in the store. - // - // Three guards keep this from ever masking corruption: - // - the value must be provably inert (NOT a malformed object that - // could be a truncated/corrupt pointer, NOT a value claiming our - // namespace) -- otherwise we might orphan chunks a broken root - // still references; - // - it must NOT be a future format. A direct envelope from a newer - // writer classifies as `Foreign` (no `edgezero_kind`), so without - // this guard it would be waved through as a zero-reference root -- - // yet a newer format may reference chunks under a scheme this build - // cannot read, and GC would plan them for deletion. Fail closed; - // - the KEY must be outside our reserved `.__edgezero_chunks.` - // namespace. A non-canonical key that still lives in that - // namespace is not an ordinary sibling; we cannot say what it is, - // so it fails closed below rather than being waved through. - Err(_) - if value_is_inert_foreign(&item.item_value) - && !value_is_future_format(&item.item_value) - && !item.item_key.contains(CHUNK_KEY_INFIX) => - { - roots = roots.saturating_add(1); - protected.insert(item.item_key.clone()); - continue; - } - Err(err) => { - return Err(format!( - "refusing to reclaim: could not classify root `{}` ({err}); nothing was deleted", - item.item_key - )); - } - }; - // A runtime-readable root, wherever it lives: never a delete candidate. - roots = roots.saturating_add(1); - protected.insert(item.item_key.clone()); - let GcRootValue::Chunked(pointer) = classified else { - continue; // A direct envelope references no chunks. - }; - // The pointer's METADATA is self-consistent by here. That is not proof - // that it honestly describes its generation: a pointer can drop its last - // chunk ref AND restate `envelope_len` as the remaining sum, and every - // metadata check still passes while the dropped chunk silently leaves - // the live set and becomes deletable. So reassemble what it references - // and hold the bytes against its content-address. - let assembled = assemble_pointer_chunks(&item.item_key, &pointer, value_by_key)?; - // The reassembled value may be a NEWER inner format (a bumped envelope - // version, or an unknown `edgezero_kind`) that `BlobEnvelope` deserialize - // silently ignores. Such a format can reference ADDITIONAL generations this - // build cannot see, so trusting only the outer pointer's chunks as the live - // set would let GC delete those as orphans. The runtime resolver rejects - // this case; GC must too. Fail closed. - if value_is_future_format(&assembled) { - return Err(format!( - "refusing to reclaim: root `{}` reconstructs to a value in a newer format this \ - build does not recognise. It may reference generations this build cannot see, so \ - treating its outer chunks as the whole live set could delete live data. Nothing \ - was deleted.", - item.item_key - )); - } - gc_verify_generation(&pointer.envelope_sha256, &assembled).map_err(|err| { - format!( - "refusing to reclaim: root `{}` names a chunk set that does not reconstruct the \ - envelope it claims ({err}). Its chunk list is therefore not a trustworthy live \ - set, and treating it as one could delete a live chunk. Nothing was deleted.", - item.item_key - ) - })?; - // Same exact-split predicate the RUNTIME resolver applies. The content - // checks above only prove the bytes; a pointer whose boundaries are not - // the ones this writer emits reassembles correctly here but is REJECTED - // at runtime -- so GC would otherwise call it a healthy live root while - // the guest 500s on it, and its generation can never satisfy - // `prove_generation` either, making it permanently unreclaimable. - // - // We still protect it (fail-closed: never delete on a judgement we are - // unsure of), but we no longer call it healthy silently -- the operator - // gets told it is unreadable and will not be reclaimed automatically. - if let Err(err) = - verify_writer_split_layout(&item.item_key, &assembled, &chunk_lengths(&pointer.chunks)) - { - warnings.push(format!( - "warning: root `{}` is NOT runtime-readable ({err}). Its chunks are kept, but this \ - generation can never be proven writer-produced, so `config gc` will never reclaim \ - it. Re-run `config push` for this key to rewrite it, then re-run `config gc`.", - item.item_key - )); +/// `Err` is only for a failure to OBTAIN an answer; a successful listing that +/// simply doesn't contain `name` is `Ok(ConfigStoreLookup::NotFound)`. +fn classify_remote_config_store(name: &str) -> Result { + classify_remote_config_store_with_cwd(name, None) +} + +fn classify_remote_config_store_with_cwd( + name: &str, + cwd: Option<&Path>, +) -> Result { + let mut command = Command::new("fastly"); + command.args(["config-store", "list", "--json"]); + if let Some(command_cwd) = cwd { + command.current_dir(command_cwd); + } + let output = command.output().map_err(|err| { + if err.kind() == ErrorKind::NotFound { + format!("`fastly` not found on PATH; {FASTLY_INSTALL_HINT}") + } else { + format!("failed to spawn `fastly`: {err}") } - live.extend(pointer.chunks.into_iter().map(|chunk| chunk.key)); + })?; + if !output.status.success() { + return Err(format!( + "`fastly config-store list --json` exited with status {}\nstderr: {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + )); } - Ok(GcClassification { - live, - protected, - roots, - warnings, - }) + // Adopt main's strict UTF-8 gate (fail closed on undecodable stdout) but + // return the raw lookup so staging callers keep the 3-way verdict; the + // SchemaDrift -> Err mapping lives in resolve_remote_config_store_id. + let stdout = strict_stdout(output.stdout, "config-store list --json")?; + Ok(find_config_store_id(&stdout, name)) +} + +/// Message for a genuinely-absent store, for the write/GC callers that treat +/// absence as a hard error (they cannot operate on a store that does not exist). +fn no_matching_store_error(name: &str) -> String { + format!( + "no fastly config-store matches `{name}` (did you run `edgezero provision --adapter fastly`?)" + ) +} + +/// # Errors +/// Returns an error if the Fastly CLI build command fails. +#[inline] +pub fn build(extra_args: &[String]) -> Result { + let manifest = + find_fastly_manifest(env::current_dir().map_err(|err| err.to_string())?.as_path())?; + let manifest_dir = manifest + .parent() + .ok_or_else(|| "fastly manifest has no parent directory".to_owned())?; + let cargo_manifest = manifest_dir.join("Cargo.toml"); + let crate_name = read_package_name(&cargo_manifest)?; + + let status = Command::new("cargo") + .args([ + "build", + "--release", + "--target", + "wasm32-wasip1", + "--manifest-path", + cargo_manifest + .to_str() + .ok_or("invalid Cargo manifest path")?, + ]) + .args(extra_args) + .status() + .map_err(|err| format!("failed to run cargo build: {err}"))?; + if !status.success() { + return Err(format!("cargo build failed with status {status}")); + } + + let workspace_root = find_workspace_root(manifest_dir); + let artifact = locate_artifact(&workspace_root, manifest_dir, &crate_name)?; + let pkg_dir = workspace_root.join("pkg"); + fs::create_dir_all(&pkg_dir) + .map_err(|err| format!("failed to create {}: {err}", pkg_dir.display()))?; + let dest = pkg_dir.join(format!("{}.wasm", crate_name.replace('-', "_"))); + fs::copy(&artifact, &dest) + .map_err(|err| format!("failed to copy artifact to {}: {err}", dest.display()))?; + + Ok(dest) +} + +/// Whether `args` already carries the Fastly CLI's non-interactive +/// switch, in either its long (`--non-interactive`) or short (`-i`) +/// form. Used to avoid passing the flag twice when a caller already +/// supplied it via `deploy-args` passthrough. +fn has_non_interactive(args: &[String]) -> bool { + args.iter() + .any(|arg| arg == "--non-interactive" || arg == "-i") +} + +/// Build the argv for `fastly compute deploy`, appending +/// `--non-interactive` (a Fastly CLI *global* flag, supported by +/// `compute deploy`) unless the caller already passed it. Without it a +/// production deploy can block on an interactive prompt in CI. +fn build_compute_deploy_args(extra_args: &[String]) -> Vec { + let mut argv = vec!["compute".to_owned(), "deploy".to_owned()]; + argv.extend_from_slice(extra_args); + if !has_non_interactive(extra_args) { + argv.push("--non-interactive".to_owned()); + } + argv } -/// The reclamation plan for one store: which orphan chunk entries to delete, and -/// the counts for the summary line. Deriving it is where every safety guard -/// lives, so it is fail-closed throughout — any unreadable/incomplete state -/// returns `Err` and the caller deletes nothing. +/// Legacy direct entry point for callers using [`AdapterAction::Deploy`]. +/// `EdgeZero`'s main deploy path uses the typed [`Adapter::deploy`] hook. /// -/// The organising idea is that **content-addressing makes a chunk set -/// self-proving**: a chunk key embeds the SHA-256 of the whole envelope it -/// belongs to, so reassembling a generation either reproduces the -/// content-address its own keys name, or it does not. Every destructive decision -/// here rests on that hash — never on what the store's metadata claims about -/// itself, which is exactly what an inconsistent store gets wrong. -fn plan_gc_reclamation( - items: &[ConfigStoreItem], - now: u64, - older_than_secs: u64, -) -> Result { - let mut value_by_key: HashMap<&str, &str> = HashMap::with_capacity(items.len()); - let mut created_by_key: HashMap<&str, u64> = HashMap::with_capacity(items.len()); - for item in items { - let Some(created) = parse_rfc3339_secs(&item.created_at) else { - // Unparseable timestamp anywhere in the listing -> fail closed. On a - // DELETE path we will not guess an age. - return Err(format!( - "refusing to reclaim: entry `{}` has an unreadable `created_at`; nothing was deleted", - item.item_key - )); - }; - created_by_key.insert(item.item_key.as_str(), created); - value_by_key.insert(item.item_key.as_str(), item.item_value.as_str()); +/// # Errors +/// Returns an error when the Fastly CLI cannot deploy the package. +#[inline] +pub fn deploy(extra_args: &[String]) -> Result<(), String> { + 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_path = resolve_deploy_manifest_path(context)?; + validate_deploy_service_id_for_manifest(context, &manifest_path)?; + let manifest_dir = manifest_path.parent().ok_or_else(|| { + format!( + "fastly manifest path {} has no parent directory", + manifest_path.display() + ) + })?; + let without_manifest = args_without_flag_value(extra_args, "--manifest-path"); + let mut forwarded = args_without_flag_value(&without_manifest, "--service-id"); + scan_reserved_deploy_args(&forwarded)?; + if let Some(service_id) = context.service_id.as_deref() { + forwarded.extend(["--service-id".to_owned(), service_id.to_owned()]); } - // ---- 1. Classify entries: live chunks, protected roots, root count ---- - let GcClassification { - live, - protected, - roots, - warnings, - } = classify_store_entries(items, &value_by_key)?; + let status = Command::new("fastly") + .args(build_compute_deploy_args(&forwarded)) + .current_dir(manifest_dir) + .status() + .map_err(|err| format!("failed to run fastly CLI: {err}"))?; + if !status.success() { + return Err(format!("fastly compute deploy failed with status {status}")); + } - // ---- 2. Per-root live-config age (best-effort; see the guard below) ---- - // rsplit_once (the LAST infix): a chunk of a chunk-shaped root nests the infix - // twice, and its root is everything before the LAST one. Splitting on the - // first would attribute a nested chunk's age to the wrong (outer) root. - let root_live_since: HashMap<&str, u64> = live.iter().fold(HashMap::new(), |mut acc, key| { - if let Some((root, _)) = key.rsplit_once(CHUNK_KEY_INFIX) { - let created = *created_by_key.get(key.as_str()).unwrap_or(&0); - let slot = acc.entry(root).or_insert(0); - *slot = (*slot).max(created); - } - acc + Ok(()) +} + +fn find_fastly_manifest(start: &Path) -> Result { + if let Some(found) = find_manifest_upwards(start, "fastly.toml") { + return Ok(found); + } + + let root = find_workspace_root(start); + let mut candidates: Vec = WalkDir::new(&root) + .follow_links(true) + .max_depth(8) + .into_iter() + .filter_map(Result::ok) + .map(|entry| entry.path().to_path_buf()) + .filter(|path| { + path.file_name().is_some_and(|n| n == "fastly.toml") + && path + .parent() + .is_some_and(|dir| dir.join("Cargo.toml").exists()) + }) + .collect(); + + if candidates.is_empty() { + return Err("could not locate fastly.toml".to_owned()); + } + + candidates.sort_by_key(|path| { + let parent = path.parent().unwrap_or(Path::new("")); + path_distance(start, parent) }); - // ---- 3. Candidates, grouped by GENERATION and proven writer-produced ---- - // A per-key decision cannot be safe: an entry is only ours if the whole - // generation it belongs to reassembles to the content-address its keys name. - // So group first, prove second, and delete whole generations or none -- a - // partial delete would leave a corrupt generation behind. - let mut groups: BTreeMap<(&str, String), Vec<&ConfigStoreItem>> = BTreeMap::new(); - for item in items { - if live.contains(&item.item_key) { - continue; - } - // A key whose own value is a runtime-readable root is never a candidate, - // even when its key is chunk-shaped (a valid direct envelope can sit at - // one). Excluding it here also means any real chunk sharing that - // generation drops to an incomplete group, which prove_generation then - // leaves untouched — safe: we leak rather than delete a possible root. - if protected.contains(&item.item_key) { - continue; + Ok(candidates.remove(0)) +} + +fn locate_artifact( + workspace_root: &Path, + manifest_dir: &Path, + crate_name: &str, +) -> Result { + let target_triple = "wasm32-wasip1"; + let release_name = format!("{}.wasm", crate_name.replace('-', "_")); + + if let Some(custom) = env::var_os("CARGO_TARGET_DIR") { + let candidate = PathBuf::from(custom) + .join(target_triple) + .join("release") + .join(&release_name); + if candidate.exists() { + return Ok(candidate); } - // rsplit_once (the LAST infix): the same nested-chunk correctness the - // live-set scan and classification use — a chunk of a chunk-shaped root - // is grouped under THAT root, not the outer one, so nested orphans are - // grouped (and thus reclaimed or reported), not silently dropped. - let Some((root, _)) = item.item_key.rsplit_once(CHUNK_KEY_INFIX) else { - continue; // a root - }; - let Some(generation) = chunk_key_generation(root, &item.item_key) else { - continue; // chunk-shaped but NOT canonical => never a key we emit - }; - groups.entry((root, generation)).or_default().push(item); } - let mut doomed: Vec> = Vec::new(); - let mut retained_recent = 0_usize; - let mut unprovable = 0_usize; - for ((root, generation), mut group) in groups { - if prove_generation(root, &generation, &group).is_err() { - // We cannot prove we wrote this, so we do not touch it. It may be an - // ordinary entry that merely LOOKS like a chunk key (a store can - // predate this feature or be shared, and push-time reserved-key - // rejection cannot protect what already exists), or a half-written - // generation. Skipped rather than fatal: one foreign entry must not - // block reclamation of the store forever. Reported in the summary. - unprovable = unprovable.saturating_add(group.len()); - continue; - } + let manifest_target = manifest_dir + .join("target") + .join(target_triple) + .join("release") + .join(&release_name); + if manifest_target.exists() { + return Ok(manifest_target); + } - // Age the generation as a UNIT, by its youngest member: deleting a - // generation is one decision, so its most restrictive age governs. - let group_age = group - .iter() - .map(|item| { - now.saturating_sub(*created_by_key.get(item.item_key.as_str()).unwrap_or(&0)) - }) - .min() - .unwrap_or(0); - // BOTH ages must clear the operator's window; neither substitutes for - // the other, so take the more restrictive (the MINIMUM). - // - // - The chunks' OWN age is mandatory: a generation written seconds ago - // is inside the propagation window whatever its root looks like (e.g. - // a concurrent push wrote it and has not committed its pointer yet), - // so an old-looking root must never license deleting it. - // - The root's live-config age (when known) is an EXTRA restriction: it - // catches an old generation superseded recently, which its own age - // cannot see. - let effective_age = root_live_since.get(root).map_or(group_age, |live_since| { - group_age.min(now.saturating_sub(*live_since)) - }); - if effective_age < older_than_secs { - retained_recent = retained_recent.saturating_add(group.len()); - continue; - } - // Delete in canonical chunk-INDEX order (`.0`, `.1`, ...), NOT the remote - // listing order. Deletion stops at a generation's first failure, so a - // reordered listing would otherwise change the preview order and which - // siblings get stranded; sorting makes both deterministic. Every member is - // a canonical chunk of `root` (it passed the grouping filter), so - // `chunk_key_index` is `Some`; `None` sorts last defensively. - group.sort_by_key(|item| chunk_key_index(root, &item.item_key).unwrap_or(usize::MAX)); - doomed.push( - group - .iter() - .map(|item| { - let age = now - .saturating_sub(*created_by_key.get(item.item_key.as_str()).unwrap_or(&0)); - (item.item_key.clone(), age) - }) - .collect(), - ); + let workspace_target = workspace_root + .join("target") + .join(target_triple) + .join("release") + .join(&release_name); + if workspace_target.exists() { + return Ok(workspace_target); + } + + Err(format!( + "compiled artifact not found (looked in {} and workspace target)", + manifest_dir.display() + )) +} + +#[inline] +pub fn register() { + register_adapter(&FASTLY_ADAPTER); + register_adapter_blueprint(&FASTLY_BLUEPRINT); +} + +#[ctor(unsafe)] +fn register_ctor() { + register(); +} + +/// # Errors +/// Returns an error if the Fastly CLI serve command (Viceroy) fails. +#[inline] +pub fn serve(extra_args: &[String]) -> Result<(), String> { + let manifest = + find_fastly_manifest(env::current_dir().map_err(|err| err.to_string())?.as_path())?; + let manifest_dir = manifest + .parent() + .ok_or_else(|| "fastly manifest has no parent directory".to_owned())?; + + let status = Command::new("fastly") + .args(["compute", "serve"]) + .args(extra_args) + .current_dir(manifest_dir) + .status() + .map_err(|err| format!("failed to run fastly CLI: {err}"))?; + if !status.success() { + return Err(format!("fastly compute serve failed with status {status}")); } - let mut kept_roots: Vec = protected.into_iter().collect(); - kept_roots.sort(); + Ok(()) +} + +// =================================================================== +// Fastly lifecycle +// =================================================================== +// +// The adapter-managed deployment path verifies the immutable application release, +// uploads its recorded package with `compute update` to an exact unreachable draft, +// reconciles and reads back exact logical resource links and the package identity, and +// stages or activates only after verification. It emits `version=` and +// `package-sha256=`. The bare store-free production manifest command is a +// compatibility path outside this managed lifecycle. +// +// These entry points also back the `healthcheck` and `rollback` app-CLI subcommands: +// +// * healthcheck → curl the domain (production) or the version's +// resolved staging IP (`--staging`); non-zero exit when unhealthy. +// * rollback → activate the explicit `--rollback-to` version +// (production) or deactivate `` (staging) via the Fastly API. +// Rollback prints `rolled-back-to=`. +// +// Provider HTTP calls shell out to `curl` (matching the lifecycle action +// conventions and avoiding a WASM-incompatible HTTP client +// in the adapter). The `FASTLY_API_TOKEN` is passed to `curl` via a +// `--config -` stdin file rather than on argv, so it never appears in +// `ps` / `/proc//cmdline` (same discipline as +// `create_config_store_entry`'s `--stdin`). - Ok(GcPlan { - doomed, - kept_roots, - live_count: live.len(), - retained_recent, - roots, - unprovable, - warnings, - }) +/// Value that follows `flag` in a `--flag value` arg slice, if present. +fn arg_value<'args>(args: &'args [String], flag: &str) -> Option<&'args str> { + args.iter() + .position(|arg| arg == flag) + .and_then(|idx| idx.checked_add(1)) + .and_then(|idx| args.get(idx)) + .map(String::as_str) } -/// Reassemble the chunks a live pointer references, in index order, checking each -/// against the pointer's own per-chunk `len`/`sha256` along the way. -/// -/// Fails closed when a referenced key is absent from the listing. This subsumes -/// the old standalone completeness guard: an incomplete or paginated listing -/// cannot produce the bytes, so it can never reach a passing verification. -fn assemble_pointer_chunks( - root_key: &str, - pointer: &GcPointer, - value_by_key: &HashMap<&str, &str>, -) -> Result { - // NOT `with_capacity(pointer.envelope_len)`: that length is untrusted stored - // metadata. `validate_pointer_chunks` bounds it, but this is a destructive - // path -- do not reserve from a number the store supplied when growing from - // the bytes we actually read costs nothing. - let mut assembled = String::new(); - // The chunk KEY is pointer-controlled (a malformed pointer can carry any - // string there), so diagnostics name a POSITION, not the key. `root_key` is - // the operator's own logical entry key and is named for context, as the rest - // of the GC diagnostics do. - for (position, chunk) in pointer.chunks.iter().enumerate() { - let Some(value) = value_by_key.get(chunk.key.as_str()) else { - return Err(format!( - "refusing to reclaim: root `{root_key}` references chunk {position}, which is \ - absent from the store listing (the listing may be incomplete/paginated, or the \ - store is already inconsistent); nothing was deleted" - )); - }; - if value.len() != chunk.len { - return Err(format!( - "refusing to reclaim: root `{root_key}` says chunk {position} is {} bytes but the \ - store holds {}; nothing was deleted", - chunk.len, - value.len() - )); +/// Whether a boolean `flag` (e.g. `--staging`) is present in `args`. +fn arg_flag(args: &[String], flag: &str) -> bool { + args.iter().any(|arg| arg == flag) +} + +/// Copy of `args` with `--flag value` removed (both tokens). Used to +/// forward operator passthrough (e.g. `--comment`) to `fastly compute +/// update` without re-passing `--service-id`, which is threaded +/// explicitly. +fn args_without_flag_value(args: &[String], flag: &str) -> Vec { + let mut out = Vec::with_capacity(args.len()); + let mut skip = false; + for arg in args { + if skip { + skip = false; + continue; } - if sha256_hex(value.as_bytes()) != chunk.sha256 { - return Err(format!( - "refusing to reclaim: the stored value of chunk {position} does not match the \ - SHA-256 that root `{root_key}` records for it; nothing was deleted" - )); + if arg == flag { + skip = true; + continue; } - assembled.push_str(value); - } - if assembled.len() != pointer.envelope_len { - return Err(format!( - "refusing to reclaim: root `{root_key}` declares an envelope of {} bytes but its \ - chunks reassemble to {}; nothing was deleted", - pointer.envelope_len, - assembled.len() - )); + out.push(arg.clone()); } - Ok(assembled) + out } -/// Is this candidate generation byte-identical to what THIS writer would have -/// produced for the bytes it contains? -/// -/// The gate on every delete. `group` is every listed entry sharing one -/// `(root, generation)`. -/// -/// **What this proves, precisely.** We reassemble the group in index order and -/// re-run `prepare_fastly_config_entries` over the result. If the writer, given -/// those exact bytes, would emit exactly these keys and these values, the entries -/// are indistinguishable from our own output: same direct-vs-chunked threshold, -/// same UTF-8-safe 7 000-byte boundaries, same content-addressed keys, same -/// count. A lone chunk fails automatically (an envelope small enough to store -/// directly round-trips to a single ROOT-keyed entry, and a large one to >= 2 -/// chunks), as does any set split at boundaries we would not choose. -/// -/// **What this does NOT prove: authorship.** Content-addressing is not a -/// signature. A foreign writer can pick envelope E, compute `H = sha256(E)`, -/// split E exactly as we would, and store the parts under our reserved -/// `.__edgezero_chunks.` namespace; that group is byte-identical to ours and we -/// will reclaim it. No preimage attack is needed, and no check over the stored -/// bytes alone can separate the two — telling them apart needs trusted -/// generation metadata or an authenticated marker, and the store offers neither -/// (any writer with store access could forge either). -/// -/// We accept that residual: the namespace is reserved by convention, push-time -/// validation rejects logical keys inside it, and anything passing this gate is -/// a faithful reproduction of our format. The spec documents it as a limitation -/// rather than claiming a guarantee we cannot make. -fn prove_generation( - root: &str, - generation: &str, - group: &[&ConfigStoreItem], -) -> Result<(), String> { - let mut ordered: Vec<(usize, &str)> = Vec::with_capacity(group.len()); - for item in group { - let index = item - .item_key - .rsplit_once('.') - .and_then(|(_, index)| index.parse::().ok()) - .ok_or_else(|| format!("`{}` has no readable index", item.item_key))?; - ordered.push((index, item.item_value.as_str())); - } - ordered.sort_by_key(|&(index, _)| index); - for (position, &(index, _)) in ordered.iter().enumerate() { - if index != position { +fn scan_reserved_deploy_args(args: &[String]) -> Result<(), String> { + for arg in args { + let reserved_flag = match arg.as_str() { + "--service-id" | "-s" | "--service-name" | "--version" | "--autoclone" | "--token" + | "-t" => Some(arg.as_str()), + value if value.starts_with("--service-id=") => Some("--service-id"), + value if value.starts_with("--service-name=") => Some("--service-name"), + value if value.starts_with("--version=") => Some("--version"), + value if value.starts_with("--autoclone=") => Some("--autoclone"), + value if value.starts_with("--token=") => Some("--token"), + value if value.starts_with("-s") && !value.starts_with("--") && value.len() > 2 => { + Some("-s") + } + value if value.starts_with("-t") && !value.starts_with("--") && value.len() > 2 => { + Some("-t") + } + _ => None, + }; + if let Some(flag) = reserved_flag { return Err(format!( - "indexes are not dense 0..n-1 (found {index} at position {position})" + "Fastly deploy argument `{flag}` is reserved for the EdgeZero deployment lifecycle" )); } } - let assembled: String = ordered.iter().map(|&(_, value)| value).collect(); - - // 1. The bytes must be the generation the keys name, and a real envelope. - gc_verify_generation(generation, &assembled)?; + Ok(()) +} - // 2. ...and the writer, given those bytes, must produce EXACTLY these - // entries. This is what pins the split boundaries and the chunked-vs- - // direct threshold, so a set assembled by anything that does not - // reproduce our writer's output byte-for-byte is left alone. - let expected = prepare_fastly_config_entries(root, &assembled) - .map_err(|err| format!("this writer could not re-derive the generation ({err})"))?; - let Some(expected_chunks) = expected.get(..expected.len().saturating_sub(1)) else { - return Err("this writer produced no chunk entries for these bytes".to_owned()); - }; - if expected_chunks.is_empty() { - // The envelope fits directly, so the writer would never have chunked it: - // whatever these entries are, they are not ours. - return Err( - "these bytes fit the entry limit, so this writer would have stored them directly \ - rather than in chunks" - .to_owned(), - ); - } - if expected_chunks.len() != ordered.len() { +fn set_managed_deploy_value( + slot: &mut Option, + flag: &str, + value: &str, +) -> Result<(), String> { + if value.is_empty() { return Err(format!( - "this writer would split these bytes into {} chunk(s), not {}", - expected_chunks.len(), - ordered.len() + "Fastly deploy argument `{flag}` requires a non-empty value" )); } - for ((expected_key, expected_value), item) in - expected_chunks.iter().zip(group_in_index_order(group)) - { - if *expected_key != item.item_key { - return Err(format!( - "this writer would not have produced the key `{}`", - item.item_key - )); - } - if *expected_value != item.item_value { - return Err(format!( - "the stored value of `{}` is not the chunk this writer would have written at that \ - index", - item.item_key - )); - } - } - Ok(()) -} - -/// `group` sorted by chunk index, so it lines up with the writer's output order. -fn group_in_index_order<'item>(group: &[&'item ConfigStoreItem]) -> Vec<&'item ConfigStoreItem> { - let mut ordered: Vec<&ConfigStoreItem> = group.to_vec(); - ordered.sort_by_key(|item| { - item.item_key - .rsplit_once('.') - .and_then(|(_, index)| index.parse::().ok()) - .unwrap_or(usize::MAX) - }); - ordered -} - -/// Is this key a chunk key of ANY root? (`config gc` scans the whole store, so -/// it cannot scope to one root up front.) Validates the canonical shape. -fn chunk_key_generation_any(key: &str) -> Option { - // Split on the LAST infix, not the first: a chunk of a root that ITSELF - // contains the infix (a pointer parked at a chunk-shaped key with self-scoped - // chunks) has the infix twice, and its chunk suffix is after the LAST one. - // Splitting on the first would misread the doubly-nested chunk as a - // non-chunk, get it classified as an unclassifiable root, and abort the whole - // store's GC. For an ordinary single-infix key the root has no infix, so the - // last infix IS the first — this only changes the nested case. - let (root, _rest) = key.rsplit_once(CHUNK_KEY_INFIX)?; - chunk_key_generation(root, key) -} - -/// Drive the common sequential commit mechanics while leaving recovery policy -/// to the operation that owns the writes. -fn commit_entries_with_committer( - entries: &[(String, String)], - mut committer: F, -) -> Result -where - F: FnMut(&str, &str) -> Result<(), String>, -{ - let mut written_keys = Vec::with_capacity(entries.len()); - for (index, (key, value)) in entries.iter().enumerate() { - if let Err(error) = committer(key, value) { - return Err(EntryCommitFailure { - committed: written_keys, - error, - failed_key: key.clone(), - not_attempted: entries - .iter() - .skip(index.saturating_add(1)) - .map(|(remaining_key, _)| remaining_key.clone()) - .collect(), - total: entries.len(), - }); - } - written_keys.push(key.clone()); + if slot.is_some() { + return Err(format!( + "Fastly deploy argument `{flag}` may be provided only once" + )); } - Ok(written_keys.len()) + *slot = Some(value.to_owned()); + Ok(()) } -/// Commit config-push entries and retain its chunk-aware retry guidance. -fn push_entries_with_committer( - entries: &[(String, String)], - committer: F, -) -> Result -where - F: FnMut(&str, &str) -> Result<(), String>, -{ - commit_entries_with_committer(entries, committer).map_err(|failure| { - format!( - "fastly push failed at entry `{failed_key}` while committing {committed} of {total} entries.\n \ - The failed entry's outcome is UNKNOWN: Fastly may have committed it before the error \ - (a timeout can arrive after the write lands), including when it is the root pointer.\n \ - Recovery: re-run the SAME `config push`. It is idempotent -- chunk keys are content-addressed \ - and writes use `--upsert` -- so entries already written are rewritten harmlessly and any \ - missing ones are filled. Do NOT hand-delete the failed key.\n \ - Already written (a retry rewrites them): {already_written:?}\n \ - Failed: `{failed_key}` (outcome unknown) -- {error}\n \ - Not attempted: {not_attempted:?}", - failed_key = failure.failed_key, - committed = failure.committed.len(), - total = failure.total, - already_written = failure.committed, - error = failure.error, - not_attempted = failure.not_attempted, - ) - }) +fn detached_managed_deploy_value<'args>( + args: &'args [String], + index: usize, + flag: &str, +) -> Result<&'args str, String> { + let value = args + .get(index.saturating_add(1)) + .ok_or_else(|| format!("Fastly deploy argument `{flag}` requires a value"))?; + if value.is_empty() || value.starts_with('-') { + return Err(format!( + "Fastly deploy argument `{flag}` requires a non-empty value" + )); + } + Ok(value) } -/// 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, - ) +fn parse_release_managed_deploy_args(args: &[String]) -> Result { + scan_reserved_deploy_args(args)?; + let mut parsed = ReleaseManagedDeployArgs { + comment: None, + globals: Vec::new(), + }; + let mut index = 0; + while let Some(arg) = args.get(index) { + if arg == "--comment" { + let value = detached_managed_deploy_value(args, index, "--comment")?; + set_managed_deploy_value(&mut parsed.comment, "--comment", value)?; + index = index.saturating_add(2); + } else if let Some(value) = arg.strip_prefix("--comment=") { + set_managed_deploy_value(&mut parsed.comment, "--comment", value)?; + index = index.saturating_add(1); + } else if arg == "--package" + || arg == "-p" + || arg.starts_with("--package=") + || (arg.starts_with("-p") && arg.len() > 2) + { + return Err( + "Fastly deploy argument `--package/-p` is owned by the immutable application release" + .to_owned(), + ); + } else if MANAGED_DEPLOY_GLOBAL_BOOL_FLAGS.contains(&arg.as_str()) { + parsed.globals.push(arg.clone()); + index = index.saturating_add(1); + } else if let Some((flag, _)) = arg.split_once('=') + && MANAGED_DEPLOY_GLOBAL_BOOL_FLAGS.contains(&flag) + { + return Err(format!( + "Fastly boolean deploy argument `{flag}` does not accept a value" + )); + } else if arg.starts_with('-') { + return Err(format!("unsupported Fastly deploy argument {arg:?}")); + } else { + return Err(format!( + "unexpected positional Fastly deploy argument {arg:?}" + )); + } + } + Ok(parsed) +} + +/// Resolve the target service id from `--service-id` or, failing that, +/// `FASTLY_SERVICE_ID`. +fn resolve_service_id(args: &[String]) -> Result { + if let Some(value) = arg_value(args, "--service-id") { + return Ok(value.to_owned()); + } + env::var(FASTLY_SERVICE_ID_ENV).map_err(|_err| { + format!("no service id: pass `--service-id ` or set {FASTLY_SERVICE_ID_ENV}") }) } -/// Shell `fastly config-store-entry update --upsert --stdin` with -/// the value piped through stdin instead of `--value=` on -/// argv. -/// -/// Two reasons for this exact invocation: -/// -/// 1. `--upsert` (vs. the original `create` subcommand): the prior -/// `create` form errored on any key that already existed in the -/// config store, which made `config push` non-repeatable — -/// after the first push, every follow-up push triggered by a -/// config edit would fail at the first unchanged key. -/// `update --upsert` is documented as "insert or update", which -/// matches the convergent semantic the other config-push paths -/// already have (axum overwrites the JSON, cloudflare's -/// `wrangler kv bulk put` overwrites, spin's -/// `cloud key-value set` overwrites). -/// -/// 2. `--stdin` (vs. `--value=`): `--value=` exposed every -/// config entry's bytes in `ps`/`/proc//cmdline` listings -/// AND was bounded by the host's `ARG_MAX` (4 KiB to 256 KiB -/// depending on platform — easy to trip with a JSON blob). -/// `--stdin` reads the value from stdin instead — keeps value -/// bytes out of argv and lifts the size cap to whatever the OS -/// pipe buffer + the CLI's read accept (megabytes in practice). -fn create_config_store_entry(store_id: &str, key: &str, value: &str) -> Result<(), String> { - create_config_store_entry_with_cwd(store_id, key, value, None) +fn validate_effective_deploy_service_id(context: &AdapterDeployContext) -> Result<(), String> { + if let Some(service_id) = context.service_id.as_deref() { + return validate_service_id(service_id); + } + if let Some(manifest_path) = context.adapter_manifest_path.as_deref() { + return validate_deploy_service_id_for_manifest(context, manifest_path); + } + match env::var(FASTLY_SERVICE_ID_ENV) { + Ok(service_id) => validate_service_id(&service_id), + Err(env::VarError::NotPresent) => Ok(()), + Err(env::VarError::NotUnicode(_)) => Err(format!( + "invalid service id from {FASTLY_SERVICE_ID_ENV}: expected ASCII letters and digits only" + )), + } } -fn create_config_store_entry_in( - store_id: &str, - key: &str, - value: &str, - cwd: &Path, +fn validate_deploy_service_id_for_manifest( + context: &AdapterDeployContext, + manifest_path: &Path, ) -> Result<(), String> { - create_config_store_entry_with_cwd(store_id, key, value, Some(cwd)) + if let Some(service_id) = context.service_id.as_deref() { + return validate_service_id(service_id); + } + effective_fastly_service_id(manifest_path)?; + Ok(()) } -fn create_config_store_entry_with_cwd( - store_id: &str, +fn effective_deploy_environment(context: &AdapterDeployContext) -> Result { + let variables = merge_env_defaults(context.variable_defaults.iter(), env::vars()); + let environment = EnvConfig::from_vars(variables); + for (kind, logical_ids) in [ + (ResourceKind::Config, &context.stores.config), + (ResourceKind::Kv, &context.stores.kv), + (ResourceKind::Secret, &context.stores.secrets), + ] { + for logical_id in logical_ids { + environment + .store_name_checked(kind.runtime_name(), logical_id) + .map_err(|error| format!("invalid Fastly deploy environment: {error}"))?; + if kind == ResourceKind::Config { + environment + .store_key_checked(kind.runtime_name(), logical_id) + .map_err(|error| format!("invalid Fastly deploy environment: {error}"))?; + } + } + } + Ok(environment) +} + +fn validate_fastly_config_key( + logical_store_id: &str, key: &str, - value: &str, - cwd: Option<&Path>, + _staging: bool, + _local: bool, ) -> Result<(), String> { - let store_arg = format!("--store-id={store_id}"); - let key_arg = format!("--key={key}"); - let mut command = Command::new("fastly"); - command.args([ - "config-store-entry", - "update", - store_arg.as_str(), - key_arg.as_str(), - "--upsert", - "--stdin", - ]); - if let Some(command_cwd) = cwd { - command.current_dir(command_cwd); - } - let mut child = command - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .map_err(|err| { - if err.kind() == ErrorKind::NotFound { - format!("`fastly` not found on PATH; {FASTLY_INSTALL_HINT}") - } else { - format!("failed to spawn `fastly`: {err}") - } - })?; - // Take stdin OUT of the child and hand it to a helper that writes the value - // and drops the handle on return — closing the pipe so the CLI sees EOF. - // Dropping on scope-exit rather than via an explicit `drop()` keeps this - // valid on targets where `ChildStdin` is a non-Drop stub. - // `child.wait_with_output()` then consumes child cleanly. - let stdin = child - .stdin - .take() - .ok_or_else(|| "failed to open stdin pipe to `fastly`".to_owned())?; - write_value_to_fastly_stdin(stdin, value)?; - let output = child - .wait_with_output() - .map_err(|err| format!("failed to wait on `fastly`: {err}"))?; - if output.status.success() { - return Ok(()); + let expected = logical_store_id; + if key == expected { + Ok(()) + } else { + Err(format!( + "Fastly uses logical config key `{expected}` for every target; remove the conflicting --key or EDGEZERO__STORES__CONFIG__{}__KEY override and select the environment's physical store with __NAME", + logical_store_id.to_ascii_uppercase() + )) } - Err(format!( - "`fastly config-store-entry update --store-id={store_id} --key={key} --upsert --stdin` exited with status {}\nstderr: {}", - output.status, - redact_stderr(&String::from_utf8_lossy(&output.stderr)) - )) } -/// Write `value` to the child's stdin, then drop the handle as it falls out of -/// scope on return — closing the pipe so the `fastly` CLI sees EOF. Taking -/// `stdin` by value gives a natural scope-end drop rather than an explicit -/// `drop()`, which also keeps this valid on targets where `ChildStdin` is a -/// non-Drop stub. -fn write_value_to_fastly_stdin(mut stdin: ChildStdin, value: &str) -> Result<(), String> { - stdin - .write_all(value.as_bytes()) - .map_err(|err| format!("failed to write value to `fastly` stdin: {err}")) +/// Read the required Fastly API token from the environment. +fn require_token() -> Result { + env::var(FASTLY_API_TOKEN_ENV) + .map_err(|_err| format!("{FASTLY_API_TOKEN_ENV} must be set in the environment")) } -fn delete_config_store_entry(store_id: &str, key: &str) -> Result<(), String> { - let store_arg = format!("--store-id={store_id}"); - let key_arg = format!("--key={key}"); - let output = Command::new("fastly") - .args([ - "config-store-entry", - "delete", - store_arg.as_str(), - key_arg.as_str(), - "--auto-yes", - ]) - .output() - .map_err(|err| { - if err.kind() == ErrorKind::NotFound { - format!("`fastly` not found on PATH; {FASTLY_INSTALL_HINT}") - } else { - format!("failed to spawn `fastly`: {err}") - } - })?; - if output.status.success() { - return Ok(()); +/// Whether an HTTP status counts as healthy (2xx only). +/// +/// A passing probe gates against an automatic rollback, so a 3xx is deliberately +/// NOT healthy: a staged version answering `301` to an error page (the probe does +/// not follow redirects) would otherwise mask a bad deploy as healthy. +fn is_healthy_status(code: u16) -> bool { + (200..300).contains(&code) +} + +/// Digits immediately following `marker` in `lower` (a lowercased +/// haystack), for the LAST occurrence of `marker`. The number must be +/// terminated by `terminator` — so a partial/confusable match (e.g. a +/// semver `15.2.0`) yields `None` rather than a bogus version. +fn last_version_after(lower: &str, marker: &str, terminator: char) -> Option { + let mut result = None; + for (idx, _) in lower.match_indices(marker) { + let after = idx.saturating_add(marker.len()); + let Some(rest) = lower.get(after..) else { + continue; + }; + let digits: String = rest.chars().take_while(char::is_ascii_digit).collect(); + if digits.is_empty() || rest.chars().nth(digits.len()) != Some(terminator) { + continue; + } + if let Ok(parsed) = digits.parse::() { + result = Some(parsed); + } } - // EVERY non-zero delete is a failure -- no "already gone" special case. - // Pattern-matching stderr for "not found"/"404" cannot reliably tell "this - // key is already gone" from "the store does not exist", an auth failure, or - // a 500: messages like `config store abc does not exist while deleting key - // ` name the key AND say "does not exist". Reporting those as a - // successful reclamation is strictly worse than a retry, and a retry is - // free: `config gc` re-lists the store, so a key that really is gone simply - // will not appear as a candidate next run. - // Redact stderr: a Fastly error can quote the entry value back, which on the - // delete path would put a stored config value into CI logs. - let stderr = String::from_utf8_lossy(&output.stderr); - Err(format!( - "`fastly config-store-entry delete --store-id={store_id} --key={key} --auto-yes` exited with status {}\n{}", - output.status, - redact_stderr(&stderr) - )) + result +} + +/// Parse a Fastly service version out of Fastly CLI output, accepting +/// ONLY the shapes the CLI actually emits, in precedence order: +/// +/// 1. Our canonical `version=` contract line. +/// 2. The CLI's success line, whose Go format string is +/// `"Updated package (service %s, version %v)"` (and +/// `"Deployed package (...)"` for `compute deploy`) — matched as +/// `, version )`. This names the version the package landed on, +/// so it wins over (3). +/// 3. The `--autoclone` notice, `"... Now operating on version %d."` — +/// the freshly-cloned draft, used when the success line is absent. +/// +/// Everything else yields `None` and the caller FAILS CLOSED. +/// +/// Deliberately strict. The previous implementation took ANY digits +/// appearing after the word "version" and let the last match win, so: +/// * `Uploaded package to service 12345, version unchanged` parsed as +/// version 12345, and +/// * the autoclone notice's *pre-clone* version +/// (`Service version 3 is not editable...`) could beat the real one, +/// since stdout and stderr are concatenated and their relative order +/// is not guaranteed. +/// +/// A misparse here stages, comments, or rolls back the WRONG service +/// version, so ambiguity must be an error, not a guess. +fn parse_fastly_version(text: &str) -> Option { + let lower = text.to_ascii_lowercase(); + parse_canonical_version_line(&lower) + .or_else(|| last_version_after(&lower, ", version ", ')')) + .or_else(|| last_version_after(&lower, "now operating on version ", '.')) } -/// Read every `(key, value)` in config store `store_id` via -/// `fastly config-store-entry list --store-id= --json`. -/// -/// Accepts a bare array or an `{"items": [...]}` envelope, and reads each -/// entry's key/value from `item_key`/`item_value` (the field names -/// `config-store-entry describe` uses), falling back to `key`/`value`. A parse -/// failure is an error, NOT an empty list: a staged deploy mirrors this store, -/// and treating an unreadable listing as "no entries" would silently drop -/// production's overrides from the staged version. -fn read_config_store_entries(store_id: &str, cwd: &Path) -> Result, String> { - let stdout = run_fastly_capture( - &[ - "config-store-entry".to_owned(), - "list".to_owned(), - format!("--store-id={store_id}"), - "--json".to_owned(), - ], - cwd, - )?; - parse_config_store_entries(&stdout) +/// Last standalone `version=` line (the whole trimmed line must be +/// exactly that, so a `--version=active` flag echoed in a command line +/// cannot masquerade as one). +fn parse_canonical_version_line(lower: &str) -> Option { + lower.lines().rev().find_map(|line| { + let digits = line.trim().strip_prefix("version=")?; + (!digits.is_empty() && digits.chars().all(|ch| ch.is_ascii_digit())) + .then(|| digits.parse().ok()) + .flatten() + }) } -/// Parse the `config-store-entry list --json` payload into `(key, value)` pairs. +/// Parse `fastly service version list --json` (or the Fastly API +/// `/service//version` array) for the `number` of the `active` +/// version. +/// Resolve the active version from a Fastly version-list JSON. /// -/// Split out from the CLI call so it is unit-testable — and, critically, so every -/// error path REDACTS the payload. The listing carries every entry's `item_value`, -/// which may be production config or secrets, and CLI status lines are logged -/// verbatim into commonly-retained CI logs. So a schema-drift / parse error must -/// summarise the response (size + top-level shape via `redact_describe_response`), -/// never echo the raw stdout. -fn parse_config_store_entries(stdout: &str) -> Result, String> { - let parsed: serde_json::Value = serde_json::from_str(stdout).map_err(|err| { - format!( - "failed to parse `fastly config-store-entry list --json` JSON: {err} ({})", - redact_describe_response(stdout) - ) - })?; - let array = parsed - .as_array() - .or_else(|| parsed.get("items").and_then(serde_json::Value::as_array)) - .ok_or_else(|| { - format!( - "`fastly config-store-entry list --json` output is neither a bare array nor an `items` envelope ({}); fastly CLI may have changed its schema", - redact_describe_response(stdout) - ) - })?; - let mut entries = Vec::with_capacity(array.len()); - for entry in array { - let key = entry - .get("item_key") - .or_else(|| entry.get("key")) - .and_then(serde_json::Value::as_str); - let value = entry - .get("item_value") - .or_else(|| entry.get("value")) - .and_then(serde_json::Value::as_str); - match (key, value) { - (Some(found_key), Some(found_value)) => { - entries.push((found_key.to_owned(), found_value.to_owned())); - } - _ => { +/// `Ok(Some(n))` — exactly one version is active. `Ok(None)` — the list parsed +/// but NO version is active (a first-ever deploy; the caller records an empty +/// rollback target and proceeds). `Err(_)` — the payload could not be parsed as +/// a version list, OR it is MALFORMED (a selection/safety field is missing or +/// has the wrong type, or MORE THAN ONE active version exists). All are +/// OPERATIONAL failures the caller must +/// NOT silently treat as "no active version" — otherwise a garbled or ambiguous +/// response would fail open and let a production deploy proceed with no rollback +/// target. +/// +/// The ENTIRE list is scanned (not short-circuited at the first active entry) so +/// that a malformed field or a second active version anywhere in the response +/// is caught rather than ignored. +fn resolve_active_version(json: &str) -> Result, String> { + let versions = parse_service_versions(json)?; + let mut active_version: Option = None; + for version in versions { + if version.active { + if active_version.is_some() { return Err(format!( - "a `fastly config-store-entry list --json` entry has no string `item_key`/`item_value` fields ({}); fastly CLI may have changed its schema", - redact_describe_response(stdout) + "the Fastly version list reports more than one active version ({} and {}); the response is ambiguous, refusing to pick one", + active_version.unwrap_or_default(), + version.number, )); } + active_version = Some(version.number); } } - Ok(entries) + Ok(active_version) } -/// `fastly config-store-entry delete --store-id= --key=`, run in the -/// app manifest directory. Distinct from the `config gc` `delete_config_store_entry` -/// (which runs in the process cwd with redacted diagnostics); runtime-env -/// reconciliation must run `fastly` in `cwd` so it resolves the right service context. -fn delete_config_store_entry_in(store_id: &str, key: &str, cwd: &Path) -> Result<(), String> { - run_fastly_status( - &[ - "config-store-entry".to_owned(), - "delete".to_owned(), - format!("--store-id={store_id}"), - format!("--key={key}"), - ], - cwd, - ) +fn parse_service_versions(json: &str) -> Result, String> { + let versions: Vec = serde_json::from_str(json) + .map_err(|error| format!("failed to parse the Fastly version list as JSON: {error}"))?; + if versions.is_empty() { + return Err( + "the Fastly version list is empty; a service always has at least an initial version, so this response cannot be trusted".to_owned(), + ); + } + let mut numbers = HashSet::with_capacity(versions.len()); + for version in &versions { + if !numbers.insert(version.number) { + return Err(format!( + "the Fastly version list contains duplicate version number {}; refusing an ambiguous response", + version.number + )); + } + for environment in &version.environments { + if environment.name.is_empty() || environment.service_id.is_empty() { + return Err(format!( + "Fastly version {} contains an incomplete environment record", + version.number + )); + } + } + } + Ok(versions) } -/// 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 -/// `_staging`, the key `config push --staging` writes. A -/// declared store gets that selector even when production has no explicit entry -/// for it (production relies on the runtime's default = the logical id; staging -/// must NOT inherit that default, or it would read production's key). -/// -/// Pure so the transform is unit-testable without the fastly CLI. -fn staging_entries_from_production( - production: &[(String, String)], - service_id: &str, - config_logical_ids: &[String], -) -> Vec<(String, String)> { - 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 +fn select_version_source(versions: &[ServiceVersionRecord]) -> Result { + let active_versions = versions .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); + .filter(|version| version.active) + .collect::>(); + if active_versions.len() > 1 { + return Err("the Fastly version list reports more than one active version".to_owned()); + } + if let Some(active_version) = active_versions.first() { + return Ok(VersionSource::Active(active_version.number)); + } - // Copy only current-service production overrides. Legacy unscoped entries - // have no safe owner, and another service's namespace does not belong in - // this per-service staging twin. Selectors are supplied below whether or - // not production carried one. - let mut out: Vec<(String, String)> = production + let highest = versions .iter() - .filter(|(key, _)| key.starts_with(&service_prefix) && !is_selector(key)) - .cloned() - .collect(); - out.extend(selectors); - out -} - -/// Resolve the staging twin store, creating it on demand. A staged deploy owns -/// 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. -fn staging_selector_store_name(service_id: &str) -> String { - format!("{RUNTIME_ENV_STAGING_STORE_PREFIX}_{service_id}") -} - -fn ensure_staging_selector_store(store_name: &str, cwd: &Path) -> Result { - match classify_remote_config_store_in(store_name, cwd)? { - ConfigStoreLookup::Found(id) => Ok(id), - ConfigStoreLookup::NotFound => { - create_fastly_store_in("config", store_name, cwd)?; - // resolve_remote_config_store_id now yields a typed absence; we just - // created the store, so a None here is fail-closed (the listing did - // not reflect our own create), not a genuine absence. - resolve_remote_config_store_id_in(store_name, cwd) - .map_err(|err| { - format!( - "created fastly config-store `{store_name}` but could not resolve its id: {err}" - ) - })? - .ok_or_else(|| { - format!( - "created fastly config-store `{store_name}` but it did not appear in `config-store list`" - ) + .map(|version| version.number) + .max() + .ok_or_else(|| "the Fastly version list is empty".to_owned())?; + let drafts = versions + .iter() + .filter(|version| !version.active && !version.locked && version.environments.is_empty()) + .collect::>(); + let staging_versions = versions + .iter() + .filter(|version| { + !version.active + && version.environments.len() == 1 + && version.environments.iter().all(|environment| { + environment.name == "staging" && environment.active_version == version.number }) + }) + .collect::>(); + let retired = versions + .iter() + .filter(|version| !version.active && version.locked && version.environments.is_empty()) + .collect::>(); + match (drafts.as_slice(), staging_versions.as_slice()) { + ([draft], []) if draft.number == highest => { + return Ok(VersionSource::InitialDraft(draft.number)); } - ConfigStoreLookup::SchemaDrift(detail) => Err(format!( - "could not parse `fastly config-store list --json` while resolving `{store_name}`: {detail}.\n Refusing to stage. Pin a known-compatible fastly CLI version and retry." - )), + ([], [staging_version]) => return Ok(VersionSource::Staging(staging_version.number)), + _ => {} } -} - -/// Reconcile the staging twin so it mirrors the current service's production -/// overrides, with only its config selectors redirected to `_staging`. -/// -/// Upserts the full desired set FIRST, then deletes twin entries production no -/// longer has (so a removed override does not linger and diverge staging from -/// production). Runs while the staged draft is still editable, before the relink. -/// When production has NO override store, `production` is empty and the twin holds -/// only the derived staging selectors — staging is still isolated. -/// -/// Order matters: this per-service twin can still be LINKED by a previously-staged -/// version of the same service, which reads it live. Upserting every desired entry -/// before deleting any stale one means that reader never observes a required -/// selector transiently absent (which would fall it back to PRODUCTION config), and -/// a mid-reconciliation failure leaves the twin a superset — never a store missing a -/// selector. `--upsert` (see `create_config_store_entry`) makes the writes -/// idempotent, so re-running is safe. -/// -/// Residual limitation: two *concurrent* staged deploys of the SAME service still -/// race on this one twin. Serialize them with a per-service concurrency group in -/// the calling workflow (see the deploy guide's reconcile section); a shared store -/// cannot make that race safe on its own. -fn mirror_production_to_staging( - production: &[(String, String)], - staging_id: &str, - service_id: &str, - config_logical_ids: &[String], - cwd: &Path, -) -> Result<(), String> { - let desired = staging_entries_from_production(production, service_id, config_logical_ids); - - for (key, value) in &desired { - create_config_store_entry_in(staging_id, key, value, cwd)?; + if drafts.is_empty() + && staging_versions.is_empty() + && let Some(retired_version) = retired.iter().find(|version| version.number == highest) + { + return Ok(VersionSource::Retired(retired_version.number)); } - let current = read_config_store_entries(staging_id, cwd)?; - for (key, _) in ¤t { - if !desired.iter().any(|(dk, _)| dk == key) { - delete_config_store_entry_in(staging_id, key, cwd)?; - } + if drafts.len() == 1 && staging_versions.len() <= 1 { + return Err(format!( + "first deployment requires the highest service version ({highest}) to be the initialized editable draft" + )); } - Ok(()) -} - -fn canonical_runtime_store_name_key(kind: &str, logical: &str) -> String { - format!( - "EDGEZERO__STORES__{kind}__{}__NAME", - logical.to_ascii_uppercase() - ) -} - -fn runtime_store_name_key(service_id: &str, kind: &str, logical: &str) -> String { - service_scoped_runtime_env_key(service_id, &canonical_runtime_store_name_key(kind, logical)) -} - -fn has_declared_stores(stores: &ProvisionStores<'_>) -> bool { - !stores.config.is_empty() || !stores.kv.is_empty() || !stores.secrets.is_empty() -} - -fn has_non_default_store_name_mappings(stores: &ProvisionStores<'_>) -> bool { - [stores.config, stores.kv, stores.secrets] - .into_iter() - .flatten() - .any(|store| store.logical != store.platform) -} - -/// Return the service-scoped runtime entries required when logical store ids -/// map to different Fastly resource names. -fn runtime_env_store_name_entries( - stores: &ProvisionStores<'_>, - service_id: &str, -) -> Vec<(String, String)> { - let mut entries = Vec::new(); - for (kind, ids) in [ - ("CONFIG", stores.config), - ("KV", stores.kv), - ("SECRETS", stores.secrets), - ] { - for store in ids { - if store.logical == store.platform { - continue; - } - entries.push(( - runtime_store_name_key(service_id, kind, &store.logical), - store.platform.clone(), - )); - } + if staging_versions.len() > 1 || drafts.len() > 1 { + return Err(format!( + "deployment without an active version requires one highest editable draft, staged source, or retired source; found {} drafts, {} staged versions, and {} retired versions", + drafts.len(), + staging_versions.len(), + retired.len() + )); } - entries + Err(format!( + "deployment without an active version requires one highest editable draft, staged source, or retired source; found {} drafts, {} staged versions, and {} retired versions", + drafts.len(), + staging_versions.len(), + retired.len() + )) } -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)), - ); +/// Best-effort staleness guard for a production rollback: the version being +/// rolled back FROM (`from_version`, the caller's `--version`) must still be the +/// ACTIVE version. A rollback can run long after its deploy; if a newer version +/// was activated since, activating the old target would clobber it — so refuse. +/// +/// This narrows but does NOT close the race: the caller reads the active version +/// and activates in two separate requests, and Fastly's activate endpoint has no +/// precondition, so a deploy landing between them can still be clobbered. +/// Service-scoped serialization is required to eliminate it. +fn ensure_rollback_from_is_active( + active: Option, + from_version: u64, + service_id: &str, +) -> Result<(), String> { + match active { + Some(active_version) if active_version == from_version => Ok(()), + Some(active_version) => Err(format!( + "refusing to roll back service {service_id}: the active version is now {active_version}, not the {from_version} being rolled back from -- a newer deploy is live and rolling back would clobber it" + )), + None => Err(format!( + "refusing to roll back service {service_id}: it has no active version" + )), } - 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<'_>, +fn staging_rollback_decision( + versions: &[ServiceVersionRecord], + requested_version: u64, 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 +) -> Result { + let staging_records = versions .iter() - .filter(|(key, value)| { - current + .flat_map(|version| { + version + .environments .iter() - .find(|(current_key, _)| current_key == key) - .is_none_or(|(_, current_value)| current_value != value) + .filter(|environment| environment.name == "staging") + .map(move |environment| (version.number, environment)) }) - .cloned() .collect::>(); - let mut deletes = current + if staging_records.len() > 1 { + return Err(format!( + "Fastly service {service_id} reports more than one staging environment record; refusing staging rollback" + )); + } + let version = versions .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 } + .find(|candidate| candidate.number == requested_version) + .ok_or_else(|| { + format!( + "Fastly version {requested_version} is absent from service {service_id}; refusing staging rollback" + ) + })?; + let is_staging_version = matches!( + staging_records.as_slice(), + [(record_version, environment)] + if *record_version == requested_version + && environment.active_version == requested_version + ); + if is_staging_version { + return Ok(StagingRollbackDecision::Deactivate); + } + let unpublished_draft = !version.active && !version.locked && version.environments.is_empty(); + if unpublished_draft { + return Ok(StagingRollbackDecision::NoopDraft); + } + Err(format!( + "Fastly version {requested_version} is neither the exact staged version nor an unpublished editable draft; refusing staging rollback" + )) } -fn persist_runtime_env_store_name_entries( - stores: &ProvisionStores<'_>, - service_id_hint: Option<&str>, - dry_run: bool, - cwd: &Path, -) -> Result, String> { - if !has_declared_stores(stores) { - return Ok(Vec::new()); +/// Staging IP for one exact domain in a Fastly +/// `GET /service//version//domain?include=staging_ips` response. +/// +/// The response is an ARRAY of domain objects, and the staging address +/// is a SINGULAR, nullable STRING field named `staging_ip` on each +/// domain (`staging_ips` is only the `include=` query-param value, never +/// a field name). Verified against the go-fastly `Domain` model, whose +/// field is `StagingIP` with the mapstructure tag `staging_ip`, and its +/// recorded API fixture `fixtures/domains/list_with_staging_ips.yaml`, +/// plus Fastly's "working with staging" guide. +fn parse_staging_ip(json: &str, domain: &str) -> Result { + #[derive(serde::Deserialize)] + struct DomainRecord { + name: String, + staging_ip: Option, } - let Some(service_id) = service_id_hint else { - if has_non_default_store_name_mappings(stores) { - return Err(format!( - "cannot persist non-default Fastly store-name mappings without top-level `service_id` or {FASTLY_SERVICE_ID_ENV}" - )); - } - return Ok(vec![ - "no Fastly service id and no non-default store-name mappings; skipping runtime-env reconciliation" - .to_owned(), - ]); - }; - let entries = runtime_env_store_name_entries(stores, service_id); - let declared = runtime_env_store_name_keys(stores, service_id); - if dry_run { - let mut out = entries - .iter() - .map(|(key, value)| { - format!( - "would upsert `{key}={value}` into fastly config-store `{RUNTIME_ENV_STORE_NAME}`" - ) - }) - .collect::>(); - out.extend( - declared - .iter() - .filter(|key| !entries.iter().any(|(entry_key, _)| entry_key == *key)) - .map(|key| { - format!( - "would remove `{key}` from fastly config-store `{RUNTIME_ENV_STORE_NAME}` if a stale mapping is present" - ) - }), - ); - return Ok(out); + + let records: Vec = serde_json::from_str(json).map_err(|error| { + format!( + "Fastly staging domain inventory has an invalid response shape (payload redacted): {error}" + ) + })?; + let mut matches = records.iter().filter(|record| record.name == domain); + let record = matches.next().ok_or_else(|| { + format!("Fastly staging domain inventory does not contain domain `{domain}`") + })?; + if matches.next().is_some() { + return Err(format!( + "Fastly staging domain inventory contains duplicate domain `{domain}`" + )); + } + record + .staging_ip + .as_deref() + .filter(|ip| !ip.is_empty()) + .map(str::to_owned) + .ok_or_else(|| format!("Fastly staging domain `{domain}` has no staging IP")) +} + +/// Build the `curl` argv for a health probe. Production probes the +/// domain directly; staging reroutes the TLS connection to the +/// resolved staging IP via `--connect-to :::443`. `path` is the +/// URL path (always begins with '/'), applied identically to both. +fn build_curl_probe_args( + domain: &str, + path: &str, + staging_ip: Option<&str>, + timeout_secs: u64, +) -> Vec { + let mut args = vec![ + // `-q` first so curl never merges `~/.curlrc` into a probe (a planted + // `proxy`/`output` there could otherwise redirect or corrupt the check). + "-q".to_owned(), + "-sS".to_owned(), + // Disable curl's URL globbing: a valid probe path may contain `[` `]` `{` + // `}` (e.g. `/health?ids[0]=1`), which curl would otherwise treat as a + // glob — failing with exit 3 or firing multiple requests, and so + // mis-reporting a healthy deployment as unhealthy. + "--globoff".to_owned(), + "-o".to_owned(), + "/dev/null".to_owned(), + "-w".to_owned(), + "%{http_code}".to_owned(), + "--max-time".to_owned(), + timeout_secs.to_string(), + ]; + if let Some(ip) = staging_ip { + // `--connect-to ::HOST:PORT` reroutes the TLS connection to the staging + // IP. An IPv6 literal must be bracketed or curl mis-parses the colons; + // the caller has already validated `ip` parses as an `IpAddr`. + let target = if ip.contains(':') { + format!("::[{ip}]:443") + } else { + format!("::{ip}:443") + }; + args.push("--connect-to".to_owned()); + args.push(target); } + args.push(format!("https://{domain}{path}")); + args +} - let Some(runtime_env_store_id) = - resolve_remote_config_store_id_in(RUNTIME_ENV_STORE_NAME, cwd)? - else { - if entries.is_empty() { - return Ok(vec![format!( - "fastly config-store `{RUNTIME_ENV_STORE_NAME}` not found; no non-default store-name mappings to write for service `{service_id}`, skipping reconciliation" - )]); - } +/// Validate a caller-supplied probe path. It is appended to +/// `https://{domain}` to form one curl argument, so it must begin with +/// '/' and carry no whitespace or control characters that would break +/// the URL or smuggle a second token. +fn validate_probe_path(path: &str) -> Result<(), String> { + if !path.starts_with('/') { + return Err(format!("healthcheck --path must begin with '/': '{path}'")); + } + if path.chars().any(|ch| ch.is_whitespace() || ch.is_control()) { 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" + "healthcheck --path must not contain whitespace or control characters: '{path}'" )); - }; - let current = read_config_store_entries(&runtime_env_store_id, cwd)?; - let reconciliation = runtime_store_name_reconciliation(stores, service_id, ¤t); - if reconciliation.upserts.is_empty() && reconciliation.deletes.is_empty() { - return Ok(Vec::new()); - } - - push_runtime_store_name_entries_with_committer(&reconciliation.upserts, |key, value| { - create_config_store_entry_in(&runtime_env_store_id, key, value, cwd) - })?; - for key in &reconciliation.deletes { - delete_config_store_entry_in(&runtime_env_store_id, key, cwd).map_err(|error| { - format!( - "fastly provision failed while deleting stale runtime store-name mapping `{key}`.\n \ - The delete's outcome is UNKNOWN: Fastly may have committed it before the error, \ - and earlier mapping upserts may already have committed.\n \ - Recovery: re-run the SAME `edgezero provision --adapter fastly` command with the same \ - `EDGEZERO__STORES__*__NAME` environment. Reconciliation rereads the current store and \ - is idempotent, so it will safely finish any remaining work.\n \ - Failed: `{key}` (outcome unknown) -- {error}" - ) - })?; } - Ok(vec![format!( - "reconciled store-name mappings for service `{service_id}` in fastly config-store `{RUNTIME_ENV_STORE_NAME}`: upserted {}, removed {} stale mapping(s)", - reconciliation.upserts.len(), - reconciliation.deletes.len() - )]) -} - -fn canonical_runtime_env_key_for(logical_id: &str) -> String { - format!( - "EDGEZERO__STORES__CONFIG__{}__KEY", - logical_id.to_ascii_uppercase() - ) -} - -/// The service-scoped runtime-override entry naming the config-store key for a -/// logical store. The runtime converts this stored key back to canonical -/// `EDGEZERO__STORES__CONFIG____KEY` before building `EnvConfig`. -fn runtime_env_key_for(service_id: &str, logical_id: &str) -> String { - service_scoped_runtime_env_key(service_id, &canonical_runtime_env_key_for(logical_id)) -} - -/// Find the id of the resource link published under `link_name` in -/// `fastly resource-link list --json` output. -/// -/// The link's own `name` is an alias that defaults to the linked resource's -/// name, so match on it rather than the resource name — the whole point of the -/// staging relink is that a store named `edgezero_runtime_env_staging` is linked -/// under the name `edgezero_runtime_env`. -/// -/// Returns `None` when the version has no such link (nothing to delete). -fn find_resource_link_id(stdout: &str, link_name: &str) -> Option { - let parsed: serde_json::Value = serde_json::from_str(stdout).ok()?; - let array = parsed - .as_array() - .or_else(|| parsed.get("items").and_then(serde_json::Value::as_array))?; - array.iter().find_map(|entry| { - let name = entry.get("name").and_then(serde_json::Value::as_str)?; - if name != link_name { - return None; - } - entry - .get("id") - .and_then(serde_json::Value::as_str) - .map(str::to_owned) - }) + Ok(()) } -/// Parse `fastly config-store list --json` output and return the -/// platform `id` of the store whose `name` matches `name`. Accepts -/// both a bare array (`[ {"id": "...", "name": "..."}, ... ]`) -/// and an `{"items": [...]}` envelope so this stays compatible -/// across fastly CLI versions. -fn find_config_store_id(stdout: &str, name: &str) -> ConfigStoreLookup { - let parsed: serde_json::Value = match serde_json::from_str(stdout) { - Ok(value) => value, - Err(err) => { - return ConfigStoreLookup::SchemaDrift(format!("stdout did not parse as JSON: {err}")); - } - }; - let Some(array) = parsed - .as_array() - .or_else(|| parsed.get("items").and_then(serde_json::Value::as_array)) - else { - return ConfigStoreLookup::SchemaDrift(format!( - "expected a bare array `[...]` or an `{{\"items\": [...]}}` envelope; got JSON of shape `{}`", - shape_summary(&parsed) - )); - }; - // FAIL CLOSED on any malformed or duplicate row: a `NotFound` here becomes a - // MissingStore that AUTHORISES an overwrite, so a listing we cannot read - // exactly must never look like a definite absence. A malformed row could BE - // the requested store (its unreadable `name` might have matched), and a - // duplicate name means we are not reading the store we think we are. Every row - // must carry a non-empty string `name` and `id`, and names must be unique. - let mut seen_names = HashSet::with_capacity(array.len()); - let mut found: Option = None; - for (idx, entry) in array.iter().enumerate() { - let name_field = entry - .get("name") - .and_then(serde_json::Value::as_str) - .filter(|value| !value.is_empty()); - let id_field = entry - .get("id") - .and_then(serde_json::Value::as_str) - .filter(|value| !value.is_empty()); - let (Some(entry_name), Some(entry_id)) = (name_field, id_field) else { - return ConfigStoreLookup::SchemaDrift(format!( - "store-list entry #{idx} is missing a non-empty string `name` or `id`; refusing to \ - treat a store as absent on a listing this build cannot read exactly" - )); - }; - if !seen_names.insert(entry_name.to_owned()) { - return ConfigStoreLookup::SchemaDrift(format!( - "store-list has a duplicate `name` (`{entry_name}`); refusing to resolve a store id \ - on an ambiguous listing" - )); +/// Retry a health probe. Returns `Ok(code)` on the first healthy +/// status, or `Err((last_code, message))` after exhausting attempts. +/// `between` runs between attempts (not after the last) so it can be a +/// no-op in tests. +fn probe_with_retries( + retry: u32, + mut prober: P, + mut between: S, +) -> Result, String)> +where + P: FnMut() -> Result, + S: FnMut(), +{ + let attempts = retry.max(1); + let mut last_code = None; + let mut last_msg = "no probe attempts were made".to_owned(); + for attempt in 0..attempts { + match prober() { + Ok(code) if is_healthy_status(code) => return Ok(code), + Ok(code) => { + last_code = Some(code); + last_msg = format!("unhealthy HTTP status {code}"); + } + Err(err) => last_msg = err, } - if entry_name == name { - found = Some(entry_id.to_owned()); + if attempt.saturating_add(1) < attempts { + between(); } } - found.map_or(ConfigStoreLookup::NotFound, ConfigStoreLookup::Found) + Err((last_code, last_msg)) } -/// Summarise a `fastly ... describe` response for diagnostics WITHOUT -/// leaking its contents. -/// -/// The response body is the stored config value. App config may hold -/// credentials, internal endpoints, or security policy, and this adapter -/// performs no secret stripping — while CLI status lines are logged -/// verbatim and CI logs are commonly retained and shared. So a schema-drift -/// diagnostic must never echo the payload: report only its size and its -/// top-level *shape* (field names for an object, type otherwise), never a -/// value. -fn redact_describe_response(stdout: &str) -> String { - let len = stdout.len(); - serde_json::from_str::(stdout).map_or_else( - |_err| format!("{len} bytes, not valid JSON"), - |value| match value { - serde_json::Value::Object(map) => { - // Object KEYS are stored/provider-controlled data (a wrong-shape - // response could be `{"": ...}`), so only the COUNT is - // reported, never the key names. - format!("{len} bytes, JSON object with {} field(s)", map.len()) - } - other @ (serde_json::Value::Null - | serde_json::Value::Bool(_) - | serde_json::Value::Number(_) - | serde_json::Value::String(_) - | serde_json::Value::Array(_)) => { - format!("{len} bytes, JSON {}", shape_summary(&other)) +/// Run `fastly ` in `cwd`, inheriting stdio, and map a non-zero +/// exit to an error. +fn run_fastly_status(fastly_args: &[String], cwd: &Path) -> Result<(), String> { + let status = Command::new("fastly") + .args(fastly_args) + .current_dir(cwd) + .status() + .map_err(|err| { + if err.kind() == ErrorKind::NotFound { + format!("`fastly` not found on PATH; {FASTLY_INSTALL_HINT}") + } else { + format!("failed to run fastly CLI: {err}") } - }, - ) -} - -/// Summarise a failing `fastly` invocation's stderr WITHOUT echoing it. -/// -/// The `describe` and `update --stdin` paths carry the stored config value, so -/// a Fastly error that quotes the payload back would put credentials straight -/// into CI logs — the same exposure as the stdout leak, via the failure branch. -/// Not-found *classification* still inspects stderr internally; only the -/// user-facing string is redacted. -fn redact_stderr(stderr: &str) -> String { - let len = stderr.trim().len(); - format!( - "{len} bytes suppressed (may echo the stored config value); re-run the `fastly` command directly to inspect it" - ) -} - -/// One-line type label for a `serde_json::Value` (for diagnostic -/// error messages — not a canonical JSON-schema description). -fn shape_summary(value: &serde_json::Value) -> &'static str { - match value { - serde_json::Value::Null => "null", - serde_json::Value::Bool(_) => "bool", - serde_json::Value::Number(_) => "number", - serde_json::Value::String(_) => "string", - serde_json::Value::Array(_) => "array", - serde_json::Value::Object(_) => "object", + })?; + if status.success() { + Ok(()) + } else { + Err(format!( + "`fastly {}` exited with status {status}", + fastly_args.join(" ") + )) } } -/// Resolve the platform config-store id on demand: shell out to -/// `fastly config-store list --json`, parse the JSON, match by -/// `name`. The provision flow doesn't persist this id, so push -/// has to re-fetch every time. +/// Run `fastly ` in `cwd` capturing stdout+stderr (combined) for +/// version parsing. Errors on a non-zero exit. +/// Run `curl -q -sS --config -`, piping `config` (which carries the +/// `Fastly-Key` header + url) through stdin so the token never touches +/// argv. Returns stdout on a zero exit. /// -/// Returns a TYPED absence: `Ok(None)` ONLY when the list call SUCCEEDS and no -/// store matches (a genuine absence). An operational failure (missing binary, -/// spawn/list failure, schema drift) stays `Err` -- callers that read for a diff -/// must not treat an operational failure as "store absent" and overwrite. -fn resolve_remote_config_store_id(name: &str) -> Result, String> { - resolve_remote_config_store_id_with_cwd(name, None) -} - -fn resolve_remote_config_store_id_in(name: &str, cwd: &Path) -> Result, String> { - resolve_remote_config_store_id_with_cwd(name, Some(cwd)) -} - -fn resolve_remote_config_store_id_with_cwd( - name: &str, - cwd: Option<&Path>, -) -> Result, String> { - let lookup = if let Some(command_cwd) = cwd { - classify_remote_config_store_in(name, command_cwd)? +/// `-q` MUST be the first argument: without it curl reads `~/.curlrc` +/// (or `$CURL_HOME/.curlrc`) and merges it into this token-bearing +/// config, so a `proxy = …` directive planted by an earlier same-job +/// build step could exfiltrate the `Fastly-Key` header. `--connect-timeout` +/// / `--max-time` bound the call. +fn curl_config_capture(config: &str) -> Result { + let connect_timeout = FASTLY_API_CONNECT_TIMEOUT_SECS.to_string(); + let max_time = FASTLY_API_MAX_TIME_SECS.to_string(); + let mut child = Command::new("curl") + .args([ + "-q", + "-sS", + "--connect-timeout", + &connect_timeout, + "--max-time", + &max_time, + "--config", + "-", + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|err| { + if err.kind() == ErrorKind::NotFound { + "`curl` not found on PATH; install curl and retry".to_owned() + } else { + format!("failed to spawn `curl`: {err}") + } + })?; + // Take stdin OUT of the child and hand it to a helper BY VALUE, so it drops at + // that helper's scope end — a natural drop rather than an explicit `drop(stdin)`, + // which trips `clippy::drop_non_drop` on wasm targets where `ChildStdin` is not + // `Drop`. The drop must precede `wait_with_output` so curl sees EOF (same pattern + // as `write_value_to_fastly_stdin` on the fastly path). + let stdin = child + .stdin + .take() + .ok_or_else(|| "failed to open stdin pipe to `curl`".to_owned())?; + write_config_to_curl_stdin(stdin, config)?; + let output = child + .wait_with_output() + .map_err(|err| format!("failed to wait on `curl`: {err}"))?; + if output.status.success() { + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) + } else if output.status.code() == Some(CURL_EXIT_TIMEOUT) { + Err(format!( + "`curl` timed out after connect-timeout {FASTLY_API_CONNECT_TIMEOUT_SECS}s / max-time {FASTLY_API_MAX_TIME_SECS}s: {}", + String::from_utf8_lossy(&output.stderr).trim() + )) } else { - classify_remote_config_store(name)? - }; - match lookup { - ConfigStoreLookup::Found(id) => Ok(Some(id)), - ConfigStoreLookup::NotFound => Ok(None), - ConfigStoreLookup::SchemaDrift(detail) => Err(format!( - "could not parse `fastly config-store list --json` output: {detail}.\n The fastly CLI may have changed its JSON schema in a recent version. Please file a bug report at https://github.com/stackpop/edgezero/issues with the fastly CLI version (`fastly version`) and the raw stdout. Workaround: pin to a known-compatible fastly CLI version." - )), + Err(format!( + "`curl` exited with status {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + )) } } -/// 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)`. -fn classify_remote_config_store(name: &str) -> Result { - classify_remote_config_store_with_cwd(name, None) -} - -fn classify_remote_config_store_in(name: &str, cwd: &Path) -> Result { - classify_remote_config_store_with_cwd(name, Some(cwd)) +/// Write `config` to curl's stdin, taking the handle BY VALUE so it drops at this +/// function's scope end. That natural drop closes the pipe (curl sees EOF) without +/// an explicit `drop(stdin)`, which trips `clippy::drop_non_drop` on wasm targets +/// where `ChildStdin` is not `Drop` (mirrors `write_value_to_fastly_stdin`). +fn write_config_to_curl_stdin(mut stdin: ChildStdin, config: &str) -> Result<(), String> { + stdin + .write_all(config.as_bytes()) + .map_err(|err| format!("failed to write curl config to stdin: {err}")) } -fn classify_remote_config_store_with_cwd( - name: &str, - cwd: Option<&Path>, -) -> Result { - let mut command = Command::new("fastly"); - command.args(["config-store", "list", "--json"]); - if let Some(command_cwd) = cwd { - command.current_dir(command_cwd); - } - let output = command.output().map_err(|err| { - if err.kind() == ErrorKind::NotFound { - format!("`fastly` not found on PATH; {FASTLY_INSTALL_HINT}") - } else { - format!("failed to spawn `fastly`: {err}") +/// Wrap `value` in a curl-config double-quoted string, escaping the +/// characters that would otherwise let a value terminate its quote and +/// inject additional curl options. Within a curl `--config` file a +/// double-quoted value only honours the escapes `\\`, `\"`, `\n`, `\r`, +/// `\t` (and the config is parsed line-by-line, so a raw newline ends +/// the directive regardless of quoting). We escape backslash and quote +/// so the value cannot break out of the quotes, and map raw control +/// characters to their escape form so NO raw newline (or CR/tab) is +/// ever written into the config file. This is the second half of the +/// injection defence: untrusted identifiers are also validated (see +/// `validate_service_id` / `validate_version_str` / `validate_domain`), +/// but the token is a secret we cannot constrain to a charset, so it +/// relies on this escaping alone. +fn curl_quote(value: &str) -> String { + let mut out = String::with_capacity(value.len().saturating_add(2)); + out.push('"'); + for ch in value.chars() { + match ch { + '\\' => out.push_str("\\\\"), + '"' => out.push_str("\\\""), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + other => out.push(other), } - })?; - if !output.status.success() { - return Err(format!( - "`fastly config-store list --json` exited with status {}\nstderr: {}", - output.status, - String::from_utf8_lossy(&output.stderr).trim() - )); } - // Adopt main's strict UTF-8 gate (fail closed on undecodable stdout) but - // return the raw lookup so staging callers keep the 3-way verdict; the - // SchemaDrift -> Err mapping lives in resolve_remote_config_store_id. - let stdout = strict_stdout(output.stdout, "config-store list --json")?; - Ok(find_config_store_id(&stdout, name)) -} - -/// Message for a genuinely-absent store, for the write/GC callers that treat -/// absence as a hard error (they cannot operate on a store that does not exist). -fn no_matching_store_error(name: &str) -> String { - format!( - "no fastly config-store matches `{name}` (did you run `edgezero provision --adapter fastly`?)" - ) + out.push('"'); + out } -/// # Errors -/// Returns an error if the Fastly CLI build command fails. -#[inline] -pub fn build(extra_args: &[String]) -> Result { - let manifest = - find_fastly_manifest(env::current_dir().map_err(|err| err.to_string())?.as_path())?; - let manifest_dir = manifest - .parent() - .ok_or_else(|| "fastly manifest has no parent directory".to_owned())?; - let cargo_manifest = manifest_dir.join("Cargo.toml"); - let crate_name = read_package_name(&cargo_manifest)?; - - let status = Command::new("cargo") - .args([ - "build", - "--release", - "--target", - "wasm32-wasip1", - "--manifest-path", - cargo_manifest - .to_str() - .ok_or("invalid Cargo manifest path")?, - ]) - .args(extra_args) - .status() - .map_err(|err| format!("failed to run cargo build: {err}"))?; - if !status.success() { - return Err(format!("cargo build failed with status {status}")); +/// Validate an operator-supplied Fastly service id before it is +/// interpolated into an API URL. Fastly service ids contain only ASCII +/// letters and digits. +/// Values carrying a quote, newline, or space could inject curl options via +/// the `--config` file. +fn validate_service_id(id: &str) -> Result<(), String> { + if !id.is_empty() && id.chars().all(|ch| ch.is_ascii_alphanumeric()) { + Ok(()) + } else { + Err(format!( + "invalid service id {id:?}: expected ASCII letters and digits only" + )) } - - let workspace_root = find_workspace_root(manifest_dir); - let artifact = locate_artifact(&workspace_root, manifest_dir, &crate_name)?; - let pkg_dir = workspace_root.join("pkg"); - fs::create_dir_all(&pkg_dir) - .map_err(|err| format!("failed to create {}: {err}", pkg_dir.display()))?; - let dest = pkg_dir.join(format!("{}.wasm", crate_name.replace('-', "_"))); - fs::copy(&artifact, &dest) - .map_err(|err| format!("failed to copy artifact to {}: {err}", dest.display()))?; - - Ok(dest) } -/// Whether `args` already carries the Fastly CLI's non-interactive -/// switch, in either its long (`--non-interactive`) or short (`-i`) -/// form. Used to avoid passing the flag twice when a caller already -/// supplied it via `deploy-args` passthrough. -fn has_non_interactive(args: &[String]) -> bool { - args.iter() - .any(|arg| arg == "--non-interactive" || arg == "-i") +/// Validate a service-version string is a plain non-negative integer +/// before it is interpolated into an API URL. Returns the parsed value +/// so callers can reuse it. +fn validate_version_str(version: &str) -> Result { + version.parse::().map_err(|err| { + format!("invalid version {version:?}: expected a non-negative integer: {err}") + }) } -/// Build the argv for `fastly compute deploy`, appending -/// `--non-interactive` (a Fastly CLI *global* flag, supported by -/// `compute deploy`) unless the caller already passed it. Without it a -/// production deploy can block on an interactive prompt in CI. -fn build_compute_deploy_args(extra_args: &[String]) -> Vec { - let mut argv = vec!["compute".to_owned(), "deploy".to_owned()]; - argv.extend_from_slice(extra_args); - if !has_non_interactive(extra_args) { - argv.push("--non-interactive".to_owned()); +/// Validate a domain is a plausible hostname before it is placed into a +/// `curl` URL. Rejects anything outside the DNS label charset +/// (`[A-Za-z0-9-.]`), empty / over-long values, leading/trailing dots, +/// and empty labels so an injected quote / slash / space / newline +/// cannot smuggle curl options or a second URL. +fn validate_domain(domain: &str) -> Result<(), String> { + let charset_ok = domain + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '.'); + let shape_ok = !domain.is_empty() + && domain.len() <= 253 + && !domain.starts_with('.') + && !domain.ends_with('.') + && !domain.contains(".."); + if charset_ok && shape_ok { + Ok(()) + } else { + Err(format!( + "invalid domain {domain:?}: expected a hostname like `example.com`" + )) } - argv } -/// # Errors -/// Returns an error if the Fastly CLI deploy command fails. +/// `GET https://api.fastly.com` with the `Fastly-Key` header; +/// returns the response body ONLY on a 2xx status. Both the header (carrying the +/// secret token) and the URL are written through `curl_quote` so neither can +/// inject curl options into the `--config` document. /// -/// 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. -#[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"); +/// The HTTP status is captured explicitly via `write-out` (as the PUT helper +/// does) and required to be 2xx before the body is trusted. `--fail` alone would +/// reject 4xx/5xx but still accept a 3xx — whose (array-shaped) body could +/// otherwise be parsed as version data. No `location` directive is set, so a +/// redirect is never followed. +fn fastly_api_get(path: &str, token: &str) -> Result { + let header = curl_quote(&format!("Fastly-Key: {token}")); + let url = curl_quote(&format!("https://api.fastly.com{path}")); + // `write-out` appends the status on its own trailing line AFTER the body. + let config = format!("header = {header}\nurl = {url}\nwrite-out = \"\\n%{{http_code}}\"\n"); + let out = curl_config_capture(&config) + .map_err(|err| format!("Fastly API GET {path} failed: {err}"))?; + let (body, status_line) = out + .rsplit_once('\n') + .ok_or_else(|| format!("Fastly API GET {path}: no HTTP status in the curl output"))?; + let status: u16 = status_line.trim().parse().map_err(|err| { + format!( + "Fastly API GET {path}: could not parse the HTTP status {:?}: {err}", + status_line.trim() + ) + })?; + if !(200..300).contains(&status) { + return Err(format!("Fastly API GET {path} returned HTTP {status}")); + } + Ok(body.to_owned()) +} - let status = Command::new("fastly") - .args(build_compute_deploy_args(&forwarded)) - .current_dir(&manifest_dir) - .status() - .map_err(|err| format!("failed to run fastly CLI: {err}"))?; - if !status.success() { - return Err(format!("fastly compute deploy failed with status {status}")); +/// `PUT https://api.fastly.com` with the `Fastly-Key` header; +/// returns the HTTP status, erroring on non-2xx. Fastly's version +/// activate/deactivate endpoints require `PUT` (not `POST`). Header and +/// URL are escaped via `curl_quote`; the literal `request`, `output`, +/// and `write-out` directives are fixed constants. +fn fastly_api_put(path: &str, token: &str) -> Result { + let header = curl_quote(&format!("Fastly-Key: {token}")); + let url = curl_quote(&format!("https://api.fastly.com{path}")); + let config = format!( + "request = \"PUT\"\nheader = {header}\nurl = {url}\noutput = \"/dev/null\"\nwrite-out = \"%{{http_code}}\"\n" + ); + let out = curl_config_capture(&config)?; + let code: u16 = out.trim().parse().map_err(|err| { + format!( + "could not parse HTTP status from curl output {:?}: {err}", + out.trim() + ) + })?; + if (200..300).contains(&code) { + Ok(code) + } else { + Err(format!("Fastly API PUT {path} returned HTTP {code}")) } - - Ok(()) } -fn find_fastly_manifest(start: &Path) -> Result { - if let Some(found) = find_manifest_upwards(start, "fastly.toml") { - return Ok(found); +/// `PUT https://api.fastly.com` and return a non-empty response body. +/// This is used for mutations, such as cloning a version, whose response +/// identifies the newly-created provider object needed for recovery. +fn fastly_api_put_capture(path: &str, token: &str) -> Result { + let header = curl_quote(&format!("Fastly-Key: {token}")); + let url = curl_quote(&format!("https://api.fastly.com{path}")); + let config = format!( + "request = \"PUT\"\nheader = {header}\nurl = {url}\nwrite-out = \"\\n%{{http_code}}\"\n" + ); + let out = curl_config_capture(&config)?; + let (body, status_line) = out + .rsplit_once('\n') + .ok_or_else(|| format!("Fastly API PUT {path}: no HTTP status in the curl output"))?; + let code: u16 = status_line.trim().parse().map_err(|error| { + format!( + "Fastly API PUT {path}: could not parse the HTTP status {:?}: {error}", + status_line.trim() + ) + })?; + if !(200..300).contains(&code) { + return Err(format!("Fastly API PUT {path} returned HTTP {code}")); } - - let root = find_workspace_root(start); - let mut candidates: Vec = WalkDir::new(&root) - .follow_links(true) - .max_depth(8) - .into_iter() - .filter_map(Result::ok) - .map(|entry| entry.path().to_path_buf()) - .filter(|path| { - path.file_name().is_some_and(|n| n == "fastly.toml") - && path - .parent() - .is_some_and(|dir| dir.join("Cargo.toml").exists()) - }) - .collect(); - - if candidates.is_empty() { - return Err("could not locate fastly.toml".to_owned()); + if body.trim().is_empty() { + return Err(format!("Fastly API PUT {path} returned an empty response")); } - - candidates.sort_by_key(|path| { - let parent = path.parent().unwrap_or(Path::new("")); - path_distance(start, parent) - }); - - Ok(candidates.remove(0)) + Ok(body.to_owned()) } -fn locate_artifact( - workspace_root: &Path, - manifest_dir: &Path, - crate_name: &str, -) -> Result { - let target_triple = "wasm32-wasip1"; - let release_name = format!("{}.wasm", crate_name.replace('-', "_")); - - if let Some(custom) = env::var_os("CARGO_TARGET_DIR") { - let candidate = PathBuf::from(custom) - .join(target_triple) - .join("release") - .join(&release_name); - if candidate.exists() { - return Ok(candidate); - } - } - - let manifest_target = manifest_dir - .join("target") - .join(target_triple) - .join("release") - .join(&release_name); - if manifest_target.exists() { - return Ok(manifest_target); +fn legacy_deploy_context(args: &[String], staging: bool) -> AdapterDeployContext { + AdapterDeployContext { + adapter_manifest_path: arg_value(args, "--manifest-path").map(PathBuf::from), + application_manifest_path: None, + application_release_root: None, + service_id: arg_value(args, "--service-id").map(str::to_owned), + stores: DeployStoreIds::default(), + staging, + variable_defaults: BTreeMap::default(), } +} - let workspace_target = workspace_root - .join("target") - .join(target_triple) - .join("release") - .join(&release_name); - if workspace_target.exists() { - return Ok(workspace_target); +/// Resolve the directory containing the Fastly manifest selected by the +/// application manifest. Fall back to discovery for direct adapter callers. +fn resolve_deploy_manifest_path(context: &AdapterDeployContext) -> Result { + if let Some(path) = context.adapter_manifest_path.as_deref() { + return Ok(path.to_path_buf()); } - - Err(format!( - "compiled artifact not found (looked in {} and workspace target)", - manifest_dir.display() - )) + find_fastly_manifest(env::current_dir().map_err(|err| err.to_string())?.as_path()) } -#[inline] -pub fn register() { - register_adapter(&FASTLY_ADAPTER); - register_adapter_blueprint(&FASTLY_BLUEPRINT); +/// Production companion to `deploy`: resolve the active service version via the +/// Fastly API and emit it as a `version=` line. +/// +/// Distinguishes "confirmed no active version" from an operational failure: a +/// service with no active version yet (a first-ever deploy) is NOT an error — it +/// emits an empty `version=` line and succeeds, so the caller records an empty +/// rollback target. Only a real failure (API/auth error, or a version list that +/// cannot be parsed) returns `Err`, so the caller can fail closed instead of +/// silently proceeding without a rollback target. +/// +/// `--require-active` flips the no-active-version case to an error: it is passed +/// by the production-`deploy` version fallback, where a version was JUST +/// activated, so "no active version" is not a valid first-deploy state but an +/// operational failure the CLI must not report as success. +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")) } -#[ctor(unsafe)] -fn register_ctor() { - register(); +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, require_active, service_id)? { + log::info!("version={version}"); + } else { + // Confirmed no active version (first-ever deploy), and it was not + // required. Emit an explicit empty line so the caller records an empty + // rollback target and succeeds — distinct from a failure (`Err`). + log::info!("version="); + log::info!( + "service {service_id} has no active version yet; emitting an empty rollback target" + ); + } + Ok(()) } -/// # Errors -/// Returns an error if the Fastly CLI serve command (Viceroy) fails. -#[inline] -pub fn serve(extra_args: &[String]) -> Result<(), String> { - let manifest = - find_fastly_manifest(env::current_dir().map_err(|err| err.to_string())?.as_path())?; - let manifest_dir = manifest - .parent() - .ok_or_else(|| "fastly manifest has no parent directory".to_owned())?; - - let status = Command::new("fastly") - .args(["compute", "serve"]) - .args(extra_args) - .current_dir(manifest_dir) - .status() - .map_err(|err| format!("failed to run fastly CLI: {err}"))?; - if !status.success() { - return Err(format!("fastly compute serve failed with status {status}")); +/// Resolve the active version and apply the `--require-active` policy. +/// +/// `Ok(Some(n))` — a version is active. `Ok(None)` — no active version and +/// `require_active` is false (a first-ever `active-version` call; the caller +/// records an empty rollback target). `Err` — the response was malformed +/// ([`resolve_active_version`]), OR no version is active while `require_active` +/// is true. The latter is the production-`deploy` fallback: a version was JUST +/// activated, so "no active version" is an error, not a valid empty result. +fn active_version_or_require( + json: &str, + require_active: bool, + service_id: &str, +) -> Result, String> { + match resolve_active_version(json)? { + Some(version) => Ok(Some(version)), + None if require_active => Err(format!( + "the deploy reported success but the Fastly API returns no active version for service {service_id}; refusing to report a deploy with no resolvable version" + )), + None => Ok(None), } - - Ok(()) } -// =================================================================== -// Fastly staging lifecycle -// =================================================================== -// -// These entry points back the `deploy --staging`, `healthcheck`, and -// `rollback` app-CLI subcommands. They mirror the Fastly semantics of -// `stackpop/trusted-server-actions`: -// -// * staged deploy → build + `compute update --autoclone` (no -// activation) + `service-version stage`; emits the staged version. -// * production → `fastly compute deploy` runs via the manifest -// command; `emit_active_version` resolves the activated version. -// * healthcheck → curl the domain (production) or the version's -// resolved staging IP (`--staging`); non-zero exit when unhealthy. -// * rollback → activate the explicit `--rollback-to` version -// (production) or deactivate `` (staging) via the Fastly API. -// -// **Version-output contract:** deploy/stage print a -// single `version=` line to stdout (via `log::info!`, which the CLI -// logger emits verbatim). The `deploy-fastly` action greps that line -// to surface `fastly-version`. Rollback prints `rolled-back-to=`. -// -// Provider HTTP calls shell out to `curl` (matching -// trusted-server-actions and avoiding a WASM-incompatible HTTP client -// in the adapter). The `FASTLY_API_TOKEN` is passed to `curl` via a -// `--config -` stdin file rather than on argv, so it never appears in -// `ps` / `/proc//cmdline` (same discipline as -// `create_config_store_entry`'s `--stdin`). +/// Require `version` to be the currently ACTIVE service version — the +/// production healthcheck's version contract. +/// +/// The production probe hits the live domain, which serves whatever version is +/// active, so "healthcheck version N" is only a true statement about N while N is +/// active. `phase` (`before probing` / `after probing`) names when the check ran, +/// so a version activated by a concurrent deploy is reported clearly rather than +/// masquerading as a healthy `version`. +fn verify_version_active( + service_id: &str, + version: u64, + token: &str, + phase: &str, +) -> Result<(), String> { + let json = fastly_api_get(&format!("/service/{service_id}/version"), token)?; + version_active_verdict(resolve_active_version(&json)?, version, service_id, phase) +} -/// Value that follows `flag` in a `--flag value` arg slice, if present. -fn arg_value<'args>(args: &'args [String], flag: &str) -> Option<&'args str> { - args.iter() - .position(|arg| arg == flag) - .and_then(|idx| idx.checked_add(1)) - .and_then(|idx| args.get(idx)) - .map(String::as_str) +/// The pure decision behind [`verify_version_active`], split out so the version +/// contract is unit-testable without a live Fastly API. +fn version_active_verdict( + active: Option, + version: u64, + service_id: &str, + phase: &str, +) -> Result<(), String> { + match active { + Some(active_version) if active_version == version => Ok(()), + Some(active_version) => Err(format!( + "production healthcheck version {version} is not active {phase}: service {service_id} currently has version {active_version} active, so the live-domain probe reflects version {active_version}, not {version}" + )), + None => Err(format!( + "production healthcheck version {version} could not be confirmed active {phase}: service {service_id} has no active version" + )), + } } -/// Whether a boolean `flag` (e.g. `--staging`) is present in `args`. -fn arg_flag(args: &[String], flag: &str) -> bool { - args.iter().any(|arg| arg == flag) -} +/// `healthcheck --adapter fastly ...`: probe the domain +/// (production) or the version's staging IP (`--staging`), retrying up +/// to `--retry` times. Emits `status-code` / `healthy` and returns +/// `Err` (non-zero exit) when unhealthy after retries. +/// +/// `--domain`, `--service-id` and `--version` are REQUIRED and validated +/// on BOTH the production and the staging path. GitHub Actions' `required: +/// true` does not actually fail a workflow when an input is omitted or +/// empty, so this is the real guard: a production healthcheck must never +/// probe on behalf of an absent/empty version it never verified — the +/// caller chains that same version into rollback. +/// +/// On the PRODUCTION path the probe reaches whatever version is live, so when a +/// token is available `version` is verified ACTIVE before and after the probe +/// (see [`verify_version_active`]); without a token the check is service-level. +fn healthcheck(args: &[String]) -> Result<(), String> { + let domain = + arg_value(args, "--domain").ok_or_else(|| "healthcheck requires --domain".to_owned())?; + validate_domain(domain)?; + let service_id = resolve_service_id(args)?; + validate_service_id(&service_id)?; + let version_str = + arg_value(args, "--version").ok_or_else(|| "healthcheck requires --version".to_owned())?; + let version = validate_version_str(version_str)?; + let path = arg_value(args, "--path").unwrap_or("/"); + validate_probe_path(path)?; + let retry = arg_value(args, "--retry") + .and_then(|value| value.parse().ok()) + .unwrap_or(3_u32); + let retry_delay = arg_value(args, "--retry-delay") + .and_then(|value| value.parse().ok()) + .unwrap_or(5_u64); + let timeout = arg_value(args, "--timeout") + .and_then(|value| value.parse().ok()) + .unwrap_or(10_u64); + // curl reads `--max-time 0` as "no limit", so a zero timeout lets a single + // probe run indefinitely. Require a positive value. + if timeout == 0 { + return Err("healthcheck --timeout must be a positive number of seconds".to_owned()); + } + + let is_staging = arg_flag(args, "--staging"); + let staging_ip = if is_staging { + let token = require_token()?; + let json = fastly_api_get( + &format!("/service/{service_id}/version/{version}/domain?include=staging_ips"), + &token, + )?; + let ip = parse_staging_ip(&json, domain)?; + // Require a real `IpAddr` before it reaches curl's `--connect-to`, which + // also settles IPv4-vs-IPv6 formatting. + ip.parse::().map_err(|err| { + format!("resolved staging IP {ip:?} is not a valid IP address: {err}") + })?; + Some(ip) + } else { + None + }; -/// Copy of `args` with `--flag value` removed (both tokens). Used to -/// forward operator passthrough (e.g. `--comment`) to `fastly compute -/// update` without re-passing `--service-id`, which is threaded -/// explicitly. -fn args_without_flag_value(args: &[String], flag: &str) -> Vec { - let mut out = Vec::with_capacity(args.len()); - let mut skip = false; - for arg in args { - if skip { - skip = false; - continue; - } - if arg == flag { - skip = true; - continue; + // Production version contract: the probe hits the live domain, which serves + // whatever version is ACTIVE — not necessarily `version`. When a token is + // available, require `version` to be active both BEFORE and AFTER the probe, so + // a version activated concurrently (by another deploy) cannot be reported as a + // healthy `version`. Without a token the production check is inherently + // service-level — say so rather than imply a version-specific guarantee. The + // staging path already targets the specific version's staging IP, so it needs + // no such check. + let production_token = if is_staging { + None + } else { + match env::var(FASTLY_API_TOKEN_ENV) { + Ok(token) if !token.is_empty() => Some(token), + _ => { + log::info!( + "no {FASTLY_API_TOKEN_ENV} available; production healthcheck is service-level (probes the live domain for service {service_id}, not specifically version {version})" + ); + None + } } - out.push(arg.clone()); - } - out -} - -/// Split an arg on a leading `--flag=value`, returning `(flag, value)`. -fn split_inline_value(arg: &str) -> (&str, Option<&str>) { - match arg.split_once('=') { - Some((flag, value)) if flag.starts_with('-') => (flag, Some(value)), - Some(_) | None => (arg, None), + }; + if let Some(token) = production_token.as_deref() { + verify_version_active(&service_id, version, token, "before probing")?; } -} -/// Partition operator passthrough args for a staged deploy: forward only -/// what `fastly compute update` supports, lift `--comment` out (it is a -/// `compute deploy` / `service-version update` flag, NOT a -/// `compute update` one), and drop the rest. -/// -/// Both `--comment value` and `--comment=value` are recognised. -fn split_staging_passthrough(args: &[String]) -> StagingPassthrough { - let mut split = StagingPassthrough { - forwarded: Vec::with_capacity(args.len()), - comment: None, - dropped: Vec::new(), - }; - let mut iter = args.iter().peekable(); - while let Some(arg) = iter.next() { - let (flag, inline) = split_inline_value(arg); - if flag == "--comment" { - split.comment = match inline { - Some(value) => Some(value.to_owned()), - None => iter.next().cloned(), - }; - } else if COMPUTE_UPDATE_VALUE_FLAGS.contains(&flag) { - split.forwarded.push(arg.clone()); - if inline.is_none() - && let Some(value) = iter.next() - { - split.forwarded.push(value.clone()); + let curl_args = build_curl_probe_args(domain, path, staging_ip.as_deref(), timeout); + let delay = Duration::from_secs(retry_delay); + let outcome = probe_with_retries(retry, || curl_status(&curl_args), || thread::sleep(delay)); + match outcome { + Ok(code) => { + // Confirm `version` is STILL active, so a deploy that activated a newer + // version during the probe+retries is not reported as a healthy `version`. + if let Some(token) = production_token.as_deref() { + verify_version_active(&service_id, version, token, "after probing")?; } - } else if COMPUTE_UPDATE_BOOL_FLAGS.contains(&flag) { - split.forwarded.push(arg.clone()); - } else { - // Unsupported by `compute update`. Consume a detached value - // too, so a stray `stage` from `--env stage` is not left - // behind as a bogus positional. - split.dropped.push(flag.to_owned()); - if inline.is_none() && iter.peek().is_some_and(|next| !next.starts_with('-')) { - iter.next(); + log::info!("status-code={code}"); + log::info!("healthy=true"); + Ok(()) + } + Err((last_code, msg)) => { + if let Some(code) = last_code { + log::info!("status-code={code}"); } + log::info!("healthy=false"); + Err(format!( + "healthcheck for {domain} failed after {} attempt(s): {msg}", + retry.max(1) + )) } } - split } -/// Resolve the target service id from `--service-id` or, failing that, -/// `FASTLY_SERVICE_ID`. -fn resolve_service_id(args: &[String]) -> Result { - if let Some(value) = arg_value(args, "--service-id") { - return Ok(value.to_owned()); +/// Run a single `curl` health probe, returning the HTTP status. A +/// transport failure (timeout, DNS, refused) surfaces as `Err` so the +/// retry loop treats it as an unhealthy attempt. +fn curl_status(args: &[String]) -> Result { + let output = Command::new("curl").args(args).output().map_err(|err| { + if err.kind() == ErrorKind::NotFound { + "`curl` not found on PATH; install curl and retry".to_owned() + } else { + format!("failed to spawn `curl`: {err}") + } + })?; + if !output.status.success() { + return Err(format!( + "curl transport failure (status {}): {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + )); } - env::var(FASTLY_SERVICE_ID_ENV).map_err(|_err| { - format!("no service id: pass `--service-id ` or set {FASTLY_SERVICE_ID_ENV}") + let stdout = String::from_utf8_lossy(&output.stdout); + stdout.trim().parse::().map_err(|err| { + format!( + "could not parse HTTP status from curl output {:?}: {err}", + stdout.trim() + ) }) } -/// Read the required Fastly API token from the environment. -fn require_token() -> Result { - env::var(FASTLY_API_TOKEN_ENV) - .map_err(|_err| format!("{FASTLY_API_TOKEN_ENV} must be set in the environment")) -} - -/// Whether an HTTP status counts as healthy (2xx only). -/// -/// A passing probe gates against an automatic rollback, so a 3xx is deliberately -/// NOT healthy: a staged version answering `301` to an error page (the probe does -/// not follow redirects) would otherwise mask a bad deploy as healthy. -fn is_healthy_status(code: u16) -> bool { - (200..300).contains(&code) -} +/// `rollback --adapter fastly ...`: production activates the explicit +/// `--rollback-to` version (Fastly cannot infer a previous version); +/// staging deactivates ``. +fn rollback(args: &[String]) -> Result<(), String> { + let service_id = resolve_service_id(args)?; + validate_service_id(&service_id)?; + let version_str = + arg_value(args, "--version").ok_or_else(|| "rollback requires --version".to_owned())?; + let version = validate_version_str(version_str)?; + let token = require_token()?; -/// Digits immediately following `marker` in `lower` (a lowercased -/// haystack), for the LAST occurrence of `marker`. The number must be -/// terminated by `terminator` — so a partial/confusable match (e.g. a -/// semver `15.2.0`) yields `None` rather than a bogus version. -fn last_version_after(lower: &str, marker: &str, terminator: char) -> Option { - let mut result = None; - for (idx, _) in lower.match_indices(marker) { - let after = idx.saturating_add(marker.len()); - let Some(rest) = lower.get(after..) else { - continue; - }; - let digits: String = rest.chars().take_while(char::is_ascii_digit).collect(); - if digits.is_empty() || rest.chars().nth(digits.len()) != Some(terminator) { - continue; - } - if let Ok(parsed) = digits.parse::() { - result = Some(parsed); + if arg_flag(args, "--staging") { + let json = fastly_api_get(&format!("/service/{service_id}/version"), &token)?; + let versions = parse_service_versions(&json)?; + match staging_rollback_decision(&versions, version, &service_id)? { + StagingRollbackDecision::Deactivate => { + // Fastly's environment-scoped deactivate is + // `PUT .../deactivate/staging`; a plain `.../deactivate` + // targets production activation. + fastly_api_put( + &format!("/service/{service_id}/version/{version}/deactivate/staging"), + &token, + )?; + log::info!( + "[edgezero] deactivated staged version {version} on Fastly service {service_id}" + ); + } + StagingRollbackDecision::NoopDraft => log::info!( + "[edgezero] Fastly version {version} is an unpublished draft; staging rollback has nothing to deactivate" + ), } + } else { + // Production rollback re-activates an EXPLICIT target. Fastly's version + // list has no field distinguishing a previously-live version from a + // staged one (`staging`/`deployed` are documented "Unused"; `locked` + // only means "not editable"), so the target cannot be inferred — it is + // captured before the superseding deploy and passed in as --rollback-to. + let previous = arg_value(args, "--rollback-to") + .and_then(|raw| validate_version_str(raw).ok()) + .ok_or_else(|| { + "production rollback requires a valid --rollback-to version".to_owned() + })?; + // Best-effort staleness check: the version being rolled back FROM + // (`--version`) must STILL be the active version. A rollback workflow can + // run long after its deploy — if a newer version was activated meanwhile, + // activating the old target would clobber that newer deploy, so refuse. + // + // This is NOT atomic: Fastly's activate endpoint has no precondition, so + // a deploy that lands BETWEEN this read and the activate below can still + // be clobbered. It narrows the window (catching the common much-later + // rollback) but does not close it — serialise deploys and rollbacks per + // SERVICE (a service-scoped concurrency group) to eliminate the race. + let json = fastly_api_get(&format!("/service/{service_id}/version"), &token)?; + ensure_rollback_from_is_active(resolve_active_version(&json)?, version, &service_id)?; + // Fastly's activate endpoint requires `PUT` (not `POST`). + fastly_api_put( + &format!("/service/{service_id}/version/{previous}/activate"), + &token, + )?; + log::info!("rolled-back-to={previous}"); } - result + Ok(()) } -/// Parse a Fastly service version out of Fastly CLI output, accepting -/// ONLY the shapes the CLI actually emits, in precedence order: -/// -/// 1. Our canonical `version=` contract line. -/// 2. The CLI's success line, whose Go format string is -/// `"Updated package (service %s, version %v)"` (and -/// `"Deployed package (...)"` for `compute deploy`) — matched as -/// `, version )`. This names the version the package landed on, -/// so it wins over (3). -/// 3. The `--autoclone` notice, `"... Now operating on version %d."` — -/// the freshly-cloned draft, used when the success line is absent. -/// -/// Everything else yields `None` and the caller FAILS CLOSED. -/// -/// Deliberately strict. The previous implementation took ANY digits -/// appearing after the word "version" and let the last match win, so: -/// * `Uploaded package to service 12345, version unchanged` parsed as -/// version 12345, and -/// * the autoclone notice's *pre-clone* version -/// (`Service version 3 is not editable...`) could beat the real one, -/// since stdout and stderr are concatenated and their relative order -/// is not guaranteed. -/// -/// A misparse here stages, comments, or rolls back the WRONG service -/// version, so ambiguity must be an error, not a guess. -fn parse_fastly_version(text: &str) -> Option { - let lower = text.to_ascii_lowercase(); - parse_canonical_version_line(&lower) - .or_else(|| last_version_after(&lower, ", version ", ')')) - .or_else(|| last_version_after(&lower, "now operating on version ", '.')) -} +#[cfg(test)] +mod tests { + use super::*; + use edgezero_adapter::cli_support::read_package_name; + use edgezero_core::env_config::EnvConfig; + #[cfg(unix)] + use edgezero_core::test_env::{EnvOverride, PathPrepend}; + #[cfg(unix)] + use std::collections::BTreeMap; + use std::collections::HashSet; + #[cfg(unix)] + use std::iter::once; + #[cfg(unix)] + use std::os::unix::fs::PermissionsExt as _; + #[cfg(unix)] + use std::sync::Mutex; + use tempfile::tempdir; + + // Shared fixture names. Pinning these as consts (instead of + // inline `"sessions"` / `"app_config"` per call site) keeps the + // setup-vs-assertion pair in sync -- a typo in one place no + // longer silently divorces from the other, because both reference + // the same const. Also names the intent: these are the LOGICAL + // store ids the fastly adapter operates on, not arbitrary strings. + const TEST_KV_ID: &str = "sessions"; + const TEST_CONFIG_ID: &str = "app_config"; + const TEST_SECRET_ID: &str = "default"; + + // `PathPrepend` (RAII $PATH guard) is the shared helper imported above from + // `edgezero_core::test_env`; the merge with edition-2024 main replaced our + // local copy with it (its `set_var` calls are wrapped for 2024's unsafe-env). -/// Last standalone `version=` line (the whole trimmed line must be -/// exactly that, so a `--version=active` flag echoed in a command line -/// cannot masquerade as one). -fn parse_canonical_version_line(lower: &str) -> Option { - lower.lines().rev().find_map(|line| { - let digits = line.trim().strip_prefix("version=")?; - (!digits.is_empty() && digits.chars().all(|ch| ch.is_ascii_digit())) - .then(|| digits.parse().ok()) - .flatten() - }) -} + // ── Fastly staging lifecycle helpers ────────────────────────────── -/// Parse `fastly service-version list --json` (or the Fastly API -/// `/service//version` array) for the `number` of the `active` -/// version. -/// Resolve the active version from a Fastly version-list JSON. -/// -/// `Ok(Some(n))` — exactly one version is active. `Ok(None)` — the list parsed -/// but NO version is active (a first-ever deploy; the caller records an empty -/// rollback target and proceeds). `Err(_)` — the payload could not be parsed as -/// a version list, OR it is MALFORMED (a non-boolean `active` on ANY entry, an -/// `active: true` entry whose `number` is missing or not an unsigned integer, or -/// MORE THAN ONE active version). All are OPERATIONAL failures the caller must -/// NOT silently treat as "no active version" — otherwise a garbled or ambiguous -/// response would fail open and let a production deploy proceed with no rollback -/// target. -/// -/// The ENTIRE list is scanned (not short-circuited at the first active entry) so -/// that a malformed `active` field or a second active version anywhere in the -/// response is caught rather than ignored. -fn resolve_active_version(json: &str) -> Result, String> { - let value: serde_json::Value = serde_json::from_str(json) - .map_err(|err| format!("failed to parse the Fastly version list as JSON: {err}"))?; - let array = value.as_array().ok_or_else(|| { - "the Fastly version list was not a JSON array; the API may have changed its schema" - .to_owned() - })?; - // A real Fastly service always has at least an initial (inactive) version, so - // an EMPTY list is an invalid response — fail closed rather than read it as a - // legitimate "no active version yet" (first deploy). - if array.is_empty() { - return Err( - "the Fastly version list is empty; a service always has at least an initial version, so this response cannot be trusted".to_owned() + #[test] + fn arg_value_reads_flag_value() { + let args = vec![ + "--service-id".to_owned(), + "SVC1".to_owned(), + "--version".to_owned(), + "42".to_owned(), + ]; + assert_eq!(arg_value(&args, "--service-id"), Some("SVC1")); + assert_eq!(arg_value(&args, "--version"), Some("42")); + assert_eq!(arg_value(&args, "--missing"), None); + } + + #[test] + fn arg_value_none_when_flag_is_last() { + let args = vec!["--version".to_owned()]; + assert_eq!(arg_value(&args, "--version"), None); + } + + #[test] + fn arg_flag_detects_presence() { + let args = vec!["--staging".to_owned()]; + assert!(arg_flag(&args, "--staging")); + assert!(!arg_flag(&args, "--nope")); + } + + #[test] + fn args_without_flag_value_strips_pair() { + let args = vec![ + "--service-id".to_owned(), + "SVC1".to_owned(), + "--comment".to_owned(), + "ci".to_owned(), + ]; + assert_eq!( + args_without_flag_value(&args, "--service-id"), + vec!["--comment".to_owned(), "ci".to_owned()] ); } - let mut active_version: Option = None; - for entry in array { - // EVERY entry must be a well-formed version object with an unsigned - // integer `number` — Fastly includes it on every version. A `null`, a - // non-object, or a missing/non-integer `number` means the response - // cannot be trusted; treating such an entry as merely "not active" would - // let a garbled payload read as "no active version" (fail open). - let Some(object) = entry.as_object() else { - return Err(format!( - "a Fastly version list element is not an object; the API may have changed its schema. Element: {entry}" - )); - }; - let number = object.get("number").and_then(serde_json::Value::as_u64).ok_or_else(|| { - format!( - "a Fastly version entry has no unsigned-integer `number`; the API may have changed its schema. Entry: {entry}" - ) - })?; - // `active` is optional (an omitted field means not active), but a PRESENT - // non-boolean is schema drift. - let active = match object.get("active") { - None => false, - Some(active_field) => active_field.as_bool().ok_or_else(|| { - format!( - "a Fastly version entry has a non-boolean `active` field; the API may have changed its schema. Entry: {entry}" - ) - })?, - }; - if active { - if active_version.is_some() { - return Err(format!( - "the Fastly version list reports more than one active version ({} and {number}); the response is ambiguous, refusing to pick one", - active_version.unwrap_or_default() - )); - } - active_version = Some(number); - } + + #[test] + fn resolve_service_id_prefers_flag() { + let args = vec!["--service-id".to_owned(), "SVCFROMARG".to_owned()]; + assert_eq!(resolve_service_id(&args).unwrap(), "SVCFROMARG"); } - Ok(active_version) -} -/// Best-effort staleness guard for a production rollback: the version being -/// rolled back FROM (`from_version`, the caller's `--version`) must still be the -/// ACTIVE version. A rollback can run long after its deploy; if a newer version -/// was activated since, activating the old target would clobber it — so refuse. -/// -/// This narrows but does NOT close the race: the caller reads the active version -/// and activates in two separate requests, and Fastly's activate endpoint has no -/// precondition, so a deploy landing between them can still be clobbered. -/// Service-scoped serialization is required to eliminate it. -fn ensure_rollback_from_is_active( - active: Option, - from_version: u64, - service_id: &str, -) -> Result<(), String> { - match active { - Some(active_version) if active_version == from_version => Ok(()), - Some(active_version) => Err(format!( - "refusing to roll back service {service_id}: the active version is now {active_version}, not the {from_version} being rolled back from -- a newer deploy is live and rolling back would clobber it" - )), - None => Err(format!( - "refusing to roll back service {service_id}: it has no active version" - )), + // ── managed deploy argument validation ──────────────────────── + + fn owned(args: &[&str]) -> Vec { + args.iter().map(|arg| (*arg).to_owned()).collect() } -} -/// First staging IP found in a Fastly -/// `GET /service//version//domain?include=staging_ips` response. -/// -/// The response is an ARRAY of domain objects, and the staging address -/// is a SINGULAR, nullable STRING field named `staging_ip` on each -/// domain (`staging_ips` is only the `include=` query-param value, never -/// a field name). Verified against the go-fastly `Domain` model, whose -/// field is `StagingIP` with the mapstructure tag `staging_ip`, and its -/// recorded API fixture `fixtures/domains/list_with_staging_ips.yaml`, -/// plus Fastly's "working with staging" guide. The field is absent from -/// the published Domain data model, so it is treated as optional. -/// -/// We also tolerate a plural `staging_ips` array, in case a Fastly -/// response (or a future API version) carries that shape. -fn parse_staging_ip(json: &str) -> Option { - let value: serde_json::Value = serde_json::from_str(json).ok()?; - find_staging_ip(&value) -} + #[cfg(unix)] + fn fake_provider_invocation_marker(marker: &Path) -> tempfile::TempDir { + use std::os::unix::fs::PermissionsExt as _; -fn find_staging_ip(value: &serde_json::Value) -> Option { - match value { - serde_json::Value::Object(map) => { - // The documented shape: a singular `staging_ip` string. - if let Some(ip) = map.get("staging_ip").and_then(serde_json::Value::as_str) { - return Some(ip.to_owned()); - } - // Tolerated: a plural `staging_ips` array of strings. - if let Some(ip) = map - .get("staging_ips") - .and_then(serde_json::Value::as_array) - .and_then(|arr| arr.iter().find_map(serde_json::Value::as_str)) - { - return Some(ip.to_owned()); - } - map.values().find_map(find_staging_ip) + let dir = tempdir().expect("provider fake dir"); + for binary in ["fastly", "curl"] { + let script_path = dir.path().join(binary); + fs::write( + &script_path, + format!( + "#!/bin/sh\nprintf '%s\\n' \"$0 $*\" >> '{}'\nexit 0\n", + marker.display() + ), + ) + .expect("write provider fake"); + let mut permissions = fs::metadata(&script_path) + .expect("provider fake metadata") + .permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&script_path, permissions).expect("chmod provider fake"); } - serde_json::Value::Array(arr) => arr.iter().find_map(find_staging_ip), - serde_json::Value::Null - | serde_json::Value::Bool(_) - | serde_json::Value::Number(_) - | serde_json::Value::String(_) => None, + dir } -} -/// Build the `curl` argv for a health probe. Production probes the -/// domain directly; staging reroutes the TLS connection to the -/// resolved staging IP via `--connect-to :::443`. `path` is the -/// URL path (always begins with '/'), applied identically to both. -fn build_curl_probe_args( - domain: &str, - path: &str, - staging_ip: Option<&str>, - timeout_secs: u64, -) -> Vec { - let mut args = vec![ - // `-q` first so curl never merges `~/.curlrc` into a probe (a planted - // `proxy`/`output` there could otherwise redirect or corrupt the check). - "-q".to_owned(), - "-sS".to_owned(), - // Disable curl's URL globbing: a valid probe path may contain `[` `]` `{` - // `}` (e.g. `/health?ids[0]=1`), which curl would otherwise treat as a - // glob — failing with exit 3 or firing multiple requests, and so - // mis-reporting a healthy deployment as unhealthy. - "--globoff".to_owned(), - "-o".to_owned(), - "/dev/null".to_owned(), - "-w".to_owned(), - "%{http_code}".to_owned(), - "--max-time".to_owned(), - timeout_secs.to_string(), - ]; - if let Some(ip) = staging_ip { - // `--connect-to ::HOST:PORT` reroutes the TLS connection to the staging - // IP. An IPv6 literal must be bracketed or curl mis-parses the colons; - // the caller has already validated `ip` parses as an `IpAddr`. - let target = if ip.contains(':') { - format!("::[{ip}]:443") - } else { - format!("::{ip}:443") - }; - args.push("--connect-to".to_owned()); - args.push(target); + #[cfg(unix)] + fn assert_provider_not_invoked(marker: &Path) { + assert!( + !marker.exists() + || fs::read_to_string(marker) + .expect("provider marker") + .is_empty(), + "invalid input must be rejected before the provider fake is invoked: {}", + fs::read_to_string(marker).unwrap_or_default() + ); } - args.push(format!("https://{domain}{path}")); - args -} -/// Validate a caller-supplied probe path. It is appended to -/// `https://{domain}` to form one curl argument, so it must begin with -/// '/' and carry no whitespace or control characters that would break -/// the URL or smuggle a second token. -fn validate_probe_path(path: &str) -> Result<(), String> { - if !path.starts_with('/') { - return Err(format!("healthcheck --path must begin with '/': '{path}'")); - } - if path.chars().any(|ch| ch.is_whitespace() || ch.is_control()) { - return Err(format!( - "healthcheck --path must not contain whitespace or control characters: '{path}'" - )); + fn assert_service_id_error(error: &str) { + assert!( + error.contains("ASCII letters and digits only"), + "service-id error must state the exact accepted alphabet: {error}" + ); } - Ok(()) -} -/// Retry a health probe. Returns `Ok(code)` on the first healthy -/// status, or `Err((last_code, message))` after exhausting attempts. -/// `between` runs between attempts (not after the last) so it can be a -/// no-op in tests. -fn probe_with_retries( - retry: u32, - mut prober: P, - mut between: S, -) -> Result, String)> -where - P: FnMut() -> Result, - S: FnMut(), -{ - let attempts = retry.max(1); - let mut last_code = None; - let mut last_msg = "no probe attempts were made".to_owned(); - for attempt in 0..attempts { - match prober() { - Ok(code) if is_healthy_status(code) => return Ok(code), - Ok(code) => { - last_code = Some(code); - last_msg = format!("unhealthy HTTP status {code}"); - } - Err(err) => last_msg = err, - } - if attempt.saturating_add(1) < attempts { - between(); + #[test] + fn deploy_arg_scan_rejects_every_reserved_spelling() { + for args in [ + owned(&["--service-id", "service1"]), + owned(&["--service-id=service1"]), + owned(&["-s", "service1"]), + owned(&["-s=service1"]), + owned(&["-sservice1"]), + owned(&["--service-name", "demo"]), + owned(&["--service-name=demo"]), + owned(&["--version", "active"]), + owned(&["--version=active"]), + owned(&["--autoclone"]), + owned(&["--token", "secret"]), + owned(&["--token=secret"]), + owned(&["-t", "secret"]), + owned(&["-t=secret"]), + owned(&["-tsecret"]), + ] { + let error = scan_reserved_deploy_args(&args) + .expect_err("lifecycle-owned deploy argument must be rejected"); + assert!( + error.contains("reserved") || error.contains("lifecycle"), + "reserved argument error must explain ownership for {args:?}: {error}" + ); } } - Err((last_code, last_msg)) -} -/// Run `fastly ` in `cwd`, inheriting stdio, and map a non-zero -/// exit to an error. -fn run_fastly_status(fastly_args: &[String], cwd: &Path) -> Result<(), String> { - let status = Command::new("fastly") - .args(fastly_args) - .current_dir(cwd) - .status() - .map_err(|err| { - if err.kind() == ErrorKind::NotFound { - format!("`fastly` not found on PATH; {FASTLY_INSTALL_HINT}") - } else { - format!("failed to run fastly CLI: {err}") - } - })?; - if status.success() { - Ok(()) - } else { - Err(format!( - "`fastly {}` exited with status {status}", - fastly_args.join(" ") - )) + #[test] + fn deploy_arg_scan_redacts_inline_token_values() { + const SENTINEL: &str = "SUPER_SECRET_TOKEN_SENTINEL"; + for (arg, expected_flag) in [ + (format!("--token={SENTINEL}"), "--token"), + (format!("-t={SENTINEL}"), "-t"), + (format!("-t{SENTINEL}"), "-t"), + ] { + let error = scan_reserved_deploy_args(&[arg]) + .expect_err("inline credential arguments must be reserved"); + assert!( + !error.contains(SENTINEL), + "reserved-argument error leaked a credential: {error}" + ); + assert!( + error.contains(expected_flag), + "reserved-argument error must identify {expected_flag}: {error}" + ); + } } -} -/// Run `fastly ` in `cwd` capturing stdout+stderr (combined) for -/// version parsing. Errors on a non-zero exit. -fn run_fastly_capture(fastly_args: &[String], cwd: &Path) -> Result { - let output = Command::new("fastly") - .args(fastly_args) - .current_dir(cwd) - .output() - .map_err(|err| { - if err.kind() == ErrorKind::NotFound { - format!("`fastly` not found on PATH; {FASTLY_INSTALL_HINT}") - } else { - format!("failed to run fastly CLI: {err}") - } - })?; - let mut combined = String::from_utf8_lossy(&output.stdout).into_owned(); - combined.push_str(&String::from_utf8_lossy(&output.stderr)); - if output.status.success() { - Ok(combined) - } else { - Err(format!( - "`fastly {}` exited with status {}\n{}", - fastly_args.join(" "), - output.status, - combined.trim() - )) + #[test] + fn deploy_arg_scan_allows_unrelated_long_flags_beginning_with_s_or_t() { + scan_reserved_deploy_args(&owned(&[ + "--skip-build", + "--status", + "--timeout=30", + "--trace", + ])) + .expect("long flags must not be mistaken for attached -s or -t values"); } -} -/// Run `curl -q -sS --config -`, piping `config` (which carries the -/// `Fastly-Key` header + url) through stdin so the token never touches -/// argv. Returns stdout on a zero exit. -/// -/// `-q` MUST be the first argument: without it curl reads `~/.curlrc` -/// (or `$CURL_HOME/.curlrc`) and merges it into this token-bearing -/// config, so a `proxy = …` directive planted by an earlier same-job -/// build step could exfiltrate the `Fastly-Key` header. `--connect-timeout` -/// / `--max-time` bound the call. -fn curl_config_capture(config: &str) -> Result { - let connect_timeout = FASTLY_API_CONNECT_TIMEOUT_SECS.to_string(); - let max_time = FASTLY_API_MAX_TIME_SECS.to_string(); - let mut child = Command::new("curl") - .args([ - "-q", - "-sS", - "--connect-timeout", - &connect_timeout, - "--max-time", - &max_time, - "--config", - "-", - ]) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .map_err(|err| { - if err.kind() == ErrorKind::NotFound { - "`curl` not found on PATH; install curl and retry".to_owned() - } else { - format!("failed to spawn `curl`: {err}") - } - })?; - // Take stdin OUT of the child and hand it to a helper BY VALUE, so it drops at - // that helper's scope end — a natural drop rather than an explicit `drop(stdin)`, - // which trips `clippy::drop_non_drop` on wasm targets where `ChildStdin` is not - // `Drop`. The drop must precede `wait_with_output` so curl sees EOF (same pattern - // as `write_value_to_fastly_stdin` on the fastly path). - let stdin = child - .stdin - .take() - .ok_or_else(|| "failed to open stdin pipe to `curl`".to_owned())?; - write_config_to_curl_stdin(stdin, config)?; - let output = child - .wait_with_output() - .map_err(|err| format!("failed to wait on `curl`: {err}"))?; - if output.status.success() { - Ok(String::from_utf8_lossy(&output.stdout).into_owned()) - } else if output.status.code() == Some(CURL_EXIT_TIMEOUT) { - Err(format!( - "`curl` timed out after connect-timeout {FASTLY_API_CONNECT_TIMEOUT_SECS}s / max-time {FASTLY_API_MAX_TIME_SECS}s: {}", - String::from_utf8_lossy(&output.stderr).trim() - )) - } else { - Err(format!( - "`curl` exited with status {}: {}", - output.status, - String::from_utf8_lossy(&output.stderr).trim() - )) + #[test] + fn package_files_hash_parsers_require_exact_provider_identity() { + let hash = "a".repeat(128); + assert_eq!( + parse_package_files_hash_output(&format!("notice\n{hash}\n")) + .expect("canonical Fastly CLI hash"), + hash + ); + parse_package_files_hash_output("abc").expect_err("short hash must fail"); + parse_package_files_hash_output(&format!("{}\n{}", "a".repeat(128), "b".repeat(128))) + .expect_err("conflicting hashes must fail"); + + let response = serde_json::json!({ + "service_id": "SVC1", + "version": 8_u64, + "metadata": { "files_hash": "a".repeat(128) } + }) + .to_string(); + assert_eq!( + parse_package_metadata_files_hash(&response, "SVC1", 8) + .expect("matching package metadata"), + "a".repeat(128) + ); + parse_package_metadata_files_hash(&response, "OTHER", 8) + .expect_err("wrong service must fail"); + parse_package_metadata_files_hash(&response, "SVC1", 9) + .expect_err("wrong version must fail"); } -} -/// Write `config` to curl's stdin, taking the handle BY VALUE so it drops at this -/// function's scope end. That natural drop closes the pipe (curl sees EOF) without -/// an explicit `drop(stdin)`, which trips `clippy::drop_non_drop` on wasm targets -/// where `ChildStdin` is not `Drop` (mirrors `write_value_to_fastly_stdin`). -fn write_config_to_curl_stdin(mut stdin: ChildStdin, config: &str) -> Result<(), String> { - stdin - .write_all(config.as_bytes()) - .map_err(|err| format!("failed to write curl config to stdin: {err}")) -} + #[test] + fn deploy_arg_preflight_scans_store_free_manifest_deploys() { + let context = AdapterDeployContext { + service_id: Some("SVC1".to_owned()), + ..AdapterDeployContext::default() + }; + assert_eq!( + FastlyCliAdapter + .preflight_deploy(&context, &owned(&["--comment", "release"])) + .expect("safe manifest-command argument"), + DeployOwnership::ManifestCommand + ); + FastlyCliAdapter + .preflight_deploy(&context, &owned(&["-tsecret"])) + .expect_err("reserved scan must run before store-free manifest dispatch"); + } -/// Wrap `value` in a curl-config double-quoted string, escaping the -/// characters that would otherwise let a value terminate its quote and -/// inject additional curl options. Within a curl `--config` file a -/// double-quoted value only honours the escapes `\\`, `\"`, `\n`, `\r`, -/// `\t` (and the config is parsed line-by-line, so a raw newline ends -/// the directive regardless of quoting). We escape backslash and quote -/// so the value cannot break out of the quotes, and map raw control -/// characters to their escape form so NO raw newline (or CR/tab) is -/// ever written into the config file. This is the second half of the -/// injection defence: untrusted identifiers are also validated (see -/// `validate_service_id` / `validate_version_str` / `validate_domain`), -/// but the token is a secret we cannot constrain to a charset, so it -/// relies on this escaping alone. -fn curl_quote(value: &str) -> String { - let mut out = String::with_capacity(value.len().saturating_add(2)); - out.push('"'); - for ch in value.chars() { - match ch { - '\\' => out.push_str("\\\\"), - '"' => out.push_str("\\\""), - '\n' => out.push_str("\\n"), - '\r' => out.push_str("\\r"), - '\t' => out.push_str("\\t"), - other => out.push(other), + #[test] + fn managed_deploy_preflight_owns_release_staging_and_each_store_kind() { + let direct = AdapterDeployContext { + service_id: Some("SVC1".to_owned()), + application_manifest_path: Some(PathBuf::from("edgezero.toml")), + ..AdapterDeployContext::default() + }; + assert_eq!( + FastlyCliAdapter.preflight_deploy(&direct, &[]), + Ok(DeployOwnership::ManifestCommand) + ); + + let mut managed = direct.clone(); + managed.application_release_root = Some(PathBuf::from("release")); + assert_eq!( + FastlyCliAdapter.preflight_deploy(&managed, &[]), + Ok(DeployOwnership::AdapterManaged) + ); + + managed = direct.clone(); + managed.staging = true; + assert_eq!( + FastlyCliAdapter.preflight_deploy(&managed, &[]), + Ok(DeployOwnership::AdapterManaged) + ); + + for stores in [ + DeployStoreIds { + config: vec!["config".to_owned()], + ..DeployStoreIds::default() + }, + DeployStoreIds { + kv: vec!["kv".to_owned()], + ..DeployStoreIds::default() + }, + DeployStoreIds { + secrets: vec!["secret".to_owned()], + ..DeployStoreIds::default() + }, + ] { + managed = direct.clone(); + managed.stores = stores; + assert_eq!( + FastlyCliAdapter.preflight_deploy(&managed, &[]), + Ok(DeployOwnership::AdapterManaged) + ); } } - out.push('"'); - out -} -/// 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]+$`. -/// 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" - )); + #[cfg(unix)] + #[test] + fn managed_deploy_requires_application_release_before_provider_mutation() { + let _lock = path_mutation_guard().lock().expect("guard"); + let _token = EnvOverride::set(FASTLY_API_TOKEN_ENV, "test-token"); + let context = AdapterDeployContext { + service_id: Some("SVC1".to_owned()), + staging: true, + ..AdapterDeployContext::default() + }; + + let error = FastlyCliAdapter + .deploy(&context, &[]) + .expect_err("managed staging deploy requires an immutable release"); + assert!(error.contains("--application-release"), "{error}"); } - if !id.is_empty() && id.chars().all(|ch| ch.is_ascii_alphanumeric()) { - Ok(()) - } else { - Err(format!( - "invalid service id {id:?}: expected only ASCII letters or digits" - )) + + #[cfg(unix)] + #[test] + fn manifest_command_finalization_binds_reported_version_to_active_service_version() { + let _lock = path_mutation_guard().lock().expect("guard"); + + let fake = tempdir().expect("provider fake"); + let marker = fake.path().join("provider.log"); + let curl = fake.path().join("curl"); + fs::write( + &curl, + format!( + "#!/bin/sh\ncat >/dev/null\nprintf 'called\\n' >> '{}'\nprintf '[{{\"number\":8,\"active\":true,\"locked\":true,\"staging\":false,\"deployed\":true,\"environments\":[{{\"active_version\":8,\"name\":\"production\",\"service_id\":\"SVC1\"}}]}}]\\n200'\n", + marker.display() + ), + ) + .expect("curl fake"); + let mut permissions = fs::metadata(&curl).expect("curl metadata").permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&curl, permissions).expect("curl executable"); + let _path = PathPrepend::new(fake.path()); + let _token = EnvOverride::set(FASTLY_API_TOKEN_ENV, "test-token"); + let context = AdapterDeployContext { + service_id: Some("SVC1".to_owned()), + ..AdapterDeployContext::default() + }; + + FastlyCliAdapter + .finalize_deploy(&context, Some("version=8")) + .expect("active version finalization"); + assert!( + marker.exists(), + "finalization must consult the exact service" + ); + + let error = FastlyCliAdapter + .finalize_deploy(&context, Some("version=9")) + .expect_err("a version from another service or deployment must fail closed"); + assert!(error.contains('8') && error.contains('9'), "{error}"); } -} -/// Validate a service-version string is a plain non-negative integer -/// before it is interpolated into an API URL. Returns the parsed value -/// so callers can reuse it. -fn validate_version_str(version: &str) -> Result { - version.parse::().map_err(|err| { - format!("invalid version {version:?}: expected a non-negative integer: {err}") - }) -} + #[test] + fn deploy_plan_final_argument_parser_accepts_comment_and_global_booleans() { + let parsed = parse_release_managed_deploy_args(&owned(&[ + "--comment=publisher deployment", + "--accept-defaults", + "-d", + "--auto-yes", + "-y", + "--debug-mode", + "--non-interactive", + "-i", + "--quiet", + "-q", + "--verbose", + "-v", + ])) + .expect("final managed argument forms"); + assert_eq!(parsed.comment.as_deref(), Some("publisher deployment")); + assert_eq!( + parsed.globals, + owned(&[ + "--accept-defaults", + "-d", + "--auto-yes", + "-y", + "--debug-mode", + "--non-interactive", + "-i", + "--quiet", + "-q", + "--verbose", + "-v" + ]) + ); -/// Validate a domain is a plausible hostname before it is placed into a -/// `curl` URL. Rejects anything outside the DNS label charset -/// (`[A-Za-z0-9-.]`), empty / over-long values, leading/trailing dots, -/// and empty labels so an injected quote / slash / space / newline -/// cannot smuggle curl options or a second URL. -fn validate_domain(domain: &str) -> Result<(), String> { - let charset_ok = domain - .chars() - .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '.'); - let shape_ok = !domain.is_empty() - && domain.len() <= 253 - && !domain.starts_with('.') - && !domain.ends_with('.') - && !domain.contains(".."); - if charset_ok && shape_ok { - Ok(()) - } else { - Err(format!( - "invalid domain {domain:?}: expected a hostname like `example.com`" - )) + let detached = + parse_release_managed_deploy_args(&owned(&["--comment", "publisher deployment"])) + .expect("detached comment form"); + assert_eq!(detached.comment.as_deref(), Some("publisher deployment")); } -} -/// `GET https://api.fastly.com` with the `Fastly-Key` header; -/// returns the response body ONLY on a 2xx status. Both the header (carrying the -/// secret token) and the URL are written through `curl_quote` so neither can -/// inject curl options into the `--config` document. -/// -/// The HTTP status is captured explicitly via `write-out` (as the PUT helper -/// does) and required to be 2xx before the body is trusted. `--fail` alone would -/// reject 4xx/5xx but still accept a 3xx — whose (array-shaped) body could -/// otherwise be parsed as version data. No `location` directive is set, so a -/// redirect is never followed. -fn fastly_api_get(path: &str, token: &str) -> Result { - let header = curl_quote(&format!("Fastly-Key: {token}")); - let url = curl_quote(&format!("https://api.fastly.com{path}")); - // `write-out` appends the status on its own trailing line AFTER the body. - let config = format!("header = {header}\nurl = {url}\nwrite-out = \"\\n%{{http_code}}\"\n"); - let out = curl_config_capture(&config) - .map_err(|err| format!("Fastly API GET {path} failed: {err}"))?; - let (body, status_line) = out - .rsplit_once('\n') - .ok_or_else(|| format!("Fastly API GET {path}: no HTTP status in the curl output"))?; - let status: u16 = status_line.trim().parse().map_err(|err| { - format!( - "Fastly API GET {path}: could not parse the HTTP status {:?}: {err}", - status_line.trim() - ) - })?; - if !(200..300).contains(&status) { - return Err(format!("Fastly API GET {path} returned HTTP {status}")); + #[test] + fn deploy_plan_final_argument_parser_rejects_every_package_spelling() { + for args in [ + owned(&["--package", "app.tar.gz"]), + owned(&["--package=app.tar.gz"]), + owned(&["-p", "app.tar.gz"]), + owned(&["-p=app.tar.gz"]), + owned(&["-papp.tar.gz"]), + ] { + let error = parse_release_managed_deploy_args(&args) + .expect_err("the immutable release owns the package path"); + assert!(error.contains("--package/-p"), "{args:?}: {error}"); + } } - Ok(body.to_owned()) -} -/// `PUT https://api.fastly.com` with the `Fastly-Key` header; -/// returns the HTTP status, erroring on non-2xx. Fastly's version -/// activate/deactivate endpoints require `PUT` (not `POST`). Header and -/// URL are escaped via `curl_quote`; the literal `request`, `output`, -/// and `write-out` directives are fixed constants. -fn fastly_api_put(path: &str, token: &str) -> Result { - let header = curl_quote(&format!("Fastly-Key: {token}")); - let url = curl_quote(&format!("https://api.fastly.com{path}")); - let config = format!( - "request = \"PUT\"\nheader = {header}\nurl = {url}\noutput = \"/dev/null\"\nwrite-out = \"%{{http_code}}\"\n" - ); - let out = curl_config_capture(&config)?; - let code: u16 = out.trim().parse().map_err(|err| { - format!( - "could not parse HTTP status from curl output {:?}: {err}", - out.trim() - ) - })?; - if (200..300).contains(&code) { - Ok(code) - } else { - Err(format!("Fastly API PUT {path} returned HTTP {code}")) + #[cfg(unix)] + #[test] + fn deploy_environment_parent_overrides_defaults_then_falls_back_to_logical_ids() { + let _lock = path_mutation_guard().lock().expect("guard"); + let config_name = "EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME"; + let kv_name = "EDGEZERO__STORES__KV__SESSIONS__NAME"; + let config_key = "EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY"; + let _parent = EnvOverride::set(config_name, "parent_config"); + let _no_parent_kv = EnvOverride::remove(kv_name); + let _parent_key = EnvOverride::set(config_key, "parent_key"); + let context = AdapterDeployContext { + stores: DeployStoreIds { + config: vec!["app_config".to_owned(), "feature_flags".to_owned()], + kv: vec!["sessions".to_owned()], + secrets: vec!["default".to_owned()], + }, + variable_defaults: BTreeMap::from([ + (config_name.to_owned(), "manifest_config".to_owned()), + (config_key.to_owned(), "manifest_key".to_owned()), + (kv_name.to_owned(), "manifest_sessions".to_owned()), + ]), + ..AdapterDeployContext::default() + }; + + let environment = + effective_deploy_environment(&context).expect("valid effective environment"); + assert_eq!( + environment.store_name("config", "app_config"), + "parent_config", + "the parent process must override the manifest default" + ); + assert_eq!( + environment.store_name("kv", "sessions"), + "manifest_sessions", + "the manifest default must fill an absent parent value" + ); + assert_eq!( + environment.store_key("config", "app_config"), + "parent_key", + "the parent config key must override the manifest default" + ); + assert_eq!( + environment.store_name("config", "feature_flags"), + "feature_flags", + "an absent selector must fall back to the logical ID" + ); + assert_eq!( + environment.store_name("secrets", "default"), + "default", + "the logical id must fill an absent optional Secret Store selector" + ); } -} -/// Resolve the directory containing the Fastly manifest for a deploy -/// (production [`deploy`] or [`deploy_staging`]). -/// -/// 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); - 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")); + #[cfg(unix)] + #[test] + fn deploy_environment_rejects_invalid_present_store_selectors() { + let _lock = path_mutation_guard().lock().expect("guard"); + let selector = "EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME"; + let _no_parent_selector = EnvOverride::remove(selector); + let base = AdapterDeployContext { + stores: DeployStoreIds { + config: vec!["app_config".to_owned()], + ..DeployStoreIds::default() + }, + ..AdapterDeployContext::default() + }; + + let mut invalid_selector = base.clone(); + invalid_selector + .variable_defaults + .insert(selector.to_owned(), String::new()); + let selector_error = effective_deploy_environment(&invalid_selector) + .expect_err("a present empty selector must not fall back to the logical id"); + assert!( + selector_error.contains(selector), + "error names invalid selector: {selector_error}" + ); + + let _invalid_parent_selector = EnvOverride::set(selector, "bad\nselector"); + let mut invalid_parent = base.clone(); + invalid_parent + .variable_defaults + .insert(selector.to_owned(), "valid_manifest_selector".to_owned()); + let parent_error = effective_deploy_environment(&invalid_parent) + .expect_err("an invalid parent selector must not fall back to a valid default"); + assert!( + parent_error.contains(selector), + "error names parent selector: {parent_error}" + ); } - let manifest = - find_fastly_manifest(env::current_dir().map_err(|err| err.to_string())?.as_path())?; - manifest - .parent() - .map(Path::to_path_buf) - .ok_or_else(|| "fastly manifest has no parent directory".to_owned()) -} -/// `deploy --adapter fastly --service-id --staging`: -/// build, upload to a new draft version (no activation), stage it, and -/// emit `version=`. -fn deploy_staging(args: &[String]) -> Result<(), String> { - let service_id = resolve_service_id(args)?; - 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 = 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 - // `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"), - "--manifest-path", - ); - let passthrough = split_staging_passthrough(&extra); - if !passthrough.dropped.is_empty() { - log::warn!( - "[edgezero] ignoring deploy args not supported by `fastly compute update`: {}", - passthrough.dropped.join(" ") + #[cfg(unix)] + #[test] + fn deploy_environment_rejects_invalid_value_before_provider_cli() { + 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("manifest"); + let marker = dir.path().join("provider.log"); + let fake = fake_provider_invocation_marker(&marker); + let _path = PathPrepend::new(fake.path()); + let _token = EnvOverride::set(FASTLY_API_TOKEN_ENV, "token"); + let selector = "EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME"; + let _no_parent_selector = EnvOverride::remove(selector); + let context = AdapterDeployContext { + adapter_manifest_path: Some(manifest), + application_manifest_path: None, + application_release_root: None, + service_id: Some("SVC1".to_owned()), + staging: true, + stores: DeployStoreIds { + config: vec!["app_config".to_owned()], + ..DeployStoreIds::default() + }, + variable_defaults: BTreeMap::from([(selector.to_owned(), String::new())]), + }; + + let error = FastlyCliAdapter + .deploy(&context, &[]) + .expect_err("invalid runtime environment must fail managed deploy"); + assert!(error.contains(selector), "error names selector: {error}"); + assert_provider_not_invoked(&marker); + } + + // ── non-interactive CI safety (`--non-interactive`) ─────────────── + + #[test] + fn build_compute_deploy_args_is_non_interactive() { + // Without this a production deploy can block on an interactive + // prompt in CI. + let argv = build_compute_deploy_args(&owned(&["--service-id", "SVC1"])); + assert_eq!( + argv, + owned(&[ + "compute", + "deploy", + "--service-id", + "SVC1", + "--non-interactive" + ]) ); } - // 1. Build the wasm package (no deploy / activation). - run_fastly_status( - &[ - "compute".to_owned(), - "build".to_owned(), - "--non-interactive".to_owned(), - ], - manifest_dir, - )?; + #[test] + fn build_compute_deploy_args_does_not_duplicate_caller_flag() { + for flag in ["--non-interactive", "-i"] { + let argv = build_compute_deploy_args(&owned(&[flag])); + assert_eq!( + argv.iter() + .filter(|arg| *arg == "--non-interactive" || *arg == "-i") + .count(), + 1, + "must not pass the non-interactive switch twice ({flag})" + ); + } + } - // 2. Clone the active version into a new draft and upload the - // package to it — `--autoclone` + `--version=active` keeps - // production traffic on the currently-active version. - let mut update = vec![ - "compute".to_owned(), - "update".to_owned(), - "--autoclone".to_owned(), - format!("--service-id={service_id}"), - "--version=active".to_owned(), - ]; - update.extend(passthrough.forwarded.iter().cloned()); - if !has_non_interactive(&passthrough.forwarded) { - update.push("--non-interactive".to_owned()); + // ── healthcheck / rollback input validation ─────────────────────── + // + // GitHub Actions' `required: true` does NOT fail when an input is + // omitted or empty, so the CLI is the real guard. An absent / empty / + // malformed `--service-id` or `--version` must be rejected on BOTH + // the production and the staging path — a production healthcheck + // that probes anyway "verifies" a version it never looked at, and + // the caller chains that same version into rollback. + + #[test] + fn healthcheck_rejects_missing_or_empty_required_values_on_production() { + for (args, needle) in [ + ( + owned(&["--domain", "example.com", "--service-id", "SVC1"]), + "--version", + ), + ( + owned(&[ + "--domain", + "example.com", + "--service-id", + "SVC1", + "--version", + "", + ]), + "invalid version", + ), + ( + owned(&[ + "--domain", + "example.com", + "--service-id", + "SVC1", + "--version", + "15.2.0", + ]), + "invalid version", + ), + ( + owned(&[ + "--domain", + "example.com", + "--service-id", + "", + "--version", + "7", + ]), + "invalid service id", + ), + ( + owned(&["--domain", "", "--service-id", "SVC1", "--version", "7"]), + "invalid domain", + ), + ( + owned(&["--service-id", "SVC1", "--version", "7"]), + "--domain", + ), + ] { + let err = healthcheck(&args).expect_err("must reject absent/empty required value"); + assert!( + err.contains(needle), + "expected {needle:?} in error for {args:?}, got: {err}" + ); + } } - let update_out = run_fastly_capture(&update, manifest_dir)?; - // Resolve the new draft version from the update output. FAIL CLOSED: - // if the version cannot be parsed with confidence we return an error - // rather than guessing. The old fallback picked the service's - // HIGHEST version, which under concurrent deploys could silently - // stage/roll back a version created by someone else's run. - let version = parse_fastly_version(&update_out).ok_or_else(|| { - format!( - "could not determine the staged version from `fastly compute update` output; \ - refusing to guess (a wrong version would stage another deploy's changes). \ - Raw output:\n{update_out}" - ) - })?; + #[test] + fn healthcheck_rejects_empty_required_values_on_staging() { + for args in [ + owned(&[ + "--staging", + "--domain", + "example.com", + "--service-id", + "", + "--version", + "7", + ]), + owned(&[ + "--staging", + "--domain", + "example.com", + "--service-id", + "SVC1", + "--version", + "", + ]), + ] { + healthcheck(&args).expect_err("staging must reject empty required values"); + } + } - // 3. Apply the operator's `--comment` to the freshly-created draft. - // `compute update` has no `--comment`; the version comment is set - // with `service-version update`. Done BEFORE staging, while the - // version is still an editable draft (and without `--autoclone`, - // so it can never clone into yet another version). - if let Some(comment) = passthrough.comment.as_deref() { - run_fastly_status( - &[ - "service-version".to_owned(), - "update".to_owned(), - format!("--service-id={service_id}"), - format!("--version={version}"), - "--comment".to_owned(), - comment.to_owned(), - ], - manifest_dir, - )?; + #[test] + fn rollback_rejects_missing_or_invalid_required_values() { + for staging in [&[][..], &["--staging".to_owned()][..]] { + for bad in [ + owned(&["--service-id", "SVC1"]), + owned(&["--service-id", "SVC1", "--version", ""]), + owned(&["--service-id", "SVC1", "--version", "12abc"]), + owned(&["--service-id", "", "--version", "7"]), + ] { + let mut args = bad.clone(); + args.extend_from_slice(staging); + rollback(&args).expect_err("rollback must reject invalid required values"); + } + } } - // 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)?; + // ── curl-config escaping + input validation (injection defence) ─── - // 5. Mark the draft version staged (no activation). - run_fastly_status( - &[ - "service-version".to_owned(), - "stage".to_owned(), - format!("--service-id={service_id}"), - format!("--version={version}"), - ], - manifest_dir, - )?; + #[test] + fn curl_quote_escapes_quotes_and_backslashes() { + assert_eq!(curl_quote("plain"), "\"plain\""); + assert_eq!(curl_quote("a\"b"), "\"a\\\"b\""); + assert_eq!(curl_quote("a\\b"), "\"a\\\\b\""); + } - // 6. Emit the staged version (parseable contract). - log::info!("version={version}"); - Ok(()) -} + #[test] + fn curl_quote_never_emits_raw_control_characters() { + // A token carrying a `"` and a newline must not be able to + // terminate its quoted value and inject a second `url = "..."` + // directive. The `"` is escaped and the newline is folded to a + // `\n` escape so NO raw newline reaches the curl config file. + let token = "tok\"en\nurl = \"https://evil.example\""; + let quoted = curl_quote(token); + assert!(quoted.starts_with('"') && quoted.ends_with('"')); + assert!(!quoted.contains('\n'), "no raw newline: {quoted}"); + assert!(!quoted.contains('\r')); + // The only unescaped `"` are the wrapping pair; every interior + // quote is preceded by a backslash. + assert_eq!(quoted, "\"tok\\\"en\\nurl = \\\"https://evil.example\\\"\""); + // A tab folds too. + assert_eq!(curl_quote("a\tb"), "\"a\\tb\""); + } -/// Point a staged draft's `edgezero_runtime_env` link at the STAGING selector -/// store, so the staged version reads staged config. -/// -/// Why this exists: `compute update --autoclone --version=active` clones the -/// active version, and a clone inherits its resource links. Without this, a -/// staged version opens the SAME `edgezero_runtime_env` store as production and -/// therefore reads production's config key — `config push --staging` would write -/// `_staging` that nothing ever reads. Flipping the shared store's selector -/// instead is worse: it redirects production too. -/// -/// Fastly resource links are per-version and their `name` is an overridable -/// alias, so linking the staging store under the name `edgezero_runtime_env` -/// gives this draft (and only this draft) staged config. -/// -/// Fails closed: if the staging store does not exist we refuse rather than stage -/// a version that would silently serve production config. -fn relink_runtime_env_for_staging( - service_id: &str, - version: u64, - config_logical_ids: &[String], - 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() { - log::info!( - "app declares no config stores, so staged version {version} has no config selector to isolate; keeping the inherited runtime-env link" - ); - return Ok(()); + #[test] + fn validate_service_id_accepts_fastly_handle() { + validate_service_id("SU1Z0isxPaozGVKXdv0eY").expect("alphanumeric handle"); } - // Read the PRODUCTION runtime-override entries to mirror. Fail CLOSED on a - // lookup FAILURE (CLI missing / non-zero exit / schema drift) — treating - // "couldn't tell" as "no store" would stage a version that silently reads - // production config. A genuine `NotFound` is NOT a no-op here: the app - // DECLARES config (checked above), so the staged version must still be - // isolated. There is simply nothing to mirror — the twin gets only the - // derived `_staging` selectors, and the staged draft is relinked to - // it so it reads staged config while production keeps its default key. - let production = match classify_remote_config_store_in(RUNTIME_ENV_STORE_NAME, manifest_dir)? { - ConfigStoreLookup::Found(id) => read_config_store_entries(&id, manifest_dir)?, - ConfigStoreLookup::NotFound => Vec::new(), - ConfigStoreLookup::SchemaDrift(detail) => { - return Err(format!( - "could not parse `fastly config-store list --json` while resolving `{RUNTIME_ENV_STORE_NAME}` for a staged deploy: {detail}.\n Refusing to stage rather than risk serving PRODUCTION config. Pin a known-compatible fastly CLI version and retry." - )); + #[test] + fn validate_service_id_rejects_punctuation() { + for invalid in ["SVC1_", "SVC-1", "SVC__OTHER"] { + let error = validate_service_id(invalid).expect_err("punctuation is not valid"); + assert_service_id_error(&error); } - }; - - // 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. - let staging_store_name = staging_selector_store_name(service_id); - let staging_store_id = ensure_staging_selector_store(&staging_store_name, manifest_dir)?; - mirror_production_to_staging( - &production, - &staging_store_id, - service_id, - config_logical_ids, - manifest_dir, - )?; - - // Drop the inherited production link first: a version cannot carry two links - // under the same name. - let existing = run_fastly_capture( - &[ - "resource-link".to_owned(), - "list".to_owned(), - format!("--service-id={service_id}"), - format!("--version={version}"), - "--json".to_owned(), - ], - manifest_dir, - )?; - if let Some(link_id) = find_resource_link_id(&existing, RUNTIME_ENV_STORE_NAME) { - run_fastly_status( - &[ - "resource-link".to_owned(), - "delete".to_owned(), - format!("--service-id={service_id}"), - format!("--version={version}"), - format!("--id={link_id}"), - ], - manifest_dir, - )?; } - // `--name` is the alias the runtime opens; the linked STORE is the staging - // twin. No `--autoclone`: the draft is already editable, and cloning here - // would silently move us onto yet another version. - run_fastly_status( - &[ - "resource-link".to_owned(), - "create".to_owned(), - format!("--service-id={service_id}"), - format!("--version={version}"), - format!("--resource-id={staging_store_id}"), - format!("--name={RUNTIME_ENV_STORE_NAME}"), - ], - manifest_dir, - )?; + #[test] + fn validate_service_id_rejects_injection_and_empty() { + // The canonical attack: a service id that closes the url value + // and appends a second url directive. + validate_service_id("abc\nurl = \"http://evil\"").expect_err("newline injection"); + validate_service_id("abc\"def").expect_err("quote"); + validate_service_id("has space").expect_err("space"); + validate_service_id("has/slash").expect_err("slash"); + validate_service_id("").expect_err("empty"); + } - log::info!("staged version {version} now reads `{staging_store_name}` for its config selector"); - Ok(()) -} + #[cfg(unix)] + #[test] + fn deploy_rejects_invalid_service_id_before_provider_cli() { + let _lock = path_mutation_guard().lock().expect("guard"); + let app = tempdir().expect("app dir"); + let manifest = app.path().join("fastly.toml"); + fs::write(&manifest, "name = \"app\"\n").expect("manifest"); + let marker = app.path().join("provider.log"); + let fake = fake_provider_invocation_marker(&marker); + let _path = PathPrepend::new(fake.path()); + let args = owned(&[ + "--manifest-path", + manifest.to_str().expect("utf8 manifest"), + "--service-id", + "SVC-1", + ]); -/// Production companion to `deploy`: resolve the active service version via the -/// Fastly API and emit it as a `version=` line. -/// -/// Distinguishes "confirmed no active version" from an operational failure: a -/// service with no active version yet (a first-ever deploy) is NOT an error — it -/// emits an empty `version=` line and succeeds, so the caller records an empty -/// rollback target. Only a real failure (API/auth error, or a version list that -/// cannot be parsed) returns `Err`, so the caller can fail closed instead of -/// silently proceeding without a rollback target. -/// -/// `--require-active` flips the no-active-version case to an error: it is passed -/// by the production-`deploy` version fallback, where a version was JUST -/// activated, so "no active version" is not a valid first-deploy state but an -/// operational failure the CLI must not report as success. -fn emit_active_version(args: &[String]) -> Result<(), String> { - let service_id = resolve_service_id(args)?; - validate_service_id(&service_id)?; - 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)? - { - log::info!("version={version}"); - } else { - // Confirmed no active version (first-ever deploy), and it was not - // required. Emit an explicit empty line so the caller records an empty - // rollback target and succeeds — distinct from a failure (`Err`). - log::info!("version="); - log::info!( - "service {service_id} has no active version yet; emitting an empty rollback target" - ); + let error = deploy(&args).expect_err("invalid service id must fail deploy"); + assert_service_id_error(&error); + assert_provider_not_invoked(&marker); } - Ok(()) -} -/// Resolve the active version and apply the `--require-active` policy. -/// -/// `Ok(Some(n))` — a version is active. `Ok(None)` — no active version and -/// `require_active` is false (a first-ever `active-version` call; the caller -/// records an empty rollback target). `Err` — the response was malformed -/// ([`resolve_active_version`]), OR no version is active while `require_active` -/// is true. The latter is the production-`deploy` fallback: a version was JUST -/// activated, so "no active version" is an error, not a valid empty result. -fn active_version_or_require( - json: &str, - require_active: bool, - service_id: &str, -) -> Result, String> { - match resolve_active_version(json)? { - Some(version) => Ok(Some(version)), - None if require_active => Err(format!( - "the deploy reported success but the Fastly API returns no active version for service {service_id}; refusing to report a deploy with no resolvable version" - )), - None => Ok(None), + #[cfg(unix)] + #[test] + fn active_version_rejects_invalid_service_id_before_provider_api() { + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + let marker = dir.path().join("provider.log"); + let fake = fake_provider_invocation_marker(&marker); + let _path = PathPrepend::new(fake.path()); + let _token = EnvOverride::set(FASTLY_API_TOKEN_ENV, "token"); + + let error = emit_active_version(&owned(&["--service-id", "SVC_1"])) + .expect_err("invalid service id must fail active-version capture"); + assert_service_id_error(&error); + assert_provider_not_invoked(&marker); } -} -/// Require `version` to be the currently ACTIVE service version — the -/// production healthcheck's version contract. -/// -/// The production probe hits the live domain, which serves whatever version is -/// active, so "healthcheck version N" is only a true statement about N while N is -/// active. `phase` (`before probing` / `after probing`) names when the check ran, -/// so a version activated by a concurrent deploy is reported clearly rather than -/// masquerading as a healthy `version`. -fn verify_version_active( - service_id: &str, - version: u64, - token: &str, - phase: &str, -) -> Result<(), String> { - let json = fastly_api_get(&format!("/service/{service_id}/version"), token)?; - version_active_verdict(resolve_active_version(&json)?, version, service_id, phase) -} + #[cfg(unix)] + #[test] + fn healthcheck_rejects_invalid_service_id_before_provider_api() { + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + let marker = dir.path().join("provider.log"); + let fake = fake_provider_invocation_marker(&marker); + let _path = PathPrepend::new(fake.path()); + let _token = EnvOverride::set(FASTLY_API_TOKEN_ENV, "token"); + let args = owned(&[ + "--domain", + "example.com", + "--service-id", + "SVC-1", + "--version", + "7", + ]); -/// The pure decision behind [`verify_version_active`], split out so the version -/// contract is unit-testable without a live Fastly API. -fn version_active_verdict( - active: Option, - version: u64, - service_id: &str, - phase: &str, -) -> Result<(), String> { - match active { - Some(active_version) if active_version == version => Ok(()), - Some(active_version) => Err(format!( - "production healthcheck version {version} is not active {phase}: service {service_id} currently has version {active_version} active, so the live-domain probe reflects version {active_version}, not {version}" - )), - None => Err(format!( - "production healthcheck version {version} could not be confirmed active {phase}: service {service_id} has no active version" - )), + let error = healthcheck(&args).expect_err("invalid service id must fail healthcheck"); + assert_service_id_error(&error); + assert_provider_not_invoked(&marker); } -} -/// `healthcheck --adapter fastly ...`: probe the domain -/// (production) or the version's staging IP (`--staging`), retrying up -/// to `--retry` times. Emits `status-code` / `healthy` and returns -/// `Err` (non-zero exit) when unhealthy after retries. -/// -/// `--domain`, `--service-id` and `--version` are REQUIRED and validated -/// on BOTH the production and the staging path. GitHub Actions' `required: -/// true` does not actually fail a workflow when an input is omitted or -/// empty, so this is the real guard: a production healthcheck must never -/// probe on behalf of an absent/empty version it never verified — the -/// caller chains that same version into rollback. -/// -/// On the PRODUCTION path the probe reaches whatever version is live, so when a -/// token is available `version` is verified ACTIVE before and after the probe -/// (see [`verify_version_active`]); without a token the check is service-level. -fn healthcheck(args: &[String]) -> Result<(), String> { - let domain = - arg_value(args, "--domain").ok_or_else(|| "healthcheck requires --domain".to_owned())?; - validate_domain(domain)?; - let service_id = resolve_service_id(args)?; - validate_service_id(&service_id)?; - let version_str = - arg_value(args, "--version").ok_or_else(|| "healthcheck requires --version".to_owned())?; - let version = validate_version_str(version_str)?; - let path = arg_value(args, "--path").unwrap_or("/"); - validate_probe_path(path)?; - let retry = arg_value(args, "--retry") - .and_then(|value| value.parse().ok()) - .unwrap_or(3_u32); - let retry_delay = arg_value(args, "--retry-delay") - .and_then(|value| value.parse().ok()) - .unwrap_or(5_u64); - let timeout = arg_value(args, "--timeout") - .and_then(|value| value.parse().ok()) - .unwrap_or(10_u64); - // curl reads `--max-time 0` as "no limit", so a zero timeout lets a single - // probe run indefinitely. Require a positive value. - if timeout == 0 { - return Err("healthcheck --timeout must be a positive number of seconds".to_owned()); - } + #[cfg(unix)] + #[test] + fn rollback_rejects_invalid_service_id_before_provider_api() { + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + let marker = dir.path().join("provider.log"); + let fake = fake_provider_invocation_marker(&marker); + let _path = PathPrepend::new(fake.path()); + let _token = EnvOverride::set(FASTLY_API_TOKEN_ENV, "token"); + let args = owned(&[ + "--service-id", + "SVC_1", + "--version", + "7", + "--rollback-to", + "6", + ]); - let is_staging = arg_flag(args, "--staging"); - let staging_ip = if is_staging { - let token = require_token()?; - let json = fastly_api_get( - &format!("/service/{service_id}/version/{version}/domain?include=staging_ips"), - &token, - )?; - let ip = parse_staging_ip(&json).ok_or_else(|| { - format!("no staging IP found for service {service_id} version {version}") - })?; - // `find_staging_ip` searches the response structurally and could surface a - // non-address string; require a real `IpAddr` before it reaches curl's - // `--connect-to`, which also settles IPv4-vs-IPv6 formatting. - ip.parse::().map_err(|err| { - format!("resolved staging IP {ip:?} is not a valid IP address: {err}") - })?; - Some(ip) - } else { - None - }; + let error = rollback(&args).expect_err("invalid service id must fail rollback"); + assert_service_id_error(&error); + assert_provider_not_invoked(&marker); + } - // Production version contract: the probe hits the live domain, which serves - // whatever version is ACTIVE — not necessarily `version`. When a token is - // available, require `version` to be active both BEFORE and AFTER the probe, so - // a version activated concurrently (by another deploy) cannot be reported as a - // healthy `version`. Without a token the production check is inherently - // service-level — say so rather than imply a version-specific guarantee. The - // staging path already targets the specific version's staging IP, so it needs - // no such check. - let production_token = if is_staging { - None - } else { - match env::var(FASTLY_API_TOKEN_ENV) { - Ok(token) if !token.is_empty() => Some(token), - _ => { - log::info!( - "no {FASTLY_API_TOKEN_ENV} available; production healthcheck is service-level (probes the live domain for service {service_id}, not specifically version {version})" - ); - None - } - } - }; - if let Some(token) = production_token.as_deref() { - verify_version_active(&service_id, version, token, "before probing")?; + #[cfg(unix)] + #[test] + fn provision_rejects_invalid_service_id_before_provider_cli() { + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + fs::write( + dir.path().join("fastly.toml"), + "name = \"app\"\nservice_id = \"SVC-1\"\n", + ) + .expect("manifest"); + let marker = dir.path().join("provider.log"); + let fake = fake_provider_invocation_marker(&marker); + let _path = PathPrepend::new(fake.path()); + let _service_env = EnvOverride::remove(FASTLY_SERVICE_ID_ENV); + let kv = vec![ResolvedStoreId::from_logical("sessions")]; + let stores = ProvisionStores { + config: &[], + kv: &kv, + secrets: &[], + }; + + let error = FastlyCliAdapter + .provision(dir.path(), Some("fastly.toml"), None, &stores, false) + .expect_err("invalid manifest service id must fail provision"); + assert_service_id_error(&error); + assert_provider_not_invoked(&marker); } - let curl_args = build_curl_probe_args(domain, path, staging_ip.as_deref(), timeout); - let delay = Duration::from_secs(retry_delay); - let outcome = probe_with_retries(retry, || curl_status(&curl_args), || thread::sleep(delay)); - match outcome { - Ok(code) => { - // Confirm `version` is STILL active, so a deploy that activated a newer - // version during the probe+retries is not reported as a healthy `version`. - if let Some(token) = production_token.as_deref() { - verify_version_active(&service_id, version, token, "after probing")?; - } - log::info!("status-code={code}"); - log::info!("healthy=true"); - Ok(()) - } - Err((last_code, msg)) => { - if let Some(code) = last_code { - log::info!("status-code={code}"); - } - log::info!("healthy=false"); - Err(format!( - "healthcheck for {domain} failed after {} attempt(s): {msg}", - retry.max(1) - )) - } + #[cfg(unix)] + #[test] + fn adapter_deploy_rejects_invalid_service_id_before_provider_cli() { + 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\"\nservice_id = \"SVC_1\"\n").expect("manifest"); + let marker = dir.path().join("provider.log"); + let fake = fake_provider_invocation_marker(&marker); + let _path = PathPrepend::new(fake.path()); + let _service_env = EnvOverride::remove(FASTLY_SERVICE_ID_ENV); + let context = AdapterDeployContext { + adapter_manifest_path: Some(manifest), + ..AdapterDeployContext::default() + }; + + let error = FastlyCliAdapter + .deploy(&context, &[]) + .expect_err("direct adapter deploy must reject invalid service id"); + assert_service_id_error(&error); + assert_provider_not_invoked(&marker); } -} -/// Run a single `curl` health probe, returning the HTTP status. A -/// transport failure (timeout, DNS, refused) surfaces as `Err` so the -/// retry loop treats it as an unhealthy attempt. -fn curl_status(args: &[String]) -> Result { - let output = Command::new("curl").args(args).output().map_err(|err| { - if err.kind() == ErrorKind::NotFound { - "`curl` not found on PATH; install curl and retry".to_owned() - } else { - format!("failed to spawn `curl`: {err}") + #[cfg(unix)] + #[test] + fn adapter_deploy_discovers_and_rejects_invalid_service_id_before_provider_cli() { + const CHILD_ENV: &str = "EDGEZERO_FASTLY_DISCOVERY_TEST_CHILD"; + const MARKER_ENV: &str = "EDGEZERO_FASTLY_DISCOVERY_TEST_MARKER"; + + if env::var_os(CHILD_ENV).is_some() { + let marker = PathBuf::from(env::var_os(MARKER_ENV).expect("child marker path")); + let error = FastlyCliAdapter + .deploy(&AdapterDeployContext::default(), &[]) + .expect_err("discovered invalid service id must fail direct adapter deploy"); + assert_service_id_error(&error); + assert_provider_not_invoked(&marker); + return; } - })?; - if !output.status.success() { - return Err(format!( - "curl transport failure (status {}): {}", - output.status, - String::from_utf8_lossy(&output.stderr).trim() - )); - } - let stdout = String::from_utf8_lossy(&output.stdout); - stdout.trim().parse::().map_err(|err| { - format!( - "could not parse HTTP status from curl output {:?}: {err}", - stdout.trim() - ) - }) -} -/// `rollback --adapter fastly ...`: production activates the explicit -/// `--rollback-to` version (Fastly cannot infer a previous version); -/// staging deactivates ``. -fn rollback(args: &[String]) -> Result<(), String> { - let service_id = resolve_service_id(args)?; - validate_service_id(&service_id)?; - let version_str = - arg_value(args, "--version").ok_or_else(|| "rollback requires --version".to_owned())?; - let version = validate_version_str(version_str)?; - let token = require_token()?; + let dir = tempdir().expect("tempdir"); + fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"app\"\n") + .expect("Cargo manifest"); + fs::write( + dir.path().join("fastly.toml"), + "name = \"app\"\nservice_id = \"SVC-1\"\n", + ) + .expect("manifest"); + let marker = dir.path().join("provider.log"); + let fake = fake_provider_invocation_marker(&marker); + let path = env::join_paths( + once(fake.path().to_path_buf()) + .chain(env::split_paths(&env::var_os("PATH").unwrap_or_default())), + ) + .expect("test PATH"); + let output = Command::new(env::current_exe().expect("current test binary")) + .args([ + "--exact", + "cli::tests::adapter_deploy_discovers_and_rejects_invalid_service_id_before_provider_cli", + "--nocapture", + ]) + .current_dir(dir.path()) + .env(CHILD_ENV, "1") + .env(MARKER_ENV, &marker) + .env("PATH", path) + .env_remove(FASTLY_SERVICE_ID_ENV) + .output() + .expect("run isolated discovery regression"); - if arg_flag(args, "--staging") { - // Staging rollback deactivates the STAGED version on the - // `staging` environment. Fastly's environment-scoped - // deactivate is `PUT .../deactivate/staging` (a plain - // `.../deactivate` would target the production activation). - fastly_api_put( - &format!("/service/{service_id}/version/{version}/deactivate/staging"), - &token, - )?; - log::info!( - "[edgezero] deactivated staged version {version} on Fastly service {service_id}" + assert!( + output.status.success(), + "isolated discovery regression failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) ); - } else { - // Production rollback re-activates an EXPLICIT target. Fastly's version - // list has no field distinguishing a previously-live version from a - // staged one (`staging`/`deployed` are documented "Unused"; `locked` - // only means "not editable"), so the target cannot be inferred — it is - // captured before the superseding deploy and passed in as --rollback-to. - let previous = arg_value(args, "--rollback-to") - .and_then(|raw| validate_version_str(raw).ok()) - .ok_or_else(|| { - "production rollback requires a valid --rollback-to version".to_owned() - })?; - // Best-effort staleness check: the version being rolled back FROM - // (`--version`) must STILL be the active version. A rollback workflow can - // run long after its deploy — if a newer version was activated meanwhile, - // activating the old target would clobber that newer deploy, so refuse. - // - // This is NOT atomic: Fastly's activate endpoint has no precondition, so - // a deploy that lands BETWEEN this read and the activate below can still - // be clobbered. It narrows the window (catching the common much-later - // rollback) but does not close it — serialise deploys and rollbacks per - // SERVICE (a service-scoped concurrency group) to eliminate the race. - let json = fastly_api_get(&format!("/service/{service_id}/version"), &token)?; - ensure_rollback_from_is_active(resolve_active_version(&json)?, version, &service_id)?; - // Fastly's activate endpoint requires `PUT` (not `POST`). - fastly_api_put( - &format!("/service/{service_id}/version/{previous}/activate"), - &token, - )?; - log::info!("rolled-back-to={previous}"); + assert_provider_not_invoked(&marker); } - Ok(()) -} -#[cfg(test)] -mod tests { - use super::*; - use edgezero_adapter::cli_support::read_package_name; - use edgezero_core::app::{StoreMetadata, StoresMetadata}; - use edgezero_core::env_config::EnvConfig; - #[cfg(unix)] - use edgezero_core::test_env::{EnvOverride, PathPrepend}; - use std::collections::{BTreeMap, HashSet}; + #[test] + fn validate_version_str_accepts_integer_rejects_junk() { + assert_eq!(validate_version_str("42"), Ok(42)); + assert_eq!(validate_version_str("0"), Ok(0)); + validate_version_str("-1").expect_err("negative"); + validate_version_str("4.2").expect_err("float"); + validate_version_str("42\nurl = \"x\"").expect_err("newline injection"); + validate_version_str("").expect_err("empty"); + } - #[cfg(unix)] - use std::sync::Mutex; - use tempfile::tempdir; + #[test] + fn validate_domain_accepts_hostnames_rejects_injection() { + validate_domain("example.com").expect("bare hostname"); + validate_domain("staging.example.co.uk").expect("multi-label hostname"); + validate_domain("host-1.example.com").expect("hostname with dash"); + validate_domain("").expect_err("empty"); + validate_domain(".example.com").expect_err("leading dot"); + validate_domain("example.com.").expect_err("trailing dot"); + validate_domain("exa..mple.com").expect_err("empty label"); + validate_domain("example.com/evil").expect_err("slash"); + validate_domain("example.com\nurl = \"x\"").expect_err("newline injection"); + validate_domain("has space.com").expect_err("space"); + } - // Shared fixture names. Pinning these as consts (instead of - // inline `"sessions"` / `"app_config"` per call site) keeps the - // setup-vs-assertion pair in sync -- a typo in one place no - // longer silently divorces from the other, because both reference - // the same const. Also names the intent: these are the LOGICAL - // store ids the fastly adapter operates on, not arbitrary strings. - const TEST_KV_ID: &str = "sessions"; - const TEST_CONFIG_ID: &str = "app_config"; - const TEST_SECRET_ID: &str = "default"; + #[test] + fn version_active_verdict_enforces_the_production_version_contract() { + // The requested version is the active one: healthy. + version_active_verdict(Some(7), 7, "SVC1", "before probing").expect("match is ok"); + // A different active version (a concurrent deploy) must fail closed and name + // BOTH versions so the mismatch is diagnosable. + let err = version_active_verdict(Some(9), 7, "SVC1", "after probing") + .expect_err("a newer active version must fail the version contract"); + assert!(err.contains('7') && err.contains('9'), "{err}"); + // No active version at all is not a healthy version-7 report either. + version_active_verdict(None, 7, "SVC1", "before probing") + .expect_err("no active version must fail the contract"); + } - // `PathPrepend` (RAII $PATH guard) is the shared helper imported above from - // `edgezero_core::test_env`; the merge with edition-2024 main replaced our - // local copy with it (its `set_var` calls are wrapped for 2024's unsafe-env). + #[test] + fn is_healthy_status_covers_2xx_only() { + assert!(is_healthy_status(200)); + assert!(is_healthy_status(204)); + assert!(is_healthy_status(299)); + // 3xx is NOT healthy: the probe does not follow redirects, so a 301 to an + // error page must not pass a gate that suppresses an automatic rollback. + assert!(!is_healthy_status(301)); + assert!(!is_healthy_status(399)); + assert!(!is_healthy_status(400)); + assert!(!is_healthy_status(500)); + assert!(!is_healthy_status(199)); + } - // ── Fastly staging lifecycle helpers ────────────────────────────── + #[test] + fn parse_fastly_version_handles_the_shapes_fastly_emits() { + // The Fastly CLI's own success lines. Go format strings: + // "Updated package (service %s, version %v)" (compute update) + // "Deployed package (service %s, version %v)" (compute deploy) + assert_eq!( + parse_fastly_version("SUCCESS: Deployed package (service abc, version 7)"), + Some(7) + ); + assert_eq!( + parse_fastly_version("\nSUCCESS: Updated package (service SU1Z0, version 42)\n"), + Some(42) + ); + // Our canonical contract line. + assert_eq!(parse_fastly_version("version=12"), Some(12)); + // The --autoclone notice, when no success line is present. + assert_eq!( + parse_fastly_version( + "Service version 3 is not editable, so it was automatically cloned because \ + --autoclone is enabled. Now operating on version 4." + ), + Some(4) + ); + // Full autoclone + success output: the SUCCESS line wins, and the + // PRE-clone version (3) never does — even though stdout/stderr are + // concatenated and their relative order is not guaranteed. + let combined = "SUCCESS: \nUpdated package (service abc, version 4)\n\ + Service version 3 is not editable, so it was automatically cloned. \ + Now operating on version 4."; + assert_eq!(parse_fastly_version(combined), Some(4)); + assert_eq!(parse_fastly_version("no numbers here"), None); + } #[test] - fn arg_value_reads_flag_value() { - let args = vec![ - "--service-id".to_owned(), - "SVC1".to_owned(), - "--version".to_owned(), - "42".to_owned(), - ]; - assert_eq!(arg_value(&args, "--service-id"), Some("SVC1")); - assert_eq!(arg_value(&args, "--version"), Some("42")); - assert_eq!(arg_value(&args, "--missing"), None); + fn parse_fastly_version_rejects_confusable_lines() { + // The old parser took ANY digits after the word "version", so each + // of these silently produced a WRONG service version. They must now + // all be `None`, which makes managed deployment fail closed. + assert_eq!( + parse_fastly_version("Uploaded package to service 12345, version unchanged"), + None + ); + // The CLI's own semver must not be mistaken for a service version. + assert_eq!(parse_fastly_version("Fastly CLI version 15.2.0"), None); + assert_eq!( + parse_fastly_version("Checking version compatibility for service 99"), + None + ); + // A bare `version ` mention with no success-line context is not + // trusted either. + assert_eq!(parse_fastly_version("cloning version 3"), None); + // `--version=active` echoed in a command line is not a contract line. + assert_eq!( + parse_fastly_version("running: fastly compute update --version=active"), + None + ); } #[test] - fn arg_value_none_when_flag_is_last() { - let args = vec!["--version".to_owned()]; - assert_eq!(arg_value(&args, "--version"), None); + fn cloned_version_requires_a_new_version_for_the_exact_service() { + assert_eq!( + parse_cloned_version(r#"{"service_id":"svc","number":42}"#, "svc", 40), + Ok(42) + ); + for invalid in [ + r#"{"service_id":"other","number":42}"#, + r#"{"service_id":"svc","number":40}"#, + r#"{"service_id":"svc","number":"42"}"#, + "[]", + "not json", + ] { + parse_cloned_version(invalid, "svc", 40) + .expect_err("an ambiguous clone response must fail closed"); + } } #[test] - fn arg_flag_detects_presence() { - let args = vec!["--staging".to_owned()]; - assert!(arg_flag(&args, "--staging")); - assert!(!arg_flag(&args, "--nope")); + fn parse_active_version_finds_active_entry() { + let json = r#"[ + {"number":1,"active":false,"locked":true,"staging":false,"deployed":true,"environments":[]}, + {"number":2,"active":true,"locked":true,"staging":false,"deployed":true,"environments":[]}, + {"number":3,"active":false,"locked":false,"staging":false,"deployed":false,"environments":[]} + ]"#; + assert_eq!(resolve_active_version(json), Ok(Some(2))); } #[test] - fn args_without_flag_value_strips_pair() { - let args = vec![ - "--service-id".to_owned(), - "SVC1".to_owned(), - "--comment".to_owned(), - "ci".to_owned(), - ]; + fn deploy_plan_version_source_parses_state_and_selects_active() { + let versions = parse_service_versions( + r#"[ + {"number":1,"active":false,"locked":true,"staging":false,"deployed":true,"environments":[{"active_version":2,"name":"production","service_id":"SVC1"}]}, + {"number":2,"active":true,"locked":true,"staging":false,"deployed":true,"environments":[{"active_version":2,"name":"production","service_id":"SVC1"}]} + ]"#, + ) + .expect("typed version list"); + assert_eq!(versions[1].number, 2); + assert!(versions[1].active); + assert!(versions[1].locked); + assert_eq!(versions[1].environments[0].name, "production"); assert_eq!( - args_without_flag_value(&args, "--service-id"), - vec!["--comment".to_owned(), "ci".to_owned()] + select_version_source(&versions), + Ok(VersionSource::Active(2)) + ); + } + + #[test] + fn deploy_plan_version_source_parses_complete_fastly_api_records() { + let versions = parse_service_versions( + r#"[{ + "active":true, + "comment":"publisher release", + "created_at":"2026-09-16T00:00:00Z", + "deployed":true, + "environments":[{ + "active_version":7, + "name":"production", + "service_id":"SVC1" + }], + "locked":true, + "number":7, + "service_id":"SVC1", + "staging":false, + "testing":false, + "updated_at":"2026-09-16T00:00:00Z" + }]"#, + ) + .expect("authoritative Fastly API version list"); + assert_eq!(versions[0].environments[0].active_version, 7); + assert_eq!(versions[0].environments[0].name, "production"); + assert_eq!(versions[0].environments[0].service_id, "SVC1"); + assert_eq!( + select_version_source(&versions), + Ok(VersionSource::Active(7)) + ); + } + + #[test] + fn version_source_parses_fastly_cli_15_1_capitalized_records() { + let versions = parse_service_versions( + r#"[{ + "Active":true, + "Comment":"publisher release", + "Deployed":true, + "Environments":[{ + "ServiceVersion":7, + "Name":"production", + "ServiceID":"SVC1" + }], + "Locked":true, + "Number":7, + "ServiceID":"SVC1", + "Staging":false, + "Testing":false + }]"#, + ) + .expect("Fastly CLI 15.1 version JSON"); + assert_eq!(versions[0].environments[0].name, "production"); + assert_eq!( + select_version_source(&versions), + Ok(VersionSource::Active(7)) ); } #[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"); - assert_eq!(dir, PathBuf::from("/repo/apps/edge")); - } - - #[test] - fn resolve_service_id_prefers_flag() { - let args = vec!["--service-id".to_owned(), "SVCFROMARG".to_owned()]; - assert_eq!(resolve_service_id(&args).unwrap(), "SVCFROMARG"); - } - - // ── `compute update` passthrough filtering (`--comment`) ───────── + fn deploy_plan_version_source_rejects_every_missing_authoritative_field() { + const VERSION_NUMBER: u64 = 1; - fn owned(args: &[&str]) -> Vec { - args.iter().map(|arg| (*arg).to_owned()).collect() + let complete = serde_json::json!({ + "active": false, + "deployed": false, + "environments": [], + "locked": false, + "number": VERSION_NUMBER, + "staging": false + }); + for field in ["active", "environments", "locked", "number"] { + let mut record = complete.clone(); + record + .as_object_mut() + .expect("version object") + .remove(field); + let raw = serde_json::json!([record]).to_string(); + parse_service_versions(&raw).expect_err(&format!("missing `{field}` must fail closed")); + } } #[test] - fn split_staging_passthrough_lifts_comment_out_of_compute_update() { - // `fastly compute update` has NO `--comment` flag (verified against - // `fastly compute update --help`, CLI v15) — forwarding it makes the - // command exit non-zero and fails the whole staged deploy. It must be - // lifted out and applied via `service-version update` instead. - for args in [owned(&["--comment", "ci run 12"]), owned(&["--comment=x"])] { - let split = split_staging_passthrough(&args); - assert!( - !split - .forwarded - .iter() - .any(|arg| arg.starts_with("--comment")), - "--comment must never reach `compute update`: {:?}", - split.forwarded - ); - assert!( - split.comment.is_some(), - "comment must be captured: {args:?}" - ); - } - assert_eq!( - split_staging_passthrough(&owned(&["--comment", "ci run 12"])).comment, - Some("ci run 12".to_owned()) - ); + fn deploy_plan_version_source_selects_unique_initialized_draft() { + let versions = parse_service_versions( + r#"[ + {"number":1,"active":false,"locked":true,"staging":false,"deployed":false,"environments":[]}, + {"number":2,"active":false,"locked":false,"staging":true,"deployed":true,"environments":[]} + ]"#, + ) + .expect("typed version list"); assert_eq!( - split_staging_passthrough(&owned(&["--comment=x"])).comment, - Some("x".to_owned()) + select_version_source(&versions), + Ok(VersionSource::InitialDraft(2)) ); } #[test] - fn split_staging_passthrough_forwards_supported_flags_only() { - let args = owned(&[ - "--package", - "pkg.tar.gz", - "--autoclone", - "--verbose", - "--comment", - "note", - "--env", - "stage", - "--status-check-off", - ]); - let split = split_staging_passthrough(&args); - // Supported by `compute update`: kept (value flags keep their value). + fn deploy_plan_version_source_selects_unique_shadow_staging_source_without_active() { + let versions = parse_service_versions( + r#"[{"number":3,"active":false,"locked":false,"staging":false,"deployed":false,"environments":[{"active_version":3,"name":"staging","service_id":"shadow-staging-service"}]}]"#, + ) + .expect("staged version list"); assert_eq!( - split.forwarded, - owned(&["--package", "pkg.tar.gz", "--autoclone", "--verbose"]) + select_version_source(&versions), + Ok(VersionSource::Staging(3)) ); - // `--env`/`--status-check-off` are `compute deploy` flags, not - // `compute update` ones: dropped, and `--env`'s detached value - // `stage` is dropped with it (never left as a bogus positional). - assert_eq!(split.dropped, owned(&["--env", "--status-check-off"])); - assert!(!split.forwarded.iter().any(|arg| arg == "stage")); - assert_eq!(split.comment, Some("note".to_owned())); } - // ── non-interactive CI safety (`--non-interactive`) ─────────────── - #[test] - fn build_compute_deploy_args_is_non_interactive() { - // Without this a production deploy can block on an interactive - // prompt in CI. - let argv = build_compute_deploy_args(&owned(&["--service-id", "SVC1"])); + fn deploy_plan_version_source_recovers_first_staging_deactivation() { + let versions = parse_service_versions( + r#"[{"number":1,"active":false,"locked":true,"staging":false,"deployed":false,"environments":[]}]"#, + ) + .expect("retired first staging version"); assert_eq!( - argv, - owned(&[ - "compute", - "deploy", - "--service-id", - "SVC1", - "--non-interactive" - ]) + select_version_source(&versions), + Ok(VersionSource::Retired(1)) ); } #[test] - fn build_compute_deploy_args_does_not_duplicate_caller_flag() { - for flag in ["--non-interactive", "-i"] { - let argv = build_compute_deploy_args(&owned(&[flag])); - assert_eq!( - argv.iter() - .filter(|arg| *arg == "--non-interactive" || *arg == "-i") - .count(), - 1, - "must not pass the non-interactive switch twice ({flag})" - ); - } + fn deploy_plan_version_source_rejects_retry_draft_beside_staging() { + let versions = parse_service_versions( + r#"[ + {"number":1,"active":false,"locked":true,"environments":[{"active_version":1,"name":"staging","service_id":"shadow-staging-service"}]}, + {"number":2,"active":false,"locked":false,"environments":[]} + ]"#, + ) + .expect("staged source and one retry draft"); + select_version_source(&versions) + .expect_err("a retry draft must not replace the real staged source"); } - // ── healthcheck / rollback input validation ─────────────────────── - // - // GitHub Actions' `required: true` does NOT fail when an input is - // omitted or empty, so the CLI is the real guard. An absent / empty / - // malformed `--service-id` or `--version` must be rejected on BOTH - // the production and the staging path — a production healthcheck - // that probes anyway "verifies" a version it never looked at, and - // the caller chains that same version into rollback. - #[test] - fn healthcheck_rejects_missing_or_empty_required_values_on_production() { - for (args, needle) in [ - ( - owned(&["--domain", "example.com", "--service-id", "SVC1"]), - "--version", - ), - ( - owned(&[ - "--domain", - "example.com", - "--service-id", - "SVC1", - "--version", - "", - ]), - "invalid version", - ), - ( - owned(&[ - "--domain", - "example.com", - "--service-id", - "SVC1", - "--version", - "15.2.0", - ]), - "invalid version", - ), - ( - owned(&[ - "--domain", - "example.com", - "--service-id", - "", - "--version", - "7", - ]), - "invalid service id", - ), - ( - owned(&["--domain", "", "--service-id", "SVC1", "--version", "7"]), - "invalid domain", - ), - ( - owned(&["--service-id", "SVC1", "--version", "7"]), - "--domain", - ), + fn deploy_plan_version_source_rejects_missing_duplicate_or_ambiguous_staging_source() { + for invalid in [ + r#"[{"number":2,"active":false,"locked":true,"staging":false,"deployed":false,"environments":[{"active_version":2,"name":"staging","service_id":"shadow-staging-service"}]},{"number":3,"active":false,"locked":true,"staging":false,"deployed":true,"environments":[{"active_version":3,"name":"staging","service_id":"shadow-staging-service"}]}]"#, + r#"[{"number":3,"active":false,"locked":true,"staging":false,"deployed":true,"environments":[{"active_version":2,"name":"staging","service_id":"shadow-staging-service"}]}]"#, + r#"[{"number":3,"active":false,"locked":true,"staging":true,"deployed":true,"environments":[{"active_version":3,"name":"production","service_id":"SVC1"}]}]"#, ] { - let err = healthcheck(&args).expect_err("must reject absent/empty required value"); + let versions = parse_service_versions(invalid).expect("well-formed version list"); assert!( - err.contains(needle), - "expected {needle:?} in error for {args:?}, got: {err}" + select_version_source(&versions).is_err(), + "unsafe staged source must fail closed: {invalid}" ); } } #[test] - fn healthcheck_rejects_empty_required_values_on_staging() { - for args in [ - owned(&[ - "--staging", - "--domain", - "example.com", - "--service-id", - "", - "--version", - "7", - ]), - owned(&[ - "--staging", - "--domain", - "example.com", - "--service-id", - "SVC1", - "--version", - "", - ]), + fn deploy_plan_version_source_rejects_missing_duplicate_and_malformed_versions() { + for invalid in [ + "[]", + r#"[{"number":1},{"number":1}]"#, + r#"[{"number":"1"}]"#, + r#"[{"number":1,"active":"false"}]"#, + r#"[{"number":1,"locked":"false"}]"#, + r#"[{"number":1,"environments":"staging"}]"#, ] { - healthcheck(&args).expect_err("staging must reject empty required values"); + assert!( + parse_service_versions(invalid).is_err(), + "invalid version list must fail: {invalid}" + ); } } #[test] - fn rollback_rejects_missing_or_invalid_required_values() { - for staging in [&[][..], &["--staging".to_owned()][..]] { - for bad in [ - owned(&["--service-id", "SVC1"]), - owned(&["--service-id", "SVC1", "--version", ""]), - owned(&["--service-id", "SVC1", "--version", "12abc"]), - owned(&["--service-id", "", "--version", "7"]), - ] { - let mut args = bad.clone(); - args.extend_from_slice(staging); - rollback(&args).expect_err("rollback must reject invalid required values"); - } - } + fn deploy_plan_version_source_ignores_unused_deployed_and_staging_fields() { + let versions = parse_service_versions( + r#"[{"number":1,"active":false,"locked":false,"staging":"unused","deployed":{"unused":true},"environments":[]}]"#, + ) + .expect("unused provider fields must not control version state"); + assert_eq!( + select_version_source(&versions), + Ok(VersionSource::InitialDraft(1)) + ); } - // ── curl-config escaping + input validation (injection defence) ─── - #[test] - fn curl_quote_escapes_quotes_and_backslashes() { - assert_eq!(curl_quote("plain"), "\"plain\""); - assert_eq!(curl_quote("a\"b"), "\"a\\\"b\""); - assert_eq!(curl_quote("a\\b"), "\"a\\\\b\""); + fn deploy_plan_version_source_rejects_unsafe_or_ambiguous_first_drafts() { + for invalid in [ + r#"[{"number":1,"active":false,"locked":false,"staging":false,"deployed":false,"environments":[]},{"number":2,"active":false,"locked":true,"staging":false,"deployed":false,"environments":[]}]"#, + r#"[{"number":1,"active":false,"locked":false,"staging":false,"deployed":false,"environments":[]},{"number":2,"active":false,"locked":false,"staging":true,"deployed":false,"environments":[]}]"#, + r#"[{"number":1,"active":false,"locked":false,"staging":false,"deployed":false,"environments":[]},{"number":2,"active":false,"locked":false,"staging":false,"deployed":true,"environments":[]}]"#, + r#"[{"number":1,"active":false,"locked":false,"staging":false,"deployed":false,"environments":[]},{"number":2,"active":false,"locked":false,"staging":false,"deployed":false,"environments":[{"active_version":2,"name":"staging","service_id":"SVC1"}]}]"#, + r#"[{"number":1,"active":false,"locked":false,"staging":false,"deployed":false,"environments":[]},{"number":2,"active":false,"locked":false,"staging":false,"deployed":false,"environments":[]}]"#, + ] { + let versions = parse_service_versions(invalid).expect("well-formed versions"); + assert!( + select_version_source(&versions).is_err(), + "unsafe first-deploy source must fail: {invalid}" + ); + } } - #[test] - fn curl_quote_never_emits_raw_control_characters() { - // A token carrying a `"` and a newline must not be able to - // terminate its quoted value and inject a second `url = "..."` - // directive. The `"` is escaped and the newline is folded to a - // `\n` escape so NO raw newline reaches the curl config file. - let token = "tok\"en\nurl = \"https://evil.example\""; - let quoted = curl_quote(token); - assert!(quoted.starts_with('"') && quoted.ends_with('"')); - assert!(!quoted.contains('\n'), "no raw newline: {quoted}"); - assert!(!quoted.contains('\r')); - // The only unescaped `"` are the wrapping pair; every interior - // quote is preceded by a backslash. - assert_eq!(quoted, "\"tok\\\"en\\nurl = \\\"https://evil.example\\\"\""); - // A tab folds too. - assert_eq!(curl_quote("a\tb"), "\"a\\tb\""); + fn deploy_plan_inventories() -> ResourceInventories { + ResourceInventories::from_json( + r#"[ + {"id":"CONFIG_A","name":"config-a"}, + {"id":"CONFIG_B","name":"config-b"} + ]"#, + r#"[{"id":"KV_A","name":"kv-a"},{"id":"KV_B","name":"kv-b"}]"#, + r#"[{"id":"SECRET_A","name":"secret-a"}]"#, + ) + .expect("valid inventories") } - #[test] - fn validate_service_id_accepts_fastly_handle() { - validate_service_id("SU1Z0isxPaozGVKXdv0eY").expect("alphanumeric handle"); + fn desired_link( + kind: ResourceKind, + alias: &str, + selected_name: &str, + resource_id: &str, + ) -> DesiredResourceLink { + DesiredResourceLink { + alias: alias.to_owned(), + kind, + resource_id: resource_id.to_owned(), + selected_name: selected_name.to_owned(), + } } - #[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"); + fn existing_link( + kind: ResourceKind, + alias: &str, + resource_id: &str, + link_id: &str, + ) -> ExistingResourceLink { + ExistingResourceLink { + alias: alias.to_owned(), + kind, + link_id: link_id.to_owned(), + resource_id: resource_id.to_owned(), + } } #[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 deploy_plan_uses_logical_aliases_and_selected_physical_resources() { + let environment = EnvConfig::from_vars([ + ("EDGEZERO__STORES__CONFIG__SHARED__NAME", "config-a"), + ("EDGEZERO__STORES__KV__SHARED__NAME", "kv-a"), + ("EDGEZERO__STORES__SECRETS__CREDENTIALS__NAME", "secret-a"), + ]); + let desired = desired_resource_links( + &RuntimeStoreIds { + config: vec!["shared".to_owned()], + kv: vec!["shared".to_owned()], + secrets: vec!["credentials".to_owned()], + }, + &environment, + &deploy_plan_inventories(), + ) + .expect("desired links"); + + assert_eq!( + desired, + vec![ + desired_link(ResourceKind::Config, "shared", "config-a", "CONFIG_A"), + desired_link(ResourceKind::Kv, "shared", "kv-a", "KV_A"), + desired_link(ResourceKind::Secret, "credentials", "secret-a", "SECRET_A",), + ] ); } #[test] - fn validate_service_id_rejects_injection_and_empty() { - // The canonical attack: a service id that closes the url value - // and appends a second url directive. - validate_service_id("abc\nurl = \"http://evil\"").expect_err("newline injection"); - validate_service_id("abc\"def").expect_err("quote"); - validate_service_id("has space").expect_err("space"); - validate_service_id("has/slash").expect_err("slash"); - validate_service_id("").expect_err("empty"); + fn deploy_plan_rejects_present_invalid_store_name_before_inventory_lookup() { + for value in ["", " ", "bad\nname"] { + let environment = + EnvConfig::from_vars([("EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME", value)]); + let error = desired_resource_links( + &RuntimeStoreIds { + config: vec!["app_config".to_owned()], + ..RuntimeStoreIds::default() + }, + &environment, + &deploy_plan_inventories(), + ) + .expect_err("present invalid selector must fail"); + assert!( + error.contains("EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME"), + "invalid selector error must name its canonical variable" + ); + } } #[test] - fn validate_version_str_accepts_integer_rejects_junk() { - assert_eq!(validate_version_str("42"), Ok(42)); - assert_eq!(validate_version_str("0"), Ok(0)); - validate_version_str("-1").expect_err("negative"); - validate_version_str("4.2").expect_err("float"); - validate_version_str("42\nurl = \"x\"").expect_err("newline injection"); - validate_version_str("").expect_err("empty"); + fn deploy_plan_replaces_declared_identity_and_preserves_undeclared_links() { + let desired = vec![desired_link( + ResourceKind::Config, + "app_config", + "config-b", + "CONFIG_B", + )]; + let existing = vec![ + existing_link(ResourceKind::Config, "app_config", "CONFIG_A", "OLD_CONFIG"), + existing_link(ResourceKind::Kv, "sessions", "KV_A", "KEEP_KV"), + ]; + + let plan = plan_link_reconciliation(&desired, &existing, &deploy_plan_inventories()) + .expect("reconciliation"); + assert_eq!(plan.delete_link_ids, vec!["OLD_CONFIG"]); + assert_eq!(plan.create, desired); + } + + #[test] + fn deploy_plan_preserves_undeclared_link_absent_from_visible_inventories() { + let existing = vec![existing_link( + ResourceKind::Secret, + "shared_by_another_app", + "INACCESSIBLE_SECRET", + "KEEP_SECRET", + )]; + + let plan = plan_link_reconciliation(&[], &existing, &deploy_plan_inventories()) + .expect("an undeclared inherited link does not require inventory visibility"); + assert!(plan.delete_link_ids.is_empty()); + assert!(plan.create.is_empty()); } #[test] - fn validate_domain_accepts_hostnames_rejects_injection() { - validate_domain("example.com").expect("bare hostname"); - validate_domain("staging.example.co.uk").expect("multi-label hostname"); - validate_domain("host-1.example.com").expect("hostname with dash"); - validate_domain("").expect_err("empty"); - validate_domain(".example.com").expect_err("leading dot"); - validate_domain("example.com.").expect_err("trailing dot"); - validate_domain("exa..mple.com").expect_err("empty label"); - validate_domain("example.com/evil").expect_err("slash"); - validate_domain("example.com\nurl = \"x\"").expect_err("newline injection"); - validate_domain("has space.com").expect_err("space"); + fn deploy_plan_keeps_same_alias_isolated_by_resource_kind() { + let desired = vec![ + desired_link(ResourceKind::Config, "shared", "config-a", "CONFIG_A"), + desired_link(ResourceKind::Kv, "shared", "kv-b", "KV_B"), + ]; + let existing = vec![ + existing_link(ResourceKind::Config, "shared", "CONFIG_A", "CONFIG_LINK"), + existing_link(ResourceKind::Kv, "shared", "KV_A", "KV_LINK"), + ]; + + let plan = plan_link_reconciliation(&desired, &existing, &deploy_plan_inventories()) + .expect("kind-isolated reconciliation"); + assert_eq!(plan.delete_link_ids, vec!["KV_LINK"]); + assert_eq!( + plan.create, + vec![desired_link(ResourceKind::Kv, "shared", "kv-b", "KV_B")] + ); } #[test] - fn version_active_verdict_enforces_the_production_version_contract() { - // The requested version is the active one: healthy. - version_active_verdict(Some(7), 7, "SVC1", "before probing").expect("match is ok"); - // A different active version (a concurrent deploy) must fail closed and name - // BOTH versions so the mismatch is diagnosable. - let err = version_active_verdict(Some(9), 7, "SVC1", "after probing") - .expect_err("a newer active version must fail the version contract"); - assert!(err.contains('7') && err.contains('9'), "{err}"); - // No active version at all is not a healthy version-7 report either. - version_active_verdict(None, 7, "SVC1", "before probing") - .expect_err("no active version must fail the contract"); + fn deploy_plan_rejects_reported_kind_that_conflicts_with_inventory() { + let existing = vec![existing_link( + ResourceKind::Secret, + "credentials", + "CONFIG_A", + "BAD_KIND", + )]; + let error = plan_link_reconciliation(&[], &existing, &deploy_plan_inventories()) + .expect_err("kind conflict must fail"); + assert!(error.contains("reports Secret Store"), "{error}"); + assert!(error.contains("belongs to Config Store"), "{error}"); } #[test] - fn is_healthy_status_covers_2xx_only() { - assert!(is_healthy_status(200)); - assert!(is_healthy_status(204)); - assert!(is_healthy_status(299)); - // 3xx is NOT healthy: the probe does not follow redirects, so a 301 to an - // error page must not pass a gate that suppresses an automatic rollback. - assert!(!is_healthy_status(301)); - assert!(!is_healthy_status(399)); - assert!(!is_healthy_status(400)); - assert!(!is_healthy_status(500)); - assert!(!is_healthy_status(199)); + fn resource_link_parser_requires_known_resource_type() { + let raw = r#"[ + {"id":"CONFIG_LINK","name":"shared","resource_id":"CONFIG_A","resource_type":"config"}, + {"id":"KV_LINK","name":"shared","resource_id":"KV_A","resource_type":"kv-store"}, + {"id":"SECRET_LINK","name":"credentials","resource_id":"SECRET_A","resource_type":"secret-store"} + ]"#; + let (links, _) = parse_resource_links(raw).expect("typed links"); + assert_eq!(links[0].kind, ResourceKind::Config); + assert_eq!(links[1].kind, ResourceKind::Kv); + assert_eq!(links[2].kind, ResourceKind::Secret); + + let unknown = + r#"[{"id":"LINK","name":"x","resource_id":"X","resource_type":"dictionary"}]"#; + let error = parse_resource_links(unknown).expect_err("unknown type must fail"); + assert!(error.contains("unknown `resource_type`"), "{error}"); } #[test] - fn parse_fastly_version_handles_the_shapes_fastly_emits() { - // The Fastly CLI's own success lines. Go format strings: - // "Updated package (service %s, version %v)" (compute update) - // "Deployed package (service %s, version %v)" (compute deploy) - assert_eq!( - parse_fastly_version("SUCCESS: Deployed package (service abc, version 7)"), - Some(7) - ); + fn version_configuration_snapshot_normalizes_clone_metadata_and_order() { assert_eq!( - parse_fastly_version("\nSUCCESS: Updated package (service SU1Z0, version 42)\n"), - Some(42) + parse_snapshot_array( + "backends", + r#"[ + {"name":"origin-b","hostname":"b.example","service_id":"SVC","version":40,"created_at":"old"}, + {"name":"origin-a","hostname":"a.example","locked":true,"updated_at":"old"} + ]"#, + ) + .expect("source snapshot"), + parse_snapshot_array( + "backends", + r#"[ + {"name":"origin-a","hostname":"a.example","locked":false,"updated_at":"new"}, + {"name":"origin-b","hostname":"b.example","service_id":"SVC","version":42,"created_at":"new"} + ]"#, + ) + .expect("clone snapshot") ); - // Our canonical contract line. - assert_eq!(parse_fastly_version("version=12"), Some(12)); - // The --autoclone notice, when no success line is present. + + parse_snapshot_array("backends", r#"[{"name":"origin"}, null]"#) + .expect_err("non-object collection entries must fail closed"); + parse_snapshot_array("backends", "{}").expect_err("objects are not collections"); + parse_snapshot_object("settings", "[]").expect_err("arrays are not settings objects"); assert_eq!( - parse_fastly_version( - "Service version 3 is not editable, so it was automatically cloned because \ - --autoclone is enabled. Now operating on version 4." + parse_snapshot_object( + "settings", + r#"{"general.default_ttl":3600,"service_id":"SVC","version":40}"#, ), - Some(4) + parse_snapshot_object( + "settings", + r#"{"general.default_ttl":3600,"service_id":"SVC","version":42}"#, + ) ); - // Full autoclone + success output: the SUCCESS line wins, and the - // PRE-clone version (3) never does — even though stdout/stderr are - // concatenated and their relative order is not guaranteed. - let combined = "SUCCESS: \nUpdated package (service abc, version 4)\n\ - Service version 3 is not editable, so it was automatically cloned. \ - Now operating on version 4."; - assert_eq!(parse_fastly_version(combined), Some(4)); - assert_eq!(parse_fastly_version("no numbers here"), None); } #[test] - fn parse_fastly_version_rejects_confusable_lines() { - // The old parser took ANY digits after the word "version", so each - // of these silently produced a WRONG service version. They must now - // all be `None`, which makes `deploy_staging` fail closed. - assert_eq!( - parse_fastly_version("Uploaded package to service 12345, version unchanged"), - None - ); - // The CLI's own semver must not be mistaken for a service version. - assert_eq!(parse_fastly_version("Fastly CLI version 15.2.0"), None); - assert_eq!( - parse_fastly_version("Checking version compatibility for service 99"), - None - ); - // A bare `version ` mention with no success-line context is not - // trusted either. - assert_eq!(parse_fastly_version("cloning version 3"), None); - // `--version=active` echoed in a command line is not a contract line. - assert_eq!( - parse_fastly_version("running: fastly compute update --version=active"), - None - ); + fn version_configuration_snapshot_ignores_json_object_key_order() { + let source = parse_snapshot_array( + "backends", + r#"[ + {"name":"origin-a","hostname":"z.example","tls":{"check":true,"sni":"z.example"}}, + {"name":"origin-b","hostname":"a.example","tls":{"check":false,"sni":"a.example"}} + ]"#, + ) + .expect("source snapshot"); + let reread = parse_snapshot_array( + "backends", + r#"[ + {"tls":{"sni":"a.example","check":false},"hostname":"a.example","name":"origin-b"}, + {"tls":{"sni":"z.example","check":true},"hostname":"z.example","name":"origin-a"} + ]"#, + ) + .expect("reordered snapshot"); + + assert_eq!(source, reread); } #[test] - fn parse_active_version_finds_active_entry() { - let json = r#"[ - {"number": 1, "active": false}, - {"number": 2, "active": true}, - {"number": 3, "active": false} - ]"#; - assert_eq!(resolve_active_version(json), Ok(Some(2))); + fn logging_snapshot_uses_fastly_api_provider_paths() { + assert!(FASTLY_LOGGING_PROVIDER_KINDS.contains(&"pubsub")); + assert!(FASTLY_LOGGING_PROVIDER_KINDS.contains(&"logentries")); + assert!(FASTLY_LOGGING_PROVIDER_KINDS.contains(&"s3")); + assert!(!FASTLY_LOGGING_PROVIDER_KINDS.contains(&"googlepubsub")); + } + + #[test] + fn fastly_config_keys_use_the_logical_id_for_every_target() { + validate_fastly_config_key("app_config", "app_config", false, false) + .expect("production key"); + validate_fastly_config_key("app_config", "app_config", true, false) + .expect("staging target key"); + validate_fastly_config_key("app_config", "app_config", false, true).expect("local key"); + + for (key, staging, local) in [ + ("custom", false, false), + ("alternate", true, false), + ("alternate", false, true), + ] { + let error = validate_fastly_config_key("app_config", key, staging, local) + .expect_err("conflicting key must fail"); + assert!(error.contains("logical config key"), "{error}"); + } } #[test] fn parse_active_version_none_when_no_active() { // A parsed list with no active version is `Ok(None)` — confirmed // no active version (first deploy), NOT an operational failure. - let json = r#"[{"number": 1, "active": false}]"#; + let json = r#"[{"number":1,"active":false,"locked":false,"staging":false,"deployed":false,"environments":[]}]"#; assert_eq!(resolve_active_version(json), Ok(None)); } @@ -6077,7 +8225,7 @@ mod tests { resolve_active_version(r#"[{"active":true,"number":9},{"active":"nope"}]"#) .expect_err("a non-boolean `active` AFTER the active entry is still schema drift"); // More than one active version is ambiguous — refuse rather than pick one. - resolve_active_version(r#"[{"active":true,"number":9},{"active":true,"number":10}]"#) + resolve_active_version(r#"[{"active":true,"number":9,"locked":true,"staging":false,"deployed":true,"environments":[]},{"active":true,"number":10,"locked":true,"staging":false,"deployed":true,"environments":[]}]"#) .expect_err("two active versions must error as ambiguous"); // EVERY element must be a version object with a numeric `number` — a // garbled entry must fail closed, not be skipped as "not active". @@ -6086,12 +8234,16 @@ mod tests { resolve_active_version("[{}]").expect_err("an entry with no `number` must error"); resolve_active_version(r#"[{"number":"invalid"}]"#) .expect_err("a non-numeric `number` must error"); - // An omitted `active` field means "not active" (not an error), as long - // as the entry is otherwise a well-formed version object. - assert_eq!(resolve_active_version(r#"[{"number":42}]"#), Ok(None)); + // Every safety field is mandatory; omission is schema drift. + resolve_active_version( + r#"[{"number":42,"locked":false,"staging":false,"deployed":false,"environments":[]}]"#, + ) + .expect_err("an omitted active field must fail closed"); // Sanity: a well-formed list still resolves. assert_eq!( - resolve_active_version(r#"[{"active":false,"number":1},{"active":true,"number":2}]"#), + resolve_active_version( + r#"[{"active":false,"number":1,"locked":true,"staging":false,"deployed":true,"environments":[]},{"active":true,"number":2,"locked":true,"staging":false,"deployed":true,"environments":[]}]"# + ), Ok(Some(2)) ); } @@ -6109,10 +8261,62 @@ mod tests { .expect_err("no active version must block the rollback"); } + #[test] + fn staging_rollback_after_failure_before_stage_is_a_noop() { + let versions = parse_service_versions( + r#"[{"active":false,"number":7,"locked":false,"staging":true,"deployed":true,"environments":[]}]"#, + ) + .expect("version list"); + assert_eq!( + staging_rollback_decision(&versions, 7, "svc"), + Ok(StagingRollbackDecision::NoopDraft) + ); + } + + #[test] + fn staging_rollback_after_failure_after_stage_deactivates_exact_version() { + let versions = parse_service_versions( + r#"[{"active":false,"number":7,"locked":false,"staging":false,"deployed":false,"environments":[{"active_version":7,"name":"staging","service_id":"shadow-staging-service"}]}]"#, + ) + .expect("version list"); + assert_eq!( + staging_rollback_decision(&versions, 7, "svc"), + Ok(StagingRollbackDecision::Deactivate) + ); + staging_rollback_decision(&versions, 8, "svc") + .expect_err("an absent version must fail closed"); + } + + #[test] + fn staging_rollback_uses_the_exact_staging_environment_record() { + let versions = parse_service_versions( + r#"[{"active":true,"number":7,"locked":true,"staging":false,"deployed":false,"environments":[{"active_version":7,"name":"production","service_id":"production-service"},{"active_version":7,"name":"staging","service_id":"shadow-staging-service"}]}]"#, + ) + .expect("version list"); + assert_eq!( + staging_rollback_decision(&versions, 7, "svc"), + Ok(StagingRollbackDecision::Deactivate) + ); + + let duplicate = parse_service_versions( + r#"[{"active":false,"number":7,"locked":true,"environments":[{"active_version":7,"name":"staging","service_id":"shadow-staging-service"},{"active_version":7,"name":"staging","service_id":"shadow-staging-service"}]}]"#, + ) + .expect("version list"); + staging_rollback_decision(&duplicate, 7, "svc") + .expect_err("duplicate staging environment records must fail closed"); + + let cross_version_duplicate = parse_service_versions( + r#"[{"active":false,"number":7,"locked":true,"environments":[{"active_version":7,"name":"staging","service_id":"shadow-staging-service"}]},{"active":false,"number":8,"locked":true,"environments":[{"active_version":8,"name":"staging","service_id":"shadow-staging-service"}]}]"#, + ) + .expect("version list"); + staging_rollback_decision(&cross_version_duplicate, 7, "svc") + .expect_err("staging records on multiple versions must fail closed"); + } + #[test] fn active_version_or_require_enforces_require_active() { - let active = r#"[{"active":true,"number":5}]"#; - let none = r#"[{"active":false,"number":5}]"#; + let active = r#"[{"active":true,"number":5,"locked":true,"staging":false,"deployed":true,"environments":[]}]"#; + let none = r#"[{"active":false,"number":5,"locked":false,"staging":false,"deployed":false,"environments":[]}]"#; // A resolvable active version is returned regardless of the flag. assert_eq!(active_version_or_require(active, false, "svc"), Ok(Some(5))); @@ -6151,74 +8355,36 @@ mod tests { "staging_ip": "167.82.81.194" } ]"#; - assert_eq!(parse_staging_ip(json).as_deref(), Some("167.82.81.194")); - } - - #[test] - fn parse_staging_ip_tolerates_a_plural_array_shape() { - let json = r#"[{"name": "example.com", "staging_ips": ["151.101.2.10"]}]"#; - assert_eq!(parse_staging_ip(json).as_deref(), Some("151.101.2.10")); - } - - #[test] - fn parse_staging_ip_none_when_absent_or_null() { - assert_eq!(parse_staging_ip(r#"[{"name": "example.com"}]"#), None); - // `staging_ip` is nullable for services without staging enabled. assert_eq!( - parse_staging_ip(r#"[{"name": "example.com", "staging_ip": null}]"#), - None + parse_staging_ip(json, "integ-test-20221104.go-fastly-1.com"), + Ok("167.82.81.194".to_owned()) ); } #[test] - fn parse_config_store_entries_reads_key_value_pairs() { - let entries = parse_config_store_entries( - r#"[{"item_key":"A","item_value":"1"},{"item_key":"B","item_value":"2"}]"#, - ) - .expect("well-formed listing parses"); + fn parse_staging_ip_selects_the_requested_domain() { + let json = r#"[ + {"name":"other.example.com","staging_ip":"151.101.1.10"}, + {"name":"example.com","staging_ip":"151.101.2.10"} + ]"#; assert_eq!( - entries, - vec![ - ("A".to_owned(), "1".to_owned()), - ("B".to_owned(), "2".to_owned()) - ] + parse_staging_ip(json, "example.com"), + Ok("151.101.2.10".to_owned()) ); } #[test] - fn parse_config_store_entries_errors_never_leak_the_value() { - // The listing carries every entry's item_value (possibly a production secret), - // and CLI status lines are logged verbatim into retained CI logs — so no error - // path may echo the payload. A sentinel secret must NEVER appear in any error. - const SECRET: &str = "s3cr3t-sentinel-value"; - - // 1. Malformed JSON. - let malformed_json = parse_config_store_entries(&format!("not json {SECRET}")) - .expect_err("malformed JSON must error"); - assert!( - !malformed_json.contains(SECRET), - "malformed-JSON error leaked the value: {malformed_json}" - ); - - // 2. Schema drift: valid JSON that is neither a bare array nor an `items` - // envelope (here an object whose VALUE is the secret). - let drift = parse_config_store_entries(&format!(r#"{{"unexpected":"{SECRET}"}}"#)) - .expect_err("schema drift must error"); - assert!( - !drift.contains(SECRET), - "schema-drift error leaked the value: {drift}" - ); - - // 3. Malformed entry: a valid array where an entry lacks item_key/item_value, - // while a SIBLING entry carries the secret in its value. - let bad_entry = parse_config_store_entries(&format!( - r#"[{{"item_key":"ok","item_value":"{SECRET}"}},{{"item_key":"bad"}}]"# - )) - .expect_err("a malformed entry must error"); - assert!( - !bad_entry.contains(SECRET), - "malformed-entry error leaked the value: {bad_entry}" - ); + fn parse_staging_ip_rejects_missing_duplicate_or_malformed_domain_records() { + for json in [ + r#"[{"name":"other.example.com","staging_ip":"151.101.1.10"}]"#, + r#"[{"name":"example.com","staging_ip":null}]"#, + r#"[{"name":"example.com","staging_ips":["151.101.2.10"]}]"#, + r#"[{"name":"example.com","staging_ip":"151.101.2.10"},{"name":"example.com","staging_ip":"151.101.2.11"}]"#, + r#"{"name":"example.com","staging_ip":"151.101.2.10"}"#, + ] { + parse_staging_ip(json, "example.com") + .expect_err("ambiguous or malformed domain inventory must fail closed"); + } } #[test] @@ -6617,7 +8783,7 @@ mod tests { // The earlier wider heuristic swallowed ANY stderr // containing "conflict" or "already exists", which would // misread an unrelated 409 from a different fastly - // subcommand (e.g. a service-version conflict during a + // subcommand (e.g. a service version conflict during a // parallel deploy) as idempotent store-create success. // Now we require the kind context too, so unrelated // conflicts surface as failures. @@ -6626,7 +8792,7 @@ mod tests { "Error: 409 Conflict on /service/abc/version/42 -- already exists", "kv", ), - "service-version conflict must NOT be misread as kv-store idempotency" + "service version conflict must NOT be misread as kv-store idempotency" ); assert!( !looks_like_already_exists( @@ -6971,172 +9137,30 @@ build = \"cargo build --release\" // ---------- provision (dry-run + error path) ---------- #[test] - fn provision_dry_run_does_not_invoke_fastly() { - let dir = tempdir().expect("tempdir"); - let path = dir.path().join("fastly.toml"); - fs::write(&path, "name = \"demo\"\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"); + fn provision_dry_run_does_not_invoke_fastly() { 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")]; + 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: &[], - kv: &kv, - secrets: &[], + config: &config_ids, + kv: &kv_ids, + secrets: &secret_ids, }; - 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}" + let out = FastlyCliAdapter + .provision(dir.path(), Some("fastly.toml"), None, &stores, true) + .expect("dry-run succeeds"); + assert_eq!(out.len(), 3, "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`")); + // 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" ); } @@ -7148,7 +9172,7 @@ build = \"cargo build --release\" 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"); + fs::write(&path, "name = \"demo\"\n").expect("write"); let kv = vec![ResolvedStoreId::from_logical("sessions")]; let stores = ProvisionStores { config: &[], @@ -7186,181 +9210,6 @@ build = \"cargo build --release\" ); } - #[cfg(unix)] - #[test] - fn provision_reconciles_runtime_store_name_mappings() { - let _lock = path_mutation_guard().lock().expect("guard"); - let dir = tempdir().expect("tempdir"); - let path = dir.path().join("fastly.toml"); - fs::write( - &path, - "service_id = \"SVCA\"\n\ - [setup.kv_stores.production_sessions]\n\ - [setup.secret_stores.default]\n", - ) - .expect("write"); - let kv = vec![ResolvedStoreId::new("sessions", "production_sessions")]; - let secrets = vec![ResolvedStoreId::from_logical("default")]; - let stores = ProvisionStores { - config: &[], - kv: &kv, - secrets: &secrets, - }; - let current = vec![ - ( - "EDGEZERO__SERVICES__SVCA__STORES__KV__SESSIONS__NAME".to_owned(), - "old_sessions".to_owned(), - ), - ( - "EDGEZERO__SERVICES__SVCA__STORES__SECRETS__DEFAULT__NAME".to_owned(), - "old_secrets".to_owned(), - ), - ( - "EDGEZERO__SERVICES__SVCB__STORES__KV__SESSIONS__NAME".to_owned(), - "service_b_sessions".to_owned(), - ), - ( - "EDGEZERO__STORES__KV__SESSIONS__NAME".to_owned(), - "legacy_sessions".to_owned(), - ), - ("EDGEZERO__LOGGING__LEVEL".to_owned(), "debug".to_owned()), - ]; - let oplog = dir.path().join("oplog.txt"); - let fake = fake_fastly_runtime_mapping(¤t, &oplog); - let _path = PathPrepend::new(fake.path()); - - let out = FastlyCliAdapter - .provision(dir.path(), Some("fastly.toml"), None, &stores, false) - .expect("mapping reconciliation succeeds"); - let log = fs::read_to_string(&oplog).expect("oplog"); - let manifest_dir = fs::canonicalize(dir.path()).expect("canonical manifest dir"); - - assert!( - log.contains(&format!("store-create cwd={}", manifest_dir.display())), - "runtime-env store creation runs in the manifest directory: {log}" - ); - assert!( - log.contains(&format!("store-list cwd={}", manifest_dir.display())), - "runtime-env store lookup runs in the manifest directory: {log}" - ); - assert!( - log.contains(&format!( - "update EDGEZERO__SERVICES__SVCA__STORES__KV__SESSIONS__NAME=production_sessions cwd={}", - manifest_dir.display() - )), - "changed non-default mapping is upserted in the manifest directory: {log}" - ); - assert!( - log.contains(&format!( - "delete EDGEZERO__SERVICES__SVCA__STORES__SECRETS__DEFAULT__NAME cwd={}", - manifest_dir.display() - )), - "stale mapping is removed in the manifest directory: {log}" - ); - assert!( - !log.contains("delete EDGEZERO__SERVICES__SVCB__STORES__KV__SESSIONS__NAME") - && !log.contains("delete EDGEZERO__STORES__KV__SESSIONS__NAME") - && !log.contains("EDGEZERO__LOGGING__LEVEL="), - "other services, legacy mappings, and unrelated runtime entries are preserved: {log}" - ); - assert!( - out.iter() - .any(|line| line.contains("upserted 1, removed 1")), - "status reports both mutations: {out:?}" - ); - } - - #[cfg(unix)] - #[test] - fn provision_mapping_failure_recommends_provision_recovery() { - let _lock = path_mutation_guard().lock().expect("guard"); - let dir = tempdir().expect("tempdir"); - let path = dir.path().join("fastly.toml"); - fs::write( - &path, - "service_id = \"SVC1\"\n\ - [setup.kv_stores.production_sessions]\n\ - [setup.config_stores.edgezero_runtime_env]\n", - ) - .expect("write"); - let kv = vec![ResolvedStoreId::new("sessions", "production_sessions")]; - let stores = ProvisionStores { - config: &[], - kv: &kv, - secrets: &[], - }; - let oplog = dir.path().join("oplog.txt"); - let fake = fake_fastly_runtime_mapping_with_update_exit(&[], &oplog, 1); - let _path = PathPrepend::new(fake.path()); - - let err = FastlyCliAdapter - .provision(dir.path(), Some("fastly.toml"), None, &stores, false) - .expect_err("mapping update fails"); - - assert!( - err.contains("UNKNOWN"), - "failed write outcome is explicit: {err}" - ); - assert!( - err.contains("edgezero provision --adapter fastly"), - "recovery names the command to retry: {err}" - ); - assert!( - !err.contains("config push"), - "wrong command is not recommended: {err}" - ); - assert!( - !err.contains("chunk") && !err.contains("root pointer"), - "mapping recovery contains no blob-specific guidance: {err}" - ); - } - - #[cfg(unix)] - #[test] - fn provision_delete_failure_recommends_provision_recovery() { - let _lock = path_mutation_guard().lock().expect("guard"); - let dir = tempdir().expect("tempdir"); - let path = dir.path().join("fastly.toml"); - fs::write( - &path, - "service_id = \"SVC1\"\n\ - [setup.kv_stores.production_sessions]\n\ - [setup.secret_stores.default]\n\ - [setup.config_stores.edgezero_runtime_env]\n", - ) - .expect("write"); - let kv = vec![ResolvedStoreId::new("sessions", "production_sessions")]; - let secrets = vec![ResolvedStoreId::from_logical("default")]; - let stores = ProvisionStores { - config: &[], - kv: &kv, - secrets: &secrets, - }; - let current = vec![( - "EDGEZERO__SERVICES__SVC1__STORES__SECRETS__DEFAULT__NAME".to_owned(), - "old_secrets".to_owned(), - )]; - let oplog = dir.path().join("oplog.txt"); - let fake = fake_fastly_runtime_mapping_with_exits(¤t, &oplog, 0, 1); - let _path = PathPrepend::new(fake.path()); - - let err = FastlyCliAdapter - .provision(dir.path(), Some("fastly.toml"), None, &stores, false) - .expect_err("stale mapping delete fails"); - - assert!(err.contains("UNKNOWN"), "delete outcome is explicit: {err}"); - assert!( - err.contains("edgezero provision --adapter fastly") && err.contains("idempotent"), - "recovery names the safe retry: {err}" - ); - let log = fs::read_to_string(&oplog).expect("oplog"); - assert!( - log.contains("update EDGEZERO__SERVICES__SVC1__STORES__KV__SESSIONS__NAME") - && log.contains("delete EDGEZERO__SERVICES__SVC1__STORES__SECRETS__DEFAULT__NAME"), - "the failure follows a committed upsert: {log}" - ); - } - #[test] fn provision_errors_when_adapter_manifest_path_missing() { let dir = tempdir().expect("tempdir"); @@ -7383,14 +9232,7 @@ build = \"cargo build --release\" fn provision_with_no_declared_stores_says_so() { let dir = tempdir().expect("tempdir"); let path = dir.path().join("fastly.toml"); - // Pre-populate the runtime-env block so the provision flow's - // unconditional runtime-env step skips (otherwise it would - // shell out to real `fastly` to create the store). - fs::write( - &path, - "name = \"demo\"\n[setup.config_stores.edgezero_runtime_env]\n", - ) - .expect("write"); + fs::write(&path, "name = \"demo\"\n").expect("write"); let stores = ProvisionStores { config: &[], kv: &[], @@ -7405,16 +9247,14 @@ 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. 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.sessions]\n[local_server.kv_stores.sessions]\n\ - [setup.config_stores.edgezero_runtime_env]\n", + [setup.kv_stores.sessions]\n[local_server.kv_stores.sessions]\n", ) .expect("write"); let kv_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_KV_ID]); @@ -7432,61 +9272,27 @@ 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 provider state" ); } /// When `fastly.toml` declares `service_id`, the next /// `fastly compute deploy` skips `[setup]` entirely. provision - /// must emit the `fastly resource-link create` remediation for - /// every store it creates -- including the implicit - /// `edgezero_runtime_env` store the runtime override path - /// depends on. Without this, a freshly-provisioned override - /// store would not be linked to the already-deployed service - /// and the runtime would silently fall back to baked defaults. - #[test] - fn provision_emits_resource_link_note_for_runtime_env_on_existing_service() { - // Dry-run only -- we just want to drive the resource_link_note - // helper for the runtime-env store branch. The real-create - // path can't run in tests (would shell out to `fastly`). - // The dry-run output line for runtime-env doesn't include the - // note (the helper only fires on real create), so we test the - // helper directly here. + /// must emit the `fastly service resource-link create` remediation for + /// every declared store it creates. + #[test] + fn provision_emits_resource_link_note_for_declared_store_on_existing_service() { 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", "app_config") .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!( @@ -7494,12 +9300,12 @@ build = \"cargo build --release\" "note tells operator how to find the store id: {note}" ); assert!( - note.contains("name=`edgezero_runtime_env`"), - "note names the runtime override store: {note}" + note.contains("name=`app_config`"), + "note names the declared store: {note}" ); assert!( note.contains( - "fastly resource-link create --service-id=abc123svc --resource-id= --version=latest --autoclone --name=edgezero_runtime_env" + "fastly service resource-link create --service-id=abc123svc --resource-id= --version=latest --autoclone --name=app_config" ), "note carries the full resource-link command: {note}" ); @@ -7512,17 +9318,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", "app_config"); 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("envservice".to_owned())) + .expect("select environment service id"); + let note = resource_link_note(selected.as_ref(), "secret", "credentials") + .expect("environment service produces a link note"); + assert!( + note.contains("`FASTLY_SERVICE_ID` selects service `envservice`") + && note.contains("--service-id=envservice") + && 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("manifestservice".to_owned()), + Some("environmentservice".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] @@ -7671,7 +9500,7 @@ build = \"cargo build --release\" {"id": "abc123", "name": "some_other_store"}, {"id": "def456"} ]"#; - let drift = find_config_store_id(stdout, "edgezero_runtime_env"); + let drift = find_config_store_id(stdout, "app_config"); assert!( matches!(drift, ConfigStoreLookup::SchemaDrift(_)), "a malformed entry alongside a well-formed one must be schema drift, got {drift:?}" @@ -7683,10 +9512,10 @@ build = \"cargo build --release\" // The full list is scanned: a malformed entry AFTER the match must still // be caught (no short-circuit on the first Found). let stdout = r#"[ - {"id": "abc123", "name": "edgezero_runtime_env"}, + {"id": "abc123", "name": "app_config"}, {"name": "broken"} ]"#; - let drift = find_config_store_id(stdout, "edgezero_runtime_env"); + let drift = find_config_store_id(stdout, "app_config"); assert!( matches!(drift, ConfigStoreLookup::SchemaDrift(_)), "a malformed entry after the match must be schema drift, got {drift:?}" @@ -7696,10 +9525,10 @@ build = \"cargo build --release\" #[test] fn find_config_store_id_flags_duplicate_names_as_ambiguous() { let stdout = r#"[ - {"id": "abc123", "name": "edgezero_runtime_env"}, - {"id": "def456", "name": "edgezero_runtime_env"} + {"id": "abc123", "name": "app_config"}, + {"id": "def456", "name": "app_config"} ]"#; - let drift = find_config_store_id(stdout, "edgezero_runtime_env"); + let drift = find_config_store_id(stdout, "app_config"); assert!( matches!(drift, ConfigStoreLookup::SchemaDrift(_)), "two stores with the same name must be ambiguous drift, got {drift:?}" @@ -8006,7 +9835,7 @@ build = \"cargo build --release\" let entry_list = dir.path().join("entries.json"); fs::write( &store_list, - format!(r#"[{{"name":"{RUNTIME_ENV_STORE_NAME}","id":"runtime-env-123"}}]"#), + format!(r#"[{{"name":"{TEST_CONFIG_ID}","id":"store-abc123"}}]"#), ) .expect("store list"); let entries = current @@ -8026,6 +9855,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 @@ -8906,11 +10736,8 @@ echo 'unexpected' >&2; exit 1 ); } - /// Pushing two blobs under different root keys - /// (e.g. `app_config` + `app_config_staging`) must leave both - /// keys readable from the local fastly.toml so the runtime - /// `EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY` override can - /// switch between them. Prior to the upsert fix the second + /// Writing two blobs under different root keys must leave both keys + /// readable from the local fastly.toml. Prior to the upsert fix the second /// push wholesale-replaced the per-store contents table. #[cfg(unix)] #[test] @@ -8938,10 +10765,7 @@ echo 'unexpected' >&2; exit 1 Some("fastly.toml"), None, &store, - &[( - "app_config_staging".to_owned(), - "{\"envelope\":\"B\"}".to_owned(), - )], + &[("other_config".to_owned(), "{\"envelope\":\"B\"}".to_owned())], &ctx, false, ) @@ -8964,11 +10788,11 @@ echo 'unexpected' >&2; exit 1 app_config, "{\"envelope\":\"A\"}", "default key value: {raw}" ); - let staging = contents - .get("app_config_staging") + let sibling = contents + .get("other_config") .and_then(toml_edit::Item::as_str) - .expect("staging key must be present"); - assert_eq!(staging, "{\"envelope\":\"B\"}", "staging key value: {raw}"); + .expect("sibling key must be present"); + assert_eq!(sibling, "{\"envelope\":\"B\"}", "sibling key value: {raw}"); } #[cfg(unix)] @@ -9554,8 +11378,8 @@ echo 'unexpected' >&2; exit 1 /// by the full-envelope SHA, so push B writes a new chunk-set and /// installs a new root pointer. /// - /// `--key app_config_staging` push leaves `app_config` intact per - /// spec 12.7). Within the SAME root key, GC on re-push prunes the + /// Writing a sibling root leaves `app_config` intact. Within the same root + /// key, GC on re-push prunes the /// prior generation: after envelope B's push, envelope A's chunks — /// now unreferenced by the `app_config` pointer — are removed from /// the contents table. A read after push B follows the active @@ -9674,165 +11498,25 @@ echo 'unexpected' >&2; exit 1 // reconstructs envelope B (NOT envelope A). let read = FastlyCliAdapter .read_config_entry_local( - dir.path(), - Some("fastly.toml"), - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - TEST_CONFIG_ID, - &AdapterPushContext::new(), - ) - .expect("local read after push B"); - let ReadConfigEntry::Present(value) = read else { - panic!("expected Present after push B"); - }; - assert_eq!( - value, envelope_b, - "read after second push must reconstruct envelope B, not A" - ); - assert_ne!( - value, envelope_a, - "old envelope A's chunks must be inert -- read must NOT return A" - ); - } - - // ── staged deploy: end-to-end argv contract (fake `fastly`) ─────── - - /// Fake `fastly` on `$PATH` that appends every invocation's argv (one - /// space-joined line per call) to a record file, and echoes - /// `update_stdout` for `fastly compute update`. Returns the temp dir - /// (which must outlive the test) and the record path. - #[cfg(unix)] - fn fake_fastly_recorder(update_stdout: &str) -> (tempfile::TempDir, PathBuf) { - use std::os::unix::fs::PermissionsExt as _; - - let dir = tempdir().expect("tempdir"); - let record = dir.path().join("argv.log"); - let script_path = dir.path().join("fastly"); - // Answers every call `deploy_staging` makes. The staging relink needs the - // selector store to resolve and the inherited link to be listed; without - // these the staged path fails closed (which is correct, but not what - // these tests are exercising). - let script = format!( - "#!/bin/sh\n\ - printf '%s\\n' \"$*\" >> '{record}'\n\ - 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\ - 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 \ - *) printf '%s\\n' '[]' ;;\n \ - esac\n\ - elif [ \"$1\" = \"resource-link\" ] && [ \"$2\" = \"list\" ]; then\n \ - printf '%s\\n' '[{{\"id\":\"LINK1\",\"name\":\"edgezero_runtime_env\"}}]'\n\ - fi\n\ - exit 0\n", - record = record.display(), - ); - fs::write(&script_path, script).expect("write fake fastly"); - let mut perms = fs::metadata(&script_path).expect("meta").permissions(); - perms.set_mode(0o755); - fs::set_permissions(&script_path, perms).expect("chmod +x"); - (dir, record) - } - - /// Run `deploy_staging` against a fake `fastly`, returning the result - /// and the recorded argv lines. - #[cfg(unix)] - fn run_deploy_staging_with_fake( - update_stdout: &str, - extra: &[&str], - ) -> (Result<(), String>, Vec) { - run_deploy_staging_with_fake_and_env(update_stdout, extra, None) - } - - #[cfg(unix)] - fn run_deploy_staging_with_fake_and_env( - update_stdout: &str, - extra: &[&str], - store_name_override: Option<(&str, &str)>, - ) -> (Result<(), String>, Vec) { - let _lock = path_mutation_guard().lock().expect("guard"); - let (fake, record) = fake_fastly_recorder(update_stdout); - let _path = PathPrepend::new(fake.path()); - let app = tempdir().expect("app dir"); - let manifest = app.path().join("fastly.toml"); - fs::write(&manifest, "name = \"app\"\n").expect("write fastly.toml"); - - // 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(), - ]; - args.extend(extra.iter().map(|arg| (*arg).to_owned())); - let result = deploy_staging(&args); - - let recorded = fs::read_to_string(&record).unwrap_or_default(); - let lines = recorded.lines().map(str::to_owned).collect(); - (result, lines) - } - - #[cfg(unix)] - #[test] - fn deploy_staging_routes_comment_to_service_version_update() { - // `--comment` is allowlisted for `deploy-args` and recommended by the - // adoption guide, but `fastly compute update` has no such flag. It - // must NOT be forwarded there (that would fail the deploy) and must - // instead land on the version via `service-version update`. - for comment_args in [vec!["--comment", "ci run 12"], vec!["--comment=ci run 12"]] { - let (result, argv) = run_deploy_staging_with_fake( - "SUCCESS: Updated package (service SVC1, version 7)", - &comment_args, - ); - result.expect("staged deploy with --comment must succeed"); - - let update = argv - .iter() - .find(|line| line.starts_with("compute update")) - .expect("compute update was invoked"); - assert!( - !update.contains("--comment"), - "--comment must not be forwarded to `compute update`: {update}" - ); - assert!( - update.contains("--non-interactive"), - "compute update must be non-interactive: {update}" - ); - - let comment_call = argv - .iter() - .find(|line| line.starts_with("service-version update")) - .expect("`service-version update` must apply the version comment"); - assert_eq!( - comment_call, - "service-version update --service-id=SVC1 --version=7 --comment ci run 12" - ); - - // The comment lands on the version BEFORE it is staged (while it - // is still an editable draft). - let comment_idx = argv - .iter() - .position(|line| line.starts_with("service-version update")) - .expect("comment call"); - let stage_idx = argv - .iter() - .position(|line| line.starts_with("service-version stage")) - .expect("stage call"); - assert!(comment_idx < stage_idx, "comment must precede staging"); - assert_eq!( - argv[stage_idx], - "service-version stage --service-id=SVC1 --version=7" - ); - } + dir.path(), + Some("fastly.toml"), + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + TEST_CONFIG_ID, + &AdapterPushContext::new(), + ) + .expect("local read after push B"); + let ReadConfigEntry::Present(value) = read else { + panic!("expected Present after push B"); + }; + assert_eq!( + value, envelope_b, + "read after second push must reconstruct envelope B, not A" + ); + assert_ne!( + value, envelope_a, + "old envelope A's chunks must be inert -- read must NOT return A" + ); } // ---------- config gc (operator-invoked reclamation) ---------- @@ -11062,555 +12746,6 @@ 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() { - let stores = StoresMetadata { - config: Some(StoreMetadata { - default: "app_config", - ids: &["app_config"], - }), - kv: Some(StoreMetadata { - default: "sessions", - ids: &["sessions"], - }), - secrets: None, - }; - let scoped_sessions = - service_scoped_runtime_env_key("SVCA", "EDGEZERO__STORES__KV__SESSIONS__NAME"); - let values = BTreeMap::from([ - (scoped_sessions.clone(), "service_a_sessions".to_owned()), - ( - service_scoped_runtime_env_key("SVCB", "EDGEZERO__STORES__KV__SESSIONS__NAME"), - "service_b_sessions".to_owned(), - ), - ( - "EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME".to_owned(), - "legacy_config".to_owned(), - ), - ( - "EDGEZERO__STORES__KV__SESSIONS__NAME".to_owned(), - "legacy_sessions".to_owned(), - ), - ]); - - assert_eq!( - scoped_sessions, - "EDGEZERO__SERVICES__SVCA__STORES__KV__SESSIONS__NAME" - ); - let vars = - crate::runtime_env_vars_for_service(stores, "SVCA", |key| values.get(key).cloned()); - let env = EnvConfig::from_vars(vars); - - assert_eq!(env.store_name("kv", "sessions"), "service_a_sessions"); - assert_eq!(env.store_name("config", "app_config"), "app_config"); - assert_ne!(env.store_name("kv", "sessions"), "service_b_sessions"); - - 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" - ); - } - - #[test] - fn runtime_env_key_is_scoped_for_the_runtime_reader() { - assert_eq!( - canonical_runtime_env_key_for("app_config"), - "EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY" - ); - assert_eq!( - runtime_env_key_for("SVCA", "app_config"), - "EDGEZERO__SERVICES__SVCA__STORES__CONFIG__APP_CONFIG__KEY" - ); - } - - #[test] - fn staging_entries_from_production_mirrors_only_current_service_entries() { - // Production carries an unscoped legacy override, this service's - // explicit selector and name mapping, and another service's mapping. - // The per-service twin keeps only current-service values, replacing - // every declared selector with its scoped staging value. - let production = vec![ - ( - "EDGEZERO__ADAPTER__FASTLY__LOG_LEVEL".to_owned(), - "debug".to_owned(), - ), - ( - "EDGEZERO__SERVICES__SVC1__STORES__CONFIG__APP_CONFIG__KEY".to_owned(), - "custom_prod_key".to_owned(), - ), - ( - "EDGEZERO__SERVICES__SVC1__STORES__CONFIG__APP_CONFIG__NAME".to_owned(), - "app_config".to_owned(), - ), - ( - "EDGEZERO__SERVICES__SVC2__STORES__SECRETS__DEFAULT__NAME".to_owned(), - "other_service_secrets".to_owned(), - ), - ]; - let out = staging_entries_from_production( - &production, - "SVC1", - &["app_config".to_owned(), "feature_flags".to_owned()], - ); - - 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__SERVICES__SVC1__STORES__CONFIG__APP_CONFIG__NAME".to_owned(), - "app_config".to_owned() - ))); - assert!(out.contains(&( - "EDGEZERO__SERVICES__SVC1__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(), - "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 - ); - } - - #[test] - fn find_resource_link_id_matches_on_link_name_not_resource_name() { - // The link's `name` is an alias defaulting to the resource's name. The - // staging relink depends on that alias: a store named - // `edgezero_runtime_env_staging` is linked AS `edgezero_runtime_env`. - let json = r#"[ - {"id":"LINK_KV","name":"sessions"}, - {"id":"LINK_ENV","name":"edgezero_runtime_env"} - ]"#; - assert_eq!( - find_resource_link_id(json, "edgezero_runtime_env").as_deref(), - Some("LINK_ENV") - ); - // Absent link -> nothing to delete, not an error. - assert_eq!(find_resource_link_id(json, "nope"), None); - // Tolerates the `{"items": [...]}` envelope, like the store lookup. - let enveloped = r#"{"items":[{"id":"L1","name":"edgezero_runtime_env"}]}"#; - assert_eq!( - find_resource_link_id(enveloped, "edgezero_runtime_env").as_deref(), - Some("L1") - ); - assert_eq!(find_resource_link_id("not json", "x"), None); - } - - #[cfg(unix)] - #[test] - fn deploy_staging_ignores_ambient_store_name_overrides() { - let (result, argv) = run_deploy_staging_with_fake_and_env( - "SUCCESS: Updated package (service SVC1, version 7)", - &["--edgezero-staging-config=app_config"], - Some(( - "EDGEZERO__STORES__SECRETS__DEFAULT__NAME", - "ambient_secrets", - )), - ); - result.expect("staged deploy succeeds"); - - assert!( - !argv.iter().any(|line| { - line.contains("EDGEZERO__STORES__SECRETS__DEFAULT__NAME") - || line.contains("ambient_secrets") - }), - "staging must mirror persisted production mappings, not ambient process env: {argv:?}" - ); - } - - #[cfg(unix)] - #[test] - fn deploy_staging_points_the_draft_at_the_staging_selector_store() { - // 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_staging_with_fake( - "SUCCESS: Updated package (service SVC1, version 7)", - &["--edgezero-staging-config=app_config"], - ); - result.expect("staged deploy must succeed"); - - // The twin MIRRORS production: the non-selector override is copied - // verbatim, and the config selector is upserted (redirected to - // `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" - )), - "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" - )), - "the config selector must be written into the twin: {argv:?}" - ); - // The mirror runs while the draft is still editable, before the relink. - let mirror_idx = argv - .iter() - .position(|line| line.starts_with("config-store-entry update --store-id=STAGEID1")) - .expect("mirror upsert"); - - // The inherited production link is dropped: a version cannot hold two - // links under one name. - let delete_idx = argv - .iter() - .position(|line| line.starts_with("resource-link delete")) - .expect("the inherited runtime-env link must be deleted"); - assert_eq!( - argv[delete_idx], - "resource-link delete --service-id=SVC1 --version=7 --id=LINK1" - ); - - // The staging STORE is linked under the name the runtime opens. - let create_idx = argv - .iter() - .position(|line| line.starts_with("resource-link create")) - .expect("the staging selector store must be linked"); - assert_eq!( - argv[create_idx], - "resource-link create --service-id=SVC1 --version=7 --resource-id=STAGEID1 --name=edgezero_runtime_env" - ); - - // Order matters: delete before create (name collision), and both while - // the version is still an editable draft -- i.e. before staging. - assert!(delete_idx < create_idx, "delete must precede create"); - assert!( - mirror_idx < delete_idx, - "the twin must be mirrored before the draft is relinked to it" - ); - let stage_idx = argv - .iter() - .position(|line| line.starts_with("service-version stage")) - .expect("stage call"); - assert!( - create_idx < stage_idx, - "the relink must happen while the version is still a draft" - ); - } - - #[cfg(unix)] - #[test] - fn deploy_staging_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: - // 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"); - let dir = tempdir().expect("tempdir"); - let script_path = dir.path().join("fastly"); - // No config stores at all on the account. - fs::write( - &script_path, - "#!/bin/sh\nif [ \"$1\" = \"compute\" ] && [ \"$2\" = \"update\" ]; then\n printf '%s\\n' 'SUCCESS: Updated package (service SVC1, version 7)'\nelif [ \"$1\" = \"config-store\" ] && [ \"$2\" = \"list\" ]; then\n printf '%s\\n' '[]'\nfi\nexit 0\n", - ) - .expect("write fake"); - let mut perms = fs::metadata(&script_path).expect("meta").permissions(); - perms.set_mode(0o755); - fs::set_permissions(&script_path, perms).expect("chmod"); - let _path = PathPrepend::new(dir.path()); - - let app = tempdir().expect("app dir"); - 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_staging(&[ - "--service-id".to_owned(), - "SVC1".to_owned(), - "--manifest-path".to_owned(), - app.path().join("fastly.toml").display().to_string(), - ]) - .expect("an app with no config selection must still be stageable"); - } - - #[cfg(unix)] - #[test] - fn deploy_staging_auto_creates_the_staging_twin_when_absent() { - use std::os::unix::fs::PermissionsExt as _; - - // A staged deploy owns the twin end to end: if the account has no - // staging store yet, the deploy creates it (rather than failing), so a - // provisioned app can stage without a separate setup step. - let _lock = path_mutation_guard().lock().expect("guard"); - let dir = tempdir().expect("tempdir"); - let record = dir.path().join("argv.log"); - let marker = dir.path().join("twin-created"); - let script_path = dir.path().join("fastly"); - // Stateful fake: `config-store list` includes the twin ONLY after a - // `config-store create` has touched the marker. - let script = format!( - "#!/bin/sh\n\ - printf '%s\\n' \"$*\" >> '{record}'\n\ - if [ \"$1\" = \"compute\" ] && [ \"$2\" = \"update\" ]; then\n \ - printf '%s\\n' 'SUCCESS: Updated package (service SVC1, version 7)'\n\ - elif [ \"$1\" = \"config-store\" ] && [ \"$2\" = \"create\" ]; then\n \ - : > '{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 \ - else\n \ - printf '%s\\n' '[{{\"id\":\"ENVSEL1\",\"name\":\"edgezero_runtime_env\"}}]'\n \ - fi\n\ - elif [ \"$1\" = \"config-store-entry\" ] && [ \"$2\" = \"update\" ]; then\n \ - cat >/dev/null\n\ - elif [ \"$1\" = \"config-store-entry\" ] && [ \"$2\" = \"list\" ]; then\n \ - printf '%s\\n' '[]'\n\ - elif [ \"$1\" = \"resource-link\" ] && [ \"$2\" = \"list\" ]; then\n \ - printf '%s\\n' '[{{\"id\":\"LINK1\",\"name\":\"edgezero_runtime_env\"}}]'\n\ - fi\n\ - exit 0\n", - record = record.display(), - marker = marker.display(), - ); - fs::write(&script_path, script).expect("write fake"); - let mut perms = fs::metadata(&script_path).expect("meta").permissions(); - perms.set_mode(0o755); - fs::set_permissions(&script_path, perms).expect("chmod"); - let _path = PathPrepend::new(dir.path()); - - let app = tempdir().expect("app dir"); - 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_staging(&[ - "--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 argv = fs::read_to_string(&record).unwrap_or_default(); - assert!( - argv.lines() - .any(|line| line == "config-store create --name=edgezero_runtime_env_staging_SVC1"), - "the per-service twin must be created on demand: {argv}" - ); - } - - #[cfg(unix)] - #[test] - fn deploy_staging_isolates_when_config_declared_but_prod_store_absent() { - use std::os::unix::fs::PermissionsExt as _; - - // The app DECLARES config but has no `edgezero_runtime_env` store (never - // provisioned an override store — production reads its default key). A - // staged deploy must NOT silently inherit production config: it creates - // the per-service twin, writes the `_staging` selector, and - // relinks the draft to it. There is nothing to mirror (no production - // entries), but staging is still isolated. - let _lock = path_mutation_guard().lock().expect("guard"); - let dir = tempdir().expect("tempdir"); - let record = dir.path().join("argv.log"); - let marker = dir.path().join("twin-created"); - let script_path = dir.path().join("fastly"); - // No `edgezero_runtime_env` ever; the twin appears only after create. - let script = format!( - "#!/bin/sh\n\ - printf '%s\\n' \"$*\" >> '{record}'\n\ - if [ \"$1\" = \"compute\" ] && [ \"$2\" = \"update\" ]; then\n \ - printf '%s\\n' 'SUCCESS: Updated package (service SVC1, version 7)'\n\ - elif [ \"$1\" = \"config-store\" ] && [ \"$2\" = \"create\" ]; then\n \ - : > '{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 \ - else\n \ - printf '%s\\n' '[]'\n \ - fi\n\ - elif [ \"$1\" = \"config-store-entry\" ] && [ \"$2\" = \"update\" ]; then\n \ - cat >/dev/null\n\ - elif [ \"$1\" = \"config-store-entry\" ] && [ \"$2\" = \"list\" ]; then\n \ - printf '%s\\n' '[]'\n\ - elif [ \"$1\" = \"resource-link\" ] && [ \"$2\" = \"list\" ]; then\n \ - printf '%s\\n' '[]'\n\ - fi\n\ - exit 0\n", - record = record.display(), - marker = marker.display(), - ); - fs::write(&script_path, script).expect("write fake"); - let mut perms = fs::metadata(&script_path).expect("meta").permissions(); - perms.set_mode(0o755); - fs::set_permissions(&script_path, perms).expect("chmod"); - let _path = PathPrepend::new(dir.path()); - - let app = tempdir().expect("app dir"); - 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_staging(&[ - "--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 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" - )), - "the staging selector must be written even with no production store: {argv}" - ); - assert!( - argv.lines().any(|line| line.starts_with( - "resource-link create --service-id=SVC1 --version=7 --resource-id=STAGEID1 --name=edgezero_runtime_env" - )), - "the draft must be relinked to the staging twin: {argv}" - ); - } - - #[cfg(unix)] - #[test] - fn deploy_staging_fails_closed_when_config_store_list_is_unreadable() { - use std::os::unix::fs::PermissionsExt as _; - - // If the store listing can't be parsed (a CLI schema change), we cannot - // tell whether production config exists — refuse rather than risk a - // staged version that silently serves PRODUCTION config. - 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\nif [ \"$1\" = \"compute\" ] && [ \"$2\" = \"update\" ]; then\n printf '%s\\n' 'SUCCESS: Updated package (service SVC1, version 7)'\nelif [ \"$1\" = \"config-store\" ] && [ \"$2\" = \"list\" ]; then\n printf '%s\\n' 'not json at all'\nfi\nexit 0\n", - ) - .expect("write fake"); - let mut perms = fs::metadata(&script_path).expect("meta").permissions(); - perms.set_mode(0o755); - fs::set_permissions(&script_path, perms).expect("chmod"); - let _path = PathPrepend::new(dir.path()); - - let app = tempdir().expect("app dir"); - 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_staging(&[ - "--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"); - assert!( - err.contains("Refusing to stage") || err.contains("could not parse"), - "the error must explain the refusal: {err}" - ); - } - - #[cfg(unix)] - #[test] - fn deploy_staging_without_comment_makes_no_version_comment_call() { - let (result, argv) = - run_deploy_staging_with_fake("SUCCESS: Updated package (service SVC1, version 7)", &[]); - result.expect("staged deploy must succeed"); - assert!( - !argv - .iter() - .any(|line| line.starts_with("service-version update")), - "no comment => no `service-version update` call: {argv:?}" - ); - } - - #[cfg(unix)] - #[test] - fn deploy_staging_fails_closed_when_version_is_unparseable() { - // The old code fell back to the service's HIGHEST version here, which - // could silently adopt a version created by a CONCURRENT deploy. We - // must error out instead of guessing. - let (result, argv) = run_deploy_staging_with_fake("uploaded, but nothing parseable", &[]); - let err = result.expect_err("unparseable version must fail closed"); - assert!( - err.contains("could not determine the staged version"), - "unexpected error: {err}" - ); - assert!( - !argv - .iter() - .any(|line| line.starts_with("service-version stage")), - "must not stage a guessed version: {argv:?}" - ); - } - - #[cfg(unix)] - #[test] - fn deploy_staging_does_not_duplicate_non_interactive_from_passthrough() { - // `--non-interactive` is an allowlisted `compute update` flag, so a - // caller-supplied one is FORWARDED. We must not then append our own: - // passing the switch twice makes the Fastly CLI exit non-zero. - let (result, argv) = run_deploy_staging_with_fake( - "SUCCESS: Updated package (service SVC1, version 7)", - &["--non-interactive"], - ); - result.expect("staged deploy with a passthrough --non-interactive must succeed"); - let update = argv - .iter() - .find(|line| line.starts_with("compute update")) - .expect("compute update was invoked"); - assert_eq!( - update.matches("--non-interactive").count(), - 1, - "the non-interactive switch must appear exactly once: {update}" - ); - } - /// Fake `fastly` on `$PATH` that records `\t` for every /// invocation. Used to prove the production deploy runs in the /// manifest-selected app directory. @@ -11685,7 +12820,7 @@ echo 'unexpected' >&2; exit 1 let mut out = Vec::new(); append_kept_roots_report( &mut out, - &["app_config".to_owned(), "app_config_staging".to_owned()], + &["app_config".to_owned(), "other_config".to_owned()], 5, ); assert!( @@ -11694,10 +12829,7 @@ echo 'unexpected' >&2; exit 1 "heading names the retained-root and referenced-chunk counts: {out:?}" ); assert!(out.iter().any(|line| line == " keeping `app_config`")); - assert!( - out.iter() - .any(|line| line == " keeping `app_config_staging`") - ); + assert!(out.iter().any(|line| line == " keeping `other_config`")); // Never the misleading "live" label -- a retained root may not be // runtime-live, and its chunks are protected/referenced, not live. assert!( @@ -13519,7 +14651,7 @@ echo 'unexpected' >&2; exit 1 /// GC of a chunked root must not touch a chunked SIBLING's chunks — /// the prefix `app_config.__edgezero_chunks.` must not match - /// `app_config_staging.__edgezero_chunks.` (shared string prefix). + /// `app_config_archive.__edgezero_chunks.` (shared string prefix). #[cfg(unix)] #[test] fn push_config_entries_local_gc_preserves_sibling_chunks() { @@ -13551,8 +14683,8 @@ echo 'unexpected' >&2; exit 1 // app_config gen X, then a chunked sibling, then app_config gen Z. push("app_config", make("x1")); - push("app_config_staging", make("staging")); - let staging_chunks = chunk_keys_of("app_config_staging", &make("staging")); + push("app_config_archive", make("archive")); + let sibling_chunks = chunk_keys_of("app_config_archive", &make("archive")); push("app_config", make("z2")); // GCs app_config's gen-X chunks let after = fs::read_to_string(&fastly_toml).expect("read"); @@ -13564,7 +14696,7 @@ echo 'unexpected' >&2; exit 1 .and_then(|st| st.get("contents")) .and_then(toml_edit::Item::as_table) .expect("contents"); - for key in &staging_chunks { + for key in &sibling_chunks { assert!( contents.get(key).is_some(), "sibling chunk `{key}` must survive app_config GC: {after}" @@ -13578,7 +14710,7 @@ echo 'unexpected' >&2; exit 1 fn reject_reserved_root_keys_accepts_clean_keys() { let entries = vec![ ("app_config".to_owned(), "{}".to_owned()), - ("app_config_staging".to_owned(), "{}".to_owned()), + ("other_config".to_owned(), "{}".to_owned()), ]; reject_reserved_root_keys(&entries).expect("clean keys accepted"); } diff --git a/crates/edgezero-adapter-fastly/src/lib.rs b/crates/edgezero-adapter-fastly/src/lib.rs index 36161a35..c9d668a5 100644 --- a/crates/edgezero-adapter-fastly/src/lib.rs +++ b/crates/edgezero-adapter-fastly/src/lib.rs @@ -1,9 +1,8 @@ //! Utilities for bridging Fastly Compute@Edge requests into the //! `edgezero-core` service abstractions. -// Only compiled where it is actually used (the CLI push/GC path and the Fastly -// runtime resolver). Gating it keeps a `--no-default-features` build dead-code -// clean instead of dragging in helpers no feature references. +// Only compiled where it is actually used by the CLI push/GC path. Gating it +// keeps a `--no-default-features` build dead-code clean. #[cfg(any(feature = "cli", feature = "fastly", test))] pub(crate) mod chunked_config; #[cfg(feature = "cli")] @@ -26,27 +25,10 @@ pub mod secret_store; #[cfg(feature = "fastly")] use edgezero_core::app::Hooks; -#[cfg(any(feature = "fastly", test))] -use edgezero_core::app::StoresMetadata; -#[cfg(any(feature = "fastly", test))] -use edgezero_core::env_config::EnvConfig; #[cfg(feature = "fastly")] 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. -/// -/// The fixed name is load-bearing: a staged deploy creates a per-service -/// staging twin and links it into the staged version under THIS name, which is -/// how the runtime resolves staged selectors without knowing the twin exists. -pub const RUNTIME_ENV_STORE_NAME: &str = "edgezero_runtime_env"; #[cfg(any(feature = "fastly", test))] #[derive(Debug, Clone)] @@ -61,62 +43,16 @@ pub struct FastlyLogging { impl From for FastlyLogging { #[inline] fn from(config: ResolvedLoggingConfig) -> Self { + let use_fastly_logger = config.endpoint.is_some(); Self { echo_stdout: config.echo_stdout.unwrap_or(true), endpoint: config.endpoint, level: config.level.into(), - use_fastly_logger: true, - } - } -} - -/// Resolve [`FastlyLogging`] from the `EDGEZERO__LOGGING__*` overlay. -/// -/// Three rules live here rather than in the caller. An unset or unparseable -/// `EDGEZERO__LOGGING__LEVEL` falls back to [`log::LevelFilter::Info`], and -/// `use_fastly_logger` is DERIVED from `endpoint.is_some()` so a Viceroy run -/// with no endpoint is never handed the reserved `stdout` name. `echo_stdout` -/// is always `true` on this path: `EDGEZERO__LOGGING__ECHO_STDOUT` is resolved -/// into the [`EnvConfig`] for downstream readers but is not applied here. -#[cfg(any(feature = "fastly", test))] -impl From<&EnvConfig> for FastlyLogging { - #[inline] - fn from(env: &EnvConfig) -> Self { - use std::str::FromStr as _; - - let level = env - .logging_level() - .and_then(|raw| log::LevelFilter::from_str(raw).ok()) - .unwrap_or(log::LevelFilter::Info); - // Only attach Fastly's named-endpoint logger when `EDGEZERO__LOGGING__ENDPOINT` - // is set. Production deployments set it to a real `[log_endpoints]` entry from - // `fastly.toml`; local Viceroy runs leave it unset and avoid the - // "endpoint not found, or is reserved" error that fires when the adapter - // would otherwise fall back to a reserved name like `stdout`. - let endpoint = env.logging_endpoint().map(str::to_owned); - let use_fastly_logger = endpoint.is_some(); - Self { - echo_stdout: true, - endpoint, - level, use_fastly_logger, } } } -/// 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 @@ -146,12 +82,12 @@ pub fn init_logger( /// Entry point for a Fastly Compute application. /// -/// Portable store config is baked into `A` by the `app!` macro; adapter-specific -/// values (platform store names, logging level) are read at runtime from -/// `EDGEZERO__*` environment variables. No `edgezero.toml` is required. +/// Portable store declarations and Fastly logging settings are baked into `A` +/// by the `app!` macro. Deployment binds physical stores to the baked logical +/// IDs through Fastly resource links. /// /// # Errors -/// Returns an error if logger setup fails or any required store cannot be opened. +/// Logger setup failures and unavailable required stores return errors. #[cfg(feature = "fastly")] #[inline] pub fn run_app(req: fastly::Request) -> Result { @@ -165,7 +101,7 @@ pub fn run_app(req: fastly::Request) -> Result( @@ -177,126 +113,19 @@ where F: FnOnce(&fastly::Request, &mut Extensions), { let stores = A::stores(); - let env = runtime_env_config(stores); - let logging = FastlyLogging::from(&env); + let logging = FastlyLogging::from(A::logging_for("fastly")); if logging.use_fastly_logger && !A::owns_logging() { let endpoint = logging.endpoint.as_deref().unwrap_or("stdout"); init_logger(endpoint, logging.level, logging.echo_stdout)?; } let app = A::build_app(); - request::dispatch_with_registries(&app, req, stores, &env, extend) + request::dispatch_with_registries(&app, req, stores, extend) } -/// Build an [`EnvConfig`] from the optional `edgezero_runtime_env` -/// Fastly Config Store. -/// -/// Compute@Edge has no process env, so the `EDGEZERO__*` runtime overrides -/// come from the Config Store. The function reads a fixed allowlist: adapter -/// host and port, logging settings, `__NAME` entries for declared stores, and -/// `__KEY` entries for declared config stores. -/// -/// Each lookup uses the current Fastly service's -/// `EDGEZERO__SERVICES____*` key. Legacy unscoped entries are not -/// read because they have no safe owner when this Config Store is linked to more -/// than one service. The returned [`EnvConfig`] contains canonical unscoped keys. -/// -/// [`run_app`] and [`run_app_with_request_extensions`] call this themselves. -/// [`run_app_with_config`] does NOT, and neither does a hand-built -/// [`FastlyService`](request::FastlyService). A custom entry point on either path -/// must call this explicitly. -/// -/// The `stores` argument must name the app's logical store ids. A handwritten -/// [`Hooks`] impl inherits the empty [`StoresMetadata::default`] and must -/// override `stores()` or pass explicit metadata here. -/// -/// If the store cannot be opened, the function logs a warning and returns an -/// empty [`EnvConfig`]. Callers then use their baked-in adapter and store defaults. -#[cfg(feature = "fastly")] -#[must_use] -#[inline] -pub fn runtime_env_config(stores: StoresMetadata) -> EnvConfig { - use fastly::ConfigStore; - use std::iter::empty; - let Ok(dict) = ConfigStore::try_open(RUNTIME_ENV_STORE_NAME) else { - // The store is optional -- a clean cutover deploy with all - // baked-in defaults works without it. But the absence means - // 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. - 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`." - ); - 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)); - EnvConfig::from_vars(vars) -} - -#[cfg(any(feature = "fastly", test))] -fn runtime_env_vars_for_service( - stores: StoresMetadata, - service_id: &str, - mut get: F, -) -> Vec<(String, String)> -where - F: FnMut(&str) -> Option, -{ - runtime_env_keys(stores) - .into_iter() - .filter_map(|canonical_key| { - let scoped_key = service_scoped_runtime_env_key(service_id, &canonical_key); - get(&scoped_key).map(|value| (canonical_key, value)) - }) - .collect() -} - -/// The `EDGEZERO__*` keys resolved from the store into the [`EnvConfig`]: the -/// fixed adapter and logging settings, plus a `__NAME` selector for every -/// declared store id and a `__KEY` selector for config-store ids only. -// The `test` arm keeps the key derivation tests in default workspace tests. -#[cfg(any(feature = "fastly", test))] -fn runtime_env_keys(stores: StoresMetadata) -> Vec { - let mut keys: Vec = vec![ - "EDGEZERO__ADAPTER__HOST".to_owned(), - "EDGEZERO__ADAPTER__PORT".to_owned(), - "EDGEZERO__LOGGING__LEVEL".to_owned(), - "EDGEZERO__LOGGING__ENDPOINT".to_owned(), - "EDGEZERO__LOGGING__USE_FASTLY_LOGGER".to_owned(), - "EDGEZERO__LOGGING__ECHO_STDOUT".to_owned(), - ]; - for (kind, store_meta) in [ - ("CONFIG", stores.config), - ("KV", stores.kv), - ("SECRETS", stores.secrets), - ] { - if let Some(meta) = store_meta { - for id in meta.ids { - let id_upper = id.to_ascii_uppercase(); - keys.push(format!("EDGEZERO__STORES__{kind}__{id_upper}__NAME")); - if kind == "CONFIG" { - keys.push(format!("EDGEZERO__STORES__{kind}__{id_upper}__KEY")); - } - } - } - } - keys -} - -/// Dispatch with a config store wired explicitly. This path does NOT apply the -/// [`EnvConfig`] overlay: the store name comes directly from -/// `config_store_name`, and its default key is always `"default"`, so staged or -/// overridden `__NAME` / `__KEY` selectors are ignored. Use -/// [`runtime_env_config`] with [`request::dispatch_with_registries`] for the -/// same selector resolution as [`run_app`]. KV is not auto-injected on this -/// path; chain `.with_kv(name)` on a [`request::FastlyService`] builder if you -/// need KV alongside the config store. +/// Dispatch with a config store wired explicitly. Its name and default key are +/// provided by the caller rather than derived from manifest store metadata. +/// KV is not auto-injected on this path; chain `.with_kv(name)` on a +/// [`request::FastlyService`] builder if you need KV alongside the config store. /// /// # Errors /// Returns an error if logger setup fails or the underlying handler returns an error. @@ -340,95 +169,12 @@ mod fastly_logging_tests { } #[test] - fn fastly_logging_from_env_falls_back_without_an_endpoint() { - let env = EnvConfig::from_vars([ - ("EDGEZERO__LOGGING__LEVEL", "not-a-level"), - ("EDGEZERO__LOGGING__ECHO_STDOUT", "false"), - ]); - - let logging = FastlyLogging::from(&env); + fn fastly_logging_without_manifest_endpoint_does_not_install_named_logger() { + let logging = FastlyLogging::from(ResolvedLoggingConfig::default()); assert_eq!(logging.level, log::LevelFilter::Info); assert_eq!(logging.endpoint, None); assert!(!logging.use_fastly_logger); assert!(logging.echo_stdout); } - - #[test] - fn fastly_logging_from_env_enables_the_named_endpoint_logger() { - let env = EnvConfig::from_vars([ - ("EDGEZERO__LOGGING__LEVEL", "debug"), - ("EDGEZERO__LOGGING__ENDPOINT", "edgezero-logs"), - ]); - - let logging = FastlyLogging::from(&env); - - assert_eq!(logging.level, log::LevelFilter::Debug); - assert_eq!(logging.endpoint.as_deref(), Some("edgezero-logs")); - assert!(logging.use_fastly_logger); - assert!(logging.echo_stdout); - } -} - -#[cfg(test)] -mod runtime_env_key_tests { - use super::runtime_env_keys; - use edgezero_core::app::{StoreMetadata, StoresMetadata}; - - #[test] - fn runtime_env_keys_name_every_store_and_key_only_config_stores() { - let stores = StoresMetadata { - config: Some(StoreMetadata { - default: "main", - ids: &["main", "edge"], - }), - kv: Some(StoreMetadata { - default: "cache", - ids: &["cache"], - }), - secrets: Some(StoreMetadata { - default: "vault", - ids: &["vault"], - }), - }; - - let mut keys = runtime_env_keys(stores); - keys.sort(); - - assert_eq!( - keys, - vec![ - "EDGEZERO__ADAPTER__HOST", - "EDGEZERO__ADAPTER__PORT", - "EDGEZERO__LOGGING__ECHO_STDOUT", - "EDGEZERO__LOGGING__ENDPOINT", - "EDGEZERO__LOGGING__LEVEL", - "EDGEZERO__LOGGING__USE_FASTLY_LOGGER", - "EDGEZERO__STORES__CONFIG__EDGE__KEY", - "EDGEZERO__STORES__CONFIG__EDGE__NAME", - "EDGEZERO__STORES__CONFIG__MAIN__KEY", - "EDGEZERO__STORES__CONFIG__MAIN__NAME", - "EDGEZERO__STORES__KV__CACHE__NAME", - "EDGEZERO__STORES__SECRETS__VAULT__NAME", - ] - ); - } - - #[test] - fn runtime_env_keys_without_declared_stores_are_the_fixed_keys_only() { - let mut keys = runtime_env_keys(StoresMetadata::default()); - keys.sort(); - - assert_eq!( - keys, - vec![ - "EDGEZERO__ADAPTER__HOST", - "EDGEZERO__ADAPTER__PORT", - "EDGEZERO__LOGGING__ECHO_STDOUT", - "EDGEZERO__LOGGING__ENDPOINT", - "EDGEZERO__LOGGING__LEVEL", - "EDGEZERO__LOGGING__USE_FASTLY_LOGGER", - ] - ); - } } diff --git a/crates/edgezero-adapter-fastly/src/request.rs b/crates/edgezero-adapter-fastly/src/request.rs index ea1a0077..c966244b 100644 --- a/crates/edgezero-adapter-fastly/src/request.rs +++ b/crates/edgezero-adapter-fastly/src/request.rs @@ -6,7 +6,6 @@ use std::sync::{Arc, Mutex, OnceLock, PoisonError}; use edgezero_core::app::{App, StoreMetadata, StoresMetadata}; use edgezero_core::body::Body; use edgezero_core::config_store::ConfigStoreHandle; -use edgezero_core::env_config::EnvConfig; use edgezero_core::error::EdgeError; use edgezero_core::http::{Extensions, Request, request_builder}; use edgezero_core::key_value_store::KvHandle; @@ -197,11 +196,9 @@ impl<'app> FastlyService<'app> { /// at request time, the dispatcher logs the warning once and /// proceeds without it. /// - /// Env-overlay limitation: this bare-handle path does not resolve - /// `EDGEZERO__STORES__CONFIG__*` selectors and binds the config registry's - /// default key to `"default"`. Use [`runtime_env_config`](crate::runtime_env_config) - /// with [`dispatch_with_registries`] when a custom entry point needs the - /// same `__NAME` / `__KEY` resolution as [`run_app`](crate::run_app). + /// This bare-handle path binds the config registry's default key to + /// `"default"`. Manifest-driven [`run_app`](crate::run_app) instead opens + /// the logical resource-link alias and uses its deterministic target key. #[must_use] #[inline] pub fn with_config>(mut self, name: S) -> Self { @@ -213,9 +210,8 @@ impl<'app> FastlyService<'app> { /// caller has already opened (or mocked) the backend. Mutually /// exclusive with `with_config(name)` -- the last call wins. /// Like [`Self::with_config`], this binds the config registry's default key - /// to `"default"` and does not apply the [`EnvConfig`] overlay. Use - /// [`runtime_env_config`](crate::runtime_env_config) with - /// [`dispatch_with_registries`] for manifest-driven selector resolution. + /// to `"default"`. Manifest-driven [`run_app`](crate::run_app) derives its + /// binding from baked store metadata instead. #[must_use] #[inline] pub fn with_config_handle(mut self, handle: ConfigStoreHandle) -> Self { @@ -318,14 +314,18 @@ where /// Dispatch with per-id store registries built from baked metadata — the same /// store wiring [`run_app`](crate::run_app) uses. /// -/// Fastly is `Multi` for all three kinds, so each declared id resolves to -/// its own platform store through the [`EnvConfig`] overlay: the -/// `EDGEZERO__STORES__CONFIG____NAME` selector (and its KV / secrets -/// counterparts) picks the platform store, and the config-only `__KEY` -/// selector picks that store's [`ConfigStoreBinding::default_key`]. Pair this -/// with [`runtime_env_config`](crate::runtime_env_config) in a custom entry -/// point for full parity with `run_app`. Contrast [`FastlyService`], whose -/// bare-handle path binds `default_key: "default"` and ignores those selectors. +/// Fastly is `Multi` for all three kinds. Each declared ID is the stable +/// resource-link alias opened by the runtime. Config always uses the logical ID +/// as its entry key. A custom entry point gets full parity with `run_app` by +/// passing the baked store metadata: +/// +/// ```rust,ignore +/// let stores = MyHooks::stores(); +/// dispatch_with_registries(&app, req, stores, |_req, _extensions| {}) +/// ``` +/// +/// [`FastlyService`]'s bare-handle path binds `default_key: "default"` and +/// ignores those selectors. /// /// KV failures escalate via `resolve_kv_handle`'s `kv_required=true` path; /// missing config / secret stores degrade silently with a one-time warning. @@ -338,15 +338,14 @@ pub fn dispatch_with_registries( app: &App, req: FastlyRequest, stores: StoresMetadata, - env: &EnvConfig, extend: F, ) -> Result where F: FnOnce(&FastlyRequest, &mut Extensions), { - let kv_registry = build_kv_registry(stores.kv, env)?; - let config_registry = build_config_registry(stores.config, env); - let secret_registry = build_secret_registry(stores.secrets, env); + let kv_registry = build_kv_registry(stores.kv)?; + let config_registry = build_config_registry(stores.config); + let secret_registry = build_secret_registry(stores.secrets); dispatch_with_handles( app, req, @@ -404,19 +403,15 @@ fn synthesise_store_registries( (config_registry, kv_registry, secret_registry) } -fn build_kv_registry( - kv_meta: Option, - env: &EnvConfig, -) -> Result, FastlyError> { +fn build_kv_registry(kv_meta: Option) -> Result, FastlyError> { let Some(meta) = kv_meta else { return Ok(None); }; let mut by_id: BTreeMap = BTreeMap::new(); for id in meta.ids { - let store_name = env.store_name("kv", id); // KV is required: if `[stores.kv]` is declared, an id failing to open // is a runtime error rather than a silent degradation. - let Some(handle) = resolve_kv_handle(&store_name, true)? else { + let Some(handle) = resolve_kv_handle(id, true)? else { continue; }; by_id.insert((*id).to_owned(), handle); @@ -430,25 +425,21 @@ fn build_kv_registry( Ok(StoreRegistry::from_parts(by_id, default_id)) } -fn build_config_registry( - config_meta: Option, - env: &EnvConfig, -) -> Option { +fn build_config_registry(config_meta: Option) -> Option { let meta = config_meta?; let mut by_id: BTreeMap = BTreeMap::new(); for id in meta.ids { - let store_name = env.store_name("config", id); - match FastlyConfigStore::try_open(&store_name) { + match FastlyConfigStore::try_open(id) { Ok(store) => { by_id.insert( (*id).to_owned(), ConfigStoreBinding { handle: ConfigStoreHandle::new(Arc::new(store)), - default_key: env.store_key("config", id), + default_key: (*id).to_owned(), }, ); } - Err(err) => warn_missing_store_once(&store_name, &err.to_string()), + Err(err) => warn_missing_store_once(id, &err.to_string()), } } let default_id = meta.default.to_owned(); @@ -460,24 +451,19 @@ fn build_config_registry( StoreRegistry::from_parts(by_id, default_id) } -fn build_secret_registry( - secret_meta: Option, - env: &EnvConfig, -) -> Option { +fn build_secret_registry(secret_meta: Option) -> Option { let meta = secret_meta?; // Fastly is `Multi` for secrets. The provider trait is stateless — // `FastlySecretStore::get_bytes(store_name, key)` opens the named Fastly // Secret Store per call — so we share one provider handle across all - // bindings, then capture the per-id platform store name in the bound - // wrapper. `EDGEZERO__STORES__SECRETS____NAME` (default = the logical - // id) decides which Fastly store each id resolves to at runtime. + // bindings, then capture the logical resource-link alias in the bound + // wrapper. let handle = SecretHandle::new(Arc::new(FastlySecretStore)); let mut by_id: BTreeMap = BTreeMap::new(); for id in meta.ids { - let store_name = env.store_name("secrets", id); by_id.insert( (*id).to_owned(), - BoundSecretStore::new(handle.clone(), store_name), + BoundSecretStore::new(handle.clone(), (*id).to_owned()), ); } // Fastly's secret-store handle wrappers are infallible to construct; @@ -779,25 +765,4 @@ mod synthesis_tests { fn resolve_secret_handle_builds_handle_when_required_true_matches_require_secrets() { let _handle = resolve_secret_handle(true); } - - /// Spec 12.7 / plan line 1526: `EDGEZERO__STORES__CONFIG____KEY` - /// must surface as `ConfigStoreBinding.default_key`. - /// - /// `build_config_registry` calls `FastlyConfigStore::try_open` which - /// requires live Fastly hostcalls and cannot be unit-tested here; this - /// test exercises the env-resolution layer that `build_config_registry` - /// reads from. Platform-integration coverage relies on the E2 smoke - /// scripts. - #[test] - fn config_default_key_env_override_resolved() { - let env = EnvConfig::from_vars([( - "EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY", - "app_config_staging", - )]); - assert_eq!( - env.store_key("config", "app_config"), - "app_config_staging", - "env override must propagate to the key resolved by build_config_registry" - ); - } } diff --git a/crates/edgezero-adapter/Cargo.toml b/crates/edgezero-adapter/Cargo.toml index bc234283..34ad5946 100644 --- a/crates/edgezero-adapter/Cargo.toml +++ b/crates/edgezero-adapter/Cargo.toml @@ -12,10 +12,14 @@ workspace = true [features] default = [] -cli = ["dep:toml"] +cli = ["dep:serde", "dep:serde_json", "dep:sha2", "dep:toml", "dep:walkdir"] [dependencies] toml = { workspace = true, optional = true } +serde = { workspace = true, optional = true } +serde_json = { workspace = true, optional = true } +sha2 = { workspace = true, optional = true } +walkdir = { workspace = true, optional = true } [dev-dependencies] tempfile = { workspace = true } diff --git a/crates/edgezero-adapter/src/lib.rs b/crates/edgezero-adapter/src/lib.rs index 607548d2..441f8516 100644 --- a/crates/edgezero-adapter/src/lib.rs +++ b/crates/edgezero-adapter/src/lib.rs @@ -4,3 +4,6 @@ pub mod scaffold; #[cfg(feature = "cli")] pub mod cli_support; + +#[cfg(feature = "cli")] +pub mod release; diff --git a/crates/edgezero-adapter/src/registry.rs b/crates/edgezero-adapter/src/registry.rs index 2ff6dc32..65f282d6 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::collections::{BTreeMap, HashMap}; +use std::path::{Path, PathBuf}; use std::sync::{LazyLock, PoisonError, RwLock}; static REGISTRY: LazyLock>> = @@ -41,6 +41,50 @@ 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() + } +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum DeployOwnership { + AdapterManaged, + #[default] + ManifestCommand, +} + +/// 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, + /// Exact application manifest loaded by the generic CLI. + pub application_manifest_path: Option, + /// Canonical root of an extracted immutable application release. + pub application_release_root: Option, + pub service_id: Option, + pub staging: bool, + pub stores: DeployStoreIds, + pub variable_defaults: BTreeMap, +} + /// A single declared store id, paired with the platform name the /// runtime will resolve via `EDGEZERO__STORES______NAME`. /// @@ -273,7 +317,42 @@ pub enum ReadConfigEntry { /// `SecretField` from `edgezero-core`) so this crate stays dep-free /// of `edgezero-core`. Defaults are no-ops; adapters override what /// they actually need. +#[expect( + clippy::arbitrary_source_item_ordering, + reason = "deploy lifecycle hooks read in invocation order: preflight before deploy" +)] pub trait Adapter: Sync + Send { + /// Decide whether the manifest command or adapter owns this deployment. + /// + /// # Errors + /// Returns an error string when the adapter cannot safely select a deploy path. + #[inline] + fn preflight_deploy( + &self, + _context: &AdapterDeployContext, + _args: &[String], + ) -> Result { + Ok(DeployOwnership::ManifestCommand) + } + + /// 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::DeployStaging + } 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 +369,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 @@ -375,6 +471,29 @@ pub trait Adapter: Sync + Send { Ok(()) } + /// Validate the final config key selected by CLI and environment precedence + /// before config push or diff performs provider I/O. + /// + /// `logical_store_id` is the portable manifest ID. `key` is the final key + /// the operation would read or write. `staging` identifies the requested + /// deployment target, and `local` identifies an emulator operation. + /// Adapters whose runtimes use fixed keys can reject a divergent selection; + /// the default preserves configurable keys. + /// + /// # Errors + /// Returns a human-readable error when the selected key cannot be read by + /// this adapter's runtime for the requested target. + #[inline] + fn validate_config_key_for_target( + &self, + _logical_store_id: &str, + _key: &str, + _staging: bool, + _local: bool, + ) -> Result<(), String> { + Ok(()) + } + /// Provision the platform resources backing each store id the /// user declared. Returns a list of human-readable /// status lines the CLI logs verbatim — one line per resource @@ -687,6 +806,18 @@ mod tests { HIT.store(0, Ordering::SeqCst); } + #[test] + fn default_deploy_preflight_keeps_manifest_command() { + let context = AdapterDeployContext::default(); + assert_eq!( + FIRST.preflight_deploy(&context, &[]).unwrap(), + DeployOwnership::ManifestCommand + ); + assert!(context.application_manifest_path.is_none()); + assert!(context.application_release_root.is_none()); + assert!(context.variable_defaults.is_empty()); + } + #[test] fn registers_and_fetches_adapter() { let _guard = TEST_LOCK.lock().expect("lock"); @@ -779,6 +910,10 @@ mod tests { ); let entry = TypedSecretEntry::new("vault", "api_token", "demo_api_token"); assert_eq!(FIRST.validate_typed_secrets(&[entry]), Ok(())); + assert_eq!( + FIRST.validate_config_key_for_target("app_config", "publisher-selected", true, false), + Ok(()) + ); } #[test] diff --git a/crates/edgezero-adapter/src/release.rs b/crates/edgezero-adapter/src/release.rs new file mode 100644 index 00000000..6428abe3 --- /dev/null +++ b/crates/edgezero-adapter/src/release.rs @@ -0,0 +1,783 @@ +use serde::Deserialize; +use sha2::{Digest as _, Sha256}; +use std::collections::BTreeSet; +use std::fs; +use std::path::{Component, Path, PathBuf}; +use walkdir::WalkDir; + +const RELEASE_METADATA_NAME: &str = "release.json"; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ApplicationReleaseMetadata { + adapter: String, + app_cli: ReleaseMember, + format: u64, + lifecycle_protocol: u64, + manifests: ReleaseManifests, + package: ReleaseMember, + source_revision: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ReleaseManifests { + adapter: ReleaseMember, + edgezero: ReleaseMember, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ReleaseMember { + path: String, + sha256: String, +} + +#[derive(Debug)] +#[cfg_attr( + not(test), + expect( + dead_code, + reason = "release verification records every member; host-only tests audit their exact bytes" + ) +)] +pub struct VerifiedApplicationRelease { + adapter_manifest: PathBuf, + application_cli: PathBuf, + application_manifest: PathBuf, + package: PathBuf, + package_sha256: String, + root: PathBuf, + source_revision: String, +} + +impl VerifiedApplicationRelease { + #[must_use] + #[inline] + pub fn adapter_manifest(&self) -> &Path { + &self.adapter_manifest + } + + #[must_use] + #[inline] + pub fn package(&self) -> &Path { + &self.package + } + + #[must_use] + #[inline] + pub fn package_sha256(&self) -> &str { + &self.package_sha256 + } +} + +/// Verifies an extracted immutable application release and returns its trusted +/// member paths and package identity. +/// +/// # Errors +/// +/// Returns an error when the release metadata, adapter or lifecycle identity, +/// recorded paths, member digests, loaded manifests, or exact member set does +/// not match the immutable release contract. +#[inline] +pub fn verify_application_release( + root: &Path, + loaded_application_manifest: &Path, + referenced_adapter_manifest: &Path, + expected_adapter: &str, + expected_lifecycle_protocol: u64, +) -> Result { + let canonical_root = root.canonicalize().map_err(|error| { + format!( + "could not resolve application release root {}: {error}", + root.display() + ) + })?; + if !canonical_root.is_dir() { + return Err(format!( + "application release root {} is not a directory", + canonical_root.display() + )); + } + + let metadata_path = canonical_root.join(RELEASE_METADATA_NAME); + let metadata_bytes = fs::read(&metadata_path).map_err(|error| { + format!("application release is missing {RELEASE_METADATA_NAME}: {error}") + })?; + let metadata: ApplicationReleaseMetadata = serde_json::from_slice(&metadata_bytes) + .map_err(|error| format!("invalid application release release.json: {error}"))?; + validate_metadata(&metadata, expected_adapter, expected_lifecycle_protocol)?; + + let app_cli_relative = validate_release_path(&metadata.app_cli.path)?; + let package_relative = validate_release_path(&metadata.package.path)?; + let application_manifest_relative = validate_release_path(&metadata.manifests.edgezero.path)?; + let adapter_manifest_relative = validate_release_path(&metadata.manifests.adapter.path)?; + let mut recorded_relative_paths = BTreeSet::new(); + for relative in [ + &app_cli_relative, + &package_relative, + &application_manifest_relative, + &adapter_manifest_relative, + ] { + if !recorded_relative_paths.insert(relative.clone()) { + return Err(format!( + "application release contains duplicate recorded path {}", + relative.display() + )); + } + } + let application_cli = verify_member( + &canonical_root, + &app_cli_relative, + &metadata.app_cli.sha256, + "application CLI", + )?; + let package = verify_member( + &canonical_root, + &package_relative, + &metadata.package.sha256, + "package", + )?; + let recorded_application_manifest = verify_member( + &canonical_root, + &application_manifest_relative, + &metadata.manifests.edgezero.sha256, + "application manifest", + )?; + let recorded_adapter_manifest = verify_member( + &canonical_root, + &adapter_manifest_relative, + &metadata.manifests.adapter.sha256, + "adapter manifest", + )?; + + let canonical_loaded_manifest = + canonical_regular_file(loaded_application_manifest, "loaded application manifest")?; + if canonical_loaded_manifest != recorded_application_manifest { + return Err(format!( + "loaded application manifest {} is not the application manifest recorded by the immutable release", + canonical_loaded_manifest.display() + )); + } + let canonical_referenced_adapter_manifest = + canonical_regular_file(referenced_adapter_manifest, "referenced adapter manifest")?; + if canonical_referenced_adapter_manifest != recorded_adapter_manifest { + return Err(format!( + "referenced adapter manifest {} is not the adapter manifest recorded by the immutable release", + canonical_referenced_adapter_manifest.display() + )); + } + + verify_exact_members(&canonical_root, &recorded_relative_paths)?; + + Ok(VerifiedApplicationRelease { + adapter_manifest: recorded_adapter_manifest, + application_cli, + application_manifest: recorded_application_manifest, + package, + package_sha256: metadata.package.sha256, + root: canonical_root, + source_revision: metadata.source_revision, + }) +} + +fn validate_metadata( + metadata: &ApplicationReleaseMetadata, + expected_adapter: &str, + expected_lifecycle_protocol: u64, +) -> Result<(), String> { + if metadata.format != 1 { + return Err(format!( + "unsupported application release format {}; expected format 1", + metadata.format + )); + } + if metadata.lifecycle_protocol != expected_lifecycle_protocol { + return Err(format!( + "unsupported application release lifecycle protocol {}; expected {}", + metadata.lifecycle_protocol, expected_lifecycle_protocol + )); + } + if metadata.adapter != expected_adapter { + return Err(format!( + "application release adapter {:?} is unsupported; expected {expected_adapter:?}", + metadata.adapter, + )); + } + if !matches!(metadata.source_revision.len(), 40 | 64) + || !is_lower_hex(&metadata.source_revision) + { + return Err( + "application release source_revision must be exactly 40 or 64 lowercase hexadecimal characters" + .to_owned(), + ); + } + for (label, member) in [ + ("app_cli", &metadata.app_cli), + ("package", &metadata.package), + ("manifests.edgezero", &metadata.manifests.edgezero), + ("manifests.adapter", &metadata.manifests.adapter), + ] { + if member.sha256.len() != 64 || !is_lower_hex(&member.sha256) { + return Err(format!( + "application release {label}.sha256 must be exactly 64 lowercase hexadecimal characters" + )); + } + } + Ok(()) +} + +fn is_lower_hex(value: &str) -> bool { + value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +fn validate_release_path(raw: &str) -> Result { + if raw.is_empty() || raw.contains('\\') { + return Err(format!( + "application release path {raw:?} must be a non-empty normalized `/`-separated relative path" + )); + } + let path = Path::new(raw); + if path.is_absolute() + || path + .components() + .any(|component| !matches!(component, Component::Normal(_))) + { + return Err(format!( + "application release path {raw:?} must be normalized and relative to the release root" + )); + } + let normalized = path + .components() + .filter_map(|component| match component { + Component::Normal(segment) => segment.to_str(), + Component::Prefix(_) + | Component::RootDir + | Component::CurDir + | Component::ParentDir => None, + }) + .collect::>() + .join("/"); + if normalized != raw { + return Err(format!( + "application release path {raw:?} is not normalized" + )); + } + Ok(path.to_path_buf()) +} + +fn verify_member( + root: &Path, + relative: &Path, + expected_digest: &str, + label: &str, +) -> Result { + let candidate = root.join(relative); + let metadata = fs::symlink_metadata(&candidate).map_err(|error| { + format!( + "application release is missing recorded {label} {}: {error}", + relative.display() + ) + })?; + if metadata.file_type().is_symlink() { + return Err(format!( + "application release recorded {label} {} is a symlink", + relative.display() + )); + } + if !metadata.is_file() { + return Err(format!( + "application release recorded {label} {} is not a regular file", + relative.display() + )); + } + let canonical = candidate.canonicalize().map_err(|error| { + format!( + "could not resolve application release {label} {}: {error}", + relative.display() + ) + })?; + if !canonical.starts_with(root) { + return Err(format!( + "application release recorded {label} {} resolves outside the release root", + relative.display() + )); + } + let bytes = fs::read(&canonical).map_err(|error| { + format!( + "could not read application release {label} {}: {error}", + relative.display() + ) + })?; + let actual = format!("{:x}", Sha256::digest(bytes)); + if actual != expected_digest { + return Err(format!( + "application release {label} {} digest does not match release.json", + relative.display() + )); + } + Ok(canonical) +} + +fn canonical_regular_file(path: &Path, label: &str) -> Result { + let metadata = fs::symlink_metadata(path) + .map_err(|error| format!("could not resolve {label} {}: {error}", path.display()))?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(format!("{label} {} is not a regular file", path.display())); + } + path.canonicalize() + .map_err(|error| format!("could not resolve {label} {}: {error}", path.display())) +} + +#[expect( + clippy::filetype_is_file, + reason = "the verifier must reject every non-directory, non-symlink, non-regular release member" +)] +fn verify_exact_members(root: &Path, recorded: &BTreeSet) -> Result<(), String> { + let mut expected = recorded.clone(); + expected.insert(PathBuf::from(RELEASE_METADATA_NAME)); + let mut expected_directories = BTreeSet::new(); + for member in &expected { + let mut parent = member.parent(); + while let Some(directory) = parent.filter(|directory| !directory.as_os_str().is_empty()) { + expected_directories.insert(directory.to_path_buf()); + parent = directory.parent(); + } + } + let mut actual = BTreeSet::new(); + for candidate in WalkDir::new(root).follow_links(false) { + let entry = candidate + .map_err(|error| format!("could not inspect extracted application release: {error}"))?; + if entry.path() == root { + continue; + } + let relative = entry + .path() + .strip_prefix(root) + .map_err(|error| format!("application release member escaped its root: {error}"))?; + if entry.file_type().is_dir() { + if !expected_directories.contains(relative) { + return Err(format!( + "application release contains extra member {}", + relative.display() + )); + } + continue; + } + if entry.file_type().is_symlink() { + return Err(format!( + "application release member {} is a symlink", + relative.display() + )); + } + if !entry.file_type().is_file() { + return Err(format!( + "application release member {} is not a regular file", + relative.display() + )); + } + actual.insert(relative.to_path_buf()); + } + if actual != expected { + let extra = actual.difference(&expected).next(); + let missing = expected.difference(&actual).next(); + if let Some(path) = extra { + return Err(format!( + "application release contains extra member {}", + path.display() + )); + } + if let Some(path) = missing { + return Err(format!( + "application release is missing recorded member {}", + path.display() + )); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + #![expect( + clippy::arbitrary_source_item_ordering, + reason = "the release fixture keeps construction helpers in lifecycle order for readable adversarial tests" + )] + + use super::*; + use sha2::Sha256; + use std::fs; + use std::path::{Path, PathBuf}; + use tempfile::TempDir; + + struct ReleaseFixture { + root: TempDir, + application_manifest: PathBuf, + adapter_manifest: PathBuf, + } + + impl ReleaseFixture { + fn new() -> Self { + let root = TempDir::new().expect("release root"); + for directory in ["cli", "pkg", "adapter"] { + fs::create_dir_all(root.path().join(directory)).expect("release directory"); + } + let application_manifest = root.path().join("edgezero.toml"); + let adapter_manifest = root.path().join("adapter/synthetic.toml"); + fs::write(root.path().join("cli/app-cli.tar.gz"), b"immutable cli") + .expect("application cli"); + fs::write(root.path().join("pkg/app.tar.gz"), b"immutable package").expect("package"); + fs::write( + &application_manifest, + b"[app]\nname = \"demo\"\n[adapters.synthetic.adapter]\nmanifest = \"adapter/synthetic.toml\"\n[adapters.spin.adapter]\nmanifest = \"adapter/spin.toml\"\n", + ) + .expect("application manifest"); + fs::write( + &adapter_manifest, + b"manifest_version = 3\nname = \"demo\"\n", + ) + .expect("adapter manifest"); + let fixture = Self { + root, + application_manifest, + adapter_manifest, + }; + fixture.write_metadata_with(|_| {}); + fixture + } + + fn digest(path: &Path) -> String { + let bytes = fs::read(path).expect("fixture member"); + format!("{:x}", Sha256::digest(bytes)) + } + + fn metadata(&self) -> serde_json::Value { + serde_json::json!({ + "format": 1, + "lifecycle_protocol": 1, + "source_revision": "a".repeat(40), + "adapter": "synthetic", + "app_cli": { + "path": "cli/app-cli.tar.gz", + "sha256": Self::digest(&self.root.path().join("cli/app-cli.tar.gz")), + }, + "package": { + "path": "pkg/app.tar.gz", + "sha256": Self::digest(&self.root.path().join("pkg/app.tar.gz")), + }, + "manifests": { + "edgezero": { + "path": "edgezero.toml", + "sha256": Self::digest(&self.application_manifest), + }, + "adapter": { + "path": "adapter/synthetic.toml", + "sha256": Self::digest(&self.adapter_manifest), + } + } + }) + } + + fn write_metadata_with(&self, mutate: impl FnOnce(&mut serde_json::Value)) { + let mut metadata = self.metadata(); + mutate(&mut metadata); + fs::write( + self.root.path().join("release.json"), + serde_json::to_vec(&metadata).expect("metadata json"), + ) + .expect("release metadata"); + } + + fn verify(&self) -> Result { + verify_application_release( + self.root.path(), + &self.application_manifest, + &self.adapter_manifest, + "synthetic", + 1, + ) + } + } + + #[test] + fn application_release_verifies_exact_members_and_returns_confined_paths() { + let fixture = ReleaseFixture::new(); + assert!( + !fixture.root.path().join("adapter/spin.toml").exists(), + "an adapter release must not include an unselected adapter manifest" + ); + let verified = fixture.verify().expect("valid release"); + + assert_eq!(verified.root, fixture.root.path().canonicalize().unwrap()); + assert_eq!( + verified.application_cli, + fixture + .root + .path() + .join("cli/app-cli.tar.gz") + .canonicalize() + .unwrap() + ); + assert_eq!( + verified.application_manifest, + fixture.application_manifest.canonicalize().unwrap() + ); + assert_eq!( + verified.adapter_manifest, + fixture.adapter_manifest.canonicalize().unwrap() + ); + assert_eq!( + verified.package, + fixture + .root + .path() + .join("pkg/app.tar.gz") + .canonicalize() + .unwrap() + ); + assert_eq!(verified.package_sha256.len(), 64); + assert_eq!(verified.source_revision, "a".repeat(40)); + } + + #[test] + fn application_release_allows_required_parents_and_rejects_extra_empty_directories() { + let fixture = ReleaseFixture::new(); + fixture + .verify() + .expect("directories required by recorded members are allowed"); + + fs::create_dir_all(fixture.root.path().join("unexpected-empty")) + .expect("extra empty directory"); + let error = fixture + .verify() + .expect_err("an extra empty directory is an extra release member"); + assert!(error.contains("extra member unexpected-empty"), "{error}"); + } + + #[test] + fn application_release_rejects_duplicate_unknown_and_unsupported_metadata() { + let fixture = ReleaseFixture::new(); + let valid = fs::read_to_string(fixture.root.path().join("release.json")).unwrap(); + let duplicate = valid.replacen("\"format\":1", "\"format\":1,\"format\":1", 1); + fs::write(fixture.root.path().join("release.json"), duplicate).unwrap(); + assert!(fixture.verify().unwrap_err().contains("release.json")); + + fixture.write_metadata_with(|metadata| { + metadata["unexpected"] = serde_json::json!(true); + }); + assert!(fixture.verify().unwrap_err().contains("unknown")); + + fixture.write_metadata_with(|metadata| metadata["format"] = serde_json::json!(2_u64)); + assert!(fixture.verify().unwrap_err().contains("format")); + + fixture.write_metadata_with(|metadata| { + metadata["app_cli"]["unexpected"] = serde_json::json!(true); + }); + assert!(fixture.verify().unwrap_err().contains("unknown")); + + fixture.write_metadata_with(|metadata| { + metadata + .as_object_mut() + .expect("release metadata object") + .remove("package"); + }); + assert!(fixture.verify().unwrap_err().contains("missing field")); + } + + #[test] + fn application_release_requires_supported_lifecycle_protocol() { + let missing = ReleaseFixture::new(); + missing.write_metadata_with(|metadata| { + metadata + .as_object_mut() + .expect("release metadata object") + .remove("lifecycle_protocol"); + }); + assert!(missing.verify().unwrap_err().contains("lifecycle_protocol")); + + let wrong_type = ReleaseFixture::new(); + wrong_type.write_metadata_with(|metadata| { + metadata["lifecycle_protocol"] = serde_json::json!("1"); + }); + assert!( + wrong_type + .verify() + .unwrap_err() + .contains("invalid application release release.json") + ); + + let unsupported = ReleaseFixture::new(); + unsupported.write_metadata_with(|metadata| { + metadata["lifecycle_protocol"] = serde_json::json!(2_u64); + }); + assert!( + unsupported + .verify() + .unwrap_err() + .contains("lifecycle protocol") + ); + } + + #[test] + fn application_release_rejects_invalid_revision_adapter_and_digest_syntax() { + for revision in ["A".repeat(40), "a".repeat(39), "z".repeat(40)] { + let revision_fixture = ReleaseFixture::new(); + revision_fixture.write_metadata_with(|metadata| { + metadata["source_revision"] = serde_json::json!(revision); + }); + assert!( + revision_fixture + .verify() + .unwrap_err() + .contains("source_revision") + ); + } + + let adapter_fixture = ReleaseFixture::new(); + adapter_fixture.write_metadata_with(|metadata| { + metadata["adapter"] = serde_json::json!("cloudflare"); + }); + assert!(adapter_fixture.verify().unwrap_err().contains("adapter")); + + for digest in ["A".repeat(64), "a".repeat(63), "g".repeat(64)] { + let digest_fixture = ReleaseFixture::new(); + digest_fixture.write_metadata_with(|metadata| { + metadata["package"]["sha256"] = serde_json::json!(digest); + }); + assert!(digest_fixture.verify().unwrap_err().contains("sha256")); + } + } + + #[test] + fn application_release_rejects_unconfined_or_non_normalized_paths() { + for path in [ + "/tmp/package.tar.gz", + "../package.tar.gz", + "pkg/../pkg/app.tar.gz", + "pkg//app.tar.gz", + "pkg\\app.tar.gz", + "./pkg/app.tar.gz", + ] { + let fixture = ReleaseFixture::new(); + fixture.write_metadata_with(|metadata| { + metadata["package"]["path"] = serde_json::json!(path); + }); + assert!(fixture.verify().is_err(), "path {path:?} must be rejected"); + } + + let fixture = ReleaseFixture::new(); + fixture.write_metadata_with(|metadata| { + metadata["package"]["path"] = serde_json::json!("cli/app-cli.tar.gz"); + metadata["package"]["sha256"] = metadata["app_cli"]["sha256"].clone(); + }); + assert!(fixture.verify().unwrap_err().contains("duplicate")); + } + + #[cfg(unix)] + #[test] + fn application_release_rejects_symlinks_and_canonical_root_escapes() { + use std::os::unix::fs::symlink; + + let file_symlink_fixture = ReleaseFixture::new(); + fs::remove_file(file_symlink_fixture.root.path().join("pkg/app.tar.gz")).unwrap(); + symlink( + file_symlink_fixture.root.path().join("cli/app-cli.tar.gz"), + file_symlink_fixture.root.path().join("pkg/app.tar.gz"), + ) + .unwrap(); + assert!( + file_symlink_fixture + .verify() + .unwrap_err() + .contains("symlink") + ); + + let directory_symlink_fixture = ReleaseFixture::new(); + let outside = TempDir::new().unwrap(); + fs::write(outside.path().join("app.tar.gz"), b"immutable package").unwrap(); + fs::remove_dir_all(directory_symlink_fixture.root.path().join("pkg")).unwrap(); + symlink( + outside.path(), + directory_symlink_fixture.root.path().join("pkg"), + ) + .unwrap(); + let error = directory_symlink_fixture.verify().unwrap_err(); + assert!( + error.contains("outside") || error.contains("symlink"), + "{error}" + ); + } + + #[test] + fn application_release_rejects_non_files_missing_and_extra_members() { + let directory_fixture = ReleaseFixture::new(); + fs::remove_file(directory_fixture.root.path().join("pkg/app.tar.gz")).unwrap(); + fs::create_dir_all(directory_fixture.root.path().join("pkg/app.tar.gz")).unwrap(); + assert!( + directory_fixture + .verify() + .unwrap_err() + .contains("regular file") + ); + + let missing_fixture = ReleaseFixture::new(); + fs::remove_file(missing_fixture.root.path().join("pkg/app.tar.gz")).unwrap(); + assert!(missing_fixture.verify().unwrap_err().contains("missing")); + + let extra_fixture = ReleaseFixture::new(); + fs::write(extra_fixture.root.path().join("extra.txt"), b"extra").unwrap(); + assert!(extra_fixture.verify().unwrap_err().contains("extra")); + } + + #[test] + fn application_release_rejects_each_digest_mismatch() { + for member in [ + "cli/app-cli.tar.gz", + "pkg/app.tar.gz", + "edgezero.toml", + "adapter/synthetic.toml", + ] { + let fixture = ReleaseFixture::new(); + fs::write(fixture.root.path().join(member), b"tampered").unwrap(); + let error = fixture.verify().unwrap_err(); + assert!(error.contains("digest"), "{member}: {error}"); + assert!(!error.contains("tampered"), "file contents leaked: {error}"); + } + } + + #[test] + fn application_release_requires_exact_loaded_manifests() { + let fixture = ReleaseFixture::new(); + let other_application = fixture.root.path().join("other-edgezero.toml"); + fs::write(&other_application, b"[app]\nname = \"other\"\n").unwrap(); + let application_error = verify_application_release( + fixture.root.path(), + &other_application, + &fixture.adapter_manifest, + "synthetic", + 1, + ) + .unwrap_err(); + assert!( + application_error.contains("application manifest"), + "{application_error}" + ); + + let other_adapter = fixture.root.path().join("adapter/other-synthetic.toml"); + fs::write(&other_adapter, b"manifest_version = 3\n").unwrap(); + let adapter_error = verify_application_release( + fixture.root.path(), + &fixture.application_manifest, + &other_adapter, + "synthetic", + 1, + ) + .unwrap_err(); + assert!( + adapter_error.contains("adapter manifest"), + "{adapter_error}" + ); + } +} diff --git a/crates/edgezero-cli/src/adapter.rs b/crates/edgezero-cli/src/adapter.rs index ad15f463..8b0a965e 100644 --- a/crates/edgezero-cli/src/adapter.rs +++ b/crates/edgezero-cli/src/adapter.rs @@ -1,4 +1,6 @@ -use edgezero_adapter::registry::{self as adapter_registry, AdapterAction}; +use edgezero_adapter::registry::{ + self as adapter_registry, AdapterAction, AdapterDeployContext, DeployOwnership, +}; use edgezero_core::manifest::{Manifest, ManifestLoader, ResolvedEnvironment}; use std::env; @@ -130,16 +132,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 +158,76 @@ 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( +/// Select deployment ownership through the registered adapter's preflight. +/// Manifest-owned and fallback deployments retain the legacy finalizer, while +/// adapter-managed deployments own the complete lifecycle in `deploy`. +pub fn deploy( adapter_name: &str, - action: Action, + context: &AdapterDeployContext, + adapter_manifest_path_error: Option<&str>, 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> { + let registered_adapter = adapter_registry::get_adapter(adapter_name); + let ownership = registered_adapter + .map_or(Ok(DeployOwnership::ManifestCommand), |registered| { + registered.preflight_deploy(context, adapter_args) + })?; + + if ownership == DeployOwnership::AdapterManaged { + if let Some(err) = adapter_manifest_path_error { + return Err(err.to_owned()); + } + let Some(managed_adapter) = registered_adapter else { + return Err(format!( + "adapter `{adapter_name}` selected managed deployment without being registered" + )); + }; + return managed_adapter.deploy(context, adapter_args); + } + + if !context.staging + && let Some(loader) = manifest_loader + && let Some(command) = manifest_command(loader.manifest(), adapter_name, Action::Deploy) { + if registered_adapter.is_some() + && !context.stores.is_empty() + && let Some(err) = adapter_manifest_path_error + { + return Err(err.to_owned()); + } 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(finalizer) = registered_adapter { + finalizer.finalize_deploy(context, Some(&output))?; + } + return Ok(()); } - execute(adapter_name, action, manifest_loader, adapter_args)?; - Ok(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>, - adapter_name: &str, - action: Action, -) -> bool { - manifest_loader - .is_some_and(|loader| manifest_command(loader.manifest(), adapter_name, action).is_some()) + if let Some(err) = adapter_manifest_path_error { + return Err(err.to_owned()); + } + let fallback_adapter = require_adapter(adapter_name, manifest_loader.is_some())?; + fallback_adapter.deploy(context, adapter_args)?; + fallback_adapter.finalize_deploy(context, None) } fn manifest_command<'manifest>( diff --git a/crates/edgezero-cli/src/args.rs b/crates/edgezero-cli/src/args.rs index cc86e79c..2e69a283 100644 --- a/crates/edgezero-cli/src/args.rs +++ b/crates/edgezero-cli/src/args.rs @@ -255,6 +255,11 @@ pub struct DeployArgs { /// staging-intended deploy to PRODUCTION. #[arg(last = true)] pub adapter_args: Vec, + /// Canonical root of an already-extracted immutable application release. + /// The generic CLI confines the loaded application manifest to this root; + /// the selected adapter validates its own release metadata. + #[arg(long)] + pub application_release: Option, /// Platform service id the deploy targets. Consumed by the Fastly /// staging lifecycle: production deploy passes it /// through to `fastly compute deploy` and resolves the activated @@ -468,9 +473,9 @@ 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 environment-selected staging Config Store. The entry + /// key remains the logical store ID. 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 @@ -560,14 +565,11 @@ pub struct ConfigPushArgs { /// `runtime-config.toml` next to the adapter manifest. #[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 - /// 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 - /// key would be written where nothing reads it. + /// Push to the physical Config Store selected by the staging environment. + /// The entry key remains the logical store ID. Production and staging may + /// select the same or different physical stores. The same `--staging` verb + /// `deploy`/`healthcheck`/`rollback` use. Mutually exclusive with `--key` so + /// the deployed runtime and pushed entry cannot diverge. #[arg(long, conflicts_with = "key")] pub staging: bool, /// Logical config store id to push to. Defaults to the @@ -800,6 +802,30 @@ mod tests { assert_eq!(adapter_args, vec!["--flag", "value"]); } + #[test] + fn deploy_parses_application_release_before_passthrough_boundary() { + let args = Args::try_parse_from([ + "edgezero", + "deploy", + "--adapter", + "fastly", + "--application-release", + "/tmp/application-release", + "--", + "--comment", + "publisher deploy", + ]) + .expect("parse deploy"); + let Command::Deploy(deploy) = args.cmd else { + panic!("expected Command::Deploy"); + }; + assert_eq!( + deploy.application_release, + Some(PathBuf::from("/tmp/application-release")) + ); + assert_eq!(deploy.adapter_args, ["--comment", "publisher deploy"]); + } + #[test] fn parses_new_command_with_defaults() { let args = Args::try_parse_from(["edgezero", "new", "demo-app"]).expect("parse new"); diff --git a/crates/edgezero-cli/src/config.rs b/crates/edgezero-cli/src/config.rs index 97a16f7c..dc20a4a8 100644 --- a/crates/edgezero-cli/src/config.rs +++ b/crates/edgezero-cli/src/config.rs @@ -23,7 +23,7 @@ use crate::args::{ parse_duration_secs, }; use crate::diff::{collect_changes, render_json, render_structured}; -use crate::ensure_adapter_defined; +use crate::{ensure_adapter_defined, manifest_variable_defaults}; use edgezero_adapter::registry::{ self as adapter_registry, ReadConfigEntry, ResolvedStoreId, TypedSecretEntry, }; @@ -32,12 +32,13 @@ use edgezero_core::app_config::{ SecretPathSegment, }; use edgezero_core::blob_envelope::{BlobEnvelope, BlobEnvelopeError, ENVELOPE_VERSION_V1}; -use edgezero_core::env_config::EnvConfig; -use edgezero_core::manifest::{Manifest, ManifestLoader, StoreDeclaration}; +use edgezero_core::env_config::{EnvConfig, merge_env_defaults}; +use edgezero_core::manifest::{Manifest, ManifestAdapter, ManifestLoader, StoreDeclaration}; use serde::Serialize; use serde::de::DeserializeOwned; use similar::TextDiff; use std::collections::BTreeMap; +use std::env; use std::io::{Error as IoError, IsTerminal as _, Write, stdin}; use std::iter; use std::path::{Path, PathBuf}; @@ -72,6 +73,9 @@ struct PushContext { /// helper borrows from this to build the `AdapterPushContext<'_>` /// it hands the adapter trait method. adapter_push_ctx: ResolvedAdapterPushContext, + /// Final config entry key after CLI/environment resolution and adapter + /// policy validation. + key: String, /// Resolved config store id (`--store` or the manifest /// default), paired with its env-resolved platform name. The /// platform name is what the adapter writes / pushes into @@ -116,6 +120,12 @@ struct ValidationContext { raw_config: Value, } +#[derive(Clone, Copy)] +enum AdapterValidationScope { + All, + Selected(&'static dyn adapter_registry::Adapter), +} + impl ValidationContext { fn manifest(&self) -> &Manifest { self.manifest_loader.manifest() @@ -218,7 +228,7 @@ struct ResolvedTomlLeaf<'raw> { #[inline] pub fn run_config_validate(args: &ConfigValidateArgs) -> Result<(), String> { let ctx = load_validation_context(args)?; - run_shared_checks(&ctx)?; + run_shared_checks(&ctx, AdapterValidationScope::All)?; log::info!( "[edgezero] config validate (raw): {} OK{}", args.manifest.display(), @@ -237,7 +247,7 @@ where C: DeserializeOwned + Validate + AppConfigMeta, { let ctx = load_validation_context(args)?; - run_shared_checks(&ctx)?; + run_shared_checks(&ctx, AdapterValidationScope::All)?; // Typed deserialise + validate_excluding_secrets (push, // diff, AND typed validate all use deserialize-only + @@ -255,7 +265,7 @@ where .map_err(|err| format!("typed app-config failed validation: {err}"))?; typed_secret_checks(&typed, &ctx)?; - run_adapter_typed_checks::(&ctx)?; + run_adapter_typed_checks::(&ctx, AdapterValidationScope::All)?; log::info!( "[edgezero] config validate (typed): {} + {} OK{}", @@ -445,7 +455,8 @@ where { // Pre-flight: load + validate. let ctx = load_push_context(args)?; - run_shared_checks(&ctx.validation)?; + let validation_scope = AdapterValidationScope::Selected(ctx.adapter); + run_shared_checks(&ctx.validation, validation_scope)?; let mut opts = AppConfigLoadOptions::default(); opts.env_overlay = !args.no_env; let typed: C = app_config::deserialize_app_config_with_options::( @@ -457,7 +468,7 @@ where app_config::validate_excluding_secrets(&typed) .map_err(|err| format!("typed app-config failed validation: {err}"))?; typed_secret_checks(&typed, &ctx.validation)?; - run_adapter_typed_checks::(&ctx.validation)?; + run_adapter_typed_checks::(&ctx.validation, validation_scope)?; // Resolve adapter paths. let (manifest_root, adapter_manifest_path, component_selector, push_ctx) = @@ -469,10 +480,8 @@ where push_ctx: &push_ctx, }; - // Build envelope. `--key` overrides the manifest's resolved logical store id; - // `--staging` instead targets the `_staging` variant the staging - // selector points at. The two are mutually exclusive. - let key = resolve_config_key(args.key.as_deref(), &ctx.store.logical, args.staging)?; + // Build the envelope only after selector and adapter key-policy validation. + let key = ctx.key.clone(); let body = build_config_envelope::(&typed)?; let local_envelope: BlobEnvelope = serde_json::from_str(&body).map_err(|err| format!("local envelope parse failed: {err}"))?; @@ -611,7 +620,16 @@ where strict: false, }; let ctx = load_validation_context(&validate_args)?; - run_shared_checks(&ctx)?; + ensure_adapter_defined(&args.adapter, Some(&ctx.manifest_loader))?; + let adapter = adapter_registry::get_adapter(&args.adapter).ok_or_else(|| { + format!( + "adapter `{}` is declared in {} but not registered in this build", + args.adapter, + args.manifest.display() + ) + })?; + let validation_scope = AdapterValidationScope::Selected(adapter); + run_shared_checks(&ctx, validation_scope)?; let mut opts = AppConfigLoadOptions::default(); opts.env_overlay = !args.no_env; let typed: C = app_config::deserialize_app_config_with_options::( @@ -623,7 +641,7 @@ where app_config::validate_excluding_secrets(&typed) .map_err(|err| format!("local validation failed: {err}"))?; typed_secret_checks(&typed, &ctx)?; - run_adapter_typed_checks::(&ctx)?; + run_adapter_typed_checks::(&ctx, validation_scope)?; // Build the local envelope. let local_data: serde_json::Value = serde_json::to_value(&typed) @@ -632,20 +650,16 @@ where let local_sha = local_envelope.sha256.clone(); // Resolve adapter + store + key (mirrors the push flow). - ensure_adapter_defined(&args.adapter, Some(&ctx.manifest_loader))?; - let adapter = adapter_registry::get_adapter(&args.adapter).ok_or_else(|| { - format!( - "adapter `{}` is declared in {} but not registered in this build", - args.adapter, - args.manifest.display() - ) - })?; let logical = resolve_config_store_id(args.store.as_deref(), ctx.manifest())?; - let env_config = EnvConfig::from_env(); - let platform = env_config.store_name("config", &logical); - let store = ResolvedStoreId::new(logical.clone(), platform); - // Diff exactly what `config push` would write, `--staging` included. - let key = resolve_config_key(args.key.as_deref(), &logical, args.staging)?; + let env_config = effective_manifest_environment(ctx.manifest(), &args.adapter, env::vars()); + let (store, key) = resolve_config_store_and_key( + adapter, + &env_config, + &logical, + args.key.as_deref(), + args.staging, + args.local, + )?; // Resolve adapter paths for the read call. let manifest_root = ctx @@ -1351,18 +1365,43 @@ fn load_push_context(args: &ConfigPushArgs) -> Result { ) })?; let logical = resolve_config_store_id(args.store.as_deref(), validation.manifest())?; - let env_config = EnvConfig::from_env(); - let platform = env_config.store_name("config", &logical); + let env_config = + effective_manifest_environment(validation.manifest(), &args.adapter, env::vars()); + let (store, key) = resolve_config_store_and_key( + adapter, + &env_config, + &logical, + args.key.as_deref(), + args.staging, + args.local, + )?; let adapter_push_ctx = resolve_adapter_push_ctx(args, &env_config, validation.manifest(), &args.adapter); Ok(PushContext { adapter, adapter_push_ctx, - store: ResolvedStoreId::new(logical, platform), + key, + store, validation, }) } +fn effective_manifest_environment( + manifest: &Manifest, + adapter: &str, + parent: I, +) -> EnvConfig +where + I: IntoIterator, + K: AsRef, + V: AsRef, +{ + EnvConfig::from_vars(merge_env_defaults( + manifest_variable_defaults(manifest, adapter), + parent, + )) +} + /// Resolve the push-time overlay values: `--local` flag (passed /// through verbatim) and the adapter-runtime-config path (`--runtime- /// config` flag if set; the adapter resolves a default location @@ -1379,33 +1418,41 @@ fn resolve_adapter_push_ctx( } } -/// Derive the config-store key a push or diff targets. +/// Resolve the config-store key a push or diff targets after [`EnvConfig`] has +/// applied the canonical environment override and production/staging fallback. /// -/// `--staging` writes (or diffs) the `_staging` variant in the SAME -/// store — never the production key the live service reads. Fastly config stores -/// are not versioned like staged service versions, so a different key is what -/// isolates staged config. -/// -/// `--key` and `--staging` are mutually exclusive, and that is not a style -/// choice. The staging key is not merely a name we write: a staged deploy puts -/// `_staging` into the staging selector store, and that selector is what -/// a staged version READS. An explicit key would be written to a key nothing -/// selects — a push that silently goes nowhere. Refuse instead. +/// `--key` keeps its production override behavior. It remains incompatible with +/// `--staging`, because a staged push must use the canonical target key resolved +/// from `EDGEZERO__STORES__CONFIG____KEY` and the adapter policy. fn resolve_config_key( explicit: Option<&str>, - logical: &str, + runtime_key: &str, staging: bool, ) -> Result { match (explicit, staging) { (Some(key), true) => Err(format!( - "`--key {key}` cannot be combined with `--staging`. The staging key is derived from the store's logical id (`{logical}_staging`) because that is what the staging selector store — created and linked by a staged deploy — points a staged version at. An explicit key would be written to a key nothing reads.\n Push the staged config without `--key`, or push to `--key {key}` without `--staging` and point the selector at it yourself." + "`--key {key}` cannot be combined with `--staging`. A staged push must use the canonical `EDGEZERO__STORES__CONFIG____KEY` selected for that target (currently `{runtime_key}`), so the pushed entry and deployed runtime cannot diverge.\n Set that canonical KEY in the staging environment and push without `--key`, or push to `--key {key}` without `--staging`." )), (Some(key), false) => Ok(key.to_owned()), - (None, false) => Ok(logical.to_owned()), - (None, true) => Ok(format!("{logical}_staging")), + (None, _) => Ok(runtime_key.to_owned()), } } +fn resolve_config_store_and_key( + adapter: &dyn adapter_registry::Adapter, + env_config: &EnvConfig, + logical: &str, + explicit_key: Option<&str>, + staging: bool, + local: bool, +) -> Result<(ResolvedStoreId, String), String> { + let platform = env_config.store_name_checked("config", logical)?; + let runtime_key = env_config.store_key_checked("config", logical)?; + let key = resolve_config_key(explicit_key, &runtime_key, staging)?; + adapter.validate_config_key_for_target(logical, &key, staging, local)?; + Ok((ResolvedStoreId::new(logical, platform), key)) +} + fn resolve_config_store_id(requested: Option<&str>, manifest: &Manifest) -> Result { let Some(declaration) = manifest.stores.config.as_ref() else { return Err( @@ -1526,10 +1573,18 @@ fn resolve_app_config_path( ) } -fn run_shared_checks(ctx: &ValidationContext) -> Result<(), String> { - run_adapter_shared_checks(ctx)?; +fn run_shared_checks( + ctx: &ValidationContext, + adapter_scope: AdapterValidationScope, +) -> Result<(), String> { + run_adapter_shared_checks(ctx, adapter_scope)?; if ctx.args_strict { - strict_capability_completeness(ctx.manifest())?; + match adapter_scope { + AdapterValidationScope::All => strict_capability_completeness(ctx.manifest())?, + AdapterValidationScope::Selected(adapter) => { + enforce_single_store_capability(ctx.manifest(), adapter.name())?; + } + } strict_handler_paths(ctx.manifest())?; } Ok(()) @@ -1540,13 +1595,13 @@ fn run_shared_checks(ctx: &ValidationContext) -> Result<(), String> { // `Adapter` trait impl. No `if adapter == "spin"` branches here. // ------------------------------------------------------------------- -/// Run the adapter-agnostic shared checks: for every adapter -/// declared in the manifest, look up its `Adapter` impl in the -/// registry and invoke `validate_app_config_keys` + -/// `validate_adapter_manifest`. Adapters not in the registry (e.g. -/// a feature-gated build that omitted some) are silently skipped — -/// they can't validate what they don't link. -fn run_adapter_shared_checks(ctx: &ValidationContext) -> Result<(), String> { +/// Run adapter-specific shared checks for the requested scope. Whole-project +/// validation checks every registered adapter declared in the manifest; +/// targeted lifecycle commands check only their selected adapter. +fn run_adapter_shared_checks( + ctx: &ValidationContext, + adapter_scope: AdapterValidationScope, +) -> Result<(), String> { let raw_table = ctx .raw_config .as_table() @@ -1554,12 +1609,17 @@ fn run_adapter_shared_checks(ctx: &ValidationContext) -> Result<(), String> { let flattened = flatten_keys(raw_table); let key_refs: Vec<&str> = flattened.iter().map(String::as_str).collect(); let manifest_root = ctx.manifest_path.parent().unwrap_or_else(|| Path::new(".")); - let env_config = EnvConfig::from_env(); - - for (name, adapter_cfg) in &ctx.manifest().adapters { - let Some(adapter) = adapter_registry::get_adapter(name) else { - continue; - }; + let parent_variables = env::vars().collect::>(); + + let validate = |name: &str, + adapter_cfg: &ManifestAdapter, + adapter: &'static dyn adapter_registry::Adapter| + -> Result<(), String> { + let env_config = effective_manifest_environment( + ctx.manifest(), + name, + parent_variables.iter().map(|(key, value)| (key, value)), + ); adapter.validate_app_config_keys(&key_refs)?; adapter.validate_adapter_manifest( manifest_root, @@ -1567,6 +1627,31 @@ fn run_adapter_shared_checks(ctx: &ValidationContext) -> Result<(), String> { adapter_cfg.adapter.component.as_deref(), )?; reject_merged_id_collisions(name, adapter, ctx.manifest(), &env_config)?; + Ok(()) + }; + + match adapter_scope { + AdapterValidationScope::All => { + for (name, adapter_cfg) in &ctx.manifest().adapters { + let Some(adapter) = adapter_registry::get_adapter(name) else { + continue; + }; + validate(name, adapter_cfg, adapter)?; + } + } + AdapterValidationScope::Selected(adapter) => { + let (name, adapter_cfg) = + ctx.manifest() + .adapter_entry(adapter.name()) + .ok_or_else(|| { + format!( + "adapter `{}` has no `[adapters.{}]` block", + adapter.name(), + adapter.name() + ) + })?; + validate(name, adapter_cfg, adapter)?; + } } Ok(()) } @@ -1745,12 +1830,15 @@ fn collect_secret_leaves<'raw>( Ok(out) } -/// Typed-only adapter dispatch: feed each adapter the `#[secret]` +/// Typed-only adapter dispatch: feed each adapter in the requested scope the `#[secret]` /// (`KeyInDefault` and `KeyInNamedStore` — `StoreRef` values are /// runtime store ids, not flat-namespace candidates) so adapters /// whose secret store has a flat-namespace constraint (Spin) can /// detect within-secrets collisions. -fn run_adapter_typed_checks(ctx: &ValidationContext) -> Result<(), String> { +fn run_adapter_typed_checks( + ctx: &ValidationContext, + adapter_scope: AdapterValidationScope, +) -> Result<(), String> { let default_store_id = ctx .manifest() .stores @@ -1780,8 +1868,16 @@ fn run_adapter_typed_checks(ctx: &ValidationContext) -> Result } } - for name in ctx.manifest().adapters.keys() { - if let Some(adapter) = adapter_registry::get_adapter(name) { + match adapter_scope { + AdapterValidationScope::All => { + for name in ctx.manifest().adapters.keys() { + let Some(adapter) = adapter_registry::get_adapter(name) else { + continue; + }; + adapter.validate_typed_secrets(&entries)?; + } + } + AdapterValidationScope::Selected(adapter) => { adapter.validate_typed_secrets(&entries)?; } } @@ -1987,17 +2083,51 @@ 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; + struct FixedConfigKeyAdapter; + + #[expect( + clippy::missing_trait_methods, + reason = "the test adapter only customizes final config-key validation" + )] + impl adapter_registry::Adapter for FixedConfigKeyAdapter { + fn execute( + &self, + _action: adapter_registry::AdapterAction, + _args: &[String], + ) -> Result<(), String> { + Ok(()) + } + + fn name(&self) -> &'static str { + "fixed-key-test" + } + + fn validate_config_key_for_target( + &self, + logical_store_id: &str, + key: &str, + _staging: bool, + _local: bool, + ) -> Result<(), String> { + if key == logical_store_id { + Ok(()) + } else { + Err("fixed key required".to_owned()) + } + } + } + + static FIXED_CONFIG_KEY_ADAPTER: FixedConfigKeyAdapter = FixedConfigKeyAdapter; + // ---------- config gc argument gating ---------- /// A destructive `config gc --yes` MUST NOT invent the safety assertion: it @@ -2444,28 +2574,200 @@ source = "target/wasm32-wasip2/release/demo.wasm" #[test] fn resolve_config_key_covers_key_and_staging_combinations() { + let defaults = EnvConfig::default(); + let production_default = defaults.store_key("config", "app_config"); // Production: the logical id, or an explicit --key verbatim. assert_eq!( - resolve_config_key(None, "app_config", false).unwrap(), + resolve_config_key(None, &production_default, false).unwrap(), "app_config" ); assert_eq!( - resolve_config_key(Some("custom"), "app_config", false).unwrap(), + resolve_config_key(Some("custom"), &production_default, false).unwrap(), "custom" ); - // Staging: the `_staging` variant the selector store points at. + let selected = + EnvConfig::from_vars([("EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY", "app_config")]); + let selected_production_key = selected.store_key("config", "app_config"); + assert_eq!( + resolve_config_key(None, &selected_production_key, false).unwrap(), + selected_production_key, + "production push and runtime selection must use the same explicit canonical KEY" + ); + let selected_runtime_key = selected.store_key("config", "app_config"); assert_eq!( - resolve_config_key(None, "app_config", true).unwrap(), - "app_config_staging" + resolve_config_key(None, &selected_runtime_key, true).unwrap(), + selected_runtime_key, + "staging push and runtime must select the same explicit canonical KEY" ); - // --key + --staging is REFUSED: an explicit staging key would be written - // to a key the staging selector never points at, so nothing would read - // it. A silent no-op is worse than an error. - let err = resolve_config_key(Some("custom"), "app_config", true) + + // --key + --staging is refused because only the canonical environment KEY + // can guarantee that the pushed entry is the one the deployed runtime + // reads. + let err = resolve_config_key(Some("custom"), &selected_runtime_key, true) .expect_err("--key with --staging must be rejected"); assert!( - err.contains("--staging") && err.contains("app_config_staging"), - "the error must explain the derivation: {err}" + err.contains("--staging") + && err.contains("EDGEZERO__STORES__CONFIG____KEY") + && !err.contains("selector store"), + "the error must explain canonical runtime-key selection without legacy selectors: {err}" + ); + } + + #[test] + fn config_target_rejects_present_invalid_selectors_before_fallback() { + for (setting, value) in [("NAME", ""), ("NAME", "bad\nname"), ("KEY", " ")] { + let variable = format!("EDGEZERO__STORES__CONFIG__APP_CONFIG__{setting}"); + let env = EnvConfig::from_vars([(variable.as_str(), value)]); + let error = resolve_config_store_and_key( + &FIXED_CONFIG_KEY_ADAPTER, + &env, + "app_config", + None, + false, + false, + ) + .expect_err("present invalid selectors must fail instead of falling back"); + assert!( + error.contains(&variable), + "error must name {variable}: {error}" + ); + assert!( + value.is_empty() || !error.contains(value), + "error must redact the invalid value: {error}" + ); + } + } + + #[test] + fn config_target_validates_the_final_key_after_cli_precedence() { + let env = EnvConfig::default(); + let (store, key) = resolve_config_store_and_key( + &FIXED_CONFIG_KEY_ADAPTER, + &env, + "app_config", + None, + false, + false, + ) + .expect("the deterministic default is accepted"); + assert_eq!(store, ResolvedStoreId::from_logical("app_config")); + assert_eq!(key, "app_config"); + + let error = resolve_config_store_and_key( + &FIXED_CONFIG_KEY_ADAPTER, + &env, + "app_config", + Some("custom"), + false, + false, + ) + .expect_err("adapter validation must see the explicit final key"); + assert_eq!(error, "fixed key required"); + + let (_, staging_key) = resolve_config_store_and_key( + &FIXED_CONFIG_KEY_ADAPTER, + &env, + "app_config", + None, + true, + false, + ) + .expect("staging uses the same logical key without target derivation"); + assert_eq!(staging_key, "app_config"); + + let staging_env = EnvConfig::from_vars([( + "EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY", + "app_config_staging", + )]); + let staging_error = resolve_config_store_and_key( + &FIXED_CONFIG_KEY_ADAPTER, + &staging_env, + "app_config", + None, + true, + false, + ) + .expect_err("target-specific keys must not change runtime behavior"); + assert_eq!(staging_error, "fixed key required"); + } + + #[test] + fn manifest_defaults_and_parent_select_the_same_store_and_key_as_deploy() { + let manifest = ManifestLoader::load_from_str( + r#" +[app] +name = "demo-app" + +[[environment.variables]] +name = "CONFIG_NAME" +env = "EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME" +value = "manifest-name" +adapters = ["fastly"] + +[[environment.variables]] +name = "CONFIG_KEY" +env = "EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY" +value = "manifest-key" +adapters = ["fastly"] + +[[environment.variables]] +name = "OTHER_ADAPTER" +env = "EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME" +value = "must-not-apply" +adapters = ["cloudflare"] + +[adapters.fastly.adapter] +crate = "crates/demo-app-adapter-fastly" + +[stores.config] +ids = ["app_config"] +"#, + ); + let defaults = effective_manifest_environment( + manifest.manifest(), + "fastly", + iter::empty::<(&str, &str)>(), + ); + assert_eq!(defaults.store_name("config", "app_config"), "manifest-name"); + assert_eq!(defaults.store_key("config", "app_config"), "manifest-key"); + + let parent = effective_manifest_environment( + manifest.manifest(), + "fastly", + [ + ("EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME", "parent-name"), + ("EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY", "parent-key"), + ], + ); + assert_eq!(parent.store_name("config", "app_config"), "parent-name"); + assert_eq!(parent.store_key("config", "app_config"), "parent-key"); + + let no_key = ManifestLoader::load_from_str( + r#" +[app] +name = "demo-app" + +[[environment.variables]] +name = "CONFIG_NAME" +env = "EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME" +value = "manifest-name" +adapters = ["fastly"] + +[adapters.fastly.adapter] +crate = "crates/demo-app-adapter-fastly" + +[stores.config] +ids = ["app_config"] +"#, + ); + let staging_without_key = effective_manifest_environment( + no_key.manifest(), + "fastly", + iter::empty::<(&str, &str)>(), + ); + assert_eq!( + staging_without_key.store_key("config", "app_config"), + "app_config" ); } @@ -3520,6 +3822,60 @@ ids = ["default"] } } + /// A targeted Fastly push validates the selected adapter only. The + /// application manifest may declare Spin for a different publisher without + /// making `spin.toml` part of the Fastly deployment input. + #[test] + fn fastly_push_does_not_require_unselected_spin_manifest() { + const MULTI_ADAPTER_MANIFEST: &str = r#" +[app] +name = "demo-app" + +[adapters.fastly.adapter] +crate = "crates/demo-app-adapter-fastly" +manifest = "fastly.toml" + +[adapters.fastly.commands] +build = "echo" +deploy = "echo" +serve = "echo" + +[adapters.spin.adapter] +crate = "crates/demo-app-adapter-spin" +manifest = "spin.toml" + +[adapters.spin.commands] +build = "echo" +deploy = "echo" +serve = "echo" + +[stores.config] +ids = ["app_config"] + +[stores.secrets] +ids = ["default"] +"#; + let _lock = manifest_guard().lock().expect("manifest guard"); + let (dir, manifest, _) = setup_project(MULTI_ADAPTER_MANIFEST, FIXTURE_APP_CONFIG); + fs::write( + dir.path().join("fastly.toml"), + "manifest_version = 3\nname = \"demo-app\"\nlanguage = \"rust\"\n", + ) + .expect("write fastly.toml"); + assert!( + !dir.path().join("spin.toml").exists(), + "the unselected adapter manifest must be absent for this regression" + ); + + let mut args = push_args(&manifest, "fastly"); + args.local = true; + args.dry_run = true; + args.app_config = Some(dir.path().join("demo-app.toml")); + + run_config_push_typed::(&args) + .expect("a Fastly push must not load the unselected Spin manifest"); + } + /// The dry-run degradation does NOT weaken the real push: a real /// `config push --local` over malformed TOML still fails fatally at the /// writer (which cannot parse the file to write into it). @@ -3558,12 +3914,10 @@ ids = ["default"] .expect_err("a real push over malformed TOML must fail at the writer"); } - /// The body-aware preflight runs BEFORE any remote I/O: an infeasible cloud - /// push (here, a reserved `--key`) fails with the preflight error, not a - /// `fastly`-not-found / auth error from the remote read. If preflight ran - /// after `read_remote`, the error would be about the missing/failed shell-out. + /// Fastly's deterministic-key policy runs before any remote I/O. A custom + /// `--key` fails locally rather than reaching a provider read or write. #[test] - fn cloud_push_preflight_rejects_reserved_key_before_remote_io() { + fn fastly_push_rejects_custom_key_before_remote_io() { const FASTLY_ONLY_MANIFEST: &str = r#" [app] name = "demo-app" @@ -3593,16 +3947,16 @@ ids = ["default"] let _prepend = PathPrepend::new(fake.path()); let mut args = push_args(&manifest, "fastly"); - // A reserved-namespace --key: infeasible, and preflight-detectable. + // Fastly derives the production key from the logical store ID. args.key = Some("app_config.__edgezero_chunks.deadbeef.0".to_owned()); args.yes = true; args.app_config = Some(dir.path().join("demo-app.toml")); let err = run_config_push_typed::(&args) - .expect_err("a reserved --key must be rejected"); + .expect_err("a custom Fastly --key must be rejected"); assert!( - err.contains("reserved infix"), - "must fail at preflight (before any remote read), not on a shell-out: {err}" + err.contains("logical config key `app_config`"), + "must fail at Fastly key validation before any remote read: {err}" ); assert!( !oplog.exists(), @@ -3611,6 +3965,57 @@ ids = ["default"] ); } + /// An explicitly exported but empty canonical selector must fail while the + /// push context is being resolved. It must never fall back to the logical + /// store or key and reach the provider. + #[test] + fn fastly_push_rejects_empty_selectors_before_remote_io() { + const FASTLY_ONLY_MANIFEST: &str = r#" +[app] +name = "demo-app" + +[adapters.fastly.adapter] +crate = "crates/demo-app-adapter-fastly" +manifest = "fastly.toml" + +[adapters.fastly.commands] +build = "echo" +deploy = "echo" +serve = "echo" + +[stores.config] +ids = ["app_config"] +"#; + let _lock = manifest_guard().lock().expect("manifest guard"); + let _path_lock = path_mutation_guard().lock().expect("path guard"); + let (dir, manifest, _) = setup_project(FASTLY_ONLY_MANIFEST, FIXTURE_APP_CONFIG); + let oplog = dir.path().join("fastly-ops.log"); + let fake = fake_fastly_logging(&oplog); + let _prepend = PathPrepend::new(fake.path()); + + let mut args = push_args(&manifest, "fastly"); + args.yes = true; + args.app_config = Some(dir.path().join("demo-app.toml")); + + for variable in [ + "EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME", + "EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY", + ] { + let selector = EnvOverride::set(variable, ""); + let error = run_config_push_typed::(&args) + .expect_err("an exported empty selector must fail before provider I/O"); + assert!( + error.contains(variable), + "error must identify the invalid canonical selector: {error}" + ); + assert!( + !oplog.exists(), + "selector validation must reject before any `fastly` invocation" + ); + drop(selector); + } + } + /// Stronger ordering proof: the generic push runs the FULL body-aware /// preflight offline before ANY remote I/O. Unlike a reserved key (rejectable /// by key SHAPE alone), a DERIVED-key overflow is only detectable by actually @@ -3622,7 +4027,7 @@ ids = ["default"] /// the generic path performs no list/describe/update/delete before the offline /// feasibility check has passed. #[test] - fn cloud_push_preflight_rejects_derived_key_overflow_before_remote_io() { + fn fastly_push_preflight_rejects_derived_key_overflow_before_remote_io() { const FASTLY_ONLY_MANIFEST: &str = r#" [app] name = "demo-app" @@ -3644,7 +4049,9 @@ ids = ["default"] "#; let _lock = manifest_guard().lock().expect("manifest guard"); let _path_lock = path_mutation_guard().lock().expect("path guard"); - let (dir, manifest, _) = setup_project(FASTLY_ONLY_MANIFEST, FIXTURE_APP_CONFIG); + let logical = "r".repeat(200); + let long_id_manifest = FASTLY_ONLY_MANIFEST.replace("app_config", &logical); + let (dir, manifest, _) = setup_project(&long_id_manifest, FIXTURE_APP_CONFIG); // A fake `fastly` on PATH records any invocation, so an ordering // regression shows up as a logged call rather than a real shell-out // against the developer's authenticated CLI. @@ -3660,9 +4067,9 @@ ids = ["default"] fs::write(dir.path().join("demo-app.toml"), big_app_config).expect("write big app config"); let mut args = push_args(&manifest, "fastly"); - // A VALID root key (<= 255 chars, no reserved infix) whose DERIVED chunk - // key (+~85 chars) overflows the store's 255-char limit once chunked. - args.key = Some("r".repeat(200)); + // A valid logical root key (<= 255 chars, no reserved infix) whose + // derived chunk key (+~85 chars) exceeds Fastly's 255-char limit. + args.store = Some(logical); args.yes = true; args.app_config = Some(dir.path().join("demo-app.toml")); @@ -4408,15 +4815,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 84a36805..edadb282 100644 --- a/crates/edgezero-cli/src/lib.rs +++ b/crates/edgezero-cli/src/lib.rs @@ -59,13 +59,17 @@ 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::{Manifest, ManifestLoader, StoreDeclaration}; +#[cfg(feature = "cli")] +use std::collections::BTreeMap; #[cfg(feature = "cli")] use std::env; #[cfg(feature = "cli")] use std::io::ErrorKind; #[cfg(feature = "cli")] -use std::path::PathBuf; +use std::path::{Path, PathBuf}; /// CLI output logger: prints `record.args()` verbatim with no /// timestamps, levels, or module prefixes — the CLI's output IS @@ -183,194 +187,114 @@ 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::DeployStaging - } else { - adapter::Action::Deploy + 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 variable_defaults = manifest + .as_ref() + .map(|loader| manifest_variable_defaults(loader.manifest(), &args.adapter)) + .unwrap_or_default(); + let (adapter_manifest_path, adapter_manifest_path_error) = + match resolve_adapter_manifest_path(manifest.as_ref(), &args.adapter) { + Ok(path) => (path.map(PathBuf::from), None), + Err(err) => (None, Some(err)), + }; + let application_manifest_path = loaded_application_manifest_path(manifest.as_ref())?; + let application_release_root = resolve_application_release_root( + args.application_release.as_deref(), + application_manifest_path.as_deref(), + )?; + let context = AdapterDeployContext { + adapter_manifest_path, + application_manifest_path, + application_release_root, + service_id: args.service_id.clone(), + stores: deploy_stores, + staging: args.staging, + variable_defaults, }; - 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::DeployStaging, - 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, + adapter_manifest_path_error.as_deref(), 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)) +fn manifest_variable_defaults(manifest: &Manifest, adapter: &str) -> BTreeMap { + manifest + .environment_for(adapter) + .variables + .into_iter() + .filter_map(|binding| binding.value.map(|value| (binding.env, value))) + .collect() } -/// 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() - }) +fn loaded_application_manifest_path( + loader: Option<&ManifestLoader>, +) -> Result, String> { + if loader.is_none() { + return Ok(None); + } + let path = env::var("EDGEZERO_MANIFEST") + .map_or_else(|_| PathBuf::from("edgezero.toml"), PathBuf::from); + let canonical = path.canonicalize().map_err(|error| { + format!( + "could not resolve loaded application manifest {}: {error}", + path.display() + ) + })?; + if !canonical.is_file() { + return Err(format!( + "loaded application manifest {} is not a regular file", + canonical.display() + )); + } + Ok(Some(canonical)) } -/// 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); - } +fn resolve_application_release_root( + requested_root: Option<&Path>, + application_manifest: Option<&Path>, +) -> Result, String> { + let Some(requested_root_path) = requested_root else { + return Ok(None); + }; + let root = requested_root_path.canonicalize().map_err(|error| { + format!( + "could not resolve application release root {}: {error}", + requested_root_path.display() + ) + })?; + if !root.is_dir() { + return Err(format!( + "application release root {} is not a directory", + root.display() + )); + } + let manifest = application_manifest + .ok_or_else(|| "--application-release requires a loaded application manifest".to_owned())?; + if !manifest.starts_with(&root) { + return Err(format!( + "loaded application manifest {} is outside application release root {}", + manifest.display(), + root.display() + )); } - result + Ok(Some(root)) } /// Resolve the absolute path of the adapter's platform manifest @@ -679,12 +603,99 @@ 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_adapter::registry::{ + self as adapter_registry, Adapter, AdapterAction, DeployOwnership, + }; use edgezero_core::manifest::ManifestLoader; + #[cfg(unix)] + use edgezero_core::test_env::PathPrepend; + use std::collections::BTreeMap; use std::fs; + #[cfg(unix)] + use std::os::unix::fs::PermissionsExt as _; use std::path::Path; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::{LazyLock, Mutex}; use tempfile::TempDir; + const PREFLIGHT_MANIFEST_COMMAND: usize = 0; + const PREFLIGHT_ADAPTER_MANAGED: usize = 1; + const PREFLIGHT_ERROR: usize = 2; + + static DEPLOY_PREFLIGHT_MODE: AtomicUsize = AtomicUsize::new(PREFLIGHT_MANIFEST_COMMAND); + static DEPLOY_CALLS: AtomicUsize = AtomicUsize::new(0); + static DEPLOY_CONTEXT: LazyLock>> = + LazyLock::new(|| Mutex::new(None)); + static DEPLOY_FINALIZE_CALLS: AtomicUsize = AtomicUsize::new(0); + static DEPLOY_PREFLIGHT_CONTEXT: LazyLock>> = + LazyLock::new(|| Mutex::new(None)); + static RECORDING_DEPLOY_ADAPTER: RecordingDeployAdapter = RecordingDeployAdapter; + + struct RecordingDeployAdapter; + + #[expect( + clippy::missing_trait_methods, + reason = "the recording adapter exercises only deploy preflight, deploy dispatch, and finalization" + )] + impl Adapter for RecordingDeployAdapter { + fn deploy(&self, context: &AdapterDeployContext, _args: &[String]) -> Result<(), String> { + *DEPLOY_CONTEXT + .lock() + .map_err(|err| format!("deploy context lock poisoned: {err}"))? = + Some(context.clone()); + DEPLOY_CALLS.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + + fn execute(&self, action: AdapterAction, _args: &[String]) -> Result<(), String> { + if action == AdapterAction::Deploy { + return Err("managed deployment must call Adapter::deploy".to_owned()); + } + Err(format!("unexpected recording adapter action: {action:?}")) + } + + fn finalize_deploy( + &self, + _context: &AdapterDeployContext, + _command_output: Option<&str>, + ) -> Result<(), String> { + DEPLOY_FINALIZE_CALLS.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + + fn name(&self) -> &'static str { + "recording_deploy_test" + } + + fn preflight_deploy( + &self, + context: &AdapterDeployContext, + _args: &[String], + ) -> Result { + *DEPLOY_PREFLIGHT_CONTEXT + .lock() + .map_err(|err| format!("deploy preflight context lock poisoned: {err}"))? = + Some(context.clone()); + match DEPLOY_PREFLIGHT_MODE.load(Ordering::SeqCst) { + PREFLIGHT_ADAPTER_MANAGED => Ok(DeployOwnership::AdapterManaged), + PREFLIGHT_ERROR => Err("recording preflight failed".to_owned()), + _ => Ok(DeployOwnership::ManifestCommand), + } + } + } + + fn reset_recording_deploy_adapter(mode: usize) { + adapter_registry::register_adapter(&RECORDING_DEPLOY_ADAPTER); + DEPLOY_PREFLIGHT_MODE.store(mode, Ordering::SeqCst); + DEPLOY_CALLS.store(0, Ordering::SeqCst); + *DEPLOY_CONTEXT.lock().expect("deploy context lock") = None; + DEPLOY_FINALIZE_CALLS.store(0, Ordering::SeqCst); + *DEPLOY_PREFLIGHT_CONTEXT + .lock() + .expect("deploy preflight context lock") = None; + } + #[test] fn load_manifest_optional_hard_errors_when_explicit_env_path_missing() { // An explicit `EDGEZERO_MANIFEST` pointing at a missing file must @@ -736,126 +747,518 @@ mod tests { assert!(manifest.manifest().adapters.contains_key("fastly")); } - // ── deploy-output version parsing ───────────────────────────────── + #[cfg(not(windows))] + #[test] + fn run_deploy_manifest_command_forwards_adapter_args_verbatim() { + // With `[adapters.fastly.commands] deploy = ...` the deploy runs + // as a shell command, NOT the built-in Fastly path — so anything + // the caller (e.g. the deploy action) passes as an adapter arg, + // `--non-interactive` included, must reach that command verbatim. + // The EdgeZero-internal `--manifest-path` must NOT: the shell + // command's own CLI has no such flag. + 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 curl = temp.path().join("curl"); + fs::write( + &curl, + "#!/bin/sh\ncat >/dev/null\nprintf '[{\"number\":42,\"active\":true,\"locked\":true,\"staging\":false,\"deployed\":true,\"environments\":[]}]\\n200'\n", + ) + .expect("write curl fake"); + let mut permissions = fs::metadata(&curl).expect("curl metadata").permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&curl, permissions).expect("make curl fake executable"); + let _path = PathPrepend::new(temp.path()); + let _token = EnvOverride::set("FASTLY_API_TOKEN", "test-token"); + let args_file = temp.path().join("argv.txt"); + let script = temp.path().join("record.sh"); + fs::write( + &script, + format!( + "#!/bin/sh\nprintf '%s\\n' \"$*\" > '{}'\necho version=42\n", + args_file.display() + ), + ) + .expect("write record script"); + let manifest_path = temp.path().join("edgezero.toml"); + fs::write( + &manifest_path, + format!( + "[app]\nname = \"demo-app\"\n\n[adapters.fastly.adapter]\ncrate = \"crates/demo-fastly\"\nmanifest = \"crates/demo-fastly/fastly.toml\"\n\n[adapters.fastly.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 args = DeployArgs { + adapter: "fastly".to_owned(), + application_release: None, + adapter_args: vec!["--non-interactive".to_owned()], + service_id: Some("SVC1".to_owned()), + staging: false, + }; + run_deploy(&args).expect("manifest deploy command runs"); + + let forwarded = fs::read_to_string(&args_file).expect("command recorded its args"); + assert_eq!( + forwarded.trim(), + "--service-id SVC1 --non-interactive", + "manifest deploy command must receive the adapter args verbatim" + ); + } + + #[cfg(not(windows))] #[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)); + 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(), + application_release: None, + 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 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)); + fn run_custom_deploy_with_stores_runs_without_registered_adapter() { + 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); + + run_deploy(&DeployArgs { + adapter: "unregistered_test".to_owned(), + application_release: None, + adapter_args: Vec::new(), + service_id: None, + staging: false, + }) + .expect("an unregistered custom adapter can run its manifest deploy command"); + + assert!( + marker.exists(), + "the custom deploy command should run without a registered adapter" + ); } + #[cfg(not(windows))] #[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); + fn deploy_preflight_adapter_managed_bypasses_manifest_command_and_receives_context() { + let _lock = manifest_guard().lock().expect("manifest guard"); + reset_recording_deploy_adapter(PREFLIGHT_ADAPTER_MANAGED); + let temp = TempDir::new().expect("temp dir"); + let marker = temp.path().join("manifest-deploy-ran"); + let adapter_dir = temp.path().join("nested/adapter"); + fs::create_dir_all(&adapter_dir).expect("adapter dir"); + let adapter_manifest = adapter_dir.join("adapter.toml"); + fs::write(&adapter_manifest, "name = \"recording\"\n").expect("adapter manifest"); + let manifest_path = temp.path().join("edgezero.toml"); + fs::write( + &manifest_path, + format!( + r#"[app] +name = "demo-app" + +[[environment.variables]] +name = "APPLICABLE_DEFAULT" +env = "EDGEZERO_TEST_APPLICABLE" +value = "from-manifest" +adapters = ["recording_deploy_test"] + +[[environment.variables]] +name = "OTHER_DEFAULT" +env = "EDGEZERO_TEST_OTHER" +value = "other-adapter" +adapters = ["other"] + +[[environment.variables]] +name = "UNSET_DEFAULT" +env = "EDGEZERO_TEST_UNSET" + +[[environment.secrets]] +name = "PRIVATE_TOKEN" +env = "EDGEZERO_TEST_SECRET" +value = "must-not-leak" +adapters = ["recording_deploy_test"] + +[adapters.recording_deploy_test.adapter] +crate = "crates/demo" +manifest = "nested/adapter/adapter.toml" + +[adapters.recording_deploy_test.commands] +deploy = "touch '{}'" +"#, + marker.display() + ), + ) + .expect("write manifest"); + let manifest_str = manifest_path.to_string_lossy().into_owned(); + let _env = EnvOverride::set("EDGEZERO_MANIFEST", &manifest_str); + + run_deploy(&DeployArgs { + adapter: "recording_deploy_test".to_owned(), + application_release: None, + adapter_args: Vec::new(), + service_id: None, + staging: false, + }) + .expect("adapter-managed deploy succeeds"); + + assert!(!marker.exists(), "adapter ownership bypasses the command"); + assert_eq!(DEPLOY_CALLS.load(Ordering::SeqCst), 1); + assert_eq!(DEPLOY_FINALIZE_CALLS.load(Ordering::SeqCst), 0); + let preflight_context = DEPLOY_PREFLIGHT_CONTEXT + .lock() + .expect("deploy preflight context lock") + .clone() + .expect("Adapter::preflight_deploy captured its context"); + let deployed_context = DEPLOY_CONTEXT + .lock() + .expect("deploy context lock") + .clone() + .expect("Adapter::deploy captured its context"); + let expected_adapter_manifest = adapter_manifest.canonicalize().expect("canonical path"); + assert_eq!( + preflight_context.adapter_manifest_path, + Some(expected_adapter_manifest.clone()) + ); + assert_eq!( + deployed_context.adapter_manifest_path, + Some(expected_adapter_manifest) + ); + assert_eq!( + deployed_context.variable_defaults, + BTreeMap::from([( + "EDGEZERO_TEST_APPLICABLE".to_owned(), + "from-manifest".to_owned() + )]) + ); + assert_eq!( + deployed_context.application_manifest_path, + Some( + manifest_path + .canonicalize() + .expect("canonical app manifest") + ) + ); + assert!(deployed_context.application_release_root.is_none()); } + #[cfg(not(windows))] #[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)); + fn deploy_preflight_receives_confined_application_release_paths() { + let _lock = manifest_guard().lock().expect("manifest guard"); + reset_recording_deploy_adapter(PREFLIGHT_ADAPTER_MANAGED); + let release = TempDir::new().expect("release root"); + let adapter_dir = release.path().join("adapter"); + fs::create_dir_all(&adapter_dir).expect("adapter directory"); + let adapter_manifest = adapter_dir.join("fastly.toml"); + fs::write(&adapter_manifest, "name = \"recording\"\n").expect("adapter manifest"); + let manifest_path = release.path().join("edgezero.toml"); + fs::write( + &manifest_path, + "[app]\nname = \"demo\"\n[adapters.recording_deploy_test.adapter]\ncrate = \"crates/demo\"\nmanifest = \"adapter/fastly.toml\"\n", + ) + .expect("application manifest"); + let manifest_str = manifest_path.to_string_lossy().into_owned(); + let _env = EnvOverride::set("EDGEZERO_MANIFEST", &manifest_str); + + run_deploy(&DeployArgs { + adapter: "recording_deploy_test".to_owned(), + application_release: Some(release.path().to_path_buf()), + ..DeployArgs::default() + }) + .expect("managed release deploy"); + + let context = DEPLOY_CONTEXT + .lock() + .expect("deploy context lock") + .clone() + .expect("deploy context"); + assert_eq!( + context.application_release_root, + Some(release.path().canonicalize().unwrap()) + ); + assert_eq!( + context.application_manifest_path, + Some(manifest_path.canonicalize().unwrap()) + ); } + #[cfg(not(windows))] #[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)); + fn deploy_rejects_application_manifest_outside_release_root() { + let _lock = manifest_guard().lock().expect("manifest guard"); + reset_recording_deploy_adapter(PREFLIGHT_ADAPTER_MANAGED); + let release = TempDir::new().expect("release root"); + let application = TempDir::new().expect("application root"); + let manifest_path = application.path().join("edgezero.toml"); + fs::write( + &manifest_path, + "[app]\nname = \"demo\"\n[adapters.recording_deploy_test.adapter]\ncrate = \"crates/demo\"\n", + ) + .expect("application manifest"); + let manifest_str = manifest_path.to_string_lossy().into_owned(); + let _env = EnvOverride::set("EDGEZERO_MANIFEST", &manifest_str); + + let error = run_deploy(&DeployArgs { + adapter: "recording_deploy_test".to_owned(), + application_release: Some(release.path().to_path_buf()), + ..DeployArgs::default() + }) + .expect_err("application manifest must be confined to release"); + + assert!(error.contains("outside application release"), "{error}"); + assert_eq!(DEPLOY_CALLS.load(Ordering::SeqCst), 0); } + #[cfg(not(windows))] #[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 + fn deploy_preflight_adapter_managed_rejects_invalid_manifest_before_deploy() { + let _lock = manifest_guard().lock().expect("manifest guard"); + reset_recording_deploy_adapter(PREFLIGHT_ADAPTER_MANAGED); + let temp = TempDir::new().expect("temp dir"); + let marker = temp.path().join("manifest-deploy-ran"); + let manifest_path = temp.path().join("edgezero.toml"); + fs::write( + &manifest_path, + format!( + "[app]\nname = \"demo-app\"\n\n[adapters.recording_deploy_test.adapter]\ncrate = \"crates/demo\"\nmanifest = \"nested/missing.toml\"\n\n[adapters.recording_deploy_test.commands]\ndeploy = \"touch '{}'\"\n", + marker.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: "recording_deploy_test".to_owned(), + application_release: None, + adapter_args: Vec::new(), + service_id: None, + staging: false, + }) + .expect_err("managed deploy requires its configured adapter manifest"); + + assert!( + err.contains("nested/missing.toml") && err.contains("could not resolve"), + "resolution error is preserved: {err}" ); - assert_eq!( - parse_deploy_version("Cloning version 3... created version 4\n"), - None + assert!(!marker.exists(), "managed ownership bypasses the command"); + assert_eq!(DEPLOY_CALLS.load(Ordering::SeqCst), 0); + assert_eq!(DEPLOY_FINALIZE_CALLS.load(Ordering::SeqCst), 0); + assert!( + DEPLOY_CONTEXT + .lock() + .expect("deploy context lock") + .is_none(), + "invalid manifest fails before Adapter::deploy" ); } + #[cfg(not(windows))] #[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)); + fn deploy_preflight_manifest_command_ignores_invalid_adapter_manifest() { + let _lock = manifest_guard().lock().expect("manifest guard"); + reset_recording_deploy_adapter(PREFLIGHT_MANIFEST_COMMAND); + let temp = TempDir::new().expect("temp dir"); + let marker = temp.path().join("manifest-deploy-ran"); + let manifest_path = temp.path().join("edgezero.toml"); + fs::write( + &manifest_path, + format!( + "[app]\nname = \"demo-app\"\n\n[adapters.recording_deploy_test.adapter]\ncrate = \"crates/demo\"\nmanifest = \"nested/missing.toml\"\n\n[adapters.recording_deploy_test.commands]\ndeploy = \"touch '{}'\"\n", + marker.display() + ), + ) + .expect("write manifest"); + let manifest_str = manifest_path.to_string_lossy().into_owned(); + let _env = EnvOverride::set("EDGEZERO_MANIFEST", &manifest_str); + + run_deploy(&DeployArgs { + adapter: "recording_deploy_test".to_owned(), + application_release: None, + adapter_args: Vec::new(), + service_id: None, + staging: false, + }) + .expect("manifest ownership does not require an adapter manifest"); + + assert!(marker.exists(), "the manifest deploy command runs"); + assert_eq!(DEPLOY_CALLS.load(Ordering::SeqCst), 0); + assert_eq!(DEPLOY_FINALIZE_CALLS.load(Ordering::SeqCst), 1); + assert!( + DEPLOY_CONTEXT + .lock() + .expect("deploy context lock") + .is_none() + ); } #[cfg(not(windows))] #[test] - fn run_deploy_manifest_command_forwards_adapter_args_verbatim() { - // With `[adapters.fastly.commands] deploy = ...` the deploy runs - // as a shell command, NOT the built-in Fastly path — so anything - // the caller (e.g. the deploy action) passes as an adapter arg, - // `--non-interactive` included, must reach that command verbatim. - // The EdgeZero-internal `--manifest-path` must NOT: the shell - // command's own CLI has no such flag. + fn deploy_preflight_manifest_command_with_stores_rejects_invalid_adapter_manifest() { let _lock = manifest_guard().lock().expect("manifest guard"); + reset_recording_deploy_adapter(PREFLIGHT_MANIFEST_COMMAND); let temp = TempDir::new().expect("temp dir"); - let args_file = temp.path().join("argv.txt"); - let script = temp.path().join("record.sh"); + let marker = temp.path().join("manifest-deploy-ran"); + let manifest_path = temp.path().join("edgezero.toml"); fs::write( - &script, + &manifest_path, format!( - "#!/bin/sh\nprintf '%s\\n' \"$*\" > '{}'\necho version=42\n", - args_file.display() + "[app]\nname = \"demo-app\"\n\n[stores.config]\nids = [\"app_config\"]\n\n[adapters.recording_deploy_test.adapter]\ncrate = \"crates/demo\"\nmanifest = \"nested/missing.toml\"\n\n[adapters.recording_deploy_test.commands]\ndeploy = \"touch '{}'\"\n", + marker.display() ), ) - .expect("write record script"); + .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: "recording_deploy_test".to_owned(), + application_release: None, + adapter_args: Vec::new(), + service_id: None, + staging: false, + }) + .expect_err("registered finalization with stores requires its adapter manifest"); + + assert!( + err.contains("nested/missing.toml") && err.contains("could not resolve"), + "resolution error is preserved: {err}" + ); + assert!( + !marker.exists(), + "manifest resolution fails before the command" + ); + assert_eq!(DEPLOY_CALLS.load(Ordering::SeqCst), 0); + assert_eq!(DEPLOY_FINALIZE_CALLS.load(Ordering::SeqCst), 0); + } + #[cfg(not(windows))] + #[test] + fn deploy_preflight_error_prevents_manifest_command() { + let _lock = manifest_guard().lock().expect("manifest guard"); + reset_recording_deploy_adapter(PREFLIGHT_ERROR); + let temp = TempDir::new().expect("temp dir"); + let marker = temp.path().join("manifest-deploy-ran"); let manifest_path = temp.path().join("edgezero.toml"); fs::write( &manifest_path, format!( - "[app]\nname = \"demo-app\"\n\n[adapters.fastly.adapter]\ncrate = \"crates/demo-fastly\"\nmanifest = \"crates/demo-fastly/fastly.toml\"\n\n[adapters.fastly.commands]\ndeploy = \"sh {}\"\n", - script.display() + "[app]\nname = \"demo-app\"\n\n[adapters.recording_deploy_test.adapter]\ncrate = \"crates/demo\"\n\n[adapters.recording_deploy_test.commands]\ndeploy = \"touch '{}'\"\n", + marker.display() ), ) .expect("write manifest"); let manifest_str = manifest_path.to_string_lossy().into_owned(); let _env = EnvOverride::set("EDGEZERO_MANIFEST", &manifest_str); - let args = DeployArgs { + let err = run_deploy(&DeployArgs { + adapter: "recording_deploy_test".to_owned(), + application_release: None, + adapter_args: Vec::new(), + service_id: None, + staging: false, + }) + .expect_err("preflight failure stops deployment"); + + assert!(err.contains("recording preflight failed"), "{err}"); + assert!(!marker.exists(), "preflight fails before the command runs"); + assert_eq!(DEPLOY_CALLS.load(Ordering::SeqCst), 0); + assert_eq!(DEPLOY_FINALIZE_CALLS.load(Ordering::SeqCst), 0); + } + + #[cfg(not(windows))] + #[test] + fn run_deploy_store_backed_fastly_bypasses_manifest_command_and_requires_release() { + use std::os::unix::fs::PermissionsExt as _; + + let _lock = manifest_guard().lock().expect("manifest guard"); + let temp = TempDir::new().expect("temp dir"); + let adapter_dir = temp.path().join("crates/demo-fastly"); + fs::create_dir_all(&adapter_dir).expect("adapter dir"); + fs::write(adapter_dir.join("fastly.toml"), "name = \"demo\"\n").expect("fastly manifest"); + + let marker = temp.path().join("manifest-command-ran"); + let deploy_script = temp.path().join("deploy.sh"); + fs::write( + &deploy_script, + format!("#!/bin/sh\ntouch '{}'\n", marker.display()), + ) + .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 manifest_path = temp.path().join("edgezero.toml"); + fs::write( + &manifest_path, + format!( + "[app]\nname = \"demo-app\"\n\n[stores.secrets]\nids = [\"credentials\"]\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 _manifest = EnvOverride::set("EDGEZERO_MANIFEST", &manifest_str); + let _selector = EnvOverride::set( + "EDGEZERO__STORES__SECRETS__CREDENTIALS__NAME", + "credentials-staging", + ); + + let error = run_deploy(&DeployArgs { adapter: "fastly".to_owned(), + application_release: None, adapter_args: vec!["--non-interactive".to_owned()], service_id: Some("SVC1".to_owned()), staging: false, - }; - run_deploy(&args).expect("manifest deploy command runs"); + }) + .expect_err("store-backed Fastly deploy requires an immutable release"); - let forwarded = fs::read_to_string(&args_file).expect("command recorded its args"); - assert_eq!( - forwarded.trim(), - "--service-id SVC1 --non-interactive", - "manifest deploy command must receive the adapter args verbatim" + assert!( + error.contains("--application-release"), + "managed ownership reaches the release verifier: {error}" ); + assert!(!marker.exists(), "the manifest deploy command is bypassed"); } #[test] @@ -868,6 +1271,7 @@ mod tests { for flag in ["--stage", "--staging", "--stage=true", "--staging=1"] { let args = DeployArgs { adapter: "fastly".to_owned(), + application_release: None, adapter_args: vec![flag.to_owned()], service_id: Some("SVC1".to_owned()), staging: false, @@ -927,6 +1331,7 @@ mod tests { let _env = EnvOverride::set("EDGEZERO_MANIFEST", &manifest_str); let args = DeployArgs { adapter: "fastly".to_owned(), + application_release: None, adapter_args: Vec::new(), // No service id → the production version-emit step is // skipped, so this test exercises only the diff --git a/crates/edgezero-cli/src/templates/root/README.md.hbs b/crates/edgezero-cli/src/templates/root/README.md.hbs index 810a010b..90f61c24 100644 --- a/crates/edgezero-cli/src/templates/root/README.md.hbs +++ b/crates/edgezero-cli/src/templates/root/README.md.hbs @@ -51,13 +51,14 @@ cargo run -p {{proj_cli}} -- config diff --adapter --format json --exit-c Uncomment `[stores.config]` in `edgezero.toml` (and the matching adapter binding in the per-adapter manifest) before running `config push`. -Use `--key ` for per-environment overrides (staging / canary): +Use `--key ` for per-environment overrides (staging / canary) on adapters +that support custom runtime keys: 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. +vars, and Spin from application variables. Fastly uses deterministic keys: +`app_config` for production and local Viceroy, and `app_config_staging` for +staging. Its deploy flow links the selected physical store under the logical +`app_config` alias; no service ID appears in an environment 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/crates/edgezero-core/src/app.rs b/crates/edgezero-core/src/app.rs index 6d1ebc89..837d7597 100644 --- a/crates/edgezero-core/src/app.rs +++ b/crates/edgezero-core/src/app.rs @@ -1,3 +1,4 @@ +use crate::manifest::ResolvedLoggingConfig; use crate::router::RouterService; /// Canonical adapter name for the Axum adapter. @@ -120,6 +121,17 @@ pub trait Hooks { #[inline] fn configure(_app: &mut App) {} + /// Logging settings for one adapter, baked from the application manifest. + /// + /// Macro-generated applications override this with the matching + /// `[adapters..logging]` or `[logging.]` configuration. A + /// handwritten implementation receives the portable defaults. + #[must_use] + #[inline] + fn logging_for(_adapter: &str) -> ResolvedLoggingConfig { + ResolvedLoggingConfig::default() + } + /// Display name for the application. Defaults to `"EdgeZero App"`. #[must_use] #[inline] @@ -157,6 +169,7 @@ mod tests { use crate::context::RequestContext; use crate::error::EdgeError; use crate::http::{Method, StatusCode, request_builder}; + use crate::manifest::LogLevel; use futures::executor::block_on; use tower_service::Service as _; @@ -253,6 +266,14 @@ mod tests { assert!(!DefaultHooks::owns_logging()); } + #[test] + fn default_hooks_use_default_logging_for_every_adapter() { + let logging = DefaultHooks::logging_for("fastly"); + assert_eq!(logging.level, LogLevel::Info); + assert!(logging.endpoint.is_none()); + assert!(logging.echo_stdout.is_none()); + } + #[test] fn default_hooks_use_default_name_and_into_router() { let app = DefaultHooks::build_app(); diff --git a/crates/edgezero-core/src/env_config.rs b/crates/edgezero-core/src/env_config.rs index 42a37440..65aff6bd 100644 --- a/crates/edgezero-core/src/env_config.rs +++ b/crates/edgezero-core/src/env_config.rs @@ -112,7 +112,11 @@ impl EnvConfig { /// Key for a logical store — `EDGEZERO__STORES______KEY` — /// falling back to `id` itself when unset, blank, whitespace-only, or - /// containing control characters. Mirrors [`store_name`]'s filter exactly. + /// containing control characters. + /// + /// This fallback form is intended for runtime registry construction. Code + /// that may mutate provider state must use [`Self::store_key_checked`] so a + /// present invalid selector fails before mutation. #[must_use] #[inline] pub fn store_key(&self, kind: &str, id: &str) -> String { @@ -121,6 +125,22 @@ impl EnvConfig { .map_or_else(|| id.to_owned(), str::to_owned) } + /// Checked key for a logical store. + /// + /// An absent selector uses the logical ID. A present blank value or a + /// value containing control characters is rejected so callers cannot + /// mutate the fallback key and later fail stricter deployment validation. + /// The error names the canonical variable without including its value. + /// + /// # Errors + /// Returns an error when the canonical `__KEY` selector is present but + /// invalid. + #[inline] + pub fn store_key_checked(&self, kind: &str, id: &str) -> Result { + self.store_selector_checked(kind, id, "key")? + .map_or_else(|| Ok(id.to_owned()), |value| Ok(value.to_owned())) + } + /// Platform name for a logical store — `EDGEZERO__STORES______NAME` /// — falling back to `id` itself when the variable is unset OR when /// the value is empty / whitespace-only. `kind` is `"kv"` / @@ -139,6 +159,10 @@ impl EnvConfig { /// Control characters are similarly rejected because no /// platform (cloudflare bindings, fastly store names, spin /// labels) accepts them as resource identifiers. + /// + /// This fallback form is intended for runtime registry construction. Code + /// that may mutate provider state must use [`Self::store_name_checked`] so + /// a present invalid selector fails before mutation. #[must_use] #[inline] pub fn store_name(&self, kind: &str, id: &str) -> String { @@ -147,6 +171,39 @@ impl EnvConfig { .map_or_else(|| id.to_owned(), str::to_owned) } + /// Checked platform name for a logical store. + /// + /// An absent selector defaults to `id`. A present blank value or a value + /// containing control characters is rejected. The error names the + /// canonical variable without including its value. + /// + /// # Errors + /// Returns an error when the canonical `__NAME` selector is present but + /// invalid. + #[inline] + pub fn store_name_checked(&self, kind: &str, id: &str) -> Result { + self.store_selector_checked(kind, id, "name")? + .map_or_else(|| Ok(id.to_owned()), |value| Ok(value.to_owned())) + } + + fn store_selector_checked<'value>( + &'value self, + kind: &str, + id: &str, + setting: &str, + ) -> Result, String> { + let value = self.get(&["stores", kind, id, setting]); + if value.is_some_and(is_blank_or_control) { + return Err(format!( + "EDGEZERO__STORES__{}__{}__{} is present but must be non-blank and contain no control characters (value redacted)", + kind.to_ascii_uppercase(), + id.to_ascii_uppercase(), + setting.to_ascii_uppercase() + )); + } + Ok(value) + } + /// Free-form per-store tuning — `EDGEZERO__STORES______`. #[must_use] #[inline] @@ -155,6 +212,37 @@ impl EnvConfig { } } +/// Merge manifest environment-variable defaults with parent-process values. +/// +/// Entries from `parent` are applied last and therefore override defaults with +/// the same exact environment-variable name. The returned map is intentionally +/// provider-neutral; callers may validate it or pass it to [`EnvConfig::from_vars`]. +#[must_use] +#[inline] +pub fn merge_env_defaults( + defaults: DI, + parent: PI, +) -> BTreeMap +where + DI: IntoIterator, + DK: AsRef, + DV: AsRef, + PI: IntoIterator, + PK: AsRef, + PV: AsRef, +{ + let mut merged = defaults + .into_iter() + .map(|(key, value)| (key.as_ref().to_owned(), value.as_ref().to_owned())) + .collect::>(); + merged.extend( + parent + .into_iter() + .map(|(key, value)| (key.as_ref().to_owned(), value.as_ref().to_owned())), + ); + merged +} + /// `true` if `value` is empty, made entirely of whitespace, or /// contains any ASCII / Unicode control character. Used to reject /// platform-name overrides that would otherwise flow as empty @@ -169,6 +257,32 @@ fn is_blank_or_control(value: &str) -> bool { mod tests { use super::*; + #[test] + fn merge_env_defaults_applies_parent_values_last() { + let merged = merge_env_defaults( + [ + ( + "EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME", + "manifest-name", + ), + ("EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY", "manifest-key"), + ], + [ + ("EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME", "parent-name"), + ("EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY", "parent-key"), + ], + ); + + assert_eq!( + merged.get("EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME"), + Some(&"parent-name".to_owned()) + ); + assert_eq!( + merged.get("EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY"), + Some(&"parent-key".to_owned()) + ); + } + fn sample() -> EnvConfig { EnvConfig::from_vars([ ("EDGEZERO__STORES__KV__SESSIONS__NAME", "prod-sessions"), @@ -239,6 +353,33 @@ mod tests { assert_eq!(with_nul.store_name("kv", "sessions"), "sessions"); } + #[test] + fn checked_store_name_rejects_present_invalid_values_without_disclosing_them() { + for invalid in ["", " \t ", "sensitive\nname", "sensitive\0name"] { + let cfg = EnvConfig::from_vars([("EDGEZERO__STORES__KV__SESSIONS__NAME", invalid)]); + let error = cfg + .store_name_checked("kv", "sessions") + .expect_err("a present invalid selector must not fall back"); + assert!( + error.contains("EDGEZERO__STORES__KV__SESSIONS__NAME"), + "the diagnostic must identify the canonical variable: {error}" + ); + assert!( + invalid.is_empty() || !error.contains(invalid), + "the diagnostic must redact the selector value: {error}" + ); + } + } + + #[test] + fn checked_store_name_defaults_only_when_selector_is_absent() { + let cfg = EnvConfig::default(); + assert_eq!( + cfg.store_name_checked("config", "app_config"), + Ok("app_config".to_owned()) + ); + } + #[test] fn store_name_accepts_real_world_punctuation() { // Underscores, dashes, and dots are valid in every platform @@ -279,6 +420,46 @@ mod tests { assert_eq!(cfg.store_key("config", "app_config"), "app_config"); } + #[test] + fn checked_store_key_rejects_present_invalid_values_without_disclosing_them() { + for invalid in ["", " \t ", "sensitive\nkey", "sensitive\0key"] { + let cfg = + EnvConfig::from_vars([("EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY", invalid)]); + let error = cfg + .store_key_checked("config", "app_config") + .expect_err("a present invalid selector must not fall back"); + assert!( + error.contains("EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY"), + "the diagnostic must identify the canonical variable: {error}" + ); + assert!( + invalid.is_empty() || !error.contains(invalid), + "the diagnostic must redact the selector value: {error}" + ); + } + } + + #[test] + fn checked_store_key_defaults_to_logical_id_only_when_selector_is_absent() { + let cfg = EnvConfig::default(); + assert_eq!( + cfg.store_key_checked("config", "app_config"), + Ok("app_config".to_owned()) + ); + } + + #[test] + fn checked_store_key_uses_canonical_environment_value() { + let cfg = EnvConfig::from_vars([( + "EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY", + "publisher-selected", + )]); + assert_eq!( + cfg.store_key_checked("config", "app_config"), + Ok("publisher-selected".to_owned()) + ); + } + #[test] fn store_setting_lookup() { let cfg = sample(); diff --git a/crates/edgezero-core/src/manifest.rs b/crates/edgezero-core/src/manifest.rs index 02dad65d..af2d0fc7 100644 --- a/crates/edgezero-core/src/manifest.rs +++ b/crates/edgezero-core/src/manifest.rs @@ -171,7 +171,7 @@ impl Manifest { for (adapter, cfg) in &self.adapters { if cfg.logging.is_specified() { resolved.insert( - adapter.clone(), + adapter.to_ascii_lowercase(), ResolvedLoggingConfig::from_manifest(&cfg.logging), ); } @@ -179,7 +179,7 @@ impl Manifest { for (adapter, cfg) in &self.logging.adapters { resolved - .entry(adapter.clone()) + .entry(adapter.to_ascii_lowercase()) .or_insert_with(|| ResolvedLoggingConfig::from_manifest(cfg)); } @@ -189,7 +189,7 @@ impl Manifest { #[must_use] #[inline] pub fn logging_for(&self, adapter: &str) -> Option<&ResolvedLoggingConfig> { - self.logging_resolved.get(adapter) + self.logging_resolved.get(&adapter.to_ascii_lowercase()) } #[must_use] @@ -860,8 +860,8 @@ fn validate_manifest_adapter(adapter: &ManifestAdapter) -> Result<(), Validation Ok(()) } -/// Reject case-fold duplicate `[adapters.*]` keys at manifest load -/// time so the case-insensitive `adapter_entry` lookup is never +/// Reject case-fold duplicate adapter names within `[adapters.*]` and +/// `[logging.*]` at manifest load time so case-insensitive lookups are never /// ambiguous. /// /// Pre-fix, an operator could declare BOTH `[adapters.fastly]` AND @@ -885,6 +885,21 @@ fn validate_manifest_adapter_keys_case_unique(manifest: &Manifest) -> Result<(), return Err(error); } } + + seen_ci.clear(); + for key in manifest.logging.adapters.keys() { + let folded = key.to_ascii_lowercase(); + if let Some(prior) = seen_ci.insert(folded, key) { + let mut error = ValidationError::new("logging_adapters_case_duplicate"); + error.message = Some( + format!( + "manifest declares `[logging.{prior}]` AND `[logging.{key}]`, which differ only in case; logging adapter names are matched case-insensitively. Pick one spelling." + ) + .into(), + ); + return Err(error); + } + } Ok(()) } @@ -1524,6 +1539,23 @@ echo_stdout = true assert_eq!(logging.echo_stdout, Some(true)); } + #[test] + fn logging_lookup_matches_adapter_case_insensitively() { + let manifest = r#" +[logging.Fastly] +level = "debug" +endpoint = "fastly_logs" +"#; + let loader = ManifestLoader::load_from_str(manifest); + let logging = loader + .manifest() + .logging_for("fastly") + .expect("lowercase adapter lookup must match mixed-case logging key"); + + assert_eq!(logging.level, LogLevel::Debug); + assert_eq!(logging.endpoint.as_deref(), Some("fastly_logs")); + } + #[test] fn adapter_logging_config_overrides_global() { let manifest = r#" @@ -1541,6 +1573,43 @@ endpoint = "https://fastly-logs.example.com" ); } + #[test] + fn adapter_logging_config_overrides_differently_cased_global_config() { + let manifest = r#" +[adapters.fastly.logging] +level = "error" +endpoint = "adapter_logs" + +[logging.FASTLY] +level = "debug" +endpoint = "global_logs" +"#; + let loader = ManifestLoader::load_from_str(manifest); + let logging = loader + .manifest() + .logging_for("FASTLY") + .expect("adapter logging must resolve regardless of lookup casing"); + + assert_eq!(logging.level, LogLevel::Error); + assert_eq!(logging.endpoint.as_deref(), Some("adapter_logs")); + } + + #[test] + fn manifest_rejects_case_fold_duplicate_logging_keys() { + let manifest: Manifest = toml::from_str( + "[logging.fastly]\nlevel = \"info\"\n[logging.Fastly]\nlevel = \"debug\"\n", + ) + .expect("case-distinct TOML keys should parse"); + let error = manifest + .validate() + .expect_err("case-fold duplicate logging keys must fail validation"); + + assert!( + error.to_string().contains("case"), + "error must call out the case collision: {error}" + ); + } + // Environment binding tests #[test] fn environment_binding_uses_env_key_when_specified() { diff --git a/crates/edgezero-macros/src/app.rs b/crates/edgezero-macros/src/app.rs index 1329991d..f1dda1ae 100644 --- a/crates/edgezero-macros/src/app.rs +++ b/crates/edgezero-macros/src/app.rs @@ -1,7 +1,8 @@ -use crate::manifest_definitions::{Manifest, StoreDeclaration}; +use crate::manifest_definitions::{LogLevel, Manifest, ResolvedLoggingConfig, StoreDeclaration}; use proc_macro::TokenStream; use proc_macro2::{Span, TokenStream as TokenStream2}; use quote::quote; +use std::collections::BTreeSet; use std::env; use std::fs; use std::path::PathBuf; @@ -123,6 +124,59 @@ fn build_stores_tokens(manifest: &Manifest) -> TokenStream2 { } } +fn logging_config_tokens(config: &ResolvedLoggingConfig) -> TokenStream2 { + let level = match config.level { + LogLevel::Trace => quote! { edgezero_core::manifest::LogLevel::Trace }, + LogLevel::Debug => quote! { edgezero_core::manifest::LogLevel::Debug }, + LogLevel::Info => quote! { edgezero_core::manifest::LogLevel::Info }, + LogLevel::Warn => quote! { edgezero_core::manifest::LogLevel::Warn }, + LogLevel::Error => quote! { edgezero_core::manifest::LogLevel::Error }, + LogLevel::Off => quote! { edgezero_core::manifest::LogLevel::Off }, + }; + let endpoint = config.endpoint.as_ref().map_or_else( + || quote! { None }, + |endpoint| { + let endpoint_literal = LitStr::new(endpoint, Span::call_site()); + quote! { Some(#endpoint_literal.to_owned()) } + }, + ); + let echo_stdout = config.echo_stdout.map_or_else( + || quote! { None }, + |echo_stdout| quote! { Some(#echo_stdout) }, + ); + quote! { + edgezero_core::manifest::ResolvedLoggingConfig { + echo_stdout: #echo_stdout, + endpoint: #endpoint, + level: #level, + } + } +} + +fn build_logging_tokens(manifest: &Manifest) -> TokenStream2 { + let adapter_names = manifest + .adapters + .keys() + .chain(manifest.logging.adapters.keys()) + .map(|adapter| adapter.to_ascii_lowercase()) + .collect::>(); + let arms = adapter_names.into_iter().map(|adapter| { + let adapter_lit = LitStr::new(&adapter, Span::call_site()); + let config = logging_config_tokens(&manifest.logging_or_default(&adapter)); + quote! { + if adapter.eq_ignore_ascii_case(#adapter_lit) { + return #config; + } + } + }); + quote! { + fn logging_for(adapter: &str) -> edgezero_core::manifest::ResolvedLoggingConfig { + #(#arms)* + edgezero_core::manifest::ResolvedLoggingConfig::default() + } + } +} + fn build_middleware_tokens(manifest: &Manifest) -> Result, String> { manifest .app @@ -206,6 +260,7 @@ pub fn expand_app(input: TokenStream) -> TokenStream { Err(msg) => return quote!(compile_error!(#msg);).into(), }; let stores_tokens = build_stores_tokens(&manifest); + let logging_tokens = build_logging_tokens(&manifest); let manifest_path_lit = LitStr::new(&manifest_path.to_string_lossy(), Span::call_site()); let owns_logging_lit = args.owns_logging.unwrap_or(false); @@ -237,6 +292,8 @@ pub fn expand_app(input: TokenStream) -> TokenStream { #owns_logging_lit } + #logging_tokens + fn name() -> &'static str { #app_name_lit } diff --git a/crates/edgezero-macros/tests/app_macro.rs b/crates/edgezero-macros/tests/app_macro.rs index 58185135..f32cf2d5 100644 --- a/crates/edgezero-macros/tests/app_macro.rs +++ b/crates/edgezero-macros/tests/app_macro.rs @@ -13,9 +13,23 @@ edgezero_core::app!( #[cfg(test)] mod tests { use edgezero_core::app::Hooks as _; + use edgezero_core::manifest::LogLevel; #[test] fn app_macro_emits_owns_logging_true() { assert!(super::OwnedLoggingApp::owns_logging()); } + + #[test] + fn app_macro_bakes_adapter_logging_from_the_manifest() { + let logging = super::OwnedLoggingApp::logging_for("FASTLY"); + assert_eq!(logging.endpoint.as_deref(), Some("fixture_logs")); + assert_eq!(logging.level, LogLevel::Debug); + assert_eq!(logging.echo_stdout, Some(false)); + + let missing = super::OwnedLoggingApp::logging_for("custom"); + assert!(missing.endpoint.is_none()); + assert_eq!(missing.level, LogLevel::Info); + assert!(missing.echo_stdout.is_none()); + } } diff --git a/crates/edgezero-macros/tests/fixtures/owns_logging.toml b/crates/edgezero-macros/tests/fixtures/owns_logging.toml index 2b009868..accd93a9 100644 --- a/crates/edgezero-macros/tests/fixtures/owns_logging.toml +++ b/crates/edgezero-macros/tests/fixtures/owns_logging.toml @@ -1,2 +1,12 @@ [app] name = "owns-logging-fixture" + +[logging.Fastly] +endpoint = "global_logs" +level = "error" +echo_stdout = true + +[adapters.fastly.logging] +endpoint = "fixture_logs" +level = "debug" +echo_stdout = false diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 81ebdb14..07db9a5e 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -63,6 +63,10 @@ export default defineConfig({ text: 'Deploying from GitHub Actions', link: '/guide/deploy-github-actions', }, + { + text: 'Adopting Deploy Actions', + link: '/guide/deploy-action-adoption', + }, { text: 'Manifest Store Migration', link: '/guide/manifest-store-migration', diff --git a/docs/guide/adapters/fastly.md b/docs/guide/adapters/fastly.md index da0185e9..13c1edbf 100644 --- a/docs/guide/adapters/fastly.md +++ b/docs/guide/adapters/fastly.md @@ -49,18 +49,55 @@ fn main(req: fastly::Request) -> Result { } ``` -`run_app` reads logging and store config at runtime from `EDGEZERO__*` -environment variables (see -[the migration guide](../manifest-store-migration.md)) and builds -per-id `KV` / `Config` / `Secret` registries from the portable store -metadata baked into `App` by the `app!` macro. No `edgezero.toml` is -loaded by the runtime. +`run_app` builds per-id `KV` / `Config` / `Secret` registries from the portable +store metadata baked into `App` by the `app!` macro. Logging settings are baked +in at the same time. A managed deployment resolves `EDGEZERO__*` store selectors +and links each selected Fastly resource under its logical ID; no +`edgezero.toml` or deployment environment is read by the runtime. See +[the migration guide](../manifest-store-migration.md). The low-level `dispatch()` helper remains available only for fully manual wiring and does not inject store metadata. Prefer `run_app` or `dispatch_with_config` for normal use. `dispatch_with_config_handle` exists for advanced/manual cases where you already have a prepared `ConfigStoreHandle`. +### Migrating a custom entrypoint + +Custom entrypoints previously loaded selectors from the +`edgezero_runtime_env` Config Store on every request and passed an `EnvConfig` +to `dispatch_with_registries`: + +```rust +let stores = MyHooks::stores(); +let env = edgezero_adapter_fastly::runtime_env_config(stores); +edgezero_adapter_fastly::request::dispatch_with_registries( + &app, + req, + stores, + &env, + extend, +) +``` + +Remove the `runtime_env_config` call, any use of +`RUNTIME_ENV_STORE_NAME`, and the `env` argument: + +```rust +let stores = MyHooks::stores(); +edgezero_adapter_fastly::request::dispatch_with_registries( + &app, + req, + stores, + extend, +) +``` + +`EDGEZERO__STORES______NAME` is now a deployment input. EdgeZero +resolves it before provider mutation and binds that physical resource to the +version under the stable logical `` alias. The runtime opens the alias +directly. Fastly Config Stores always use `` as the entry key, so remove +Fastly `__KEY` selectors as well. + ### Capturing raw-request signals (JA4, H2 fingerprint) `run_app` converts the `fastly::Request` into a neutral core request before @@ -135,16 +172,21 @@ This starts a local server at `http://127.0.0.1:7676`. ## Deployment -Deploy to Fastly Compute@Edge: +Deploy a verified application release with the adapter-managed lifecycle: ```bash -# Using the CLI -edgezero deploy --adapter fastly - -# Or directly -fastly compute deploy +edgezero deploy --adapter fastly \ + --service-id "$FASTLY_SERVICE_ID" \ + --application-release "$RELEASE_ROOT" ``` +The release fixes the package, `edgezero.toml`, and its referenced Fastly +manifest before runtime configuration is selected. A bare +`edgezero deploy --adapter fastly` remains only as store-free production +compatibility for an existing manifest command. Staging and any deployment that +declares a Config, KV, or Secret Store require the verified release-backed +managed command above. + ## Backends EdgeZero's Fastly proxy client uses **dynamic backends** derived from the target URI (host + scheme). @@ -184,64 +226,90 @@ fn main() { Fastly logging is wired when you call `init_logger` (or `run_app`); otherwise no logger is installed. ::: -## Config Store - -Fastly uses a native Config Store resource link for runtime configuration. Declare logical config -ids in `edgezero.toml`; each id opens its own platform store via -`EDGEZERO__STORES__CONFIG____NAME` (default = the logical id): +## Store selection and deployment -Because `edgezero_runtime_env` is an account-wide Fastly resource, its stored -keys are scoped by the current service ID: +Fastly Compute applications open Config, KV, and Secret Stores by resource-link +name. EdgeZero bakes the logical IDs declared in `edgezero.toml` into the package. +A managed deployment resolves these optional deployment selectors: ```text -EDGEZERO__SERVICES____STORES__CONFIG____NAME -EDGEZERO__SERVICES____STORES__CONFIG____KEY +EDGEZERO__STORES__CONFIG____NAME +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. +Each selected physical store is linked to the unpublished target version under +the stable logical `` alias. An absent `__NAME` defaults to ``; a present +blank or invalid value fails before provider mutation. The same logical ID can be +used independently by Config, KV, and Secret Stores because link identity is the +pair `(resource kind, logical ID)`. + +Config keys are deterministic on Fastly: production, staging, and local Viceroy +all read ``. The selected Environment chooses the physical store through +`__NAME`; the same name shares config and different names isolate it. A +conflicting `__KEY` or `--key` fails before a write. Logging is resolved from +`[adapters.fastly.logging]` when the package is built. + +Before publication, EdgeZero: + +1. verifies the immutable release package and manifests; +2. resolves complete Config, KV, and Secret Store inventories; +3. selects the exact active, staged, or initialized-draft source; +4. uploads the verified package to an unreachable draft; +5. replaces declared links whose selected physical resource changed and creates + missing declared links under their logical aliases; +6. preserves links not declared by the application; +7. re-reads the exact links, source state, draft state, and provider-visible + package identity; and +8. stages or activates the prepared version without another EdgeZero mutation. + +Production and staging can select different physical resources while deploying +identical package bytes. Secret Stores remain optional. Links the application +does not declare are preserved. + +### Declaring and using stores + +Declare portable logical IDs in `edgezero.toml`: ```toml [stores.config] -ids = ["app_config"] -# default = "app_config" # required when ids.len() > 1 +ids = ["app_config"] + +[stores.kv] +ids = ["cache"] + +# Optional: omit this table when the app uses no Secret Store. +[stores.secrets] +ids = ["credentials"] ``` -For local Viceroy testing, mirror the platform name in `fastly.toml`: +For local Viceroy tests, expose the Config Store under its logical ID and +write the production key under that store. Local Viceroy uses the production +key because it has no Fastly staging publication state: ```toml [local_server.config_stores.app_config] format = "inline-toml" [local_server.config_stores.app_config.contents] -greeting = "hello from config store" +app_config = "hello from config store" ``` -Handlers read values through the `Config` extractor or `ctx.config_store(id)`: +Handlers read values through the `Config` extractor or +`ctx.config_store(id)`: ```rust async fn handler(config: Config) -> Result { - let store = config.named("app_config").ok_or_else(|| EdgeError::service_unavailable("no `app_config`"))?; + let store = config + .named("app_config") + .ok_or_else(|| EdgeError::service_unavailable("no `app_config`"))?; let greeting = store.get("greeting").await?.unwrap_or_default(); // … } ``` -If a configured store link is missing, the adapter logs a one-time warning -and drops that id from the registry. Migrating from `name`/`adapters.*`? -See [the migration guide](../manifest-store-migration.md). +See [the store migration guide](../manifest-store-migration.md) for store selection and [the GitHub Actions guide](../deploy-github-actions.md) for the +immutable-release workflow. ## Context Access diff --git a/docs/guide/blob-app-config-migration.md b/docs/guide/blob-app-config-migration.md index 6fdea5d0..39695ebd 100644 --- a/docs/guide/blob-app-config-migration.md +++ b/docs/guide/blob-app-config-migration.md @@ -233,46 +233,39 @@ mechanism.** | **Axum** | Process env: `EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY=app_config_staging serve --adapter axum` | | **Cloudflare** | `.dev.vars` (local) or `wrangler.toml` `[vars]` (deployed) -- wrangler surfaces it to `env.var(...)` in the worker | | **Spin** | `[application.variables]` in `spin.toml` (defaulted) plus `SPIN_VARIABLE_EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY=app_config_staging spin up` for a per-invocation override | -| **Fastly** | A dedicated `edgezero_runtime_env` Config Store (Compute@Edge has no process env). See below. | +| **Fastly** | Do not set a custom key. Production, staging, and local Viceroy all use the logical key `app_config`; select a different physical store with `__NAME`. | #### Fastly specifically -Compute@Edge has no `std::env`, so EdgeZero reads runtime overrides -from a Fastly Config Store named `edgezero_runtime_env`. The store is -created automatically by `edgezero provision --adapter fastly`. After -provisioning: +Fastly deployment variables select physical stores, while the application always +opens the logical Config Store ID. Managed deploy links the selected physical +store under that logical alias. Production and staging both read key +`app_config` from the physical store selected by their deployment environment: -```sh -# 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. -fastly config-store-entry update \ - --store-id= \ - --key=EDGEZERO__SERVICES____STORES__CONFIG__APP_CONFIG__KEY \ - --value=app_config_staging \ - --upsert +```bash +# Production environment +EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME=config-prod + config push --adapter fastly --store app_config --yes + +# Staging environment +EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME=config-stage + config push --adapter fastly --store app_config --staging --yes ``` -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. +Do not set a custom Fastly `__KEY`: a conflicting value fails before provider +mutation. Production and staging may select the same physical Config Store or +different stores. Store selection changes resource links on the target Fastly +version and does not change the application release or package. -Locally, Viceroy reports the fixed service ID -`0000000000000000000000`, regardless of the deployment `service_id` in -`fastly.toml`. Put local overrides under that namespace: +For local Viceroy testing, use the logical store name and production key: ```toml -[local_server.config_stores.edgezero_runtime_env.contents] -EDGEZERO__SERVICES__0000000000000000000000__STORES__CONFIG__APP_CONFIG__KEY = "app_config_staging" -``` +[local_server.config_stores.app_config] +format = "inline-toml" -If the local `edgezero_runtime_env` store is missing, EdgeZero logs a one-line -warning and falls back to the binding's default id. The runtime keeps serving, -but the per-environment override is inactive. +[local_server.config_stores.app_config.contents] +app_config = '''{"version":1,"generated_at":"2026-09-17T00:00:00Z","sha256":"","data":{}}''' +``` ### Drift detection in CI diff --git a/docs/guide/cli-reference.md b/docs/guide/cli-reference.md index 779c38e7..e6115324 100644 --- a/docs/guide/cli-reference.md +++ b/docs/guide/cli-reference.md @@ -157,22 +157,29 @@ edgezero deploy --adapter - `--adapter ` - Target adapter (`fastly`, `cloudflare`, `spin`) - `--service-id ` - Platform service id the deploy targets (Fastly). Passed through to the provider; adapters that don't need one ignore it. +- `--application-release ` - Extracted, verified immutable application + release root. The generic CLI confines this path and records its exact + application manifest; only the selected adapter interprets provider package + metadata. - `--staging` - Deploy to a **staged** draft version instead of activating production (Fastly staging lifecycle). Non-Fastly adapters reject it. This is the same `--staging` verb `healthcheck`/`rollback`/`config push` use. -- `-- ` - Args after `--` are forwarded verbatim to the adapter - deploy command (e.g. `-- --comment "ci build"`). A hyphenated token before `--` - is rejected, so a mistyped flag can never silently route a staging deploy to - production. +- `-- ` - Adapter arguments after `--`. A hyphenated token before + `--` is rejected. **Examples:** ```bash -# Deploy to Fastly -edgezero deploy --adapter fastly +# Deploy a verified Fastly application release +edgezero deploy --adapter fastly \ + --service-id "$FASTLY_SERVICE_ID" \ + --application-release "$RELEASE_ROOT" -# Stage a Fastly draft version (no activation) -edgezero deploy --adapter fastly --service-id "$FASTLY_SERVICE_ID" --staging +# Stage the same release (no production activation) +edgezero deploy --adapter fastly \ + --service-id "$FASTLY_SERVICE_ID" \ + --application-release "$RELEASE_ROOT" \ + --staging # Deploy to Cloudflare edgezero deploy --adapter cloudflare @@ -183,10 +190,49 @@ edgezero deploy --adapter spin **Provider behavior:** -- **Fastly**: Runs `fastly compute deploy` +- **Fastly**: Claims adapter-managed deployment whenever an application release + is supplied, `--staging` is selected, or the application declares a Config, + KV, or Secret Store. Every managed deployment requires the verified release. + Only a direct, store-free production call without a release retains the + manifest command for compatibility. - **Cloudflare**: Runs `wrangler deploy` - **Spin**: Runs `spin deploy` +Deployment ownership is resolved through the adapter registry. This is +provider-neutral deployment ownership: the generic CLI has no Fastly branch or +hidden provider flag. Registered adapters can claim a deployment; unregistered +adapters keep their manifest command. + +### Managed Fastly argument contract + +The managed lifecycle owns service targeting, source version, cloning, package, +credential, and publication decisions. These passthrough flags are reserved and +rejected in detached, attached, or `=` forms where applicable: + +```text +--service-id -s --service-name --version --autoclone --token -t +--package/-p +``` + +The complete allowlist is a single `--comment VALUE` or `--comment=VALUE`, plus +Fastly's non-targeting global booleans `--accept-defaults` / `-d`, `--auto-yes` / +`-y`, `--debug-mode`, `--non-interactive` / `-i`, `--quiet` / `-q`, and +`--verbose` / `-v`. Boolean `=value` forms, duplicates, other options, and +positional arguments fail before provider mutation. + +Fastly service IDs follow the same validation in deploy, healthcheck, and +rollback: ASCII letters and digits only. The release verifier checks strict +`release.json` metadata, confined normalized member paths, regular non-symlink +files, exact membership, file digests, and the selected Fastly manifest reference +in `edgezero.toml` before Fastly receives a mutation. + +Managed deployment uploads only the recorded package, prepares exact logical +resource links, verifies the links and package, and then stages or activates. +The adapter emits `package-sha256=` for the verified package and +`version=` as soon as a recoverable target draft exists, so a caller can +recover that version when later preparation fails. The `deploy-fastly` action +maps `package-sha256` to its public `package-digest` output. + ::: warning The `axum` adapter doesn't support `deploy` - use standard container/binary deployment instead. ::: @@ -317,15 +363,12 @@ flags and exits `2` with a pointer to the typed CLI — it cannot push (see - `--manifest ` — manifest path (default: `edgezero.toml`). - `--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 - `--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 - staging selector store (see [the blob migration guide](./blob-app-config-migration.md#per-environment-key-override)). +- `--key ` — override the config-store key the blob is written under (spec §5.4). Fastly accepts only the logical store ID for every target. +- `--staging` — target the staging publication flow. It does not change the + config entry key. The selected environment's `__NAME` chooses the physical + store, so production and staging may share or isolate config. The flag remains + mutually exclusive with `--key`; other adapters retain explicit key selection (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.