diff --git a/.github/actions/build-app-cli/action.yml b/.github/actions/build-app-cli/action.yml new file mode 100644 index 00000000..56d44c56 --- /dev/null +++ b/.github/actions/build-app-cli/action.yml @@ -0,0 +1,60 @@ +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 + +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: Build application CLI package + id: build + 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'] }} + run: $GITHUB_ACTION_PATH/scripts/build-app-cli.sh + + - name: Upload CLI artifact + uses: actions/upload-artifact@v4 + 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..e63c5d3d --- /dev/null +++ b/.github/actions/build-app-cli/scripts/build-app-cli.sh @@ -0,0 +1,217 @@ +#!/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" + +# --- Rust toolchain resolution helpers --------------------------------------- +parse_toolchain_from_channel_file() { + local file="$1" + 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 + + # Require a committed lockfile and validate the package + binary target. + cd "$app_dir" + local metadata package_json + metadata=$(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" + + [[ -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" + 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" + "$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'" + 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..ed9a75e3 --- /dev/null +++ b/.github/actions/build-app-cli/scripts/common.sh @@ -0,0 +1,138 @@ +#!/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 + 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_.=-' '-' +} + +# 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/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..ce63efa1 --- /dev/null +++ b/.github/actions/deploy-core/scripts/common.sh @@ -0,0 +1,195 @@ +#!/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 +} + +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") + if tar -tvf "$tarball" | grep -qE '^[lh]'; 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" +} + +# 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 "rm -f -- '$LIFECYCLE_LOG'" EXIT +} + +# 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 +} 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..8c352f80 --- /dev/null +++ b/.github/actions/deploy-core/scripts/download-app-cli.sh @@ -0,0 +1,72 @@ +#!/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. +find_cli_tarball() { + local artifact_dir="$1" + find "$artifact_dir" -maxdepth 2 -type f -name '*.tar' | head -n 1 +} + +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" + 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..e1321803 --- /dev/null +++ b/.github/actions/deploy-core/scripts/resolve-project.sh @@ -0,0 +1,180 @@ +#!/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() { + 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") + + # Optional explicit manifest. + 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 "$workspace_real" "$manifest_path" || fail "input 'manifest' must resolve inside github.workspace" + manifest_summary=$(relative_to "$workspace_real" "$manifest_path") + else + manifest_path="" + manifest_summary="EdgeZero default discovery" + fi + + # Git root: source revision + dirty-source guard. + 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" + source_revision=$(git -C "$git_root" rev-parse HEAD) + assert_committed_source "$git_root" "$app_rel" + + # Cargo workspace root: lockfile + target/ + cache. + 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")") + 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" +} + +main "$@" 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..f71fff5c --- /dev/null +++ b/.github/actions/deploy-core/scripts/run-app-cli.sh @@ -0,0 +1,192 @@ +#!/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__BIN required binary name to invoke (on PATH) +# 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: +# nothing — execs 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" + + # 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. +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="${EDGEZERO__APP__CLI__BIN:?EDGEZERO__APP__CLI__BIN is required}" + 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" + 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-production-deploy.sh b/.github/actions/deploy-core/tests/assert-production-deploy.sh new file mode 100755 index 00000000..29d1e7c5 --- /dev/null +++ b/.github/actions/deploy-core/tests/assert-production-deploy.sh @@ -0,0 +1,55 @@ +#!/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, EDGEZERO__TEST__FASTLY_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 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 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-rollback-calls.sh b/.github/actions/deploy-core/tests/assert-rollback-calls.sh new file mode 100755 index 00000000..6345d0c7 --- /dev/null +++ b/.github/actions/deploy-core/tests/assert-rollback-calls.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Asserts the rollback verbs, paths, and version threading. +# +# Regression test for real defects a review found: +# * Rollback used POST; the Fastly API requires PUT. +# * Staging rollback hit `/deactivate`, which deactivates the LIVE version. +# Undoing a stage is `/deactivate/staging`. +# +# Reads (env): FAKE_CALL_LOG, EDGEZERO__TEST__STAGED_VERSION, EDGEZERO__TEST__ROLLED_BACK_TO. + +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' + local previous=$((staged - 1)) + + 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 rollback activates the PREVIOUS version. + grep -qE "^PUT $api/version/$previous/activate\$" "$log" || + fail "production rollback did not PUT /version/$previous/activate" + + [[ "$rolled_back_to" == "$previous" ]] || + fail "expected rolled-back-to=$previous, got '${rolled_back_to:-}'" + + notice "rollback used PUT with the correct paths; rolled-back-to=$previous threaded out" +} + +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..ef1c303f --- /dev/null +++ b/.github/actions/deploy-core/tests/assert-staged-calls.sh @@ -0,0 +1,72 @@ +#!/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. +# +# Regression test for real defects a review found: +# * `--comment` was forwarded to `fastly compute update`, which has no such +# flag, so the upload failed. It must be applied via +# `fastly service-version update --comment` BEFORE the version is staged. +# * A manifest-command deploy never received `--non-interactive` and could +# block on a TTY prompt in CI. The wrapper now supplies it as an +# action-owned passthrough arg. +# * The staged upload must clone the active version. +# +# Reads (env): FAKE_CALL_LOG, EDGEZERO__TEST__STAGED_VERSION. + +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" +} + +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" + + # 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..ca2cb30b --- /dev/null +++ b/.github/actions/deploy-core/tests/assert-staging-probe.sh @@ -0,0 +1,31 @@ +#!/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, EDGEZERO__TEST__STAGED_VERSION. + +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}" + + 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?)" + + notice "staging probe was rerouted through the resolved staging IP" +} + +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..7d2b23d2 --- /dev/null +++ b/.github/actions/deploy-core/tests/assert-unhealthy-failed.sh @@ -0,0 +1,28 @@ +#!/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 (the step's outcome), EDGEZERO__TEST__HEALTHY (its output). + +main() { + local outcome="${EDGEZERO__TEST__OUTCOME:?EDGEZERO__TEST__OUTCOME is required}" + local healthy="${EDGEZERO__TEST__HEALTHY:-}" + + if [[ "$outcome" != "failure" ]]; then + echo "::error::an unhealthy probe did not fail healthcheck-fastly (outcome=$outcome)" >&2 + return 1 + fi + if [[ "$healthy" == "true" ]]; then + echo "::error::an unhealthy probe still reported healthy=true" >&2 + return 1 + fi + + echo "healthcheck-fastly failed closed on an unhealthy probe" +} + +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..a646e78a --- /dev/null +++ b/.github/actions/deploy-core/tests/check-action-pins.sh @@ -0,0 +1,45 @@ +#!/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/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" +) + +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..1062ceb2 --- /dev/null +++ b/.github/actions/deploy-core/tests/make-fake-fastly-env.sh @@ -0,0 +1,120 @@ +#!/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 +# regression-tests the defects a review found: +# * `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 placed in the ACTION-OWNED TOOL ROOT, reporting the pinned +# version, so `install-fastly.sh` adopts it instead of downloading the real CLI +# (its idempotency check). That is what lets the staged path be exercised through +# the real deploy-fastly wrapper rather than by calling the CLI directly. +# 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" ;; + *) + case "\${1:-}" in + version | --version) echo "Fastly CLI version v$version (fake)" ;; + *) echo "fake fastly: unhandled: \$*" >&2 ;; + esac + ;; +esac +exit 0 +SHIM + chmod +x "$path" +} + +write_fake_curl() { + local path="$1" + cat >"$path" <<'SHIM' +#!/usr/bin/env bash +# 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" + echo 200 + exit 0 + fi + printf 'GET %s\n' "$url" >>"$FAKE_CALL_LOG" + # Fastly returns a SINGULAR `staging_ip` string per domain object. + printf '[{"name":"staging.example.com","staging_ip":"151.101.2.10"}]\n' + exit 0 +fi +printf 'PROBE %s\n' "$*" >>"$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" + local tool_bin="$runner_temp/edgezero-action-tools/bin" + local log="$workspace/fake-calls.log" + + mkdir -p "$path_dir" "$tool_bin" + : >"$log" + + # The fake must claim the pinned version, or install-fastly.sh replaces it. + local pinned + pinned=$(json_get "$action_dir/versions.json" fastly.version) + + write_fake_fastly "$tool_bin/fastly" "$pinned" + write_fake_fastly "$path_dir/fastly" "$pinned" + write_fake_curl "$path_dir/curl" + + printf '%s\n' "$path_dir" >>"${GITHUB_PATH:?GITHUB_PATH is required}" + { + printf 'FAKE_CALL_LOG=%s\n' "$log" + } >>"${GITHUB_ENV:?GITHUB_ENV is required}" + + notice "fake fastly (v$pinned) installed in the tool root and on PATH; 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..fe693127 --- /dev/null +++ b/.github/actions/deploy-core/tests/make-smoke-fixture.sh @@ -0,0 +1,127 @@ +#!/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"] } +TOML + + cat >crates/fixture-app-cli/src/main.rs <<'RS' +//! Fixture app CLI: the smoke test's stand-in for an application-owned CLI. +use clap::{Parser, Subcommand}; +use edgezero_cli::args::{DeployArgs, HealthcheckArgs, RollbackArgs}; + +#[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 { + Deploy(DeployArgs), + Healthcheck(HealthcheckArgs), + Rollback(RollbackArgs), +} + +fn main() { + edgezero_cli::init_cli_logger(); + let result = match Args::parse().cmd { + Cmd::Deploy(args) => edgezero_cli::run_deploy(&args), + Cmd::Healthcheck(args) => edgezero_cli::run_healthcheck(&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" +echo "version=7" +SH + chmod +x fake-deploy.sh + + cat >edgezero.toml <<'ETOML' +[adapters.fastly.commands] +deploy = "bash fake-deploy.sh" +ETOML + + # 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..c21aa07f --- /dev/null +++ b/.github/actions/deploy-core/tests/run.sh @@ -0,0 +1,523 @@ +#!/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_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 + else + fail "download-app-cli failed to execute" + fi +} + +# --------------------------------------------------------------------------- +# 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 +# --------------------------------------------------------------------------- +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]+\$'" + + # 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 +# --------------------------------------------------------------------------- +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" +} + +# --------------------------------------------------------------------------- +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_fastly_versions + test_cleanup_confinement + test_action_env_scrub + test_deploy_args_prepend + test_lifecycle_helpers + test_toolchain_boundary + + 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..80c09a59 --- /dev/null +++ b/.github/actions/deploy-fastly/action.yml @@ -0,0 +1,375 @@ +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 deploy step. + 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'] }} + 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_HOME"]' + 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_HOME: "" + run: | + [[ "${EDGEZERO__FASTLY__API_TOKEN_PRESENT}" == "true" ]] || { echo "::error::missing required input 'fastly-api-token'"; exit 1; } + [[ -n "${EDGEZERO__FASTLY__SERVICE_ID}" ]] || { echo "::error::missing required input 'fastly-service-id'"; exit 1; } + [[ "${EDGEZERO__FASTLY__SERVICE_ID}" =~ ^[A-Za-z0-9_-]+$ ]] || { echo "::error::input 'fastly-service-id' must match ^[A-Za-z0-9_-]+$"; exit 1; } + # validate-inputs.sh also rejects a non-boolean 'stage' before any deploy. + "$GITHUB_ACTION_PATH/../deploy-core/scripts/validate-inputs.sh" + + - name: Download CLI artifact + uses: actions/download-artifact@v4 + 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_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_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_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@v4 + 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_HOME: "" + + - name: Install Rust toolchain + # Trusted, widely-used action; a readable major-version tag pin is our + # policy (design principle #9). zizmor's blanket policy wants a hash pin — + # allow the tag here explicitly with justification. + uses: actions-rust-lang/setup-rust-toolchain@v1 # zizmor: ignore[unpinned-uses] + 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_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_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__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_HOME: "" + run: $GITHUB_ACTION_PATH/../deploy-core/scripts/run-app-cli.sh build + + - name: Deploy + id: deploy + shell: bash + env: + EDGEZERO__APP__CLI__BIN: ${{ steps.cli.outputs['app-cli-bin'] }} + 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@v4 + 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_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_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_HOME: "" + run: $GITHUB_ACTION_PATH/../deploy-core/scripts/cleanup.sh 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..79cc0420 --- /dev/null +++ b/.github/actions/deploy-fastly/scripts/deploy.sh @@ -0,0 +1,48 @@ +#!/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): +# 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 + "$SCRIPT_DIR/../../deploy-core/scripts/run-app-cli.sh" deploy 2>&1 | tee "$LIFECYCLE_LOG" + + 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..8f21b7bd --- /dev/null +++ b/.github/actions/deploy-fastly/scripts/install-fastly.sh @@ -0,0 +1,95 @@ +#!/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 +} + +# Whether the action-owned tool root already holds an executable `fastly` +# reporting the pinned version. +tool_root_has_pinned_fastly() { + local tool_root="$1" version="$2" + local bin="$tool_root/bin/fastly" + + [[ -x "$bin" ]] || return 1 + local reported + reported=$("$bin" version 2>/dev/null | head -n 1) || return 1 + [[ "$reported" == *"$version"* ]] || return 1 + + notice "reusing the already-installed Fastly CLI: $reported" + return 0 +} + +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 + mkdir -p "$tool_root/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" + + # Idempotent: if the action-owned tool root already holds a `fastly` reporting + # the pinned version, adopt it instead of downloading again. Running the + # wrapper twice in one job should not refetch a binary we already verified. + # The scope is deliberately narrow — only this dir, which the action creates + # under RUNNER_TEMP, populates, executes from, and deletes on cleanup. A + # `fastly` found merely on PATH is NOT trusted and is always superseded. + if ! tool_root_has_pinned_fastly "$tool_root" "$version"; then + local url sha256 archive + 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" + + local actual + actual=$(sha256_file "$archive") + [[ "$actual" == "$sha256" ]] || fail "Fastly CLI checksum mismatch for version $version" + + tar -xzf "$archive" -C "$tool_root/bin" fastly + chmod +x "$tool_root/bin/fastly" + fi + + printf '%s\n' "$tool_root/bin" >>"${GITHUB_PATH:-/dev/null}" + export PATH="$tool_root/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/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..1658be58 --- /dev/null +++ b/.github/actions/healthcheck-fastly/action.yml @@ -0,0 +1,133 @@ +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 (required for staging IP resolution). + required: true + 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 + deploy-to: + description: Deployment target, 'production' or 'staging'. + required: false + default: production + retry: + description: Number of retries. + required: false + default: "3" + retry-delay: + description: Seconds between retries. + required: false + default: "5" + timeout: + description: Request timeout in seconds. + 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: + - name: Download CLI artifact + uses: actions/download-artifact@v4 + 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_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_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__LIFECYCLE__SERVICE_ID: ${{ inputs['fastly-service-id'] }} + EDGEZERO__LIFECYCLE__VERSION: ${{ inputs['fastly-version'] }} + EDGEZERO__LIFECYCLE__DOMAIN: ${{ inputs.domain }} + 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. + 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_HOME: "" + run: $GITHUB_ACTION_PATH/scripts/healthcheck.sh + + - name: Cleanup + if: ${{ always() }} + shell: bash + env: + EDGEZERO__ACTION__TOOL_ROOT: ${{ runner.temp }}/edgezero-action-tools + 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..90734db2 --- /dev/null +++ b/.github/actions/healthcheck-fastly/scripts/healthcheck.sh @@ -0,0 +1,82 @@ +#!/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__BIN required app CLI binary to invoke +# EDGEZERO__LIFECYCLE__SERVICE_ID required Fastly service id +# EDGEZERO__LIFECYCLE__VERSION required version to probe +# EDGEZERO__LIFECYCLE__DOMAIN required domain to probe +# FASTLY_API_TOKEN required provider token (Fastly's own convention) +# 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._-]+$' + require_input fastly-api-token "${FASTLY_API_TOKEN:-}" + require_input_matching retry "${EDGEZERO__LIFECYCLE__RETRY:-}" '^[0-9]+$' + require_input_matching retry-delay "${EDGEZERO__LIFECYCLE__RETRY_DELAY:-}" '^[0-9]+$' + require_input_matching timeout "${EDGEZERO__LIFECYCLE__TIMEOUT:-}" '^[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 +} + +main() { + validate_inputs + + local argv=( + "$EDGEZERO__APP__CLI__BIN" healthcheck + --adapter fastly + --service-id "$EDGEZERO__LIFECYCLE__SERVICE_ID" + --version "$EDGEZERO__LIFECYCLE__VERSION" + --domain "$EDGEZERO__LIFECYCLE__DOMAIN" + --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 "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/rollback-fastly/action.yml b/.github/actions/rollback-fastly/action.yml new file mode 100644 index 00000000..4256d464 --- /dev/null +++ b/.github/actions/rollback-fastly/action.yml @@ -0,0 +1,111 @@ +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 + 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'] }} + +runs: + using: composite + steps: + - name: Download CLI artifact + uses: actions/download-artifact@v4 + 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_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_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__LIFECYCLE__SERVICE_ID: ${{ inputs['fastly-service-id'] }} + EDGEZERO__LIFECYCLE__VERSION: ${{ inputs['fastly-version'] }} + 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_HOME: "" + run: $GITHUB_ACTION_PATH/scripts/rollback.sh + + - name: Cleanup + if: ${{ always() }} + shell: bash + env: + EDGEZERO__ACTION__TOOL_ROOT: ${{ runner.temp }}/edgezero-action-tools + 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..e357fb48 --- /dev/null +++ b/.github/actions/rollback-fastly/scripts/rollback.sh @@ -0,0 +1,59 @@ +#!/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__BIN required app CLI binary to invoke +# EDGEZERO__LIFECYCLE__SERVICE_ID required Fastly service id +# EDGEZERO__LIFECYCLE__VERSION required the current (bad) version to roll back from +# FASTLY_API_TOKEN required provider token (Fastly's own convention) +# EDGEZERO__DEPLOY__TO optional production | staging (default: production) +# Writes (outputs): +# 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 | staging) ;; + *) fail "input 'deploy-to' must be 'production' or 'staging' (got '${EDGEZERO__DEPLOY__TO:-}')" ;; + esac +} + +main() { + validate_inputs + + local argv=("$EDGEZERO__APP__CLI__BIN" rollback --adapter fastly --service-id "$EDGEZERO__LIFECYCLE__SERVICE_ID" --version "$EDGEZERO__LIFECYCLE__VERSION") + if [[ "$EDGEZERO__DEPLOY__TO" == "staging" ]]; then + argv+=(--staging) + fi + + new_private_log + local rc=0 + "${argv[@]}" 2>&1 | tee "$LIFECYCLE_LOG" || rc=$? + + local rolled + rolled=$(read_numeric_line rolled-back-to "$LIFECYCLE_LOG") + append_output rolled-back-to "$rolled" + + if [[ "$rc" -ne 0 ]]; then + fail "rollback failed (CLI exit $rc)" + fi + 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/workflows/deploy-action.yml b/.github/workflows/deploy-action.yml new file mode 100644 index 00000000..a930dd7d --- /dev/null +++ b/.github/workflows/deploy-action.yml @@ -0,0 +1,263 @@ +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/workflows/deploy-action.yml + - 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/workflows/deploy-action.yml + - 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: | + set -euo pipefail + base="https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}" + archive="actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" + curl --fail --location --silent --show-error "$base/$archive" --output "/tmp/$archive" + # Verify against the checksums file published with the same release. + curl --fail --location --silent --show-error \ + "$base/actionlint_${ACTIONLINT_VERSION}_checksums.txt" --output /tmp/actionlint_checksums.txt + ( cd /tmp && grep -- " ${archive}\$" actionlint_checksums.txt | sha256sum --check --strict - ) + tar -xzf "/tmp/$archive" -C /tmp actionlint + sudo install -m 0755 /tmp/actionlint /usr/local/bin/actionlint + + - name: Actionlint (this workflow) + run: actionlint .github/workflows/deploy-action.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/actions/build-app-cli/action.yml \ + .github/actions/deploy-fastly/action.yml \ + .github/actions/healthcheck-fastly/action.yml \ + .github/actions/rollback-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/build-app-cli/scripts/*.sh \ + .github/actions/deploy-core/scripts/*.sh \ + .github/actions/deploy-core/tests/*.sh \ + .github/actions/deploy-fastly/scripts/*.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 + + - 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'] }} + run: .github/actions/deploy-core/tests/assert-production-deploy.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. + 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) + 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 + 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'] }} + 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 }} + run: .github/actions/deploy-core/tests/assert-unhealthy-failed.sh + + - 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 + fastly-version: ${{ steps.stage.outputs['fastly-version'] }} + 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 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..871d1c0a 100644 --- a/crates/edgezero-adapter-axum/src/cli.rs +++ b/crates/edgezero-adapter-axum/src/cli.rs @@ -147,6 +147,13 @@ impl Adapter for AxumCliAdapter { AdapterAction::Build => build(args), AdapterAction::Deploy => deploy(args), AdapterAction::Serve => serve(args), + // The Fastly staging lifecycle (spec §5.4) is Fastly-only. + AdapterAction::DeployStaged + | AdapterAction::EmitVersion + | AdapterAction::Healthcheck + | AdapterAction::Rollback => Err(format!( + "axum adapter does not support the Fastly staging lifecycle action {action:?} (spec §5.4)" + )), other => Err(format!("axum adapter does not support {other:?}")), } } diff --git a/crates/edgezero-adapter-cloudflare/src/cli.rs b/crates/edgezero-adapter-cloudflare/src/cli.rs index 14a92df0..baa133fc 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 (spec §5.4) is Fastly-only. + AdapterAction::DeployStaged + | AdapterAction::EmitVersion + | AdapterAction::Healthcheck + | AdapterAction::Rollback => Err(format!( + "cloudflare adapter does not support the Fastly staging lifecycle action {action:?} (spec §5.4)" + )), 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..5ebb7e7e 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,67 @@ 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"; +/// 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 (deploy-github-action spec §5.4). +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 (spec §5.4). +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 +248,11 @@ impl Adapter for FastlyCliAdapter { } AdapterAction::Deploy => deploy(args), AdapterAction::Serve => serve(args), + // Fastly staging lifecycle (deploy-github-action spec §5.4). + 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:?}")), } } @@ -1252,20 +1318,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,12 +1474,814 @@ pub fn serve(extra_args: &[String]) -> Result<(), String> { Ok(()) } +// =================================================================== +// Fastly staging lifecycle (deploy-github-action spec §5.4) +// =================================================================== +// +// 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 (spec §5.4.2):** 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) +} + +/// 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) +} + +/// The version to activate when rolling a production service back from +/// `version`. `None` when there is no earlier version (`version <= 1`). +fn previous_version(version: u64) -> Option { + (version > 1).then(|| version.saturating_sub(1)) +} + +/// 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 (spec §5.4.2). +/// 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. +fn parse_active_version(json: &str) -> Option { + let value: serde_json::Value = serde_json::from_str(json).ok()?; + value.as_array()?.iter().find_map(|entry| { + (entry.get("active").and_then(serde_json::Value::as_bool) == Some(true)) + .then(|| entry.get("number").and_then(serde_json::Value::as_u64)) + .flatten() + }) +} + +/// 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`. +fn build_curl_probe_args(domain: &str, staging_ip: Option<&str>, timeout_secs: u64) -> Vec { + let mut args = vec![ + "-sS".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}/")); + args +} + +/// 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. 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. +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}")); + let config = format!("header = {header}\nurl = {url}\n"); + curl_config_capture(&config) +} + +/// `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` (spec §5.4): +/// 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(); + // 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(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. 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, + )?; + + // 5. Emit the staged version (spec §5.4.2 parseable contract). + log::info!("version={version}"); + Ok(()) +} + +/// Production companion to `deploy` (spec §5.4.2): resolve the active +/// service version via the Fastly API and emit `version=`. +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)?; + let version = parse_active_version(&json).ok_or_else(|| { + format!("could not resolve the active version for service {service_id} from the Fastly API") + })?; + log::info!("version={version}"); + Ok(()) +} + +/// `healthcheck --adapter fastly ...` (spec §5.4): 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 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); + + 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, 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 ...` (spec §5.4): 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 the previous version. + // Fastly's activate endpoint requires `PUT` (not `POST`). + let previous = previous_version(version).ok_or_else(|| { + format!("cannot roll back version {version}: no previous version exists") + })?; + 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::PathPrepend; + use edgezero_core::test_env::{EnvOverride, PathPrepend}; #[cfg(unix)] use std::sync::Mutex; @@ -1404,6 +2297,577 @@ mod tests { 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 (spec §5.4) ────────────────── + + #[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 (spec §5.4) ─────────── + // + // 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 previous_version_computes_predecessor() { + assert_eq!(previous_version(42), Some(41)); + assert_eq!(previous_version(2), Some(1)); + assert_eq!(previous_version(1), None); + assert_eq!(previous_version(0), None); + } + + #[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 (spec §5.4.2). + 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!(parse_active_version(json), Some(2)); + } + + #[test] + fn parse_active_version_none_when_no_active() { + let json = r#"[{"number": 1, "active": false}]"#; + assert_eq!(parse_active_version(json), None); + } + + #[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())); + } + + #[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 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()))); + } + + #[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() { let dir = tempdir().unwrap(); @@ -3407,4 +4871,232 @@ 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"); + let script = format!( + "#!/bin/sh\nprintf '%s\\n' \"$*\" >> '{}'\nif [ \"$1\" = \"compute\" ] && [ \"$2\" = \"update\" ]; then\n printf '%s\\n' '{update_stdout}'\nfi\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) + } + + /// 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" + ); + } + } + + #[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..d822431b 100644 --- a/crates/edgezero-adapter-spin/src/cli.rs +++ b/crates/edgezero-adapter-spin/src/cli.rs @@ -157,6 +157,13 @@ impl Adapter for SpinCliAdapter { } AdapterAction::Deploy => deploy(args), AdapterAction::Serve => serve(args), + // The Fastly staging lifecycle (spec §5.4) is Fastly-only. + AdapterAction::DeployStaged + | AdapterAction::EmitVersion + | AdapterAction::Healthcheck + | AdapterAction::Rollback => Err(format!( + "spin adapter does not support the Fastly staging lifecycle action {action:?} (spec §5.4)" + )), other => Err(format!("spin adapter does not support {other:?}")), } } diff --git a/crates/edgezero-adapter/src/registry.rs b/crates/edgezero-adapter/src/registry.rs index 0f8850d0..c2a6be36 100644 --- a/crates/edgezero-adapter/src/registry.rs +++ b/crates/edgezero-adapter/src/registry.rs @@ -19,6 +19,27 @@ 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 + /// (deploy-github-action spec §5.4). + 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 + /// (spec §5.4.2). + 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" (spec §5.4). + Healthcheck, + /// Roll a service back: production activates the previous version, + /// staging deactivates the staged version. Fastly-only; other + /// adapters return "unsupported" (spec §5.4). + Rollback, Serve, } diff --git a/crates/edgezero-cli/src/adapter.rs b/crates/edgezero-cli/src/adapter.rs index d122f6ba..9cd9e385 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 (spec §5.4): stage a draft version. + DeployStaged, + /// Fastly staging lifecycle (spec §5.4): emit the active version. + EmitVersion, + /// Fastly staging lifecycle (spec §5.4): probe health. + Healthcheck, + /// Fastly staging lifecycle (spec §5.4): 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 (spec §5.4) 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..3bcf711f 100644 --- a/crates/edgezero-cli/src/args.rs +++ b/crates/edgezero-cli/src/args.rs @@ -33,6 +33,9 @@ pub enum Command { Demo, /// Deploy to a target edge. Deploy(DeployArgs), + /// Probe a deployed version's health (Fastly staging lifecycle, + /// spec §5.4). 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 +43,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, spec §5.4). + Rollback(RollbackArgs), /// Run a local simulation (adapter-specific). Serve(ServeArgs), } @@ -148,6 +154,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 (spec §5.4): 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, spec + /// §5.4): 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,6 +224,68 @@ pub struct ServeArgs { pub adapter: String, } +/// Arguments for the `healthcheck` command (Fastly staging lifecycle, +/// spec §5.4). +/// +/// 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, + /// 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, +/// spec §5.4). +/// +/// 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, + /// 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 previous). + #[arg(long)] + pub staging: bool, + /// Reference version: production activates ` - 1`; + /// staging deactivates ``. Required. + #[arg(long, required = true)] + pub version: String, +} + /// Output format for `config diff`. #[derive(clap::ValueEnum, Clone, Debug, Default, PartialEq)] pub enum DiffFormat { @@ -665,6 +746,254 @@ mod tests { .expect_err("`provision` without --adapter must error"); } + // ── Fastly staging lifecycle arg tests (spec §5.4) ───────────────── + + #[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 spec §5.4 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 (spec §5.4 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 (12.8 + 12.11) ────────────────── /// Bundled binary: bare `config push` parses to the stub variant. diff --git a/crates/edgezero-cli/src/lib.rs b/crates/edgezero-cli/src/lib.rs index 68eac9d8..a7859c5a 100644 --- a/crates/edgezero-cli/src/lib.rs +++ b/crates/edgezero-cli/src/lib.rs @@ -55,7 +55,7 @@ pub use config::{ pub use provision::run_provision; #[cfg(feature = "cli")] -use args::{BuildArgs, DeployArgs, NewArgs, ServeArgs}; +use args::{BuildArgs, DeployArgs, HealthcheckArgs, NewArgs, RollbackArgs, ServeArgs}; #[cfg(feature = "cli")] use edgezero_core::manifest::ManifestLoader; #[cfg(feature = "cli")] @@ -160,11 +160,260 @@ 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` (spec §5.4) 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 { + // Staged deploy: clone the active version, upload the built + // package to a new draft, mark it staged, and emit the staged + // version (spec §5.4). 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 (spec §5.4.2) + // 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 finding. + 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(()); + } + return adapter::execute( + &args.adapter, + adapter::Action::EmitVersion, + manifest.as_ref(), + &passthrough, + ) + .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, spec +/// §5.4) 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> { + let manifest = load_manifest_optional()?; + ensure_adapter_defined(&args.adapter, manifest.as_ref())?; + 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(), + ]; + 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, + manifest.as_ref(), + &passthrough, + ) +} + +/// Roll a service back (Fastly staging lifecycle, spec §5.4): +/// 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> { + let manifest = load_manifest_optional()?; + ensure_adapter_defined(&args.adapter, manifest.as_ref())?; + 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()); + } + adapter::execute( + &args.adapter, + adapter::Action::Rollback, + manifest.as_ref(), + &passthrough, ) } @@ -350,6 +599,128 @@ mod tests { assert!(manifest.manifest().adapters.contains_key("fastly")); } + // ── deploy-output version parsing (spec §5.4.2) ─────────────────── + + #[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 +768,11 @@ mod tests { let args = DeployArgs { adapter: "fastly".to_owned(), adapter_args: Vec::new(), + // No service id → the production version-emit step (spec + // §5.4.2) 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..c20dce72 100644 --- a/crates/edgezero-cli/src/main.rs +++ b/crates/edgezero-cli/src/main.rs @@ -31,7 +31,9 @@ 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::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..aec905d1 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, + 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, @@ -37,11 +37,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), } @@ -94,8 +100,10 @@ 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 { 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/deploy-github-actions.md b/docs/guide/deploy-github-actions.md new file mode 100644 index 00000000..84f22803 --- /dev/null +++ b/docs/guide/deploy-github-actions.md @@ -0,0 +1,306 @@ +# Deploying from GitHub Actions + +EdgeZero ships a set of reusable GitHub composite actions that deploy a +checked-out EdgeZero application to Fastly Compute. They are **layered** so that +adding another provider later does not rewrite the deploy engine, and the +**EdgeZero CLI is the boundary** — the actions never reproduce provider build or +deploy logic in YAML; they compile your CLI, scope credentials, and invoke it. + +The design reference lives in +[`docs/specs/edgezero-deploy-github-action.md`](https://github.com/stackpop/edgezero/blob/main/docs/specs/edgezero-deploy-github-action.md); +this page is the practical how-to. + +## The three layers + +| Action | Role | +| -------------------- | ----------------------------------------------------------------------------------------- | +| `build-app-cli` | Compile the CLI package **your app provides** once, publish it as an artifact. | +| `deploy-fastly` | Deploy a checked-out Fastly app using that CLI artifact (production, or a staged draft). | +| `healthcheck-fastly` | Probe a deployed/staged version; exit non-zero when unhealthy so you can gate a rollback. | +| `rollback-fastly` | Production: activate the previous version. Staging: deactivate the staged version. | + +Under the hood a private `deploy-core` engine (a set of shared scripts) holds all +provider-neutral behavior; the wrappers above are thin. + +**Runner support:** Linux x86-64 only (`ubuntu-24.04` is tested). + +## What you provide + +- **Checkout.** The actions never call `actions/checkout` — you own checkout, ref + selection, permissions, environments, concurrency, and timeouts. +- **A CLI package.** Name a Cargo package in your own workspace (the crate that + builds your `edgezero`-based CLI binary) via `app-cli-package`. `build-app-cli` + compiles exactly that, from your checkout's `Cargo.lock`, so the CLI and your + app can never disagree on schema. +- **Typed provider credentials.** Pass `fastly-api-token` / `fastly-service-id` + through the wrapper inputs — never through workflow `env:`. They reach only the + deploy step. + +## Quick start (same repository) + +```yaml +jobs: + deploy: + runs-on: ubuntu-24.04 + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - id: cli + uses: stackpop/edgezero/.github/actions/build-app-cli@ + with: + app-cli-package: my-app-cli # the CLI crate in your workspace + + - uses: stackpop/edgezero/.github/actions/deploy-fastly@ + with: + app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} + fastly-api-token: ${{ secrets.FASTLY_API_TOKEN }} + fastly-service-id: ${{ vars.FASTLY_SERVICE_ID }} +``` + +Use a trusted `@` — a released tag, or a full commit SHA when you need a +reproducible production deploy. + +## Separate deployer and application repositories + +Check the application into a path and point both actions at it. A **private** app +repository is not readable with the deployer job's default `GITHUB_TOKEN` — mint +an app-scoped token first (a GitHub App installation token, or a fine-grained PAT +with `contents: read`) and pass it to the application checkout. + +```yaml +steps: + - name: Checkout deployer + uses: actions/checkout@v4 + with: + path: deployer + persist-credentials: false + + - name: Checkout application + uses: actions/checkout@v4 + with: + repository: stackpop/my-edgezero-app + ref: ${{ inputs.ref }} + path: app + persist-credentials: false + token: ${{ steps.app-token.outputs.token }} # app-scoped token + + - id: cli + uses: stackpop/edgezero/.github/actions/build-app-cli@ + with: + app-cli-package: my-app-cli + working-directory: app + + - uses: stackpop/edgezero/.github/actions/deploy-fastly@ + with: + app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} + working-directory: app + fastly-api-token: ${{ secrets.FASTLY_API_TOKEN }} + fastly-service-id: ${{ vars.FASTLY_SERVICE_ID }} +``` + +## Monorepo application + +Select the app subdirectory and, when needed, an explicit manifest. Caching keys +on the **Cargo workspace root** for that subdirectory (which in a nested +workspace may be the subdirectory itself), so a monorepo caches the right +`target/`. + +```yaml +- id: cli + uses: stackpop/edgezero/.github/actions/build-app-cli@ + with: + app-cli-package: api-cli + working-directory: apps/api + +- uses: stackpop/edgezero/.github/actions/deploy-fastly@ + with: + app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} + working-directory: apps/api + manifest: edgezero.toml + cache: true + fastly-api-token: ${{ secrets.FASTLY_API_TOKEN }} + fastly-service-id: ${{ vars.FASTLY_SERVICE_ID }} +``` + +## Inputs and outputs + +### `build-app-cli` + +| Input | Required | Default | Meaning | +| ------------------- | -------- | --------------- | ---------------------------------------------------------------- | +| `app-cli-package` | Yes | — | Cargo package name of the CLI, in your app's workspace. | +| `app-cli-bin` | No | `` | Binary name the package produces. | +| `working-directory` | No | `.` | App directory (relative to `github.workspace`). | +| `rust-toolchain` | No | `auto` | Explicit toolchain, or `auto` (rustup files → `.tool-versions`). | +| `app-cli-artifact` | No | `edgezero-cli` | Uploaded artifact name. | + +Outputs: `app-cli-version`, `app-cli-package`, `app-cli-bin`, `artifact-name`. + +### `deploy-fastly` + +| Input | Required | Default | Meaning | +| ------------------- | -------- | --------------- | --------------------------------------------------------------- | +| `app-cli-artifact` | Yes | — | The `build-app-cli` artifact to run. | +| `fastly-api-token` | Yes | — | Injected only into the deploy step. | +| `fastly-service-id` | Yes | — | Passed as the typed `--service-id` flag. | +| `app-cli-bin` | No | artifact's name | Binary name inside the artifact. | +| `working-directory` | No | `.` | App directory. | +| `manifest` | No | empty | Optional `edgezero.toml` path relative to `working-directory`. | +| `build-mode` | No | `auto` | `auto` (→ `never` for Fastly), `always`, or `never`. | +| `build-args` | No | `[]` | JSON array passed to ` build`. No secrets. | +| `deploy-args` | No | `[]` | JSON array — allowlisted to `--comment` for Fastly. No secrets. | +| `stage` | No | `false` | Deploy to a staged draft version instead of activating. | +| `cache` | No | `false` | Exact-key Cargo-workspace `target/` caching. | + +Outputs: `fastly-version`, `source-revision`, `app-cli-version`. + +The action always adds `--non-interactive` to the deploy itself, so a deploy +declared as an `edgezero.toml` command (`[adapters.fastly.commands] deploy = +"fastly compute deploy"`) cannot block on a prompt in CI. You do not need to — +and cannot — pass it through `deploy-args`. + +### `healthcheck-fastly` + +| Input | Required | Default | Meaning | +| ------------------- | -------- | --------------- | ------------------------------------------------------------- | +| `app-cli-artifact` | Yes | — | The `build-app-cli` artifact to run. | +| `fastly-api-token` | Yes | — | Needed to resolve a staged version's IP. | +| `fastly-service-id` | Yes | — | Service to probe. | +| `fastly-version` | Yes | — | Version to probe — thread the deploy's `fastly-version`. | +| `domain` | Yes | — | Domain to probe, e.g. `www.example.com`. | +| `app-cli-bin` | No | artifact's name | Binary name inside the artifact. | +| `deploy-to` | No | `production` | `staging` probes the staged version via its resolved edge IP. | +| `retry` | No | `3` | Attempts before declaring the deployment unhealthy. | +| `retry-delay` | No | `5` | Seconds between attempts. | +| `timeout` | No | `10` | Per-attempt timeout in seconds. | + +Outputs: `healthy`, `status-code`. + +**This action fails when the deployment is unhealthy** — that is the point. Gate +your rollback on the step failing (`if: failure()`), not on the `healthy` output. + +### `rollback-fastly` + +| Input | Required | Default | Meaning | +| ------------------- | -------- | --------------- | ---------------------------------------------------------------------------------- | +| `app-cli-artifact` | Yes | — | The `build-app-cli` artifact to run. | +| `fastly-api-token` | Yes | — | Fastly API token. | +| `fastly-service-id` | Yes | — | Service to roll back. | +| `fastly-version` | Yes | — | The current (bad) version to roll back **from**. | +| `app-cli-bin` | No | artifact's name | Binary name inside the artifact. | +| `deploy-to` | No | `production` | `production` activates the previous version; `staging` deactivates the staged one. | + +Outputs: `rolled-back-to` (production only — the version that was activated). + +## Strict lifecycle values (fail closed) + +`stage` and `deploy-to` are validated exactly, and a bad value **fails the run** +rather than falling back to production: + +- `stage` must be exactly `true` or `false`. +- `deploy-to` must be exactly `production` or `staging`. + +A typo like `stage: True` or `deploy-to: Staging` is rejected up front — it will +never silently deploy to, probe, or roll back production. + +## Credentials + +Fastly credentials are typed inputs, not workflow `env:`. Setup and build steps +never see the token, and it never reaches outputs, caches, logs, or step +summaries. Do not duplicate provider credentials in `env:`; prefer +provider-managed runtime secret stores for application secrets. + +The deploy step enforces a hard credential boundary: before the CLI runs, every +known provider alias (`FASTLY_TOKEN`, `FASTLY_ENDPOINT`, `FASTLY_API_URL`, …) is +**cleared**, and only the typed values you passed are exported. An inherited +`FASTLY_ENDPOINT` or `FASTLY_TOKEN` from the surrounding workflow cannot reach +the deploy. + +Deploy runs trusted application code: because Fastly's default `build-mode: +never` lets `fastly compute deploy` build during deploy, the application is +compiled while the token is in scope. **Deploy only trusted, immutable refs** +(full SHAs or protected tags) and use GitHub Environment approvals. + +## Fastly staging lifecycle + +Staging parity with `stackpop/trusted-server-actions` is supported for Fastly. +The capability is scaffolded into the CLI's Fastly adapter and exposed through +your app CLI; the actions are thin wrappers. You wire the trio — the actions +carry no orchestration policy of their own. + +```yaml +- id: cli + uses: stackpop/edgezero/.github/actions/build-app-cli@ + with: { app-cli-package: my-app-cli } + +- id: stage + uses: stackpop/edgezero/.github/actions/deploy-fastly@ + with: + app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} + stage: true + fastly-api-token: ${{ secrets.FASTLY_API_TOKEN }} + fastly-service-id: ${{ vars.FASTLY_SERVICE_ID }} + +- id: check + uses: stackpop/edgezero/.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: ${{ secrets.FASTLY_API_TOKEN }} + fastly-service-id: ${{ vars.FASTLY_SERVICE_ID }} + +- if: failure() && steps.stage.outputs.fastly-version != '' + uses: stackpop/edgezero/.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: ${{ secrets.FASTLY_API_TOKEN }} + fastly-service-id: ${{ vars.FASTLY_SERVICE_ID }} +``` + +- `deploy-fastly` with `stage: true` clones the active version, uploads the built + package to a new draft, marks it staged, and outputs `fastly-version`. +- `healthcheck-fastly` resolves the staged version's Fastly staging IP and probes + it, retrying and exiting non-zero when unhealthy. +- `rollback-fastly` deactivates the staged version (or, for `deploy-to: +production`, activates the previous version). + +## Build behavior and caching + +`build-mode: auto` resolves to `never` for Fastly, because `fastly compute +deploy` builds unless a prebuilt package is provided. `always` runs a separate +credential-free validation build first; the deploy may still recompile. + +Caching is opt-in (`cache: false` by default) and, when enabled, caches only the +Cargo workspace root `target/` under an exact key (runner OS/arch, toolchain, +target, CLI version, source revision, and `Cargo.lock` hash). Enable it only for +trusted, immutable refs. + +## Recommended job hardening + +```yaml +permissions: + contents: read +concurrency: + group: deploy-${{ github.ref }} + cancel-in-progress: false +``` + +Add `timeout-minutes`, a protected GitHub Environment with required reviewers, +and pin third-party actions to readable released tags (or full SHAs for +production). + +## Non-goals + +The actions do not check out source, expand or convert configuration, or push +runtime config as a side effect of deploy. Config push and provisioning are +explicit CLI subcommands (`edgezero config push`, `edgezero provision`) you run +as separate steps. Cloudflare and Spin deploy wrappers are future work; today +these actions target Fastly. diff --git a/docs/specs/edgezero-deploy-action-implementation-plan.md b/docs/specs/edgezero-deploy-action-implementation-plan.md new file mode 100644 index 00000000..24f92b47 --- /dev/null +++ b/docs/specs/edgezero-deploy-action-implementation-plan.md @@ -0,0 +1,248 @@ +# EdgeZero Deploy GitHub Actions Implementation Plan + +**Status:** Revised plan (layered, adapter-independent) + +**Spec:** `docs/specs/edgezero-deploy-github-action.md` + +## Scope + +Implement the layered deploy actions in the EdgeZero monorepo: + +```text +.github/actions/build-app-cli +.github/actions/deploy-core +.github/actions/deploy-fastly +.github/actions/healthcheck-fastly +.github/actions/rollback-fastly +``` + +`build-app-cli` compiles the app-provided CLI package once and publishes it as an +artifact. `deploy-core` is the adapter-independent deploy engine that consumes +the prebuilt CLI. `deploy-fastly` is a minimal wrapper that types Fastly +credentials and calls the engine, with an optional `stage` mode. `healthcheck-fastly` +and `rollback-fastly` are thin Fastly-specific wrappers for the staging lifecycle +(§5.4). Provider orchestration (build, deploy, config push, provision, stage, +healthcheck, rollback) stays in the CLI. + +The actions deploy already checked-out application source. They do not perform +checkout or runtime config mutation as a deploy side effect. Fastly staging, +health checks, and rollback are provided as provider-specific actions (§5.4) +driven by the app CLI; the generic engine stays neutral. + +## Porting from the existing #303 action (reference) + +This design **supersedes** the monolithic Fastly action from #303 +(`.github/actions/deploy/`). That branch is not the base; its scripts are a +reference to port from. Most transfer with light changes: + +| Existing `.github/actions/deploy/` | New home | Disposition | +| -------------------------------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `scripts/common.sh` | `deploy-core/scripts/` | Reuse ~as-is (annotation escaping, helpers). | +| `scripts/cleanup.sh` | `deploy-core/scripts/` | Reuse. | +| `scripts/write-summary.sh` | `deploy-core/scripts/` | Reuse; update summary field names. | +| `scripts/validate-inputs.sh` | `deploy-core/scripts/` | Reuse; move Fastly-specific allowlist to the wrapper. | +| `scripts/resolve-project.sh` | `deploy-core/scripts/` | Reuse + split Git root vs Cargo workspace root. | +| `scripts/install-rust.sh` | dropped | Replaced by `actions-rust-lang/setup-rust-toolchain@v1` in deploy-fastly (toolchain from resolve output + wasm target). build-app-cli keeps `rustup` for dynamic app-resolved toolchain install. | +| `scripts/run-edgezero.sh` | `deploy-core/scripts/` | Adapt to invoke `` from the artifact + provider-env. | +| `tests/run.sh` | `deploy-core/tests/` | Reuse the harness; add new cases. | +| `scripts/install-fastly.sh`, `versions.json` | `deploy-fastly/` | Move (provider-specific install + checksum). | +| `scripts/install-edgezero.sh` | → `build-app-cli` | Rewrite: build the **app's** CLI package, not the monorepo CLI. | +| `action.yml` (one composite) | `build-app-cli/` + `deploy-fastly/` | Split into build + wrapper; engine is sourced scripts. | +| `.github/workflows/deploy-action.yml` | same path | Rewrite: de-Python, repin actions to tags. | +| cache `uses: actions/cache@` | `actions/cache@v4` | Repin to readable tag. | + +## Implementation phases + +1. **`build-app-cli` action** + - Required `app-cli-package` input: the Cargo package name of the CLI defined in + the application's own workspace. Fail if missing or not found in the app + workspace under `working-directory`. + - `working-directory`, `rust-toolchain`, `artifact-name` inputs (no `adapters` + input — the app's `Cargo.toml` pins adapters). + - Optional `app-cli-bin` input (default = `app-cli-package`; the generated CLI names + its bin after the package). + - Require a `Cargo.lock` at the app's Cargo workspace root; run all Cargo + commands with `--locked` (never mutate the lockfile). Validate via + `cargo metadata --locked` that `app-cli-package` exists and declares a + `` binary target. + - Install the host toolchain (no WASM target — the CLI is a native tool); + build into an **action-owned `CARGO_TARGET_DIR` under `RUNNER_TEMP`** (never + the checkout) so the CLI build leaves the app tree clean for the later + dirty-source guard, via + `cargo build --locked --release -p --bin `. No + `--features` injection: the app's own `Cargo.toml` pins its adapters. + - Read `app-cli-version` from `cargo metadata`; smoke-check with ` --help` + (today's CLI has no `--version`); write `app-cli-meta.json` (`app-cli-bin`, + `app-cli-version`, `app-cli-package`) next to the binary and upload both as one + **tar** so the executable bit survives `actions/upload-artifact` and the + artifact is self-describing. + - Outputs: `app-cli-version`, `app-cli-package`, `app-cli-bin`, `artifact-name`. + - No provider credentials in scope. Never builds the EdgeZero monorepo CLI. + +2. **`deploy-core` shared engine scripts (provider-neutral)** + - A directory of scripts under `.github/actions/deploy-core/`, **not** a + standalone composite action. Wrappers source them via + `$GITHUB_ACTION_PATH/../deploy-core/…`. + - Non-secret parameters (available to all steps): `adapter`, `app-cli-artifact`, + `app-cli-bin`, `working-directory`, `manifest`, `rust-toolchain`, `target`, + `build-mode`, `build-args`, `deploy-args`, `deploy-arg-allow`, + `provider-env-clear`, `deploy-flags`, `cache`. + - **`provider-env` is NOT one of these.** It is bound only to the deploy + step's own `env:` (step-scoped) and parsed only there — never present in the + setup/build step environments, so the secret-bearing blob cannot leak (spec + §5.2, §10). Setup/build see only the non-secret parameters plus + `provider-env-clear`. + - Download the CLI artifact (tar) under `RUNNER_TEMP`, extract preserving the + executable bit (or `chmod +x `), read `app-cli-meta.json` for + `app-cli-bin`/`app-cli-version` (wrapper `app-cli-bin` overrides), and PATH-scope it. + - Validate `adapter` well-formedness (no compiled-adapter enumeration — the + CLI rejects unknown adapters itself), booleans, JSON arrays/object, NUL + bytes. + - Confine `working-directory` and `manifest` inside `github.workspace` + (canonical paths, symlink resolution). + - Resolve **Git root** for `source-revision` and the dirty-source guard + (`build-app-cli`'s isolated target dir keeps this clean). + - Resolve the **Cargo workspace root** (`cargo locate-project --workspace`) + for lockfile hashing and `target/` caching — in a monorepo this may be under + `working-directory`, not the Git root (spec §11.1). + - Resolve Rust toolchain (explicit → Rustup files → `.tool-versions` → repo + fallback) and install the **wrapper-provided** concrete `target` (the engine + never maps `adapter` → target). + - Optional exact-key cache of the **Cargo workspace root** `target/` + restore/save. + - Resolve `build-mode`; optional credential-free build. + - Non-deploy steps: unset the `provider-env-clear` names (defense-in-depth; + `provider-env` itself is absent here). + - Deploy step only (its `env:` carries `provider-env`): clear the + `provider-env-clear` aliases, parse `provider-env` and export only its + values, then run + ` deploy --adapter -- ` via + Bash arrays. Note the build-in-deploy caveat: Fastly's default `never` + compiles the app during deploy with the token in scope, so require trusted + immutable refs (spec §10.1). + - Surface results to the wrapper: `adapter`, `source-revision`, `app-cli-version`, + `effective-build-mode`. + - Contains no provider-specific credential names, service concepts, endpoints, + or CLI flags; invokes ``, never a hard-coded `edgezero`. + +3. **`deploy-fastly` wrapper (minimal composite action)** + - Typed inputs: `app-cli-artifact`, `app-cli-bin`, `fastly-api-token`, + `fastly-service-id`, plus forwarded `working-directory`, `manifest`, + `build-mode`, `build-args`, `deploy-args`, `cache`, and `stage` (§5.4). + - Map `fastly-api-token` → `provider-env: {FASTLY_API_TOKEN: …}` and + `fastly-service-id` → action-owned + `deploy-flags: ["--service-id", …, "--non-interactive"]`; when + `stage: true`, add `--stage`. + - Set `adapter: fastly`, `target: wasm32-wasip1`, `deploy-arg-allow` = + `--comment` only, `provider-env-clear` = Fastly auth/endpoint aliases + (`FASTLY_API_TOKEN`, `FASTLY_SERVICE_ID`, `FASTLY_ENDPOINT`, …), Fastly + `auto` build-mode → `never`. + - **Install the pinned Fastly CLI** (official release + checksum, action-owned + dir on `PATH`) before sourcing the engine, so ` deploy --adapter fastly` + finds `fastly`. This is the wrapper's provider-tool responsibility; the + engine assumes `fastly` is already on `PATH`. + - Output `fastly-version` (parsed from the app CLI). Source the shared + `deploy-core` scripts; no build, toolchain, or path logic of its own. + +4. **Fastly staging lifecycle actions (§5.4)** + - `healthcheck-fastly`: thin wrapper — inputs `app-cli-artifact`, `app-cli-bin`, + `fastly-api-token`, `fastly-service-id`, `fastly-version`, `domain`, + `deploy-to` (`production`/`staging`), retry/timeout; runs + ` healthcheck --adapter fastly --service-id --version …` with + `FASTLY_API_TOKEN` in the step env; outputs `healthy`, `status-code`. + - `rollback-fastly`: thin wrapper — inputs `app-cli-artifact`, `app-cli-bin`, + `fastly-api-token`, `fastly-service-id`, `fastly-version`, `deploy-to`; + runs ` rollback --adapter fastly --service-id --version …` with + `FASTLY_API_TOKEN` in the step env; outputs `rolled-back-to`. + - Both map `fastly-service-id` → `--service-id` and `fastly-api-token` → + step-scoped `FASTLY_API_TOKEN`. They reuse only the CLI-artifact download + + credential-scoping helpers from `deploy-core`; no source resolution, + toolchain, build, cache, or Fastly CLI install (they call the Fastly API). + Carry no orchestration policy — the caller wires stage → healthcheck → + rollback. + +5. **`config-push-fastly` action + CLI staging support (§5.5)** + - CLI (`edgezero-adapter-fastly` + `edgezero-cli`): add `--staging` to + `config push`. `config.rs` already resolves one push entry + `(key, body)` where `key = args.key.unwrap_or(logical_store_id)`; when + `--staging` is set, derive the staging variant `_staging` (same + resolved store, different key). Mirror the derivation in `config diff` so a + staged diff compares against the staged key. Colocated tests: production key + unchanged, `--staging` suffixes, explicit `--key` + `--staging` composes. + - Engine (`deploy-core/scripts/run-app-cli.sh`): add a `config-push` mode + beside `build`/`deploy`. Same credential boundary — `provider-env` in, the + `EDGEZERO__*` namespace scrubbed before exec — invoking + ` config push --adapter ` with the wrapper's typed flags + (`--store`, `--key`, `--staging`). + - `config-push-fastly` wrapper: thin composite mirroring `deploy-fastly`. + Inputs `app-cli-artifact`, `app-cli-bin`, `fastly-api-token`, + `working-directory`, `manifest`, `app-config`, `store`, `key`, `deploy-to` + (`production`/`staging`, validated + fail-closed). Maps `fastly-api-token` + → `provider-env: {FASTLY_API_TOKEN: …}`; blanks the full alias list on every + step; installs the pinned Fastly CLI (the push shells out to + `fastly config-store-entry update`). Outputs `pushed-key`, `store`. + - Contract + smoke coverage: a `config-push` engine argv test; the lifecycle + smoke job's fake `fastly` gains `config-store list` / `config-store-entry +update` handlers, and a staged push asserts the `_staging` key reached + the store. + +6. **Scripts layout** + - Provider-neutral scripts under `deploy-core/`; the Fastly install + checksum + step lives with `deploy-fastly/` (or a shared script keyed by adapter). + - No CLI-build script here — CLI build lives entirely in `build-app-cli`. + +7. **CI workflow (`.github/workflows/deploy-action.yml`) — no Python** + - Pin third-party actions to readable released tags (`actions/checkout@v4`, + `actions/cache@v4`, artifact upload/download at released tags). + - Run `actionlint` from a pinned release binary (no `go run @`). + - Run `zizmor` from a pinned release binary or `cargo install zizmor --locked` + (no `pip`). + - Port the metadata-validation heredocs into `tests/run.sh`. + - Composite smoke test: `build-app-cli` → `deploy-fastly` (both production and + `stage: true`) → `healthcheck-fastly` → `rollback-fastly`. Fake each action's + real dependency: a fake `fastly` binary (marker files + printed version) for + `deploy-fastly`; a fake app CLI or stubbed Fastly API/`curl` responses for + `healthcheck-fastly`/`rollback-fastly` (they call the API, not `fastly`). + Assert CLI-artifact reuse, credential scoping, and `fastly-version` + threading. + +8. **Bash contract tests (`tests/run.sh`)** + - Cover engine + wrappers: adapter/boolean/JSON validation, path confinement, + symlink escape, dirty source, toolchain precedence, cache keys, credential + scoping, deploy-arg allowlist, build/deploy argv, cleanup, log redaction, + metadata contract checks, and the staging lifecycle (stage flag, version + output parsing, healthcheck/rollback argv, staging vs production paths). + - No Python; no live provider credentials. + +9. **Companion CLI scaffolding (`crates/edgezero-cli`, `edgezero-adapter-fastly`)** + - Add `#[command(version)]` to the downstream CLI template + (`crates/edgezero-cli/src/templates/cli/src/main.rs.hbs`) so generated app + CLIs expose `--version`. Until adopted, `build-app-cli` reads the version from + `cargo metadata` and smoke-checks with `--help`. + - Fastly staging deploy: extend the Fastly adapter `deploy` path with + `--stage` → `fastly compute update --autoclone --version=active` + + `fastly service-version stage`; emit the service version in a parseable form. + - Add `healthcheck` and `rollback` CLI subcommands with a Fastly adapter + implementation (staging-IP resolution via the Fastly API + `curl`; activate + previous / deactivate staged), and wire `Healthcheck` / `Rollback` arms into + the downstream CLI template so app CLIs expose them. + +10. **Docs** + +- Write `docs/guide/deploy-github-actions.md` around the three-layer model, + general EdgeZero-app-repo adoption, and the Fastly staging lifecycle. +- Document the app-provided CLI package build, artifact reuse, credential + scoping, adapter layering, staging trio, and non-goals. + +11. **Validation** + - Bash contract tests, `actionlint`, `shellcheck`, `zizmor`, checksum + metadata, docs validation, composite smoke test. + - Workspace Rust tests, format, clippy, and feature checks. + +## Known follow-up candidates + +- Add `deploy-cloudflare` / `deploy-spin` wrappers via the same engine. +- Add staging/health-check/rollback lifecycle actions for adapters **beyond + Fastly** (Fastly's trio is in current scope, phases 3–4 / 8). +- Optionally consume a prebuilt/attested CLI binary matching the app's pinned + version instead of compiling from source. diff --git a/docs/specs/edgezero-deploy-adoption-guide.md b/docs/specs/edgezero-deploy-adoption-guide.md new file mode 100644 index 00000000..1f3432c6 --- /dev/null +++ b/docs/specs/edgezero-deploy-adoption-guide.md @@ -0,0 +1,255 @@ +# EdgeZero Deploy Actions — Adoption Guide + +**Status:** Adoption guide for any EdgeZero application repository + +**Spec:** `docs/specs/edgezero-deploy-github-action.md` + +The layered deploy actions are for **any** EdgeZero application repository, not a +single deployer. This guide describes the general adoption shape and then walks +through the Trusted Server deployer as one concrete migration example. + +## 1. What a consumer gets + +Composable actions: + +- `build-app-cli` — compile the CLI package the application provides (a crate in the + app's own workspace) once, publish it as an artifact; +- `deploy-fastly` — deploy a checked-out Fastly application using the prebuilt + CLI artifact, to production or (with `stage: true`) a staged draft version; +- `healthcheck-fastly` / `rollback-fastly` — the Fastly staging lifecycle (§4); +- future `deploy-cloudflare` / `deploy-spin` wrappers over the same engine. + +The actions own repeatable deploy setup and the Fastly staging mechanisms. The +consumer owns checkout, ref selection, permissions, environments, concurrency, +timeouts, and **orchestrating** the health-check / rollback flow. + +## 2. Checkout layouts + +The adapters your CLI supports come from your app's own `Cargo.toml`, so +`build-app-cli` takes no `adapters` input — it builds your CLI package as declared. + +### 2.1 Same-repository application + +The app and its deploy workflow live in one repo. + +```yaml +jobs: + deploy: + runs-on: ubuntu-24.04 + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - id: cli + uses: stackpop/edgezero/.github/actions/build-app-cli@ + with: + app-cli-package: my-app-cli # the CLI crate in your app's workspace + + - uses: stackpop/edgezero/.github/actions/deploy-fastly@ + with: + app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} + fastly-api-token: ${{ secrets.FASTLY_API_TOKEN }} + fastly-service-id: ${{ vars.FASTLY_SERVICE_ID }} +``` + +### 2.2 Separate deployer and application repositories + +A deployment repo drives a separate application repo. Check the app into a path +and point both actions at it. + +> **Private app repos need their own token.** The deployer job's default +> `GITHUB_TOKEN` cannot read a _different_ private repository. Mint an app-scoped +> token first — e.g. with `actions/create-github-app-token` for a GitHub App +> installed on the app repo, or a fine-grained PAT with `contents: read` — and +> pass it to the application checkout's `token:`. The step below assumes an +> earlier `id: app-token` step produced `steps.app-token.outputs.token`. + +```yaml +steps: + - name: Checkout deployer + uses: actions/checkout@v4 + with: + path: deployer + persist-credentials: false + + - name: Checkout application + uses: actions/checkout@v4 + with: + repository: stackpop/my-edgezero-app + # MUST be a trusted, immutable ref (a full commit SHA, or a protected tag) + # — never an arbitrary branch. Fastly's default `build-mode: never` means + # `fastly compute deploy` COMPILES the application while the API token is + # in scope, so untrusted code would run with your credentials (spec §10.1). + ref: ${{ inputs.ref }} + path: app + persist-credentials: false + # A private app repo is NOT readable with the deployer's default + # GITHUB_TOKEN. Supply a token scoped to the app repo — a GitHub App + # installation token (preferred) or a fine-grained PAT with + # `contents: read` on the app repo: + token: ${{ steps.app-token.outputs.token }} + + - id: cli + uses: stackpop/edgezero/.github/actions/build-app-cli@ + with: + app-cli-package: my-app-cli + working-directory: app + + - uses: stackpop/edgezero/.github/actions/deploy-fastly@ + with: + app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} + working-directory: app + fastly-api-token: ${{ secrets.FASTLY_API_TOKEN }} + fastly-service-id: ${{ vars.FASTLY_SERVICE_ID }} +``` + +### 2.3 Monorepo application + +Select the app subdirectory and, when needed, an explicit manifest. Caching +resolves `target/` and `Cargo.lock` at the **Cargo workspace root** for that +subdirectory (which in a nested workspace may be the subdirectory itself, not the +repo root), so a monorepo caches the right artifacts. + +```yaml +steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - id: cli + uses: stackpop/edgezero/.github/actions/build-app-cli@ + with: + app-cli-package: api-cli + working-directory: apps/api + + - uses: stackpop/edgezero/.github/actions/deploy-fastly@ + with: + app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} + working-directory: apps/api + manifest: edgezero.toml + cache: true + fastly-api-token: ${{ secrets.FASTLY_API_TOKEN }} + fastly-service-id: ${{ vars.FASTLY_SERVICE_ID }} +``` + +## 3. Consumer requirements + +- Check out application source yourself; the actions never call + `actions/checkout`. +- Provide a CLI package in your own workspace and name it via `app-cli-package`; + `build-app-cli` compiles that package from your checkout, so the CLI and your app + never disagree on schema. `build-app-cli` does not use the EdgeZero monorepo CLI. +- Provide typed provider credentials through wrapper inputs, not caller `env:`. +- Ensure the deployed ref has committed source (no dirty working tree) and a + `Cargo.lock` at your app's **Cargo workspace root** (the workspace that owns + `app-cli-package` — in a nested-workspace monorepo this may be your app + subdirectory, not the repo root). `build-app-cli` requires it, and caching keys on + it. +- Pin action references to readable released tags, or full SHAs for production + reproducibility. +- Use least-privilege permissions (`contents: read`), protected environments, + `timeout-minutes`, and appropriate concurrency. + +## 4. Fastly staging lifecycle + +For Fastly, staging deploy, health checks, and rollback are supported as a +provider-specific trio, scaffolded into the CLI and exposed through your app CLI: + +- `deploy-fastly` with `stage: true` — deploy to a **staged** draft version + (Fastly `service-version stage`) instead of activating production; outputs + `fastly-version`. +- `healthcheck-fastly` — verify a version; for staging it resolves the Fastly + staging IP and probes the staged version specifically. +- `rollback-fastly` — production: activate the previous version; staging: + deactivate the staged version. + +You wire the trio; the actions carry no orchestration policy (see the spec §5.4 +for a worked staging workflow). + +## 5. What the actions intentionally do not do + +The deploy actions do not perform: internal application checkout; config +expansion or JSON→provider config conversion. Config push and provisioning are +explicit CLI subcommands (`edgezero config push`, `edgezero provision`) a +consumer may run as separate steps, not deploy side effects. The generic engine +stays provider-neutral — staging/health/rollback exist only as Fastly-specific +actions (§4), not engine behavior. + +## 6. Worked example — Trusted Server deployer migration + +### 6.1 Current behavior + +The Trusted Server deployer orchestrates Fastly deploys with: + +- `.github/workflows/deploy.yml` (manual) and `daily-deploy.yml` (scheduled); +- `stackpop/trusted-server-actions/fastly/deploy@v2`; +- `stackpop/trusted-server-actions/fastly/healthcheck@v2`; and +- `stackpop/trusted-server-actions/fastly/rollback@v2`. + +The old deploy action checks out Trusted Server internally, accepts +`trusted-server-ref`, expands `trusted-server-config`, supports Fastly staging, +and returns `fastly-version`. + +### 6.2 Compatibility with the EdgeZero actions + +The EdgeZero actions now cover Fastly **staging, health checks, rollback, and the +`fastly-version` output**, so the Trusted Server deployer can move off the legacy +`fastly/deploy|healthcheck|rollback@v2` actions. The remaining differences the +deployer must handle itself are: internal checkout, `trusted-server-ref`, +`trusted-server-config` expansion, and legacy JSON→Config Store TOML conversion. + +### 6.3 Recommended migration + +Map the legacy trio onto the EdgeZero staging trio: + +| Legacy action | EdgeZero replacement | +| ------------------------------------------ | -------------------------------------------------- | +| `fastly/deploy@v2` (with `fastly-staging`) | `build-app-cli` + `deploy-fastly` (`stage:` input) | +| `fastly/healthcheck@v2` | `healthcheck-fastly` | +| `fastly/rollback@v2` | `rollback-fastly` | + +Workflow shape: + +1. check out `trusted-server-deployer` with `persist-credentials: false`; +2. check out Trusted Server source separately at the selected ref into + `trusted-server`; +3. run `build-app-cli` with `app-cli-package: ` and + `working-directory: trusted-server` (Trusted Server's own CLI package, whose + `Cargo.toml` already pins the Fastly adapter); +4. run `deploy-fastly` (set `stage: true` for staging) with the CLI artifact, + `working-directory: trusted-server`, typed Fastly credentials, and optional + `deploy-args: ["--comment", …]`; capture `fastly-version`; +5. run `healthcheck-fastly` with the CLI artifact, typed Fastly credentials + (`fastly-api-token`, `fastly-service-id`), `deploy-to`, `domain`, and the + captured `fastly-version`; +6. on failure, run `rollback-fastly` with the CLI artifact, typed Fastly + credentials (`fastly-api-token`, `fastly-service-id`), and the same + `deploy-to` / `fastly-version`; and +7. write a summary from the action outputs. + +### 6.4 Required deployer changes + +- Add explicit Trusted Server checkout; the EdgeZero actions do not call + `actions/checkout`. +- Replace the legacy `fastly/*@v2` trio with `build-app-cli` + `deploy-fastly` + + `healthcheck-fastly` + `rollback-fastly`. +- Pin action references to readable released tags, or full SHAs for production. +- Read the version from `steps..outputs.fastly-version` (same concept as + the legacy `fastly-version`). +- Audit `TRUSTED_SERVER_CONFIG`; if still needed, keep config expansion in the + deployer workflow or run `edgezero config push` as an explicit step before or + after deploy. +- Confirm the canonical Trusted Server repository/ref has a `Cargo.lock` at the + CLI package's Cargo workspace root, plus `Cargo.toml`, `fastly.toml`, and + preferably `edgezero.toml`. + +### 6.5 Gotchas + +- `daily-deploy.yml` appears to stage but health-check/rollback production by + default. Decide whether the scheduled workflow is production or staging and set + `deploy-to` / `stage` consistently before migration. +- The old action targets `IABTechLab/trusted-server`; verify the actual + deployment refs before switching. diff --git a/docs/specs/edgezero-deploy-github-action.md b/docs/specs/edgezero-deploy-github-action.md new file mode 100644 index 00000000..4f8ce698 --- /dev/null +++ b/docs/specs/edgezero-deploy-github-action.md @@ -0,0 +1,1000 @@ +# EdgeZero Deploy GitHub Actions — Layered, Adapter-Independent Spec + +**Status:** Revised design (supersedes the Fastly-only v0 spec) + +**Date:** 2026-07-08 + +**Delivery target:** implementation in the `stackpop/edgezero` monorepo + +**Action paths:** + +```text +.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 +``` + +## 1. Executive summary + +Ship reusable GitHub composite actions that deploy a checked-out EdgeZero +application to a supported provider by driving the EdgeZero CLI. + +The design is **layered** so a new adapter is added without rewriting the deploy +engine, and the **CLI is the boundary** — the actions never reproduce provider +build, deploy, config-push, or provision logic in YAML or shell. Everything an +adapter needs (`edgezero build`, `edgezero deploy`, `edgezero config push`, +`edgezero provision`) already lives in the CLI; the actions only compile the +CLI, scope credentials, and invoke the right subcommand. + +The CLI is **the app's own CLI package.** The application tells `build-app-cli` which +CLI package to compile (a crate in the application's own workspace), and +`build-app-cli` builds it from the application checkout. It is **not** the EdgeZero +monorepo CLI and **not** the action's own repository revision. Because the CLI is +the app's own package, built from the app's source and lockfile, the CLI and the +application always agree on the manifest, adapter, and config schema — and an app +may ship a CLI extended with its own commands. + +Three layers: + +| Layer | Action | Responsibility | +| --------------- | ------------------- | ------------------------------------------------------------------------------------- | +| Build | `build-app-cli` | Compile the app-provided CLI package **once** and publish it. | +| Engine (shared) | `deploy-core` | Adapter-independent engine **scripts** sourced by wrappers; consume the prebuilt CLI. | +| Adapter wrapper | `deploy-fastly` (…) | Minimal per-adapter shim: type provider credentials, call the engine. | + +The actions do not check out application source. The caller owns checkout, +repository permissions, ref selection, GitHub Environment policy, concurrency, +timeouts, and **orchestrating** the health-check / rollback process (the Fastly +mechanisms themselves are provided as actions — §5.4). + +The core boundary is EdgeZero itself: + +```text + build --adapter + deploy --adapter +``` + +where `` is the application's own CLI binary built by `build-app-cli`. + +The generic engine stays provider-neutral. Provider-specific staging, health +checks, and rollback are supported for Fastly as a separate lifecycle (§5.4) — +scaffolded into the EdgeZero CLI's Fastly adapter and exposed through the app's +own CLI, with thin action wrappers — so the engine never grows provider logic. + +## 2. Design principles + +1. **EdgeZero CLI is the deployment boundary.** The actions invoke CLI + subcommands instead of reproducing provider logic. Provider orchestration + belongs in the CLI, not in action shell scripts. +2. **Layered and adapter-independent.** A generic `deploy-core` engine holds all + provider-neutral behavior. Adapter wrappers are minimal and only supply typed + credentials and the adapter name. Adding an adapter adds a wrapper; it does + not fork the engine. +3. **The caller owns source.** The actions never call `actions/checkout`. +4. **The application provides the CLI package.** The app tells `build-app-cli` which + CLI package to compile via a required `app-cli-package` input, and `build-app-cli` + builds that package from the application's own checkout. It never builds the + EdgeZero monorepo CLI or the action's own repository revision. The + application owns which CLI deploys it. +5. **Compile once, reuse everywhere.** `build-app-cli` compiles the CLI a single + time per workflow and publishes it as an artifact. Deploy actions consume the + prebuilt binary and never recompile it. +6. **Typed provider credentials.** Credentials are passed through wrapper action + inputs, not caller `env:`, so setup and build steps never inherit provider + tokens. Only the provider mutation step receives them. +7. **No shell string APIs.** Passthrough arguments are JSON arrays invoked + through Bash arrays without `eval`. +8. **No Python in tooling or CI.** Validation, metadata checks, and security + scans run through Bash, `jq`, and pinned release binaries (`actionlint`, + `zizmor`). No `python3` heredocs and no `pip install`. +9. **Pin third-party actions to readable released tags.** Reusable third-party + actions are referenced by their published major/minor tag (for example + `actions/checkout@v4`), not opaque commit SHAs chosen ad hoc. +10. **Safe by default.** Caching is opt-in, deploys require committed source, and + provider credentials never reach outputs, summaries, caches, or + action-global environment files. + +## 3. Goals + +1. Deploy any checked-out EdgeZero application from GitHub Actions through the + EdgeZero CLI. +2. Keep the deploy engine adapter-independent so Cloudflare, Spin, and future + adapters reuse it. +3. Compile the CLI package the application provides, from the application's own + source, so the CLI and the deployed application never disagree on schema. +4. Compile the CLI once and share the artifact across deploy steps and jobs. +5. Support same-repository, separate-repository, private-repository, and + monorepo checkout layouts. +6. Respect the application's `edgezero.toml` when present and support explicit + `working-directory` and `manifest` selection. +7. Accept typed provider credentials and expose them only to the provider + mutation step. +8. Support JSON-array build and deploy passthrough arguments. +9. Support opt-in, exact-key application `target/` caching. +10. Produce actionable validation failures before deployment begins. +11. Keep all tooling and CI free of Python; use pinned release binaries. + +## 4. Non-goals + +The **generic** engine (`deploy-core`) will not: + +1. check out application source; +2. choose an application ref; +3. deploy more than one adapter per `deploy-*` invocation; +4. provision provider resources or push runtime config as a side effect of + deploy — config push is its own action (`config-push-fastly`, §5.5), and + provision remains an explicit CLI subcommand the caller may run separately; +5. implement provider staging, health checks, rollback, or deployment-version + parsing **in the provider-neutral engine** — these are provider-specific and + live in the Fastly staging lifecycle actions (§5.4), driven by the app CLI; +6. configure GitHub job permissions, environments, approvals, concurrency, or + timeouts; +7. support Windows or macOS runners; +8. publish a stable version alias; or +9. provide a general `setup` action for running arbitrary EdgeZero commands + (the CLI is available via the `build-app-cli` artifact for callers who need it). + +Staging deploy, health checks, and rollback **are** supported for Fastly, as a +provider-specific lifecycle (§5.4). The engine stays neutral; the capability is +scaffolded into the EdgeZero CLI's Fastly adapter and exposed through the app's +own CLI, with thin action wrappers. + +## 5. Architecture + +### 5.1 Layer 1 — `build-app-cli` + +Compiles the **CLI package the application provides** — a crate in the +application's own workspace, named by the required `app-cli-package` input — once, +and publishes it as a workflow artifact so every downstream deploy step consumes +the same binary. The CLI is built from the application checkout and its lockfile, +so it matches the application and may include app-specific commands. `build-app-cli` +never builds the EdgeZero monorepo CLI. + +**Inputs** + +| Input | Required | Default | Contract | +| ------------------- | -------- | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `app-cli-package` | Yes | none | Cargo package name of the CLI to build, defined in the application's own workspace. `build-app-cli` builds this package from the application checkout. | +| `app-cli-bin` | No | `` | Binary name produced by `app-cli-package`. Defaults to the package name (the generated downstream CLI names its bin after the package). | +| `working-directory` | No | `.` | Application directory (relative to `github.workspace`) containing the workspace/lockfile that defines `app-cli-package`. Must resolve inside `github.workspace`. | +| `rust-toolchain` | No | `auto` | Explicit toolchain, or `auto` to follow the application toolchain resolution precedence (§7). | +| `app-cli-artifact` | No | `edgezero-cli` | Name of the uploaded artifact. | + +There is intentionally no `adapters` / features input. The application's own +`Cargo.toml` already pins which adapters compile into its CLI (through the +`edgezero-cli` dependency it declares); `build-app-cli` builds the package exactly as +the application declares it, so the app owns adapter selection. + +**Outputs** + +| Output | Meaning | +| ----------------- | -------------------------------------------------------------- | +| `app-cli-version` | CLI package version, read from `cargo metadata` at build time. | +| `app-cli-package` | The application CLI package that was built. | +| `app-cli-bin` | The binary name inside the artifact. | +| `artifact-name` | Name of the uploaded CLI artifact for downstream `download`. | + +**Behavior** + +1. Require a `Cargo.lock` at the app's Cargo workspace root (see §11.1); fail + with a remediation message if it is missing. All Cargo commands run with + `--locked` so the build never creates or updates the lockfile. +2. Confirm via `cargo metadata --locked` that `app-cli-package` exists in the + application workspace under `working-directory` and that it declares a binary + target named `` (default ``). Fail if either is absent. +3. Install the resolved host Rust toolchain (§7). The CLI is a native host tool; + the WASM target needed to build the _application_ is installed later by the + deploy engine, not here. +4. Build **into an action-owned `CARGO_TARGET_DIR` below `RUNNER_TEMP`**, never + the app checkout, so the CLI build does not write `target/` into the + application working tree (which the deploy engine later dirty-checks): + + ```text + CARGO_TARGET_DIR=/edgezero-cli-build \ + cargo build --locked --release -p --bin + ``` + +5. Read `app-cli-version` from `cargo metadata` for `app-cli-package`, and smoke-check + the binary with ` --help` (today's CLI has no `--version`; see the + note below). +6. Write a small metadata file (`app-cli-meta.json`) next to the binary containing + `app-cli-bin`, `app-cli-version`, and `app-cli-package`. +7. Upload the binary **and `app-cli-meta.json`** as a single **tar archive** so the + executable bit survives the round trip (`actions/upload-artifact` zips and + drops POSIX permissions). + +The artifact is self-describing: the engine reads `app-cli-meta.json` to learn the +binary name and version, so callers do not have to re-pass `app-cli-bin`/`app-cli-version` +(a wrapper `app-cli-bin` input, if given, overrides the metadata). + +`build-app-cli` never receives provider credentials and leaves the app checkout +clean (no `target/`, no lockfile mutation), so a later dirty-source guard passes. + +> **Companion CLI improvement (tracked separately):** the generated downstream +> CLI template currently sets no clap `version`, so ` --version` fails. Add +> `#[command(version)]` to the downstream CLI template so future apps expose a +> version surface. Until then, `app-cli-version` comes from `cargo metadata` and the +> runnability check uses `--help`. + +### 5.2 Layer 2 — `deploy-core` (shared engine scripts) + +Adapter-independent. Holds every provider-neutral concern so wrappers stay +minimal. `deploy-core` is **not a standalone composite action**; it is a +directory of shared scripts under `.github/actions/deploy-core/`. Each adapter +wrapper is the real composite action and sources these scripts through +`$GITHUB_ACTION_PATH/../deploy-core/…`, which resolves inside the same fetched +EdgeZero action repository. This avoids referencing a "private" sibling action by +ref and keeps one engine for every adapter. + +The engine is parameterized by the values the wrapper passes to those scripts +(as environment variables), conceptually: + +| Parameter | Meaning | +| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `adapter` | Passed to ` deploy --adapter `. The engine does **not** enumerate compiled adapters; the CLI rejects an unknown adapter with its own error. | +| `app-cli-artifact` | Name of the `build-app-cli` artifact to download. The engine reads `app-cli-bin` and `app-cli-version` from the artifact's `app-cli-meta.json`. | +| `app-cli-bin` | Optional override for the binary name; if empty, taken from `app-cli-meta.json`. | +| `working-directory` | Application directory relative to `github.workspace`. Must resolve inside `github.workspace`. | +| `manifest` | Optional `edgezero.toml` path relative to `working-directory`. If set, must exist; exported as `EDGEZERO_MANIFEST`. | +| `rust-toolchain` | Application Rust toolchain for the deploy build. `auto` follows §7. | +| `target` | Concrete application build target the **wrapper** supplies (Fastly → `wasm32-wasip1`). The engine installs exactly this target and never maps `adapter` → target, so adding an adapter does not touch the engine. | +| `build-mode` | One of `auto`, `always`, `never` (§8). | +| `build-args` | JSON array of strings passed after ` build --adapter --`. Must not contain secrets. | +| `deploy-args` | JSON array of caller-supplied deploy args appended after action-owned deploy flags. Must not contain secrets. | +| `deploy-arg-allow` | Adapter allowlist pattern for caller `deploy-args` (wrapper-provided; §9). | +| `provider-env` | JSON object of provider credential names → values. Present **only in the deploy step's own environment** (step-scoped `env:`); the variable never reaches setup/build steps, and the engine parses it only inside the deploy step (§10). | +| `provider-env-clear` | JSON array of env var names (wrapper-provided) the engine unsets in non-deploy steps and clears + re-exports from `provider-env` inside the deploy step (§10). Defense-in-depth against inherited caller `env:`; keeps clearing provider-agnostic. | +| `deploy-flags` | JSON array of action-owned deploy flags the wrapper injects before caller `deploy-args` (`--service-id …`, `--non-interactive`). | +| `cache` | Enable exact-key application `target/` caching (`true`/`false`). | + +The wrapper surfaces engine results as its own outputs: `adapter`, +`source-revision`, `app-cli-version`, `effective-build-mode`. + +The engine contains no provider-specific credential names, service concepts, +endpoints, or CLI flags — those, and the list of aliases to clear +(`provider-env-clear`), all arrive from the wrapper. It invokes the application's +CLI binary (``), not a hard-coded `edgezero`. + +The engine runs one of three modes: `build`, `deploy`, and `config-push` (§5.5). +`config-push` reuses the same credential boundary as `deploy` — the token is +delivered through `provider-env`, and the private `EDGEZERO__*` namespace is +scrubbed before the CLI runs — but invokes the app CLI's `config push` subcommand +(with the wrapper's typed `store`, `key`, and `--staging` flags) instead of +`deploy`. + +### 5.3 Layer 3 — adapter wrappers (`deploy-fastly`, …) + +Minimal composite actions. A wrapper only: + +1. declares the provider's typed credential inputs; +2. maps them into `provider-env` and action-owned `deploy-flags`; +3. sets `adapter`, a **concrete `target`** (for Fastly, `wasm32-wasip1`), the + adapter `deploy-arg` allowlist, and the `provider-env-clear` alias list; +4. **installs the pinned provider CLI it needs** — for Fastly, the Fastly CLI + (official release, checksum-verified, into an action-owned dir on `PATH`), so + the app CLI's ` deploy --adapter fastly` (which shells out to `fastly`) + finds it. This is the one provider-specific install, and it lives in the + wrapper precisely so the provider-neutral engine never learns provider tools; + and +5. sources the shared `deploy-core` scripts via `$GITHUB_ACTION_PATH/../deploy-core`. + +A wrapper contains no build logic, no toolchain resolution, no path +confinement — those are engine concerns. Provider **tooling** (the Fastly CLI) +is a wrapper concern; the engine assumes the provider CLI is already on `PATH`. + +**`deploy-fastly` inputs** + +| Input | Required | Default | Contract | +| ------------------- | -------- | ------------- | ---------------------------------------------------------------------------------------------- | +| `app-cli-artifact` | Yes | none | `build-app-cli` artifact name. Forwarded to the engine. | +| `app-cli-bin` | No | from artifact | Binary name inside the artifact. Forwarded to the engine. | +| `fastly-api-token` | Yes | none | Mapped into `provider-env` as `FASTLY_API_TOKEN`, deploy step only. | +| `fastly-service-id` | Yes | none | Mapped into action-owned `deploy-flags` as `--service-id ` to prevent accidental creation. | +| `working-directory` | No | `.` | Forwarded to the engine. | +| `manifest` | No | empty | Forwarded to the engine. | +| `build-mode` | No | `auto` | Forwarded. Fastly `auto` resolves to `never`. | +| `build-args` | No | `[]` | Forwarded to the engine. | +| `deploy-args` | No | `[]` | Forwarded. Allowlisted to `--comment` for Fastly (§9). | +| `stage` | No | `false` | When `true`, deploy to a **staged** draft version instead of activating production (§5.4). | +| `cache` | No | `false` | Forwarded to the engine. | + +**`deploy-fastly` outputs** + +| Output | Meaning | +| ----------------- | ------------------------------------------------------------------------------------------ | +| `fastly-version` | The Fastly service version deployed (production) or staged. Emitted by the app CLI (§5.4). | +| `source-revision` | Passthrough from the engine. | +| `app-cli-version` | Passthrough from the engine. | + +The wrapper sets `adapter: fastly`, `target: wasm32-wasip1`, the action-owned +`deploy-flags` (`--service-id …`, `--non-interactive`) so deployments cannot +prompt in CI or select an unintended service, and +`provider-env-clear: ["FASTLY_API_TOKEN", "FASTLY_SERVICE_ID", "FASTLY_ENDPOINT", +"FASTLY_CARGO_PROFILE", …]` so the engine clears Fastly auth/endpoint aliases +without the engine itself knowing Fastly's names. When `stage: true` it adds +`--stage` to the deploy flags (§5.4). + +### 5.4 Fastly staging lifecycle (`deploy-fastly` stage mode, `healthcheck-fastly`, `rollback-fastly`) + +Staging parity with `stackpop/trusted-server-actions` is supported for Fastly as +a **provider-specific lifecycle**. The generic engine is untouched; all Fastly +staging semantics live in the **EdgeZero CLI's Fastly adapter** and are invoked +through the app's own CLI. The three actions are thin wrappers over app-CLI +subcommands. + +#### 5.4.1 CLI scaffolding (companion work, in `edgezero-adapter-fastly`) + +The capability is scaffolded into the CLI, not reproduced in action shell: + +| App-CLI invocation | Fastly operations the adapter performs | +| --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| ` deploy --adapter fastly --service-id ` (production, existing) | `fastly compute deploy` → builds, uploads, **activates**; emits the activated version. | +| ` deploy --adapter fastly --service-id --stage` | `fastly compute update --autoclone --version=active` (upload to a new **draft** version, no activation) → `fastly service-version stage`; emits the staged version. | +| ` healthcheck --adapter fastly --service-id --version --domain [--staging]` | Production: `curl` the domain. Staging: resolve `staging_ips` for `` on `` via the Fastly API, then `curl --connect-to` that IP; emits healthy/status. | +| ` rollback --adapter fastly --service-id --version [--staging]` | Production: activate ` - 1` on ``. Staging: deactivate the staged `` on ``. | + +Every Fastly subcommand takes `--service-id ` (the service the operation +targets) and reads `FASTLY_API_TOKEN` from the environment. The app CLI (built by +`build-app-cli`) exposes these subcommands the same way it exposes `deploy`/`config`. +The downstream CLI template gains `Healthcheck` and `Rollback` arms and a +deployment-version surface, tracked with the other companion CLI changes. + +#### 5.4.2 Version output + +Because staging must thread a version from deploy → healthcheck → rollback, the +Fastly path emits the service version. The app CLI prints it in a parseable form +(e.g. a `version=` line or `--output` file); `deploy-fastly` surfaces it as +the `fastly-version` output. This is the one **provider-specific** output; the +generic engine still exposes no deployment version. + +#### 5.4.3 The three actions + +- **`deploy-fastly` (`stage: true`)** — runs + ` deploy --adapter fastly --service-id --stage` (the wrapper injects + `--service-id` via `deploy-flags`); outputs `fastly-version` (the staged + draft). Reuses the engine for build/source/credential scoping; only the + `--stage` flag differs. +- **`healthcheck-fastly`** — thin wrapper: downloads the CLI artifact, takes + `fastly-api-token`, `fastly-service-id`, `fastly-version`, `domain`, + `deploy-to` (`production`/`staging`), retry/timeout inputs; runs + ` healthcheck --adapter fastly --service-id --version …` with + `FASTLY_API_TOKEN` in the step env; outputs `healthy` and `status-code`. It + **exits non-zero after retries when the probe is unhealthy** (so a caller can + gate rollback on `if: failure()`), while still emitting the outputs. Needs no + application source or build. +- **`rollback-fastly`** — thin wrapper: takes `fastly-api-token`, + `fastly-service-id`, `fastly-version`, `deploy-to`; runs + ` rollback --adapter fastly --service-id --version …` with + `FASTLY_API_TOKEN` in the step env; on production emits `rolled-back-to`. Needs + no application source or build. + +`healthcheck-fastly` and `rollback-fastly` map `fastly-service-id` → the +`--service-id` flag and `fastly-api-token` → step-scoped `FASTLY_API_TOKEN` +(same credential discipline as deploy). They reuse only the CLI-artifact download +and credential-scoping helpers from `deploy-core`; they skip source resolution, +toolchain install, build, and cache, since they operate on Fastly service +versions via the API, not on application source. They need no Fastly CLI install +(they call the Fastly API, not `fastly compute …`). + +#### 5.4.4 Composing the lifecycle + +A caller wires the trio; the actions carry no orchestration policy of their own: + +```yaml +- id: cli + uses: stackpop/edgezero/.github/actions/build-app-cli@ + with: { app-cli-package: my-app-cli } + +- id: stage + uses: stackpop/edgezero/.github/actions/deploy-fastly@ + with: + app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} + stage: true + fastly-api-token: ${{ secrets.FASTLY_API_TOKEN }} + fastly-service-id: ${{ vars.FASTLY_SERVICE_ID }} + +- id: check + uses: stackpop/edgezero/.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: ${{ secrets.FASTLY_API_TOKEN }} + fastly-service-id: ${{ vars.FASTLY_SERVICE_ID }} + +- if: failure() && steps.stage.outputs.fastly-version != '' + uses: stackpop/edgezero/.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: ${{ secrets.FASTLY_API_TOKEN }} + fastly-service-id: ${{ vars.FASTLY_SERVICE_ID }} +``` + +Because these are Fastly-specific, future adapters do not inherit them; a new +adapter adds its own lifecycle actions if its provider supports staging. + +### 5.5 Config push (`config-push-fastly`) + +Deploy activates code; it never writes runtime config (§4). Pushing the typed +app config to the provider's config store is a **separate** action, +`config-push-fastly`, so a caller decides when config moves and can push it +independently of a code deploy. + +It follows the deploy pattern exactly: it consumes the prebuilt `build-app-cli` +artifact, takes typed provider credentials, and drives the **app's own CLI** +(` config push --adapter fastly`). The engine adds a `config-push` mode +alongside `build`/`deploy` (§5.2); the credential boundary (§10) is identical — +the token reaches only this step, and the private namespace is scrubbed before +the CLI runs. + +#### 5.5.1 Staging model — same store, different key + +Fastly config stores are **not versioned** like the draft service versions the +deploy-staging path clones, so "push config to staging" cannot mean a draft. It +means the **same config store, a different key**: + +- **Production** writes the config blob under the resolved key (the logical store + id, or an explicit `--key`). +- **Staging** writes under the staging variant of that key — `_staging` — + in the **same** store. + +This mirrors the runtime-override store the Fastly adapter already scaffolds +(`provision` emits a store whose `EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY` +entry selects `app_config` vs `app_config_staging`). The service reads whichever +key that override selects, so pushing staging config never touches the key the +production service is reading. The CLI gains a `config push --staging` flag (the +same `--staging` verb `deploy`/`healthcheck`/`rollback` already use); the wrapper +exposes it as `deploy-to: production | staging`, validated exactly and +fail-closed like the lifecycle actions. + +#### 5.5.2 Inputs / outputs + +| Input | Required | Default | Meaning | +| ------------------- | -------- | ------------- | --------------------------------------------------------------- | +| `app-cli-artifact` | Yes | — | The `build-app-cli` artifact to run. | +| `fastly-api-token` | Yes | — | Injected only into the push step. | +| `working-directory` | No | `.` | App directory (holds the manifest + typed config). | +| `app-cli-bin` | No | from artifact | Binary name inside the artifact. | +| `manifest` | No | empty | `edgezero.toml` path relative to `working-directory`. | +| `app-config` | No | empty | Typed config file path (default: resolved from the manifest). | +| `store` | No | empty | Logical config-store id (default: the manifest's resolved id). | +| `key` | No | empty | Explicit base key (default: the logical store id). | +| `deploy-to` | No | `production` | `staging` writes the `_staging` variant in the same store. | + +Outputs: `pushed-key` (the key that was written — the base key, or its +`_staging` variant), `store` (the resolved logical store id). + +A staged deploy plus a staged config push and a healthcheck compose the same way +the lifecycle trio does (§5.4.4): push staging config, deploy the staged version, +probe it, roll back on failure. + +## 6. Execution flow (engine) + +1. Verify the runner is Linux x86-64 (`ubuntu-24.04` is the tested environment). +2. Validate that `adapter` is a well-formed, non-empty token. The engine does + **not** enumerate the CLI's compiled adapters (there is no introspection + command); an unsupported adapter surfaces as the CLI's own error at build or + deploy time. +3. Validate exact boolean inputs. +4. Download the `app-cli-artifact` (a tar) into an action-owned directory below + `RUNNER_TEMP`, extract it preserving permissions (or `chmod +x `), + read `app-cli-meta.json` for `app-cli-bin`/`app-cli-version` (a wrapper `app-cli-bin` input + overrides), and prepend the directory to `PATH` for action steps only. +5. Parse `build-args`, `deploy-args`, `deploy-flags`, `provider-env-clear` as + JSON string arrays. **`provider-env` is not present in these steps** — it is + scoped to the deploy step's own `env:` and parsed only there (step 17), so + setup/build never hold the secret-bearing blob. +6. In every non-deploy step (setup, build), unset each name in + `provider-env-clear` (defense-in-depth against inherited caller `env:`). +7. Reject NUL-containing argument or value entries. +8. Apply the adapter's deploy-arg allowlist (wrapper-provided) to `deploy-args`. +9. Resolve `working-directory` beneath `github.workspace` using canonical paths + and symlink resolution; fail if missing or not a directory. +10. If `manifest` is non-empty: resolve it under `working-directory`, fail if it + escapes `github.workspace` or is not a regular file, and export + `EDGEZERO_MANIFEST`. +11. Resolve the application **Git root**; record `source-revision`; fail on a + dirty working tree (`build-app-cli` used an isolated `CARGO_TARGET_DIR`, so its + CLI build did not dirty this tree). +12. Resolve the **Cargo workspace root** for `working-directory` (§11.1) for all + Cargo-scoped operations that follow. +13. Resolve the application Rust toolchain (§7) and install it plus the + **wrapper-provided** application `target` (Fastly → `wasm32-wasip1`). The + engine does not map `adapter` → target. +14. If `cache: true`, restore the exact-key **Cargo workspace root** `target/` + cache. +15. Print non-sensitive diagnostics. +16. Resolve `build-mode` (§8). If `always`, run + ` build --adapter -- ` with **no** provider + credentials in scope (the `provider-env-clear` names stay unset here). +17. In a separate deploy step whose step-scoped `env:` is the **only** place + `provider-env` is exposed: clear the `provider-env-clear` aliases, parse + `provider-env` and export only its values, and run: + + ```text + deploy --adapter -- + ``` + + For adapters whose deploy also compiles the application (Fastly's default), + this step builds application code with credentials in scope — see §10.1. + +18. Clean action-owned temporary tool, auth, log, and cache state with + `if: always()`. +19. Save the application cache when enabled and safe. +20. Set outputs and write a non-sensitive GitHub step summary with + `if: always()`. + +When an argument array is empty, the trailing `--` may be omitted. + +## 7. Toolchain resolution + +Application Rust toolchain resolution precedence: + +1. explicit `rust-toolchain` input when not `auto`; +2. nearest `rust-toolchain.toml` or `rust-toolchain`, walking from + `working-directory` to the application Git root; +3. nearest `rust` entry in `.tool-versions` over the same path; and +4. the EdgeZero repository root `.tool-versions` `rust` entry. + +At each directory, Rustup-native files take precedence over `.tool-versions`. +Malformed toolchain files fail instead of silently selecting a different +compiler. `build-app-cli` uses the same precedence for the CLI build. + +## 8. Build behavior + +| Value | Behavior | +| -------- | ---------------------------------------------------------------- | +| `auto` | Apply the adapter's default policy. | +| `always` | Run ` build --adapter ` before deploy. | +| `never` | Skip the separate build; deploy builds or consumes the artifact. | + +Fastly `auto` resolves to `never` because ` deploy --adapter fastly` +builds unless a prebuilt package is provided. Other adapters define their own +default policy in their wrapper. + +## 9. Passthrough arguments + +`build-args`, `deploy-args`, and `deploy-flags` are JSON arrays so argument +boundaries are explicit: + +```yaml +with: + build-args: '["--verbose"]' + deploy-args: '["--comment", "deployed by GitHub Actions"]' +``` + +`deploy-core` must: + +- parse arrays with `jq` (no Python); +- reject non-arrays, non-string entries, and NUL bytes; +- construct commands as Bash arrays; +- never use `eval`; and +- avoid printing raw JSON inputs during validation. + +Each adapter wrapper supplies an allowlist for caller `deploy-args`. For Fastly, +`deploy-args` are allowlisted to `--comment VALUE` / `--comment=VALUE`; all other +deploy args are rejected so caller input cannot override typed service selection, +authentication, non-interactive mode, endpoint, profile, or debug behavior. The +allowlist ships with accept/reject tests. + +### 9.1 `deploy-args` under `--stage` + +A staged deploy does not run `fastly compute deploy`; it runs +`fastly compute update` against a cloned draft version (§5.4). Flags that only +exist on `compute deploy` — `--env`, `--domain`, `--status-check-*`, `--dir` / +`-C`, `--no-default-domain` — are therefore **no-ops under `--stage`**: the +adapter drops them with a warning rather than passing an unsupported flag to +`compute update`. + +`--comment` is a special case. `compute update` has no `--comment`, so the +adapter applies the comment out-of-band via `fastly service-version update +--comment …` **before** staging the version, preserving the caller's intent +without breaking the upload. + +## 10. Provider credential contract + +Credentials flow through the `provider-env` JSON object, which `deploy-core` +injects **only** into the deploy step: + +```text +wrapper inputs (typed) → provider-env {NAME: value} → deploy step env → CLI +``` + +Rules: + +- **`provider-env` is bound only to the deploy step's own `env:`** — a + step-scoped environment, not a job/engine-global variable. Setup, `build-app-cli`, + and separate build steps never receive the `provider-env` variable at all, so + the secret-bearing blob is absent from their environments (not merely unset + after the fact). The engine parses `provider-env` only inside the deploy step. +- Alias clearing is **wrapper-driven and provider-agnostic**, and is + defense-in-depth for a _different_ threat — a caller who sets provider aliases + through their own workflow `env:`. The engine unsets the wrapper-supplied + `provider-env-clear` names in non-deploy steps, and clears them in the deploy + step before exporting only the `provider-env` values. The engine hard-codes no + provider names, so caller `env:` cannot override the typed contract. +- `provider-env` values never reach `GITHUB_ENV`, `GITHUB_OUTPUT`, caches, or + summaries. +- **The engine's own private environment is scrubbed before the CLI is exec'd.** + The wrapper necessarily carries the token into the deploy step twice — once as + `EDGEZERO___API_TOKEN` (so the step's YAML can build the JSON without + interpolating a secret into a `run:` block, itself a template-injection sink), + and once inside `EDGEZERO__PROVIDER_ENV`. Both are secret-bearing. `run-app-cli.sh` + therefore unsets its entire private namespace (§10.2) after reading it, so the + app CLI — and every subprocess it spawns, including a manifest + `[adapters.*.commands]` shell command — receives **only** the typed provider + aliases plus `EDGEZERO_MANIFEST`. Without this, an `env`-dumping build script + would print the raw token under a name we never promised. +- Every step of a wrapper — shell steps and third-party `uses:` steps alike — + blanks the full alias list in its own `env:`. The list a wrapper blanks and the + list it passes as `provider-env-clear` are the same list. + +Application (non-credential) configuration may still pass through normal +workflow `env:`. + +### 10.1 Action-owned passthrough arguments + +The deploy-arg allowlist (§9) governs **caller** input. A wrapper may also +prepend args of its own, which are not caller input and so are not +allowlist-checked. For Fastly that is `--non-interactive`. + +It is needed because the two deploy paths differ. The built-in Fastly deploy +appends `--non-interactive` for itself, but a deploy declared as a manifest +command (`[adapters.fastly.commands] deploy = "fastly compute deploy"` — a +documented, common configuration) is run as a shell command with the adapter args +appended verbatim, so nothing would add it and the deploy could block on a TTY +prompt in CI. Supplying it from the wrapper covers both paths; the built-in path +de-duplicates it (both `--non-interactive` and `-i`). + +Action-owned args are prepended, so a caller arg still wins where the provider +CLI takes last-wins. A caller cannot smuggle one in through `deploy-args` — the +allowlist still rejects it. + +### 10.2 Environment variable convention + +Every variable the actions own lives in one namespace: + +```text +EDGEZERO__
__ + ^^ ^^ + `__` separates sections; `_` separates words within a section. +``` + +Sections in use: `ACTION` (action-owned dirs), `INPUT` (raw wrapper inputs), +`PROJECT` (working directory, manifest), `CLI` (the app CLI artifact), `BUILD` / +`DEPLOY` (argument files), `PROVIDER` (the credential JSON and its clear list), +`RUNNER`, `LIFECYCLE` (healthcheck/rollback parameters), `SUMMARY`, `FASTLY` +(provider-specific), and `TEST`. + +This is not cosmetic. It is what makes the credential boundary (§10) a **single +rule**: `run-app-cli.sh` unsets everything matching `EDGEZERO__*` before exec'ing the +app CLI. With the previous mix of `DEPLOY_*`, `INPUT_*`, `SUMMARY_*`, and bare +`CLI_BIN` / `VERSION`, the scrub needed a hand-maintained list — and a variable +added later without touching that list would have silently leaked. + +`EDGEZERO_MANIFEST` (**single** underscore) is deliberately outside the +namespace: it is the CLI's own public contract, not an action variable, and it is +the one variable the actions deliberately pass through. + +### 10.3 Build-in-deploy caveat (trusted source requirement) + +Some adapters compile the application **inside** the deploy step. Fastly's +default `build-mode: never` relies on ` deploy`, which runs +`fastly compute deploy` — and that command builds the application unless a +prebuilt package already exists. Consequently, with the Fastly default, the +application is compiled while `FASTLY_API_TOKEN` is in scope. + +This is an explicit, accepted boundary, not an oversight: + +- The action still guarantees credentials are absent from setup, `build-app-cli`, + and any separate `build-mode: always` build step. +- Because deploy may still recompile, a credential-free `always` prebuild does + not remove the exposure; it only front-loads a validation build. +- Therefore callers **must** deploy only trusted, immutable source refs (full + SHAs or protected tags) and use GitHub Environment approvals, so untrusted + code never runs with the token in scope. + +Adapters that support a genuinely credential-free prebuild followed by a +credential-only publish may set a different default in their wrapper; Fastly does +not today. + +## 11. Caching + +`cache` enables opt-in application build caching, `false` by default. + +### 11.1 Git root vs Cargo workspace root + +Two distinct roots are resolved from `working-directory`, and they are **not** +interchangeable: + +- **Git root** — the enclosing repository. Used only for `source-revision` and + the dirty-source guard. +- **Cargo workspace root** — resolved with `cargo locate-project --workspace` + (or `cargo metadata`) from `working-directory`. Owns the real `Cargo.lock` and + the real `target/` directory. In a monorepo or nested workspace this is often + under `working-directory` (for example `apps/api/`), not the Git root. + +Cargo-scoped operations — lockfile hashing, lockfile presence checks, and +`target/` caching — use the **Cargo workspace root**. Git-scoped operations use +the **Git root**. + +The **application's Git root is also the toolchain search boundary.** +`build-app-cli` resolves the Rust toolchain by walking up from +`working-directory` looking for `rust-toolchain.toml`, `rust-toolchain`, then +`.tool-versions` — and that walk stops at the app's Git root, never at +`github.workspace`. In the separate-repository layout the deployer repo sits at +`github.workspace` with the application checked out into a subdirectory; walking +to `github.workspace` would cross the app's Git boundary and let the _deployer's_ +`.tool-versions` silently decide which Rust compiles the application. Every path +is canonicalized before comparison, because a symlinked `TMPDIR` or checkout +would otherwise never match the boundary and the walk would climb straight past +it. When the app directory is not a Git checkout, the boundary falls back to +`github.workspace`; the walk never rises above it either way. + +### 11.2 Cache contents and key + +When enabled, cache only the resolved **Cargo workspace root** `target/`. Never +cache provider auth files, action-owned tool installs, logs, temporary deploy +state, or paths outside that `target/`. + +The cache key is exact and includes at least: runner OS, runner architecture, +resolved Rust toolchain, resolved `target`, CLI version, application source +revision, and the **Cargo workspace root** `Cargo.lock` hash. No broad restore +prefixes. If `cache: true` and that lockfile is missing, fail before deployment +with a remediation message. + +## 12. Logging and summary + +Log and summarize non-sensitive facts only: adapter, workspace-relative +application directory, source revision, manifest path or default discovery, Rust +toolchain and target, CLI version, requested/effective build mode, cache +enabled/disabled and key fingerprint, and final result. + +Never log provider credentials, full process environments, application secret +values, or provider auth state. + +### 12.1 Action-owned logs are private and transient + +The deploy, healthcheck, and rollback wrappers tee the CLI's combined output to a +file so they can parse a canonical `version=` / `healthy=` line out of +it. Provider CLIs print request URLs and service metadata, and under debug flags +can print credential material — so that file is created with `mktemp` at mode +`600` and removed by an `EXIT` trap whatever the outcome. It is never left behind +in `RUNNER_TEMP` for a later step in the job to read. + +Canonical lines are matched with a **fully anchored** pattern (`^=[0-9]+$`). +A 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, the version has not been parsed, it +has been guessed; the wrapper fails instead. The CLI's own parser is anchored the +same way, and falls back to the Fastly API rather than guessing. + +### 12.2 Cleanup removes only what the action owns + +Cleanup runs `rm -rf`, so it removes only real paths strictly beneath +`RUNNER_TEMP`, resolving symlinks before comparing. A directory named by an +inherited variable — or any path outside the action-owned temp root — is refused +with a diagnostic, not deleted. Every wrapper that installs a tool or extracts the +CLI artifact runs cleanup with `if: always()`. + +## 13. Error handling + +All validation and setup failures stop before invoking provider deployment. + +| Failure | Required diagnostic | +| ----------------------------------------------- | ------------------------------------------------------------------------------- | +| Missing/unknown `app-cli-package` | State that the app must name a CLI package present in its own workspace. | +| Missing `app-cli-artifact` | State that a compiled CLI artifact from `build-app-cli` is required. | +| Malformed `adapter` token | Name the input and its allowed shape (the CLI validates support at run time). | +| Invalid boolean | Name the input and allowed values. | +| Missing working directory | Print the workspace-relative requested path. | +| Path escapes workspace | Name the input; require paths under `github.workspace`. | +| Missing explicit manifest | Print the workspace-relative requested path. | +| Invalid JSON arguments/env | Name the invalid input without printing its value. | +| Non-string entry | State that every array/object value must be a string. | +| Disallowed deploy arg | State the allowlist and rejected position without printing the array. | +| Rust toolchain cannot be resolved | List files checked and suggest explicit `rust-toolchain`. | +| Dirty working tree | State that deployments require committed source. | +| Missing `Cargo.lock` when cache enabled | Explain the exact-key cache requirement. | +| Missing provider credential input | Name the missing input, never its value. | +| Build command fails | Preserve exit status; state that deploy was not attempted. | +| Deploy command fails | Preserve exit status; state that rollback is caller-owned. | +| Staged deploy fails | Preserve exit status; emit no `fastly-version` so the caller skips rollback. | +| Missing `fastly-version` (healthcheck/rollback) | State it is required, sourced from the deploy/stage output. | +| Health check unhealthy after retries | Exit non-zero and set `healthy=false`/`status-code` so the caller can rollback. | +| Rollback command fails | Preserve exit status; state the version was not rolled back. | +| Cleanup fails | Mark the action failed; identify the area without printing secrets. | + +Provider CLI stderr passes through so provider API errors stay actionable. The +actions never construct error messages containing credentials. + +## 14. Security requirements + +1. Recommend readable released tags for third-party actions and, for production, + full commit SHAs of the EdgeZero action ref where reproducibility matters. +2. Compile the CLI package the application provides, from the application + checkout and its lockfile; do not build the EdgeZero monorepo CLI or the + action's own revision. +3. Compile the CLI once in `build-app-cli`; deploy steps consume the artifact and + never recompile. +4. Download provider tools and validation binaries only from official release + locations and verify SHA-256 checksums. +5. Install action-owned binaries below `RUNNER_TEMP`. +6. Use Bash arrays; never use `eval`; never use Python. +7. Allow-list `adapter` before using it in file selection or command arguments. +8. Treat the checked-out application and `edgezero.toml` as executable code. +9. Inject provider credentials only into the deploy step via `provider-env`. +10. Never write provider credentials to `GITHUB_ENV`, `GITHUB_OUTPUT`, caches, or + summaries. +11. Clear the wrapper-supplied `provider-env-clear` aliases from non-provider + steps; the engine hard-codes no provider names. +12. Reject caller paths outside `github.workspace`, including symlink escapes. +13. Escape percent, carriage return, and newline characters before emitting + user-influenced GitHub annotations or masking commands; reject CR/LF in + single-line output values. +14. Disable caching by default; use exact keys only when enabled. +15. Do not auto-retry provider deployment; retries are limited to idempotent + downloads. +16. Do not use `github.token` for provider authentication. +17. Document least-privilege workflow permissions (`contents: read` unless the + caller needs more) and caller-owned environment protection, concurrency, and + timeouts. + +## 15. Testing strategy + +### 15.1 Static validation (no Python) + +CI for the actions runs: + +- `actionlint` from a **pinned release binary** over workflow and action files; +- `shellcheck` over shell scripts; +- YAML parsing for each `action.yml`; +- metadata contract tests for public inputs/outputs, ported into the Bash + `tests/run.sh` harness (replacing the previous `python3` heredocs); +- a check that action tool versions agree with `.tool-versions`; +- `zizmor` from a **pinned release binary** (Rust; installed as a release + artifact or via `cargo install zizmor --locked`, never `pip`); and +- Markdown/example validation. + +Third-party actions used by CI (`actions/checkout`, `actions/cache`, artifact +upload/download) are pinned to readable released tags. + +### 15.2 Script contract tests (Bash) + +Use temporary directories and fake binaries to test, across the engine and the +Fastly wrapper: + +- `adapter` well-formedness validation (unknown adapters surface as the CLI's + own error, not an engine allowlist); +- app-provided `app-cli-package` build (fail on missing/unknown package), tar + round-trip preserving the executable bit, and artifact consumption; +- exact boolean parsing; +- toolchain precedence and malformed-file failure; +- working-directory confinement and symlink-escape rejection; +- dirty-source rejection and source-revision output; +- explicit and default manifest behavior; +- JSON argument/env parsing and boundary preservation; +- rejected non-string and NUL-containing entries; +- adapter deploy-arg allowlist (accept `--comment`, reject service/auth/endpoint/ + profile/interactive/short-flag/debug overrides); +- build-mode resolution and build-failure-prevents-deploy; +- deploy exit-code propagation; +- credential presence validation and scoping (absent from build-app-cli/setup/build, + present only in deploy); +- cache key construction and missing-lockfile failure; +- staging lifecycle: `stage` flag adds `--stage`; `fastly-version` parsed from CLI + output; `healthcheck-fastly` / `rollback-fastly` pass `--service-id` + version + and scope `FASTLY_API_TOKEN`; healthcheck exits non-zero on unhealthy; staging + vs production argv; +- cleanup on success and failure; and +- redaction of credentials from action-owned logs. + +Tests must not need live provider credentials. + +### 15.3 Composite smoke test + +A workflow exercises the layered actions end to end with a minimal fixture +EdgeZero app: run `build-app-cli`, then `deploy-fastly` (both production and +`stage: true`), then `healthcheck-fastly` and `rollback-fastly`. Fake the +dependencies each action actually uses: + +- for `deploy-fastly`, a fake `fastly` binary that writes marker files and prints + a version instead of contacting Fastly; +- for `healthcheck-fastly` / `rollback-fastly`, a fake **app CLI** (or stubbed + Fastly API / `curl` responses) — these actions call the Fastly API, not the + `fastly` CLI, so no fake `fastly` binary is involved. + +Assert CLI-artifact reuse, invocation order, working directory, argument +boundaries (`--service-id`, `--stage`), `fastly-version` threading stage → +healthcheck → rollback, cache behavior, credential scope, and public outputs. + +### 15.4 Installer / live gates + +- Scheduled CI verifies the pinned Fastly CLI installer still produces a runnable + binary matching the expected version, without deploying. +- A protected manual workflow may eventually deploy a disposable Fastly fixture + before any stable version alias is created; it runs only from protected + branches or approved dispatch, never from fork PRs, uses isolated resources, + and treats rollback/cleanup as caller-owned. + +## 16. Documentation requirements + +User-facing docs must cover: the three-layer model and when to use each action; +how `build-app-cli` compiles the app-provided CLI package; supported adapters and how new adapters +layer on; runner support; same-repo, separate-repo, and monorepo checkout +examples; complete input/output tables per action; typed provider credential +guidance and why credentials must not pass through caller `env:`; build-mode and +cache behavior with security caveats; least-privilege permissions and +environment/concurrency/timeout recommendations; explicit non-goals; and future +adapter notes. + +## 17. Acceptance criteria + +The design is implemented when: + +1. A caller can compile the CLI once with `build-app-cli` and deploy a checked-out + EdgeZero application with `deploy-fastly`, reusing the same CLI artifact. +2. `build-app-cli` compiles the app-provided `app-cli-package` from the application + checkout and never builds the EdgeZero monorepo CLI or the action's own + revision. +3. `deploy-core` contains no provider-specific credential names, service + concepts, endpoints, or CLI flags — only `provider-env`, `provider-env-clear`, + `deploy-flags`, and `deploy-args` carry them. +4. Adding a second adapter is a new minimal wrapper plus target/allowlist data, + with no engine fork. +5. Deploy steps consume the prebuilt CLI artifact and never recompile it. +6. Typed provider credentials reach only the deploy step and never appear in + outputs, caches, action-owned logs, or summaries. +7. Passthrough argument boundaries are preserved; no `eval`. +8. `cache: true` uses exact keys and caches only the **Cargo workspace root** + `target/` (§11.1), so nested-workspace monorepos cache the right artifacts. +9. All CI, tooling, and tests run without Python; `actionlint` and `zizmor` run + from pinned release binaries. +10. Third-party actions are pinned to readable released tags. +11. Fastly staging lifecycle works end to end: `deploy-fastly` `stage: true` + stages a draft and outputs `fastly-version`; `healthcheck-fastly` probes the + staged version (via its staging IP) and exits non-zero when unhealthy; + `rollback-fastly` deactivates the staged version (or activates the previous + production version). All three thread `--service-id` and `fastly-version` and + scope `FASTLY_API_TOKEN`; the generic engine is unchanged. +12. Static checks, Bash contract tests, and the composite smoke test pass. +13. Docs include same-repo, separate-repo, and monorepo examples across the + three-layer model, plus a Fastly staging-lifecycle example. + +## 18. Risks and mitigations + +| Risk | Mitigation | +| ----------------------------------------------------- | -------------------------------------------------------------------------------------------------- | +| CLI and application manifest schema incompatible | CLI is the app's own package, built from the app checkout, so they cannot diverge. | +| Provider deploy builds while credentials are in scope | Keep the separate build credential-free; document caching caveats; require trusted immutable refs. | +| Mutable refs execute unexpected manifest commands | Caller owns checkout; document tag/SHA protection and GitHub Environment approvals. | +| Caching stores sensitive generated output | Disable by default; exact keys only; cache only `target/`. | +| Provider CLI installer changes or disappears | Pin versions and checksums; run scheduled installer tests. | +| Monorepo has multiple provider manifests | Require deterministic `working-directory` or explicit `edgezero.toml`; the actions do not guess. | +| Engine grows provider-specific behavior | Keep provider concepts in wrappers and the CLI; keep `deploy-core` provider-neutral. | + +## 19. Future work + +1. Cloudflare Workers deployment (`deploy-cloudflare` wrapper). +2. Spin/Fermyon Cloud preview deployment (`deploy-spin` wrapper). +3. Staging / health-check / rollback lifecycle for adapters **beyond Fastly** + (Fastly's is delivered in §5.4). +4. Optionally consume a prebuilt/attested CLI binary matching the application's + pinned version instead of compiling from source. +5. Release artifact reuse between build and deploy jobs beyond the CLI. +6. Stable version aliases such as `v1`. +7. Linux arm64, macOS, or other runner support. + +## 20. References + +- EdgeZero CLI reference: `docs/guide/cli-reference.md` +- EdgeZero Fastly adapter: `crates/edgezero-adapter-fastly/src/cli.rs` +- EdgeZero CLI dispatch: `crates/edgezero-cli/src/main.rs` +- Fastly Compute deploy reference: +- GitHub Actions secure use reference: