From b946250c858352c009605647aa09ed4bdc37f144 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 9 Jul 2026 08:05:03 -0700 Subject: [PATCH 01/20] Design: layered EdgeZero deploy actions with Fastly staging lifecycle Design docs (spec + implementation plan + adoption guide) for GitHub Actions that deploy EdgeZero apps, superseding the Fastly-only monolith from #303. Architecture: - build-cli compiles the CLI package the *application* provides (a crate in the app's own workspace), from the app checkout, isolated CARGO_TARGET_DIR + --locked, self-describing tar (cli-meta.json). - deploy-core: adapter-independent shared engine scripts sourced by wrappers; provider creds/flags only via provider-env (deploy-step-scoped), provider-env-clear, deploy-flags, deploy-args. - deploy-fastly: minimal wrapper; optional stage: true. - Fastly staging lifecycle (parity with trusted-server-actions): deploy-fastly stage mode + healthcheck-fastly + rollback-fastly, scaffolded into the CLI's Fastly adapter and exposed via the app CLI; fastly-version output. Cross-cutting: Git root vs Cargo workspace root for monorepo caching; no Python (actionlint/zizmor pinned binaries); third-party actions on readable tags; explicit Fastly build-in-deploy credential caveat. Plan includes a porting map from the #303 reference scripts. Based off main; supersedes #303. --- ...ezero-deploy-action-implementation-plan.md | 201 +++++ docs/specs/edgezero-deploy-adoption-guide.md | 249 ++++++ docs/specs/edgezero-deploy-github-action.md | 787 ++++++++++++++++++ 3 files changed, 1237 insertions(+) create mode 100644 docs/specs/edgezero-deploy-action-implementation-plan.md create mode 100644 docs/specs/edgezero-deploy-adoption-guide.md create mode 100644 docs/specs/edgezero-deploy-github-action.md 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..d3b7c0e8 --- /dev/null +++ b/docs/specs/edgezero-deploy-action-implementation-plan.md @@ -0,0 +1,201 @@ +# 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-cli +.github/actions/deploy-core +.github/actions/deploy-fastly +.github/actions/healthcheck-fastly +.github/actions/rollback-fastly +``` + +`build-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` | shared | Reuse; parameterize (build-cli host-only; engine adds target). | +| `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-cli` | Rewrite: build the **app's** CLI package, not the monorepo CLI. | +| `action.yml` (one composite) | `build-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-cli` action** + - Required `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 `cli-bin` input (default = `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 `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 `cli-version` from `cargo metadata`; smoke-check with ` --help` + (today's CLI has no `--version`); write `cli-meta.json` (`cli-bin`, + `cli-version`, `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: `cli-version`, `cli-package`, `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/…`. + - Parameters (via env from the wrapper): `adapter`, `cli-artifact`, `cli-bin`, + `working-directory`, `manifest`, `rust-toolchain`, `target`, `build-mode`, + `build-args`, `deploy-args`, `deploy-arg-allow`, `provider-env`, + `provider-env-clear`, `deploy-flags`, `cache`. + - Download the CLI artifact (tar) under `RUNNER_TEMP`, extract preserving the + executable bit (or `chmod +x `), read `cli-meta.json` for + `cli-bin`/`cli-version` (wrapper `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-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 application `target` (from `adapter` when `auto`). + - 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. + - Deploy step: clear the `provider-env-clear` aliases, export only + `provider-env`, 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`, `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: `cli-artifact`, `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`. + - 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 `cli-artifact`, `cli-bin`, + `fastly-api-token`, `fastly-service-id`, `fastly-version`, `domain`, + `deploy-to` (`production`/`staging`), retry/timeout; runs + ` healthcheck --adapter fastly …`; outputs `healthy`, `status-code`. + - `rollback-fastly`: thin wrapper — inputs `cli-artifact`, `cli-bin`, + `fastly-api-token`, `fastly-service-id`, `fastly-version`, `deploy-to`; + runs ` rollback --adapter fastly …`; outputs `rolled-back-to`. + - Both reuse only the CLI-artifact download + credential-scoping helpers from + `deploy-core`; no source resolution, toolchain, build, or cache. Carry no + orchestration policy — the caller wires stage → healthcheck → rollback. + +5. **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-cli`. + +6. **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-cli` → `deploy-fastly` (both production and + `stage: true`) → `healthcheck-fastly` → `rollback-fastly` with fake `fastly` + binaries writing marker files; assert CLI-artifact reuse, credential + scoping, and version threading. + +7. **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. + +8. **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-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. + +9. **Docs** + - Rewrite `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. + +10. **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 provider-specific staging/health-check/rollback as separate actions. +- 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..fcff0f15 --- /dev/null +++ b/docs/specs/edgezero-deploy-adoption-guide.md @@ -0,0 +1,249 @@ +# 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-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-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-cli@ + with: + cli-package: my-app-cli # the CLI crate in your app's workspace + + - uses: stackpop/edgezero/.github/actions/deploy-fastly@ + with: + cli-artifact: ${{ steps.cli.outputs.artifact-name }} + 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 + 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-cli@ + with: + cli-package: my-app-cli + working-directory: app + + - uses: stackpop/edgezero/.github/actions/deploy-fastly@ + with: + cli-artifact: ${{ steps.cli.outputs.artifact-name }} + 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-cli@ + with: + cli-package: api-cli + working-directory: apps/api + + - uses: stackpop/edgezero/.github/actions/deploy-fastly@ + with: + cli-artifact: ${{ steps.cli.outputs.artifact-name }} + 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 `cli-package`; + `build-cli` compiles that package from your checkout, so the CLI and your app + never disagree on schema. `build-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 + `cli-package` — in a nested-workspace monorepo this may be your app + subdirectory, not the repo root). `build-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-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-cli` with `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 `deploy-to`, `domain`, and the captured + `fastly-version`; +6. on failure, run `rollback-fastly` with 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-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..4781003f --- /dev/null +++ b/docs/specs/edgezero-deploy-github-action.md @@ -0,0 +1,787 @@ +# 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-cli +.github/actions/deploy-core +.github/actions/deploy-fastly +.github/actions/healthcheck-fastly +.github/actions/rollback-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-cli` which +CLI package to compile (a crate in the application's own workspace), and +`build-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-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-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-cli` which + CLI package to compile via a required `cli-package` input, and `build-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-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 (these remain explicit CLI subcommands 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-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-cli` + +Compiles the **CLI package the application provides** — a crate in the +application's own workspace, named by the required `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-cli` +never builds the EdgeZero monorepo CLI. + +**Inputs** + +| Input | Required | Default | Contract | +| ------------------- | -------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `cli-package` | Yes | none | Cargo package name of the CLI to build, defined in the application's own workspace. `build-cli` builds this package from the application checkout. | +| `cli-bin` | No | `` | Binary name produced by `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 `cli-package`. Must resolve inside `github.workspace`. | +| `rust-toolchain` | No | `auto` | Explicit toolchain, or `auto` to follow the application toolchain resolution precedence (§7). | +| `artifact-name` | 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-cli` builds the package exactly as +the application declares it, so the app owns adapter selection. + +**Outputs** + +| Output | Meaning | +| --------------- | -------------------------------------------------------------- | +| `cli-version` | CLI package version, read from `cargo metadata` at build time. | +| `cli-package` | The application CLI package that was built. | +| `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 `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 `cli-version` from `cargo metadata` for `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 (`cli-meta.json`) next to the binary containing + `cli-bin`, `cli-version`, and `cli-package`. +7. Upload the binary **and `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 `cli-meta.json` to learn the +binary name and version, so callers do not have to re-pass `cli-bin`/`cli-version` +(a wrapper `cli-bin` input, if given, overrides the metadata). + +`build-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, `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. | +| `cli-artifact` | Name of the `build-cli` artifact to download. The engine reads `cli-bin` and `cli-version` from the artifact's `cli-meta.json`. | +| `cli-bin` | Optional override for the binary name; if empty, taken from `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` | Application build target. `auto` derives it from `adapter` (for example `fastly` → `wasm32-wasip1`). | +| `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`, `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`. + +### 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`, `target`, the adapter `deploy-arg` allowlist, and the + `provider-env-clear` alias list; and +4. 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. + +**`deploy-fastly` inputs** + +| Input | Required | Default | Contract | +| ------------------- | -------- | ------------- | ---------------------------------------------------------------------------------------------- | +| `cli-artifact` | Yes | none | `build-cli` artifact name. Forwarded to the engine. | +| `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. | +| `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` (production, existing) | `fastly compute deploy` → builds, uploads, **activates**; emits the activated version. | +| ` deploy --adapter fastly --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 --version --domain [--staging]` | Production: `curl` the domain. Staging: resolve `staging_ips` for `` via the Fastly API, then `curl --connect-to` that IP; emits healthy/status. | +| ` rollback --adapter fastly --version [--staging]` | Production: activate ` - 1`. Staging: deactivate the staged ``. | + +The app CLI (built by `build-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 +--stage`; 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 …`; outputs `healthy` and `status-code`. + 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 …`; on production emits `rolled-back-to`. + Needs no application source or build. + +`healthcheck-fastly` and `rollback-fastly` 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. + +#### 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-cli@ + with: { cli-package: my-app-cli } + +- id: stage + uses: stackpop/edgezero/.github/actions/deploy-fastly@ + with: + cli-artifact: ${{ steps.cli.outputs.artifact-name }} + 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: + cli-artifact: ${{ steps.cli.outputs.artifact-name }} + 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: + cli-artifact: ${{ steps.cli.outputs.artifact-name }} + 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. + +## 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 `cli-artifact` (a tar) into an action-owned directory below + `RUNNER_TEMP`, extract it preserving permissions (or `chmod +x `), + read `cli-meta.json` for `cli-bin`/`cli-version` (a wrapper `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-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 resolved + application `target` (for example `wasm32-wasip1` for Fastly). +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-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. + +## 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-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. + +Application (non-credential) configuration may still pass through normal +workflow `env:`. + +### 10.1 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-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**. + +### 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. + +## 13. Error handling + +All validation and setup failures stop before invoking provider deployment. + +| Failure | Required diagnostic | +| --------------------------------------- | ----------------------------------------------------------------------------- | +| Missing/unknown `cli-package` | State that the app must name a CLI package present in its own workspace. | +| Missing `cli-artifact` | State that a compiled CLI artifact from `build-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. | +| 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-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 `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-cli/setup/build, + present only in deploy); +- cache key construction and missing-lockfile failure; +- 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-cli`, then `deploy-fastly`, using fake provider binaries +that write marker files instead of contacting Fastly; assert CLI-artifact reuse, +invocation order, working directory, argument boundaries, 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-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-cli` and deploy a checked-out + EdgeZero application with `deploy-fastly`, reusing the same CLI artifact. +2. `build-cli` compiles the app-provided `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. Static checks, Bash contract tests, and the composite smoke test pass. +12. Docs include same-repo, separate-repo, and monorepo examples across the + three-layer model. + +## 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: From 49f5668fe07a26a88f31ead7cc5b52befdc59cd9 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 9 Jul 2026 09:59:44 -0700 Subject: [PATCH 02/20] Plan: align deploy-core credential scoping with the spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit provider-env is no longer listed among the engine's globally-passed parameters. It is bound only to the deploy step's own env: and parsed only there; setup/build steps receive only non-secret parameters plus provider-env-clear. Mirrors spec §5.2/§10 so the plan no longer reintroduces the secret-blob leak. --- ...ezero-deploy-action-implementation-plan.md | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/docs/specs/edgezero-deploy-action-implementation-plan.md b/docs/specs/edgezero-deploy-action-implementation-plan.md index d3b7c0e8..29be6608 100644 --- a/docs/specs/edgezero-deploy-action-implementation-plan.md +++ b/docs/specs/edgezero-deploy-action-implementation-plan.md @@ -83,10 +83,15 @@ reference to port from. Most transfer with light changes: - A directory of scripts under `.github/actions/deploy-core/`, **not** a standalone composite action. Wrappers source them via `$GITHUB_ACTION_PATH/../deploy-core/…`. - - Parameters (via env from the wrapper): `adapter`, `cli-artifact`, `cli-bin`, - `working-directory`, `manifest`, `rust-toolchain`, `target`, `build-mode`, - `build-args`, `deploy-args`, `deploy-arg-allow`, `provider-env`, + - Non-secret parameters (available to all steps): `adapter`, `cli-artifact`, + `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 `cli-meta.json` for `cli-bin`/`cli-version` (wrapper `cli-bin` overrides), and PATH-scope it. @@ -105,9 +110,11 @@ reference to port from. Most transfer with light changes: - 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. - - Deploy step: clear the `provider-env-clear` aliases, export only - `provider-env`, then run + - 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 From 7446fbb58155a0aa794a38fdd040b46191234d07 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 9 Jul 2026 11:34:55 -0700 Subject: [PATCH 03/20] Spec/plan review: lifecycle service-id, provider-tool install, concrete target - healthcheck-fastly / rollback-fastly now pass --service-id (and step- scoped FASTLY_API_TOKEN) in their app-CLI invocations; without it the CLI can't resolve staging IPs or activate/deactivate versions. - Make provider CLI install an explicit wrapper responsibility: deploy-fastly installs the pinned Fastly CLI onto PATH; the engine assumes it is present and never learns provider tools. healthcheck/rollback need no Fastly CLI (Fastly API only). - target is wrapper-provided concrete (Fastly -> wasm32-wasip1); the engine no longer maps adapter -> target, keeping it provider-neutral. - Qualify the follow-up list: additional staging/health/rollback lifecycles are 'beyond Fastly' (Fastly's is in scope). --- ...ezero-deploy-action-implementation-plan.md | 25 +++++--- docs/specs/edgezero-deploy-github-action.md | 60 ++++++++++++------- 2 files changed, 55 insertions(+), 30 deletions(-) diff --git a/docs/specs/edgezero-deploy-action-implementation-plan.md b/docs/specs/edgezero-deploy-action-implementation-plan.md index 29be6608..2fe47274 100644 --- a/docs/specs/edgezero-deploy-action-implementation-plan.md +++ b/docs/specs/edgezero-deploy-action-implementation-plan.md @@ -106,7 +106,8 @@ reference to port from. Most transfer with light changes: 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 application `target` (from `adapter` when `auto`). + 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. @@ -136,6 +137,10 @@ reference to port from. Most transfer with light changes: `--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. @@ -143,13 +148,18 @@ reference to port from. Most transfer with light changes: - `healthcheck-fastly`: thin wrapper — inputs `cli-artifact`, `cli-bin`, `fastly-api-token`, `fastly-service-id`, `fastly-version`, `domain`, `deploy-to` (`production`/`staging`), retry/timeout; runs - ` healthcheck --adapter fastly …`; outputs `healthy`, `status-code`. + ` healthcheck --adapter fastly --service-id --version …` with + `FASTLY_API_TOKEN` in the step env; outputs `healthy`, `status-code`. - `rollback-fastly`: thin wrapper — inputs `cli-artifact`, `cli-bin`, `fastly-api-token`, `fastly-service-id`, `fastly-version`, `deploy-to`; - runs ` rollback --adapter fastly …`; outputs `rolled-back-to`. - - Both reuse only the CLI-artifact download + credential-scoping helpers from - `deploy-core`; no source resolution, toolchain, build, or cache. Carry no - orchestration policy — the caller wires stage → healthcheck → rollback. + 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. **Scripts layout** - Provider-neutral scripts under `deploy-core/`; the Fastly install + checksum @@ -203,6 +213,7 @@ reference to port from. Most transfer with light changes: ## Known follow-up candidates - Add `deploy-cloudflare` / `deploy-spin` wrappers via the same engine. -- Add provider-specific staging/health-check/rollback as separate actions. +- 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-github-action.md b/docs/specs/edgezero-deploy-github-action.md index 4781003f..d20e956a 100644 --- a/docs/specs/edgezero-deploy-github-action.md +++ b/docs/specs/edgezero-deploy-github-action.md @@ -238,7 +238,7 @@ The engine is parameterized by the values the wrapper passes to those scripts | `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` | Application build target. `auto` derives it from `adapter` (for example `fastly` → `wasm32-wasip1`). | +| `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. | @@ -262,12 +262,19 @@ 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`, `target`, the adapter `deploy-arg` allowlist, and the - `provider-env-clear` alias list; and -4. sources the shared `deploy-core` scripts via `$GITHUB_ACTION_PATH/../deploy-core`. +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. +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** @@ -313,17 +320,18 @@ subcommands. The capability is scaffolded into the CLI, not reproduced in action shell: -| App-CLI invocation | Fastly operations the adapter performs | -| --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| ` deploy --adapter fastly` (production, existing) | `fastly compute deploy` → builds, uploads, **activates**; emits the activated version. | -| ` deploy --adapter fastly --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 --version --domain [--staging]` | Production: `curl` the domain. Staging: resolve `staging_ips` for `` via the Fastly API, then `curl --connect-to` that IP; emits healthy/status. | -| ` rollback --adapter fastly --version [--staging]` | Production: activate ` - 1`. Staging: deactivate the staged ``. | +| 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 ``. | -The app CLI (built by `build-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. +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-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 @@ -341,17 +349,22 @@ generic engine still exposes no deployment version. - **`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 …`; outputs `healthy` and `status-code`. - Needs no application source or build. + ` healthcheck --adapter fastly --service-id --version …` with + `FASTLY_API_TOKEN` in the step env; outputs `healthy` and `status-code`. 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 …`; on production emits `rolled-back-to`. - Needs no application source or build. + ` 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` reuse only the CLI-artifact download +`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. +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 @@ -423,8 +436,9 @@ adapter adds its own lifecycle actions if its provider supports staging. 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 resolved - application `target` (for example `wasm32-wasip1` for Fastly). +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. From 6f18573b1353f1ddb1beda94e1cfbd1a6c5f75e6 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 9 Jul 2026 12:03:35 -0700 Subject: [PATCH 04/20] Self-review: wire staging lifecycle through error/testing/acceptance; guide creds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gaps found in self-review + review: - §13 error handling: add rows for staged-deploy failure, missing fastly-version, unhealthy-after-retries, rollback failure. - Pin healthcheck-fastly exit semantics: exits non-zero on unhealthy so callers can gate rollback on if: failure() (the composing example relied on this implicitly). - §5.4.3: deploy-fastly stage command now shows --service-id (matches §5.4.1). - §15 testing + §17 acceptance: cover the staging lifecycle (were absent). - §15.3 / plan smoke test: fake the app CLI + Fastly API/curl for healthcheck/rollback (they call the API, not the fastly CLI), not fake fastly binaries. - Adoption guide §6.3: healthcheck/rollback steps now pass fastly-api-token + fastly-service-id (required by the CLI --service-id path). --- ...ezero-deploy-action-implementation-plan.md | 9 +- docs/specs/edgezero-deploy-adoption-guide.md | 10 ++- docs/specs/edgezero-deploy-github-action.md | 89 ++++++++++++------- 3 files changed, 70 insertions(+), 38 deletions(-) diff --git a/docs/specs/edgezero-deploy-action-implementation-plan.md b/docs/specs/edgezero-deploy-action-implementation-plan.md index 2fe47274..1dc69028 100644 --- a/docs/specs/edgezero-deploy-action-implementation-plan.md +++ b/docs/specs/edgezero-deploy-action-implementation-plan.md @@ -174,9 +174,12 @@ reference to port from. Most transfer with light changes: (no `pip`). - Port the metadata-validation heredocs into `tests/run.sh`. - Composite smoke test: `build-cli` → `deploy-fastly` (both production and - `stage: true`) → `healthcheck-fastly` → `rollback-fastly` with fake `fastly` - binaries writing marker files; assert CLI-artifact reuse, credential - scoping, and version threading. + `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. 7. **Bash contract tests (`tests/run.sh`)** - Cover engine + wrappers: adapter/boolean/JSON validation, path confinement, diff --git a/docs/specs/edgezero-deploy-adoption-guide.md b/docs/specs/edgezero-deploy-adoption-guide.md index fcff0f15..88f9ce22 100644 --- a/docs/specs/edgezero-deploy-adoption-guide.md +++ b/docs/specs/edgezero-deploy-adoption-guide.md @@ -218,10 +218,12 @@ Workflow shape: 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 `deploy-to`, `domain`, and the captured - `fastly-version`; -6. on failure, run `rollback-fastly` with the same `deploy-to`/`fastly-version`; - and +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 diff --git a/docs/specs/edgezero-deploy-github-action.md b/docs/specs/edgezero-deploy-github-action.md index d20e956a..4c92a5bb 100644 --- a/docs/specs/edgezero-deploy-github-action.md +++ b/docs/specs/edgezero-deploy-github-action.md @@ -343,15 +343,19 @@ generic engine still exposes no deployment version. #### 5.4.3 The three actions -- **`deploy-fastly` (`stage: true`)** — runs ` deploy --adapter fastly ---stage`; outputs `fastly-version` (the staged draft). Reuses the engine for - build/source/credential scoping; only the `--stage` flag differs. +- **`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`. Needs - no application source or build. + `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 @@ -611,25 +615,29 @@ values, or provider auth state. All validation and setup failures stop before invoking provider deployment. -| Failure | Required diagnostic | -| --------------------------------------- | ----------------------------------------------------------------------------- | -| Missing/unknown `cli-package` | State that the app must name a CLI package present in its own workspace. | -| Missing `cli-artifact` | State that a compiled CLI artifact from `build-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. | -| Cleanup fails | Mark the action failed; identify the area without printing secrets. | +| Failure | Required diagnostic | +| ----------------------------------------------- | ------------------------------------------------------------------------------- | +| Missing/unknown `cli-package` | State that the app must name a CLI package present in its own workspace. | +| Missing `cli-artifact` | State that a compiled CLI artifact from `build-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. @@ -708,6 +716,10 @@ Fastly wrapper: - credential presence validation and scoping (absent from build-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. @@ -716,10 +728,19 @@ 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-cli`, then `deploy-fastly`, using fake provider binaries -that write marker files instead of contacting Fastly; assert CLI-artifact reuse, -invocation order, working directory, argument boundaries, cache behavior, -credential scope, and public outputs. +EdgeZero app: run `build-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 @@ -764,9 +785,15 @@ The design is implemented when: 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. Static checks, Bash contract tests, and the composite smoke test pass. -12. Docs include same-repo, separate-repo, and monorepo examples across the - three-layer model. +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 From 193c045c8a64a5feb3275eefd2f7ec7cc683e67b Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 9 Jul 2026 22:02:28 -0700 Subject: [PATCH 05/20] impl(actions): build-cli + deploy-core engine foundation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - build-cli: action.yml + build-cli.sh (resolve app cli-package via cargo metadata --locked, isolated CARGO_TARGET_DIR build, cli-meta.json, tar upload). - deploy-core shared scripts: common, validate-inputs (provider-neutral allowlist + JSON→NUL parsing), install-rust (wrapper-provided target), download-cli (extract tar, read cli-meta.json, PATH-scope), resolve-project (Git root vs Cargo workspace root, cache key), cleanup, write-summary. Wrappers (deploy-fastly, healthcheck/rollback), run-cli, CI, and tests follow. All scripts shellcheck-clean; validate-inputs functionally tested. --- .github/actions/build-cli/action.yml | 60 ++++++++ .../actions/build-cli/scripts/build-cli.sh | 139 ++++++++++++++++++ .github/actions/build-cli/scripts/common.sh | 105 +++++++++++++ .../actions/deploy-core/scripts/cleanup.sh | 17 +++ .github/actions/deploy-core/scripts/common.sh | 105 +++++++++++++ .../deploy-core/scripts/download-cli.sh | 42 ++++++ .../deploy-core/scripts/install-rust.sh | 16 ++ .../deploy-core/scripts/resolve-project.sh | 138 +++++++++++++++++ .../deploy-core/scripts/validate-inputs.sh | 108 ++++++++++++++ .../deploy-core/scripts/write-summary.sh | 24 +++ 10 files changed, 754 insertions(+) create mode 100644 .github/actions/build-cli/action.yml create mode 100755 .github/actions/build-cli/scripts/build-cli.sh create mode 100755 .github/actions/build-cli/scripts/common.sh create mode 100755 .github/actions/deploy-core/scripts/cleanup.sh create mode 100755 .github/actions/deploy-core/scripts/common.sh create mode 100755 .github/actions/deploy-core/scripts/download-cli.sh create mode 100755 .github/actions/deploy-core/scripts/install-rust.sh create mode 100755 .github/actions/deploy-core/scripts/resolve-project.sh create mode 100755 .github/actions/deploy-core/scripts/validate-inputs.sh create mode 100755 .github/actions/deploy-core/scripts/write-summary.sh diff --git a/.github/actions/build-cli/action.yml b/.github/actions/build-cli/action.yml new file mode 100644 index 00000000..14b51d67 --- /dev/null +++ b/.github/actions/build-cli/action.yml @@ -0,0 +1,60 @@ +name: EdgeZero build-cli +description: Compile the CLI package the application provides and publish it as a self-describing artifact. + +inputs: + cli-package: + description: Cargo package name of the CLI to build, defined in the application's own workspace. + required: true + cli-bin: + description: Binary name produced by cli-package. Defaults to the package name. + required: false + default: "" + working-directory: + description: Application directory (relative to github.workspace) whose workspace defines cli-package. + required: false + default: . + rust-toolchain: + description: Explicit host Rust toolchain, or 'auto' to follow application discovery. + required: false + default: auto + artifact-name: + description: Name of the uploaded CLI artifact. + required: false + default: edgezero-cli + +outputs: + cli-version: + description: CLI package version read from cargo metadata. + value: ${{ steps.build.outputs['cli-version'] }} + cli-package: + description: The application CLI package that was built. + value: ${{ steps.build.outputs['cli-package'] }} + cli-bin: + description: The binary name inside the artifact. + value: ${{ steps.build.outputs['cli-bin'] }} + artifact-name: + description: Name of the uploaded CLI artifact for downstream download. + value: ${{ steps.build.outputs['artifact-name'] }} + +runs: + using: composite + steps: + - name: Build application CLI package + id: build + shell: bash + env: + EDGEZERO_ACTION_ROOT: ${{ github.action_path }}/../../.. + INPUT_CLI_PACKAGE: ${{ inputs['cli-package'] }} + INPUT_CLI_BIN: ${{ inputs['cli-bin'] }} + INPUT_WORKING_DIRECTORY: ${{ inputs['working-directory'] }} + INPUT_RUST_TOOLCHAIN: ${{ inputs['rust-toolchain'] }} + INPUT_ARTIFACT_NAME: ${{ inputs['artifact-name'] }} + run: $GITHUB_ACTION_PATH/scripts/build-cli.sh + + - name: Upload CLI artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ steps.build.outputs['artifact-name'] }} + path: ${{ steps.build.outputs['tarball-path'] }} + if-no-files-found: error + retention-days: 1 diff --git a/.github/actions/build-cli/scripts/build-cli.sh b/.github/actions/build-cli/scripts/build-cli.sh new file mode 100755 index 00000000..2c930179 --- /dev/null +++ b/.github/actions/build-cli/scripts/build-cli.sh @@ -0,0 +1,139 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Compiles the CLI package the *application* provides (a crate in the app's own +# workspace, named by INPUT_CLI_PACKAGE) into an action-owned CARGO_TARGET_DIR, +# then packages the binary plus a self-describing cli-meta.json into a tar so the +# executable bit survives actions/upload-artifact. Never builds the EdgeZero +# monorepo CLI. + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=common.sh +source "$SCRIPT_DIR/common.sh" + +WORKSPACE=${GITHUB_WORKSPACE:?GITHUB_WORKSPACE is required} +ACTION_ROOT=${EDGEZERO_ACTION_ROOT:?EDGEZERO_ACTION_ROOT is required} +CLI_PACKAGE=${INPUT_CLI_PACKAGE:?input 'cli-package' is required} +CLI_BIN=${INPUT_CLI_BIN:-} +WORKING_DIRECTORY=${INPUT_WORKING_DIRECTORY:-.} +RUST_TOOLCHAIN_INPUT=${INPUT_RUST_TOOLCHAIN:-auto} +ARTIFACT_NAME=${INPUT_ARTIFACT_NAME:-edgezero-cli} +STAGE_ROOT=${EDGEZERO_CLI_STAGE_ROOT:-${RUNNER_TEMP:-/tmp}/edgezero-cli-artifact} +BUILD_TARGET_DIR=${CARGO_TARGET_DIR_OVERRIDE:-${RUNNER_TEMP:-/tmp}/edgezero-cli-build} + +case "$(uname -s)-$(uname -m)" in + Linux-x86_64 | Linux-amd64) ;; + *) fail "build-cli supports only Linux x86-64 runners" ;; +esac + +require_cmd cargo +require_cmd rustup +require_cmd jq +require_cmd tar + +# --- Resolve the application directory beneath github.workspace --------------- +WORKSPACE_REAL=$(canonical_path "$WORKSPACE") +APP_INPUT="$WORKSPACE/$WORKING_DIRECTORY" +[[ -d "$APP_INPUT" ]] || fail "working-directory '$WORKING_DIRECTORY' does not exist or is not a directory" +APP_DIR=$(canonical_path "$APP_INPUT") +is_under "$WORKSPACE_REAL" "$APP_DIR" || fail "input 'working-directory' must resolve inside github.workspace" + +# --- Resolve the application Rust toolchain (input > rustup files > .tool-versions) -- +parse_rust_toolchain_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_rust_toolchain_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_rust_toolchain() { + if [[ "$RUST_TOOLCHAIN_INPUT" != "auto" ]]; then + [[ -n "$RUST_TOOLCHAIN_INPUT" ]] || fail "input 'rust-toolchain' cannot be empty" + printf '%s\n' "$RUST_TOOLCHAIN_INPUT" + return + fi + local directory="$APP_DIR" value + while true; do + if [[ -f "$directory/rust-toolchain.toml" ]]; then + parse_rust_toolchain_toml "$directory/rust-toolchain.toml" + return + fi + if [[ -f "$directory/rust-toolchain" ]]; then + parse_rust_toolchain_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" == "$WORKSPACE_REAL" ]] && break + local next + next=$(dirname "$directory") + [[ "$next" == "$directory" ]] && break + directory="$next" + 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" +} +RUST_TOOLCHAIN=$(resolve_rust_toolchain) + +# Install the host toolchain only. The CLI is a native tool; the WASM target the +# *application* needs is installed later by the deploy engine, not here. +rustup toolchain install "$RUST_TOOLCHAIN" --profile minimal + +# --- Validate the package + resolve the binary/version via cargo metadata ----- +cd "$APP_DIR" +[[ -f Cargo.lock || -f "$(cargo locate-project --workspace --message-format plain 2>/dev/null | xargs -r dirname)/Cargo.lock" ]] || + fail "no Cargo.lock at the app's Cargo workspace root; build-cli requires a committed lockfile" + +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" + +PKG_JSON=$(jq -c --arg p "$CLI_PACKAGE" '.packages[] | select(.name == $p)' <<<"$METADATA") +[[ -n "$PKG_JSON" ]] || fail "cli-package '$CLI_PACKAGE' was not found in the application workspace" + +# Default the binary name to the package name; verify the bin target exists. +[[ -n "$CLI_BIN" ]] || CLI_BIN="$CLI_PACKAGE" +HAS_BIN=$(jq -r --arg b "$CLI_BIN" '[.targets[] | select(.kind | index("bin")) | .name] | index($b) != null' <<<"$PKG_JSON") +[[ "$HAS_BIN" == "true" ]] || fail "cli-package '$CLI_PACKAGE' declares no binary target named '$CLI_BIN'" +CLI_VERSION=$(jq -r '.version' <<<"$PKG_JSON") + +# --- Build into an action-owned target dir (never dirties the checkout) ------- +rm -rf "$BUILD_TARGET_DIR" +mkdir -p "$BUILD_TARGET_DIR" +CARGO_TARGET_DIR="$BUILD_TARGET_DIR" cargo +"$RUST_TOOLCHAIN" build \ + --locked --release -p "$CLI_PACKAGE" --bin "$CLI_BIN" + +BIN_PATH="$BUILD_TARGET_DIR/release/$CLI_BIN" +[[ -x "$BIN_PATH" ]] || fail "build did not produce an executable at $BIN_PATH" + +# Smoke-check runnability (today's generated CLI may have no --version). +"$BIN_PATH" --help >/dev/null 2>&1 || fail "built CLI '$CLI_BIN' did not run '$CLI_BIN --help'" + +# --- Package binary + self-describing metadata into a tar --------------------- +rm -rf "$STAGE_ROOT" +mkdir -p "$STAGE_ROOT" +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" \ + '{"cli-bin": $bin, "cli-version": $version, "cli-package": $package}' \ + >"$STAGE_ROOT/cli-meta.json" + +TARBALL="$STAGE_ROOT/../${ARTIFACT_NAME}.tar" +tar -C "$STAGE_ROOT" -cf "$TARBALL" "$CLI_BIN" cli-meta.json +TARBALL=$(canonical_path "$TARBALL") + +notice "built app CLI '$CLI_BIN' v$CLI_VERSION from package '$CLI_PACKAGE'" +append_output cli-version "$CLI_VERSION" +append_output cli-package "$CLI_PACKAGE" +append_output cli-bin "$CLI_BIN" +append_output artifact-name "$ARTIFACT_NAME" +append_output tarball-path "$TARBALL" diff --git a/.github/actions/build-cli/scripts/common.sh b/.github/actions/build-cli/scripts/common.sh new file mode 100755 index 00000000..38f96051 --- /dev/null +++ b/.github/actions/build-cli/scripts/common.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +set -euo pipefail + +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-core/scripts/cleanup.sh b/.github/actions/deploy-core/scripts/cleanup.sh new file mode 100755 index 00000000..d7e21eec --- /dev/null +++ b/.github/actions/deploy-core/scripts/cleanup.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT=${EDGEZERO_ACTION_STATE_DIR:-} +if [[ -n "$ROOT" && -d "$ROOT" ]]; then + rm -rf "$ROOT" +fi + +TOOL_ROOT=${EDGEZERO_TOOL_ROOT:-} +if [[ -n "$TOOL_ROOT" && -d "$TOOL_ROOT" ]]; then + rm -rf "$TOOL_ROOT" +fi + +# Remove action-owned Fastly auth state if future installers create it. +if [[ -n "${EDGEZERO_FASTLY_HOME:-}" && -d "$EDGEZERO_FASTLY_HOME" ]]; then + rm -rf "$EDGEZERO_FASTLY_HOME" +fi diff --git a/.github/actions/deploy-core/scripts/common.sh b/.github/actions/deploy-core/scripts/common.sh new file mode 100755 index 00000000..38f96051 --- /dev/null +++ b/.github/actions/deploy-core/scripts/common.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +set -euo pipefail + +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-core/scripts/download-cli.sh b/.github/actions/deploy-core/scripts/download-cli.sh new file mode 100755 index 00000000..edff8a76 --- /dev/null +++ b/.github/actions/deploy-core/scripts/download-cli.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Extracts the build-cli artifact tar (downloaded by actions/download-artifact) +# into an action-owned tool dir, preserving the executable bit, reads the +# self-describing cli-meta.json, and prepends the dir to PATH for action steps. +# A wrapper-supplied CLI_BIN overrides the metadata's binary name. + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=common.sh +source "$SCRIPT_DIR/common.sh" + +ARTIFACT_DIR=${EDGEZERO_CLI_ARTIFACT_DIR:?EDGEZERO_CLI_ARTIFACT_DIR is required} +CLI_BIN_OVERRIDE=${INPUT_CLI_BIN:-} +TOOL_ROOT=${EDGEZERO_TOOL_ROOT:-${RUNNER_TEMP:-/tmp}/edgezero-action-tools} +require_cmd jq +require_cmd tar + +mkdir -p "$TOOL_ROOT/bin" + +# The artifact contains a single tar (built by build-cli). Locate it. +TARBALL=$(find "$ARTIFACT_DIR" -maxdepth 2 -type f -name '*.tar' | head -n 1) +[[ -n "$TARBALL" ]] || fail "no CLI tar found under the downloaded artifact at '$ARTIFACT_DIR'" + +tar -xf "$TARBALL" -C "$TOOL_ROOT/bin" +[[ -f "$TOOL_ROOT/bin/cli-meta.json" ]] || fail "CLI artifact is missing cli-meta.json" + +META_BIN=$(jq -er '."cli-bin"' "$TOOL_ROOT/bin/cli-meta.json") || fail "cli-meta.json has no cli-bin" +CLI_VERSION=$(jq -er '."cli-version"' "$TOOL_ROOT/bin/cli-meta.json") || fail "cli-meta.json has no cli-version" +CLI_BIN=${CLI_BIN_OVERRIDE:-$META_BIN} + +[[ -f "$TOOL_ROOT/bin/$CLI_BIN" ]] || fail "CLI binary '$CLI_BIN' not present in the artifact" +chmod +x "$TOOL_ROOT/bin/$CLI_BIN" +"$TOOL_ROOT/bin/$CLI_BIN" --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 cli-bin "$CLI_BIN" +append_output cli-version "$CLI_VERSION" +append_env EDGEZERO_TOOL_ROOT "$TOOL_ROOT" diff --git a/.github/actions/deploy-core/scripts/install-rust.sh b/.github/actions/deploy-core/scripts/install-rust.sh new file mode 100755 index 00000000..31d3ba3a --- /dev/null +++ b/.github/actions/deploy-core/scripts/install-rust.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Installs the application Rust toolchain plus the wrapper-provided concrete +# target. The engine never maps adapter -> target; the wrapper supplies it. + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=common.sh +source "$SCRIPT_DIR/common.sh" + +RUST_TOOLCHAIN=${RUST_TOOLCHAIN:?RUST_TOOLCHAIN is required} +TARGET=${RUST_TARGET:?RUST_TARGET is required (wrapper-provided concrete target)} +require_cmd rustup +rustup toolchain install "$RUST_TOOLCHAIN" --profile minimal +rustup target add "$TARGET" --toolchain "$RUST_TOOLCHAIN" +append_env RUSTUP_TOOLCHAIN "$RUST_TOOLCHAIN" 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..0de2ae37 --- /dev/null +++ b/.github/actions/deploy-core/scripts/resolve-project.sh @@ -0,0 +1,138 @@ +#!/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. + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=common.sh +source "$SCRIPT_DIR/common.sh" + +WORKSPACE=${GITHUB_WORKSPACE:?GITHUB_WORKSPACE is required} +ACTION_ROOT=${EDGEZERO_ACTION_ROOT:?EDGEZERO_ACTION_ROOT is required} +WORKING_DIRECTORY=${INPUT_WORKING_DIRECTORY:-.} +MANIFEST=${INPUT_MANIFEST:-} +RUST_TOOLCHAIN_INPUT=${INPUT_RUST_TOOLCHAIN:-auto} +RUST_TARGET=${INPUT_TARGET:?INPUT_TARGET is required (wrapper-provided concrete target)} +CACHE=${INPUT_CACHE:-false} +CLI_VERSION=${EDGEZERO_CLI_VERSION:-unknown} + +require_cmd git +require_cmd cargo + +WORKSPACE_REAL=$(canonical_path "$WORKSPACE") +APP_INPUT="$WORKSPACE/$WORKING_DIRECTORY" +[[ -d "$APP_INPUT" ]] || fail "working-directory '$WORKING_DIRECTORY' does not exist or is not a directory" +APP_DIR=$(canonical_path "$APP_INPUT") +is_under "$WORKSPACE_REAL" "$APP_DIR" || fail "input 'working-directory' must resolve inside github.workspace" +APP_REL=$(relative_to "$WORKSPACE_REAL" "$APP_DIR") + +if [[ -n "$MANIFEST" ]]; then + MANIFEST_INPUT="$APP_DIR/$MANIFEST" + [[ -f "$MANIFEST_INPUT" ]] || fail "manifest '$APP_REL/$MANIFEST' does not exist or is not a regular file" + MANIFEST_PATH=$(canonical_path "$MANIFEST_INPUT") + is_under "$WORKSPACE_REAL" "$MANIFEST_PATH" || fail "input 'manifest' must resolve inside github.workspace" + MANIFEST_REL=$(relative_to "$WORKSPACE_REAL" "$MANIFEST_PATH") +else + MANIFEST_PATH="" + MANIFEST_REL="EdgeZero default discovery" +fi + +# --- Git root: source revision + dirty-source guard --------------------------- +APP_GIT_ROOT=$(git -C "$APP_DIR" rev-parse --show-toplevel 2>/dev/null || true) +[[ -n "$APP_GIT_ROOT" ]] || fail "working-directory '$APP_REL' is not inside a Git repository" +APP_GIT_ROOT=$(canonical_path "$APP_GIT_ROOT") +is_under "$WORKSPACE_REAL" "$APP_GIT_ROOT" || fail "application Git root must resolve inside github.workspace" +SOURCE_REVISION=$(git -C "$APP_GIT_ROOT" rev-parse HEAD) +if ! git -C "$APP_GIT_ROOT" diff --quiet --ignore-submodules -- || + ! git -C "$APP_GIT_ROOT" diff --cached --quiet --ignore-submodules -- || + [[ -n "$(git -C "$APP_GIT_ROOT" ls-files --others --exclude-standard)" ]]; then + fail "deployments require committed source; working tree for '$APP_REL' is dirty" +fi + +# --- Cargo workspace root: lockfile + target/ + cache ------------------------- +if ! WORKSPACE_MANIFEST=$(cd "$APP_DIR" && cargo locate-project --workspace --message-format plain 2>/dev/null); then + WORKSPACE_MANIFEST="" +fi +[[ -n "$WORKSPACE_MANIFEST" ]] || fail "could not locate the Cargo workspace root from '$APP_REL'" +CARGO_WS_ROOT=$(canonical_path "$(dirname "$WORKSPACE_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" +[[ -f "$LOCKFILE" ]] && LOCK_HASH=$(sha256_file "$LOCKFILE") +TARGET_DIR="$CARGO_WS_ROOT/target" + +# --- Rust toolchain resolution (input > rustup files > .tool-versions) -------- +parse_rust_toolchain_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_rust_toolchain_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_rust_toolchain() { + if [[ "$RUST_TOOLCHAIN_INPUT" != "auto" ]]; then + [[ -n "$RUST_TOOLCHAIN_INPUT" ]] || fail "input 'rust-toolchain' cannot be empty" + printf '%s\n' "$RUST_TOOLCHAIN_INPUT" + return + fi + local directory="$APP_DIR" value + while true; do + if [[ -f "$directory/rust-toolchain.toml" ]]; then + parse_rust_toolchain_toml "$directory/rust-toolchain.toml" + return + fi + if [[ -f "$directory/rust-toolchain" ]]; then + parse_rust_toolchain_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" == "$APP_GIT_ROOT" ]] && break + local next + next=$(dirname "$directory") + [[ "$next" == "$directory" ]] && break + directory="$next" + 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" +} +RUST_TOOLCHAIN=$(resolve_rust_toolchain) + +CACHE_KEY="edgezero-deploy-${RUNNER_OS:-Linux}-${RUNNER_ARCH:-X64}-$(sanitize_ref "$RUST_TOOLCHAIN")-$(sanitize_ref "$RUST_TARGET")-$(sanitize_ref "$CLI_VERSION")-${SOURCE_REVISION}-${LOCK_HASH}" + +effective_build_mode() { + case "${INPUT_BUILD_MODE:-auto}" in + auto) printf 'never\n' ;; + always) printf 'always\n' ;; + never) printf 'never\n' ;; + *) fail "input 'build-mode' must be one of: auto, always, never" ;; + esac +} +EFFECTIVE_BUILD_MODE=$(effective_build_mode) + +append_output working-directory "$APP_DIR" +append_output working-directory-relative "$APP_REL" +append_output manifest "$MANIFEST_PATH" +append_output manifest-summary "$MANIFEST_REL" +append_output app-git-root "$APP_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" 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..5b98f1fb --- /dev/null +++ b/.github/actions/deploy-core/scripts/validate-inputs.sh @@ -0,0 +1,108 @@ +#!/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. + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=common.sh +source "$SCRIPT_DIR/common.sh" + +ADAPTER=${INPUT_ADAPTER:-} +BUILD_MODE=${INPUT_BUILD_MODE:-auto} +CACHE=${INPUT_CACHE:-false} +BUILD_ARGS=${INPUT_BUILD_ARGS:-[]} +DEPLOY_ARGS=${INPUT_DEPLOY_ARGS:-[]} +DEPLOY_FLAGS=${INPUT_DEPLOY_FLAGS:-[]} +PROVIDER_ENV_CLEAR=${INPUT_PROVIDER_ENV_CLEAR:-[]} +# Space-separated list of value-taking flags the caller's deploy-args may use +# (wrapper-supplied). Empty means no caller deploy-args are permitted. +DEPLOY_ARG_ALLOW=${INPUT_DEPLOY_ARG_ALLOW:-} +RUNNER_OS_VALUE=${EDGEZERO_RUNNER_OS:-} +RUNNER_ARCH_VALUE=${EDGEZERO_RUNNER_ARCH:-} + +if [[ -n "$RUNNER_OS_VALUE" || -n "$RUNNER_ARCH_VALUE" ]]; then + [[ "$RUNNER_OS_VALUE" == "Linux" && "$RUNNER_ARCH_VALUE" == "X64" ]] || + fail "the EdgeZero deploy engine supports only Linux x86-64 runners; received ${RUNNER_OS_VALUE:-unknown}/${RUNNER_ARCH_VALUE:-unknown}" +fi + +# Well-formedness only: the CLI validates whether the adapter is actually +# supported (there is no engine allowlist). +[[ -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 + +require_cmd jq + +parse_args() { + local name="$1" value="$2" out="$3" + if ! printf '%s' "$value" | jq -e 'type == "array"' >/dev/null 2>&1; then + fail "parameter '$name' must be a JSON array of strings" + fi + if ! printf '%s' "$value" | jq -e 'all(.[]; type == "string")' >/dev/null; then + fail "every element of parameter '$name' must be a string" + fi + 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" +} + +STATE_DIR=${EDGEZERO_ACTION_STATE_DIR:-${RUNNER_TEMP:-/tmp}/edgezero-action-state} +mkdir -p "$STATE_DIR" +BUILD_ARGS_FILE="$STATE_DIR/build-args.nul" +DEPLOY_ARGS_FILE="$STATE_DIR/deploy-args.nul" +DEPLOY_FLAGS_FILE="$STATE_DIR/deploy-flags.nul" +PROVIDER_ENV_CLEAR_FILE="$STATE_DIR/provider-env-clear.nul" +parse_args "build-args" "$BUILD_ARGS" "$BUILD_ARGS_FILE" +parse_args "deploy-args" "$DEPLOY_ARGS" "$DEPLOY_ARGS_FILE" +parse_args "deploy-flags" "$DEPLOY_FLAGS" "$DEPLOY_FLAGS_FILE" +parse_args "provider-env-clear" "$PROVIDER_ENV_CLEAR" "$PROVIDER_ENV_CLEAR_FILE" + +# Apply the wrapper-supplied deploy-arg allowlist. Each permitted flag accepts +# either `--flag=value` (one token) or `--flag value` (two tokens). +validate_deploy_args_allowlist() { + local file="$1" + local -a allowed=() + read -r -a allowed <<<"$DEPLOY_ARG_ALLOW" + local item position=0 expect_value=false + while IFS= read -r -d '' item; do + position=$((position + 1)) + if [[ "$expect_value" == "true" ]]; then + expect_value=false + continue + fi + local flag="${item%%=*}" + local matched=false permitted + for permitted in "${allowed[@]}"; do + if [[ "$flag" == "$permitted" ]]; then + matched=true + [[ "$item" == *=* ]] || expect_value=true + break + fi + done + [[ "$matched" == "true" ]] || + fail "deploy-args allows only: ${DEPLOY_ARG_ALLOW:-} (as '--flag value' or '--flag=value'); rejected argument $position" + done <"$file" + [[ "$expect_value" == "false" ]] || fail "a value-taking deploy-arg flag is missing its value" +} +validate_deploy_args_allowlist "$DEPLOY_ARGS_FILE" + +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" 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..bae59256 --- /dev/null +++ b/.github/actions/deploy-core/scripts/write-summary.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Writes a non-sensitive GitHub step summary. Never emits credentials, full +# environments, or raw argument arrays. + +SUMMARY=${GITHUB_STEP_SUMMARY:-} +[[ -n "$SUMMARY" ]] || exit 0 +{ + echo "## EdgeZero deploy" + echo + echo "| Field | Value |" + echo "| ----- | ----- |" + echo "| Adapter | ${SUMMARY_ADAPTER:-unknown} |" + echo "| Application directory | ${SUMMARY_WORKING_DIRECTORY:-unknown} |" + echo "| Source revision | ${SUMMARY_SOURCE_REVISION:-unknown} |" + echo "| Manifest | ${SUMMARY_MANIFEST:-EdgeZero default discovery} |" + echo "| Rust toolchain | ${SUMMARY_RUST_TOOLCHAIN:-unknown} |" + echo "| Target | ${SUMMARY_TARGET:-unknown} |" + echo "| CLI version | ${SUMMARY_CLI_VERSION:-unknown} |" + echo "| Effective build mode | ${SUMMARY_EFFECTIVE_BUILD_MODE:-unknown} |" + echo "| Cache | ${SUMMARY_CACHE:-false} |" + echo "| Result | ${SUMMARY_RESULT:-unknown} |" +} >>"$SUMMARY" From a668a8f652442721e6feb497d0dbdd0b396a5e3d Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 9 Jul 2026 22:50:46 -0700 Subject: [PATCH 06/20] impl(actions): deploy-fastly Fastly CLI installer + versions.json Port install-fastly.sh (official release + SHA-256 checksum, action-owned PATH dir) and versions.json (Fastly 15.1.0) into the deploy-fastly wrapper. The wrapper action.yml and the shared run-cli.sh follow once the CLI staging contract is finalized. --- .../actions/deploy-fastly/scripts/common.sh | 105 ++++++++++++++++++ .../deploy-fastly/scripts/install-fastly.sh | 44 ++++++++ .github/actions/deploy-fastly/versions.json | 10 ++ 3 files changed, 159 insertions(+) create mode 100644 .github/actions/deploy-fastly/scripts/common.sh create mode 100644 .github/actions/deploy-fastly/scripts/install-fastly.sh create mode 100644 .github/actions/deploy-fastly/versions.json diff --git a/.github/actions/deploy-fastly/scripts/common.sh b/.github/actions/deploy-fastly/scripts/common.sh new file mode 100644 index 00000000..38f96051 --- /dev/null +++ b/.github/actions/deploy-fastly/scripts/common.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +set -euo pipefail + +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/install-fastly.sh b/.github/actions/deploy-fastly/scripts/install-fastly.sh new file mode 100644 index 00000000..5debc582 --- /dev/null +++ b/.github/actions/deploy-fastly/scripts/install-fastly.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=common.sh +source "$SCRIPT_DIR/common.sh" + +ACTION_DIR=$(cd -- "$SCRIPT_DIR/.." && pwd) +ACTION_ROOT=${EDGEZERO_ACTION_ROOT:-$(cd -- "$ACTION_DIR/../../.." && pwd)} +VERSIONS_JSON=${VERSIONS_JSON:-$ACTION_DIR/versions.json} +TOOL_ROOT=${EDGEZERO_TOOL_ROOT:-${RUNNER_TEMP:-/tmp}/edgezero-action-tools} +mkdir -p "$TOOL_ROOT/bin" "$TOOL_ROOT/downloads" + +require_cmd jq +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" +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" + +case "$(uname -s)-$(uname -m)" in + Linux-x86_64|Linux-amd64) ;; + *) fail "Fastly v0 action supports only Linux x86-64 runners" ;; +esac + +if [[ ! -f "$ARCHIVE" ]]; then + require_cmd curl + curl --fail --location --silent --show-error "$URL" --output "$ARCHIVE" +fi + +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" +printf '%s\n' "$TOOL_ROOT/bin" >>"${GITHUB_PATH:-/dev/null}" +export PATH="$TOOL_ROOT/bin:$PATH" +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" +printf '%s\n' "$PROVIDER_CLI_VERSION" +append_output provider-cli-version "$PROVIDER_CLI_VERSION" 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" +} From 658e6dae9cedcef67707259480395ceec6fb1c9c Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 9 Jul 2026 23:23:04 -0700 Subject: [PATCH 07/20] impl(actions): run-cli engine script + deploy-fastly/healthcheck/rollback wrappers - deploy-core/run-cli.sh: provider-neutral CLI runner; typed deploy-flags before --, caller passthrough after --; build-mode clears wrapper-named aliases. - deploy-fastly/action.yml: full orchestration (validate -> download+extract CLI -> resolve -> cache -> install rust + Fastly CLI -> optional build -> deploy), credential scoping via step-level env:, stage input -> --stage, captures fastly-version from the CLI's version= line. - healthcheck-fastly / rollback-fastly: thin wrappers over healthcheck / rollback (Fastly API); healthcheck exits non-zero on unhealthy while still emitting healthy/status-code outputs. All action.yml parse; deploy-core scripts shellcheck-clean. --- .../actions/deploy-core/scripts/run-cli.sh | 82 +++++++ .github/actions/deploy-fastly/action.yml | 217 ++++++++++++++++++ .github/actions/healthcheck-fastly/action.yml | 91 ++++++++ .github/actions/rollback-fastly/action.yml | 67 ++++++ 4 files changed, 457 insertions(+) create mode 100644 .github/actions/deploy-core/scripts/run-cli.sh create mode 100644 .github/actions/deploy-fastly/action.yml create mode 100644 .github/actions/healthcheck-fastly/action.yml create mode 100644 .github/actions/rollback-fastly/action.yml diff --git a/.github/actions/deploy-core/scripts/run-cli.sh b/.github/actions/deploy-core/scripts/run-cli.sh new file mode 100644 index 00000000..593141a8 --- /dev/null +++ b/.github/actions/deploy-core/scripts/run-cli.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Runs the application CLI (build or deploy) through Bash arrays — never eval. +# Provider-neutral: it invokes `` with the adapter, the wrapper's typed +# deploy-flags (before `--`), and caller passthrough deploy-args (after `--`). +# Credential scoping is done by the wrapper via step-level env: — this script +# only clears the wrapper-named aliases during a credential-free build. + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=common.sh +source "$SCRIPT_DIR/common.sh" + +MODE=${1:?usage: run-cli.sh build|deploy} +case "$MODE" in build | deploy) ;; *) fail "mode must be build or deploy" ;; esac + +CLI_BIN=${EDGEZERO_CLI_BIN:?EDGEZERO_CLI_BIN is required} +ADAPTER=${EDGEZERO_ADAPTER:?EDGEZERO_ADAPTER is required} +WORKING_DIRECTORY=${EDGEZERO_WORKING_DIRECTORY:?EDGEZERO_WORKING_DIRECTORY is required} +MANIFEST=${EDGEZERO_MANIFEST_PATH:-} +require_cmd "$CLI_BIN" + +read_nul_into() { + # read_nul_into + local -n _dest="$1" + local file="$2" + [[ -s "$file" ]] || return 0 + local item + while IFS= read -r -d '' item; do + _dest+=("$item") + done <"$file" +} + +clear_named_aliases() { + local file="$1" name + [[ -s "$file" ]] || return 0 + while IFS= read -r -d '' name; do + if [[ "$name" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then + unset "$name" || true + fi + done <"$file" +} + +args=("$CLI_BIN" "$MODE" --adapter "$ADAPTER") + +case "$MODE" in + build) + # Credential-free: clear the wrapper-named provider aliases defensively. + clear_named_aliases "${DEPLOY_PROVIDER_ENV_CLEAR_FILE:-/dev/null}" + passthrough=() + read_nul_into passthrough "${DEPLOY_BUILD_ARGS_FILE:-/dev/null}" + if ((${#passthrough[@]})); then + args+=(--) + args+=("${passthrough[@]}") + fi + ;; + deploy) + # Typed adapter flags (before `--`): --service-id , optional --stage. + flags=() + read_nul_into flags "${DEPLOY_FLAGS_FILE:-/dev/null}" + if ((${#flags[@]})); then + args+=("${flags[@]}") + fi + # Caller passthrough (after `--`): allowlisted deploy-args (e.g. --comment). + passthrough=() + read_nul_into passthrough "${DEPLOY_ARGS_FILE:-/dev/null}" + if ((${#passthrough[@]})); then + args+=(--) + args+=("${passthrough[@]}") + fi + ;; +esac + +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" +"${args[@]}" diff --git a/.github/actions/deploy-fastly/action.yml b/.github/actions/deploy-fastly/action.yml new file mode 100644 index 00000000..06eac528 --- /dev/null +++ b/.github/actions/deploy-fastly/action.yml @@ -0,0 +1,217 @@ +name: EdgeZero deploy-fastly +description: Deploy a checked-out EdgeZero application to Fastly Compute using a prebuilt app CLI artifact. + +inputs: + cli-artifact: + description: Name of the build-cli artifact to download and run. + required: true + 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'] }} + cli-version: + description: Version of the app CLI consumed from the artifact. + value: ${{ steps.cli.outputs['cli-version'] }} + +runs: + using: composite + steps: + - name: Validate inputs + id: validate + shell: bash + env: + INPUT_ADAPTER: fastly + INPUT_BUILD_MODE: ${{ inputs['build-mode'] }} + INPUT_CACHE: ${{ inputs.cache }} + INPUT_BUILD_ARGS: ${{ inputs['build-args'] }} + INPUT_DEPLOY_ARGS: ${{ inputs['deploy-args'] }} + INPUT_DEPLOY_ARG_ALLOW: "--comment" + INPUT_DEPLOY_FLAGS: ${{ inputs.stage == 'true' && format('["--service-id","{0}","--stage"]', inputs['fastly-service-id']) || format('["--service-id","{0}"]', inputs['fastly-service-id']) }} + INPUT_PROVIDER_ENV_CLEAR: '["FASTLY_API_TOKEN","FASTLY_SERVICE_ID","FASTLY_TOKEN","FASTLY_API_ENDPOINT","FASTLY_ENDPOINT"]' + INPUT_FASTLY_API_TOKEN_PRESENT: ${{ inputs['fastly-api-token'] != '' && 'true' || 'false' }} + INPUT_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_API_ENDPOINT: "" + FASTLY_ENDPOINT: "" + run: | + [[ "${INPUT_FASTLY_API_TOKEN_PRESENT}" == "true" ]] || { echo "::error::missing required input 'fastly-api-token'"; exit 1; } + [[ -n "${INPUT_FASTLY_SERVICE_ID}" ]] || { echo "::error::missing required input 'fastly-service-id'"; exit 1; } + "$GITHUB_ACTION_PATH/../deploy-core/scripts/validate-inputs.sh" + + - name: Download CLI artifact + uses: actions/download-artifact@v4 + with: + name: ${{ inputs['cli-artifact'] }} + path: ${{ runner.temp }}/edgezero-cli-download + + - name: Extract CLI + id: cli + shell: bash + env: + EDGEZERO_CLI_ARTIFACT_DIR: ${{ runner.temp }}/edgezero-cli-download + EDGEZERO_TOOL_ROOT: ${{ runner.temp }}/edgezero-action-tools + INPUT_CLI_BIN: ${{ inputs['cli-bin'] }} + FASTLY_API_TOKEN: "" + FASTLY_SERVICE_ID: "" + run: $GITHUB_ACTION_PATH/../deploy-core/scripts/download-cli.sh + + - name: Resolve project + id: resolve + shell: bash + env: + EDGEZERO_ACTION_ROOT: ${{ github.action_path }}/../../.. + EDGEZERO_CLI_VERSION: ${{ steps.cli.outputs['cli-version'] }} + INPUT_WORKING_DIRECTORY: ${{ inputs['working-directory'] }} + INPUT_MANIFEST: ${{ inputs.manifest }} + INPUT_RUST_TOOLCHAIN: auto + INPUT_TARGET: wasm32-wasip1 + INPUT_BUILD_MODE: ${{ inputs['build-mode'] }} + INPUT_CACHE: ${{ inputs.cache }} + FASTLY_API_TOKEN: "" + FASTLY_SERVICE_ID: "" + FASTLY_TOKEN: "" + FASTLY_API_ENDPOINT: "" + FASTLY_ENDPOINT: "" + 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'] }} + + - name: Install Rust + shell: bash + env: + RUST_TOOLCHAIN: ${{ steps.resolve.outputs['rust-toolchain'] }} + RUST_TARGET: wasm32-wasip1 + FASTLY_API_TOKEN: "" + FASTLY_SERVICE_ID: "" + run: $GITHUB_ACTION_PATH/../deploy-core/scripts/install-rust.sh + + - name: Install Fastly CLI + id: install-fastly + shell: bash + env: + EDGEZERO_ACTION_ROOT: ${{ github.action_path }}/../../.. + VERSIONS_JSON: ${{ github.action_path }}/versions.json + FASTLY_API_TOKEN: "" + FASTLY_SERVICE_ID: "" + run: $GITHUB_ACTION_PATH/scripts/install-fastly.sh + + - name: Build (validation) + if: ${{ steps.resolve.outputs['effective-build-mode'] == 'always' }} + shell: bash + env: + EDGEZERO_CLI_BIN: ${{ steps.cli.outputs['cli-bin'] }} + EDGEZERO_ADAPTER: fastly + EDGEZERO_WORKING_DIRECTORY: ${{ steps.resolve.outputs['working-directory'] }} + EDGEZERO_MANIFEST_PATH: ${{ steps.resolve.outputs['manifest'] }} + DEPLOY_BUILD_ARGS_FILE: ${{ steps.validate.outputs['build-args-file'] }} + DEPLOY_PROVIDER_ENV_CLEAR_FILE: ${{ steps.validate.outputs['provider-env-clear-file'] }} + FASTLY_API_TOKEN: "" + FASTLY_SERVICE_ID: "" + run: $GITHUB_ACTION_PATH/../deploy-core/scripts/run-cli.sh build + + - name: Deploy + id: deploy + shell: bash + env: + EDGEZERO_CLI_BIN: ${{ steps.cli.outputs['cli-bin'] }} + EDGEZERO_ADAPTER: fastly + EDGEZERO_WORKING_DIRECTORY: ${{ steps.resolve.outputs['working-directory'] }} + EDGEZERO_MANIFEST_PATH: ${{ steps.resolve.outputs['manifest'] }} + DEPLOY_FLAGS_FILE: ${{ steps.validate.outputs['deploy-flags-file'] }} + DEPLOY_ARGS_FILE: ${{ steps.validate.outputs['deploy-args-file'] }} + FASTLY_API_TOKEN: ${{ inputs['fastly-api-token'] }} + FASTLY_SERVICE_ID: ${{ inputs['fastly-service-id'] }} + run: | + set -o pipefail + log="${RUNNER_TEMP:-/tmp}/edgezero-deploy.log" + "$GITHUB_ACTION_PATH/../deploy-core/scripts/run-cli.sh" deploy 2>&1 | tee "$log" + version=$(grep -oE '^version=[0-9]+' "$log" | tail -n 1 | cut -d= -f2 || true) + echo "fastly-version=${version}" >> "$GITHUB_OUTPUT" + + - 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'] }} + + - name: Write summary + if: ${{ always() }} + shell: bash + env: + SUMMARY_ADAPTER: fastly + SUMMARY_WORKING_DIRECTORY: ${{ steps.resolve.outputs['working-directory-relative'] }} + SUMMARY_SOURCE_REVISION: ${{ steps.resolve.outputs['source-revision'] }} + SUMMARY_MANIFEST: ${{ steps.resolve.outputs['manifest-summary'] }} + SUMMARY_RUST_TOOLCHAIN: ${{ steps.resolve.outputs['rust-toolchain'] }} + SUMMARY_TARGET: wasm32-wasip1 + SUMMARY_CLI_VERSION: ${{ steps.cli.outputs['cli-version'] }} + SUMMARY_EFFECTIVE_BUILD_MODE: ${{ steps.resolve.outputs['effective-build-mode'] }} + SUMMARY_CACHE: ${{ inputs.cache }} + SUMMARY_RESULT: ${{ steps.deploy.outcome }} + FASTLY_API_TOKEN: "" + FASTLY_SERVICE_ID: "" + 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_TOOL_ROOT: ${{ runner.temp }}/edgezero-action-tools + FASTLY_API_TOKEN: "" + FASTLY_SERVICE_ID: "" + run: $GITHUB_ACTION_PATH/../deploy-core/scripts/cleanup.sh diff --git a/.github/actions/healthcheck-fastly/action.yml b/.github/actions/healthcheck-fastly/action.yml new file mode 100644 index 00000000..b5946b32 --- /dev/null +++ b/.github/actions/healthcheck-fastly/action.yml @@ -0,0 +1,91 @@ +name: EdgeZero healthcheck-fastly +description: Probe a deployed Fastly version's health via the app CLI. Exits non-zero when unhealthy after retries. + +inputs: + cli-artifact: + description: Name of the build-cli artifact to download and run. + required: true + 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['cli-artifact'] }} + path: ${{ runner.temp }}/edgezero-cli-download + + - name: Extract CLI + id: cli + shell: bash + env: + EDGEZERO_CLI_ARTIFACT_DIR: ${{ runner.temp }}/edgezero-cli-download + EDGEZERO_TOOL_ROOT: ${{ runner.temp }}/edgezero-action-tools + INPUT_CLI_BIN: ${{ inputs['cli-bin'] }} + FASTLY_API_TOKEN: "" + run: $GITHUB_ACTION_PATH/../deploy-core/scripts/download-cli.sh + + - name: Health check + id: check + shell: bash + env: + CLI_BIN: ${{ steps.cli.outputs['cli-bin'] }} + SERVICE_ID: ${{ inputs['fastly-service-id'] }} + VERSION: ${{ inputs['fastly-version'] }} + DOMAIN: ${{ inputs.domain }} + DEPLOY_TO: ${{ inputs['deploy-to'] }} + RETRY: ${{ inputs.retry }} + RETRY_DELAY: ${{ inputs['retry-delay'] }} + TIMEOUT: ${{ inputs.timeout }} + FASTLY_API_TOKEN: ${{ inputs['fastly-api-token'] }} + run: | + log="${RUNNER_TEMP:-/tmp}/edgezero-healthcheck.log" + args=("$CLI_BIN" healthcheck --adapter fastly --service-id "$SERVICE_ID" --version "$VERSION" --domain "$DOMAIN" --retry "$RETRY" --retry-delay "$RETRY_DELAY" --timeout "$TIMEOUT") + [[ "$DEPLOY_TO" == "staging" ]] && args+=(--staging) + rc=0 + "${args[@]}" 2>&1 | tee "$log" || rc=$? + healthy=$(grep -oE '^healthy=(true|false)' "$log" | tail -n 1 | cut -d= -f2 || true) + status=$(grep -oE '^status-code=[0-9]+' "$log" | tail -n 1 | cut -d= -f2 || true) + echo "healthy=${healthy:-false}" >> "$GITHUB_OUTPUT" + echo "status-code=${status}" >> "$GITHUB_OUTPUT" + exit "$rc" diff --git a/.github/actions/rollback-fastly/action.yml b/.github/actions/rollback-fastly/action.yml new file mode 100644 index 00000000..c0769ef8 --- /dev/null +++ b/.github/actions/rollback-fastly/action.yml @@ -0,0 +1,67 @@ +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: + cli-artifact: + description: Name of the build-cli artifact to download and run. + required: true + 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['cli-artifact'] }} + path: ${{ runner.temp }}/edgezero-cli-download + + - name: Extract CLI + id: cli + shell: bash + env: + EDGEZERO_CLI_ARTIFACT_DIR: ${{ runner.temp }}/edgezero-cli-download + EDGEZERO_TOOL_ROOT: ${{ runner.temp }}/edgezero-action-tools + INPUT_CLI_BIN: ${{ inputs['cli-bin'] }} + FASTLY_API_TOKEN: "" + run: $GITHUB_ACTION_PATH/../deploy-core/scripts/download-cli.sh + + - name: Rollback + id: rollback + shell: bash + env: + CLI_BIN: ${{ steps.cli.outputs['cli-bin'] }} + SERVICE_ID: ${{ inputs['fastly-service-id'] }} + VERSION: ${{ inputs['fastly-version'] }} + DEPLOY_TO: ${{ inputs['deploy-to'] }} + FASTLY_API_TOKEN: ${{ inputs['fastly-api-token'] }} + run: | + log="${RUNNER_TEMP:-/tmp}/edgezero-rollback.log" + args=("$CLI_BIN" rollback --adapter fastly --service-id "$SERVICE_ID" --version "$VERSION") + [[ "$DEPLOY_TO" == "staging" ]] && args+=(--staging) + rc=0 + "${args[@]}" 2>&1 | tee "$log" || rc=$? + rolled=$(grep -oE '^rolled-back-to=[0-9]+' "$log" | tail -n 1 | cut -d= -f2 || true) + echo "rolled-back-to=${rolled}" >> "$GITHUB_OUTPUT" + exit "$rc" From 1007c37c244409edf6aea11339f606ecf541ae1c Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 9 Jul 2026 23:34:10 -0700 Subject: [PATCH 08/20] impl(actions): readability pass on run-cli, build-cli, tests Apply Bash best-practices structure: wrap logic in main() with explicit local parameters and single-responsibility helpers; route the progress line to stderr; portable NUL-array collection (no bash 4.3 namerefs); a small named assertion harness (assert_succeeds/assert_fails/assert_equals) in the test runner. Kept coreutils short flags for macOS/BSD portability. All shellcheck-clean; 10/10 contract tests pass. --- .../actions/build-cli/scripts/build-cli.sh | 219 ++++++++++-------- .../actions/deploy-core/scripts/run-cli.sh | 141 ++++++----- .github/actions/deploy-core/tests/run.sh | 202 ++++++++++++++++ 3 files changed, 405 insertions(+), 157 deletions(-) create mode 100755 .github/actions/deploy-core/tests/run.sh diff --git a/.github/actions/build-cli/scripts/build-cli.sh b/.github/actions/build-cli/scripts/build-cli.sh index 2c930179..9832971d 100755 --- a/.github/actions/build-cli/scripts/build-cli.sh +++ b/.github/actions/build-cli/scripts/build-cli.sh @@ -6,134 +6,153 @@ set -euo pipefail # then packages the binary plus a self-describing cli-meta.json into a tar so the # executable bit survives actions/upload-artifact. Never builds the EdgeZero # monorepo CLI. +# +# Inputs (environment): +# INPUT_CLI_PACKAGE required Cargo package name to build +# INPUT_CLI_BIN optional binary name (defaults to the package name) +# INPUT_WORKING_DIRECTORY optional app dir relative to github.workspace (".") +# INPUT_RUST_TOOLCHAIN optional explicit toolchain or "auto" +# INPUT_ARTIFACT_NAME optional uploaded artifact name ("edgezero-cli") SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) # shellcheck source=common.sh source "$SCRIPT_DIR/common.sh" -WORKSPACE=${GITHUB_WORKSPACE:?GITHUB_WORKSPACE is required} -ACTION_ROOT=${EDGEZERO_ACTION_ROOT:?EDGEZERO_ACTION_ROOT is required} -CLI_PACKAGE=${INPUT_CLI_PACKAGE:?input 'cli-package' is required} -CLI_BIN=${INPUT_CLI_BIN:-} -WORKING_DIRECTORY=${INPUT_WORKING_DIRECTORY:-.} -RUST_TOOLCHAIN_INPUT=${INPUT_RUST_TOOLCHAIN:-auto} -ARTIFACT_NAME=${INPUT_ARTIFACT_NAME:-edgezero-cli} -STAGE_ROOT=${EDGEZERO_CLI_STAGE_ROOT:-${RUNNER_TEMP:-/tmp}/edgezero-cli-artifact} -BUILD_TARGET_DIR=${CARGO_TARGET_DIR_OVERRIDE:-${RUNNER_TEMP:-/tmp}/edgezero-cli-build} - -case "$(uname -s)-$(uname -m)" in - Linux-x86_64 | Linux-amd64) ;; - *) fail "build-cli supports only Linux x86-64 runners" ;; -esac - -require_cmd cargo -require_cmd rustup -require_cmd jq -require_cmd tar - -# --- Resolve the application directory beneath github.workspace --------------- -WORKSPACE_REAL=$(canonical_path "$WORKSPACE") -APP_INPUT="$WORKSPACE/$WORKING_DIRECTORY" -[[ -d "$APP_INPUT" ]] || fail "working-directory '$WORKING_DIRECTORY' does not exist or is not a directory" -APP_DIR=$(canonical_path "$APP_INPUT") -is_under "$WORKSPACE_REAL" "$APP_DIR" || fail "input 'working-directory' must resolve inside github.workspace" - -# --- Resolve the application Rust toolchain (input > rustup files > .tool-versions) -- -parse_rust_toolchain_file() { +# --- Rust toolchain resolution helpers --------------------------------------- +parse_toolchain_from_channel_file() { + local file="$1" local value - value=$(sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' "$1" | awk 'NF { print; exit }') - [[ -n "$value" ]] || fail "malformed Rust toolchain file: $1" + 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_rust_toolchain_toml() { + +parse_toolchain_from_toml() { + local file="$1" 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" + 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" } + +# Resolve the application toolchain: explicit input > rustup files (walking up to +# github.workspace) > .tool-versions > the EdgeZero action repo fallback. resolve_rust_toolchain() { - if [[ "$RUST_TOOLCHAIN_INPUT" != "auto" ]]; then - [[ -n "$RUST_TOOLCHAIN_INPUT" ]] || fail "input 'rust-toolchain' cannot be empty" - printf '%s\n' "$RUST_TOOLCHAIN_INPUT" + 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 - local directory="$APP_DIR" value + + local directory="$app_dir" value while true; do if [[ -f "$directory/rust-toolchain.toml" ]]; then - parse_rust_toolchain_toml "$directory/rust-toolchain.toml" + parse_toolchain_from_toml "$directory/rust-toolchain.toml" return fi if [[ -f "$directory/rust-toolchain" ]]; then - parse_rust_toolchain_file "$directory/rust-toolchain" + 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" == "$WORKSPACE_REAL" ]] && break - local next - next=$(dirname "$directory") - [[ "$next" == "$directory" ]] && break - directory="$next" + [[ "$directory" == "$workspace_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 + + 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" } -RUST_TOOLCHAIN=$(resolve_rust_toolchain) - -# Install the host toolchain only. The CLI is a native tool; the WASM target the -# *application* needs is installed later by the deploy engine, not here. -rustup toolchain install "$RUST_TOOLCHAIN" --profile minimal - -# --- Validate the package + resolve the binary/version via cargo metadata ----- -cd "$APP_DIR" -[[ -f Cargo.lock || -f "$(cargo locate-project --workspace --message-format plain 2>/dev/null | xargs -r dirname)/Cargo.lock" ]] || - fail "no Cargo.lock at the app's Cargo workspace root; build-cli requires a committed lockfile" - -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" - -PKG_JSON=$(jq -c --arg p "$CLI_PACKAGE" '.packages[] | select(.name == $p)' <<<"$METADATA") -[[ -n "$PKG_JSON" ]] || fail "cli-package '$CLI_PACKAGE' was not found in the application workspace" - -# Default the binary name to the package name; verify the bin target exists. -[[ -n "$CLI_BIN" ]] || CLI_BIN="$CLI_PACKAGE" -HAS_BIN=$(jq -r --arg b "$CLI_BIN" '[.targets[] | select(.kind | index("bin")) | .name] | index($b) != null' <<<"$PKG_JSON") -[[ "$HAS_BIN" == "true" ]] || fail "cli-package '$CLI_PACKAGE' declares no binary target named '$CLI_BIN'" -CLI_VERSION=$(jq -r '.version' <<<"$PKG_JSON") - -# --- Build into an action-owned target dir (never dirties the checkout) ------- -rm -rf "$BUILD_TARGET_DIR" -mkdir -p "$BUILD_TARGET_DIR" -CARGO_TARGET_DIR="$BUILD_TARGET_DIR" cargo +"$RUST_TOOLCHAIN" build \ - --locked --release -p "$CLI_PACKAGE" --bin "$CLI_BIN" - -BIN_PATH="$BUILD_TARGET_DIR/release/$CLI_BIN" -[[ -x "$BIN_PATH" ]] || fail "build did not produce an executable at $BIN_PATH" - -# Smoke-check runnability (today's generated CLI may have no --version). -"$BIN_PATH" --help >/dev/null 2>&1 || fail "built CLI '$CLI_BIN' did not run '$CLI_BIN --help'" - -# --- Package binary + self-describing metadata into a tar --------------------- -rm -rf "$STAGE_ROOT" -mkdir -p "$STAGE_ROOT" -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" \ - '{"cli-bin": $bin, "cli-version": $version, "cli-package": $package}' \ - >"$STAGE_ROOT/cli-meta.json" - -TARBALL="$STAGE_ROOT/../${ARTIFACT_NAME}.tar" -tar -C "$STAGE_ROOT" -cf "$TARBALL" "$CLI_BIN" cli-meta.json -TARBALL=$(canonical_path "$TARBALL") - -notice "built app CLI '$CLI_BIN' v$CLI_VERSION from package '$CLI_PACKAGE'" -append_output cli-version "$CLI_VERSION" -append_output cli-package "$CLI_PACKAGE" -append_output cli-bin "$CLI_BIN" -append_output artifact-name "$ARTIFACT_NAME" -append_output tarball-path "$TARBALL" + +require_linux_x86_64() { + case "$(uname -s)-$(uname -m)" in + Linux-x86_64 | Linux-amd64) ;; + *) fail "build-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="${INPUT_CLI_PACKAGE:?input 'cli-package' is required}" + local cli_bin="${INPUT_CLI_BIN:-}" + local working_directory="${INPUT_WORKING_DIRECTORY:-.}" + local rust_toolchain_input="${INPUT_RUST_TOOLCHAIN:-auto}" + local artifact_name="${INPUT_ARTIFACT_NAME:-edgezero-cli}" + local stage_root="${EDGEZERO_CLI_STAGE_ROOT:-${RUNNER_TEMP:-/tmp}/edgezero-cli-artifact}" + local build_target_dir="${CARGO_TARGET_DIR_OVERRIDE:-${RUNNER_TEMP:-/tmp}/edgezero-cli-build}" + + require_linux_x86_64 + require_cmd cargo + require_cmd rustup + require_cmd jq + require_cmd tar + + # 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 "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 "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. + rm -rf "$build_target_dir" + mkdir -p "$build_target_dir" + 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. + rm -rf "$stage_root" + mkdir -p "$stage_root" + 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" \ + '{"cli-bin": $bin, "cli-version": $version, "cli-package": $package}' \ + >"$stage_root/cli-meta.json" + + local tarball="$stage_root/../${artifact_name}.tar" + tar -C "$stage_root" -cf "$tarball" "$cli_bin" cli-meta.json + tarball=$(canonical_path "$tarball") + + notice "built app CLI '$cli_bin' v$cli_version from package '$cli_package'" + append_output cli-version "$cli_version" + append_output cli-package "$cli_package" + append_output cli-bin "$cli_bin" + append_output artifact-name "$artifact_name" + append_output tarball-path "$tarball" +} + +main "$@" diff --git a/.github/actions/deploy-core/scripts/run-cli.sh b/.github/actions/deploy-core/scripts/run-cli.sh index 593141a8..946a9b78 100644 --- a/.github/actions/deploy-core/scripts/run-cli.sh +++ b/.github/actions/deploy-core/scripts/run-cli.sh @@ -2,38 +2,45 @@ set -euo pipefail # Runs the application CLI (build or deploy) through Bash arrays — never eval. -# Provider-neutral: it invokes `` with the adapter, the wrapper's typed -# deploy-flags (before `--`), and caller passthrough deploy-args (after `--`). -# Credential scoping is done by the wrapper via step-level env: — this script -# only clears the wrapper-named aliases during a credential-free build. +# +# Provider-neutral: it invokes ` --adapter ` with the +# wrapper's typed deploy-flags (before `--`) and the caller's passthrough +# deploy-args (after `--`). Credential scoping is the wrapper's job, done with +# step-level `env:`; this script only clears the wrapper-named aliases during a +# credential-free build. +# +# Inputs (environment): +# EDGEZERO_CLI_BIN required binary name to invoke (on PATH) +# EDGEZERO_ADAPTER required adapter passed as --adapter +# EDGEZERO_WORKING_DIRECTORY required directory to run the CLI from +# EDGEZERO_MANIFEST_PATH optional exported as EDGEZERO_MANIFEST when set +# DEPLOY_BUILD_ARGS_FILE optional NUL-delimited build passthrough (build) +# DEPLOY_FLAGS_FILE optional NUL-delimited typed flags (deploy) +# DEPLOY_ARGS_FILE optional NUL-delimited passthrough (deploy) +# DEPLOY_PROVIDER_ENV_CLEAR_FILE optional NUL-delimited env names to clear (build) SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) # shellcheck source=common.sh source "$SCRIPT_DIR/common.sh" -MODE=${1:?usage: run-cli.sh build|deploy} -case "$MODE" in build | deploy) ;; *) fail "mode must be build or deploy" ;; esac - -CLI_BIN=${EDGEZERO_CLI_BIN:?EDGEZERO_CLI_BIN is required} -ADAPTER=${EDGEZERO_ADAPTER:?EDGEZERO_ADAPTER is required} -WORKING_DIRECTORY=${EDGEZERO_WORKING_DIRECTORY:?EDGEZERO_WORKING_DIRECTORY is required} -MANIFEST=${EDGEZERO_MANIFEST_PATH:-} -require_cmd "$CLI_BIN" - -read_nul_into() { - # read_nul_into - local -n _dest="$1" - local file="$2" +# 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 item - while IFS= read -r -d '' item; do - _dest+=("$item") + 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" name + 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 @@ -41,42 +48,62 @@ clear_named_aliases() { done <"$file" } -args=("$CLI_BIN" "$MODE" --adapter "$ADAPTER") +# 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 "${DEPLOY_PROVIDER_ENV_CLEAR_FILE:-/dev/null}" + collect_nul "${DEPLOY_BUILD_ARGS_FILE:-/dev/null}" + if ((${#COLLECTED[@]})); then + ARGV+=(-- "${COLLECTED[@]}") + fi +} -case "$MODE" in - build) - # Credential-free: clear the wrapper-named provider aliases defensively. - clear_named_aliases "${DEPLOY_PROVIDER_ENV_CLEAR_FILE:-/dev/null}" - passthrough=() - read_nul_into passthrough "${DEPLOY_BUILD_ARGS_FILE:-/dev/null}" - if ((${#passthrough[@]})); then - args+=(--) - args+=("${passthrough[@]}") - fi - ;; - deploy) - # Typed adapter flags (before `--`): --service-id , optional --stage. - flags=() - read_nul_into flags "${DEPLOY_FLAGS_FILE:-/dev/null}" - if ((${#flags[@]})); then - args+=("${flags[@]}") - fi - # Caller passthrough (after `--`): allowlisted deploy-args (e.g. --comment). - passthrough=() - read_nul_into passthrough "${DEPLOY_ARGS_FILE:-/dev/null}" - if ((${#passthrough[@]})); then - args+=(--) - args+=("${passthrough[@]}") - fi - ;; -esac +# 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 "${DEPLOY_FLAGS_FILE:-/dev/null}" + ((${#COLLECTED[@]})) && ARGV+=("${COLLECTED[@]}") + # Caller passthrough (after `--`): allowlisted deploy-args, e.g. --comment. + collect_nul "${DEPLOY_ARGS_FILE:-/dev/null}" + if ((${#COLLECTED[@]})); then + ARGV+=(-- "${COLLECTED[@]}") + fi +} + +ARGV=() +main() { + local mode="${1:-}" + case "$mode" in + build | deploy) ;; + *) fail "usage: run-cli.sh build|deploy" ;; + esac + + local cli_bin="${EDGEZERO_CLI_BIN:?EDGEZERO_CLI_BIN is required}" + local adapter="${EDGEZERO_ADAPTER:?EDGEZERO_ADAPTER is required}" + local working_directory="${EDGEZERO_WORKING_DIRECTORY:?EDGEZERO_WORKING_DIRECTORY is required}" + local manifest="${EDGEZERO_MANIFEST_PATH:-}" + require_cmd "$cli_bin" -if [[ -n "$MANIFEST" ]]; then - export EDGEZERO_MANIFEST="$MANIFEST" -else - unset EDGEZERO_MANIFEST || true -fi + case "$mode" in + build) build_build_argv "$cli_bin" "$adapter" ;; + deploy) build_deploy_argv "$cli_bin" "$adapter" ;; + esac + + 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[@]}" +} -cd "$WORKING_DIRECTORY" -echo "[edgezero-action] running $CLI_BIN $MODE for adapter $ADAPTER" -"${args[@]}" +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..0f8190ae --- /dev/null +++ b/.github/actions/deploy-core/tests/run.sh @@ -0,0 +1,202 @@ +#!/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" \ + INPUT_ADAPTER="${VALIDATE_ADAPTER:-fastly}" \ + INPUT_CACHE="${VALIDATE_CACHE:-false}" \ + INPUT_BUILD_MODE="${VALIDATE_BUILD_MODE:-auto}" \ + INPUT_BUILD_ARGS="${VALIDATE_BUILD_ARGS:-[]}" \ + INPUT_DEPLOY_ARGS="${VALIDATE_DEPLOY_ARGS:-[]}" \ + INPUT_DEPLOY_FLAGS="${VALIDATE_DEPLOY_FLAGS:-[]}" \ + INPUT_PROVIDER_ENV_CLEAR="${VALIDATE_PROVIDER_ENV_CLEAR:-[]}" \ + INPUT_DEPLOY_ARG_ALLOW="${VALIDATE_ALLOW:-}" \ + 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_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 +} + +# --------------------------------------------------------------------------- +# run-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_CLI_BIN=fakecli \ + EDGEZERO_ADAPTER=fastly \ + EDGEZERO_WORKING_DIRECTORY="$app_dir" \ + DEPLOY_FLAGS_FILE="$WORK_DIR/deploy-flags.nul" \ + DEPLOY_ARGS_FILE="$WORK_DIR/deploy-args.nul" \ + bash "$CORE_SCRIPTS/run-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-cli.sh — self-describing artifact +# --------------------------------------------------------------------------- +# Builds a fake artifact tar (binary + cli-meta.json) and asserts download-cli +# extracts it and surfaces the metadata. +test_download_cli_metadata() { + section "download-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 '{"cli-bin":"myapp-cli","cli-version":"1.2.3","cli-package":"myapp-cli"}\n' \ + >"$stage_dir/cli-meta.json" + tar -C "$stage_dir" -cf "$artifact_dir/edgezero-cli.tar" myapp-cli cli-meta.json + + local output_file="$WORK_DIR/download-output.txt" + if env -i PATH="$PATH" \ + EDGEZERO_CLI_ARTIFACT_DIR="$artifact_dir" \ + EDGEZERO_TOOL_ROOT="$WORK_DIR/tools" \ + GITHUB_OUTPUT="$output_file" \ + GITHUB_PATH="$WORK_DIR/download-path.txt" \ + bash "$CORE_SCRIPTS/download-cli.sh" >/dev/null 2>&1; then + if grep -qx 'cli-bin=myapp-cli' "$output_file" && grep -qx 'cli-version=1.2.3' "$output_file"; then + pass "extracts the tar and reads cli-meta.json" + else + fail "download-cli did not surface the expected metadata" + fi + else + fail "download-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 +} + +# --------------------------------------------------------------------------- +main() { + test_validate_inputs + test_run_cli_argv + test_download_cli_metadata + test_fastly_versions + + printf '\nPassed: %d Failed: %d\n' "$tests_passed" "$tests_failed" + [[ "$tests_failed" -eq 0 ]] +} + +main "$@" From 344d244e0284c43609f0aa2c994ebb5f137115e5 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 9 Jul 2026 23:56:21 -0700 Subject: [PATCH 09/20] impl(actions): consistent main() structure, CI workflow, use setup-rust-toolchain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Apply the main()/helper structure and Bash best-practices across all engine scripts (validate-inputs, resolve-project, download-cli, install-fastly, cleanup, write-summary); route diagnostics to stderr; local scoping throughout. - Replace the custom deploy-core install-rust.sh with the maintained actions-rust-lang/setup-rust-toolchain@v1 (readable tag) in deploy-fastly, feeding the resolved toolchain + wasm32-wasip1 target; cache: false so our exact-key target/ cache stays authoritative. build-cli keeps rustup for dynamic (app-resolved) toolchain install. - Add .github/workflows/deploy-action.yml: no Python — actionlint from a pinned release binary, zizmor via cargo install (no pip), shellcheck, Bash contract tests, check-action-pins.sh (flags floating @main/@master refs), docs validation, and a build-cli -> deploy-fastly composite smoke test. - Add check-action-pins.sh; all third-party actions pinned to readable tags. --- .../actions/deploy-core/scripts/cleanup.sh | 25 ++- .../deploy-core/scripts/download-cli.sh | 64 ++++-- .../deploy-core/scripts/install-rust.sh | 16 -- .../deploy-core/scripts/resolve-project.sh | 205 ++++++++++-------- .../deploy-core/scripts/validate-inputs.sh | 165 +++++++------- .../deploy-core/scripts/write-summary.sh | 42 ++-- .../deploy-core/tests/check-action-pins.sh | 43 ++++ .github/actions/deploy-fastly/action.yml | 16 +- .../deploy-fastly/scripts/install-fastly.sh | 93 +++++--- .github/workflows/deploy-action.yml | 155 +++++++++++++ 10 files changed, 538 insertions(+), 286 deletions(-) delete mode 100755 .github/actions/deploy-core/scripts/install-rust.sh create mode 100755 .github/actions/deploy-core/tests/check-action-pins.sh create mode 100644 .github/workflows/deploy-action.yml diff --git a/.github/actions/deploy-core/scripts/cleanup.sh b/.github/actions/deploy-core/scripts/cleanup.sh index d7e21eec..cf82f376 100755 --- a/.github/actions/deploy-core/scripts/cleanup.sh +++ b/.github/actions/deploy-core/scripts/cleanup.sh @@ -1,17 +1,18 @@ #!/usr/bin/env bash set -euo pipefail -ROOT=${EDGEZERO_ACTION_STATE_DIR:-} -if [[ -n "$ROOT" && -d "$ROOT" ]]; then - rm -rf "$ROOT" -fi +# Removes action-owned temporary state, tool installs, and any provider auth +# state. Runs with `if: always()`, so it must tolerate partially-created dirs. -TOOL_ROOT=${EDGEZERO_TOOL_ROOT:-} -if [[ -n "$TOOL_ROOT" && -d "$TOOL_ROOT" ]]; then - rm -rf "$TOOL_ROOT" -fi +remove_if_present() { + local dir="$1" + [[ -n "$dir" && -d "$dir" ]] && rm -rf "$dir" +} -# Remove action-owned Fastly auth state if future installers create it. -if [[ -n "${EDGEZERO_FASTLY_HOME:-}" && -d "$EDGEZERO_FASTLY_HOME" ]]; then - rm -rf "$EDGEZERO_FASTLY_HOME" -fi +main() { + remove_if_present "${EDGEZERO_ACTION_STATE_DIR:-}" + remove_if_present "${EDGEZERO_TOOL_ROOT:-}" + remove_if_present "${EDGEZERO_FASTLY_HOME:-}" +} + +main "$@" diff --git a/.github/actions/deploy-core/scripts/download-cli.sh b/.github/actions/deploy-core/scripts/download-cli.sh index edff8a76..fc737bcb 100755 --- a/.github/actions/deploy-core/scripts/download-cli.sh +++ b/.github/actions/deploy-core/scripts/download-cli.sh @@ -4,39 +4,55 @@ set -euo pipefail # Extracts the build-cli artifact tar (downloaded by actions/download-artifact) # into an action-owned tool dir, preserving the executable bit, reads the # self-describing cli-meta.json, and prepends the dir to PATH for action steps. -# A wrapper-supplied CLI_BIN overrides the metadata's binary name. +# A wrapper-supplied INPUT_CLI_BIN overrides the metadata's binary name. +# +# Inputs (environment): +# EDGEZERO_CLI_ARTIFACT_DIR required dir containing the downloaded tar +# INPUT_CLI_BIN optional override for the binary name +# EDGEZERO_TOOL_ROOT optional install dir (defaults under RUNNER_TEMP) SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) # shellcheck source=common.sh source "$SCRIPT_DIR/common.sh" -ARTIFACT_DIR=${EDGEZERO_CLI_ARTIFACT_DIR:?EDGEZERO_CLI_ARTIFACT_DIR is required} -CLI_BIN_OVERRIDE=${INPUT_CLI_BIN:-} -TOOL_ROOT=${EDGEZERO_TOOL_ROOT:-${RUNNER_TEMP:-/tmp}/edgezero-action-tools} -require_cmd jq -require_cmd tar +# Locate the single CLI tar produced by build-cli within the downloaded artifact. +find_cli_tarball() { + local artifact_dir="$1" + find "$artifact_dir" -maxdepth 2 -type f -name '*.tar' | head -n 1 +} -mkdir -p "$TOOL_ROOT/bin" +main() { + local artifact_dir="${EDGEZERO_CLI_ARTIFACT_DIR:?EDGEZERO_CLI_ARTIFACT_DIR is required}" + local cli_bin_override="${INPUT_CLI_BIN:-}" + local tool_root="${EDGEZERO_TOOL_ROOT:-${RUNNER_TEMP:-/tmp}/edgezero-action-tools}" -# The artifact contains a single tar (built by build-cli). Locate it. -TARBALL=$(find "$ARTIFACT_DIR" -maxdepth 2 -type f -name '*.tar' | head -n 1) -[[ -n "$TARBALL" ]] || fail "no CLI tar found under the downloaded artifact at '$ARTIFACT_DIR'" + require_cmd jq + require_cmd tar + mkdir -p "$tool_root/bin" -tar -xf "$TARBALL" -C "$TOOL_ROOT/bin" -[[ -f "$TOOL_ROOT/bin/cli-meta.json" ]] || fail "CLI artifact is missing cli-meta.json" + local tarball + tarball=$(find_cli_tarball "$artifact_dir") + [[ -n "$tarball" ]] || fail "no CLI tar found under the downloaded artifact at '$artifact_dir'" -META_BIN=$(jq -er '."cli-bin"' "$TOOL_ROOT/bin/cli-meta.json") || fail "cli-meta.json has no cli-bin" -CLI_VERSION=$(jq -er '."cli-version"' "$TOOL_ROOT/bin/cli-meta.json") || fail "cli-meta.json has no cli-version" -CLI_BIN=${CLI_BIN_OVERRIDE:-$META_BIN} + tar -xf "$tarball" -C "$tool_root/bin" + [[ -f "$tool_root/bin/cli-meta.json" ]] || fail "CLI artifact is missing cli-meta.json" -[[ -f "$TOOL_ROOT/bin/$CLI_BIN" ]] || fail "CLI binary '$CLI_BIN' not present in the artifact" -chmod +x "$TOOL_ROOT/bin/$CLI_BIN" -"$TOOL_ROOT/bin/$CLI_BIN" --help >/dev/null 2>&1 || fail "downloaded CLI '$CLI_BIN' did not run '--help'" + local meta_bin cli_version cli_bin + meta_bin=$(jq -er '."cli-bin"' "$tool_root/bin/cli-meta.json") || fail "cli-meta.json has no cli-bin" + cli_version=$(jq -er '."cli-version"' "$tool_root/bin/cli-meta.json") || fail "cli-meta.json has no cli-version" + cli_bin="${cli_bin_override:-$meta_bin}" -printf '%s\n' "$TOOL_ROOT/bin" >>"${GITHUB_PATH:-/dev/null}" -export PATH="$TOOL_ROOT/bin:$PATH" + [[ -f "$tool_root/bin/$cli_bin" ]] || fail "CLI binary '$cli_bin' not present in the artifact" + chmod +x "$tool_root/bin/$cli_bin" + "$tool_root/bin/$cli_bin" --help >/dev/null 2>&1 || fail "downloaded CLI '$cli_bin' did not run '--help'" -notice "using app CLI '$CLI_BIN' v$CLI_VERSION from artifact" -append_output cli-bin "$CLI_BIN" -append_output cli-version "$CLI_VERSION" -append_env EDGEZERO_TOOL_ROOT "$TOOL_ROOT" + 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 cli-bin "$cli_bin" + append_output cli-version "$cli_version" + append_env EDGEZERO_TOOL_ROOT "$tool_root" +} + +main "$@" diff --git a/.github/actions/deploy-core/scripts/install-rust.sh b/.github/actions/deploy-core/scripts/install-rust.sh deleted file mode 100755 index 31d3ba3a..00000000 --- a/.github/actions/deploy-core/scripts/install-rust.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Installs the application Rust toolchain plus the wrapper-provided concrete -# target. The engine never maps adapter -> target; the wrapper supplies it. - -SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) -# shellcheck source=common.sh -source "$SCRIPT_DIR/common.sh" - -RUST_TOOLCHAIN=${RUST_TOOLCHAIN:?RUST_TOOLCHAIN is required} -TARGET=${RUST_TARGET:?RUST_TARGET is required (wrapper-provided concrete target)} -require_cmd rustup -rustup toolchain install "$RUST_TOOLCHAIN" --profile minimal -rustup target add "$TARGET" --toolchain "$RUST_TOOLCHAIN" -append_env RUSTUP_TOOLCHAIN "$RUST_TOOLCHAIN" diff --git a/.github/actions/deploy-core/scripts/resolve-project.sh b/.github/actions/deploy-core/scripts/resolve-project.sh index 0de2ae37..5bc9544b 100755 --- a/.github/actions/deploy-core/scripts/resolve-project.sh +++ b/.github/actions/deploy-core/scripts/resolve-project.sh @@ -5,134 +5,157 @@ set -euo pipefail # 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. +# +# Inputs (environment): INPUT_WORKING_DIRECTORY, INPUT_MANIFEST, +# INPUT_RUST_TOOLCHAIN, INPUT_TARGET (required), INPUT_BUILD_MODE, INPUT_CACHE, +# EDGEZERO_ACTION_ROOT (required), EDGEZERO_CLI_VERSION. SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) # shellcheck source=common.sh source "$SCRIPT_DIR/common.sh" -WORKSPACE=${GITHUB_WORKSPACE:?GITHUB_WORKSPACE is required} -ACTION_ROOT=${EDGEZERO_ACTION_ROOT:?EDGEZERO_ACTION_ROOT is required} -WORKING_DIRECTORY=${INPUT_WORKING_DIRECTORY:-.} -MANIFEST=${INPUT_MANIFEST:-} -RUST_TOOLCHAIN_INPUT=${INPUT_RUST_TOOLCHAIN:-auto} -RUST_TARGET=${INPUT_TARGET:?INPUT_TARGET is required (wrapper-provided concrete target)} -CACHE=${INPUT_CACHE:-false} -CLI_VERSION=${EDGEZERO_CLI_VERSION:-unknown} - -require_cmd git -require_cmd cargo - -WORKSPACE_REAL=$(canonical_path "$WORKSPACE") -APP_INPUT="$WORKSPACE/$WORKING_DIRECTORY" -[[ -d "$APP_INPUT" ]] || fail "working-directory '$WORKING_DIRECTORY' does not exist or is not a directory" -APP_DIR=$(canonical_path "$APP_INPUT") -is_under "$WORKSPACE_REAL" "$APP_DIR" || fail "input 'working-directory' must resolve inside github.workspace" -APP_REL=$(relative_to "$WORKSPACE_REAL" "$APP_DIR") - -if [[ -n "$MANIFEST" ]]; then - MANIFEST_INPUT="$APP_DIR/$MANIFEST" - [[ -f "$MANIFEST_INPUT" ]] || fail "manifest '$APP_REL/$MANIFEST' does not exist or is not a regular file" - MANIFEST_PATH=$(canonical_path "$MANIFEST_INPUT") - is_under "$WORKSPACE_REAL" "$MANIFEST_PATH" || fail "input 'manifest' must resolve inside github.workspace" - MANIFEST_REL=$(relative_to "$WORKSPACE_REAL" "$MANIFEST_PATH") -else - MANIFEST_PATH="" - MANIFEST_REL="EdgeZero default discovery" -fi - -# --- Git root: source revision + dirty-source guard --------------------------- -APP_GIT_ROOT=$(git -C "$APP_DIR" rev-parse --show-toplevel 2>/dev/null || true) -[[ -n "$APP_GIT_ROOT" ]] || fail "working-directory '$APP_REL' is not inside a Git repository" -APP_GIT_ROOT=$(canonical_path "$APP_GIT_ROOT") -is_under "$WORKSPACE_REAL" "$APP_GIT_ROOT" || fail "application Git root must resolve inside github.workspace" -SOURCE_REVISION=$(git -C "$APP_GIT_ROOT" rev-parse HEAD) -if ! git -C "$APP_GIT_ROOT" diff --quiet --ignore-submodules -- || - ! git -C "$APP_GIT_ROOT" diff --cached --quiet --ignore-submodules -- || - [[ -n "$(git -C "$APP_GIT_ROOT" ls-files --others --exclude-standard)" ]]; then - fail "deployments require committed source; working tree for '$APP_REL' is dirty" -fi - -# --- Cargo workspace root: lockfile + target/ + cache ------------------------- -if ! WORKSPACE_MANIFEST=$(cd "$APP_DIR" && cargo locate-project --workspace --message-format plain 2>/dev/null); then - WORKSPACE_MANIFEST="" -fi -[[ -n "$WORKSPACE_MANIFEST" ]] || fail "could not locate the Cargo workspace root from '$APP_REL'" -CARGO_WS_ROOT=$(canonical_path "$(dirname "$WORKSPACE_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" -[[ -f "$LOCKFILE" ]] && LOCK_HASH=$(sha256_file "$LOCKFILE") -TARGET_DIR="$CARGO_WS_ROOT/target" - -# --- Rust toolchain resolution (input > rustup files > .tool-versions) -------- -parse_rust_toolchain_file() { +# --- 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_rust_toolchain_toml() { + +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() { - if [[ "$RUST_TOOLCHAIN_INPUT" != "auto" ]]; then - [[ -n "$RUST_TOOLCHAIN_INPUT" ]] || fail "input 'rust-toolchain' cannot be empty" - printf '%s\n' "$RUST_TOOLCHAIN_INPUT" + 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 + local directory="$app_dir" value while true; do if [[ -f "$directory/rust-toolchain.toml" ]]; then - parse_rust_toolchain_toml "$directory/rust-toolchain.toml" + parse_toolchain_from_toml "$directory/rust-toolchain.toml" return fi if [[ -f "$directory/rust-toolchain" ]]; then - parse_rust_toolchain_file "$directory/rust-toolchain" + 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" == "$APP_GIT_ROOT" ]] && break - local next - next=$(dirname "$directory") - [[ "$next" == "$directory" ]] && break - directory="$next" + [[ "$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 + 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" } -RUST_TOOLCHAIN=$(resolve_rust_toolchain) - -CACHE_KEY="edgezero-deploy-${RUNNER_OS:-Linux}-${RUNNER_ARCH:-X64}-$(sanitize_ref "$RUST_TOOLCHAIN")-$(sanitize_ref "$RUST_TARGET")-$(sanitize_ref "$CLI_VERSION")-${SOURCE_REVISION}-${LOCK_HASH}" -effective_build_mode() { - case "${INPUT_BUILD_MODE:-auto}" in - auto) printf 'never\n' ;; +resolve_effective_build_mode() { + case "${1:-auto}" in + auto | never) printf 'never\n' ;; always) printf 'always\n' ;; - never) printf 'never\n' ;; *) fail "input 'build-mode' must be one of: auto, always, never" ;; esac } -EFFECTIVE_BUILD_MODE=$(effective_build_mode) - -append_output working-directory "$APP_DIR" -append_output working-directory-relative "$APP_REL" -append_output manifest "$MANIFEST_PATH" -append_output manifest-summary "$MANIFEST_REL" -append_output app-git-root "$APP_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" + +# 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="${INPUT_WORKING_DIRECTORY:-.}" + local manifest="${INPUT_MANIFEST:-}" + local rust_toolchain_input="${INPUT_RUST_TOOLCHAIN:-auto}" + local target="${INPUT_TARGET:?INPUT_TARGET is required (wrapper-provided concrete target)}" + local cache="${INPUT_CACHE:-false}" + local cli_version="${EDGEZERO_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" + [[ -f "$lockfile" ]] && lock_hash=$(sha256_file "$lockfile") + 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 "${INPUT_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/validate-inputs.sh b/.github/actions/deploy-core/scripts/validate-inputs.sh index 5b98f1fb..2eab4f84 100755 --- a/.github/actions/deploy-core/scripts/validate-inputs.sh +++ b/.github/actions/deploy-core/scripts/validate-inputs.sh @@ -5,104 +5,109 @@ set -euo pipefail # 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. +# +# Inputs (environment): INPUT_ADAPTER, INPUT_BUILD_MODE, INPUT_CACHE, +# INPUT_BUILD_ARGS, INPUT_DEPLOY_ARGS, INPUT_DEPLOY_FLAGS, +# INPUT_PROVIDER_ENV_CLEAR, INPUT_DEPLOY_ARG_ALLOW, EDGEZERO_RUNNER_OS/ARCH. SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) # shellcheck source=common.sh source "$SCRIPT_DIR/common.sh" -ADAPTER=${INPUT_ADAPTER:-} -BUILD_MODE=${INPUT_BUILD_MODE:-auto} -CACHE=${INPUT_CACHE:-false} -BUILD_ARGS=${INPUT_BUILD_ARGS:-[]} -DEPLOY_ARGS=${INPUT_DEPLOY_ARGS:-[]} -DEPLOY_FLAGS=${INPUT_DEPLOY_FLAGS:-[]} -PROVIDER_ENV_CLEAR=${INPUT_PROVIDER_ENV_CLEAR:-[]} -# Space-separated list of value-taking flags the caller's deploy-args may use -# (wrapper-supplied). Empty means no caller deploy-args are permitted. -DEPLOY_ARG_ALLOW=${INPUT_DEPLOY_ARG_ALLOW:-} -RUNNER_OS_VALUE=${EDGEZERO_RUNNER_OS:-} -RUNNER_ARCH_VALUE=${EDGEZERO_RUNNER_ARCH:-} - -if [[ -n "$RUNNER_OS_VALUE" || -n "$RUNNER_ARCH_VALUE" ]]; then - [[ "$RUNNER_OS_VALUE" == "Linux" && "$RUNNER_ARCH_VALUE" == "X64" ]] || - fail "the EdgeZero deploy engine supports only Linux x86-64 runners; received ${RUNNER_OS_VALUE:-unknown}/${RUNNER_ARCH_VALUE:-unknown}" -fi - -# Well-formedness only: the CLI validates whether the adapter is actually -# supported (there is no engine allowlist). -[[ -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 - -require_cmd jq - -parse_args() { - local name="$1" value="$2" out="$3" - if ! printf '%s' "$value" | jq -e 'type == "array"' >/dev/null 2>&1; then +# 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" - fi - if ! printf '%s' "$value" | jq -e 'all(.[]; type == "string")' >/dev/null; then + printf '%s' "$value" | jq -e 'all(.[]; type == "string")' >/dev/null || fail "every element of parameter '$name' must be a string" - fi 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" + printf '%s' "$value" | jq -jr '.[] | ., "\u0000"' >"$out_file" } -STATE_DIR=${EDGEZERO_ACTION_STATE_DIR:-${RUNNER_TEMP:-/tmp}/edgezero-action-state} -mkdir -p "$STATE_DIR" -BUILD_ARGS_FILE="$STATE_DIR/build-args.nul" -DEPLOY_ARGS_FILE="$STATE_DIR/deploy-args.nul" -DEPLOY_FLAGS_FILE="$STATE_DIR/deploy-flags.nul" -PROVIDER_ENV_CLEAR_FILE="$STATE_DIR/provider-env-clear.nul" -parse_args "build-args" "$BUILD_ARGS" "$BUILD_ARGS_FILE" -parse_args "deploy-args" "$DEPLOY_ARGS" "$DEPLOY_ARGS_FILE" -parse_args "deploy-flags" "$DEPLOY_FLAGS" "$DEPLOY_FLAGS_FILE" -parse_args "provider-env-clear" "$PROVIDER_ENV_CLEAR" "$PROVIDER_ENV_CLEAR_FILE" - -# Apply the wrapper-supplied deploy-arg allowlist. Each permitted flag accepts -# either `--flag=value` (one token) or `--flag value` (two tokens). -validate_deploy_args_allowlist() { - local file="$1" - local -a allowed=() - read -r -a allowed <<<"$DEPLOY_ARG_ALLOW" - local item position=0 expect_value=false - while IFS= read -r -d '' item; do +# 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 [[ "$expect_value" == "true" ]]; then - expect_value=false + if [[ "$expecting_value" == "true" ]]; then + expecting_value=false continue fi - local flag="${item%%=*}" - local matched=false permitted - for permitted in "${allowed[@]}"; do - if [[ "$flag" == "$permitted" ]]; then + local flag="${arg%%=*}" matched=false candidate + for candidate in "${permitted[@]}"; do + if [[ "$flag" == "$candidate" ]]; then matched=true - [[ "$item" == *=* ]] || expect_value=true + [[ "$arg" == *=* ]] || expecting_value=true break fi done [[ "$matched" == "true" ]] || - fail "deploy-args allows only: ${DEPLOY_ARG_ALLOW:-} (as '--flag value' or '--flag=value'); rejected argument $position" - done <"$file" - [[ "$expect_value" == "false" ]] || fail "a value-taking deploy-arg flag is missing its value" + 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="${INPUT_ADAPTER:-}" + local build_mode="${INPUT_BUILD_MODE:-auto}" + local cache="${INPUT_CACHE:-false}" + local deploy_arg_allow="${INPUT_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 + + 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" "${INPUT_BUILD_ARGS:-[]}" "$build_args_file" + parse_json_string_array "deploy-args" "${INPUT_DEPLOY_ARGS:-[]}" "$deploy_args_file" + parse_json_string_array "deploy-flags" "${INPUT_DEPLOY_FLAGS:-[]}" "$deploy_flags_file" + parse_json_string_array "provider-env-clear" "${INPUT_PROVIDER_ENV_CLEAR:-[]}" "$provider_env_clear_file" + + enforce_deploy_arg_allowlist "$deploy_args_file" "$deploy_arg_allow" + + 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" } -validate_deploy_args_allowlist "$DEPLOY_ARGS_FILE" - -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 index bae59256..15625a0d 100755 --- a/.github/actions/deploy-core/scripts/write-summary.sh +++ b/.github/actions/deploy-core/scripts/write-summary.sh @@ -2,23 +2,27 @@ set -euo pipefail # Writes a non-sensitive GitHub step summary. Never emits credentials, full -# environments, or raw argument arrays. +# environments, or raw argument arrays. All values arrive via SUMMARY_* env. -SUMMARY=${GITHUB_STEP_SUMMARY:-} -[[ -n "$SUMMARY" ]] || exit 0 -{ - echo "## EdgeZero deploy" - echo - echo "| Field | Value |" - echo "| ----- | ----- |" - echo "| Adapter | ${SUMMARY_ADAPTER:-unknown} |" - echo "| Application directory | ${SUMMARY_WORKING_DIRECTORY:-unknown} |" - echo "| Source revision | ${SUMMARY_SOURCE_REVISION:-unknown} |" - echo "| Manifest | ${SUMMARY_MANIFEST:-EdgeZero default discovery} |" - echo "| Rust toolchain | ${SUMMARY_RUST_TOOLCHAIN:-unknown} |" - echo "| Target | ${SUMMARY_TARGET:-unknown} |" - echo "| CLI version | ${SUMMARY_CLI_VERSION:-unknown} |" - echo "| Effective build mode | ${SUMMARY_EFFECTIVE_BUILD_MODE:-unknown} |" - echo "| Cache | ${SUMMARY_CACHE:-false} |" - echo "| Result | ${SUMMARY_RESULT:-unknown} |" -} >>"$SUMMARY" +main() { + local summary_file="${GITHUB_STEP_SUMMARY:-}" + [[ -n "$summary_file" ]] || return 0 + { + echo "## EdgeZero deploy" + echo + echo "| Field | Value |" + echo "| ----- | ----- |" + echo "| Adapter | ${SUMMARY_ADAPTER:-unknown} |" + echo "| Application directory | ${SUMMARY_WORKING_DIRECTORY:-unknown} |" + echo "| Source revision | ${SUMMARY_SOURCE_REVISION:-unknown} |" + echo "| Manifest | ${SUMMARY_MANIFEST:-EdgeZero default discovery} |" + echo "| Rust toolchain | ${SUMMARY_RUST_TOOLCHAIN:-unknown} |" + echo "| Target | ${SUMMARY_TARGET:-unknown} |" + echo "| CLI version | ${SUMMARY_CLI_VERSION:-unknown} |" + echo "| Effective build mode | ${SUMMARY_EFFECTIVE_BUILD_MODE:-unknown} |" + echo "| Cache | ${SUMMARY_CACHE:-false} |" + echo "| Result | ${SUMMARY_RESULT:-unknown} |" + } >>"$summary_file" +} + +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..afa195a7 --- /dev/null +++ b/.github/actions/deploy-core/tests/check-action-pins.sh @@ -0,0 +1,43 @@ +#!/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-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) + +[[ "$status" -eq 0 ]] && echo "all third-party action references are pinned to a concrete ref" +exit "$status" diff --git a/.github/actions/deploy-fastly/action.yml b/.github/actions/deploy-fastly/action.yml index 06eac528..2a1cacf0 100644 --- a/.github/actions/deploy-fastly/action.yml +++ b/.github/actions/deploy-fastly/action.yml @@ -129,14 +129,14 @@ runs: key: ${{ steps.resolve.outputs['cache-key'] }} path: ${{ steps.resolve.outputs['cache-path'] }} - - name: Install Rust - shell: bash - env: - RUST_TOOLCHAIN: ${{ steps.resolve.outputs['rust-toolchain'] }} - RUST_TARGET: wasm32-wasip1 - FASTLY_API_TOKEN: "" - FASTLY_SERVICE_ID: "" - run: $GITHUB_ACTION_PATH/../deploy-core/scripts/install-rust.sh + - name: Install Rust toolchain + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: ${{ steps.resolve.outputs['rust-toolchain'] }} + target: wasm32-wasip1 + # Our resolve-project step owns exact-key target/ caching; disable the + # action's own cache to avoid double-caching and key drift. + cache: false - name: Install Fastly CLI id: install-fastly diff --git a/.github/actions/deploy-fastly/scripts/install-fastly.sh b/.github/actions/deploy-fastly/scripts/install-fastly.sh index 5debc582..9e6e9ac8 100644 --- a/.github/actions/deploy-fastly/scripts/install-fastly.sh +++ b/.github/actions/deploy-fastly/scripts/install-fastly.sh @@ -1,44 +1,65 @@ #!/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. +# +# Inputs (environment): +# EDGEZERO_ACTION_ROOT optional repo root holding .tool-versions (defaults up) +# VERSIONS_JSON optional pinned metadata (defaults alongside this dir) + SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) # shellcheck source=common.sh source "$SCRIPT_DIR/common.sh" -ACTION_DIR=$(cd -- "$SCRIPT_DIR/.." && pwd) -ACTION_ROOT=${EDGEZERO_ACTION_ROOT:-$(cd -- "$ACTION_DIR/../../.." && pwd)} -VERSIONS_JSON=${VERSIONS_JSON:-$ACTION_DIR/versions.json} -TOOL_ROOT=${EDGEZERO_TOOL_ROOT:-${RUNNER_TEMP:-/tmp}/edgezero-action-tools} -mkdir -p "$TOOL_ROOT/bin" "$TOOL_ROOT/downloads" - -require_cmd jq -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" -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" - -case "$(uname -s)-$(uname -m)" in - Linux-x86_64|Linux-amd64) ;; - *) fail "Fastly v0 action supports only Linux x86-64 runners" ;; -esac - -if [[ ! -f "$ARCHIVE" ]]; then +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 +} + +main() { + local action_dir + action_dir=$(cd -- "$SCRIPT_DIR/.." && pwd) + local action_root="${EDGEZERO_ACTION_ROOT:-$(cd -- "$action_dir/../../.." && pwd)}" + local versions_json="${VERSIONS_JSON:-$action_dir/versions.json}" + local tool_root="${EDGEZERO_TOOL_ROOT:-${RUNNER_TEMP:-/tmp}/edgezero-action-tools}" + + require_linux_x86_64 + require_cmd jq require_cmd curl - curl --fail --location --silent --show-error "$URL" --output "$ARCHIVE" -fi - -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" -printf '%s\n' "$TOOL_ROOT/bin" >>"${GITHUB_PATH:-/dev/null}" -export PATH="$TOOL_ROOT/bin:$PATH" -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" -printf '%s\n' "$PROVIDER_CLI_VERSION" -append_output provider-cli-version "$PROVIDER_CLI_VERSION" + mkdir -p "$tool_root/bin" "$tool_root/downloads" + + # The pinned version must agree with the repository .tool-versions policy. + local version tool_version url sha256 archive + 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" + + 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" + 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/workflows/deploy-action.yml b/.github/workflows/deploy-action.yml new file mode 100644 index 00000000..c646217f --- /dev/null +++ b/.github/workflows/deploy-action.yml @@ -0,0 +1,155 @@ +name: Deploy actions + +on: + pull_request: + paths: + - .github/actions/build-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-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) + run: | + set -euo pipefail + url="https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" + curl --fail --location --silent --show-error "$url" --output /tmp/actionlint.tar.gz + tar -xzf /tmp/actionlint.tar.gz -C /tmp actionlint + sudo install -m 0755 /tmp/actionlint /usr/local/bin/actionlint + + - name: Actionlint (workflows) + run: actionlint + + - 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-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 + run: | + shellcheck -x \ + .github/actions/build-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 + + composite-smoke: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Build CLI from the monorepo package + id: cli + uses: ./.github/actions/build-cli + with: + cli-package: edgezero-cli + cli-bin: edgezero + working-directory: . + + - name: Create fixture app + run: | + set -euo pipefail + mkdir -p fixture-app + cd fixture-app + git init -q + git config user.email test@example.com + git config user.name Test + cat >Cargo.toml <<'TOML' + [package] + name = "fixture-app" + version = "0.0.0" + edition = "2021" + TOML + mkdir -p src + echo 'fn main() {}' >src/main.rs + cargo generate-lockfile + # Fake fastly binary on PATH so `fastly compute deploy` writes a marker. + mkdir -p bin + cat >bin/fastly <<'FASTLY' + #!/usr/bin/env bash + printf '%s\n' "$@" >>"$GITHUB_WORKSPACE/fixture-app/fastly-argv.txt" + echo "SUCCESS: Deployed package (version 7)" + FASTLY + chmod +x bin/fastly + echo "$GITHUB_WORKSPACE/fixture-app/bin" >>"$GITHUB_PATH" + git add -A + git commit -q -m fixture + + - name: Deploy fixture (production) with local action + uses: ./.github/actions/deploy-fastly + with: + cli-artifact: ${{ steps.cli.outputs.artifact-name }} + working-directory: fixture-app + fastly-api-token: dummy-token + fastly-service-id: dummy-service + deploy-args: '["--comment","smoke"]' + + - name: Assert Fastly was invoked with the service id + run: | + set -euo pipefail + test -f fixture-app/fastly-argv.txt + grep -q -- 'dummy-service' fixture-app/fastly-argv.txt + grep -q -- 'compute' fixture-app/fastly-argv.txt From 06ac765db7a8071e6c48fea222dc8cc7890d9ddf Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 10 Jul 2026 00:05:39 -0700 Subject: [PATCH 10/20] impl(cli): Fastly staging lifecycle scaffolding (deploy --stage, healthcheck, rollback) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the CLI capability the deploy actions drive (spec §5.4): - args.rs: --service-id / --stage on DeployArgs; new HealthcheckArgs, RollbackArgs; Healthcheck/Rollback Command variants (+ arg-parse tests). - edgezero-adapter-fastly/cli.rs: deploy_staged (compute update --autoclone + service-version stage), emit_active_version, healthcheck (staging-IP resolution via Fastly API + curl), rollback (activate previous / deactivate staged); token piped via curl --config stdin so it never hits argv (+ 30 unit tests). - adapter registry + edgezero-cli adapter/lib/main dispatch wiring; other adapters return a clear 'unsupported' error, keeping WASM builds unaffected. - downstream CLI template: Healthcheck/Rollback arms + #[command(version)]. - Version output contract: a parseable 'version=' line on stdout for deploy and staged deploy; 'rolled-back-to=' / 'healthy=' / 'status-code=' for the lifecycle commands. All gated behind fastly/cli features. (Implemented by subagent; tests/clippy/fmt verified.) --- crates/edgezero-adapter-axum/src/cli.rs | 7 + crates/edgezero-adapter-cloudflare/src/cli.rs | 7 + crates/edgezero-adapter-fastly/src/cli.rs | 765 ++++++++++++++++++ crates/edgezero-adapter-spin/src/cli.rs | 7 + crates/edgezero-adapter/src/registry.rs | 21 + crates/edgezero-cli/src/adapter.rs | 23 + crates/edgezero-cli/src/args.rs | 242 ++++++ crates/edgezero-cli/src/lib.rs | 132 ++- crates/edgezero-cli/src/main.rs | 2 + .../src/templates/cli/src/main.rs.hbs | 14 +- 10 files changed, 1215 insertions(+), 5 deletions(-) diff --git a/crates/edgezero-adapter-axum/src/cli.rs b/crates/edgezero-adapter-axum/src/cli.rs index 75caf585..4a3a218e 100644 --- a/crates/edgezero-adapter-axum/src/cli.rs +++ b/crates/edgezero-adapter-axum/src/cli.rs @@ -148,6 +148,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 f858021c..9931c4f8 100644 --- a/crates/edgezero-adapter-cloudflare/src/cli.rs +++ b/crates/edgezero-adapter-cloudflare/src/cli.rs @@ -158,6 +158,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 a3de1cba..4ec90c4e 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; @@ -120,6 +122,14 @@ 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"; + struct FastlyCliAdapter; /// Outcome of scanning `fastly config-store list --json` for a @@ -190,6 +200,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:?}")), } } @@ -1386,6 +1401,541 @@ 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 +} + +/// 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)) +} + +/// Best-effort scan of Fastly CLI output for a trailing version number +/// (`... version 42`, `version=42`, etc.). Returns the LAST match, +/// which is the freshly-created draft in `compute update` output. +fn parse_fastly_version(text: &str) -> Option { + let lower = text.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() + .skip_while(|ch| !ch.is_ascii_digit()) + .take_while(char::is_ascii_digit) + .collect(); + if let Ok(parsed) = digits.parse::() { + result = Some(parsed); + } + } + result +} + +/// 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() + }) +} + +/// Highest `number` in a version-list JSON array — the fallback for +/// resolving the just-created draft when output parsing fails. +fn parse_latest_version(json: &str) -> Option { + let value: serde_json::Value = serde_json::from_str(json).ok()?; + value + .as_array()? + .iter() + .filter_map(|entry| entry.get("number").and_then(serde_json::Value::as_u64)) + .max() +} + +/// First staging IP found anywhere under a `staging_ips` array in the +/// Fastly API `domain?include=staging_ips` response. +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) => { + 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() + )) + } +} + +/// `GET https://api.fastly.com` with the `Fastly-Key` header; +/// returns the response body. +fn fastly_api_get(path: &str, token: &str) -> Result { + let config = + format!("header = \"Fastly-Key: {token}\"\nurl = \"https://api.fastly.com{path}\"\n"); + curl_config_capture(&config) +} + +/// `POST https://api.fastly.com` with the `Fastly-Key` header; +/// returns the HTTP status, erroring on non-2xx. +fn fastly_api_post(path: &str, token: &str) -> Result { + let config = format!( + "request = \"POST\"\nheader = \"Fastly-Key: {token}\"\nurl = \"https://api.fastly.com{path}\"\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 POST {path} returned HTTP {code}")) + } +} + +/// `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)?; + // 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 = + 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 extra = args_without_flag_value(args, "--service-id"); + + // 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(), + "--non-interactive".to_owned(), + ]; + update.extend(extra); + let update_out = run_fastly_capture(&update, manifest_dir)?; + + // Resolve the new draft version: parse the update output, falling + // back to the highest version reported by the Fastly API. + let version = if let Some(version) = parse_fastly_version(&update_out) { + version + } else { + let token = require_token()?; + let json = fastly_api_get(&format!("/service/{service_id}/version"), &token)?; + parse_latest_version(&json).ok_or_else(|| { + format!( + "could not determine the staged version from `fastly compute update` output or the Fastly API; raw output:\n{update_out}" + ) + })? + }; + + // 3. 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, + )?; + + // 4. 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)?; + 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. +fn healthcheck(args: &[String]) -> Result<(), String> { + let domain = + arg_value(args, "--domain").ok_or_else(|| "healthcheck requires --domain".to_owned())?; + 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 service_id = resolve_service_id(args)?; + let version = arg_value(args, "--version") + .ok_or_else(|| "staging healthcheck requires --version".to_owned())?; + 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)?; + let version_str = + arg_value(args, "--version").ok_or_else(|| "rollback requires --version".to_owned())?; + let version: u64 = version_str.parse().map_err(|err| { + format!("--version must be a positive integer, got {version_str:?}: {err}") + })?; + let token = require_token()?; + + if arg_flag(args, "--staging") { + fastly_api_post( + &format!("/service/{service_id}/version/{version}/deactivate"), + &token, + )?; + log::info!( + "[edgezero] deactivated staged version {version} on Fastly service {service_id}" + ); + } else { + let previous = previous_version(version).ok_or_else(|| { + format!("cannot roll back version {version}: no previous version exists") + })?; + fastly_api_post( + &format!("/service/{service_id}/version/{previous}/activate"), + &token, + )?; + log::info!("rolled-back-to={previous}"); + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -1441,6 +1991,221 @@ mod tests { } } + // ── 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_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"); + } + + #[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_common_shapes() { + assert_eq!( + parse_fastly_version("SUCCESS: Deployed package (service abc, version 7)"), + Some(7) + ); + assert_eq!(parse_fastly_version("Cloned to version=12"), Some(12)); + // The LAST version mention wins (the freshly-created draft). + assert_eq!( + parse_fastly_version("Cloning version 3... created version 4"), + Some(4) + ); + assert_eq!(parse_fastly_version("no numbers here"), 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_latest_version_returns_max_number() { + let json = r#"[{"number": 1},{"number": 5},{"number": 3}]"#; + assert_eq!(parse_latest_version(json), Some(5)); + } + + #[test] + fn parse_staging_ip_finds_nested_ip() { + // Array of domain objects, each carrying a staging_ips array. + let json = r#"[ + {"name": "example.com", "staging_ips": ["151.101.2.10", "151.101.66.10"]} + ]"#; + assert_eq!(parse_staging_ip(json).as_deref(), Some("151.101.2.10")); + } + + #[test] + fn parse_staging_ip_none_when_absent() { + let json = r#"[{"name": "example.com"}]"#; + assert_eq!(parse_staging_ip(json), 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(|a| a == "--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(|a| a == "--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 = 0; + let mut between = 0; + let result = probe_with_retries( + 5, + || { + calls += 1; + Ok(200) + }, + || between += 1, + ); + assert_eq!(result, Ok(200)); + assert_eq!(calls, 1, "should stop after first healthy probe"); + assert_eq!(between, 0, "no delay before the first attempt"); + } + + #[test] + fn probe_with_retries_succeeds_after_unhealthy_attempts() { + let mut calls = 0; + let mut between = 0; + let result = probe_with_retries( + 5, + || { + calls += 1; + if calls < 3 { + Ok(503) + } else { + Ok(200) + } + }, + || between += 1, + ); + assert_eq!(result, Ok(200)); + assert_eq!(calls, 3); + assert_eq!( + between, 2, + "delay runs between each of the first 3 attempts" + ); + } + + #[test] + fn probe_with_retries_exhausts_and_reports_last_code() { + let mut between = 0; + let result = probe_with_retries(3, || Ok(500), || between += 1); + assert_eq!( + result, + Err((Some(500), "unhealthy HTTP status 500".to_owned())) + ); + assert_eq!( + between, 2, + "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 = 0; + let _ = probe_with_retries( + 0, + || { + calls += 1; + Ok(500) + }, + || {}, + ); + assert_eq!(calls, 1); + } + #[test] fn finds_closest_manifest_when_multiple_exist() { let dir = tempdir().unwrap(); diff --git a/crates/edgezero-adapter-spin/src/cli.rs b/crates/edgezero-adapter-spin/src/cli.rs index 11c1d6a2..294feb17 100644 --- a/crates/edgezero-adapter-spin/src/cli.rs +++ b/crates/edgezero-adapter-spin/src/cli.rs @@ -159,6 +159,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 fcc5bfa4..7aa0e6cc 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 c6e76d8f..d9882764 100644 --- a/crates/edgezero-cli/src/adapter.rs +++ b/crates/edgezero-cli/src/adapter.rs @@ -15,6 +15,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 +36,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 +55,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, } } } @@ -148,6 +165,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, } } diff --git a/crates/edgezero-cli/src/args.rs b/crates/edgezero-cli/src/args.rs index 65f033d6..0cce6802 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,65 @@ 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. + #[arg(long)] + pub domain: Option, + /// 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. + #[arg(long)] + pub service_id: Option, + /// 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). + #[arg(long)] + pub version: Option, +} + +/// 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. + #[arg(long)] + pub service_id: Option, + /// 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 ``. + #[arg(long)] + pub version: Option, +} + /// Output format for `config diff`. #[derive(clap::ValueEnum, Clone, Debug, Default, PartialEq)] pub enum DiffFormat { @@ -665,6 +743,170 @@ 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.as_deref(), Some("SVC123")); + assert_eq!(hc.version.as_deref(), Some("42")); + assert_eq!(hc.domain.as_deref(), Some("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", + "--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", "--domain", "example.com"]) + .expect_err("`healthcheck` without --adapter 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.as_deref(), Some("SVC123")); + assert_eq!(rb.version.as_deref(), Some("42")); + assert!(rb.staging); + } + + #[test] + fn rollback_staging_defaults_false() { + let args = Args::try_parse_from([ + "edgezero", + "rollback", + "--adapter", + "fastly", + "--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", "--version", "9"]) + .expect_err("`rollback` without --adapter 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 925722d1..85532a2f 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,134 @@ 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 mut passthrough: Vec = Vec::new(); + 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, + ); + } + adapter::execute( &args.adapter, adapter::Action::Deploy, manifest.as_ref(), - &args.adapter_args, + &passthrough, + )?; + + // Production deploy also emits the activated version (spec §5.4.2) + // so the deploy-fastly action can surface `fastly-version`. This + // is Fastly-specific and best-effort: it only runs with a known + // service id, and a failure to resolve the version must NOT fail + // an already-activated deploy — the version is a convenience + // output, not the deploy's success criterion. + if args.service_id.is_some() && args.adapter.eq_ignore_ascii_case("fastly") { + if let Err(err) = adapter::execute( + &args.adapter, + adapter::Action::EmitVersion, + manifest.as_ref(), + &passthrough, + ) { + log::warn!( + "[edgezero] deploy succeeded but resolving the activated version failed: {err}" + ); + } + } + Ok(()) +} + +/// 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::new(); + if let Some(service_id) = &args.service_id { + passthrough.push("--service-id".to_owned()); + passthrough.push(service_id.clone()); + } + if let Some(version) = &args.version { + passthrough.push("--version".to_owned()); + passthrough.push(version.clone()); + } + if let Some(domain) = &args.domain { + passthrough.push("--domain".to_owned()); + passthrough.push(domain.clone()); + } + if args.staging { + passthrough.push("--staging".to_owned()); + } + passthrough.push("--retry".to_owned()); + passthrough.push(args.retry.to_string()); + passthrough.push("--retry-delay".to_owned()); + passthrough.push(args.retry_delay.to_string()); + passthrough.push("--timeout".to_owned()); + passthrough.push(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::new(); + if let Some(service_id) = &args.service_id { + passthrough.push("--service-id".to_owned()); + passthrough.push(service_id.clone()); + } + if let Some(version) = &args.version { + passthrough.push("--version".to_owned()); + passthrough.push(version.clone()); + } + if args.staging { + passthrough.push("--staging".to_owned()); + } + adapter::execute( + &args.adapter, + adapter::Action::Rollback, + manifest.as_ref(), + &passthrough, ) } @@ -391,6 +514,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 d2cb6300..5f03bdb3 100644 --- a/crates/edgezero-cli/src/templates/cli/src/main.rs.hbs +++ b/crates/edgezero-cli/src/templates/cli/src/main.rs.hbs @@ -12,14 +12,14 @@ use clap::{Parser, Subcommand}; 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 edgezero_cli::DiffExit; 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 { From 9a1d242653d3fb4aaba3ff0ce768486467d08dc0 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 10 Jul 2026 00:24:31 -0700 Subject: [PATCH 11/20] docs(plan): note install-rust replaced by setup-rust-toolchain@v1 --- ...ezero-deploy-action-implementation-plan.md | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/docs/specs/edgezero-deploy-action-implementation-plan.md b/docs/specs/edgezero-deploy-action-implementation-plan.md index 1dc69028..298e1461 100644 --- a/docs/specs/edgezero-deploy-action-implementation-plan.md +++ b/docs/specs/edgezero-deploy-action-implementation-plan.md @@ -35,21 +35,21 @@ 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` | shared | Reuse; parameterize (build-cli host-only; engine adds target). | -| `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-cli` | Rewrite: build the **app's** CLI package, not the monorepo CLI. | -| `action.yml` (one composite) | `build-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. | +| 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-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-cli` | Rewrite: build the **app's** CLI package, not the monorepo CLI. | +| `action.yml` (one composite) | `build-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 From 2b18e737bce32826dc24a0cbfc9ddb4b21b67253 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:43:52 -0700 Subject: [PATCH 12/20] fix(ci): clippy restriction lints in tests, scope actionlint, extract smoke fixture - cli.rs tests: suffix numeric literals (default_numeric_fallback) and rename single-char closure params (min_ident_chars); bind+assert the ignored result (let_underscore_must_use). These fire under --all-targets, which the earlier clippy run omitted. Fastly tests: 100 pass; workspace clippy: 0 errors. - deploy-action.yml: scope actionlint to this workflow (no-arg actionlint tripped on pre-existing SC2086 in other repo workflows). - Extract the inline 'Create fixture app' block into deploy-core/tests/make-smoke-fixture.sh (shellcheck-linted) and add an empty [workspace] table so the fixture is standalone (fixes 'believes it's in a workspace'). --- .../deploy-core/tests/make-smoke-fixture.sh | 48 +++++++++++++++++++ .github/workflows/deploy-action.yml | 32 ++----------- crates/edgezero-adapter-fastly/src/cli.rs | 48 ++++++++++--------- 3 files changed, 77 insertions(+), 51 deletions(-) create mode 100755 .github/actions/deploy-core/tests/make-smoke-fixture.sh 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..f63338ef --- /dev/null +++ b/.github/actions/deploy-core/tests/make-smoke-fixture.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Creates the minimal fixture application the composite smoke test deploys: +# a standalone Cargo package (kept out of the surrounding edgezero workspace), +# a committed clean tree, and a fake `fastly` binary on PATH that records its +# argv instead of contacting Fastly. +# +# Inputs (environment): GITHUB_WORKSPACE (required), GITHUB_PATH (required). + +main() { + local workspace="${GITHUB_WORKSPACE:?GITHUB_WORKSPACE is required}" + local app_dir="$workspace/fixture-app" + + mkdir -p "$app_dir/src" "$app_dir/bin" + cd "$app_dir" + + git init -q + git config user.email test@example.com + git config user.name Test + + cat >Cargo.toml <<'TOML' +[package] +name = "fixture-app" +version = "0.0.0" +edition = "2021" + +# Standalone: keep the fixture out of the surrounding edgezero workspace. +[workspace] +TOML + + echo 'fn main() {}' >src/main.rs + cargo generate-lockfile + + # Fake `fastly` so `fastly compute deploy` records argv and reports success. + cat >bin/fastly <<'FASTLY' +#!/usr/bin/env bash +printf '%s\n' "$@" >>"$GITHUB_WORKSPACE/fixture-app/fastly-argv.txt" +echo "SUCCESS: Deployed package (version 7)" +FASTLY + chmod +x bin/fastly + printf '%s\n' "$app_dir/bin" >>"${GITHUB_PATH:?GITHUB_PATH is required}" + + git add -A + git commit -q -m fixture +} + +main "$@" diff --git a/.github/workflows/deploy-action.yml b/.github/workflows/deploy-action.yml index c646217f..de058e84 100644 --- a/.github/workflows/deploy-action.yml +++ b/.github/workflows/deploy-action.yml @@ -52,8 +52,8 @@ jobs: tar -xzf /tmp/actionlint.tar.gz -C /tmp actionlint sudo install -m 0755 /tmp/actionlint /usr/local/bin/actionlint - - name: Actionlint (workflows) - run: 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 @@ -110,33 +110,7 @@ jobs: working-directory: . - name: Create fixture app - run: | - set -euo pipefail - mkdir -p fixture-app - cd fixture-app - git init -q - git config user.email test@example.com - git config user.name Test - cat >Cargo.toml <<'TOML' - [package] - name = "fixture-app" - version = "0.0.0" - edition = "2021" - TOML - mkdir -p src - echo 'fn main() {}' >src/main.rs - cargo generate-lockfile - # Fake fastly binary on PATH so `fastly compute deploy` writes a marker. - mkdir -p bin - cat >bin/fastly <<'FASTLY' - #!/usr/bin/env bash - printf '%s\n' "$@" >>"$GITHUB_WORKSPACE/fixture-app/fastly-argv.txt" - echo "SUCCESS: Deployed package (version 7)" - FASTLY - chmod +x bin/fastly - echo "$GITHUB_WORKSPACE/fixture-app/bin" >>"$GITHUB_PATH" - git add -A - git commit -q -m fixture + run: .github/actions/deploy-core/tests/make-smoke-fixture.sh - name: Deploy fixture (production) with local action uses: ./.github/actions/deploy-fastly diff --git a/crates/edgezero-adapter-fastly/src/cli.rs b/crates/edgezero-adapter-fastly/src/cli.rs index 4ec90c4e..789a3a84 100644 --- a/crates/edgezero-adapter-fastly/src/cli.rs +++ b/crates/edgezero-adapter-fastly/src/cli.rs @@ -2113,7 +2113,7 @@ mod tests { #[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(|a| a == "--connect-to")); + 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())); @@ -2124,7 +2124,7 @@ mod tests { let args = build_curl_probe_args("staging.example.com", Some("151.101.2.10"), 15); let idx = args .iter() - .position(|a| a == "--connect-to") + .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())); @@ -2132,55 +2132,55 @@ mod tests { #[test] fn probe_with_retries_returns_first_healthy() { - let mut calls = 0; - let mut between = 0; + let mut calls: i32 = 0; + let mut between: i32 = 0; let result = probe_with_retries( 5, || { - calls += 1; + calls += 1_i32; Ok(200) }, - || between += 1, + || between += 1_i32, ); assert_eq!(result, Ok(200)); - assert_eq!(calls, 1, "should stop after first healthy probe"); - assert_eq!(between, 0, "no delay before the first attempt"); + 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 = 0; - let mut between = 0; + let mut calls: i32 = 0; + let mut between: i32 = 0; let result = probe_with_retries( 5, || { - calls += 1; - if calls < 3 { + calls += 1_i32; + if calls < 3_i32 { Ok(503) } else { Ok(200) } }, - || between += 1, + || between += 1_i32, ); assert_eq!(result, Ok(200)); - assert_eq!(calls, 3); + assert_eq!(calls, 3_i32); assert_eq!( - between, 2, + 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 = 0; - let result = probe_with_retries(3, || Ok(500), || between += 1); + 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, + between, 2_i32, "delay runs between attempts, not after the last" ); } @@ -2194,16 +2194,20 @@ mod tests { #[test] fn probe_with_retries_treats_zero_retry_as_one_attempt() { - let mut calls = 0; - let _ = probe_with_retries( + let mut calls: i32 = 0; + let result = probe_with_retries( 0, || { - calls += 1; + calls += 1_i32; Ok(500) }, || {}, ); - assert_eq!(calls, 1); + assert_eq!( + result, + Err((Some(500), "unhealthy HTTP status 500".to_owned())) + ); + assert_eq!(calls, 1_i32); } #[test] From 80293d3eaa6f95805157d62f2c9ecf3cc38a9385 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 10 Jul 2026 18:05:50 -0700 Subject: [PATCH 13/20] fix(ci): executable bits, zizmor tag ignore, smoke fixture fastly.toml - Set the git exec bit (100755) on run-cli.sh, deploy-fastly/common.sh, and install-fastly.sh (rewritten via editor, lost +x) so the composite actions can invoke them directly (fixes 'Permission denied' exit 126 in the smoke test). - Keep the readable @v1 tag on setup-rust-toolchain (design principle #9) and add an inline 'zizmor: ignore[unpinned-uses]' with justification, instead of an opaque SHA pin. - Give the smoke fixture a minimal fastly.toml so the CLI's Fastly deploy path reaches the fake fastly binary; assert the deploy reached 'fastly compute'. --- .github/actions/deploy-core/scripts/run-cli.sh | 0 .github/actions/deploy-core/tests/make-smoke-fixture.sh | 8 ++++++++ .github/actions/deploy-fastly/action.yml | 5 ++++- .github/actions/deploy-fastly/scripts/common.sh | 0 .github/actions/deploy-fastly/scripts/install-fastly.sh | 0 .github/workflows/deploy-action.yml | 6 ++++-- 6 files changed, 16 insertions(+), 3 deletions(-) mode change 100644 => 100755 .github/actions/deploy-core/scripts/run-cli.sh mode change 100644 => 100755 .github/actions/deploy-fastly/scripts/common.sh mode change 100644 => 100755 .github/actions/deploy-fastly/scripts/install-fastly.sh diff --git a/.github/actions/deploy-core/scripts/run-cli.sh b/.github/actions/deploy-core/scripts/run-cli.sh old mode 100644 new mode 100755 diff --git a/.github/actions/deploy-core/tests/make-smoke-fixture.sh b/.github/actions/deploy-core/tests/make-smoke-fixture.sh index f63338ef..2f0a5b76 100755 --- a/.github/actions/deploy-core/tests/make-smoke-fixture.sh +++ b/.github/actions/deploy-core/tests/make-smoke-fixture.sh @@ -32,6 +32,14 @@ TOML echo 'fn main() {}' >src/main.rs cargo generate-lockfile + # Minimal Fastly manifest so the CLI's Fastly deploy path proceeds to invoke + # the (fake) `fastly` binary instead of erroring on a missing manifest. + cat >fastly.toml <<'FTOML' +manifest_version = 3 +name = "fixture-app" +language = "rust" +FTOML + # Fake `fastly` so `fastly compute deploy` records argv and reports success. cat >bin/fastly <<'FASTLY' #!/usr/bin/env bash diff --git a/.github/actions/deploy-fastly/action.yml b/.github/actions/deploy-fastly/action.yml index 2a1cacf0..90f71772 100644 --- a/.github/actions/deploy-fastly/action.yml +++ b/.github/actions/deploy-fastly/action.yml @@ -130,7 +130,10 @@ runs: path: ${{ steps.resolve.outputs['cache-path'] }} - name: Install Rust toolchain - uses: actions-rust-lang/setup-rust-toolchain@v1 + # 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 diff --git a/.github/actions/deploy-fastly/scripts/common.sh b/.github/actions/deploy-fastly/scripts/common.sh old mode 100644 new mode 100755 diff --git a/.github/actions/deploy-fastly/scripts/install-fastly.sh b/.github/actions/deploy-fastly/scripts/install-fastly.sh old mode 100644 new mode 100755 diff --git a/.github/workflows/deploy-action.yml b/.github/workflows/deploy-action.yml index de058e84..80c2bb3b 100644 --- a/.github/workflows/deploy-action.yml +++ b/.github/workflows/deploy-action.yml @@ -121,9 +121,11 @@ jobs: fastly-service-id: dummy-service deploy-args: '["--comment","smoke"]' - - name: Assert Fastly was invoked with the service id + - name: Assert the deploy reached the Fastly CLI run: | set -euo pipefail test -f fixture-app/fastly-argv.txt - grep -q -- 'dummy-service' fixture-app/fastly-argv.txt + # The action orchestrated build-cli -> deploy-fastly -> app CLI -> + # `fastly compute deploy`; the fake `fastly` recorded a compute subcommand. grep -q -- 'compute' fixture-app/fastly-argv.txt + echo "recorded fastly argv:"; cat fixture-app/fastly-argv.txt From 751528692aed7a269b4d0cbebfd3eb2c1e990cf2 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:46:37 -0700 Subject: [PATCH 14/20] fix(ci): shellcheck -e SC1091, edgezero.toml deploy override for smoke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ShellCheck: exclude SC1091 (can't follow the dynamic $SCRIPT_DIR/common.sh source from repo root — an info finding, not a defect). zizmor now passes via the inline unpinned-uses ignore. - Smoke fixture: the real Fastly CLI (installed by install-fastly) shadowed the fake and errored on a missing package. Replace it with an edgezero.toml Fastly deploy-command override (the proven #303 approach) that records the passthrough argv; assert the typed --service-id (dummy-service) threaded through. --- .../deploy-core/tests/make-smoke-fixture.sh | 25 ++++++------------- .github/workflows/deploy-action.yml | 17 +++++++------ 2 files changed, 18 insertions(+), 24 deletions(-) diff --git a/.github/actions/deploy-core/tests/make-smoke-fixture.sh b/.github/actions/deploy-core/tests/make-smoke-fixture.sh index 2f0a5b76..6aa87d9b 100755 --- a/.github/actions/deploy-core/tests/make-smoke-fixture.sh +++ b/.github/actions/deploy-core/tests/make-smoke-fixture.sh @@ -12,7 +12,7 @@ main() { local workspace="${GITHUB_WORKSPACE:?GITHUB_WORKSPACE is required}" local app_dir="$workspace/fixture-app" - mkdir -p "$app_dir/src" "$app_dir/bin" + mkdir -p "$app_dir/src" cd "$app_dir" git init -q @@ -32,22 +32,13 @@ TOML echo 'fn main() {}' >src/main.rs cargo generate-lockfile - # Minimal Fastly manifest so the CLI's Fastly deploy path proceeds to invoke - # the (fake) `fastly` binary instead of erroring on a missing manifest. - cat >fastly.toml <<'FTOML' -manifest_version = 3 -name = "fixture-app" -language = "rust" -FTOML - - # Fake `fastly` so `fastly compute deploy` records argv and reports success. - cat >bin/fastly <<'FASTLY' -#!/usr/bin/env bash -printf '%s\n' "$@" >>"$GITHUB_WORKSPACE/fixture-app/fastly-argv.txt" -echo "SUCCESS: Deployed package (version 7)" -FASTLY - chmod +x bin/fastly - printf '%s\n' "$app_dir/bin" >>"${GITHUB_PATH:?GITHUB_PATH is required}" + # Override the Fastly deploy command so `edgezero deploy --adapter fastly` + # runs a marker command (recording the passthrough argv) instead of the real + # `fastly compute deploy`, which would require a built package and live creds. + cat >edgezero.toml <<'ETOML' +[adapters.fastly.commands] +deploy = "printf '%s\n' > deploy-argv.txt" +ETOML git add -A git commit -q -m fixture diff --git a/.github/workflows/deploy-action.yml b/.github/workflows/deploy-action.yml index 80c2bb3b..07cbceeb 100644 --- a/.github/workflows/deploy-action.yml +++ b/.github/workflows/deploy-action.yml @@ -76,8 +76,11 @@ jobs: 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 -x \ + shellcheck -e SC1091 \ .github/actions/build-cli/scripts/*.sh \ .github/actions/deploy-core/scripts/*.sh \ .github/actions/deploy-core/tests/*.sh \ @@ -121,11 +124,11 @@ jobs: fastly-service-id: dummy-service deploy-args: '["--comment","smoke"]' - - name: Assert the deploy reached the Fastly CLI + - name: Assert the deploy ran with the typed service id run: | set -euo pipefail - test -f fixture-app/fastly-argv.txt - # The action orchestrated build-cli -> deploy-fastly -> app CLI -> - # `fastly compute deploy`; the fake `fastly` recorded a compute subcommand. - grep -q -- 'compute' fixture-app/fastly-argv.txt - echo "recorded fastly argv:"; cat fixture-app/fastly-argv.txt + test -f fixture-app/deploy-argv.txt + # build-cli -> deploy-fastly -> app CLI ran the overridden deploy + # command with the wrapper's typed --service-id threaded through. + grep -q -- 'dummy-service' fixture-app/deploy-argv.txt + echo "recorded deploy argv:"; cat fixture-app/deploy-argv.txt From 7492be3331a92b3ac3dbe83188556d488370b861 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 10 Jul 2026 22:04:23 -0700 Subject: [PATCH 15/20] fix(ci): cleanup.sh set -e footgun (absent dirs) + two more [[..]] && cmd sites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cleanup.sh remove_if_present used '[[ -n && -d ]] && rm', which returns 1 when the dir is absent; called as a bare statement under set -e it exited non-zero, failing the deploy-fastly Cleanup step (with if: always()) even though the deploy succeeded. Use if/fi so it always returns 0. - Same footgun fixed in resolve-project.sh (lockfile hash — a real correctness bug for lockfile-less apps with cache:false) and check-action-pins.sh. - Relax the smoke assertion to marker-file existence (robust regardless of how the CLI threads passthrough args into an overridden manifest command). --- .github/actions/deploy-core/scripts/cleanup.sh | 6 +++++- .github/actions/deploy-core/scripts/resolve-project.sh | 4 +++- .github/actions/deploy-core/tests/check-action-pins.sh | 4 +++- .github/workflows/deploy-action.yml | 10 +++++----- 4 files changed, 16 insertions(+), 8 deletions(-) diff --git a/.github/actions/deploy-core/scripts/cleanup.sh b/.github/actions/deploy-core/scripts/cleanup.sh index cf82f376..ca6ac0b0 100755 --- a/.github/actions/deploy-core/scripts/cleanup.sh +++ b/.github/actions/deploy-core/scripts/cleanup.sh @@ -6,7 +6,11 @@ set -euo pipefail remove_if_present() { local dir="$1" - [[ -n "$dir" && -d "$dir" ]] && rm -rf "$dir" + # Always return success: an absent dir is not an error, and this runs under + # `set -e` where a bare `[[…]] && rm` would exit non-zero when the dir is gone. + if [[ -n "$dir" && -d "$dir" ]]; then + rm -rf "$dir" + fi } main() { diff --git a/.github/actions/deploy-core/scripts/resolve-project.sh b/.github/actions/deploy-core/scripts/resolve-project.sh index 5bc9544b..ba741b6b 100755 --- a/.github/actions/deploy-core/scripts/resolve-project.sh +++ b/.github/actions/deploy-core/scripts/resolve-project.sh @@ -137,7 +137,9 @@ main() { 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" - [[ -f "$lockfile" ]] && lock_hash=$(sha256_file "$lockfile") + if [[ -f "$lockfile" ]]; then + lock_hash=$(sha256_file "$lockfile") + fi target_dir="$cargo_ws_root/target" local rust_toolchain effective_build_mode cache_key diff --git a/.github/actions/deploy-core/tests/check-action-pins.sh b/.github/actions/deploy-core/tests/check-action-pins.sh index afa195a7..842ebeb5 100755 --- a/.github/actions/deploy-core/tests/check-action-pins.sh +++ b/.github/actions/deploy-core/tests/check-action-pins.sh @@ -39,5 +39,7 @@ while IFS= read -r line; do esac done < <(grep -rEn '^[[:space:]]*(-[[:space:]]+)?uses:' "${files[@]}" 2>/dev/null || true) -[[ "$status" -eq 0 ]] && echo "all third-party action references are pinned to a concrete ref" +if [[ "$status" -eq 0 ]]; then + echo "all third-party action references are pinned to a concrete ref" +fi exit "$status" diff --git a/.github/workflows/deploy-action.yml b/.github/workflows/deploy-action.yml index 07cbceeb..d73e2aa2 100644 --- a/.github/workflows/deploy-action.yml +++ b/.github/workflows/deploy-action.yml @@ -124,11 +124,11 @@ jobs: fastly-service-id: dummy-service deploy-args: '["--comment","smoke"]' - - name: Assert the deploy ran with the typed service id + - name: Assert the deploy command ran run: | set -euo pipefail + # The marker file exists only if build-cli -> deploy-fastly -> the app + # CLI ran the overridden Fastly deploy command end to end. test -f fixture-app/deploy-argv.txt - # build-cli -> deploy-fastly -> app CLI ran the overridden deploy - # command with the wrapper's typed --service-id threaded through. - grep -q -- 'dummy-service' fixture-app/deploy-argv.txt - echo "recorded deploy argv:"; cat fixture-app/deploy-argv.txt + echo "deploy marker present; recorded argv:" + cat fixture-app/deploy-argv.txt From 3370d2b98ce85fd1cdf558cbcebec546e371346c Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Fri, 10 Jul 2026 23:46:13 -0700 Subject: [PATCH 16/20] docs(guide): add 'Deploying from GitHub Actions' user guide (plan phase 9) User-facing VitePress guide for the layered deploy actions: three-layer model, runner support, same-repo/separate-repo/monorepo checkout examples, build-cli and deploy-fastly input/output tables, typed-credential and trusted-ref guidance, the Fastly staging lifecycle (stage -> healthcheck -> rollback), build-mode/cache behavior, and job hardening. Wired into the VitePress sidebar under Reference. prettier + eslint + vitepress build pass locally. --- docs/.vitepress/config.mts | 4 + docs/guide/deploy-github-actions.md | 251 ++++++++++++++++++ ...ezero-deploy-action-implementation-plan.md | 2 +- 3 files changed, 256 insertions(+), 1 deletion(-) create mode 100644 docs/guide/deploy-github-actions.md 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..e2b6a7ed --- /dev/null +++ b/docs/guide/deploy-github-actions.md @@ -0,0 +1,251 @@ +# 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-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 `cli-package`. `build-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-cli@ + with: + cli-package: my-app-cli # the CLI crate in your workspace + + - uses: stackpop/edgezero/.github/actions/deploy-fastly@ + with: + cli-artifact: ${{ steps.cli.outputs.artifact-name }} + 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-cli@ + with: + cli-package: my-app-cli + working-directory: app + + - uses: stackpop/edgezero/.github/actions/deploy-fastly@ + with: + cli-artifact: ${{ steps.cli.outputs.artifact-name }} + 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-cli@ + with: + cli-package: api-cli + working-directory: apps/api + +- uses: stackpop/edgezero/.github/actions/deploy-fastly@ + with: + cli-artifact: ${{ steps.cli.outputs.artifact-name }} + 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-cli` + +| Input | Required | Default | Meaning | +| ------------------- | -------- | --------------- | ---------------------------------------------------------------- | +| `cli-package` | Yes | — | Cargo package name of the CLI, in your app's workspace. | +| `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`). | +| `artifact-name` | No | `edgezero-cli` | Uploaded artifact name. | + +Outputs: `cli-version`, `cli-package`, `cli-bin`, `artifact-name`. + +### `deploy-fastly` + +| Input | Required | Default | Meaning | +| ------------------- | -------- | ------- | --------------------------------------------------------------- | +| `cli-artifact` | Yes | — | The `build-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. | +| `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`, `cli-version`. + +## Credentials + +Fastly credentials are typed inputs, not workflow `env:`. The engine binds the +token to the deploy step's own environment only — setup and build steps never see +it, 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. + +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-cli@ + with: { cli-package: my-app-cli } + +- id: stage + uses: stackpop/edgezero/.github/actions/deploy-fastly@ + with: + cli-artifact: ${{ steps.cli.outputs.artifact-name }} + 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: + cli-artifact: ${{ steps.cli.outputs.artifact-name }} + 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: + cli-artifact: ${{ steps.cli.outputs.artifact-name }} + 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 index 298e1461..eaeb271b 100644 --- a/docs/specs/edgezero-deploy-action-implementation-plan.md +++ b/docs/specs/edgezero-deploy-action-implementation-plan.md @@ -203,7 +203,7 @@ reference to port from. Most transfer with light changes: the downstream CLI template so app CLIs expose them. 9. **Docs** - - Rewrite `docs/guide/deploy-github-actions.md` around the three-layer model, + - 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. From a7bc4d2034dd6d4d20598aecce885039b208ecfa Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Sun, 12 Jul 2026 14:17:04 -0700 Subject: [PATCH 17/20] fix: address security + production-safety review (9 findings) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HIGH 1. Fail closed on invalid lifecycle values. 'stage' must be exactly true|false (validate-inputs) and 'deploy-to' exactly production|staging (healthcheck / rollback wrappers). A typo previously fell through to PRODUCTION, so it could activate a previous production version. 2. Rollback used wrong Fastly API semantics: POST -> PUT, and staging rollback now uses PUT /version//deactivate/staging (was a plain /deactivate). Verified against Fastly's version API reference + the 2024-08 staging change. 3. curl-config injection: tokens/service-ids were interpolated into a 'curl --config -' document unescaped, so a quote/newline could terminate a value and inject options (another URL/proxy). Added curl_quote escaping plus validate_service_id / validate_version / validate_domain. The token still travels via the config file (never argv). 4. Implement the specified provider-env boundary. The wrapper no longer exports FASTLY_* directly; it passes typed values as data and run-cli.sh CLEARS every provider alias (FASTLY_TOKEN/ENDPOINT/API_URL/...) before exporting only the declared, typed credentials. Inherited aliases can no longer reach a deploy. 5. Staged deploy selected the wrong manifest: it bypassed manifest commands and searched fastly.toml from the cwd, ignoring EDGEZERO_MANIFEST — unsafe in monorepos. It now resolves and threads the configured manifest path. MEDIUM 6. A successful deploy could emit an empty fastly-version (errors were demoted to warnings), breaking deploy->healthcheck->rollback threading. Version is now parsed from the deploy output (canonical version=, then Fastly's native phrasing), API only as fallback, Err if both fail; the action also fails if no version is emitted. 7. Lifecycle inputs are now required in the CLI: --service-id/--version for healthcheck and rollback, --domain for healthcheck; the token is required where it is actually used. 8. Test coverage: Bash contract tests 10 -> 19 (stage validation, artifact-name traversal, provider-env boundary), and the composite smoke now asserts version threading AND that an inherited FASTLY_ENDPOINT is cleared before deploy. 9. artifact-name is validated (no separators/traversal/leading dot) and the tarball name is fixed, so caller input is never a path component. Verified: cargo fmt/clippy(-D warnings)/test --workspace --all-targets, feature + spin-wasm checks, shellcheck, actionlint, 19/19 bash tests, prettier + docs build. --- .../actions/build-cli/scripts/build-cli.sh | 4 +- .github/actions/build-cli/scripts/common.sh | 14 + .../actions/deploy-core/scripts/run-cli.sh | 60 +++- .../deploy-core/scripts/validate-inputs.sh | 6 + .../deploy-core/tests/make-smoke-fixture.sh | 33 ++- .github/actions/deploy-core/tests/run.sh | 66 +++++ .github/actions/deploy-fastly/action.yml | 20 +- .github/actions/healthcheck-fastly/action.yml | 13 +- .github/actions/rollback-fastly/action.yml | 13 +- .github/workflows/deploy-action.yml | 23 +- crates/edgezero-adapter-fastly/src/cli.rs | 259 ++++++++++++++++-- crates/edgezero-cli/src/adapter.rs | 147 +++++++++- crates/edgezero-cli/src/args.rs | 133 +++++++-- crates/edgezero-cli/src/lib.rs | 234 ++++++++++++---- docs/guide/deploy-github-actions.md | 26 +- 15 files changed, 928 insertions(+), 123 deletions(-) diff --git a/.github/actions/build-cli/scripts/build-cli.sh b/.github/actions/build-cli/scripts/build-cli.sh index 9832971d..48554093 100755 --- a/.github/actions/build-cli/scripts/build-cli.sh +++ b/.github/actions/build-cli/scripts/build-cli.sh @@ -96,6 +96,7 @@ main() { 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 @@ -143,7 +144,8 @@ main() { '{"cli-bin": $bin, "cli-version": $version, "cli-package": $package}' \ >"$stage_root/cli-meta.json" - local tarball="$stage_root/../${artifact_name}.tar" + # 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" cli-meta.json tarball=$(canonical_path "$tarball") diff --git a/.github/actions/build-cli/scripts/common.sh b/.github/actions/build-cli/scripts/common.sh index 38f96051..576dd854 100755 --- a/.github/actions/build-cli/scripts/common.sh +++ b/.github/actions/build-cli/scripts/common.sh @@ -103,3 +103,17 @@ read_tool_version() { 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'" +} diff --git a/.github/actions/deploy-core/scripts/run-cli.sh b/.github/actions/deploy-core/scripts/run-cli.sh index 946a9b78..53d54dcb 100755 --- a/.github/actions/deploy-core/scripts/run-cli.sh +++ b/.github/actions/deploy-core/scripts/run-cli.sh @@ -5,9 +5,15 @@ set -euo pipefail # # Provider-neutral: it invokes ` --adapter ` with the # wrapper's typed deploy-flags (before `--`) and the caller's passthrough -# deploy-args (after `--`). Credential scoping is the wrapper's job, done with -# step-level `env:`; this script only clears the wrapper-named aliases during a -# credential-free build. +# deploy-args (after `--`). +# +# Credential boundary (deploy mode): the wrapper never exports provider tokens +# onto the step directly. It passes DEPLOY_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 DEPLOY_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. # # Inputs (environment): # EDGEZERO_CLI_BIN required binary name to invoke (on PATH) @@ -17,7 +23,8 @@ set -euo pipefail # DEPLOY_BUILD_ARGS_FILE optional NUL-delimited build passthrough (build) # DEPLOY_FLAGS_FILE optional NUL-delimited typed flags (deploy) # DEPLOY_ARGS_FILE optional NUL-delimited passthrough (deploy) -# DEPLOY_PROVIDER_ENV_CLEAR_FILE optional NUL-delimited env names to clear (build) +# DEPLOY_PROVIDER_ENV_CLEAR_FILE optional NUL-delimited env names to clear +# DEPLOY_PROVIDER_ENV optional JSON object of typed creds (deploy) SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) # shellcheck source=common.sh @@ -48,6 +55,45 @@ clear_named_aliases() { 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 +# DEPLOY_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="${DEPLOY_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 "DEPLOY_PROVIDER_ENV must be a JSON object of string values" + printf '%s' "$json" | jq -e 'all(.[]; type == "string")' >/dev/null 2>&1 || + fail "every DEPLOY_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 "DEPLOY_PROVIDER_ENV name '$name' is not a valid environment variable name" + name_in_clear_list "$name" "$clear_file" || + fail "DEPLOY_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" @@ -92,7 +138,11 @@ main() { case "$mode" in build) build_build_argv "$cli_bin" "$adapter" ;; - deploy) build_deploy_argv "$cli_bin" "$adapter" ;; + deploy) + # Clear inherited provider aliases and export only the typed credentials. + import_provider_env "${DEPLOY_PROVIDER_ENV_CLEAR_FILE:-/dev/null}" + build_deploy_argv "$cli_bin" "$adapter" + ;; esac if [[ -n "$manifest" ]]; then diff --git a/.github/actions/deploy-core/scripts/validate-inputs.sh b/.github/actions/deploy-core/scripts/validate-inputs.sh index 2eab4f84..9b8ec43d 100755 --- a/.github/actions/deploy-core/scripts/validate-inputs.sh +++ b/.github/actions/deploy-core/scripts/validate-inputs.sh @@ -68,6 +68,7 @@ main() { local adapter="${INPUT_ADAPTER:-}" local build_mode="${INPUT_BUILD_MODE:-auto}" local cache="${INPUT_CACHE:-false}" + local stage="${INPUT_STAGE:-false}" local deploy_arg_allow="${INPUT_DEPLOY_ARG_ALLOW:-}" require_supported_runner "${EDGEZERO_RUNNER_OS:-}" "${EDGEZERO_RUNNER_ARCH:-}" @@ -84,6 +85,11 @@ main() { 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 diff --git a/.github/actions/deploy-core/tests/make-smoke-fixture.sh b/.github/actions/deploy-core/tests/make-smoke-fixture.sh index 6aa87d9b..59b6d235 100755 --- a/.github/actions/deploy-core/tests/make-smoke-fixture.sh +++ b/.github/actions/deploy-core/tests/make-smoke-fixture.sh @@ -3,10 +3,13 @@ set -euo pipefail # Creates the minimal fixture application the composite smoke test deploys: # a standalone Cargo package (kept out of the surrounding edgezero workspace), -# a committed clean tree, and a fake `fastly` binary on PATH that records its -# argv instead of contacting Fastly. +# a committed clean tree, and an edgezero.toml whose Fastly deploy command is +# overridden by a marker script. The script emits `version=` (so version +# threading is exercised), records what credentials it actually saw (so the +# provider-env boundary is exercised), and records its argv — all without +# contacting Fastly. # -# Inputs (environment): GITHUB_WORKSPACE (required), GITHUB_PATH (required). +# Inputs (environment): GITHUB_WORKSPACE (required). main() { local workspace="${GITHUB_WORKSPACE:?GITHUB_WORKSPACE is required}" @@ -32,12 +35,28 @@ TOML echo 'fn main() {}' >src/main.rs cargo generate-lockfile - # Override the Fastly deploy command so `edgezero deploy --adapter fastly` - # runs a marker command (recording the passthrough argv) instead of the real - # `fastly compute deploy`, which would require a built package and live creds. + # Marker "deploy" script the CLI runs in place of `fastly compute deploy`. + # It records the credentials it actually saw and its argv, and emits a version + # line so `deploy-fastly` can thread `fastly-version`. + 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 check: an inherited endpoint alias must have been cleared. + printf 'endpoint=%s\n' "${FASTLY_ENDPOINT:-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 + + # Override the Fastly deploy command so `edgezero deploy --adapter fastly` runs + # the marker script instead of the real `fastly compute deploy` (which would + # need a built package and live credentials). cat >edgezero.toml <<'ETOML' [adapters.fastly.commands] -deploy = "printf '%s\n' > deploy-argv.txt" +deploy = "bash fake-deploy.sh" ETOML git add -A diff --git a/.github/actions/deploy-core/tests/run.sh b/.github/actions/deploy-core/tests/run.sh index 0f8190ae..6a643076 100755 --- a/.github/actions/deploy-core/tests/run.sh +++ b/.github/actions/deploy-core/tests/run.sh @@ -73,6 +73,7 @@ run_validate_inputs() { INPUT_DEPLOY_FLAGS="${VALIDATE_DEPLOY_FLAGS:-[]}" \ INPUT_PROVIDER_ENV_CLEAR="${VALIDATE_PROVIDER_ENV_CLEAR:-[]}" \ INPUT_DEPLOY_ARG_ALLOW="${VALIDATE_ALLOW:-}" \ + INPUT_STAGE="${VALIDATE_STAGE:-false}" \ EDGEZERO_ACTION_STATE_DIR="$state_dir" \ GITHUB_OUTPUT="$state_dir/output.txt" \ bash "$CORE_SCRIPTS/validate-inputs.sh" @@ -83,6 +84,8 @@ test_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' \ @@ -91,6 +94,67 @@ test_validate_inputs() { VALIDATE_BUILD_ARGS='[1,2]' assert_fails "rejects non-string build-args" run_validate_inputs } +# --------------------------------------------------------------------------- +# build-cli artifact-name — never usable as a path traversal +# --------------------------------------------------------------------------- +check_artifact_name() { + # Run validate_artifact_name from build-cli's common.sh in a subshell. + bash -c 'source "$1"; validate_artifact_name "$2"' _ \ + "$ACTIONS_DIR/build-cli/scripts/common.sh" "$1" +} + +test_artifact_name() { + section "build-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 "" +} + +# --------------------------------------------------------------------------- +# run-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_CLI_BIN=fakecli EDGEZERO_ADAPTER=fastly \ + EDGEZERO_WORKING_DIRECTORY="$app_dir" \ + DEPLOY_PROVIDER_ENV_CLEAR_FILE="$clear" \ + DEPLOY_PROVIDER_ENV="$1" \ + FASTLY_API_TOKEN=inherited-BAD FASTLY_ENDPOINT=https://inherited.invalid \ + bash "$CORE_SCRIPTS/run-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-cli.sh — CLI argv construction # --------------------------------------------------------------------------- @@ -191,7 +255,9 @@ test_fastly_versions() { # --------------------------------------------------------------------------- main() { test_validate_inputs + test_artifact_name test_run_cli_argv + test_provider_env_boundary test_download_cli_metadata test_fastly_versions diff --git a/.github/actions/deploy-fastly/action.yml b/.github/actions/deploy-fastly/action.yml index 90f71772..945da734 100644 --- a/.github/actions/deploy-fastly/action.yml +++ b/.github/actions/deploy-fastly/action.yml @@ -68,8 +68,9 @@ runs: INPUT_BUILD_ARGS: ${{ inputs['build-args'] }} INPUT_DEPLOY_ARGS: ${{ inputs['deploy-args'] }} INPUT_DEPLOY_ARG_ALLOW: "--comment" + INPUT_STAGE: ${{ inputs.stage }} INPUT_DEPLOY_FLAGS: ${{ inputs.stage == 'true' && format('["--service-id","{0}","--stage"]', inputs['fastly-service-id']) || format('["--service-id","{0}"]', inputs['fastly-service-id']) }} - INPUT_PROVIDER_ENV_CLEAR: '["FASTLY_API_TOKEN","FASTLY_SERVICE_ID","FASTLY_TOKEN","FASTLY_API_ENDPOINT","FASTLY_ENDPOINT"]' + INPUT_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"]' INPUT_FASTLY_API_TOKEN_PRESENT: ${{ inputs['fastly-api-token'] != '' && 'true' || 'false' }} INPUT_FASTLY_SERVICE_ID: ${{ inputs['fastly-service-id'] }} EDGEZERO_RUNNER_OS: ${{ runner.os }} @@ -83,6 +84,8 @@ runs: run: | [[ "${INPUT_FASTLY_API_TOKEN_PRESENT}" == "true" ]] || { echo "::error::missing required input 'fastly-api-token'"; exit 1; } [[ -n "${INPUT_FASTLY_SERVICE_ID}" ]] || { echo "::error::missing required input 'fastly-service-id'"; exit 1; } + [[ "${INPUT_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 @@ -175,13 +178,24 @@ runs: EDGEZERO_MANIFEST_PATH: ${{ steps.resolve.outputs['manifest'] }} DEPLOY_FLAGS_FILE: ${{ steps.validate.outputs['deploy-flags-file'] }} DEPLOY_ARGS_FILE: ${{ steps.validate.outputs['deploy-args-file'] }} - FASTLY_API_TOKEN: ${{ inputs['fastly-api-token'] }} - FASTLY_SERVICE_ID: ${{ inputs['fastly-service-id'] }} + # Credential boundary: pass the typed values as data (not as FASTLY_* + # aliases). run-cli.sh clears every provider-env-clear alias — including + # any inherited FASTLY_ENDPOINT/FASTLY_TOKEN — and then exports only these. + DEPLOY_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: | set -o pipefail + command -v jq >/dev/null || { echo "::error::jq is required"; exit 1; } + DEPLOY_PROVIDER_ENV=$(jq -n \ + --arg t "$EDGEZERO_FASTLY_API_TOKEN" \ + --arg s "$EDGEZERO_FASTLY_SERVICE_ID" \ + '{FASTLY_API_TOKEN: $t, FASTLY_SERVICE_ID: $s}') + export DEPLOY_PROVIDER_ENV log="${RUNNER_TEMP:-/tmp}/edgezero-deploy.log" "$GITHUB_ACTION_PATH/../deploy-core/scripts/run-cli.sh" deploy 2>&1 | tee "$log" version=$(grep -oE '^version=[0-9]+' "$log" | tail -n 1 | cut -d= -f2 || true) + [[ -n "$version" ]] || { echo "::error::deploy reported success but emitted no fastly-version"; exit 1; } echo "fastly-version=${version}" >> "$GITHUB_OUTPUT" - name: Save application target cache diff --git a/.github/actions/healthcheck-fastly/action.yml b/.github/actions/healthcheck-fastly/action.yml index b5946b32..40eda316 100644 --- a/.github/actions/healthcheck-fastly/action.yml +++ b/.github/actions/healthcheck-fastly/action.yml @@ -77,11 +77,22 @@ runs: RETRY: ${{ inputs.retry }} RETRY_DELAY: ${{ inputs['retry-delay'] }} 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: "" run: | + # A typo in deploy-to must never silently probe production. + case "$DEPLOY_TO" in production | staging) ;; *) echo "::error::input 'deploy-to' must be 'production' or 'staging' (got '$DEPLOY_TO')"; exit 1 ;; esac log="${RUNNER_TEMP:-/tmp}/edgezero-healthcheck.log" args=("$CLI_BIN" healthcheck --adapter fastly --service-id "$SERVICE_ID" --version "$VERSION" --domain "$DOMAIN" --retry "$RETRY" --retry-delay "$RETRY_DELAY" --timeout "$TIMEOUT") - [[ "$DEPLOY_TO" == "staging" ]] && args+=(--staging) + if [[ "$DEPLOY_TO" == "staging" ]]; then args+=(--staging); fi rc=0 "${args[@]}" 2>&1 | tee "$log" || rc=$? healthy=$(grep -oE '^healthy=(true|false)' "$log" | tail -n 1 | cut -d= -f2 || true) diff --git a/.github/actions/rollback-fastly/action.yml b/.github/actions/rollback-fastly/action.yml index c0769ef8..1062b1e7 100644 --- a/.github/actions/rollback-fastly/action.yml +++ b/.github/actions/rollback-fastly/action.yml @@ -55,11 +55,22 @@ runs: SERVICE_ID: ${{ inputs['fastly-service-id'] }} VERSION: ${{ inputs['fastly-version'] }} 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: "" run: | + # A typo in deploy-to must never silently roll back production. + case "$DEPLOY_TO" in production | staging) ;; *) echo "::error::input 'deploy-to' must be 'production' or 'staging' (got '$DEPLOY_TO')"; exit 1 ;; esac log="${RUNNER_TEMP:-/tmp}/edgezero-rollback.log" args=("$CLI_BIN" rollback --adapter fastly --service-id "$SERVICE_ID" --version "$VERSION") - [[ "$DEPLOY_TO" == "staging" ]] && args+=(--staging) + if [[ "$DEPLOY_TO" == "staging" ]]; then args+=(--staging); fi rc=0 "${args[@]}" 2>&1 | tee "$log" || rc=$? rolled=$(grep -oE '^rolled-back-to=[0-9]+' "$log" | tail -n 1 | cut -d= -f2 || true) diff --git a/.github/workflows/deploy-action.yml b/.github/workflows/deploy-action.yml index d73e2aa2..733426cf 100644 --- a/.github/workflows/deploy-action.yml +++ b/.github/workflows/deploy-action.yml @@ -99,6 +99,9 @@ jobs: composite-smoke: runs-on: ubuntu-24.04 + # An inherited provider alias the deploy MUST clear (provider-env boundary). + env: + FASTLY_ENDPOINT: https://inherited.invalid steps: - uses: actions/checkout@v4 with: @@ -116,6 +119,7 @@ jobs: run: .github/actions/deploy-core/tests/make-smoke-fixture.sh - name: Deploy fixture (production) with local action + id: deploy uses: ./.github/actions/deploy-fastly with: cli-artifact: ${{ steps.cli.outputs.artifact-name }} @@ -124,11 +128,20 @@ jobs: fastly-service-id: dummy-service deploy-args: '["--comment","smoke"]' - - name: Assert the deploy command ran + - name: Assert end-to-end deploy, version threading, and credential boundary + env: + FASTLY_VERSION_OUT: ${{ steps.deploy.outputs['fastly-version'] }} run: | set -euo pipefail - # The marker file exists only if build-cli -> deploy-fastly -> the app - # CLI ran the overridden Fastly deploy command end to end. + # 1) The whole chain ran: build-cli -> deploy-fastly -> app CLI -> the + # overridden Fastly deploy command. test -f fixture-app/deploy-argv.txt - echo "deploy marker present; recorded argv:" - cat fixture-app/deploy-argv.txt + test -f fixture-app/env-seen.txt + echo "recorded argv:"; cat fixture-app/deploy-argv.txt + echo "credentials the deploy saw:"; cat fixture-app/env-seen.txt + # 2) fastly-version threaded out of the action. + [[ "$FASTLY_VERSION_OUT" == "7" ]] || { echo "::error::expected fastly-version=7, got '$FASTLY_VERSION_OUT'"; exit 1; } + # 3) provider-env boundary: typed creds present, inherited alias cleared. + grep -qx 'token=dummy-token' fixture-app/env-seen.txt || { echo "::error::typed FASTLY_API_TOKEN not delivered"; exit 1; } + grep -qx 'service-id=dummy-service' fixture-app/env-seen.txt || { echo "::error::typed FASTLY_SERVICE_ID not delivered"; exit 1; } + grep -qx 'endpoint=CLEARED' fixture-app/env-seen.txt || { echo "::error::inherited FASTLY_ENDPOINT was NOT cleared before deploy"; exit 1; } diff --git a/crates/edgezero-adapter-fastly/src/cli.rs b/crates/edgezero-adapter-fastly/src/cli.rs index 789a3a84..025fb187 100644 --- a/crates/edgezero-adapter-fastly/src/cli.rs +++ b/crates/edgezero-adapter-fastly/src/cli.rs @@ -1710,19 +1710,108 @@ fn curl_config_capture(config: &str) -> Result { } } +/// 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. +/// 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 config = - format!("header = \"Fastly-Key: {token}\"\nurl = \"https://api.fastly.com{path}\"\n"); + 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) } -/// `POST https://api.fastly.com` with the `Fastly-Key` header; -/// returns the HTTP status, erroring on non-2xx. -fn fastly_api_post(path: &str, token: &str) -> Result { +/// `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 = \"POST\"\nheader = \"Fastly-Key: {token}\"\nurl = \"https://api.fastly.com{path}\"\noutput = \"/dev/null\"\nwrite-out = \"%{{http_code}}\"\n" + "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| { @@ -1734,8 +1823,38 @@ fn fastly_api_post(path: &str, token: &str) -> Result { if (200..300).contains(&code) { Ok(code) } else { - Err(format!("Fastly API POST {path} returned HTTP {code}")) + Err(format!("Fastly API PUT {path} returned HTTP {code}")) + } +} + +/// Resolve the directory containing the Fastly manifest for a staged +/// deploy. +/// +/// 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 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 the +/// legacy `deploy` path used. +fn resolve_staged_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): @@ -1743,17 +1862,22 @@ fn fastly_api_post(path: &str, token: &str) -> Result { /// 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 = - 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 extra = args_without_flag_value(args, "--service-id"); + let manifest_dir_buf = resolve_staged_manifest_dir(args)?; + let manifest_dir = manifest_dir_buf.as_path(); + // Strip both the explicitly-threaded `--service-id` and the + // CLI-injected `--manifest-path` so neither is forwarded to + // `fastly compute update` (which doesn't understand + // `--manifest-path`). + let extra = args_without_flag_value( + &args_without_flag_value(args, "--service-id"), + "--manifest-path", + ); // 1. Build the wasm package (no deploy / activation). run_fastly_status( @@ -1813,6 +1937,7 @@ fn deploy_staged(args: &[String]) -> Result<(), String> { /// 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(|| { @@ -1829,6 +1954,7 @@ fn emit_active_version(args: &[String]) -> Result<(), String> { fn healthcheck(args: &[String]) -> Result<(), String> { let domain = arg_value(args, "--domain").ok_or_else(|| "healthcheck requires --domain".to_owned())?; + validate_domain(domain)?; let retry = arg_value(args, "--retry") .and_then(|value| value.parse().ok()) .unwrap_or(3_u32); @@ -1841,8 +1967,10 @@ fn healthcheck(args: &[String]) -> Result<(), String> { let staging_ip = if arg_flag(args, "--staging") { let service_id = resolve_service_id(args)?; - let version = arg_value(args, "--version") + validate_service_id(&service_id)?; + let version_str = arg_value(args, "--version") .ok_or_else(|| "staging healthcheck requires --version".to_owned())?; + let version = validate_version_str(version_str)?; let token = require_token()?; let json = fastly_api_get( &format!("/service/{service_id}/version/{version}/domain?include=staging_ips"), @@ -1908,26 +2036,31 @@ fn curl_status(args: &[String]) -> Result { /// ` - 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: u64 = version_str.parse().map_err(|err| { - format!("--version must be a positive integer, got {version_str:?}: {err}") - })?; + let version = validate_version_str(version_str)?; let token = require_token()?; if arg_flag(args, "--staging") { - fastly_api_post( - &format!("/service/{service_id}/version/{version}/deactivate"), + // 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_post( + fastly_api_put( &format!("/service/{service_id}/version/{previous}/activate"), &token, )?; @@ -2033,12 +2166,96 @@ mod tests { ); } + #[test] + fn resolve_staged_manifest_dir_prefers_manifest_path_flag() { + // When the CLI threads `--manifest-path `, the + // staged deploy 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_staged_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"); } + // ── 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)); diff --git a/crates/edgezero-cli/src/adapter.rs b/crates/edgezero-cli/src/adapter.rs index d9882764..4704ab53 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")); @@ -152,6 +154,42 @@ 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 { + if 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) +} + fn manifest_command<'manifest>( manifest: &'manifest Manifest, adapter_name: &str, @@ -188,15 +226,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 { @@ -244,6 +285,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}"))?; @@ -257,6 +319,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 0cce6802..3bcf711f 100644 --- a/crates/edgezero-cli/src/args.rs +++ b/crates/edgezero-cli/src/args.rs @@ -236,18 +236,20 @@ pub struct HealthcheckArgs { /// Target adapter name. #[arg(long = "adapter", required = true)] pub adapter: String, - /// Public domain to probe. - #[arg(long)] - pub domain: Option, + /// 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. - #[arg(long)] - pub service_id: Option, + /// 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)] @@ -256,8 +258,9 @@ pub struct HealthcheckArgs { #[arg(long, default_value_t = 10)] pub timeout: u64, /// Service version to probe (threaded from a prior deploy/stage). - #[arg(long)] - pub version: Option, + /// Required so the version is never silently dropped. + #[arg(long, required = true)] + pub version: String, } /// Arguments for the `rollback` command (Fastly staging lifecycle, @@ -270,17 +273,17 @@ pub struct RollbackArgs { /// Target adapter name. #[arg(long = "adapter", required = true)] pub adapter: String, - /// Platform service id to roll back. - #[arg(long)] - pub service_id: Option, + /// Platform service id to roll back. Required. + #[arg(long, required = true)] + pub service_id: String, /// Roll back the staged version (deactivate) instead of the /// production version (activate previous). #[arg(long)] pub staging: bool, /// Reference version: production activates ` - 1`; - /// staging deactivates ``. - #[arg(long)] - pub version: Option, + /// staging deactivates ``. Required. + #[arg(long, required = true)] + pub version: String, } /// Output format for `config diff`. @@ -826,9 +829,9 @@ mod tests { panic!("expected Command::Healthcheck"); }; assert_eq!(hc.adapter, "fastly"); - assert_eq!(hc.service_id.as_deref(), Some("SVC123")); - assert_eq!(hc.version.as_deref(), Some("42")); - assert_eq!(hc.domain.as_deref(), Some("staging.example.com")); + 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); @@ -842,6 +845,10 @@ mod tests { "healthcheck", "--adapter", "fastly", + "--service-id", + "SVC123", + "--version", + "42", "--domain", "example.com", ]) @@ -857,8 +864,57 @@ mod tests { #[test] fn healthcheck_requires_adapter() { - Args::try_parse_from(["edgezero", "healthcheck", "--domain", "example.com"]) - .expect_err("`healthcheck` without --adapter must error"); + 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] @@ -879,8 +935,8 @@ mod tests { panic!("expected Command::Rollback"); }; assert_eq!(rb.adapter, "fastly"); - assert_eq!(rb.service_id.as_deref(), Some("SVC123")); - assert_eq!(rb.version.as_deref(), Some("42")); + assert_eq!(rb.service_id, "SVC123"); + assert_eq!(rb.version, "42"); assert!(rb.staging); } @@ -891,6 +947,8 @@ mod tests { "rollback", "--adapter", "fastly", + "--service-id", + "SVC123", "--version", "9", ]) @@ -903,8 +961,37 @@ mod tests { #[test] fn rollback_requires_adapter() { - Args::try_parse_from(["edgezero", "rollback", "--version", "9"]) - .expect_err("`rollback` without --adapter must error"); + 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) ────────────────── diff --git a/crates/edgezero-cli/src/lib.rs b/crates/edgezero-cli/src/lib.rs index 85532a2f..a10005d8 100644 --- a/crates/edgezero-cli/src/lib.rs +++ b/crates/edgezero-cli/src/lib.rs @@ -176,40 +176,146 @@ pub fn run_deploy(args: &DeployArgs) -> Result<(), String> { // 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. + // + // Thread the manifest-configured Fastly manifest path (resolved + // from `[adapters..adapter].manifest` relative to the + // `EDGEZERO_MANIFEST`-honoring manifest root) so the staged + // deploy 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. + let mut staged: Vec = Vec::new(); + if let Some(manifest_path) = resolve_adapter_manifest_path(manifest.as_ref(), &args.adapter) + { + staged.push("--manifest-path".to_owned()); + staged.push(manifest_path); + } + staged.extend(passthrough); return adapter::execute( &args.adapter, adapter::Action::DeployStaged, manifest.as_ref(), - &passthrough, + &staged, ); } - adapter::execute( - &args.adapter, - adapter::Action::Deploy, - manifest.as_ref(), - &passthrough, - )?; - // Production deploy also emits the activated version (spec §5.4.2) - // so the deploy-fastly action can surface `fastly-version`. This - // is Fastly-specific and best-effort: it only runs with a known - // service id, and a failure to resolve the version must NOT fail - // an already-activated deploy — the version is a convenience - // output, not the deploy's success criterion. + // 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") { - if let Err(err) = adapter::execute( + 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, - ) { - log::warn!( - "[edgezero] deploy succeeded but resolving the activated version failed: {err}" - ); + ) + .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(), + &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). +#[cfg(feature = "cli")] +fn parse_canonical_version_line(output: &str) -> Option { + output.lines().rev().find_map(|line| { + let digits: String = line + .trim() + .strip_prefix("version=")? + .chars() + .take_while(char::is_ascii_digit) + .collect(); + digits.parse::().ok() + }) +} + +/// Last `version ` mention anywhere in `output` (case-insensitive), +/// covering Fastly's `Deployed package (service abc, version 12)`. +#[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() + .skip_while(|ch| !ch.is_ascii_digit()) + .take_while(char::is_ascii_digit) + .collect(); + if let Ok(parsed) = digits.parse::() { + result = Some(parsed); } } - Ok(()) + 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 @@ -227,28 +333,25 @@ pub fn run_deploy(args: &DeployArgs) -> Result<(), String> { 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::new(); - if let Some(service_id) = &args.service_id { - passthrough.push("--service-id".to_owned()); - passthrough.push(service_id.clone()); - } - if let Some(version) = &args.version { - passthrough.push("--version".to_owned()); - passthrough.push(version.clone()); - } - if let Some(domain) = &args.domain { - passthrough.push("--domain".to_owned()); - passthrough.push(domain.clone()); - } + 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.push("--retry".to_owned()); - passthrough.push(args.retry.to_string()); - passthrough.push("--retry-delay".to_owned()); - passthrough.push(args.retry_delay.to_string()); - passthrough.push("--timeout".to_owned()); - passthrough.push(args.timeout.to_string()); + 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, @@ -271,15 +374,12 @@ pub fn run_healthcheck(args: &HealthcheckArgs) -> Result<(), String> { 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::new(); - if let Some(service_id) = &args.service_id { - passthrough.push("--service-id".to_owned()); - passthrough.push(service_id.clone()); - } - if let Some(version) = &args.version { - passthrough.push("--version".to_owned()); - passthrough.push(version.clone()); - } + 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()); } @@ -467,6 +567,46 @@ 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_mention() { + let output = "Cloning version 3... created version 4\n"; + assert_eq!(parse_deploy_version(output), Some(4)); + } + #[test] fn ensure_adapter_defined_accepts_known_adapter() { let loader = ManifestLoader::load_from_str(BASIC_MANIFEST); diff --git a/docs/guide/deploy-github-actions.md b/docs/guide/deploy-github-actions.md index e2b6a7ed..d8cf21ab 100644 --- a/docs/guide/deploy-github-actions.md +++ b/docs/guide/deploy-github-actions.md @@ -157,13 +157,29 @@ Outputs: `cli-version`, `cli-package`, `cli-bin`, `artifact-name`. Outputs: `fastly-version`, `source-revision`, `cli-version`. +## 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:`. The engine binds the -token to the deploy step's own environment only — setup and build steps never see -it, 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. +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 From de476c688f2aaa6d910c8f6552abb4b03a0c9326 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Mon, 13 Jul 2026 10:45:25 -0700 Subject: [PATCH 18/20] Add Fastly lifecycle acceptance test; close the --features cli test gap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The composite smoke test only covered a production deploy. The staging lifecycle — stage, healthcheck, rollback — had no end-to-end coverage, which is exactly where the review found real defects (--comment forwarded to a command that doesn't support it, a plural staging_ips misread, POST instead of PUT). Those are argv/verb bugs, so the test has to assert argv and verbs. - lifecycle-smoke job: builds the app-owned fixture CLI, installs fake `fastly`/`curl` that mirror the real contracts (singular `staging_ip`, `--config -` on stdin), then drives stage -> healthcheck -> rollback through the real wrappers and asserts: * `compute update` carries --autoclone/--version=active/--non-interactive and never --comment; * the comment is applied via `service-version update` BEFORE staging; * the probe is rerouted to the staging IP via --connect-to; * an unhealthy probe FAILS healthcheck-fastly (the rollback gate); * staging rollback PUTs /deactivate/staging, production PUTs /version/41/activate, and rolled-back-to threads out. - test.yml: run `cargo test -p edgezero-adapter-fastly --all-targets --features cli`. The workspace gate never enabled the `cli` feature, so 115 adapter dispatch tests were compiled by nothing but the clippy job. - spec §9.1: document that compute-deploy-only flags are no-ops under --stage. --- .../actions/build-cli/scripts/build-cli.sh | 15 +- .github/actions/build-cli/scripts/common.sh | 14 + .github/actions/deploy-core/scripts/common.sh | 27 + .../deploy-core/scripts/download-cli.sh | 13 +- .../deploy-core/tests/make-fake-fastly-env.sh | 87 +++ .../deploy-core/tests/make-smoke-fixture.sh | 96 ++- .github/actions/deploy-core/tests/run.sh | 65 ++ .github/actions/deploy-fastly/action.yml | 64 ++ .github/actions/healthcheck-fastly/action.yml | 12 +- .github/actions/rollback-fastly/action.yml | 11 +- .github/workflows/deploy-action.yml | 193 +++++- .github/workflows/test.yml | 7 + crates/edgezero-adapter-fastly/src/cli.rs | 630 ++++++++++++++++-- crates/edgezero-cli/src/lib.rs | 49 +- docs/specs/edgezero-deploy-adoption-guide.md | 4 + docs/specs/edgezero-deploy-github-action.md | 14 + 16 files changed, 1185 insertions(+), 116 deletions(-) create mode 100644 .github/actions/deploy-core/tests/make-fake-fastly-env.sh diff --git a/.github/actions/build-cli/scripts/build-cli.sh b/.github/actions/build-cli/scripts/build-cli.sh index 48554093..6e8f35f2 100755 --- a/.github/actions/build-cli/scripts/build-cli.sh +++ b/.github/actions/build-cli/scripts/build-cli.sh @@ -88,8 +88,13 @@ main() { local working_directory="${INPUT_WORKING_DIRECTORY:-.}" local rust_toolchain_input="${INPUT_RUST_TOOLCHAIN:-auto}" local artifact_name="${INPUT_ARTIFACT_NAME:-edgezero-cli}" - local stage_root="${EDGEZERO_CLI_STAGE_ROOT:-${RUNNER_TEMP:-/tmp}/edgezero-cli-artifact}" - local build_target_dir="${CARGO_TARGET_DIR_OVERRIDE:-${RUNNER_TEMP:-/tmp}/edgezero-cli-build}" + + # 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 @@ -126,8 +131,7 @@ main() { cli_version=$(jq -r '.version' <<<"$package_json") # Build into an action-owned target dir so the checkout stays clean. - rm -rf "$build_target_dir" - mkdir -p "$build_target_dir" + 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" @@ -136,8 +140,7 @@ main() { "$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. - rm -rf "$stage_root" - mkdir -p "$stage_root" + 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" \ diff --git a/.github/actions/build-cli/scripts/common.sh b/.github/actions/build-cli/scripts/common.sh index 576dd854..91ab9190 100755 --- a/.github/actions/build-cli/scripts/common.sh +++ b/.github/actions/build-cli/scripts/common.sh @@ -117,3 +117,17 @@ validate_artifact_name() { [[ "$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/common.sh b/.github/actions/deploy-core/scripts/common.sh index 38f96051..2d3baf4e 100755 --- a/.github/actions/deploy-core/scripts/common.sh +++ b/.github/actions/deploy-core/scripts/common.sh @@ -103,3 +103,30 @@ read_tool_version() { 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 +} diff --git a/.github/actions/deploy-core/scripts/download-cli.sh b/.github/actions/deploy-core/scripts/download-cli.sh index fc737bcb..b144ff34 100755 --- a/.github/actions/deploy-core/scripts/download-cli.sh +++ b/.github/actions/deploy-core/scripts/download-cli.sh @@ -34,6 +34,7 @@ main() { 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/cli-meta.json" ]] || fail "CLI artifact is missing cli-meta.json" @@ -41,10 +42,16 @@ main() { meta_bin=$(jq -er '."cli-bin"' "$tool_root/bin/cli-meta.json") || fail "cli-meta.json has no cli-bin" cli_version=$(jq -er '."cli-version"' "$tool_root/bin/cli-meta.json") || fail "cli-meta.json has no cli-version" cli_bin="${cli_bin_override:-$meta_bin}" + validate_cli_bin "$cli_bin" - [[ -f "$tool_root/bin/$cli_bin" ]] || fail "CLI binary '$cli_bin' not present in the artifact" - chmod +x "$tool_root/bin/$cli_bin" - "$tool_root/bin/$cli_bin" --help >/dev/null 2>&1 || fail "downloaded CLI '$cli_bin' did not run '--help'" + 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" 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 100644 index 00000000..ef91409d --- /dev/null +++ b/.github/actions/deploy-core/tests/make-fake-fastly-env.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Installs fake `fastly` and `curl` binaries on PATH for the lifecycle smoke +# test, plus a call log the assertions read back. +# +# These fakes mirror the REAL contracts the adapter depends on, so the smoke +# test regression-tests the bugs a review found: +# * `fastly compute update` must NOT receive --comment (it does not support it); +# the comment must be applied via `fastly service-version update` BEFORE +# `service-version stage`. +# * `compute update` output must be a realistic success line, because the +# version parser is now fail-closed (it refuses to guess). +# * The Fastly domain API returns a SINGULAR `staging_ip` string. +# * activate/deactivate are PUT, and staging deactivate is /deactivate/staging. +# +# Inputs (environment): GITHUB_WORKSPACE, GITHUB_PATH. + +main() { + local workspace="${GITHUB_WORKSPACE:?GITHUB_WORKSPACE is required}" + local bin_dir="$workspace/fake-bin" + local log="$workspace/fake-calls.log" + local state="$workspace/fake-state" + + mkdir -p "$bin_dir" "$state" + : >"$log" + + cat >"$bin_dir/fastly" <<'SH' +#!/usr/bin/env bash +printf 'fastly %s\n' "$*" >>"$FAKE_CALL_LOG" +case "${1:-} ${2:-}" in + "compute build") + echo "Built package (fixture)" + ;; + "compute update") + # Realistic success line — the version parser is fail-closed and will + # refuse to stage if it cannot parse a version from 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" ;; + *) echo "fake fastly: unhandled: $*" >&2 ;; +esac +exit 0 +SH + chmod +x "$bin_dir/fastly" + + cat >"$bin_dir/curl" <<'SH' +#!/usr/bin/env bash +# Two shapes: an API call via `--config -` (config on stdin), or a health 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:-}" || -f "$FAKE_STATE_DIR/unhealthy" ]]; then + echo 503 +else + echo 200 +fi +exit 0 +SH + chmod +x "$bin_dir/curl" + + # Prepend the fakes so they shadow the real tools for subsequent steps. + printf '%s\n' "$bin_dir" >>"${GITHUB_PATH:?GITHUB_PATH is required}" + { + printf 'FAKE_CALL_LOG=%s\n' "$log" + printf 'FAKE_STATE_DIR=%s\n' "$state" + } >>"${GITHUB_ENV:?GITHUB_ENV is required}" + + echo "fake fastly + curl installed at $bin_dir" +} + +main "$@" diff --git a/.github/actions/deploy-core/tests/make-smoke-fixture.sh b/.github/actions/deploy-core/tests/make-smoke-fixture.sh index 59b6d235..56bd5c07 100755 --- a/.github/actions/deploy-core/tests/make-smoke-fixture.sh +++ b/.github/actions/deploy-core/tests/make-smoke-fixture.sh @@ -1,13 +1,17 @@ #!/usr/bin/env bash set -euo pipefail -# Creates the minimal fixture application the composite smoke test deploys: -# a standalone Cargo package (kept out of the surrounding edgezero workspace), -# a committed clean tree, and an edgezero.toml whose Fastly deploy command is -# overridden by a marker script. The script emits `version=` (so version -# threading is exercised), records what credentials it actually saw (so the -# provider-env boundary is exercised), and records its argv — all without -# contacting Fastly. +# 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). @@ -15,29 +19,74 @@ main() { local workspace="${GITHUB_WORKSPACE:?GITHUB_WORKSPACE is required}" local app_dir="$workspace/fixture-app" - mkdir -p "$app_dir/src" + 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" -version = "0.0.0" +name = "fixture-app-cli" +version = "0.1.0" edition = "2021" -# Standalone: keep the fixture out of the surrounding edgezero workspace. -[workspace] +[[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 - echo 'fn main() {}' >src/main.rs - cargo generate-lockfile + 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}; - # Marker "deploy" script the CLI runs in place of `fastly compute deploy`. - # It records the credentials it actually saw and its argv, and emits a version - # line so `deploy-fastly` can thread `fastly-version`. +#[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 { @@ -51,14 +100,21 @@ echo "version=7" SH chmod +x fake-deploy.sh - # Override the Fastly deploy command so `edgezero deploy --adapter fastly` runs - # the marker script instead of the real `fastly compute deploy` (which would - # need a built package and live credentials). 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 } diff --git a/.github/actions/deploy-core/tests/run.sh b/.github/actions/deploy-core/tests/run.sh index 6a643076..bca043a8 100755 --- a/.github/actions/deploy-core/tests/run.sh +++ b/.github/actions/deploy-core/tests/run.sh @@ -112,6 +112,69 @@ test_artifact_name() { assert_fails "rejects an empty name" check_artifact_name "" } +# --------------------------------------------------------------------------- +# build-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-cli/scripts/common.sh" "$1" "$2" +} + +test_owned_dir_confinement() { + section "build-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-cli — 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-cli cli-bin + archive safety" + assert_succeeds "accepts a bare binary name" check_cli_bin "myapp-cli" + assert_fails "rejects a traversal cli-bin ('../../outside/tool')" check_cli_bin "../../outside/tool" + assert_fails "rejects a cli-bin with a separator" check_cli_bin "sub/tool" + assert_fails "rejects an empty 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/cli-meta.json" + tar -C "$good/stage" -cf "$good/good.tar" myapp-cli cli-meta.json + assert_succeeds "accepts a well-formed archive" check_tarball "$good/good.tar" +} + # --------------------------------------------------------------------------- # run-cli.sh — provider-env credential boundary # --------------------------------------------------------------------------- @@ -256,6 +319,8 @@ test_fastly_versions() { 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 diff --git a/.github/actions/deploy-fastly/action.yml b/.github/actions/deploy-fastly/action.yml index 945da734..7cf71375 100644 --- a/.github/actions/deploy-fastly/action.yml +++ b/.github/actions/deploy-fastly/action.yml @@ -79,8 +79,15 @@ runs: 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: "" run: | [[ "${INPUT_FASTLY_API_TOKEN_PRESENT}" == "true" ]] || { echo "::error::missing required input 'fastly-api-token'"; exit 1; } [[ -n "${INPUT_FASTLY_SERVICE_ID}" ]] || { echo "::error::missing required input 'fastly-service-id'"; exit 1; } @@ -103,6 +110,16 @@ runs: INPUT_CLI_BIN: ${{ inputs['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: "" run: $GITHUB_ACTION_PATH/../deploy-core/scripts/download-cli.sh - name: Resolve project @@ -120,8 +137,15 @@ runs: 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: "" run: $GITHUB_ACTION_PATH/../deploy-core/scripts/resolve-project.sh - name: Restore application target cache @@ -152,6 +176,16 @@ runs: 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: "" run: $GITHUB_ACTION_PATH/scripts/install-fastly.sh - name: Build (validation) @@ -166,6 +200,16 @@ runs: DEPLOY_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: "" run: $GITHUB_ACTION_PATH/../deploy-core/scripts/run-cli.sh build - name: Deploy @@ -221,6 +265,16 @@ runs: 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: "" run: $GITHUB_ACTION_PATH/../deploy-core/scripts/write-summary.sh - name: Cleanup @@ -231,4 +285,14 @@ runs: EDGEZERO_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: "" run: $GITHUB_ACTION_PATH/../deploy-core/scripts/cleanup.sh diff --git a/.github/actions/healthcheck-fastly/action.yml b/.github/actions/healthcheck-fastly/action.yml index 40eda316..e4558b5c 100644 --- a/.github/actions/healthcheck-fastly/action.yml +++ b/.github/actions/healthcheck-fastly/action.yml @@ -99,4 +99,14 @@ runs: status=$(grep -oE '^status-code=[0-9]+' "$log" | tail -n 1 | cut -d= -f2 || true) echo "healthy=${healthy:-false}" >> "$GITHUB_OUTPUT" echo "status-code=${status}" >> "$GITHUB_OUTPUT" - exit "$rc" + # Fail closed. A zero-exit CLI that reports healthy=false, or emits no + # verdict at all, must NOT let this action succeed — callers gate their + # rollback on this step failing. + if [[ "$rc" -ne 0 ]]; then + echo "::error::health check failed (CLI exit $rc, healthy=${healthy:-}, status=${status:-})" + exit "$rc" + fi + if [[ "$healthy" != "true" ]]; then + echo "::error::health check did not report healthy=true (got '${healthy:-}')" + exit 1 + fi diff --git a/.github/actions/rollback-fastly/action.yml b/.github/actions/rollback-fastly/action.yml index 1062b1e7..1da1faa6 100644 --- a/.github/actions/rollback-fastly/action.yml +++ b/.github/actions/rollback-fastly/action.yml @@ -75,4 +75,13 @@ runs: "${args[@]}" 2>&1 | tee "$log" || rc=$? rolled=$(grep -oE '^rolled-back-to=[0-9]+' "$log" | tail -n 1 | cut -d= -f2 || true) echo "rolled-back-to=${rolled}" >> "$GITHUB_OUTPUT" - exit "$rc" + # Fail closed: a rollback that did not report what it activated has not + # provably rolled anything back. + if [[ "$rc" -ne 0 ]]; then + echo "::error::rollback failed (CLI exit $rc)" + exit "$rc" + fi + if [[ "$DEPLOY_TO" == "production" && -z "$rolled" ]]; then + echo "::error::production rollback reported success but did not emit rolled-back-to" + exit 1 + fi diff --git a/.github/workflows/deploy-action.yml b/.github/workflows/deploy-action.yml index 733426cf..52cad1df 100644 --- a/.github/workflows/deploy-action.yml +++ b/.github/workflows/deploy-action.yml @@ -44,12 +44,17 @@ jobs: with: persist-credentials: false - - name: Install actionlint (pinned release binary) + - name: Install actionlint (pinned release binary, checksum-verified) run: | set -euo pipefail - url="https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" - curl --fail --location --silent --show-error "$url" --output /tmp/actionlint.tar.gz - tar -xzf /tmp/actionlint.tar.gz -C /tmp actionlint + 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) @@ -107,16 +112,17 @@ jobs: with: persist-credentials: false - - name: Build CLI from the monorepo package + # The fixture is a REAL app-owned CLI (its own crate depending on + # edgezero-cli) — the contract build-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-cli with: - cli-package: edgezero-cli - cli-bin: edgezero - working-directory: . - - - name: Create fixture app - run: .github/actions/deploy-core/tests/make-smoke-fixture.sh + cli-package: fixture-app-cli + working-directory: fixture-app - name: Deploy fixture (production) with local action id: deploy @@ -127,8 +133,9 @@ jobs: fastly-api-token: dummy-token fastly-service-id: dummy-service deploy-args: '["--comment","smoke"]' + cache: true - - name: Assert end-to-end deploy, version threading, and credential boundary + - name: Assert production deploy, version threading, and credential boundary env: FASTLY_VERSION_OUT: ${{ steps.deploy.outputs['fastly-version'] }} run: | @@ -145,3 +152,165 @@ jobs: grep -qx 'token=dummy-token' fixture-app/env-seen.txt || { echo "::error::typed FASTLY_API_TOKEN not delivered"; exit 1; } grep -qx 'service-id=dummy-service' fixture-app/env-seen.txt || { echo "::error::typed FASTLY_SERVICE_ID not delivered"; exit 1; } grep -qx 'endpoint=CLEARED' fixture-app/env-seen.txt || { echo "::error::inherited FASTLY_ENDPOINT was NOT cleared before deploy"; exit 1; } + + # Exercises the FULL Fastly staging lifecycle (stage -> healthcheck -> + # rollback) against faithful fake `fastly`/`curl` binaries, asserting the exact + # verbs and paths. This is the regression test for the review findings: + # --comment must not reach `compute update`; the domain API returns a singular + # `staging_ip`; activate/deactivate are PUT; staging deactivate is + # /deactivate/staging; and an unhealthy probe must FAIL the healthcheck action. + lifecycle-smoke: + runs-on: ubuntu-24.04 + 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-cli + with: + cli-package: fixture-app-cli + working-directory: fixture-app + + - name: Install fake fastly + curl + run: .github/actions/deploy-core/tests/make-fake-fastly-env.sh + + - name: Download the app CLI artifact + uses: actions/download-artifact@v4 + with: + name: ${{ steps.cli.outputs.artifact-name }} + path: cli-dl + + - name: Extract the app CLI + run: | + set -euo pipefail + mkdir -p cli-bin + tar -xf cli-dl/*.tar -C cli-bin + chmod +x "cli-bin/${{ steps.cli.outputs.cli-bin }}" + + - name: Staged deploy (asserts --comment never reaches `compute update`) + env: + FASTLY_API_TOKEN: dummy-token + FASTLY_SERVICE_ID: dummy-service + run: | + set -euo pipefail + cd fixture-app + out=$("$GITHUB_WORKSPACE/cli-bin/${{ steps.cli.outputs.cli-bin }}" \ + deploy --adapter fastly --service-id dummy-service --stage -- --comment "staged smoke" 2>&1 | tee /dev/stderr) + echo "$out" | grep -qE '^version=42$' || { echo "::error::staged deploy did not emit version=42"; exit 1; } + + - name: Assert the staged Fastly call sequence + run: | + set -euo pipefail + log="$FAKE_CALL_LOG" + echo "--- recorded fastly/curl calls:"; cat "$log" + + update=$(grep -E '^fastly compute update ' "$log" | head -n 1) + [[ -n "$update" ]] || { echo "::error::no 'compute update' call"; exit 1; } + # `compute update` does NOT support --comment; it must never be forwarded. + if grep -qE '^fastly compute update .*--comment' "$log"; then + echo "::error::--comment was forwarded to 'compute update' (unsupported flag)"; exit 1 + fi + for flag in -- --autoclone --version=active --non-interactive; do + [[ "$update" == *"$flag"* ]] || { echo "::error::'compute update' missing $flag"; exit 1; } + done + + # The comment must be applied to the version, BEFORE staging it. + grep -qE '^fastly service-version update .*--comment' "$log" \ + || { echo "::error::comment was not applied via 'service-version update'"; exit 1; } + cu=$(grep -n '^fastly service-version update ' "$log" | head -n 1 | cut -d: -f1) + st=$(grep -n '^fastly service-version stage ' "$log" | head -n 1 | cut -d: -f1) + [[ -n "$st" ]] || { echo "::error::version was never staged"; exit 1; } + [[ "$cu" -lt "$st" ]] || { echo "::error::comment applied after staging (must precede it)"; exit 1; } + + - name: Health check the staged version (singular staging_ip) + uses: ./.github/actions/healthcheck-fastly + with: + cli-artifact: ${{ steps.cli.outputs.artifact-name }} + deploy-to: staging + domain: staging.example.com + fastly-version: "42" + fastly-api-token: dummy-token + fastly-service-id: dummy-service + retry: "1" + retry-delay: "1" + + - name: Assert staging IP was resolved and probed + run: | + set -euo pipefail + log="$FAKE_CALL_LOG" + grep -qE '^GET https://api\.fastly\.com/service/dummy-service/version/42/domain\?include=staging_ips$' "$log" \ + || { echo "::error::staging-IP lookup not performed"; exit 1; } + # The probe must be rerouted to the staging IP from the singular field. + grep -qE '^PROBE .*--connect-to ::151\.101\.2\.10:443 .*https://staging\.example\.com/' "$log" \ + || { echo "::error::probe was not rerouted to the staging IP"; exit 1; } + + - name: Health check FAILS closed when unhealthy + id: unhealthy + continue-on-error: true + uses: ./.github/actions/healthcheck-fastly + with: + cli-artifact: ${{ steps.cli.outputs.artifact-name }} + deploy-to: staging + domain: staging.example.com + fastly-version: "42" + fastly-api-token: dummy-token + fastly-service-id: dummy-service + retry: "1" + retry-delay: "1" + env: + # Flip the fake probe to 503 for this step only. + FORCE_UNHEALTHY: "1" + + - name: Assert the unhealthy check actually failed the wrapper + env: + OUTCOME: ${{ steps.unhealthy.outcome }} + HEALTHY: ${{ steps.unhealthy.outputs.healthy }} + run: | + set -euo pipefail + # The rollback gate is "healthcheck step failed". If an unhealthy probe + # lets the action succeed, no caller would ever roll back. + [[ "$OUTCOME" == "failure" ]] \ + || { echo "::error::unhealthy probe did not fail healthcheck-fastly (outcome=$OUTCOME)"; exit 1; } + [[ "$HEALTHY" != "true" ]] \ + || { echo "::error::unhealthy probe still reported healthy=true"; exit 1; } + echo "healthcheck-fastly failed closed on an unhealthy probe" + + - name: Rollback the staged version (PUT /deactivate/staging) + uses: ./.github/actions/rollback-fastly + with: + cli-artifact: ${{ steps.cli.outputs.artifact-name }} + deploy-to: staging + fastly-version: "42" + fastly-api-token: dummy-token + fastly-service-id: dummy-service + + - name: Rollback production (PUT /activate on v-1) + id: prod-rollback + uses: ./.github/actions/rollback-fastly + with: + cli-artifact: ${{ steps.cli.outputs.artifact-name }} + deploy-to: production + fastly-version: "42" + fastly-api-token: dummy-token + fastly-service-id: dummy-service + + - name: Assert rollback verbs, paths, and version threading + env: + ROLLED_BACK_TO: ${{ steps.prod-rollback.outputs['rolled-back-to'] }} + run: | + set -euo pipefail + log="$FAKE_CALL_LOG" + echo "--- recorded calls:"; cat "$log" + # Staging rollback: PUT .../deactivate/staging (NOT a plain /deactivate). + grep -qE '^PUT https://api\.fastly\.com/service/dummy-service/version/42/deactivate/staging$' "$log" \ + || { echo "::error::staging rollback did not PUT /deactivate/staging"; exit 1; } + # Production rollback activates the PREVIOUS version (42 -> 41), via PUT. + grep -qE '^PUT https://api\.fastly\.com/service/dummy-service/version/41/activate$' "$log" \ + || { echo "::error::production rollback did not PUT /version/41/activate"; exit 1; } + # Version threading out of the action. + [[ "$ROLLED_BACK_TO" == "41" ]] || { echo "::error::expected rolled-back-to=41, got '$ROLLED_BACK_TO'"; exit 1; } 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-fastly/src/cli.rs b/crates/edgezero-adapter-fastly/src/cli.rs index 025fb187..f3d608ef 100644 --- a/crates/edgezero-adapter-fastly/src/cli.rs +++ b/crates/edgezero-adapter-fastly/src/cli.rs @@ -130,8 +130,59 @@ const FASTLY_API_TOKEN_ENV: &str = "FASTLY_API_TOKEN"; /// `--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: @@ -1270,6 +1321,28 @@ 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. #[inline] @@ -1281,8 +1354,7 @@ pub fn deploy(extra_args: &[String]) -> Result<(), String> { .ok_or_else(|| "fastly manifest has no parent directory".to_owned())?; let status = Command::new("fastly") - .args(["compute", "deploy"]) - .args(extra_args) + .args(build_compute_deploy_args(extra_args)) .current_dir(manifest_dir) .status() .map_err(|err| format!("failed to run fastly CLI: {err}"))?; @@ -1465,6 +1537,56 @@ fn args_without_flag_value(args: &[String], flag: &str) -> Vec { 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() { + if 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 { @@ -1493,22 +1615,21 @@ fn previous_version(version: u64) -> Option { (version > 1).then(|| version.saturating_sub(1)) } -/// Best-effort scan of Fastly CLI output for a trailing version number -/// (`... version 42`, `version=42`, etc.). Returns the LAST match, -/// which is the freshly-created draft in `compute update` output. -fn parse_fastly_version(text: &str) -> Option { - let lower = text.to_ascii_lowercase(); +/// 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("version") { - let after = idx.saturating_add("version".len()); + 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() - .skip_while(|ch| !ch.is_ascii_digit()) - .take_while(char::is_ascii_digit) - .collect(); + 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); } @@ -1516,6 +1637,50 @@ fn parse_fastly_version(text: &str) -> Option { 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. @@ -1528,19 +1693,20 @@ fn parse_active_version(json: &str) -> Option { }) } -/// Highest `number` in a version-list JSON array — the fallback for -/// resolving the just-created draft when output parsing fails. -fn parse_latest_version(json: &str) -> Option { - let value: serde_json::Value = serde_json::from_str(json).ok()?; - value - .as_array()? - .iter() - .filter_map(|entry| entry.get("number").and_then(serde_json::Value::as_u64)) - .max() -} - -/// First staging IP found anywhere under a `staging_ips` array in the -/// Fastly API `domain?include=staging_ips` response. +/// 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) @@ -1549,6 +1715,11 @@ fn parse_staging_ip(json: &str) -> Option { 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) @@ -1871,13 +2042,22 @@ fn deploy_staged(args: &[String]) -> Result<(), String> { let manifest_dir_buf = resolve_staged_manifest_dir(args)?; let manifest_dir = manifest_dir_buf.as_path(); // Strip both the explicitly-threaded `--service-id` and the - // CLI-injected `--manifest-path` so neither is forwarded to - // `fastly compute update` (which doesn't understand - // `--manifest-path`). + // 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( @@ -1898,26 +2078,46 @@ fn deploy_staged(args: &[String]) -> Result<(), String> { "--autoclone".to_owned(), format!("--service-id={service_id}"), "--version=active".to_owned(), - "--non-interactive".to_owned(), ]; - update.extend(extra); + 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: parse the update output, falling - // back to the highest version reported by the Fastly API. - let version = if let Some(version) = parse_fastly_version(&update_out) { - version - } else { - let token = require_token()?; - let json = fastly_api_get(&format!("/service/{service_id}/version"), &token)?; - parse_latest_version(&json).ok_or_else(|| { - format!( - "could not determine the staged version from `fastly compute update` output or the Fastly API; raw output:\n{update_out}" - ) - })? - }; + // 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. Mark the draft version staged (no activation). + // 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(), @@ -1928,7 +2128,7 @@ fn deploy_staged(args: &[String]) -> Result<(), String> { manifest_dir, )?; - // 4. Emit the staged version (spec §5.4.2 parseable contract). + // 5. Emit the staged version (spec §5.4.2 parseable contract). log::info!("version={version}"); Ok(()) } @@ -2188,6 +2388,103 @@ mod tests { 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})" + ); + } + } + // ── curl-config escaping + input validation (injection defence) ─── #[test] @@ -2276,20 +2573,63 @@ mod tests { } #[test] - fn parse_fastly_version_handles_common_shapes() { + 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("Cloned to version=12"), Some(12)); - // The LAST version mention wins (the freshly-created draft). assert_eq!( - parse_fastly_version("Cloning version 3... created version 4"), + 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#"[ @@ -2307,24 +2647,44 @@ mod tests { } #[test] - fn parse_latest_version_returns_max_number() { - let json = r#"[{"number": 1},{"number": 5},{"number": 3}]"#; - assert_eq!(parse_latest_version(json), Some(5)); + 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_finds_nested_ip() { - // Array of domain objects, each carrying a staging_ips array. - let json = r#"[ - {"name": "example.com", "staging_ips": ["151.101.2.10", "151.101.66.10"]} - ]"#; + 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() { - let json = r#"[{"name": "example.com"}]"#; - assert_eq!(parse_staging_ip(json), None); + 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] @@ -4430,4 +4790,150 @@ 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"); + + let previous_token = env::var_os(FASTLY_API_TOKEN_ENV); + env::set_var(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); + match previous_token { + Some(prev) => env::set_var(FASTLY_API_TOKEN_ENV, prev), + None => env::remove_var(FASTLY_API_TOKEN_ENV), + } + + 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:?}" + ); + } } diff --git a/crates/edgezero-cli/src/lib.rs b/crates/edgezero-cli/src/lib.rs index a10005d8..e22a3f2d 100644 --- a/crates/edgezero-cli/src/lib.rs +++ b/crates/edgezero-cli/src/lib.rs @@ -281,22 +281,31 @@ fn parse_canonical_version_line(output: &str) -> Option { }) } -/// Last `version ` mention anywhere in `output` (case-insensitive), -/// covering Fastly's `Deployed package (service abc, version 12)`. +/// 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()); + 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() - .skip_while(|ch| !ch.is_ascii_digit()) - .take_while(char::is_ascii_digit) - .collect(); + 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); } @@ -602,11 +611,29 @@ mod tests { } #[test] - fn parse_deploy_version_native_takes_last_mention() { - let output = "Cloning version 3... created version 4\n"; + 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 ensure_adapter_defined_accepts_known_adapter() { let loader = ManifestLoader::load_from_str(BASIC_MANIFEST); diff --git a/docs/specs/edgezero-deploy-adoption-guide.md b/docs/specs/edgezero-deploy-adoption-guide.md index 88f9ce22..33c4e5db 100644 --- a/docs/specs/edgezero-deploy-adoption-guide.md +++ b/docs/specs/edgezero-deploy-adoption-guide.md @@ -79,6 +79,10 @@ steps: 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 diff --git a/docs/specs/edgezero-deploy-github-action.md b/docs/specs/edgezero-deploy-github-action.md index 4c92a5bb..5f253247 100644 --- a/docs/specs/edgezero-deploy-github-action.md +++ b/docs/specs/edgezero-deploy-github-action.md @@ -519,6 +519,20 @@ 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` From 730b27c80a326f40f0d54d1dc4889295613cc8ad Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Mon, 13 Jul 2026 11:44:47 -0700 Subject: [PATCH 19/20] lifecycle-smoke: exec bit on fake-env script; bind cli-bin via env (zizmor) --- .github/actions/deploy-core/tests/make-fake-fastly-env.sh | 0 .github/workflows/deploy-action.yml | 7 +++++-- 2 files changed, 5 insertions(+), 2 deletions(-) mode change 100644 => 100755 .github/actions/deploy-core/tests/make-fake-fastly-env.sh diff --git a/.github/actions/deploy-core/tests/make-fake-fastly-env.sh b/.github/actions/deploy-core/tests/make-fake-fastly-env.sh old mode 100644 new mode 100755 diff --git a/.github/workflows/deploy-action.yml b/.github/workflows/deploy-action.yml index 52cad1df..f2729114 100644 --- a/.github/workflows/deploy-action.yml +++ b/.github/workflows/deploy-action.yml @@ -186,20 +186,23 @@ jobs: path: cli-dl - name: Extract the app CLI + env: + CLI_BIN: ${{ steps.cli.outputs.cli-bin }} run: | set -euo pipefail mkdir -p cli-bin tar -xf cli-dl/*.tar -C cli-bin - chmod +x "cli-bin/${{ steps.cli.outputs.cli-bin }}" + chmod +x "cli-bin/$CLI_BIN" - name: Staged deploy (asserts --comment never reaches `compute update`) env: + CLI_BIN: ${{ steps.cli.outputs.cli-bin }} FASTLY_API_TOKEN: dummy-token FASTLY_SERVICE_ID: dummy-service run: | set -euo pipefail cd fixture-app - out=$("$GITHUB_WORKSPACE/cli-bin/${{ steps.cli.outputs.cli-bin }}" \ + out=$("$GITHUB_WORKSPACE/cli-bin/$CLI_BIN" \ deploy --adapter fastly --service-id dummy-service --stage -- --comment "staged smoke" 2>&1 | tee /dev/stderr) echo "$out" | grep -qE '^version=42$' || { echo "::error::staged deploy did not emit version=42"; exit 1; } From 348e6801d3ac60f87fc95af789c34b8fd828d415 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Mon, 13 Jul 2026 12:09:55 -0700 Subject: [PATCH 20/20] deploy-action: move every smoke assertion into a shellcheck'd script The lifecycle job's inline run blocks had grown into the largest logic in the workflow, unreadable and unlinted. Each assertion is now a named script under deploy-core/tests/ that documents the defect it regression-tests, and the YAML is a list of steps again. --- .../tests/assert-production-deploy.sh | 42 ++++++ .../tests/assert-rollback-calls.sh | 40 ++++++ .../deploy-core/tests/assert-staged-calls.sh | 79 ++++++++++++ .../deploy-core/tests/assert-staging-probe.sh | 29 +++++ .../tests/assert-unhealthy-failed.sh | 28 ++++ .../actions/deploy-core/tests/extract-cli.sh | 27 ++++ .../deploy-core/tests/staged-deploy.sh | 33 +++++ .github/workflows/deploy-action.yml | 121 ++++-------------- 8 files changed, 305 insertions(+), 94 deletions(-) create mode 100755 .github/actions/deploy-core/tests/assert-production-deploy.sh create mode 100755 .github/actions/deploy-core/tests/assert-rollback-calls.sh create mode 100755 .github/actions/deploy-core/tests/assert-staged-calls.sh create mode 100755 .github/actions/deploy-core/tests/assert-staging-probe.sh create mode 100755 .github/actions/deploy-core/tests/assert-unhealthy-failed.sh create mode 100755 .github/actions/deploy-core/tests/extract-cli.sh create mode 100755 .github/actions/deploy-core/tests/staged-deploy.sh 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..cdf37947 --- /dev/null +++ b/.github/actions/deploy-core/tests/assert-production-deploy.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Asserts the production deploy path end to end: build-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, and an inherited alias (FASTLY_ENDPOINT, set at job level) is CLEARED. +# +# Inputs (environment): GITHUB_WORKSPACE, FASTLY_VERSION_OUT. + +main() { + local workspace="${GITHUB_WORKSPACE:?GITHUB_WORKSPACE is required}" + local version_out="${FASTLY_VERSION_OUT:-}" + local env_seen="$workspace/fixture-app/env-seen.txt" + local argv="$workspace/fixture-app/deploy-argv.txt" + + if [[ ! -f "$argv" || ! -f "$env_seen" ]]; then + echo "::error::the deploy never reached the app CLI's Fastly deploy command" >&2 + return 1 + fi + echo "recorded argv:"; cat "$argv" + echo "credentials the deploy saw:"; cat "$env_seen" + + if [[ "$version_out" != "7" ]]; then + echo "::error::expected fastly-version=7 out of the action, got '${version_out:-}'" >&2 + return 1 + fi + + # The provider-env boundary: typed values in, inherited aliases out. + local expected + for expected in 'token=dummy-token' 'service-id=dummy-service' 'endpoint=CLEARED'; do + if ! grep -qx -- "$expected" "$env_seen"; then + echo "::error::provider-env boundary violated: expected '$expected' in env-seen.txt" >&2 + return 1 + fi + done + + echo "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..7f050c12 --- /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 two 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`. +# +# Inputs (environment): FAKE_CALL_LOG, ROLLED_BACK_TO. + +main() { + local log="${FAKE_CALL_LOG:?FAKE_CALL_LOG is required}" + local rolled_back_to="${ROLLED_BACK_TO:-}" + local api="https://api\.fastly\.com/service/dummy-service" + + echo "--- recorded fastly/curl calls:" + cat "$log" + + if ! grep -qE "^PUT $api/version/42/deactivate/staging\$" "$log"; then + echo "::error::staging rollback did not PUT /version/42/deactivate/staging" >&2 + return 1 + fi + + # Production rollback activates the PREVIOUS version (42 -> 41). + if ! grep -qE "^PUT $api/version/41/activate\$" "$log"; then + echo "::error::production rollback did not PUT /version/41/activate" >&2 + return 1 + fi + + if [[ "$rolled_back_to" != "41" ]]; then + echo "::error::expected rolled-back-to=41, got '${rolled_back_to:-}'" >&2 + return 1 + fi + + echo "rollback used PUT with the correct paths, and rolled-back-to=41 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..3e9d24e6 --- /dev/null +++ b/.github/actions/deploy-core/tests/assert-staged-calls.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Asserts the exact Fastly call sequence a staged deploy must produce. +# +# Regression test for two real defects a review found: +# * `--comment` was forwarded to `fastly compute update`, which has no such +# flag — the upload failed. It must instead be applied via +# `fastly service-version update --comment`, BEFORE the version is staged. +# * The staged upload must clone the active version and stay non-interactive. +# +# Inputs (environment): FAKE_CALL_LOG. + +require_call() { + local pattern="$1" what="$2" log="$3" + if ! grep -qE -- "$pattern" "$log"; then + echo "::error::$what" >&2 + return 1 + fi +} + +assert_update_flags() { + local update="$1" flag + for flag in --autoclone --version=active --non-interactive --service-id; do + if [[ "$update" != *"$flag"* ]]; then + echo "::error::'compute update' is missing $flag (got: $update)" >&2 + return 1 + fi + done +} + +assert_no_comment_on_update() { + local log="$1" + if grep -qE '^fastly compute update .*--comment' "$log"; then + echo "::error::--comment was forwarded to 'compute update', which does not support it" >&2 + return 1 + 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) + + if [[ -z "$comment_line" ]]; then + echo "::error::the comment was never applied via 'service-version update'" >&2 + return 1 + fi + if [[ -z "$stage_line" ]]; then + echo "::error::the version was never staged" >&2 + return 1 + fi + if [[ "$comment_line" -ge "$stage_line" ]]; then + echo "::error::the comment was applied after staging; it must precede it" >&2 + return 1 + fi +} + +main() { + local log="${FAKE_CALL_LOG:?FAKE_CALL_LOG is required}" + local update + + echo "--- recorded fastly/curl calls:" + cat "$log" + + update=$(grep -E '^fastly compute update ' "$log" | head -n 1 || true) + if [[ -z "$update" ]]; then + echo "::error::the staged deploy never ran 'fastly compute update'" >&2 + return 1 + fi + + assert_update_flags "$update" + assert_no_comment_on_update "$log" + assert_comment_precedes_stage "$log" + + echo "staged call sequence is correct" +} + +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..a8b606a7 --- /dev/null +++ b/.github/actions/deploy-core/tests/assert-staging-probe.sh @@ -0,0 +1,29 @@ +#!/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. +# +# Inputs (environment): FAKE_CALL_LOG. + +main() { + local log="${FAKE_CALL_LOG:?FAKE_CALL_LOG is required}" + + if ! grep -qE '^GET https://api\.fastly\.com/service/dummy-service/version/42/domain\?include=staging_ips$' "$log"; then + echo "::error::the staging-IP lookup was never performed" >&2 + return 1 + fi + + if ! grep -qE '^PROBE .*--connect-to ::151\.101\.2\.10:443 .*https://staging\.example\.com/' "$log"; then + echo "::error::the probe was not rerouted to the staging IP (singular staging_ip not read?)" >&2 + return 1 + fi + + echo "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..960feccb --- /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. +# +# Inputs (environment): OUTCOME (the step's outcome), HEALTHY (its output). + +main() { + local outcome="${OUTCOME:?OUTCOME is required}" + local healthy="${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/extract-cli.sh b/.github/actions/deploy-core/tests/extract-cli.sh new file mode 100755 index 00000000..0c5541f2 --- /dev/null +++ b/.github/actions/deploy-core/tests/extract-cli.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Extracts the build-cli artifact tarball so the lifecycle test can drive the +# app CLI directly (the wrappers do this themselves via download-cli.sh). +# +# Inputs (environment): GITHUB_WORKSPACE, CLI_BIN. + +main() { + local workspace="${GITHUB_WORKSPACE:?GITHUB_WORKSPACE is required}" + local cli_bin="${CLI_BIN:?CLI_BIN is required}" + local dest="$workspace/cli-bin" + local tarball + + tarball=$(find "$workspace/cli-dl" -maxdepth 1 -name '*.tar' -print -quit) + if [[ -z "$tarball" ]]; then + echo "::error::no CLI tarball found under $workspace/cli-dl" >&2 + return 1 + fi + + mkdir -p "$dest" + tar -xf "$tarball" -C "$dest" + chmod +x "$dest/$cli_bin" + echo "extracted $cli_bin from $tarball" +} + +main "$@" diff --git a/.github/actions/deploy-core/tests/staged-deploy.sh b/.github/actions/deploy-core/tests/staged-deploy.sh new file mode 100755 index 00000000..51889399 --- /dev/null +++ b/.github/actions/deploy-core/tests/staged-deploy.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Runs a STAGED deploy through the app-owned CLI against the fake `fastly` +# (see make-fake-fastly-env.sh) and asserts the staged version is emitted. +# +# The version parser is fail-closed, so an empty `version=` here means the +# adapter failed to read the version back out of `fastly compute update`. +# +# Inputs (environment): GITHUB_WORKSPACE, CLI_BIN, FASTLY_API_TOKEN, +# FASTLY_SERVICE_ID. + +main() { + local workspace="${GITHUB_WORKSPACE:?GITHUB_WORKSPACE is required}" + local cli_bin="${CLI_BIN:?CLI_BIN is required}" + local out + + cd "$workspace/fixture-app" + + out=$("$workspace/cli-bin/$cli_bin" deploy \ + --adapter fastly \ + --service-id dummy-service \ + --stage \ + -- --comment "staged smoke" 2>&1 | tee /dev/stderr) + + if ! printf '%s\n' "$out" | grep -qE '^version=42$'; then + echo "::error::staged deploy did not emit version=42" >&2 + return 1 + fi + echo "staged deploy emitted version=42" +} + +main "$@" diff --git a/.github/workflows/deploy-action.yml b/.github/workflows/deploy-action.yml index f2729114..7ab91906 100644 --- a/.github/workflows/deploy-action.yml +++ b/.github/workflows/deploy-action.yml @@ -102,6 +102,10 @@ jobs: npm run lint npm run build + # Production path: build the app's OWN CLI, deploy through the wrapper, and + # prove the whole chain ran with the credential boundary intact. Every + # assertion lives in a script under deploy-core/tests/ so it is shellcheck'd + # and readable outside the YAML. composite-smoke: runs-on: ubuntu-24.04 # An inherited provider alias the deploy MUST clear (provider-env boundary). @@ -138,27 +142,12 @@ jobs: - name: Assert production deploy, version threading, and credential boundary env: FASTLY_VERSION_OUT: ${{ steps.deploy.outputs['fastly-version'] }} - run: | - set -euo pipefail - # 1) The whole chain ran: build-cli -> deploy-fastly -> app CLI -> the - # overridden Fastly deploy command. - test -f fixture-app/deploy-argv.txt - test -f fixture-app/env-seen.txt - echo "recorded argv:"; cat fixture-app/deploy-argv.txt - echo "credentials the deploy saw:"; cat fixture-app/env-seen.txt - # 2) fastly-version threaded out of the action. - [[ "$FASTLY_VERSION_OUT" == "7" ]] || { echo "::error::expected fastly-version=7, got '$FASTLY_VERSION_OUT'"; exit 1; } - # 3) provider-env boundary: typed creds present, inherited alias cleared. - grep -qx 'token=dummy-token' fixture-app/env-seen.txt || { echo "::error::typed FASTLY_API_TOKEN not delivered"; exit 1; } - grep -qx 'service-id=dummy-service' fixture-app/env-seen.txt || { echo "::error::typed FASTLY_SERVICE_ID not delivered"; exit 1; } - grep -qx 'endpoint=CLEARED' fixture-app/env-seen.txt || { echo "::error::inherited FASTLY_ENDPOINT was NOT cleared before deploy"; exit 1; } - - # Exercises the FULL Fastly staging lifecycle (stage -> healthcheck -> - # rollback) against faithful fake `fastly`/`curl` binaries, asserting the exact - # verbs and paths. This is the regression test for the review findings: - # --comment must not reach `compute update`; the domain API returns a singular - # `staging_ip`; activate/deactivate are PUT; staging deactivate is - # /deactivate/staging; and an unhealthy probe must FAIL the healthcheck action. + run: .github/actions/deploy-core/tests/assert-production-deploy.sh + + # Staging path: the full lifecycle (stage -> healthcheck -> rollback) against + # fake `fastly`/`curl` binaries that mirror the real contracts. Because the + # bugs a review found were argv/verb bugs, the assertions check argv and verbs + # — see the assert-*.sh scripts for what each one regression-tests. lifecycle-smoke: runs-on: ubuntu-24.04 steps: @@ -188,49 +177,19 @@ jobs: - name: Extract the app CLI env: CLI_BIN: ${{ steps.cli.outputs.cli-bin }} - run: | - set -euo pipefail - mkdir -p cli-bin - tar -xf cli-dl/*.tar -C cli-bin - chmod +x "cli-bin/$CLI_BIN" + run: .github/actions/deploy-core/tests/extract-cli.sh - - name: Staged deploy (asserts --comment never reaches `compute update`) + - name: Staged deploy env: CLI_BIN: ${{ steps.cli.outputs.cli-bin }} FASTLY_API_TOKEN: dummy-token FASTLY_SERVICE_ID: dummy-service - run: | - set -euo pipefail - cd fixture-app - out=$("$GITHUB_WORKSPACE/cli-bin/$CLI_BIN" \ - deploy --adapter fastly --service-id dummy-service --stage -- --comment "staged smoke" 2>&1 | tee /dev/stderr) - echo "$out" | grep -qE '^version=42$' || { echo "::error::staged deploy did not emit version=42"; exit 1; } + run: .github/actions/deploy-core/tests/staged-deploy.sh - name: Assert the staged Fastly call sequence - run: | - set -euo pipefail - log="$FAKE_CALL_LOG" - echo "--- recorded fastly/curl calls:"; cat "$log" - - update=$(grep -E '^fastly compute update ' "$log" | head -n 1) - [[ -n "$update" ]] || { echo "::error::no 'compute update' call"; exit 1; } - # `compute update` does NOT support --comment; it must never be forwarded. - if grep -qE '^fastly compute update .*--comment' "$log"; then - echo "::error::--comment was forwarded to 'compute update' (unsupported flag)"; exit 1 - fi - for flag in -- --autoclone --version=active --non-interactive; do - [[ "$update" == *"$flag"* ]] || { echo "::error::'compute update' missing $flag"; exit 1; } - done - - # The comment must be applied to the version, BEFORE staging it. - grep -qE '^fastly service-version update .*--comment' "$log" \ - || { echo "::error::comment was not applied via 'service-version update'"; exit 1; } - cu=$(grep -n '^fastly service-version update ' "$log" | head -n 1 | cut -d: -f1) - st=$(grep -n '^fastly service-version stage ' "$log" | head -n 1 | cut -d: -f1) - [[ -n "$st" ]] || { echo "::error::version was never staged"; exit 1; } - [[ "$cu" -lt "$st" ]] || { echo "::error::comment applied after staging (must precede it)"; exit 1; } - - - name: Health check the staged version (singular staging_ip) + run: .github/actions/deploy-core/tests/assert-staged-calls.sh + + - name: Health check the staged version uses: ./.github/actions/healthcheck-fastly with: cli-artifact: ${{ steps.cli.outputs.artifact-name }} @@ -242,17 +201,10 @@ jobs: retry: "1" retry-delay: "1" - - name: Assert staging IP was resolved and probed - run: | - set -euo pipefail - log="$FAKE_CALL_LOG" - grep -qE '^GET https://api\.fastly\.com/service/dummy-service/version/42/domain\?include=staging_ips$' "$log" \ - || { echo "::error::staging-IP lookup not performed"; exit 1; } - # The probe must be rerouted to the staging IP from the singular field. - grep -qE '^PROBE .*--connect-to ::151\.101\.2\.10:443 .*https://staging\.example\.com/' "$log" \ - || { echo "::error::probe was not rerouted to the staging IP"; exit 1; } - - - name: Health check FAILS closed when unhealthy + - name: Assert the staging IP was resolved and probed + 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 @@ -266,24 +218,16 @@ jobs: retry: "1" retry-delay: "1" env: - # Flip the fake probe to 503 for this step only. + # Flips the fake probe to 503 for this step only. FORCE_UNHEALTHY: "1" - - name: Assert the unhealthy check actually failed the wrapper + - name: Assert the unhealthy check failed the wrapper env: OUTCOME: ${{ steps.unhealthy.outcome }} HEALTHY: ${{ steps.unhealthy.outputs.healthy }} - run: | - set -euo pipefail - # The rollback gate is "healthcheck step failed". If an unhealthy probe - # lets the action succeed, no caller would ever roll back. - [[ "$OUTCOME" == "failure" ]] \ - || { echo "::error::unhealthy probe did not fail healthcheck-fastly (outcome=$OUTCOME)"; exit 1; } - [[ "$HEALTHY" != "true" ]] \ - || { echo "::error::unhealthy probe still reported healthy=true"; exit 1; } - echo "healthcheck-fastly failed closed on an unhealthy probe" - - - name: Rollback the staged version (PUT /deactivate/staging) + run: .github/actions/deploy-core/tests/assert-unhealthy-failed.sh + + - name: Roll back the staged version uses: ./.github/actions/rollback-fastly with: cli-artifact: ${{ steps.cli.outputs.artifact-name }} @@ -292,7 +236,7 @@ jobs: fastly-api-token: dummy-token fastly-service-id: dummy-service - - name: Rollback production (PUT /activate on v-1) + - name: Roll back production id: prod-rollback uses: ./.github/actions/rollback-fastly with: @@ -305,15 +249,4 @@ jobs: - name: Assert rollback verbs, paths, and version threading env: ROLLED_BACK_TO: ${{ steps.prod-rollback.outputs['rolled-back-to'] }} - run: | - set -euo pipefail - log="$FAKE_CALL_LOG" - echo "--- recorded calls:"; cat "$log" - # Staging rollback: PUT .../deactivate/staging (NOT a plain /deactivate). - grep -qE '^PUT https://api\.fastly\.com/service/dummy-service/version/42/deactivate/staging$' "$log" \ - || { echo "::error::staging rollback did not PUT /deactivate/staging"; exit 1; } - # Production rollback activates the PREVIOUS version (42 -> 41), via PUT. - grep -qE '^PUT https://api\.fastly\.com/service/dummy-service/version/41/activate$' "$log" \ - || { echo "::error::production rollback did not PUT /version/41/activate"; exit 1; } - # Version threading out of the action. - [[ "$ROLLED_BACK_TO" == "41" ]] || { echo "::error::expected rolled-back-to=41, got '$ROLLED_BACK_TO'"; exit 1; } + run: .github/actions/deploy-core/tests/assert-rollback-calls.sh