diff --git a/.github/actions/build-app-cli/action.yml b/.github/actions/build-app-cli/action.yml index ecad412f..c38dcf28 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: diff --git a/.github/actions/config-push-fastly/action.yml b/.github/actions/config-push-fastly/action.yml index 533de968..045ce1cb 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,16 @@ 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__ROOT: ${{ steps.ws.outputs.root }}/release FASTLY_API_TOKEN: "" FASTLY_SERVICE_ID: "" FASTLY_TOKEN: "" @@ -141,16 +140,16 @@ runs: FASTLY_CONFIG_FILE: "" FASTLY_CARGO_PROFILE: "" FASTLY_HOME: "" + run: exec "$GITHUB_ACTION_PATH/../fastly-common/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,26 +198,17 @@ 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'] }} FASTLY_SERVICE_ID: "" FASTLY_TOKEN: "" diff --git a/.github/actions/config-push-fastly/scripts/config-push.sh b/.github/actions/config-push-fastly/scripts/config-push.sh index 006c542b..332a17d8 100755 --- a/.github/actions/config-push-fastly/scripts/config-push.sh +++ b/.github/actions/config-push-fastly/scripts/config-push.sh @@ -10,16 +10,13 @@ set -euo pipefail # wrapper blanks every other FASTLY_* alias, so an inherited FASTLY_ENDPOINT or # FASTLY_TOKEN can never redirect or re-auth the push. # -# Staging: `deploy-to: staging` passes `--staging` to the CLI, which writes the -# `_staging` variant in the SAME store — the key the staging -# selector points a staged version at, never the production key the live service -# reads. `key` is production-only (the wrapper rejects key + staging up front). +# 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) @@ -29,15 +26,15 @@ set -euo pipefail # 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) @@ -66,14 +63,20 @@ 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="" + if [[ -n "$deprecated_key" ]]; then + fail "input 'key' is deprecated and unsupported; use EDGEZERO__STORES__CONFIG____KEY" + fi require_input fastly-api-token "${FASTLY_API_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" require_cmd git # A typo in deploy-to must never silently push to production. @@ -89,8 +92,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 +105,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,12 +151,10 @@ 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=("$cli_bin" 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) 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/download-app-cli.sh b/.github/actions/deploy-core/scripts/download-app-cli.sh index 244ca0ea..36c6e7b2 100755 --- a/.github/actions/deploy-core/scripts/download-app-cli.sh +++ b/.github/actions/deploy-core/scripts/download-app-cli.sh @@ -9,7 +9,8 @@ set -euo pipefail # after a tool the action shells out to (jq, fastly, …) 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" @@ -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..ce8312d7 100755 --- a/.github/actions/deploy-core/scripts/run-app-cli.sh +++ b/.github/actions/deploy-core/scripts/run-app-cli.sh @@ -196,6 +196,33 @@ scrub_action_private_env() { done < <(compgen -e) } +PUBLIC_RUNTIME_ENV_NAMES=() +PUBLIC_RUNTIME_ENV_VALUES=() +capture_public_runtime_env() { + local name + while IFS= read -r name; do + if [[ "$name" == "EDGEZERO__ADAPTER__HOST" ]] || + [[ "$name" == "EDGEZERO__ADAPTER__PORT" ]] || + [[ "$name" == "EDGEZERO__LOGGING__ENDPOINT" ]] || + [[ "$name" == "EDGEZERO__LOGGING__LEVEL" ]] || + [[ "$name" == "EDGEZERO__LOGGING__USE_FASTLY_LOGGER" ]] || + [[ "$name" == "EDGEZERO__LOGGING__ECHO_STDOUT" ]] || + [[ "$name" =~ ^EDGEZERO__STORES__CONFIG__[A-Z0-9_]+__(NAME|KEY)$ ]] || + [[ "$name" =~ ^EDGEZERO__STORES__(KV|SECRETS)__[A-Z0-9_]+__NAME$ ]]; then + PUBLIC_RUNTIME_ENV_NAMES+=("$name") + PUBLIC_RUNTIME_ENV_VALUES+=("${!name}") + fi + done < <(compgen -e) +} + +restore_public_runtime_env() { + local index name + for ((index = 0; index < ${#PUBLIC_RUNTIME_ENV_NAMES[@]}; index++)); do + name="${PUBLIC_RUNTIME_ENV_NAMES[$index]}" + export "$name=${PUBLIC_RUNTIME_ENV_VALUES[$index]}" + done +} + ARGV=() main() { local mode="${1:-}" @@ -217,11 +244,16 @@ main() { # Clear inherited provider aliases and export only the typed credentials. import_provider_env "${EDGEZERO__PROVIDER__ENV_CLEAR_FILE:-/dev/null}" build_deploy_argv "$cli_bin" "$adapter" + # GitHub Environment selection happens outside this action. Preserve the + # public canonical store selectors it supplied while the broad scrub below + # removes action-private EDGEZERO__ carriers. + capture_public_runtime_env ;; esac # Everything the action needed from its own env is now in locals or in ARGV. scrub_action_private_env + restore_public_runtime_env if [[ -n "$manifest" ]]; then export EDGEZERO_MANIFEST="$manifest" diff --git a/.github/actions/deploy-core/tests/assert-config-push.sh b/.github/actions/deploy-core/tests/assert-config-push.sh index dcfeaa75..aa2d4cec 100755 --- a/.github/actions/deploy-core/tests/assert-config-push.sh +++ b/.github/actions/deploy-core/tests/assert-config-push.sh @@ -4,9 +4,9 @@ set -euo pipefail # Asserts one config-push-fastly invocation against the fake Fastly CLI. # # The contract that matters is the staging model: staging and production write -# DIFFERENT KEYS in the SAME store, so a staged push can never overwrite the key -# the live service is reading. This runs once per push — re-seeding the fake -# truncates the call log, so each push is asserted against its own log. +# 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 @@ -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-core/tests/assert-lost-version.sh b/.github/actions/deploy-core/tests/assert-lost-version.sh index 2fc3fb61..994c9fc2 100755 --- a/.github/actions/deploy-core/tests/assert-lost-version.sh +++ b/.github/actions/deploy-core/tests/assert-lost-version.sh @@ -1,42 +1,23 @@ #!/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" + 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 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-core/tests/assert-production-deploy.sh b/.github/actions/deploy-core/tests/assert-production-deploy.sh index 4d7893fc..b7ad7aa2 100755 --- a/.github/actions/deploy-core/tests/assert-production-deploy.sh +++ b/.github/actions/deploy-core/tests/assert-production-deploy.sh @@ -1,65 +1,56 @@ #!/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" + 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-store"}, + {alias:"cache", resource:"KVPROD", type:"object-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 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 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/diff/from/42/to/42$' "$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) + [[ "$(grep -c '^GET https://api.fastly.com/service/dummyservice/diff/from/40/to/40$' "$log")" -eq 3 ]] || fail "production did not preserve and revalidate the complete source configuration" + [[ "$(grep -c '^GET https://api.fastly.com/service/dummyservice/diff/from/42/to/42$' "$log")" -eq 3 ]] || fail "production did not verify the fresh clone and final draft configuration" + [[ "$final_links_line" -lt "$publish_line" && "$package_line" -lt "$publish_line" && "$configuration_line" -lt "$publish_line" ]] || fail "final link, package, and complete-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-staged-calls.sh b/.github/actions/deploy-core/tests/assert-staged-calls.sh index 10539e2d..7c76f839 100755 --- a/.github/actions/deploy-core/tests/assert-staged-calls.sh +++ b/.github/actions/deploy-core/tests/assert-staged-calls.sh @@ -1,114 +1,53 @@ #!/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" + 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-store"}, + {alias:"cache", resource:"KVSTAGE", type:"object-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 resource-link delete --service-id=dummyservice --version=42 --id=LINK_CONFIG_PROD +fastly resource-link delete --service-id=dummyservice --version=42 --id=LINK_KV_PROD +fastly resource-link delete --service-id=dummyservice --version=42 --id=LINK_SECRET_PROD +fastly resource-link create --service-id=dummyservice --version=42 --resource-id=CONFIGSTAGE --name=app_config +fastly resource-link create --service-id=dummyservice --version=42 --resource-id=KVSTAGE --name=cache +fastly resource-link create --service-id=dummyservice --version=42 --resource-id=SECRETSTAGE --name=credentials +MUTATIONS +) + mutations=$(grep -E '^fastly 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 resource-link create ' "$log" | tail -n1 | cut -d: -f1) + final_links=$(grep -n '^fastly 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/diff/from/42/to/42$' "$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) + [[ "$(grep -c '^GET https://api.fastly.com/service/dummyservice/diff/from/40/to/40$' "$log")" -eq 3 ]] || fail "staging did not preserve and revalidate the complete source configuration" + [[ "$(grep -c '^GET https://api.fastly.com/service/dummyservice/diff/from/42/to/42$' "$log")" -eq 3 ]] || fail "staging did not verify the fresh clone and final draft configuration" + [[ "$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 complete-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-core/tests/assert-staging-probe.sh index 957e8de7..4b546822 100755 --- a/.github/actions/deploy-core/tests/assert-staging-probe.sh +++ b/.github/actions/deploy-core/tests/assert-staging-probe.sh @@ -24,12 +24,15 @@ main() { 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/make-fake-fastly-env.sh b/.github/actions/deploy-core/tests/make-fake-fastly-env.sh index e10af721..5b3e1d11 100755 --- a/.github/actions/deploy-core/tests/make-fake-fastly-env.sh +++ b/.github/actions/deploy-core/tests/make-fake-fastly-env.sh @@ -1,34 +1,9 @@ #!/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). +# 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=../scripts/common.sh @@ -38,56 +13,115 @@ 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 ") 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)" + '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 + ;; + 'resource-link list') + [[ "\$#" -eq 5 && "\$3" == --service-id=dummyservice && + ("\$4" == --version=40 || "\$4" == --version=42) && "\$5" == --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" + ;; + '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 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 + '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)' ;; - "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 '[]' ;; + 'service-version update') + [[ "\$#" -eq 6 && "\$3" == --service-id=dummyservice && "\$4" == --version=42 && + "\$5" == --comment ]] || exit 94 + case "\$6" in + 'production smoke' | 'staged smoke' | 'store-free managed smoke') ;; + *) exit 94;; 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 ;; + 'service-version 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" + ;; + 'resource-link delete') + [[ "\$#" -eq 5 && "\$3" == --service-id=dummyservice && "\$4" == --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" + ;; + 'resource-link create') + [[ "\$#" -eq 6 && "\$3" == --service-id=dummyservice && "\$4" == --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-store ;; + KVSTAGE/cache) type=object-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" + ;; + '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 -exit 0 SHIM chmod +x "$path" } @@ -96,124 +130,100 @@ 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 +set -euo pipefail -# 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" +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 -fake_active_version() { +active_version() { local active - active=$(cat "${FAKE_ACTIVE_VERSION_FILE:-/dev/null}" 2>/dev/null || true) + active=$(cat "$FAKE_ACTIVE_VERSION_FILE" 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" +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":"dummyservice"}]}' + 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 ']' } -# Two shapes: a Fastly API call via `--config -` (config on stdin), or a probe. -if [[ "$*" == *"--config"* ]]; then +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" + 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 - */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 - ;; + */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 - 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)" + printf '200' 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' + 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/diff/from/42/to/42) + printf '{"from":42,"to":42,"format":"text","diff":"complete fixture configuration"}\n200' ;; + */service/dummyservice/diff/from/40/to/40) + printf '{"from":40,"to":40,"format":"text","diff":"complete fixture configuration"}\n200' ;; + */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" -# 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 +if [[ -n "${FORCE_UNHEALTHY:-}" ]]; then echo 503; else echo 200; fi SHIM chmod +x "$path" } @@ -221,69 +231,44 @@ SHIM main() { local workspace="${GITHUB_WORKSPACE:?GITHUB_WORKSPACE is required}" local runner_temp="${RUNNER_TEMP:?RUNNER_TEMP is required}" - local action_dir + local action_dir path_dir downloads log state pinned stage archive sha expected_digest 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" + 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" + 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-store\nLINK_KV_PROD\tcache\tKVPROD\tobject-store\nLINK_SECRET_PROD\tcredentials\tSECRETPROD\tsecret-store\n' >"$state/links/version-40.tsv" + : >"$state/staged-version" + : >"$state/package-digest" - 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" + 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" + 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-core/tests/make-smoke-fixture.sh b/.github/actions/deploy-core/tests/make-smoke-fixture.sh index c1bd3619..75064c1d 100755 --- a/.github/actions/deploy-core/tests/make-smoke-fixture.sh +++ b/.github/actions/deploy-core/tests/make-smoke-fixture.sh @@ -1,24 +1,24 @@ #!/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). +# 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. -main() { +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=../scripts/common.sh +source "$SCRIPT_DIR/../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" @@ -26,14 +26,12 @@ main() { 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" @@ -56,11 +54,6 @@ 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, @@ -68,7 +61,6 @@ use edgezero_cli::args::{ 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 { @@ -110,111 +102,113 @@ fn main() { 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}"); + if let Err(error) = result { + eprintln!("[fixture-app] {error}"); 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' + cat >edgezero.toml <<'TOML' [app] name = "fixture-app" -[adapters.fastly.commands] -build = "bash fake-build.sh" -deploy = "bash fake-deploy.sh" +[adapters.fastly.adapter] +manifest = "adapter/fastly.toml" +TOML + if [[ "$mode" == store-aware ]]; then + cat >>edgezero.toml <<'TOML' -# 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 +[stores.kv] +ids = ["cache"] +default = "cache" + +[stores.secrets] +ids = ["credentials"] +default = "credentials" +TOML + fi - # 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' + mkdir -p adapter + cat >adapter/fastly.toml <<'TOML' 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 +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 adapter_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") + adapter_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 adapter "$adapter_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: $adapter} + } + }' >"$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/run.sh b/.github/actions/deploy-core/tests/run.sh index afd3aea4..aebca667 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) @@ -399,6 +399,286 @@ 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' \ + >"$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/fastly-common/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" + export EDGEZERO__APP__RELEASE__EXPECTED_SOURCE_REVISION + + 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 +687,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 +702,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 +714,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 +816,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 +850,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-fastly-application-release 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-fastly-application-release 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 @@ -658,6 +985,17 @@ printf 'FASTLY_API_TOKEN=%s\n' "${FASTLY_API_TOKEN:-ABSENT}" printf 'EDGEZERO__PROVIDER__ENV=%s\n' "${EDGEZERO__PROVIDER__ENV:-ABSENT}" printf 'EDGEZERO__FASTLY__API_TOKEN=%s\n' "${EDGEZERO__FASTLY__API_TOKEN:-ABSENT}" printf 'EDGEZERO__DEPLOY__ARGS_FILE=%s\n' "${EDGEZERO__DEPLOY__ARGS_FILE:-ABSENT}" +printf 'EDGEZERO__STORES__SECRETS__CREDENTIALS__NAME=%s\n' "${EDGEZERO__STORES__SECRETS__CREDENTIALS__NAME:-ABSENT}" +printf 'EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY=%s\n' "${EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY:-ABSENT}" +printf 'EDGEZERO__STORES__SECRETS____NAME=%s\n' "${EDGEZERO__STORES__SECRETS____NAME:-ABSENT}" +printf 'EDGEZERO__ADAPTER__HOST=%s\n' "${EDGEZERO__ADAPTER__HOST:-ABSENT}" +printf 'EDGEZERO__ADAPTER__PORT=%s\n' "${EDGEZERO__ADAPTER__PORT:-ABSENT}" +printf 'EDGEZERO__LOGGING__ENDPOINT=%s\n' "${EDGEZERO__LOGGING__ENDPOINT:-ABSENT}" +printf 'EDGEZERO__LOGGING__LEVEL=%s\n' "${EDGEZERO__LOGGING__LEVEL:-ABSENT}" +printf 'EDGEZERO__LOGGING__USE_FASTLY_LOGGER=%s\n' "${EDGEZERO__LOGGING__USE_FASTLY_LOGGER:-ABSENT}" +printf 'EDGEZERO__LOGGING__ECHO_STDOUT=%s\n' "${EDGEZERO__LOGGING__ECHO_STDOUT:-ABSENT}" +printf 'EDGEZERO__ADAPTER__HOST__EXTRA=%s\n' "${EDGEZERO__ADAPTER__HOST__EXTRA:-ABSENT}" +printf 'EDGEZERO__UNDECLARED=%s\n' "${EDGEZERO__UNDECLARED:-ABSENT}" printf 'EDGEZERO_MANIFEST=%s\n' "${EDGEZERO_MANIFEST:-ABSENT}" CLI chmod +x "$dir/bin/scrub-cli" @@ -671,6 +1009,13 @@ CLI EDGEZERO__PROVIDER__ENV_CLEAR_FILE="$dir/clear.nul" \ EDGEZERO__PROVIDER__ENV='{"FASTLY_API_TOKEN":"s3cret"}' \ EDGEZERO__FASTLY__API_TOKEN='s3cret' \ + EDGEZERO__STORES__SECRETS__CREDENTIALS__NAME='credentials-staging' \ + EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY='app_config' \ + EDGEZERO__STORES__SECRETS____NAME='must-not-survive' \ + EDGEZERO__ADAPTER__HOST='127.0.0.1' EDGEZERO__ADAPTER__PORT='7676' \ + EDGEZERO__LOGGING__ENDPOINT='https://logs.example.test' EDGEZERO__LOGGING__LEVEL='debug' \ + EDGEZERO__LOGGING__USE_FASTLY_LOGGER='true' EDGEZERO__LOGGING__ECHO_STDOUT='false' \ + EDGEZERO__ADAPTER__HOST__EXTRA='must-not-survive' EDGEZERO__UNDECLARED='must-not-survive' \ "$CORE_SCRIPTS/run-app-cli.sh" deploy 2>/dev/null ) @@ -679,6 +1024,27 @@ CLI "FASTLY_API_TOKEN=s3cret" "$(grep '^FASTLY_API_TOKEN=' <<<"$out")" assert_equals "EDGEZERO_MANIFEST is delivered" \ "EDGEZERO_MANIFEST=$dir/edgezero.toml" "$(grep '^EDGEZERO_MANIFEST=' <<<"$out")" + assert_equals "the selected secret-store name is delivered" \ + "EDGEZERO__STORES__SECRETS__CREDENTIALS__NAME=credentials-staging" \ + "$(grep '^EDGEZERO__STORES__SECRETS__CREDENTIALS__NAME=' <<<"$out")" + assert_equals "the selected config-store key is delivered" \ + "EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY=app_config" \ + "$(grep '^EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY=' <<<"$out")" + assert_equals "the fixed adapter host is delivered" "EDGEZERO__ADAPTER__HOST=127.0.0.1" \ + "$(grep '^EDGEZERO__ADAPTER__HOST=' <<<"$out")" + assert_equals "the fixed adapter port is delivered" "EDGEZERO__ADAPTER__PORT=7676" \ + "$(grep '^EDGEZERO__ADAPTER__PORT=' <<<"$out")" + assert_equals "the fixed logging endpoint is delivered" \ + "EDGEZERO__LOGGING__ENDPOINT=https://logs.example.test" \ + "$(grep '^EDGEZERO__LOGGING__ENDPOINT=' <<<"$out")" + assert_equals "the fixed logging level is delivered" "EDGEZERO__LOGGING__LEVEL=debug" \ + "$(grep '^EDGEZERO__LOGGING__LEVEL=' <<<"$out")" + assert_equals "the Fastly logger selector is delivered" \ + "EDGEZERO__LOGGING__USE_FASTLY_LOGGER=true" \ + "$(grep '^EDGEZERO__LOGGING__USE_FASTLY_LOGGER=' <<<"$out")" + assert_equals "the stdout echo selector is delivered" \ + "EDGEZERO__LOGGING__ECHO_STDOUT=false" \ + "$(grep '^EDGEZERO__LOGGING__ECHO_STDOUT=' <<<"$out")" # What it must NEVER see: the same secret under names we never promised. assert_equals "the provider-env JSON blob does not survive" \ @@ -687,6 +1053,13 @@ CLI "EDGEZERO__FASTLY__API_TOKEN=ABSENT" "$(grep '^EDGEZERO__FASTLY__API_TOKEN=' <<<"$out")" assert_equals "action-private file handles do not survive" \ "EDGEZERO__DEPLOY__ARGS_FILE=ABSENT" "$(grep '^EDGEZERO__DEPLOY__ARGS_FILE=' <<<"$out")" + assert_equals "malformed selector names do not survive" \ + "EDGEZERO__STORES__SECRETS____NAME=ABSENT" \ + "$(grep '^EDGEZERO__STORES__SECRETS____NAME=' <<<"$out")" + assert_equals "near-match fixed names do not survive" "EDGEZERO__ADAPTER__HOST__EXTRA=ABSENT" \ + "$(grep '^EDGEZERO__ADAPTER__HOST__EXTRA=' <<<"$out")" + assert_equals "arbitrary EDGEZERO names do not survive" "EDGEZERO__UNDECLARED=ABSENT" \ + "$(grep '^EDGEZERO__UNDECLARED=' <<<"$out")" } # --------------------------------------------------------------------------- @@ -1163,14 +1536,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 +1556,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,7 +1577,13 @@ 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 \ GITHUB_WORKSPACE="$dir" \ @@ -1207,12 +1591,13 @@ CLI 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. @@ -1222,6 +1607,7 @@ config_push_rejects_path() { env "$var=$value" PATH="$dir/bin:$PATH" FAKE_ARGV_OUT="$dir/argv.txt" \ EDGEZERO__APP__CLI__BIN=fake-cli 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 +1618,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). @@ -1263,6 +1662,8 @@ test_config_push_argv() { env PATH="$cpdir/bin:$PATH" EDGEZERO__APP__CLI__BIN=fake-cli 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" @@ -1284,15 +1685,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" \ + "exactly one" \ env PATH="$WORK_DIR/config-push/bin:$PATH" EDGEZERO__APP__CLI__BIN=fake-cli 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 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 \ 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" @@ -1325,25 +1736,23 @@ test_config_push_argv() { 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" } @@ -1818,21 +2227,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 +2246,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 +2266,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 +2281,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 +2297,643 @@ out healthy out status-code EOF )" "$(parse_action_surface "$ACTIONS_DIR/healthcheck-fastly/action.yml")" + + assert_equals "package-fastly-application-release 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-fastly-application-release/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 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 'fastly-common/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 'fastly-common/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_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 + 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 "Fastly application release packager verifies its lifecycle protocol" \ + bash "$ACTIONS_DIR/package-fastly-application-release/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-core/tests/make-smoke-fixture.sh" + local fake="$ACTIONS_DIR/deploy-core/tests/make-fake-fastly-env.sh" + local staged="$ACTIONS_DIR/deploy-core/tests/assert-staged-calls.sh" + local production="$ACTIONS_DIR/deploy-core/tests/assert-production-deploy.sh" + local lost="$ACTIONS_DIR/deploy-core/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 fake seeds active v40 with logical Config, KV, and Secret aliases" \ + grep -Fq 'LINK_CONFIG_PROD\tapp_config\tCONFIGPROD\tconfig-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' >"$workspace/fixture-app/edgezero.toml" + printf '[package]\nname = "fixture"\n' >"$workspace/fixture-app/adapter/fastly.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-core/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 both 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 both recorded manifest bytes" \ + grep -Fq '`edgezero.toml`, and `fastly.toml` to every publisher' "$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() { @@ -2151,7 +3195,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" @@ -2165,6 +3210,8 @@ test_mutation_attempted_signal() { : >"$out" env PATH="$dir/bin:$PATH" EDGEZERO__APP__CLI__BIN=fake-cli 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" \ @@ -2320,6 +3367,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 +3381,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 +3435,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 +3481,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() { @@ -2455,6 +3657,7 @@ main() { test_run_cli_build_isolation test_provider_env_boundary test_download_cli_metadata + test_fastly_application_release test_wrapper_validate test_resolve_app_cli test_fastly_versions @@ -2485,6 +3688,12 @@ main() { test_action_metadata test_action_output_contracts test_action_public_surface + test_fastly_release_action_wiring + test_release_producer_and_environment_preflight + test_fastly_smoke_release_contract + test_smoke_release_uses_application_revision + test_fastly_logical_link_documentation + test_workflow_duplicate_env_keys test_action_pin_gate printf '\nPassed: %d Failed: %d Skipped: %d\n' "$tests_passed" "$tests_failed" "$tests_skipped" diff --git a/.github/actions/deploy-fastly/action.yml b/.github/actions/deploy-fastly/action.yml index adb0576b..1ab339ea 100644 --- a/.github/actions/deploy-fastly/action.yml +++ b/.github/actions/deploy-fastly/action.yml @@ -1,87 +1,60 @@ name: EdgeZero deploy-fastly -description: Deploy a checked-out EdgeZero application to Fastly Compute using a prebuilt app CLI artifact. +description: Deploy a verified immutable EdgeZero application release to Fastly Compute. 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 produced by the application release pipeline. + required: true + app-release-sha256: + description: Expected lowercase SHA-256 digest of app-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 provider steps — the rollback-target capture (as FASTLY_API_TOKEN, the adapter convention) and the deploy (as provider-env data); every other step blanks it. + description: Fastly API token, scoped only to provider operations. required: true fastly-service-id: - description: Fastly service ID. Passed as the typed --service-id CLI flag. + description: Alphanumeric Fastly service ID. required: true - working-directory: - description: Application directory relative to github.workspace. - required: false - default: . - manifest: - description: Optional edgezero.toml path relative to working-directory. - required: false - default: "" - rust-toolchain: - description: Application Rust toolchain for the deploy build, or 'auto' to follow application discovery (see the toolchain precedence in the deploy spec). - required: false - default: auto - build-mode: - description: One of auto, always, or never. Fastly auto resolves to never. - required: false - default: auto - build-args: - description: JSON array of strings passed to the CLI build after --. - required: false - default: "[]" deploy-args: - description: JSON array of Fastly --comment passthrough args (allowlisted). + description: JSON array containing only allowed Fastly deploy comment arguments. required: false default: "[]" deploy-to: - description: "'production' activates the deployed version; 'staging' produces a staged draft version instead. One consistent verb across the actions (matches config-push/healthcheck/rollback and the CLI's --staging)." + description: "'production' activates the prepared version; 'staging' stages it." required: false default: production - cache: - description: Enable exact-key Cargo workspace target/ caching. - required: false - default: "false" outputs: fastly-version: - description: Fastly service version deployed (production) or staged. + description: Prepared Fastly version; it can remain available when a later deploy operation fails. value: ${{ steps.deploy.outputs['fastly-version'] }} mutation-attempted: - description: "'true', emitted immediately BEFORE the deploy CLI runs (so a cancel/timeout mid-mutation can preserve it; a hard runner loss can still drop it, so absence is not proof nothing deployed, 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 nothing was deployed." + description: "'true' when the application CLI was invoked for deployment." value: ${{ steps.deploy.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'] }} previous-version: - description: "Production only: the version active BEFORE this deploy — the rollback target. Empty on a first-ever deploy. Wire it to rollback-fastly's rollback-to." + description: Production version active before this deployment, when one exists. value: ${{ steps.capture.outputs['previous-version'] }} source-revision: - description: Git revision deployed from working-directory. - value: ${{ steps.resolve.outputs['source-revision'] }} + description: Source revision recorded by the verified application release. + value: ${{ steps.release.outputs['source-revision'] }} app-cli-version: - description: Version of the app CLI consumed from the artifact. + description: Version of the application CLI recorded by the release. value: ${{ steps.cli.outputs['app-cli-version'] }} + package-digest: + description: Verified SHA-256 digest of the immutable Fastly package. + value: ${{ steps.deploy.outputs['package-digest'] }} 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 share fixed temp paths - # — one could otherwise overwrite the other's CLI, tools, or service flags and - # then run them with the wrong token. Every temp path below hangs off - # steps.ws.outputs.root; 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: "" @@ -110,19 +83,17 @@ runs: BASH_ENV: "" ENV: "" EDGEZERO__ADAPTER: fastly - EDGEZERO__BUILD__MODE: ${{ inputs['build-mode'] }} - EDGEZERO__BUILD__CACHE: ${{ inputs.cache }} - EDGEZERO__BUILD__ARGS: ${{ inputs['build-args'] }} + EDGEZERO__BUILD__MODE: never + EDGEZERO__BUILD__CACHE: "false" + EDGEZERO__BUILD__ARGS: "[]" EDGEZERO__DEPLOY__ARGS: ${{ inputs['deploy-args'] }} EDGEZERO__DEPLOY__ARG_ALLOW: "--comment=" - # Action-owned (not caller input, not allowlist-checked): keeps a - # manifest-command deploy from blocking on a TTY prompt in CI. The - # built-in Fastly deploy path de-duplicates it. EDGEZERO__DEPLOY__ARGS_PREPEND: '["--non-interactive"]' EDGEZERO__DEPLOY__TO: ${{ inputs['deploy-to'] }} - EDGEZERO__DEPLOY__FLAGS: ${{ inputs['deploy-to'] == 'staging' && format('["--service-id","{0}","--staging"]', inputs['fastly-service-id']) || format('["--service-id","{0}"]', inputs['fastly-service-id']) }} + EDGEZERO__DEPLOY__FLAGS: ${{ inputs['deploy-to'] == 'staging' && format('["--service-id","{0}","--staging","--application-release","{1}/release"]', inputs['fastly-service-id'], steps.ws.outputs.root) || format('["--service-id","{0}","--application-release","{1}/release"]', inputs['fastly-service-id'], steps.ws.outputs.root) }} EDGEZERO__PROVIDER__ENV_CLEAR: '["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"]' - 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__FASTLY__SERVICE_ID: ${{ inputs['fastly-service-id'] }} EDGEZERO__RUNNER__OS: ${{ runner.os }} @@ -146,38 +117,16 @@ 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 - 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: "" - - - name: Extract CLI - id: cli + - name: Verify application release + id: release shell: bash env: BASH_ENV: "" ENV: "" - EDGEZERO__APP__CLI__ARTIFACT_DIR: ${{ steps.ws.outputs.root }}/cli-download - EDGEZERO__ACTION__TOOL_ROOT: ${{ steps.ws.outputs.root }}/tools - EDGEZERO__APP__CLI__BIN: ${{ inputs['app-cli-bin'] }} + 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__ROOT: ${{ steps.ws.outputs.root }}/release FASTLY_API_TOKEN: "" FASTLY_SERVICE_ID: "" FASTLY_TOKEN: "" @@ -194,81 +143,16 @@ runs: FASTLY_CONFIG_FILE: "" FASTLY_CARGO_PROFILE: "" FASTLY_HOME: "" - run: exec "$GITHUB_ACTION_PATH/../deploy-core/scripts/download-app-cli.sh" + run: exec "$GITHUB_ACTION_PATH/../fastly-common/scripts/prepare-release.sh" - - name: Resolve project - id: resolve + - name: Extract application CLI + id: cli shell: bash env: BASH_ENV: "" ENV: "" - EDGEZERO__ACTION__ROOT: ${{ github.action_path }}/../../.. - EDGEZERO__APP__CLI__VERSION: ${{ steps.cli.outputs['app-cli-version'] }} - EDGEZERO__PROJECT__WORKING_DIRECTORY: ${{ inputs['working-directory'] }} - EDGEZERO__PROJECT__MANIFEST: ${{ inputs.manifest }} - EDGEZERO__PROJECT__RUST_TOOLCHAIN: ${{ inputs['rust-toolchain'] }} - EDGEZERO__PROJECT__TARGET: wasm32-wasip1 - EDGEZERO__BUILD__MODE: ${{ inputs['build-mode'] }} - EDGEZERO__BUILD__CACHE: ${{ inputs.cache }} - EDGEZERO__BUILD__ARGS: ${{ inputs['build-args'] }} - 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/resolve-project.sh" - - - name: Restore application target cache - # Only when a credential-free build will actually USE and re-SAVE it — i.e. - # build-mode: always. With build-mode: never the seed build and save are - # skipped, so restoring would be a no-op; skip it too, matching the docs. - if: ${{ inputs.cache == 'true' && steps.resolve.outputs['effective-build-mode'] == 'always' }} - id: cache-restore - uses: actions/cache/restore@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: "" - - - name: Install Rust toolchain - # Pinned to a released major version tag, per the repo's pin policy - # (check-action-pins.sh + .github/zizmor.yml accept a SHA or a version tag; - # branch/floating refs are rejected). - uses: actions-rust-lang/setup-rust-toolchain@v1 - with: - toolchain: ${{ steps.resolve.outputs['rust-toolchain'] }} - target: wasm32-wasip1 - # Our resolve-project step owns exact-key target/ caching; disable the - # action's own cache to avoid double-caching and key drift. - cache: false - env: + EDGEZERO__APP__CLI__ARCHIVE: ${{ steps.release.outputs['app-cli-archive'] }} + EDGEZERO__ACTION__TOOL_ROOT: ${{ steps.ws.outputs.root }}/tools FASTLY_API_TOKEN: "" FASTLY_SERVICE_ID: "" FASTLY_TOKEN: "" @@ -285,6 +169,7 @@ runs: FASTLY_CONFIG_FILE: "" FASTLY_CARGO_PROFILE: "" FASTLY_HOME: "" + run: exec "$GITHUB_ACTION_PATH/../deploy-core/scripts/download-app-cli.sh" - name: Install Fastly CLI id: install-fastly @@ -312,95 +197,16 @@ runs: FASTLY_HOME: "" run: exec "$GITHUB_ACTION_PATH/scripts/install-fastly.sh" - # Runs on build-mode: always — a validation gate that ALSO seeds the target/ - # cache from a step holding NO provider token, so a build script cannot persist a - # secret into the cache (the token-bearing deploy below is never cached). Note: - # this runs ` 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'] }} FASTLY_SERVICE_ID: "" FASTLY_TOKEN: "" @@ -423,44 +229,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 +258,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 +267,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..d0ae9458 100755 --- a/.github/actions/deploy-fastly/scripts/capture-previous.sh +++ b/.github/actions/deploy-fastly/scripts/capture-previous.sh @@ -33,13 +33,14 @@ set -euo pipefail # 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 cli_bin=$(resolve_app_cli) service_id="${EDGEZERO__FASTLY__SERVICE_ID:?EDGEZERO__FASTLY__SERVICE_ID is required}" + require_fastly_service_id "$service_id" require_input fastly-api-token "${FASTLY_API_TOKEN:-}" require_cmd "$cli_bin" diff --git a/.github/actions/deploy-fastly/scripts/deploy.sh b/.github/actions/deploy-fastly/scripts/deploy.sh index 101d48e6..ab7665e4 100755 --- a/.github/actions/deploy-fastly/scripts/deploy.sh +++ b/.github/actions/deploy-fastly/scripts/deploy.sh @@ -18,17 +18,35 @@ 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 EDGEZERO__PROVIDER__ENV=$(jq -n --arg t "$token" --arg s "$service_id" \ @@ -43,38 +61,33 @@ main() { # 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 - # 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/fastly-common/scripts/common.sh b/.github/actions/fastly-common/scripts/common.sh new file mode 100755 index 00000000..426e231a --- /dev/null +++ b/.github/actions/fastly-common/scripts/common.sh @@ -0,0 +1,10 @@ +#!/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" + +require_fastly_service_id() { + require_input_matching fastly-service-id "$1" '^[A-Za-z0-9]+$' +} diff --git a/.github/actions/fastly-common/scripts/prepare-release.sh b/.github/actions/fastly-common/scripts/prepare-release.sh new file mode 100755 index 00000000..783d8c17 --- /dev/null +++ b/.github/actions/fastly-common/scripts/prepare-release.sh @@ -0,0 +1,245 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=common.sh +source "$SCRIPT_DIR/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" status=0 + python3 - "$file" 2>/dev/null <<'PY' || status=$? +import json +import sys + + +class DuplicateField(ValueError): + pass + + +class InvalidConstant(ValueError): + pass + + +def unique_object(pairs): + result = {} + for key, value in pairs: + if key in result: + raise DuplicateField + result[key] = value + return result + + +def reject_constant(_value): + raise InvalidConstant + + +try: + with open(sys.argv[1], encoding="utf-8") as source: + document = json.load( + source, + object_pairs_hook=unique_object, + parse_constant=reject_constant, + ) +except DuplicateField: + sys.exit(20) +except (InvalidConstant, json.JSONDecodeError, OSError, UnicodeError): + sys.exit(21) + +if isinstance(document, dict) and "format" in document: + if type(document["format"]) is not int or document["format"] != 1: + sys.exit(22) +if isinstance(document, dict): + if "lifecycle_protocol" not in document or type(document["lifecycle_protocol"]) is not int: + sys.exit(23) + if document["lifecycle_protocol"] != 1: + sys.exit(24) +PY + + case "$status" in + 0) ;; + 20) fail "release.json contains a duplicate field" ;; + 21) fail "release.json is not valid JSON" ;; + 22) fail "release.json has an unsupported format" ;; + 23) fail "release.json has an invalid lifecycle_protocol" ;; + 24) fail "release.json has an unsupported lifecycle protocol" ;; + *) fail "release.json validation failed" ;; + esac +} + +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 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 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" + [[ -f "$archive" && ! -L "$archive" ]] || fail "application release archive is missing or is not a regular file" + require_cmd jq + require_cmd python3 + require_cmd tar + + 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" + + 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 '.format == 1 and .lifecycle_protocol == 1 and .adapter == "fastly"' "$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 release.json "$cli_path" "$package_path" "$edgezero_path" "$adapter_path"; 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 release.json "$cli_path" "$package_path" "$edgezero_path" "$adapter_path"; 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 "Fastly package digest mismatch" + [[ "$(sha256_file "$scratch/$edgezero_path")" == "$edgezero_digest" ]] || fail "edgezero manifest digest mismatch" + [[ "$(sha256_file "$scratch/$adapter_path")" == "$adapter_digest" ]] || fail "Fastly manifest digest mismatch" + + mv "$scratch" "$release_root" + trap - EXIT + local root_real + root_real=$(canonical_path "$release_root") + notice "verified immutable Fastly application release" + 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-digest "$package_digest" + append_output source-revision "$revision" +} + +main "$@" diff --git a/.github/actions/healthcheck-fastly/action.yml b/.github/actions/healthcheck-fastly/action.yml index c4b0631d..d40eaacd 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,16 @@ 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__ROOT: ${{ steps.ws.outputs.root }}/release FASTLY_API_TOKEN: "" FASTLY_SERVICE_ID: "" FASTLY_TOKEN: "" @@ -135,26 +132,26 @@ runs: FASTLY_CONFIG_FILE: "" FASTLY_CARGO_PROFILE: "" FASTLY_HOME: "" + run: exec "$GITHUB_ACTION_PATH/../fastly-common/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 +160,32 @@ 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. + EDGEZERO__DEPLOY__TO: ${{ inputs['deploy-to'] }} FASTLY_API_TOKEN: ${{ inputs['deploy-to'] == 'staging' && inputs['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..d2de1adf 100755 --- a/.github/actions/healthcheck-fastly/scripts/healthcheck.sh +++ b/.github/actions/healthcheck-fastly/scripts/healthcheck.sh @@ -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 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-fastly-application-release/action.yml b/.github/actions/package-fastly-application-release/action.yml new file mode 100644 index 00000000..3990ea05 --- /dev/null +++ b/.github/actions/package-fastly-application-release/action.yml @@ -0,0 +1,148 @@ +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 referenced fastly.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 + + - 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-fastly-application-release/scripts/package-release.sh b/.github/actions/package-fastly-application-release/scripts/package-release.sh new file mode 100755 index 00000000..15a76388 --- /dev/null +++ b/.github/actions/package-fastly-application-release/scripts/package-release.sh @@ -0,0 +1,178 @@ +#!/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" + +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_flags() { + local cli="$1" command="$2" + shift 2 + local help expected + case "$command" in + 'config push') + help=$(env -i PATH="/usr/bin:/bin" HOME="${HOME:-/tmp}" "$cli" config push --help 2>&1) || + fail "application CLI does not support 'config push --help' required by lifecycle protocol 1" + ;; + deploy | healthcheck | rollback | active-version) + 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 1" + ;; + *) fail "internal error: unsupported lifecycle command probe '$command'" ;; + esac + for expected in "$@"; 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 1" + done +} + +resolve_adapter_manifest_member() { + local application_manifest="$1" adapter_manifest="$2" + python3 - "$application_manifest" "$adapter_manifest" <<'PY' +import pathlib +import sys + +try: + import tomllib +except ImportError: + sys.stderr.write("Python 3.11 or newer is required to parse edgezero.toml\n") + sys.exit(20) + +application = pathlib.Path(sys.argv[1]).resolve(strict=True) +adapter = pathlib.Path(sys.argv[2]).resolve(strict=True) +try: + with application.open("rb") as source: + document = tomllib.load(source) +except (OSError, tomllib.TOMLDecodeError) as error: + sys.stderr.write(f"could not parse application-manifest: {error}\n") + sys.exit(21) + +adapters = document.get("adapters") +if not isinstance(adapters, dict): + sys.stderr.write("application-manifest must declare [adapters.fastly.adapter]\n") + sys.exit(22) +matches = [value for key, value in adapters.items() if key.lower() == "fastly"] +if len(matches) != 1 or not isinstance(matches[0], dict): + sys.stderr.write("application-manifest must declare exactly one Fastly adapter\n") + sys.exit(23) +adapter_config = matches[0].get("adapter") +relative = adapter_config.get("manifest") if isinstance(adapter_config, dict) else None +if not isinstance(relative, str) or not relative: + sys.stderr.write("application-manifest Fastly adapter must declare a manifest path\n") + sys.exit(24) +path = pathlib.PurePosixPath(relative) +if "\\" in relative or path.is_absolute() or path.as_posix() != relative or any(part in ("", ".", "..") for part in path.parts): + sys.stderr.write("application-manifest Fastly manifest path must be normalized and relative\n") + sys.exit(25) +try: + referenced = (application.parent / pathlib.Path(*path.parts)).resolve(strict=True) +except OSError as error: + sys.stderr.write(f"application-manifest Fastly manifest path cannot be resolved: {error}\n") + sys.exit(26) +if not referenced.is_file() or referenced != adapter: + sys.stderr.write("adapter-manifest must be the file referenced by application-manifest\n") + sys.exit(27) +if relative in {"release.json", "edgezero.toml", "cli/app-cli.tar", "package/app.tar.gz"}: + sys.stderr.write("application-manifest Fastly manifest path collides with a reserved release member\n") + sys.exit(28) +print(relative) +PY +} + +main() { + local workspace_real runner_temp artifact_name revision + 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:-}" + [[ "$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" + for command in jq python3 tar; do require_cmd "$command"; done + + 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__FASTLY_PACKAGE:-}" fastly-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_flags "$cli_path" deploy --adapter --service-id --application-release --staging + probe_flags "$cli_path" 'config push' --adapter --manifest --app-config --store --staging --no-env --yes --no-diff + probe_flags "$cli_path" healthcheck --adapter --service-id --version --domain --path --retry --retry-delay --timeout --staging + probe_flags "$cli_path" rollback --adapter --service-id --version --rollback-to --staging + probe_flags "$cli_path" active-version --adapter --service-id + + local adapter_member stage release_archive package_digest archive_digest verify_root + adapter_member=$(resolve_adapter_manifest_member "$application_manifest" "$adapter_manifest") || + fail "could not resolve the Fastly manifest path declared by application-manifest" + stage="$work/stage" + mkdir -p "$stage/cli" "$stage/package" "$(dirname -- "$stage/$adapter_member")" + cp "$cli_archive" "$stage/cli/app-cli.tar" + cp "$package" "$stage/package/app.tar.gz" + cp "$application_manifest" "$stage/edgezero.toml" + cp "$adapter_manifest" "$stage/$adapter_member" + package_digest=$(sha256_file "$stage/package/app.tar.gz") + jq -n \ + --arg revision "$revision" \ + --arg cli "$(sha256_file "$stage/cli/app-cli.tar")" \ + --arg package "$package_digest" \ + --arg edgezero "$(sha256_file "$stage/edgezero.toml")" \ + --arg adapter "$(sha256_file "$stage/$adapter_member")" \ + --arg adapter_path "$adapter_member" \ + '{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_path,sha256:$adapter}}}' \ + >"$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_member" + 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__ROOT="$verify_root" \ + GITHUB_OUTPUT="$work/verify.outputs" \ + "$SCRIPT_DIR/../../fastly-common/scripts/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/package-fastly-application-release/tests/run.sh b/.github/actions/package-fastly-application-release/tests/run.sh new file mode 100755 index 00000000..a9ecc314 --- /dev/null +++ b/.github/actions/package-fastly-application-release/tests/run.sh @@ -0,0 +1,79 @@ +#!/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" +printf '[app]\nname = "fixture"\n[adapters.fastly.adapter]\nmanifest = "fastly.toml"\n' >"$WORK_DIR/workspace/edgezero.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 '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" + +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/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..fd2e8331 --- /dev/null +++ b/.github/actions/require-github-environment/scripts/require-environment.sh @@ -0,0 +1,58 @@ +#!/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 python3; do + command -v "$command" >/dev/null 2>&1 || fail "required command '$command' was not found" +done + +encoded_name=$(python3 - "$environment_name" <<'PY' +import sys +import urllib.parse + +print(urllib.parse.quote(sys.argv[1], safe="")) +PY +) + +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..ddeb9d39 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,16 @@ 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__ROOT: ${{ steps.ws.outputs.root }}/release FASTLY_API_TOKEN: "" FASTLY_SERVICE_ID: "" FASTLY_TOKEN: "" @@ -119,26 +116,26 @@ runs: FASTLY_CONFIG_FILE: "" FASTLY_CARGO_PROFILE: "" FASTLY_HOME: "" + run: exec "$GITHUB_ACTION_PATH/../fastly-common/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 +148,24 @@ 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'] }} + 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..3b384cbd 100755 --- a/.github/actions/rollback-fastly/scripts/rollback.sh +++ b/.github/actions/rollback-fastly/scripts/rollback.sh @@ -20,12 +20,12 @@ set -euo pipefail # 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:-}" # A typo in deploy-to must never silently roll back production. 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/workflows/deploy-action.yml b/.github/workflows/deploy-action.yml index eff8262d..3a0cfca2 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 @@ -110,15 +90,15 @@ jobs: .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-fastly-application-release/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 @@ -134,567 +114,499 @@ 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-core/tests/make-smoke-fixture.sh source store-aware - - name: Build the APP's own CLI package - id: cli + - name: Build the application-owned CLI once 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-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 + app-cli-artifact: fixture-app-cli - - name: Deploy fixture (production) with local action - id: deploy - uses: ./.github/actions/deploy-fastly + - name: Download the one 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 - deploy-args: '["--comment","smoke"]' + name: fixture-app-cli + path: fixture-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 + - name: Assemble the immutable application release + id: release + run: .github/actions/deploy-core/tests/make-smoke-fixture.sh release fixture-cli/edgezero-cli.tar - # 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: Upload the immutable application release + uses: actions/upload-artifact@v7 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: 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 + 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 - # 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: + # 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 + 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 - - 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) + + - name: Create the store-free fixture application + run: .github/actions/deploy-core/tests/make-smoke-fixture.sh source store-free + + - 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-handoff + app-cli-artifact: fixture-app-cli-store-free + + - name: Download the store-free application CLI archive + uses: actions/download-artifact@v8 + with: + name: fixture-app-cli-store-free + path: fixture-cli + + - name: Assemble the immutable store-free application release + id: release + run: .github/actions/deploy-core/tests/make-smoke-fixture.sh release fixture-cli/edgezero-cli.tar + + - name: Upload the immutable store-free application release + uses: actions/upload-artifact@v7 + with: + 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 - handoff-deploy: - needs: handoff-build + production-smoke: + needs: fixture-release runs-on: ubuntu-latest - # The deploy job carries the token; inherited aliases it MUST still clear. env: + 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 - # 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 + - uses: actions/download-artifact@v8 + with: + name: edgezero-fastly-release + path: fixture-release + - name: Install the stateful fake Fastly provider run: .github/actions/deploy-core/tests/make-fake-fastly-env.sh - - name: Deploy using the artifact built in the other job (literal name) + + - name: Deploy the immutable release to production id: deploy uses: ./.github/actions/deploy-fastly with: - app-cli-artifact: edgezero-cli-handoff - 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 cross-job handoff produced a real production deploy + deploy-args: '["--comment","production smoke"]' + + - name: Assert the production resource links and package env: - EDGEZERO__TEST__FASTLY_VERSION: ${{ steps.deploy.outputs['fastly-version'] }} - EDGEZERO__TEST__PREVIOUS_VERSION: ${{ steps.deploy.outputs['previous-version'] }} + 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-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 - 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 - 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 - 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: - 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" - 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 - 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) - id: deploy - continue-on-error: true - uses: ./.github/actions/deploy-fastly + - name: Probe production using the same immutable release + uses: ./.github/actions/healthcheck-fastly with: - app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} - working-directory: fixture-app - fastly-api-token: dummy-token + 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 - - name: Assert the deploy failed but signalled a possible mutation - 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 - 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 + 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-core/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 - - # 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) + name: edgezero-fastly-store-free-release + path: fixture-release + - name: Install the stateful fake Fastly provider 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 + 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-core/tests/assert-production-deploy.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: + 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 + name: edgezero-fastly-release + path: fixture-release + - name: Install the stateful fake Fastly provider run: .github/actions/deploy-core/tests/make-fake-fastly-env.sh - - name: Staged deploy through the deploy-fastly wrapper + - 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'] }} + EDGEZERO__TEST__STAGED_VERSION: ${{ steps.stage.outputs.fastly-version }} + EDGEZERO__TEST__PACKAGE_DIGEST: ${{ steps.stage.outputs.package-digest }} run: .github/actions/deploy-core/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'] }} + EDGEZERO__TEST__STATUS_CODE: ${{ steps.health.outputs.status-code }} run: .github/actions/deploy-core/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'] }} + EDGEZERO__TEST__STATUS_CODE: ${{ steps.unhealthy.outputs.status-code }} run: .github/actions/deploy-core/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 }} + EDGEZERO__TEST__HEALTHY: ${{ steps.production-health.outputs.healthy }} run: .github/actions/deploy-core/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'] }} + 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-core/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 + + 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-core/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-core/tests/assert-config-push.sh + + - name: Reset fake Fastly for publisher A + run: .github/actions/deploy-core/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-core/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-core/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-core/tests/assert-lost-version.sh 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 b29a43ec..93908994 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 crate::release::{VerifiedApplicationRelease, verify_application_release}; 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::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_staged`). -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,38 @@ const COMPUTE_UPDATE_BOOL_FLAGS: &[&str] = &[ "-v", ]; +/// Version-scoped logging endpoint collections exposed by Fastly CLI 15.1. +/// `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", + "googlepubsub", + "grafanacloudlogs", + "heroku", + "honeycomb", + "https", + "kafka", + "kinesis", + "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,258 +210,1938 @@ 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_staged_passthrough`). -struct StagedPassthrough { - /// 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), + Staged(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 { + backends: serde_json::Value, + domains: serde_json::Value, + links: serde_json::Value, + logging: Vec, + metadata: serde_json::Value, + settings: 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::DeployStaged => deploy_staged(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(Debug, PartialEq)] +struct LoggingProviderSnapshot { + endpoints: serde_json::Value, + kind: &'static str, +} + +#[derive(Debug)] +enum EditableVersionSource { + CloneActive { active_version: u64 }, + CloneRetired(Box), + CloneStaged(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: String, + 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, +} + +#[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, + } } - 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(), - ); + #[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 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() + )); + } + } } - 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(Self { + config, + kind_by_resource_id, + kv, + secret, + }) } - fn provision( + 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() + )); + } + if !ids.insert(id.clone()) { + return Err(format!( + "Fastly {} inventory contains duplicate resource id `{id}`", + kind.display_name() + )); + } + } + Ok(StoreInventory { by_name }) +} + +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) +} + +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) +} + +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(desired_by_identity.into_values().collect()) +} + +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 + )); + } + } + 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 + )); + } + 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 + )); + } + } + + 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()); + } + None => create.push(desired_link.clone()), + } + } + + 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() + })?; + 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)?; + 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" + )); + } + + 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::Staged(version) => version, + }; + let links_raw = run_fastly_json_capture( + &[ + "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)?; + + 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!("selected initial draft version {draft_version} disappeared") + })?; + let metadata = exact_version_metadata(&versions_raw, draft_version)?; + EditableVersionSource::InitialDraft(Box::new(snapshot_initial_draft( + &service_id, + version, + metadata, + links_snapshot, + token.as_str(), + )?)) + } + VersionSource::Retired(inactive_version) | VersionSource::Staged(inactive_version) => { + let version = versions + .iter() + .find(|version| version.number == inactive_version) + .cloned() + .ok_or_else(|| { + format!("selected inactive version {inactive_version} disappeared") + })?; + if version + .environments + .iter() + .any(|record| record.service_id != service_id) + { + return Err(format!( + "inactive Fastly version {inactive_version} belongs to a different service environment" + )); + } + let metadata = exact_version_metadata(&versions_raw, inactive_version)?; + let snapshot = Box::new(InactiveSourceSnapshot { + links: links_snapshot, + metadata, + version, + }); + if matches!(selected_source, VersionSource::Retired(_)) { + EditableVersionSource::CloneRetired(snapshot) + } else { + EditableVersionSource::CloneStaged(snapshot) + } + } + }; + 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-version".to_owned(), + "update".to_owned(), + format!("--service-id={}", plan.service_id), + format!("--version={version}"), + "--comment".to_owned(), + comment.to_owned(), + ], + cwd, + )?; + } + + 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( + &[ + "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( + &[ + "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")?; + + // Fastly's self-diff returns the complete version configuration. Capture it + // only after EdgeZero has finished every intended mutation, then compare it + // again in the immediate publication barrier below. This covers versioned + // configuration outside the package and resource-link APIs (domains, + // backends, logging endpoints, headers, snippets, conditions, and so on). + let expected_configuration = read_version_configuration_snapshot(plan, version)?; + + revalidate_managed_draft(plan, version, &expected_links, &expected_configuration, cwd)?; + + match plan.target { + PublishTarget::Staging => run_fastly_status( + &[ + "service-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| ()), + } +} + +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::CloneStaged(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()); + } + + 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() + )); + } + + if let Some(reported) = reported_version + && reported != version + { + return Err(format!( + "Fastly updated version {reported}, but the verified draft was version {version}" + )); + } + Ok(version) +} + +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::CloneStaged(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) +} + +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(version) +} + +fn run_fastly_capture_outcome( + fastly_args: &[String], + cwd: &Path, +) -> Result { + let output = Command::new("fastly") + .args(fastly_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 fastly CLI: {error}") + } + })?; + 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(), + ); + } + 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() + }) +} + +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 package hash command exited with status {}\n{}", + outcome.status, + redacted.trim() + )); + } + 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( + &[ + "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 resource-link inventory contains duplicate link id `{}`", + link.link_id + )); + } + let identity = (link.kind, link.alias.clone()); + if by_identity.insert(identity, link.clone()).is_some() { + return Err(format!( + "Fastly resource-link inventory contains duplicate {} alias `{}`", + link.kind.display_name(), + link.alias + )); + } + } + Ok(by_identity) +} + +fn link_resource_map( + links: &[ExistingResourceLink], +) -> Result, String> { + Ok(links_by_identity(links)? + .into_iter() + .map(|(identity, link)| (identity, link.resource_id)) + .collect()) +} + +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) +} + +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" + )) + } +} + +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() +} + +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 + )); + } + } + 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) +} + +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()); + } + 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()); + } + 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()); + } + require_protected_initial_snapshot(plan, snapshot)?; + require_source_configuration(plan, version)?; + Ok(()) +} + +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) +} + +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} complete configuration changed after preflight" + )) + } +} + +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::CloneStaged(_) => VersionSource::Staged(snapshot.version.number), + EditableVersionSource::CloneActive { .. } | EditableVersionSource::InitialDraft(_) => { + return Err("internal managed deploy inactive source mismatch".to_owned()); + } + }; + if select_version_source(&source_versions)? != expected { + return Err("Fastly inactive source selection changed after preflight".to_owned()); + } + 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()); + } + 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 require_protected_initial_snapshot( + plan: &ManagedDeployPlan, + snapshot: &InitialDraftSnapshot, +) -> Result<(), String> { + let base = format!( + "/service/{}/version/{}", + plan.service_id, snapshot.version.number + ); + let domains = parse_snapshot_array( + "domains", + &fastly_api_get(&format!("{base}/domain"), plan.token.as_str())?, + )?; + let backends = parse_snapshot_array( + "backends", + &fastly_api_get(&format!("{base}/backend"), plan.token.as_str())?, + )?; + let logging = snapshot_logging_providers(&base, plan.token.as_str())?; + let settings = parse_snapshot_object( + "settings", + &fastly_api_get(&format!("{base}/settings"), plan.token.as_str())?, + )?; + if domains == snapshot.domains + && backends == snapshot.backends + && logging == snapshot.logging + && settings == snapshot.settings + { + Ok(()) + } else { + Err("Fastly initial draft protected configuration changed after preflight".to_owned()) + } +} + +fn revalidate_managed_draft( + plan: &ManagedDeployPlan, + version: u64, + expected_links: &BTreeMap, + expected_configuration: &str, + 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::CloneStaged(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()); + } + require_protected_initial_snapshot(plan, snapshot)?; + } + } + 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 current_configuration != expected_configuration { + return Err(format!( + "Fastly version {version} complete configuration changed before publication" + )); + } + Ok(()) +} + +fn read_version_configuration_snapshot( + plan: &ManagedDeployPlan, + version: u64, +) -> Result { + read_version_configuration_snapshot_for(&plan.service_id, plan.token.as_str(), version) +} + +fn read_version_configuration_snapshot_for( + service_id: &str, + token: &str, + version: u64, +) -> Result { + let raw = fastly_api_get( + &format!("/service/{service_id}/diff/from/{version}/to/{version}"), + token, + )?; + parse_version_configuration_snapshot(&raw, version) +} + +fn parse_version_configuration_snapshot(raw: &str, version: u64) -> Result { + let value: serde_json::Value = serde_json::from_str(raw).map_err(|_error| { + "Fastly complete version configuration response is malformed".to_owned() + })?; + let object = value.as_object().ok_or_else(|| { + "Fastly complete version configuration response must be an object".to_owned() + })?; + let from = object.get("from").and_then(serde_json::Value::as_u64); + let to = object.get("to").and_then(serde_json::Value::as_u64); + let format = object.get("format").and_then(serde_json::Value::as_str); + let diff = object.get("diff").and_then(serde_json::Value::as_str); + if from != Some(version) || to != Some(version) || format != Some("text") { + return Err( + "Fastly complete version configuration response does not identify the requested self-diff" + .to_owned(), + ); + } + diff.filter(|snapshot| !snapshot.is_empty()) + .map(str::to_owned) + .ok_or_else(|| "Fastly complete version configuration snapshot is empty".to_owned()) +} + +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}" + ) + }) +} + +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") +} + +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-store" => ResourceKind::Config, + "object-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)) +} + +fn snapshot_initial_draft( + service_id: &str, + version: ServiceVersionRecord, + metadata: serde_json::Value, + links: serde_json::Value, + token: &str, +) -> Result { + let base = format!("/service/{service_id}/version/{}", version.number); + 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 logging = snapshot_logging_providers(&base, token)?; + let settings = parse_snapshot_object( + "settings", + &fastly_api_get(&format!("{base}/settings"), token)?, + )?; + Ok(InitialDraftSnapshot { + backends, + domains, + links, + logging, + metadata, + settings, + version, + }) +} + +fn snapshot_logging_providers( + version_base: &str, + token: &str, +) -> Result, String> { + FASTLY_LOGGING_PROVIDER_KINDS + .iter() + .map(|&kind| { + let endpoints = parse_snapshot_array( + &format!("logging/{kind}"), + &fastly_api_get(&format!("{version_base}/logging/{kind}"), token)?, + )?; + Ok(LoggingProviderSnapshot { endpoints, kind }) + }) + .collect() +} + +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 value: serde_json::Value = serde_json::from_str(raw) + .map_err(|error| format!("failed to parse initial draft {label} snapshot: {error}"))?; + if !value.is_array() { + return Err(format!( + "initial draft {label} snapshot must be a complete JSON array" + )); + } + Ok(value) +} + +fn parse_snapshot_object(label: &str, raw: &str) -> Result { + let value: serde_json::Value = serde_json::from_str(raw) + .map_err(|error| format!("failed to parse initial draft {label} snapshot: {error}"))?; + if !value.is_object() { + return Err(format!( + "initial draft {label} snapshot must be a complete JSON object" + )); + } + Ok(value) +} + +/// 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), +} + +#[derive(Clone, Copy, Debug)] +enum FastlyServiceIdSource { + Environment, + Manifest, +} + +#[derive(Debug)] +struct SelectedFastlyService { + id: String, + source: FastlyServiceIdSource, +} + +/// The reclamation plan for `config gc`: the orphan chunk entries to delete +/// (with their ages) plus the counts for the summary line. Produced by +/// `plan_gc_reclamation` (which owns every safety guard); consumed by +/// `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, +} + +/// 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, +} + +/// 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, +} + +/// 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, +} + +/// 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>, +} + +/// 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, +} + +/// 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, +} + +struct EntryCommitFailure { + committed: Vec, + error: String, + failed_key: String, + not_attempted: Vec, + total: usize, +} + +#[derive(Debug, Default, PartialEq, Eq)] +struct RuntimeStoreIds { + config: Vec, + kv: Vec, + secrets: Vec, +} + +impl From<&DeployStoreIds> for RuntimeStoreIds { + fn from(stores: &DeployStoreIds) -> Self { + Self { + config: stores.config.clone(), + kv: stores.kv.clone(), + secrets: stores.secrets.clone(), + } + } +} + +fn owns_managed_deploy(context: &AdapterDeployContext) -> bool { + context.application_release_root.is_some() || context.staging || !context.stores.is_empty() +} + +// 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) + } + } + + 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::DeployStaged => 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:?}")), + } + } + + fn finalize_deploy( + &self, + context: &AdapterDeployContext, + command_output: Option<&str>, + ) -> Result<(), String> { + if context.staging { + return Ok(()); + } + + 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}" + ) + }) + } + + 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 name(&self) -> &'static str { + "fastly" + } + + 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(()) + } + + 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) + } + + 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) + } + } + + fn provision( &self, manifest_root: &Path, adapter_manifest_path: Option<&str>, @@ -480,9 +2162,7 @@ impl Adapter for FastlyCliAdapter { }; let fastly_path = manifest_root.join(rel); let manifest_dir = fastly_path.parent().unwrap_or(manifest_root); - let runtime_env_service_id = - provision_runtime_env_service_id_for_stores(&fastly_path, stores)?; - + let selected_service = effective_fastly_service_id(&fastly_path)?; let mut out = Vec::new(); for (kind, ids) in [ ("kv", stores.kv), @@ -544,7 +2224,7 @@ impl Adapter for FastlyCliAdapter { // service is surprising. The instruction names // both the store-id lookup AND the link command so // the operator can audit before committing. - let post_create_note = resource_link_note(&fastly_path, kind, name)?; + let post_create_note = resource_link_note(selected_service.as_ref(), kind, name); let mut line = format!( "created fastly {kind}-store `{name}` (logical id `{logical}`); appended setup tables to {}", fastly_path.display() @@ -556,76 +2236,6 @@ impl Adapter for FastlyCliAdapter { 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() - )); - } 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. - } - - out.extend(persist_runtime_env_store_name_entries( - stores, - runtime_env_service_id.as_deref(), - dry_run, - manifest_dir, - )?); - - // The STAGING twin of the runtime-override store is created and - // populated entirely by a staged deploy (see - // `relink_runtime_env_for_staging` → `mirror_production_to_staging`), so - // 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. - if out.is_empty() { out.push("fastly has no declared stores to provision".to_owned()); } @@ -740,7 +2350,7 @@ impl Adapter for FastlyCliAdapter { dry_run: bool, ) -> Result, String> { // Local-emulator path: edit - // `[local_server.config_stores..contents]` in + // `[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 @@ -754,7 +2364,7 @@ impl Adapter for FastlyCliAdapter { }; let fastly_path = manifest_root.join(rel); let logical = store.logical.as_str(); - let name = store.platform.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}`)", @@ -942,7 +2552,7 @@ impl Adapter for FastlyCliAdapter { key: &str, _push_ctx: &AdapterPushContext<'_>, ) -> Result { - // Read from `[local_server.config_stores..contents]` + // 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( @@ -951,7 +2561,7 @@ impl Adapter for FastlyCliAdapter { ); }; let fastly_path = manifest_root.join(rel); - let name = store.platform.as_str(); + 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 @@ -1555,8 +3165,8 @@ fn looks_like_already_exists(stderr: &str, kind: &str) -> bool { /// 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 +/// `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> { @@ -1574,75 +3184,78 @@ fn read_fastly_service_id(path: &Path) -> Result, String> { let svc = doc .get("service_id") .and_then(|item| item.as_str()) - .map(str::to_owned) - .filter(|svc_id| !svc_id.is_empty()); + .map(str::to_owned); Ok(svc) } -/// Resolve the service namespace provision uses for account-wide runtime-env -/// entries. A manifest id and environment id must agree so Fastly CLI project -/// context cannot write mappings owned by a different service. -fn provision_runtime_env_service_id(path: &Path) -> Result, String> { - resolve_provision_runtime_env_service_id(path, env::var_os(FASTLY_SERVICE_ID_ENV)) -} - -fn resolve_provision_runtime_env_service_id( - path: &Path, - env_value: Option, -) -> Result, String> { - let manifest_id = read_fastly_service_id(path)?; - let env_id = match env_value { - None => None, - Some(value) => Some( - value - .into_string() - .map_err(|_value| format!("{FASTLY_SERVICE_ID_ENV} must contain valid UTF-8"))?, - ), - }; - - if let Some(service_id) = manifest_id.as_deref() { - validate_service_id(service_id)?; +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(service_id) = env_id.as_deref() { - validate_service_id(service_id)?; - } - match (manifest_id, env_id) { - (Some(manifest), Some(environment)) if manifest != environment => Err(format!( - "Fastly service id mismatch: {} declares `{manifest}` but {FASTLY_SERVICE_ID_ENV} is `{environment}`; refusing to write runtime mappings across service namespaces", - path.display() - )), - (Some(manifest), _) => Ok(Some(manifest)), - (None, Some(environment)) => Ok(Some(environment)), - (None, None) => Ok(None), + if let Some(environment) = environment_id.as_deref() { + validate_service_id(environment)?; } -} - -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() { + if let (Some(manifest), Some(environment)) = (&manifest_id, &environment_id) + && manifest != environment + { return Err(format!( - "cannot persist non-default Fastly store-name mappings without a service namespace: set top-level `service_id` in {} or set {FASTLY_SERVICE_ID_ENV}", - path.display() + "fastly.toml service_id `{manifest}` conflicts with {FASTLY_SERVICE_ID_ENV} `{environment}`; make them agree before provisioning" )); } - Ok(service_id) + + let selected = match (manifest_id, environment_id) { + (Some(id), _) => Some(SelectedFastlyService { + id, + source: FastlyServiceIdSource::Manifest, + }), + (None, Some(id)) => Some(SelectedFastlyService { + id, + source: FastlyServiceIdSource::Environment, + }), + (None, None) => None, + }; + 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 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| { +/// If a service is selected through fastly.toml or `FASTLY_SERVICE_ID`, the +/// next `fastly compute deploy` targets an existing service and skips `[setup]`. +/// Any store created by provision then needs a separate resource link. +fn resource_link_note( + selected: Option<&SelectedFastlyService>, + kind: &str, + name: &str, +) -> Option { + selected.map(|service| { + let svc_id = &service.id; + let selection = match service.source { + FastlyServiceIdSource::Manifest => { + format!("fastly.toml declares `service_id = \"{svc_id}\"`") + } + FastlyServiceIdSource::Environment => { + format!("`{FASTLY_SERVICE_ID_ENV}` selects service `{svc_id}`") + } + }; format!( - " Fastly service id resolves to `{svc_id}`, so `[setup]` will NOT be re-run on the next `fastly compute deploy`. The store exists in the account but is NOT yet linked to the service. To finish provisioning, look up the store id with `fastly {kind}-store list --json` (match by name=`{name}`), then run:\n fastly resource-link create --service-id={svc_id} --resource-id= --version=latest --autoclone --name={name}\n (the link clones the active version so existing traffic is not affected until you `fastly service-version activate`)." + " {selection}, so this service is already deployed -- `[setup]` will NOT be re-run on the next `fastly compute deploy`. The store exists in the account but is NOT yet linked to the service. To finish provisioning, look up the store id with `fastly {kind}-store list --json` (match by name=`{name}`), then run:\n fastly resource-link create --service-id={svc_id} --resource-id= --version=latest --autoclone --name={name}\n (the link clones the active version so existing traffic is not affected until you `fastly service-version activate`)." ) - }); - Ok(note) + }) } /// Probe `fastly.toml` for the existence of `[setup._stores.]`. @@ -1929,16 +3542,11 @@ fn write_fastly_local_config_store( ) })?; - // 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.) + // 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")); @@ -3306,34 +4914,6 @@ where }) } -/// Commit runtime store-name mappings with provision-specific recovery advice. -fn push_runtime_store_name_entries_with_committer( - entries: &[(String, String)], - committer: F, -) -> Result -where - F: FnMut(&str, &str) -> Result<(), String>, -{ - commit_entries_with_committer(entries, committer).map_err(|failure| { - format!( - "fastly provision failed while writing runtime store-name mapping `{failed_key}` after committing {committed} of {total} mappings.\n \ - The failed mapping's outcome is UNKNOWN: Fastly may have committed it before the error.\n \ - Recovery: re-run the SAME `edgezero provision --adapter fastly` command with the same \ - `EDGEZERO__STORES__*__NAME` environment. Mapping writes use `--upsert`, so mappings \ - already written are rewritten harmlessly and missing ones are filled.\n \ - Already written (a retry rewrites them): {already_written:?}\n \ - Failed: `{failed_key}` (outcome unknown) -- {error}\n \ - Not attempted: {not_attempted:?}", - failed_key = failure.failed_key, - committed = failure.committed.len(), - total = failure.total, - already_written = failure.committed, - error = failure.error, - not_attempted = failure.not_attempted, - ) - }) -} - /// Shell `fastly config-store-entry update --upsert --stdin` with /// the value piped through stdin instead of `--value=` on /// argv. @@ -3362,15 +4942,6 @@ fn create_config_store_entry(store_id: &str, key: &str, value: &str) -> Result<( create_config_store_entry_with_cwd(store_id, key, value, None) } -fn create_config_store_entry_in( - store_id: &str, - key: &str, - value: &str, - cwd: &Path, -) -> Result<(), String> { - create_config_store_entry_with_cwd(store_id, key, value, Some(cwd)) -} - fn create_config_store_entry_with_cwd( store_id: &str, key: &str, @@ -3477,2575 +5048,3122 @@ fn delete_config_store_entry(store_id: &str, key: &str) -> Result<(), String> { )) } -/// 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) -} - -/// Parse the `config-store-entry list --json` payload into `(key, value)` pairs. -/// -/// 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 +/// 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)) - .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())); - } - _ => { - 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) - )); - } + 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()); } } - Ok(entries) + found.map_or(ConfigStoreLookup::NotFound, ConfigStoreLookup::Found) } -/// `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, +/// 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)) + } + }, ) } -/// 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). +/// Summarise a failing `fastly` invocation's stderr WITHOUT echoing it. /// -/// 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 - .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); +/// 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" + ) +} - // Copy only current-service production overrides. Legacy unscoped entries - // have no safe owner, and another service's namespace does not belong in - // this per-service staging twin. Selectors are supplied below whether or - // not production carried one. - let mut out: Vec<(String, String)> = production - .iter() - .filter(|(key, _)| key.starts_with(&service_prefix) && !is_selector(key)) - .cloned() - .collect(); - out.extend(selectors); - out +/// One-line type label for a `serde_json::Value` (for diagnostic +/// error messages — not a canonical JSON-schema description). +fn shape_summary(value: &serde_json::Value) -> &'static str { + match value { + serde_json::Value::Null => "null", + serde_json::Value::Bool(_) => "bool", + serde_json::Value::Number(_) => "number", + serde_json::Value::String(_) => "string", + serde_json::Value::Array(_) => "array", + serde_json::Value::Object(_) => "object", + } } -/// Resolve the 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`" - ) - }) - } +/// 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. +/// +/// 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` while resolving `{store_name}`: {detail}.\n Refusing to stage. Pin a known-compatible fastly CLI version and retry." + "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." )), } } -/// 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. +/// 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). /// -/// 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); +/// `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) +} - for (key, value) in &desired { - create_config_store_entry_in(staging_id, key, value, cwd)?; +fn classify_remote_config_store_with_cwd( + name: &str, + cwd: Option<&Path>, +) -> Result { + let mut command = Command::new("fastly"); + command.args(["config-store", "list", "--json"]); + if let Some(command_cwd) = cwd { + command.current_dir(command_cwd); } - let 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)?; + let output = command.output().map_err(|err| { + if err.kind() == ErrorKind::NotFound { + format!("`fastly` not found on PATH; {FASTLY_INSTALL_HINT}") + } else { + format!("failed to spawn `fastly`: {err}") } + })?; + if !output.status.success() { + return Err(format!( + "`fastly config-store list --json` exited with status {}\nstderr: {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + )); } - Ok(()) + // 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)) } -fn canonical_runtime_store_name_key(kind: &str, logical: &str) -> String { +/// 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!( - "EDGEZERO__STORES__{kind}__{}__NAME", - logical.to_ascii_uppercase() + "no fastly config-store matches `{name}` (did you run `edgezero provision --adapter fastly`?)" ) } -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)) -} +/// # 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()))?; -fn has_declared_stores(stores: &ProvisionStores<'_>) -> bool { - !stores.config.is_empty() || !stores.kv.is_empty() || !stores.secrets.is_empty() + Ok(dest) } -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) +/// 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") } -/// 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(), - )); - } +/// 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()); } - entries + argv } -fn runtime_env_store_name_keys(stores: &ProvisionStores<'_>, service_id: &str) -> Vec { - let mut keys = Vec::new(); - for (kind, ids) in [ - ("CONFIG", stores.config), - ("KV", stores.kv), - ("SECRETS", stores.secrets), - ] { - keys.extend( - ids.iter() - .map(|store| runtime_store_name_key(service_id, kind, &store.logical)), - ); - } - keys +/// Legacy direct entry point for callers using [`AdapterAction::Deploy`]. +/// `EdgeZero`'s main deploy path uses the typed [`Adapter::deploy`] hook. +/// +/// # 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) } -/// Compute the minimal changes needed for store-name mappings owned by this -/// Fastly service and the logical ids the app currently declares. Legacy -/// unscoped entries, other service namespaces, undeclared ids, and unrelated -/// runtime settings are preserved. -fn runtime_store_name_reconciliation( - stores: &ProvisionStores<'_>, - service_id: &str, - current: &[(String, String)], -) -> RuntimeStoreNameReconciliation { - let desired = runtime_env_store_name_entries(stores, service_id); - let declared = runtime_env_store_name_keys(stores, service_id); +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()]); + } - let mut upserts = desired - .iter() - .filter(|(key, value)| { - current - .iter() - .find(|(current_key, _)| current_key == key) - .is_none_or(|(_, current_value)| current_value != value) - }) - .cloned() - .collect::>(); - let mut deletes = current - .iter() - .filter(|(key, _)| { - declared.iter().any(|declared_key| declared_key == key) - && !desired.iter().any(|(desired_key, _)| desired_key == key) - }) - .map(|(key, _)| key.clone()) - .collect::>(); - upserts.sort_by(|left, right| left.0.cmp(&right.0)); - deletes.sort(); + 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}")); + } - RuntimeStoreNameReconciliation { deletes, upserts } + Ok(()) } -fn persist_runtime_env_store_name_entries( - stores: &ProvisionStores<'_>, - service_id_hint: Option<&str>, - dry_run: bool, - cwd: &Path, -) -> Result, String> { - if !has_declared_stores(stores) { - return Ok(Vec::new()); - } - let Some(service_id) = service_id_hint else { - if has_non_default_store_name_mappings(stores) { - return Err(format!( - "cannot persist non-default Fastly store-name mappings without top-level `service_id` or {FASTLY_SERVICE_ID_ENV}" - )); - } - return Ok(vec![ - "no Fastly service id and no non-default store-name mappings; skipping runtime-env reconciliation" - .to_owned(), - ]); - }; - let entries = runtime_env_store_name_entries(stores, service_id); - let declared = runtime_env_store_name_keys(stores, service_id); - if dry_run { - let mut out = entries - .iter() - .map(|(key, value)| { - format!( - "would upsert `{key}={value}` into fastly config-store `{RUNTIME_ENV_STORE_NAME}`" - ) - }) - .collect::>(); - out.extend( - declared - .iter() - .filter(|key| !entries.iter().any(|(entry_key, _)| entry_key == *key)) - .map(|key| { - format!( - "would remove `{key}` from fastly config-store `{RUNTIME_ENV_STORE_NAME}` if a stale mapping is present" - ) - }), - ); - return Ok(out); +fn find_fastly_manifest(start: &Path) -> Result { + if let Some(found) = find_manifest_upwards(start, "fastly.toml") { + return Ok(found); } - let Some(runtime_env_store_id) = - resolve_remote_config_store_id_in(RUNTIME_ENV_STORE_NAME, cwd)? - else { - if entries.is_empty() { - return Ok(vec![format!( - "fastly config-store `{RUNTIME_ENV_STORE_NAME}` not found; no non-default store-name mappings to write for service `{service_id}`, skipping reconciliation" - )]); - } - return Err(format!( - "cannot write non-default store-name mappings for service `{service_id}`: fastly config-store `{RUNTIME_ENV_STORE_NAME}` does not exist remotely even though its setup block is declared. Create it with `fastly config-store create --name={RUNTIME_ENV_STORE_NAME}` (and link it to an existing service when needed), then re-run provision" - )); - }; - let current = read_config_store_entries(&runtime_env_store_id, cwd)?; - let reconciliation = runtime_store_name_reconciliation(stores, service_id, ¤t); - if reconciliation.upserts.is_empty() && reconciliation.deletes.is_empty() { - return Ok(Vec::new()); - } + 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(); - 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}" - ) - })?; + if candidates.is_empty() { + return Err("could not locate fastly.toml".to_owned()); } - 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() - ) -} + candidates.sort_by_key(|path| { + let parent = path.parent().unwrap_or(Path::new("")); + path_distance(start, parent) + }); -/// 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)) + Ok(candidates.remove(0)) } -/// 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) - }) -} +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('-', "_")); -/// 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()); + 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); } } - found.map_or(ConfigStoreLookup::NotFound, ConfigStoreLookup::Found) + + let manifest_target = manifest_dir + .join("target") + .join(target_triple) + .join("release") + .join(&release_name); + if manifest_target.exists() { + return Ok(manifest_target); + } + + let workspace_target = workspace_root + .join("target") + .join(target_triple) + .join("release") + .join(&release_name); + if workspace_target.exists() { + return Ok(workspace_target); + } + + Err(format!( + "compiled artifact not found (looked in {} and workspace target)", + manifest_dir.display() + )) } -/// 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)) - } - }, - ) +#[inline] +pub fn register() { + register_adapter(&FASTLY_ADAPTER); + register_adapter_blueprint(&FASTLY_BLUEPRINT); } -/// 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" - ) +#[ctor(unsafe)] +fn register_ctor() { + register(); } -/// 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", +/// # 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 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. -/// -/// 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) + Ok(()) } -fn resolve_remote_config_store_id_in(name: &str, cwd: &Path) -> Result, String> { - resolve_remote_config_store_id_with_cwd(name, Some(cwd)) +// =================================================================== +// 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`). + +/// 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) } -fn resolve_remote_config_store_id_with_cwd( - name: &str, - cwd: Option<&Path>, -) -> Result, String> { - let lookup = if let Some(command_cwd) = cwd { - classify_remote_config_store_in(name, command_cwd)? - } else { - classify_remote_config_store(name)? - }; - match lookup { - ConfigStoreLookup::Found(id) => Ok(Some(id)), - ConfigStoreLookup::NotFound => Ok(None), - ConfigStoreLookup::SchemaDrift(detail) => Err(format!( - "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." - )), - } +/// 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) } -/// 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) +/// 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; + } + out.push(arg.clone()); + } + out } -fn classify_remote_config_store_in(name: &str, cwd: &Path) -> Result { - classify_remote_config_store_with_cwd(name, Some(cwd)) +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!( + "Fastly deploy argument `{flag}` is reserved for the EdgeZero deployment lifecycle" + )); + } + } + Ok(()) } -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); +fn set_managed_deploy_value( + slot: &mut Option, + flag: &str, + value: &str, +) -> Result<(), String> { + if value.is_empty() { + return Err(format!( + "Fastly deploy argument `{flag}` requires a non-empty value" + )); } - 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() { + if slot.is_some() { return Err(format!( - "`fastly config-store list --json` exited with status {}\nstderr: {}", - output.status, - String::from_utf8_lossy(&output.stderr).trim() + "Fastly deploy argument `{flag}` may be provided only once" )); } - // 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)) + *slot = Some(value.to_owned()); + Ok(()) } -/// 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`?)" - ) +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) } -/// # 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)?; +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) +} - 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}")); +/// 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}") + }) +} - 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()))?; +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" + )), + } +} - Ok(dest) +fn validate_deploy_service_id_for_manifest( + context: &AdapterDeployContext, + manifest_path: &Path, +) -> Result<(), String> { + if let Some(service_id) = context.service_id.as_deref() { + return validate_service_id(service_id); + } + effective_fastly_service_id(manifest_path)?; + Ok(()) } -/// 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") +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) } -/// 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()); +fn validate_fastly_config_key( + logical_store_id: &str, + key: &str, + _staging: bool, + _local: bool, +) -> Result<(), String> { + 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() + )) } - argv } -/// # Errors -/// Returns an error if the Fastly CLI deploy command fails. +/// 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). /// -/// 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"); +/// 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) +} - 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}")); +/// 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); + } } - - Ok(()) + result } -fn find_fastly_manifest(start: &Path) -> Result { - if let Some(found) = find_manifest_upwards(start, "fastly.toml") { - return Ok(found); - } +/// 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 ", '.')) +} - 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(); +/// 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() + }) +} - if candidates.is_empty() { - return Err("could not locate fastly.toml".to_owned()); +/// 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 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!( + "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(active_version) +} - candidates.sort_by_key(|path| { - let parent = path.parent().unwrap_or(Path::new("")); - path_distance(start, parent) - }); - - Ok(candidates.remove(0)) +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) } -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('-', "_")); +fn select_version_source(versions: &[ServiceVersionRecord]) -> Result { + let active_versions = versions + .iter() + .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)); + } - 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 highest = versions + .iter() + .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 staged = 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(), staged.as_slice()) { + ([draft], [] | [_]) if draft.number == highest => { + return Ok(VersionSource::InitialDraft(draft.number)); } + ([], [staged_version]) => return Ok(VersionSource::Staged(staged_version.number)), + _ => {} + } + if drafts.is_empty() + && staged.is_empty() + && let Some(retired_version) = retired.iter().find(|version| version.number == highest) + { + return Ok(VersionSource::Retired(retired_version.number)); + } + if drafts.len() == 1 && staged.len() <= 1 { + return Err(format!( + "first deployment requires the highest service version ({highest}) to be the initialized editable draft" + )); + } + if staged.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(), + staged.len(), + retired.len() + )); + } + 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(), + staged.len(), + retired.len() + )) +} + +/// 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" + )), } +} - let manifest_target = manifest_dir - .join("target") - .join(target_triple) - .join("release") - .join(&release_name); - if manifest_target.exists() { - return Ok(manifest_target); +fn staging_rollback_decision( + versions: &[ServiceVersionRecord], + requested_version: u64, + service_id: &str, +) -> Result { + let staging_records = versions + .iter() + .flat_map(|version| { + version + .environments + .iter() + .filter(|environment| environment.name == "staging") + .map(move |environment| (version.number, environment)) + }) + .collect::>(); + if staging_records.len() > 1 { + return Err(format!( + "Fastly service {service_id} reports more than one staging environment record; refusing staging rollback" + )); } - - let workspace_target = workspace_root - .join("target") - .join(target_triple) - .join("release") - .join(&release_name); - if workspace_target.exists() { - return Ok(workspace_target); + let version = versions + .iter() + .find(|candidate| candidate.number == requested_version) + .ok_or_else(|| { + format!( + "Fastly version {requested_version} is absent from service {service_id}; refusing staging rollback" + ) + })?; + let staged = matches!( + staging_records.as_slice(), + [(record_version, environment)] + if *record_version == requested_version + && environment.service_id == service_id + && environment.active_version == requested_version + ); + if staged { + return Ok(StagingRollbackDecision::Deactivate); + } + let unpublished_draft = !version.active && !version.locked && version.environments.is_empty(); + if unpublished_draft { + return Ok(StagingRollbackDecision::NoopDraft); } - Err(format!( - "compiled artifact not found (looked in {} and workspace target)", - manifest_dir.display() + "Fastly version {requested_version} is neither the exact staged version nor an unpublished editable draft; refusing staging rollback" )) } -#[inline] -pub fn register() { - register_adapter(&FASTLY_ADAPTER); - register_adapter_blueprint(&FASTLY_BLUEPRINT); +/// 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 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")) } -#[ctor(unsafe)] -fn register_ctor() { - register(); +/// 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 } -/// # 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())?; +/// 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}'" + )); + } + 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(); + } + } + 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(["compute", "serve"]) - .args(extra_args) - .current_dir(manifest_dir) + .args(fastly_args) + .current_dir(cwd) .status() - .map_err(|err| format!("failed to run fastly CLI: {err}"))?; - if !status.success() { - return Err(format!("fastly compute serve failed with status {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(" ") + )) } - - 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`). - -/// 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) +/// 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. +/// +/// `-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() + )) + } } -/// 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) +/// 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}")) } -/// 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; +/// 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), } - out.push(arg.clone()); } + out.push('"'); 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), - } -} - -/// 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_staged_passthrough(args: &[String]) -> StagedPassthrough { - let mut split = StagedPassthrough { - 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()); - } - } 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(); - } - } +/// 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" + )) } - 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()); - } - env::var(FASTLY_SERVICE_ID_ENV).map_err(|_err| { - format!("no service id: pass `--service-id ` or set {FASTLY_SERVICE_ID_ENV}") +/// 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}") }) } -/// 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")) +/// 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`" + )) + } } -/// Whether an HTTP status counts as healthy (2xx only). +/// `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. /// -/// 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) +/// 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()) } -/// 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); - } +/// `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}")) } - 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 ", '.')) +/// `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}")); + } + if body.trim().is_empty() { + return Err(format!("Fastly API PUT {path} returned an empty response")); + } + Ok(body.to_owned()) } -/// 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() - }) +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(), + } } -/// 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. +/// 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()); + } + find_fastly_manifest(env::current_dir().map_err(|err| err.to_string())?.as_path()) +} + +/// Production companion to `deploy`: resolve the active service version via the +/// Fastly API and emit it as a `version=` line. /// -/// `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. +/// 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. /// -/// 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() +/// `--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")) +} + +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" ); } - 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); - } + 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), } - 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. +/// Require `version` to be the currently ACTIVE service version — the +/// production healthcheck's version contract. /// -/// 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( +/// 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) +} + +/// 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, - from_version: u64, + version: u64, service_id: &str, + phase: &str, ) -> Result<(), String> { match active { - Some(active_version) if active_version == from_version => Ok(()), + Some(active_version) if active_version == 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" + "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!( - "refusing to roll back service {service_id}: it has no active version" + "production healthcheck version {version} could not be confirmed active {phase}: service {service_id} has no active version" )), } } -/// 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) -} +/// `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 + }; + + // 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")?; + } + + 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) + )) + } + } +} + +/// 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() + )); + } + 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()?; -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()); + 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}" + ); } - map.values().find_map(find_staging_ip) + StagingRollbackDecision::NoopDraft => log::info!( + "[edgezero] Fastly version {version} is an unpublished draft; staging rollback has nothing to deactivate" + ), } - 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, + } 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}"); } + Ok(()) } -/// 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(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). + + // ── Fastly staging lifecycle helpers ────────────────────────────── + + #[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); } - 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}'")); + #[test] + fn arg_value_none_when_flag_is_last() { + let args = vec!["--version".to_owned()]; + assert_eq!(arg_value(&args, "--version"), None); } - if path.chars().any(|ch| ch.is_whitespace() || ch.is_control()) { - return Err(format!( - "healthcheck --path must not contain whitespace or control characters: '{path}'" - )); + + #[test] + fn arg_flag_detects_presence() { + let args = vec!["--staging".to_owned()]; + assert!(arg_flag(&args, "--staging")); + assert!(!arg_flag(&args, "--nope")); } - 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 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()] + ); } - 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 resolve_service_id_prefers_flag() { + let args = vec!["--service-id".to_owned(), "SVCFROMARG".to_owned()]; + assert_eq!(resolve_service_id(&args).unwrap(), "SVCFROMARG"); } -} -/// 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() - )) + // ── managed deploy argument validation ──────────────────────── + + fn owned(args: &[&str]) -> Vec { + args.iter().map(|arg| (*arg).to_owned()).collect() } -} -/// 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() - )) + #[cfg(unix)] + fn fake_provider_invocation_marker(marker: &Path) -> tempfile::TempDir { + use std::os::unix::fs::PermissionsExt as _; + + 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"); + } + dir } -} -/// 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}")) -} + #[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() + ); + } -/// 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), + 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}" + ); + } + + #[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}" + ); } } - 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" - )); + #[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}" + ); + } } - 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" - )) + + #[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"); } -} -/// 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 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"); + } -/// 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`" - )) + #[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"); } -} -/// `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() + #[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) + ); + } + } + + #[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}"); + } + + #[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() + ), ) - })?; - if !(200..300).contains(&status) { - return Err(format!("Fastly API GET {path} returned HTTP {status}")); + .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}"); + } + + #[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" + ]) + ); + + let detached = + parse_release_managed_deploy_args(&owned(&["--comment", "publisher deployment"])) + .expect("detached comment form"); + assert_eq!(detached.comment.as_deref(), Some("publisher deployment")); } - 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}")) + #[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}"); + } } -} -/// Resolve the directory containing the Fastly manifest for a deploy -/// (production [`deploy`] or [`deploy_staged`]). -/// -/// The CLI (`edgezero_cli::run_deploy`) resolves the `edgezero.toml` -/// manifest — honouring `EDGEZERO_MANIFEST` — and threads the -/// manifest-configured `[adapters.fastly.adapter].manifest` path in as -/// `--manifest-path `. Prefer that so a monorepo with -/// multiple Fastly apps deploys/stages the app the operator actually -/// selected, rather than whichever `fastly.toml` a bare working-directory -/// search happens to find first. Only when no `--manifest-path` is -/// threaded (e.g. a manifest that declares Fastly commands but no adapter -/// `manifest` key) do we fall back to the working-directory search. -fn resolve_manifest_dir(args: &[String]) -> Result { - if let Some(raw) = arg_value(args, "--manifest-path") { - let path = PathBuf::from(raw); - 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_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" + ); } - 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_staged(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_staged_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_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}" ); } - // 1. Build the wasm package (no deploy / activation). - run_fastly_status( - &[ - "compute".to_owned(), - "build".to_owned(), - "--non-interactive".to_owned(), - ], - manifest_dir, - )?; + #[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())]), + }; - // 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()); + 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); } - 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}" - ) - })?; + // ── non-interactive CI safety (`--non-interactive`) ─────────────── - // 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 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" + ]) + ); } - // 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)?; + #[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})" + ); + } + } - // 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, - )?; + // ── 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. - // 6. Emit the staged version (parseable contract). - log::info!("version={version}"); - Ok(()) -} + #[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}" + ); + } + } -/// 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 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"); + } } - // 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 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"); + } } - }; + } - // 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, - )?; + // ── curl-config escaping + input validation (injection defence) ─── - // 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, - )?; + #[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\""); } - // `--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 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\""); + } - log::info!("staged version {version} now reads `{staging_store_name}` for its config selector"); - Ok(()) -} + #[test] + fn validate_service_id_accepts_fastly_handle() { + validate_service_id("SU1Z0isxPaozGVKXdv0eY").expect("alphanumeric handle"); + } -/// 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" - ); + #[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); + } } - 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), + #[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"); } -} -/// 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 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", + ]); -/// 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 = deploy(&args).expect_err("invalid service id must fail deploy"); + 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 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); } - 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 - }; + #[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", + ]); - // 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")?; + let error = healthcheck(&args).expect_err("invalid service id must fail healthcheck"); + assert_service_id_error(&error); + assert_provider_not_invoked(&marker); + } + + #[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 error = rollback(&args).expect_err("invalid service id must fail rollback"); + 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 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); } -} -/// 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() - )); + #[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); } - 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()?; + #[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 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}" + 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; + } + + 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"); + + 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_staged_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_staged_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:?}" - ); - } + 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_staged_passthrough(&owned(&["--comment", "ci run 12"])).comment, - Some("ci run 12".to_owned()) + select_version_source(&versions), + Ok(VersionSource::InitialDraft(2)) ); + } + + #[test] + fn deploy_plan_version_source_selects_unique_staged_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":"SVC1"}]}]"#, + ) + .expect("staged version list"); assert_eq!( - split_staged_passthrough(&owned(&["--comment=x"])).comment, - Some("x".to_owned()) + select_version_source(&versions), + Ok(VersionSource::Staged(3)) ); } #[test] - fn split_staged_passthrough_forwards_supported_flags_only() { - let args = owned(&[ - "--package", - "pkg.tar.gz", - "--autoclone", - "--verbose", - "--comment", - "note", - "--env", - "stage", - "--status-check-off", - ]); - let split = split_staged_passthrough(&args); - // Supported by `compute update`: kept (value flags keep their value). + 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!( - split.forwarded, - owned(&["--package", "pkg.tar.gz", "--autoclone", "--verbose"]) + select_version_source(&versions), + Ok(VersionSource::Retired(1)) ); - // `--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_reuses_highest_retry_draft_beside_staging() { + let versions = parse_service_versions( + r#"[ + {"number":1,"active":false,"locked":true,"environments":[{"active_version":1,"name":"staging","service_id":"SVC1"}]}, + {"number":2,"active":false,"locked":false,"environments":[]} + ]"#, + ) + .expect("staged source and one retry draft"); assert_eq!( - argv, - owned(&[ - "compute", - "deploy", - "--service-id", - "SVC1", - "--non-interactive" - ]) + select_version_source(&versions), + Ok(VersionSource::InitialDraft(2)) ); } #[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_missing_duplicate_or_ambiguous_staged_source() { + for invalid in [ + r#"[{"number":2,"active":false,"locked":true,"staging":false,"deployed":false,"environments":[{"active_version":2,"name":"staging","service_id":"SVC1"}]},{"number":3,"active":false,"locked":true,"staging":false,"deployed":true,"environments":[{"active_version":3,"name":"staging","service_id":"SVC1"}]}]"#, + r#"[{"number":3,"active":false,"locked":true,"staging":false,"deployed":true,"environments":[{"active_version":2,"name":"staging","service_id":"SVC1"}]}]"#, + r#"[{"number":3,"active":false,"locked":true,"staging":true,"deployed":true,"environments":[{"active_version":3,"name":"production","service_id":"SVC1"}]}]"#, + ] { + let versions = parse_service_versions(invalid).expect("well-formed version list"); + assert!( + select_version_source(&versions).is_err(), + "unsafe staged source must fail closed: {invalid}" ); } } - // ── 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_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"}]"#, ] { - let err = healthcheck(&args).expect_err("must reject absent/empty required value"); assert!( - err.contains(needle), - "expected {needle:?} in error for {args:?}, got: {err}" + parse_service_versions(invalid).is_err(), + "invalid version list must fail: {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", - "", - ]), - ] { - healthcheck(&args).expect_err("staging must reject empty 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)) + ); } #[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_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}" + ); } } - // ── 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_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 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 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_accepts_fastly_handle() { - validate_service_id("SU1Z0isxPaozGVKXdv0eY").expect("alphanumeric handle"); + 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_non_alphanumeric_characters() { - validate_service_id("SVC1_").expect_err("trailing underscore"); - validate_service_id("SVC-1").expect_err("hyphen"); + 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_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_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_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_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 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_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-store"}, + {"id":"KV_LINK","name":"shared","resource_id":"KV_A","resource_type":"object-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) - ); - 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. + fn version_configuration_snapshot_requires_an_exact_nonempty_self_diff() { 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_version_configuration_snapshot( + r#"{"from":42,"to":42,"format":"text","diff":"complete configuration"}"#, + 42, ), - Some(4) + Ok("complete configuration".to_owned()) ); - // 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_staged` 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 - ); + for invalid in [ + r#"{"from":41,"to":42,"format":"text","diff":"configuration"}"#, + r#"{"from":42,"to":42,"format":"html","diff":"configuration"}"#, + r#"{"from":42,"to":42,"format":"text","diff":""}"#, + r#"{"from":42,"to":42,"format":"text"}"#, + "[]", + "not json", + ] { + parse_version_configuration_snapshot(invalid, 42) + .expect_err("malformed or mismatched self-diff must fail closed"); + } } #[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 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 +8195,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 +8204,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 +8231,64 @@ 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":"svc"}]}]"#, + ) + .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"); + staging_rollback_decision(&versions, 7, "other-service") + .expect_err("a staged version for another service 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":"svc"},{"active_version":7,"name":"staging","service_id":"svc"}]}]"#, + ) + .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":"svc"},{"active_version":7,"name":"staging","service_id":"svc"}]}]"#, + ) + .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":"svc"}]},{"active":false,"number":8,"locked":true,"environments":[{"active_version":8,"name":"staging","service_id":"svc"}]}]"#, + ) + .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 +8327,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] @@ -6971,172 +9109,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 +9144,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 +9182,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 +9204,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 +9219,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 +9244,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. + /// 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 +9272,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 resource-link create --service-id=abc123svc --resource-id= --version=latest --autoclone --name=app_config" ), "note carries the full resource-link command: {note}" ); @@ -7512,17 +9290,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 +9472,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 +9484,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 +9497,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 +9807,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 +9827,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 +10708,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 +10737,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 +10760,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 +11350,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 +11470,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_staged` 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_staged` against a fake `fastly`, returning the result - /// and the recorded argv lines. - #[cfg(unix)] - fn run_deploy_staged_with_fake( - update_stdout: &str, - extra: &[&str], - ) -> (Result<(), String>, Vec) { - run_deploy_staged_with_fake_and_env(update_stdout, extra, None) - } - - #[cfg(unix)] - fn run_deploy_staged_with_fake_and_env( - update_stdout: &str, - extra: &[&str], - store_name_override: Option<(&str, &str)>, - ) -> (Result<(), String>, Vec) { - let _lock = path_mutation_guard().lock().expect("guard"); - let (fake, record) = fake_fastly_recorder(update_stdout); - 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_staged(&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_staged_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_staged_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 +12718,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_staged_ignores_ambient_store_name_overrides() { - let (result, argv) = run_deploy_staged_with_fake_and_env( - "SUCCESS: Updated package (service SVC1, version 7)", - &["--edgezero-staging-config=app_config"], - Some(( - "EDGEZERO__STORES__SECRETS__DEFAULT__NAME", - "ambient_secrets", - )), - ); - result.expect("staged deploy succeeds"); - - assert!( - !argv.iter().any(|line| { - line.contains("EDGEZERO__STORES__SECRETS__DEFAULT__NAME") - || line.contains("ambient_secrets") - }), - "staging must mirror persisted production mappings, not ambient process env: {argv:?}" - ); - } - - #[cfg(unix)] - #[test] - fn deploy_staged_points_the_draft_at_the_staging_selector_store() { - // The defect this closes: a clone inherits the active version's links, - // so without a relink the staged version opens production's selector - // store and reads PRODUCTION config -- `config push --staging` would - // write a key nothing ever reads. The CLI threads the declared config - // store as `--edgezero-staging-config=`. - let (result, argv) = run_deploy_staged_with_fake( - "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_staged_works_for_an_app_that_selects_no_config() { - use std::os::unix::fs::PermissionsExt as _; - - // An app declaring no config stores threads no - // `--edgezero-staging-config`, so there is no selector to isolate: - // 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_staged(&[ - "--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_staged_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_staged(&[ - "--service-id".to_owned(), - "SVC1".to_owned(), - "--manifest-path".to_owned(), - app.path().join("fastly.toml").display().to_string(), - "--edgezero-staging-config=app_config".to_owned(), - ]) - .expect("staged deploy must auto-create the twin and succeed"); - - let 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_staged_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_staged(&[ - "--service-id".to_owned(), - "SVC1".to_owned(), - "--manifest-path".to_owned(), - app.path().join("fastly.toml").display().to_string(), - "--edgezero-staging-config=app_config".to_owned(), - ]) - .expect("must isolate staging even with no production override store"); - - let 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_staged_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_staged(&[ - "--service-id".to_owned(), - "SVC1".to_owned(), - "--manifest-path".to_owned(), - app.path().join("fastly.toml").display().to_string(), - "--edgezero-staging-config=app_config".to_owned(), - ]) - .expect_err("an unreadable config-store listing must fail closed"); - assert!( - err.contains("Refusing to stage") || err.contains("could not parse"), - "the error must explain the refusal: {err}" - ); - } - - #[cfg(unix)] - #[test] - fn deploy_staged_without_comment_makes_no_version_comment_call() { - let (result, argv) = - run_deploy_staged_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_staged_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_staged_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_staged_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_staged_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 +12792,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 +12801,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 +14623,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 +14655,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 +14668,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 +14682,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..36e9c2fb 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")] @@ -17,6 +16,8 @@ pub mod key_value_store; pub mod logger; #[cfg(feature = "fastly")] pub mod proxy; +#[cfg(feature = "cli")] +pub(crate) mod release; #[cfg(feature = "fastly")] pub mod request; #[cfg(feature = "fastly")] @@ -26,27 +27,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 +45,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 +84,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 +103,7 @@ pub fn run_app(req: fastly::Request) -> Result( @@ -177,126 +115,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) -} - -/// 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 + request::dispatch_with_registries(&app, req, stores, extend) } -/// 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 +171,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/release.rs b/crates/edgezero-adapter-fastly/src/release.rs new file mode 100644 index 00000000..dbb13fe0 --- /dev/null +++ b/crates/edgezero-adapter-fastly/src/release.rs @@ -0,0 +1,753 @@ +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"; +const LIFECYCLE_PROTOCOL: u64 = 1; + +#[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(crate) struct VerifiedApplicationRelease { + adapter_manifest: PathBuf, + application_cli: PathBuf, + application_manifest: PathBuf, + package: PathBuf, + package_sha256: String, + root: PathBuf, + source_revision: String, +} + +impl VerifiedApplicationRelease { + pub(crate) fn adapter_manifest(&self) -> &Path { + &self.adapter_manifest + } + + pub(crate) fn package(&self) -> &Path { + &self.package + } + + pub(crate) fn package_sha256(&self) -> &str { + &self.package_sha256 + } +} + +pub(crate) fn verify_application_release( + root: &Path, + loaded_application_manifest: &Path, + referenced_adapter_manifest: &Path, +) -> 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)?; + + 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) -> Result<(), String> { + if metadata.format != 1 { + return Err(format!( + "unsupported application release format {}; expected format 1", + metadata.format + )); + } + if metadata.lifecycle_protocol != LIFECYCLE_PROTOCOL { + return Err(format!( + "unsupported application release lifecycle protocol {}; expected {}", + metadata.lifecycle_protocol, LIFECYCLE_PROTOCOL + )); + } + if metadata.adapter != "fastly" { + return Err(format!( + "application release adapter {:?} is unsupported; expected `fastly`", + 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/fastly.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.fastly.adapter]\nmanifest = \"adapter/fastly.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": "fastly", + "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/fastly.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, + ) + } + } + + #[test] + fn application_release_verifies_exact_members_and_returns_confined_paths() { + let fixture = ReleaseFixture::new(); + 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/fastly.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, + ) + .unwrap_err(); + assert!( + application_error.contains("application manifest"), + "{application_error}" + ); + + let other_adapter = fixture.root.path().join("adapter/other-fastly.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, + ) + .unwrap_err(); + assert!( + adapter_error.contains("adapter manifest"), + "{adapter_error}" + ); + } +} 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/src/registry.rs b/crates/edgezero-adapter/src/registry.rs index a2d18c8a..59e78ab6 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::DeployStaged + } else { + AdapterAction::Deploy + }; + self.execute(action, args) + } + /// Execute the requested action with optional adapter-specific args. /// /// `args` is a stringly-typed pass-through for arguments meant @@ -290,6 +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-cli/src/adapter.rs b/crates/edgezero-cli/src/adapter.rs index 54dc6253..fcee12d4 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..6257e27e 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::env_config::{EnvConfig, merge_env_defaults}; use edgezero_core::manifest::{Manifest, 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 @@ -469,10 +473,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}"))?; @@ -641,11 +643,15 @@ where ) })?; 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 +1357,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 +1410,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( @@ -1554,12 +1593,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(); + let parent_variables = env::vars().collect::>(); for (name, adapter_cfg) in &ctx.manifest().adapters { let Some(adapter) = adapter_registry::get_adapter(name) else { continue; }; + 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, @@ -1987,17 +2031,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 +2522,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" ); } @@ -3558,12 +3808,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 +3841,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 +3859,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 +3921,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 +3943,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 +3961,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 +4709,6 @@ ids = ["default"] // --- PATH-mutation helpers (mirrors Cloudflare adapter test pattern) --- - /// Process-wide mutex serialising PATH-mutating tests so parallel - /// test threads don't race on the `$PATH` environment variable. - #[cfg(unix)] - fn path_mutation_guard() -> &'static Mutex<()> { - use std::sync::OnceLock; - static GUARD: OnceLock> = OnceLock::new(); - GUARD.get_or_init(|| Mutex::new(())) - } - /// Build a tempdir containing a `fastly` script that APPENDS every /// invocation to `oplog` and fails. Injected via PATH so an ordering /// regression is caught as a recorded invocation instead of silently diff --git a/crates/edgezero-cli/src/generator.rs b/crates/edgezero-cli/src/generator.rs index f566e76b..9f2e0225 100644 --- a/crates/edgezero-cli/src/generator.rs +++ b/crates/edgezero-cli/src/generator.rs @@ -805,6 +805,7 @@ fn initialize_git_repo(out_dir: &Path) { #[cfg(test)] mod tests { use super::*; + use crate::test_support::path_mutation_guard; use edgezero_core::app_config::app_name_prefix; use edgezero_core::test_env::PathPrepend as PathOverride; use std::path::Path; @@ -1301,6 +1302,7 @@ mod tests { #[test] fn generate_new_scaffolds_workspace_layout() { + let _path_lock = path_mutation_guard().lock().expect("path guard"); let temp = TempDir::new().expect("temp dir"); let bin_dir = temp.path().join("bin"); write_git_stub(&bin_dir); diff --git a/crates/edgezero-cli/src/lib.rs b/crates/edgezero-cli/src/lib.rs index cbe17c49..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::DeployStaged - } 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::DeployStaged, - manifest.as_ref(), - &passthrough, - ); - } - - // Production deploy also emits the activated version - // so the deploy-fastly action can surface `fastly-version` and the - // deploy→healthcheck→rollback chain has a real version to thread. - // - // Resolution precedence (cheapest + most reliable first): - // 1. The deploy command's OWN output. We tee it (echoed live to - // the operator, captured for us) and look for a canonical - // `version=` line, then for Fastly's native phrasing - // ("... version 12"). The deploy command already knows the - // version it activated, so this needs no API round-trip and - // works under a manifest `[adapters.fastly.commands].deploy` - // override (including test fixtures with dummy credentials). - // 2. Only when the output yields nothing: the Fastly API lookup - // (`EmitVersion`), which needs a live API + a real token. - // 3. If BOTH fail: a clear `Err`. We never silently emit an empty - // version — that was the original bug. - if args.service_id.is_some() && args.adapter.eq_ignore_ascii_case("fastly") { - let captured = adapter::execute_capture( - &args.adapter, - adapter::Action::Deploy, - manifest.as_ref(), - &passthrough, - )?; - if let Some(version) = captured.as_deref().and_then(parse_deploy_version) { - log::info!("version={version}"); - return Ok(()); - } - // Fallback: resolve the version the deploy just activated via the Fastly - // API. `--require-active` makes EmitVersion FAIL (not emit an empty - // `version=`) when the API reports no active version — a deploy that - // activated a version but resolves to none is an error, never a silent - // empty-version success. - let mut emit_args = passthrough.clone(); - emit_args.push("--require-active".to_owned()); - return adapter::execute( - &args.adapter, - adapter::Action::EmitVersion, - manifest.as_ref(), - &emit_args, - ) - .map_err(|err| { - format!( - "deploy succeeded but the activated version could not be resolved: no `version=` \ - (or Fastly `version `) line in the deploy output, and the Fastly API fallback \ - failed: {err}" - ) - }); - } - - adapter::execute( + adapter::deploy( &args.adapter, - adapter::Action::Deploy, + &context, + 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..3a388c10 100644 --- a/crates/edgezero-core/src/env_config.rs +++ b/crates/edgezero-core/src/env_config.rs @@ -112,7 +112,7 @@ 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. #[must_use] #[inline] pub fn store_key(&self, kind: &str, id: &str) -> String { @@ -121,6 +121,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"` / @@ -147,6 +163,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 +204,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 +249,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 +345,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 +412,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..0eb5c49c 100644 --- a/docs/guide/adapters/fastly.md +++ b/docs/guide/adapters/fastly.md @@ -135,16 +135,20 @@ 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 and both manifests 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 +188,90 @@ fn main() { Fastly logging is wired when you call `init_logger` (or `run_app`); otherwise no logger is installed. ::: -## Config Store +## Store selection and deployment -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): - -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..7b403bb3 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 `edgezero.toml` to `fastly.toml` +relationship 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.