diff --git a/.github/actions/build-app-cli/action.yml b/.github/actions/build-app-cli/action.yml new file mode 100644 index 00000000..ffcabab1 --- /dev/null +++ b/.github/actions/build-app-cli/action.yml @@ -0,0 +1,224 @@ +name: EdgeZero build-app-cli +description: Compile the CLI package the application provides and publish it as a self-describing artifact. + +inputs: + app-cli-package: + description: Cargo package name of the CLI to build, defined in the application's own workspace. + required: true + app-cli-bin: + description: Binary name produced by app-cli-package. Defaults to the package name. + required: false + default: "" + working-directory: + description: Application directory (relative to github.workspace) whose workspace defines app-cli-package. + required: false + default: . + rust-toolchain: + description: Explicit host Rust toolchain, or 'auto' to follow application discovery. + required: false + default: auto + app-cli-artifact: + description: Name for the uploaded app-CLI artifact. + required: false + default: edgezero-cli + provider-env-clear: + description: >- + The DYNAMIC half of a two-layer credential scrub. A JSON array of + environment variable names re-exec'd out of the process before any + app-controlled command runs (cargo metadata, cargo build, the built CLI's + --help). The STATIC half lives in this action's step env, which blanks the + shipped adapters' aliases outright (and is the only layer that also protects + the upload step). This input exists so a consumer can add a provider this + action does not know about; its default repeats the shipped aliases so the + dynamic layer is self-contained too. Must be a JSON array of non-empty + identifier strings or the build fails closed. + required: false + default: >- + ["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"] + +outputs: + app-cli-version: + description: CLI package version read from cargo metadata. + value: ${{ steps.build.outputs['app-cli-version'] }} + app-cli-package: + description: The application CLI package that was built. + value: ${{ steps.build.outputs['app-cli-package'] }} + app-cli-bin: + description: The binary name inside the artifact. + value: ${{ steps.build.outputs['app-cli-bin'] }} + app-cli-artifact: + description: Artifact name the deploy, healthcheck, and rollback actions consume. + value: ${{ steps.build.outputs['app-cli-artifact'] }} + +runs: + using: composite + steps: + - name: Compile application CLI package + id: compile + shell: bash + env: + EDGEZERO__ACTION__ROOT: ${{ github.action_path }}/../../.. + EDGEZERO__APP__CLI__PACKAGE: ${{ inputs['app-cli-package'] }} + EDGEZERO__APP__CLI__BIN: ${{ inputs['app-cli-bin'] }} + EDGEZERO__PROJECT__WORKING_DIRECTORY: ${{ inputs['working-directory'] }} + EDGEZERO__PROJECT__RUST_TOOLCHAIN: ${{ inputs['rust-toolchain'] }} + EDGEZERO__APP__CLI__ARTIFACT: ${{ inputs['app-cli-artifact'] }} + # This step runs app-controlled code, so it emits NO outputs through the + # runner's GITHUB_OUTPUT (blanked below). It collects them into this + # action-owned file; the separate "Publish CLI outputs" step re-emits them. + EDGEZERO__ACTION__OUTPUT_FILE: ${{ runner.temp }}/edgezero-build-app-cli-outputs.env + # This step runs APP-CONTROLLED code (cargo metadata/build, and the built + # CLI's `--help`), which must never see a provider token. + # + # Two layers, because neither alone is sufficient: + # 1. STATIC (below): blank the shipped adapters' aliases in the step's + # own `env:`. A `uses:`/`run:` step's environment can only be set + # statically, so these must be spelled out — and because they are set + # here, even this step's own `/proc//environ` never contained + # them. This is the only layer that also protects the upload step. + # 2. DYNAMIC (`provider-env-clear`): covers aliases this action cannot + # know about (a caller's OWN provider). The `run:` body `exec`s the + # script, replacing the generated wrapper shell, and the script then + # re-execs itself with those names stripped — so no ancestor process + # that app code could walk up to still holds them. + EDGEZERO__PROVIDER__ENV_CLEAR: ${{ inputs['provider-env-clear'] }} + # BASH_ENV (bash) and ENV (POSIX sh) name a file the shell SOURCES AT + # STARTUP — before the first line of this step's script, and so before the + # credential scrub re-exec runs. If a caller's job env pointed either at + # checkout-controlled code, that code would run with the provider token + # still present AND could forge the re-exec sentinel argument (`set -- + # `) to skip the scrub entirely. Blank both so no startup file + # is sourced. + BASH_ENV: "" + ENV: "" + # Isolate the app-controlled build from the runner's command storage. The + # runner keeps GITHUB_ENV/OUTPUT/PATH/STATE/step-summary as files in ONE + # directory, so holding ANY of their real paths lets a build script derive + # the siblings and append (e.g.) LD_PRELOAD to the real GITHUB_ENV, which + # the runner then applies to a LATER, secret-bearing step. The authoritative + # defense is the script's re-exec: it does `env -u` on all five channels, so + # the process that runs cargo (and every child) has none of their real + # paths in its environment or /proc. We do NOT blank them in this step's + # `env:` — the runner reinjects reserved GITHUB_* values after step-env + # evaluation, so that would be an ineffective no-op. Outputs are emitted by + # the separate publish step instead. NOTE: this closes the env/proc vectors, + # but a fully malicious build can still enumerate the runner's command + # directory at the same uid — so a provider secret must never share a step + # or job with the build (see the guide's security notes). + 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: "" + # `exec` REPLACES the generated wrapper shell with the script, so there is + # no wrapper-shell ancestor still holding the caller's custom provider + # secret (from its own execve environment) for app-controlled Cargo code to + # read via `/proc//environ`. The script then re-execs itself with the + # `provider-env-clear` names stripped. + run: exec "$GITHUB_ACTION_PATH/scripts/build-app-cli.sh" + + # The trusted output boundary: a step that runs NO app-controlled code reads + # the compile step's handoff file and re-emits the outputs to the real + # GITHUB_OUTPUT, re-validating each (first-occurrence-wins; tarball-path + # confined to the action-owned temp area). Its `id` is `build` so the action's + # `outputs:` mapping (steps.build.outputs.*) is unchanged for consumers. + - name: Publish CLI outputs + id: build + shell: bash + env: + EDGEZERO__BUILD__OUTPUTS_FILE: ${{ runner.temp }}/edgezero-build-app-cli-outputs.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/publish-outputs.sh" + + # A script-level scrub cannot reach a LATER step: each step gets its own + # process with the job environment re-applied. This non-provider step blanks + # the SHIPPED aliases itself rather than inheriting them. + # + # A `uses:` step's `env:` is static, so it CANNOT blank a caller's custom + # `provider-env-clear` alias (only the compile step, which runs a script, can + # do that dynamically). That is an accepted boundary: this step runs no + # application code — it hands the publish step's confined tarball path to a + # pinned first-party action. The compile step runs every app-controlled command + # with all GitHub file-command channels blanked, so a build script cannot + # inject an environment variable (e.g. `LD_PRELOAD`) or PATH entry into this + # later step through them. That closes the demonstrated vectors, but is not a + # hard boundary against a fully malicious same-uid build (see the compile + # step's note): a custom provider secret should be scoped to the one step that + # needs it, never to job-level `env:` shared with the build. + - name: Upload CLI artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + 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.build.outputs['app-cli-artifact'] }} + path: ${{ steps.build.outputs['tarball-path'] }} + if-no-files-found: error + retention-days: 1 diff --git a/.github/actions/build-app-cli/scripts/build-app-cli.sh b/.github/actions/build-app-cli/scripts/build-app-cli.sh new file mode 100755 index 00000000..5e6f6ac9 --- /dev/null +++ b/.github/actions/build-app-cli/scripts/build-app-cli.sh @@ -0,0 +1,278 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Compiles the CLI package the APPLICATION provides — a crate in the app's own +# workspace — into an action-owned CARGO_TARGET_DIR, then packages the binary +# plus a self-describing app-cli-meta.json into a tar so the executable bit +# survives actions/upload-artifact. Never builds the EdgeZero monorepo CLI. +# +# Reads (env): +# EDGEZERO__APP__CLI__PACKAGE required Cargo package name to build +# EDGEZERO__ACTION__ROOT required EdgeZero action repo (toolchain fallback) +# GITHUB_WORKSPACE required checkout root; the search ceiling +# EDGEZERO__APP__CLI__BIN optional binary name (default: the package name) +# EDGEZERO__PROJECT__WORKING_DIRECTORY optional app dir under the workspace (default: ".") +# EDGEZERO__PROJECT__RUST_TOOLCHAIN optional explicit toolchain or "auto" (default: "auto") +# EDGEZERO__APP__CLI__ARTIFACT optional uploaded artifact name (default: "edgezero-cli") +# RUNNER_TEMP optional action-owned scratch root (default: /tmp) +# Writes (outputs): +# app-cli-package the package that was built +# app-cli-bin the binary name inside the artifact +# app-cli-version version from cargo metadata +# app-cli-artifact uploaded artifact name for downstream download +# tarball-path absolute path of the staged tar + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=common.sh +source "$SCRIPT_DIR/common.sh" + +# Before ANY app-controlled command runs (cargo metadata, cargo build, the built +# CLI's `--help`), re-exec ourselves with the caller-named provider credentials +# stripped from the process environment. This must be a re-exec rather than +# `unset`: `/proc//environ` still exposes the environment we were `execve`d +# with, so a Cargo build script could otherwise read a token we had "unset". +# +# The "already cleared" marker is the sentinel ARGUMENT, not an env var: a caller +# controls the job environment but not this composite step's argv, so an env +# sentinel could be inherited to bypass the scrub. The `run:` body `exec`s this +# script, so after the re-exec there is no dirtier ancestor shell to walk up to. +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + if [[ "${1:-}" == "$PROVIDER_ENV_CLEARED_SENTINEL" ]]; then + shift + else + exec_with_cleared_provider_env "${EDGEZERO__PROVIDER__ENV_CLEAR:-[]}" \ + "$0" "$PROVIDER_ENV_CLEARED_SENTINEL" "$@" + fi +fi + +# --- Rust toolchain resolution helpers --------------------------------------- +parse_toolchain_from_channel_file() { + local file="$1" + # An extensionless `rust-toolchain` is EITHER a legacy single-line channel + # (e.g. `1.95.0`) OR a TOML document (`[toolchain]` + `channel = "..."`). + # rustup accepts both, so detect the TOML form and parse its channel rather + # than taking the literal `[toolchain]` line as the channel. + if grep -qE '^[[:space:]]*\[toolchain\]' "$file"; then + parse_toolchain_from_toml "$file" + return + fi + local value + value=$(sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' "$file" | awk 'NF { print; exit }') + [[ -n "$value" ]] || fail "malformed Rust toolchain file: $file" + printf '%s\n' "$value" +} + +parse_toolchain_from_toml() { + local file="$1" + local value + value=$(sed -nE 's/^[[:space:]]*channel[[:space:]]*=[[:space:]]*["'\''`]([^"'\''`]+)["'\''`][[:space:]]*(#.*)?$/\1/p' "$file" | head -n 1) + [[ -n "$value" ]] || fail "malformed Rust toolchain TOML file: $file" + printf '%s\n' "$value" +} + +# The upward search for a toolchain file must not leave the APPLICATION's +# repository. +# +# The adoption guide's recommended layout checks the app out into a subdirectory +# of a separate deployer repo. Walking to github.workspace would then cross the +# app's Git boundary and let the *deployer's* `.tool-versions` silently decide +# which Rust compiles the app. The app's Git root is the honest boundary; fall +# back to github.workspace when the app dir is not a Git checkout at all. +# +# Every path here is canonicalized before comparison. The walk below tests the +# boundary with a string equality, so a symlinked TMPDIR or checkout would +# otherwise never match it — and the search would climb straight past the Git +# root it was meant to stop at. +resolve_search_boundary() { + local app_dir="$1" workspace_root="$2" + workspace_root=$(canonical_path "$workspace_root") + + local git_root + git_root=$(git -C "$app_dir" rev-parse --show-toplevel 2>/dev/null) || { + printf '%s\n' "$workspace_root" + return + } + git_root=$(canonical_path "$git_root") + + # Never search above github.workspace, even if the app's Git root is higher + # (a checkout mounted from outside the workspace). + if is_under "$workspace_root" "$git_root"; then + printf '%s\n' "$git_root" + else + printf '%s\n' "$workspace_root" + fi +} + +# Resolve the application toolchain: explicit input > rustup files (walking up to +# the app's Git root) > .tool-versions > the EdgeZero action repo fallback. +resolve_rust_toolchain() { + local input="$1" app_dir="$2" workspace_root="$3" action_root="$4" + if [[ "$input" != "auto" ]]; then + [[ -n "$input" ]] || fail "input 'rust-toolchain' cannot be empty" + printf '%s\n' "$input" + return + fi + + # Stop at the application repository boundary, not github.workspace. + local boundary + boundary=$(resolve_search_boundary "$app_dir" "$workspace_root") + + local directory value + directory=$(canonical_path "$app_dir") + while true; do + if [[ -f "$directory/rust-toolchain.toml" ]]; then + parse_toolchain_from_toml "$directory/rust-toolchain.toml" + return + fi + if [[ -f "$directory/rust-toolchain" ]]; then + parse_toolchain_from_channel_file "$directory/rust-toolchain" + return + fi + if [[ -f "$directory/.tool-versions" ]] && value=$(read_tool_version "$directory/.tool-versions" rust) && [[ -n "$value" ]]; then + printf '%s\n' "$value" + return + fi + if [[ "$directory" == "$boundary" ]]; then + break + fi + local parent + parent=$(dirname "$directory") + if [[ "$parent" == "$directory" ]]; then + break + fi + directory="$parent" + done + + if [[ -f "$action_root/.tool-versions" ]] && value=$(read_tool_version "$action_root/.tool-versions" rust) && [[ -n "$value" ]]; then + printf '%s\n' "$value" + return + fi + fail "could not resolve Rust toolchain; checked rust-toolchain.toml, rust-toolchain, .tool-versions; set input 'rust-toolchain' explicitly" +} + +require_linux_x86_64() { + case "$(uname -s)-$(uname -m)" in + Linux-x86_64 | Linux-amd64) ;; + *) fail "build-app-cli supports only Linux x86-64 runners" ;; + esac +} + +main() { + local workspace="${GITHUB_WORKSPACE:?GITHUB_WORKSPACE is required}" + local action_root="${EDGEZERO__ACTION__ROOT:?EDGEZERO__ACTION__ROOT is required}" + local cli_package="${EDGEZERO__APP__CLI__PACKAGE:?input 'app-cli-package' is required}" + local cli_bin="${EDGEZERO__APP__CLI__BIN:-}" + local working_directory="${EDGEZERO__PROJECT__WORKING_DIRECTORY:-.}" + local rust_toolchain_input="${EDGEZERO__PROJECT__RUST_TOOLCHAIN:-auto}" + local artifact_name="${EDGEZERO__APP__CLI__ARTIFACT:-edgezero-cli}" + + # These directories are `rm -rf`d below, so they must NEVER come from the + # inherited environment — a colliding job-level variable could otherwise point + # them at the checkout. Always derive them from the action-owned temp root. + local runner_temp="${RUNNER_TEMP:-/tmp}" + local stage_root="$runner_temp/edgezero-cli-artifact" + local build_target_dir="$runner_temp/edgezero-cli-build" + + require_linux_x86_64 + require_cmd cargo + require_cmd rustup + require_cmd jq + require_cmd tar + validate_artifact_name "$artifact_name" + + # Resolve the application directory beneath github.workspace. + local workspace_real app_dir + workspace_real=$(canonical_path "$workspace") + [[ -d "$workspace/$working_directory" ]] || fail "working-directory '$working_directory' does not exist or is not a directory" + app_dir=$(canonical_path "$workspace/$working_directory") + is_under "$workspace_real" "$app_dir" || fail "input 'working-directory' must resolve inside github.workspace" + + # Install the host toolchain only. The CLI is a native tool; the WASM target + # the *application* needs is installed later by the deploy engine. + local rust_toolchain + rust_toolchain=$(resolve_rust_toolchain "$rust_toolchain_input" "$app_dir" "$workspace_real" "$action_root") + rustup toolchain install "$rust_toolchain" --profile minimal + + # The application repository boundary — the same one the toolchain search stops + # at. Falls back to github.workspace when the app dir is not a Git checkout. + local app_boundary + app_boundary=$(resolve_search_boundary "$app_dir" "$workspace_real") + + # Require a committed lockfile and validate the package + binary target. + cd "$app_dir" + local metadata package_json + metadata=$(run_untrusted cargo +"$rust_toolchain" metadata --locked --no-deps --format-version 1) || + fail "cargo metadata --locked failed; ensure Cargo.lock is present and up to date" + package_json=$(jq -c --arg p "$cli_package" '.packages[] | select(.name == $p)' <<<"$metadata") + [[ -n "$package_json" ]] || fail "app-cli-package '$cli_package' was not found in the application workspace" + + # The Cargo WORKSPACE must live inside the application's repository. + # + # It is the workspace root that owns the lockfile and the resolved dependency + # graph the artifact is built from. `cargo metadata` climbs to whatever + # workspace encloses the app dir, so an enclosing deployer workspace would + # otherwise control the deps of a CLI built from the app's source — the same + # boundary violation as the package check below, but for the whole build. + local workspace_root workspace_real + workspace_root=$(jq -r '.workspace_root' <<<"$metadata") + workspace_real=$(canonical_path "$workspace_root") + is_under "$app_boundary" "$workspace_real" || + fail "the Cargo workspace root ($workspace_real) is outside the application at $app_boundary; an enclosing workspace would control the lockfile and dependencies the CLI is built from" + + # The package must live inside the APPLICATION's repository. + # + # `cargo metadata` resolves through whatever workspace encloses the app dir, + # which in the separate-repository layout can be the DEPLOYER's workspace. The + # app is supposed to own the CLI that deploys it, so a package resolved from + # outside the app's Git root would compile code that `source-revision` never + # describes. Bound it at the same boundary the toolchain search uses. + local pkg_manifest pkg_real + pkg_manifest=$(jq -r '.manifest_path' <<<"$package_json") + pkg_real=$(canonical_path "$pkg_manifest") + is_under "$app_boundary" "$pkg_real" || + fail "app-cli-package '$cli_package' resolves to $pkg_real, outside the application at $app_boundary; the app must own the CLI that deploys it" + + [[ -n "$cli_bin" ]] || cli_bin="$cli_package" + local has_bin cli_version + has_bin=$(jq -r --arg b "$cli_bin" '[.targets[] | select(.kind | index("bin")) | .name] | index($b) != null' <<<"$package_json") + [[ "$has_bin" == "true" ]] || fail "app-cli-package '$cli_package' declares no binary target named '$cli_bin'" + cli_version=$(jq -r '.version' <<<"$package_json") + + # Build into an action-owned target dir so the checkout stays clean. + reset_owned_dir "$build_target_dir" "$runner_temp" + run_untrusted CARGO_TARGET_DIR="$build_target_dir" cargo +"$rust_toolchain" build \ + --locked --release -p "$cli_package" --bin "$cli_bin" + + local bin_path="$build_target_dir/release/$cli_bin" + [[ -x "$bin_path" ]] || fail "build did not produce an executable at $bin_path" + run_untrusted "$bin_path" --help >/dev/null 2>&1 || fail "built CLI '$cli_bin' did not run '$cli_bin --help'" + + # Package the binary and self-describing metadata into a tar. + reset_owned_dir "$stage_root" "$runner_temp" + cp "$bin_path" "$stage_root/$cli_bin" + chmod +x "$stage_root/$cli_bin" + jq -n --arg bin "$cli_bin" --arg version "$cli_version" --arg package "$cli_package" \ + '{"app-cli-bin": $bin, "app-cli-version": $version, "app-cli-package": $package}' \ + >"$stage_root/app-cli-meta.json" + + # Fixed tarball name — never derive a path component from caller input. + local tarball="$stage_root/../edgezero-cli.tar" + tar -C "$stage_root" -cf "$tarball" "$cli_bin" app-cli-meta.json + tarball=$(canonical_path "$tarball") + + notice "built app CLI '$cli_bin' v$cli_version from package '$cli_package'" + # Start the outputs handoff file fresh so a retried compile step never leaves a + # previous run's lines for the publish step's first-occurrence-wins parse. + [[ -z "${EDGEZERO__ACTION__OUTPUT_FILE:-}" ]] || : >"${EDGEZERO__ACTION__OUTPUT_FILE}" + append_output app-cli-version "$cli_version" + append_output app-cli-package "$cli_package" + append_output app-cli-bin "$cli_bin" + append_output app-cli-artifact "$artifact_name" + append_output tarball-path "$tarball" +} + +# Sourcing this file exposes its functions without running a build, so the +# contract tests can exercise toolchain resolution directly. +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + main "$@" +fi diff --git a/.github/actions/build-app-cli/scripts/common.sh b/.github/actions/build-app-cli/scripts/common.sh new file mode 100755 index 00000000..25ac1595 --- /dev/null +++ b/.github/actions/build-app-cli/scripts/common.sh @@ -0,0 +1,252 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Sourced helper library for build-app-cli. Defines the shared shell helpers +# (annotations, output writers, artifact-name and owned-dir guards). It is never +# executed directly and reads no environment of its own; callers source it right +# after their own strict-mode preamble. + +escape_annotation() { + local value="$*" + value=${value//%/%25} + value=${value//$'\r'/%0D} + value=${value//$'\n'/%0A} + printf '%s' "$value" +} + +fail() { + local message + message=$(escape_annotation "$*") + echo "::error::$message" >&2 + exit 1 +} + +notice() { + local message + message=$(escape_annotation "$*") + echo "::notice::$message" >&2 +} + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || fail "required command '$1' was not found" +} + +append_output() { + local name="$1" + local value="$2" + [[ "$name" =~ ^[A-Za-z_][A-Za-z0-9_-]*$ ]] || fail "invalid output name '$name'" + if [[ "$value" == *$'\n'* || "$value" == *$'\r'* ]]; then + fail "output '$name' contains a newline or carriage return" + fi + # The compile step runs app-controlled code, so its `env:` blanks every GitHub + # file-command channel — GITHUB_OUTPUT included — and instead names an + # action-owned file via EDGEZERO__ACTION__OUTPUT_FILE. A separate publish step + # (which runs no app code) re-emits the collected outputs to the real + # GITHUB_OUTPUT. When neither is set, fall back to stdout. + local dest="${EDGEZERO__ACTION__OUTPUT_FILE:-${GITHUB_OUTPUT:-}}" + if [[ -n "$dest" ]]; then + printf '%s=%s\n' "$name" "$value" >>"$dest" + else + printf '%s=%s\n' "$name" "$value" + fi +} + +append_env() { + local name="$1" + local value="$2" + [[ "$name" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || fail "invalid environment name '$name'" + if [[ "$value" == *$'\n'* || "$value" == *$'\r'* ]]; then + fail "environment value '$name' contains a newline or carriage return" + fi + if [[ -n "${GITHUB_ENV:-}" ]]; then + printf '%s=%s\n' "$name" "$value" >>"$GITHUB_ENV" + else + export "$name=$value" + fi +} + +canonical_path() { + require_cmd realpath + local path + path=$(realpath "$1" 2>/dev/null) || fail "could not resolve path '$1'" + printf '%s\n' "$path" +} + +relative_to() { + local root="${1%/}" + local path="${2%/}" + if [[ "$path" == "$root" ]]; then + printf '.\n' + elif [[ "$path" == "$root"/* ]]; then + printf '%s\n' "${path#"$root"/}" + else + printf '%s\n' "$path" + fi +} + +is_under() { + local root="${1%/}" + local path="${2%/}" + [[ "$path" == "$root" || "$path" == "$root"/* ]] +} + +json_get() { + require_cmd jq + jq -er ".$2" "$1" +} + +sha256_file() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{ print $1 }' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" | awk '{ print $1 }' + else + fail "required command 'sha256sum' or 'shasum' was not found" + fi +} + +read_tool_version() { + local file="$1" + local tool="$2" + awk -v tool="$tool" '$1 == tool { print $2; found=1; exit } END { if (!found) exit 1 }' "$file" +} + +sanitize_ref() { + printf '%s' "$1" | tr -c 'A-Za-z0-9_.=-' '-' +} + +# Argument (NOT env var) that marks the already-re-exec'd invocation. It must be +# an argument because a caller controls the job environment but NOT the argv of a +# composite action's `run:` command; an env-var sentinel could be inherited to +# skip the scrub entirely. Not `readonly`: this file may be sourced more than +# once, and a second `readonly` of the same name errors under `set -e`. +# shellcheck disable=SC2034 # used by build-app-cli.sh, which sources this file +PROVIDER_ENV_CLEARED_SENTINEL="--edgezero-provider-env-cleared" + +# Validate the `provider-env-clear` input and print the credential alias names it +# contains, one per line. +# +# Fails CLOSED on anything that is not a JSON array of non-empty environment- +# variable identifiers. TWO reasons this must be strict: +# 1. A permissive parse silently clears NOTHING: with `jq '.[]?'`, the values +# `"FASTLY_API_TOKEN"`, `{}`, `null`, `123` all exit 0 and yield no names, so +# a typo would leave every inherited credential in scope while "succeeding". +# 2. Validation runs ENTIRELY IN JQ, on the DECODED strings, so a control +# character cannot survive `jq -r` transport through bash's line/NUL- +# sensitive streams. A member with an escaped newline would otherwise reach +# `jq -r`, split into two "names", and leave the real variable untouched; an +# escaped NUL would truncate under `$(...)`. The `\A...\z` anchors match the +# WHOLE decoded string (not up to a trailing newline), so any control +# character fails the test. +provider_env_clear_names() { + local json="${1-}" + require_cmd jq + printf '%s' "$json" | jq -e ' + type == "array" + and (all(.[]; + type == "string" + and . != "" + and test("\\A[A-Za-z_][A-Za-z0-9_]*\\z"))) + ' >/dev/null 2>&1 || + fail "input 'provider-env-clear' must be a JSON array of non-empty environment-variable names (identifier characters only, no control characters)" + # Every element is now a validated identifier (no control chars), so line-based + # bash transport is safe. + printf '%s' "$json" | jq -r '.[]' +} + +# Re-exec this script with the named provider credentials REMOVED from the +# process environment, then continue. +# +# `unset` alone is not sufficient. On Linux, `/proc//environ` exposes the +# environment block a process was `execve`d with, and later `unset`/`setenv` do +# not rewrite it, so an app-controlled Cargo build script (or the built CLI's +# `--help`) could read the token straight out of the process's `/proc` entry even +# after we unset it. Replacing the process image via `env -u ... exec` gives THIS +# process, its `/proc` entry, and every descendant a genuinely clean environment. +# (The `run:` body must `exec` this script so no dirtier ancestor shell survives +# for app code to walk up to; see the action.) +# +# Provider-NEUTRAL: the names come from the caller's `provider-env-clear` input. +# The re-exec re-invokes with the sentinel ARGUMENT so it runs exactly once. +exec_with_cleared_provider_env() { + local json="$1" + shift + # COMMAND substitution, not process substitution: a validation failure inside + # `provider_env_clear_names` must abort the build. Under `< <(...)` its `fail` + # would only kill the subshell, leaving an EMPTY name list here, and we would + # then exec with nothing stripped, i.e. fail OPEN with the credentials intact. + local names_raw + names_raw=$(provider_env_clear_names "$json") || exit 1 + + local -a cmd=(env) + # Strip EVERY GitHub Actions file-command channel from the process IMAGE, not + # just from the immediate child. Overriding them for the child alone is not + # enough: this (re-exec'd) process is the parent of every app-controlled command + # (cargo, the built CLI), and any real channel path it holds stays readable + # through `/proc//environ`. A build script could recover one path that way + # and — because the runner keeps all of them in one directory — derive the + # sibling `$GITHUB_ENV` path, then append `LD_PRELOAD=…` (or a `$GITHUB_PATH` + # entry) to the real file, which the runner applies to a LATER, secret-bearing + # step (the artifact upload). GITHUB_OUTPUT is stripped too — the compile step + # collects its outputs into EDGEZERO__ACTION__OUTPUT_FILE, and a separate publish + # step (no app code) emits them. (This closes the /proc-derivation vector; see + # docs — a fully malicious build can still enumerate the runner's command + # directory at same-uid, so provider secrets must not share a step/job with it.) + local ghvar + for ghvar in GITHUB_ENV GITHUB_OUTPUT GITHUB_PATH GITHUB_STATE GITHUB_STEP_SUMMARY; do + cmd+=(-u "$ghvar") + done + local name + while IFS= read -r name; do + [[ -n "$name" ]] || continue + cmd+=(-u "$name") + done <<<"$names_raw" + cmd+=("$@") + exec "${cmd[@]}" +} + +# Run an APP-CONTROLLED command (a Cargo build script, or the built CLI) with the +# GitHub Actions per-step file-command channels pointed away from the real files. +# +# The primary defenses are elsewhere: the compile step's `env:` blanks every +# GitHub file-command channel, and `exec_with_cleared_provider_env` strips them +# from the whole process image so their real paths are not recoverable from any +# ancestor's `/proc//environ`. This is a belt-and-suspenders third layer — +# it also points the child's channels at `/dev/null`, so even if a channel were +# ever reintroduced upstream, the untrusted command still writes nowhere real. +run_untrusted() { + env \ + GITHUB_ENV=/dev/null \ + GITHUB_PATH=/dev/null \ + GITHUB_OUTPUT=/dev/null \ + GITHUB_STATE=/dev/null \ + "$@" +} + +# Reject an artifact name that could escape the action-owned staging directory +# when used as a path component: no separators, no traversal, no leading dot, +# only a conservative character set. +validate_artifact_name() { + local name="$1" + [[ -n "$name" ]] || fail "input 'artifact-name' must not be empty" + case "$name" in + */* | *\\* | *..*) fail "input 'artifact-name' must not contain path separators or '..': '$name'" ;; + .*) fail "input 'artifact-name' must not start with '.': '$name'" ;; + esac + [[ "$name" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || + fail "input 'artifact-name' may contain only letters, digits, '.', '_', '-': '$name'" +} + +# Recreate an action-owned scratch directory. Refuses to remove anything not +# beneath the temp root, so a stray/inherited value can never delete the checkout. +reset_owned_dir() { + local dir="$1" temp_root="$2" + [[ -n "$dir" && -n "$temp_root" ]] || fail "internal: reset_owned_dir needs a dir and a temp root" + case "$dir" in + *..*) fail "refusing to remove '$dir': path traversal" ;; + esac + [[ "$dir" == "$temp_root"/* ]] || + fail "refusing to remove '$dir': not beneath the action-owned temp root '$temp_root'" + rm -rf "$dir" + mkdir -p "$dir" +} diff --git a/.github/actions/build-app-cli/scripts/publish-outputs.sh b/.github/actions/build-app-cli/scripts/publish-outputs.sh new file mode 100755 index 00000000..b2da0e8c --- /dev/null +++ b/.github/actions/build-app-cli/scripts/publish-outputs.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Emit the app-CLI build outputs to the real GITHUB_OUTPUT. This is the TRUSTED +# boundary of the two-step build: the compile step runs app-controlled code with +# every GitHub file-command channel blanked and collects its outputs into an +# action-owned file; this step (which runs NO app code) reads that file and +# re-emits each known output through the validating `append_output`. +# +# Isolating the untrusted build from the runner's command storage closes the +# /proc-derivation and duplicate-append vectors, but it is not a hard boundary +# against a fully malicious build (which can enumerate the runner's command +# directory at the same uid). This step therefore also RE-VALIDATES the handoff: +# only the known outputs are emitted, first-occurrence-wins defeats a tampered +# trailing duplicate, and `tarball-path` — which drives the artifact upload — is +# confined to the action-owned temp area and required to exist. See the guide's +# security notes: a provider secret must never share a step/job with the build. +# +# Reads (env): +# EDGEZERO__BUILD__OUTPUTS_FILE required file the compile step wrote (KEY=VALUE lines) +# RUNNER_TEMP optional action-owned temp root (tarball confinement) +# Writes (outputs): app-cli-version, app-cli-package, app-cli-bin, app-cli-artifact, tarball-path + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=common.sh +source "$SCRIPT_DIR/common.sh" + +# First occurrence only: the compile step writes each output exactly once, so a +# tampered LATER duplicate (which would win last-write-wins in GITHUB_OUTPUT) is +# ignored here. Fails closed if the output is absent or empty. +read_build_output() { + local key="$1" file="$2" val + val=$(grep -m1 -E "^${key}=" "$file" | sed -E "s/^${key}=//") || true + [[ -n "$val" ]] || fail "the compile step did not emit a non-empty '$key'" + printf '%s' "$val" +} + +main() { + local src="${EDGEZERO__BUILD__OUTPUTS_FILE:?EDGEZERO__BUILD__OUTPUTS_FILE is required}" + [[ -f "$src" ]] || fail "the compile step produced no outputs file at '$src'" + + local version package bin artifact tarball + version=$(read_build_output app-cli-version "$src") + package=$(read_build_output app-cli-package "$src") + bin=$(read_build_output app-cli-bin "$src") + artifact=$(read_build_output app-cli-artifact "$src") + tarball=$(read_build_output tarball-path "$src") + + # tarball-path drives the artifact upload, so confine it to the action-owned + # temp area and require it to exist: a tampered handoff cannot then redirect the + # upload to an arbitrary file. + local runner_temp runner_real tarball_real + runner_temp="${RUNNER_TEMP:-/tmp}" + runner_real=$(canonical_path "$runner_temp") + tarball_real=$(canonical_path "$tarball") + is_under "$runner_real" "$tarball_real" || + fail "tarball-path is not beneath the action-owned temp root; refusing to publish it" + [[ -f "$tarball_real" ]] || fail "tarball-path does not point at an existing file" + + append_output app-cli-version "$version" + append_output app-cli-package "$package" + append_output app-cli-bin "$bin" + append_output app-cli-artifact "$artifact" + append_output tarball-path "$tarball" +} + +main "$@" diff --git a/.github/actions/config-push-fastly/action.yml b/.github/actions/config-push-fastly/action.yml new file mode 100644 index 00000000..ef5e879b --- /dev/null +++ b/.github/actions/config-push-fastly/action.yml @@ -0,0 +1,218 @@ +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. + +inputs: + app-cli-artifact: + description: Name of the build-app-cli artifact to download and run. + 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. + required: true + working-directory: + description: Application directory relative to github.workspace (holds the manifest + typed config). + 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." + 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." + 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'." + required: false + default: "false" + store: + description: "Optional logical config-store id (default: the manifest's resolved id)." + 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." + 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)." + required: false + default: production + +outputs: + pushed-key: + description: The key that was written (the base key, or the derived _staging variant). + 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). + value: ${{ steps.push.outputs.store }} + mutation-attempted: + description: "'true' once the config-push CLI is invoked (emitted before it runs). If the action fails, read this via `if: always()` to know the config store may have changed and reconcile — do not assume it is unchanged." + value: ${{ steps.push.outputs['mutation-attempted'] }} + provider-cli-version: + description: The pinned Fastly CLI version this action installed and ran. + value: ${{ steps.install-fastly.outputs['provider-cli-version'] }} + +runs: + using: composite + steps: + - name: Validate inputs + shell: bash + env: + EDGEZERO__APP__CLI__ARTIFACT_PRESENT: ${{ inputs['app-cli-artifact'] != '' && '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' }} + 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: $GITHUB_ACTION_PATH/scripts/validate.sh + + - name: Download CLI artifact + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: ${{ inputs['app-cli-artifact'] }} + path: ${{ runner.temp }}/edgezero-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 + shell: bash + env: + EDGEZERO__APP__CLI__ARTIFACT_DIR: ${{ runner.temp }}/edgezero-cli-download + EDGEZERO__ACTION__TOOL_ROOT: ${{ runner.temp }}/edgezero-action-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_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: $GITHUB_ACTION_PATH/../deploy-core/scripts/download-app-cli.sh + + - name: Install Fastly CLI + id: install-fastly + shell: bash + env: + EDGEZERO__ACTION__ROOT: ${{ github.action_path }}/../../.. + EDGEZERO__FASTLY__VERSIONS_JSON: ${{ github.action_path }}/../deploy-fastly/versions.json + 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: $GITHUB_ACTION_PATH/../deploy-fastly/scripts/install-fastly.sh + + - name: Config push + id: push + shell: bash + env: + 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__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: "" + 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: $GITHUB_ACTION_PATH/scripts/config-push.sh + + - name: Cleanup + if: ${{ always() }} + shell: bash + env: + EDGEZERO__ACTION__TOOL_ROOT: ${{ runner.temp }}/edgezero-action-tools + 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: $GITHUB_ACTION_PATH/../deploy-core/scripts/cleanup.sh diff --git a/.github/actions/config-push-fastly/scripts/config-push.sh b/.github/actions/config-push-fastly/scripts/config-push.sh new file mode 100755 index 00000000..e36784c4 --- /dev/null +++ b/.github/actions/config-push-fastly/scripts/config-push.sh @@ -0,0 +1,177 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Pushes the application's typed config to a Fastly config store, and emits the +# key that was written. +# +# Like healthcheck.sh and rollback.sh (its sibling lifecycle actions), this calls +# the app CLI directly with FASTLY_API_TOKEN in the step env — the adapter's own +# convention, which `fastly config-store-entry update` reads to authenticate. The +# wrapper blanks every other FASTLY_* alias, so an inherited FASTLY_ENDPOINT or +# FASTLY_TOKEN can never redirect or re-auth the push. +# +# 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). +# +# 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. +# +# Reads (env): +# EDGEZERO__APP__CLI__PATH optional absolute path to the app CLI (preferred; avoids PATH shadowing) +# EDGEZERO__APP__CLI__BIN optional app CLI name, used when __PATH is unset +# FASTLY_API_TOKEN required provider token (Fastly's own convention) +# EDGEZERO__PROJECT__WORKING_DIRECTORY required app dir, relative to github.workspace +# GITHUB_WORKSPACE required confinement root +# EDGEZERO__DEPLOY__TO optional production | staging (default: production) +# EDGEZERO__CONFIG_PUSH__STORE optional logical config-store id +# EDGEZERO__CONFIG_PUSH__KEY optional explicit base key +# EDGEZERO__CONFIG_PUSH__MANIFEST optional edgezero.toml path (relative to the app dir) +# EDGEZERO__CONFIG_PUSH__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 +# Writes (outputs): +# mutation-attempted true, emitted before the CLI runs (reconcile signal) +# pushed-key the key written (base, or its _staging variant) +# store the logical store id the CLI resolved + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=../../deploy-core/scripts/common.sh +source "$SCRIPT_DIR/../../deploy-core/scripts/common.sh" + +# Resolve a caller-supplied file path relative to the app dir and prove it stays +# inside it. Echoes the path relative to the app dir (what the CLI is given). +confine_to_app() { + local input="$1" app_dir="$2" label="$3" + case "$input" in + /*) fail "input '$label' must be relative to working-directory, not absolute: '$input'" ;; + esac + [[ -f "$app_dir/$input" ]] || fail "input '$label' does not exist or is not a regular file: '$input'" + local real + real=$(canonical_path "$app_dir/$input") + is_under "$app_dir" "$real" || + fail "input '$label' must resolve inside working-directory: '$input'" + relative_to "$app_dir" "$real" +} + +main() { + local cli_bin + cli_bin=$(resolve_app_cli) + local working_directory="${EDGEZERO__PROJECT__WORKING_DIRECTORY:?EDGEZERO__PROJECT__WORKING_DIRECTORY is required}" + 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 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="" + + require_input fastly-api-token "${FASTLY_API_TOKEN:-}" + require_cmd "$cli_bin" + # 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 '$deploy_to')" ;; + esac + # A typo in no-env must never silently apply the env overlay the caller meant + # to skip (which could push different values than intended). + case "$no_env" in + true | false) ;; + *) fail "input 'no-env' must be 'true' or 'false' (got '$no_env')" ;; + 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" + fi + + # Confine the app directory to github.workspace, then every path to the app. + local workspace_real app_dir + workspace_real=$(canonical_path "$workspace") + [[ -d "$workspace/$working_directory" ]] || + fail "working-directory '$working_directory' does not exist or is not a directory" + 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 + # Open the private log and install the sensitive-temp cleanup FIRST, so there is + # never a window where a temp file exists without a trap covering it (a cancel in + # such a window would leave raw config behind). + new_private_log + + # Inline config is caller-supplied CONTENT (from a GitHub variable), not a + # checkout path, so it needs no path confinement — this step chooses the file. + # It is written to an action-owned temp file (removed on exit) and passed by + # ABSOLUTE path, which the CLI reads as-is. A checked-out path is confined to + # the app directory as before. + if [[ -n "$app_config_inline" ]]; then + # A deterministic per-process path so the combined trap can name the file + # BEFORE it is created — closing the create-then-trap race. config-push runs + # no app-controlled code, so a predictable name is not a tampering risk. + inline_file="${RUNNER_TEMP:-/tmp}/edgezero-inline-config.$$" + # shellcheck disable=SC2064 # expand both paths now, not at trap time + trap "cleanup_sensitive_temps '$LIFECYCLE_LOG' '$inline_file'" EXIT + ( + umask 077 + printf '%s' "$app_config_inline" >"$inline_file" + ) + app_config="$inline_file" + elif [[ -n "$app_config" ]]; then + app_config=$(confine_to_app "$app_config" "$app_dir" app-config) + 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 + 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) + + # Record that a provider mutation is being ATTEMPTED before the CLI runs, so the + # signal survives a failed step (readable via `if: always()`). If the push + # succeeds but its canonical `pushed-key=`/`pushed-store=` lines are missing + # below, the caller can still reconcile the config store rather than assume the + # store is unchanged. + append_output mutation-attempted true + local rc=0 + (cd "$app_dir" && "${argv[@]}") 2>&1 | tee "$LIFECYCLE_LOG" || rc=$? + if [[ "$rc" -ne 0 ]]; then + fail_with "$rc" "config push failed (CLI exit $rc)" + fi + + # Anchored parses of the canonical lines the CLI emits. The store is whatever + # the CLI RESOLVED from the manifest — not the optional raw input, which is + # empty on the default path. + local pushed resolved_store + pushed=$(grep -oE '^pushed-key=[A-Za-z0-9._-]+$' "$LIFECYCLE_LOG" | tail -n 1 | cut -d= -f2 || true) + resolved_store=$(grep -oE '^pushed-store=[A-Za-z0-9._-]+$' "$LIFECYCLE_LOG" | tail -n 1 | cut -d= -f2 || true) + [[ -n "$pushed" ]] || + fail "config push reported success but emitted no canonical 'pushed-key=' line" + [[ -n "$resolved_store" ]] || + fail "config push reported success but emitted no canonical 'pushed-store=' line" + + append_output pushed-key "$pushed" + append_output store "$resolved_store" +} + +main "$@" diff --git a/.github/actions/config-push-fastly/scripts/validate.sh b/.github/actions/config-push-fastly/scripts/validate.sh new file mode 100755 index 00000000..3d61ca96 --- /dev/null +++ b/.github/actions/config-push-fastly/scripts/validate.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Validates the config-push-fastly wrapper's inputs. In a script (not inline +# 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__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 + +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 fastly-api-token "${EDGEZERO__FASTLY__API_TOKEN_PRESENT:-}" + local deploy_to="${EDGEZERO__DEPLOY__TO:-production}" + # A typo in deploy-to must never silently push to production. + case "$deploy_to" in + production | staging) ;; + *) fail "input 'deploy-to' must be 'production' or 'staging' (got '${EDGEZERO__DEPLOY__TO:-}')" ;; + esac + # A staging push derives its key from the store's logical id (`_staging`), + # which is what the staging selector store points a staged version at. An + # explicit `key` would be written to a key nothing reads, so the CLI refuses + # the combination — reject it here with a clearer, earlier message. + if [[ "$deploy_to" == "staging" && "${EDGEZERO__CONFIG_PUSH__KEY_PRESENT:-}" == "true" ]]; then + fail "input 'key' cannot be combined with deploy-to: staging; the staging key is derived from the store's logical id (_staging). Push to production with 'key', or push staging without it." + fi +} + +main "$@" diff --git a/.github/actions/deploy-core/scripts/cleanup.sh b/.github/actions/deploy-core/scripts/cleanup.sh new file mode 100755 index 00000000..fd16d3ab --- /dev/null +++ b/.github/actions/deploy-core/scripts/cleanup.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Removes action-owned temporary state and tool installs. Runs with +# `if: always()`, so it must tolerate partially-created dirs. +# +# This script does `rm -rf`, so it removes ONLY directories it can prove the +# action owns: real paths strictly beneath RUNNER_TEMP. An inherited or +# job-level value pointing at the checkout — or anywhere else on a self-hosted +# runner — is refused, not deleted. (An earlier revision removed +# `$EDGEZERO_FASTLY_HOME`, a variable nothing in the action ever set: its value +# could only ever come from the caller's environment.) +# +# Reads (env): +# RUNNER_TEMP required the only root anything may be removed beneath +# EDGEZERO__ACTION__STATE_DIR optional action-owned state dir to remove +# EDGEZERO__ACTION__TOOL_ROOT optional action-owned tool install to remove +# Writes: +# nothing — removes action-owned dirs; emits no outputs. + +remove_owned_dir() { + local dir="$1" temp_root="$2" + + [[ -n "$dir" ]] || return 0 + [[ -d "$dir" ]] || return 0 + + # Resolve symlinks before comparing: a symlinked state dir must not be able to + # smuggle the removal outside the temp root. + local real_dir real_root + real_dir=$(cd -- "$dir" 2>/dev/null && pwd -P) || return 0 + real_root=$(cd -- "$temp_root" 2>/dev/null && pwd -P) || { + echo "[edgezero-action] cleanup: RUNNER_TEMP '$temp_root' does not exist; nothing removed" >&2 + return 0 + } + + if [[ "$real_dir" != "$real_root"/* ]]; then + echo "[edgezero-action] cleanup: refusing to remove '$dir': not beneath the action-owned temp root '$real_root'" >&2 + return 0 + fi + + rm -rf "$real_dir" +} + +main() { + local temp_root="${RUNNER_TEMP:-}" + if [[ -z "$temp_root" ]]; then + echo "[edgezero-action] cleanup: RUNNER_TEMP is unset; nothing removed" >&2 + return 0 + fi + + remove_owned_dir "${EDGEZERO__ACTION__STATE_DIR:-}" "$temp_root" + remove_owned_dir "${EDGEZERO__ACTION__TOOL_ROOT:-}" "$temp_root" +} + +main "$@" diff --git a/.github/actions/deploy-core/scripts/common.sh b/.github/actions/deploy-core/scripts/common.sh new file mode 100755 index 00000000..984e83ba --- /dev/null +++ b/.github/actions/deploy-core/scripts/common.sh @@ -0,0 +1,263 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Sourced helper library for the deploy engine and adapter wrappers. Defines the +# shared shell helpers (annotations, output/env writers, input guards, lifecycle +# log and version-parse helpers, tar and cli-bin safety checks). It is never +# executed directly and reads no environment of its own; callers source it right +# after their own strict-mode preamble. + +escape_annotation() { + local value="$*" + value=${value//%/%25} + value=${value//$'\r'/%0D} + value=${value//$'\n'/%0A} + printf '%s' "$value" +} + +fail() { + local message + message=$(escape_annotation "$*") + echo "::error::$message" >&2 + exit 1 +} + +notice() { + local message + message=$(escape_annotation "$*") + echo "::notice::$message" >&2 +} + +# Fail with the SAME exit status the underlying tool returned. +# +# `fail` always exits 1, which erases a provider CLI's exit code. Callers that +# wrap a CLI must preserve it: an operator's `if: steps.x.outcome` logic and any +# retry tooling keys off the real status, and a rollback that exited 3 is not the +# same event as one that exited 1. +fail_with() { + local code="$1" + shift + local message + message=$(escape_annotation "$*") + echo "::error::$message" >&2 + # Guard against a 0/blank status turning a failure into a silent success. + [[ "$code" =~ ^[0-9]+$ ]] && ((code != 0)) || code=1 + exit "$code" +} + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || fail "required command '$1' was not found" +} + +append_output() { + local name="$1" + local value="$2" + [[ "$name" =~ ^[A-Za-z_][A-Za-z0-9_-]*$ ]] || fail "invalid output name '$name'" + if [[ "$value" == *$'\n'* || "$value" == *$'\r'* ]]; then + fail "output '$name' contains a newline or carriage return" + fi + if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + printf '%s=%s\n' "$name" "$value" >>"$GITHUB_OUTPUT" + else + printf '%s=%s\n' "$name" "$value" + fi +} + +append_env() { + local name="$1" + local value="$2" + [[ "$name" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || fail "invalid environment name '$name'" + if [[ "$value" == *$'\n'* || "$value" == *$'\r'* ]]; then + fail "environment value '$name' contains a newline or carriage return" + fi + if [[ -n "${GITHUB_ENV:-}" ]]; then + printf '%s=%s\n' "$name" "$value" >>"$GITHUB_ENV" + else + export "$name=$value" + fi +} + +canonical_path() { + require_cmd realpath + local path + path=$(realpath "$1" 2>/dev/null) || fail "could not resolve path '$1'" + printf '%s\n' "$path" +} + +relative_to() { + local root="${1%/}" + local path="${2%/}" + if [[ "$path" == "$root" ]]; then + printf '.\n' + elif [[ "$path" == "$root"/* ]]; then + printf '%s\n' "${path#"$root"/}" + else + printf '%s\n' "$path" + fi +} + +is_under() { + local root="${1%/}" + local path="${2%/}" + [[ "$path" == "$root" || "$path" == "$root"/* ]] +} + +json_get() { + require_cmd jq + jq -er ".$2" "$1" +} + +sha256_file() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{ print $1 }' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" | awk '{ print $1 }' + else + fail "required command 'sha256sum' or 'shasum' was not found" + fi +} + +read_tool_version() { + local file="$1" + local tool="$2" + awk -v tool="$tool" '$1 == tool { print $2; found=1; exit } END { if (!found) exit 1 }' "$file" +} + +sanitize_ref() { + printf '%s' "$1" | tr -c 'A-Za-z0-9_.=-' '-' +} + +# The CLI binary name becomes a path component and is then chmod'd and executed, +# so it must be a bare filename — never a path, traversal, or dotfile. +validate_cli_bin() { + local name="$1" + [[ -n "$name" ]] || fail "CLI binary name must not be empty" + case "$name" in + */* | *\\* | *..*) fail "CLI binary name must not contain path separators or '..': '$name'" ;; + .*) fail "CLI binary name must not start with '.': '$name'" ;; + esac + [[ "$name" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || + fail "CLI binary name may contain only letters, digits, '.', '_', '-': '$name'" +} + +# Refuse an archive that could write outside the extraction directory: absolute +# paths, traversal, or symlink/hardlink members. +assert_safe_tarball() { + local tarball="$1" member + while IFS= read -r member; do + case "$member" in + /* | *..*) fail "refusing unsafe CLI archive member '$member'" ;; + esac + done < <(tar -tf "$tarball") + # NOT `tar -tvf … | grep -q …`. Under `set -o pipefail`, grep -q exits on the + # FIRST match, tar takes SIGPIPE, and the pipeline reports tar's failure — so + # the `if` would be false exactly when a link WAS found, letting the unsafe + # archive through. Capture first, then match. + local listing + listing=$(tar -tvf "$tarball") + if grep -qE '^[lh]' <<<"$listing"; then + fail "refusing CLI archive containing a symlink or hardlink member" + fi +} + +# ── Lifecycle helpers (deploy / healthcheck / rollback wrappers) ────────────── + +# GitHub Actions does NOT enforce `required: true` on action inputs: an omitted +# or empty input is simply the empty string, and the step runs anyway. So the +# wrappers must check for themselves — otherwise an empty service-id or version +# silently reaches the provider. +require_input() { + local name="$1" value="$2" + [[ -n "$value" ]] || fail "missing required input '$name'" +} + +require_input_matching() { + local name="$1" value="$2" pattern="$3" + require_input "$name" "$value" + [[ "$value" =~ $pattern ]] || fail "input '$name' must match $pattern" +} + +# For a required input whose VALUE must not reach this step (a credential): the +# wrapper passes a precomputed ` != '' && 'true' || 'false'` flag, and we +# assert presence without ever seeing the secret. +require_present() { + local name="$1" present="$2" + [[ "$present" == "true" ]] || fail "missing required input '$name'" +} + +# The Fastly provider tooling and its pinned release binary are Linux x86-64 +# only. Fail with a clear message rather than a confusing exec error later. +require_linux_x86_64() { + case "$(uname -s)-$(uname -m)" in + Linux-x86_64 | Linux-amd64) ;; + *) fail "the Fastly wrapper supports only Linux x86-64 runners" ;; + esac +} + +# Create a private log file that is REMOVED when the caller exits, whatever the +# exit status. Provider CLIs print request URLs, service metadata, and — with +# debug flags — credential material; leaving a raw log behind in RUNNER_TEMP +# hands it to every later step in the job. +# +# Sets the global LIFECYCLE_LOG. Callers must have `set -euo pipefail`. +LIFECYCLE_LOG="" +new_private_log() { + local dir="${RUNNER_TEMP:-/tmp}" + LIFECYCLE_LOG=$(mktemp "$dir/edgezero-lifecycle.XXXXXX") + chmod 600 "$LIFECYCLE_LOG" + # shellcheck disable=SC2064 # expand LIFECYCLE_LOG now, not at trap time + trap "cleanup_sensitive_temps '$LIFECYCLE_LOG'" EXIT +} + +# EXIT-trap cleanup for sensitive temp files (the private log; an inline config). +# A removal FAILURE must not be swallowed: these files can hold provider output +# or raw config, so a leftover is a real exposure the spec requires the action to +# surface (§13, "Cleanup fails → mark the action failed"). The original exit code +# is preserved; only a clean (0) exit is escalated to 1, so a real error is never +# masked. Re-installing callers pass ALL files to remove (a bare `trap` REPLACES). +cleanup_sensitive_temps() { + local rc=$? + local file failed=0 + for file in "$@"; do + [[ -e "$file" ]] || continue + rm -f -- "$file" || failed=1 + done + if [[ "$failed" -ne 0 ]]; then + echo "::error::failed to remove a sensitive temporary file" >&2 + [[ "$rc" -eq 0 ]] && rc=1 + fi + exit "$rc" +} + +# Read a canonical `=` line from a log. +# +# ANCHORED at both ends on purpose. An unanchored prefix match reads +# `version=15.2.0` as `15` and `version=12abc` as `12`, threading a version that +# was never deployed into the healthcheck and rollback that follow. If the value +# is not exactly digits, we have not parsed a version — we have guessed one. +read_numeric_line() { + local key="$1" log="$2" + grep -oE "^${key}=[0-9]+\$" "$log" | tail -n 1 | cut -d= -f2 || true +} + +read_bool_line() { + local key="$1" log="$2" + grep -oE "^${key}=(true|false)\$" "$log" | tail -n 1 | cut -d= -f2 || true +} + +# The app CLI to invoke — the ABSOLUTE path the download step resolved, when +# available, else the bare name. +# +# Bare-name resolution goes through PATH, and the provider CLI install prepends +# its own directory: an app CLI legitimately named `fastly` would then resolve to +# the provider's `fastly`, not the app's. Invoking the absolute path is immune to +# PATH ordering. `EDGEZERO__APP__CLI__PATH` comes from download-app-cli.sh's +# `app-cli-path` output. +resolve_app_cli() { + local path="${EDGEZERO__APP__CLI__PATH:-}" + if [[ -n "$path" ]]; then + printf '%s\n' "$path" + else + printf '%s\n' "${EDGEZERO__APP__CLI__BIN:?EDGEZERO__APP__CLI__PATH or EDGEZERO__APP__CLI__BIN is required}" + fi +} diff --git a/.github/actions/deploy-core/scripts/download-app-cli.sh b/.github/actions/deploy-core/scripts/download-app-cli.sh new file mode 100755 index 00000000..4713e3f2 --- /dev/null +++ b/.github/actions/deploy-core/scripts/download-app-cli.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Extracts the build-app-cli artifact tar (downloaded by actions/download-artifact) +# into an action-owned tool dir, preserving the executable bit, reads the +# self-describing app-cli-meta.json, and prepends the dir to PATH for action steps. +# A wrapper-supplied EDGEZERO__APP__CLI__BIN overrides the metadata's binary name. +# +# Reads (env): +# EDGEZERO__APP__CLI__ARTIFACT_DIR required dir containing the downloaded tar +# EDGEZERO__APP__CLI__BIN optional override for the binary name +# EDGEZERO__ACTION__TOOL_ROOT optional install dir (default: under RUNNER_TEMP) +# Writes (outputs): +# app-cli-bin resolved binary name +# app-cli-version version from app-cli-meta.json +# Writes (env): +# EDGEZERO__ACTION__TOOL_ROOT the install dir (for later steps + cleanup) +# Writes (PATH): +# the install dir's bin/, so the app CLI is callable by name + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=common.sh +source "$SCRIPT_DIR/common.sh" + +# Locate the single CLI tar produced by build-app-cli within the downloaded +# artifact. +# +# Refuses to guess. `actions/download-artifact` with an empty `name:` downloads +# EVERY artifact in the run, so silently taking the first tar could execute an +# unrelated binary — with provider credentials in scope. Exactly one tar is the +# only unambiguous case; zero or many is a caller error, not a coin toss. +find_cli_tarball() { + local artifact_dir="$1" + local -a tarballs=() + while IFS= read -r found; do + tarballs+=("$found") + done < <(find "$artifact_dir" -maxdepth 2 -type f -name '*.tar' | sort) + + case "${#tarballs[@]}" in + 1) printf '%s\n' "${tarballs[0]}" ;; + 0) fail "no CLI tar found in the downloaded artifact ($artifact_dir); check the 'app-cli-artifact' input names a build-app-cli artifact" ;; + *) fail "expected exactly 1 CLI tar in the downloaded artifact, found ${#tarballs[@]} — refusing to guess which CLI to run: ${tarballs[*]}" ;; + esac +} + +main() { + local artifact_dir="${EDGEZERO__APP__CLI__ARTIFACT_DIR:?EDGEZERO__APP__CLI__ARTIFACT_DIR is required}" + local cli_bin_override="${EDGEZERO__APP__CLI__BIN:-}" + local tool_root="${EDGEZERO__ACTION__TOOL_ROOT:-${RUNNER_TEMP:-/tmp}/edgezero-action-tools}" + + require_cmd jq + require_cmd tar + 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'" + + assert_safe_tarball "$tarball" + tar -xf "$tarball" -C "$tool_root/bin" + [[ -f "$tool_root/bin/app-cli-meta.json" ]] || fail "CLI artifact is missing app-cli-meta.json" + + local meta_bin cli_version cli_bin + meta_bin=$(jq -er '."app-cli-bin"' "$tool_root/bin/app-cli-meta.json") || fail "app-cli-meta.json has no app-cli-bin" + cli_version=$(jq -er '."app-cli-version"' "$tool_root/bin/app-cli-meta.json") || fail "app-cli-meta.json has no app-cli-version" + cli_bin="${cli_bin_override:-$meta_bin}" + validate_cli_bin "$cli_bin" + + local cli_path="$tool_root/bin/$cli_bin" + [[ -f "$cli_path" && ! -L "$cli_path" ]] || fail "CLI binary '$cli_bin' is not a regular file in the artifact" + chmod +x "$cli_path" + + # Smoke-check with a scrubbed environment: no inherited provider credential + # (FASTLY_KEY, FASTLY_AUTH_TOKEN, ...) may reach the app CLI here. + env -i PATH="/usr/bin:/bin" HOME="${HOME:-/tmp}" "$cli_path" --help >/dev/null 2>&1 || + fail "downloaded CLI '$cli_bin' did not run '--help'" + + printf '%s\n' "$tool_root/bin" >>"${GITHUB_PATH:-/dev/null}" + export PATH="$tool_root/bin:$PATH" + + notice "using app CLI '$cli_bin' v$cli_version from artifact" + append_output app-cli-bin "$cli_bin" + # The ABSOLUTE path, so callers invoke this exact binary rather than resolving + # the bare name through PATH. The provider CLI install prepends its own dir, so + # an app CLI legitimately named `fastly` would otherwise be shadowed by it. + append_output app-cli-path "$cli_path" + append_output app-cli-version "$cli_version" + append_env EDGEZERO__ACTION__TOOL_ROOT "$tool_root" +} + +main "$@" diff --git a/.github/actions/deploy-core/scripts/resolve-project.sh b/.github/actions/deploy-core/scripts/resolve-project.sh new file mode 100755 index 00000000..b41edde6 --- /dev/null +++ b/.github/actions/deploy-core/scripts/resolve-project.sh @@ -0,0 +1,219 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Resolves the application context for the deploy engine. Distinguishes the Git +# root (source revision + dirty-source guard) from the Cargo workspace root +# (Cargo.lock hash + target/ cache), so nested-workspace monorepos cache the +# right artifacts. Provider-neutral: no provider names appear here. +# +# Reads (env): +# EDGEZERO__PROJECT__TARGET required concrete build target (e.g. wasm32-wasip1) +# EDGEZERO__ACTION__ROOT required EdgeZero action repo (toolchain fallback) +# GITHUB_WORKSPACE required checkout root; the search ceiling +# EDGEZERO__PROJECT__WORKING_DIRECTORY optional app dir under the workspace (default: ".") +# EDGEZERO__PROJECT__MANIFEST optional edgezero.toml path, relative to the app dir +# EDGEZERO__PROJECT__RUST_TOOLCHAIN optional explicit toolchain or "auto" (default: "auto") +# EDGEZERO__BUILD__MODE optional auto | always | never (default: auto) +# EDGEZERO__BUILD__CACHE optional true | false (default: false) +# EDGEZERO__APP__CLI__VERSION optional folded into the cache key (default: unknown) +# Writes (outputs): +# working-directory resolved absolute app directory +# working-directory-relative app directory relative to the workspace +# app-git-root enclosing Git repository (source revision) +# cargo-workspace-root Cargo workspace root (Cargo.lock + target/) +# source-revision Git revision of the app +# manifest / manifest-summary resolved manifest path (and a display form) +# rust-toolchain resolved toolchain +# effective-build-mode build mode after auto resolution +# cache-key / cache-path exact cache key and the target/ path it covers + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=common.sh +source "$SCRIPT_DIR/common.sh" + +# --- Rust toolchain resolution helpers --------------------------------------- +parse_toolchain_from_channel_file() { + # An extensionless `rust-toolchain` is EITHER a legacy single-line channel + # (e.g. `1.95.0`) OR a TOML document (`[toolchain]` + `channel = "..."`). + # rustup accepts both, so detect the TOML form and parse its channel rather + # than taking the literal `[toolchain]` line as the channel. + if grep -qE '^[[:space:]]*\[toolchain\]' "$1"; then + parse_toolchain_from_toml "$1" + return + fi + local value + value=$(sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' "$1" | awk 'NF { print; exit }') + [[ -n "$value" ]] || fail "malformed Rust toolchain file: $1" + printf '%s\n' "$value" +} + +parse_toolchain_from_toml() { + local value + value=$(sed -nE 's/^[[:space:]]*channel[[:space:]]*=[[:space:]]*["'\''`]([^"'\''`]+)["'\''`][[:space:]]*(#.*)?$/\1/p' "$1" | head -n 1) + [[ -n "$value" ]] || fail "malformed Rust toolchain TOML file: $1" + printf '%s\n' "$value" +} + +# Resolve the toolchain: explicit input > rustup files (walking to the Git root) +# > .tool-versions > the EdgeZero action repo fallback. +resolve_rust_toolchain() { + local input="$1" app_dir="$2" git_root="$3" action_root="$4" + if [[ "$input" != "auto" ]]; then + [[ -n "$input" ]] || fail "input 'rust-toolchain' cannot be empty" + printf '%s\n' "$input" + return + fi + local directory="$app_dir" value + while true; do + if [[ -f "$directory/rust-toolchain.toml" ]]; then + parse_toolchain_from_toml "$directory/rust-toolchain.toml" + return + fi + if [[ -f "$directory/rust-toolchain" ]]; then + parse_toolchain_from_channel_file "$directory/rust-toolchain" + return + fi + if [[ -f "$directory/.tool-versions" ]] && value=$(read_tool_version "$directory/.tool-versions" rust) && [[ -n "$value" ]]; then + printf '%s\n' "$value" + return + fi + [[ "$directory" == "$git_root" ]] && break + local parent + parent=$(dirname "$directory") + [[ "$parent" == "$directory" ]] && break + directory="$parent" + done + if [[ -f "$action_root/.tool-versions" ]] && value=$(read_tool_version "$action_root/.tool-versions" rust) && [[ -n "$value" ]]; then + printf '%s\n' "$value" + return + fi + fail "could not resolve Rust toolchain; checked rust-toolchain.toml, rust-toolchain, .tool-versions; set input 'rust-toolchain' explicitly" +} + +resolve_effective_build_mode() { + case "${1:-auto}" in + auto | never) printf 'never\n' ;; + always) printf 'always\n' ;; + *) fail "input 'build-mode' must be one of: auto, always, never" ;; + esac +} + +# Record the source revision and fail on a dirty working tree. +assert_committed_source() { + local git_root="$1" app_rel="$2" + if ! git -C "$git_root" diff --quiet --ignore-submodules -- || + ! git -C "$git_root" diff --cached --quiet --ignore-submodules -- || + [[ -n "$(git -C "$git_root" ls-files --others --exclude-standard)" ]]; then + fail "deployments require committed source; working tree for '$app_rel' is dirty" + fi +} + +main() { + local workspace="${GITHUB_WORKSPACE:?GITHUB_WORKSPACE is required}" + local action_root="${EDGEZERO__ACTION__ROOT:?EDGEZERO__ACTION__ROOT is required}" + local working_directory="${EDGEZERO__PROJECT__WORKING_DIRECTORY:-.}" + local manifest="${EDGEZERO__PROJECT__MANIFEST:-}" + local rust_toolchain_input="${EDGEZERO__PROJECT__RUST_TOOLCHAIN:-auto}" + local target="${EDGEZERO__PROJECT__TARGET:?EDGEZERO__PROJECT__TARGET is required (wrapper-provided concrete target)}" + local cache="${EDGEZERO__BUILD__CACHE:-false}" + local cli_version="${EDGEZERO__APP__CLI__VERSION:-unknown}" + + require_cmd git + require_cmd cargo + + # Application directory, confined to github.workspace. + local workspace_real app_dir app_rel + workspace_real=$(canonical_path "$workspace") + [[ -d "$workspace/$working_directory" ]] || fail "working-directory '$working_directory' does not exist or is not a directory" + app_dir=$(canonical_path "$workspace/$working_directory") + is_under "$workspace_real" "$app_dir" || fail "input 'working-directory' must resolve inside github.workspace" + app_rel=$(relative_to "$workspace_real" "$app_dir") + + # Git root: source revision + dirty-source guard. Resolved BEFORE the manifest + # so it can bound it — github.workspace is too loose a boundary. In the + # separate-repository layout the deployer repo IS the workspace, so a manifest + # like `../deployer/edgezero.toml` would be "inside github.workspace" yet come + # from a different repository than the source-revision we report. + local git_root source_revision + git_root=$(git -C "$app_dir" rev-parse --show-toplevel 2>/dev/null || true) + [[ -n "$git_root" ]] || fail "working-directory '$app_rel' is not inside a Git repository" + git_root=$(canonical_path "$git_root") + is_under "$workspace_real" "$git_root" || fail "application Git root must resolve inside github.workspace" + + # Optional explicit manifest, confined to the application repository. + local manifest_path manifest_summary + if [[ -n "$manifest" ]]; then + [[ -f "$app_dir/$manifest" ]] || fail "manifest '$app_rel/$manifest' does not exist or is not a regular file" + manifest_path=$(canonical_path "$app_dir/$manifest") + is_under "$git_root" "$manifest_path" || + fail "input 'manifest' must resolve inside the application repository ($git_root); '$manifest' escapes it" + manifest_summary=$(relative_to "$workspace_real" "$manifest_path") + else + manifest_path="" + manifest_summary="EdgeZero default discovery" + # Default discovery is NOT unconfined. The CLI runs from the app dir and + # defaults to `edgezero.toml` there; a committed symlink named `edgezero.toml` + # could point outside the app repository, and its manifest deploy command + # would run with provider credentials. If that file exists, it must resolve + # inside the app repository — the same rule as an explicit manifest. + if [[ -e "$app_dir/edgezero.toml" ]]; then + local default_manifest + default_manifest=$(canonical_path "$app_dir/edgezero.toml") + is_under "$git_root" "$default_manifest" || + fail "the default 'edgezero.toml' in '$app_rel' resolves outside the application repository ($git_root) — refusing to run a manifest that escapes it" + fi + fi + + # Source revision + dirty-source guard (git_root resolved above). + local source_revision + source_revision=$(git -C "$git_root" rev-parse HEAD) + assert_committed_source "$git_root" "$app_rel" + + # Cargo workspace root: lockfile + target/ + cache. + # + # It may legitimately sit ABOVE the app dir (a monorepo app in `apps/api/` + # keyed on the repo-root workspace), but never above the app's REPOSITORY: + # `cargo locate-project --workspace` climbs until it finds a workspace root, so + # in the separate-repository layout it can escape into the deployer's workspace + # and we would build and cache code that `source-revision` does not describe. + local cargo_ws_manifest cargo_ws_root lockfile lock_hash target_dir + if ! cargo_ws_manifest=$(cd "$app_dir" && cargo locate-project --workspace --message-format plain 2>/dev/null); then + cargo_ws_manifest="" + fi + [[ -n "$cargo_ws_manifest" ]] || fail "could not locate the Cargo workspace root from '$app_rel'" + cargo_ws_root=$(canonical_path "$(dirname "$cargo_ws_manifest")") + is_under "$git_root" "$cargo_ws_root" || + fail "the Cargo workspace root ($cargo_ws_root) is outside the application repository ($git_root); refusing to build code that 'source-revision' does not describe" + lockfile="$cargo_ws_root/Cargo.lock" + if [[ "$cache" == "true" && ! -f "$lockfile" ]]; then + fail "cache is enabled but Cargo.lock was not found at the Cargo workspace root ($cargo_ws_root); exact-key caching requires Cargo.lock" + fi + lock_hash="none" + if [[ -f "$lockfile" ]]; then + lock_hash=$(sha256_file "$lockfile") + fi + target_dir="$cargo_ws_root/target" + + local rust_toolchain effective_build_mode cache_key + rust_toolchain=$(resolve_rust_toolchain "$rust_toolchain_input" "$app_dir" "$git_root" "$action_root") + effective_build_mode=$(resolve_effective_build_mode "${EDGEZERO__BUILD__MODE:-auto}") + cache_key="edgezero-deploy-${RUNNER_OS:-Linux}-${RUNNER_ARCH:-X64}-$(sanitize_ref "$rust_toolchain")-$(sanitize_ref "$target")-$(sanitize_ref "$cli_version")-${source_revision}-${lock_hash}" + + append_output working-directory "$app_dir" + append_output working-directory-relative "$app_rel" + append_output manifest "$manifest_path" + append_output manifest-summary "$manifest_summary" + append_output app-git-root "$git_root" + append_output cargo-workspace-root "$cargo_ws_root" + append_output source-revision "$source_revision" + append_output rust-toolchain "$rust_toolchain" + append_output effective-build-mode "$effective_build_mode" + append_output cache-key "$cache_key" + append_output cache-path "$target_dir" +} + +# Sourcing this file exposes its functions without resolving a project, so the +# contract tests can exercise the dirty-source guard directly. +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + main "$@" +fi diff --git a/.github/actions/deploy-core/scripts/run-app-cli.sh b/.github/actions/deploy-core/scripts/run-app-cli.sh new file mode 100755 index 00000000..c2a607b0 --- /dev/null +++ b/.github/actions/deploy-core/scripts/run-app-cli.sh @@ -0,0 +1,219 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Runs the application CLI (build or deploy) through Bash arrays — never eval. +# +# Provider-neutral: it invokes ` --adapter ` with the +# wrapper's typed deploy-flags (before `--`) and the caller's passthrough +# deploy-args (after `--`). +# +# Credential boundary (deploy mode): the wrapper never exports provider tokens +# onto the step directly. It passes EDGEZERO__PROVIDER__ENV (a JSON object of typed +# credential name -> value) plus a provider-env-clear name list. This script +# first UNSETS every clear-listed alias (removing any inherited FASTLY_* value), +# then exports only the typed values from EDGEZERO__PROVIDER__ENV — and only names +# that are declared in the clear list. So inherited endpoint/token aliases can +# never survive into the deploy. Build mode is credential-free and only clears. +# +# Reads (env): +# EDGEZERO__APP__CLI__PATH optional absolute path to the app CLI (preferred; avoids PATH shadowing) +# EDGEZERO__APP__CLI__BIN optional app CLI name, used when __PATH is unset +# EDGEZERO__ADAPTER required adapter passed as --adapter +# EDGEZERO__PROJECT__WORKING_DIRECTORY required directory to run the CLI from +# EDGEZERO__PROJECT__MANIFEST_PATH optional exported as EDGEZERO_MANIFEST when set +# EDGEZERO__BUILD__ARGS_FILE optional NUL-delimited build passthrough (build) +# EDGEZERO__DEPLOY__FLAGS_FILE optional NUL-delimited typed flags (deploy) +# EDGEZERO__DEPLOY__ARGS_FILE optional NUL-delimited passthrough (deploy) +# EDGEZERO__PROVIDER__ENV_CLEAR_FILE optional NUL-delimited alias names to clear +# EDGEZERO__PROVIDER__ENV optional JSON object of typed creds (deploy) +# Writes (outputs): +# mutation-attempted deploy mode only: 'true', written immediately BEFORE the +# CLI runs (durable reconcile signal; see below). +# Otherwise runs the app CLI, which owns stdout/stderr and the exit status. +# EDGEZERO_MANIFEST is exported to the CLI; the whole EDGEZERO__* namespace is +# scrubbed first (see scrub_action_private_env). + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=common.sh +source "$SCRIPT_DIR/common.sh" + +# Collect a NUL-delimited file into the global COLLECTED array (portable; avoids +# Bash 4.3 namerefs, which some runners/macOS Bash 3.2 lack). +COLLECTED=() +collect_nul() { + local file="$1" + COLLECTED=() + [[ -s "$file" ]] || return 0 + local entry + while IFS= read -r -d '' entry; do + COLLECTED+=("$entry") + done <"$file" +} + +# Unset each wrapper-named provider alias listed (NUL-delimited) in a file. +clear_named_aliases() { + local file="$1" + [[ -s "$file" ]] || return 0 + local name + while IFS= read -r -d '' name; do + if [[ "$name" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then + unset "$name" || true + fi + done <"$file" +} + +# Return 0 if appears in the NUL-delimited clear-list file. +name_in_clear_list() { + local wanted="$1" file="$2" name + [[ -s "$file" ]] || return 1 + while IFS= read -r -d '' name; do + [[ "$name" == "$wanted" ]] && return 0 + done <"$file" + return 1 +} + +# Clear the provider aliases, then export ONLY the typed values from +# EDGEZERO__PROVIDER__ENV whose names are declared in the clear list. jq parses the +# JSON, so values are opaque data (never interpreted by the shell). +import_provider_env() { + local clear_file="$1" + local json="${EDGEZERO__PROVIDER__ENV:-}" + [[ -n "$json" ]] || json='{}' + clear_named_aliases "$clear_file" + + require_cmd jq + require_cmd base64 + printf '%s' "$json" | jq -e 'type == "object"' >/dev/null 2>&1 || + fail "EDGEZERO__PROVIDER__ENV must be a JSON object of string values" + printf '%s' "$json" | jq -e 'all(.[]; type == "string")' >/dev/null 2>&1 || + fail "every EDGEZERO__PROVIDER__ENV value must be a string" + # A NUL cannot survive the Bash boundary: `export NAME=value` truncates at the + # first NUL, so a value carrying one would be silently altered — a credential + # that is quietly wrong is worse than one that is rejected. + printf '%s' "$json" | jq -e 'all(.[]; contains("\u0000") | not)' >/dev/null 2>&1 || + fail "EDGEZERO__PROVIDER__ENV values must not contain NUL bytes" + + # One "NAME BASE64VALUE" line per entry. Base64 keeps values line-safe + # (newlines, spaces, quotes cannot break the read loop) and opaque. + local name b64 value + while read -r name b64; do + [[ "$name" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || + fail "EDGEZERO__PROVIDER__ENV name '$name' is not a valid environment variable name" + name_in_clear_list "$name" "$clear_file" || + fail "EDGEZERO__PROVIDER__ENV name '$name' must be declared in provider-env-clear" + value=$(printf '%s' "$b64" | base64 --decode) + export "$name=$value" + done < <(printf '%s' "$json" | jq -r 'to_entries[] | "\(.key) \(.value | @base64)"') +} + +# Build the CLI argv for `build` mode into the global ARGV array. +build_build_argv() { + local cli_bin="$1" + local adapter="$2" + ARGV=("$cli_bin" build --adapter "$adapter") + # Credential-free build: defensively drop the wrapper-named aliases. + clear_named_aliases "${EDGEZERO__PROVIDER__ENV_CLEAR_FILE:-/dev/null}" + collect_nul "${EDGEZERO__BUILD__ARGS_FILE:-/dev/null}" + if ((${#COLLECTED[@]})); then + ARGV+=(-- "${COLLECTED[@]}") + fi +} + +# Build the CLI argv for `deploy` mode into the global ARGV array. +build_deploy_argv() { + local cli_bin="$1" + local adapter="$2" + ARGV=("$cli_bin" deploy --adapter "$adapter") + # Typed adapter flags (before `--`): e.g. --service-id , --stage. + collect_nul "${EDGEZERO__DEPLOY__FLAGS_FILE:-/dev/null}" + ((${#COLLECTED[@]})) && ARGV+=("${COLLECTED[@]}") + # Caller passthrough (after `--`): allowlisted deploy-args, e.g. --comment. + collect_nul "${EDGEZERO__DEPLOY__ARGS_FILE:-/dev/null}" + if ((${#COLLECTED[@]})); then + ARGV+=(-- "${COLLECTED[@]}") + fi +} + +# Unset the action's PRIVATE environment namespace before handing control to the +# application CLI. +# +# This is a credential boundary, not tidiness. The wrapper carries the typed +# token into this script twice: once as `EDGEZERO___API_TOKEN` (so the +# step's YAML can build the JSON without interpolating a secret into a `run:` +# block), and once inside `EDGEZERO__PROVIDER__ENV` itself. Both are +# secret-bearing. Without this scrub they stay exported, so the app CLI — and +# every subprocess it spawns, including a manifest `[adapters.*.commands]` shell +# command — inherits the raw token under names we never promised, and any +# `env`-dumping build script would print it. +# +# This is why every action-owned variable lives under the double-underscore +# `EDGEZERO__` prefix: the boundary is then one rule with no list to keep in sync. +# `EDGEZERO_MANIFEST` (SINGLE underscore) is deliberately outside it — that is the +# CLI's own public contract, not ours, and it is the one variable we do pass on. +# +# This is `unset`, not a re-exec: it scrubs what the CLI INHERITS, so a token never +# appears under an unpromised name or in an accidental `env` dump. It is NOT a +# process-image boundary — on Linux this shell's original environment stays +# readable via `/proc//environ`. That is acceptable here (unlike the +# untrusted build in build-app-cli/common.sh, which re-execs with `env -u`): the +# deploy runs the app's OWN trusted CLI, which already gets the same token as +# FASTLY_API_TOKEN. Deploying means trusting that CLI with the credential. +scrub_action_private_env() { + local name + while IFS= read -r name; do + case "$name" in + EDGEZERO__*) unset "$name" || true ;; + *) ;; + esac + done < <(compgen -e) +} + +ARGV=() +main() { + local mode="${1:-}" + case "$mode" in + build | deploy) ;; + *) fail "usage: run-app-cli.sh build|deploy" ;; + esac + + local cli_bin + cli_bin=$(resolve_app_cli) + local adapter="${EDGEZERO__ADAPTER:?EDGEZERO__ADAPTER is required}" + local working_directory="${EDGEZERO__PROJECT__WORKING_DIRECTORY:?EDGEZERO__PROJECT__WORKING_DIRECTORY is required}" + local manifest="${EDGEZERO__PROJECT__MANIFEST_PATH:-}" + require_cmd "$cli_bin" + + case "$mode" in + build) build_build_argv "$cli_bin" "$adapter" ;; + deploy) + # Clear inherited provider aliases and export only the typed credentials. + import_provider_env "${EDGEZERO__PROVIDER__ENV_CLEAR_FILE:-/dev/null}" + build_deploy_argv "$cli_bin" "$adapter" + ;; + esac + + # Everything the action needed from its own env is now in locals or in ARGV. + scrub_action_private_env + + if [[ -n "$manifest" ]]; then + export EDGEZERO_MANIFEST="$manifest" + else + unset EDGEZERO_MANIFEST || true + fi + + cd "$working_directory" + # Publish the reconcile signal for a MUTATING invocation HERE — after all setup + # succeeded (binary resolve, credential import, cd) and immediately before the + # CLI runs. Writing it now, from the launcher itself, is what makes it correct on + # every axis: a setup failure ABOVE never falsely claims the CLI ran, and because + # it is durable in GITHUB_OUTPUT before the mutation starts, it survives even if + # the step is cancelled or times out mid-mutation. `build` is credential-free and + # mutates nothing, so it is not signalled. + if [[ "$mode" == "deploy" ]]; then + append_output mutation-attempted true + fi + echo "[edgezero-action] running $cli_bin $mode for adapter $adapter" >&2 + "${ARGV[@]}" +} + +main "$@" diff --git a/.github/actions/deploy-core/scripts/validate-inputs.sh b/.github/actions/deploy-core/scripts/validate-inputs.sh new file mode 100755 index 00000000..4555cfb2 --- /dev/null +++ b/.github/actions/deploy-core/scripts/validate-inputs.sh @@ -0,0 +1,153 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Provider-neutral input validation for the deploy engine. Parses the JSON-array +# parameters into NUL-delimited files, applies the wrapper-supplied deploy-arg +# allowlist, and validates booleans. It never learns provider credential names +# or provider CLI flags — those arrive from the wrapper as opaque data. +# +# Reads (env): +# EDGEZERO__ADAPTER required adapter token (well-formedness only) +# EDGEZERO__RUNNER__OS required runner OS guard (Linux) +# EDGEZERO__RUNNER__ARCH required runner arch guard (X64) +# EDGEZERO__BUILD__MODE optional auto | always | never (default: auto) +# EDGEZERO__BUILD__CACHE optional true | false (default: false) +# EDGEZERO__DEPLOY__STAGE optional true | false (default: false) +# EDGEZERO__BUILD__ARGS optional JSON string array (default: []) +# EDGEZERO__DEPLOY__ARGS optional caller JSON string array (default: []) +# EDGEZERO__DEPLOY__ARGS_PREPEND optional action-owned JSON array, prepended (default: []) +# EDGEZERO__DEPLOY__FLAGS optional typed JSON string array (default: []) +# EDGEZERO__DEPLOY__ARG_ALLOW optional space-separated deploy-arg allowlist +# EDGEZERO__PROVIDER__ENV_CLEAR optional JSON array of alias names to clear (default: []) +# EDGEZERO__ACTION__STATE_DIR optional where the .nul files are written (default: under RUNNER_TEMP) +# Writes (outputs): +# adapter the validated adapter +# build-args-file NUL-delimited build args +# deploy-args-file NUL-delimited deploy args (prepend + allowlisted caller) +# deploy-flags-file NUL-delimited typed deploy flags +# provider-env-clear-file NUL-delimited alias names to clear +# requested-build-mode the validated build mode +# cache the validated cache flag + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=common.sh +source "$SCRIPT_DIR/common.sh" + +# Fail unless the runner is the tested Linux x86-64 environment. +require_supported_runner() { + local os="$1" arch="$2" + [[ -z "$os" && -z "$arch" ]] && return 0 + [[ "$os" == "Linux" && "$arch" == "X64" ]] || + fail "the EdgeZero deploy engine supports only Linux x86-64 runners; received ${os:-unknown}/${arch:-unknown}" +} + +# Parse a JSON string array into a NUL-delimited file, rejecting non-arrays, +# non-string entries, and embedded NUL bytes. +parse_json_string_array() { + local name="$1" value="$2" out_file="$3" + printf '%s' "$value" | jq -e 'type == "array"' >/dev/null 2>&1 || + fail "parameter '$name' must be a JSON array of strings" + printf '%s' "$value" | jq -e 'all(.[]; type == "string")' >/dev/null || + fail "every element of parameter '$name' must be a string" + if printf '%s' "$value" | jq -e 'any(.[]; contains("\u0000"))' >/dev/null; then + fail "parameter '$name' contains a NUL byte, which cannot be passed as an OS argument" + fi + printf '%s' "$value" | jq -jr '.[] | ., "\u0000"' >"$out_file" +} + +# Enforce the wrapper's deploy-arg allowlist. Each permitted flag accepts either +# `--flag=value` (one token) or `--flag value` (two tokens). +enforce_deploy_arg_allowlist() { + local args_file="$1" allow_list="$2" + local -a permitted=() + read -r -a permitted <<<"$allow_list" + + local arg position=0 expecting_value=false + while IFS= read -r -d '' arg; do + position=$((position + 1)) + if [[ "$expecting_value" == "true" ]]; then + expecting_value=false + continue + fi + local flag="${arg%%=*}" matched=false candidate + for candidate in "${permitted[@]}"; do + if [[ "$flag" == "$candidate" ]]; then + matched=true + [[ "$arg" == *=* ]] || expecting_value=true + break + fi + done + [[ "$matched" == "true" ]] || + fail "deploy-args allows only: ${allow_list:-} (as '--flag value' or '--flag=value'); rejected argument $position" + done <"$args_file" + [[ "$expecting_value" == "false" ]] || fail "a value-taking deploy-arg flag is missing its value" +} + +main() { + local adapter="${EDGEZERO__ADAPTER:-}" + local build_mode="${EDGEZERO__BUILD__MODE:-auto}" + local cache="${EDGEZERO__BUILD__CACHE:-false}" + local stage="${EDGEZERO__DEPLOY__STAGE:-false}" + local deploy_arg_allow="${EDGEZERO__DEPLOY__ARG_ALLOW:-}" + + require_supported_runner "${EDGEZERO__RUNNER__OS:-}" "${EDGEZERO__RUNNER__ARCH:-}" + + # Well-formedness only: the CLI decides whether the adapter is supported. + [[ -n "$adapter" ]] || fail "internal parameter 'adapter' is required" + [[ "$adapter" =~ ^[a-z][a-z0-9-]*$ ]] || fail "adapter '$adapter' is malformed; expected a lowercase token like 'fastly'" + + case "$build_mode" in + auto | always | never) ;; + *) fail "input 'build-mode' must be one of: auto, always, never" ;; + esac + case "$cache" in + true | false) ;; + *) fail "input 'cache' must be exactly 'true' or 'false'" ;; + esac + # A typo here must never silently fall back to a production deploy. + case "$stage" in + true | false) ;; + *) fail "input 'stage' must be exactly 'true' or 'false'" ;; + esac + + require_cmd jq + + local state_dir="${EDGEZERO__ACTION__STATE_DIR:-${RUNNER_TEMP:-/tmp}/edgezero-action-state}" + mkdir -p "$state_dir" + local build_args_file="$state_dir/build-args.nul" + local deploy_args_file="$state_dir/deploy-args.nul" + local deploy_flags_file="$state_dir/deploy-flags.nul" + local provider_env_clear_file="$state_dir/provider-env-clear.nul" + + parse_json_string_array "build-args" "${EDGEZERO__BUILD__ARGS:-[]}" "$build_args_file" + parse_json_string_array "deploy-args" "${EDGEZERO__DEPLOY__ARGS:-[]}" "$deploy_args_file" + parse_json_string_array "deploy-flags" "${EDGEZERO__DEPLOY__FLAGS:-[]}" "$deploy_flags_file" + parse_json_string_array "provider-env-clear" "${EDGEZERO__PROVIDER__ENV_CLEAR:-[]}" "$provider_env_clear_file" + + # The allowlist governs CALLER deploy-args only. + enforce_deploy_arg_allowlist "$deploy_args_file" "$deploy_arg_allow" + + # Action-owned passthrough args are prepended AFTER the allowlist check, + # because they are not caller input — the wrapper supplies them to make the + # deploy safe in CI (for Fastly: `--non-interactive`, which the built-in + # deploy path adds for itself but a manifest `[adapters.fastly.commands] + # deploy = "fastly compute deploy"` override would otherwise never get, so the + # deploy could block on a TTY prompt). They go first so a caller arg can still + # override them where the provider CLI takes last-wins. + local prepend_file="$state_dir/deploy-args-prepend.nul" + parse_json_string_array "deploy-args-prepend" "${EDGEZERO__DEPLOY__ARGS_PREPEND:-[]}" "$prepend_file" + if [[ -s "$prepend_file" ]]; then + cat "$prepend_file" "$deploy_args_file" >"$deploy_args_file.merged" + mv "$deploy_args_file.merged" "$deploy_args_file" + fi + + append_output adapter "$adapter" + append_output build-args-file "$build_args_file" + append_output deploy-args-file "$deploy_args_file" + append_output deploy-flags-file "$deploy_flags_file" + append_output provider-env-clear-file "$provider_env_clear_file" + append_output requested-build-mode "$build_mode" + append_output cache "$cache" +} + +main "$@" diff --git a/.github/actions/deploy-core/scripts/write-summary.sh b/.github/actions/deploy-core/scripts/write-summary.sh new file mode 100755 index 00000000..f1e21c20 --- /dev/null +++ b/.github/actions/deploy-core/scripts/write-summary.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Writes a non-sensitive GitHub step summary. Never emits credentials, full +# environments, or raw argument arrays. +# +# Reads (env): +# EDGEZERO__SUMMARY__ADAPTER optional adapter name +# EDGEZERO__SUMMARY__WORKING_DIRECTORY optional app directory (relative, for display) +# EDGEZERO__SUMMARY__SOURCE_REVISION optional deployed Git revision +# EDGEZERO__SUMMARY__MANIFEST optional manifest path or default-discovery note +# EDGEZERO__SUMMARY__RUST_TOOLCHAIN optional resolved toolchain +# EDGEZERO__SUMMARY__TARGET optional build target +# EDGEZERO__SUMMARY__APP_CLI_VERSION optional app CLI version +# EDGEZERO__SUMMARY__EFFECTIVE_BUILD_MODE optional build mode after resolution +# EDGEZERO__SUMMARY__CACHE optional whether caching was enabled +# EDGEZERO__SUMMARY__RESULT optional final step result +# GITHUB_STEP_SUMMARY optional summary sink (no-op when unset) +# Writes (step summary): +# a Markdown table of the non-sensitive facts above. + +main() { + local summary_file="${GITHUB_STEP_SUMMARY:-}" + [[ -n "$summary_file" ]] || return 0 + { + echo "## EdgeZero deploy" + echo + echo "| Field | Value |" + echo "| ----- | ----- |" + echo "| Adapter | ${EDGEZERO__SUMMARY__ADAPTER:-unknown} |" + echo "| Application directory | ${EDGEZERO__SUMMARY__WORKING_DIRECTORY:-unknown} |" + echo "| Source revision | ${EDGEZERO__SUMMARY__SOURCE_REVISION:-unknown} |" + echo "| Manifest | ${EDGEZERO__SUMMARY__MANIFEST:-EdgeZero default discovery} |" + echo "| Rust toolchain | ${EDGEZERO__SUMMARY__RUST_TOOLCHAIN:-unknown} |" + echo "| Target | ${EDGEZERO__SUMMARY__TARGET:-unknown} |" + echo "| CLI version | ${EDGEZERO__SUMMARY__APP_CLI_VERSION:-unknown} |" + echo "| Effective build mode | ${EDGEZERO__SUMMARY__EFFECTIVE_BUILD_MODE:-unknown} |" + echo "| Cache | ${EDGEZERO__SUMMARY__CACHE:-false} |" + echo "| Result | ${EDGEZERO__SUMMARY__RESULT:-unknown} |" + } >>"$summary_file" +} + +main "$@" diff --git a/.github/actions/deploy-core/tests/assert-config-push.sh b/.github/actions/deploy-core/tests/assert-config-push.sh new file mode 100755 index 00000000..555ede73 --- /dev/null +++ b/.github/actions/deploy-core/tests/assert-config-push.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +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. +# +# Reads (env): +# FAKE_CALL_LOG required the fake fastly call log +# EDGEZERO__TEST__EXPECT_KEY required the key this push must have written +# EDGEZERO__TEST__PUSHED_KEY required the action's pushed-key output +# EDGEZERO__TEST__PUSHED_STORE required the action's store output +# EDGEZERO__TEST__REJECT_KEY optional a key that must NOT appear in the log + +log="${FAKE_CALL_LOG:?FAKE_CALL_LOG is required}" +expect_key="${EDGEZERO__TEST__EXPECT_KEY:?EDGEZERO__TEST__EXPECT_KEY is required}" +pushed_key="${EDGEZERO__TEST__PUSHED_KEY:-}" +pushed_store="${EDGEZERO__TEST__PUSHED_STORE:-}" +reject_key="${EDGEZERO__TEST__REJECT_KEY:-}" + +echo "--- recorded fastly calls:" +cat "$log" || true + +fail() { + echo "::error::$1" + exit 1 +} + +# The action's own output must name the key it wrote. +[[ "$pushed_key" == "$expect_key" ]] || + fail "pushed-key output should be '$expect_key', got '$pushed_key'" + +# The store output must be the id the CLI RESOLVED from the manifest — neither +# push passes --store, so an empty value would mean the wrapper echoed its input. +[[ "$pushed_store" == "app_config" ]] || + fail "store output should be the resolved logical id 'app_config', got '$pushed_store'" + +# The push must have gone through the real Fastly config-store surface, and +# written the expected key. +grep -q 'fastly config-store list' "$log" || + fail "config push never resolved the store via 'fastly config-store list'" +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). +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" + fi +fi + +echo "config-push OK (wrote $expect_key in store $pushed_store)" diff --git a/.github/actions/deploy-core/tests/assert-production-deploy.sh b/.github/actions/deploy-core/tests/assert-production-deploy.sh new file mode 100755 index 00000000..83727360 --- /dev/null +++ b/.github/actions/deploy-core/tests/assert-production-deploy.sh @@ -0,0 +1,65 @@ +#!/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=dummy-service' \ + 'endpoint=CLEARED' \ + 'home=CLEARED' \ + 'action-token-carrier=CLEARED' \ + 'provider-env-json=CLEARED'; do + grep -qx -- "$expected" "$env_seen" || + fail "credential boundary violated: expected '$expected' in env-seen.txt" + done + + notice "production deploy, version threading, and the credential boundary all hold" +} + +main "$@" diff --git a/.github/actions/deploy-core/tests/assert-production-probe-tokenless.sh b/.github/actions/deploy-core/tests/assert-production-probe-tokenless.sh new file mode 100755 index 00000000..dc784511 --- /dev/null +++ b/.github/actions/deploy-core/tests/assert-production-probe-tokenless.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Asserts a PRODUCTION healthcheck probes WITHOUT any provider token, even when +# one is inherited from the job environment. +# +# A production probe just curls the public domain — it needs no credential, so the +# wrapper passes none. This reads the delta of the fake call log (the calls this +# probe made) and requires the probe to have run with FASTLY_API_TOKEN absent. +# Without the delta, an earlier STAGING probe (which legitimately holds a token) +# would mask a regression here. +# +# Reads (env): +# FAKE_CALL_LOG required the fake fastly/curl call log +# EDGEZERO__TEST__LOG_SNAPSHOT required call-log line count BEFORE the probe +# EDGEZERO__TEST__HEALTHY required the healthcheck's `healthy` output + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=../scripts/common.sh +source "$SCRIPT_DIR/../scripts/common.sh" + +main() { + local log="${FAKE_CALL_LOG:?FAKE_CALL_LOG is required}" + local snapshot="${EDGEZERO__TEST__LOG_SNAPSHOT:?EDGEZERO__TEST__LOG_SNAPSHOT is required}" + local healthy="${EDGEZERO__TEST__HEALTHY:-}" + + local delta + delta=$(tail -n "+$((snapshot + 1))" "$log") + echo "--- production-probe call delta:" + printf '%s\n' "$delta" + + # The probe must actually have run (otherwise "no token" is vacuous). + grep -qE '^PROBE ' <<<"$delta" || + fail "the production healthcheck never issued a probe" + + # Every probe in the delta must have run with NO token in scope. + if grep -qE '^PROBE-TOKEN=set$' <<<"$delta"; then + fail "a production probe ran with FASTLY_API_TOKEN in scope; a production healthcheck must receive no token even when one is inherited from the job env" + fi + grep -qE '^PROBE-TOKEN=$' <<<"$delta" || + fail "the production probe recorded no token state — the fake curl did not run as expected" + + # And it still worked (the token is genuinely unnecessary for production). + [[ "$healthy" == "true" ]] || + fail "the production healthcheck should succeed without a token, got healthy='${healthy:-}'" + + notice "production healthcheck probed with NO provider token and reported healthy" +} + +main "$@" diff --git a/.github/actions/deploy-core/tests/assert-rollback-calls.sh b/.github/actions/deploy-core/tests/assert-rollback-calls.sh new file mode 100755 index 00000000..a7b55748 --- /dev/null +++ b/.github/actions/deploy-core/tests/assert-rollback-calls.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Asserts the rollback verbs, paths, and explicit-target threading: +# * Activate/deactivate use PUT (the Fastly API rejects POST). +# * Staging rollback deactivates on the `staging` environment +# (`/deactivate/staging`), not the live one (`/deactivate`). +# * Production activates the EXPLICIT `rollback-to`, never the staged version: +# Fastly exposes no field to infer the previously-live version, so it must be +# passed in (rollback-to=40 here) rather than computed. +# +# Reads (env): +# FAKE_CALL_LOG required the fake fastly/curl call log +# EDGEZERO__TEST__STAGED_VERSION required the version the staged deploy produced +# EDGEZERO__TEST__ROLLED_BACK_TO required the production rollback's rolled-back-to output + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=../scripts/common.sh +source "$SCRIPT_DIR/../scripts/common.sh" + +main() { + local log="${FAKE_CALL_LOG:?FAKE_CALL_LOG is required}" + local staged="${EDGEZERO__TEST__STAGED_VERSION:?EDGEZERO__TEST__STAGED_VERSION is required}" + local rolled_back_to="${EDGEZERO__TEST__ROLLED_BACK_TO:-}" + local api='https://api\.fastly\.com/service/dummy-service' + # The EXPLICIT rollback-to the smoke passes to the production rollback (it rolls + # back FROM the active version 40 TO 39; the best-effort staleness guard requires the + # rolled-back-from version to still be active). + local target=39 + + echo "--- recorded fastly/curl calls:" + cat "$log" + + grep -qE "^PUT $api/version/$staged/deactivate/staging\$" "$log" || + fail "staging rollback did not PUT /version/$staged/deactivate/staging" + + # Production activates the EXPLICIT target, never the staged version (which is + # what a `version - 1` inference would have picked here). + if grep -qE "^PUT $api/version/$staged/activate\$" "$log"; then + fail "production rollback activated the STAGED version $staged" + fi + grep -qE "^PUT $api/version/$target/activate\$" "$log" || + fail "production rollback did not PUT /version/$target/activate (the explicit rollback-to)" + + [[ "$rolled_back_to" == "$target" ]] || + fail "expected rolled-back-to=$target, got '${rolled_back_to:-}'" + + notice "rollback used PUT with the correct paths; rolled-back-to=$target threaded out" +} + +main "$@" diff --git a/.github/actions/deploy-core/tests/assert-rollback-threaded.sh b/.github/actions/deploy-core/tests/assert-rollback-threaded.sh new file mode 100755 index 00000000..1eb96dce --- /dev/null +++ b/.github/actions/deploy-core/tests/assert-rollback-threaded.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Asserts the real production-rollback wiring: a production rollback consumes the +# deploy's `previous-version` output as `rollback-to` (no hardcoded version), and +# the version it activates threads back out as `rolled-back-to`. The fake Fastly +# API reports version 40 active before the deploy, so both must be 40. +# +# Reads (env): +# EDGEZERO__TEST__ROLLED_BACK_TO required the rollback's rolled-back-to output +# EDGEZERO__TEST__PREVIOUS_VERSION required the deploy's captured previous-version + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=../scripts/common.sh +source "$SCRIPT_DIR/../scripts/common.sh" + +main() { + local rolled_back_to="${EDGEZERO__TEST__ROLLED_BACK_TO:-}" + local previous_version="${EDGEZERO__TEST__PREVIOUS_VERSION:-}" + + [[ "$rolled_back_to" == "$previous_version" ]] || + fail "the rollback must activate the captured previous-version; rolled-back-to='${rolled_back_to:-}' != previous-version='${previous_version:-}'" + [[ "$rolled_back_to" == "40" ]] || + fail "expected the captured rollback target to be 40, got '${rolled_back_to:-}'" + + notice "production rollback activated the captured previous-version ($rolled_back_to)" +} + +main "$@" diff --git a/.github/actions/deploy-core/tests/assert-staged-calls.sh b/.github/actions/deploy-core/tests/assert-staged-calls.sh new file mode 100755 index 00000000..f8a9144b --- /dev/null +++ b/.github/actions/deploy-core/tests/assert-staged-calls.sh @@ -0,0 +1,114 @@ +#!/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 production's runtime overrides: the non-config +# override (LOG_LEVEL) is copied verbatim and the config selector is redirected +# to `_staging`, both written into the twin (STAGESEL1) before the +# relink. Without the mirror the staged version would lose production's adapter / +# logging overrides. +assert_twin_mirrors_production() { + local log="$1" + grep -qE '^fastly config-store-entry update .*--store-id=STAGESEL1 .*--key=EDGEZERO__ADAPTER__FASTLY__LOG_LEVEL' "$log" || + fail "production's non-config override was not mirrored into the staging twin" + grep -qE '^fastly config-store-entry update .*--store-id=STAGESEL1 .*--key=EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY' "$log" || + fail "the config selector was not written into the staging twin" + + # The mirror must land before the relink points the draft at the twin. + local mirror_line create_line + mirror_line=$(grep -nE '^fastly config-store-entry update .*--store-id=STAGESEL1' "$log" | head -n 1 | cut -d: -f1) + create_line=$(grep -n '^fastly resource-link create ' "$log" | head -n 1 | cut -d: -f1) + if [[ -n "$mirror_line" && -n "$create_line" ]] && ((mirror_line >= create_line)); then + fail "the twin must be mirrored BEFORE the draft is relinked to it" + fi +} + +# The staged draft must be re-pointed at the STAGING selector store, or it reads +# production config and `config push --staging` writes a key nothing reads. The +# link name stays `edgezero_runtime_env` (what the runtime opens); only the store +# behind it changes. +assert_relinked_to_staging_selector() { + local log="$1" + grep -qE '^fastly resource-link delete .*--id=LINK_ENV( |$)' "$log" || + fail "the staged deploy never dropped the inherited 'edgezero_runtime_env' link" + grep -qE '^fastly resource-link create .*--resource-id=STAGESEL1 .*--name=edgezero_runtime_env( |$)' "$log" || + fail "the staged deploy never linked the staging selector store as 'edgezero_runtime_env'" + + # Both must land while the version is still an editable draft. + local create_line stage_line + create_line=$(grep -n '^fastly resource-link create ' "$log" | head -n 1 | cut -d: -f1) + stage_line=$(grep -n '^fastly service-version stage ' "$log" | head -n 1 | cut -d: -f1) + if [[ -n "$create_line" && -n "$stage_line" ]] && ((create_line >= stage_line)); then + fail "the staging relink must happen BEFORE the version is staged" + fi +} + +main() { + local log="${FAKE_CALL_LOG:?FAKE_CALL_LOG is required}" + local staged_version="${EDGEZERO__TEST__STAGED_VERSION:-}" + + echo "--- recorded fastly/curl calls:" + cat "$log" + + local update + update=$(grep -E '^fastly compute update ' "$log" | head -n 1 || true) + [[ -n "$update" ]] || fail "the staged deploy never ran 'fastly compute update'" + + assert_update_flags "$update" + assert_no_comment_on_update "$log" + assert_comment_precedes_stage "$log" + assert_twin_mirrors_production "$log" + assert_relinked_to_staging_selector "$log" + + # The staged version must thread out of deploy-fastly, or the healthcheck and + # rollback that follow have nothing to act on. + [[ "$staged_version" == "42" ]] || + fail "expected fastly-version=42 out of the staged deploy, got '${staged_version:-}'" + + notice "staged call sequence is correct and fastly-version=$staged_version threaded out" +} + +main "$@" diff --git a/.github/actions/deploy-core/tests/assert-staging-probe.sh b/.github/actions/deploy-core/tests/assert-staging-probe.sh new file mode 100755 index 00000000..9c996ae0 --- /dev/null +++ b/.github/actions/deploy-core/tests/assert-staging-probe.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Asserts the staging health check resolved the staged version's IP and probed +# through it. +# +# Regression test: the Fastly domain API returns a SINGULAR `staging_ip` string +# per domain object (`staging_ips` is only the `include=` query param). Reading +# it as an array silently found no IP and probed PRODUCTION instead — a staging +# check that was quietly testing the wrong thing. +# +# Reads (env): +# FAKE_CALL_LOG required the fake fastly/curl call log +# EDGEZERO__TEST__STAGED_VERSION required the version the staged deploy produced +# EDGEZERO__TEST__HEALTHY required the healthcheck's `healthy` output +# EDGEZERO__TEST__STATUS_CODE required the healthcheck's `status-code` output + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=../scripts/common.sh +source "$SCRIPT_DIR/../scripts/common.sh" + +main() { + local log="${FAKE_CALL_LOG:?FAKE_CALL_LOG is required}" + local staged="${EDGEZERO__TEST__STAGED_VERSION:?EDGEZERO__TEST__STAGED_VERSION is required}" + local healthy="${EDGEZERO__TEST__HEALTHY:-}" + local status_code="${EDGEZERO__TEST__STATUS_CODE:-}" + + grep -qE "^GET https://api\.fastly\.com/service/dummy-service/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" || + fail "the probe was not rerouted to the staging IP (was the singular staging_ip read?)" + + # 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. + [[ "$healthy" == "true" ]] || + fail "expected the healthcheck output healthy=true, got '${healthy:-}'" + [[ "$status_code" == "200" ]] || + fail "expected the healthcheck output status-code=200, got '${status_code:-}'" + + notice "staging probe was rerouted through the resolved staging IP (healthy=true, status-code=200)" +} + +main "$@" diff --git a/.github/actions/deploy-core/tests/assert-stale-rollback-refused.sh b/.github/actions/deploy-core/tests/assert-stale-rollback-refused.sh new file mode 100755 index 00000000..9a5d3908 --- /dev/null +++ b/.github/actions/deploy-core/tests/assert-stale-rollback-refused.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Asserts a STALE production rollback is refused and mutates NOTHING. +# +# A rollback workflow can run long after its deploy. If a newer version became +# active meanwhile, activating the old target would clobber that newer deploy, so +# the rollback must fail — and crucially must NOT have issued ANY activate PUT. +# (The check is best-effort, not atomic: it cannot close the window between the +# read and the activation. Service-scoped serialization is what does that.) +# +# To avoid passing for the WRONG reason (an artifact-download or CLI-startup +# failure would also produce a failed outcome with no PUT), this inspects only +# the DELTA of calls the stale rollback made and requires positive evidence the +# staleness guard actually ran: the active-version GET must be present, no +# activate PUT may appear, and the fake API's active version must still be 99. +# +# Reads (env): +# FAKE_CALL_LOG required the fake fastly/curl call log +# FAKE_ACTIVE_VERSION_FILE required the fake API's active-version state +# EDGEZERO__TEST__OUTCOME required the stale rollback step's outcome +# EDGEZERO__TEST__LOG_SNAPSHOT required call-log line count BEFORE the rollback + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=../scripts/common.sh +source "$SCRIPT_DIR/../scripts/common.sh" + +main() { + local log="${FAKE_CALL_LOG:?FAKE_CALL_LOG is required}" + local state="${FAKE_ACTIVE_VERSION_FILE:?FAKE_ACTIVE_VERSION_FILE is required}" + local outcome="${EDGEZERO__TEST__OUTCOME:?EDGEZERO__TEST__OUTCOME is required}" + local snapshot="${EDGEZERO__TEST__LOG_SNAPSHOT:?EDGEZERO__TEST__LOG_SNAPSHOT is required}" + local api='https://api\.fastly\.com/service/dummy-service' + + [[ "$outcome" == "failure" ]] || + fail "a stale production rollback must fail (the rolled-back-from version is no longer active), got outcome=$outcome" + + # Only the calls the stale rollback itself made. + local delta + delta=$(tail -n "+$((snapshot + 1))" "$log") + echo "--- stale-rollback call delta:" + printf '%s\n' "$delta" + + # Positive evidence the staleness guard ran (not an earlier startup failure): + # it must have READ the active version. + grep -qE "^GET $api/version\$" <<<"$delta" || + fail "the stale rollback never issued the active-version GET — it may have failed BEFORE the staleness check (download/startup), not because of it" + + # And it must NOT have activated ANYTHING — refusal precedes every mutation. + if grep -qE "^PUT $api/version/[0-9]+/activate\$" <<<"$delta"; then + fail "a stale rollback issued an activate PUT; it must refuse before mutating" + fi + + # The active version is unchanged: the newer deploy (99) was not clobbered. + local active + active=$(cat "$state") + [[ "$active" == "99" ]] || + fail "the active version changed to '$active'; a stale rollback must leave it at 99" + + notice "stale production rollback read the active version, refused, and activated nothing (active still 99)" +} + +main "$@" diff --git a/.github/actions/deploy-core/tests/assert-unhealthy-failed.sh b/.github/actions/deploy-core/tests/assert-unhealthy-failed.sh new file mode 100755 index 00000000..49f06bf0 --- /dev/null +++ b/.github/actions/deploy-core/tests/assert-unhealthy-failed.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Asserts healthcheck-fastly FAILS CLOSED on an unhealthy probe. +# +# Callers gate their rollback on "the healthcheck step failed". If an unhealthy +# probe lets the action succeed, no caller would ever roll back — so this is the +# single most important contract in the lifecycle. +# +# Reads (env): +# EDGEZERO__TEST__OUTCOME required the healthcheck step's outcome (success/failure) +# EDGEZERO__TEST__HEALTHY required the healthcheck step's healthy output +# EDGEZERO__TEST__STATUS_CODE required the healthcheck step's status-code output + +main() { + local outcome="${EDGEZERO__TEST__OUTCOME:?EDGEZERO__TEST__OUTCOME is required}" + local healthy="${EDGEZERO__TEST__HEALTHY:-}" + local status_code="${EDGEZERO__TEST__STATUS_CODE:-}" + + if [[ "$outcome" != "failure" ]]; then + echo "::error::an unhealthy probe did not fail healthcheck-fastly (outcome=$outcome)" >&2 + return 1 + fi + # The verdict must be an EXPLICIT `false`, not merely "not true" — an empty + # output would mean the step failed without emitting a verdict at all. + if [[ "$healthy" != "false" ]]; then + echo "::error::an unhealthy probe must report healthy=false, got '${healthy:-}'" >&2 + return 1 + fi + # The fake probe returns 503 when FORCE_UNHEALTHY is set; the status-code must + # thread out so callers can see WHY it was unhealthy. + if [[ "$status_code" != "503" ]]; then + echo "::error::an unhealthy probe must report status-code=503, got '${status_code:-}'" >&2 + return 1 + fi + + echo "healthcheck-fastly failed closed on an unhealthy probe (healthy=false, status-code=503)" +} + +main "$@" diff --git a/.github/actions/deploy-core/tests/check-action-pins.sh b/.github/actions/deploy-core/tests/check-action-pins.sh new file mode 100755 index 00000000..6e9189f1 --- /dev/null +++ b/.github/actions/deploy-core/tests/check-action-pins.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Verifies every third-party `uses:` reference across the deploy actions and the +# deploy-action workflow is pinned to a concrete ref (a readable released tag or +# a full commit SHA) rather than a floating branch like @main or an unpinned +# reference. Local (`./...`) and docker refs are exempt. + +REPO_ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../../../.." && pwd) + +files=( + "$REPO_ROOT/.github/workflows/deploy-action.yml" + "$REPO_ROOT/.github/workflows/fastly-installer-check.yml" + "$REPO_ROOT/.github/actions/build-app-cli/action.yml" + "$REPO_ROOT/.github/actions/deploy-core" + "$REPO_ROOT/.github/actions/deploy-fastly/action.yml" + "$REPO_ROOT/.github/actions/healthcheck-fastly/action.yml" + "$REPO_ROOT/.github/actions/rollback-fastly/action.yml" + "$REPO_ROOT/.github/actions/config-push-fastly/action.yml" +) + +status=0 +while IFS= read -r line; do + # line format: :: + ref=$(printf '%s' "$line" | sed -nE 's/.*uses:[[:space:]]*//p' | tr -d '"'"'"'') + [[ -z "$ref" ]] && continue + case "$ref" in + ./* | docker://*) continue ;; + esac + if [[ ! "$ref" == *@* ]]; then + echo "::error::unpinned action reference (no @ref): $line" >&2 + status=1 + continue + fi + suffix="${ref##*@}" + case "$suffix" in + main | master | HEAD) + echo "::error::action pinned to a floating ref '@$suffix': $line" >&2 + status=1 + ;; + esac +done < <(grep -rEn '^[[:space:]]*(-[[:space:]]+)?uses:' "${files[@]}" 2>/dev/null || true) + +if [[ "$status" -eq 0 ]]; then + echo "all third-party action references are pinned to a concrete ref" +fi +exit "$status" diff --git a/.github/actions/deploy-core/tests/make-fake-fastly-env.sh b/.github/actions/deploy-core/tests/make-fake-fastly-env.sh new file mode 100755 index 00000000..a292574c --- /dev/null +++ b/.github/actions/deploy-core/tests/make-fake-fastly-env.sh @@ -0,0 +1,267 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Installs fake `fastly` and `curl` binaries for the lifecycle smoke test, plus a +# call log the assertions read back. +# +# The fakes mirror the REAL contracts the adapter depends on, so the smoke test +# exercises the exact call shapes that matter: +# * `fastly compute update` must NOT receive --comment (it has no such flag); +# the comment goes through `fastly service-version update` BEFORE +# `service-version stage`. +# * `compute update` output must be a realistic success line, because the +# version parser is fail-closed and refuses to guess. +# * The Fastly domain API returns a SINGULAR `staging_ip` string. +# * activate/deactivate are PUT, and staging deactivate is /deactivate/staging. +# +# The fake `fastly` is packaged as a tar.gz at install-fastly.sh's cache path and +# the checked-out `versions.json` is repointed at it with a matching SHA-256, so +# install-fastly.sh VERIFIES and extracts the fake through its real +# download+checksum+extract path — never adopting a planted binary. That lets the +# staged path be exercised through the real deploy-fastly wrapper while keeping +# the installer's provenance guarantee intact. The fake `curl` goes on PATH, +# which nothing reinstalls. +# +# The fake binaries write their call log to FAKE_CALL_LOG and read FORCE_UNHEALTHY. +# These are deliberately OUTSIDE the EDGEZERO__ namespace: the app CLI scrubs +# every EDGEZERO__* var before exec, and these must survive that scrub because +# the fake fastly/curl are spawned BY the app CLI and read them there. +# +# Reads (env): GITHUB_WORKSPACE, GITHUB_PATH, GITHUB_ENV, RUNNER_TEMP. +# Writes (env): FAKE_CALL_LOG (the call-log path). + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=../scripts/common.sh +source "$SCRIPT_DIR/../scripts/common.sh" + +write_fake_fastly() { + local path="$1" version="$2" + cat >"$path" <>"\$FAKE_CALL_LOG" +case "\${1:-} \${2:-}" in + "version ") echo "Fastly CLI version v$version (fake)" ;; + "compute build") echo "Built package (fixture)" ;; + "compute update") + # A realistic success line: the version parser is fail-closed and will + # refuse to stage if it cannot read a version out of this output. + echo "SUCCESS: Updated package (service dummy-service, version 42)" + ;; + "compute deploy") echo "SUCCESS: Deployed package (service dummy-service, version 43)" ;; + "service-version update") echo "Updated version comment" ;; + "service-version stage") echo "Staged version" ;; + # An app WITH config selection: the app config store, the production selector + # store edgezero_runtime_env (so a staged deploy relinks rather than skipping), + # and its staging twin (the store the relink points at). config push resolves a + # store id by name from this list, reads the current entry to diff, then upserts. + "config-store list") echo '[{"id":"STOREID1","name":"app_config"},{"id":"ENVSEL1","name":"edgezero_runtime_env"},{"id":"STAGESEL1","name":"edgezero_runtime_env_staging_dummy-service"}]' ;; + # A cloned draft inherits the active version's links; the staged deploy drops + # this one and re-links the staging store under the same name. + "resource-link list") echo '[{"id":"LINK_ENV","name":"edgezero_runtime_env"}]' ;; + "resource-link delete") echo "SUCCESS: Deleted resource link" ;; + "resource-link create") echo "SUCCESS: Created resource link" ;; + "config-store-entry describe") + # Report the key as absent so the push proceeds to a first write. The real + # CLI distinguishes "missing" from "unparseable" — returning nothing at all + # is a parse error, not an absent key. + echo "Error: config store entry not found" >&2 + exit 1 + ;; + "config-store-entry list") + # A staged deploy MIRRORS the production selector store into the staging twin. + # Production (ENVSEL1) carries a non-config override the twin must copy + # verbatim; the twin (STAGESEL1) starts empty. + case "\$*" in + *--store-id=ENVSEL1*) echo '[{"item_key":"EDGEZERO__ADAPTER__FASTLY__LOG_LEVEL","item_value":"debug"}]' ;; + *) echo '[]' ;; + esac + ;; + "config-store-entry update") echo "SUCCESS: Updated config store entry" ;; + "config-store-entry delete") echo "SUCCESS: Deleted config store entry" ;; + *) + case "\${1:-}" in + version | --version) echo "Fastly CLI version v$version (fake)" ;; + # An UNHANDLED command must fail: an unexpected provider call (a new command + # the code started issuing) should break the smoke, not pass silently. + *) echo "fake fastly: unhandled command: \$*" >&2; exit 90 ;; + esac + ;; +esac +exit 0 +SHIM + chmod +x "$path" +} + +write_fake_curl() { + local path="$1" + cat >"$path" <<'SHIM' +#!/usr/bin/env bash +# The versions this fake service has. The ACTIVE one is tracked separately (in +# FAKE_ACTIVE_VERSION_FILE) and is always considered to exist. +FAKE_KNOWN_VERSIONS="1 38 39 40 41 42" + +fake_active_version() { + local active + active=$(cat "${FAKE_ACTIVE_VERSION_FILE:-/dev/null}" 2>/dev/null || true) + printf '%s' "${active:-40}" +} + +fake_version_exists() { + local want="$1" active + active=$(fake_active_version) + case " $FAKE_KNOWN_VERSIONS $active " in + *" $want "*) return 0 ;; + *) return 1 ;; + esac +} + +# Render the version list the Fastly API would return: every known version, with +# `active: true` on exactly the current one. +fake_version_list_json() { + local active out="" sep="" n flag + active=$(fake_active_version) + local all="$FAKE_KNOWN_VERSIONS" + case " $all " in *" $active "*) ;; *) all="$all $active" ;; esac + for n in $all; do + if [ "$n" = "$active" ]; then flag=true; else flag=false; fi + out="$out$sep{\"number\":$n,\"active\":$flag}" + sep="," + done + printf '[%s]' "$out" +} + +# Two shapes: a Fastly API call via `--config -` (config on stdin), or a probe. +if [[ "$*" == *"--config"* ]]; then + config=$(cat) + url=$(printf '%s\n' "$config" | sed -nE 's/^url = "(.*)"$/\1/p') + if printf '%s\n' "$config" | grep -q '^request = "PUT"$'; then + printf 'PUT %s\n' "$url" >>"$FAKE_CALL_LOG" + case "$url" in + */version/*/activate) + activated="${url##*/version/}" + activated="${activated%%/activate}" + # A real API rejects activating a version the service does not have, so + # the fixture must too — otherwise a smoke could "succeed" against a + # version that never existed. + if ! fake_version_exists "$activated"; then + printf 'PUT-REJECTED %s (no such version)\n' "$url" >>"$FAKE_CALL_LOG" + echo 404 + exit 0 + fi + # Model reality: activating version N makes N the active version, so a + # later read (e.g. another rollback's staleness check) sees the mutation. + if [ -n "${FAKE_ACTIVE_VERSION_FILE:-}" ]; then + printf '%s\n' "$activated" >"$FAKE_ACTIVE_VERSION_FILE" + fi + ;; + esac + echo 200 + exit 0 + fi + printf 'GET %s\n' "$url" >>"$FAKE_CALL_LOG" + # fastly_api_get appends `write-out = "\n%{http_code}"`, so the real curl emits + # `\n` and the caller requires a 2xx. Mirror that: body, then a + # trailing `\n200`, with NO trailing newline after the code. + # + # The service-version list. The ACTIVE version is read from a state file so the + # smoke can model reality: it is 40 before the production deploy (rollback-target + # capture), and a deploy (or a test step) updates it. The production-rollback + # best-effort staleness guard requires the active version to equal the `--version` + # being rolled back from. Every version the fixture may activate is listed, so a + # rollback target is a version the service actually has. + if [[ "$url" == */version ]]; then + printf '%s\n200' "$(fake_version_list_json)" + exit 0 + fi + # Domain lookup: Fastly returns a SINGULAR `staging_ip` string per domain. + printf '[{"name":"staging.example.com","staging_ip":"151.101.2.10"}]\n200' + exit 0 +fi +printf 'PROBE %s\n' "$*" >>"$FAKE_CALL_LOG" +# Record whether a provider token was in scope for this probe. A PRODUCTION +# healthcheck just curls the public domain and must receive NO token, even when +# one is inherited from the job env; a staging probe needs one (staging-IP +# resolution). The assertions read this back. +printf 'PROBE-TOKEN=%s\n' "${FASTLY_API_TOKEN:+set}" >>"$FAKE_CALL_LOG" +if [[ -n "${FORCE_UNHEALTHY:-}" ]]; then + echo 503 +else + echo 200 +fi +exit 0 +SHIM + chmod +x "$path" +} + +main() { + local workspace="${GITHUB_WORKSPACE:?GITHUB_WORKSPACE is required}" + local runner_temp="${RUNNER_TEMP:?RUNNER_TEMP is required}" + local action_dir + action_dir=$(cd -- "$SCRIPT_DIR/../../deploy-fastly" && pwd) + local path_dir="$workspace/fake-bin" + # install-fastly.sh extracts the provider CLI from a checksum-verified archive + # into `/provider-bin`, caching the archive under `downloads/`. + # Deliver the fake THROUGH that verified path (not by planting a binary), so the + # smoke exercises the real download+verify+extract and never relies on a bypass. + local downloads="$runner_temp/edgezero-action-tools/downloads" + local log="$workspace/fake-calls.log" + + mkdir -p "$path_dir" "$downloads" + : >"$log" + + local pinned + pinned=$(json_get "$action_dir/versions.json" fastly.version) + + # Regression guard against the installer trust-by-version-text bypass: plant a + # DIFFERENT binary at the extract target that REPORTS the pinned version but + # errors on every real command. install-fastly.sh must overwrite it from the + # verified archive; if it ever went back to adopting a pre-seeded binary by its + # version text, THIS one would survive and the staged deploy's first real + # `fastly` call would exit non-zero — failing the smoke. + local provider_bin="$runner_temp/edgezero-action-tools/provider-bin" + mkdir -p "$provider_bin" + # `$1`/`$2` here are the PLANTED script's args, not this script's — intentionally + # literal (single-quoted printf format). + # shellcheck disable=SC2016 + printf '#!/bin/sh\ncase "$1" in\n version|--version) echo "Fastly CLI version v%s (planted, should have been overwritten)";;\n *) echo "install-fastly adopted a planted binary instead of extracting the verified archive" >&2; exit 97;;\nesac\n' "$pinned" >"$provider_bin/fastly" + chmod +x "$provider_bin/fastly" + + # Package a fake `fastly` as the archive install-fastly.sh expects, pre-placed + # at its cache path so no network fetch happens. + local stage archive sha + stage=$(mktemp -d) + write_fake_fastly "$stage/fastly" "$pinned" + archive="$downloads/fastly-$pinned-linux-amd64.tar.gz" + tar -C "$stage" -czf "$archive" fastly + sha=$(sha256_file "$archive") + + # Repoint the CHECKED-OUT versions.json (what the local action reads) at the + # fake archive with its real checksum, so install-fastly.sh verifies and + # extracts the fake. The version stays pinned, so the `.tool-versions` + # agreement check still holds. This modifies only the job's checkout, never a + # committed file — production reads the real, pinned versions.json. + local patched + patched=$(mktemp) + jq --arg url "file://$archive" --arg sha "$sha" \ + '.fastly.linux_amd64.url = $url | .fastly.linux_amd64.sha256 = $sha' \ + "$action_dir/versions.json" >"$patched" + mv "$patched" "$action_dir/versions.json" + + write_fake_curl "$path_dir/curl" + + # The active version the fake Fastly API reports, in a file so a deploy or a + # test step can update it (see the production-rollback guard). Starts at 40 — + # the version rollback-target capture sees BEFORE the first production deploy. + local active_state="$workspace/fake-active-version" + printf '40\n' >"$active_state" + + printf '%s\n' "$path_dir" >>"${GITHUB_PATH:?GITHUB_PATH is required}" + { + printf 'FAKE_CALL_LOG=%s\n' "$log" + printf 'FAKE_ACTIVE_VERSION_FILE=%s\n' "$active_state" + } >>"${GITHUB_ENV:?GITHUB_ENV is required}" + + notice "fake fastly (v$pinned) packaged as a checksum-verified archive at $archive; fake curl on PATH" +} + +main "$@" diff --git a/.github/actions/deploy-core/tests/make-smoke-fixture.sh b/.github/actions/deploy-core/tests/make-smoke-fixture.sh new file mode 100755 index 00000000..3077583d --- /dev/null +++ b/.github/actions/deploy-core/tests/make-smoke-fixture.sh @@ -0,0 +1,174 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Builds the fixture the composite smoke test deploys. +# +# This is a REAL app-owned CLI: a standalone Cargo workspace (kept out of the +# surrounding edgezero workspace) whose own crate depends on `edgezero-cli` and +# exposes deploy / healthcheck / rollback. That exercises the actual contract — +# "the application provides the CLI package" — instead of building the monorepo's +# own CLI. +# +# The Fastly deploy command is overridden by a marker script that emits +# `version=` (version threading), records the credentials it actually saw +# (provider-env boundary), and records its argv — all without contacting Fastly. +# +# Inputs (environment): GITHUB_WORKSPACE (required). + +main() { + local workspace="${GITHUB_WORKSPACE:?GITHUB_WORKSPACE is required}" + local app_dir="$workspace/fixture-app" + + mkdir -p "$app_dir/crates/fixture-app-cli/src" + cd "$app_dir" + + git init -q + git config user.email test@example.com + git config user.name Test + + # Standalone workspace: not a member of the surrounding edgezero workspace. + cat >Cargo.toml <<'TOML' +[workspace] +members = ["crates/fixture-app-cli"] +resolver = "2" +TOML + + # The app's OWN CLI crate, built on edgezero-cli (path dep into the checkout). + cat >crates/fixture-app-cli/Cargo.toml <<'TOML' +[package] +name = "fixture-app-cli" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "fixture-app-cli" +path = "src/main.rs" + +[dependencies] +edgezero-cli = { path = "../../../crates/edgezero-cli", default-features = false, features = [ + "cli", + "edgezero-adapter-fastly", +] } +clap = { version = "4", features = ["derive"] } +edgezero-core = { path = "../../../crates/edgezero-core" } +serde = { version = "1", features = ["derive"] } +validator = { version = "0.20", features = ["derive"] } +TOML + + cat >crates/fixture-app-cli/src/main.rs <<'RS' +//! Fixture app CLI: the smoke test's stand-in for an application-owned CLI. +//! +//! It wires the TYPED `config push` (not the bundled stub), because that is the +//! contract config-push-fastly depends on: only an app-owned CLI has the +//! app-config struct, so only it can push typed config. +use clap::{Parser, Subcommand}; +use edgezero_cli::args::{ActiveVersionArgs, ConfigPushArgs, DeployArgs, HealthcheckArgs, RollbackArgs}; +use serde::{Deserialize, Serialize}; +use validator::Validate; + +/// The fixture's typed app config, loaded from `fixture-app.toml`. +#[derive(Debug, Deserialize, Serialize, Validate, edgezero_core::AppConfig)] +#[serde(deny_unknown_fields)] +struct FixtureAppConfig { + greeting: String, +} + +#[derive(Parser, Debug)] +#[command(name = "fixture-app-cli", version, about = "fixture app edge CLI")] +struct Args { + #[command(subcommand)] + cmd: Cmd, +} + +#[derive(Subcommand, Debug)] +enum Cmd { + #[command(subcommand)] + Config(ConfigCmd), + Deploy(DeployArgs), + Healthcheck(HealthcheckArgs), + ActiveVersion(ActiveVersionArgs), + Rollback(RollbackArgs), +} + +#[derive(Subcommand, Debug)] +enum ConfigCmd { + Push(ConfigPushArgs), +} + +fn main() { + edgezero_cli::init_cli_logger(); + let result = match Args::parse().cmd { + Cmd::Config(ConfigCmd::Push(args)) => { + edgezero_cli::run_config_push_typed::(&args) + } + Cmd::Deploy(args) => edgezero_cli::run_deploy(&args), + Cmd::Healthcheck(args) => edgezero_cli::run_healthcheck(&args), + Cmd::ActiveVersion(args) => edgezero_cli::run_active_version(&args), + Cmd::Rollback(args) => edgezero_cli::run_rollback(&args), + }; + if let Err(err) = result { + eprintln!("[fixture-app] {err}"); + std::process::exit(2); + } +} +RS + + # Marker "deploy" the CLI runs instead of `fastly compute deploy`. It records + # the credentials it saw and its argv, and emits a version line. + cat >fake-deploy.sh <<'SH' +#!/usr/bin/env bash +{ + printf 'token=%s\n' "${FASTLY_API_TOKEN:-MISSING}" + printf 'service-id=%s\n' "${FASTLY_SERVICE_ID:-MISSING}" + # Boundary: inherited provider aliases must have been cleared... + printf 'endpoint=%s\n' "${FASTLY_ENDPOINT:-CLEARED}" + printf 'home=%s\n' "${FASTLY_HOME:-CLEARED}" + # ...and the action's own secret-bearing helpers must NOT have survived into + # this process: they carry the raw token under names we never promised. + printf 'action-token-carrier=%s\n' "${EDGEZERO__FASTLY__API_TOKEN:-CLEARED}" + printf 'provider-env-json=%s\n' "${EDGEZERO__PROVIDER__ENV:-CLEARED}" +} >"${GITHUB_WORKSPACE}/fixture-app/env-seen.txt" +printf '%s\n' "$@" >"${GITHUB_WORKSPACE}/fixture-app/deploy-argv.txt" +# Reflect the activation in the fake Fastly API's state: this "deploy" makes +# version 7 the active one, so the production-rollback guard (which requires the +# rolled-back-from --version to still be active) sees 7 rather than the 40 that +# capture saw before this deploy. +[ -n "${FAKE_ACTIVE_VERSION_FILE:-}" ] && printf '7\n' >"${FAKE_ACTIVE_VERSION_FILE}" +echo "version=7" +SH + chmod +x fake-deploy.sh + + cat >edgezero.toml <<'ETOML' +[app] +name = "fixture-app" + +[adapters.fastly.commands] +deploy = "bash fake-deploy.sh" + +# config push resolves this logical id, then the Fastly adapter matches it by +# name against `fastly config-store list --json`. +[stores.config] +ids = ["app_config"] +default = "app_config" +ETOML + + # The typed app config `config push` reads (named from `[app].name`). + cat >fixture-app.toml <<'ATOML' +greeting = "hello from the fixture" +ATOML + + # The staged-deploy path bypasses manifest commands and drives the Fastly CLI, + # so it needs a Fastly manifest to resolve its working directory. + cat >fastly.toml <<'FTOML' +manifest_version = 3 +name = "fixture-app" +language = "rust" +FTOML + + cargo generate-lockfile + + git add -A + git commit -q -m fixture +} + +main "$@" diff --git a/.github/actions/deploy-core/tests/run.sh b/.github/actions/deploy-core/tests/run.sh new file mode 100755 index 00000000..85df7d90 --- /dev/null +++ b/.github/actions/deploy-core/tests/run.sh @@ -0,0 +1,1814 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Contract tests for the EdgeZero deploy actions. +# +# Pure Bash: no Python, no network, no 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) +WORK_DIR=$(mktemp -d) +readonly REPO_ROOT WORK_DIR +readonly ACTIONS_DIR="$REPO_ROOT/.github/actions" +readonly CORE_SCRIPTS="$ACTIONS_DIR/deploy-core/scripts" +trap 'rm -rf "$WORK_DIR"' EXIT + +# --------------------------------------------------------------------------- +# Tiny assertion harness +# --------------------------------------------------------------------------- +tests_passed=0 +tests_failed=0 + +pass() { + tests_passed=$((tests_passed + 1)) + printf ' \033[32mok\033[0m %s\n' "$1" +} + +fail() { + tests_failed=$((tests_failed + 1)) + printf ' \033[31mFAIL\033[0m %s\n' "$1" >&2 +} + +# assert_succeeds "" +assert_succeeds() { + local description="$1" + shift + if "$@" >/dev/null 2>&1; then pass "$description"; else fail "$description"; fi +} + +# assert_fails "" +assert_fails() { + local description="$1" + shift + if "$@" >/dev/null 2>&1; then fail "$description (expected non-zero exit)"; else pass "$description"; fi +} + +# assert_fails_with "" "" +# Asserts the command fails AND fails for the expected REASON. A bare exit-code +# check can pass by accident when the command would have failed later anyway +# (e.g. a missing required variable AFTER the scrub check we meant to exercise). +assert_fails_with() { + local description="$1" needle="$2" + shift 2 + local out + if out=$("$@" 2>&1); then + fail "$description (expected non-zero exit)" + elif [[ "$out" == *"$needle"* ]]; then + pass "$description" + else + fail "$description (failed, but not with: $needle)" + fi +} + +# assert_equals "" "" "" +assert_equals() { + local description="$1" expected="$2" actual="$3" + if [[ "$expected" == "$actual" ]]; then + pass "$description" + else + fail "$description" + diff <(printf '%s\n' "$expected") <(printf '%s\n' "$actual") >&2 || true + fi +} + +section() { printf '\n== %s ==\n' "$1"; } + +# --------------------------------------------------------------------------- +# validate-inputs.sh — provider-neutral input validation +# --------------------------------------------------------------------------- +# Runs validate-inputs in a clean environment. Inputs are supplied by the +# caller through the VALIDATE_* variables (all optional; sane defaults below). +run_validate_inputs() { + local state_dir + state_dir=$(mktemp -d "$WORK_DIR/validate.XXXXXX") + env -i PATH="$PATH" \ + EDGEZERO__ADAPTER="${VALIDATE_ADAPTER:-fastly}" \ + EDGEZERO__BUILD__CACHE="${VALIDATE_CACHE:-false}" \ + EDGEZERO__BUILD__MODE="${VALIDATE_BUILD_MODE:-auto}" \ + EDGEZERO__BUILD__ARGS="${VALIDATE_BUILD_ARGS:-[]}" \ + EDGEZERO__DEPLOY__ARGS="${VALIDATE_DEPLOY_ARGS:-[]}" \ + EDGEZERO__DEPLOY__FLAGS="${VALIDATE_DEPLOY_FLAGS:-[]}" \ + EDGEZERO__PROVIDER__ENV_CLEAR="${VALIDATE_PROVIDER_ENV_CLEAR:-[]}" \ + EDGEZERO__DEPLOY__ARG_ALLOW="${VALIDATE_ALLOW:-}" \ + EDGEZERO__DEPLOY__STAGE="${VALIDATE_STAGE:-false}" \ + EDGEZERO__ACTION__STATE_DIR="$state_dir" \ + GITHUB_OUTPUT="$state_dir/output.txt" \ + bash "$CORE_SCRIPTS/validate-inputs.sh" +} + +test_validate_inputs() { + section "validate-inputs" + VALIDATE_ADAPTER=fastly assert_succeeds "accepts a well-formed adapter" run_validate_inputs + VALIDATE_ADAPTER=FASTLY assert_fails "rejects a malformed adapter" run_validate_inputs + VALIDATE_CACHE=maybe assert_fails "rejects a non-boolean cache" run_validate_inputs + VALIDATE_STAGE=true assert_succeeds "accepts stage=true" run_validate_inputs + VALIDATE_STAGE=True assert_fails "rejects a non-boolean stage (typo -> no silent prod)" run_validate_inputs + VALIDATE_DEPLOY_ARGS='["--comment","hi"]' VALIDATE_ALLOW='--comment' \ + assert_succeeds "allows an allowlisted deploy-arg (--comment)" run_validate_inputs + VALIDATE_DEPLOY_ARGS='["--service-id","x"]' VALIDATE_ALLOW='--comment' \ + assert_fails "rejects a non-allowlisted deploy-arg (--service-id)" run_validate_inputs + VALIDATE_DEPLOY_ARGS='"not-an-array"' assert_fails "rejects non-array deploy-args" run_validate_inputs + VALIDATE_BUILD_ARGS='[1,2]' assert_fails "rejects non-string build-args" run_validate_inputs +} + +# --------------------------------------------------------------------------- +# build-app-cli artifact-name — never usable as a path traversal +# --------------------------------------------------------------------------- +check_artifact_name() { + # Run validate_artifact_name from build-app-cli's common.sh in a subshell. + bash -c 'source "$1"; validate_artifact_name "$2"' _ \ + "$ACTIONS_DIR/build-app-cli/scripts/common.sh" "$1" +} + +test_artifact_name() { + section "build-app-cli artifact-name" + assert_succeeds "accepts a conservative artifact name" check_artifact_name "edgezero-cli.v1" + assert_fails "rejects path traversal ('../x')" check_artifact_name "../x" + assert_fails "rejects path separators ('a/b')" check_artifact_name "a/b" + assert_fails "rejects a leading dot" check_artifact_name ".hidden" + assert_fails "rejects an empty name" check_artifact_name "" +} + +# --------------------------------------------------------------------------- +# build-app-cli reset_owned_dir — never rm -rf outside the action-owned temp root +# --------------------------------------------------------------------------- +check_owned_dir() { + bash -c 'source "$1"; reset_owned_dir "$2" "$3"' _ \ + "$ACTIONS_DIR/build-app-cli/scripts/common.sh" "$1" "$2" +} + +test_owned_dir_confinement() { + section "build-app-cli owned-dir confinement" + local temp_root="$WORK_DIR/temproot" + mkdir -p "$temp_root" + assert_succeeds "recreates a dir beneath the temp root" \ + check_owned_dir "$temp_root/build" "$temp_root" + # An inherited value pointing at the checkout must be refused, not deleted. + assert_fails "refuses a dir outside the temp root (would delete the checkout)" \ + check_owned_dir "$WORK_DIR/not-temp" "$temp_root" + assert_fails "refuses a traversal path" \ + check_owned_dir "$temp_root/../escape" "$temp_root" + # Prove the refusal did not delete anything. + mkdir -p "$WORK_DIR/not-temp" + check_owned_dir "$WORK_DIR/not-temp" "$temp_root" >/dev/null 2>&1 || true + if [[ -d "$WORK_DIR/not-temp" ]]; then + pass "the refused directory still exists (nothing was removed)" + else + fail "the refused directory was deleted" + fi +} + +# --------------------------------------------------------------------------- +# download-app-cli — app-cli-bin confinement + unsafe archive rejection +# --------------------------------------------------------------------------- +check_cli_bin() { + bash -c 'source "$1"; validate_cli_bin "$2"' _ "$CORE_SCRIPTS/common.sh" "$1" +} + +check_tarball() { + bash -c 'source "$1"; assert_safe_tarball "$2"' _ "$CORE_SCRIPTS/common.sh" "$1" +} + +test_cli_bin_confinement() { + section "download-app-cli app-cli-bin + archive safety" + assert_succeeds "accepts a bare app-cli-bin" check_cli_bin "myapp-cli" + assert_fails "rejects a traversal app-cli-bin ('../../outside/tool')" check_cli_bin "../../outside/tool" + assert_fails "rejects an app-cli-bin with a separator" check_cli_bin "sub/tool" + assert_fails "rejects an empty app-cli-bin" check_cli_bin "" + + # A tar carrying a symlink member must be refused before extraction. + local evil="$WORK_DIR/evil" + mkdir -p "$evil/stage" + ln -sf /etc/passwd "$evil/stage/pwned" + tar -C "$evil/stage" -cf "$evil/evil.tar" pwned 2>/dev/null + assert_fails "refuses an archive containing a symlink member" check_tarball "$evil/evil.tar" + + # A well-formed archive is accepted. + local good="$WORK_DIR/good" + mkdir -p "$good/stage" + echo x >"$good/stage/myapp-cli" + printf '{}' >"$good/stage/app-cli-meta.json" + tar -C "$good/stage" -cf "$good/good.tar" myapp-cli app-cli-meta.json + assert_succeeds "accepts a well-formed archive" check_tarball "$good/good.tar" +} + +# --------------------------------------------------------------------------- +# run-app-cli.sh — provider-env credential boundary +# --------------------------------------------------------------------------- +# A fake CLI records the FASTLY_* it actually saw; run-cli must clear inherited +# aliases and export only the declared, typed values. +test_provider_env_boundary() { + section "run-cli provider-env boundary" + + local bin_dir="$WORK_DIR/pe-bin" app_dir="$WORK_DIR/pe-app" + local seen="$WORK_DIR/pe-seen.txt" clear="$WORK_DIR/pe-clear.nul" + mkdir -p "$bin_dir" "$app_dir" + cat >"$bin_dir/fakecli" <"$seen" +EOF + chmod +x "$bin_dir/fakecli" + printf 'FASTLY_API_TOKEN\0FASTLY_ENDPOINT\0' >"$clear" + + run_deploy_pe() { + env -i PATH="$bin_dir:$PATH" \ + EDGEZERO__APP__CLI__BIN=fakecli EDGEZERO__ADAPTER=fastly \ + EDGEZERO__PROJECT__WORKING_DIRECTORY="$app_dir" \ + EDGEZERO__PROVIDER__ENV_CLEAR_FILE="$clear" \ + EDGEZERO__PROVIDER__ENV="$1" \ + FASTLY_API_TOKEN=inherited-BAD FASTLY_ENDPOINT=https://inherited.invalid \ + bash "$CORE_SCRIPTS/run-app-cli.sh" deploy + } + + if run_deploy_pe '{"FASTLY_API_TOKEN":"typed-tok"}' >/dev/null 2>&1; then + assert_equals "typed token wins; inherited endpoint cleared" \ + $'TOKEN=typed-tok\nENDPOINT=unset' "$(cat "$seen")" + else + fail "run-cli deploy (provider-env) failed to execute" + fi + + # A provider-env name not declared in provider-env-clear is rejected. + assert_fails "rejects an undeclared provider-env name" \ + run_deploy_pe '{"FASTLY_TOKEN":"x"}' +} + +# --------------------------------------------------------------------------- +# run-app-cli.sh — CLI argv construction +# --------------------------------------------------------------------------- +# Installs a fake CLI that records its argv, then asserts run-cli places typed +# deploy-flags before `--` and caller passthrough after `--`. +test_run_cli_argv() { + section "run-cli argv" + + local bin_dir="$WORK_DIR/bin" + local argv_file="$WORK_DIR/recorded-argv.txt" + local app_dir="$WORK_DIR/app" + mkdir -p "$bin_dir" "$app_dir" + + cat >"$bin_dir/fakecli" <"$argv_file" +EOF + chmod +x "$bin_dir/fakecli" + + # NUL-delimited argument files, exactly as validate-inputs would emit them. + printf -- '--service-id\0abc\0--stage\0' >"$WORK_DIR/deploy-flags.nul" + printf -- '--comment\0hello\0' >"$WORK_DIR/deploy-args.nul" + + if env -i PATH="$bin_dir:$PATH" \ + EDGEZERO__APP__CLI__BIN=fakecli \ + EDGEZERO__ADAPTER=fastly \ + EDGEZERO__PROJECT__WORKING_DIRECTORY="$app_dir" \ + EDGEZERO__DEPLOY__FLAGS_FILE="$WORK_DIR/deploy-flags.nul" \ + EDGEZERO__DEPLOY__ARGS_FILE="$WORK_DIR/deploy-args.nul" \ + bash "$CORE_SCRIPTS/run-app-cli.sh" deploy >/dev/null 2>&1; then + local expected + expected=$'deploy\n--adapter\nfastly\n--service-id\nabc\n--stage\n--\n--comment\nhello' + assert_equals "flags precede --, passthrough follows --" "$expected" "$(cat "$argv_file")" + else + fail "run-cli deploy failed to execute" + fi +} + +# --------------------------------------------------------------------------- +# download-app-cli.sh — self-describing artifact +# --------------------------------------------------------------------------- +# Builds a fake artifact tar (binary + app-cli-meta.json) and asserts download-cli +# extracts it and surfaces the metadata. +test_download_cli_metadata() { + section "download-app-cli metadata" + + local artifact_dir="$WORK_DIR/artifact" + local stage_dir="$artifact_dir/stage" + mkdir -p "$stage_dir" + + cat >"$stage_dir/myapp-cli" <<'EOF' +#!/usr/bin/env bash +exit 0 +EOF + chmod +x "$stage_dir/myapp-cli" + printf '{"app-cli-bin":"myapp-cli","app-cli-version":"1.2.3","app-cli-package":"myapp-cli"}\n' \ + >"$stage_dir/app-cli-meta.json" + tar -C "$stage_dir" -cf "$artifact_dir/edgezero-cli.tar" myapp-cli app-cli-meta.json + + local output_file="$WORK_DIR/download-output.txt" + if env -i PATH="$PATH" \ + EDGEZERO__APP__CLI__ARTIFACT_DIR="$artifact_dir" \ + EDGEZERO__ACTION__TOOL_ROOT="$WORK_DIR/tools" \ + GITHUB_OUTPUT="$output_file" \ + GITHUB_PATH="$WORK_DIR/download-path.txt" \ + bash "$CORE_SCRIPTS/download-app-cli.sh" >/dev/null 2>&1; then + if grep -qx 'app-cli-bin=myapp-cli' "$output_file" && grep -qx 'app-cli-version=1.2.3' "$output_file"; then + pass "extracts the tar and reads app-cli-meta.json" + else + fail "download-app-cli did not surface the expected metadata" + fi + # The ABSOLUTE path output is what lets callers dodge PATH shadowing. + if grep -qx "app-cli-path=$WORK_DIR/tools/bin/myapp-cli" "$output_file"; then + pass "emits the absolute app-cli-path" + else + fail "download-app-cli did not emit app-cli-path" + fi + else + fail "download-app-cli failed to execute" + fi +} + +# --------------------------------------------------------------------------- +# wrapper validate.sh — the per-wrapper input validation (now scripts, not inline +# YAML, so it is shellcheck'd AND testable). GitHub does not enforce +# `required: true`, so these guards are the real gate. +# --------------------------------------------------------------------------- +test_wrapper_validate() { + section "wrapper validate.sh" + + # deploy-fastly: artifact + 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}" \ + EDGEZERO__FASTLY__API_TOKEN_PRESENT="${T:-true}" \ + EDGEZERO__FASTLY__SERVICE_ID="${S-svc_1}" \ + EDGEZERO__ADAPTER=fastly EDGEZERO__RUNNER__OS=Linux EDGEZERO__RUNNER__ARCH=X64 \ + EDGEZERO__ACTION__STATE_DIR="$WORK_DIR/dfl-state" \ + GITHUB_OUTPUT="$WORK_DIR/dfl-out.txt" \ + bash "$dfl" + } + assert_succeeds "deploy-fastly: well-formed inputs pass" run_dfl + A=false assert_fails "deploy-fastly: missing artifact is rejected" run_dfl + T=false assert_fails "deploy-fastly: missing token (by presence) is rejected" run_dfl + S='bad id!' assert_fails "deploy-fastly: malformed service-id is rejected" run_dfl + S='' assert_fails "deploy-fastly: empty service-id is rejected" run_dfl + + # 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}" \ + EDGEZERO__FASTLY__API_TOKEN_PRESENT="${T:-true}" \ + EDGEZERO__DEPLOY__TO="${D:-production}" \ + EDGEZERO__CONFIG_PUSH__KEY_PRESENT="${K:-false}" 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. + 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" + 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" +} + +# --------------------------------------------------------------------------- +# resolve_app_cli — invoke the absolute path, so a `fastly`-named app CLI is not +# shadowed by the provider CLI the install step prepends to PATH. +# --------------------------------------------------------------------------- +test_resolve_app_cli() { + section "app-cli resolution (PATH shadowing)" + local resolved + resolved=$(EDGEZERO__APP__CLI__PATH=/opt/tools/bin/fastly EDGEZERO__APP__CLI__BIN=fastly \ + bash -c "source '$CORE_SCRIPTS/common.sh'; resolve_app_cli") + assert_equals "prefers the absolute path when set" "/opt/tools/bin/fastly" "$resolved" + + resolved=$(EDGEZERO__APP__CLI__BIN=myapp-cli \ + bash -c "source '$CORE_SCRIPTS/common.sh'; resolve_app_cli") + assert_equals "falls back to the bare name when no path is given" "myapp-cli" "$resolved" + + assert_fails "fails when neither is set" \ + bash -c "source '$CORE_SCRIPTS/common.sh'; resolve_app_cli" +} + +# --------------------------------------------------------------------------- +# versions.json — pinned Fastly CLI metadata +# --------------------------------------------------------------------------- +# The pinned Fastly version must agree with .tool-versions and the checksum +# must be a well-formed SHA-256 (replaces the old Python metadata check). +check_fastly_versions() { + command -v jq >/dev/null 2>&1 || return 0 + local versions_json="$ACTIONS_DIR/deploy-fastly/versions.json" + local pinned tool_versions_entry checksum + pinned=$(jq -er '.fastly.version' "$versions_json") + tool_versions_entry=$(awk '$1 == "fastly" { print $2 }' "$REPO_ROOT/.tool-versions") + [[ "$pinned" == "$tool_versions_entry" ]] || return 1 + checksum=$(jq -er '.fastly.linux_amd64.sha256' "$versions_json") + [[ ${#checksum} -eq 64 && "$checksum" =~ ^[0-9a-f]+$ ]] +} + +test_fastly_versions() { + section "Fastly versions.json" + assert_succeeds "pinned version matches .tool-versions and sha256 is well-formed" check_fastly_versions +} + +# --------------------------------------------------------------------------- +# cleanup.sh — it runs `rm -rf`, so confinement is the whole contract +# --------------------------------------------------------------------------- +test_cleanup_confinement() { + section "cleanup confinement" + local temp_root="$WORK_DIR/cleanup-temp" outside="$WORK_DIR/cleanup-outside" + mkdir -p "$temp_root/owned" "$outside/checkout" + + RUNNER_TEMP="$temp_root" EDGEZERO__ACTION__TOOL_ROOT="$temp_root/owned" \ + EDGEZERO__ACTION__STATE_DIR="" "$CORE_SCRIPTS/cleanup.sh" >/dev/null 2>&1 || true + assert_fails "removes an action-owned dir beneath RUNNER_TEMP" test -d "$temp_root/owned" + + # The original defect: cleanup removed $EDGEZERO_FASTLY_HOME, a variable the + # action never set — so its value could only ever be inherited. Any dir handed + # to cleanup from outside the temp root must be refused, not deleted. + RUNNER_TEMP="$temp_root" EDGEZERO__ACTION__TOOL_ROOT="$outside/checkout" \ + EDGEZERO__ACTION__STATE_DIR="" "$CORE_SCRIPTS/cleanup.sh" >/dev/null 2>&1 || true + assert_succeeds "refuses a dir outside RUNNER_TEMP (the checkout survives)" test -d "$outside/checkout" + + # A symlink must not smuggle the removal out of the temp root either. + ln -s "$outside/checkout" "$temp_root/link-out" + RUNNER_TEMP="$temp_root" EDGEZERO__ACTION__TOOL_ROOT="$temp_root/link-out" \ + EDGEZERO__ACTION__STATE_DIR="" "$CORE_SCRIPTS/cleanup.sh" >/dev/null 2>&1 || true + assert_succeeds "refuses a symlink pointing outside RUNNER_TEMP" test -d "$outside/checkout" + + RUNNER_TEMP="" EDGEZERO__ACTION__TOOL_ROOT="$outside/checkout" \ + assert_succeeds "no RUNNER_TEMP: removes nothing" "$CORE_SCRIPTS/cleanup.sh" +} + +# --------------------------------------------------------------------------- +# run-app-cli.sh — the action's private env must not survive into the app CLI +# --------------------------------------------------------------------------- +test_action_env_scrub() { + section "action-private env scrub" + local dir="$WORK_DIR/scrub" + mkdir -p "$dir/bin" + # A stand-in CLI that reports the environment it was handed. + cat >"$dir/bin/scrub-cli" <<'CLI' +#!/usr/bin/env bash +printf 'FASTLY_API_TOKEN=%s\n' "${FASTLY_API_TOKEN:-ABSENT}" +printf 'EDGEZERO__PROVIDER__ENV=%s\n' "${EDGEZERO__PROVIDER__ENV:-ABSENT}" +printf 'EDGEZERO__FASTLY__API_TOKEN=%s\n' "${EDGEZERO__FASTLY__API_TOKEN:-ABSENT}" +printf 'EDGEZERO__DEPLOY__ARGS_FILE=%s\n' "${EDGEZERO__DEPLOY__ARGS_FILE:-ABSENT}" +printf 'EDGEZERO_MANIFEST=%s\n' "${EDGEZERO_MANIFEST:-ABSENT}" +CLI + chmod +x "$dir/bin/scrub-cli" + printf 'FASTLY_API_TOKEN\0' >"$dir/clear.nul" + + local out + out=$( + PATH="$dir/bin:$PATH" \ + EDGEZERO__APP__CLI__BIN=scrub-cli EDGEZERO__ADAPTER=fastly EDGEZERO__PROJECT__WORKING_DIRECTORY="$dir" \ + EDGEZERO__PROJECT__MANIFEST_PATH="$dir/edgezero.toml" \ + EDGEZERO__PROVIDER__ENV_CLEAR_FILE="$dir/clear.nul" \ + EDGEZERO__PROVIDER__ENV='{"FASTLY_API_TOKEN":"s3cret"}' \ + EDGEZERO__FASTLY__API_TOKEN='s3cret' \ + "$CORE_SCRIPTS/run-app-cli.sh" deploy 2>/dev/null + ) + + # What the CLI IS promised. + assert_equals "the typed provider alias is delivered" \ + "FASTLY_API_TOKEN=s3cret" "$(grep '^FASTLY_API_TOKEN=' <<<"$out")" + assert_equals "EDGEZERO_MANIFEST is delivered" \ + "EDGEZERO_MANIFEST=$dir/edgezero.toml" "$(grep '^EDGEZERO_MANIFEST=' <<<"$out")" + + # What it must NEVER see: the same secret under names we never promised. + assert_equals "the provider-env JSON blob does not survive" \ + "EDGEZERO__PROVIDER__ENV=ABSENT" "$(grep '^EDGEZERO__PROVIDER__ENV=' <<<"$out")" + assert_equals "the action's token carrier does not survive" \ + "EDGEZERO__FASTLY__API_TOKEN=ABSENT" "$(grep '^EDGEZERO__FASTLY__API_TOKEN=' <<<"$out")" + assert_equals "action-private file handles do not survive" \ + "EDGEZERO__DEPLOY__ARGS_FILE=ABSENT" "$(grep '^EDGEZERO__DEPLOY__ARGS_FILE=' <<<"$out")" +} + +# --------------------------------------------------------------------------- +# validate-inputs.sh — action-owned passthrough bypasses the caller allowlist +# --------------------------------------------------------------------------- +test_deploy_args_prepend() { + section "action-owned deploy-args prepend" + local state="$WORK_DIR/prepend" + local out args + out=$( + EDGEZERO__ACTION__STATE_DIR="$state" EDGEZERO__ADAPTER=fastly \ + EDGEZERO__DEPLOY__ARG_ALLOW="--comment" \ + EDGEZERO__DEPLOY__ARGS='["--comment","hi"]' \ + EDGEZERO__DEPLOY__ARGS_PREPEND='["--non-interactive"]' \ + "$CORE_SCRIPTS/validate-inputs.sh" + ) + args=$(tr '\0' '\n' <"$state/deploy-args.nul") + # `--non-interactive` is action-owned: it is NOT caller input, so it is not + # allowlist-checked, and it must come first. + assert_equals "action-owned args are prepended, caller args preserved" \ + $'--non-interactive\n--comment\nhi' "$args" + [[ -n "$out" ]] || true + + # A caller still cannot smuggle it in themselves. + assert_fails "the caller allowlist still rejects --non-interactive from deploy-args" \ + env EDGEZERO__ACTION__STATE_DIR="$state" EDGEZERO__ADAPTER=fastly \ + EDGEZERO__DEPLOY__ARG_ALLOW="--comment" \ + EDGEZERO__DEPLOY__ARGS='["--non-interactive"]' \ + "$CORE_SCRIPTS/validate-inputs.sh" +} + +# --------------------------------------------------------------------------- +# common.sh — anchored version parsing, required inputs, private logs +# --------------------------------------------------------------------------- +# --------------------------------------------------------------------------- +# run-app-cli.sh — provider values must survive the Bash boundary intact +# --------------------------------------------------------------------------- +# `export NAME=value` truncates at the first NUL, so a NUL-bearing credential +# would be silently altered rather than rejected. The guard must reject NUL and +# still accept ordinary values — a NUL check that also rejects spaces would break +# every real token. +test_provider_env_nul() { + section "provider-env NUL rejection" + local dir="$WORK_DIR/nul" + mkdir -p "$dir/bin" "$dir/app" + printf '#!/usr/bin/env bash\nexit 0\n' >"$dir/bin/nul-cli" + chmod +x "$dir/bin/nul-cli" + printf 'FASTLY_API_TOKEN\0' >"$dir/clear.nul" + + run_with_env() { + PATH="$dir/bin:$PATH" \ + EDGEZERO__APP__CLI__BIN=nul-cli EDGEZERO__ADAPTER=fastly \ + EDGEZERO__PROJECT__WORKING_DIRECTORY="$dir/app" \ + EDGEZERO__PROVIDER__ENV_CLEAR_FILE="$dir/clear.nul" \ + EDGEZERO__PROVIDER__ENV="$1" \ + "$CORE_SCRIPTS/run-app-cli.sh" deploy >/dev/null 2>&1 + } + + # jq builds the NUL: a raw NUL cannot survive argv, which is the whole point. + local nul_json + nul_json=$(jq -nc '{FASTLY_API_TOKEN: "abc\u0000def"}') + assert_fails "a NUL-bearing provider value is rejected" run_with_env "$nul_json" + + # A NUL check must not become a space check. + assert_succeeds "an ordinary value containing spaces is accepted" \ + run_with_env '{"FASTLY_API_TOKEN":"tok with spaces"}' + assert_succeeds "a plain token is accepted" \ + run_with_env '{"FASTLY_API_TOKEN":"abc123"}' +} + +test_lifecycle_helpers() { + section "lifecycle helpers" + # NB: sourced in subshells only — common.sh defines its own `fail`, which would + # otherwise clobber this harness's. + local helpers="source '$CORE_SCRIPTS/common.sh'" + + local log="$WORK_DIR/version.log" + # An UNanchored parser reads `version=15.2.0` as 15 and `version=12abc` as 12, + # threading a version that was never deployed into healthcheck and rollback. + printf 'version=15.2.0\nversion=12abc\n' >"$log" + assert_equals "a malformed version line yields nothing (never a prefix guess)" \ + "" "$(bash -c "$helpers; read_numeric_line version '$log'")" + printf 'noise\nversion=41\nversion=42\n' >"$log" + assert_equals "the last well-formed version line wins" \ + "42" "$(bash -c "$helpers; read_numeric_line version '$log'")" + printf 'healthy=maybe\n' >"$log" + assert_equals "a non-boolean verdict yields nothing" \ + "" "$(bash -c "$helpers; read_bool_line healthy '$log'")" + + # GitHub Actions does not enforce `required: true`, so these are the real guard. + assert_fails "an empty required input is rejected" \ + bash -c "source '$CORE_SCRIPTS/common.sh'; require_input fastly-service-id ''" + assert_fails "a required input that fails its pattern is rejected" \ + bash -c "source '$CORE_SCRIPTS/common.sh'; require_input_matching fastly-version '15.2.0' '^[0-9]+\$'" + assert_succeeds "a well-formed required input is accepted" \ + bash -c "source '$CORE_SCRIPTS/common.sh'; require_input_matching fastly-version '42' '^[0-9]+\$'" + + # `fail` always exits 1, which erases a provider CLI's real status. Wrappers + # use fail_with so an operator's retry/branch logic sees the true code. + local rc=0 + bash -c "$helpers; fail_with 3 'boom'" >/dev/null 2>&1 || rc=$? + assert_equals "fail_with preserves the tool's exit status" "3" "$rc" + rc=0 + bash -c "$helpers; fail_with 0 'boom'" >/dev/null 2>&1 || rc=$? + assert_equals "fail_with never turns a failure into success (0 -> 1)" "1" "$rc" + rc=0 + bash -c "$helpers; fail_with '' 'boom'" >/dev/null 2>&1 || rc=$? + assert_equals "fail_with rejects a blank status (-> 1)" "1" "$rc" + + # Provider CLIs print request URLs and service metadata; the log must not be + # left behind in RUNNER_TEMP for later steps in the job to read. + local leaked + leaked=$( + RUNNER_TEMP="$WORK_DIR" bash -c " + source '$CORE_SCRIPTS/common.sh' + new_private_log + printf '%s\n' \"\$LIFECYCLE_LOG\" + " + ) + assert_fails "the private log is removed when its owner exits" test -e "$leaked" +} + +# --------------------------------------------------------------------------- +# build-app-cli.sh — the toolchain search must not cross the app's Git boundary +# --------------------------------------------------------------------------- +# --------------------------------------------------------------------------- +# clear_provider_env_aliases — build-app-cli runs APP-CONTROLLED code (cargo +# build, the built CLI's --help), so every caller-named provider credential must +# be unset first. The names come from the input, so the helper is provider-neutral. +# --------------------------------------------------------------------------- +# --------------------------------------------------------------------------- +# versions.json must pin an OFFICIAL release. make-fake-fastly-env.sh repoints +# this file at a local fake archive (that is how the smoke exercises the real +# download+verify path), so running it locally dirties a tracked file. If such an +# override were ever committed, the real installer would try to fetch from a +# machine-local path — this guard fails fast instead. +# --------------------------------------------------------------------------- +test_versions_json_pins_official_release() { + section "versions.json pins an official release" + local vj="$ACTIONS_DIR/deploy-fastly/versions.json" + local url verdict + url=$(jq -r '.fastly.linux_amd64.url' "$vj") + case "$url" in + https://github.com/fastly/cli/releases/download/*) verdict=official ;; + *) verdict="$url" ;; + esac + assert_equals "versions.json pins an official https release URL (never a local file:// override)" \ + "official" "$verdict" + + # The URL must point at the pinned VERSION: a version bump that forgets to + # update the URL — or an URL swapped to a different build — is a real regression + # the checksum alone would not localize. + local version expected_tail + version=$(jq -r '.fastly.version' "$vj") + expected_tail="/releases/download/v${version}/fastly_v${version}_linux-amd64.tar.gz" + case "$url" in + *"$expected_tail") verdict=matches ;; + *) verdict="$url" ;; + esac + assert_equals "versions.json URL filename embeds the pinned version" "matches" "$verdict" + + local sha + sha=$(jq -r '.fastly.linux_amd64.sha256' "$vj") + case "$sha" in + [0-9a-f]*) verdict=$([ ${#sha} -eq 64 ] && echo sha256 || echo "$sha") ;; + *) verdict="$sha" ;; + esac + assert_equals "versions.json pins a 64-hex SHA-256" "sha256" "$verdict" +} + +test_clear_provider_env_aliases() { + section "provider-env-clear scrubbing" + local lib="$ACTIONS_DIR/build-app-cli/scripts/common.sh" + + # --- input validation must FAIL CLOSED ------------------------------------- + # A permissive parse (`jq '.[]?'`) accepts these and yields NO names, silently + # leaving every inherited credential in scope. Each must be an error instead. + local bad + for bad in '"FASTLY_API_TOKEN"' '{}' 'null' '123' 'true' '"[]"'; do + assert_fails "valid-but-not-an-array input is rejected: $bad" \ + bash -c "source '$lib'; provider_env_clear_names '$bad'" + done + assert_fails "invalid JSON is rejected" \ + bash -c "source '$lib'; provider_env_clear_names 'not-json'" + assert_fails "a non-string member is rejected" \ + bash -c "source '$lib'; provider_env_clear_names '[\"OK\",42]'" + assert_fails "an empty-string member is rejected" \ + bash -c "source '$lib'; provider_env_clear_names '[\"OK\",\"\"]'" + assert_fails "an invalid environment variable name is rejected" \ + bash -c "source '$lib'; provider_env_clear_names '[\"not a name\"]'" + assert_succeeds "an empty array is a no-op" \ + bash -c "source '$lib'; provider_env_clear_names '[]'" + + local names + names=$(bash -c "source '$lib'; provider_env_clear_names '[\"A_TOKEN\",\"B_TOKEN\"]' | tr '\n' ' '") + assert_equals "a well-formed array yields its names" "A_TOKEN B_TOKEN " "$names" + + # A JSON member with an ESCAPED control character (valid JSON, decoding to a + # newline/NUL) must be REJECTED. Otherwise it would reach `jq -r`, split into + # two "names" (newline) or be truncated (NUL), leaving the real variable + # untouched. Fixtures are printf'd so this source carries no raw control chars. + local bs=$'\\' + printf '["A%snB"]' "$bs" >"$WORK_DIR/pec-nl.json" + printf '["WRONG%su0000SECRET"]' "$bs" >"$WORK_DIR/pec-nul.json" + assert_fails "an escaped newline in a name is rejected" \ + bash -c "source '$lib'; provider_env_clear_names \"\$(cat '$WORK_DIR/pec-nl.json')\"" + assert_fails "an escaped NUL in a name is rejected" \ + bash -c "source '$lib'; provider_env_clear_names \"\$(cat '$WORK_DIR/pec-nul.json')\"" + + # --- the scrub must clear the credential from ANCESTOR /proc, not only $$ ---- + # A child spawned after the scrub must not find the credential in its parent's + # (`/proc//environ`) environment. The re-exec (`env -u` + exec) gives the + # script process a clean environ, so the child's parent (the script) is clean. + # This mirrors build-app-cli.sh's arg-SENTINEL guard (an env sentinel would be + # forgeable via job env). + local sentinel="--edgezero-provider-env-cleared" + local probe="$WORK_DIR/scrub-probe.sh" + cat >"$probe" </dev/null || true) + assert_equals "a malformed input never reaches the command with credentials intact" "" "$leaked" + + # An inherited env sentinel must NOT bypass the scrub: the guard keys off an + # ARGUMENT, so the legacy env var is inert and a malformed input still fails. + # Assert the SCRUB VALIDATION diagnostic, not merely a non-zero exit: the script + # would also exit later on the unset EDGEZERO__ACTION__ROOT, so a bare exit-code + # check would pass even if the sentinel HAD bypassed validation. + assert_fails_with "an inherited env sentinel does not bypass validation" \ + "input 'provider-env-clear' must be a JSON array" \ + env EDGEZERO__PROVIDER__ENV_CLEARED=1 EDGEZERO__PROVIDER__ENV_CLEAR='{}' \ + GITHUB_WORKSPACE="$WORK_DIR" \ + bash "$ACTIONS_DIR/build-app-cli/scripts/build-app-cli.sh" + + # The real build script must also refuse to proceed on a malformed input, and + # do so AT THE SCRUB — before it reaches any app-controlled command. + assert_fails_with "build-app-cli.sh fails closed on a malformed provider-env-clear" \ + "input 'provider-env-clear' must be a JSON array" \ + env EDGEZERO__PROVIDER__ENV_CLEAR='{}' GITHUB_WORKSPACE="$WORK_DIR" \ + bash "$ACTIONS_DIR/build-app-cli/scripts/build-app-cli.sh" + + # The action must `exec` the script from its run body, so no dirty wrapper-shell + # ancestor survives for app code to walk up to. Guard against silently dropping it. + grep -qE 'run: exec .*build-app-cli\.sh' "$ACTIONS_DIR/build-app-cli/action.yml" || + fail "build-app-cli action must 'exec' the build script (eliminates the dirty wrapper-shell ancestor)" + + # BASH_ENV / ENV are sourced at shell STARTUP, before the script's re-exec scrub + # runs, so the scrub cannot clear them — they must be blanked statically in the + # build step's `env:`. Guard against a regression that drops the static clear. + local action_yml="$ACTIONS_DIR/build-app-cli/action.yml" + grep -qE '^[[:space:]]+BASH_ENV: ""' "$action_yml" || + fail "build-app-cli build step must statically blank BASH_ENV (sourced before the scrub can run)" + grep -qE '^[[:space:]]+ENV: ""' "$action_yml" || + fail "build-app-cli build step must statically blank ENV (sourced before the scrub can run)" + + # `run_untrusted` must point the GitHub Actions file-command channels away from + # the real per-step files: an app-controlled build script could otherwise append + # (e.g.) LD_PRELOAD to $GITHUB_ENV and have a LATER step run it with a credential + # in scope, or forge this action's outputs via $GITHUB_OUTPUT. + local ru_env ru_out + ru_env="$WORK_DIR/ru-github-env" + ru_out="$WORK_DIR/ru-github-out" + : >"$ru_env" + : >"$ru_out" + GITHUB_ENV="$ru_env" GITHUB_OUTPUT="$ru_out" bash -c " + source '$lib' + run_untrusted /bin/bash -c 'printf \"LD_PRELOAD=/evil.so\n\" >>\"\$GITHUB_ENV\"; printf \"forged=1\n\" >>\"\$GITHUB_OUTPUT\"' + append_output real value + " >/dev/null 2>&1 + assert_equals "run_untrusted discards a child's GITHUB_ENV writes" "" "$(cat "$ru_env")" + # The parent's own append_output still reaches the real file; the child's forged + # output does not. + assert_equals "the parent still writes real outputs while the child's are discarded" \ + "real=value" "$(cat "$ru_out")" +} + +test_toolchain_boundary() { + section "toolchain search boundary" + # The adoption guide's layout: a deployer repo at github.workspace, with the + # application checked out into a subdirectory. The DEPLOYER's .tool-versions + # must never decide which Rust compiles the APPLICATION. + local ws="$WORK_DIR/tc-workspace" + mkdir -p "$ws/app" + printf 'rust 1.60.0\n' >"$ws/.tool-versions" + git -C "$ws/app" init -q 2>/dev/null || return 0 + printf 'rust 1.95.0\n' >"$ws/app/.tool-versions" + + local resolved + resolved=$( + bash -c " + source '$ACTIONS_DIR/build-app-cli/scripts/build-app-cli.sh' + resolve_rust_toolchain auto '$ws/app' '$ws' '$REPO_ROOT' + " + ) + assert_equals "the app's own .tool-versions wins over the deployer's" "1.95.0" "$resolved" + + # With no toolchain file in the app repo, the search must STOP at the app's + # Git root rather than picking up the deployer's file one level up. + rm -f "$ws/app/.tool-versions" + local fallback + fallback=$( + bash -c " + source '$ACTIONS_DIR/build-app-cli/scripts/build-app-cli.sh' + resolve_rust_toolchain auto '$ws/app' '$ws' '$REPO_ROOT' + " + ) + local edgezero_rust + edgezero_rust=$(awk '$1 == "rust" { print $2 }' "$REPO_ROOT/.tool-versions") + assert_equals "the search stops at the app's Git root (deployer's 1.60.0 ignored)" \ + "$edgezero_rust" "$fallback" + + # An extensionless `rust-toolchain` in TOML form must resolve its channel, not + # the literal `[toolchain]` header line. rustup accepts both forms. The fixture + # uses the `stable` channel keyword — distinct from the `[toolchain]` header + # (which a broken parser would return), so a pass proves the file was parsed as + # TOML. It names a channel, not a pinned version. + printf '[toolchain]\nchannel = "stable"\n' >"$ws/app/rust-toolchain" + local toml_form + toml_form=$( + bash -c " + source '$ACTIONS_DIR/build-app-cli/scripts/build-app-cli.sh' + resolve_rust_toolchain auto '$ws/app' '$ws' '$REPO_ROOT' + " + ) + assert_equals "a TOML-form extensionless rust-toolchain resolves its channel" "stable" "$toml_form" + # The same file must resolve identically through resolve-project.sh's copy. + local toml_form_deploy + toml_form_deploy=$( + bash -c " + source '$ACTIONS_DIR/deploy-core/scripts/resolve-project.sh' + parse_toolchain_from_channel_file '$ws/app/rust-toolchain' + " + ) + assert_equals "resolve-project.sh parses the TOML-form channel too" "stable" "$toml_form_deploy" + rm -f "$ws/app/rust-toolchain" + + # A trailing `# comment` after the channel value is valid TOML and must parse. + printf '[toolchain]\nchannel = "stable" # pinned\n' >"$ws/app/rust-toolchain.toml" + local commented + commented=$( + bash -c " + source '$ACTIONS_DIR/build-app-cli/scripts/build-app-cli.sh' + resolve_rust_toolchain auto '$ws/app' '$ws' '$REPO_ROOT' + " + ) + assert_equals "a channel with a trailing comment parses" "stable" "$commented" + rm -f "$ws/app/rust-toolchain.toml" +} + +# --------------------------------------------------------------------------- +# config-push.sh — the staging key is a different key, driven by --staging +# --------------------------------------------------------------------------- +# 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" + # 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' +#!/usr/bin/env bash +printf '%s\n' "$@" >"$FAKE_ARGV_OUT" +# Capture the --app-config file's content while it still exists (the wrapper +# removes an inline temp file on exit), so a test can verify what was pushed. +prev="" +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" +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" + + 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__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__APP_CONFIG="${CP_APP_CONFIG:-}" \ + EDGEZERO__CONFIG_PUSH__APP_CONFIG_INLINE="${CP_APP_CONFIG_INLINE:-}" \ + EDGEZERO__CONFIG_PUSH__NO_ENV="${CP_NO_ENV:-false}" \ + "$ACTIONS_DIR/config-push-fastly/scripts/config-push.sh" >/dev/null 2>&1 + cat "$dir/argv.txt" 2>/dev/null +} + +# Run config-push.sh with a caller-supplied path; used for confinement checks. +config_push_rejects_path() { + local var="$1" value="$2" + local dir="$WORK_DIR/config-push" + env "$var=$value" PATH="$dir/bin:$PATH" FAKE_ARGV_OUT="$dir/argv.txt" \ + EDGEZERO__APP__CLI__BIN=fake-cli FASTLY_API_TOKEN=tok \ + GITHUB_WORKSPACE="$dir" EDGEZERO__PROJECT__WORKING_DIRECTORY=app \ + "$ACTIONS_DIR/config-push-fastly/scripts/config-push.sh" +} + +test_config_push_argv() { + section "config-push argv" + + # Production: the base subcommand, non-interactive flags, and NO --staging. + 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). + local staged + staged=$(CP_DEPLOY_TO=staging run_config_push_argv) + assert_succeeds "staging appends --staging" grep -qx -- '--staging' <<<"$staged" + assert_fails "production does NOT pass --staging" grep -qx -- '--staging' <<<"$prod" + + # Typed --store / --key are threaded through when supplied. + local with_store + with_store=$(CP_STORE=cfg CP_KEY=mykey run_config_push_argv) + assert_succeeds "--store is threaded" grep -qx -- 'cfg' <<<"$with_store" + assert_succeeds "--key is threaded" grep -qx -- 'mykey' <<<"$with_store" + + # Inline config: threaded as --app-config pointing at an action-owned temp file + # that holds exactly the supplied content (no checkout file required). + local inline_argv + inline_argv=$(CP_APP_CONFIG_INLINE='greeting = "hi"' run_config_push_argv) + assert_succeeds "inline config threads --app-config" grep -qx -- '--app-config' <<<"$inline_argv" + assert_equals "inline content is written to the file the CLI reads" \ + 'greeting = "hi"' "$(cat "$WORK_DIR/config-push/argv.txt.appconfig" 2>/dev/null)" + + # no-env: --no-env is appended only when requested. + local noenv_argv + noenv_argv=$(CP_NO_ENV=true run_config_push_argv) + assert_succeeds "no-env=true appends --no-env" grep -qx -- '--no-env' <<<"$noenv_argv" + assert_fails "the default does NOT pass --no-env" grep -qx -- '--no-env' <<<"$prod" + + # A file path and inline content are mutually exclusive, and no-env must be a + # boolean — both fail closed with a named diagnostic (never a silent default). + assert_fails_with "app-config and app-config-inline are mutually exclusive" \ + "mutually exclusive" \ + env PATH="$WORK_DIR/config-push/bin:$PATH" EDGEZERO__APP__CLI__BIN=fake-cli FASTLY_API_TOKEN=tok \ + GITHUB_WORKSPACE="$WORK_DIR/config-push" EDGEZERO__PROJECT__WORKING_DIRECTORY=app \ + 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 "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__NO_ENV=yes \ + "$ACTIONS_DIR/config-push-fastly/scripts/config-push.sh" + + # The inline temp file must NOT survive the step. new_private_log installs its + # own EXIT trap AFTER the inline-file trap, so the cleanup must be re-installed + # — verify nothing is left in RUNNER_TEMP on the success path AND when the CLI + # fails and the script exits via fail_with. + local rt="$WORK_DIR/config-push/runner-temp" scenario cli + cli="$WORK_DIR/config-push/bin/fake-cli" + for scenario in success failure; do + rm -rf "$rt" + mkdir -p "$rt" + if [[ "$scenario" == failure ]]; then + printf '#!/usr/bin/env bash\nexit 7\n' >"$cli" + chmod +x "$cli" + fi + env PATH="$WORK_DIR/config-push/bin:$PATH" FAKE_ARGV_OUT="$rt/argv.txt" \ + EDGEZERO__APP__CLI__BIN=fake-cli FASTLY_API_TOKEN=tok \ + GITHUB_WORKSPACE="$WORK_DIR/config-push" EDGEZERO__PROJECT__WORKING_DIRECTORY=app \ + RUNNER_TEMP="$rt" EDGEZERO__CONFIG_PUSH__APP_CONFIG_INLINE='a = 1' \ + "$ACTIONS_DIR/config-push-fastly/scripts/config-push.sh" >/dev/null 2>&1 || true + assert_succeeds "the inline config temp file is removed ($scenario path)" \ + bash -c "! ls '$rt'/edgezero-inline-config.* >/dev/null 2>&1" + done + + # A bad deploy-to must fail closed, never silently push to production. + assert_fails "a non-{production,staging} deploy-to is rejected" \ + env EDGEZERO__APP__CLI__BIN=fake-cli FASTLY_API_TOKEN=tok \ + GITHUB_WORKSPACE="$WORK_DIR/config-push" EDGEZERO__PROJECT__WORKING_DIRECTORY=app \ + EDGEZERO__DEPLOY__TO=Staging \ + "$ACTIONS_DIR/config-push-fastly/scripts/config-push.sh" + + # Path confinement: manifest/app-config are caller strings handed to a + # credential-bearing CLI, so nothing may escape the app directory. + 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" + + # 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" \ + grep -qx -- 'real.toml' <<<"$ok" +} + +# --------------------------------------------------------------------------- +# run-app-cli.sh — the CLI's exit status is the step's exit status +# --------------------------------------------------------------------------- +# A deploy that fails must fail the step. If the engine swallowed the exit code, +# a broken deploy would report success and the caller would never roll back. +test_exit_propagation() { + section "exit propagation" + local dir="$WORK_DIR/exit-prop" + mkdir -p "$dir/bin" "$dir/app" + cat >"$dir/bin/exit-cli" <<'CLI' +#!/usr/bin/env bash +exit "${FAKE_EXIT_CODE:-0}" +CLI + chmod +x "$dir/bin/exit-cli" + + run_with_exit() { + PATH="$dir/bin:$PATH" FAKE_EXIT_CODE="$1" \ + EDGEZERO__APP__CLI__BIN=exit-cli EDGEZERO__ADAPTER=fastly \ + EDGEZERO__PROJECT__WORKING_DIRECTORY="$dir/app" \ + "$CORE_SCRIPTS/run-app-cli.sh" build >/dev/null 2>&1 + } + + # NB: capture with `|| rc=$?` — a trailing `|| true` would reset $? to 0 and + # make this test vacuously pass. + local rc=0 + run_with_exit 0 || rc=$? + assert_equals "a succeeding CLI exits 0" "0" "$rc" + rc=0 + run_with_exit 42 || rc=$? + assert_equals "a failing CLI's exit code reaches the step (42, not 1)" "42" "$rc" +} + +# --------------------------------------------------------------------------- +# resolve-project.sh — deploys require committed source +# --------------------------------------------------------------------------- +# The dirty-source guard is what makes `source-revision` honest: it is the +# revision that was DEPLOYED, so an uncommitted edit must not ship under a clean +# SHA. Modified, staged, and untracked all count as dirty. +test_dirty_source_guard() { + section "dirty-source guard" + local repo="$WORK_DIR/dirty-src" + mkdir -p "$repo" + git -C "$repo" init -q 2>/dev/null || return 0 + git -C "$repo" config user.email t@t.invalid + git -C "$repo" config user.name t + echo one >"$repo/file.txt" + git -C "$repo" add -A && git -C "$repo" commit -qm init + + # resolve-project.sh guards its own main(), so sourcing it just exposes the + # guard function (no project resolution, no cargo). + local guard="source '$CORE_SCRIPTS/resolve-project.sh'" + + assert_succeeds "a clean tree passes" \ + bash -c "$guard; assert_committed_source '$repo' app" + + echo two >>"$repo/file.txt" + assert_fails "an unstaged modification is dirty" \ + bash -c "$guard; assert_committed_source '$repo' app" + + git -C "$repo" add -A + assert_fails "a staged-but-uncommitted change is dirty" \ + bash -c "$guard; assert_committed_source '$repo' app" + + git -C "$repo" commit -qm two + echo x >"$repo/untracked.txt" + assert_fails "an untracked file is dirty (it would ship unbuilt)" \ + bash -c "$guard; assert_committed_source '$repo' app" +} + +# --------------------------------------------------------------------------- +# resolve-project.sh — the cache key is exact +# --------------------------------------------------------------------------- +# The cache key decides whether a build reuses target/. If it omits an input that +# changes the artifacts, CI silently ships a stale build. Cargo.lock is only +# hashed (never parsed), so a minimal fixture proves the composition offline. +cache_key_for() { + local ws="$WORK_DIR/cache-key" + # NB: the output file lives OUTSIDE the fixture repo — inside it, it would be + # an untracked file and the dirty-source guard would (correctly) reject it. + local out="$WORK_DIR/cache-key-out.txt" + : >"$out" + env -i PATH="$PATH" HOME="${HOME:-/tmp}" \ + GITHUB_WORKSPACE="$ws" \ + GITHUB_OUTPUT="$out" \ + RUNNER_OS=Linux RUNNER_ARCH=X64 \ + EDGEZERO__ACTION__ROOT="$REPO_ROOT" \ + EDGEZERO__PROJECT__WORKING_DIRECTORY=app \ + EDGEZERO__PROJECT__RUST_TOOLCHAIN="${CK_TOOLCHAIN:-1.95.0}" \ + EDGEZERO__PROJECT__TARGET="${CK_TARGET:-wasm32-wasip1}" \ + EDGEZERO__APP__CLI__VERSION="${CK_CLI_VERSION:-1.0.0}" \ + EDGEZERO__BUILD__CACHE="${CK_CACHE:-false}" \ + bash "$CORE_SCRIPTS/resolve-project.sh" >/dev/null 2>&1 || return $? + grep -oE '^cache-key=.*$' "$out" | tail -n 1 | cut -d= -f2- +} + +test_cache_key() { + section "cache key" + local ws="$WORK_DIR/cache-key" + mkdir -p "$ws/app/src" + cat >"$ws/app/Cargo.toml" <<'TOML' +[package] +name = "ck-fixture" +version = "0.1.0" +edition = "2021" +TOML + echo 'fn main() {}' >"$ws/app/src/main.rs" + printf 'version = 3\n' >"$ws/app/Cargo.lock" + git -C "$ws" init -q 2>/dev/null || return 0 + git -C "$ws" config user.email t@t.invalid + git -C "$ws" config user.name t + git -C "$ws" add -A && git -C "$ws" commit -qm init + + local base + base=$(cache_key_for) || { fail "resolve-project could not produce a cache key"; return 0; } + [[ -n "$base" ]] || { fail "cache key is empty"; return 0; } + + assert_succeeds "the key is namespaced and carries OS+arch" \ + grep -qE '^edgezero-deploy-Linux-X64-' <<<"$base" + + # Each input that changes the artifacts must change the key. + assert_fails "a different toolchain changes the key" \ + bash -c "[[ '$(CK_TOOLCHAIN=1.60.0 cache_key_for)' == '$base' ]]" + assert_fails "a different target changes the key" \ + bash -c "[[ '$(CK_TARGET=wasm32-unknown-unknown cache_key_for)' == '$base' ]]" + assert_fails "a different app-CLI version changes the key" \ + bash -c "[[ '$(CK_CLI_VERSION=2.0.0 cache_key_for)' == '$base' ]]" + + # The lockfile hash is the point: new deps must not reuse an old target/. + printf 'version = 3\n# changed\n' >"$ws/app/Cargo.lock" + git -C "$ws" add -A && git -C "$ws" commit -qm lockfile-change + assert_fails "a changed Cargo.lock busts the key (no stale target/ reuse)" \ + bash -c "[[ '$(cache_key_for)' == '$base' ]]" + + # cache: true with no lockfile cannot key exactly — fail rather than guess. + rm -f "$ws/app/Cargo.lock" + git -C "$ws" add -A && git -C "$ws" commit -qm drop-lockfile + if CK_CACHE=true cache_key_for >/dev/null 2>&1; then + fail "cache=true without Cargo.lock was accepted (cannot key exactly)" + else + pass "cache=true without Cargo.lock is rejected" + fi +} + +# --------------------------------------------------------------------------- +# action.yml metadata — every declared output is produced by the step it names +# --------------------------------------------------------------------------- +# A declared output whose step never emits that name silently resolves to "". +# That is exactly how the app-cli-artifact rename broke the deploy wiring: the +# consumers read an output the producer no longer wrote. +# +# This resolves each `steps..outputs.` to the SPECIFIC script that step +# runs, so a name emitted by some other action cannot vouch for this one. Both +# `outputs['name']` and `outputs.name` spellings are recognised — GitHub accepts +# either, so a test that only understood one would silently skip the rest. + +# Echo " " for every step in an action.yml that runs a +# script, resolving $GITHUB_ACTION_PATH to the action's own directory. +action_step_scripts() { + local action="$1" action_dir + action_dir=$(dirname "$action") + awk -v dir="$action_dir" ' + /^[[:space:]]*-[[:space:]]*name:/ { id = "" } + /^[[:space:]]*id:[[:space:]]*/ { id = $2 } + /^[[:space:]]*run:.*\.sh/ { + if (id == "") next + line = $0 + sub(/^[[:space:]]*run:[[:space:]]*/, "", line) + sub(/^exec[[:space:]]+/, "", line) # a run body may exec the script + gsub(/"/, "", line) # strip quoting around the path + gsub(/\$GITHUB_ACTION_PATH/, dir, line) + gsub(/\$\{\{[^}]*\}\}/, "", line) + print id, line + id = "" + } + ' "$action" +} + +# --------------------------------------------------------------------------- +# action.yml metadata — public surface is well-formed +# --------------------------------------------------------------------------- +# Pure Bash/awk (no Python, per the project's tooling rule). actionlint only +# parses composite metadata it reaches through a `uses:`, and these wrappers are +# also consumed directly by callers — so check every action.yml on its own. +# +# The duplicate-env-key case is not hypothetical: a bad edit on this branch +# defined the same key twice in one step, which YAML resolves silently to the +# last value. +# --------------------------------------------------------------------------- +# resolve-project.sh — the app REPOSITORY is the boundary, not github.workspace +# --------------------------------------------------------------------------- +# In the separate-repository layout the deployer repo IS github.workspace, so +# "inside the workspace" is not a boundary at all: a `../deployer/edgezero.toml` +# manifest, or a Cargo workspace root that `cargo locate-project` climbs into, +# would build code that `source-revision` never describes. + +# Build a deployer-repo-at-the-workspace-root layout with the app checked out +# beneath it as its OWN repository. $1 is the workspace dir; when $2 is +# "capture", the deployer's Cargo workspace lists the app as a member — which is +# how `cargo locate-project --workspace` climbs out of the app repository. +make_boundary_fixture() { + local ws="$1" mode="${2:-independent}" + mkdir -p "$ws/app/src" "$ws/deployer" + printf 'name = "deployer-manifest"\n' >"$ws/deployer/edgezero.toml" + + if [[ "$mode" == "capture" ]]; then + printf '[workspace]\nmembers = ["app"]\nresolver = "2"\n' >"$ws/Cargo.toml" + # A member of the parent workspace: no [workspace] of its own. + printf '[package]\nname = "bnd-fixture"\nversion = "0.1.0"\nedition = "2021"\n' >"$ws/app/Cargo.toml" + else + # Its own workspace root, so cargo stops inside the app repository. + printf '[package]\nname = "bnd-fixture"\nversion = "0.1.0"\nedition = "2021"\n\n[workspace]\n' >"$ws/app/Cargo.toml" + fi + + echo 'fn main() {}' >"$ws/app/src/main.rs" + printf 'version = 3\n' >"$ws/app/Cargo.lock" + printf 'name = "app-manifest"\n' >"$ws/app/edgezero.toml" + + git -C "$ws" init -q + git -C "$ws" config user.email t@t.invalid + git -C "$ws" config user.name t + git -C "$ws" add -A && git -C "$ws" commit -qm deployer + git -C "$ws/app" init -q + git -C "$ws/app" config user.email t@t.invalid + git -C "$ws/app" config user.name t + git -C "$ws/app" add -A && git -C "$ws/app" commit -qm app +} + +run_resolve_in() { + local ws="$1" + env -i PATH="$PATH" HOME="${HOME:-/tmp}" \ + GITHUB_WORKSPACE="$ws" GITHUB_OUTPUT="$WORK_DIR/boundary-out.txt" \ + RUNNER_OS=Linux RUNNER_ARCH=X64 \ + EDGEZERO__ACTION__ROOT="$REPO_ROOT" \ + EDGEZERO__PROJECT__WORKING_DIRECTORY=app \ + EDGEZERO__PROJECT__RUST_TOOLCHAIN=1.95.0 \ + EDGEZERO__PROJECT__TARGET=wasm32-wasip1 \ + EDGEZERO__PROJECT__MANIFEST="${BND_MANIFEST:-}" \ + bash "$CORE_SCRIPTS/resolve-project.sh" +} + +test_app_repo_boundary() { + section "app repository boundary" + local ok="$WORK_DIR/bnd-ok" + mkdir -p "$ok" + git -C "$ok" init -q 2>/dev/null || return 0 + rm -rf "$ok" + mkdir -p "$ok" + make_boundary_fixture "$ok" independent + + # The boundary must not over-reject a legitimate app. + assert_succeeds "an app that owns its workspace resolves" run_resolve_in "$ok" + BND_MANIFEST=edgezero.toml \ + assert_succeeds "the app's own manifest is accepted" run_resolve_in "$ok" + + # Inside github.workspace, but a different repository than source-revision names. + BND_MANIFEST=../deployer/edgezero.toml \ + assert_fails "a manifest in the deployer repo is rejected" run_resolve_in "$ok" + + # The deployer's workspace claims the app, so cargo resolves the workspace root + # OUT of the app repository — we would build and cache the deployer's tree. + local cap="$WORK_DIR/bnd-capture" + mkdir -p "$cap" + make_boundary_fixture "$cap" capture + assert_fails "a Cargo workspace root outside the app repository is rejected" \ + run_resolve_in "$cap" +} + +test_action_metadata() { + section "action metadata" + local action bad=0 + + for action in "$ACTIONS_DIR"/*/action.yml; do + local who; who=$(basename "$(dirname "$action")") + + # Required top-level keys. + local key + for key in name description runs; do + grep -qE "^${key}:" "$action" || + { fail "$who action.yml has no top-level '$key:'"; bad=$((bad + 1)); } + done + + # Every declared input needs a description — it is the public contract. + local undescribed + undescribed=$(awk ' + /^inputs:/ { in_inputs = 1; next } + /^[a-z]+:/ && !/^inputs:/ { in_inputs = 0 } + in_inputs && /^ [a-z][a-z0-9-]*:/ { + if (name != "" && !described) print name + name = $1; sub(/:$/, "", name); described = 0 + } + in_inputs && /^ description:/ { described = 1 } + END { if (name != "" && !described) print name } + ' "$action") + if [[ -n "$undescribed" ]]; then + fail "$who has inputs without a description: $(tr '\n' ' ' <<<"$undescribed")" + bad=$((bad + 1)) + fi + + # A key defined twice in ONE step's env: YAML keeps the last silently. + local dupes + dupes=$(awk ' + /^ - name:/ { delete seen; next } + /^ env:/ { in_env = 1; next } + /^ [a-z]+:/ { in_env = 0 } + in_env && /^ [A-Za-z_][A-Za-z0-9_]*:/ { + k = $1; sub(/:$/, "", k) + if (k in seen) print k + seen[k] = 1 + } + ' "$action" | sort -u) + if [[ -n "$dupes" ]]; then + fail "$who defines the same env key twice in one step: $(tr '\n' ' ' <<<"$dupes")" + bad=$((bad + 1)) + fi + done + + [[ "$bad" -eq 0 ]] && pass "every action.yml declares a well-formed public surface" +} + +test_action_output_contracts() { + section "action output contracts" + local action missing=0 checked=0 + + for action in "$ACTIONS_DIR"/*/action.yml; do + local name_of; name_of=$(basename "$(dirname "$action")") + local scripts; scripts=$(action_step_scripts "$action") + + local ref step_id out_name script emitted + # Both spellings: steps..outputs[''] and steps..outputs. + while IFS= read -r ref; do + [[ -n "$ref" ]] || continue + step_id=${ref%% *} + out_name=${ref##* } + checked=$((checked + 1)) + + script=$(awk -v want="$step_id" '$1 == want { $1 = ""; sub(/^ /, ""); print; exit }' <<<"$scripts") + if [[ -z "$script" ]]; then + fail "$name_of output '$out_name' names step '$step_id', which runs no script" + missing=$((missing + 1)) + continue + fi + if [[ ! -f "$script" ]]; then + fail "$name_of step '$step_id' points at a missing script: $script" + missing=$((missing + 1)) + continue + fi + # The named step's OWN script must emit it — not merely some other action. + # Exception: a script may DELEGATE the run to the shared run-app-cli.sh + # launcher (which emits `mutation-attempted` itself, right before it invokes + # the CLI); if the step's script calls it, that counts as emitting. + emitted=$(grep -oE "append_output ${out_name}( |\$)" "$script" || true) + if [[ -z "$emitted" ]] && grep -q 'run-app-cli\.sh' "$script"; then + emitted=$(grep -oE "append_output ${out_name}( |\$)" "$CORE_SCRIPTS/run-app-cli.sh" || true) + fi + if [[ -z "$emitted" ]]; then + fail "$name_of output '$out_name' claims step '$step_id' ($(basename "$script")) emits it, but that script does not" + missing=$((missing + 1)) + fi + done < <(sed -n '/^outputs:/,/^runs:/p' "$action" | + grep -oE "steps\.[a-z-]+\.outputs(\['[a-z0-9-]+'\]|\.[a-z0-9-]+)" | + sed -E "s/steps\.([a-z-]+)\.outputs\['([a-z0-9-]+)'\]/\1 \2/; s/steps\.([a-z-]+)\.outputs\.([a-z0-9-]+)/\1 \2/" | + sort -u) + done + + if [[ "$checked" -eq 0 ]]; then + fail "the output-contract test matched no outputs at all (it is not testing anything)" + elif [[ "$missing" -eq 0 ]]; then + pass "all $checked declared outputs are emitted by the step they name" + fi +} + +# --------------------------------------------------------------------------- +# capture-previous.sh — the production rollback-target capture. A first deploy +# (no active version) must yield an EMPTY previous-version and still succeed; an +# active version threads out; and an operational failure must fail CLOSED. +# --------------------------------------------------------------------------- +test_capture_previous() { + section "capture-previous (rollback target)" + local dir="$WORK_DIR/capture" + rm -rf "$dir" + mkdir -p "$dir/bin" + cat >"$dir/bin/fake-cli" <<'CLI' +#!/usr/bin/env bash +# `active-version --help` is the credential-free preflight: it must run WITHOUT +# the token. Assert that so removing the token scrub would fail the tests. +if [ "$1" = active-version ] && [ "$2" = --help ]; then + [ -z "${FASTLY_API_TOKEN:-}" ] || { echo "preflight must run without FASTLY_API_TOKEN" >&2; exit 91; } + exit 0 +fi +if [ "$1" = active-version ]; then + # FAKE_SILENT models a broken CLI that exits 0 but prints no contract line. + [ -n "${FAKE_SILENT:-}" ] || printf '%s\n' "${FAKE_VERSION_LINE-version=}" + # FAKE_EXTRA_LINE models a CLI emitting a SECOND contract line. + [ -z "${FAKE_EXTRA_LINE:-}" ] || printf '%s\n' "$FAKE_EXTRA_LINE" + exit "${FAKE_EXIT:-0}" +fi +exit 3 +CLI + chmod +x "$dir/bin/fake-cli" + + run_capture() { + : >"$dir/out.txt" + PATH="$dir/bin:$PATH" \ + EDGEZERO__APP__CLI__BIN=fake-cli \ + EDGEZERO__FASTLY__SERVICE_ID=svc \ + FASTLY_API_TOKEN=tok \ + GITHUB_OUTPUT="$dir/out.txt" \ + FAKE_VERSION_LINE="${FVL-version=}" FAKE_EXIT="${FE:-0}" FAKE_SILENT="${FS:-}" \ + FAKE_EXTRA_LINE="${FXL:-}" \ + "$ACTIONS_DIR/deploy-fastly/scripts/capture-previous.sh" + } + capture_prev() { sed -nE 's/^previous-version=(.*)$/\1/p' "$dir/out.txt"; } + + # First deploy: no active version (empty `version=`) → empty target, success. + FVL='version=' FE=0 assert_succeeds "first deploy: capture succeeds with no active version" run_capture + assert_equals "first deploy: previous-version is empty" "" "$(capture_prev)" + + # Normal deploy: an active version threads out as previous-version. + FVL='version=40' FE=0 assert_succeeds "capture succeeds with an active version" run_capture + assert_equals "previous-version threads the active version" "40" "$(capture_prev)" + + # Operational failure: a non-zero active-version exit must fail CLOSED, so a + # production deploy never proceeds with no captured rollback target. + FVL='' FE=2 assert_fails "capture fails closed when active-version errors" run_capture + + # A silent exit-ZERO CLI (no `version=` line at all) must ALSO fail closed — + # not be mistaken for a first deploy. + FS=1 FE=0 assert_fails "capture fails closed on a silent exit-zero CLI" run_capture + + # A MALFORMED value (a `version=` line that isn't empty and isn't all digits) + # must fail closed, not be silently dropped to an empty first-deploy target. + FVL='version=12abc' FE=0 assert_fails "capture fails closed on a malformed version value" run_capture + + # ORDER must not matter: a malformed line followed by a well-formed one must + # STILL fail closed (taking only the last line would read this as a first + # deploy). Exactly one contract line is required. + FVL='version=12abc' FXL='version=' FE=0 \ + assert_fails "capture fails closed when a malformed line precedes a valid one" run_capture + FVL='version=40' FXL='version=41' FE=0 \ + assert_fails "capture fails closed on conflicting version lines" run_capture + + # A CLI without `active-version` fails the credential-free preflight early. + printf '#!/usr/bin/env bash\nexit 2\n' >"$dir/bin/fake-cli" + chmod +x "$dir/bin/fake-cli" + assert_fails "capture fails when the CLI lacks active-version" run_capture +} + +# --------------------------------------------------------------------------- +# healthcheck.sh — the probe path is threaded to the CLI and validated +# --------------------------------------------------------------------------- +# Runs healthcheck.sh against a fake app CLI that records its argv and emits the +# healthy verdict. Returns the recorded argv (one arg per line). +run_healthcheck_argv() { + local dir="$WORK_DIR/healthcheck-argv" + rm -rf "$dir" + mkdir -p "$dir/bin" + cat >"$dir/bin/fake-cli" <<'CLI' +#!/usr/bin/env bash +printf '%s\n' "$@" >"$FAKE_ARGV_OUT" +echo "status-code=200" +echo "healthy=true" +CLI + chmod +x "$dir/bin/fake-cli" + PATH="$dir/bin:$PATH" FAKE_ARGV_OUT="$dir/argv.txt" \ + EDGEZERO__APP__CLI__BIN=fake-cli \ + EDGEZERO__LIFECYCLE__SERVICE_ID=svc123 \ + EDGEZERO__LIFECYCLE__VERSION=7 \ + EDGEZERO__LIFECYCLE__DOMAIN=www.example.com \ + EDGEZERO__LIFECYCLE__PATH="${HC_PATH:-/}" \ + EDGEZERO__DEPLOY__TO=production \ + EDGEZERO__LIFECYCLE__RETRY=1 \ + EDGEZERO__LIFECYCLE__RETRY_DELAY=0 \ + EDGEZERO__LIFECYCLE__TIMEOUT=1 \ + "$ACTIONS_DIR/healthcheck-fastly/scripts/healthcheck.sh" >/dev/null 2>&1 + cat "$dir/argv.txt" 2>/dev/null +} + +test_healthcheck_path() { + section "healthcheck probe path" + # healthcheck.sh gates on a Linux x86-64 runner, so only there does main() + # reach the CLI invocation whose argv we inspect. Skip elsewhere (local macOS); + # CI's static-checks job runs on Linux and exercises it for real. + case "$(uname -s)-$(uname -m)" in + Linux-x86_64 | Linux-amd64) ;; + *) + pass "healthcheck path threading (skipped: non-Linux runner)" + return + ;; + esac + + # Default '/' is threaded as --path. + local default_argv after + default_argv=$(run_healthcheck_argv) + assert_succeeds "default path is threaded as --path" grep -qx -- '--path' <<<"$default_argv" + after=$(grep -A1 -x -- '--path' <<<"$default_argv" | tail -n1) + assert_equals "default --path value is '/'" "/" "$after" + + # A custom path is threaded verbatim. + local custom_argv + custom_argv=$(HC_PATH=/health run_healthcheck_argv) + after=$(grep -A1 -x -- '--path' <<<"$custom_argv" | tail -n1) + assert_equals "a custom --path value is threaded to the CLI" "/health" "$after" + + # A path without a leading slash fails closed, before the CLI is invoked. + assert_fails "a path without a leading slash is rejected" \ + env PATH="$WORK_DIR/healthcheck-argv/bin:$PATH" EDGEZERO__APP__CLI__BIN=fake-cli \ + EDGEZERO__LIFECYCLE__SERVICE_ID=svc123 EDGEZERO__LIFECYCLE__VERSION=7 \ + EDGEZERO__LIFECYCLE__DOMAIN=www.example.com EDGEZERO__LIFECYCLE__PATH=health \ + EDGEZERO__DEPLOY__TO=production EDGEZERO__LIFECYCLE__RETRY=1 \ + EDGEZERO__LIFECYCLE__RETRY_DELAY=0 EDGEZERO__LIFECYCLE__TIMEOUT=1 \ + "$ACTIONS_DIR/healthcheck-fastly/scripts/healthcheck.sh" + + # timeout=0 becomes curl --max-time 0 (no limit): must be rejected. retry-delay=0 + # is legitimate (no wait), so only timeout is constrained to a positive integer. + assert_fails "a zero timeout is rejected (would disable curl's timeout)" \ + env PATH="$WORK_DIR/healthcheck-argv/bin:$PATH" EDGEZERO__APP__CLI__BIN=fake-cli \ + EDGEZERO__LIFECYCLE__SERVICE_ID=svc123 EDGEZERO__LIFECYCLE__VERSION=7 \ + EDGEZERO__LIFECYCLE__DOMAIN=www.example.com EDGEZERO__LIFECYCLE__PATH=/ \ + EDGEZERO__DEPLOY__TO=production EDGEZERO__LIFECYCLE__RETRY=1 \ + EDGEZERO__LIFECYCLE__RETRY_DELAY=0 EDGEZERO__LIFECYCLE__TIMEOUT=0 \ + "$ACTIONS_DIR/healthcheck-fastly/scripts/healthcheck.sh" +} + +# --------------------------------------------------------------------------- +# The mutation-attempted reconcile signal: a provider mutation whose canonical +# output line is missing STILL fails the step (loud), but the signal is already +# durable so an `if: always()` caller can reconcile provider state. +# --------------------------------------------------------------------------- +test_mutation_attempted_signal() { + section "mutation-attempted reconcile signal" + local dir="$WORK_DIR/mutation-signal" + rm -rf "$dir" + mkdir -p "$dir/bin" "$dir/app" + # 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" + + # config-push (cross-platform): missing pushed-key fails the step, yet + # mutation-attempted=true is already written to GITHUB_OUTPUT. + local out="$dir/cp-out.txt" rc=0 + : >"$out" + env PATH="$dir/bin:$PATH" EDGEZERO__APP__CLI__BIN=fake-cli FASTLY_API_TOKEN=tok \ + GITHUB_WORKSPACE="$dir" EDGEZERO__PROJECT__WORKING_DIRECTORY=app GITHUB_OUTPUT="$out" \ + "$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" \ + grep -qx 'mutation-attempted=true' "$out" + + # rollback gates on a Linux runner; only there does main() reach the CLI. + case "$(uname -s)-$(uname -m)" in + Linux-x86_64 | Linux-amd64) ;; + *) + pass "rollback mutation-attempted signal (skipped: non-Linux runner)" + return + ;; + esac + local rout="$dir/rb-out.txt" + rc=0 + : >"$rout" + env PATH="$dir/bin:$PATH" EDGEZERO__APP__CLI__BIN=fake-cli FASTLY_API_TOKEN=tok \ + EDGEZERO__LIFECYCLE__SERVICE_ID=svc123 EDGEZERO__LIFECYCLE__VERSION=9 \ + EDGEZERO__LIFECYCLE__ROLLBACK_TO=8 EDGEZERO__DEPLOY__TO=production GITHUB_OUTPUT="$rout" \ + "$ACTIONS_DIR/rollback-fastly/scripts/rollback.sh" >/dev/null 2>&1 || rc=$? + assert_succeeds "rollback fails on a missing rolled-back-to line" test "$rc" -ne 0 + assert_succeeds "rollback still signals mutation-attempted on that failure" \ + grep -qx 'mutation-attempted=true' "$rout" + + # A missing CLI must NOT signal a mutation — require_cmd fails before the emit. + : >"$rout" + rc=0 + env PATH="$dir/bin:$PATH" EDGEZERO__APP__CLI__BIN=nonexistent-cli FASTLY_API_TOKEN=tok \ + EDGEZERO__LIFECYCLE__SERVICE_ID=svc123 EDGEZERO__LIFECYCLE__VERSION=9 \ + EDGEZERO__LIFECYCLE__ROLLBACK_TO=8 EDGEZERO__DEPLOY__TO=production GITHUB_OUTPUT="$rout" \ + "$ACTIONS_DIR/rollback-fastly/scripts/rollback.sh" >/dev/null 2>&1 || rc=$? + assert_succeeds "a rollback whose CLI is missing fails" test "$rc" -ne 0 + assert_fails "a rollback whose CLI is missing does NOT signal mutation-attempted" \ + grep -qx 'mutation-attempted=true' "$rout" + + # On a CLI failure the exit status is surfaced BEFORE any output write, so + # rolled-back-to is not emitted (an output-write failure cannot mask the status). + printf '#!/usr/bin/env bash\necho "rolled-back-to=8"\nexit 3\n' >"$dir/bin/fake-cli" + chmod +x "$dir/bin/fake-cli" + : >"$rout" + rc=0 + env PATH="$dir/bin:$PATH" EDGEZERO__APP__CLI__BIN=fake-cli FASTLY_API_TOKEN=tok \ + EDGEZERO__LIFECYCLE__SERVICE_ID=svc123 EDGEZERO__LIFECYCLE__VERSION=9 \ + EDGEZERO__LIFECYCLE__ROLLBACK_TO=8 EDGEZERO__DEPLOY__TO=production GITHUB_OUTPUT="$rout" \ + "$ACTIONS_DIR/rollback-fastly/scripts/rollback.sh" >/dev/null 2>&1 || rc=$? + assert_succeeds "a failing rollback CLI exits non-zero" test "$rc" -ne 0 + assert_succeeds "a failing rollback still signals mutation-attempted" \ + grep -qx 'mutation-attempted=true' "$rout" + assert_fails "a failing rollback does NOT write rolled-back-to (status not masked)" \ + grep -qx 'rolled-back-to=8' "$rout" +} + +# --------------------------------------------------------------------------- +# publish-outputs.sh — the trusted output boundary of the two-step build. +# --------------------------------------------------------------------------- +test_publish_outputs() { + section "publish-outputs (trusted output boundary)" + local dir="$WORK_DIR/publish" + rm -rf "$dir" + mkdir -p "$dir/rt" + local pub="$ACTIONS_DIR/build-app-cli/scripts/publish-outputs.sh" + touch "$dir/rt/edgezero-cli.tar" + + # A valid handoff, with a TAMPERED trailing duplicate tarball-path: first wins. + { + printf 'app-cli-version=1.2.3\n' + printf 'app-cli-package=my-cli\n' + printf 'app-cli-bin=my-cli\n' + printf 'app-cli-artifact=edgezero-cli\n' + printf 'tarball-path=%s\n' "$dir/rt/edgezero-cli.tar" + printf 'tarball-path=/evil/hijack.tar\n' + } >"$dir/outputs.env" + local out="$dir/gh-output" + : >"$out" + RUNNER_TEMP="$dir/rt" EDGEZERO__BUILD__OUTPUTS_FILE="$dir/outputs.env" GITHUB_OUTPUT="$out" \ + bash "$pub" >/dev/null 2>&1 + assert_succeeds "publishes the legit tarball-path (first occurrence)" \ + grep -qx "tarball-path=$dir/rt/edgezero-cli.tar" "$out" + assert_fails "ignores a tampered trailing duplicate tarball-path" \ + grep -qx 'tarball-path=/evil/hijack.tar' "$out" + assert_succeeds "publishes the version" grep -qx 'app-cli-version=1.2.3' "$out" + + # A tarball-path escaping the action-owned temp root is refused. + printf 'app-cli-version=1\napp-cli-package=p\napp-cli-bin=b\napp-cli-artifact=a\ntarball-path=/etc/passwd\n' \ + >"$dir/escape.env" + assert_fails_with "a tarball-path outside RUNNER_TEMP is refused" \ + "not beneath the action-owned temp root" \ + env RUNNER_TEMP="$dir/rt" EDGEZERO__BUILD__OUTPUTS_FILE="$dir/escape.env" GITHUB_OUTPUT="$dir/o2" \ + bash "$pub" + + # A missing required output fails closed. + printf 'app-cli-version=1\n' >"$dir/partial.env" + assert_fails "a missing required output fails closed" \ + env RUNNER_TEMP="$dir/rt" EDGEZERO__BUILD__OUTPUTS_FILE="$dir/partial.env" GITHUB_OUTPUT="$dir/o3" \ + bash "$pub" + + # The isolation of the GitHub file-command channels is proved at RUNTIME by the + # scrubbed-ancestor test (the re-exec strips them from the process image); + # blanking them in the step `env:` would be an ineffective no-op (the runner + # reinjects reserved GITHUB_* values), so this action must NOT rely on that. The + # re-exec must strip all five — guard against dropping one. + local common="$ACTIONS_DIR/build-app-cli/scripts/common.sh" ch + for ch in GITHUB_ENV GITHUB_OUTPUT GITHUB_PATH GITHUB_STATE GITHUB_STEP_SUMMARY; do + assert_succeeds "the re-exec strips $ch from the process image" \ + grep -qE "for ghvar in .*\b$ch\b" "$common" + done +} + +# --------------------------------------------------------------------------- +# cleanup_sensitive_temps — a removal FAILURE must fail the action (§13), while +# a real error code is preserved. +# --------------------------------------------------------------------------- +test_cleanup_sensitive_temps() { + section "sensitive temp cleanup surfaces failures" + local lib="$ACTIONS_DIR/deploy-core/scripts/common.sh" + local dir="$WORK_DIR/cleanup-sensitive" + rm -rf "$dir" + mkdir -p "$dir/undeletable" + + local f="$dir/f" rc=0 + : >"$f" + bash -c "source '$lib'; trap \"cleanup_sensitive_temps '$f'\" EXIT; exit 0" || rc=$? + assert_succeeds "a successful cleanup preserves exit 0" test "$rc" -eq 0 + assert_fails "the sensitive file is removed" test -e "$f" + + # `rm -f` cannot remove a directory, so this deterministically fails regardless + # of uid (root included). + rc=0 + bash -c "source '$lib'; trap \"cleanup_sensitive_temps '$dir/undeletable'\" EXIT; exit 0" >/dev/null 2>&1 || rc=$? + assert_succeeds "a cleanup failure on a clean exit fails the action" test "$rc" -ne 0 + + rc=0 + bash -c "source '$lib'; trap \"cleanup_sensitive_temps '$dir/undeletable'\" EXIT; exit 5" >/dev/null 2>&1 || rc=$? + assert_succeeds "a cleanup failure preserves the original non-zero exit" test "$rc" -eq 5 +} + +# --------------------------------------------------------------------------- +# deploy.sh — mutation-attempted must reflect ACTUAL invocation, not setup. +# --------------------------------------------------------------------------- +test_deploy_signal_timing() { + section "deploy mutation-attempted timing" + local dir="$WORK_DIR/deploy-signal" + rm -rf "$dir" + mkdir -p "$dir/bin" "$dir/app" "$dir/rt" + # The fake CLI records whether the signal was ALREADY in GITHUB_OUTPUT when it + # ran — proving the launcher publishes it BEFORE the mutation (so it survives a + # cancellation mid-mutation), not after the CLI returns. + cat >"$dir/bin/fakecli" <<'CLI' +#!/usr/bin/env bash +if grep -qx 'mutation-attempted=true' "${GITHUB_OUTPUT:-/dev/null}" 2>/dev/null; then + echo "signal-before-cli=yes" >"$PROBE" +else + echo "signal-before-cli=no" >"$PROBE" +fi +echo "version=42" +CLI + chmod +x "$dir/bin/fakecli" + printf 'FASTLY_API_TOKEN\0FASTLY_SERVICE_ID\0' >"$dir/clear.nul" + + run_deploy() { + env -i PATH="$dir/bin:$PATH" RUNNER_TEMP="$dir/rt" GITHUB_OUTPUT="$dir/out" \ + PROBE="$dir/probe" \ + EDGEZERO__FASTLY__API_TOKEN=tok EDGEZERO__FASTLY__SERVICE_ID=svc123 \ + EDGEZERO__APP__CLI__BIN="$1" EDGEZERO__ADAPTER=fastly \ + EDGEZERO__PROJECT__WORKING_DIRECTORY="$dir/app" \ + EDGEZERO__PROVIDER__ENV_CLEAR_FILE="$dir/clear.nul" \ + bash "$ACTIONS_DIR/deploy-fastly/scripts/deploy.sh" + } + + # The CLI is invoked and succeeds: both signal and version are emitted. + : >"$dir/out" + : >"$dir/probe" + assert_succeeds "a successful deploy exits 0" run_deploy fakecli + 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" + # Durability: the signal was present BEFORE the CLI finished, so a cancel or + # timeout mid-mutation cannot lose it. + assert_equals "the signal is published before the CLI runs" \ + "signal-before-cli=yes" "$(cat "$dir/probe")" + + # 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 + assert_fails "a deploy that never reaches the CLI does NOT signal mutation-attempted" \ + grep -qx 'mutation-attempted=true' "$dir/out" +} + +# --------------------------------------------------------------------------- +main() { + test_validate_inputs + test_artifact_name + test_owned_dir_confinement + test_cli_bin_confinement + test_run_cli_argv + test_provider_env_boundary + test_download_cli_metadata + test_wrapper_validate + test_resolve_app_cli + test_fastly_versions + test_cleanup_confinement + test_action_env_scrub + test_deploy_args_prepend + test_provider_env_nul + test_lifecycle_helpers + test_capture_previous + test_versions_json_pins_official_release + test_clear_provider_env_aliases + test_toolchain_boundary + test_config_push_argv + test_healthcheck_path + test_mutation_attempted_signal + test_publish_outputs + test_cleanup_sensitive_temps + test_deploy_signal_timing + test_exit_propagation + test_dirty_source_guard + test_cache_key + test_app_repo_boundary + test_action_metadata + test_action_output_contracts + + printf '\nPassed: %d Failed: %d\n' "$tests_passed" "$tests_failed" + [[ "$tests_failed" -eq 0 ]] +} + +main "$@" diff --git a/.github/actions/deploy-fastly/action.yml b/.github/actions/deploy-fastly/action.yml new file mode 100644 index 00000000..74ca6534 --- /dev/null +++ b/.github/actions/deploy-fastly/action.yml @@ -0,0 +1,425 @@ +name: EdgeZero deploy-fastly +description: Deploy a checked-out EdgeZero application to Fastly Compute using a prebuilt app CLI artifact. + +inputs: + app-cli-artifact: + description: Name of the build-app-cli artifact to download and run. + 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. + required: true + fastly-service-id: + description: Fastly service ID. Passed as the typed --service-id CLI flag. + 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: "" + 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). + required: false + default: "[]" + stage: + description: When true, deploy to a staged draft version instead of activating production. + required: false + default: "false" + cache: + description: Enable exact-key Cargo workspace target/ caching. + required: false + default: "false" + +outputs: + fastly-version: + description: Fastly service version deployed (production) or staged. + value: ${{ steps.deploy.outputs['fastly-version'] }} + mutation-attempted: + description: "'true' once the deploy CLI is invoked (emitted before it runs). If the action fails, read this via `if: always()` to know a provider mutation may have occurred and reconcile — do not assume nothing was deployed." + value: ${{ steps.deploy.outputs['mutation-attempted'] }} + provider-cli-version: + description: The pinned Fastly CLI version this action installed and ran. + 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." + value: ${{ steps.capture.outputs['previous-version'] }} + source-revision: + description: Git revision deployed from working-directory. + value: ${{ steps.resolve.outputs['source-revision'] }} + app-cli-version: + description: Version of the app CLI consumed from the artifact. + value: ${{ steps.cli.outputs['app-cli-version'] }} + +runs: + using: composite + steps: + - name: Validate inputs + id: validate + shell: bash + env: + EDGEZERO__ADAPTER: fastly + EDGEZERO__BUILD__MODE: ${{ inputs['build-mode'] }} + EDGEZERO__BUILD__CACHE: ${{ inputs.cache }} + EDGEZERO__BUILD__ARGS: ${{ inputs['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__STAGE: ${{ inputs.stage }} + EDGEZERO__DEPLOY__FLAGS: ${{ inputs.stage == 'true' && format('["--service-id","{0}","--stage"]', inputs['fastly-service-id']) || format('["--service-id","{0}"]', inputs['fastly-service-id']) }} + 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__FASTLY__API_TOKEN_PRESENT: ${{ inputs['fastly-api-token'] != '' && 'true' || 'false' }} + EDGEZERO__FASTLY__SERVICE_ID: ${{ inputs['fastly-service-id'] }} + EDGEZERO__RUNNER__OS: ${{ runner.os }} + EDGEZERO__RUNNER__ARCH: ${{ runner.arch }} + EDGEZERO__ACTION__STATE_DIR: ${{ runner.temp }}/edgezero-deploy-state + 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: $GITHUB_ACTION_PATH/scripts/validate.sh + + - name: Download CLI artifact + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: ${{ inputs['app-cli-artifact'] }} + path: ${{ runner.temp }}/edgezero-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 + shell: bash + env: + EDGEZERO__APP__CLI__ARTIFACT_DIR: ${{ runner.temp }}/edgezero-cli-download + EDGEZERO__ACTION__TOOL_ROOT: ${{ runner.temp }}/edgezero-action-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_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: $GITHUB_ACTION_PATH/../deploy-core/scripts/download-app-cli.sh + + - name: Resolve project + id: resolve + shell: bash + 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: auto + EDGEZERO__PROJECT__TARGET: wasm32-wasip1 + EDGEZERO__BUILD__MODE: ${{ inputs['build-mode'] }} + EDGEZERO__BUILD__CACHE: ${{ inputs.cache }} + 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: $GITHUB_ACTION_PATH/../deploy-core/scripts/resolve-project.sh + + - name: Restore application target cache + if: ${{ inputs.cache == 'true' }} + id: cache-restore + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + 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 full commit SHA (with a readable version comment): a consumer + # who SHA-pins THIS action cannot otherwise freeze a mutable child tag, so + # nested third-party uses are hash-pinned regardless of the tag-pin policy + # that applies at the workflow level. + uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0 + 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: + 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 Fastly CLI + id: install-fastly + shell: bash + env: + EDGEZERO__ACTION__ROOT: ${{ github.action_path }}/../../.. + EDGEZERO__FASTLY__VERSIONS_JSON: ${{ github.action_path }}/versions.json + 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: $GITHUB_ACTION_PATH/scripts/install-fastly.sh + + - name: Build (validation) + if: ${{ steps.resolve.outputs['effective-build-mode'] == 'always' }} + shell: bash + 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: $GITHUB_ACTION_PATH/../deploy-core/scripts/run-app-cli.sh build + + # 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.stage != 'true' }} + shell: bash + env: + 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: "" + 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: $GITHUB_ACTION_PATH/scripts/capture-previous.sh + + - name: Deploy + id: deploy + shell: bash + 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__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: $GITHUB_ACTION_PATH/scripts/deploy.sh + + - name: Save application target cache + if: ${{ inputs.cache == 'true' && steps.cache-restore.outputs['cache-hit'] != 'true' }} + uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + 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: Write summary + if: ${{ always() }} + shell: bash + 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: "" + 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: $GITHUB_ACTION_PATH/../deploy-core/scripts/write-summary.sh + + - name: Cleanup + if: ${{ always() }} + shell: bash + env: + EDGEZERO__ACTION__STATE_DIR: ${{ runner.temp }}/edgezero-deploy-state + EDGEZERO__ACTION__TOOL_ROOT: ${{ runner.temp }}/edgezero-action-tools + 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: $GITHUB_ACTION_PATH/../deploy-core/scripts/cleanup.sh diff --git a/.github/actions/deploy-fastly/scripts/capture-previous.sh b/.github/actions/deploy-fastly/scripts/capture-previous.sh new file mode 100755 index 00000000..f9ef02df --- /dev/null +++ b/.github/actions/deploy-fastly/scripts/capture-previous.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Captures the version that is active BEFORE this deploy — the production rollback +# target — and emits it as `previous-version`. +# +# Fastly's version list exposes no field to tell a previously-live version from a +# staged one (`staging`/`deployed` are documented "Unused"; `locked` only means +# "not editable"), so the rollback target CANNOT be inferred after a deploy +# supersedes it. It has to be read here, first. +# +# Runs only for a production deploy: a staged deploy never activates, so there is +# nothing to roll back TO (staging rollback deactivates the staged version). When +# the service has no active version yet (a first-ever deploy), there is no +# previous version and the output is left empty — the caller simply has no +# production rollback target, which is correct. +# +# The CLI distinguishes "confirmed no active version" (exit 0, empty version) +# from an operational failure (non-zero exit: API/auth error, unparseable list). +# A non-zero exit is NOT tolerated: proceeding to deploy without knowing the +# rollback target would leave production with no way back. Only the genuine +# first-deploy case yields an empty target. +# +# `active-version` is manifest-independent (a pure Fastly-API call keyed on the +# service id), so this runs the CLI from wherever the step is — no app-directory +# resolution needed. +# +# Reads (env): +# EDGEZERO__APP__CLI__PATH / _BIN required the app CLI (via resolve_app_cli) +# EDGEZERO__FASTLY__SERVICE_ID required the Fastly service id +# FASTLY_API_TOKEN required provider token (Fastly's own convention) +# Writes (outputs): +# previous-version the active version before this deploy (may be empty) + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=../../deploy-core/scripts/common.sh +source "$SCRIPT_DIR/../../deploy-core/scripts/common.sh" + +main() { + local cli_bin service_id + cli_bin=$(resolve_app_cli) + service_id="${EDGEZERO__FASTLY__SERVICE_ID:?EDGEZERO__FASTLY__SERVICE_ID is required}" + require_input fastly-api-token "${FASTLY_API_TOKEN:-}" + require_cmd "$cli_bin" + + # Credential-free preflight: confirm the app CLI actually exposes + # `active-version` BEFORE invoking it with the token. A missing subcommand here + # means the CLI was built without the lifecycle commands wired — fail with a + # clear, actionable message instead of a bare clap "unrecognized subcommand". + # The token is UNSET for this probe so it truly never reaches a `--help` call; + # only the real API invocation below sees it. + if ! env -u FASTLY_API_TOKEN "$cli_bin" active-version --help >/dev/null 2>&1; then + fail "the app CLI does not expose the \`active-version\` command, which a production deploy needs to capture the rollback target. Wire \`edgezero_cli::run_active_version\` (and \`run_healthcheck\` / \`run_rollback\`) into your CLI -- see the 'Deploying from GitHub Actions' guide's required command surface." + fi + + new_private_log + # Fail CLOSED on an operational failure: the CLI exits 0 for "no active version" + # (first deploy) and non-zero only for a real failure. Capture the CLI's exit + # (pipefail makes it the pipeline status; tee exits 0). + local rc=0 + "$cli_bin" active-version --adapter fastly --service-id "$service_id" \ + 2>&1 | tee "$LIFECYCLE_LOG" || rc=$? + if [[ "$rc" -ne 0 ]]; then + fail_with "$rc" "could not determine the active version (CLI exit $rc); refusing to deploy without a captured rollback target. A first-ever deploy (no active version) exits 0 with an empty target — a non-zero exit means an API/auth/parse failure." + fi + + # A successful exit is NOT enough: the CLI must emit EXACTLY one of the + # `version=` contract shapes — `version=` for an active version, or an + # empty `version=` for a confirmed first deploy. Anything else fails CLOSED: + # * NO `version=` line at all (a CLI that exited 0 but printed nothing + # parseable — e.g. one that never called `init_cli_logger`); or + # * a MALFORMED value like `version=12abc`, which the anchored numeric parse + # would otherwise silently drop to empty and mistake for a first deploy. + # EXACTLY ONE `version=` line must be present. Taking only the last would let a + # malformed line hide behind a later well-formed one (e.g. `version=12abc` + # followed by `version=`, which would read as a first deploy), so a conflicting + # or duplicated contract line is refused outright. + local version_lines version_count version_line + version_lines=$(grep -E '^version=' "$LIFECYCLE_LOG" || true) + version_count=$(printf '%s' "$version_lines" | grep -c . || true) + if [[ "$version_count" -eq 0 ]]; then + fail_with 1 "active-version exited 0 but emitted no \`version=\` line; refusing to deploy without a confirmed rollback target. Ensure the app CLI dispatches \`active-version\` to \`edgezero_cli::run_active_version\` and initialises its logger (\`edgezero_cli::init_cli_logger()\`) so machine-readable lines are printed unprefixed." + fi + if [[ "$version_count" -gt 1 ]]; then + fail_with 1 "active-version emitted $version_count \`version=\` lines; exactly one is required. Refusing to guess which is the rollback target. Lines: $(printf '%s' "$version_lines" | tr '\n' ' ')" + fi + version_line="$version_lines" + if [[ ! "$version_line" =~ ^version=([0-9]+)?$ ]]; then + fail_with 1 "active-version emitted a malformed rollback target '$version_line'; expected \`version=\` or an empty \`version=\`. Refusing to deploy with an unparseable version." + fi + + local previous="${BASH_REMATCH[1]:-}" + append_output previous-version "$previous" + if [[ -n "$previous" ]]; then + notice "captured production rollback target: previous-version=$previous" + else + notice "no active version yet (first deploy?); previous-version is empty" + fi +} + +main "$@" diff --git a/.github/actions/deploy-fastly/scripts/common.sh b/.github/actions/deploy-fastly/scripts/common.sh new file mode 100755 index 00000000..25de5f3b --- /dev/null +++ b/.github/actions/deploy-fastly/scripts/common.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Sourced helper library for the Fastly wrapper. Defines the shared shell helpers +# (annotations, output/env writers, version pinning and checksum helpers). It is +# never executed directly and reads no environment of its own; callers source it +# right after their own strict-mode preamble. + +escape_annotation() { + local value="$*" + value=${value//%/%25} + value=${value//$'\r'/%0D} + value=${value//$'\n'/%0A} + printf '%s' "$value" +} + +fail() { + local message + message=$(escape_annotation "$*") + echo "::error::$message" >&2 + exit 1 +} + +notice() { + local message + message=$(escape_annotation "$*") + echo "::notice::$message" >&2 +} + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || fail "required command '$1' was not found" +} + +append_output() { + local name="$1" + local value="$2" + [[ "$name" =~ ^[A-Za-z_][A-Za-z0-9_-]*$ ]] || fail "invalid output name '$name'" + if [[ "$value" == *$'\n'* || "$value" == *$'\r'* ]]; then + fail "output '$name' contains a newline or carriage return" + fi + if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + printf '%s=%s\n' "$name" "$value" >>"$GITHUB_OUTPUT" + else + printf '%s=%s\n' "$name" "$value" + fi +} + +append_env() { + local name="$1" + local value="$2" + [[ "$name" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || fail "invalid environment name '$name'" + if [[ "$value" == *$'\n'* || "$value" == *$'\r'* ]]; then + fail "environment value '$name' contains a newline or carriage return" + fi + if [[ -n "${GITHUB_ENV:-}" ]]; then + printf '%s=%s\n' "$name" "$value" >>"$GITHUB_ENV" + else + export "$name=$value" + fi +} + +canonical_path() { + require_cmd realpath + local path + path=$(realpath "$1" 2>/dev/null) || fail "could not resolve path '$1'" + printf '%s\n' "$path" +} + +relative_to() { + local root="${1%/}" + local path="${2%/}" + if [[ "$path" == "$root" ]]; then + printf '.\n' + elif [[ "$path" == "$root"/* ]]; then + printf '%s\n' "${path#"$root"/}" + else + printf '%s\n' "$path" + fi +} + +is_under() { + local root="${1%/}" + local path="${2%/}" + [[ "$path" == "$root" || "$path" == "$root"/* ]] +} + +json_get() { + require_cmd jq + jq -er ".$2" "$1" +} + +sha256_file() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{ print $1 }' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" | awk '{ print $1 }' + else + fail "required command 'sha256sum' or 'shasum' was not found" + fi +} + +read_tool_version() { + local file="$1" + local tool="$2" + awk -v tool="$tool" '$1 == tool { print $2; found=1; exit } END { if (!found) exit 1 }' "$file" +} + +sanitize_ref() { + printf '%s' "$1" | tr -c 'A-Za-z0-9_.=-' '-' +} diff --git a/.github/actions/deploy-fastly/scripts/deploy.sh b/.github/actions/deploy-fastly/scripts/deploy.sh new file mode 100755 index 00000000..82a73b23 --- /dev/null +++ b/.github/actions/deploy-fastly/scripts/deploy.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Runs the application CLI's deploy through the provider-env credential boundary +# and emits the resulting Fastly version. +# +# Credentials are handed to run-app-cli.sh as DATA (a JSON object), not as FASTLY_* +# aliases on this step. run-app-cli.sh clears every declared alias — including any +# inherited FASTLY_ENDPOINT / FASTLY_TOKEN — exports only these typed values, and +# then scrubs its own private variables (including this JSON) before exec'ing the +# CLI. Building the JSON here, from step `env:`, is also what keeps the secret out +# of an interpolated `run:` block. +# +# Reads (env): +# EDGEZERO__FASTLY__API_TOKEN required typed Fastly API token +# EDGEZERO__FASTLY__SERVICE_ID required typed Fastly service id +# (plus the run-app-cli.sh Reads contract, which this delegates to) +# Writes (outputs): +# mutation-attempted true, emitted before the CLI runs (reconcile signal) +# fastly-version the deployed/staged Fastly version + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=../../deploy-core/scripts/common.sh +source "$SCRIPT_DIR/../../deploy-core/scripts/common.sh" + +main() { + local token="${EDGEZERO__FASTLY__API_TOKEN:-}" + local service_id="${EDGEZERO__FASTLY__SERVICE_ID:-}" + + require_input fastly-api-token "$token" + require_input_matching fastly-service-id "$service_id" '^[A-Za-z0-9_-]+$' + require_cmd jq + + EDGEZERO__PROVIDER__ENV=$(jq -n --arg t "$token" --arg s "$service_id" \ + '{FASTLY_API_TOKEN: $t, FASTLY_SERVICE_ID: $s}') + export EDGEZERO__PROVIDER__ENV + + new_private_log + # run-app-cli.sh publishes `mutation-attempted=true` itself, immediately before + # it invokes the CLI — so a setup failure never falsely signals, and the signal + # is durable in GITHUB_OUTPUT before the mutation starts (surviving a cancel / + # timeout mid-mutation). This wrapper only threads the resulting version out. + local rc=0 + "$SCRIPT_DIR/../../deploy-core/scripts/run-app-cli.sh" deploy 2>&1 | tee "$LIFECYCLE_LOG" || rc=$? + if [[ "$rc" -ne 0 ]]; then + fail_with "$rc" "deploy failed (CLI exit $rc or setup error before invocation)" + fi + + local version + version=$(read_numeric_line version "$LIFECYCLE_LOG") + [[ -n "$version" ]] || + fail "deploy reported success but emitted no canonical 'version=' line, so there is no version to thread into healthcheck or rollback" + + append_output fastly-version "$version" +} + +main "$@" diff --git a/.github/actions/deploy-fastly/scripts/install-fastly.sh b/.github/actions/deploy-fastly/scripts/install-fastly.sh new file mode 100755 index 00000000..d1b5a38b --- /dev/null +++ b/.github/actions/deploy-fastly/scripts/install-fastly.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Installs the pinned Fastly CLI from an official release, verifying its SHA-256 +# checksum, into an action-owned dir on PATH. This is the Fastly wrapper's +# provider-tool responsibility; the provider-neutral engine never installs it. +# +# Reads (env): +# EDGEZERO__ACTION__ROOT optional repo root holding .tool-versions (default: walk up) +# EDGEZERO__FASTLY__VERSIONS_JSON optional pinned metadata (default: alongside this dir) +# EDGEZERO__ACTION__TOOL_ROOT optional install dir (default: under RUNNER_TEMP) +# Writes (outputs): +# provider-cli-version the installed Fastly CLI version +# Writes (PATH): +# the tool root's bin/, so `fastly` is callable + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=common.sh +source "$SCRIPT_DIR/common.sh" + +require_linux_x86_64() { + case "$(uname -s)-$(uname -m)" in + Linux-x86_64 | Linux-amd64) ;; + *) fail "the Fastly wrapper supports only Linux x86-64 runners" ;; + esac +} + +# The provider CLI gets its OWN directory, never the app CLI's `bin/`. The app +# names its own binary, and `fastly` is a legal app-CLI name -- sharing one +# directory would let this install silently overwrite the very CLI we are about +# to run. Both dirs live under the action-owned tool root, so cleanup still +# removes them together. +provider_bin_dir() { printf '%s\n' "$1/provider-bin"; } + +main() { + local action_dir + action_dir=$(cd -- "$SCRIPT_DIR/.." && pwd) + local action_root="${EDGEZERO__ACTION__ROOT:-$(cd -- "$action_dir/../../.." && pwd)}" + local versions_json="${EDGEZERO__FASTLY__VERSIONS_JSON:-$action_dir/versions.json}" + local tool_root="${EDGEZERO__ACTION__TOOL_ROOT:-${RUNNER_TEMP:-/tmp}/edgezero-action-tools}" + + require_linux_x86_64 + require_cmd jq + require_cmd curl + local provider_bin + provider_bin=$(provider_bin_dir "$tool_root") + mkdir -p "$provider_bin" "$tool_root/downloads" + + # The pinned version must agree with the repository .tool-versions policy. + local version tool_version + version=$(json_get "$versions_json" fastly.version) + tool_version=$(read_tool_version "$action_root/.tool-versions" fastly || true) + [[ -n "$tool_version" ]] || fail "EdgeZero repository .tool-versions must contain a fastly entry" + [[ "$version" == "$tool_version" ]] || fail "Fastly version mismatch: versions.json has $version but .tool-versions has $tool_version" + + # The `fastly` binary is ALWAYS (re)extracted from a checksum-verified archive + # — never adopted from a pre-existing binary. Trusting an already-present + # binary by its version text would let an earlier step plant an arbitrary + # executable in this predictable dir that then runs with FASTLY_API_TOKEN. The + # ARCHIVE is the trust anchor: its SHA-256 must match `versions.json`, and the + # binary is extracted from it every run. + # + # This stays idempotent WITHOUT trusting a binary: the archive is cached under + # `downloads/`, so a second run in the same job re-verifies the cached archive + # (cheap) and re-extracts rather than refetching. + local url sha256 archive actual + url=$(json_get "$versions_json" fastly.linux_amd64.url) + sha256=$(json_get "$versions_json" fastly.linux_amd64.sha256) + archive="$tool_root/downloads/fastly-$version-linux-amd64.tar.gz" + + [[ -f "$archive" ]] || curl --fail --location --silent --show-error "$url" --output "$archive" + + actual=$(sha256_file "$archive") + [[ "$actual" == "$sha256" ]] || fail "Fastly CLI checksum mismatch for version $version" + + tar -xzf "$archive" -C "$provider_bin" fastly + chmod +x "$provider_bin/fastly" + + printf '%s\n' "$provider_bin" >>"${GITHUB_PATH:-/dev/null}" + export PATH="$provider_bin:$PATH" + + local provider_cli_version + provider_cli_version=$(fastly version 2>/dev/null || fastly --version 2>/dev/null || true) + provider_cli_version=${provider_cli_version%%$'\n'*} + [[ -n "$provider_cli_version" ]] || fail "installed Fastly CLI did not report a version" + notice "installed Fastly CLI: $provider_cli_version" + append_output provider-cli-version "$provider_cli_version" +} + +main "$@" diff --git a/.github/actions/deploy-fastly/scripts/validate.sh b/.github/actions/deploy-fastly/scripts/validate.sh new file mode 100755 index 00000000..5ca8914e --- /dev/null +++ b/.github/actions/deploy-fastly/scripts/validate.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Validates the deploy-fastly wrapper's own (Fastly-specific) inputs, then +# delegates the provider-neutral checks to the engine's validate-inputs.sh. +# +# It is a script rather than inline action.yml, so CI lints and contract-tests it +# like every other action script. The Fastly-specific checks stay in the WRAPPER, +# never in the provider-neutral engine (which hard-codes no provider names). +# +# The credential is checked by PRESENCE, not value: the token never reaches this +# step (it is scoped to the deploy step only), so the wrapper passes a +# precomputed `…_PRESENT` boolean instead. +# +# Reads (env): +# EDGEZERO__APP__CLI__ARTIFACT_PRESENT required "true" when app-cli-artifact is non-empty +# 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" + +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:-}" + require_present fastly-api-token "${EDGEZERO__FASTLY__API_TOKEN_PRESENT:-}" + require_input_matching fastly-service-id "${EDGEZERO__FASTLY__SERVICE_ID:-}" '^[A-Za-z0-9_-]+$' + + # Provider-neutral validation (adapter, booleans, JSON-array args, the + # allowlist). It also rejects a non-boolean 'stage' before any deploy. + "$SCRIPT_DIR/../../deploy-core/scripts/validate-inputs.sh" +} + +main "$@" diff --git a/.github/actions/deploy-fastly/scripts/verify-installed-version.sh b/.github/actions/deploy-fastly/scripts/verify-installed-version.sh new file mode 100755 index 00000000..6fef0160 --- /dev/null +++ b/.github/actions/deploy-fastly/scripts/verify-installed-version.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Confirms the Fastly CLI that install-fastly.sh placed in the action-owned tool +# root actually reports the pinned version. Runs after a REAL (un-faked) install +# so a version/URL/checksum bump in versions.json is validated against the +# genuine release binary — the lifecycle smoke test fakes the CLI and never +# exercises this. +# +# Reads (env): +# EDGEZERO__FASTLY__VERSIONS_JSON required pinned metadata (holds fastly.version) +# EDGEZERO__ACTION__TOOL_ROOT required the install dir install-fastly.sh wrote to + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=../../deploy-core/scripts/common.sh +source "$SCRIPT_DIR/../../deploy-core/scripts/common.sh" + +main() { + local versions_json="${EDGEZERO__FASTLY__VERSIONS_JSON:?EDGEZERO__FASTLY__VERSIONS_JSON is required}" + local tool_root="${EDGEZERO__ACTION__TOOL_ROOT:?EDGEZERO__ACTION__TOOL_ROOT is required}" + + require_cmd jq + local pinned fastly_bin + pinned=$(json_get "$versions_json" fastly.version) + fastly_bin="$tool_root/provider-bin/fastly" + + [[ -x "$fastly_bin" ]] || + fail "installer did not place an executable fastly at $fastly_bin" + + # Capture the full output and take the first line via parameter expansion — + # piping to `head` would SIGPIPE `fastly` (exit 141) under `set -o pipefail`. + local reported + reported=$("$fastly_bin" version) + reported=${reported%%$'\n'*} + + [[ "$reported" == *"$pinned"* ]] || + fail "installed Fastly CLI reports '$reported', expected version $pinned" + + notice "installed Fastly CLI reports the pinned version $pinned: $reported" +} + +main "$@" diff --git a/.github/actions/deploy-fastly/versions.json b/.github/actions/deploy-fastly/versions.json new file mode 100644 index 00000000..1304e487 --- /dev/null +++ b/.github/actions/deploy-fastly/versions.json @@ -0,0 +1,10 @@ +{ + "fastly": { + "version": "15.1.0", + "linux_amd64": { + "url": "https://github.com/fastly/cli/releases/download/v15.1.0/fastly_v15.1.0_linux-amd64.tar.gz", + "sha256": "3ba3d8a739b7a88d0a612825a9755d735efb87a9b02ea67e53a11b96d178d500" + } + }, + "rust_target": "wasm32-wasip1" +} diff --git a/.github/actions/healthcheck-fastly/action.yml b/.github/actions/healthcheck-fastly/action.yml new file mode 100644 index 00000000..cbdf41bd --- /dev/null +++ b/.github/actions/healthcheck-fastly/action.yml @@ -0,0 +1,188 @@ +name: EdgeZero healthcheck-fastly +description: Probe a deployed Fastly version's health via the app CLI. Exits non-zero when unhealthy after retries. + +inputs: + app-cli-artifact: + description: Name of the build-app-cli artifact to download and run. + 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. + required: true + fastly-version: + description: Fastly service version to check. + required: true + domain: + description: Domain to probe (e.g. www.example.com). + 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 '/'." + 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. + required: false + default: "3" + retry-delay: + description: Seconds between attempts. + required: false + default: "5" + timeout: + description: Per-attempt request timeout in seconds. Must be a positive integer (0 would disable curl's timeout). + required: false + default: "10" + +outputs: + healthy: + description: Whether the deployment is healthy (true/false). + value: ${{ steps.check.outputs.healthy }} + status-code: + description: HTTP status code returned. + value: ${{ steps.check.outputs['status-code'] }} + +runs: + using: composite + steps: + # 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: + 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). + 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: $GITHUB_ACTION_PATH/scripts/validate.sh + + - name: Download CLI artifact + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: ${{ inputs['app-cli-artifact'] }} + path: ${{ runner.temp }}/edgezero-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 + shell: bash + env: + EDGEZERO__APP__CLI__ARTIFACT_DIR: ${{ runner.temp }}/edgezero-cli-download + EDGEZERO__ACTION__TOOL_ROOT: ${{ runner.temp }}/edgezero-action-tools + EDGEZERO__APP__CLI__BIN: ${{ inputs['app-cli-bin'] }} + FASTLY_API_TOKEN: "" + FASTLY_TOKEN: "" + FASTLY_KEY: "" + FASTLY_API_KEY: "" + FASTLY_AUTH_TOKEN: "" + FASTLY_ENDPOINT: "" + FASTLY_API_ENDPOINT: "" + FASTLY_API_URL: "" + FASTLY_PROFILE: "" + FASTLY_SERVICE_ID: "" + FASTLY_SERVICE_NAME: "" + FASTLY_DEBUG: "" + FASTLY_DEBUG_MODE: "" + FASTLY_CONFIG_FILE: "" + FASTLY_CARGO_PROFILE: "" + FASTLY_HOME: "" + run: $GITHUB_ACTION_PATH/../deploy-core/scripts/download-app-cli.sh + + - name: Health check + id: check + shell: bash + env: + EDGEZERO__APP__CLI__BIN: ${{ steps.cli.outputs['app-cli-bin'] }} + EDGEZERO__APP__CLI__PATH: ${{ steps.cli.outputs['app-cli-path'] }} + EDGEZERO__LIFECYCLE__SERVICE_ID: ${{ inputs['fastly-service-id'] }} + EDGEZERO__LIFECYCLE__VERSION: ${{ inputs['fastly-version'] }} + EDGEZERO__LIFECYCLE__DOMAIN: ${{ inputs.domain }} + EDGEZERO__LIFECYCLE__PATH: ${{ inputs.path }} + EDGEZERO__DEPLOY__TO: ${{ inputs['deploy-to'] }} + EDGEZERO__LIFECYCLE__RETRY: ${{ inputs.retry }} + EDGEZERO__LIFECYCLE__RETRY_DELAY: ${{ inputs['retry-delay'] }} + EDGEZERO__LIFECYCLE__TIMEOUT: ${{ inputs.timeout }} + # Only the typed token reaches the CLI; blank any inherited alias. + # Only a STAGING probe needs the token (staging-IP resolution). A + # production probe just curls the domain, so it receives no token. + FASTLY_API_TOKEN: ${{ inputs['deploy-to'] == 'staging' && inputs['fastly-api-token'] || '' }} + FASTLY_TOKEN: "" + FASTLY_KEY: "" + FASTLY_API_KEY: "" + FASTLY_AUTH_TOKEN: "" + FASTLY_ENDPOINT: "" + FASTLY_API_ENDPOINT: "" + FASTLY_API_URL: "" + FASTLY_PROFILE: "" + FASTLY_SERVICE_ID: "" + FASTLY_SERVICE_NAME: "" + FASTLY_DEBUG: "" + FASTLY_DEBUG_MODE: "" + FASTLY_CONFIG_FILE: "" + FASTLY_CARGO_PROFILE: "" + FASTLY_HOME: "" + run: $GITHUB_ACTION_PATH/scripts/healthcheck.sh + + - name: Cleanup + if: ${{ always() }} + shell: bash + env: + EDGEZERO__ACTION__TOOL_ROOT: ${{ runner.temp }}/edgezero-action-tools + 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: $GITHUB_ACTION_PATH/../deploy-core/scripts/cleanup.sh diff --git a/.github/actions/healthcheck-fastly/scripts/healthcheck.sh b/.github/actions/healthcheck-fastly/scripts/healthcheck.sh new file mode 100755 index 00000000..c1a9b18b --- /dev/null +++ b/.github/actions/healthcheck-fastly/scripts/healthcheck.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Probes a deployed Fastly version through the application CLI and fails closed. +# +# Callers gate their rollback on this action FAILING. So every path that cannot +# prove the deployment is healthy must exit non-zero: a non-zero CLI, a +# `healthy=false` verdict, and — critically — no verdict at all. +# +# Reads (env): +# EDGEZERO__APP__CLI__PATH optional absolute path to the app CLI (preferred; avoids PATH shadowing) +# EDGEZERO__APP__CLI__BIN optional app CLI name, used when __PATH is unset +# EDGEZERO__LIFECYCLE__SERVICE_ID required Fastly service id +# EDGEZERO__LIFECYCLE__VERSION required version to probe +# EDGEZERO__LIFECYCLE__DOMAIN required domain to probe +# EDGEZERO__LIFECYCLE__PATH optional URL path to probe (default: /) +# FASTLY_API_TOKEN staging-only provider token (staging-IP resolution) +# EDGEZERO__DEPLOY__TO optional production | staging (default: production) +# EDGEZERO__LIFECYCLE__RETRY optional attempts before unhealthy (default: 3) +# EDGEZERO__LIFECYCLE__RETRY_DELAY optional seconds between attempts (default: 5) +# EDGEZERO__LIFECYCLE__TIMEOUT optional per-attempt timeout seconds (default: 10) +# Writes (outputs): +# healthy true | false +# status-code last HTTP status observed +# 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" + +validate_inputs() { + require_linux_x86_64 + # `required: true` in action metadata does not fail an omitted input, so the + # only real guard against probing with an empty service/version is this one. + require_input_matching fastly-service-id "${EDGEZERO__LIFECYCLE__SERVICE_ID:-}" '^[A-Za-z0-9_-]+$' + require_input_matching fastly-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 + # re-validates), so it must begin with '/' and carry no whitespace that would + # break the URL or smuggle a second token. + local probe_path="${EDGEZERO__LIFECYCLE__PATH:-/}" + case "$probe_path" in + /*) ;; + *) fail "input 'path' must begin with '/' (got '$probe_path')" ;; + esac + [[ "$probe_path" != *[[:space:]]* ]] || + fail "input 'path' must not contain whitespace (got '$probe_path')" + # `retry` is a TOTAL attempt count, so 0 is meaningless (the CLI would silently + # clamp it to 1). Require at least one attempt rather than accept-and-coerce. + require_input_matching retry "${EDGEZERO__LIFECYCLE__RETRY:-}" '^[1-9][0-9]*$' + require_input_matching retry-delay "${EDGEZERO__LIFECYCLE__RETRY_DELAY:-}" '^[0-9]+$' + # `timeout` becomes curl `--max-time`, where 0 means "no limit" — a probe could + # then run until the whole step is externally cancelled. Require a positive value + # (retry-delay may be 0: no wait between attempts is legitimate). + require_input_matching timeout "${EDGEZERO__LIFECYCLE__TIMEOUT:-}" '^[1-9][0-9]*$' + # A typo in deploy-to must never silently probe production. + case "${EDGEZERO__DEPLOY__TO:-}" in + production | staging) ;; + *) fail "input 'deploy-to' must be 'production' or 'staging' (got '${EDGEZERO__DEPLOY__TO:-}')" ;; + esac + # The token is required ONLY for a staging probe (staging-IP resolution). A + # production probe just curls the public domain, so it needs no token — and the + # wrapper passes none. + if [[ "${EDGEZERO__DEPLOY__TO:-}" == "staging" ]]; then + require_input fastly-api-token "${FASTLY_API_TOKEN:-}" + fi +} + +main() { + validate_inputs + + # `healthcheck` is manifest-independent (a pure API/curl probe), so it runs + # from wherever the step is — no app-directory resolution needed. + local argv=( + "$(resolve_app_cli)" healthcheck + --adapter fastly + --service-id "$EDGEZERO__LIFECYCLE__SERVICE_ID" + --version "$EDGEZERO__LIFECYCLE__VERSION" + --domain "$EDGEZERO__LIFECYCLE__DOMAIN" + --path "${EDGEZERO__LIFECYCLE__PATH:-/}" + --retry "$EDGEZERO__LIFECYCLE__RETRY" + --retry-delay "$EDGEZERO__LIFECYCLE__RETRY_DELAY" + --timeout "$EDGEZERO__LIFECYCLE__TIMEOUT" + ) + if [[ "$EDGEZERO__DEPLOY__TO" == "staging" ]]; then + argv+=(--staging) + fi + + new_private_log + local rc=0 + "${argv[@]}" 2>&1 | tee "$LIFECYCLE_LOG" || rc=$? + + local healthy status + healthy=$(read_bool_line healthy "$LIFECYCLE_LOG") + status=$(read_numeric_line status-code "$LIFECYCLE_LOG") + append_output healthy "${healthy:-false}" + append_output status-code "$status" + + if [[ "$rc" -ne 0 ]]; then + fail_with "$rc" "health check failed (CLI exit $rc, healthy=${healthy:-}, status=${status:-})" + fi + if [[ "$healthy" != "true" ]]; then + fail "health check did not report healthy=true (got '${healthy:-}')" + fi +} + +main "$@" diff --git a/.github/actions/healthcheck-fastly/scripts/validate.sh b/.github/actions/healthcheck-fastly/scripts/validate.sh new file mode 100755 index 00000000..5edcf440 --- /dev/null +++ b/.github/actions/healthcheck-fastly/scripts/validate.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Validates the healthcheck-fastly wrapper's inputs before downloading the artifact. 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 + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=../../deploy-core/scripts/common.sh +source "$SCRIPT_DIR/../../deploy-core/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:-}" diff --git a/.github/actions/rollback-fastly/action.yml b/.github/actions/rollback-fastly/action.yml new file mode 100644 index 00000000..2f2ca3e6 --- /dev/null +++ b/.github/actions/rollback-fastly/action.yml @@ -0,0 +1,166 @@ +name: EdgeZero rollback-fastly +description: Roll back a Fastly deployment via the app CLI. Production activates the previous version; staging deactivates the staged version. + +inputs: + app-cli-artifact: + description: Name of the build-app-cli artifact to download and run. + 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. + required: true + fastly-service-id: + description: Fastly service ID. + required: true + fastly-version: + description: The current (bad) Fastly version to roll back from. + 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." + required: false + default: "" + deploy-to: + 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' once the rollback CLI is invoked (emitted before it runs). If the action fails, read this via `if: always()` to know the active version may have changed and reconcile — do not assume the rollback was a no-op." + value: ${{ steps.rollback.outputs['mutation-attempted'] }} + +runs: + using: composite + steps: + # 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: + 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). + 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: $GITHUB_ACTION_PATH/scripts/validate.sh + + - name: Download CLI artifact + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: ${{ inputs['app-cli-artifact'] }} + path: ${{ runner.temp }}/edgezero-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 + shell: bash + env: + EDGEZERO__APP__CLI__ARTIFACT_DIR: ${{ runner.temp }}/edgezero-cli-download + EDGEZERO__ACTION__TOOL_ROOT: ${{ runner.temp }}/edgezero-action-tools + EDGEZERO__APP__CLI__BIN: ${{ inputs['app-cli-bin'] }} + FASTLY_API_TOKEN: "" + FASTLY_TOKEN: "" + FASTLY_KEY: "" + FASTLY_API_KEY: "" + FASTLY_AUTH_TOKEN: "" + FASTLY_ENDPOINT: "" + FASTLY_API_ENDPOINT: "" + FASTLY_API_URL: "" + FASTLY_PROFILE: "" + FASTLY_SERVICE_ID: "" + FASTLY_SERVICE_NAME: "" + FASTLY_DEBUG: "" + FASTLY_DEBUG_MODE: "" + FASTLY_CONFIG_FILE: "" + FASTLY_CARGO_PROFILE: "" + FASTLY_HOME: "" + run: $GITHUB_ACTION_PATH/../deploy-core/scripts/download-app-cli.sh + + - name: Rollback + id: rollback + shell: bash + env: + 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_TOKEN: "" + FASTLY_KEY: "" + FASTLY_API_KEY: "" + FASTLY_AUTH_TOKEN: "" + FASTLY_ENDPOINT: "" + FASTLY_API_ENDPOINT: "" + FASTLY_API_URL: "" + FASTLY_PROFILE: "" + FASTLY_SERVICE_ID: "" + FASTLY_SERVICE_NAME: "" + FASTLY_DEBUG: "" + FASTLY_DEBUG_MODE: "" + FASTLY_CONFIG_FILE: "" + FASTLY_CARGO_PROFILE: "" + FASTLY_HOME: "" + run: $GITHUB_ACTION_PATH/scripts/rollback.sh + + - name: Cleanup + if: ${{ always() }} + shell: bash + env: + EDGEZERO__ACTION__TOOL_ROOT: ${{ runner.temp }}/edgezero-action-tools + 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: $GITHUB_ACTION_PATH/../deploy-core/scripts/cleanup.sh diff --git a/.github/actions/rollback-fastly/scripts/rollback.sh b/.github/actions/rollback-fastly/scripts/rollback.sh new file mode 100755 index 00000000..48ff74c7 --- /dev/null +++ b/.github/actions/rollback-fastly/scripts/rollback.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Rolls a Fastly deployment back through the application CLI. +# +# Production activates the previous version; staging deactivates the staged one. +# Fails closed: a rollback that cannot say what it activated has not provably +# rolled anything back. +# +# Reads (env): +# EDGEZERO__APP__CLI__PATH optional absolute path to the app CLI (preferred; avoids PATH shadowing) +# EDGEZERO__APP__CLI__BIN optional app CLI name, used when __PATH is unset +# EDGEZERO__LIFECYCLE__SERVICE_ID required Fastly service id +# EDGEZERO__LIFECYCLE__VERSION required the current (bad) version to roll back from +# EDGEZERO__LIFECYCLE__ROLLBACK_TO required (production) the version to re-activate +# FASTLY_API_TOKEN required provider token (Fastly's own convention) +# EDGEZERO__DEPLOY__TO optional production | staging (default: production) +# Writes (outputs): +# mutation-attempted true, emitted before the CLI runs (reconcile signal) +# rolled-back-to the activated version (production only) + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=../../deploy-core/scripts/common.sh +source "$SCRIPT_DIR/../../deploy-core/scripts/common.sh" + +validate_inputs() { + require_linux_x86_64 + require_input_matching fastly-service-id "${EDGEZERO__LIFECYCLE__SERVICE_ID:-}" '^[A-Za-z0-9_-]+$' + require_input_matching fastly-version "${EDGEZERO__LIFECYCLE__VERSION:-}" '^[0-9]+$' + require_input fastly-api-token "${FASTLY_API_TOKEN:-}" + # A typo in deploy-to must never silently roll back production. + case "${EDGEZERO__DEPLOY__TO:-}" in + production) + # Fastly cannot infer the previously-live version, so production requires + # an explicit target (wired from deploy-fastly's previous-version output). + require_input_matching rollback-to "${EDGEZERO__LIFECYCLE__ROLLBACK_TO:-}" '^[0-9]+$' + ;; + staging) ;; + *) fail "input 'deploy-to' must be 'production' or 'staging' (got '${EDGEZERO__DEPLOY__TO:-}')" ;; + esac +} + +main() { + validate_inputs + + # `rollback` is manifest-independent (a pure Fastly-API call), so it runs from + # wherever the step is — no app-directory resolution needed. Resolve and VERIFY + # the CLI before signalling a mutation: a missing binary must not falsely claim a + # rollback was attempted. + local cli_bin + cli_bin=$(resolve_app_cli) + require_cmd "$cli_bin" + local argv=("$cli_bin" rollback --adapter fastly --service-id "$EDGEZERO__LIFECYCLE__SERVICE_ID" --version "$EDGEZERO__LIFECYCLE__VERSION") + if [[ "$EDGEZERO__DEPLOY__TO" == "staging" ]]; then + argv+=(--staging) + else + argv+=(--rollback-to "$EDGEZERO__LIFECYCLE__ROLLBACK_TO") + fi + + new_private_log + # Record that a provider mutation is being ATTEMPTED after setup (CLI verified) + # and immediately before the CLI runs: a setup failure never falsely signals, and + # because it is durable in GITHUB_OUTPUT before the mutation starts it survives a + # cancel/timeout mid-activation (readable via `if: always()`). + append_output mutation-attempted true + local rc=0 + "${argv[@]}" 2>&1 | tee "$LIFECYCLE_LOG" || rc=$? + + # Surface the CLI's exit status BEFORE writing any output, so an output-write + # failure can never replace the real provider result. + if [[ "$rc" -ne 0 ]]; then + fail_with "$rc" "rollback failed (CLI exit $rc)" + fi + + local rolled + rolled=$(read_numeric_line rolled-back-to "$LIFECYCLE_LOG") + append_output rolled-back-to "$rolled" + + if [[ "$EDGEZERO__DEPLOY__TO" == "production" && -z "$rolled" ]]; then + fail "production rollback reported success but did not emit rolled-back-to" + fi +} + +main "$@" diff --git a/.github/actions/rollback-fastly/scripts/validate.sh b/.github/actions/rollback-fastly/scripts/validate.sh new file mode 100755 index 00000000..10657231 --- /dev/null +++ b/.github/actions/rollback-fastly/scripts/validate.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Validates the rollback-fastly wrapper's inputs before downloading the artifact. 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 + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=../../deploy-core/scripts/common.sh +source "$SCRIPT_DIR/../../deploy-core/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:-}" diff --git a/.github/workflows/deploy-action.yml b/.github/workflows/deploy-action.yml new file mode 100644 index 00000000..a4b5e23d --- /dev/null +++ b/.github/workflows/deploy-action.yml @@ -0,0 +1,470 @@ +name: Deploy actions + +on: + pull_request: + paths: + - .github/actions/build-app-cli/** + - .github/actions/deploy-core/** + - .github/actions/deploy-fastly/** + - .github/actions/healthcheck-fastly/** + - .github/actions/rollback-fastly/** + - .github/actions/config-push-fastly/** + - .github/workflows/deploy-action.yml + - .github/workflows/fastly-installer-check.yml + - scripts/install-actionlint.sh + - crates/edgezero-adapter/** + - crates/edgezero-adapter-fastly/** + - crates/edgezero-cli/** + - docs/guide/deploy-github-actions.md + - docs/specs/** + push: + branches: [main] + paths: + - .github/actions/build-app-cli/** + - .github/actions/deploy-core/** + - .github/actions/deploy-fastly/** + - .github/actions/healthcheck-fastly/** + - .github/actions/rollback-fastly/** + - .github/actions/config-push-fastly/** + - .github/workflows/deploy-action.yml + - .github/workflows/fastly-installer-check.yml + - scripts/install-actionlint.sh + - crates/edgezero-adapter/** + - crates/edgezero-adapter-fastly/** + - crates/edgezero-cli/** + - docs/guide/deploy-github-actions.md + - docs/specs/** + +permissions: + contents: read + +env: + ACTIONLINT_VERSION: 1.7.7 + ZIZMOR_VERSION: 1.16.3 + +jobs: + static-checks: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Install actionlint (pinned release binary, checksum-verified) + run: scripts/install-actionlint.sh "${ACTIONLINT_VERSION}" + + - name: Actionlint (this workflow) + run: | + actionlint \ + .github/workflows/deploy-action.yml \ + .github/workflows/fastly-installer-check.yml + + - name: Third-party actions pinned to a ref + run: .github/actions/deploy-core/tests/check-action-pins.sh + + - name: Install zizmor (cargo, no pip) + run: cargo install zizmor --version "${ZIZMOR_VERSION}" --locked + + - name: Zizmor security scan + run: | + zizmor --offline \ + .github/workflows/deploy-action.yml \ + .github/workflows/fastly-installer-check.yml \ + .github/actions/build-app-cli/action.yml \ + .github/actions/deploy-fastly/action.yml \ + .github/actions/healthcheck-fastly/action.yml \ + .github/actions/rollback-fastly/action.yml \ + .github/actions/config-push-fastly/action.yml + + - name: Install ShellCheck + run: | + sudo apt-get update + sudo apt-get install -y shellcheck + + - 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/deploy-core/tests/*.sh \ + scripts/install-actionlint.sh + + - name: Bash contract tests + run: .github/actions/deploy-core/tests/run.sh + + - name: Validate docs + run: | + cd docs + npm ci + npm run format + 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: + runs-on: ubuntu-24.04 + # Inherited provider aliases the deploy MUST clear (provider-env boundary). + env: + FASTLY_ENDPOINT: https://inherited.invalid + FASTLY_HOME: /nonexistent/inherited + steps: + - uses: actions/checkout@v4 + 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: 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 + + # The production deploy runs a manifest-command deploy (fake-deploy.sh), but + # deploy-fastly now also captures the rollback target first via a real + # `active-version` Fastly API call. Provide a fake `curl` (and `fastly`) so + # that capture resolves the active version (40) instead of hitting the real + # API. The deploy itself still exercises the manifest command. + - name: Set up fake Fastly API for rollback-target capture + run: .github/actions/deploy-core/tests/make-fake-fastly-env.sh + + - name: Deploy fixture (production) with local action + id: deploy + uses: ./.github/actions/deploy-fastly + with: + app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} + working-directory: fixture-app + fastly-api-token: dummy-token + fastly-service-id: dummy-service + deploy-args: '["--comment","smoke"]' + cache: true + + - name: Assert production deploy, version threading, and credential boundary + env: + EDGEZERO__TEST__FASTLY_VERSION: ${{ steps.deploy.outputs['fastly-version'] }} + EDGEZERO__TEST__PREVIOUS_VERSION: ${{ steps.deploy.outputs['previous-version'] }} + run: .github/actions/deploy-core/tests/assert-production-deploy.sh + + # Prove the real rollback wiring: a production rollback consumes the deploy's + # `previous-version` output as `rollback-to` (no hardcoded version), and the + # activated target threads back out as `rolled-back-to`. + - name: Roll back production using the captured previous-version + id: rollback + uses: ./.github/actions/rollback-fastly + with: + app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} + fastly-api-token: dummy-token + fastly-service-id: dummy-service + 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 + + # 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: + runs-on: ubuntu-24.04 + # Inherited provider aliases the push MUST clear. + env: + FASTLY_ENDPOINT: https://inherited.invalid + FASTLY_HOME: /nonexistent/inherited + steps: + - uses: actions/checkout@v4 + 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 + + - 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 + 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) + run: .github/actions/deploy-core/tests/make-fake-fastly-env.sh + + - name: Push config to production + id: prod + uses: ./.github/actions/config-push-fastly + with: + app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} + working-directory: fixture-app + fastly-api-token: dummy-token + + - name: Assert production wrote the base key and never the staging key + env: + EDGEZERO__TEST__EXPECT_KEY: app_config + EDGEZERO__TEST__REJECT_KEY: app_config_staging + EDGEZERO__TEST__PUSHED_KEY: ${{ steps.prod.outputs['pushed-key'] }} + EDGEZERO__TEST__PUSHED_STORE: ${{ steps.prod.outputs.store }} + run: .github/actions/deploy-core/tests/assert-config-push.sh + + # Staging path: the full lifecycle through the REAL wrappers — deploy-fastly + # with `stage: true`, 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: + runs-on: ubuntu-24.04 + # 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 + FASTLY_API_TOKEN: inherited-must-not-reach-production-probes + steps: + - uses: actions/checkout@v4 + 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 + + # Seeds the action-owned tool root with a fake `fastly` reporting the + # pinned version, so install-fastly.sh adopts it and the staged path runs + # through the real wrapper without contacting Fastly. + - name: Install fake fastly + curl + run: .github/actions/deploy-core/tests/make-fake-fastly-env.sh + + - name: Staged deploy through the deploy-fastly wrapper + id: stage + 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: dummy-service + deploy-args: '["--comment","staged smoke"]' + stage: true + + - name: Assert the staged Fastly call sequence + env: + EDGEZERO__TEST__STAGED_VERSION: ${{ steps.stage.outputs['fastly-version'] }} + run: .github/actions/deploy-core/tests/assert-staged-calls.sh + + - name: Health check the staged version + id: health + uses: ./.github/actions/healthcheck-fastly + with: + app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} + deploy-to: staging + domain: staging.example.com + fastly-version: ${{ steps.stage.outputs['fastly-version'] }} + fastly-api-token: dummy-token + fastly-service-id: dummy-service + retry: "1" + retry-delay: "1" + + - name: Assert the staging IP was resolved and probed + env: + EDGEZERO__TEST__STAGED_VERSION: ${{ steps.stage.outputs['fastly-version'] }} + EDGEZERO__TEST__HEALTHY: ${{ steps.health.outputs.healthy }} + EDGEZERO__TEST__STATUS_CODE: ${{ steps.health.outputs['status-code'] }} + run: .github/actions/deploy-core/tests/assert-staging-probe.sh + + - name: Health check must FAIL when the probe is unhealthy + id: unhealthy + continue-on-error: true + uses: ./.github/actions/healthcheck-fastly + with: + app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} + deploy-to: staging + domain: staging.example.com + fastly-version: ${{ steps.stage.outputs['fastly-version'] }} + fastly-api-token: dummy-token + fastly-service-id: dummy-service + 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 + env: + EDGEZERO__TEST__OUTCOME: ${{ steps.unhealthy.outcome }} + EDGEZERO__TEST__HEALTHY: ${{ steps.unhealthy.outputs.healthy }} + EDGEZERO__TEST__STATUS_CODE: ${{ steps.unhealthy.outputs['status-code'] }} + run: .github/actions/deploy-core/tests/assert-unhealthy-failed.sh + + # 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 + run: printf 'PROD_PROBE_SNAPSHOT=%s\n' "$(wc -l <"$FAKE_CALL_LOG")" >>"$GITHUB_ENV" + + - name: Production health check (no token supplied) + id: prod-health + uses: ./.github/actions/healthcheck-fastly + with: + app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} + deploy-to: production + domain: staging.example.com + fastly-version: ${{ steps.stage.outputs['fastly-version'] }} + fastly-service-id: dummy-service + retry: "1" + retry-delay: "1" + + - name: Assert the production probe ran without any provider token + env: + EDGEZERO__TEST__LOG_SNAPSHOT: ${{ env.PROD_PROBE_SNAPSHOT }} + EDGEZERO__TEST__HEALTHY: ${{ steps.prod-health.outputs.healthy }} + run: .github/actions/deploy-core/tests/assert-production-probe-tokenless.sh + + # 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 + id: staging-no-token + continue-on-error: true + uses: ./.github/actions/healthcheck-fastly + with: + app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} + deploy-to: staging + domain: staging.example.com + fastly-version: ${{ steps.stage.outputs['fastly-version'] }} + fastly-service-id: dummy-service + retry: "1" + retry-delay: "1" + + - name: Assert the tokenless staging check 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; } + + - name: Roll back the staged version + uses: ./.github/actions/rollback-fastly + with: + app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} + deploy-to: staging + fastly-version: ${{ steps.stage.outputs['fastly-version'] }} + fastly-api-token: dummy-token + fastly-service-id: dummy-service + + - name: Roll back production + id: prod-rollback + uses: ./.github/actions/rollback-fastly + with: + app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} + 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: dummy-service + + - name: Assert rollback verbs, paths, and version threading + env: + EDGEZERO__TEST__STAGED_VERSION: ${{ steps.stage.outputs['fastly-version'] }} + EDGEZERO__TEST__ROLLED_BACK_TO: ${{ steps.prod-rollback.outputs['rolled-back-to'] }} + run: .github/actions/deploy-core/tests/assert-rollback-calls.sh + + # 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 + id: rollback-no-target + continue-on-error: true + uses: ./.github/actions/rollback-fastly + with: + app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} + deploy-to: production + fastly-version: ${{ steps.stage.outputs['fastly-version'] }} + fastly-api-token: dummy-token + fastly-service-id: dummy-service + + - name: Assert the targetless production 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; } + + # 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 + 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 + id: stale-rollback + continue-on-error: true + uses: ./.github/actions/rollback-fastly + with: + app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} + deploy-to: production + # 40 is no longer active (99 is), so this rollback is stale. + fastly-version: "40" + rollback-to: "38" + fastly-api-token: dummy-token + fastly-service-id: dummy-service + + - name: Assert the stale rollback was refused and activated 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 diff --git a/.github/workflows/fastly-installer-check.yml b/.github/workflows/fastly-installer-check.yml new file mode 100644 index 00000000..7fb0f786 --- /dev/null +++ b/.github/workflows/fastly-installer-check.yml @@ -0,0 +1,54 @@ +name: Fastly installer check + +# The lifecycle smoke test fakes the Fastly CLI, so the REAL download + checksum +# path in install-fastly.sh is never exercised there. This job runs it un-faked — +# it actually fetches the pinned release, verifies its SHA-256, and confirms the +# installed binary reports the pinned version. It is path-filtered to the +# installer and BOTH of its pin sources — versions.json AND .tool-versions, which +# install-fastly.sh requires to agree — plus common.sh (NOT scheduled), so a +# version/URL/checksum bump is validated against the real release exactly when it +# changes. +on: + pull_request: + paths: + - .github/actions/deploy-fastly/scripts/install-fastly.sh + - .github/actions/deploy-fastly/scripts/verify-installed-version.sh + - .github/actions/deploy-fastly/scripts/common.sh + - .github/actions/deploy-fastly/versions.json + - .github/actions/deploy-core/scripts/common.sh + - .tool-versions + - .github/workflows/fastly-installer-check.yml + push: + branches: [main] + paths: + - .github/actions/deploy-fastly/scripts/install-fastly.sh + - .github/actions/deploy-fastly/scripts/verify-installed-version.sh + - .github/actions/deploy-fastly/scripts/common.sh + - .github/actions/deploy-fastly/versions.json + - .github/actions/deploy-core/scripts/common.sh + - .tool-versions + - .github/workflows/fastly-installer-check.yml + +permissions: + contents: read + +jobs: + real-install: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Install the pinned Fastly CLI for real + env: + EDGEZERO__ACTION__ROOT: ${{ github.workspace }} + EDGEZERO__FASTLY__VERSIONS_JSON: ${{ github.workspace }}/.github/actions/deploy-fastly/versions.json + EDGEZERO__ACTION__TOOL_ROOT: ${{ runner.temp }}/edgezero-action-tools + run: .github/actions/deploy-fastly/scripts/install-fastly.sh + + - name: Verify the installed CLI reports the pinned version + env: + EDGEZERO__FASTLY__VERSIONS_JSON: ${{ github.workspace }}/.github/actions/deploy-fastly/versions.json + EDGEZERO__ACTION__TOOL_ROOT: ${{ runner.temp }}/edgezero-action-tools + run: .github/actions/deploy-fastly/scripts/verify-installed-version.sh diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9a390532..98f1dd33 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -68,6 +68,13 @@ jobs: - name: Run workspace tests run: cargo test --workspace --all-targets + # The adapter CLI dispatch (build/deploy/stage/healthcheck/rollback) and + # its tests live behind the `cli` feature, which the unfeatured + # `cargo test --workspace` step above does not enable — so none of those + # tests compile or run there. Exercise them explicitly. + - name: Adapter CLI dispatch tests + run: cargo test -p edgezero-adapter-fastly --all-targets --features cli + - name: Check feature compilation run: cargo check --workspace --all-targets --features "fastly cloudflare spin" diff --git a/crates/edgezero-adapter-axum/src/cli.rs b/crates/edgezero-adapter-axum/src/cli.rs index a609106f..bfe5781d 100644 --- a/crates/edgezero-adapter-axum/src/cli.rs +++ b/crates/edgezero-adapter-axum/src/cli.rs @@ -137,7 +137,7 @@ impl Adapter for AxumCliAdapter { match action { // The axum adapter is the in-process native dev server — // there is no remote auth provider to sign in/out of. - // Per spec this is an explicit no-op. + // This is an explicit no-op. AdapterAction::AuthLogin | AdapterAction::AuthLogout | AdapterAction::AuthStatus => { log::info!( "[edgezero] axum has no remote auth surface; `auth` is a no-op for this adapter" @@ -147,6 +147,13 @@ impl Adapter for AxumCliAdapter { AdapterAction::Build => build(args), AdapterAction::Deploy => deploy(args), AdapterAction::Serve => serve(args), + // The Fastly staging lifecycle is Fastly-only. + AdapterAction::DeployStaged + | AdapterAction::EmitVersion + | AdapterAction::Healthcheck + | AdapterAction::Rollback => Err(format!( + "axum adapter does not support the Fastly staging lifecycle action {action:?}" + )), other => Err(format!("axum adapter does not support {other:?}")), } } @@ -184,7 +191,7 @@ impl Adapter for AxumCliAdapter { // Axum reads `.edgezero/local-config-.json`. // The platform name is informational here -- the env // overlay isn't used for local file paths because the - // path encoding is the spec's canonical form. + // path encoding is the canonical form. let logical = store.logical.as_str(); out.push(format!( "axum config store `{logical}` reads `.edgezero/local-config-{logical}.json`; nothing to provision" @@ -216,7 +223,7 @@ impl Adapter for AxumCliAdapter { // `string -> string` JSON object `AxumConfigStore` reads // back from `.edgezero/local-config-.json`. The path // is keyed on the LOGICAL id, not the env-resolved - // platform name -- the local file flow is the spec's + // platform name -- the local file flow is the // canonical form and isn't subject to the per-store env // overlay (which targets platform store names, not local // file paths). @@ -234,8 +241,8 @@ impl Adapter for AxumCliAdapter { .map_err(|err| format!("failed to create {}: {err}", local_dir.display()))?; // Upsert into any existing map so a `config push --key // app_config_staging` doesn't wipe a previously-pushed - // `app_config` blob (spec 12.7 requires default + staging - // to coexist for the `EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY` + // `app_config` blob (default + staging must coexist + // for the `EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY` // override to switch between them). The map is owned (rather // than borrowed) so we can merge old + new without lifetime // surgery on the slice. @@ -591,7 +598,7 @@ fn find_axum_manifest(start: &Path) -> Result { } fn read_axum_project(manifest: &Path) -> Result { - // Per the spec hard-cutoff: only the canonical + // Per the hard-cutoff: only the canonical // `EDGEZERO__ADAPTER__HOST` / `EDGEZERO__ADAPTER__PORT` env // vars are honoured. The pre-rewrite `EDGEZERO_HOST` / // `EDGEZERO_PORT` shim is gone -- the core runtime stopped @@ -1408,7 +1415,7 @@ mod tests { } } - /// Spec 12.7: pushing two blobs under different keys (e.g. + /// Pushing two blobs under different keys (e.g. /// `app_config` + `app_config_staging`) must leave both keys /// readable so the runtime /// `EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY` override can diff --git a/crates/edgezero-adapter-cloudflare/src/cli.rs b/crates/edgezero-adapter-cloudflare/src/cli.rs index 14a92df0..bd6cdcc8 100644 --- a/crates/edgezero-adapter-cloudflare/src/cli.rs +++ b/crates/edgezero-adapter-cloudflare/src/cli.rs @@ -156,6 +156,13 @@ impl Adapter for CloudflareCliAdapter { }), AdapterAction::Deploy => deploy(args), AdapterAction::Serve => serve(args), + // The Fastly staging lifecycle is Fastly-only. + AdapterAction::DeployStaged + | AdapterAction::EmitVersion + | AdapterAction::Healthcheck + | AdapterAction::Rollback => Err(format!( + "cloudflare adapter does not support the Fastly staging lifecycle action {action:?}" + )), other => Err(format!("cloudflare adapter does not support {other:?}")), } } diff --git a/crates/edgezero-adapter-fastly/src/cli.rs b/crates/edgezero-adapter-fastly/src/cli.rs index 678f7b3f..67c79bd0 100644 --- a/crates/edgezero-adapter-fastly/src/cli.rs +++ b/crates/edgezero-adapter-fastly/src/cli.rs @@ -4,6 +4,8 @@ use std::io::{ErrorKind, Write as _}; use std::path::{Path, PathBuf}; use std::process::Command; use std::process::Stdio; +use std::thread; +use std::time::Duration; use crate::chunked_config::{prepare_fastly_config_entries, resolve_fastly_config_value}; use ctor::ctor; @@ -117,8 +119,86 @@ 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"; +/// The config store the runtime opens for `EDGEZERO__*` overrides. Compute@Edge +/// has no process env, so the runtime reads its config-store KEY selector from +/// here (see `env_config_from_runtime_dictionary` in lib.rs). +const RUNTIME_ENV_STORE: &str = "edgezero_runtime_env"; + +/// Base name of the staging twin of [`RUNTIME_ENV_STORE`]. 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. +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. +const FASTLY_SERVICE_ID_ENV: &str = "FASTLY_SERVICE_ID"; + +/// 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", + "--accept-defaults", + "-d", + "--auto-yes", + "-y", + "--debug-mode", + "--non-interactive", + "-i", + "--quiet", + "-q", + "--verbose", + "-v", +]; + 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). + 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, +} + /// 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: @@ -187,6 +267,11 @@ impl Adapter for FastlyCliAdapter { } 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:?}")), } } @@ -324,8 +409,13 @@ impl Adapter for FastlyCliAdapter { // 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 mut line = format!( - "created fastly {runtime_env_kind}-store `{runtime_env_name}` (EdgeZero runtime override store); appended setup tables to {}\n Populate per-environment override keys with:\n fastly config-store-entry update --store-id= --key=EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY --value=app_config_staging --upsert", + "created fastly {runtime_env_kind}-store `{runtime_env_name}` (EdgeZero runtime override store, read by the ACTIVE version); appended setup tables to {}\n It already selects each store's default key, so no edit is needed for a normal setup.\n To point PRODUCTION at a different key (e.g. a renamed store), and only then:\n fastly config-store-entry update --store-id= --key=EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY --value= --upsert\n Do NOT set a `_staging` key here: staged config is isolated by a per-service `{RUNTIME_ENV_STAGING_STORE_PREFIX}_` store, which a staged deploy creates and links automatically.", fastly_path.display() ); if let Some(note) = post_create_note { @@ -337,6 +427,13 @@ impl Adapter for FastlyCliAdapter { // Already declared; nothing to do. } + // 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()); } @@ -955,8 +1052,8 @@ 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. Spec 12.7 requires - // default + staging keys to coexist so the runtime + // 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: @@ -1114,6 +1211,205 @@ fn create_config_store_entry(store_id: &str, key: &str, value: &str) -> Result<( )) } +/// 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, + )?; + 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}\nraw stdout: {stdout}") + })?; + let array = parsed + .as_array() + .or_else(|| parsed.get("items").and_then(serde_json::Value::as_array)) + .ok_or_else(|| { + format!( + "`fastly config-store-entry list --json` output is neither a bare array nor an `items` envelope; fastly CLI may have changed its schema. Raw stdout: {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. Raw stdout: {stdout}" + )); + } + } + } + Ok(entries) +} + +/// `fastly config-store-entry delete --store-id= --key=`. +fn delete_config_store_entry(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, + ) +} + +/// Compute the staging selector store's entries from production's, given the +/// declared config-store logical ids. +/// +/// The twin is a faithful MIRROR of production's runtime overrides — adapter +/// host, logging level, `__NAME` redirects — with exactly one transform: every +/// declared config store's selector key (`EDGEZERO__STORES__CONFIG____KEY`) +/// points at `_staging`, the key `config push --staging` writes. A +/// declared store gets that selector even when production has no explicit entry +/// for it (production relies on the runtime's default = the logical id; staging +/// must NOT inherit that default, or it would read production's key). +/// +/// Pure so the transform is unit-testable without the fastly CLI. +fn staging_entries_from_production( + production: &[(String, String)], + config_logical_ids: &[String], +) -> Vec<(String, String)> { + // selector key -> staging value, one per declared config store. + let selectors: Vec<(String, String)> = config_logical_ids + .iter() + .map(|id| (runtime_env_key_for(id), format!("{id}_staging"))) + .collect(); + let is_selector = |key: &str| selectors.iter().any(|(sel, _)| sel == key); + + // Copy every non-selector production override verbatim; selectors are + // supplied from `selectors` below (whether or not production carried one). + let mut out: Vec<(String, String)> = production + .iter() + .filter(|(key, _)| !is_selector(key)) + .cloned() + .collect(); + out.extend(selectors); + out +} + +/// Resolve the staging twin store, creating it on demand. A staged deploy owns +/// this store end to end (it is never linked on the ACTIVE version), so it does +/// not depend on `provision` having created it first. Fails closed on a lookup +/// FAILURE rather than blindly creating a duplicate. +/// The per-service staging twin store name — the base prefix plus the service +/// id, so concurrent staged deploys of different services on one account never +/// clobber each other's selectors. +fn staging_selector_store_name(service_id: &str) -> String { + format!("{RUNTIME_ENV_STAGING_STORE_PREFIX}_{service_id}") +} + +fn ensure_staging_selector_store(store_name: &str) -> Result { + match classify_remote_config_store(store_name)? { + ConfigStoreLookup::Found(id) => Ok(id), + ConfigStoreLookup::NotFound => { + create_fastly_store("config", store_name)?; + resolve_remote_config_store_id(store_name).map_err(|err| { + format!( + "created fastly config-store `{store_name}` but could not resolve its id: {err}" + ) + }) + } + 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." + )), + } +} + +/// Reconcile the staging twin so it MIRRORS production's runtime overrides +/// (`production`) with only the config selectors redirected to +/// `_staging`. +/// +/// Deletes twin entries production no longer has (so a removed override does not +/// linger and diverge staging from production), then upserts the full desired +/// set. 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. +fn mirror_production_to_staging( + production: &[(String, String)], + staging_id: &str, + config_logical_ids: &[String], + cwd: &Path, +) -> Result<(), String> { + let desired = staging_entries_from_production(production, config_logical_ids); + + let current = read_config_store_entries(staging_id, cwd)?; + for (key, _) in ¤t { + if !desired.iter().any(|(dk, _)| dk == key) { + delete_config_store_entry(staging_id, key, cwd)?; + } + } + for (key, value) in &desired { + create_config_store_entry(staging_id, key, value)?; + } + Ok(()) +} + +/// The runtime-override entry naming the config-store KEY for logical store +/// `id` — `EDGEZERO__STORES__CONFIG____KEY`. +/// +/// Must match what the runtime reads: `EnvConfig::from_vars` strips the +/// `EDGEZERO__` prefix, splits on `__`, and lowercases each segment, and +/// `store_key("config", id)` looks up `["stores", "config", id, "key"]`. So the +/// entry name is the id uppercased. A near-miss here is silent — the runtime +/// would just fall back to the id and read production config. +fn runtime_env_key_for(logical_id: &str) -> String { + format!( + "EDGEZERO__STORES__CONFIG__{}__KEY", + logical_id.to_ascii_uppercase() + ) +} + +/// 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) + }) +} + /// 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": "..."}, ... ]`) @@ -1135,32 +1431,32 @@ fn find_config_store_id(stdout: &str, name: &str) -> ConfigStoreLookup { shape_summary(&parsed) )); }; - let mut any_well_formed = false; + // Scan the WHOLE list — never short-circuit on the first match — so every + // entry is validated and a second entry with the same name (an ambiguous + // listing) is caught. EVERY entry must be well-formed before a Found/NotFound + // verdict can be trusted: a malformed entry could be the very store we're + // looking for, hidden behind a missing `name`/`id`, and reporting NotFound + // then would fail OPEN (e.g. staging would mirror no production overrides). + let mut found: Option = None; for entry in array { - let entry_name = entry.get("name").and_then(serde_json::Value::as_str); - let entry_id = entry.get("id").and_then(serde_json::Value::as_str); - if entry_name.is_some() && entry_id.is_some() { - any_well_formed = true; - } - if entry_name == Some(name) { - return entry_id.map_or_else( - || { - ConfigStoreLookup::SchemaDrift(format!( - "entry matched name `{name}` but is missing a string `id` field" - )) - }, - |id| ConfigStoreLookup::Found(id.to_owned()), - ); + let (Some(entry_name), Some(entry_id)) = ( + entry.get("name").and_then(serde_json::Value::as_str), + entry.get("id").and_then(serde_json::Value::as_str), + ) else { + return ConfigStoreLookup::SchemaDrift(format!( + "a config-store entry is missing a string `name` or `id` field, so the listing cannot be trusted -- fastly CLI may have changed its output schema. Entry: {entry}" + )); + }; + if entry_name == name { + if found.is_some() { + return ConfigStoreLookup::SchemaDrift(format!( + "the config-store listing has more than one store named `{name}`, so a lookup is ambiguous -- refusing to pick one" + )); + } + found = Some(entry_id.to_owned()); } } - if array.is_empty() || any_well_formed { - ConfigStoreLookup::NotFound - } else { - ConfigStoreLookup::SchemaDrift( - "no entry has both string `name` and `id` fields -- fastly CLI may have changed its output schema" - .to_owned(), - ) - } + found.map_or(ConfigStoreLookup::NotFound, ConfigStoreLookup::Found) } /// One-line type label for a `serde_json::Value` (for diagnostic @@ -1181,6 +1477,26 @@ fn shape_summary(value: &serde_json::Value) -> &'static str { /// `name`. The provision flow doesn't persist this id, so push /// has to re-fetch every time. fn resolve_remote_config_store_id(name: &str) -> Result { + match classify_remote_config_store(name)? { + ConfigStoreLookup::Found(id) => Ok(id), + ConfigStoreLookup::NotFound => Err(format!( + "no fastly config-store matches `{name}` (did you run `edgezero provision --adapter fastly`?)" + )), + ConfigStoreLookup::SchemaDrift(detail) => Err(format!( + "could not parse `fastly config-store list --json` output: {detail}.\n The fastly CLI may have changed its JSON schema in a recent version. Please file a bug report at https://github.com/stackpop/edgezero/issues with the fastly CLI version (`fastly version`) and the raw stdout. Workaround: pin to a known-compatible fastly CLI version." + )), + } +} + +/// 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 { let output = Command::new("fastly") .args(["config-store", "list", "--json"]) .output() @@ -1199,15 +1515,7 @@ fn resolve_remote_config_store_id(name: &str) -> Result { )); } let stdout = String::from_utf8_lossy(&output.stdout); - match find_config_store_id(&stdout, name) { - ConfigStoreLookup::Found(id) => Ok(id), - ConfigStoreLookup::NotFound => Err(format!( - "no fastly config-store matches `{name}` (did you run `edgezero provision --adapter fastly`?)" - )), - ConfigStoreLookup::SchemaDrift(detail) => Err(format!( - "could not parse `fastly config-store list --json` output: {detail}.\n The fastly CLI may have changed its JSON schema in a recent version. Please file a bug report at https://github.com/stackpop/edgezero/issues with the fastly CLI version (`fastly version`) and the raw stdout. Workaround: pin to a known-compatible fastly CLI version." - )), - } + Ok(find_config_store_id(&stdout, name)) } /// # Errors @@ -1252,20 +1560,45 @@ pub fn build(extra_args: &[String]) -> Result { Ok(dest) } +/// Whether `args` already carries the Fastly CLI's non-interactive +/// switch, in either its long (`--non-interactive`) or short (`-i`) +/// form. Used to avoid passing the flag twice when a caller already +/// supplied it via `deploy-args` passthrough. +fn has_non_interactive(args: &[String]) -> bool { + args.iter() + .any(|arg| arg == "--non-interactive" || arg == "-i") +} + +/// Build the argv for `fastly compute deploy`, appending +/// `--non-interactive` (a Fastly CLI *global* flag, supported by +/// `compute deploy`) unless the caller already passed it. Without it a +/// production deploy can block on an interactive prompt in CI. +fn build_compute_deploy_args(extra_args: &[String]) -> Vec { + let mut argv = vec!["compute".to_owned(), "deploy".to_owned()]; + argv.extend_from_slice(extra_args); + if !has_non_interactive(extra_args) { + argv.push("--non-interactive".to_owned()); + } + argv +} + /// # Errors /// Returns an error if the Fastly CLI deploy command fails. +/// +/// 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 = - 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 manifest_dir = resolve_manifest_dir(extra_args)?; + let forwarded = args_without_flag_value(extra_args, "--manifest-path"); let status = Command::new("fastly") - .args(["compute", "deploy"]) - .args(extra_args) - .current_dir(manifest_dir) + .args(build_compute_deploy_args(&forwarded)) + .current_dir(&manifest_dir) .status() .map_err(|err| format!("failed to run fastly CLI: {err}"))?; if !status.success() { @@ -1383,26 +1716,1858 @@ pub fn serve(extra_args: &[String]) -> Result<(), String> { Ok(()) } -#[cfg(test)] -mod tests { - use super::*; - use edgezero_adapter::cli_support::read_package_name; - #[cfg(unix)] - use edgezero_core::test_env::PathPrepend; +// =================================================================== +// Fastly staging lifecycle +// =================================================================== +// +// These entry points back the `deploy --stage`, `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 `-1` (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) +} - #[cfg(unix)] - use std::sync::Mutex; - use tempfile::tempdir; +/// Whether a boolean `flag` (e.g. `--staging`) is present in `args`. +fn arg_flag(args: &[String], flag: &str) -> bool { + args.iter().any(|arg| arg == flag) +} + +/// Copy of `args` with `--flag value` removed (both tokens). Used to +/// forward operator passthrough (e.g. `--comment`) to `fastly compute +/// update` without re-passing `--service-id`, which is threaded +/// explicitly. +fn args_without_flag_value(args: &[String], flag: &str) -> Vec { + let mut out = Vec::with_capacity(args.len()); + let mut skip = false; + for arg in args { + if skip { + skip = false; + continue; + } + if arg == flag { + skip = true; + continue; + } + out.push(arg.clone()); + } + out +} + +/// Split an arg on a leading `--flag=value`, returning `(flag, value)`. +fn split_inline_value(arg: &str) -> (&str, Option<&str>) { + match arg.split_once('=') { + Some((flag, value)) if flag.starts_with('-') => (flag, Some(value)), + Some(_) | None => (arg, None), + } +} + +/// 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(); + } + } + } + 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}") + }) +} + +/// 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/3xx). +fn is_healthy_status(code: u16) -> bool { + (200..400).contains(&code) +} + +/// Digits immediately following `marker` in `lower` (a lowercased +/// haystack), for the LAST occurrence of `marker`. The number must be +/// terminated by `terminator` — so a partial/confusable match (e.g. a +/// semver `15.2.0`) yields `None` rather than a bogus version. +fn last_version_after(lower: &str, marker: &str, terminator: char) -> Option { + let mut result = None; + for (idx, _) in lower.match_indices(marker) { + let after = idx.saturating_add(marker.len()); + let Some(rest) = lower.get(after..) else { + continue; + }; + let digits: String = rest.chars().take_while(char::is_ascii_digit).collect(); + if digits.is_empty() || rest.chars().nth(digits.len()) != Some(terminator) { + continue; + } + if let Ok(parsed) = digits.parse::() { + result = Some(parsed); + } + } + 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 ", '.')) +} + +/// Last standalone `version=` line (the whole trimmed line must be +/// exactly that, so a `--version=active` flag echoed in a command line +/// cannot masquerade as one). +fn parse_canonical_version_line(lower: &str) -> Option { + lower.lines().rev().find_map(|line| { + let digits = line.trim().strip_prefix("version=")?; + (!digits.is_empty() && digits.chars().all(|ch| ch.is_ascii_digit())) + .then(|| digits.parse().ok()) + .flatten() + }) +} + +/// Parse `fastly service-version list --json` (or the Fastly API +/// `/service//version` array) for the `number` of the `active` +/// version. +/// Resolve the active version from a Fastly version-list JSON. +/// +/// `Ok(Some(n))` — exactly one version is active. `Ok(None)` — the list parsed +/// but NO version is active (a first-ever deploy; the caller records an empty +/// rollback target and proceeds). `Err(_)` — the payload could not be parsed as +/// a version list, OR it is MALFORMED (a non-boolean `active` on ANY entry, an +/// `active: true` entry whose `number` is missing or not an unsigned integer, or +/// MORE THAN ONE active version). All are OPERATIONAL failures the caller must +/// NOT silently treat as "no active version" — otherwise a garbled or ambiguous +/// response would fail open and let a production deploy proceed with no rollback +/// target. +/// +/// The ENTIRE list is scanned (not short-circuited at the first active entry) so +/// that a malformed `active` field or a second active version anywhere in the +/// response is caught rather than ignored. +fn resolve_active_version(json: &str) -> Result, String> { + let value: serde_json::Value = serde_json::from_str(json) + .map_err(|err| format!("failed to parse the Fastly version list as JSON: {err}"))?; + let array = value.as_array().ok_or_else(|| { + "the Fastly version list was not a JSON array; the API may have changed its schema" + .to_owned() + })?; + // A real Fastly service always has at least an initial (inactive) version, so + // an EMPTY list is an invalid response — fail closed rather than read it as a + // legitimate "no active version yet" (first deploy). + if array.is_empty() { + return Err( + "the Fastly version list is empty; a service always has at least an initial version, so this response cannot be trusted".to_owned() + ); + } + 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(active_version) +} + +/// Best-effort staleness guard for a production rollback: the version being +/// rolled back FROM (`from_version`, the caller's `--version`) must still be the +/// ACTIVE version. A rollback can run long after its deploy; if a newer version +/// was activated since, activating the old target would clobber it — so refuse. +/// +/// This narrows but does NOT close the race: the caller reads the active version +/// and activates in two separate requests, and Fastly's activate endpoint has no +/// precondition, so a deploy landing between them can still be clobbered. +/// Service-scoped serialization is required to eliminate it. +fn ensure_rollback_from_is_active( + active: Option, + from_version: u64, + service_id: &str, +) -> Result<(), String> { + match active { + Some(active_version) if active_version == from_version => Ok(()), + Some(active_version) => Err(format!( + "refusing to roll back service {service_id}: the active version is now {active_version}, not the {from_version} being rolled back from -- a newer deploy is live and rolling back would clobber it" + )), + None => Err(format!( + "refusing to roll back service {service_id}: it has no active version" + )), + } +} + +/// 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) +} + +fn find_staging_ip(value: &serde_json::Value) -> Option { + match value { + serde_json::Value::Object(map) => { + // The documented shape: a singular `staging_ip` string. + if let Some(ip) = map.get("staging_ip").and_then(serde_json::Value::as_str) { + return Some(ip.to_owned()); + } + // Tolerated: a plural `staging_ips` array of strings. + if let Some(ip) = map + .get("staging_ips") + .and_then(serde_json::Value::as_array) + .and_then(|arr| arr.iter().find_map(serde_json::Value::as_str)) + { + return Some(ip.to_owned()); + } + map.values().find_map(find_staging_ip) + } + 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, + } +} + +/// 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![ + "-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 { + args.push("--connect-to".to_owned()); + args.push(format!("::{ip}:443")); + } + args.push(format!("https://{domain}{path}")); + args +} + +/// Validate a caller-supplied probe path. It is appended to +/// `https://{domain}` to form one curl argument, so it must begin with +/// '/' and carry no whitespace or control characters that would break +/// the URL or smuggle a second token. +fn validate_probe_path(path: &str) -> Result<(), String> { + if !path.starts_with('/') { + return Err(format!("healthcheck --path must begin with '/': '{path}'")); + } + if path.chars().any(|ch| ch.is_whitespace() || ch.is_control()) { + return Err(format!( + "healthcheck --path must not contain whitespace or control characters: '{path}'" + )); + } + 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(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(" ") + )) + } +} + +/// 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() + )) + } +} + +/// Run `curl -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. +fn curl_config_capture(config: &str) -> Result { + let mut child = Command::new("curl") + .args(["-sS", "--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}") + } + })?; + let mut stdin = child + .stdin + .take() + .ok_or_else(|| "failed to open stdin pipe to `curl`".to_owned())?; + stdin + .write_all(config.as_bytes()) + .map_err(|err| format!("failed to write curl config to stdin: {err}"))?; + drop(stdin); + 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 { + Err(format!( + "`curl` exited with status {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + )) + } +} + +/// 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('"'); + out +} + +/// Validate an operator-supplied Fastly service id before it is +/// interpolated into an API URL. Fastly service ids are opaque +/// alphanumeric handles; constrain to `^[A-Za-z0-9_-]+$` so a value +/// carrying a quote / newline / space (which could inject curl options +/// via the `--config` file) is rejected with a clear error. +fn validate_service_id(id: &str) -> Result<(), String> { + if !id.is_empty() + && id + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-') + { + Ok(()) + } else { + Err(format!( + "invalid service id {id:?}: expected only ASCII letters, digits, `_`, or `-`" + )) + } +} + +/// 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}") + }) +} + +/// 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`" + )) + } +} + +/// `GET https://api.fastly.com` with the `Fastly-Key` header; +/// returns the response body ONLY on a 2xx status. Both the header (carrying the +/// secret token) and the URL are written through `curl_quote` so neither can +/// inject curl options into the `--config` document. +/// +/// The HTTP status is captured explicitly via `write-out` (as the PUT helper +/// does) and required to be 2xx before the body is trusted. `--fail` alone would +/// reject 4xx/5xx but still accept a 3xx — whose (array-shaped) body could +/// otherwise be parsed as version data. No `location` directive is set, so a +/// redirect is never followed. +fn fastly_api_get(path: &str, token: &str) -> Result { + let header = curl_quote(&format!("Fastly-Key: {token}")); + let url = curl_quote(&format!("https://api.fastly.com{path}")); + // `write-out` appends the status on its own trailing line AFTER the body. + let config = format!("header = {header}\nurl = {url}\nwrite-out = \"\\n%{{http_code}}\"\n"); + let out = curl_config_capture(&config) + .map_err(|err| format!("Fastly API GET {path} failed: {err}"))?; + let (body, status_line) = out + .rsplit_once('\n') + .ok_or_else(|| format!("Fastly API GET {path}: no HTTP status in the curl output"))?; + let status: u16 = status_line.trim().parse().map_err(|err| { + format!( + "Fastly API GET {path}: could not parse the HTTP status {:?}: {err}", + status_line.trim() + ) + })?; + if !(200..300).contains(&status) { + return Err(format!("Fastly API GET {path} returned HTTP {status}")); + } + 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}")) + } +} + +/// 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")); + } + 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 --stage`: +/// 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(" ") + ); + } + + // 1. Build the wasm package (no deploy / activation). + run_fastly_status( + &[ + "compute".to_owned(), + "build".to_owned(), + "--non-interactive".to_owned(), + ], + manifest_dir, + )?; + + // 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 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}" + ) + })?; + + // 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, + )?; + } + + // 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)?; + + // 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, + )?; + + // 6. Emit the staged version (parseable contract). + log::info!("version={version}"); + Ok(()) +} + +/// 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(()); + } + + // 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(RUNTIME_ENV_STORE)? { + 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}` for a staged deploy: {detail}.\n Refusing to stage rather than risk serving PRODUCTION config. Pin a known-compatible fastly CLI version and retry." + )); + } + }; + + // 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)?; + mirror_production_to_staging( + &production, + &staging_store_id, + config_logical_ids, + manifest_dir, + )?; + + // Drop the inherited production link first: a version cannot carry two links + // under the same name. + let existing = run_fastly_capture( + &[ + "resource-link".to_owned(), + "list".to_owned(), + format!("--service-id={service_id}"), + format!("--version={version}"), + "--json".to_owned(), + ], + manifest_dir, + )?; + if let Some(link_id) = find_resource_link_id(&existing, RUNTIME_ENV_STORE) { + run_fastly_status( + &[ + "resource-link".to_owned(), + "delete".to_owned(), + format!("--service-id={service_id}"), + format!("--version={version}"), + format!("--id={link_id}"), + ], + manifest_dir, + )?; + } + + // `--name` is the alias the runtime opens; the linked STORE is the staging + // twin. No `--autoclone`: the draft is already editable, and cloning here + // would silently move us onto yet another version. + run_fastly_status( + &[ + "resource-link".to_owned(), + "create".to_owned(), + format!("--service-id={service_id}"), + format!("--version={version}"), + format!("--resource-id={staging_store_id}"), + format!("--name={RUNTIME_ENV_STORE}"), + ], + manifest_dir, + )?; + + log::info!("staged version {version} now reads `{staging_store_name}` for its config selector"); + Ok(()) +} + +/// 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" + ); + } + 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), + } +} + +/// `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. +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 staging_ip = if arg_flag(args, "--staging") { + let token = require_token()?; + let json = fastly_api_get( + &format!("/service/{service_id}/version/{version}/domain?include=staging_ips"), + &token, + )?; + Some(parse_staging_ip(&json).ok_or_else(|| { + format!("no staging IP found for service {service_id} version {version}") + })?) + } else { + None + }; + + 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) => { + 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 +/// ` - 1`; 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()?; + + 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}" + ); + } 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(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use edgezero_adapter::cli_support::read_package_name; + #[cfg(unix)] + use edgezero_core::test_env::{EnvOverride, PathPrepend}; + + #[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); + } + + #[test] + fn arg_value_none_when_flag_is_last() { + let args = vec!["--version".to_owned()]; + assert_eq!(arg_value(&args, "--version"), None); + } + + #[test] + fn arg_flag_detects_presence() { + let args = vec!["--staging".to_owned()]; + assert!(arg_flag(&args, "--staging")); + assert!(!arg_flag(&args, "--nope")); + } + + #[test] + fn args_without_flag_value_strips_pair() { + let args = vec![ + "--service-id".to_owned(), + "SVC1".to_owned(), + "--comment".to_owned(), + "ci".to_owned(), + ]; + assert_eq!( + args_without_flag_value(&args, "--service-id"), + vec!["--comment".to_owned(), "ci".to_owned()] + ); + } + + #[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(), "SVC_FROM_ARG".to_owned()]; + assert_eq!(resolve_service_id(&args).unwrap(), "SVC_FROM_ARG"); + } + + // ── `compute update` passthrough filtering (`--comment`) ───────── + + fn owned(args: &[&str]) -> Vec { + args.iter().map(|arg| (*arg).to_owned()).collect() + } + + #[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:?}" + ); + } + assert_eq!( + split_staged_passthrough(&owned(&["--comment", "ci run 12"])).comment, + Some("ci run 12".to_owned()) + ); + assert_eq!( + split_staged_passthrough(&owned(&["--comment=x"])).comment, + Some("x".to_owned()) + ); + } + + #[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). + assert_eq!( + split.forwarded, + owned(&["--package", "pkg.tar.gz", "--autoclone", "--verbose"]) + ); + // `--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"])); + assert_eq!( + argv, + owned(&[ + "compute", + "deploy", + "--service-id", + "SVC1", + "--non-interactive" + ]) + ); + } + + #[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})" + ); + } + } + + // ── healthcheck / rollback input validation ─────────────────────── + // + // GitHub Actions' `required: true` does NOT fail when an input is + // omitted or empty, so the CLI is the real guard. An absent / empty / + // malformed `--service-id` or `--version` must be rejected on BOTH + // the production and the staging path — a production healthcheck + // that probes anyway "verifies" a version it never looked at, and + // the caller chains that same version into rollback. + + #[test] + fn healthcheck_rejects_missing_or_empty_required_values_on_production() { + for (args, needle) in [ + ( + owned(&["--domain", "example.com", "--service-id", "SVC1"]), + "--version", + ), + ( + owned(&[ + "--domain", + "example.com", + "--service-id", + "SVC1", + "--version", + "", + ]), + "invalid version", + ), + ( + owned(&[ + "--domain", + "example.com", + "--service-id", + "SVC1", + "--version", + "15.2.0", + ]), + "invalid version", + ), + ( + owned(&[ + "--domain", + "example.com", + "--service-id", + "", + "--version", + "7", + ]), + "invalid service id", + ), + ( + owned(&["--domain", "", "--service-id", "SVC1", "--version", "7"]), + "invalid domain", + ), + ( + owned(&["--service-id", "SVC1", "--version", "7"]), + "--domain", + ), + ] { + let err = healthcheck(&args).expect_err("must reject absent/empty required value"); + assert!( + err.contains(needle), + "expected {needle:?} in error for {args:?}, got: {err}" + ); + } + } + + #[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"); + } + } + + #[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"); + } + } + } + + // ── 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\""); + } + + #[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\""); + } + + #[test] + fn validate_service_id_accepts_opaque_handles() { + validate_service_id("SU1Z0isxPaozGVKXdv0eY").expect("alphanumeric handle"); + validate_service_id("abc_DEF-123").expect("underscore + dash handle"); + } + + #[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"); + } + + #[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"); + } + + #[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"); + } + + #[test] + fn is_healthy_status_covers_2xx_3xx() { + assert!(is_healthy_status(200)); + assert!(is_healthy_status(204)); + 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)); + } + + #[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 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 + ); + } + + #[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))); + } + + #[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}]"#; + assert_eq!(resolve_active_version(json), Ok(None)); + } + + #[test] + fn resolve_active_version_errors_on_unparseable_payload() { + // A truncated / non-array body is an operational failure, distinct from + // "no active version" — the caller must fail closed, not record empty. + resolve_active_version("not json").expect_err("non-JSON must be an operational error"); + resolve_active_version(r#"{"error":"unauthorized"}"#) + .expect_err("a non-array body must be an operational error"); + } + + #[test] + fn resolve_active_version_errors_on_malformed_active_entries() { + // A garbled ACTIVE entry must fail closed, not read as "no active + // version" — otherwise a production deploy proceeds with no rollback + // target. Each of these is malformed and must be an operational error. + resolve_active_version(r#"[{"active":true}]"#) + .expect_err("active entry with no `number` must error"); + resolve_active_version(r#"[{"active":true,"number":"7"}]"#) + .expect_err("active entry with a string `number` must error"); + resolve_active_version(r#"[{"active":"true","number":7}]"#) + .expect_err("a non-boolean `active` must error"); + // A non-boolean `active` ANYWHERE is schema drift — the whole list is + // scanned, so it is caught even AFTER a valid active entry (a naive + // first-match parser would miss this one). + resolve_active_version(r#"[{"active":"false"},{"active":true,"number":9}]"#) + .expect_err("a non-boolean `active` before the active entry is schema drift"); + 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}]"#) + .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". + resolve_active_version("[]").expect_err("an empty version list is an invalid response"); + resolve_active_version("[null]").expect_err("a null element must error"); + 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)); + // Sanity: a well-formed list still resolves. + assert_eq!( + resolve_active_version(r#"[{"active":false,"number":1},{"active":true,"number":2}]"#), + Ok(Some(2)) + ); + } + + #[test] + fn ensure_rollback_from_is_active_blocks_racing_deploys() { + // The version being rolled back FROM is still active → proceed. + assert_eq!(ensure_rollback_from_is_active(Some(7), 7, "svc"), Ok(())); + // A NEWER version is active (a deploy raced the rollback) → refuse, so + // the newer deploy is not clobbered. + ensure_rollback_from_is_active(Some(9), 7, "svc") + .expect_err("a newer active version must block the rollback"); + // No active version at all → refuse. + ensure_rollback_from_is_active(None, 7, "svc") + .expect_err("no active version must block the rollback"); + } + + #[test] + fn active_version_or_require_enforces_require_active() { + let active = r#"[{"active":true,"number":5}]"#; + let none = r#"[{"active":false,"number":5}]"#; + + // A resolvable active version is returned regardless of the flag. + assert_eq!(active_version_or_require(active, false, "svc"), Ok(Some(5))); + assert_eq!(active_version_or_require(active, true, "svc"), Ok(Some(5))); + + // No active version: tolerated for `active-version` (first deploy), but an + // ERROR for the production-deploy fallback (`--require-active`), which + // must never report a deploy with no resolvable version. + assert_eq!(active_version_or_require(none, false, "svc"), Ok(None)); + active_version_or_require(none, true, "svc") + .expect_err("require-active with no active version must fail closed"); + + // A malformed response is an error either way. + active_version_or_require("not json", false, "svc").expect_err("malformed must error"); + } + + #[test] + fn parse_staging_ip_reads_the_singular_staging_ip_field() { + // The REAL Fastly response shape for + // `GET /service//version//domain?include=staging_ips`: + // an array of domain objects, each with a SINGULAR `staging_ip` + // STRING. Body copied from go-fastly's recorded API fixture + // `fastly/fixtures/domains/list_with_staging_ips.yaml`, matching + // its `StagingIP *string `mapstructure:"staging_ip"`` field. + // (`staging_ips` is only the `include=` query value, never a + // field name — the previous parser looked for it as an array and + // therefore NEVER found a staging IP.) + let json = r#"[ + { + "created_at": "2022-11-04T17:36:56Z", + "service_id": "kKJb5bOFI47uHeBVluGfX1", + "name": "integ-test-20221104.go-fastly-1.com", + "version": 73, + "comment": "comment", + "deleted_at": null, + "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 + ); + } + + #[test] + fn build_curl_probe_args_production_has_no_connect_to() { + let args = build_curl_probe_args("example.com", "/", None, 10); + assert!(!args.iter().any(|arg| arg == "--connect-to")); + assert!(args.contains(&"https://example.com/".to_owned())); + assert!(args.contains(&"--max-time".to_owned())); + assert!(args.contains(&"10".to_owned())); + // Globbing must be off so bracket/brace characters in a path are literal. + assert!(args.contains(&"--globoff".to_owned())); + } + + #[test] + fn build_curl_probe_args_path_with_glob_chars_is_literal() { + // A path with `[` `]` would be a curl glob without --globoff; here it must + // appear verbatim in the single URL argument, with globbing disabled. + let args = build_curl_probe_args("example.com", "/health?ids[0]=1", None, 10); + assert!(args.contains(&"--globoff".to_owned())); + assert!(args.contains(&"https://example.com/health?ids[0]=1".to_owned())); + } + + #[test] + fn build_curl_probe_args_staging_reroutes_to_ip() { + let args = build_curl_probe_args("staging.example.com", "/", Some("151.101.2.10"), 15); + let idx = args + .iter() + .position(|arg| arg == "--connect-to") + .expect("--connect-to present for staging"); + assert_eq!(args[idx + 1], "::151.101.2.10:443"); + assert!(args.contains(&"https://staging.example.com/".to_owned())); + } + + #[test] + fn build_curl_probe_args_honors_path_on_production_and_staging() { + // Production: the path is appended to the domain URL. + let prod = build_curl_probe_args("example.com", "/health", None, 10); + assert!(prod.contains(&"https://example.com/health".to_owned())); + // Staging: same URL (with the path), rerouted to the staging IP. + let staging = + build_curl_probe_args("staging.example.com", "/health", Some("151.101.2.10"), 10); + assert!(staging.contains(&"https://staging.example.com/health".to_owned())); + let idx = staging + .iter() + .position(|arg| arg == "--connect-to") + .expect("--connect-to present for staging"); + assert_eq!(staging[idx + 1], "::151.101.2.10:443"); + } + + #[test] + fn validate_probe_path_requires_leading_slash_and_no_whitespace() { + validate_probe_path("/").expect("root"); + validate_probe_path("/health").expect("simple path"); + validate_probe_path("/api/v1/status?ready=1").expect("path with query"); + validate_probe_path("health").expect_err("no leading slash"); + validate_probe_path("").expect_err("empty"); + validate_probe_path("/ with space").expect_err("whitespace"); + validate_probe_path("/inject\nHost: evil").expect_err("newline injection"); + } + + #[test] + fn healthcheck_rejects_zero_timeout() { + // A zero timeout becomes curl `--max-time 0` (no limit); reject it before + // any probe. The other required args are valid so we reach the check. + let args = [ + "--adapter", + "fastly", + "--domain", + "example.com", + "--service-id", + "svc123", + "--version", + "1", + "--timeout", + "0", + ] + .map(str::to_owned) + .to_vec(); + let err = healthcheck(&args).expect_err("zero timeout must be rejected"); + assert!(err.contains("timeout"), "unexpected error: {err}"); + } + + #[test] + fn probe_with_retries_returns_first_healthy() { + let mut calls: i32 = 0; + let mut between: i32 = 0; + let result = probe_with_retries( + 5, + || { + calls += 1_i32; + Ok(200) + }, + || between += 1_i32, + ); + assert_eq!(result, Ok(200)); + assert_eq!(calls, 1_i32, "should stop after first healthy probe"); + assert_eq!(between, 0_i32, "no delay before the first attempt"); + } + + #[test] + fn probe_with_retries_succeeds_after_unhealthy_attempts() { + let mut calls: i32 = 0; + let mut between: i32 = 0; + let result = probe_with_retries( + 5, + || { + calls += 1_i32; + if calls < 3_i32 { Ok(503) } else { Ok(200) } + }, + || between += 1_i32, + ); + assert_eq!(result, Ok(200)); + assert_eq!(calls, 3_i32); + assert_eq!( + between, 2_i32, + "delay runs between each of the first 3 attempts" + ); + } + + #[test] + fn probe_with_retries_exhausts_and_reports_last_code() { + let mut between: i32 = 0; + let result = probe_with_retries(3, || Ok(500), || between += 1_i32); + assert_eq!( + result, + Err((Some(500), "unhealthy HTTP status 500".to_owned())) + ); + assert_eq!( + between, 2_i32, + "delay runs between attempts, not after the last" + ); + } + + #[test] + fn probe_with_retries_reports_transport_error() { + let result: Result, String)> = + probe_with_retries(1, || Err("connection refused".to_owned()), || {}); + assert_eq!(result, Err((None, "connection refused".to_owned()))); + } - // 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 probe_with_retries_treats_zero_retry_as_one_attempt() { + let mut calls: i32 = 0; + let result = probe_with_retries( + 0, + || { + calls += 1_i32; + Ok(500) + }, + || {}, + ); + assert_eq!( + result, + Err((Some(500), "unhealthy HTTP status 500".to_owned())) + ); + assert_eq!(calls, 1_i32); + } #[test] fn finds_closest_manifest_when_multiple_exist() { @@ -1669,7 +3834,7 @@ mod tests { #[test] fn setup_block_present_true_when_only_setup_exists() { - // Post-F6 (PR #269 round 2): `setup_block_present` only + // `setup_block_present` only // checks `[setup._stores.]`. The pre-fix check // ALSO required `[local_server._stores.]`, but // writing an empty `[local_server.*]` table didn't match @@ -1711,7 +3876,7 @@ mod tests { after.contains("[setup.kv_stores.sessions]"), "setup table added: {after}" ); - // Post-F6: no `[local_server.*]` write — that empty stanza + // No `[local_server.*]` write — that empty stanza // didn't satisfy fastly's local-server schema and made // `fastly compute serve` error or skip the store. Local- // server seeding is now `config push --adapter fastly @@ -1922,8 +4087,10 @@ build = \"cargo build --release\" let out = FastlyCliAdapter .provision(dir.path(), Some("fastly.toml"), None, &stores, true) .expect("dry-run succeeds"); - // 1 KV + 1 config + 1 secret + 1 runtime-env = 4 status lines. - assert_eq!(out.len(), 4); + // 1 KV + 1 config + 1 secret + runtime-env = 4 status lines. The staging + // twin is created and populated by a staged deploy, NOT by provision, so + // it does not appear here. + assert_eq!(out.len(), 4, "dry-run rows: {out:?}"); 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`")); @@ -1931,6 +4098,11 @@ build = \"cargo build --release\" out[3].contains("would run `fastly config-store create --name=edgezero_runtime_env`"), "runtime-env store row: {out:?}", ); + assert!( + !out.iter() + .any(|row| row.contains("edgezero_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\"\n", "dry-run mutated fastly.toml"); @@ -2172,6 +4344,51 @@ build = \"cargo build --release\" ); } + #[test] + fn find_config_store_id_flags_schema_drift_when_any_entry_is_malformed() { + // A well-formed, non-matching entry must NOT mask a malformed one: the + // malformed entry could be the store we're looking for (hidden behind a + // missing name/id). Deciding NotFound here would fail OPEN — staging + // would then mirror no production overrides. Any malformed entry is drift. + let stdout = r#"[ + {"id": "abc123", "name": "some_other_store"}, + {"id": "def456"} + ]"#; + let drift = find_config_store_id(stdout, "edgezero_runtime_env"); + assert!( + matches!(drift, ConfigStoreLookup::SchemaDrift(_)), + "a malformed entry alongside a well-formed one must be schema drift, got {drift:?}" + ); + } + + #[test] + fn find_config_store_id_scans_past_a_match() { + // 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"}, + {"name": "broken"} + ]"#; + let drift = find_config_store_id(stdout, "edgezero_runtime_env"); + assert!( + matches!(drift, ConfigStoreLookup::SchemaDrift(_)), + "a malformed entry after the match must be schema drift, got {drift:?}" + ); + } + + #[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"} + ]"#; + let drift = find_config_store_id(stdout, "edgezero_runtime_env"); + assert!( + matches!(drift, ConfigStoreLookup::SchemaDrift(_)), + "two stores with the same name must be ambiguous drift, got {drift:?}" + ); + } + // ---------- push_config_entries (dry-run + error paths) ---------- #[test] @@ -2838,7 +5055,7 @@ build = \"cargo build --release\" ); } - /// Spec 12.7: pushing two blobs under different root keys + /// 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 @@ -3256,14 +5473,14 @@ build = \"cargo build --release\" ); } - /// Spec 12.3 + 9.3: a second oversized push must converge the + /// A second oversized push must converge the /// runtime on the NEW envelope — chunk keys are content-addressed /// by the full-envelope SHA, so push B writes a new chunk-set and /// installs a new root pointer. /// /// The local fastly.toml writer upserts per-key (so a sibling - /// `--key app_config_staging` push leaves `app_config` intact per - /// spec 12.7). Within the SAME root key, old chunks for envelope + /// `--key app_config_staging` push leaves `app_config` intact). + /// Within the SAME root key, old chunks for envelope /// A remain in the contents table after envelope B's push — they're /// unreferenced (the root pointer at `app_config` now names B's /// chunks), matching the remote Fastly behaviour where the @@ -3285,7 +5502,7 @@ build = \"cargo build --release\" // First push: envelope A. Records the chunk-key set so we can // confirm they survive the second push (no garbage collection - // in v1 — spec 9.3 + Q6). + // in v1). let envelope_a = make_test_envelope(FASTLY_CONFIG_ENTRY_LIMIT.saturating_add(1)); FastlyCliAdapter .push_config_entries_local( @@ -3407,4 +5624,640 @@ build = \"cargo build --release\" "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\" = \"list\" ]; then\n \ + case \"$*\" in\n \ + *--store-id=ENVSEL1*) printf '%s\\n' '[{{\"item_key\":\"EDGEZERO__ADAPTER__FASTLY__LOG_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) { + 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 token for the call, restore it on drop. Uses the shared + // guard (edition-2024 wraps the env mutation's `unsafe` and holds the + // lock we already took above). + let _token = EnvOverride::set(FASTLY_API_TOKEN_ENV, "test-token"); + 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" + ); + } + } + + #[test] + fn runtime_env_key_matches_what_the_runtime_reads() { + use edgezero_core::env_config::EnvConfig; + + // EnvConfig::from_vars strips `EDGEZERO__`, splits on `__`, lowercases; + // store_key("config", id) looks up ["stores","config",id,"key"]. So the + // entry name is the id uppercased. A near-miss is SILENT: the runtime + // would fall back to the id and read production config. + assert_eq!( + runtime_env_key_for("app_config"), + "EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY" + ); + + // Prove it against the real reader rather than restating the format. + let cfg = EnvConfig::from_vars([( + runtime_env_key_for("app_config"), + "app_config_staging".to_owned(), + )]); + assert_eq!( + cfg.store_key("config", "app_config"), + "app_config_staging", + "the entry provision writes must be the one the runtime reads" + ); + } + + #[test] + fn staging_entries_from_production_mirrors_and_overrides() { + // Production carries a non-config override, an explicit config selector, + // and a __NAME redirect. The twin must copy the non-config entries + // verbatim and redirect EVERY declared config store to `_staging` + // — including one production has no explicit entry for (it relies on the + // runtime default; the twin must NOT inherit that default). + let production = vec![ + ( + "EDGEZERO__ADAPTER__FASTLY__LOG_LEVEL".to_owned(), + "debug".to_owned(), + ), + ( + "EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY".to_owned(), + "custom_prod_key".to_owned(), + ), + ( + "EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME".to_owned(), + "app_config".to_owned(), + ), + ]; + let out = staging_entries_from_production( + &production, + &["app_config".to_owned(), "feature_flags".to_owned()], + ); + + // Non-selector overrides copied verbatim. + assert!(out.contains(&( + "EDGEZERO__ADAPTER__FASTLY__LOG_LEVEL".to_owned(), + "debug".to_owned() + ))); + assert!(out.contains(&( + "EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME".to_owned(), + "app_config".to_owned() + ))); + // The selector production HAD is overridden to `_staging`, NOT + // production's custom value. + assert!(out.contains(&( + "EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY".to_owned(), + "app_config_staging".to_owned() + ))); + assert!(!out.iter().any(|(_, value)| value == "custom_prod_key")); + // The declared store production LACKED a selector for still gets one. + assert!(out.contains(&( + "EDGEZERO__STORES__CONFIG__FEATURE_FLAGS__KEY".to_owned(), + "feature_flags_staging".to_owned() + ))); + // Exactly one entry per selector key (no duplicate from the copy path). + assert_eq!( + out.iter() + .filter(|(key, _)| key == "EDGEZERO__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_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__ADAPTER__FASTLY__LOG_LEVEL" + )), + "production's non-config override must be mirrored into the twin: {argv:?}" + ); + assert!( + argv.iter().any(|line| line.starts_with( + "config-store-entry update --store-id=STAGEID1 --key=EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY" + )), + "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\" = \"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\" = \"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__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. + #[cfg(unix)] + fn fake_fastly_cwd_recorder() -> (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"); + let script = format!( + "#!/bin/sh\nprintf '%s\\t%s\\n' \"$PWD\" \"$*\" >> '{}'\nexit 0\n", + 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) + } + + #[cfg(unix)] + #[test] + fn deploy_honours_threaded_manifest_path_and_strips_it_from_the_fastly_argv() { + // Production deploys used to ignore the CLI-threaded + // `--manifest-path` and fall back to `find_fastly_manifest(cwd)`, + // which in a monorepo picks the CLOSEST fastly.toml — the wrong + // app. The threaded path must select the app directory, and must + // be STRIPPED from the argv (`fastly compute deploy` has no such + // flag and would exit non-zero). + let _lock = path_mutation_guard().lock().expect("guard"); + let (fake, record) = fake_fastly_cwd_recorder(); + 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"); + + let args = vec![ + "--manifest-path".to_owned(), + manifest.display().to_string(), + "--service-id".to_owned(), + "SVC1".to_owned(), + ]; + deploy(&args).expect("deploy must run against the threaded manifest"); + + let recorded = fs::read_to_string(&record).expect("fastly was invoked"); + let (cwd, recorded_argv) = recorded + .trim_end() + .split_once('\t') + .expect("recorded `\\t`"); + assert_eq!( + fs::canonicalize(cwd).expect("cwd"), + fs::canonicalize(app.path()).expect("app dir"), + "deploy must run in the manifest-selected app directory" + ); + assert_eq!( + recorded_argv, + "compute deploy --service-id SVC1 --non-interactive" + ); + } } diff --git a/crates/edgezero-adapter-spin/src/cli.rs b/crates/edgezero-adapter-spin/src/cli.rs index 7b0c635f..623223d2 100644 --- a/crates/edgezero-adapter-spin/src/cli.rs +++ b/crates/edgezero-adapter-spin/src/cli.rs @@ -133,7 +133,7 @@ struct SpinCliAdapter; #[expect( clippy::missing_trait_methods, - reason = "Stage 6: KV-backed config dropped Spin's `^[a-z][a-z0-9_]*$` key rule and the config-vs-secret collision check, so `validate_app_config_keys` falls back to the trait default `Ok(())`. `validate_typed_secrets` IS overridden below (secret-value canonicalisation + within-secrets uniqueness still apply). `validate_adapter_manifest` IS overridden below (Spin's multi-component disambiguation). `read_config_entry` and `read_config_entry_local` are both overridden below (four-branch SQLite-direct / Fermyon Cloud / non-Spin-backend dispatch)." + reason = "KV-backed config dropped Spin's `^[a-z][a-z0-9_]*$` key rule and the config-vs-secret collision check, so `validate_app_config_keys` falls back to the trait default `Ok(())`. `validate_typed_secrets` IS overridden below (secret-value canonicalisation + within-secrets uniqueness still apply). `validate_adapter_manifest` IS overridden below (Spin's multi-component disambiguation). `read_config_entry` and `read_config_entry_local` are both overridden below (four-branch SQLite-direct / Fermyon Cloud / non-Spin-backend dispatch)." )] impl Adapter for SpinCliAdapter { fn execute(&self, action: AdapterAction, args: &[String]) -> Result<(), String> { @@ -157,6 +157,13 @@ impl Adapter for SpinCliAdapter { } AdapterAction::Deploy => deploy(args), AdapterAction::Serve => serve(args), + // The Fastly staging lifecycle is Fastly-only. + AdapterAction::DeployStaged + | AdapterAction::EmitVersion + | AdapterAction::Healthcheck + | AdapterAction::Rollback => Err(format!( + "spin adapter does not support the Fastly staging lifecycle action {action:?}" + )), other => Err(format!("spin adapter does not support {other:?}")), } } @@ -184,8 +191,7 @@ impl Adapter for SpinCliAdapter { //: spin provision is pure spin.toml editing — no // shell-out (Spin KV stores are provisioned by the Spin // runtime / Fermyon at deploy). For each declared KV id - // AND each declared CONFIG id (KV-backed since Stage 5 - // of the spin-kv-config plan), append the env-resolved + // AND each declared CONFIG id (KV-backed), append the env-resolved // platform label to the component's `key_value_stores` // array. Secret variables are manually declared by the // developer in spin.toml -- secrets stay on Spin @@ -312,7 +318,7 @@ impl Adapter for SpinCliAdapter { // 2. Deploy command targets Fermyon Cloud → `Unsupported`. // Fermyon Cloud's `spin cloud key-value list` enumerates // STORES, not keys; there is no stable per-key get CLI in - // v1 (8.3 / 9.4 of the spec). NO shell-out. + // v1. NO shell-out. // 3. `runtime-config.toml` declares a non-`spin` backend // (Redis / AzureCosmos / Unknown) → error naming the backend // and pointing at its native CLI, matching the writer at @@ -449,7 +455,7 @@ impl Adapter for SpinCliAdapter { fn single_store_kinds(&self) -> &'static [&'static str] { //: Multi for KV AND Config (both label-backed via the - // Spin KV API since Stage 5 of the spin-kv-config plan). + // Spin KV API). // Single for Secrets (still flat-variable namespace). &["secrets"] } @@ -511,9 +517,9 @@ impl Adapter for SpinCliAdapter { fn validate_typed_secrets(&self, entries: &[TypedSecretEntry<'_>]) -> Result<(), String> { use std::collections::HashMap; - // Stage 5+: KV-backed config no longer shares Spin's flat + // KV-backed config no longer shares Spin's flat // variable namespace, so config keys are NOT considered here - // (and the trait dropped the parameter in Stage 6+) — config + // (and the trait dropped the parameter) — config // can use arbitrary UTF-8 keys without colliding with // `#[secret]` values. Secrets still resolve through // `spin_sdk::variables`, so two checks remain: @@ -1332,11 +1338,11 @@ mod tests { ); } - // 12.16 — named-store secret adapter validation + // named-store secret adapter validation #[test] fn collision_error_names_both_field_names_and_lowercased_variable() { - // 12.16 case (b): KeyInDefault and KeyInNamedStore that + // case (b): KeyInDefault and KeyInNamedStore that // collide on the lowercased Spin variable. let entries = [ TypedSecretEntry::new("default", "one", "Demo_Token"), @@ -1350,7 +1356,7 @@ mod tests { #[test] fn rejects_invalid_spin_variable_name_with_hyphen() { - // 12.16 case (a): KeyInNamedStore value contains a hyphen, + // case (a): KeyInNamedStore value contains a hyphen, // which is not a valid Spin variable name. let entries = [TypedSecretEntry::new("vault", "api_token", "demo-token")]; let err = SpinCliAdapter.validate_typed_secrets(&entries).unwrap_err(); @@ -1364,7 +1370,7 @@ mod tests { #[test] fn non_spin_adapter_is_exempt_from_collision_check() { - // 12.16 case (c): same collision fixture against a manifest + // case (c): same collision fixture against a manifest // declaring only [adapters.axum] — covered by run_adapter_ // typed_checks NOT calling SpinCliAdapter at all. This is more // naturally a CLI-level integration test, but the adapter @@ -1428,7 +1434,7 @@ mod tests { #[test] fn single_store_kinds_is_secrets_only() { - // Stage 5: config moved to KV (provisioned via `key_value_stores`, + // Config moved to KV (provisioned via `key_value_stores`, // entries pushed via the seed handler). Secrets remain Spin // `[variables]` until we ship native secret support. assert_eq!(SpinCliAdapter.single_store_kinds(), &["secrets"]); @@ -1740,7 +1746,7 @@ mod tests { #[test] fn provision_writes_config_labels_into_kv_array_and_leaves_secrets_manual() { - // Stage 5: config now lives in Spin KV. Provision writes each + // Config now lives in Spin KV. Provision writes each // `[stores.config].id` into `[component.X].key_value_stores` // (same machinery as `[stores.kv]`). Secrets stay manual until // we ship native secret support. @@ -1848,7 +1854,7 @@ mod tests { #[test] fn dispatch_push_local_forces_sqlite_even_when_runtime_config_declares_redis() { - // F1 (blocker): `--local` MUST bypass runtime-config backend + // `--local` MUST bypass runtime-config backend // dispatch. Without this test, the code that says "Redis: error // out" would silently fire even under --local. let dir = tempdir().expect("tempdir"); diff --git a/crates/edgezero-adapter/src/registry.rs b/crates/edgezero-adapter/src/registry.rs index 0f8850d0..f875ca03 100644 --- a/crates/edgezero-adapter/src/registry.rs +++ b/crates/edgezero-adapter/src/registry.rs @@ -19,6 +19,25 @@ pub enum AdapterAction { AuthStatus, Build, Deploy, + /// Stage a draft platform version without activating it. Fastly + /// clones the active service version, uploads the built package + /// to it, then marks it staged; other adapters return an + /// "unsupported" error. Part of the Fastly staging lifecycle. + DeployStaged, + /// Emit the deployed/active platform version in a parseable form + /// (`version=`) so a CI action can capture it. Fastly resolves + /// the active service version; other adapters return + /// "unsupported". Companion to `Deploy` for the staging lifecycle. + EmitVersion, + /// Probe a deployed version's health and exit non-zero when + /// unhealthy after retries. Fastly curls the domain (or its + /// staging IP resolved from the Fastly API); other adapters + /// return "unsupported". + Healthcheck, + /// Roll a service back: production activates the previous version, + /// staging deactivates the staged version. Fastly-only; other + /// adapters return "unsupported". + Rollback, Serve, } @@ -201,7 +220,7 @@ impl<'entry> TypedSecretEntry<'entry> { } } -/// Outcome of a single-key read. See spec 9.0. +/// Outcome of a single-key read. #[non_exhaustive] pub enum ReadConfigEntry { /// The store exists but the key is absent (operator hasn't pushed yet, @@ -215,7 +234,7 @@ pub enum ReadConfigEntry { Present(String), /// The adapter cannot query the backend for this entry — e.g. Spin /// Cloud's CLI exposes no `get`. `&'static str` carries the human- - /// readable reason. See spec 8.3 four-branch UX. + /// readable reason. See the four-branch UX. Unsupported(&'static str), } @@ -379,7 +398,7 @@ pub trait Adapter: Sync + Send { } /// Single-key read against the LIVE platform. Mirrors - /// [`Self::push_config_entries`]'s argument list per spec 9.0 so + /// [`Self::push_config_entries`]'s argument list so /// adapters can share helpers (`find_namespace_id` for Cloudflare, /// `resolve_label_for_store` for Spin, etc.). /// @@ -430,8 +449,8 @@ pub trait Adapter: Sync + Send { )) } - /// Store kinds for which this adapter is Single-capable per - /// spec — `--strict` rejects `[stores.].ids.len() > 1` + /// Store kinds for which this adapter is Single-capable. + /// `--strict` rejects `[stores.].ids.len() > 1` /// when any listed kind matches. Default: `&[]` (Multi for /// every store kind). #[inline] @@ -488,7 +507,7 @@ pub trait Adapter: Sync + Send { /// /// Note: the previous signature took a `_config_keys` parameter /// so Spin could detect cross-namespace collision with KV-stored - /// values; KV-backed config dropped that need in Stage 6, and no + /// values; KV-backed config dropped that need, and no /// remaining adapter consults it. If a future adapter needs the /// flattened config-key set here, add it back via a builder /// context rather than re-introducing a positional parameter diff --git a/crates/edgezero-cli/src/adapter.rs b/crates/edgezero-cli/src/adapter.rs index d122f6ba..6f402efe 100644 --- a/crates/edgezero-cli/src/adapter.rs +++ b/crates/edgezero-cli/src/adapter.rs @@ -3,8 +3,10 @@ use edgezero_core::manifest::{Manifest, ManifestLoader, ResolvedEnvironment}; use std::env; use std::fmt; +use std::io::{self, BufRead as _, BufReader, Read, Write}; use std::path::Path; -use std::process::Command; +use std::process::{Command, Stdio}; +use std::thread; include!(concat!(env!("OUT_DIR"), "/linked_adapters.rs")); @@ -15,6 +17,15 @@ pub enum Action { AuthStatus, Build, Deploy, + /// Fastly staging lifecycle: stage a draft version. + DeployStaged, + /// Fastly staging lifecycle: emit the active version. + EmitVersion, + /// Fastly staging lifecycle: probe health. + Healthcheck, + /// Fastly staging lifecycle: activate previous / + /// deactivate staged. + Rollback, Serve, } @@ -27,6 +38,10 @@ impl fmt::Display for Action { Action::Build => "build", Action::Deploy => "deploy", Action::Serve => "serve", + Action::DeployStaged => "deploy --stage", + Action::EmitVersion => "deploy (version)", + Action::Healthcheck => "healthcheck", + Action::Rollback => "rollback", }; f.write_str(label) } @@ -42,6 +57,10 @@ impl From for AdapterAction { Action::Build => AdapterAction::Build, Action::Deploy => AdapterAction::Deploy, Action::Serve => AdapterAction::Serve, + Action::DeployStaged => AdapterAction::DeployStaged, + Action::EmitVersion => AdapterAction::EmitVersion, + Action::Healthcheck => AdapterAction::Healthcheck, + Action::Rollback => AdapterAction::Rollback, } } } @@ -135,6 +154,58 @@ pub fn execute( 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( + adapter_name: &str, + action: Action, + 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) + { + 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( + command, + root, + adapter_name, + action, + Some(env), + adapter_bind, + adapter_args, + ) + .map(Some); + } + 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()) +} + fn manifest_command<'manifest>( manifest: &'manifest Manifest, adapter_name: &str, @@ -148,6 +219,12 @@ fn manifest_command<'manifest>( Action::Build => cfg.commands.build.as_deref(), Action::Deploy => cfg.commands.deploy.as_deref(), Action::Serve => cfg.commands.serve.as_deref(), + // The Fastly staging lifecycle actions are not + // manifest-configurable shell commands — they run the + // adapter's built-in Fastly logic directly. Returning None + // routes `adapter::execute` past the manifest-command path to + // the registered adapter's `execute`. + Action::DeployStaged | Action::EmitVersion | Action::Healthcheck | Action::Rollback => None, } } @@ -165,15 +242,18 @@ fn adapter_bind_from_manifest( (cfg.adapter.host.clone(), cfg.adapter.port) } -fn run_shell( +/// Build the `sh -c ` child for a manifest-declared adapter +/// command, with the manifest environment / bind hints applied. Shared +/// by [`run_shell`] (inherited stdio) and [`run_shell_tee`] (piped + +/// echoed stdio) so both dispatch paths apply identical env precedence. +fn build_shell_command( command: &str, cwd: &Path, adapter_name: &str, - action: Action, environment: Option, adapter_bind: (Option, Option), adapter_args: &[String], -) -> Result<(), String> { +) -> Result { let full_command = if adapter_args.is_empty() { command.to_owned() } else { @@ -221,6 +301,27 @@ fn run_shell( apply_environment(adapter_name, &env, &mut cmd)?; } + Ok(cmd) +} + +fn run_shell( + command: &str, + cwd: &Path, + adapter_name: &str, + action: Action, + environment: Option, + adapter_bind: (Option, Option), + adapter_args: &[String], +) -> Result<(), String> { + let mut cmd = build_shell_command( + command, + cwd, + adapter_name, + environment, + adapter_bind, + adapter_args, + )?; + let status = cmd .status() .map_err(|err| format!("failed to run {action} command `{command}`: {err}"))?; @@ -234,6 +335,83 @@ fn run_shell( } } +/// Stream `reader` to `writer` line-by-line while accumulating a copy. +/// This is the "tee" half of [`run_shell_tee`]: the operator still sees +/// the child's output as it happens, and the caller still gets the text +/// to parse. +fn tee_stream(reader: R, mut writer: W) -> String { + let mut buffered = BufReader::new(reader); + let mut captured = String::new(); + let mut line = String::new(); + loop { + line.clear(); + match buffered.read_line(&mut line) { + Ok(0) | Err(_) => break, + Ok(_) => { + let _echoed = writer.write_all(line.as_bytes()); + let _flushed = writer.flush(); + captured.push_str(&line); + } + } + } + captured +} + +/// Same dispatch as [`run_shell`], but the child's stdout/stderr are +/// piped, echoed through to our own stdout/stderr as they arrive, AND +/// captured. Returns the captured `stdout + stderr` text so the caller +/// can parse machine-readable lines (e.g. Fastly's activated +/// `version=`) out of a command it does not otherwise control. +fn run_shell_tee( + command: &str, + cwd: &Path, + adapter_name: &str, + action: Action, + environment: Option, + adapter_bind: (Option, Option), + adapter_args: &[String], +) -> Result { + let mut cmd = build_shell_command( + command, + cwd, + adapter_name, + environment, + adapter_bind, + adapter_args, + )?; + cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); + + let mut child = cmd + .spawn() + .map_err(|err| format!("failed to run {action} command `{command}`: {err}"))?; + let child_stdout = child + .stdout + .take() + .ok_or_else(|| format!("failed to capture stdout of {action} command `{command}`"))?; + let child_stderr = child + .stderr + .take() + .ok_or_else(|| format!("failed to capture stderr of {action} command `{command}`"))?; + + // stderr is drained on a worker thread so a chatty child cannot + // deadlock by filling the stderr pipe while we block on stdout. + let stderr_worker = thread::spawn(move || tee_stream(child_stderr, io::stderr())); + let captured_stdout = tee_stream(child_stdout, io::stdout()); + let captured_stderr = stderr_worker.join().unwrap_or_default(); + + let status = child + .wait() + .map_err(|err| format!("failed to run {action} command `{command}`: {err}"))?; + + if status.success() { + Ok(format!("{captured_stdout}{captured_stderr}")) + } else { + Err(format!( + "{action} command `{command}` exited with status {status}" + )) + } +} + fn shell_escape(arg: &str) -> String { if arg.is_empty() { "''".to_owned() diff --git a/crates/edgezero-cli/src/args.rs b/crates/edgezero-cli/src/args.rs index 65f033d6..90ba333d 100644 --- a/crates/edgezero-cli/src/args.rs +++ b/crates/edgezero-cli/src/args.rs @@ -19,6 +19,11 @@ pub struct Args { #[derive(Subcommand, Debug)] pub enum Command { + /// Resolve and print the currently-active service version + /// (`version=`). Used to capture a production rollback target + /// BEFORE a deploy supersedes it, since Fastly exposes no metadata to + /// infer it afterward. + ActiveVersion(ActiveVersionArgs), /// Sign in / out / status against the adapter's native CLI /// (`wrangler` / `fastly` / `spin`). `EdgeZero` stores no /// credentials itself — `auth` just delegates. @@ -33,6 +38,9 @@ pub enum Command { Demo, /// Deploy to a target edge. Deploy(DeployArgs), + /// Probe a deployed version's health (Fastly staging lifecycle). + /// Exits non-zero when unhealthy after retries. + Healthcheck(HealthcheckArgs), /// Create a new `EdgeZero` app skeleton (multi-crate workspace). New(NewArgs), /// Create the platform resources backing the declared @@ -40,6 +48,9 @@ pub enum Command { /// own dispatch: cloudflare shells out to `wrangler`, fastly to /// `fastly`, spin edits `spin.toml` in-place, axum is a no-op. Provision(ProvisionArgs), + /// Roll a service back to a previous version, or deactivate a + /// staged version (Fastly staging lifecycle). + Rollback(RollbackArgs), /// Run a local simulation (adapter-specific). Serve(ServeArgs), } @@ -58,7 +69,7 @@ pub enum ConfigCmd { Diff(ConfigCmdStubArgs), /// Push the typed `.toml` as a single blob envelope to the /// adapter's config store. The blob carries every field verbatim - /// (per spec 3.3 Model A — `#[secret]` fields store the key NAME, + /// (Model A — `#[secret]` fields store the key NAME, /// resolved at runtime); a SHA over the canonical-form data gates /// drift detection. /// (Bundled `edgezero` stub — see after-help for the typed CLI.) @@ -71,10 +82,10 @@ pub enum ConfigCmd { /// Hidden catch-all argument sink for the bundled stub variants of /// `config push` and `config diff`. Absorbs any flags the user types -/// so clap does not error before we can print the pointer text (3.2.2). +/// so clap does not error before we can print the pointer text. #[derive(clap::Args, Debug)] pub struct ConfigCmdStubArgs { - /// Hidden catch-all sink (see spec 3.2.2). + /// Hidden catch-all sink. #[arg(trailing_var_arg = true, allow_hyphen_values = true, hide = true)] pub trailing: Vec, } @@ -148,6 +159,19 @@ pub struct DeployArgs { /// Arguments passed through to the adapter deploy command. #[arg(trailing_var_arg = true, allow_hyphen_values = true)] pub adapter_args: Vec, + /// 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 + /// version; `--stage` uses it to clone + stage a draft version. + /// Adapters that don't need a service id ignore it. + #[arg(long)] + pub service_id: Option, + /// Stage a draft version instead of activating (Fastly only): + /// builds and uploads to a new draft service version cloned + /// from the active one, marks it staged, and emits the staged + /// version. Non-Fastly adapters reject `--stage`. + #[arg(long)] + pub stage: bool, } /// Arguments for the `new` command. @@ -205,11 +229,94 @@ pub struct ServeArgs { pub adapter: String, } +/// Arguments for the `healthcheck` command (Fastly staging lifecycle). +/// +/// No `Default` impl (like `AuthArgs`): `--adapter` is required and +/// the numeric flags carry clap defaults that a derived `Default` +/// would zero out. Tests exercise it through clap parsing instead. +#[derive(clap::Args, Debug)] +#[non_exhaustive] +pub struct HealthcheckArgs { + /// Target adapter name. + #[arg(long = "adapter", required = true)] + pub adapter: String, + /// Public domain to probe. Required: the deploy→healthcheck→rollback + /// contract always threads it. + #[arg(long, required = true)] + pub domain: String, + /// URL path to probe on the domain (must begin with '/'). Applies to + /// production and staging alike — staging reroutes the same URL to the + /// resolved staging IP. Defaults to '/'. + #[arg(long, default_value = "/")] + pub path: String, + /// Total number of attempts before declaring the probe unhealthy. + #[arg(long, default_value_t = 3)] + pub retry: u32, + /// Seconds to wait between attempts. + #[arg(long = "retry-delay", default_value_t = 5)] + pub retry_delay: u64, + /// Platform service id to probe. Required (staging resolves the + /// staging IP from it; production threads it for parity). + #[arg(long, required = true)] + pub service_id: String, + /// Probe the staged version via its resolved staging IP rather + /// than the live production endpoint. + #[arg(long)] + pub staging: bool, + /// Per-attempt connect/read timeout in seconds. + #[arg(long, default_value_t = 10)] + pub timeout: u64, + /// Service version to probe (threaded from a prior deploy/stage). + /// Required so the version is never silently dropped. + #[arg(long, required = true)] + pub version: String, +} + +/// Arguments for the `rollback` command (Fastly staging lifecycle). +/// +/// No `Default` impl (like `AuthArgs`): `--adapter` is required. +#[derive(clap::Args, Debug)] +#[non_exhaustive] +pub struct RollbackArgs { + /// Target adapter name. + #[arg(long = "adapter", required = true)] + pub adapter: String, + /// Production only: the version to re-activate. Fastly exposes no + /// metadata to tell a previously-live version from a staged one, so + /// the rollback target CANNOT be inferred; it is captured before the + /// deploy that superseded it (see `deploy`'s `previous-version`) and + /// passed here. Required for a production rollback; ignored for staging. + #[arg(long)] + pub rollback_to: Option, + /// Platform service id to roll back. Required. + #[arg(long, required = true)] + pub service_id: String, + /// Roll back the staged version (deactivate) instead of the + /// production version (activate `--rollback-to`). + #[arg(long)] + pub staging: bool, + /// The current (bad) version. Staging deactivates it. Required. + #[arg(long, required = true)] + pub version: String, +} + +/// Arguments for the `active-version` command. +#[derive(clap::Args, Debug, Default)] +#[non_exhaustive] +pub struct ActiveVersionArgs { + /// Target adapter name. + #[arg(long = "adapter", required = true)] + pub adapter: String, + /// Platform service id whose active version to resolve. Required. + #[arg(long, required = true)] + pub service_id: String, +} + /// Output format for `config diff`. #[derive(clap::ValueEnum, Clone, Debug, Default, PartialEq)] pub enum DiffFormat { /// Machine-readable JSON object with `local_sha256`, `remote_sha256`, - /// `added`, `removed`, `changed` fields (per spec 8.1.3). + /// `added`, `removed`, `changed` fields. Json, /// Machine-readable structured representation (key/old/new triples). Structured, @@ -226,6 +333,12 @@ pub enum DiffFormat { /// CLI at runtime. #[derive(clap::Args, Debug)] #[non_exhaustive] +#[expect( + clippy::struct_excessive_bools, + reason = "clap args struct: each bool is a distinct CLI flag \ + (exit_code, local, no_env, staging); a state machine \ + would be inappropriate here" +)] pub struct ConfigDiffArgs { /// Target adapter name. #[arg(long, required = true)] @@ -240,7 +353,7 @@ pub struct ConfigDiffArgs { /// Output format for the diff. #[arg(long, default_value = "unified")] pub format: DiffFormat, - /// Override the default key — 5.4. + /// Override the default key. #[arg(long)] pub key: Option, /// Diff against the adapter's local-emulator state instead of the @@ -257,6 +370,11 @@ 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. + #[arg(long, conflicts_with = "key")] + pub staging: bool, /// Logical config store id to diff against. Defaults to the /// `[stores.config].default` (or the only declared id when /// `[stores.config].ids` has length 1). @@ -278,6 +396,7 @@ impl Default for ConfigDiffArgs { manifest: default_manifest_path(), no_env: false, runtime_config: None, + staging: false, store: None, } } @@ -294,7 +413,7 @@ impl Default for ConfigDiffArgs { #[expect( clippy::struct_excessive_bools, reason = "clap args struct: each bool is a distinct CLI flag \ - (dry_run, local, no_diff, no_env, yes); a state machine \ + (dry_run, local, no_diff, no_env, staging, yes); a state machine \ would be inappropriate here" )] pub struct ConfigPushArgs { @@ -308,7 +427,7 @@ pub struct ConfigPushArgs { /// Print the would-be operations without performing them. #[arg(long)] pub dry_run: bool, - /// Override the default key — 5.4. + /// Override the default key. #[arg(long)] pub key: Option, /// Push to the adapter's local-emulator state instead of the live @@ -343,6 +462,15 @@ 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 (from + /// `provision`) points a staged version at, so an explicit key would be + /// written where nothing reads it. + #[arg(long, conflicts_with = "key")] + pub staging: bool, /// Logical config store id to push to. Defaults to the /// `[stores.config].default` (or the only declared id when /// `[stores.config].ids` has length 1). @@ -367,6 +495,7 @@ impl Default for ConfigPushArgs { no_diff: false, no_env: false, runtime_config: None, + staging: false, store: None, yes: false, } @@ -456,7 +585,7 @@ mod tests { #[test] fn provision_args_default_manifest_matches_clap_default() { - // PR #269 round 4 / F4: library callers using + // Library callers using // `ProvisionArgs { adapter: "...", ..Default::default() }` // must end up with `manifest = "edgezero.toml"`, matching // what clap writes when no `--manifest` is passed on the @@ -584,7 +713,7 @@ mod tests { #[test] fn config_validate_args_defaults() { - // Post-F4 (PR #269 round 4): library callers using + // Library callers using // `..Default::default()` now get the same `manifest` // value clap writes when no `--manifest` is passed // (`edgezero.toml`), instead of the empty-PathBuf the @@ -665,7 +794,255 @@ mod tests { .expect_err("`provision` without --adapter must error"); } - // ── config push / diff stub tests (12.8 + 12.11) ────────────────── + // ── Fastly staging lifecycle arg tests ───────────────────────────── + + #[test] + fn deploy_stage_flag_defaults_false() { + let args = Args::try_parse_from(["edgezero", "deploy", "--adapter", "fastly"]) + .expect("parse deploy"); + let Command::Deploy(deploy) = args.cmd else { + panic!("expected Command::Deploy"); + }; + assert!(!deploy.stage); + assert!(deploy.service_id.is_none()); + } + + #[test] + fn deploy_parses_service_id_and_stage() { + // Mirrors the Fastly staging lifecycle invocation: + // ` deploy --adapter fastly --service-id --stage`. + let args = Args::try_parse_from([ + "edgezero", + "deploy", + "--adapter", + "fastly", + "--service-id", + "SVC123", + "--stage", + ]) + .expect("parse deploy --service-id --stage"); + let Command::Deploy(deploy) = args.cmd else { + panic!("expected Command::Deploy"); + }; + assert!(deploy.stage); + assert_eq!(deploy.service_id.as_deref(), Some("SVC123")); + assert!(deploy.adapter_args.is_empty()); + } + + #[test] + fn deploy_service_id_and_stage_coexist_with_passthrough() { + let args = Args::try_parse_from([ + "edgezero", + "deploy", + "--adapter", + "fastly", + "--service-id", + "SVC123", + "--", + "--comment", + "ci build", + ]) + .expect("parse deploy with passthrough"); + let Command::Deploy(deploy) = args.cmd else { + panic!("expected Command::Deploy"); + }; + assert_eq!(deploy.service_id.as_deref(), Some("SVC123")); + assert!(!deploy.stage); + assert_eq!(deploy.adapter_args, vec!["--comment", "ci build"]); + } + + #[test] + fn healthcheck_parses_full_flags() { + let args = Args::try_parse_from([ + "edgezero", + "healthcheck", + "--adapter", + "fastly", + "--service-id", + "SVC123", + "--version", + "42", + "--domain", + "staging.example.com", + "--staging", + "--retry", + "7", + "--retry-delay", + "2", + "--timeout", + "15", + ]) + .expect("parse healthcheck"); + let Command::Healthcheck(hc) = args.cmd else { + panic!("expected Command::Healthcheck"); + }; + assert_eq!(hc.adapter, "fastly"); + assert_eq!(hc.service_id, "SVC123"); + assert_eq!(hc.version, "42"); + assert_eq!(hc.domain, "staging.example.com"); + assert!(hc.staging); + assert_eq!(hc.retry, 7); + assert_eq!(hc.retry_delay, 2); + assert_eq!(hc.timeout, 15); + } + + #[test] + fn healthcheck_defaults_retry_delay_timeout() { + let args = Args::try_parse_from([ + "edgezero", + "healthcheck", + "--adapter", + "fastly", + "--service-id", + "SVC123", + "--version", + "42", + "--domain", + "example.com", + ]) + .expect("parse healthcheck with defaults"); + let Command::Healthcheck(hc) = args.cmd else { + panic!("expected Command::Healthcheck"); + }; + assert!(!hc.staging); + assert_eq!(hc.retry, 3); + assert_eq!(hc.retry_delay, 5); + assert_eq!(hc.timeout, 10); + } + + #[test] + fn healthcheck_requires_adapter() { + Args::try_parse_from([ + "edgezero", + "healthcheck", + "--service-id", + "SVC123", + "--version", + "42", + "--domain", + "example.com", + ]) + .expect_err("`healthcheck` without --adapter must error"); + } + + #[test] + fn healthcheck_requires_service_id_version_and_domain() { + // Each required lifecycle input must be present; omitting any + // one is a parse error (deploy→healthcheck→rollback + // threading depends on them). + Args::try_parse_from([ + "edgezero", + "healthcheck", + "--adapter", + "fastly", + "--version", + "42", + "--domain", + "example.com", + ]) + .expect_err("missing --service-id must error"); + Args::try_parse_from([ + "edgezero", + "healthcheck", + "--adapter", + "fastly", + "--service-id", + "SVC123", + "--domain", + "example.com", + ]) + .expect_err("missing --version must error"); + Args::try_parse_from([ + "edgezero", + "healthcheck", + "--adapter", + "fastly", + "--service-id", + "SVC123", + "--version", + "42", + ]) + .expect_err("missing --domain must error"); + } + + #[test] + fn rollback_parses_flags() { + let args = Args::try_parse_from([ + "edgezero", + "rollback", + "--adapter", + "fastly", + "--service-id", + "SVC123", + "--version", + "42", + "--staging", + ]) + .expect("parse rollback"); + let Command::Rollback(rb) = args.cmd else { + panic!("expected Command::Rollback"); + }; + assert_eq!(rb.adapter, "fastly"); + assert_eq!(rb.service_id, "SVC123"); + assert_eq!(rb.version, "42"); + assert!(rb.staging); + } + + #[test] + fn rollback_staging_defaults_false() { + let args = Args::try_parse_from([ + "edgezero", + "rollback", + "--adapter", + "fastly", + "--service-id", + "SVC123", + "--version", + "9", + ]) + .expect("parse rollback without --staging"); + let Command::Rollback(rb) = args.cmd else { + panic!("expected Command::Rollback"); + }; + assert!(!rb.staging); + } + + #[test] + fn rollback_requires_adapter() { + Args::try_parse_from([ + "edgezero", + "rollback", + "--service-id", + "SVC123", + "--version", + "9", + ]) + .expect_err("`rollback` without --adapter must error"); + } + + #[test] + fn rollback_requires_service_id_and_version() { + Args::try_parse_from([ + "edgezero", + "rollback", + "--adapter", + "fastly", + "--version", + "9", + ]) + .expect_err("missing --service-id must error"); + Args::try_parse_from([ + "edgezero", + "rollback", + "--adapter", + "fastly", + "--service-id", + "SVC123", + ]) + .expect_err("missing --version must error"); + } + + // ── config push / diff stub tests ───────────────────────────────── /// Bundled binary: bare `config push` parses to the stub variant. /// The catch-all absorbs nothing; trailing is empty. @@ -707,7 +1084,7 @@ mod tests { assert!(matches!(args.cmd, Command::Config(ConfigCmd::Diff(_)))); } - /// 12.11 — `ConfigPushArgs` new flags: `--yes` / `-y` / `--no-diff` + /// `ConfigPushArgs` new flags: `--yes` / `-y` / `--no-diff` /// / `--dry-run` parse correctly on the *downstream* typed struct. /// (The bundled binary uses `ConfigCmdStubArgs`; these tests cover the /// struct fields directly via `Default` + mutation, since clap can only @@ -728,7 +1105,7 @@ mod tests { assert!(ConfigPushArgs::default().key.is_none()); } - // ── ConfigDiffArgs parser-roundtrip tests (12.11) ────────────────── + // ── ConfigDiffArgs parser-roundtrip tests ───────────────────────── /// Default `ConfigDiffArgs` has correct zero-values. /// KEEP as struct-literal sanity check — this is a Default-impl pin, diff --git a/crates/edgezero-cli/src/config.rs b/crates/edgezero-cli/src/config.rs index ef4ac6a4..07e21678 100644 --- a/crates/edgezero-cli/src/config.rs +++ b/crates/edgezero-cli/src/config.rs @@ -108,11 +108,11 @@ impl ValidationContext { /// Typed exit-code outcome of a successful `config diff` run. /// -/// Returned so the generated CLI's main can pick the right exit code -/// per Q10. NOT used on the error path — `Result::Err` bypasses this +/// Returned so the generated CLI's main can pick the right exit code. +/// NOT used on the error path — `Result::Err` bypasses this /// and the main always exits ≥ 2. /// -/// Exit code semantics (Q10): +/// Exit code semantics: /// - `0` — no changes, or `--exit-code` is false. /// - `1` — diff present AND `--exit-code` was set (CI gate signal). /// - `2` — diff structurally impossible (`Unsupported`). @@ -215,7 +215,7 @@ where let ctx = load_validation_context(args)?; run_shared_checks(&ctx)?; - // Typed deserialise + validate_excluding_secrets (spec 3.3.8: push, + // Typed deserialise + validate_excluding_secrets (push, // diff, AND typed validate all use deserialize-only + // validate_excluding_secrets; the runtime is the only path that runs // full validate against RESOLVED secret values). @@ -244,7 +244,7 @@ where // ------------------------------------------------------------------- /// Stub-pointer for the bundled `edgezero` binary's `config push` -/// subcommand (spec 3.2.1). +/// subcommand. /// /// The blob app-config rewrite requires a TYPED downstream CLI that /// embeds the app's `AppConfig` struct. The bundled `edgezero` @@ -254,13 +254,13 @@ where /// [`run_config_push_typed`] with their concrete `C`. /// /// This function always returns `Err(...)` with a pointer to the -/// typed downstream CLI and the spec section. The subcommand itself +/// typed downstream CLI. The subcommand itself /// must still be registered in the bundled binary's `Command` enum so /// `edgezero config push --help` is reachable and the error message /// can be displayed. /// /// # Errors -/// Always returns a pointer error explaining 3.2.1. +/// Always returns a pointer error. #[inline] pub fn run_config_push(_args: &ConfigPushArgs) -> Result<(), String> { Err( @@ -311,13 +311,10 @@ where push_ctx: &push_ctx, }; - // Build envelope. - // Honour --key override (5.4): if the caller supplied an explicit key, - // use it; otherwise fall back to the manifest's resolved logical store id. - let key = args - .key - .clone() - .unwrap_or_else(|| ctx.store.logical.clone()); + // 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)?; let body = build_config_envelope::(&typed)?; let local_envelope: BlobEnvelope = serde_json::from_str(&body).map_err(|err| format!("local envelope parse failed: {err}"))?; @@ -329,6 +326,10 @@ where match render_first_read_diff(&remote, &key, &local_envelope, &local_sha, args.no_diff)? { FirstReadOutcome::NoChange => { push_info(&format!("# no changes (sha256 matches: {local_sha})")); + // A no-op push still SUCCEEDED at putting `key` in the store, so + // it reports the same outputs a writing push does. Config push is + // idempotent; re-running it must not fail the caller. + emit_push_outputs(args, &key, &ctx.store.logical); return Ok(()); } FirstReadOutcome::ProceedFromPresent { @@ -337,7 +338,7 @@ where FirstReadOutcome::ProceedFromMissingOrUnsupported => None, }; - // Consent gate (8.2 default or 8.3 Spin Cloud Unsupported). + // Consent gate (default flow or Spin Cloud Unsupported). handle_consent(args, &remote)?; // Pre-write re-fetch + skip-on-equal + concurrent-push detection. @@ -352,13 +353,39 @@ where &remote, approved_remote_sha.as_deref(), )? { - RecheckOutcome::Skip => return Ok(()), + RecheckOutcome::Skip => { + // A concurrent push already landed the same content — the store + // holds what we wanted, so this is a success with the same + // outputs, not a silent no-output exit. + emit_push_outputs(args, &key, &ctx.store.logical); + return Ok(()); + } RecheckOutcome::Write => {} } } // Write. - write_envelope(ctx.adapter, args, &ctx, &paths, &key, body) + write_envelope(ctx.adapter, args, &ctx, &paths, &key, body)?; + emit_push_outputs(args, &key, &ctx.store.logical); + Ok(()) +} + +/// Emit the canonical machine-readable lines the config-push-fastly action greps +/// (`^pushed-key=$` / `^pushed-store=$`) to thread the written key and +/// the resolved store out as outputs. +/// +/// Every SUCCESSFUL push emits these — including the idempotent no-op paths, +/// where the store already holds the desired content. The action treats their +/// absence as a failure, so a push that changed nothing must still report what +/// key it put there. The store must come from here: the wrapper only has the +/// optional raw input, which is empty on the default path where the id is +/// resolved from the manifest. The CLI logger routes `log::info!` to stdout, so +/// these reach the action's log. +fn emit_push_outputs(args: &ConfigPushArgs, key: &str, store: &str) { + if !args.dry_run { + log::info!("pushed-key={key}"); + log::info!("pushed-store={store}"); + } } // ------------------------------------------------------------------- @@ -375,8 +402,7 @@ fn diff_info(msg: &str) { eprintln!("{msg}"); } -/// Translate an outcome + `--exit-code` flag into a typed exit code -/// per Q10's table. +/// Translate an outcome + `--exit-code` flag into a typed exit code. fn apply_exit_code(exit_code_flag: bool, outcome: DiffOutcome) -> DiffExit { let code = match (exit_code_flag, outcome) { (_, DiffOutcome::Unsupported) => 2_i32, @@ -408,7 +434,7 @@ pub fn run_config_diff_typed(args: &ConfigDiffArgs) -> Result Result<(), String> { if let ReadConfigEntry::Unsupported(reason) = remote { if args.dry_run { @@ -797,7 +824,7 @@ fn render_first_read_diff( } } -/// Consent gate for 8.2 default flow (non-Spin-Cloud adapters and all +/// Consent gate for the default flow (non-Spin-Cloud adapters and all /// read-capable variants). `--yes` or `--dry-run` bypass the prompt. /// TTY: prompt. Non-TTY without `--yes`: error. fn require_consent(args: &ConfigPushArgs, _read: &ReadConfigEntry) -> Result<(), String> { @@ -922,6 +949,8 @@ fn write_envelope( for line in lines { log::info!("{line}"); } + // The canonical `pushed-*` lines are emitted by the caller, so that the + // idempotent no-op paths (which never reach this function) report them too. Ok(()) } @@ -1028,7 +1057,7 @@ pub(crate) fn print_unified_diff_inline( // ------------------------------------------------------------------- fn load_push_context(args: &ConfigPushArgs) -> Result { - // Spec: push is strict — the synthesized validate args + // Push is strict — the synthesized validate args // unconditionally request `--strict` so `run_shared_checks` // runs the capability-completeness + handler-path checks // alongside the schema and per-adapter shared checks. @@ -1076,6 +1105,33 @@ fn resolve_adapter_push_ctx( } } +/// Derive the config-store key a push or diff targets. +/// +/// `--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: `provision` 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. +fn resolve_config_key( + explicit: Option<&str>, + logical: &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 — written by `provision` — 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." + )), + (Some(key), false) => Ok(key.to_owned()), + (None, false) => Ok(logical.to_owned()), + (None, true) => Ok(format!("{logical}_staging")), + } +} + fn resolve_config_store_id(requested: Option<&str>, manifest: &Manifest) -> Result { let Some(declaration) = manifest.stores.config.as_ref() else { return Err( @@ -1114,7 +1170,7 @@ fn resolved_default(declaration: &StoreDeclaration) -> String { /// resolved secret value. The runtime extractor /// (`crates/edgezero-core/src/extractor.rs`, `secret_walk`) reads those /// key names from `data` and swaps each one for the resolved secret value -/// from the appropriate secret store. Spec 3.3 (secret-key NAMES at rest). +/// from the appropriate secret store (secret-key NAMES at rest). /// /// `generated_at` is stamped with the current UTC second. /// @@ -1128,7 +1184,7 @@ where // and #[secret(store_ref)] fields, whose value at rest is the // operator-supplied key NAME (e.g. "demo_api_token"). The runtime // extractor reads those key names from data and swaps each one for - // the resolved secret value. Spec 3.3 (secret-key NAMES at rest). + // the resolved secret value (secret-key NAMES at rest). let data: serde_json::Value = serde_json::to_value(typed) .map_err(|err| format!("failed to serialise typed config: {err}"))?; let envelope = BlobEnvelope::new(data, generated_at_rfc3339()); @@ -1136,7 +1192,7 @@ where } /// Current UTC timestamp formatted as RFC 3339 with second precision and -/// a trailing `Z` (`2026-06-17T18:42:31Z`). Matches spec 4.1 example. +/// a trailing `Z` (`2026-06-17T18:42:31Z`). /// `generated_at` is informational only — it is NOT part of the SHA. fn generated_at_rfc3339() -> String { chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true) @@ -1146,7 +1202,7 @@ fn load_validation_context(args: &ConfigValidateArgs) -> Result) { // ------------------------------------------------------------------- fn strict_capability_completeness(manifest: &Manifest) -> Result<(), String> { - // Spec capability matrix, driven by each adapter crate's + // Capability matrix, driven by each adapter crate's // `Adapter::single_store_kinds()` impl. Adapters not in the // registry (e.g. a feature-gated build that omitted some) are // skipped — we can't speak for what isn't linked. @@ -1781,6 +1837,7 @@ source = "target/wasm32-wasip2/release/demo.wasm" no_env: true, runtime_config: None, store: None, + staging: false, yes: false, } } @@ -1794,6 +1851,33 @@ source = "target/wasm32-wasip2/release/demo.wasm" run_config_validate(&args_for(&manifest)).expect("valid project passes"); } + #[test] + fn resolve_config_key_covers_key_and_staging_combinations() { + // Production: the logical id, or an explicit --key verbatim. + assert_eq!( + resolve_config_key(None, "app_config", false).unwrap(), + "app_config" + ); + assert_eq!( + resolve_config_key(Some("custom"), "app_config", false).unwrap(), + "custom" + ); + // Staging: the `_staging` variant the selector store points at. + assert_eq!( + resolve_config_key(None, "app_config", true).unwrap(), + "app_config_staging" + ); + // --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) + .expect_err("--key with --staging must be rejected"); + assert!( + err.contains("--staging") && err.contains("app_config_staging"), + "the error must explain the derivation: {err}" + ); + } + #[test] fn raw_errors_on_unknown_manifest_path() { let _lock = manifest_guard().lock().expect("manifest guard"); @@ -1941,7 +2025,7 @@ serve = "echo" ); } - /// High 2 — spec 3.3.8: typed validate uses `validate_excluding_secrets`, + /// Typed validate uses `validate_excluding_secrets`, /// so a `#[secret]` field annotated with `length(min = 32)` must NOT /// reject a short key name like `"short_key"` (9 bytes). The runtime /// resolves it to the real secret value and runs the full validator there. @@ -2322,7 +2406,7 @@ ids = ["default"] #[test] fn spin_distinct_logical_ids_collide_when_env_overlay_resolves_to_same_platform_label() { - // F2: distinct logical ids `sessions` (KV) and `app_config` + // Distinct logical ids `sessions` (KV) and `app_config` // (Config) BOTH map to the same Spin KV label via the env // overlay. The runtime opens one underlying store for both // -- silent shared writes. The merged-id check must catch @@ -2712,12 +2796,12 @@ deep = true } // ------------------------------------------------------------------- - // config push (raw + typed) — spec + // config push (raw + typed) // ------------------------------------------------------------------- - // ---------- raw push (stub-pointer, spec 3.2.1) ---------- + // ---------- raw push (stub-pointer) ---------- - /// Spec 3.2.1: `config push` on the bundled `edgezero` binary is a + /// `config push` on the bundled `edgezero` binary is a /// stub-pointer. The blob app-config rewrite requires a typed downstream /// CLI; the bundled binary has no `AppConfig` in scope. The subcommand /// must always return `Err(...)` with a pointer to the typed downstream CLI. @@ -2770,7 +2854,7 @@ deep = true // `vault` (#[secret(store_ref)]) — must be PRESENT in envelope.data. // Their value at rest is the operator-supplied key NAME, not the // resolved secret value. The runtime extractor (`secret_walk`) reads - // those key names and swaps them for the resolved secret. Spec 3.3. + // those key names and swaps them for the resolved secret. let data = &envelope.data; assert_eq!( data.get("api_token").and_then(|val| val.as_str()), @@ -2800,7 +2884,7 @@ deep = true // object is deterministic. // All fields are present verbatim — secret key names included. - // Spec 3.3: secret-field VALUES at rest are the operator-supplied + // Secret-field VALUES at rest are the operator-supplied // key NAMEs (not the resolved secret values). let data = serde_json::json!({ "api_token": "demo_api_token", @@ -2829,7 +2913,7 @@ deep = true ); } - /// Spec 3.3: the blob MUST carry the secret key NAME verbatim. + /// The blob MUST carry the secret key NAME verbatim. /// The runtime extractor (`secret_walk`) reads that name to look up /// the resolved value in the secret store. Stripping the field would /// cause `ConfigOutOfDate` on every request after a push. @@ -2957,7 +3041,7 @@ timeout_ms = 50 ); } - /// 5.4: `--key` overrides the default logical store id used as the + /// `--key` overrides the default logical store id used as the /// blob key. Without `--key`, the written file is keyed by the /// manifest's `[stores.config]` id (`"app_config"`). With /// `--key staging`, the blob must appear under "staging" instead. @@ -3004,7 +3088,7 @@ timeout_ms = 50 // ---------- push runs the strict preflight (regression) ---------- /// Push must run the same shared adapter checks `config - /// validate` runs (spec strict pre-flight). Pre-fix, + /// validate` runs (strict pre-flight). Pre-fix, /// `load_push_context` synthesised `ConfigValidateArgs { strict: /// false }` and `run_config_push*` never called /// `run_shared_checks`, so an adapter-specific shape error @@ -3117,7 +3201,7 @@ default = "one" } // ------------------------------------------------------------------- - // run_config_push_typed — 8.2 consent rules + diff + // run_config_push_typed — consent rules + diff // ------------------------------------------------------------------- /// Build a valid `BlobEnvelope` JSON string for the given data, suitable @@ -3146,7 +3230,7 @@ default = "one" // ---------- deserialise + validate_excluding_secrets ---------- - /// 3.3.8 rule: a `#[secret]` field's VALUE is a key name like + /// A `#[secret]` field's VALUE is a key name like /// "my-prod-api-key", which may be shorter than a `length(min = 32)` /// rule intended for the resolved runtime value. The old /// `load_app_config_with_options` path validated the key name against @@ -3227,7 +3311,7 @@ ids = ["default"] .expect("second push with same content must exit Ok via skip-on-equal"); } - // ---------- 8.2 consent gate ---------- + // ---------- consent gate ---------- #[test] fn c4_non_tty_without_yes_errors_on_consent() { @@ -3309,12 +3393,12 @@ ids = ["default"] .expect("--no-diff --yes typed push must succeed"); } - // ---------- 8.3 Spin Cloud Unsupported four-branch UX ---------- + // ---------- Spin Cloud Unsupported four-branch UX ---------- // // The Spin adapter returns ReadConfigEntry::Unsupported when the // deploy command targets Fermyon Cloud ("spin deploy" / "spin cloud - // deploy"). We use that to exercise the four-branch UX defined in - // spec 8.3. The manifest uses `deploy = "spin deploy"` to trigger + // deploy"). We use that to exercise the four-branch Unsupported + // UX. The manifest uses `deploy = "spin deploy"` to trigger // the Fermyon Cloud detection path inside `read_config_entry`. // --- PATH-mutation helpers (mirrors Cloudflare adapter test pattern) --- @@ -3357,7 +3441,7 @@ ids = ["default"] tmp } - /// Manifest fixture for the 8.3 tests: Spin adapter with a Fermyon + /// Manifest fixture for the Unsupported tests: Spin adapter with a Fermyon /// Cloud deploy command so `read_config_entry` returns `Unsupported`. fn spin_cloud_manifest() -> String { r#" @@ -3392,14 +3476,14 @@ ids = ["default"] } /// Non-TTY + no --yes + Spin Cloud (Unsupported) must error with the - /// 8.3 non-interactive message. CI has no TTY; no --yes is passed. + /// non-interactive message. CI has no TTY; no --yes is passed. #[test] fn c4_unsupported_non_tty_without_yes_errors() { let _lock = manifest_guard().lock().expect("manifest guard"); let (dir, manifest_path, _) = setup_project(&spin_cloud_manifest(), FIXTURE_APP_CONFIG); write_minimal_spin_toml(dir.path()); let args = push_args(&manifest_path, "spin"); - // No --yes, no TTY: must error with the 8.3 non-interactive error. + // No --yes, no TTY: must error with the non-interactive error. let err = run_config_push_typed::(&args) .expect_err("Unsupported + non-TTY + no --yes must error"); assert!( @@ -3409,7 +3493,7 @@ ids = ["default"] } /// `--dry-run` against Spin Cloud (Unsupported) must error immediately - /// with the 8.3 dry-run message — the flag's contract is "show the + /// with the dry-run message — the flag's contract is "show the /// diff", which is structurally impossible without a remote read-back. #[test] fn c4_unsupported_dry_run_errors_with_spin_cloud_message() { @@ -3620,10 +3704,10 @@ ids = ["default"] } // ------------------------------------------------------------------- - // High — diff runs run_shared_checks (adapter manifest + collision) + // diff runs run_shared_checks (adapter manifest + collision) // ------------------------------------------------------------------- - /// High — spec 3.3.2: `run_config_diff_typed` must run + /// `run_config_diff_typed` must run /// `run_shared_checks` (which includes `validate_adapter_manifest`) /// before reaching the remote-read step. A broken Spin /// `spin.toml` (no `[component.*]` sections) triggers @@ -3670,6 +3754,7 @@ ids = ["default"] no_env: true, runtime_config: None, store: None, + staging: false, }; let err = run_config_diff_typed::(&diff_args) .expect_err("missing [component.*] must fail Spin's shared-check preflight in diff"); @@ -3680,7 +3765,7 @@ ids = ["default"] } // ------------------------------------------------------------------- - // Medium 1 — diff runs typed_secret_checks + adapter_typed_checks + // diff runs typed_secret_checks + adapter_typed_checks // ------------------------------------------------------------------- /// A NESTED `#[secret]` that is present but empty must be caught by the @@ -3748,6 +3833,7 @@ ids = ["default"] no_env: true, runtime_config: None, store: None, + staging: false, }; // The nested empty secret must be rejected by the path-aware // typed_secret_checks before the remote-read step, naming the path. @@ -3759,7 +3845,7 @@ ids = ["default"] ); } - /// Medium 1 — spec 3.3.2: `run_config_diff_typed` must run the same + /// `run_config_diff_typed` must run the same /// structural checks as push, including `typed_secret_checks`. A /// `#[secret]` field that is present but empty must be rejected even /// on a read-only diff operation. @@ -3819,6 +3905,7 @@ ids = ["default"] no_env: true, runtime_config: None, store: None, + staging: false, }; // typed_secret_checks must catch the empty `#[secret]` field // before the function reaches the remote-read step. diff --git a/crates/edgezero-cli/src/generator.rs b/crates/edgezero-cli/src/generator.rs index 720d8243..e3123212 100644 --- a/crates/edgezero-cli/src/generator.rs +++ b/crates/edgezero-cli/src/generator.rs @@ -1340,16 +1340,20 @@ mod tests { let main_path = project_dir.join("crates/demo-app-cli/src/main.rs"); let main = fs::read_to_string(&main_path).expect("read cli main.rs"); - // Imports — every args type the seven-command Cmd enum - // references must be in scope. + // Imports — every args type the Cmd enum references must be in scope, + // including the Fastly lifecycle commands (active-version / healthcheck / + // rollback) a deployment workflow drives. for import in [ + "ActiveVersionArgs", "AuthArgs", "BuildArgs", "ConfigPushArgs", "ConfigValidateArgs", "DeployArgs", + "HealthcheckArgs", "NewArgs", "ProvisionArgs", + "RollbackArgs", "ServeArgs", ] { assert!( @@ -1366,14 +1370,19 @@ mod tests { "-cli must import the typed config via the underscored core module name: {main}" ); - // Cmd variants — all seven plus the nested ConfigCmd. + // Cmd variants — plus the nested ConfigCmd. The Fastly lifecycle + // commands must be present so a generated app can be driven by the + // deploy actions (capture/healthcheck/rollback). for variant in [ + "ActiveVersion(ActiveVersionArgs)", "Auth(AuthArgs)", "Build(BuildArgs)", "Config(DemoAppConfigCmd)", "Deploy(DeployArgs)", + "Healthcheck(HealthcheckArgs)", "New(NewArgs)", "Provision(ProvisionArgs)", + "Rollback(RollbackArgs)", "Serve(ServeArgs)", ] { assert!( @@ -1389,6 +1398,11 @@ mod tests { "run_config_validate_typed::", "edgezero_cli::run_auth", "edgezero_cli::run_provision", + // The rollback-target capture step invokes this on the app CLI; a + // missing dispatch would fail every production deploy's capture. + "edgezero_cli::run_active_version", + "edgezero_cli::run_healthcheck", + "edgezero_cli::run_rollback", ] { assert!( main.contains(call), diff --git a/crates/edgezero-cli/src/lib.rs b/crates/edgezero-cli/src/lib.rs index 68eac9d8..a9459926 100644 --- a/crates/edgezero-cli/src/lib.rs +++ b/crates/edgezero-cli/src/lib.rs @@ -55,7 +55,9 @@ pub use config::{ pub use provision::run_provision; #[cfg(feature = "cli")] -use args::{BuildArgs, DeployArgs, NewArgs, ServeArgs}; +use args::{ + ActiveVersionArgs, BuildArgs, DeployArgs, HealthcheckArgs, NewArgs, RollbackArgs, ServeArgs, +}; #[cfg(feature = "cli")] use edgezero_core::manifest::ManifestLoader; #[cfg(feature = "cli")] @@ -160,11 +162,317 @@ pub fn run_build(args: &BuildArgs) -> Result<(), String> { 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.stage { + adapter::Action::DeployStaged + } else { + adapter::Action::Deploy + }; + + 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.stage { + // 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( &args.adapter, adapter::Action::Deploy, manifest.as_ref(), - &args.adapter_args, + &passthrough, + ) +} + +/// Parse an activated service version out of a deploy command's output. +/// +/// Precedence: +/// 1. A canonical `version=` line (what a manifest +/// `[adapters.fastly.commands].deploy` override — or a CI fixture — +/// emits, and what `EdgeZero` itself prints). +/// 2. Fastly's native phrasing, e.g. +/// `SUCCESS: Deployed package (service abc, version 12)`. The LAST +/// mention wins, which is the version the deploy ended on. +/// +/// Returns `None` when neither shape is present, which sends the caller +/// to the Fastly API fallback. +#[cfg(feature = "cli")] +fn parse_deploy_version(output: &str) -> Option { + parse_canonical_version_line(output).or_else(|| parse_native_version_mention(output)) +} + +/// Last `version=` line in `output` (leading/trailing whitespace on +/// the line is ignored). +/// +/// FAIL CLOSED: the whole value after `version=` must be ASCII digits. +/// A `take_while(is_ascii_digit)` prefix scan would read `version=15.2.0` +/// as `15` and `version=12abc` as `12`, threading a WRONG version into +/// healthcheck / rollback. `None` sends the caller to the Fastly API +/// fallback (the version the deploy actually activated) instead. +#[cfg(feature = "cli")] +fn parse_canonical_version_line(output: &str) -> Option { + output.lines().rev().find_map(|line| { + let digits = line.trim().strip_prefix("version=")?; + if digits.is_empty() || !digits.chars().all(|ch| ch.is_ascii_digit()) { + return None; + } + digits.parse::().ok() + }) +} + +/// Last `, version )` mention in `output` (case-insensitive) — the +/// Fastly CLI's own success line, whose Go format string is +/// `"Deployed package (service %s, version %v)"`. +/// +/// Deliberately narrow: it previously accepted ANY digits appearing +/// after the word "version", so `Fastly CLI version 15.2.0` or +/// `... service 12345, version unchanged` parsed as a service version. +/// A misparse here emits a WRONG `version=` line, which the deploy → +/// healthcheck → rollback chain would then act on. When this returns +/// `None`, `run_deploy` falls back to the Fastly API's *active* version +/// (the version the deploy actually activated) rather than guessing. +#[cfg(feature = "cli")] +fn parse_native_version_mention(output: &str) -> Option { + let lower = output.to_ascii_lowercase(); + let mut result = None; + for (idx, _) in lower.match_indices(", version ") { + let after = idx.saturating_add(", version ".len()); + let Some(rest) = lower.get(after..) else { + continue; + }; + let digits: String = rest.chars().take_while(char::is_ascii_digit).collect(); + // The number must be closed by the success line's `)`. + if digits.is_empty() || rest.chars().nth(digits.len()) != Some(')') { + continue; + } + if let Ok(parsed) = digits.parse::() { + result = Some(parsed); + } + } + result +} + +/// Resolve the absolute path of the adapter's platform manifest +/// (`[adapters..adapter].manifest`) relative to the manifest +/// root. Returns `None` when there is no loaded manifest, no root, no +/// entry for the adapter, or no `manifest` key. Used by the Fastly +/// staged deploy to target the operator-selected app in a monorepo. +#[cfg(feature = "cli")] +fn resolve_adapter_manifest_path(loader: Option<&ManifestLoader>, adapter: &str) -> Option { + let manifest = loader?.manifest(); + let root = manifest.root()?; + let (_canonical, cfg) = manifest.adapter_entry(adapter)?; + let rel = cfg.adapter.manifest.as_deref()?; + Some(root.join(rel).to_string_lossy().into_owned()) +} + +/// Probe a deployed version's health (Fastly staging lifecycle) +/// and return `Err` when the probe is unhealthy after retries so +/// the process exits non-zero (letting a CI caller gate rollback on +/// failure). +/// +/// # Errors +/// +/// Returns an error if the manifest cannot be loaded, the adapter is +/// not configured / registered, the adapter does not support +/// healthchecks, or the probe is unhealthy after all retries. +#[cfg(feature = "cli")] +#[inline] +pub fn run_healthcheck(args: &HealthcheckArgs) -> Result<(), String> { + // Manifest-independent, like `active-version`: a pure API/curl probe keyed on + // explicit flags, never a manifest-command override. Not loading the manifest + // keeps it correct regardless of the current directory (monorepo safety). + let mut passthrough: Vec = vec![ + "--service-id".to_owned(), + args.service_id.clone(), + "--version".to_owned(), + args.version.clone(), + "--domain".to_owned(), + args.domain.clone(), + "--path".to_owned(), + args.path.clone(), + ]; + if args.staging { + passthrough.push("--staging".to_owned()); + } + passthrough.extend([ + "--retry".to_owned(), + args.retry.to_string(), + "--retry-delay".to_owned(), + args.retry_delay.to_string(), + "--timeout".to_owned(), + args.timeout.to_string(), + ]); + adapter::execute( + &args.adapter, + adapter::Action::Healthcheck, + None, + &passthrough, + ) +} + +/// Roll a service back (Fastly staging lifecycle): +/// production activates the previous version; staging deactivates the +/// staged version. +/// +/// # Errors +/// +/// Returns an error if the manifest cannot be loaded, the adapter is +/// not configured / registered, the adapter does not support +/// rollback, or the rollback API call fails. +#[cfg(feature = "cli")] +#[inline] +pub fn run_rollback(args: &RollbackArgs) -> Result<(), String> { + // Manifest-independent, like `active-version` / `healthcheck`: a pure API + // operation keyed on explicit flags, never a manifest-command override. + let mut passthrough: Vec = vec![ + "--service-id".to_owned(), + args.service_id.clone(), + "--version".to_owned(), + args.version.clone(), + ]; + if args.staging { + passthrough.push("--staging".to_owned()); + } else if let Some(target) = args.rollback_to.as_deref() { + // Production activates an EXPLICIT target — Fastly has no field to infer + // a previously-live version from a staged one, so the caller passes the + // version captured before the superseding deploy. + passthrough.push("--rollback-to".to_owned()); + passthrough.push(target.to_owned()); + } else { + return Err( + "a production rollback requires --rollback-to (the version to re-activate). Fastly \ + exposes no metadata to infer it, so it must be captured before the deploy that \ + superseded it -- use `deploy`'s `previous-version` output. Pass --staging to \ + deactivate a staged version instead." + .to_owned(), + ); + } + adapter::execute(&args.adapter, adapter::Action::Rollback, None, &passthrough) +} + +/// Resolve and print the currently-active service version as `version=`. +/// +/// Captured BEFORE a deploy so it can be threaded to a later production +/// rollback — Fastly's version list has no field to infer a previously-live +/// version afterward. +/// +/// # Errors +/// +/// Returns an error if the manifest cannot be loaded, the adapter is not +/// configured, or the active version cannot be resolved. +#[inline] +pub fn run_active_version(args: &ActiveVersionArgs) -> Result<(), String> { + // No manifest load: `active-version` is a pure Fastly-API operation keyed on + // `--adapter` + `--service-id`, and `EmitVersion` can never be a + // manifest-command override (see `adapter::manifest_command`). Loading the + // manifest would only couple it to the current directory — breaking it in a + // monorepo where a stray root `edgezero.toml` shadows the app's. The adapter + // registry still validates `--adapter`. + adapter::execute( + &args.adapter, + adapter::Action::EmitVersion, + None, + &["--service-id".to_owned(), args.service_id.clone()], ) } @@ -350,6 +658,128 @@ mod tests { assert!(manifest.manifest().adapters.contains_key("fastly")); } + // ── deploy-output version parsing ───────────────────────────────── + + #[test] + fn parse_deploy_version_reads_canonical_line() { + // What a manifest `[adapters.fastly.commands].deploy` override + // (or a CI fixture running with dummy creds) emits. Must be + // parsed WITHOUT any Fastly API round-trip. + let output = "building...\nversion=7\ndone\n"; + assert_eq!(parse_deploy_version(output), Some(7)); + } + + #[test] + fn parse_deploy_version_reads_fastly_native_phrasing() { + let output = "SUCCESS: Deployed package (service abc123, version 12)\n"; + assert_eq!(parse_deploy_version(output), Some(12)); + } + + #[test] + fn parse_deploy_version_none_when_absent_triggers_fallback() { + // No version anywhere -> `None`, which routes run_deploy to the + // Fastly API fallback (and to a clear Err if that also fails). + let output = "Building package...\nUploading...\nAll good.\n"; + assert_eq!(parse_deploy_version(output), None); + assert_eq!(parse_deploy_version(""), None); + } + + #[test] + fn parse_deploy_version_prefers_canonical_over_native_mention() { + // A fixture that both narrates a clone AND emits the canonical + // line: the canonical line is authoritative. + let output = "Cloning version 3...\nversion=9\n"; + assert_eq!(parse_deploy_version(output), Some(9)); + } + + #[test] + fn parse_deploy_version_native_takes_last_success_line() { + let output = "SUCCESS: Deployed package (service abc, version 3)\n\ + SUCCESS: Deployed package (service abc, version 4)\n"; + assert_eq!(parse_deploy_version(output), Some(4)); + } + + #[test] + fn parse_deploy_version_rejects_confusable_mentions() { + // Loose `version ` narration is NOT a service version. Each of + // these used to parse (and would have emitted a wrong `version=` + // for healthcheck/rollback to act on). `None` routes run_deploy to + // the Fastly API's *active* version instead — the safe answer. + assert_eq!(parse_deploy_version("Fastly CLI version 15.2.0\n"), None); + assert_eq!( + parse_deploy_version("Uploaded to service 12345, version unchanged\n"), + None + ); + assert_eq!( + parse_deploy_version("Cloning version 3... created version 4\n"), + None + ); + } + + #[test] + fn parse_deploy_version_rejects_malformed_canonical_lines() { + // The canonical-line parser must be FAIL CLOSED: a prefix scan + // (`take_while(is_ascii_digit)`) read `version=15.2.0` as 15 and + // `version=12abc` as 12, threading a WRONG version into + // healthcheck / rollback. `None` routes run_deploy to the Fastly + // API fallback instead. + assert_eq!(parse_deploy_version("version=15.2.0\n"), None); + assert_eq!(parse_deploy_version("version=12abc\n"), None); + assert_eq!(parse_deploy_version("version=\n"), None); + // A well-formed line is still accepted (leading zeros included). + assert_eq!(parse_deploy_version("version=007\n"), Some(7)); + } + + #[cfg(not(windows))] + #[test] + fn run_deploy_manifest_command_forwards_adapter_args_verbatim() { + // 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 temp = TempDir::new().expect("temp dir"); + 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(), + adapter_args: vec!["--non-interactive".to_owned()], + service_id: Some("SVC1".to_owned()), + stage: 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" + ); + } + #[test] fn ensure_adapter_defined_accepts_known_adapter() { let loader = ManifestLoader::load_from_str(BASIC_MANIFEST); @@ -397,6 +827,11 @@ mod tests { let args = DeployArgs { adapter: "fastly".to_owned(), adapter_args: Vec::new(), + // No service id → the production version-emit step is + // skipped, so this test exercises only the + // manifest `deploy` command path. + service_id: None, + stage: false, }; run_deploy(&args).expect("deploy command runs"); } diff --git a/crates/edgezero-cli/src/main.rs b/crates/edgezero-cli/src/main.rs index f94fe817..f194db03 100644 --- a/crates/edgezero-cli/src/main.rs +++ b/crates/edgezero-cli/src/main.rs @@ -31,7 +31,10 @@ fn main() { Command::Deploy(cmd_args) => edgezero_cli::run_deploy(&cmd_args), #[cfg(feature = "demo-example")] Command::Demo => edgezero_cli::run_demo(), + Command::Healthcheck(cmd_args) => edgezero_cli::run_healthcheck(&cmd_args), + Command::ActiveVersion(cmd_args) => edgezero_cli::run_active_version(&cmd_args), Command::New(cmd_args) => edgezero_cli::run_new(&cmd_args), + Command::Rollback(cmd_args) => edgezero_cli::run_rollback(&cmd_args), Command::Provision(cmd_args) => edgezero_cli::run_provision(&cmd_args), Command::Serve(cmd_args) => edgezero_cli::run_serve(&cmd_args), }; diff --git a/crates/edgezero-cli/src/templates/cli/src/main.rs.hbs b/crates/edgezero-cli/src/templates/cli/src/main.rs.hbs index 3586a187..cca6c849 100644 --- a/crates/edgezero-cli/src/templates/cli/src/main.rs.hbs +++ b/crates/edgezero-cli/src/templates/cli/src/main.rs.hbs @@ -13,13 +13,13 @@ use clap::{Parser, Subcommand}; use edgezero_cli::DiffExit; use edgezero_cli::args::{ - AuthArgs, BuildArgs, ConfigDiffArgs, ConfigPushArgs, ConfigValidateArgs, DeployArgs, NewArgs, - ProvisionArgs, ServeArgs, + ActiveVersionArgs, AuthArgs, BuildArgs, ConfigDiffArgs, ConfigPushArgs, ConfigValidateArgs, + DeployArgs, HealthcheckArgs, NewArgs, ProvisionArgs, RollbackArgs, ServeArgs, }; use {{proj_core_mod}}::config::{{NameUpperCamel}}Config; #[derive(Parser, Debug)] -#[command(name = "{{proj_cli}}", about = "{{name}} edge CLI")] +#[command(name = "{{proj_cli}}", version, about = "{{name}} edge CLI")] struct Args { #[command(subcommand)] cmd: Cmd, @@ -27,8 +27,12 @@ struct Args { #[derive(Subcommand, Debug)] enum Cmd { + /// Resolve and print the currently-active service version + /// (`version=`) -- capture a production rollback target before a + /// deploy supersedes it. + ActiveVersion(ActiveVersionArgs), /// Sign in / out / status against the adapter's native CLI - /// (`wrangler` / `fastly` / `spin`). See spec. + /// (`wrangler` / `fastly` / `spin`). Auth(AuthArgs), /// Build the project for a target edge. Build(BuildArgs), @@ -37,11 +41,17 @@ enum Cmd { Config({{NameUpperCamel}}ConfigCmd), /// Deploy to a target edge. Deploy(DeployArgs), + /// Probe a deployed version's health (Fastly staging lifecycle). + /// Exits non-zero when unhealthy after retries. + Healthcheck(HealthcheckArgs), /// Create a new `EdgeZero` app skeleton. New(NewArgs), /// Create the platform resources backing the declared /// `[stores.].ids`. Provision(ProvisionArgs), + /// Roll a service back to a previous version, or deactivate a + /// staged version (Fastly staging lifecycle). + Rollback(RollbackArgs), /// Run a local simulation (adapter-specific). Serve(ServeArgs), } @@ -60,7 +70,7 @@ enum {{NameUpperCamel}}ConfigCmd { Diff(ConfigDiffArgs), /// Push `{{name}}.toml` as a single blob envelope to the /// adapter's config store. The blob carries every field verbatim - /// (per spec 3.3 Model A -- `#[secret]` fields store the key NAME, + /// (Model A -- `#[secret]` fields store the key NAME, /// resolved at runtime); a SHA over the canonical-form data gates /// drift detection. Push(ConfigPushArgs), @@ -74,6 +84,7 @@ fn main() { edgezero_cli::init_cli_logger(); let result: Result<(), String> = match Args::parse().cmd { + Cmd::ActiveVersion(args) => edgezero_cli::run_active_version(&args), Cmd::Auth(args) => edgezero_cli::run_auth(&args), Cmd::Build(args) => edgezero_cli::run_build(&args), Cmd::Config({{NameUpperCamel}}ConfigCmd::Diff(args)) => { @@ -94,14 +105,16 @@ fn main() { edgezero_cli::run_config_validate_typed::<{{NameUpperCamel}}Config>(&args) } Cmd::Deploy(args) => edgezero_cli::run_deploy(&args), + Cmd::Healthcheck(args) => edgezero_cli::run_healthcheck(&args), Cmd::New(args) => edgezero_cli::run_new(&args), Cmd::Provision(args) => edgezero_cli::run_provision(&args), + Cmd::Rollback(args) => edgezero_cli::run_rollback(&args), Cmd::Serve(args) => edgezero_cli::run_serve(&args), }; if let Err(err) = result { log::error!("[{{name}}] {err}"); - // Exit 2 for all errors so diff errors satisfy Q10's "errors always ≥ 2" rule. - // Push / validate errors are not behaviour-checked against the 1 vs 2 distinction. + // Exit 2 for all errors so diff errors always exit >= 2. Push / validate + // errors are not behaviour-checked against the 1 vs 2 distinction. process::exit(2); } } diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index b30fd7ff..81ebdb14 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -59,6 +59,10 @@ export default defineConfig({ }, { text: 'CLI Reference', link: '/guide/cli-reference' }, { text: 'CLI Walkthrough', link: '/guide/cli-walkthrough' }, + { + text: 'Deploying from GitHub Actions', + link: '/guide/deploy-github-actions', + }, { text: 'Manifest Store Migration', link: '/guide/manifest-store-migration', diff --git a/docs/guide/cli-reference.md b/docs/guide/cli-reference.md index f25c7404..b072e2fb 100644 --- a/docs/guide/cli-reference.md +++ b/docs/guide/cli-reference.md @@ -221,7 +221,7 @@ each adapter crate owns its own implementation, the CLI is a thin delegate. ```bash -edgezero config push --adapter [--manifest ] [--app-config ] [--store ] [--key ] [--no-env] [--local] [--runtime-config ] [--no-diff] [--yes] [--dry-run] +edgezero config push --adapter [--manifest ] [--app-config ] [--store ] [--key ] [--staging] [--no-env] [--local] [--runtime-config ] [--no-diff] [--yes] [--dry-run] ``` **Arguments:** @@ -230,7 +230,14 @@ edgezero config push --adapter [--manifest ] [--app-config ] - `--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). Defaults to the logical store id. Use it to publish a side channel (e.g. `--key app_config_staging`); the runtime selects it back via `EDGEZERO__STORES__CONFIG____KEY` (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). +- `--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 `EDGEZERO__STORES__CONFIG____KEY` + in its staging selector store (see [the blob migration guide](./blob-app-config-migration.md#per-environment-key-override)). - `--no-env` — skip the `__…__` env-var overlay when loading the app config. By default the loader reads the overlay so the push sends the same values the runtime would. - `--local` — push into the adapter's local-emulator state instead of the live platform. Fastly edits `[local_server.config_stores]` in `fastly.toml` (Viceroy reads it on startup); Cloudflare runs `wrangler kv bulk put --local` so writes land in `.wrangler/state`; Spin forces SQLite-direct against `/.spin/sqlite_key_value.db` even when the manifest's deploy command targets Fermyon Cloud (the runtime-config `[key_value_store.