From 09c7bf5c052d7d3addb256cc00afc9373179178a Mon Sep 17 00:00:00 2001 From: dhruv8sh Date: Fri, 25 Sep 2026 19:54:24 +0530 Subject: [PATCH 1/6] Add machine-readable CLI output spec Spec for #383: settles the open questions (shared OutputFormat enum, `text` default, stdout/stderr discipline, error envelopes, schema versioning, bundled stubs) and defines the per-command JSON schema. Also corrects the docs/.prettierignore comment: design docs are tracked in git. --- docs/.prettierignore | 2 +- .../2026-09-25-cli-format-json-design.md | 650 ++++++++++++++++++ 2 files changed, 651 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/specs/2026-09-25-cli-format-json-design.md diff --git a/docs/.prettierignore b/docs/.prettierignore index 2879ebbd6..d3426352c 100644 --- a/docs/.prettierignore +++ b/docs/.prettierignore @@ -7,5 +7,5 @@ node_modules # these handwritten documents (e.g. lines starting with `[[...]]` get # treated as link references), so leave them un-reformatted. They sit # under `docs/` only because the path is convenient for note-taking; -# they're gitignored and not part of the published site. +# they're tracked in git but excluded from the published site. superpowers diff --git a/docs/superpowers/specs/2026-09-25-cli-format-json-design.md b/docs/superpowers/specs/2026-09-25-cli-format-json-design.md new file mode 100644 index 000000000..100fece50 --- /dev/null +++ b/docs/superpowers/specs/2026-09-25-cli-format-json-design.md @@ -0,0 +1,650 @@ +# Machine-readable CLI output (`--format json`) — Design + +- **Issue:** [#383](https://github.com/stackpop/edgezero/issues/383) +- **Status:** Approved; implemented (revision 2 records what implementation changed, see §13) +- **Date:** 2026-09-25 +- **Delivery:** a single implementation PR, opened only after this spec is approved + +## 1. Summary + +Nine CLI commands gain a `--format ` flag. `text`, the default, keeps +today's output byte for byte. `json` writes exactly one versioned JSON envelope +to stdout and sends everything else to stderr. To make this possible, adapters +return typed outcomes to the CLI instead of printing their results as log +lines. The CLI then owns both renderings. + +**Commands in scope:** `active-version`, `auth status`, `build`, `config gc`, +`config validate`, `deploy`, `healthcheck`, `provision`, `rollback`. + +## 2. Goals and non-goals + +### Goals + +1. Scripts can gate on structured results without parsing log lines. +2. Every existing invocation behaves exactly as before: same bytes, same + streams, same exit codes. +3. Under `--format json`, no byte on stdout is anything other than the + envelope. This holds by construction and is enforced by lint and tests, + not by convention. +4. The JSON shape is a documented, versioned contract with an explicit + compatibility policy. + +### Non-goals + +- `--format` on `new`, `serve`, `demo`, `auth login` / `auth logout`, or + `config push`. +- Any change to `config diff` or to its existing JSON envelope (§6.6). +- Streaming or progress events (NDJSON). One command produces one document. +- Machine-readable error codes. v1 errors carry a message only, and codes can + be added later without breaking consumers (§6.5). +- Published JSON Schema files, or a `schemars` dependency. +- JSON for command-line usage errors, which clap reports before `--format` is + known (§6.4). +- Migrating the `.github/actions/*` scripts from `key=value` parsing to JSON. + They keep working unchanged, and moving them is optional follow-up work. +- An environment variable that selects the format. + +## 3. Current state + +These are the findings this design responds to. Line numbers are as of +`563fb65`. + +| Area | Today | +| --- | --- | +| Result data | The values a script wants (`version=`, `healthy=`, `status-code=`, `rolled-back-to=`) exist only as `log::info!` lines inside the Fastly adapter. `Adapter::execute` returns `Result<(), String>`. `provision` and `gc_config_entries` return prose `Vec` (`edgezero-adapter/src/registry.rs:291`, `:322`, `:398`). | +| Logger | `CliLogger` sends `Info` to **stdout** via `println!`, and `Warn`/`Error` to stderr (`edgezero-cli/src/lib.rs:96-118`). This is the only production `print_stdout` site outside `config diff`. | +| Child processes | `run_native_cli` (`edgezero-adapter/src/cli_support.rs:82`), the Fastly `run_fastly_status`, each adapter's `cargo` runner, and the CLI's `run_shell` all inherit stdout. `run_shell_tee` copies child stdout to our stdout (`edgezero-cli/src/adapter.rs:344-422`). | +| Text contract | CI actions parse `^version=[0-9]+$`, `healthy=` and `pushed-key=` from stdout (`.github/actions/*/scripts/*.sh`). | +| Error exit | The bundled binary logs `[edgezero] ` to stderr and exits 1. The downstream template does the same with its own prefix and exits 2. Both call the library's `run_*(&Args) -> Result<(), String>`. | +| Lints | The workspace denies clippy `restriction`, so `print_stdout` and `print_stderr` are already forbidden outside `#[expect]` sites. | +| Issue drift | #383 says `config gc` / `config validate` print through `eprintln!`. They actually use `log::info!`, which goes to stdout. `config validate` stops at the first error, so it has no list of findings to emit. | + +## 4. Decisions on the issue's open questions + +| # | Question | Decision | Section | +| --- | --- | --- | --- | +| 1 | Enum sharing | A new shared `OutputFormat { Text, Json }`. `DiffFormat` is left alone. | §5.1 | +| 2 | Name of the non-JSON value | `text`, used consistently across the CLI. `config diff` keeps `unified` as its only exception. | §5.1 | +| 3 | Stream discipline | Under `json`, stdout holds one envelope. Logs and child output go to stderr. Enforced by a scoped routing guard, a spawn lint and an end-to-end test. | §6.2, §7.4 | +| 4 | Error representation | Failures still emit the envelope, with `ok: false` and `error.message`. Exit codes are unchanged. | §6.3, §6.4 | +| 5 | Envelope stability | `schema_version: 1` is shared by every command. Additive changes don't bump it; breaking changes do. | §6.5 | +| 6 | Stub commands | No change. They exit 2 with empty stdout and the pointer on stderr. | §5.3 | + +## 5. Command-line interface + +### 5.1 The flag + +```rust +/// Output format for commands with a machine-readable mode. +#[derive(clap::ValueEnum, Clone, Copy, Debug, Default, PartialEq, Eq)] +#[non_exhaustive] +pub enum OutputFormat { + /// Human-readable output (the pre-existing behaviour). + #[default] + Text, + /// A single JSON envelope on stdout (see the cli-reference docs). + Json, +} +``` + +Each in-scope args struct gets: + +```rust +/// Output format: `text` (default) or `json`. +#[arg(long, value_enum, default_value_t = OutputFormat::Text)] +pub format: OutputFormat, +``` + +The structs are `ActiveVersionArgs`, `BuildArgs`, `ConfigGcArgs`, +`ConfigValidateArgs`, `DeployArgs`, `HealthcheckArgs`, `ProvisionArgs`, +`RollbackArgs`, and the `AuthSub::Status` variant only. + +`DiffFormat` is **not** generalized or reused. Its `structured` and `unified` +values only make sense for a diff, and changing it would change how +`config diff` is spelled. A separate enum means no command advertises a value +it cannot honour. + +### 5.2 Parsing constraints + +- `--format=json` and `--format json` are both accepted, as clap handles either. +- `build` collects passthrough arguments with `trailing_var_arg`, so + `--format` has to come before the first passthrough token. After that point + it is forwarded to the adapter. This is documented and covered by a parse + test. +- `deploy` passes arguments through only after `--`, so `--format` can go + anywhere before `--`. + +### 5.3 Bundled-binary stubs + +`config push` and `config diff` in the bundled `edgezero` binary stay as they +are: + +- They absorb every token, including `--format json`. +- They print the pointer text to stderr, write nothing to stdout, and exit 2. + +They point elsewhere rather than being in-scope commands, and emitting JSON +would mean interpreting tokens they deliberately never parse. The +documentation tells scripts to read "exit 2 with empty stdout" as "not +available in this binary". + +## 6. Output contract + +### 6.1 The envelope + +Every in-scope command run with `--format json` writes exactly this shape, +whether it succeeds or fails: + +```json +{ + "schema_version": 1, + "command": "healthcheck", + "ok": true, + "result": { "...": "command-specific, see §8" }, + "error": null +} +``` + +| Key | Type | Rule | +| --- | --- | --- | +| `schema_version` | integer | `1` for this spec. See §6.5. | +| `command` | string | The command path as the user types it: `"active-version"`, `"auth status"`, `"build"`, `"config gc"`, `"config validate"`, `"deploy"`, `"healthcheck"`, `"provision"`, `"rollback"`. | +| `ok` | boolean | `true` exactly when the process will exit 0. | +| `result` | object or `null` | Never `null` when `ok` is `true`. When `ok` is `false` it holds the **partial result** if the command produced one (an unhealthy healthcheck, a partially failed gc, an unauthenticated `auth status`, `active-version --require-active` with nothing active), and is `null` otherwise. | +| `error` | object or `null` | `null` exactly when `ok` is `true`. Otherwise `{ "message": string }`. | + +**Field conventions**, applied to the envelope and every result: + +- Keys are `snake_case`. +- All five envelope keys and every documented result key are always present. + An unknown value is `null`, never an omitted key. An empty collection is + `[]`, never `null`. +- Integers are JSON numbers. Durations are integer seconds, and their keys end + in `_secs`. +- Filesystem paths are strings, lossily converted from the OS encoding. +- String-valued enums are lowercase `snake_case`. +- The order of keys is not part of the contract. + +### 6.2 Streams + +| | `--format text` (default) | `--format json` | +| --- | --- | --- | +| stdout | Unchanged: info logs, `key=value` lines, and inherited child stdout | **Exactly one envelope**: pretty-printed (`serde_json::to_string_pretty`, matching `config diff`), ending with `\n`, written once when the command finishes | +| stderr | Unchanged: warnings and errors | Everything `text` mode prints (every log level, including the `key=value` lines), all child-process stdout and stderr, and the binary's final `[edgezero] ` line | + +Rules: + +- Nothing is silenced. Each command prints its human-readable output through + the logger in both formats; under JSON it moves to stderr, so CI logs keep + showing progress and the text output needs no second renderer. +- The envelope is written only by `edgezero_cli::output`, and only once per + run. +- An empty stdout with a non-zero exit means no envelope was produced. That + happens on usage errors, stub commands and panics, and consumers must treat + it as failure. + +### 6.3 Where the envelope is written + +The envelope is written **inside the library's `run_*` functions**, not in +`main`. Their public signatures stay `pub fn run_x(&XArgs) -> Result<(), String>`. + +- On failure, a JSON-mode `run_*` writes the failure envelope to stdout and + then returns `Err(message)` as it does today. +- Each binary's `main` then logs the error to stderr and exits with its usual + code. + +This way, downstream CLIs generated from the template before this change get +`--format json`, including failure envelopes, without regenerating `main.rs`. + +### 6.4 Exit codes + +Exit codes do not change. + +| Situation | Exit | stdout under `json` | +| --- | --- | --- | +| Success | 0 | envelope, `ok: true` | +| Command failure | 1 (bundled binary) / 2 (template-generated CLIs) | envelope, `ok: false` | +| Usage error (reported by clap) | 2 | empty. clap parses before the format is known, and its message goes to stderr as today | +| Bundled stub (`config push` / `config diff`) | 2 | empty | + +Consumers should branch on `ok`, not on the specific non-zero code, because +failure codes differ between binaries. + +### 6.5 Versioning and compatibility + +- One `schema_version` covers the whole JSON surface of the CLI (the envelope + and every result), so there is one number to pin. It starts at `1`. +- **Compatible changes (no bump):** adding a key to `result` or `error`; adding + `error.code`; turning a nullable field into one that is always non-null; + adding a value to a string enum. +- **Breaking changes (bump):** removing or renaming a key; changing a key's + type or meaning; making a non-null field nullable; changing the envelope's + meaning (`ok` semantics, when `result` is `null`). +- **What consumers must do:** ignore unknown keys; handle unknown enum values; + check `schema_version`. +- A bump is recorded in the CLI reference changelog together with the PR that + makes it. + +### 6.6 `config diff` is excluded + +`config diff --format json` keeps its existing unversioned shape +`{ local_sha256, remote_sha256, added, removed, changed }`, including its +current empty-stdout outcomes. Moving it into the envelope would break its +consumers, and #383 requires it to stay "exactly as it does today". The docs +note it as the one legacy shape. Bringing it into the envelope, under a +`schema_version` bump, is left to a separate issue. + +## 7. Architecture + +### 7.1 Overview + +``` + args (format) ──► run_x ──► OutputScope::enter(format) (json: logs + child stdout → stderr) + │ + ├──► adapter / CLI logic ──► Outcome = Result> + │ (logs today's human-readable lines as it goes) + │ + └──► output::finish(command, format, outcome) + └── json only: envelope → stdout +``` + +There are three layers, each with one job: + +1. **`edgezero-adapter`: domain outcomes.** Plain Rust types that describe + what happened. No `serde`, and no new dependencies. +2. **The wire schema** (`edgezero-cli/src/output.rs`). `serde::Serialize` + structs that match §6.1 and §8 exactly, converted from the domain outcomes. + They are the only types that define the JSON contract, so an internal + refactor of an adapter type cannot change the public schema by accident. +3. **Rendering and streams** (also `output.rs`). `OutputScope`, `Failure`, the + envelope, and `finish`. + +**Text output is not re-rendered.** Every command keeps logging its +human-readable lines through the logger exactly as before, in both formats. +`text` mode is therefore byte-identical by construction, and `json` mode is +the same lines on stderr plus the envelope on stdout. + +### 7.2 Adapter trait changes + +The crates are `publish = false`, and every `Adapter` implementation is in +this workspace (fastly, cloudflare, spin, axum, plus two test adapters), so +changing the trait breaks no external implementor. + +```rust +// edgezero-adapter/src/registry.rs (no serde) + +#[non_exhaustive] +pub enum ActionOutcome { + ActiveVersion(ActiveVersionOutcome), // { service_id, version: Option } + AuthStatus(AuthStatusOutcome), // { failure: Option, state: AuthState } + Build(BuildOutcome), // { artifact: Option } + Deploy(DeployOutcome), // { version: Option } + Empty, // login, logout, serve, manifest command overrides + Healthcheck(HealthcheckOutcome), // { attempts, domain, failure, healthy, path, service_id, + // staging, staging_ip, status_code, version, version_verified } + Rollback(RollbackOutcome), // { rolled_back_to, service_id, staging, version } +} + +pub enum AuthState { Authenticated, NotApplicable, Unauthenticated } + +fn execute(&self, action: AdapterAction, args: &[String]) -> Result; +fn provision(/* unchanged params */) -> Result; +fn gc_config_entries(/* unchanged params */) -> Result; +``` + +The outcome structs have public fields and no `#[non_exhaustive]`, so adapter +crates can build them. `AuthState`, `ProvisionAction` and `StoreKind` are +deliberately **exhaustive**: the CLI maps each variant onto the wire schema, so +adding a variant fails to compile until the schema covers it. + +**Outcomes versus errors.** A negative result that the command still finished +measuring is an `Ok` outcome carrying a `failure` message, not an `Err`: + +- an unhealthy healthcheck is `healthy: false`; +- a non-zero exit from `auth status` is `Unauthenticated` + (`cli_support::native_auth_status`, or a manifest `auth-status` command); +- a gc run with failed deletes has a non-empty `failed` list and a + `failure_diagnostic` holding exactly the error text it returned before. + +The CLI turns these into a non-zero exit, using the same message as before. +`Err(String)` is kept for "could not determine a result": bad arguments, API +failures, a missing binary. This is what lets the failure envelope carry the +partial `result` described in §6.1. + +```rust +pub struct ProvisionReport { pub entries: Vec } // + into_messages() +pub struct ProvisionEntry { + pub action: ProvisionAction, // AlreadyPresent | Created | NotApplicable | Note + // | Updated | WouldCreate | WouldUpdate + pub message: String, // the exact line text mode prints + pub store: Option, // { kind: StoreKind, logical: Option, platform } +} + +pub struct GcReport { + pub deleted: Option, // None on a dry run + pub entries: usize, + pub failed: Vec, + pub failure_diagnostic: Option, + pub generations_planned: usize, + pub kept_roots: Vec, + pub planned: Vec, // { age_secs, key } + pub referenced_chunks: usize, + pub retained_recent: usize, + pub roots: usize, + pub store_id: Option, + pub stranded: Vec, + pub text_lines: Vec, // text-mode lines only; not on the wire + pub uncertain: Vec, + pub unprovable: usize, + pub warnings: Vec, +} +``` + +**Data lines move from the Fastly adapter to the CLI.** The adapter used to +print `version=`, `status-code=`, `healthy=` and `rolled-back-to=`, plus the +prose line `active-version` prints after an empty `version=`. It now returns +them in its outcome, and the CLI logs the same bytes through the same +`log::info!` channel. Prose-only lines, such as the staged-rollback message +and the "no FASTLY_API_TOKEN" note, stay in the adapter. Under JSON they move +to stderr like any other log line. + +### 7.3 CLI flow (`run_*`) + +```rust +pub fn run_healthcheck(args: &HealthcheckArgs) -> Result<(), String> { + let _scope = OutputScope::enter(args.format); + output::finish(CommandName::Healthcheck, args.format, healthcheck(args)) +} + +fn healthcheck(args: &HealthcheckArgs) -> Outcome { … } +``` + +- `Outcome` is `Result>`. `Failure` carries the message and an + optional boxed partial result, and `From` lets `?` keep working on + existing `String` errors. +- `finish` writes the envelope to stdout only under `json`, then returns + `Err(message)` on failure so each binary's `main` exits as before. + +### 7.4 Stream routing and enforcement + +`OutputScope::enter(OutputFormat::Json)` does two things and undoes both when +dropped (an RAII guard). A `text` scope changes nothing, so text-mode tests +running in parallel never touch the global state. + +1. **Logger.** `CliLogger` reads an `INFO_TO_STDERR: AtomicBool`, and while it + is set `Info` is written with `eprintln!`. This has to be process-wide state, + because the `log` facade has a single global logger. +2. **Child stdout.** A new, always-compiled module `edgezero_adapter::process` + holds a process-wide policy (`set_child_stdout_to_stderr`) and the one + sanctioned inheriting spawn, `process::status(&mut Command)`. Under the + policy it sets `.stdout(Stdio::from(io::stderr()))` before waiting for the + child. It is not in `cli_support`, because `edgezero-cli` depends on + `edgezero-adapter` without the `cli` feature. + - It replaces every `Command::status()` call: `run_native_cli`, Fastly + `run_fastly_status`, each adapter's `cargo`, `fastly`, `wrangler` and + `spin` runners, the CLI's `run_shell`, and `git init` in `new`. + - `run_shell_tee` reads the same policy, so its tee target switches to + stderr. + - Calls that capture output with `.output()` or piped stdio already keep + stdout clean and are unchanged. + +**Why a scoped policy and not a parameter.** Adding a parameter to every +spawn helper would change about 15 Fastly helpers plus the runners in every +adapter. It still wouldn't stop a future helper from calling +`Command::status()` directly and leaking. The property that matters is that +every child is spawned through one choke point, and that is enforced +mechanically: + +- **Lint.** `clippy.toml` has `disallowed-methods` for + `std::process::Command::status` and `std::process::Command::spawn`. The + exceptions are documented `#[expect(clippy::disallowed_methods)]` sites: + `process::status` itself, and the three spawns whose stdio is fully piped + (Fastly's config-entry writer, Fastly's curl API client, and + `run_shell_tee`). The ignored `generated_project_builds` test drives `cargo` + directly and carries the same `#[expect]`. +- **Existing lint.** `print_stdout` is already denied workspace-wide. The + envelope is written through `io::stdout()` in exactly one function, + `output::finish`. +- **End-to-end tests.** See §9.3. + +## 8. Per-command result schemas + +Every command's `result` includes `adapter` (string), except +`config validate`, which is adapter-independent. `ok` follows §6.1. + +### `active-version` + +```json +{ "adapter": "fastly", "service_id": "SU1Z0isxPaozGVKXdv0eY", "version": 42 } +``` + +- `version` is `null` when no version is active. +- With `--require-active` and no active version: `ok: false`, and `result` is + present with `version: null`. + +### `auth status` + +```json +{ "adapter": "cloudflare", "state": "authenticated" } +``` + +- `state` is one of `authenticated`, `unauthenticated` or `not_applicable`. + axum reports `not_applicable` with `ok: true`. +- `unauthenticated` means `ok: false` with `result` present. This keeps + today's non-zero exit. +- A native CLI that is missing or fails to spawn gives `ok: false` with + `result: null`. +- `state` comes from the native CLI's exit status, or from a manifest + `commands.auth_status` override. Account details are not parsed. + +### `build` + +```json +{ "adapter": "fastly", "artifact": "/abs/path/target/wasm32-wasip1/release/app.wasm" } +``` + +`artifact` is `null` when a manifest `commands.build` override ran or the +adapter cannot know the path. + +### `deploy` + +```json +{ "adapter": "fastly", "staging": false, "service_id": "SU1Z0isxPaozGVKXdv0eY", "version": 43 } +``` + +- For a staged deploy, `version` is the staged version. +- For a production Fastly deploy with a service id, `version` is the active + version after the deploy, found the same way as today (captured `version=` + output, then falling back to the API). +- Otherwise `version` is `null`. `service_id` is `null` when none was given. + +### `healthcheck` + +```json +{ + "adapter": "fastly", "service_id": "SU1Z0isxPaozGVKXdv0eY", "domain": "app.example.com", + "path": "/", "version": 43, "staging": true, "staging_ip": "151.101.2.10", + "healthy": true, "status_code": 200, "attempts": 1, "version_verified": false +} +``` + +- `attempts` is the number of probes actually made. +- `version_verified` is `true` when the check that the version is active ran + both before and after the probe (production with an API token). +- Unhealthy means `ok: false` with `result` present, `healthy: false`, and + `error.message` set to today's failure message. +- A failure before the probe (invalid arguments, version not active, staging + IP lookup) gives `result: null`. + +### `rollback` + +```json +{ "adapter": "fastly", "service_id": "SU1Z0isxPaozGVKXdv0eY", "staging": false, "version": 43, "rolled_back_to": 42 } +``` + +- `version` is the value of `--version`: the version rolled back from, or the + staged version that was deactivated. +- `rolled_back_to` is `null` for a staged rollback. + +### `provision` + +```json +{ + "adapter": "fastly", "dry_run": false, + "entries": [ + { "action": "created", + "store": { "kind": "kv", "logical": "sessions", "platform": "sessions" }, + "message": "created fastly kv-store `sessions` (logical id `sessions`); appended setup tables to fastly.toml" } + ] +} +``` + +- `action` is one of `created`, `already_present`, `would_create`, `updated`, + `would_update`, `not_applicable` or `note`. +- `store` is `null` for adapter-level notes. `store.logical` is `null` for + stores EdgeZero owns rather than the manifest, such as Fastly's + `edgezero_runtime_env`. +- `message` is the exact line text mode prints. It may span several lines, and + it is meant for people, not parsing. + +### `config gc` + +```json +{ + "adapter": "fastly", "dry_run": true, "older_than_secs": null, + "store": { "logical": "app_config", "platform": "app_config", "id": "7Ab…" }, + "summary": { "entries": 40, "roots": 3, "referenced_chunks": 12, + "orphans_planned": 6, "generations_planned": 2, + "orphans_too_recent": 1, "unprovable": 0 }, + "kept_roots": ["app_config"], + "planned_deletions": [ { "key": "app_config.__chunk.…", "age_secs": 90211 } ], + "deleted": null, "failed": [], "stranded": [], "uncertain": [], "warnings": [] +} +``` + +- `older_than_secs` is `null` when `--older-than` was not given. +- `deleted` is `null` on a dry run. +- If any delete failed: `ok: false`, `result` present, and `error.message` set + to today's diagnostic, including the recovery commands. +- The dry-run advisory text goes to stderr and is not part of `result`. + +### `config validate` + +```json +{ "mode": "typed", "manifest": "edgezero.toml", "app_config": "app-demo.toml", "app_name": "app-demo", "strict": false } +``` + +- `mode` is `raw` in the bundled binary and `typed` in template-generated + CLIs. +- `app_config` is `null` in `raw` mode. +- A validation failure gives `ok: false` and `result: null`, with the first + failing check in `error.message`. Validation stops at the first failure + today. +- A later, additive change may add a `findings` array. + +## 9. Testing + +The rule is that JSON tests parse the output and assert on its structure, +never on string matches (#383), and text tests assert exact bytes. + +1. **Wire and envelope unit tests** (`edgezero-cli/src/output.rs`). Each result + type is serialized to `serde_json::Value` and compared whole. Envelopes are + checked against the §6.1 invariants: all five keys present, `ok` matches + whether `error` is `null`, and a success carries a result. The partial result + survives a failure, command names match their spelling, and `OutputScope` + routes only while it is alive. +2. **Text is byte-identical to `main`.** Because text output is not + re-rendered (§7.1), this is verified by running the branch and `main` + binaries through the same 21 hermetic scenarios and diffing stdout, stderr + and exit code. The scenarios cover success and failure paths for build, + auth, provision, validate, deploy, healthcheck (fake `curl`), active-version, + rollback, gc, a usage error, and the stub. One case is pinned permanently as + a test: `healthcheck_text_bytes_match_the_line_contract` asserts the exact + healthcheck bytes the CI action parses. +3. **End-to-end stream tests** (`edgezero-cli/tests/format_json.rs`). These run + `env!("CARGO_BIN_EXE_edgezero")` in a temp project and need no network or + credentials. Each asserts that stdout parses as exactly one JSON value: + - `build --format json`, where a manifest command writes to stdout: its + output appears only on stderr; + - `auth status --format json` with a failing manifest probe: exit 1, and the + envelope carries the `unauthenticated` partial result; + - `provision` and `config validate`, with the parsed structure asserted; + - `healthcheck --format json` against a fake `curl` answering 503: exit 1, + the full partial result is present, and the line contract is on stderr; + - text-mode `build` output is unchanged, a usage error leaves stdout empty, + and the bundled stub still absorbs `--format json`. +4. **Argument parsing** (`args.rs`). Every in-scope command defaults to `Text` + and accepts `--format json` and `--format=text`. `unified` and `structured` + are rejected. `build --adapter fastly --format json --release` parses the + format and forwards `--release`. +5. **Adapter tests.** A Fastly healthcheck test against a fake `curl` asserts + the whole `HealthcheckOutcome` for both healthy and unhealthy probes. A + Cloudflare provision test asserts each entry's `action` and store. The + existing provision tests keep their text assertions through + `ProvisionReport::into_messages()`. The existing gc tests run unchanged, + because their harness applies the same failure-diagnostic-to-`Err` mapping + the CLI uses. `native_auth_status` and `process::status` have unit tests. +6. **Lint gate.** `cargo clippy --workspace --all-targets --all-features -- -D warnings` + passes with the new `disallowed-methods` entries, and so does every clippy + variant in `format.yml`, including the wasm matrix. + +## 10. Documentation + +In `docs/guide/cli-reference.md`: + +- Add `--format ` to the flag list of each in-scope command. +- Add a new section, "Machine-readable output". It covers: + - the envelope (§6.1) + - streams (§6.2) + - exit codes, including "empty stdout means no envelope" (§6.4) + - the compatibility policy (§6.5) + - one schema table per command (§8) + - the `config diff` exception (§6.6) + - a `jq` example such as `edgezero healthcheck … --format json | jq -e '.ok'` + - a schema changelog starting at version 1 +- Fix the `config gc` / `config validate` descriptions if they say output goes + to stderr. + +## 11. Alternatives considered + +| Alternative | Why not | +| --- | --- | +| A `--json` boolean flag | Ruled out by #383, and it can't grow into other formats. | +| Reusing or extending `DiffFormat` | It would advertise `structured` and `unified` on commands that can't honour them, or change `config diff`'s spelling. | +| A report sink passed to adapters (`report.set("version", 5)`) | Less adapter churn, but the schema ends up loosely typed and spread across four crates, so it can't be versioned or reviewed as one contract. | +| Parsing today's `key=value` log lines in JSON mode | Provision and gc output is prose, the log is global and can't be captured per call, and the contract would stay implicit. | +| `Serialize` on the adapter domain types | It adds `serde` to `edgezero-adapter` and ties the public schema to internal type layout. | +| Writing the envelope in `main` | Existing downstream CLIs would never get failure envelopes without regenerating `main.rs`. | +| An explicit child-stdout parameter on every spawn helper | Large churn, and it doesn't stop a new direct `Command::status()`. The lint plus a single spawning API does. | +| Silencing `log::info!` under JSON | CI logs would lose progress and remediation hints. | +| NDJSON event stream | Harder to gate on, and #383 asks for results. It could be added later as another `OutputFormat` value. | +| A schema version per command | More numbers to track for little benefit. The whole surface is released together. | + +## 12. Risks + +| Risk | Mitigation | +| --- | --- | +| Text output drifts, breaking CI actions | Text output is not re-rendered (§7.1). A 21-scenario byte diff against `main`, plus a permanent healthcheck byte test (§9.2). | +| A missed stdout writer corrupts JSON | The `print_stdout` deny, the `disallowed-methods` spawn lint, and the end-to-end test (§9.3). | +| JSON numbers above 2^53 lose precision in JavaScript consumers | Fastly service versions are small integers. u64 values are documented as JSON numbers, and none of today's fields comes close. | +| A native CLI reads from or checks stdout as a TTY and behaves differently when stdout is redirected to stderr | Only under `--format json`, which is new and opt-in. Interactive `auth login` is out of scope. | +| One large PR | Changes are grouped by crate, with the trait change mechanical across four adapters. The plan orders the commits so that each one compiles and passes its tests. | + +## 13. Revision notes + +Revision 2 records what changed during implementation. None of it changes the +contract in §5, §6 or §8. + +- **The spawn policy lives in `edgezero_adapter::process`, not `cli_support`.** + `cli_support` is gated behind the adapter crate's `cli` feature, which + `edgezero-cli` does not enable (§7.4). +- **There is no separate text renderer.** Commands keep logging their existing + lines in both formats, and JSON mode redirects the logger (§7.1). This removes + a second rendering path that could drift. +- **Only data lines moved to the CLI** (§7.2). Prose lines, such as the + staged-rollback message, stay in the adapter. +- **`AuthStatus` carries a `failure` message**, so an unauthenticated status + keeps its exact error text. +- **The enums mapped to the wire are exhaustive**, not `#[non_exhaustive]` + (§7.2). +- **Wire types live in a single `output.rs`**, not an `output::wire` submodule. + From c74d707aae061c705e9424d57be6e7334e1634c3 Mon Sep 17 00:00:00 2001 From: dhruv8sh Date: Fri, 25 Sep 2026 19:54:24 +0530 Subject: [PATCH 2/6] Add --format json to CLI commands active-version, auth status, build, config gc, config validate, deploy, healthcheck, provision and rollback accept --format text|json. JSON mode writes one versioned envelope to stdout and routes logs and child-process stdout to stderr; text mode is byte-identical to before. Adapters now return typed outcomes (ActionOutcome, ProvisionReport, GcReport) instead of printing their results, and every inheriting child spawn goes through edgezero_adapter::process::status, enforced by a clippy disallowed-methods lint. Closes #383 --- clippy.toml | 9 + crates/edgezero-adapter-axum/src/cli.rs | 65 +- crates/edgezero-adapter-cloudflare/src/cli.rs | 182 ++-- crates/edgezero-adapter-fastly/src/cli.rs | 616 +++++++++---- crates/edgezero-adapter-spin/src/cli.rs | 139 +-- crates/edgezero-adapter/src/cli_support.rs | 60 +- crates/edgezero-adapter/src/lib.rs | 2 + crates/edgezero-adapter/src/process.rs | 81 ++ crates/edgezero-adapter/src/registry.rs | 258 +++++- crates/edgezero-cli/src/adapter.rs | 54 +- crates/edgezero-cli/src/args.rs | 172 +++- crates/edgezero-cli/src/auth.rs | 33 +- crates/edgezero-cli/src/config.rs | 122 ++- crates/edgezero-cli/src/generator.rs | 13 +- crates/edgezero-cli/src/lib.rs | 262 +++++- crates/edgezero-cli/src/output.rs | 809 ++++++++++++++++++ crates/edgezero-cli/src/provision.rs | 28 +- crates/edgezero-cli/tests/format_json.rs | 298 +++++++ .../tests/generated_project_builds.rs | 4 + docs/guide/cli-reference.md | 173 +++- 20 files changed, 2939 insertions(+), 441 deletions(-) create mode 100644 crates/edgezero-adapter/src/process.rs create mode 100644 crates/edgezero-cli/src/output.rs create mode 100644 crates/edgezero-cli/tests/format_json.rs diff --git a/clippy.toml b/clippy.toml index 0b4d3d8cc..4ccdf43c4 100644 --- a/clippy.toml +++ b/clippy.toml @@ -7,3 +7,12 @@ allow-expect-in-tests = true allow-indexing-slicing-in-tests = true allow-panic-in-tests = true allow-unwrap-in-tests = true + +# `--format json` reserves stdout for the JSON envelope, so every child that +# inherits stdout must go through `edgezero_adapter::process::status`, which +# applies the stdout-to-stderr policy. Captured spawns (`.output()`, or +# `.spawn()` with piped stdout) carry a documented `#[expect]`. +disallowed-methods = [ + { path = "std::process::Command::status", reason = "use `edgezero_adapter::process::status` so the `--format json` stdout policy applies" }, + { path = "std::process::Command::spawn", reason = "inherits stdout by default; pipe stdout and `#[expect]` with a reason, or use `edgezero_adapter::process::status`" }, +] diff --git a/crates/edgezero-adapter-axum/src/cli.rs b/crates/edgezero-adapter-axum/src/cli.rs index 9d2a237ee..9dc28a39d 100644 --- a/crates/edgezero-adapter-axum/src/cli.rs +++ b/crates/edgezero-adapter-axum/src/cli.rs @@ -10,9 +10,11 @@ use ctor::ctor; use edgezero_adapter::cli_support::{ find_manifest_upwards, find_workspace_root, path_distance, read_package_name, }; +use edgezero_adapter::process; use edgezero_adapter::registry::{ - Adapter, AdapterAction, AdapterPushContext, ProvisionStores, ReadConfigEntry, ResolvedStoreId, - register_adapter, + ActionOutcome, Adapter, AdapterAction, AdapterPushContext, AuthState, AuthStatusOutcome, + BuildOutcome, ProvisionAction, ProvisionEntry, ProvisionReport, ProvisionStores, + ReadConfigEntry, ResolvedStoreId, StoreKind, register_adapter, }; use edgezero_adapter::scaffold::{ AdapterBlueprint, AdapterFileSpec, CommandTemplates, DependencySpec, LoggingDefaults, @@ -133,7 +135,7 @@ struct EdgezeroAxumConfig { reason = "axum has no validate_app_config_keys / validate_adapter_manifest / validate_typed_secrets requirements; those three trait defaults are intentionally inherited. `read_config_entry` delegates to `read_config_entry_local` (axum is local-only). `single_store_kinds` IS overridden below (returns `&[\"secrets\"]`)." )] impl Adapter for AxumCliAdapter { - fn execute(&self, action: AdapterAction, args: &[String]) -> Result<(), String> { + fn execute(&self, action: AdapterAction, args: &[String]) -> Result { match action { // The axum adapter is the in-process native dev server — // there is no remote auth provider to sign in/out of. @@ -142,11 +144,22 @@ impl Adapter for AxumCliAdapter { log::info!( "[edgezero] axum has no remote auth surface; `auth` is a no-op for this adapter" ); - Ok(()) + Ok(if action == AdapterAction::AuthStatus { + ActionOutcome::AuthStatus(AuthStatusOutcome { + failure: None, + state: AuthState::NotApplicable, + }) + } else { + ActionOutcome::Empty + }) } - AdapterAction::Build => build(args), - AdapterAction::Deploy => deploy(args), - AdapterAction::Serve => serve(args), + // `cargo build` picks the artifact path itself; axum does not + // track it, so the outcome reports none. + AdapterAction::Build => { + build(args).map(|()| ActionOutcome::Build(BuildOutcome { artifact: None })) + } + AdapterAction::Deploy => deploy(args).map(|()| ActionOutcome::Empty), + AdapterAction::Serve => serve(args).map(|()| ActionOutcome::Empty), // The Fastly staging lifecycle is Fastly-only. AdapterAction::DeployStaging | AdapterAction::EmitVersion @@ -169,12 +182,12 @@ impl Adapter for AxumCliAdapter { _component_selector: Option<&str>, stores: &ProvisionStores<'_>, _dry_run: bool, - ) -> Result, String> { + ) -> Result { //: axum has no remote resources. Print one note per // declared store id so the operator sees the CLI heard // them — same shape `dry_run` would have, since there is // nothing to actually perform. - let mut out = Vec::with_capacity( + let mut entries = Vec::with_capacity( stores .kv .len() @@ -183,8 +196,11 @@ impl Adapter for AxumCliAdapter { ); for store in stores.kv { let logical = store.logical.as_str(); - out.push(format!( - "axum KV store `{logical}` is in-memory; nothing to provision" + entries.push(ProvisionEntry::for_store( + ProvisionAction::NotApplicable, + StoreKind::Kv, + store, + format!("axum KV store `{logical}` is in-memory; nothing to provision"), )); } for store in stores.config { @@ -193,20 +209,30 @@ impl Adapter for AxumCliAdapter { // overlay isn't used for local file paths because the // path encoding is the canonical form. let logical = store.logical.as_str(); - out.push(format!( - "axum config store `{logical}` reads `.edgezero/local-config-{logical}.json`; nothing to provision" + entries.push(ProvisionEntry::for_store( + ProvisionAction::NotApplicable, + StoreKind::Config, + store, + format!( + "axum config store `{logical}` reads `.edgezero/local-config-{logical}.json`; nothing to provision" + ), )); } for store in stores.secrets { let logical = store.logical.as_str(); - out.push(format!( - "axum secret store `{logical}` reads env vars; nothing to provision" + entries.push(ProvisionEntry::for_store( + ProvisionAction::NotApplicable, + StoreKind::Secrets, + store, + format!("axum secret store `{logical}` reads env vars; nothing to provision"), )); } - if out.is_empty() { - out.push("axum has no declared stores to provision".to_owned()); + if entries.is_empty() { + entries.push(ProvisionEntry::note( + "axum has no declared stores to provision".to_owned(), + )); } - Ok(out) + Ok(ProvisionReport { entries }) } fn push_config_entries( @@ -418,8 +444,7 @@ fn run_cargo(project: &AxumProject, subcommand: &str, extra_args: &[String]) -> // no-op for the child process. command.env("EDGEZERO__ADAPTER__HOST", bind_addr.ip().to_string()); command.env("EDGEZERO__ADAPTER__PORT", bind_addr.port().to_string()); - let status = command - .status() + let status = process::status(&mut command) .map_err(|err| format!("failed to run cargo {subcommand}: {err}"))?; if status.success() { Ok(()) diff --git a/crates/edgezero-adapter-cloudflare/src/cli.rs b/crates/edgezero-adapter-cloudflare/src/cli.rs index f60893b1b..ccebe583b 100644 --- a/crates/edgezero-adapter-cloudflare/src/cli.rs +++ b/crates/edgezero-adapter-cloudflare/src/cli.rs @@ -7,10 +7,13 @@ use std::process::Command; use ctor::ctor; use edgezero_adapter::cli_support::{ - find_manifest_upwards, find_workspace_root, path_distance, read_package_name, run_native_cli, + find_manifest_upwards, find_workspace_root, native_auth_status, path_distance, + read_package_name, run_native_cli, }; +use edgezero_adapter::process; use edgezero_adapter::registry::{ - Adapter, AdapterAction, AdapterPushContext, ProvisionStores, ReadConfigEntry, ResolvedStoreId, + ActionOutcome, Adapter, AdapterAction, AdapterPushContext, BuildOutcome, ProvisionAction, + ProvisionEntry, ProvisionReport, ProvisionStores, ReadConfigEntry, ResolvedStoreId, StoreKind, register_adapter, }; use edgezero_adapter::scaffold::{ @@ -134,28 +137,34 @@ struct CloudflareCliAdapter; reason = "cloudflare has no validate_app_config_keys / validate_adapter_manifest / validate_typed_secrets requirements; those three trait defaults are intentionally inherited. `read_config_entry` and `read_config_entry_local` are both overridden below (wrangler kv key get --remote / --local). `single_store_kinds` IS overridden below (returns `&[\"secrets\"]`)." )] impl Adapter for CloudflareCliAdapter { - fn execute(&self, action: AdapterAction, args: &[String]) -> Result<(), String> { + fn execute(&self, action: AdapterAction, args: &[String]) -> Result { match action { // `wrangler` is the native sign-in surface for Cloudflare // Workers. EdgeZero stores no credentials — this is a thin // shell-out. AdapterAction::AuthLogin => { run_native_cli("wrangler", &["login"], WRANGLER_INSTALL_HINT) + .map(|()| ActionOutcome::Empty) } AdapterAction::AuthLogout => { run_native_cli("wrangler", &["logout"], WRANGLER_INSTALL_HINT) + .map(|()| ActionOutcome::Empty) } AdapterAction::AuthStatus => { - run_native_cli("wrangler", &["whoami"], WRANGLER_INSTALL_HINT) + native_auth_status("wrangler", &["whoami"], WRANGLER_INSTALL_HINT) + .map(ActionOutcome::AuthStatus) } AdapterAction::Build => build(args).map(|artifact| { log::info!( "[edgezero] Cloudflare build artifact -> {}", artifact.display() ); + ActionOutcome::Build(BuildOutcome { + artifact: Some(artifact), + }) }), - AdapterAction::Deploy => deploy(args), - AdapterAction::Serve => serve(args), + AdapterAction::Deploy => deploy(args).map(|()| ActionOutcome::Empty), + AdapterAction::Serve => serve(args).map(|()| ActionOutcome::Empty), // The Fastly staging lifecycle is Fastly-only. AdapterAction::DeployStaging | AdapterAction::EmitVersion @@ -196,7 +205,7 @@ impl Adapter for CloudflareCliAdapter { _component_selector: Option<&str>, stores: &ProvisionStores<'_>, dry_run: bool, - ) -> Result, String> { + ) -> Result { //: KV ids and config ids both back to Cloudflare KV // namespaces. Secrets are runtime-managed via // `wrangler secret put` — provision is a no-op for them. @@ -208,8 +217,13 @@ impl Adapter for CloudflareCliAdapter { }; let wrangler_path = manifest_root.join(rel); - let mut out = Vec::new(); - for store in stores.kv.iter().chain(stores.config.iter()) { + let mut entries = Vec::new(); + for (kind, store) in stores + .kv + .iter() + .map(|store| (StoreKind::Kv, store)) + .chain(stores.config.iter().map(|store| (StoreKind::Config, store))) + { let logical = &store.logical; // The Cloudflare KV binding name is what the runtime // calls `env.kv(...)` with -- it's resolved at request @@ -243,10 +257,10 @@ impl Adapter for CloudflareCliAdapter { // entry by hand before re-running provision. let existing = existing_real_namespace_id(&wrangler_path, binding)?; if let Some(existing_id) = existing { - out.push(format!( + entries.push(ProvisionEntry::for_store(ProvisionAction::AlreadyPresent, kind, store, format!( "binding `{binding}` (logical id `{logical}`) already provisioned (id={existing_id} in {}); skipping. To force a fresh namespace: delete the [[kv_namespaces]] entry for binding `{binding}` AND run `wrangler kv namespace delete --namespace-id={existing_id}` (the old remote namespace lingers otherwise), then re-run provision.", wrangler_path.display() - )); + ))); continue; } // Pre-flight the writeback shape BEFORE shelling @@ -266,30 +280,32 @@ impl Adapter for CloudflareCliAdapter { // BEFORE any account-side mutation. check_kv_namespaces_writeback_shape(&wrangler_path)?; if dry_run { - out.push(format!( + entries.push(ProvisionEntry::for_store(ProvisionAction::WouldCreate, kind, store, format!( "would run `wrangler kv namespace create {binding}` and append [[kv_namespaces]] binding = \"{binding}\" to {} (logical id `{logical}`)", wrangler_path.display() - )); + ))); continue; } let namespace_id = create_kv_namespace(binding)?; upsert_kv_namespace(&wrangler_path, binding, &namespace_id)?; - out.push(format!( + entries.push(ProvisionEntry::for_store(ProvisionAction::Created, kind, store, format!( "created KV namespace `{binding}` (logical id `{logical}`, namespace id={namespace_id}); written to {}", wrangler_path.display() - )); + ))); } for store in stores.secrets { let logical = &store.logical; let platform = &store.platform; - out.push(format!( + entries.push(ProvisionEntry::for_store(ProvisionAction::NotApplicable, StoreKind::Secrets, store, format!( "cloudflare secret `{platform}` (logical id `{logical}`) is runtime-managed via `wrangler secret put`; nothing to provision" - )); + ))); } - if out.is_empty() { - out.push("cloudflare has no declared stores to provision".to_owned()); + if entries.is_empty() { + entries.push(ProvisionEntry::note( + "cloudflare has no declared stores to provision".to_owned(), + )); } - Ok(out) + Ok(ProvisionReport { entries }) } fn push_config_entries( @@ -935,20 +951,21 @@ pub fn build(extra_args: &[String]) -> Result { let cargo_manifest = manifest_dir.join("Cargo.toml"); let crate_name = read_package_name(&cargo_manifest)?; - let status = Command::new("cargo") - .args([ - "build", - "--release", - "--target", - TARGET_TRIPLE, - "--manifest-path", - cargo_manifest - .to_str() - .ok_or("invalid Cargo manifest path")?, - ]) - .args(extra_args) - .status() - .map_err(|err| format!("failed to run cargo build: {err}"))?; + let status = process::status( + Command::new("cargo") + .args([ + "build", + "--release", + "--target", + TARGET_TRIPLE, + "--manifest-path", + cargo_manifest + .to_str() + .ok_or("invalid Cargo manifest path")?, + ]) + .args(extra_args), + ) + .map_err(|err| format!("failed to run cargo build: {err}"))?; if !status.success() { return Err(format!("cargo build failed with status {status}")); } @@ -978,12 +995,13 @@ pub fn deploy(extra_args: &[String]) -> Result<(), String> { .to_str() .ok_or_else(|| "invalid wrangler config path".to_owned())?; - let status = Command::new("wrangler") - .args(["deploy", "--config", config]) - .args(extra_args) - .current_dir(manifest_dir) - .status() - .map_err(|err| format!("failed to run wrangler CLI: {err}"))?; + let status = process::status( + Command::new("wrangler") + .args(["deploy", "--config", config]) + .args(extra_args) + .current_dir(manifest_dir), + ) + .map_err(|err| format!("failed to run wrangler CLI: {err}"))?; if !status.success() { return Err(format!("wrangler deploy failed with status {status}")); } @@ -1119,12 +1137,13 @@ pub fn serve(extra_args: &[String]) -> Result<(), String> { .to_str() .ok_or_else(|| "invalid wrangler config path".to_owned())?; - let status = Command::new("wrangler") - .args(["dev", "--config", config]) - .args(extra_args) - .current_dir(manifest_dir) - .status() - .map_err(|err| format!("failed to run wrangler CLI: {err}"))?; + let status = process::status( + Command::new("wrangler") + .args(["dev", "--config", config]) + .args(extra_args) + .current_dir(manifest_dir), + ) + .map_err(|err| format!("failed to run wrangler CLI: {err}"))?; if !status.success() { return Err(format!("wrangler dev failed with status {status}")); } @@ -1544,7 +1563,8 @@ id = "00112233445566778899aabbccddeeff" }; let out = CloudflareCliAdapter .provision(dir.path(), Some("wrangler.toml"), None, &stores, true) - .expect("dry-run succeeds"); + .expect("dry-run succeeds") + .into_messages(); // 2 KV + 1 config + 1 secret = 4 status lines. assert_eq!(out.len(), 4); assert!(out[0].contains("would run `wrangler kv namespace create sessions`")); @@ -1575,7 +1595,8 @@ id = "00112233445566778899aabbccddeeff" }; let out = CloudflareCliAdapter .provision(dir.path(), Some("wrangler.toml"), None, &stores, true) - .expect("dry-run succeeds"); + .expect("dry-run succeeds") + .into_messages(); assert_eq!(out.len(), 1); assert!( out[0].contains("wrangler kv namespace create prod_config"), @@ -1625,7 +1646,8 @@ id = "00112233445566778899aabbccddeeff" }; let out = CloudflareCliAdapter .provision(dir.path(), Some("wrangler.toml"), None, &stores, true) - .expect("dry-run succeeds"); + .expect("dry-run succeeds") + .into_messages(); assert_eq!(out.len(), 1); assert!( out[0].contains("already provisioned") @@ -1658,7 +1680,8 @@ id = "00112233445566778899aabbccddeeff" }; let out = CloudflareCliAdapter .provision(dir.path(), Some("wrangler.toml"), None, &stores, true) - .expect("dry-run succeeds"); + .expect("dry-run succeeds") + .into_messages(); assert_eq!(out.len(), 1); assert!( out[0].contains("would run `wrangler kv namespace create sessions`"), @@ -1677,10 +1700,67 @@ id = "00112233445566778899aabbccddeeff" }; let out = CloudflareCliAdapter .provision(dir.path(), Some("wrangler.toml"), None, &stores, false) - .expect("no-store provision is fine"); + .expect("no-store provision is fine") + .into_messages(); assert_eq!(out, vec!["cloudflare has no declared stores to provision"]); } + #[test] + fn provision_report_classifies_each_store() { + let dir = tempdir().expect("tempdir"); + write_wrangler( + dir.path(), + "name = \"demo\"\n[[kv_namespaces]]\nbinding = \"sessions\"\nid = \"00112233445566778899aabbccddeeff\"\n", + ); + let kv_ids = ResolvedStoreId::from_logicals(&[TEST_KV_ID]); + let config_ids = vec![ResolvedStoreId::new(TEST_CONFIG_ID, "prod_config")]; + let secret_ids = ResolvedStoreId::from_logicals(&[TEST_SECRET_ID]); + let stores = ProvisionStores { + config: &config_ids, + kv: &kv_ids, + secrets: &secret_ids, + }; + let report = CloudflareCliAdapter + .provision(dir.path(), Some("wrangler.toml"), None, &stores, true) + .expect("dry-run succeeds"); + let summary: Vec<_> = report + .entries + .iter() + .map(|entry| { + let store = entry.store.as_ref().expect("every entry names a store"); + ( + entry.action, + store.kind, + store.logical.as_deref(), + store.platform.as_str(), + ) + }) + .collect(); + assert_eq!( + summary, + vec![ + ( + ProvisionAction::AlreadyPresent, + StoreKind::Kv, + Some(TEST_KV_ID), + TEST_KV_ID + ), + ( + ProvisionAction::WouldCreate, + StoreKind::Config, + Some(TEST_CONFIG_ID), + "prod_config" + ), + ( + ProvisionAction::NotApplicable, + StoreKind::Secrets, + Some(TEST_SECRET_ID), + TEST_SECRET_ID + ), + ] + ); + } + // ---------- find_namespace_id ---------- #[test] diff --git a/crates/edgezero-adapter-fastly/src/cli.rs b/crates/edgezero-adapter-fastly/src/cli.rs index 9bc1a0632..cfc8c7319 100644 --- a/crates/edgezero-adapter-fastly/src/cli.rs +++ b/crates/edgezero-adapter-fastly/src/cli.rs @@ -24,11 +24,15 @@ use crate::chunked_config::{ use crate::service_scoped_runtime_env_key; use ctor::ctor; use edgezero_adapter::cli_support::{ - find_manifest_upwards, find_workspace_root, path_distance, read_package_name, run_native_cli, + find_manifest_upwards, find_workspace_root, native_auth_status, path_distance, + read_package_name, run_native_cli, }; +use edgezero_adapter::process; use edgezero_adapter::registry::{ - Adapter, AdapterAction, AdapterPushContext, ProvisionStores, ReadConfigEntry, ResolvedStoreId, - register_adapter, + ActionOutcome, ActiveVersionOutcome, Adapter, AdapterAction, AdapterPushContext, BuildOutcome, + DeployOutcome, GcCandidate, GcReport, HealthcheckOutcome, ProvisionAction, ProvisionEntry, + ProvisionReport, ProvisionStoreRef, ProvisionStores, ReadConfigEntry, ResolvedStoreId, + RollbackOutcome, StoreKind, register_adapter, }; use edgezero_adapter::scaffold::{ AdapterBlueprint, AdapterFileSpec, CommandTemplates, DependencySpec, LoggingDefaults, @@ -389,32 +393,47 @@ struct RuntimeStoreNameReconciliation { reason = "see the explanatory block comment immediately above; fastly's no-op defaults for the three validate_* hooks are intentional and documented. `read_config_entry` and `read_config_entry_local` are both overridden below. `single_store_kinds` IS overridden below (returns `&[]`)." )] impl Adapter for FastlyCliAdapter { - fn execute(&self, action: AdapterAction, args: &[String]) -> Result<(), String> { + fn execute(&self, action: AdapterAction, args: &[String]) -> Result { match action { // `fastly profile {create|delete|list}` is the native // sign-in surface for Fastly Compute. EdgeZero stores no // credentials — this is a thin shell-out. AdapterAction::AuthLogin => { run_native_cli("fastly", &["profile", "create"], FASTLY_INSTALL_HINT) + .map(|()| ActionOutcome::Empty) } AdapterAction::AuthLogout => { run_native_cli("fastly", &["profile", "delete"], FASTLY_INSTALL_HINT) + .map(|()| ActionOutcome::Empty) } AdapterAction::AuthStatus => { - run_native_cli("fastly", &["profile", "list"], FASTLY_INSTALL_HINT) + native_auth_status("fastly", &["profile", "list"], FASTLY_INSTALL_HINT) + .map(ActionOutcome::AuthStatus) } AdapterAction::Build => { let artifact = build(args)?; log::info!("[edgezero] Fastly build complete -> {}", artifact.display()); - Ok(()) + Ok(ActionOutcome::Build(BuildOutcome { + artifact: Some(artifact), + })) + } + // The CLI resolves the activated version (deploy output, then the + // Fastly API), so the built-in deploy reports none. + AdapterAction::Deploy => { + deploy(args).map(|()| ActionOutcome::Deploy(DeployOutcome { version: None })) } - AdapterAction::Deploy => deploy(args), - AdapterAction::Serve => serve(args), + AdapterAction::Serve => serve(args).map(|()| ActionOutcome::Empty), // Fastly staging lifecycle. - AdapterAction::DeployStaging => deploy_staging(args), - AdapterAction::EmitVersion => emit_active_version(args), - AdapterAction::Healthcheck => healthcheck(args), - AdapterAction::Rollback => rollback(args), + AdapterAction::DeployStaging => deploy_staging(args).map(|version| { + ActionOutcome::Deploy(DeployOutcome { + version: Some(version), + }) + }), + AdapterAction::EmitVersion => { + emit_active_version(args).map(ActionOutcome::ActiveVersion) + } + AdapterAction::Healthcheck => healthcheck(args).map(ActionOutcome::Healthcheck), + AdapterAction::Rollback => rollback(args).map(ActionOutcome::Rollback), other => Err(format!("fastly adapter does not support {other:?}")), } } @@ -428,7 +447,7 @@ impl Adapter for FastlyCliAdapter { _push_ctx: &AdapterPushContext<'_>, older_than_secs: u64, dry_run: bool, - ) -> Result, String> { + ) -> Result { gc_fastly_config_store(store.platform.as_str(), older_than_secs, dry_run) } @@ -466,7 +485,7 @@ impl Adapter for FastlyCliAdapter { _component_selector: Option<&str>, stores: &ProvisionStores<'_>, dry_run: bool, - ) -> Result, String> { + ) -> Result { // Fastly is Multi for every store kind. Each id maps 1:1 // to a Fastly resource (kv-store / config-store / // secret-store) created via the Fastly CLI; the manifest @@ -483,11 +502,11 @@ impl Adapter for FastlyCliAdapter { let runtime_env_service_id = provision_runtime_env_service_id_for_stores(&fastly_path, stores)?; - let mut out = Vec::new(); - for (kind, ids) in [ - ("kv", stores.kv), - ("config", stores.config), - ("secret", stores.secrets), + let mut entries = Vec::new(); + for (kind, store_kind, ids) in [ + ("kv", StoreKind::Kv, stores.kv), + ("config", StoreKind::Config, stores.config), + ("secret", StoreKind::Secrets, stores.secrets), ] { for store in ids { // Fastly setup tables key on the resource name the @@ -499,17 +518,22 @@ impl Adapter for FastlyCliAdapter { let logical = store.logical.as_str(); let name = store.platform.as_str(); if dry_run { - out.push(format!( - "would run `fastly {kind}-store create --name={name}` and append [setup.{kind}_stores.{name}] to {} (logical id `{logical}`)", - fastly_path.display() + entries.push(ProvisionEntry::for_store( + ProvisionAction::WouldCreate, + store_kind, + store, + format!( + "would run `fastly {kind}-store create --name={name}` and append [setup.{kind}_stores.{name}] to {} (logical id `{logical}`)", + fastly_path.display() + ), )); continue; } if setup_block_present(&fastly_path, kind, name)? { - out.push(format!( + entries.push(ProvisionEntry::for_store(ProvisionAction::AlreadyPresent, store_kind, store, format!( "fastly {kind}-store `{name}` (logical id `{logical}`) already declared in {}; skipping. To force a fresh remote: delete the [setup.{kind}_stores.{name}] block AND run `fastly {kind}-store delete --name={name}` (the old remote store lingers otherwise), then re-run provision.", fastly_path.display() - )); + ))); continue; } create_fastly_store_in(kind, name, manifest_dir)?; @@ -553,66 +577,24 @@ impl Adapter for FastlyCliAdapter { line.push('\n'); line.push_str(¬e); } - out.push(line); - } - } - // EdgeZero runtime overrides live in a dedicated Fastly Config - // Store named `edgezero_runtime_env`. Compute@Edge has no - // process env, so `EDGEZERO__STORES__CONFIG____KEY` and - // similar overrides have to come from a platform Config Store - // the runtime opens by name (see `runtime_env_config` in - // lib.rs). Provision owns the store creation alongside the - // operator's declared stores so the runtime override path is - // wired correctly out of the box; if the store already appears - // in `[setup.config_stores.edgezero_runtime_env]`, skip. - let runtime_env_kind = "config"; - let runtime_env_name = RUNTIME_ENV_STORE_NAME; - if dry_run { - out.push(format!( - "would run `fastly {runtime_env_kind}-store create --name={runtime_env_name}` and append [setup.{runtime_env_kind}_stores.{runtime_env_name}] to {} (EdgeZero runtime override store)", - fastly_path.display() - )); - } else if !setup_block_present(&fastly_path, runtime_env_kind, runtime_env_name)? { - create_fastly_store_in(runtime_env_kind, runtime_env_name, manifest_dir)?; - append_fastly_setup(&fastly_path, runtime_env_kind, runtime_env_name).map_err( - |err| { - format!( - "fastly {runtime_env_kind}-store `{runtime_env_name}` was created remotely, but writeback to {path} failed: {err}\n Recover via `fastly {runtime_env_kind}-store delete --name={runtime_env_name}` then re-run `edgezero provision --adapter fastly`.", - path = fastly_path.display() - ) - }, - )?; - // Same already-deployed-service caveat as the declared-store - // path: if `service_id` is set in fastly.toml, the - // `[setup.config_stores.edgezero_runtime_env]` table won't - // be re-applied by the next `fastly compute deploy`, so the - // runtime can't open the store. Emit the resource-link - // remediation alongside the populate-keys hint. - let post_create_note = - resource_link_note(&fastly_path, runtime_env_kind, runtime_env_name)?; - // NB: this store is what the ACTIVE (production) service reads. The - // example must never point it at a staging key — following that would - // make production serve staged config. Staged versions get their own - // selector via `edgezero_runtime_env_staging`, wired automatically by - // a staged deploy; nothing here should be edited to stage config. - let production_selector_key = runtime_env_key_for( - runtime_env_service_id.as_deref().unwrap_or(""), - "app_config", - ); - let mut line = format!( - "created fastly {runtime_env_kind}-store `{runtime_env_name}` (EdgeZero runtime override store, read by the ACTIVE version); appended setup tables to {}\n Provision writes service-scoped non-default store-name mappings below. Config stores still select their logical id as the default key.\n To point PRODUCTION at a different config key, and only then:\n fastly config-store-entry update --store-id= --key={production_selector_key} --value= --upsert\n Do NOT set a `_staging` key here: staged config is isolated by a per-service `{RUNTIME_ENV_STAGING_STORE_PREFIX}_` store, which a staged deploy creates and links automatically.", - fastly_path.display() - ); - if let Some(note) = post_create_note { - line.push('\n'); - line.push_str(¬e); + entries.push(ProvisionEntry::for_store( + ProvisionAction::Created, + store_kind, + store, + line, + )); } - out.push(line); - } else { - // Already declared; nothing to do. } + // EdgeZero's runtime-override store; skipped when + // `[setup.config_stores.edgezero_runtime_env]` already declares it. + entries.extend(provision_runtime_env_store( + &fastly_path, + manifest_dir, + runtime_env_service_id.as_deref(), + dry_run, + )?); - out.extend(persist_runtime_env_store_name_entries( + entries.extend(persist_runtime_env_store_name_entries( stores, runtime_env_service_id.as_deref(), dry_run, @@ -626,10 +608,12 @@ impl Adapter for FastlyCliAdapter { // touch it: a twin populated here would drift the moment an operator // edited a production override. - if out.is_empty() { - out.push("fastly has no declared stores to provision".to_owned()); + if entries.is_empty() { + entries.push(ProvisionEntry::note( + "fastly has no declared stores to provision".to_owned(), + )); } - Ok(out) + Ok(ProvisionReport { entries }) } fn push_config_entries( @@ -2507,6 +2491,36 @@ fn append_kept_roots_report(out: &mut Vec, kept_roots: &[String], live_c } } +/// The typed `config gc` report for `plan`, before any delete runs. +fn gc_report_for(plan: &GcPlan, entries: usize, store_id: &str) -> GcReport { + GcReport { + deleted: None, + entries, + failed: Vec::new(), + failure_diagnostic: None, + generations_planned: plan.doomed.len(), + kept_roots: plan.kept_roots.clone(), + planned: plan + .doomed + .iter() + .flatten() + .map(|(key, age)| GcCandidate { + age_secs: *age, + key: key.clone(), + }) + .collect(), + referenced_chunks: plan.live_count, + retained_recent: plan.retained_recent, + roots: plan.roots, + store_id: Some(store_id.to_owned()), + stranded: Vec::new(), + text_lines: Vec::new(), + uncertain: Vec::new(), + unprovable: plan.unprovable, + warnings: plan.warnings.clone(), + } +} + /// `config gc` for Fastly: delete chunk entries that no LIVE root pointer /// references and that are older than the operator's `older_than_secs`. /// @@ -2521,7 +2535,7 @@ fn gc_fastly_config_store( store_name: &str, older_than_secs: u64, dry_run: bool, -) -> Result, String> { +) -> Result { // THE destructive boundary enforces its own precondition. The CLI rejects a // zero window too, but `gc_config_entries` is a public trait method any // caller can reach directly -- a safety rule that lives only in the CLI is @@ -2540,6 +2554,7 @@ fn gc_fastly_config_store( .ok_or_else(|| no_matching_store_error(store_name))?; let items = list_config_store_entries(&resolved_id)?; let plan = plan_gc_reclamation(&items, unix_now_secs(), older_than_secs)?; + let mut report = gc_report_for(&plan, items.len(), &resolved_id); let GcPlan { doomed, kept_roots, @@ -2568,7 +2583,8 @@ fn gc_fastly_config_store( } if doomed_count == 0 { out.push("nothing to reclaim".to_owned()); - return Ok(out); + report.text_lines = out; + return Ok(report); } if dry_run { // A dry-run only PLANS: list every candidate and stop. Nothing is @@ -2583,7 +2599,8 @@ fn gc_fastly_config_store( "dry-run: {doomed_count} orphan chunk(s) planned for deletion; re-run with \ `--yes --older-than ` (a non-zero window is required) to apply" )); - return Ok(out); + report.text_lines = out; + return Ok(report); } // Real run: `doomed_count` is the PLANNED count. Do NOT pre-print each key as // "deleting" -- execution stops at a generation's first failure, so some @@ -2603,9 +2620,39 @@ fn gc_fastly_config_store( out.push(format!( "reclaimed {deleted} of {doomed_count} orphan chunk entries" )); + report.deleted = Some(deleted); if failed.is_empty() { - return Ok(out); - } + report.text_lines = out; + return Ok(report); + } + // Partial/total failure is still a REPORT (what was and was not deleted); + // the CLI turns `failure_diagnostic` into a non-zero exit. + let diagnostic = gc_failure_diagnostic( + &out, + doomed_count, + &failed, + &uncertain, + &stranded, + &resolved_id, + )?; + report.failed = failed; + report.failure_diagnostic = Some(diagnostic); + report.stranded = stranded; + report.text_lines = out; + report.uncertain = uncertain; + Ok(report) +} + +/// The operator-facing text for a `config gc` run whose deletes failed: the +/// run's report, which deletes failed, and how to recover. +fn gc_failure_diagnostic( + out: &[String], + doomed_count: usize, + failed: &[String], + uncertain: &[String], + stranded: &[String], + resolved_id: &str, +) -> Result { // Partial/total failure must be a non-zero exit so automation can see it. let mut diagnostic = format!( "{}\nconfig gc: {} of {doomed_count} deletes FAILED ({})", @@ -2623,7 +2670,7 @@ fn gc_fastly_config_store( before returning an error. Re-run `config gc`: it reclaims each affected generation \ if it is still whole, or reports it as an unprovable fragment (\"left untouched\") if \ a delete did commit. If reported as a fragment, remove the survivors by hand:\n{}", - recovery_commands(&resolved_id, &uncertain) + recovery_commands(resolved_id, uncertain) ) .map_err(|err| format!("failed to format the gc diagnostic: {err}"))?; } @@ -2638,11 +2685,11 @@ fn gc_fastly_config_store( by hand once you are satisfied they are unreferenced:\n{}", stranded.len(), stranded.join(", "), - recovery_commands(&resolved_id, &stranded), + recovery_commands(resolved_id, stranded), ) .map_err(|err| format!("failed to format the gc diagnostic: {err}"))?; } - Err(diagnostic) + Ok(diagnostic) } /// Render copy-pasteable `fastly config-store-entry delete` commands, one per @@ -3391,6 +3438,10 @@ fn create_config_store_entry_with_cwd( if let Some(command_cwd) = cwd { command.current_dir(command_cwd); } + #[expect( + clippy::disallowed_methods, + reason = "stdio is fully piped, so the child never writes to our stdout" + )] let mut child = command .stdin(Stdio::piped()) .stdout(Stdio::piped()) @@ -3784,7 +3835,7 @@ fn persist_runtime_env_store_name_entries( service_id_hint: Option<&str>, dry_run: bool, cwd: &Path, -) -> Result, String> { +) -> Result, String> { if !has_declared_stores(stores) { return Ok(Vec::new()); } @@ -3794,10 +3845,10 @@ fn persist_runtime_env_store_name_entries( "cannot persist non-default Fastly store-name mappings without top-level `service_id` or {FASTLY_SERVICE_ID_ENV}" )); } - return Ok(vec![ + return Ok(vec![ProvisionEntry::note( "no Fastly service id and no non-default store-name mappings; skipping runtime-env reconciliation" .to_owned(), - ]); + )]); }; let entries = runtime_env_store_name_entries(stores, service_id); let declared = runtime_env_store_name_keys(stores, service_id); @@ -3805,8 +3856,11 @@ fn persist_runtime_env_store_name_entries( let mut out = entries .iter() .map(|(key, value)| { - format!( - "would upsert `{key}={value}` into fastly config-store `{RUNTIME_ENV_STORE_NAME}`" + runtime_env_entry( + ProvisionAction::WouldUpdate, + format!( + "would upsert `{key}={value}` into fastly config-store `{RUNTIME_ENV_STORE_NAME}`" + ), ) }) .collect::>(); @@ -3815,8 +3869,11 @@ fn persist_runtime_env_store_name_entries( .iter() .filter(|key| !entries.iter().any(|(entry_key, _)| entry_key == *key)) .map(|key| { - format!( - "would remove `{key}` from fastly config-store `{RUNTIME_ENV_STORE_NAME}` if a stale mapping is present" + runtime_env_entry( + ProvisionAction::WouldUpdate, + format!( + "would remove `{key}` from fastly config-store `{RUNTIME_ENV_STORE_NAME}` if a stale mapping is present" + ), ) }), ); @@ -3827,9 +3884,9 @@ fn persist_runtime_env_store_name_entries( resolve_remote_config_store_id_in(RUNTIME_ENV_STORE_NAME, cwd)? else { if entries.is_empty() { - return Ok(vec![format!( + return Ok(vec![ProvisionEntry::note(format!( "fastly config-store `{RUNTIME_ENV_STORE_NAME}` not found; no non-default store-name mappings to write for service `{service_id}`, skipping reconciliation" - )]); + ))]); } return Err(format!( "cannot write non-default store-name mappings for service `{service_id}`: fastly config-store `{RUNTIME_ENV_STORE_NAME}` does not exist remotely even though its setup block is declared. Create it with `fastly config-store create --name={RUNTIME_ENV_STORE_NAME}` (and link it to an existing service when needed), then re-run provision" @@ -3857,13 +3914,99 @@ fn persist_runtime_env_store_name_entries( ) })?; } - Ok(vec![format!( - "reconciled store-name mappings for service `{service_id}` in fastly config-store `{RUNTIME_ENV_STORE_NAME}`: upserted {}, removed {} stale mapping(s)", - reconciliation.upserts.len(), - reconciliation.deletes.len() + Ok(vec![runtime_env_entry( + ProvisionAction::Updated, + format!( + "reconciled store-name mappings for service `{service_id}` in fastly config-store `{RUNTIME_ENV_STORE_NAME}`: upserted {}, removed {} stale mapping(s)", + reconciliation.upserts.len(), + reconciliation.deletes.len() + ), )]) } +/// Provision's step for `EdgeZero`'s runtime-override config store: create it +/// and declare its setup table unless `fastly.toml` already declares it. +/// +/// `EdgeZero` runtime overrides live in a dedicated Fastly Config Store named +/// `edgezero_runtime_env`. Compute@Edge has no process env, so +/// `EDGEZERO__STORES__CONFIG____KEY` and similar overrides have to come +/// from a platform Config Store the runtime opens by name (see +/// `runtime_env_config` in lib.rs). Provision owns the store creation +/// alongside the operator's declared stores so the runtime override path is +/// wired correctly out of the box. +fn provision_runtime_env_store( + fastly_path: &Path, + manifest_dir: &Path, + runtime_env_service_id: Option<&str>, + dry_run: bool, +) -> Result, String> { + let runtime_env_kind = "config"; + let runtime_env_name = RUNTIME_ENV_STORE_NAME; + if dry_run { + return Ok(Some(runtime_env_entry( + ProvisionAction::WouldCreate, + format!( + "would run `fastly {runtime_env_kind}-store create --name={runtime_env_name}` and append [setup.{runtime_env_kind}_stores.{runtime_env_name}] to {} (EdgeZero runtime override store)", + fastly_path.display() + ), + ))); + } + if setup_block_present(fastly_path, runtime_env_kind, runtime_env_name)? { + // Already declared; nothing to do. + return Ok(None); + } + { + create_fastly_store_in(runtime_env_kind, runtime_env_name, manifest_dir)?; + append_fastly_setup(fastly_path, runtime_env_kind, runtime_env_name).map_err( + |err| { + format!( + "fastly {runtime_env_kind}-store `{runtime_env_name}` was created remotely, but writeback to {path} failed: {err}\n Recover via `fastly {runtime_env_kind}-store delete --name={runtime_env_name}` then re-run `edgezero provision --adapter fastly`.", + path = fastly_path.display() + ) + }, + )?; + // Same already-deployed-service caveat as the declared-store + // path: if `service_id` is set in fastly.toml, the + // `[setup.config_stores.edgezero_runtime_env]` table won't + // be re-applied by the next `fastly compute deploy`, so the + // runtime can't open the store. Emit the resource-link + // remediation alongside the populate-keys hint. + let post_create_note = resource_link_note(fastly_path, runtime_env_kind, runtime_env_name)?; + // NB: this store is what the ACTIVE (production) service reads. The + // example must never point it at a staging key — following that would + // make production serve staged config. Staged versions get their own + // selector via `edgezero_runtime_env_staging`, wired automatically by + // a staged deploy; nothing here should be edited to stage config. + let production_selector_key = runtime_env_key_for( + runtime_env_service_id.unwrap_or(""), + "app_config", + ); + let mut line = format!( + "created fastly {runtime_env_kind}-store `{runtime_env_name}` (EdgeZero runtime override store, read by the ACTIVE version); appended setup tables to {}\n Provision writes service-scoped non-default store-name mappings below. Config stores still select their logical id as the default key.\n To point PRODUCTION at a different config key, and only then:\n fastly config-store-entry update --store-id= --key={production_selector_key} --value= --upsert\n Do NOT set a `_staging` key here: staged config is isolated by a per-service `{RUNTIME_ENV_STAGING_STORE_PREFIX}_` store, which a staged deploy creates and links automatically.", + fastly_path.display() + ); + if let Some(note) = post_create_note { + line.push('\n'); + line.push_str(¬e); + } + Ok(Some(runtime_env_entry(ProvisionAction::Created, line))) + } +} + +/// A provision entry about `EdgeZero`'s own runtime-override config store +/// (declared by no manifest, so it has no logical id). +fn runtime_env_entry(action: ProvisionAction, message: String) -> ProvisionEntry { + ProvisionEntry { + action, + message, + store: Some(ProvisionStoreRef { + kind: StoreKind::Config, + logical: None, + platform: RUNTIME_ENV_STORE_NAME.to_owned(), + }), + } +} + fn canonical_runtime_env_key_for(logical_id: &str) -> String { format!( "EDGEZERO__STORES__CONFIG__{}__KEY", @@ -4121,20 +4264,21 @@ pub fn build(extra_args: &[String]) -> Result { let cargo_manifest = manifest_dir.join("Cargo.toml"); let crate_name = read_package_name(&cargo_manifest)?; - let status = Command::new("cargo") - .args([ - "build", - "--release", - "--target", - "wasm32-wasip1", - "--manifest-path", - cargo_manifest - .to_str() - .ok_or("invalid Cargo manifest path")?, - ]) - .args(extra_args) - .status() - .map_err(|err| format!("failed to run cargo build: {err}"))?; + let status = process::status( + Command::new("cargo") + .args([ + "build", + "--release", + "--target", + "wasm32-wasip1", + "--manifest-path", + cargo_manifest + .to_str() + .ok_or("invalid Cargo manifest path")?, + ]) + .args(extra_args), + ) + .map_err(|err| format!("failed to run cargo build: {err}"))?; if !status.success() { return Err(format!("cargo build failed with status {status}")); } @@ -4187,11 +4331,12 @@ pub fn deploy(extra_args: &[String]) -> Result<(), String> { let manifest_dir = resolve_manifest_dir(extra_args)?; let forwarded = args_without_flag_value(extra_args, "--manifest-path"); - let status = Command::new("fastly") - .args(build_compute_deploy_args(&forwarded)) - .current_dir(&manifest_dir) - .status() - .map_err(|err| format!("failed to run fastly CLI: {err}"))?; + let status = process::status( + Command::new("fastly") + .args(build_compute_deploy_args(&forwarded)) + .current_dir(&manifest_dir), + ) + .map_err(|err| format!("failed to run fastly CLI: {err}"))?; if !status.success() { return Err(format!("fastly compute deploy failed with status {status}")); } @@ -4294,12 +4439,13 @@ pub fn serve(extra_args: &[String]) -> Result<(), String> { .parent() .ok_or_else(|| "fastly manifest has no parent directory".to_owned())?; - let status = Command::new("fastly") - .args(["compute", "serve"]) - .args(extra_args) - .current_dir(manifest_dir) - .status() - .map_err(|err| format!("failed to run fastly CLI: {err}"))?; + let status = process::status( + Command::new("fastly") + .args(["compute", "serve"]) + .args(extra_args) + .current_dir(manifest_dir), + ) + .map_err(|err| format!("failed to run fastly CLI: {err}"))?; if !status.success() { return Err(format!("fastly compute serve failed with status {status}")); } @@ -4749,10 +4895,7 @@ where /// 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() + let status = process::status(Command::new("fastly").args(fastly_args).current_dir(cwd)) .map_err(|err| { if err.kind() == ErrorKind::NotFound { format!("`fastly` not found on PATH; {FASTLY_INSTALL_HINT}") @@ -4810,6 +4953,10 @@ fn run_fastly_capture(fastly_args: &[String], cwd: &Path) -> Result Result { let connect_timeout = FASTLY_API_CONNECT_TIMEOUT_SECS.to_string(); let max_time = FASTLY_API_MAX_TIME_SECS.to_string(); + #[expect( + clippy::disallowed_methods, + reason = "stdio is fully piped, so the child never writes to our stdout" + )] let mut child = Command::new("curl") .args([ "-q", @@ -5042,7 +5189,7 @@ fn resolve_manifest_dir(args: &[String]) -> Result { /// `deploy --adapter fastly --service-id --staging`: /// build, upload to a new draft version (no activation), stage it, and /// emit `version=`. -fn deploy_staging(args: &[String]) -> Result<(), String> { +fn deploy_staging(args: &[String]) -> Result { let service_id = resolve_service_id(args)?; validate_service_id(&service_id)?; // The Fastly CLI reads FASTLY_API_TOKEN from the env; fail fast @@ -5161,9 +5308,8 @@ fn deploy_staging(args: &[String]) -> Result<(), String> { manifest_dir, )?; - // 6. Emit the staged version (parseable contract). - log::info!("version={version}"); - Ok(()) + // 6. Report the staged version; the CLI emits it (`version=`). + Ok(version) } /// Point a staged draft's `edgezero_runtime_env` link at the STAGING selector @@ -5288,25 +5434,20 @@ fn relink_runtime_env_for_staging( /// by the production-`deploy` version fallback, where a version was JUST /// activated, so "no active version" is not a valid first-deploy state but an /// operational failure the CLI must not report as success. -fn emit_active_version(args: &[String]) -> Result<(), String> { +fn emit_active_version(args: &[String]) -> Result { let service_id = resolve_service_id(args)?; validate_service_id(&service_id)?; let token = require_token()?; let json = fastly_api_get(&format!("/service/{service_id}/version"), &token)?; - if let Some(version) = - active_version_or_require(&json, arg_flag(args, "--require-active"), &service_id)? - { - log::info!("version={version}"); - } else { - // Confirmed no active version (first-ever deploy), and it was not - // required. Emit an explicit empty line so the caller records an empty - // rollback target and succeeds — distinct from a failure (`Err`). - log::info!("version="); - log::info!( - "service {service_id} has no active version yet; emitting an empty rollback target" - ); - } - Ok(()) + // `None` = confirmed no active version (first-ever deploy) and it was not + // required: the CLI emits an explicit empty `version=` so the caller records + // an empty rollback target and succeeds — distinct from a failure (`Err`). + let version = + active_version_or_require(&json, arg_flag(args, "--require-active"), &service_id)?; + Ok(ActiveVersionOutcome { + service_id, + version, + }) } /// Resolve the active version and apply the `--require-active` policy. @@ -5383,7 +5524,7 @@ fn version_active_verdict( /// On the PRODUCTION path the probe reaches whatever version is live, so when a /// token is available `version` is verified ACTIVE before and after the probe /// (see [`verify_version_active`]); without a token the check is service-level. -fn healthcheck(args: &[String]) -> Result<(), String> { +fn healthcheck(args: &[String]) -> Result { let domain = arg_value(args, "--domain").ok_or_else(|| "healthcheck requires --domain".to_owned())?; validate_domain(domain)?; @@ -5457,29 +5598,48 @@ fn healthcheck(args: &[String]) -> Result<(), String> { let curl_args = build_curl_probe_args(domain, path, staging_ip.as_deref(), timeout); let delay = Duration::from_secs(retry_delay); - let outcome = probe_with_retries(retry, || curl_status(&curl_args), || thread::sleep(delay)); - match outcome { + let mut attempts = 0_u32; + let outcome = probe_with_retries( + retry, + || { + attempts = attempts.saturating_add(1); + curl_status(&curl_args) + }, + || thread::sleep(delay), + ); + // The CLI emits `status-code=` / `healthy=` from this outcome, and + // turns an unhealthy one into a non-zero exit. + let (healthy, status_code, failure) = match outcome { Ok(code) => { // Confirm `version` is STILL active, so a deploy that activated a newer // version during the probe+retries is not reported as a healthy `version`. if let Some(token) = production_token.as_deref() { verify_version_active(&service_id, version, token, "after probing")?; } - log::info!("status-code={code}"); - log::info!("healthy=true"); - Ok(()) + (true, Some(code), None) } - Err((last_code, msg)) => { - if let Some(code) = last_code { - log::info!("status-code={code}"); - } - log::info!("healthy=false"); - Err(format!( + Err((last_code, msg)) => ( + false, + last_code, + Some(format!( "healthcheck for {domain} failed after {} attempt(s): {msg}", retry.max(1) - )) - } - } + )), + ), + }; + Ok(HealthcheckOutcome { + attempts, + domain: domain.to_owned(), + failure, + healthy, + path: path.to_owned(), + service_id, + staging: is_staging, + staging_ip, + status_code, + version, + version_verified: production_token.is_some(), + }) } /// Run a single `curl` health probe, returning the HTTP status. A @@ -5512,7 +5672,7 @@ fn curl_status(args: &[String]) -> Result { /// `rollback --adapter fastly ...`: production activates the explicit /// `--rollback-to` version (Fastly cannot infer a previous version); /// staging deactivates ``. -fn rollback(args: &[String]) -> Result<(), String> { +fn rollback(args: &[String]) -> Result { let service_id = resolve_service_id(args)?; validate_service_id(&service_id)?; let version_str = @@ -5532,6 +5692,12 @@ fn rollback(args: &[String]) -> Result<(), String> { log::info!( "[edgezero] deactivated staged version {version} on Fastly service {service_id}" ); + Ok(RollbackOutcome { + rolled_back_to: None, + service_id, + staging: true, + version, + }) } else { // Production rollback re-activates an EXPLICIT target. Fastly's version // list has no field distinguishing a previously-live version from a @@ -5560,9 +5726,14 @@ fn rollback(args: &[String]) -> Result<(), String> { &format!("/service/{service_id}/version/{previous}/activate"), &token, )?; - log::info!("rolled-back-to={previous}"); + // The CLI emits `rolled-back-to=`. + Ok(RollbackOutcome { + rolled_back_to: Some(previous), + service_id, + staging: false, + version, + }) } - Ok(()) } #[cfg(test)] @@ -6985,7 +7156,8 @@ build = \"cargo build --release\" }; let out = FastlyCliAdapter .provision(dir.path(), Some("fastly.toml"), None, &stores, true) - .expect("dry-run succeeds"); + .expect("dry-run succeeds") + .into_messages(); // 1 KV + 1 config + 1 secret + runtime-env + 3 possible stale-mapping // removals = 7 status lines. The staging twin is created and populated by // a staged deploy, NOT by provision, so it does not appear here. @@ -7029,7 +7201,8 @@ build = \"cargo build --release\" let out = FastlyCliAdapter .provision(dir.path(), Some("fastly.toml"), None, &stores, true) - .expect("dry-run succeeds"); + .expect("dry-run succeeds") + .into_messages(); assert!(out.iter().any(|line| { line.contains( @@ -7091,7 +7264,8 @@ build = \"cargo build --release\" let out = FastlyCliAdapter .provision(dir.path(), Some("fastly.toml"), None, &stores, false) - .expect("default mappings need no remote runtime-env store"); + .expect("default mappings need no remote runtime-env store") + .into_messages(); assert!( out.iter() @@ -7231,7 +7405,8 @@ build = \"cargo build --release\" let out = FastlyCliAdapter .provision(dir.path(), Some("fastly.toml"), None, &stores, false) - .expect("mapping reconciliation succeeds"); + .expect("mapping reconciliation succeeds") + .into_messages(); let log = fs::read_to_string(&oplog).expect("oplog"); let manifest_dir = fs::canonicalize(dir.path()).expect("canonical manifest dir"); @@ -7398,7 +7573,8 @@ build = \"cargo build --release\" }; let out = FastlyCliAdapter .provision(dir.path(), Some("fastly.toml"), None, &stores, false) - .expect("no-store provision is fine"); + .expect("no-store provision is fine") + .into_messages(); assert_eq!(out, vec!["fastly has no declared stores to provision"]); } @@ -7429,7 +7605,8 @@ build = \"cargo build --release\" let out = FastlyCliAdapter .provision(dir.path(), Some("fastly.toml"), None, &stores, false) - .expect("skip path succeeds"); + .expect("skip path succeeds") + .into_messages(); assert_eq!(out.len(), 1); assert!(out[0].contains("already declared"), "got: {out:?}"); let manifest_dir = fs::canonicalize(dir.path()).expect("canonical manifest dir"); @@ -8158,6 +8335,80 @@ exit 1 GUARD.get_or_init(|| Mutex::new(())) } + /// A fake `curl` that answers every health probe with HTTP `code`. + #[cfg(unix)] + fn fake_curl(code: u16) -> tempfile::TempDir { + use std::os::unix::fs::PermissionsExt as _; + let dir = tempdir().expect("tempdir"); + let script_path = dir.path().join("curl"); + fs::write(&script_path, format!("#!/bin/sh\necho {code}\n")).expect("write script"); + let mut perms = fs::metadata(&script_path).expect("meta").permissions(); + perms.set_mode(0o755); + fs::set_permissions(&script_path, perms).expect("chmod +x"); + dir + } + + #[cfg(unix)] + #[test] + fn healthcheck_reports_a_structured_outcome() { + let _lock = path_mutation_guard().lock().expect("guard"); + // No token: the production probe is service-level (no API calls). + let _token = EnvOverride::remove(FASTLY_API_TOKEN_ENV); + let args = |retry: &str| -> Vec { + [ + "--domain", + "app.example.com", + "--service-id", + "SVC1", + "--version", + "7", + "--retry", + retry, + "--retry-delay", + "0", + ] + .into_iter() + .map(str::to_owned) + .collect() + }; + + let healthy_curl = fake_curl(200); + let healthy_path = PathPrepend::new(healthy_curl.path()); + let healthy = healthcheck(&args("3")).expect("probe runs"); + assert_eq!( + healthy, + HealthcheckOutcome { + attempts: 1, + domain: "app.example.com".to_owned(), + failure: None, + healthy: true, + path: "/".to_owned(), + service_id: "SVC1".to_owned(), + staging: false, + staging_ip: None, + status_code: Some(200), + version: 7, + version_verified: false, + } + ); + drop(healthy_path); + + let unhealthy_curl = fake_curl(503); + let _path = PathPrepend::new(unhealthy_curl.path()); + let unhealthy = + healthcheck(&args("2")).expect("an unhealthy probe is an outcome, not an error"); + assert!(!unhealthy.healthy); + assert_eq!(unhealthy.attempts, 2); + assert_eq!(unhealthy.status_code, Some(503)); + let failure = unhealthy + .failure + .expect("an unhealthy outcome carries its reason"); + assert!( + failure.contains("failed after 2 attempt(s)"), + "keeps the pre-existing message: {failure}" + ); + } + #[cfg(unix)] #[test] fn read_remote_returns_present_on_success() { @@ -9774,7 +10025,7 @@ echo 'unexpected' >&2; exit 1 manifest.display().to_string(), ]; args.extend(extra.iter().map(|arg| (*arg).to_owned())); - let result = deploy_staging(&args); + let result = deploy_staging(&args).map(|_version| ()); let recorded = fs::read_to_string(&record).unwrap_or_default(); let lines = recorded.lines().map(str::to_owned).collect(); @@ -9839,7 +10090,8 @@ echo 'unexpected' >&2; exit 1 #[cfg(unix)] fn run_gc(dir: &Path, older_than_secs: u64, dry_run: bool) -> Result, String> { - FastlyCliAdapter.gc_config_entries( + // Mirror the CLI: a report carrying a failure diagnostic is an error. + let report = FastlyCliAdapter.gc_config_entries( dir, None, None, @@ -9847,7 +10099,11 @@ echo 'unexpected' >&2; exit 1 &AdapterPushContext::new(), older_than_secs, dry_run, - ) + )?; + match report.failure_diagnostic { + Some(diagnostic) => Err(diagnostic), + None => Ok(report.text_lines), + } } /// gc never deletes a chunk the LIVE root pointer references, however old. diff --git a/crates/edgezero-adapter-spin/src/cli.rs b/crates/edgezero-adapter-spin/src/cli.rs index 2faf73d0c..3cf745a8f 100644 --- a/crates/edgezero-adapter-spin/src/cli.rs +++ b/crates/edgezero-adapter-spin/src/cli.rs @@ -14,10 +14,13 @@ use std::process::Command; use ctor::ctor; use edgezero_adapter::cli_support::{ - find_manifest_upwards, find_workspace_root, path_distance, read_package_name, run_native_cli, + find_manifest_upwards, find_workspace_root, native_auth_status, path_distance, + read_package_name, run_native_cli, }; +use edgezero_adapter::process; use edgezero_adapter::registry::{ - Adapter, AdapterAction, AdapterPushContext, ProvisionStores, ReadConfigEntry, ResolvedStoreId, + ActionOutcome, Adapter, AdapterAction, AdapterPushContext, BuildOutcome, ProvisionAction, + ProvisionEntry, ProvisionReport, ProvisionStores, ReadConfigEntry, ResolvedStoreId, StoreKind, TypedSecretEntry, register_adapter, }; use edgezero_adapter::scaffold::{ @@ -136,27 +139,32 @@ struct SpinCliAdapter; reason = "KV-backed config dropped Spin's `^[a-z][a-z0-9_]*$` key rule and the config-vs-secret collision check, so `validate_app_config_keys` falls back to the trait default `Ok(())`. `validate_typed_secrets` IS overridden below (secret-value canonicalisation + within-secrets uniqueness still apply). `validate_adapter_manifest` IS overridden below (Spin's multi-component disambiguation). `read_config_entry` and `read_config_entry_local` are both overridden below (four-branch SQLite-direct / Fermyon Cloud / non-Spin-backend dispatch)." )] impl Adapter for SpinCliAdapter { - fn execute(&self, action: AdapterAction, args: &[String]) -> Result<(), String> { + fn execute(&self, action: AdapterAction, args: &[String]) -> Result { match action { // `spin cloud {login|logout|info}` is the native sign-in // surface for Fermyon Cloud. EdgeZero stores no // credentials — this is a thin shell-out. AdapterAction::AuthLogin => { run_native_cli("spin", &["cloud", "login"], SPIN_INSTALL_HINT) + .map(|()| ActionOutcome::Empty) } AdapterAction::AuthLogout => { run_native_cli("spin", &["cloud", "logout"], SPIN_INSTALL_HINT) + .map(|()| ActionOutcome::Empty) } AdapterAction::AuthStatus => { - run_native_cli("spin", &["cloud", "info"], SPIN_INSTALL_HINT) + native_auth_status("spin", &["cloud", "info"], SPIN_INSTALL_HINT) + .map(ActionOutcome::AuthStatus) } AdapterAction::Build => { let artifact = build(args)?; log::info!("[edgezero] Spin build complete -> {}", artifact.display()); - Ok(()) + Ok(ActionOutcome::Build(BuildOutcome { + artifact: Some(artifact), + })) } - AdapterAction::Deploy => deploy(args), - AdapterAction::Serve => serve(args), + AdapterAction::Deploy => deploy(args).map(|()| ActionOutcome::Empty), + AdapterAction::Serve => serve(args).map(|()| ActionOutcome::Empty), // The Fastly staging lifecycle is Fastly-only. AdapterAction::DeployStaging | AdapterAction::EmitVersion @@ -187,7 +195,7 @@ impl Adapter for SpinCliAdapter { component_selector: Option<&str>, stores: &ProvisionStores<'_>, dry_run: bool, - ) -> Result, String> { + ) -> Result { //: spin provision is pure spin.toml editing — no // shell-out (Spin KV stores are provisioned by the Spin // runtime / Fermyon at deploy). For each declared KV id @@ -203,17 +211,22 @@ impl Adapter for SpinCliAdapter { }; let spin_path = manifest_root.join(rel); - let mut out = Vec::new(); + let mut entries = Vec::new(); // Resolve the component once if either KV or config has // anything to provision. let needs_component = !stores.kv.is_empty() || !stores.config.is_empty(); if needs_component { let component_id = resolve_spin_component(&spin_path, component_selector)?; - for (kind, store) in stores + for (kind, store_kind, store) in stores .kv .iter() - .map(|store| ("KV", store)) - .chain(stores.config.iter().map(|store| ("config", store))) + .map(|store| ("KV", StoreKind::Kv, store)) + .chain( + stores + .config + .iter() + .map(|store| ("config", StoreKind::Config, store)), + ) { let logical = store.logical.as_str(); // The label the runtime opens is what @@ -225,37 +238,39 @@ impl Adapter for SpinCliAdapter { // runtime lookup match. let label = store.platform.as_str(); if dry_run { - out.push(format!( + entries.push(ProvisionEntry::for_store(ProvisionAction::WouldCreate, store_kind, store, format!( "would ensure {kind} label `{label}` (logical id `{logical}`) is in [component.{component_id}].key_value_stores in {}", spin_path.display() - )); + ))); continue; } let added = ensure_kv_label_in_component(&spin_path, &component_id, label)?; if added { - out.push(format!( + entries.push(ProvisionEntry::for_store(ProvisionAction::Updated, store_kind, store, format!( "added {kind} label `{label}` (logical id `{logical}`) to [component.{component_id}].key_value_stores in {}", spin_path.display() - )); + ))); } else { - out.push(format!( + entries.push(ProvisionEntry::for_store(ProvisionAction::AlreadyPresent, store_kind, store, format!( "{kind} label `{label}` (logical id `{logical}`) already present in [component.{component_id}].key_value_stores in {}; skipping", spin_path.display() - )); + ))); } } } for store in stores.secrets { let logical = store.logical.as_str(); let platform = store.platform.as_str(); - out.push(format!( + entries.push(ProvisionEntry::for_store(ProvisionAction::NotApplicable, StoreKind::Secrets, store, format!( "spin secret id `{logical}` (platform name `{platform}`) requires manual `[variables].* secret = true` + `[component.*.variables].*` declarations in spin.toml; nothing to do here" - )); + ))); } - if out.is_empty() { - out.push("spin has no declared stores to provision".to_owned()); + if entries.is_empty() { + entries.push(ProvisionEntry::note( + "spin has no declared stores to provision".to_owned(), + )); } - Ok(out) + Ok(ProvisionReport { entries }) } fn push_config_entries( @@ -1060,20 +1075,21 @@ pub fn build(extra_args: &[String]) -> Result { let cargo_manifest = manifest_dir.join("Cargo.toml"); let crate_name = read_package_name(&cargo_manifest)?; - let status = Command::new("cargo") - .args([ - "build", - "--release", - "--target", - TARGET_TRIPLE, - "--manifest-path", - cargo_manifest - .to_str() - .ok_or("invalid Cargo manifest path")?, - ]) - .args(extra_args) - .status() - .map_err(|err| format!("failed to run cargo build: {err}"))?; + let status = process::status( + Command::new("cargo") + .args([ + "build", + "--release", + "--target", + TARGET_TRIPLE, + "--manifest-path", + cargo_manifest + .to_str() + .ok_or("invalid Cargo manifest path")?, + ]) + .args(extra_args), + ) + .map_err(|err| format!("failed to run cargo build: {err}"))?; if !status.success() { return Err(format!("cargo build failed with status {status}")); } @@ -1100,12 +1116,13 @@ pub fn deploy(extra_args: &[String]) -> Result<(), String> { .parent() .ok_or_else(|| "spin manifest has no parent directory".to_owned())?; - let status = Command::new("spin") - .args(["deploy"]) - .args(extra_args) - .current_dir(manifest_dir) - .status() - .map_err(|err| format!("failed to run spin CLI: {err}"))?; + let status = process::status( + Command::new("spin") + .args(["deploy"]) + .args(extra_args) + .current_dir(manifest_dir), + ) + .map_err(|err| format!("failed to run spin CLI: {err}"))?; if !status.success() { return Err(format!("spin deploy failed with status {status}")); } @@ -1207,12 +1224,13 @@ pub fn serve(extra_args: &[String]) -> Result<(), String> { .parent() .ok_or_else(|| "spin manifest has no parent directory".to_owned())?; - let status = Command::new("spin") - .args(["up"]) - .args(extra_args) - .current_dir(manifest_dir) - .status() - .map_err(|err| format!("failed to run spin CLI: {err}"))?; + let status = process::status( + Command::new("spin") + .args(["up"]) + .args(extra_args) + .current_dir(manifest_dir), + ) + .map_err(|err| format!("failed to run spin CLI: {err}"))?; if !status.success() { return Err(format!("spin up failed with status {status}")); } @@ -1382,8 +1400,12 @@ mod tests { reason = "StubAdapter exercises only the trait default for validate_typed_secrets" )] impl Adapter for StubAdapter { - fn execute(&self, _action: AdapterAction, _args: &[String]) -> Result<(), String> { - Ok(()) + fn execute( + &self, + _action: AdapterAction, + _args: &[String], + ) -> Result { + Ok(ActionOutcome::Empty) } fn name(&self) -> &'static str { "stub" @@ -1653,7 +1675,8 @@ mod tests { }; let out = SpinCliAdapter .provision(dir.path(), Some("spin.toml"), None, &stores, true) - .expect("dry-run succeeds"); + .expect("dry-run succeeds") + .into_messages(); assert_eq!(out.len(), 2); assert!(out[0].contains("would ensure KV label `sessions`")); assert!(out[1].contains("would ensure KV label `cache`")); @@ -1684,7 +1707,8 @@ mod tests { }; let out = SpinCliAdapter .provision(dir.path(), Some("spin.toml"), None, &stores, false) - .expect("real-run succeeds"); + .expect("real-run succeeds") + .into_messages(); assert!( out[0].contains("`prod_sessions`") && out[0].contains("`sessions`"), "status line names BOTH the platform label and the logical id: {out:?}" @@ -1716,7 +1740,8 @@ mod tests { }; let out = SpinCliAdapter .provision(dir.path(), Some("spin.toml"), None, &stores, false) - .expect("real run succeeds"); + .expect("real run succeeds") + .into_messages(); assert_eq!(out.len(), 1); assert!(out[0].contains("added KV label `sessions`"), "got: {out:?}"); let after = fs::read_to_string(dir.path().join("spin.toml")).expect("read back"); @@ -1764,7 +1789,8 @@ mod tests { }; let out = SpinCliAdapter .provision(dir.path(), Some("spin.toml"), None, &stores, false) - .expect("config + secrets provision succeeds"); + .expect("config + secrets provision succeeds") + .into_messages(); assert_eq!(out.len(), 2); assert!( out[0].contains("config label") && out[0].contains("key_value_stores"), @@ -1796,7 +1822,8 @@ mod tests { }; let out = SpinCliAdapter .provision(dir.path(), Some("spin.toml"), None, &stores, false) - .expect("no-store provision is fine"); + .expect("no-store provision is fine") + .into_messages(); assert_eq!(out, vec!["spin has no declared stores to provision"]); } diff --git a/crates/edgezero-adapter/src/cli_support.rs b/crates/edgezero-adapter/src/cli_support.rs index a5e664d9f..4837aa85d 100644 --- a/crates/edgezero-adapter/src/cli_support.rs +++ b/crates/edgezero-adapter/src/cli_support.rs @@ -8,6 +8,9 @@ use std::io::ErrorKind; use std::path::{Path, PathBuf}; use std::process::Command; +use crate::process; +use crate::registry::{AuthState, AuthStatusOutcome}; + /// Walks up the directory tree looking for `manifest_name` alongside a `Cargo.toml`. #[inline] #[must_use] @@ -80,21 +83,46 @@ pub fn path_distance(left: &Path, right: &Path) -> usize { /// the child fails to spawn, or it exits non-zero. #[inline] pub fn run_native_cli(program: &str, args: &[&str], install_hint: &str) -> Result<(), String> { - let status = Command::new(program).args(args).status().map_err(|err| { + match native_auth_status(program, args, install_hint)?.failure { + None => Ok(()), + Some(failure) => Err(failure), + } +} + +/// [`run_native_cli`] for a session probe (`wrangler whoami`, …): a non-zero +/// exit is a RESULT (`Unauthenticated`, carrying the same message +/// `run_native_cli` would return), not an error. +/// +/// # Errors +/// Returns an error string if the binary is missing from `PATH` or the child +/// fails to spawn. +#[inline] +pub fn native_auth_status( + program: &str, + args: &[&str], + install_hint: &str, +) -> Result { + let status = process::status(Command::new(program).args(args)).map_err(|err| { if err.kind() == ErrorKind::NotFound { format!("`{program}` not found on PATH; {install_hint}") } else { format!("failed to spawn `{program}`: {err}") } })?; - if status.success() { - Ok(()) + Ok(if status.success() { + AuthStatusOutcome { + failure: None, + state: AuthState::Authenticated, + } } else { - Err(format!( - "`{program} {}` exited with status {status}", - args.join(" ") - )) - } + AuthStatusOutcome { + failure: Some(format!( + "`{program} {}` exited with status {status}", + args.join(" ") + )), + state: AuthState::Unauthenticated, + } + }) } /// Reads the crate name from a `Cargo.toml`, supporting both the inline and `[package]` forms. @@ -242,4 +270,20 @@ mod tests { "expected the exit-status branch, got: {err}" ); } + + #[test] + fn native_auth_status_maps_exit_status_to_state() { + let ok = native_auth_status("true", &[], "hint").expect("spawns"); + assert_eq!(ok.state, AuthState::Authenticated); + assert_eq!(ok.failure, None); + + let unauthenticated = native_auth_status("false", &[], "hint").expect("spawns"); + assert_eq!(unauthenticated.state, AuthState::Unauthenticated); + let failure = unauthenticated.failure.expect("failure message"); + assert!(failure.contains("exited with status"), "got: {failure}"); + + let missing = native_auth_status("edgezero-no-such-program-xyz", &[], "install it") + .expect_err("missing program is an error, not a state"); + assert!(missing.contains("install it"), "got: {missing}"); + } } diff --git a/crates/edgezero-adapter/src/lib.rs b/crates/edgezero-adapter/src/lib.rs index 607548d25..3cf555717 100644 --- a/crates/edgezero-adapter/src/lib.rs +++ b/crates/edgezero-adapter/src/lib.rs @@ -1,3 +1,5 @@ +pub mod process; + pub mod registry; pub mod scaffold; diff --git a/crates/edgezero-adapter/src/process.rs b/crates/edgezero-adapter/src/process.rs new file mode 100644 index 000000000..15a26ec1d --- /dev/null +++ b/crates/edgezero-adapter/src/process.rs @@ -0,0 +1,81 @@ +//! Child-process spawning under a process-wide stdout policy. +//! +//! `--format json` reserves the CLI's stdout for exactly one JSON document, so +//! a child that inherits stdout (`cargo`, `fastly`, `wrangler`, `spin`, a +//! manifest shell command) must write to stderr instead. Every inheriting +//! spawn in the workspace goes through [`status`]; `clippy.toml` disallows +//! `Command::status` / `Command::spawn` elsewhere so a new call site cannot +//! bypass the policy. Children whose stdout is captured (`.output()`, piped +//! stdio) never touch the CLI's stdout and are unaffected. +//! +//! The policy is process-wide because a CLI run executes exactly one command; +//! the CLI sets it once per command (see `edgezero_cli`'s `OutputScope`). + +use std::io; +use std::process::{Command, ExitStatus, Stdio}; +use std::sync::atomic::{AtomicBool, Ordering}; + +static CHILD_STDOUT_TO_STDERR: AtomicBool = AtomicBool::new(false); + +/// Whether inheriting children currently have their stdout redirected to the +/// parent's stderr. +#[inline] +#[must_use] +pub fn child_stdout_to_stderr() -> bool { + CHILD_STDOUT_TO_STDERR.load(Ordering::SeqCst) +} + +/// Redirect (`true`) or restore (`false`) inheriting children's stdout. +/// Returns the previous setting so a scope guard can restore it. +#[inline] +pub fn set_child_stdout_to_stderr(enabled: bool) -> bool { + CHILD_STDOUT_TO_STDERR.swap(enabled, Ordering::SeqCst) +} + +/// Run `command` to completion with inherited stdio, except that its stdout is +/// sent to the parent's stderr while [`child_stdout_to_stderr`] is set. +/// +/// # Errors +/// Returns the spawn error if the child cannot be started. +#[inline] +pub fn status(command: &mut Command) -> io::Result { + if child_stdout_to_stderr() { + command.stdout(Stdio::from(io::stderr())); + } + #[expect( + clippy::disallowed_methods, + reason = "the one sanctioned inheriting spawn: the stdout policy is applied above" + )] + command.status() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{LazyLock, Mutex}; + + /// Serialises tests that flip the process-wide policy. + static POLICY_LOCK: LazyLock> = LazyLock::new(|| Mutex::new(())); + + #[test] + fn set_returns_previous_and_get_reflects_it() { + let _guard = POLICY_LOCK.lock().expect("lock"); + let original = set_child_stdout_to_stderr(true); + assert!(child_stdout_to_stderr()); + assert!(set_child_stdout_to_stderr(false)); + assert!(!child_stdout_to_stderr()); + set_child_stdout_to_stderr(original); + } + + #[cfg(unix)] + #[test] + fn status_reports_child_exit() { + let _guard = POLICY_LOCK.lock().expect("lock"); + let original = set_child_stdout_to_stderr(true); + let ok = status(Command::new("sh").args(["-c", "echo routed; exit 0"])).expect("spawn"); + let failed = status(Command::new("sh").args(["-c", "exit 3"])).expect("spawn"); + set_child_stdout_to_stderr(original); + assert!(ok.success()); + assert_eq!(failed.code(), Some(3_i32)); + } +} diff --git a/crates/edgezero-adapter/src/registry.rs b/crates/edgezero-adapter/src/registry.rs index 2ff6dc326..5cfedf134 100644 --- a/crates/edgezero-adapter/src/registry.rs +++ b/crates/edgezero-adapter/src/registry.rs @@ -1,5 +1,5 @@ use std::collections::HashMap; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::sync::{LazyLock, PoisonError, RwLock}; static REGISTRY: LazyLock>> = @@ -41,6 +41,102 @@ pub enum AdapterAction { Serve, } +/// What an [`Adapter::execute`] action produced. The CLI renders it as the +/// pre-existing text output or, under `--format json`, as a JSON result, so +/// adapters report data here instead of printing it. +/// +/// A negative result the action still measured (an unhealthy probe, an +/// unauthenticated session) is an `Ok` outcome carrying a `failure` message; +/// the CLI turns it into a non-zero exit. `Err` is reserved for "no result +/// could be determined". +#[derive(Clone, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum ActionOutcome { + ActiveVersion(ActiveVersionOutcome), + AuthStatus(AuthStatusOutcome), + Build(BuildOutcome), + Deploy(DeployOutcome), + /// Nothing structured to report (login, logout, serve). + Empty, + Healthcheck(HealthcheckOutcome), + Rollback(RollbackOutcome), +} + +/// Result of [`AdapterAction::EmitVersion`]. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ActiveVersionOutcome { + pub service_id: String, + /// `None` when the service has no active version yet. + pub version: Option, +} + +/// Result of [`AdapterAction::AuthStatus`]. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AuthStatusOutcome { + /// Why the session is not authenticated (`Some` exactly when `state` is + /// [`AuthState::Unauthenticated`]). + pub failure: Option, + pub state: AuthState, +} + +/// Session state reported by `auth status`. +/// +/// Deliberately exhaustive (like [`ProvisionAction`] and [`StoreKind`]): the +/// CLI maps each variant onto its versioned JSON schema, so a new variant must +/// fail to compile there until the schema covers it. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AuthState { + Authenticated, + /// The adapter has no remote auth surface (axum). + NotApplicable, + Unauthenticated, +} + +/// Result of [`AdapterAction::Build`]. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BuildOutcome { + /// The built artifact, when the adapter knows it. + pub artifact: Option, +} + +/// Result of [`AdapterAction::Deploy`] / [`AdapterAction::DeployStaging`]. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DeployOutcome { + /// The staged (or activated) platform version, when known. + pub version: Option, +} + +/// Result of [`AdapterAction::Healthcheck`]. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct HealthcheckOutcome { + /// Probes actually made. + pub attempts: u32, + pub domain: String, + /// Why the probe is unhealthy (`Some` exactly when `healthy` is false). + pub failure: Option, + pub healthy: bool, + pub path: String, + pub service_id: String, + pub staging: bool, + pub staging_ip: Option, + /// The last HTTP status observed, if any probe got a response. + pub status_code: Option, + pub version: u64, + /// Whether `version` was verified ACTIVE before and after the probe. + pub version_verified: bool, +} + +/// Result of [`AdapterAction::Rollback`]. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RollbackOutcome { + /// The re-activated version (`None` for a staging rollback). + pub rolled_back_to: Option, + pub service_id: String, + pub staging: bool, + /// The version rolled back from, or the staged version deactivated. + pub version: u64, +} + /// A single declared store id, paired with the platform name the /// runtime will resolve via `EDGEZERO__STORES______NAME`. /// @@ -114,6 +210,138 @@ pub struct ProvisionStores<'stores> { pub secrets: &'stores [ResolvedStoreId], } +/// What [`Adapter::provision`] did, one entry per status line, in order. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct ProvisionReport { + pub entries: Vec, +} + +impl ProvisionReport { + /// The entries' text-output lines, in order. + #[inline] + #[must_use] + pub fn into_messages(self) -> Vec { + self.entries + .into_iter() + .map(|entry| entry.message) + .collect() + } +} + +/// One provision step. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ProvisionEntry { + pub action: ProvisionAction, + /// The exact line the text output prints (may span several lines). + pub message: String, + /// The store the step concerns; `None` for adapter-level notes. + pub store: Option, +} + +impl ProvisionEntry { + /// An entry about one declared store. + #[inline] + #[must_use] + pub fn for_store( + action: ProvisionAction, + kind: StoreKind, + store: &ResolvedStoreId, + message: String, + ) -> Self { + Self { + action, + message, + store: Some(ProvisionStoreRef { + kind, + logical: Some(store.logical.clone()), + platform: store.platform.clone(), + }), + } + } + + /// An adapter-level note that concerns no single store. + #[inline] + #[must_use] + pub fn note(message: String) -> Self { + Self { + action: ProvisionAction::Note, + message, + store: None, + } + } +} + +/// What a provision step did (or would do, under `dry_run`). +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ProvisionAction { + AlreadyPresent, + Created, + /// The platform manages this store itself; nothing to provision. + NotApplicable, + Note, + Updated, + WouldCreate, + WouldUpdate, +} + +/// The store a [`ProvisionEntry`] concerns. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ProvisionStoreRef { + pub kind: StoreKind, + /// The declared logical id; `None` for stores `EdgeZero` itself owns + /// (e.g. Fastly's runtime-override store). + pub logical: Option, + pub platform: String, +} + +/// A `[stores.]` kind. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum StoreKind { + Config, + Kv, + Secrets, +} + +/// What [`Adapter::gc_config_entries`] found and did. +/// +/// A run whose deletes partly failed is still `Ok`: `failed` is non-empty and +/// `failure_diagnostic` carries the operator-facing recovery text, which the +/// CLI turns into a non-zero exit. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct GcReport { + /// Entries deleted; `None` on a dry run. + pub deleted: Option, + /// Entries in the store. + pub entries: usize, + pub failed: Vec, + /// `Some` exactly when `failed` is non-empty. + pub failure_diagnostic: Option, + pub generations_planned: usize, + pub kept_roots: Vec, + /// Orphan chunks planned for deletion, with their ages. + pub planned: Vec, + pub referenced_chunks: usize, + /// Orphans too recent for the `older_than` window. + pub retained_recent: usize, + pub roots: usize, + /// The platform's id for the swept store, when resolved. + pub store_id: Option, + pub stranded: Vec, + /// The human-readable report, rendered only by the text output. + pub text_lines: Vec, + pub uncertain: Vec, + /// Chunk-shaped entries left untouched because they could not be proved. + pub unprovable: usize, + pub warnings: Vec, +} + +/// One orphan chunk `config gc` would delete. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GcCandidate { + pub age_secs: u64, + pub key: String, +} + /// Context passed to [`Adapter::push_config_entries`] and /// [`Adapter::push_config_entries_local`] carrying already-resolved /// `config push` overlay values. @@ -286,9 +514,12 @@ pub trait Adapter: Sync + Send { /// typed parameter structs (e.g. `BuildArgs { manifest_root, /// extra_args }`) mirroring the rest of the trait. /// + /// Returns what the action produced (see [`ActionOutcome`]); the CLI + /// owns rendering it, so adapters report data here rather than logging it. + /// /// # Errors /// Returns an error string if the requested adapter action fails. - fn execute(&self, action: AdapterAction, args: &[String]) -> Result<(), String>; + fn execute(&self, action: AdapterAction, args: &[String]) -> Result; /// Reclaim chunk entries that no LIVE config pointer references. /// @@ -328,7 +559,7 @@ pub trait Adapter: Sync + Send { _push_ctx: &AdapterPushContext<'_>, _older_than_secs: u64, _dry_run: bool, - ) -> Result, String> { + ) -> Result { Err(format!( "adapter `{}` does not implement `config gc`", self.name() @@ -376,9 +607,10 @@ pub trait Adapter: Sync + Send { } /// Provision the platform resources backing each store id the - /// user declared. Returns a list of human-readable - /// status lines the CLI logs verbatim — one line per resource - /// created, skipped, or that would be created under `dry_run`. + /// user declared. Returns a [`ProvisionReport`] with one entry per + /// resource created, skipped, or that would be created under + /// `dry_run`; each entry's `message` is the line the CLI's text + /// output prints verbatim. /// /// `manifest_root` is the directory containing the user's /// `edgezero.toml`. `adapter_manifest_path` and @@ -387,7 +619,7 @@ pub trait Adapter: Sync + Send { /// (`wrangler.toml`, `fastly.toml`, `spin.toml`) relative to /// the root. `stores` carries the declared ids per kind. /// - /// Default: no-op (returns an empty `Vec`) so adapters that + /// Default: no-op (returns an empty report) so adapters that /// don't own any platform resources don't need to override. /// /// # Errors @@ -402,8 +634,8 @@ pub trait Adapter: Sync + Send { _component_selector: Option<&str>, _stores: &ProvisionStores<'_>, _dry_run: bool, - ) -> Result, String> { - Ok(Vec::new()) + ) -> Result { + Ok(ProvisionReport::default()) } /// Push config entries into the platform's config store backing @@ -671,9 +903,13 @@ mod tests { reason = "TestAdapter only exercises register / get / execute; the validation methods inherit the trait defaults (no-ops)" )] impl Adapter for TestAdapter { - fn execute(&self, _action: AdapterAction, _args: &[String]) -> Result<(), String> { + fn execute( + &self, + _action: AdapterAction, + _args: &[String], + ) -> Result { HIT.store(self.hit_value, Ordering::SeqCst); - Ok(()) + Ok(ActionOutcome::Empty) } fn name(&self) -> &'static str { diff --git a/crates/edgezero-cli/src/adapter.rs b/crates/edgezero-cli/src/adapter.rs index ad15f4636..8af39c9a5 100644 --- a/crates/edgezero-cli/src/adapter.rs +++ b/crates/edgezero-cli/src/adapter.rs @@ -1,4 +1,7 @@ -use edgezero_adapter::registry::{self as adapter_registry, AdapterAction}; +use edgezero_adapter::process; +use edgezero_adapter::registry::{ + self as adapter_registry, ActionOutcome, AdapterAction, AuthState, AuthStatusOutcome, +}; use edgezero_core::manifest::{Manifest, ManifestLoader, ResolvedEnvironment}; use std::env; @@ -112,7 +115,7 @@ pub fn execute( action: Action, manifest_loader: Option<&ManifestLoader>, adapter_args: &[String], -) -> Result<(), String> { +) -> Result { if let Some(loader) = manifest_loader && let Some(command) = manifest_command(loader.manifest(), adapter_name, action) { @@ -162,7 +165,8 @@ pub fn execute( /// 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). +/// the Fastly API). The built-in outcome carries no version for a +/// production deploy, so it is not returned. pub fn execute_capture( adapter_name: &str, action: Action, @@ -306,6 +310,12 @@ fn build_shell_command( Ok(cmd) } +/// Run a manifest-declared adapter command with inherited stdio (stdout +/// routed to stderr under `--format json`, see [`process::status`]). +/// +/// A manifest `auth_status` command is a session probe: its non-zero exit +/// is an `Unauthenticated` outcome (carrying the usual message), not an +/// error. Every other command reports no structured outcome. fn run_shell( command: &str, cwd: &Path, @@ -314,7 +324,7 @@ fn run_shell( environment: Option, adapter_bind: (Option, Option), adapter_args: &[String], -) -> Result<(), String> { +) -> Result { let mut cmd = build_shell_command( command, cwd, @@ -324,16 +334,22 @@ fn run_shell( adapter_args, )?; - let status = cmd - .status() + let status = process::status(&mut cmd) .map_err(|err| format!("failed to run {action} command `{command}`: {err}"))?; - if status.success() { - Ok(()) - } else { - Err(format!( - "{action} command `{command}` exited with status {status}" - )) + let exit_failure = (!status.success()) + .then(|| format!("{action} command `{command}` exited with status {status}")); + match (action, exit_failure) { + (Action::AuthStatus, failure) => Ok(ActionOutcome::AuthStatus(AuthStatusOutcome { + state: if failure.is_some() { + AuthState::Unauthenticated + } else { + AuthState::Authenticated + }, + failure, + })), + (_, Some(message)) => Err(message), + (_, None) => Ok(ActionOutcome::Empty), } } @@ -367,8 +383,8 @@ fn tee_stream(reader: R, mut writer: W) -> String { } /// 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 +/// piped, echoed through to our own stdout/stderr as they arrive (stdout +/// is echoed to OUR stderr under `--format json`), 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( @@ -390,6 +406,10 @@ fn run_shell_tee( )?; cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); + #[expect( + clippy::disallowed_methods, + reason = "stdout/stderr are piped above; the echo target below honours the stdout policy" + )] let mut child = cmd .spawn() .map_err(|err| format!("failed to run {action} command `{command}`: {err}"))?; @@ -405,7 +425,11 @@ fn run_shell_tee( // 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_stdout = if process::child_stdout_to_stderr() { + tee_stream(child_stdout, io::stderr()) + } else { + tee_stream(child_stdout, io::stdout()) + }; let captured_stderr = stderr_worker.join().unwrap_or_default(); let status = child diff --git a/crates/edgezero-cli/src/args.rs b/crates/edgezero-cli/src/args.rs index cc86e79c3..4365a059c 100644 --- a/crates/edgezero-cli/src/args.rs +++ b/crates/edgezero-cli/src/args.rs @@ -130,6 +130,11 @@ pub struct ConfigGcArgs { /// with `--yes` -- a single run cannot both preview and delete. #[arg(long, conflicts_with = "yes")] pub dry_run: bool, + /// Output format: `text` (default, the human-readable output) or + /// `json` (a single versioned JSON envelope on stdout; everything else + /// goes to stderr). + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + pub format: OutputFormat, /// Path to `edgezero.toml`. #[arg(long, default_value = "edgezero.toml")] pub manifest: PathBuf, @@ -173,6 +178,7 @@ impl Default for ConfigGcArgs { Self { adapter: String::new(), dry_run: false, + format: OutputFormat::Text, manifest: PathBuf::from("edgezero.toml"), no_env: false, older_than: None, @@ -226,6 +232,9 @@ pub enum AuthSub { Status { #[arg(long)] adapter: String, + /// Output format: `text` (default) or `json`. + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, }, } @@ -236,9 +245,15 @@ pub struct BuildArgs { /// Target adapter name. #[arg(long = "adapter", required = true)] pub adapter: String, - /// Arguments passed through to the adapter build command. + /// Arguments passed through to the adapter build command. `--format` + /// must come before the first passthrough token. #[arg(trailing_var_arg = true, allow_hyphen_values = true)] pub adapter_args: Vec, + /// Output format: `text` (default, the human-readable output) or + /// `json` (a single versioned JSON envelope on stdout; everything else + /// goes to stderr). + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + pub format: OutputFormat, } /// Arguments for the `deploy` command. @@ -255,6 +270,11 @@ pub struct DeployArgs { /// staging-intended deploy to PRODUCTION. #[arg(last = true)] pub adapter_args: Vec, + /// Output format: `text` (default, the human-readable output) or + /// `json` (a single versioned JSON envelope on stdout; everything else + /// goes to stderr). + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + pub format: OutputFormat, /// Platform service id the deploy targets. Consumed by the Fastly /// staging lifecycle: production deploy passes it /// through to `fastly compute deploy` and resolves the activated @@ -293,6 +313,11 @@ pub struct ProvisionArgs { /// without performing them. #[arg(long)] pub dry_run: bool, + /// Output format: `text` (default, the human-readable output) or + /// `json` (a single versioned JSON envelope on stdout; everything else + /// goes to stderr). + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + pub format: OutputFormat, /// Path to the manifest (default: `edgezero.toml`). #[arg(long, default_value = "edgezero.toml")] pub manifest: PathBuf, @@ -312,6 +337,7 @@ impl Default for ProvisionArgs { Self { adapter: String::new(), dry_run: false, + format: OutputFormat::Text, manifest: default_manifest_path(), } } @@ -341,6 +367,11 @@ pub struct HealthcheckArgs { /// contract always threads it. #[arg(long, required = true)] pub domain: String, + /// Output format: `text` (default, the human-readable output) or + /// `json` (a single versioned JSON envelope on stdout; everything else + /// goes to stderr). + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + pub format: OutputFormat, /// URL path to probe on the domain (must begin with '/'). Applies to /// production and staging alike — staging reroutes the same URL to the /// resolved staging IP. Defaults to '/'. @@ -378,6 +409,11 @@ pub struct RollbackArgs { /// Target adapter name. #[arg(long = "adapter", required = true)] pub adapter: String, + /// Output format: `text` (default, the human-readable output) or + /// `json` (a single versioned JSON envelope on stdout; everything else + /// goes to stderr). + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + pub format: OutputFormat, /// Production only: the version to re-activate. Fastly exposes no /// metadata to tell a previously-live version from a staged one, so /// the rollback target CANNOT be inferred; it is captured before the @@ -405,11 +441,33 @@ pub struct ActiveVersionArgs { /// Target adapter name. #[arg(long = "adapter", required = true)] pub adapter: String, + /// Output format: `text` (default, the human-readable output) or + /// `json` (a single versioned JSON envelope on stdout; everything else + /// goes to stderr). + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + pub format: OutputFormat, /// Platform service id whose active version to resolve. Required. #[arg(long, required = true)] pub service_id: String, } +/// Output format for the commands with a machine-readable mode +/// (`active-version`, `auth status`, `build`, `config gc`, `config validate`, +/// `deploy`, `healthcheck`, `provision`, `rollback`). +/// +/// Distinct from [`DiffFormat`]: `config diff`'s `structured` / `unified` +/// renderings are diff-specific and meaningless here. +#[derive(clap::ValueEnum, Clone, Copy, Debug, Default, Eq, PartialEq)] +#[non_exhaustive] +pub enum OutputFormat { + /// A single versioned JSON envelope on stdout; all other output goes to + /// stderr. + Json, + /// Human-readable output (the default). + #[default] + Text, +} + /// Output format for `config diff`. #[derive(clap::ValueEnum, Clone, Debug, Default, PartialEq)] pub enum DiffFormat { @@ -609,6 +667,11 @@ pub struct ConfigValidateArgs { /// resolved from the manifest's `[app].name`, next to the manifest). #[arg(long)] pub app_config: Option, + /// Output format: `text` (default, the human-readable output) or + /// `json` (a single versioned JSON envelope on stdout; everything else + /// goes to stderr). + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + pub format: OutputFormat, /// Path to the manifest (default: `edgezero.toml`). #[arg(long, default_value = "edgezero.toml")] pub manifest: PathBuf, @@ -629,6 +692,7 @@ impl Default for ConfigValidateArgs { fn default() -> Self { Self { app_config: None, + format: OutputFormat::Text, manifest: default_manifest_path(), no_env: false, strict: false, @@ -687,6 +751,46 @@ pub fn parse_duration_secs(raw: &str) -> Result { mod tests { use super::*; + /// Every command with a machine-readable mode, without `--format`. + const FORMAT_COMMANDS: [&[&str]; 9] = [ + &[ + "edgezero", + "active-version", + "--adapter", + "fastly", + "--service-id", + "SVC1", + ], + &["edgezero", "auth", "status", "--adapter", "fastly"], + &["edgezero", "build", "--adapter", "fastly"], + &["edgezero", "config", "gc", "--adapter", "fastly"], + &["edgezero", "config", "validate"], + &["edgezero", "deploy", "--adapter", "fastly"], + &[ + "edgezero", + "healthcheck", + "--adapter", + "fastly", + "--domain", + "app.example.com", + "--service-id", + "SVC1", + "--version", + "7", + ], + &["edgezero", "provision", "--adapter", "fastly"], + &[ + "edgezero", + "rollback", + "--adapter", + "fastly", + "--service-id", + "SVC1", + "--version", + "7", + ], + ]; + /// Thin wrapper so `ConfigDiffArgs` (a `clap::Args`) can be /// tested via real clap parsing. `ConfigDiffArgs` is `#[non_exhaustive]` /// so it cannot be constructed with struct-literal syntax outside this @@ -792,6 +896,7 @@ mod tests { let Command::Build(BuildArgs { adapter, adapter_args, + .. }) = args.cmd else { panic!("expected Command::Build"); @@ -926,7 +1031,7 @@ mod tests { let args = Args::try_parse_from(["edgezero", "auth", "status", "--adapter", "spin"]) .expect("parse `auth status --adapter spin`"); let Command::Auth(AuthArgs { - sub: AuthSub::Status { adapter }, + sub: AuthSub::Status { adapter, .. }, }) = args.cmd else { panic!("expected Command::Auth(AuthSub::Status)"); @@ -1456,4 +1561,67 @@ mod tests { parse_duration_secs("-1d").unwrap_err(); parse_duration_secs("d").unwrap_err(); } + + /// The `--format` a `FORMAT_COMMANDS` invocation parsed to. + #[expect( + clippy::wildcard_enum_match_arm, + reason = "test helper: any other command is a bug in the test table" + )] + fn format_of(invocation: &[&str]) -> OutputFormat { + match Args::try_parse_from(invocation).expect("parses").cmd { + Command::ActiveVersion(parsed) => parsed.format, + Command::Auth(AuthArgs { + sub: AuthSub::Status { format, .. }, + }) => format, + Command::Build(parsed) => parsed.format, + Command::Config(ConfigCmd::Gc(args)) => args.format, + Command::Config(ConfigCmd::Validate(args)) => args.format, + Command::Deploy(parsed) => parsed.format, + Command::Healthcheck(parsed) => parsed.format, + Command::Provision(parsed) => parsed.format, + Command::Rollback(parsed) => parsed.format, + other => panic!("no `--format` on {other:?}"), + } + } + + #[test] + fn format_defaults_to_text_and_accepts_json_on_every_command() { + for argv in FORMAT_COMMANDS { + assert_eq!(format_of(argv), OutputFormat::Text, "{argv:?}"); + let mut with_json = argv.to_vec(); + with_json.extend(["--format", "json"]); + assert_eq!(format_of(&with_json), OutputFormat::Json, "{with_json:?}"); + let mut with_text = argv.to_vec(); + with_text.extend(["--format=text"]); + assert_eq!(format_of(&with_text), OutputFormat::Text, "{with_text:?}"); + } + } + + #[test] + fn format_rejects_diff_only_values() { + for value in ["unified", "structured"] { + let mut argv = FORMAT_COMMANDS[2].to_vec(); + argv.extend(["--format", value]); + Args::try_parse_from(&argv).expect_err("diff-only formats are rejected"); + } + } + + #[test] + fn build_format_precedes_passthrough_args() { + let args = Args::try_parse_from([ + "edgezero", + "build", + "--adapter", + "fastly", + "--format", + "json", + "--release", + ]) + .expect("parses"); + let Command::Build(build) = args.cmd else { + panic!("expected Command::Build"); + }; + assert_eq!(build.format, OutputFormat::Json); + assert_eq!(build.adapter_args, vec!["--release".to_owned()]); + } } diff --git a/crates/edgezero-cli/src/auth.rs b/crates/edgezero-cli/src/auth.rs index 02383b471..2c840b9f5 100644 --- a/crates/edgezero-cli/src/auth.rs +++ b/crates/edgezero-cli/src/auth.rs @@ -8,8 +8,11 @@ //! overrides live in `[adapters..commands].auth-{login,logout, //! status}` in `edgezero.toml`; `axum` is a no-op (no remote auth). +use edgezero_adapter::registry::{ActionOutcome, AuthState}; + use crate::adapter::{self, Action}; -use crate::args::{AuthArgs, AuthSub}; +use crate::args::{AuthArgs, AuthSub, OutputFormat}; +use crate::output::{self, AuthStatusResult, CommandName, Failure, Outcome, OutputScope}; use crate::{ensure_adapter_defined, load_manifest_optional}; /// Sign in / out / status against the adapter's native auth surface. @@ -24,12 +27,35 @@ pub fn run_auth(args: &AuthArgs) -> Result<(), String> { let (adapter_name, action) = match &args.sub { AuthSub::Login { adapter } => (adapter.as_str(), Action::AuthLogin), AuthSub::Logout { adapter } => (adapter.as_str(), Action::AuthLogout), - AuthSub::Status { adapter } => (adapter.as_str(), Action::AuthStatus), + AuthSub::Status { adapter, format } => return run_auth_status(adapter, *format), }; let manifest = load_manifest_optional()?; ensure_adapter_defined(adapter_name, manifest.as_ref())?; - adapter::execute(adapter_name, action, manifest.as_ref(), &[]) + adapter::execute(adapter_name, action, manifest.as_ref(), &[]).map(|_| ()) +} + +/// `auth status`: the only `auth` subcommand with a `--format`. +fn run_auth_status(adapter_name: &str, format: OutputFormat) -> Result<(), String> { + let _scope = OutputScope::enter(format); + output::finish(CommandName::AuthStatus, format, auth_status(adapter_name)) +} + +fn auth_status(adapter_name: &str) -> Outcome { + let manifest = load_manifest_optional()?; + ensure_adapter_defined(adapter_name, manifest.as_ref())?; + let outcome = adapter::execute(adapter_name, Action::AuthStatus, manifest.as_ref(), &[])?; + let ActionOutcome::AuthStatus(status) = outcome else { + return Err(format!("adapter `{adapter_name}` returned no auth status result").into()); + }; + let result = AuthStatusResult::new(adapter_name, status.state); + match (status.state, status.failure) { + (AuthState::Unauthenticated, failure) => Err(Failure::with_result( + failure.unwrap_or_else(|| format!("adapter `{adapter_name}` is not authenticated")), + result, + )), + (AuthState::Authenticated | AuthState::NotApplicable, _) => Ok(result), + } } #[cfg(test)] @@ -66,6 +92,7 @@ mod tests { }, AuthSub::Status { adapter: "fastly".to_owned(), + format: OutputFormat::Text, }, ] { run_auth(&AuthArgs { sub }).expect("auth subcommand runs"); diff --git a/crates/edgezero-cli/src/config.rs b/crates/edgezero-cli/src/config.rs index 97a16f7c7..18b59ec12 100644 --- a/crates/edgezero-cli/src/config.rs +++ b/crates/edgezero-cli/src/config.rs @@ -19,11 +19,12 @@ //! the values the runtime would. use crate::args::{ - ConfigDiffArgs, ConfigGcArgs, ConfigPushArgs, ConfigValidateArgs, DiffFormat, + ConfigDiffArgs, ConfigGcArgs, ConfigPushArgs, ConfigValidateArgs, DiffFormat, OutputFormat, parse_duration_secs, }; use crate::diff::{collect_changes, render_json, render_structured}; use crate::ensure_adapter_defined; +use crate::output::{self, CommandName, Failure, GcResult, Outcome, OutputScope, ValidateResult}; use edgezero_adapter::registry::{ self as adapter_registry, ReadConfigEntry, ResolvedStoreId, TypedSecretEntry, }; @@ -217,6 +218,11 @@ struct ResolvedTomlLeaf<'raw> { /// Returns a human-readable error string on any validation failure. #[inline] pub fn run_config_validate(args: &ConfigValidateArgs) -> Result<(), String> { + let _scope = OutputScope::enter(args.format); + output::finish(CommandName::ConfigValidate, args.format, validate_raw(args)) +} + +fn validate_raw(args: &ConfigValidateArgs) -> Outcome { let ctx = load_validation_context(args)?; run_shared_checks(&ctx)?; log::info!( @@ -224,7 +230,12 @@ pub fn run_config_validate(args: &ConfigValidateArgs) -> Result<(), String> { args.manifest.display(), if args.strict { " (strict)" } else { "" }, ); - Ok(()) + Ok(ValidateResult::new( + &args.manifest, + None, + &ctx.app_name, + args.strict, + )) } /// Typed flow — adds the checks that need the user's `C` struct. @@ -233,6 +244,18 @@ pub fn run_config_validate(args: &ConfigValidateArgs) -> Result<(), String> { /// Returns a human-readable error string on any validation failure. #[inline] pub fn run_config_validate_typed(args: &ConfigValidateArgs) -> Result<(), String> +where + C: DeserializeOwned + Validate + AppConfigMeta, +{ + let _scope = OutputScope::enter(args.format); + output::finish( + CommandName::ConfigValidate, + args.format, + validate_typed::(args), + ) +} + +fn validate_typed(args: &ConfigValidateArgs) -> Outcome where C: DeserializeOwned + Validate + AppConfigMeta, { @@ -263,7 +286,12 @@ where ctx.app_config_path.display(), if args.strict { " (strict)" } else { "" }, ); - Ok(()) + Ok(ValidateResult::new( + &args.manifest, + Some(&ctx.app_config_path), + &ctx.app_name, + args.strict, + )) } // ------------------------------------------------------------------- @@ -311,31 +339,26 @@ pub fn run_config_push(_args: &ConfigPushArgs) -> Result<(), String> { /// adapter refuses to reclaim (unreadable/unclassifiable state). #[inline] pub fn run_config_gc(args: &ConfigGcArgs) -> Result<(), String> { - // Reject the contradictory combination clap also forbids, so the PUBLIC API is - // safe for a library caller that bypasses clap: a single run cannot both - // preview and delete. Checked FIRST so the error is unambiguous, rather than - // the threshold validation or the `dry_run || !yes` fallback silently - // resolving it. (Passing NEITHER flag is valid -- that is the default dry-run.) - if args.dry_run && args.yes { - return Err( - "`config gc` cannot both preview and delete: `--dry-run` and `--yes` are mutually \ - exclusive. Pass at most one (omit both for the default dry-run preview)." + let _scope = OutputScope::enter(args.format); + output::finish(CommandName::ConfigGc, args.format, config_gc(args)) +} + +/// The `--older-than` window `config gc` sweeps with, in seconds. +/// +/// A destructive run must not invent the operator's safety assertion: `--yes` +/// requires an explicit, non-zero window. A dry-run without one previews every +/// orphan (a zero window). +fn gc_older_than_secs(args: &ConfigGcArgs) -> Result { + match (args.yes, args.older_than.as_deref()) { + (true, None) => Err( + "`config gc --yes` requires an explicit `--older-than `: a destructive run \ + must not guess it. It asserts that NO root in the selected store changed within \ + that window and that no writer is targeting the store, so nothing POPs may still \ + be serving is deleted -- `gc` sweeps the whole physical store, not just the \ + config you have in mind. Run without `--yes` first to preview every orphan and \ + its age." .to_owned(), - ); - } - // A destructive run must not invent the operator's safety assertion. - let older_than_secs = match (args.yes, args.older_than.as_deref()) { - (true, None) => { - return Err( - "`config gc --yes` requires an explicit `--older-than `: a destructive run \ - must not guess it. It asserts that NO root in the selected store changed within \ - that window and that no writer is targeting the store, so nothing POPs may still \ - be serving is deleted -- `gc` sweeps the whole physical store, not just the \ - config you have in mind. Run without `--yes` first to preview every orphan and \ - its age." - .to_owned(), - ); - } + ), (yes, Some(raw)) => { let secs = parse_duration_secs(raw)?; // `--older-than 0 --yes` asserts nothing: it makes EVERY orphan @@ -350,12 +373,29 @@ pub fn run_config_gc(args: &ConfigGcArgs) -> Result<(), String> { time and no longer than the time since ANY root in this store last changed." )); } - secs + Ok(secs) } // Dry-run with no threshold: preview EVERY orphan (age >= 0) with ages, // so the operator can choose `--older-than` from real data. - (false, None) => 0, - }; + (false, None) => Ok(0), + } +} + +fn config_gc(args: &ConfigGcArgs) -> Outcome { + // Reject the contradictory combination clap also forbids, so the PUBLIC API is + // safe for a library caller that bypasses clap: a single run cannot both + // preview and delete. Checked FIRST so the error is unambiguous, rather than + // the threshold validation or the `dry_run || !yes` fallback silently + // resolving it. (Passing NEITHER flag is valid -- that is the default dry-run.) + if args.dry_run && args.yes { + return Err( + "`config gc` cannot both preview and delete: `--dry-run` and `--yes` are mutually \ + exclusive. Pass at most one (omit both for the default dry-run preview)." + .to_owned() + .into(), + ); + } + let older_than_secs = gc_older_than_secs(args)?; // Manifest-only resolution. Unlike `push`/`diff`, `gc` reclaims by // inspecting the STORE, so it must NOT require the typed app-config file to @@ -405,7 +445,7 @@ pub fn run_config_gc(args: &ConfigGcArgs) -> Result<(), String> { // `--dry-run` states that intent explicitly (clap makes the two mutually // exclusive, so an explicit `--dry-run` always means `--yes` is unset). let dry_run = args.dry_run || !args.yes; - let lines = adapter.gc_config_entries( + let report = adapter.gc_config_entries( manifest_root, adapter_manifest_path.as_deref(), component_selector.as_deref(), @@ -414,7 +454,20 @@ pub fn run_config_gc(args: &ConfigGcArgs) -> Result<(), String> { older_than_secs, dry_run, )?; - for line in lines { + let result = GcResult::new( + &args.adapter, + &store, + dry_run, + args.older_than.is_some().then_some(older_than_secs), + &report, + ); + // A run whose deletes failed reports through its error (the full report + // plus recovery commands), exactly as before; its lines are not logged + // separately. + if let Some(diagnostic) = report.failure_diagnostic { + return Err(Failure::with_result(diagnostic, result)); + } + for line in &report.text_lines { log::info!("[edgezero] {line}"); } if dry_run { @@ -427,7 +480,7 @@ pub fn run_config_gc(args: &ConfigGcArgs) -> Result<(), String> { ), } } - Ok(()) + Ok(result) } /// Typed flow — push the user's `C` struct. Runs strict pre-flight @@ -606,6 +659,7 @@ where // adapter_typed_checks; no consent gate, no re-fetch). let validate_args = ConfigValidateArgs { app_config: args.app_config.clone(), + format: OutputFormat::Text, manifest: args.manifest.clone(), no_env: args.no_env, strict: false, @@ -1337,6 +1391,7 @@ fn load_push_context(args: &ConfigPushArgs) -> Result { // alongside the schema and per-adapter shared checks. let validate_args = ConfigValidateArgs { app_config: args.app_config.clone(), + format: OutputFormat::Text, manifest: args.manifest.clone(), no_env: args.no_env, strict: true, @@ -2410,6 +2465,7 @@ source = "target/wasm32-wasip2/release/demo.wasm" fn args_for(manifest: &Path) -> ConfigValidateArgs { ConfigValidateArgs { app_config: None, + format: OutputFormat::Text, manifest: manifest.to_path_buf(), no_env: true, // tests don't want env leakage strict: false, diff --git a/crates/edgezero-cli/src/generator.rs b/crates/edgezero-cli/src/generator.rs index f566e76bc..155f456cf 100644 --- a/crates/edgezero-cli/src/generator.rs +++ b/crates/edgezero-cli/src/generator.rs @@ -3,6 +3,7 @@ use crate::scaffold::{ ResolvedDependency, ScaffoldError, register_templates, resolve_dep_line, sanitize_crate_name, write_tmpl, }; +use edgezero_adapter::process; use edgezero_adapter::scaffold; use edgezero_adapter::scaffold::AdapterBlueprint; use handlebars::Handlebars; @@ -781,12 +782,12 @@ fn render_templates( fn initialize_git_repo(out_dir: &Path) { log::info!("[edgezero] initializing git repository"); - match Command::new("git") - .arg("init") - .arg("--quiet") - .current_dir(out_dir) - .status() - { + match process::status( + Command::new("git") + .arg("init") + .arg("--quiet") + .current_dir(out_dir), + ) { Ok(status) if status.success() => { log::info!( "[edgezero] initialized empty Git repository in {}/.git/", diff --git a/crates/edgezero-cli/src/lib.rs b/crates/edgezero-cli/src/lib.rs index 84a368053..ec12c3a1e 100644 --- a/crates/edgezero-cli/src/lib.rs +++ b/crates/edgezero-cli/src/lib.rs @@ -32,6 +32,8 @@ mod diff; #[cfg(feature = "cli")] mod generator; #[cfg(feature = "cli")] +mod output; +#[cfg(feature = "cli")] mod provision; #[cfg(feature = "cli")] mod scaffold; @@ -59,18 +61,34 @@ use args::{ ActiveVersionArgs, BuildArgs, DeployArgs, HealthcheckArgs, NewArgs, RollbackArgs, ServeArgs, }; #[cfg(feature = "cli")] +use edgezero_adapter::registry::ActionOutcome; +#[cfg(feature = "cli")] use edgezero_core::manifest::ManifestLoader; #[cfg(feature = "cli")] +use output::{ + ActiveVersionResult, BuildResult, CommandName, DeployResult, Failure, HealthcheckResult, + Outcome, OutputScope, RollbackResult, +}; +#[cfg(feature = "cli")] use std::env; #[cfg(feature = "cli")] use std::io::ErrorKind; #[cfg(feature = "cli")] use std::path::PathBuf; +#[cfg(feature = "cli")] +use std::sync::atomic::{AtomicBool, Ordering}; + +/// When set, [`CliLogger`] writes `info` to stderr instead of stdout. +/// `--format json` sets it (via `output::OutputScope`) so stdout carries only +/// the JSON envelope. +#[cfg(feature = "cli")] +static INFO_TO_STDERR: AtomicBool = AtomicBool::new(false); /// CLI output logger: prints `record.args()` verbatim with no /// timestamps, levels, or module prefixes — the CLI's output IS -/// the user-facing UX, not a debug log. `info` goes to stdout; -/// `warn`/`error` go to stderr. `debug` and `trace` are filtered +/// the user-facing UX, not a debug log. `info` goes to stdout (stderr +/// under `--format json`, see [`INFO_TO_STDERR`]); `warn`/`error` go to +/// stderr. `debug` and `trace` are filtered /// out by `enabled()` and `LevelFilter::Info`; there is no /// verbosity flag yet — adding one is a follow-up that would /// route debug/trace alongside info. @@ -107,6 +125,15 @@ impl log::Log for CliLogger { eprintln!("{}", record.args()); } } + log::Level::Info if INFO_TO_STDERR.load(Ordering::SeqCst) => { + #[expect( + clippy::print_stderr, + reason = "`--format json` keeps stdout for the JSON envelope" + )] + { + eprintln!("{}", record.args()); + } + } log::Level::Info => { #[expect(clippy::print_stdout, reason = "CLI UX output goes to stdout for info")] { @@ -118,6 +145,13 @@ impl log::Log for CliLogger { } } +/// Route [`CliLogger`]'s `info` output to stderr (`true`) or stdout (`false`). +/// Returns the previous setting so a scope guard can restore it. +#[cfg(feature = "cli")] +fn set_info_to_stderr(enabled: bool) -> bool { + INFO_TO_STDERR.swap(enabled, Ordering::SeqCst) +} + /// Initialize a CLI logger that prints messages without timestamps /// or level prefixes — the CLI's output IS the user-facing UX, not /// a debug log. See [`CliLogger`] for the routing rules. @@ -138,17 +172,30 @@ pub fn init_cli_logger() { #[cfg(feature = "cli")] #[inline] pub fn run_build(args: &BuildArgs) -> Result<(), String> { + let _scope = OutputScope::enter(args.format); + output::finish(CommandName::Build, args.format, build(args)) +} + +#[cfg(feature = "cli")] +fn build(args: &BuildArgs) -> Outcome { let manifest = load_manifest_optional()?; ensure_adapter_defined(&args.adapter, manifest.as_ref())?; if let Some(loader) = &manifest { log_store_bindings(&args.adapter, loader); } - adapter::execute( + let outcome = adapter::execute( &args.adapter, adapter::Action::Build, manifest.as_ref(), &args.adapter_args, - ) + )?; + // A manifest `commands.build` override reports no artifact. + let artifact = if let ActionOutcome::Build(built) = &outcome { + built.artifact.as_deref() + } else { + None + }; + Ok(BuildResult::new(&args.adapter, artifact)) } /// Deploy the project to a target edge adapter. @@ -160,6 +207,20 @@ pub fn run_build(args: &BuildArgs) -> Result<(), String> { #[cfg(feature = "cli")] #[inline] pub fn run_deploy(args: &DeployArgs) -> Result<(), String> { + let _scope = OutputScope::enter(args.format); + output::finish(CommandName::Deploy, args.format, deploy(args)) +} + +#[cfg(feature = "cli")] +fn deploy(args: &DeployArgs) -> Outcome { + let result = |version: Option| { + DeployResult::new( + &args.adapter, + args.service_id.clone(), + args.staging, + version, + ) + }; // Reject reserved staging-lifecycle spellings in the passthrough. `--staging` is // a typed flag (before `--`); if it — or the renamed-away `--stage` — appears in // the passthrough (after `--`), the operator meant to stage but `args.staging` is @@ -178,7 +239,8 @@ pub fn run_deploy(args: &DeployArgs) -> Result<(), String> { `deploy --adapter {} --staging` (before any `--`). Refusing to run a \ production deploy with `{flag}` after `--`.", args.adapter - )); + ) + .into()); } let manifest = load_manifest_optional()?; @@ -240,12 +302,17 @@ pub fn run_deploy(args: &DeployArgs) -> Result<(), String> { // package to a new draft, mark it staged, and emit the staged // version. Never runs the manifest `deploy` // command, which would activate production. - return adapter::execute( + let outcome = adapter::execute( &args.adapter, adapter::Action::DeployStaging, manifest.as_ref(), &passthrough, - ); + )?; + let version = deployed_version(&outcome); + if let Some(staged) = version { + log::info!("version={staged}"); + } + return Ok(result(version)); } // Production deploy also emits the activated version @@ -265,44 +332,82 @@ pub fn run_deploy(args: &DeployArgs) -> Result<(), String> { // 3. If BOTH fail: a clear `Err`. We never silently emit an empty // version — that was the original bug. if args.service_id.is_some() && args.adapter.eq_ignore_ascii_case("fastly") { - let captured = adapter::execute_capture( - &args.adapter, - adapter::Action::Deploy, - manifest.as_ref(), - &passthrough, - )?; - if let Some(version) = captured.as_deref().and_then(parse_deploy_version) { - log::info!("version={version}"); - return Ok(()); - } - // Fallback: resolve the version the deploy just activated via the Fastly - // API. `--require-active` makes EmitVersion FAIL (not emit an empty - // `version=`) when the API reports no active version — a deploy that - // activated a version but resolves to none is an error, never a silent - // empty-version success. - let mut emit_args = passthrough.clone(); - emit_args.push("--require-active".to_owned()); - return adapter::execute( - &args.adapter, - adapter::Action::EmitVersion, - manifest.as_ref(), - &emit_args, - ) - .map_err(|err| { - format!( - "deploy succeeded but the activated version could not be resolved: no `version=` \ - (or Fastly `version `) line in the deploy output, and the Fastly API fallback \ - failed: {err}" - ) - }); + let version = fastly_production_deploy(&args.adapter, manifest.as_ref(), &passthrough)?; + log::info!("version={version}"); + return Ok(result(Some(version))); } - adapter::execute( + let outcome = adapter::execute( &args.adapter, adapter::Action::Deploy, manifest.as_ref(), &passthrough, + )?; + Ok(result(deployed_version(&outcome))) +} + +/// Run a production Fastly deploy for a known service and resolve the version +/// it activated (see `deploy` for the resolution precedence). +#[cfg(feature = "cli")] +fn fastly_production_deploy( + adapter_name: &str, + manifest: Option<&ManifestLoader>, + passthrough: &[String], +) -> Result { + let captured = + adapter::execute_capture(adapter_name, adapter::Action::Deploy, manifest, passthrough)?; + if let Some(version) = captured.as_deref().and_then(parse_deploy_version) { + return Ok(version); + } + // Fallback: resolve the version the deploy just activated via the Fastly + // API. `--require-active` makes EmitVersion FAIL (not emit an empty + // `version=`) when the API reports no active version — a deploy that + // activated a version but resolves to none is an error, never a silent + // empty-version success. + let mut emit_args = passthrough.to_vec(); + emit_args.push("--require-active".to_owned()); + let unresolved = |err: &str| { + 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}" + ) + }; + let outcome = adapter::execute( + adapter_name, + adapter::Action::EmitVersion, + manifest, + &emit_args, ) + .map_err(|err| unresolved(&err))?; + // `--require-active` makes EmitVersion fail rather than report none. + active_version(&outcome).ok_or_else(|| unresolved("no active version was reported")) +} + +/// The version a deploy outcome reports, if any. +#[cfg(feature = "cli")] +const fn deployed_version(outcome: &ActionOutcome) -> Option { + if let ActionOutcome::Deploy(deployed) = outcome { + deployed.version + } else { + None + } +} + +/// The version an `EmitVersion` outcome reports, if any. +#[cfg(feature = "cli")] +const fn active_version(outcome: &ActionOutcome) -> Option { + if let ActionOutcome::ActiveVersion(active) = outcome { + active.version + } else { + None + } +} + +/// The error for an adapter that answered `action` with the wrong outcome. +#[cfg(feature = "cli")] +fn unexpected_outcome(adapter_name: &str, action: adapter::Action) -> String { + format!("adapter `{adapter_name}` returned no {action} result") } /// Parse an activated service version out of a deploy command's output. @@ -452,6 +557,12 @@ fn resolve_adapter_manifest_path( #[cfg(feature = "cli")] #[inline] pub fn run_healthcheck(args: &HealthcheckArgs) -> Result<(), String> { + let _scope = OutputScope::enter(args.format); + output::finish(CommandName::Healthcheck, args.format, healthcheck(args)) +} + +#[cfg(feature = "cli")] +fn healthcheck(args: &HealthcheckArgs) -> Outcome { // Manifest-independent, like `active-version`: a pure API/curl probe keyed on // explicit flags, never a manifest-command override. Not loading the manifest // keeps it correct regardless of the current directory (monorepo safety). @@ -476,12 +587,25 @@ pub fn run_healthcheck(args: &HealthcheckArgs) -> Result<(), String> { "--timeout".to_owned(), args.timeout.to_string(), ]); - adapter::execute( + let ActionOutcome::Healthcheck(outcome) = adapter::execute( &args.adapter, adapter::Action::Healthcheck, None, &passthrough, - ) + )? + else { + return Err(unexpected_outcome(&args.adapter, adapter::Action::Healthcheck).into()); + }; + // The line-oriented contract the healthcheck action parses. + if let Some(code) = outcome.status_code { + log::info!("status-code={code}"); + } + log::info!("healthy={}", outcome.healthy); + let result = HealthcheckResult::new(&args.adapter, &outcome); + match outcome.failure { + Some(failure) => Err(Failure::with_result(failure, result)), + None => Ok(result), + } } /// Roll a service back (Fastly staging lifecycle): @@ -496,6 +620,12 @@ pub fn run_healthcheck(args: &HealthcheckArgs) -> Result<(), String> { #[cfg(feature = "cli")] #[inline] pub fn run_rollback(args: &RollbackArgs) -> Result<(), String> { + let _scope = OutputScope::enter(args.format); + output::finish(CommandName::Rollback, args.format, rollback(args)) +} + +#[cfg(feature = "cli")] +fn rollback(args: &RollbackArgs) -> Outcome { // Manifest-independent, like `active-version` / `healthcheck`: a pure API // operation keyed on explicit flags, never a manifest-command override. let mut passthrough: Vec = vec![ @@ -520,10 +650,19 @@ pub fn run_rollback(args: &RollbackArgs) -> Result<(), String> { or wire the deploy-fastly GitHub action's `previous-version` output. If it was \ never captured, choose the target from the service's version history -- Fastly \ cannot identify it for you. Pass --staging to deactivate a staged version instead." - .to_owned(), + .to_owned() + .into(), ); } - adapter::execute(&args.adapter, adapter::Action::Rollback, None, &passthrough) + let ActionOutcome::Rollback(outcome) = + adapter::execute(&args.adapter, adapter::Action::Rollback, None, &passthrough)? + else { + return Err(unexpected_outcome(&args.adapter, adapter::Action::Rollback).into()); + }; + if let Some(target) = outcome.rolled_back_to { + log::info!("rolled-back-to={target}"); + } + Ok(RollbackResult::new(&args.adapter, &outcome)) } /// Resolve and print the currently-active service version as `version=`. @@ -539,18 +678,47 @@ pub fn run_rollback(args: &RollbackArgs) -> Result<(), String> { #[cfg(feature = "cli")] #[inline] pub fn run_active_version(args: &ActiveVersionArgs) -> Result<(), String> { + let _scope = OutputScope::enter(args.format); + output::finish( + CommandName::ActiveVersion, + args.format, + active_version_of(args), + ) +} + +#[cfg(feature = "cli")] +fn active_version_of(args: &ActiveVersionArgs) -> Outcome { // No manifest load: `active-version` is a pure Fastly-API operation keyed on // `--adapter` + `--service-id`, and `EmitVersion` can never be a // manifest-command override (see `adapter::manifest_command`). Loading the // manifest would only couple it to the current directory — breaking it in a // monorepo where a stray root `edgezero.toml` shadows the app's. The adapter // registry still validates `--adapter`. - adapter::execute( + let ActionOutcome::ActiveVersion(outcome) = adapter::execute( &args.adapter, adapter::Action::EmitVersion, None, &["--service-id".to_owned(), args.service_id.clone()], - ) + )? + else { + return Err(unexpected_outcome(&args.adapter, adapter::Action::EmitVersion).into()); + }; + if let Some(version) = outcome.version { + log::info!("version={version}"); + } else { + // Confirmed no active version (first-ever deploy): an explicit empty + // line so the caller records an empty rollback target and succeeds. + log::info!("version="); + log::info!( + "service {} has no active version yet; emitting an empty rollback target", + outcome.service_id + ); + } + Ok(ActiveVersionResult::new( + &args.adapter, + outcome.service_id, + outcome.version, + )) } /// Run a local simulation for a target edge adapter. @@ -570,6 +738,7 @@ pub fn run_serve(args: &ServeArgs) -> Result<(), String> { manifest.as_ref(), &[], ) + .map(|_| ()) } /// Create a new `EdgeZero` app skeleton. @@ -679,6 +848,7 @@ fn load_manifest_optional() -> Result, String> { #[cfg(feature = "cli")] mod tests { use super::*; + use crate::args::OutputFormat; use crate::test_support::{BASIC_MANIFEST, EnvOverride, manifest_guard}; use edgezero_core::manifest::ManifestLoader; use std::fs; @@ -845,6 +1015,7 @@ mod tests { let args = DeployArgs { adapter: "fastly".to_owned(), adapter_args: vec!["--non-interactive".to_owned()], + format: OutputFormat::Text, service_id: Some("SVC1".to_owned()), staging: false, }; @@ -869,6 +1040,7 @@ mod tests { let args = DeployArgs { adapter: "fastly".to_owned(), adapter_args: vec![flag.to_owned()], + format: OutputFormat::Text, service_id: Some("SVC1".to_owned()), staging: false, }; @@ -912,6 +1084,7 @@ mod tests { let args = BuildArgs { adapter: "fastly".to_owned(), adapter_args: Vec::new(), + format: OutputFormat::Text, }; run_build(&args).expect("build command runs"); } @@ -931,6 +1104,7 @@ mod tests { // No service id → the production version-emit step is // skipped, so this test exercises only the // manifest `deploy` command path. + format: OutputFormat::Text, service_id: None, staging: false, }; diff --git a/crates/edgezero-cli/src/output.rs b/crates/edgezero-cli/src/output.rs new file mode 100644 index 000000000..951f01e32 --- /dev/null +++ b/crates/edgezero-cli/src/output.rs @@ -0,0 +1,809 @@ +//! `--format` output: stream routing, the JSON envelope, and the wire schema. +//! +//! The contract (documented in `docs/guide/cli-reference.md`, "Machine-readable +//! output"): +//! +//! - Every command emits its human-readable output through the logger, in +//! both formats. Under `--format json`, [`OutputScope`] routes that output +//! and every child process's stdout to stderr. +//! - stdout then carries exactly one [`Envelope`], written by [`finish`] when +//! the command ends, on success AND failure. +//! +//! The wire structs below ARE the JSON schema (versioned by +//! [`SCHEMA_VERSION`]). They are deliberately separate from the adapter's +//! domain types, so refactoring those cannot change the public JSON by +//! accident. + +use std::io::{self, Write as _}; +use std::path::Path; + +use edgezero_adapter::process; +use edgezero_adapter::registry::{ + AuthState, GcReport, HealthcheckOutcome, ProvisionAction, ProvisionReport, ProvisionStoreRef, + ResolvedStoreId, RollbackOutcome, StoreKind, +}; +use serde::Serialize; + +use crate::args::OutputFormat; + +/// Version of the whole JSON surface (envelope + every command result). Bump +/// only for a breaking change (removing / renaming / retyping a key, making a +/// non-null key nullable); additive changes keep it. +const SCHEMA_VERSION: u32 = 1; + +/// The command an envelope reports on, spelled as the user types it. +#[derive(Clone, Copy, Debug)] +pub(crate) enum CommandName { + ActiveVersion, + AuthStatus, + Build, + ConfigGc, + ConfigValidate, + Deploy, + Healthcheck, + Provision, + Rollback, +} + +impl CommandName { + const fn as_str(self) -> &'static str { + match self { + Self::ActiveVersion => "active-version", + Self::AuthStatus => "auth status", + Self::Build => "build", + Self::ConfigGc => "config gc", + Self::ConfigValidate => "config validate", + Self::Deploy => "deploy", + Self::Healthcheck => "healthcheck", + Self::Provision => "provision", + Self::Rollback => "rollback", + } + } +} + +/// Routes stdout for one command run: under `--format json`, logger `info` +/// output and inheriting children's stdout go to stderr, and the previous +/// routing is restored on drop. A `text` scope changes nothing. +#[must_use = "the routing lasts only while the scope is alive"] +pub(crate) struct OutputScope { + /// The routing to restore (`(child_stdout, info)`); `None` for `text`. + previous: Option<(bool, bool)>, +} + +impl OutputScope { + pub(crate) fn enter(format: OutputFormat) -> Self { + let previous = (format == OutputFormat::Json).then(|| { + ( + process::set_child_stdout_to_stderr(true), + crate::set_info_to_stderr(true), + ) + }); + Self { previous } + } +} + +impl Drop for OutputScope { + fn drop(&mut self) { + if let Some((child_stdout, info)) = self.previous { + process::set_child_stdout_to_stderr(child_stdout); + crate::set_info_to_stderr(info); + } + } +} + +/// A command failure: the message the binary prints, plus the partial result +/// when the command still measured something (an unhealthy probe, a partly +/// failed gc, an unauthenticated session). +#[derive(Debug)] +pub(crate) struct Failure { + message: String, + /// Boxed so `Outcome`'s error variant stays small. + partial: Option>, +} + +impl Failure { + pub(crate) fn with_result(message: String, result: R) -> Self { + Self { + message, + partial: Some(Box::new(result)), + } + } +} + +impl From for Failure { + fn from(message: String) -> Self { + Self { + message, + partial: None, + } + } +} + +/// What a command produced: its result, or a failure. +pub(crate) type Outcome = Result>; + +#[derive(Serialize)] +struct Envelope<'outcome, R> { + command: &'static str, + error: Option>, + ok: bool, + result: Option<&'outcome R>, + schema_version: u32, +} + +#[derive(Serialize)] +struct ErrorBody<'msg> { + message: &'msg str, +} + +// ---------------------------------------------------------------- wire schema + +/// `active-version` result. +#[derive(Debug, Serialize)] +pub(crate) struct ActiveVersionResult { + adapter: String, + service_id: String, + version: Option, +} + +impl ActiveVersionResult { + pub(crate) fn new(adapter: &str, service_id: String, version: Option) -> Self { + Self { + adapter: adapter.to_owned(), + service_id, + version, + } + } +} + +/// `auth status` result. +#[derive(Debug, Serialize)] +pub(crate) struct AuthStatusResult { + adapter: String, + state: WireAuthState, +} + +impl AuthStatusResult { + pub(crate) fn new(adapter: &str, state: AuthState) -> Self { + Self { + adapter: adapter.to_owned(), + state: match state { + AuthState::Authenticated => WireAuthState::Authenticated, + AuthState::NotApplicable => WireAuthState::NotApplicable, + AuthState::Unauthenticated => WireAuthState::Unauthenticated, + }, + } + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "snake_case")] +enum WireAuthState { + Authenticated, + NotApplicable, + Unauthenticated, +} + +/// `build` result. +#[derive(Debug, Serialize)] +pub(crate) struct BuildResult { + adapter: String, + artifact: Option, +} + +impl BuildResult { + pub(crate) fn new(adapter: &str, artifact: Option<&Path>) -> Self { + Self { + adapter: adapter.to_owned(), + artifact: artifact.map(path_string), + } + } +} + +/// `deploy` result. +#[derive(Debug, Serialize)] +pub(crate) struct DeployResult { + adapter: String, + service_id: Option, + staging: bool, + version: Option, +} + +impl DeployResult { + pub(crate) fn new( + adapter: &str, + service_id: Option, + staging: bool, + version: Option, + ) -> Self { + Self { + adapter: adapter.to_owned(), + service_id, + staging, + version, + } + } +} + +/// `healthcheck` result. +#[derive(Debug, Serialize)] +pub(crate) struct HealthcheckResult { + adapter: String, + attempts: u32, + domain: String, + healthy: bool, + path: String, + service_id: String, + staging: bool, + staging_ip: Option, + status_code: Option, + version: u64, + version_verified: bool, +} + +impl HealthcheckResult { + pub(crate) fn new(adapter: &str, outcome: &HealthcheckOutcome) -> Self { + Self { + adapter: adapter.to_owned(), + attempts: outcome.attempts, + domain: outcome.domain.clone(), + healthy: outcome.healthy, + path: outcome.path.clone(), + service_id: outcome.service_id.clone(), + staging: outcome.staging, + staging_ip: outcome.staging_ip.clone(), + status_code: outcome.status_code, + version: outcome.version, + version_verified: outcome.version_verified, + } + } +} + +/// `rollback` result. +#[derive(Debug, Serialize)] +pub(crate) struct RollbackResult { + adapter: String, + rolled_back_to: Option, + service_id: String, + staging: bool, + version: u64, +} + +impl RollbackResult { + pub(crate) fn new(adapter: &str, outcome: &RollbackOutcome) -> Self { + Self { + adapter: adapter.to_owned(), + rolled_back_to: outcome.rolled_back_to, + service_id: outcome.service_id.clone(), + staging: outcome.staging, + version: outcome.version, + } + } +} + +/// `provision` result. +#[derive(Debug, Serialize)] +pub(crate) struct ProvisionResult { + adapter: String, + dry_run: bool, + entries: Vec, +} + +impl ProvisionResult { + pub(crate) fn new(adapter: &str, dry_run: bool, report: &ProvisionReport) -> Self { + Self { + adapter: adapter.to_owned(), + dry_run, + entries: report + .entries + .iter() + .map(|entry| ProvisionEntryResult { + action: WireProvisionAction::from(entry.action), + message: entry.message.clone(), + store: entry.store.as_ref().map(StoreResult::from), + }) + .collect(), + } + } +} + +#[derive(Debug, Serialize)] +struct ProvisionEntryResult { + action: WireProvisionAction, + message: String, + store: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "snake_case")] +enum WireProvisionAction { + AlreadyPresent, + Created, + NotApplicable, + Note, + Updated, + WouldCreate, + WouldUpdate, +} + +impl From for WireProvisionAction { + fn from(action: ProvisionAction) -> Self { + match action { + ProvisionAction::AlreadyPresent => Self::AlreadyPresent, + ProvisionAction::Created => Self::Created, + ProvisionAction::NotApplicable => Self::NotApplicable, + ProvisionAction::Note => Self::Note, + ProvisionAction::Updated => Self::Updated, + ProvisionAction::WouldCreate => Self::WouldCreate, + ProvisionAction::WouldUpdate => Self::WouldUpdate, + } + } +} + +#[derive(Debug, Serialize)] +struct StoreResult { + kind: WireStoreKind, + logical: Option, + platform: String, +} + +impl From<&ProvisionStoreRef> for StoreResult { + fn from(store: &ProvisionStoreRef) -> Self { + Self { + kind: match store.kind { + StoreKind::Config => WireStoreKind::Config, + StoreKind::Kv => WireStoreKind::Kv, + StoreKind::Secrets => WireStoreKind::Secrets, + }, + logical: store.logical.clone(), + platform: store.platform.clone(), + } + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "snake_case")] +enum WireStoreKind { + Config, + Kv, + Secrets, +} + +/// `config gc` result. +#[derive(Debug, Serialize)] +pub(crate) struct GcResult { + adapter: String, + deleted: Option, + dry_run: bool, + failed: Vec, + kept_roots: Vec, + older_than_secs: Option, + planned_deletions: Vec, + store: GcStoreResult, + stranded: Vec, + summary: GcSummaryResult, + uncertain: Vec, + warnings: Vec, +} + +impl GcResult { + pub(crate) fn new( + adapter: &str, + store: &ResolvedStoreId, + dry_run: bool, + older_than_secs: Option, + report: &GcReport, + ) -> Self { + Self { + adapter: adapter.to_owned(), + deleted: report.deleted, + dry_run, + failed: report.failed.clone(), + kept_roots: report.kept_roots.clone(), + older_than_secs, + planned_deletions: report + .planned + .iter() + .map(|candidate| GcCandidateResult { + age_secs: candidate.age_secs, + key: candidate.key.clone(), + }) + .collect(), + store: GcStoreResult { + id: report.store_id.clone(), + logical: store.logical.clone(), + platform: store.platform.clone(), + }, + stranded: report.stranded.clone(), + summary: GcSummaryResult { + entries: report.entries, + generations_planned: report.generations_planned, + orphans_planned: report.planned.len(), + orphans_too_recent: report.retained_recent, + referenced_chunks: report.referenced_chunks, + roots: report.roots, + unprovable: report.unprovable, + }, + uncertain: report.uncertain.clone(), + warnings: report.warnings.clone(), + } + } +} + +#[derive(Debug, Serialize)] +struct GcCandidateResult { + age_secs: u64, + key: String, +} + +#[derive(Debug, Serialize)] +struct GcStoreResult { + id: Option, + logical: String, + platform: String, +} + +#[derive(Debug, Serialize)] +struct GcSummaryResult { + entries: usize, + generations_planned: usize, + orphans_planned: usize, + orphans_too_recent: usize, + referenced_chunks: usize, + roots: usize, + unprovable: usize, +} + +/// `config validate` result. Present only when validation passed. +#[derive(Debug, Serialize)] +pub(crate) struct ValidateResult { + app_config: Option, + app_name: String, + manifest: String, + mode: ValidateMode, + strict: bool, +} + +impl ValidateResult { + /// `app_config` is `None` for the raw (untyped) flow. + pub(crate) fn new( + manifest: &Path, + app_config: Option<&Path>, + app_name: &str, + strict: bool, + ) -> Self { + Self { + app_config: app_config.map(path_string), + app_name: app_name.to_owned(), + manifest: path_string(manifest), + mode: if app_config.is_some() { + ValidateMode::Typed + } else { + ValidateMode::Raw + }, + strict, + } + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "snake_case")] +enum ValidateMode { + Raw, + Typed, +} + +// ---------------------------------------------------------------- rendering + +/// Render `outcome` as the pretty-printed JSON envelope. +fn render_envelope( + command: CommandName, + outcome: &Outcome, +) -> Result { + let envelope = match outcome { + Ok(result) => Envelope { + command: command.as_str(), + error: None, + ok: true, + result: Some(result), + schema_version: SCHEMA_VERSION, + }, + Err(failure) => Envelope { + command: command.as_str(), + error: Some(ErrorBody { + message: &failure.message, + }), + ok: false, + result: failure.partial.as_deref(), + schema_version: SCHEMA_VERSION, + }, + }; + serde_json::to_string_pretty(&envelope) + .map_err(|err| format!("failed to render the JSON result: {err}")) +} + +/// End a command: under `--format json` write the envelope to stdout, then +/// return the `Result` the binary's `main` turns into an exit code. +/// +/// The envelope is written here, inside the library, so downstream CLIs whose +/// `main` predates `--format` still emit failure envelopes. +pub(crate) fn finish( + command: CommandName, + format: OutputFormat, + outcome: Outcome, +) -> Result<(), String> { + if format == OutputFormat::Json { + let document = render_envelope(command, &outcome)?; + let mut stdout = io::stdout().lock(); + writeln!(stdout, "{document}") + .and_then(|()| stdout.flush()) + .map_err(|err| format!("failed to write the JSON result to stdout: {err}"))?; + } + outcome.map(|_| ()).map_err(|failure| failure.message) +} + +fn path_string(path: &Path) -> String { + path.to_string_lossy().into_owned() +} + +#[cfg(test)] +#[expect( + clippy::default_numeric_fallback, + reason = "integer literals in the `json!` expectations compare by value; their Rust type is irrelevant" +)] +mod tests { + use super::*; + use crate::test_support::manifest_guard; + use edgezero_adapter::registry::{GcCandidate, ProvisionEntry}; + use serde_json::{Value, json}; + + fn envelope_of(command: CommandName, outcome: &Outcome) -> Value { + let rendered = render_envelope(command, outcome).expect("renders"); + serde_json::from_str(&rendered).expect("valid JSON") + } + + /// The §6.1 invariants every envelope must satisfy. + fn assert_envelope_invariants(envelope: &Value) { + let object = envelope.as_object().expect("envelope is an object"); + let mut keys: Vec<&str> = object.keys().map(String::as_str).collect(); + keys.sort_unstable(); + assert_eq!(keys, ["command", "error", "ok", "result", "schema_version"]); + assert_eq!(envelope["schema_version"], json!(1)); + let ok = envelope["ok"].as_bool().expect("ok is a bool"); + assert_eq!( + ok, + envelope["error"].is_null(), + "error is null exactly when ok" + ); + if ok { + assert!(!envelope["result"].is_null(), "a success carries a result"); + } else { + assert!(envelope["error"]["message"].is_string()); + } + } + + #[test] + fn success_envelope_carries_the_result() { + let outcome: Outcome = Ok(ActiveVersionResult::new( + "fastly", + "SVC1".to_owned(), + Some(42), + )); + let envelope = envelope_of(CommandName::ActiveVersion, &outcome); + assert_envelope_invariants(&envelope); + assert_eq!(envelope["command"], json!("active-version")); + assert_eq!( + envelope["result"], + json!({"adapter": "fastly", "service_id": "SVC1", "version": 42}) + ); + } + + #[test] + fn failure_envelope_without_partial_result_has_null_result() { + let outcome: Outcome = Err(Failure::from("boom".to_owned())); + let envelope = envelope_of(CommandName::ActiveVersion, &outcome); + assert_envelope_invariants(&envelope); + assert_eq!(envelope["ok"], json!(false)); + assert_eq!(envelope["error"], json!({"message": "boom"})); + assert!(envelope["result"].is_null()); + } + + #[test] + fn failure_envelope_keeps_the_partial_result() { + let outcome = HealthcheckOutcome { + attempts: 3, + domain: "app.example.com".to_owned(), + failure: Some("healthcheck failed".to_owned()), + healthy: false, + path: "/".to_owned(), + service_id: "SVC1".to_owned(), + staging: true, + staging_ip: Some("151.101.2.10".to_owned()), + status_code: Some(503), + version: 7, + version_verified: false, + }; + let failed: Outcome = Err(Failure::with_result( + "healthcheck failed".to_owned(), + HealthcheckResult::new("fastly", &outcome), + )); + let envelope = envelope_of(CommandName::Healthcheck, &failed); + assert_envelope_invariants(&envelope); + assert_eq!( + envelope["result"], + json!({ + "adapter": "fastly", "attempts": 3, "domain": "app.example.com", + "healthy": false, "path": "/", "service_id": "SVC1", "staging": true, + "staging_ip": "151.101.2.10", "status_code": 503, "version": 7, + "version_verified": false + }) + ); + } + + #[test] + fn every_command_name_is_spelled_as_typed() { + let names: Vec<&str> = [ + CommandName::ActiveVersion, + CommandName::AuthStatus, + CommandName::Build, + CommandName::ConfigGc, + CommandName::ConfigValidate, + CommandName::Deploy, + CommandName::Healthcheck, + CommandName::Provision, + CommandName::Rollback, + ] + .into_iter() + .map(CommandName::as_str) + .collect(); + assert_eq!( + names, + [ + "active-version", + "auth status", + "build", + "config gc", + "config validate", + "deploy", + "healthcheck", + "provision", + "rollback" + ] + ); + } + + #[test] + fn auth_build_deploy_rollback_results_serialize_every_key() { + let auth = serde_json::to_value(AuthStatusResult::new("axum", AuthState::NotApplicable)) + .expect("serialize"); + assert_eq!(auth, json!({"adapter": "axum", "state": "not_applicable"})); + + let build = serde_json::to_value(BuildResult::new("cloudflare", None)).expect("serialize"); + assert_eq!(build, json!({"adapter": "cloudflare", "artifact": null})); + + let deploy = serde_json::to_value(DeployResult::new("fastly", None, true, Some(43))) + .expect("serialize"); + assert_eq!( + deploy, + json!({"adapter": "fastly", "service_id": null, "staging": true, "version": 43}) + ); + + let rollback = serde_json::to_value(RollbackResult::new( + "fastly", + &RollbackOutcome { + rolled_back_to: None, + service_id: "SVC1".to_owned(), + staging: true, + version: 43, + }, + )) + .expect("serialize"); + assert_eq!( + rollback, + json!({"adapter": "fastly", "rolled_back_to": null, "service_id": "SVC1", "staging": true, "version": 43}) + ); + } + + #[test] + fn provision_result_maps_actions_and_stores() { + let store = ResolvedStoreId::new("sessions", "prod_sessions"); + let report = ProvisionReport { + entries: vec![ + ProvisionEntry::for_store( + ProvisionAction::WouldCreate, + StoreKind::Kv, + &store, + "would create".to_owned(), + ), + ProvisionEntry::note("nothing else".to_owned()), + ], + }; + let result = + serde_json::to_value(ProvisionResult::new("fastly", true, &report)).expect("serialize"); + assert_eq!( + result, + json!({ + "adapter": "fastly", + "dry_run": true, + "entries": [ + {"action": "would_create", "message": "would create", + "store": {"kind": "kv", "logical": "sessions", "platform": "prod_sessions"}}, + {"action": "note", "message": "nothing else", "store": null} + ] + }) + ); + } + + #[test] + fn gc_result_summarises_the_report() { + let report = GcReport { + deleted: None, + entries: 40, + generations_planned: 1, + kept_roots: vec!["app_config".to_owned()], + planned: vec![GcCandidate { + age_secs: 90_211, + key: "app_config.__chunk.a".to_owned(), + }], + referenced_chunks: 12, + retained_recent: 2, + roots: 3, + store_id: Some("7Ab".to_owned()), + text_lines: vec!["not on the wire".to_owned()], + ..GcReport::default() + }; + let store = ResolvedStoreId::from_logical("app_config"); + let result = serde_json::to_value(GcResult::new("fastly", &store, true, None, &report)) + .expect("serialize"); + assert_eq!( + result, + json!({ + "adapter": "fastly", "deleted": null, "dry_run": true, "failed": [], + "kept_roots": ["app_config"], "older_than_secs": null, + "planned_deletions": [{"age_secs": 90_211, "key": "app_config.__chunk.a"}], + "store": {"id": "7Ab", "logical": "app_config", "platform": "app_config"}, + "stranded": [], + "summary": {"entries": 40, "generations_planned": 1, "orphans_planned": 1, + "orphans_too_recent": 2, "referenced_chunks": 12, "roots": 3, + "unprovable": 0}, + "uncertain": [], "warnings": [] + }) + ); + } + + #[test] + fn validate_result_reports_mode_from_the_app_config() { + let raw = serde_json::to_value(ValidateResult::new( + Path::new("edgezero.toml"), + None, + "demo", + true, + )) + .expect("serialize"); + assert_eq!( + raw, + json!({"app_config": null, "app_name": "demo", "manifest": "edgezero.toml", "mode": "raw", "strict": true}) + ); + let typed = serde_json::to_value(ValidateResult::new( + Path::new("edgezero.toml"), + Some(Path::new("demo.toml")), + "demo", + false, + )) + .expect("serialize"); + assert_eq!(typed["mode"], json!("typed")); + assert_eq!(typed["app_config"], json!("demo.toml")); + } + + #[test] + fn output_scope_routes_only_while_alive() { + let _lock = manifest_guard().lock().expect("manifest guard"); + let text = OutputScope::enter(OutputFormat::Text); + assert!(!process::child_stdout_to_stderr(), "text changes nothing"); + drop(text); + let json = OutputScope::enter(OutputFormat::Json); + assert!(process::child_stdout_to_stderr()); + drop(json); + assert!(!process::child_stdout_to_stderr(), "restored on drop"); + } +} diff --git a/crates/edgezero-cli/src/provision.rs b/crates/edgezero-cli/src/provision.rs index 723576f9e..569b0e218 100644 --- a/crates/edgezero-cli/src/provision.rs +++ b/crates/edgezero-cli/src/provision.rs @@ -15,6 +15,7 @@ use crate::config::{ enforce_single_store_capability, reject_merged_id_collisions, strict_handler_paths, }; use crate::ensure_adapter_defined; +use crate::output::{self, CommandName, Outcome, OutputScope, ProvisionResult}; use edgezero_adapter::registry::{self as adapter_registry, ProvisionStores, ResolvedStoreId}; use edgezero_core::env_config::EnvConfig; use edgezero_core::manifest::{ManifestLoader, StoreDeclaration}; @@ -27,6 +28,11 @@ use edgezero_core::manifest::{ManifestLoader, StoreDeclaration}; /// reports a failure. #[inline] pub fn run_provision(args: &ProvisionArgs) -> Result<(), String> { + let _scope = OutputScope::enter(args.format); + output::finish(CommandName::Provision, args.format, provision(args)) +} + +fn provision(args: &ProvisionArgs) -> Outcome { let manifest_loader = ManifestLoader::from_path(&args.manifest) .map_err(|err| format!("failed to load {}: {err}", args.manifest.display()))?; let manifest = manifest_loader.manifest(); @@ -114,7 +120,7 @@ pub fn run_provision(args: &ProvisionArgs) -> Result<(), String> { secrets: &secret_ids, }; - let lines = adapter.provision( + let report = adapter.provision( manifest_root, adapter_cfg.adapter.manifest.as_deref(), adapter_cfg.adapter.component.as_deref(), @@ -125,10 +131,10 @@ pub fn run_provision(args: &ProvisionArgs) -> Result<(), String> { if args.dry_run { log::info!("[edgezero] provision --dry-run for `{}`:", args.adapter); } - for line in lines { - log::info!("{line}"); + for entry in &report.entries { + log::info!("{}", entry.message); } - Ok(()) + Ok(ProvisionResult::new(&args.adapter, args.dry_run, &report)) } /// Pair each declared id in `declaration` with its platform name @@ -150,7 +156,7 @@ fn resolve_kind( #[cfg(test)] mod tests { use super::*; - use crate::args::ProvisionArgs; + use crate::args::{OutputFormat, ProvisionArgs}; use crate::test_support::{EnvOverride, PROVISION_MANIFEST, manifest_guard}; use std::fs; use tempfile::TempDir; @@ -167,6 +173,7 @@ mod tests { run_provision(&ProvisionArgs { adapter: "axum".to_owned(), dry_run: false, + format: OutputFormat::Text, manifest: manifest_path.clone(), }) .expect("axum provision exits 0 (no remote resources)"); @@ -184,6 +191,7 @@ mod tests { run_provision(&ProvisionArgs { adapter: "axum".to_owned(), dry_run: true, + format: OutputFormat::Text, manifest: manifest_path.clone(), }) .expect("axum dry-run also exits 0"); @@ -201,6 +209,7 @@ mod tests { let err = run_provision(&ProvisionArgs { adapter: "wat".to_owned(), dry_run: false, + format: OutputFormat::Text, manifest: manifest_path.clone(), }) .expect_err("unknown adapter must error"); @@ -230,6 +239,7 @@ mod tests { run_provision(&ProvisionArgs { adapter: "spin".to_owned(), dry_run: true, + format: OutputFormat::Text, manifest: manifest_path.clone(), }) .expect("spin dry-run dispatches cleanly"); @@ -268,6 +278,7 @@ adapters = ["axum"] let err = run_provision(&ProvisionArgs { adapter: "axum".to_owned(), dry_run: true, + format: OutputFormat::Text, manifest: manifest_path.clone(), }) .expect_err("malformed handler must error before dispatch"); @@ -298,6 +309,7 @@ adapters = ["axum"] let err = run_provision(&ProvisionArgs { adapter: "spin".to_owned(), dry_run: true, + format: OutputFormat::Text, manifest: manifest_path.clone(), }) .expect_err("zero-component spin.toml must error pre-dispatch"); @@ -349,6 +361,7 @@ default = "default" let err = run_provision(&ProvisionArgs { adapter: "spin".to_owned(), dry_run: true, + format: OutputFormat::Text, manifest: manifest_path.clone(), }) .expect_err("Single-capability violation must error"); @@ -398,6 +411,7 @@ ids = ["default"] run_provision(&ProvisionArgs { adapter: "spin".to_owned(), dry_run: true, + format: OutputFormat::Text, manifest: manifest_path.clone(), }) .expect("multi-config dispatch must succeed under KV-backed config"); @@ -451,6 +465,7 @@ ids = ["default"] let err = run_provision(&ProvisionArgs { adapter: "spin".to_owned(), dry_run: true, + format: OutputFormat::Text, manifest: manifest_path.clone(), }) .expect_err("env-overlay platform-label collision must fail provision"); @@ -480,6 +495,7 @@ ids = ["default"] run_provision(&ProvisionArgs { adapter: "spin".to_owned(), dry_run: true, + format: OutputFormat::Text, manifest: manifest_path.clone(), }) .expect("single-id case dispatches cleanly"); @@ -501,6 +517,7 @@ ids = ["default"] run_provision(&ProvisionArgs { adapter: "cloudflare".to_owned(), dry_run: true, + format: OutputFormat::Text, manifest: manifest_path.clone(), }) .expect("cloudflare dry-run dispatches cleanly"); @@ -521,6 +538,7 @@ ids = ["default"] run_provision(&ProvisionArgs { adapter: "fastly".to_owned(), dry_run: true, + format: OutputFormat::Text, manifest: manifest_path.clone(), }) .expect("fastly dry-run dispatches cleanly"); diff --git a/crates/edgezero-cli/tests/format_json.rs b/crates/edgezero-cli/tests/format_json.rs new file mode 100644 index 000000000..d4c1517dc --- /dev/null +++ b/crates/edgezero-cli/tests/format_json.rs @@ -0,0 +1,298 @@ +//! End-to-end `--format` stream contract, against the real `edgezero` binary. +//! +//! Under `--format json`, stdout must be exactly one JSON envelope, even when a +//! child process (here: a manifest shell command, or a fake `curl`) writes to +//! its inherited stdout. That output, and every log line, must land on stderr. +//! `--format text` (the default) must keep today's bytes on stdout. +//! +//! Hermetic: manifest commands are `echo` / `false`, and the healthcheck probes +//! a fake `curl` on `PATH`. No network, no platform credentials. + +#![cfg(unix)] + +#[cfg(test)] +mod tests { + use std::env; + use std::fs; + use std::os::unix::fs::PermissionsExt as _; + use std::path::Path; + use std::process::{Command, Output}; + + use serde_json::{Value, json}; + use tempfile::TempDir; + + const MANIFEST: &str = r#" +[app] +name = "demo-app" + +[adapters.axum.adapter] +crate = "crates/demo-axum" + +[adapters.axum.commands] +build = "echo child-wrote-to-stdout" +auth-status = "echo child-wrote-to-stdout; false" + +[stores.kv] +ids = ["sessions"] +"#; + + /// `healthcheck` against a fake `curl`: one attempt, no delay. + const HEALTHCHECK: [&str; 13] = [ + "healthcheck", + "--adapter", + "fastly", + "--domain", + "app.example.com", + "--service-id", + "SVC1", + "--version", + "7", + "--retry", + "1", + "--retry-delay", + "0", + ]; + + /// A temp project holding [`MANIFEST`] as `edgezero.toml`. + fn project() -> TempDir { + let dir = TempDir::new().expect("temp dir"); + fs::write(dir.path().join("edgezero.toml"), MANIFEST).expect("write manifest"); + dir + } + + /// Run `edgezero ` in `dir`, optionally with `bin_dir` first on `PATH`. + fn edgezero(dir: &Path, args: &[&str], bin_dir: Option<&Path>) -> Output { + let mut command = Command::new(env!("CARGO_BIN_EXE_edgezero")); + command + .args(args) + .current_dir(dir) + .env("EDGEZERO_MANIFEST", dir.join("edgezero.toml")) + .env_remove("FASTLY_API_TOKEN"); + if let Some(bin) = bin_dir { + let path = env::var_os("PATH").unwrap_or_default(); + let mut paths = vec![bin.to_path_buf()]; + paths.extend(env::split_paths(&path)); + command.env("PATH", env::join_paths(paths).expect("join PATH")); + } + command.output().expect("run edgezero") + } + + fn stdout_of(output: &Output) -> String { + String::from_utf8(output.stdout.clone()).expect("utf-8 stdout") + } + + fn stderr_of(output: &Output) -> String { + String::from_utf8(output.stderr.clone()).expect("utf-8 stderr") + } + + /// stdout parses as exactly ONE JSON value (nothing before or after it). + fn sole_json_document(output: &Output) -> Value { + let stdout = stdout_of(output); + let mut stream = serde_json::Deserializer::from_str(&stdout).into_iter::(); + let document = stream + .next() + .expect("stdout holds a JSON document") + .unwrap_or_else(|err| panic!("stdout is not clean JSON ({err}): {stdout:?}")); + assert!( + stream.next().is_none(), + "stdout holds more than one JSON value: {stdout:?}" + ); + document + } + + /// A fake `curl` answering every probe with HTTP `code`. + fn fake_curl(code: u16) -> TempDir { + let dir = TempDir::new().expect("temp dir"); + let script = dir.path().join("curl"); + fs::write(&script, format!("#!/bin/sh\necho {code}\n")).expect("write curl"); + let mut perms = fs::metadata(&script).expect("meta").permissions(); + perms.set_mode(0o755); + fs::set_permissions(&script, perms).expect("chmod +x"); + dir + } + + #[test] + fn json_build_keeps_child_stdout_off_the_envelope_stream() { + let dir = project(); + let output = edgezero( + dir.path(), + &["build", "--adapter", "axum", "--format", "json"], + None, + ); + assert!(output.status.success(), "stderr: {}", stderr_of(&output)); + let envelope = sole_json_document(&output); + assert_eq!( + envelope, + json!({ + "command": "build", + "error": null, + "ok": true, + "result": {"adapter": "axum", "artifact": null}, + "schema_version": 1_i32 + }) + ); + let stderr = stderr_of(&output); + assert!(stderr.contains("child-wrote-to-stdout"), "stderr: {stderr}"); + assert!(stderr.contains("[edgezero] executing"), "stderr: {stderr}"); + } + + #[test] + fn text_build_output_is_unchanged() { + let dir = project(); + let output = edgezero(dir.path(), &["build", "--adapter", "axum"], None); + assert!(output.status.success(), "stderr: {}", stderr_of(&output)); + let stdout = stdout_of(&output); + assert!( + stdout.starts_with( + "[edgezero] executing `echo child-wrote-to-stdout` for adapter `axum` in " + ), + "stdout: {stdout:?}" + ); + assert!( + stdout.ends_with("\nchild-wrote-to-stdout\n"), + "stdout: {stdout:?}" + ); + } + + #[test] + fn json_failure_still_emits_an_envelope_with_the_partial_result() { + let dir = project(); + let output = edgezero( + dir.path(), + &["auth", "status", "--adapter", "axum", "--format", "json"], + None, + ); + assert_eq!(output.status.code(), Some(1_i32)); + let envelope = sole_json_document(&output); + assert_eq!(envelope["command"], json!("auth status")); + assert_eq!(envelope["ok"], json!(false)); + assert_eq!( + envelope["result"], + json!({"adapter": "axum", "state": "unauthenticated"}) + ); + let message = envelope["error"]["message"] + .as_str() + .expect("error message"); + assert!(message.contains("exited with status"), "{message}"); + // The binary still logs the error to stderr, as in text mode. + assert!( + stderr_of(&output).contains(message), + "stderr repeats the error" + ); + } + + #[test] + fn json_provision_reports_each_store() { + let dir = project(); + let output = edgezero( + dir.path(), + &["provision", "--adapter", "axum", "--format", "json"], + None, + ); + assert!(output.status.success(), "stderr: {}", stderr_of(&output)); + let envelope = sole_json_document(&output); + assert_eq!( + envelope["result"], + json!({ + "adapter": "axum", + "dry_run": false, + "entries": [{ + "action": "not_applicable", + "message": "axum KV store `sessions` is in-memory; nothing to provision", + "store": {"kind": "kv", "logical": "sessions", "platform": "sessions"} + }] + }) + ); + } + + #[test] + fn json_config_validate_reports_the_validated_inputs() { + let dir = project(); + fs::write(dir.path().join("demo-app.toml"), "").expect("write app config"); + let output = edgezero( + dir.path(), + &["config", "validate", "--format", "json"], + None, + ); + assert!(output.status.success(), "stderr: {}", stderr_of(&output)); + let envelope = sole_json_document(&output); + assert_eq!(envelope["command"], json!("config validate")); + assert_eq!( + envelope["result"], + json!({ + "app_config": null, "app_name": "demo-app", "manifest": "edgezero.toml", + "mode": "raw", "strict": false + }) + ); + assert!( + stderr_of(&output).contains("config validate (raw): edgezero.toml OK"), + "the human summary moves to stderr" + ); + } + + #[test] + fn healthcheck_text_bytes_match_the_line_contract() { + let dir = project(); + let curl = fake_curl(200); + let output = edgezero(dir.path(), &HEALTHCHECK, Some(curl.path())); + assert!(output.status.success(), "stderr: {}", stderr_of(&output)); + assert_eq!( + stdout_of(&output), + "no FASTLY_API_TOKEN available; production healthcheck is service-level (probes the \ + live domain for service SVC1, not specifically version 7)\n\ + status-code=200\n\ + healthy=true\n" + ); + } + + #[test] + fn json_unhealthy_healthcheck_fails_with_its_measurements() { + let dir = project(); + let curl = fake_curl(503); + let mut args = HEALTHCHECK.to_vec(); + args.extend(["--format", "json"]); + let output = edgezero(dir.path(), &args, Some(curl.path())); + assert_eq!(output.status.code(), Some(1_i32)); + let envelope = sole_json_document(&output); + assert_eq!(envelope["ok"], json!(false)); + assert_eq!( + envelope["result"], + json!({ + "adapter": "fastly", "attempts": 1_i32, "domain": "app.example.com", + "healthy": false, "path": "/", "service_id": "SVC1", "staging": false, + "staging_ip": null, "status_code": 503_i32, "version": 7_i32, + "version_verified": false + }) + ); + // The line contract still reaches the logs, on stderr. + let stderr = stderr_of(&output); + assert!( + stderr.contains("status-code=503\nhealthy=false\n"), + "stderr: {stderr}" + ); + } + + #[test] + fn usage_errors_leave_stdout_empty() { + let dir = project(); + let output = edgezero( + dir.path(), + &["build", "--adapter", "axum", "--format", "yaml"], + None, + ); + assert_eq!(output.status.code(), Some(2_i32)); + assert!(output.stdout.is_empty(), "stdout: {:?}", stdout_of(&output)); + } + + #[test] + fn bundled_stub_ignores_format_and_leaves_stdout_empty() { + let dir = project(); + let output = edgezero( + dir.path(), + &["config", "diff", "--adapter", "fastly", "--format", "json"], + None, + ); + assert_eq!(output.status.code(), Some(2_i32)); + assert!(output.stdout.is_empty(), "stdout: {:?}", stdout_of(&output)); + } +} diff --git a/crates/edgezero-cli/tests/generated_project_builds.rs b/crates/edgezero-cli/tests/generated_project_builds.rs index da77786a5..a50d379dd 100644 --- a/crates/edgezero-cli/tests/generated_project_builds.rs +++ b/crates/edgezero-cli/tests/generated_project_builds.rs @@ -15,6 +15,10 @@ //! ``` #[cfg(test)] +#[expect( + clippy::disallowed_methods, + reason = "test harness runs `cargo` with inherited stdio to show build output; the `--format json` stdout policy does not apply" +)] mod tests { use std::path::Path; use std::process::{Command, ExitStatus}; diff --git a/docs/guide/cli-reference.md b/docs/guide/cli-reference.md index 779c38e76..ebe15b766 100644 --- a/docs/guide/cli-reference.md +++ b/docs/guide/cli-reference.md @@ -84,6 +84,7 @@ edgezero build --adapter **Arguments:** - `--adapter ` - Target adapter (`fastly`, `cloudflare`, `spin`, `axum`) +- `--format ` - output format (default `text`). `json` writes one [JSON envelope](#machine-readable-output) to stdout and moves all other output to stderr. Must come before any passthrough argument. **Examples:** @@ -155,6 +156,7 @@ edgezero deploy --adapter **Arguments:** - `--adapter ` - Target adapter (`fastly`, `cloudflare`, `spin`) +- `--format ` - output format (default `text`). `json` writes one [JSON envelope](#machine-readable-output) to stdout and moves all other output to stderr. - `--service-id ` - Platform service id the deploy targets (Fastly). Passed through to the provider; adapters that don't need one ignore it. - `--staging` - Deploy to a **staged** draft version instead of activating @@ -200,13 +202,14 @@ infer it afterward. The `deploy-fastly` recovery snippets in the [GitHub Actions guide](/guide/deploy-github-actions) invoke it directly. ```bash -edgezero active-version --adapter --service-id +edgezero active-version --adapter --service-id [--format ] ``` **Arguments:** - `--adapter ` — target adapter (required). Fastly implements it; other adapters have no active-version concept. - `--service-id ` — platform service id whose active version to resolve (required). +- `--format ` — output format (default `text`). `json` writes one [JSON envelope](#machine-readable-output) to stdout and moves all other output to stderr. Reads the Fastly API token from `FASTLY_API_TOKEN` in the environment. Emits `version=` on stdout, or an empty `version=` when the service has no active @@ -221,7 +224,7 @@ when the deployment is not provably healthy** — that non-zero exit is what gates a rollback. ```bash -edgezero healthcheck --adapter --service-id --version --domain [--path ] [--staging] [--retry ] [--retry-delay ] [--timeout ] +edgezero healthcheck --adapter --service-id --version --domain [--path ] [--staging] [--retry ] [--retry-delay ] [--timeout ] [--format ] ``` **Arguments:** @@ -235,6 +238,7 @@ edgezero healthcheck --adapter --service-id --version --domain < - `--retry ` — total number of attempts before declaring the probe unhealthy. Default: `3`. - `--retry-delay ` — seconds to wait between attempts. Default: `5`. - `--timeout ` — per-attempt connect/read timeout in seconds. Default: `10`. +- `--format ` — output format (default `text`). `json` writes one [JSON envelope](#machine-readable-output) to stdout and moves all other output to stderr. Only a **staging** probe needs `FASTLY_API_TOKEN` (to resolve the staging IP); a production probe just curls the domain and needs no token. Emits `healthy=` @@ -246,7 +250,7 @@ Roll a service back to a previous version, or deactivate a staged version (Fastly staging lifecycle). ```bash -edgezero rollback --adapter --service-id --version [--rollback-to ] [--staging] +edgezero rollback --adapter --service-id --version [--rollback-to ] [--staging] [--format ] ``` **Arguments:** @@ -256,6 +260,7 @@ edgezero rollback --adapter --service-id --version [--rollback-t - `--version ` — the current (bad) version to roll back **from** (required; staging deactivates it). - `--rollback-to ` — **production only:** the version to re-activate. Fastly cannot tell a previously-live version from a staged draft, so the target **cannot be inferred** — capture it before the superseding deploy (run [`active-version`](#edgezero-active-version), or use `deploy-fastly`'s `previous-version` output) and pass it here. Required for a production rollback; ignored for staging. - `--staging` — deactivate the staged version instead of activating `--rollback-to`. +- `--format ` — output format (default `text`). `json` writes one [JSON envelope](#machine-readable-output) to stdout and moves all other output to stderr. Reads the Fastly API token from `FASTLY_API_TOKEN` in the environment. A production rollback emits `rolled-back-to=` (the version it activated). Exits @@ -267,7 +272,7 @@ Validate `edgezero.toml` together with the typed `.toml` app config (see [Application config](/guide/configuration#application-config)). ```bash -edgezero config validate [--manifest ] [--app-config ] [--strict] [--no-env] +edgezero config validate [--manifest ] [--app-config ] [--strict] [--no-env] [--format ] ``` **Arguments:** @@ -276,6 +281,7 @@ edgezero config validate [--manifest ] [--app-config ] [--strict] [- - `--app-config ` — typed app-config path (default: `.toml` next to the manifest). - `--strict` — additionally check capability-aware completeness for the declared adapter set (spec §6.6) and well-formed Rust handler paths. - `--no-env` — skip the `__…__` env-var overlay when loading the app config. By default the validator reads the overlay so it sees the same values the runtime would. +- `--format ` — output format (default `text`). `json` writes one [JSON envelope](#machine-readable-output) to stdout and moves all other output to stderr. **Two flavours:** @@ -428,7 +434,7 @@ Local (`fastly.toml`) pushes prune their own prior chunks eagerly and never need `gc`. ```bash -edgezero config gc --adapter fastly [--manifest ] [--store ] [--older-than ] [--no-env] [--dry-run] [--yes] +edgezero config gc --adapter fastly [--manifest ] [--store ] [--older-than ] [--no-env] [--dry-run] [--yes] [--format ] ``` **Arguments:** @@ -440,6 +446,7 @@ edgezero config gc --adapter fastly [--manifest ] [--store ] [--older- - `--no-env` — ignore `EDGEZERO__STORES__CONFIG____NAME`, so the logical store id `` is used as the physical store name. This is **not** the app-config overlay that `validate`/`push`/`diff` mean by `--no-env` — `gc` never loads your typed app config. Because that variable is normally what maps a logical id onto the real store, `--no-env` **changes which store is swept**, and this command deletes. Check the store id `gc` reports before passing `--yes`. - `--dry-run` — preview only: name every key and age it would delete, and delete nothing. This is already the **default** (a run without `--yes` never deletes); the flag just states that intent explicitly to double-check a sweep. It **conflicts with `--yes`** — a single run cannot both preview and delete. - `--yes` — actually delete. **Without it, `config gc` is a dry run** that names every key and age it would delete and deletes nothing. +- `--format ` — output format (default `text`). `json` writes one [JSON envelope](#machine-readable-output) to stdout and moves all other output to stderr. **`--older-than` is an assertion only you can make, and it covers the whole store.** Fastly's config store is eventually consistent and offers no @@ -506,9 +513,16 @@ manifest declares — KV namespaces, config stores, secret stores crate owns its own implementation, the CLI is a thin delegate. ```bash -edgezero provision --adapter [--manifest ] [--dry-run] +edgezero provision --adapter [--manifest ] [--dry-run] [--format ] ``` +**Arguments:** + +- `--adapter ` — target adapter (required). +- `--manifest ` — manifest path (default: `edgezero.toml`). +- `--dry-run` — describe what would be done without doing it (see below). +- `--format ` — output format (default `text`). `json` writes one [JSON envelope](#machine-readable-output) to stdout and moves all other output to stderr. + **Per-adapter behaviour:** | `--adapter` | Behaviour | @@ -557,9 +571,13 @@ platform CLI, hit an HTTP API, or no-op (spec §11). ```bash edgezero auth login --adapter edgezero auth logout --adapter -edgezero auth status --adapter +edgezero auth status --adapter [--format ] ``` +`auth status` accepts `--format ` (default `text`); `json` writes +one [JSON envelope](#machine-readable-output) to stdout. `login` and `logout` +have no machine-readable mode. + Dispatch follows the same path as `build` / `deploy` / `serve`: the CLI looks up `[adapters..commands].auth-login` (or `auth-logout` / `auth-status`) in `edgezero.toml` first; if absent, @@ -593,6 +611,147 @@ server reads secrets from process env vars (`EDGEZERO__STORES__SECRETS____ not from a remote auth provider. ::: +## Machine-readable output + +Nine commands accept `--format json` so that scripts and CI can gate on +structured results instead of parsing log lines: `active-version`, +`auth status`, `build`, `config gc`, `config validate`, `deploy`, +`healthcheck`, `provision`, and `rollback`. `--format text` is the default and +leaves each command's output exactly as it was. + +```bash +edgezero healthcheck --adapter fastly --service-id "$SID" --version 7 \ + --domain www.example.com --format json | jq -e '.ok' +``` + +`config diff` keeps its own `--format ` and its own +JSON shape (`{ local_sha256, remote_sha256, added, removed, changed }`). It is +not wrapped in the envelope described here. + +### Streams + +With `--format json`: + +- **stdout** holds exactly one JSON document, the envelope. It is + pretty-printed, ends with a newline, and is written once when the command + finishes. +- **stderr** gets everything else: the human-readable output the command + prints in `text` mode (including `key=value` lines such as `version=`), + progress and warnings, the output of child processes (`cargo`, `fastly`, + `wrangler`, `spin`, and manifest `[adapters..commands]`), and the final + error line. + +An **empty stdout** with a non-zero exit means no envelope was produced. That +happens on command-line usage errors (clap reports them before `--format` is +read), on the bundled binary's `config push` / `config diff` stubs, and on a +crash. Treat it as a failure. + +### The envelope + +```json +{ + "command": "healthcheck", + "error": null, + "ok": true, + "result": { "adapter": "fastly", "healthy": true, "status_code": 200 }, + "schema_version": 1 +} +``` + +The `result` above is shortened; the full shape of each command's result is +listed under [Results](#results). + +| Key | Type | Meaning | +| ---------------- | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `schema_version` | integer | Version of the JSON surface. Currently `1`. | +| `command` | string | The command, spelled as typed: `active-version`, `auth status`, `build`, `config gc`, `config validate`, `deploy`, `healthcheck`, `provision`, `rollback`. | +| `ok` | boolean | `true` exactly when the process exits `0`. | +| `result` | object or null | Never null when `ok` is `true`. When `ok` is `false` it holds the partial result if the command still measured something (an unhealthy healthcheck, a partly failed `config gc`, an unauthenticated `auth status`), and is `null` otherwise. | +| `error` | object or null | `null` exactly when `ok` is `true`; otherwise `{ "message": string }`, the same message printed to stderr. | + +Every key listed for a result is always present: an unknown value is `null`, +never a missing key, and an empty list is `[]`. Durations are whole seconds in +keys ending `_secs`. Enum-like values are lowercase `snake_case` strings. Key +order is not part of the contract. + +**Exit codes** are the same in both formats: `0` on success, and non-zero on +failure (`1` for the bundled `edgezero` binary, `2` for a CLI generated from the +template). Branch on `ok` rather than on the specific non-zero code. + +### Compatibility + +`schema_version` covers the envelope and every command's result. + +- **Not breaking, no version bump:** adding a key, adding `error.code`, making + a nullable key always non-null, adding a value to a string enum. +- **Breaking, version bump:** removing or renaming a key, changing a key's type + or meaning, making a non-null key nullable, or changing what `ok` / `result` + mean. + +Consumers should ignore unknown keys, handle unknown enum values, and check +`schema_version`. + +### Results + +**`active-version`:** `{ adapter, service_id, version }`. `version` is `null` +when the service has no active version yet. + +**`auth status`:** `{ adapter, state }`, where `state` is `authenticated`, +`unauthenticated`, or `not_applicable` (axum). `unauthenticated` gives +`ok: false` with the result present. A missing native CLI gives `result: null`. + +**`build`:** `{ adapter, artifact }`. `artifact` is the built file's path, or +`null` when a manifest `commands.build` override ran or the adapter does not +track it (axum). + +**`deploy`:** `{ adapter, service_id, staging, version }`. `version` is the +staged version for `--staging`, the activated version for a Fastly production +deploy with a service id, and `null` otherwise. + +**`healthcheck`:** `{ adapter, service_id, domain, path, version, staging, +staging_ip, healthy, status_code, attempts, version_verified }`. `attempts` is +the number of probes actually made. `version_verified` is `true` when the +version was confirmed active before and after the probe (a production probe with +`FASTLY_API_TOKEN` set). An unhealthy probe gives `ok: false` with the result +present. + +**`rollback`:** `{ adapter, service_id, staging, version, rolled_back_to }`. +`version` is the version rolled back from, or the staged version that was +deactivated. `rolled_back_to` is `null` for `--staging`. + +**`provision`:** `{ adapter, dry_run, entries }`. Each entry is +`{ action, store, message }`: + +- `action` is one of `created`, `already_present`, `would_create`, `updated`, + `would_update`, `not_applicable`, or `note`. +- `store` is `{ kind, logical, platform }` (`kind` is `config`, `kv`, or + `secrets`), or `null` for an adapter-level note. `logical` is `null` for a + store EdgeZero owns itself, such as Fastly's `edgezero_runtime_env`. +- `message` is the line `text` mode prints. It may span several lines and is + meant for people, not parsing. + +**`config gc`:** `{ adapter, store, dry_run, older_than_secs, summary, +kept_roots, planned_deletions, deleted, failed, stranded, uncertain, warnings }`. + +- `store` is `{ logical, platform, id }`. +- `summary` holds the counts `entries`, `roots`, `referenced_chunks`, + `orphans_planned`, `generations_planned`, `orphans_too_recent`, and + `unprovable`. +- `planned_deletions` is a list of `{ key, age_secs }`. +- `older_than_secs` is `null` without `--older-than`, and `deleted` is `null` on + a dry run. +- If any delete failed, the command gives `ok: false` with the result present, + and `error.message` carries the recovery commands. + +**`config validate`:** `{ mode, manifest, app_config, app_name, strict }`. +`mode` is `raw` for the bundled binary (where `app_config` is `null`) and +`typed` for a generated CLI. A validation failure gives `ok: false` and +`result: null`, with the first failing check in `error.message`. + +### Schema changelog + +- **1**: initial version. + ## Environment Variables The CLI respects these environment variables: From 12ed8fc4c39384325ce8948fcf0a99bb6af33a24 Mon Sep 17 00:00:00 2001 From: dhruv8sh Date: Mon, 5 Oct 2026 14:39:54 +0530 Subject: [PATCH 3/6] Update --format json spec for the PR #388 review Revision 3 records the review's contract fixes: version_verified only when both active checks ran, config gc deleted: 0 for an empty real run, build rejecting a late --format, config validate always reporting app_config, deploy keeping its result and resolved service id, and Spin reporting created. Drops the unreachable active-version --require-active promise and documents --help/--version and the exit-code-2 overlap. --- .../2026-09-25-cli-format-json-design.md | 55 +++++++++++++++---- 1 file changed, 45 insertions(+), 10 deletions(-) diff --git a/docs/superpowers/specs/2026-09-25-cli-format-json-design.md b/docs/superpowers/specs/2026-09-25-cli-format-json-design.md index 100fece50..9bafd8eb8 100644 --- a/docs/superpowers/specs/2026-09-25-cli-format-json-design.md +++ b/docs/superpowers/specs/2026-09-25-cli-format-json-design.md @@ -149,7 +149,7 @@ whether it succeeds or fails: | `schema_version` | integer | `1` for this spec. See §6.5. | | `command` | string | The command path as the user types it: `"active-version"`, `"auth status"`, `"build"`, `"config gc"`, `"config validate"`, `"deploy"`, `"healthcheck"`, `"provision"`, `"rollback"`. | | `ok` | boolean | `true` exactly when the process will exit 0. | -| `result` | object or `null` | Never `null` when `ok` is `true`. When `ok` is `false` it holds the **partial result** if the command produced one (an unhealthy healthcheck, a partially failed gc, an unauthenticated `auth status`, `active-version --require-active` with nothing active), and is `null` otherwise. | +| `result` | object or `null` | Never `null` when `ok` is `true`. When `ok` is `false` it holds the **partial result** if the command produced one (an unhealthy healthcheck, a partially failed gc, an unauthenticated `auth status`, a deploy that went live without a resolvable version), and is `null` otherwise. | | `error` | object or `null` | `null` exactly when `ok` is `true`. Otherwise `{ "message": string }`. | **Field conventions**, applied to the envelope and every result: @@ -205,6 +205,11 @@ Exit codes do not change. | Command failure | 1 (bundled binary) / 2 (template-generated CLIs) | envelope, `ok: false` | | Usage error (reported by clap) | 2 | empty. clap parses before the format is known, and its message goes to stderr as today | | Bundled stub (`config push` / `config diff`) | 2 | empty | +| `--help` / `--version` (clap) | 0 | clap's text, not an envelope | + +In a generated CLI, exit code 2 covers a command failure, a usage error and an +unsupported `config diff`. Only the envelope tells them apart: an empty stdout +means no envelope was produced. Consumers should branch on `ok`, not on the specific non-zero code, because failure codes differ between binaries. @@ -418,8 +423,6 @@ Every command's `result` includes `adapter` (string), except ``` - `version` is `null` when no version is active. -- With `--require-active` and no active version: `ok: false`, and `result` is - present with `version: null`. ### `auth status` @@ -455,7 +458,11 @@ adapter cannot know the path. - For a production Fastly deploy with a service id, `version` is the active version after the deploy, found the same way as today (captured `version=` output, then falling back to the API). -- Otherwise `version` is `null`. `service_id` is `null` when none was given. +- Otherwise `version` is `null`. +- `service_id` is the id the adapter resolved (a Fastly staged deploy falls + back to `FASTLY_SERVICE_ID`), else `--service-id` as passed, else `null`. +- A production deploy that went live but whose version could not be resolved + gives `ok: false` with `result` present and `version: null`. ### `healthcheck` @@ -469,7 +476,8 @@ adapter cannot know the path. - `attempts` is the number of probes actually made. - `version_verified` is `true` when the check that the version is active ran - both before and after the probe (production with an API token). + both before and after the probe (a healthy production probe with an API + token). An unhealthy probe skips the after-probe check, so it is `false`. - Unhealthy means `ok: false` with `result` present, `healthy: false`, and `error.message` set to today's failure message. - A failure before the probe (invalid arguments, version not active, staging @@ -499,7 +507,10 @@ adapter cannot know the path. ``` - `action` is one of `created`, `already_present`, `would_create`, `updated`, - `would_update`, `not_applicable` or `note`. + `would_update`, `not_applicable` or `note`. A dry run's `would_create` pairs + with a real run's `created` or `already_present`, and `would_update` with + `updated`. The Fastly and Spin dry runs report the plan without checking the + current state. - `store` is `null` for adapter-level notes. `store.logical` is `null` for stores EdgeZero owns rather than the manifest, such as Fastly's `edgezero_runtime_env`. @@ -522,7 +533,8 @@ adapter cannot know the path. ``` - `older_than_secs` is `null` when `--older-than` was not given. -- `deleted` is `null` on a dry run. +- `deleted` is `null` on a dry run, and `0` for a real run with nothing to + reclaim. - If any delete failed: `ok: false`, `result` present, and `error.message` set to today's diagnostic, including the recovery commands. - The dry-run advisory text goes to stderr and is not part of `result`. @@ -535,7 +547,7 @@ adapter cannot know the path. - `mode` is `raw` in the bundled binary and `typed` in template-generated CLIs. -- `app_config` is `null` in `raw` mode. +- `app_config` is the app-config file both modes read and require. - A validation failure gives `ok: false` and `result: null`, with the first failing check in `error.message`. Validation stops at the first failure today. @@ -642,9 +654,32 @@ contract in §5, §6 or §8. a second rendering path that could drift. - **Only data lines moved to the CLI** (§7.2). Prose lines, such as the staged-rollback message, stay in the adapter. -- **`AuthStatus` carries a `failure` message**, so an unauthenticated status - keeps its exact error text. +- **`AuthState::Unauthenticated` carries its reason**, so an unauthenticated + status keeps its exact error text. - **The enums mapped to the wire are exhaustive**, not `#[non_exhaustive]` (§7.2). - **Wire types live in a single `output.rs`**, not an `output::wire` submodule. +Revision 3 records the changes from the review of the implementation PR +(#388). They fix results that contradicted §8 before schema version 1 shipped. + +- **`healthcheck.version_verified`** is `true` only when both active-version + checks ran. It was `true` on an unhealthy probe with a token. +- **`config gc` `deleted`** is `0`, not `null`, for a real run with nothing to + reclaim. +- **`build`** rejects a `--format` that follows a passthrough argument instead + of forwarding it to the build command. +- **`config validate.app_config`** is always set, in both modes. +- **`deploy`** reports the adapter-resolved `service_id`, and keeps its result + when the deploy went live but the version could not be resolved. +- **Spin `provision`** reports `created` for an added label, pairing with the + dry run's `would_create`. +- **Outcome types hold each fact once**: `HealthcheckOutcome::healthy()` is + derived from `failure`, `AuthState::Unauthenticated` carries its reason, and + a failed gc is one `GcFailure`. +- **`std::io::stdout` is a disallowed method**, with an `#[expect]` at each of + the three deliberate writers. The generated project's `clippy.toml` carries + the same guard. +- `active-version` has no `--require-active` flag (only the CLI's internal + deploy fallback passes it), so §6.1 and §8 no longer promise a partial result + for it. From 9b8c7108092d4b56af7d3eb5139f4070f0802630 Mon Sep 17 00:00:00 2001 From: dhruv8sh Date: Mon, 5 Oct 2026 14:39:54 +0530 Subject: [PATCH 4/6] Add plan recording the PR #388 review fixes Maps each review comment to the change that addresses it, with the tests and verification that back it. --- .../plans/2026-10-05-pr-388-review-fixes.md | 1507 +++++++++++++++++ 1 file changed, 1507 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-05-pr-388-review-fixes.md diff --git a/docs/superpowers/plans/2026-10-05-pr-388-review-fixes.md b/docs/superpowers/plans/2026-10-05-pr-388-review-fixes.md new file mode 100644 index 000000000..75b5e12d6 --- /dev/null +++ b/docs/superpowers/plans/2026-10-05-pr-388-review-fixes.md @@ -0,0 +1,1507 @@ +# PR #388 Review Fixes Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +> **Status: retroactive record.** This plan records changes that were already +> applied to the working tree of `spec/383-cli-format-json` on 2026-10-05, in +> response to aram356's "changes requested" review of +> [#388](https://github.com/stackpop/edgezero/pull/388). Every step is checked +> because it has been done and verified. The commit steps describe how to split +> the working tree into reviewable commits; none has been made yet. + +**Goal:** Fix the `--format json` results that contradicted the documented +schema before schema version 1 ships, and close the review's lint, docs and test +gaps. + +**Architecture:** Adapters return typed outcomes (`edgezero-adapter/src/registry.rs`), +the CLI maps them onto wire structs (`edgezero-cli/src/output.rs`), and +`output::finish` writes the envelope. The fixes correct the outcome producers +(Fastly, Spin adapters), the CLI mapping (`lib.rs`, `config.rs`), and the +outcome types themselves, so that each fact is stored once. + +**Tech Stack:** Rust 1.95.0 (edition 2024), clap, serde/serde_json, clippy +`disallowed-methods`, VitePress docs with Prettier. + +**Spec:** `docs/superpowers/specs/2026-09-25-cli-format-json-design.md` +(§6 output contract, §8 per-command schemas, §13 "Revision 3" lists these +changes). + +## Global Constraints + +- Run every cargo command with `cargo +1.95.0` (the `.tool-versions` pin). The + default toolchain on this machine reports false clippy failures. +- `--format text` output must stay byte-identical, except that `build` now + rejects a `--format` after a passthrough argument. +- No `http` crate imports, no Tokio in core or adapter crates. +- Tests must not need a network or platform credentials. Fake `curl` / `fastly` + binaries go on `PATH`. +- No `Co-Authored-By` trailers or AI bylines in commits. +- CI gates: `cargo fmt --all -- --check`; + `cargo clippy --workspace --all-targets --all-features -- -D warnings`; + `cargo test --workspace --all-targets`; + `cargo test -p edgezero-adapter-fastly --all-targets --features cli`; + `cargo check --workspace --all-targets --features "fastly cloudflare spin"`; + `cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin`; + the wasm clippy matrix in `.github/workflows/format.yml`; + `cargo test -p edgezero-cli --test generated_project_builds -- --ignored`; + `npm run lint` and `npm run format` in `docs/`. + +## Review item → task map + +| Review comment | Severity | Task | +| ------------------------------------------------------ | -------- | ---- | +| `version_verified: true` on an unhealthy probe | blocking | 2 | +| `build` swallows a trailing `--format json` | blocking | 4 | +| `config gc --yes` with nothing to reclaim → `null` | blocking | 3 | +| Spec approval link | question | (author answers on the PR; no code) | +| Raw `config validate` reports `app_config: null` | 🤔 | 5 | +| Provision dry-run / real-run actions don't pair | 🤔 | 7, 11 | +| Deploy went live, version unknown → `result: null` | 🤔 | 6 | +| `deploy.service_id` reflects only the flag | 🤔 | 6 | +| Outcome types can hold contradictory values | ♻️ | 1 | +| `AuthSub::Status`, `OutputFormat ==`, bare block, spec `--require-active` | ⛏ | 8, 11 | +| Docs: nullability, exit code 2, `--help`, logger | 🤔 | 11 | +| Test gaps | 🤔 | 2, 3, 4, 5, 6, 10 | +| Lint gaps (`io::stdout`, template `clippy.toml`) | 🌱 | 9 | +| Closed stderr loses the envelope | 🌱 | 9 | +| `wrangler whoami` exit code | out of scope | not addressed | + +## File map + +| File | Responsibility | Tasks | +| ---- | -------------- | ----- | +| `crates/edgezero-adapter/src/registry.rs` | Typed outcomes adapters return | 1, 6 | +| `crates/edgezero-adapter/src/cli_support.rs` | `native_auth_status`, `run_native_cli` | 1 | +| `crates/edgezero-adapter-axum/src/cli.rs` | axum `AuthStatus` outcome | 1 | +| `crates/edgezero-adapter-fastly/src/cli.rs` | Fastly healthcheck, gc, deploy, provision | 1, 2, 3, 6, 8 | +| `crates/edgezero-adapter-spin/src/cli.rs` | Spin provision | 7 | +| `crates/edgezero-cli/src/adapter.rs` | Manifest-command dispatch, tee | 1, 9 | +| `crates/edgezero-cli/src/auth.rs` | `auth status` mapping | 1 | +| `crates/edgezero-cli/src/output.rs` | Wire schema, `OutputScope`, `finish` | 1, 3, 5, 8, 9 | +| `crates/edgezero-cli/src/lib.rs` | `build`, `deploy`, healthcheck, logger | 1, 4, 6, 9 | +| `crates/edgezero-cli/src/config.rs` | `config validate`, `config gc` | 1, 5, 9 | +| `crates/edgezero-cli/src/args.rs` | clap argument types | 8 | +| `crates/edgezero-cli/tests/format_json.rs` | End-to-end stream tests | 4, 5, 6, 10 | +| `clippy.toml`, `crates/edgezero-cli/src/templates/root/clippy.toml.hbs` | stdout lint guard | 9 | +| `docs/guide/cli-reference.md`, the spec | Public contract | 11 | + +--- + +### Task 1: Store each outcome fact once + +The review found `HealthcheckOutcome { failure, healthy }`, +`AuthStatusOutcome { failure, state }` and +`GcReport { failed, failure_diagnostic, stranded, uncertain }` each record one +fact twice, kept in sync only by doc comments. This task makes the invalid +combinations unrepresentable. It is a pure refactor: JSON and text output do +not change, so the existing tests are the safety net. It goes first because +Tasks 2 and 3 build on the new shapes. + +**Files:** +- Modify: `crates/edgezero-adapter/src/registry.rs` (`AuthStatusOutcome`, `AuthState`, `HealthcheckOutcome`, `GcReport`, new `GcFailure`) +- Modify: `crates/edgezero-adapter/src/cli_support.rs` (`run_native_cli`, `native_auth_status`, its test) +- Modify: `crates/edgezero-adapter-axum/src/cli.rs` (`AuthStatus` arm) +- Modify: `crates/edgezero-adapter-fastly/src/cli.rs` (`healthcheck`, `gc_report_for`, `gc_fastly_config_store`, tests) +- Modify: `crates/edgezero-cli/src/adapter.rs` (`AuthStatus` arm of the manifest dispatch) +- Modify: `crates/edgezero-cli/src/auth.rs` (`auth_status`) +- Modify: `crates/edgezero-cli/src/output.rs` (`AuthStatusResult::new`, `HealthcheckResult::new`, `GcResult::new`, tests) +- Modify: `crates/edgezero-cli/src/config.rs` (`config_gc` failure mapping) +- Modify: `crates/edgezero-cli/src/lib.rs` (`healthy=` line) + +**Interfaces:** +- Produces: + - `pub struct AuthStatusOutcome { pub state: AuthState }` + - `pub enum AuthState { Authenticated, NotApplicable, Unauthenticated { reason: String } }` (no longer `Copy`) + - `HealthcheckOutcome` loses `healthy: bool`; gains `pub const fn healthy(&self) -> bool` + - `GcReport { ..., failure: Option, ... }` replaces `failed`, `failure_diagnostic`, `stranded`, `uncertain` + - `pub struct GcFailure { pub diagnostic: String, pub failed: Vec, pub stranded: Vec, pub uncertain: Vec }` (`Clone, Debug, Default, Eq, PartialEq`) + - `AuthStatusResult::new(adapter: &str, state: &AuthState) -> Self` + +- [x] **Step 1: Change the types in `registry.rs`** + +```rust +/// Result of [`AdapterAction::AuthStatus`]. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AuthStatusOutcome { + pub state: AuthState, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum AuthState { + Authenticated, + /// The adapter has no remote auth surface (axum). + NotApplicable, + Unauthenticated { + /// Why the session is not authenticated. + reason: String, + }, +} +``` + +In `HealthcheckOutcome`, delete `pub healthy: bool`, reword the `failure` doc +to `/// \`None\` when healthy; why the probe is unhealthy otherwise.`, and add: + +```rust +impl HealthcheckOutcome { + /// Whether the probe came back healthy. + #[inline] + #[must_use] + pub const fn healthy(&self) -> bool { + self.failure.is_none() + } +} +``` + +In `GcReport`, replace `failed`, `failure_diagnostic`, `stranded` and +`uncertain` with `/// \`None\` unless a delete failed.\npub failure: Option,` +and add: + +```rust +/// The deletes a `config gc` run could not complete. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct GcFailure { + /// The operator-facing report and recovery text. + pub diagnostic: String, + /// Keys whose delete failed (never empty). + pub failed: Vec, + /// Keys left in a generation that lost a sibling to a confirmed delete. + pub stranded: Vec, + /// Keys whose generation's only failure has an unknown outcome. + pub uncertain: Vec, +} +``` + +- [x] **Step 2: Update the auth producers** + +`cli_support.rs`: + +```rust +pub fn run_native_cli(program: &str, args: &[&str], install_hint: &str) -> Result<(), String> { + match native_auth_status(program, args, install_hint)?.state { + AuthState::Unauthenticated { reason } => Err(reason), + AuthState::Authenticated | AuthState::NotApplicable => Ok(()), + } +} +// in native_auth_status: + Ok(if status.success() { + AuthStatusOutcome { + state: AuthState::Authenticated, + } + } else { + AuthStatusOutcome { + state: AuthState::Unauthenticated { + reason: format!("`{program} {}` exited with status {status}", args.join(" ")), + }, + } + }) +``` + +axum: drop `failure: None,` from its `AuthStatusOutcome`. `edgezero-cli/src/adapter.rs`: + +```rust + (Action::AuthStatus, failure) => Ok(ActionOutcome::AuthStatus(AuthStatusOutcome { + state: failure.map_or(AuthState::Authenticated, |reason| { + AuthState::Unauthenticated { reason } + }), + })), +``` + +- [x] **Step 3: Update the auth consumers** + +`auth.rs`: + +```rust + let result = AuthStatusResult::new(adapter_name, &status.state); + match status.state { + AuthState::Unauthenticated { reason } => Err(Failure::with_result(reason, result)), + AuthState::Authenticated | AuthState::NotApplicable => Ok(result), + } +``` + +`output.rs`: `AuthStatusResult::new` takes `state: &AuthState` and matches +`AuthState::Unauthenticated { .. } => WireAuthState::Unauthenticated`. + +- [x] **Step 4: Update the healthcheck and gc consumers** + +- `output.rs` `HealthcheckResult::new`: `healthy: outcome.healthy(),` +- `lib.rs` healthcheck: `log::info!("healthy={}", outcome.healthy());` +- `output.rs` `GcResult::new`: start with + `let failure = report.failure.clone().unwrap_or_default();` and fill + `failed: failure.failed`, `stranded: failure.stranded`, + `uncertain: failure.uncertain`. +- `config.rs` `config_gc`: + +```rust + if let Some(failure) = report.failure { + return Err(Failure::with_result(failure.diagnostic, result)); + } +``` + +- Fastly `gc_fastly_config_store` failure branch: + +```rust + report.failure = Some(GcFailure { + diagnostic, + failed, + stranded, + uncertain, + }); + report.text_lines = out; + Ok(report) +``` + + `gc_report_for` drops the four old fields and sets `failure: None`. Add + `GcFailure` to the Fastly `use edgezero_adapter::registry::{…}` list. + +- [x] **Step 5: Update the tests that named the old fields** + +- `cli_support.rs` `native_auth_status_maps_exit_status_to_state`: + +```rust + let unauthenticated = native_auth_status("false", &[], "hint").expect("spawns"); + let AuthState::Unauthenticated { reason } = unauthenticated.state else { + panic!("expected Unauthenticated, got {:?}", unauthenticated.state); + }; + assert!(reason.contains("exited with status"), "got: {reason}"); +``` + +- Fastly `healthcheck_reports_a_structured_outcome`: drop `healthy: true,` from + the expected struct; `assert!(!unhealthy.healthy());`. +- Fastly test helper `run_gc`: `match report.failure { Some(failure) => Err(failure.diagnostic), None => Ok(report.text_lines) }`. +- `output.rs`: drop `healthy: false,` from `failure_envelope_keeps_the_partial_result`; + `AuthStatusResult::new("axum", &AuthState::NotApplicable)`. + +- [x] **Step 6: Verify** + +Run: `cargo +1.95.0 test --workspace --all-targets && cargo +1.95.0 test -p edgezero-adapter-fastly --features cli` +Expected: all pass, with no expectation changes beyond the field renames above. + +- [ ] **Step 7: Commit** + +```bash +git add crates/edgezero-adapter/src/registry.rs crates/edgezero-adapter/src/cli_support.rs \ + crates/edgezero-adapter-axum/src/cli.rs crates/edgezero-cli/src/adapter.rs \ + crates/edgezero-cli/src/auth.rs crates/edgezero-cli/src/output.rs \ + crates/edgezero-cli/src/config.rs crates/edgezero-cli/src/lib.rs +git add -p crates/edgezero-adapter-fastly/src/cli.rs # only the Task 1 hunks +git commit -m "Store each adapter outcome fact once" +``` + +--- + +### Task 2: `version_verified` only when both checks ran (blocking) + +The "after probing" `verify_version_active` call runs only in the healthy arm, +but the field was `production_token.is_some()`. An unhealthy production probe +with `FASTLY_API_TOKEN` set reported `version_verified: true`. + +**Files:** +- Modify: `crates/edgezero-adapter-fastly/src/cli.rs` (`healthcheck`, its doc comment, a new test after `healthcheck_reports_a_structured_outcome`) +- Test: `crates/edgezero-cli/tests/format_json.rs` (Task 10 adds the end-to-end pair) + +**Interfaces:** +- Consumes: `HealthcheckOutcome::healthy()` (Task 1) +- Produces: `HealthcheckOutcome::version_verified` is `true` only for a healthy production probe with a token. + +- [x] **Step 1: Write the failing test** + +```rust + /// `version_verified` claims the version was ACTIVE before AND after the + /// probe, so it is true only when both API checks actually ran: an + /// unhealthy probe skips the "after" check and must report `false`. + #[cfg(unix)] + #[test] + fn healthcheck_version_verified_only_when_both_checks_ran() { + use std::os::unix::fs::PermissionsExt as _; + let _lock = path_mutation_guard().lock().expect("guard"); + let _token = EnvOverride::set(FASTLY_API_TOKEN_ENV, "test-token"); + let args: Vec = [ + "--domain", "app.example.com", "--service-id", "SVC1", "--version", "7", + "--retry", "1", "--retry-delay", "0", + ] + .into_iter() + .map(str::to_owned) + .collect(); + // A fake `curl` serving the versions API (`--config -`, version 7 + // active) and answering health probes with `code`; each API call is + // logged so the test can count the checks. + let fake_curl_with_api = |code: u16| { + let dir = tempdir().expect("tempdir"); + let log = dir.path().join("api.log"); + let script = format!( + "#!/bin/sh\n\ + case \" $* \" in *\" --config \"*) cat >/dev/null; echo get >> '{log}'; \ + printf '%s\\n200' '[{{\"number\":7,\"active\":true}}]'; exit 0;; esac\n\ + echo {code}\n", + log = log.display() + ); + let script_path = dir.path().join("curl"); + fs::write(&script_path, script).expect("write script"); + let mut perms = fs::metadata(&script_path).expect("meta").permissions(); + perms.set_mode(0o755); + fs::set_permissions(&script_path, perms).expect("chmod +x"); + (dir, log) + }; + let api_calls = + |log: &Path| fs::read_to_string(log).unwrap_or_default().lines().count(); + + let (unhealthy_curl, unhealthy_log) = fake_curl_with_api(503); + let unhealthy_path = PathPrepend::new(unhealthy_curl.path()); + let unhealthy = healthcheck(&args).expect("an unhealthy probe is an outcome"); + assert!(!unhealthy.healthy()); + assert!( + !unhealthy.version_verified, + "the after-probe check never ran on an unhealthy probe" + ); + assert_eq!(api_calls(&unhealthy_log), 1, "only the before-probe check"); + drop(unhealthy_path); + + let (healthy_curl, healthy_log) = fake_curl_with_api(200); + let _path = PathPrepend::new(healthy_curl.path()); + let healthy = healthcheck(&args).expect("probe runs"); + assert!(healthy.healthy()); + assert!(healthy.version_verified, "both checks ran and passed"); + assert_eq!(api_calls(&healthy_log), 2, "before and after the probe"); + } +``` + +- [x] **Step 2: Run it to verify it fails** + +Run: `cargo +1.95.0 test -p edgezero-adapter-fastly --features cli -- healthcheck_version_verified` +Expected: FAIL at `!unhealthy.version_verified` (verified against the old code). + +- [x] **Step 3: Implement** + +```rust + // `version_verified` means BOTH checks ran, so it is true only on the + // healthy arm, the one that runs the "after probing" check. + let (version_verified, status_code, failure) = match outcome { + Ok(code) => { + if let Some(token) = production_token.as_deref() { + verify_version_active(&service_id, version, token, "after probing")?; + } + (production_token.is_some(), Some(code), None) + } + Err((last_code, msg)) => ( + false, + last_code, + Some(format!( + "healthcheck for {domain} failed after {} attempt(s): {msg}", + retry.max(1) + )), + ), + }; +``` + +and `version_verified,` in the returned struct. Fix the stale function doc: + +```rust +/// to `--retry` times. An unhealthy probe is still `Ok`, with `failure` +/// set; the CLI emits `status-code` / `healthy` and turns that into a +/// non-zero exit. +``` + +- [x] **Step 4: Run it to verify it passes** + +Run: `cargo +1.95.0 test -p edgezero-adapter-fastly --features cli -- healthcheck` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add -p crates/edgezero-adapter-fastly/src/cli.rs # Task 2 hunks +git commit -m "Report version_verified only when both active checks ran" +``` + +--- + +### Task 3: `config gc --yes` with nothing to reclaim reports `deleted: 0` (blocking) + +`gc_report_for` started every report at `deleted: None`, and the +`doomed_count == 0` early return came before `report.deleted = Some(deleted)`. +A real run against a store without orphans reported `deleted: null`, which the +docs define as a dry run. + +**Files:** +- Modify: `crates/edgezero-adapter-fastly/src/cli.rs` (`gc_report_for`, its call site, gc test helpers, two new tests) +- Modify: `crates/edgezero-cli/src/output.rs` (new `gc_failure_fills_the_partial_result` test) + +**Interfaces:** +- Consumes: `GcReport::failure`, `GcFailure` (Task 1) +- Produces: `fn gc_report_for(plan: &GcPlan, entries: usize, store_id: &str, dry_run: bool) -> GcReport`; test helper `fn gc_report(dir: &Path, older_than_secs: u64, dry_run: bool) -> Result` + +- [x] **Step 1: Split the test helper** + +```rust + #[cfg(unix)] + fn gc_report(dir: &Path, older_than_secs: u64, dry_run: bool) -> Result { + FastlyCliAdapter.gc_config_entries( + dir, + None, + None, + &ResolvedStoreId::from_logical(TEST_CONFIG_ID), + &AdapterPushContext::new(), + older_than_secs, + dry_run, + ) + } + + #[cfg(unix)] + fn run_gc(dir: &Path, older_than_secs: u64, dry_run: bool) -> Result, String> { + // Mirror the CLI: a report carrying a failure is an error. + let report = gc_report(dir, older_than_secs, dry_run)?; + match report.failure { + Some(failure) => Err(failure.diagnostic), + None => Ok(report.text_lines), + } + } +``` + +- [x] **Step 2: Write the failing tests** + +```rust + /// `deleted` is `None` only on a dry run: a real run that finds nothing to + /// reclaim deleted zero entries, and says so. + #[cfg(unix)] + #[test] + fn gc_report_deleted_is_zero_for_a_real_run_with_nothing_to_reclaim() { + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + let oplog = dir.path().join("ops.log"); + let live = gen_envelope("live"); + let mut listing = vec![listed_root(TEST_CONFIG_ID, &live, 172_800)]; + listing.extend(listed_generation(TEST_CONFIG_ID, &live, 172_800)); + let fake = fake_fastly_gc(TEST_CONFIG_ID, &[], &listing, None, false, &oplog); + let _path = PathPrepend::new(fake.path()); + + let real = gc_report(dir.path(), 86_400, false).expect("gc runs"); + assert_eq!(real.deleted, Some(0)); + assert_eq!(real.failure, None); + assert!(real.planned.is_empty()); + + let preview = gc_report(dir.path(), 86_400, true).expect("gc runs"); + assert_eq!(preview.deleted, None, "a dry run deletes nothing at all"); + } + + /// A failed delete is still a report: `failure` names the failed key, and + /// the keys whose outcome is unknown, alongside the recovery text. + #[cfg(unix)] + #[test] + fn gc_report_carries_a_failed_delete() { + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + let oplog = dir.path().join("ops.log"); + let live = gen_envelope("live"); + let dead = gen_envelope("dead"); + let dead_chunks = chunk_keys_of(TEST_CONFIG_ID, &dead); + let mut listing = vec![listed_root(TEST_CONFIG_ID, &live, 172_800)]; + listing.extend(listed_generation(TEST_CONFIG_ID, &live, 172_800)); + listing.extend(listed_generation(TEST_CONFIG_ID, &dead, 604_800)); + let fake = fake_fastly_gc( + TEST_CONFIG_ID, &[], &listing, Some(&dead_chunks[0]), false, &oplog, + ); + let _path = PathPrepend::new(fake.path()); + + let report = gc_report(dir.path(), 86_400, false).expect("a failed delete is a report"); + assert_eq!(report.deleted, Some(0)); + assert_eq!(report.planned.len(), dead_chunks.len()); + let failure = report.failure.expect("the failed delete is reported"); + assert_eq!(failure.failed, vec![dead_chunks[0].clone()]); + assert!(failure.stranded.is_empty(), "nothing was deleted before the failure"); + assert!(!failure.uncertain.is_empty(), "the failed first delete has an unknown outcome"); + assert!(failure.diagnostic.contains("unknown outcome"), "{}", failure.diagnostic); + } +``` + +In `output.rs` tests (add `GcFailure` to the test module's registry import): + +```rust + #[test] + fn gc_failure_fills_the_partial_result() { + let report = GcReport { + deleted: Some(1), + failure: Some(GcFailure { + diagnostic: "config gc: 1 of 3 deletes FAILED".to_owned(), + failed: vec!["c.2".to_owned()], + stranded: vec!["c.3".to_owned()], + uncertain: vec!["d.1".to_owned()], + }), + store_id: Some("7Ab".to_owned()), + ..GcReport::default() + }; + let store = ResolvedStoreId::from_logical("app_config"); + let result = GcResult::new("fastly", &store, false, Some(3_600), &report); + let failed: Outcome = Err(Failure::with_result( + report.failure.expect("failure").diagnostic, + result, + )); + let envelope = envelope_of(CommandName::ConfigGc, &failed); + assert_envelope_invariants(&envelope); + assert_eq!(envelope["ok"], json!(false)); + assert_eq!(envelope["error"], json!({"message": "config gc: 1 of 3 deletes FAILED"})); + let gc = &envelope["result"]; + assert_eq!(gc["deleted"], json!(1)); + assert_eq!(gc["failed"], json!(["c.2"])); + assert_eq!(gc["stranded"], json!(["c.3"])); + assert_eq!(gc["uncertain"], json!(["d.1"])); + assert_eq!(gc["older_than_secs"], json!(3_600)); + } +``` + +- [x] **Step 3: Run them to verify the first fails** + +Run: `cargo +1.95.0 test -p edgezero-adapter-fastly --features cli -- gc_report_` +Expected: `gc_report_deleted_is_zero_for_a_real_run_with_nothing_to_reclaim` FAILS (`left: None, right: Some(0)`); `gc_report_carries_a_failed_delete` passes, since it pins existing behavior. + +- [x] **Step 4: Implement** + +```rust +/// The typed `config gc` report for `plan`, before any delete runs. +fn gc_report_for(plan: &GcPlan, entries: usize, store_id: &str, dry_run: bool) -> GcReport { + GcReport { + // A real run that finds nothing to reclaim deleted zero entries; only a + // dry run reports `None`. + deleted: (!dry_run).then_some(0), + entries, + failure: None, + // ... remaining fields unchanged + } +} +``` + +Call site: `let mut report = gc_report_for(&plan, items.len(), &resolved_id, dry_run);` + +- [x] **Step 5: Run to verify they pass** + +Run: `cargo +1.95.0 test -p edgezero-adapter-fastly --features cli -- gc_ && cargo +1.95.0 test -p edgezero-cli --lib output::` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add -p crates/edgezero-adapter-fastly/src/cli.rs crates/edgezero-cli/src/output.rs +git commit -m "Report deleted: 0 for a real config gc run with nothing to reclaim" +``` + +--- + +### Task 4: `build` rejects a `--format` after passthrough args (blocking) + +`BuildArgs::adapter_args` uses `trailing_var_arg = true`, so +`edgezero build --adapter axum --release --format json` forwarded +`--format json` to the build command, printed text, and exited 0. + +**Files:** +- Modify: `crates/edgezero-cli/src/lib.rs` (`build`) +- Test: `crates/edgezero-cli/tests/format_json.rs` + +**Interfaces:** +- Consumes: none +- Produces: `build` returns `Err` (exit 1, empty stdout) when `adapter_args` contains `--format` or `--format=*`. + +- [x] **Step 1: Write the failing test** + +```rust + #[test] + fn json_build_rejects_a_format_after_passthrough_args() { + let dir = project(); + let output = edgezero( + dir.path(), + &["build", "--adapter", "axum", "--release", "--format", "json"], + None, + ); + assert_eq!(output.status.code(), Some(1_i32)); + // The late `--format` was never read, so this is a text-mode failure: + // nothing on stdout, and the build command never ran. + assert!(output.stdout.is_empty(), "stdout: {:?}", stdout_of(&output)); + let stderr = stderr_of(&output); + assert!( + stderr.contains("`--format` after a passthrough argument"), + "stderr: {stderr}" + ); + assert!(!stderr.contains("child-wrote-to-stdout"), "stderr: {stderr}"); + } +``` + +- [x] **Step 2: Run it to verify it fails** + +Run: `cargo +1.95.0 test -p edgezero-cli --test format_json json_build_rejects` +Expected: FAIL, exit code `Some(0)`. + +- [x] **Step 3: Implement** (first statement of `fn build`, before the manifest loads, so the check is hermetic) + +```rust + // `trailing_var_arg` captures everything after the first passthrough token, + // so a late `--format json` would be forwarded to the build command and the + // run would silently print text. Fail closed instead. + if let Some(flag) = args + .adapter_args + .iter() + .find(|arg| *arg == "--format" || arg.starts_with("--format=")) + { + return Err(format!( + "`{flag}` after a passthrough argument is forwarded to the build command, not read \ + as EdgeZero's `--format`. Put `--format` before the first passthrough argument." + ) + .into()); + } +``` + +`deploy -- --format=json` is deliberately left alone: the explicit `--` makes +the intent clear. + +- [x] **Step 4: Run it to verify it passes** + +Run: `cargo +1.95.0 test -p edgezero-cli --test format_json` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add crates/edgezero-cli/src/lib.rs crates/edgezero-cli/tests/format_json.rs +git commit -m "Reject a build --format that follows passthrough args" +``` + +--- + +### Task 5: `config validate` always reports `app_config` + +Both flows load and require `.toml`, but raw mode reported +`app_config: null` and the mode was inferred from that null. + +**Files:** +- Modify: `crates/edgezero-cli/src/output.rs` (`ValidateResult`, `ValidateMode`, unit test) +- Modify: `crates/edgezero-cli/src/config.rs` (`validate_raw`, `validate_typed`, import) +- Test: `crates/edgezero-cli/tests/format_json.rs` + +**Interfaces:** +- Produces: `ValidateResult::new(manifest: &Path, app_config: &Path, app_name: &str, strict: bool, mode: ValidateMode) -> Self`; `pub(crate) enum ValidateMode { Raw, Typed }`; wire key `app_config: String` (non-null; compatible under spec §6.5). + +- [x] **Step 1: Update the tests first** + +`format_json.rs` `json_config_validate_reports_the_validated_inputs`: expect +`"app_config": "demo-app.toml"`. Add: + +```rust + #[test] + fn json_config_validate_failure_has_no_result() { + // No `demo-app.toml`: the raw validator requires it. + let dir = project(); + let output = edgezero(dir.path(), &["config", "validate", "--format", "json"], None); + assert_eq!(output.status.code(), Some(1_i32)); + let envelope = sole_json_document(&output); + assert_eq!(envelope["ok"], json!(false)); + assert!(envelope["result"].is_null(), "{envelope}"); + let message = envelope["error"]["message"].as_str().expect("error message"); + assert!(message.contains("demo-app.toml"), "{message}"); + } +``` + +`output.rs`: rename the unit test to +`validate_result_reports_the_app_config_in_both_modes`, pass +`Path::new("demo.toml")` and `ValidateMode::Raw` / `ValidateMode::Typed`, and +expect `"app_config": "demo.toml"` for raw. + +- [x] **Step 2: Run to verify the e2e test fails** + +Run: `cargo +1.95.0 test -p edgezero-cli --test format_json json_config_validate` +Expected: FAIL, `left: … "app_config": String("demo-app.toml") … right: … Null` +(after Step 3 compiles), or a compile error in `output.rs` before it. + +- [x] **Step 3: Implement** + +```rust +pub(crate) struct ValidateResult { + app_config: String, + app_name: String, + manifest: String, + mode: ValidateMode, + strict: bool, +} + +impl ValidateResult { + pub(crate) fn new( + manifest: &Path, + app_config: &Path, + app_name: &str, + strict: bool, + mode: ValidateMode, + ) -> Self { + Self { + app_config: path_string(app_config), + app_name: app_name.to_owned(), + manifest: path_string(manifest), + mode, + strict, + } + } +} + +/// Which `config validate` flow ran. +#[derive(Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ValidateMode { + Raw, + Typed, +} +``` + +`config.rs`: both flows call +`ValidateResult::new(&args.manifest, &ctx.app_config_path, &ctx.app_name, args.strict, ValidateMode::Raw)` +(`ValidateMode::Typed` in `validate_typed`), and the `use crate::output::{…}` +list gains `ValidateMode`. + +- [x] **Step 4: Run to verify it passes** + +Run: `cargo +1.95.0 test -p edgezero-cli` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add crates/edgezero-cli/src/output.rs crates/edgezero-cli/src/config.rs crates/edgezero-cli/tests/format_json.rs +git commit -m "Always report app_config from config validate" +``` + +--- + +### Task 6: `deploy` keeps its result when it went live, and reports the resolved service id + +Two review items in one function: (a) when a Fastly production deploy succeeded +but neither the captured output nor the API fallback resolved the version, `?` +turned it into `result: null`, the same shape as "never ran"; (b) +`service_id` echoed `--service-id`, so a staged deploy using +`FASTLY_SERVICE_ID` reported `null`. + +**Files:** +- Modify: `crates/edgezero-adapter/src/registry.rs` (`DeployOutcome`) +- Modify: `crates/edgezero-adapter-fastly/src/cli.rs` (`Deploy` / `DeployStaging` arms) +- Modify: `crates/edgezero-cli/src/lib.rs` (`deploy`, `fastly_production_deploy`, `deployed_version` → `deployed`) +- Test: `crates/edgezero-cli/tests/format_json.rs` + +**Interfaces:** +- Consumes: `Failure::with_result` (`output.rs`) +- Produces: + - `DeployOutcome { pub service_id: Option, pub version: Option }` + - `fn fastly_production_deploy(..) -> Result, String>`: outer `Err` = deploy failed; inner `Err` = deployed, version unresolved + - `fn deployed(outcome: ActionOutcome) -> (Option, Option)` + +- [x] **Step 1: Write the failing tests** (uses `FASTLY_MANIFEST`, `DEPLOY` and `project_with` from Task 10 Step 1) + +```rust + #[test] + fn deploy_reads_the_version_from_the_captured_output() { + let dir = project_with(FASTLY_MANIFEST); + let text = edgezero(dir.path(), &DEPLOY, None); + assert!(text.status.success(), "stderr: {}", stderr_of(&text)); + let stdout = stdout_of(&text); + // The manifest command receives `--service-id SVC1` as passthrough. + assert!( + stdout.ends_with( + "SUCCESS: Deployed package (service SVC1, version 12) --service-id SVC1\n\ + version=12\n" + ), + "the tee echoes the child live, then the version line: {stdout:?}" + ); + + let mut args = DEPLOY.to_vec(); + args.extend(["--format", "json"]); + let output = edgezero(dir.path(), &args, None); + assert!(output.status.success(), "stderr: {}", stderr_of(&output)); + assert_eq!( + sole_json_document(&output)["result"], + json!({"adapter": "fastly", "service_id": "SVC1", "staging": false, "version": 12_i32}) + ); + // The tee sends the captured child's stdout to stderr under JSON. + let stderr = stderr_of(&output); + assert!( + stderr.contains("SUCCESS: Deployed package (service SVC1, version 12) --service-id"), + "stderr: {stderr}" + ); + assert!(stderr.contains("version=12\n"), "stderr: {stderr}"); + } + + #[test] + fn deploy_that_went_live_without_a_version_keeps_its_result() { + let manifest = FASTLY_MANIFEST.replace( + "echo 'SUCCESS: Deployed package (service SVC1, version 12)'", + "echo deployed", + ); + let dir = project_with(&manifest); + let mut args = DEPLOY.to_vec(); + args.extend(["--format", "json"]); + // No token: the API fallback cannot resolve the version either. + let output = edgezero(dir.path(), &args, None); + assert_eq!(output.status.code(), Some(1_i32)); + let envelope = sole_json_document(&output); + assert_eq!(envelope["ok"], json!(false)); + assert_eq!( + envelope["result"], + json!({"adapter": "fastly", "service_id": "SVC1", "staging": false, "version": null}), + "the deploy went live, so the result is present" + ); + let message = envelope["error"]["message"].as_str().expect("error message"); + assert!(message.starts_with("deploy succeeded but"), "{message}"); + } +``` + +- [x] **Step 2: Run to verify the second fails** + +Run: `cargo +1.95.0 test -p edgezero-cli --test format_json deploy_` +Expected: `deploy_that_went_live_without_a_version_keeps_its_result` FAILS (`result` is `null`); the first passes and pins the tee path. + +- [x] **Step 3: Add `service_id` to `DeployOutcome`** + +```rust +pub struct DeployOutcome { + /// The service the deploy targeted, when the adapter resolved one. + pub service_id: Option, + /// The staged (or activated) platform version, when known. + pub version: Option, +} +``` + +Fastly arms: + +```rust + AdapterAction::Deploy => deploy(args).map(|()| { + ActionOutcome::Deploy(DeployOutcome { + service_id: None, + version: None, + }) + }), + // ... + AdapterAction::DeployStaging => deploy_staging(args).map(|version| { + ActionOutcome::Deploy(DeployOutcome { + // `deploy_staging` already resolved this (flag, then + // `FASTLY_SERVICE_ID`), so it cannot fail here. + service_id: resolve_service_id(args).ok(), + version: Some(version), + }) + }), +``` + +- [x] **Step 4: Rework `deploy` in `lib.rs`** + +```rust + // `service_id`: the id the adapter resolved, else `--service-id` as passed. + let result = |service_id: Option, version: Option| { + DeployResult::new( + &args.adapter, + service_id.or_else(|| args.service_id.clone()), + args.staging, + version, + ) + }; +``` + +Staged branch: `let (service_id, version) = deployed(outcome); … return Ok(result(service_id, version));` + +Production Fastly branch: + +```rust + if args.service_id.is_some() && args.adapter.eq_ignore_ascii_case("fastly") { + return match fastly_production_deploy(&args.adapter, manifest.as_ref(), &passthrough)? { + Ok(version) => { + log::info!("version={version}"); + Ok(result(None, Some(version))) + } + Err(unresolved) => Err(Failure::with_result(unresolved, result(None, None))), + }; + } +``` + +Fallthrough: `let (service_id, version) = deployed(outcome); Ok(result(service_id, version))`. + +`fastly_production_deploy` (doc: "The outer `Err` means the deploy itself +failed. The inner `Err` means the deploy went live but the activated version +could not be resolved."): `return Ok(Ok(version));` on a parsed version, and end +with: + +```rust + // `--require-active` makes EmitVersion fail rather than report none. + Ok( + match adapter::execute( + adapter_name, + adapter::Action::EmitVersion, + manifest, + &emit_args, + ) { + Ok(outcome) => active_version(&outcome) + .ok_or_else(|| unresolved("no active version was reported")), + Err(err) => Err(unresolved(&err)), + }, + ) +``` + +Replace `deployed_version`: + +```rust +/// The service id and version a deploy outcome reports, if any. +#[cfg(feature = "cli")] +fn deployed(outcome: ActionOutcome) -> (Option, Option) { + if let ActionOutcome::Deploy(deployed) = outcome { + (deployed.service_id, deployed.version) + } else { + (None, None) + } +} +``` + +The text-mode error message is unchanged: `Failure`'s message is the same +`unresolved(..)` string. + +- [x] **Step 5: Run to verify** + +Run: `cargo +1.95.0 test -p edgezero-cli && cargo +1.95.0 clippy -p edgezero-cli --all-targets --all-features -- -D warnings` +Expected: PASS. (A `match outcome { Ok(outcome) => … }` form trips +`clippy::shadow_reuse`; the inline `match adapter::execute(..)` avoids it.) + +- [ ] **Step 6: Commit** + +```bash +git add crates/edgezero-adapter/src/registry.rs crates/edgezero-cli/src/lib.rs +git add -p crates/edgezero-adapter-fastly/src/cli.rs crates/edgezero-cli/tests/format_json.rs +git commit -m "Keep the deploy result when the version is unresolved; report the resolved service id" +``` + +--- + +### Task 7: Spin reports `created` for an added label + +The Spin dry run reports `would_create`, but adding the label reported +`updated`, so a consumer couldn't pair a plan with its outcome. The text line is +unchanged. + +**Files:** +- Modify: `crates/edgezero-adapter-spin/src/cli.rs` (`provision` add branch; `provision_writes_kv_labels_into_resolved_component` test) + +**Interfaces:** +- Produces: Spin real run → `ProvisionAction::Created` (added) or `AlreadyPresent`. + +- [x] **Step 1: Write the failing assertion** + +```rust + let report = SpinCliAdapter + .provision(dir.path(), Some("spin.toml"), None, &stores, false) + .expect("real run succeeds"); + // Pairs with the dry run's `WouldCreate`. + assert_eq!(report.entries[0].action, ProvisionAction::Created); + let out = report.into_messages(); + assert_eq!(out.len(), 1); + assert!(out[0].contains("added KV label `sessions`"), "got: {out:?}"); +``` + +- [x] **Step 2: Run to verify it fails** + +Run: `cargo +1.95.0 test -p edgezero-adapter-spin --features cli provision_writes_kv_labels` +Expected: FAIL, `left: Updated, right: Created`. + +- [x] **Step 3: Implement** + +In the `if added {` branch, change `ProvisionAction::Updated` to `ProvisionAction::Created`. + +- [x] **Step 4: Run to verify it passes** + +Same command. Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add crates/edgezero-adapter-spin/src/cli.rs +git commit -m "Report created for a Spin label provision adds" +``` + +(The Fastly dry run also reports `would_create` before checking +`setup_block_present`. Making it check would change text output, so the review +agreed to document it instead; see Task 11.) + +--- + +### Task 8: Review nits + +**Files:** +- Modify: `crates/edgezero-cli/src/args.rs` (`AuthSub::Status`) +- Modify: `crates/edgezero-cli/src/output.rs` (`OutputScope::enter`, `finish`) +- Modify: `crates/edgezero-adapter-fastly/src/cli.rs` (`provision_runtime_env_store`) + +**Interfaces:** no signature changes. + +- [x] **Step 1: `#[non_exhaustive]` on the variant that gained `format`** + +```rust + /// Show the current session (`wrangler whoami` / `fastly profile + /// list` / `spin cloud info`). + #[non_exhaustive] + Status { +``` + +- [x] **Step 2: Exhaustive `match` on `OutputFormat`** (it is `#[non_exhaustive]`, but an in-crate `match` is still checked, so a future `Ndjson` fails to compile here instead of silently acting like `text`) + +```rust + let previous = match format { + OutputFormat::Text => None, + OutputFormat::Json => Some(( + process::set_child_stdout_to_stderr(true), + crate::set_info_to_stderr(true), + )), + }; +``` + +and in `finish`: `match format { OutputFormat::Text => {} OutputFormat::Json => { … } }` +around the existing envelope write. + +- [x] **Step 3: Flatten the leftover bare block** + +In `provision_runtime_env_store`, remove the `{` … `}` that wraps everything +after the `setup_block_present` early return, and dedent its body by one level. +No logic changes. + +- [x] **Step 4: Verify** + +Run: `cargo +1.95.0 fmt --all && cargo +1.95.0 clippy --workspace --all-targets --all-features -- -D warnings && cargo +1.95.0 test -p edgezero-cli` +Expected: clean, PASS. + +- [ ] **Step 5: Commit** + +```bash +git add crates/edgezero-cli/src/args.rs +git add -p crates/edgezero-cli/src/output.rs crates/edgezero-adapter-fastly/src/cli.rs +git commit -m "Address review nits in the --format plumbing" +``` + +--- + +### Task 9: Guard every stdout writer, and survive a closed stderr + +`disallowed-methods` caught `status` / `spawn`, but not +`writeln!(io::stdout(), …)` (`print_stdout` only covers the macros). And the +logger's `eprintln!` panicked on a closed stderr, which under JSON is the busy +stream, so the command could die (exit 101) before writing its envelope. + +**Files:** +- Modify: `clippy.toml` +- Modify: `crates/edgezero-cli/src/templates/root/clippy.toml.hbs` +- Modify: `crates/edgezero-cli/src/output.rs` (`finish`), `crates/edgezero-cli/src/adapter.rs` (`run_shell_tee`), `crates/edgezero-cli/src/config.rs` (`print_unified_diff_inline`) +- Modify: `crates/edgezero-cli/src/lib.rs` (`CliLogger::log`, imports) + +**Interfaces:** none. + +- [x] **Step 1: Add the lint entry** + +`clippy.toml` (and extend the comment above it with "as does every direct +`std::io::stdout()` writer (`print_stdout` covers only the macros)"): + +```toml + { path = "std::io::stdout", reason = "`--format json` reserves stdout for the JSON envelope; write through the logger, or `#[expect]` with a reason" }, +``` + +- [x] **Step 2: Run clippy to see it fire** + +Run: `cargo +1.95.0 clippy --workspace --all-targets --all-features -- -D warnings` +Expected: three `disallowed_methods` errors: `output.rs` (`finish`), +`adapter.rs` (`run_shell_tee`), `config.rs` (`print_unified_diff_inline`). + +- [x] **Step 3: `#[expect]` the three deliberate writers** + +```rust + // output.rs, finish: + #[expect( + clippy::disallowed_methods, + reason = "the JSON envelope is the one thing `--format json` writes to stdout" + )] + let mut stdout = io::stdout().lock(); + + // adapter.rs, run_shell_tee: + } else { + #[expect( + clippy::disallowed_methods, + reason = "text mode echoes the child's stdout to stdout, exactly as before `--format`" + )] + let stdout = io::stdout(); + tee_stream(child_stdout, stdout) + }; + + // config.rs, print_unified_diff_inline: + #[expect( + clippy::disallowed_methods, + reason = "`config push` has no `--format`; its inline diff is text-only output" + )] + let mut stdout = stdout().lock(); +``` + +- [x] **Step 4: Carry the guard into generated projects** + +Append to `templates/root/clippy.toml.hbs`: + +```toml + +# The built-in commands' `--format json` reserves stdout for one JSON envelope. +# Mirror the EdgeZero workspace guard so your own subcommands keep stdout clean +# too: route output through the logger (`edgezero_cli::init_cli_logger`), and +# `#[expect]` any deliberate stdout write or child spawn with a reason. +disallowed-methods = [ + { path = "std::process::Command::status", reason = "the child inherits stdout; pipe or redirect it, or `#[expect]` with a reason" }, + { path = "std::process::Command::spawn", reason = "the child inherits stdout by default; pipe or redirect it, or `#[expect]` with a reason" }, + { path = "std::io::stdout", reason = "`--format json` reserves stdout for the JSON envelope; write through the logger, or `#[expect]` with a reason" }, +] +``` + +The reasons don't point at `edgezero_adapter::process`: a generated CLI depends +only on `edgezero-cli`. + +- [x] **Step 5: Make the logger's stderr writes infallible** + +`lib.rs` imports: `use std::io::{self, ErrorKind, Write as _};`. In `CliLogger::log`: + +```rust + // `writeln!` rather than `eprintln!`: a closed stderr must not panic + // the command before `--format json` writes its envelope. + match record.level() { + // warn/error always go to stderr; `--format json` sends info there + // too, keeping stdout for the JSON envelope. + log::Level::Error | log::Level::Warn => { + let _ignored = writeln!(io::stderr(), "{}", record.args()); + } + log::Level::Info if INFO_TO_STDERR.load(Ordering::SeqCst) => { + let _ignored = writeln!(io::stderr(), "{}", record.args()); + } + // the stdout `info` arm is unchanged +``` + +- [x] **Step 6: Verify** + +Run: `cargo +1.95.0 clippy --workspace --all-targets --all-features -- -D warnings && cargo +1.95.0 test -p edgezero-cli --test generated_project_builds -- --ignored` +Expected: clippy clean (the three `#[expect]`s are fulfilled, so none is +reported as unfulfilled); the generated workspace compiles (about 2 minutes). + +- [ ] **Step 7: Commit** + +```bash +git add clippy.toml crates/edgezero-cli/src/templates/root/clippy.toml.hbs crates/edgezero-cli/src/adapter.rs +git add -p crates/edgezero-cli/src/output.rs crates/edgezero-cli/src/config.rs crates/edgezero-cli/src/lib.rs +git commit -m "Lint direct stdout writers and never panic on a closed stderr" +``` + +--- + +### Task 10: End-to-end coverage for active-version, rollback and the token healthcheck + +The review listed commands whose result mapping, and the +`version=` / `rolled-back-to=` / "no active version yet" lines that moved into +the CLI, had no tests. These tests also commit a reduced form of the text-mode +byte comparison: each checks the exact text bytes as well as the JSON. + +**Files:** +- Test: `crates/edgezero-cli/tests/format_json.rs` + +**Interfaces:** +- Produces (test helpers, used by Tasks 4–6 tests too): `const FASTLY_MANIFEST: &str`, `const VERSIONS: &str`, `const ACTIVE_VERSION: [&str; 5]`, `const ROLLBACK: [&str; 9]`, `const DEPLOY: [&str; 5]`, `fn project_with(manifest: &str) -> TempDir`, `fn edgezero_with_token(dir: &Path, args: &[&str], bin_dir: Option<&Path>, token: Option<&str>) -> Output`, `fn fake_curl_api(code: u16) -> TempDir`, `fn fake_curl_script(body: &str) -> TempDir`, `fn api_calls(curl: &TempDir) -> Vec` + +- [x] **Step 1: Add the helpers** + +```rust + /// A Fastly app whose production deploy is a manifest command, so `deploy` + /// runs it through the capturing (tee) path. + const FASTLY_MANIFEST: &str = r#" +[app] +name = "demo-app" + +[adapters.fastly.adapter] +crate = "crates/demo-fastly" + +[adapters.fastly.commands] +deploy = "echo 'SUCCESS: Deployed package (service SVC1, version 12)'" +"#; + + /// The versions API answer: 6 inactive, 7 active. + const VERSIONS: &str = r#"[{"number":6,"active":false},{"number":7,"active":true}]"#; + + const ACTIVE_VERSION: [&str; 5] = + ["active-version", "--adapter", "fastly", "--service-id", "SVC1"]; + + const ROLLBACK: [&str; 9] = [ + "rollback", "--adapter", "fastly", "--service-id", "SVC1", + "--version", "7", "--rollback-to", "6", + ]; + + const DEPLOY: [&str; 5] = ["deploy", "--adapter", "fastly", "--service-id", "SVC1"]; +``` + +These consts sit with `HEALTHCHECK`, before the functions +(`clippy::arbitrary_source_item_ordering` rejects consts between tests). + +```rust + /// A temp project holding `manifest` as `edgezero.toml`. + fn project_with(manifest: &str) -> TempDir { + let dir = TempDir::new().expect("temp dir"); + fs::write(dir.path().join("edgezero.toml"), manifest).expect("write manifest"); + dir + } +``` + +`project()` becomes `project_with(MANIFEST)`. `edgezero(dir, args, bin_dir)` +becomes `edgezero_with_token(dir, args, bin_dir, None)`, and +`edgezero_with_token` is the old body plus `.env_remove("FASTLY_SERVICE_ID")` +and: + +```rust + if let Some(value) = token { + command.env("FASTLY_API_TOKEN", value); + } +``` + +(`value`, not `token`: clippy rejects shadowing the parameter.) + +```rust + /// A fake `curl` answering every probe with HTTP `code`. + fn fake_curl(code: u16) -> TempDir { + fake_curl_script(&format!("echo {code}")) + } + + /// A fake `curl` that also serves the Fastly API: a request read from + /// `--config -` gets [`VERSIONS`] (GET) or a bare 200 (PUT), and is logged + /// to `api.log` as `GET` / `PUT`. A health probe gets HTTP `code`. + fn fake_curl_api(code: u16) -> TempDir { + fake_curl_script(&format!( + "case \" $* \" in *\" --config \"*)\n\ + \x20 cfg=$(cat)\n\ + \x20 case \"$cfg\" in *'request = \"PUT\"'*) echo PUT >> \"$(dirname \"$0\")/api.log\"; printf 200;;\n\ + \x20 *) echo GET >> \"$(dirname \"$0\")/api.log\"; printf '%s\\n200' '{VERSIONS}';; esac\n\ + \x20 exit 0;;\n\ + esac\n\ + echo {code}" + )) + } + + fn fake_curl_script(body: &str) -> TempDir { + let dir = TempDir::new().expect("temp dir"); + let script = dir.path().join("curl"); + fs::write(&script, format!("#!/bin/sh\n{body}\n")).expect("write curl"); + let mut perms = fs::metadata(&script).expect("meta").permissions(); + perms.set_mode(0o755); + fs::set_permissions(&script, perms).expect("chmod +x"); + dir + } + + /// The Fastly API calls a [`fake_curl_api`] served, in order. + fn api_calls(curl: &TempDir) -> Vec { + fs::read_to_string(curl.path().join("api.log")) + .unwrap_or_default() + .lines() + .map(str::to_owned) + .collect() + } +``` + +- [x] **Step 2: Add the tests** + +```rust + #[test] + fn json_healthcheck_with_a_token_verifies_the_version() { + let dir = project(); + let curl = fake_curl_api(200); + let mut args = HEALTHCHECK.to_vec(); + args.extend(["--format", "json"]); + let output = edgezero_with_token(dir.path(), &args, Some(curl.path()), Some("tok")); + assert!(output.status.success(), "stderr: {}", stderr_of(&output)); + let envelope = sole_json_document(&output); + assert_eq!(envelope["result"]["healthy"], json!(true)); + assert_eq!(envelope["result"]["version_verified"], json!(true)); + assert_eq!(api_calls(&curl), ["GET", "GET"], "before and after the probe"); + } + + #[test] + fn json_unhealthy_healthcheck_with_a_token_is_not_verified() { + let dir = project(); + let curl = fake_curl_api(503); + let mut args = HEALTHCHECK.to_vec(); + args.extend(["--format", "json"]); + let output = edgezero_with_token(dir.path(), &args, Some(curl.path()), Some("tok")); + assert_eq!(output.status.code(), Some(1_i32)); + let envelope = sole_json_document(&output); + assert_eq!(envelope["result"]["healthy"], json!(false)); + assert_eq!( + envelope["result"]["version_verified"], + json!(false), + "the after-probe check never ran" + ); + assert_eq!(api_calls(&curl), ["GET"]); + } + + #[test] + fn active_version_text_and_json() { + let dir = project(); + let curl = fake_curl_api(200); + let text = edgezero_with_token(dir.path(), &ACTIVE_VERSION, Some(curl.path()), Some("t")); + assert!(text.status.success(), "stderr: {}", stderr_of(&text)); + assert_eq!(stdout_of(&text), "version=7\n"); + + let mut args = ACTIVE_VERSION.to_vec(); + args.extend(["--format", "json"]); + let output = edgezero_with_token(dir.path(), &args, Some(curl.path()), Some("t")); + assert!(output.status.success(), "stderr: {}", stderr_of(&output)); + assert_eq!( + sole_json_document(&output)["result"], + json!({"adapter": "fastly", "service_id": "SVC1", "version": 7_i32}) + ); + assert!(stderr_of(&output).contains("version=7\n")); + } + + #[test] + fn active_version_with_none_active_reports_null() { + let dir = project(); + // Overwrite the API answer: no version is active yet. + let curl = fake_curl_script( + "case \" $* \" in *\" --config \"*) cat >/dev/null; \ + printf '%s\\n200' '[{\"number\":1,\"active\":false}]'; exit 0;; esac", + ); + let text = edgezero_with_token(dir.path(), &ACTIVE_VERSION, Some(curl.path()), Some("t")); + assert!(text.status.success(), "stderr: {}", stderr_of(&text)); + assert_eq!( + stdout_of(&text), + "version=\nservice SVC1 has no active version yet; emitting an empty rollback target\n" + ); + + let mut args = ACTIVE_VERSION.to_vec(); + args.extend(["--format", "json"]); + let output = edgezero_with_token(dir.path(), &args, Some(curl.path()), Some("t")); + assert!(output.status.success(), "stderr: {}", stderr_of(&output)); + assert_eq!(sole_json_document(&output)["result"]["version"], json!(null)); + } + + #[test] + fn rollback_text_and_json() { + let dir = project(); + let curl = fake_curl_api(200); + let text = edgezero_with_token(dir.path(), &ROLLBACK, Some(curl.path()), Some("t")); + assert!(text.status.success(), "stderr: {}", stderr_of(&text)); + assert!( + stdout_of(&text).ends_with("rolled-back-to=6\n"), + "stdout: {:?}", + stdout_of(&text) + ); + + let mut args = ROLLBACK.to_vec(); + args.extend(["--format", "json"]); + let output = edgezero_with_token(dir.path(), &args, Some(curl.path()), Some("t")); + assert!(output.status.success(), "stderr: {}", stderr_of(&output)); + assert_eq!( + sole_json_document(&output)["result"], + json!({ + "adapter": "fastly", "rolled_back_to": 6_i32, "service_id": "SVC1", + "staging": false, "version": 7_i32 + }) + ); + assert!(stderr_of(&output).contains("rolled-back-to=6\n")); + } +``` + +- [x] **Step 3: Run** + +Run: `cargo +1.95.0 test -p edgezero-cli --test format_json` +Expected: 18 passed. `json_unhealthy_healthcheck_with_a_token_is_not_verified` +fails without Task 2; the others pin existing behavior. + +- [ ] **Step 4: Commit** (Tasks 4–6 commit their own tests; if they land first, this commit holds only the helpers and the five tests above) + +```bash +git add crates/edgezero-cli/tests/format_json.rs +git commit -m "Cover active-version, rollback and the token healthcheck end to end" +``` + +--- + +### Task 11: Docs and spec + +**Files:** +- Modify: `docs/guide/cli-reference.md` ("Machine-readable output") +- Modify: `docs/superpowers/specs/2026-09-25-cli-format-json-design.md` (§6.1, §6.4, §8, §13) + +**Interfaces:** none (documents Tasks 2–9). + +- [x] **Step 1: CLI reference, Streams** + +After the "empty stdout" paragraph, add: + +```markdown +`--help` and `--version` are handled by clap before `--format` is read: they +print clap's text to stdout and exit `0`, with no envelope. + +The routing relies on the EdgeZero logger (`edgezero_cli::init_cli_logger()`). +A downstream CLI that installs a different logger, such as `simple_logger`, +which writes every level to stdout, will put log lines on stdout and break the +one-document rule. +``` + +Extend the exit-code paragraph with: "In a generated CLI, `2` also means a +usage error or an unsupported `config diff`; only the envelope tells them apart, +and an empty stdout means there is none." + +- [x] **Step 2: CLI reference, Results** + +Replace the prose list with one table per command with columns +`Key | Type | Nullable | Meaning`, covering every key. The nullable keys under +schema 1 are exactly: `active-version.version`, `build.artifact`, +`deploy.service_id`, `deploy.version`, `healthcheck.staging_ip`, +`healthcheck.status_code`, `rollback.rolled_back_to`, `provision.entries[].store`, +`provision.entries[].store.logical`, `config gc` `older_than_secs`, `deleted` and +`store.id`. `config validate.app_config` is now non-null. Notes under the +tables: + +- `build`: a `--format` after a passthrough argument is rejected. +- `deploy`: `service_id` resolution order; a deploy that went live without a + resolvable version gives `ok: false` with the result present and + `version: null`. +- `healthcheck.version_verified`: true only for a healthy production probe with + `FASTLY_API_TOKEN` set. +- `provision`: `would_create` → `created` or `already_present`; `would_update` → + `updated`. The Fastly and Spin dry runs report the plan without checking + current state; Fastly's real run omits the `edgezero_runtime_env` entry when + it is already declared. +- `config gc.deleted`: `null` on a dry run, `0` for a real run with nothing to + reclaim. + +- [x] **Step 3: Spec** + +- §6.1 `result` row: replace "`active-version --require-active` with nothing + active" with "a deploy that went live without a resolvable version". +- §6.4: add a `--help` / `--version` row (exit 0, clap's text) and the exit-2 + paragraph. +- §8: drop the `--require-active` bullet from `active-version`; update + `deploy`, `healthcheck`, `provision`, `config gc` and `config validate` to + match Step 2. +- §13: reword the `AuthStatus` bullet to "`AuthState::Unauthenticated` carries + its reason", and append a "Revision 3" list naming each change in Tasks 1–9. + +- [x] **Step 4: Format and lint the docs** + +Run: `cd docs && npx prettier --write guide/cli-reference.md superpowers/specs/2026-09-25-cli-format-json-design.md && npm run lint && npm run format` +Expected: "All matched files use Prettier code style!" and no ESLint errors. + +- [ ] **Step 5: Commit** + +```bash +git add docs/guide/cli-reference.md docs/superpowers/specs/2026-09-25-cli-format-json-design.md +git commit -m "Document result nullability and the review's contract fixes" +``` + +--- + +### Task 12: Final verification, push, reply + +- [x] **Step 1: Run every CI gate** (see Global Constraints) + +Results on 2026-10-05: fmt clean; workspace clippy clean; workspace tests pass +(edgezero-cli lib 217, `format_json` 18); Fastly `--features cli` 291 pass; the +feature check and Spin wasm32 check pass; the wasm clippy matrix (cloudflare, +fastly, fastly+cli, spin) is clean; app-demo fmt, clippy and tests pass; +`generated_project_builds` passes (116 s); docs ESLint and Prettier are clean. + +- [ ] **Step 2: Push** `spec/383-cli-format-json` (with the author's go-ahead). + +- [ ] **Step 3: Reply in each review thread** + +Use `gh api repos/stackpop/edgezero/pulls/388/comments/{id}/replies`, one reply +per inline comment, naming the fix and its commit. The spec-approval question +and the out-of-scope `wrangler whoami` note are the author's to answer. From cc6f1ac99aa09de8025bc26847e424dd7b02fba9 Mon Sep 17 00:00:00 2001 From: dhruv8sh Date: Mon, 5 Oct 2026 14:39:54 +0530 Subject: [PATCH 5/6] Fix --format json results from the PR #388 review - healthcheck: version_verified is true only when the after-probe check ran - config gc: a real run with nothing to reclaim reports deleted: 0, not null - build: reject a --format that follows passthrough args instead of forwarding it - config validate: always report app_config; the mode is passed explicitly - deploy: report the adapter-resolved service id, and keep the result when the deploy went live but its version could not be resolved - spin provision: an added label reports created, pairing with would_create - Outcome types store each fact once: HealthcheckOutcome::healthy(), AuthState::Unauthenticated { reason }, GcFailure - Lint direct std::io::stdout writers (also in the generated project's clippy.toml) and never panic the logger on a closed stderr - Exhaustive OutputFormat matches, #[non_exhaustive] AuthSub::Status, flatten a leftover block - Docs: per-command result tables with nullability, --help/--version, exit code 2, logger dependency, provision action pairing - Tests: token healthcheck, gc report fields, deploy tee and partial result, active-version, rollback, validate failure, late --format --- clippy.toml | 4 +- crates/edgezero-adapter-axum/src/cli.rs | 1 - crates/edgezero-adapter-fastly/src/cli.rs | 278 ++++++++++++---- crates/edgezero-adapter-spin/src/cli.rs | 10 +- crates/edgezero-adapter/src/cli_support.rs | 23 +- crates/edgezero-adapter/src/registry.rs | 49 ++- crates/edgezero-cli/src/adapter.rs | 16 +- crates/edgezero-cli/src/args.rs | 1 + crates/edgezero-cli/src/auth.rs | 11 +- crates/edgezero-cli/src/config.rs | 18 +- crates/edgezero-cli/src/lib.rs | 99 +++--- crates/edgezero-cli/src/output.rs | 107 ++++-- .../src/templates/root/clippy.toml.hbs | 10 + crates/edgezero-cli/tests/format_json.rs | 310 +++++++++++++++++- docs/guide/cli-reference.md | 188 +++++++---- 15 files changed, 872 insertions(+), 253 deletions(-) diff --git a/clippy.toml b/clippy.toml index 4ccdf43c4..1c25038fc 100644 --- a/clippy.toml +++ b/clippy.toml @@ -11,8 +11,10 @@ allow-unwrap-in-tests = true # `--format json` reserves stdout for the JSON envelope, so every child that # inherits stdout must go through `edgezero_adapter::process::status`, which # applies the stdout-to-stderr policy. Captured spawns (`.output()`, or -# `.spawn()` with piped stdout) carry a documented `#[expect]`. +# `.spawn()` with piped stdout) carry a documented `#[expect]`, as does every +# direct `std::io::stdout()` writer (`print_stdout` covers only the macros). disallowed-methods = [ { path = "std::process::Command::status", reason = "use `edgezero_adapter::process::status` so the `--format json` stdout policy applies" }, { path = "std::process::Command::spawn", reason = "inherits stdout by default; pipe stdout and `#[expect]` with a reason, or use `edgezero_adapter::process::status`" }, + { path = "std::io::stdout", reason = "`--format json` reserves stdout for the JSON envelope; write through the logger, or `#[expect]` with a reason" }, ] diff --git a/crates/edgezero-adapter-axum/src/cli.rs b/crates/edgezero-adapter-axum/src/cli.rs index 9dc28a39d..82fa3b61e 100644 --- a/crates/edgezero-adapter-axum/src/cli.rs +++ b/crates/edgezero-adapter-axum/src/cli.rs @@ -146,7 +146,6 @@ impl Adapter for AxumCliAdapter { ); Ok(if action == AdapterAction::AuthStatus { ActionOutcome::AuthStatus(AuthStatusOutcome { - failure: None, state: AuthState::NotApplicable, }) } else { diff --git a/crates/edgezero-adapter-fastly/src/cli.rs b/crates/edgezero-adapter-fastly/src/cli.rs index cfc8c7319..d94b493c4 100644 --- a/crates/edgezero-adapter-fastly/src/cli.rs +++ b/crates/edgezero-adapter-fastly/src/cli.rs @@ -30,9 +30,9 @@ use edgezero_adapter::cli_support::{ use edgezero_adapter::process; use edgezero_adapter::registry::{ ActionOutcome, ActiveVersionOutcome, Adapter, AdapterAction, AdapterPushContext, BuildOutcome, - DeployOutcome, GcCandidate, GcReport, HealthcheckOutcome, ProvisionAction, ProvisionEntry, - ProvisionReport, ProvisionStoreRef, ProvisionStores, ReadConfigEntry, ResolvedStoreId, - RollbackOutcome, StoreKind, register_adapter, + DeployOutcome, GcCandidate, GcFailure, GcReport, HealthcheckOutcome, ProvisionAction, + ProvisionEntry, ProvisionReport, ProvisionStoreRef, ProvisionStores, ReadConfigEntry, + ResolvedStoreId, RollbackOutcome, StoreKind, register_adapter, }; use edgezero_adapter::scaffold::{ AdapterBlueprint, AdapterFileSpec, CommandTemplates, DependencySpec, LoggingDefaults, @@ -419,13 +419,19 @@ impl Adapter for FastlyCliAdapter { } // The CLI resolves the activated version (deploy output, then the // Fastly API), so the built-in deploy reports none. - AdapterAction::Deploy => { - deploy(args).map(|()| ActionOutcome::Deploy(DeployOutcome { version: None })) - } + AdapterAction::Deploy => deploy(args).map(|()| { + ActionOutcome::Deploy(DeployOutcome { + service_id: None, + version: None, + }) + }), AdapterAction::Serve => serve(args).map(|()| ActionOutcome::Empty), // Fastly staging lifecycle. AdapterAction::DeployStaging => deploy_staging(args).map(|version| { ActionOutcome::Deploy(DeployOutcome { + // `deploy_staging` already resolved this (flag, then + // `FASTLY_SERVICE_ID`), so it cannot fail here. + service_id: resolve_service_id(args).ok(), version: Some(version), }) }), @@ -2492,12 +2498,13 @@ fn append_kept_roots_report(out: &mut Vec, kept_roots: &[String], live_c } /// The typed `config gc` report for `plan`, before any delete runs. -fn gc_report_for(plan: &GcPlan, entries: usize, store_id: &str) -> GcReport { +fn gc_report_for(plan: &GcPlan, entries: usize, store_id: &str, dry_run: bool) -> GcReport { GcReport { - deleted: None, + // A real run that finds nothing to reclaim deleted zero entries; only a + // dry run reports `None`. + deleted: (!dry_run).then_some(0), entries, - failed: Vec::new(), - failure_diagnostic: None, + failure: None, generations_planned: plan.doomed.len(), kept_roots: plan.kept_roots.clone(), planned: plan @@ -2513,9 +2520,7 @@ fn gc_report_for(plan: &GcPlan, entries: usize, store_id: &str) -> GcReport { retained_recent: plan.retained_recent, roots: plan.roots, store_id: Some(store_id.to_owned()), - stranded: Vec::new(), text_lines: Vec::new(), - uncertain: Vec::new(), unprovable: plan.unprovable, warnings: plan.warnings.clone(), } @@ -2554,7 +2559,7 @@ fn gc_fastly_config_store( .ok_or_else(|| no_matching_store_error(store_name))?; let items = list_config_store_entries(&resolved_id)?; let plan = plan_gc_reclamation(&items, unix_now_secs(), older_than_secs)?; - let mut report = gc_report_for(&plan, items.len(), &resolved_id); + let mut report = gc_report_for(&plan, items.len(), &resolved_id, dry_run); let GcPlan { doomed, kept_roots, @@ -2626,7 +2631,7 @@ fn gc_fastly_config_store( return Ok(report); } // Partial/total failure is still a REPORT (what was and was not deleted); - // the CLI turns `failure_diagnostic` into a non-zero exit. + // the CLI turns `failure` into a non-zero exit. let diagnostic = gc_failure_diagnostic( &out, doomed_count, @@ -2635,11 +2640,13 @@ fn gc_fastly_config_store( &stranded, &resolved_id, )?; - report.failed = failed; - report.failure_diagnostic = Some(diagnostic); - report.stranded = stranded; + report.failure = Some(GcFailure { + diagnostic, + failed, + stranded, + uncertain, + }); report.text_lines = out; - report.uncertain = uncertain; Ok(report) } @@ -3955,42 +3962,40 @@ fn provision_runtime_env_store( // Already declared; nothing to do. return Ok(None); } - { - create_fastly_store_in(runtime_env_kind, runtime_env_name, manifest_dir)?; - append_fastly_setup(fastly_path, runtime_env_kind, runtime_env_name).map_err( - |err| { - format!( - "fastly {runtime_env_kind}-store `{runtime_env_name}` was created remotely, but writeback to {path} failed: {err}\n Recover via `fastly {runtime_env_kind}-store delete --name={runtime_env_name}` then re-run `edgezero provision --adapter fastly`.", - path = fastly_path.display() - ) - }, - )?; - // Same already-deployed-service caveat as the declared-store - // path: if `service_id` is set in fastly.toml, the - // `[setup.config_stores.edgezero_runtime_env]` table won't - // be re-applied by the next `fastly compute deploy`, so the - // runtime can't open the store. Emit the resource-link - // remediation alongside the populate-keys hint. - let post_create_note = resource_link_note(fastly_path, runtime_env_kind, runtime_env_name)?; - // NB: this store is what the ACTIVE (production) service reads. The - // example must never point it at a staging key — following that would - // make production serve staged config. Staged versions get their own - // selector via `edgezero_runtime_env_staging`, wired automatically by - // a staged deploy; nothing here should be edited to stage config. - let production_selector_key = runtime_env_key_for( - runtime_env_service_id.unwrap_or(""), - "app_config", - ); - let mut line = format!( - "created fastly {runtime_env_kind}-store `{runtime_env_name}` (EdgeZero runtime override store, read by the ACTIVE version); appended setup tables to {}\n Provision writes service-scoped non-default store-name mappings below. Config stores still select their logical id as the default key.\n To point PRODUCTION at a different config key, and only then:\n fastly config-store-entry update --store-id= --key={production_selector_key} --value= --upsert\n Do NOT set a `_staging` key here: staged config is isolated by a per-service `{RUNTIME_ENV_STAGING_STORE_PREFIX}_` store, which a staged deploy creates and links automatically.", - fastly_path.display() - ); - if let Some(note) = post_create_note { - line.push('\n'); - line.push_str(¬e); - } - Ok(Some(runtime_env_entry(ProvisionAction::Created, line))) + create_fastly_store_in(runtime_env_kind, runtime_env_name, manifest_dir)?; + append_fastly_setup(fastly_path, runtime_env_kind, runtime_env_name).map_err( + |err| { + format!( + "fastly {runtime_env_kind}-store `{runtime_env_name}` was created remotely, but writeback to {path} failed: {err}\n Recover via `fastly {runtime_env_kind}-store delete --name={runtime_env_name}` then re-run `edgezero provision --adapter fastly`.", + path = fastly_path.display() + ) + }, + )?; + // Same already-deployed-service caveat as the declared-store + // path: if `service_id` is set in fastly.toml, the + // `[setup.config_stores.edgezero_runtime_env]` table won't + // be re-applied by the next `fastly compute deploy`, so the + // runtime can't open the store. Emit the resource-link + // remediation alongside the populate-keys hint. + let post_create_note = resource_link_note(fastly_path, runtime_env_kind, runtime_env_name)?; + // NB: this store is what the ACTIVE (production) service reads. The + // example must never point it at a staging key — following that would + // make production serve staged config. Staged versions get their own + // selector via `edgezero_runtime_env_staging`, wired automatically by + // a staged deploy; nothing here should be edited to stage config. + let production_selector_key = runtime_env_key_for( + runtime_env_service_id.unwrap_or(""), + "app_config", + ); + let mut line = format!( + "created fastly {runtime_env_kind}-store `{runtime_env_name}` (EdgeZero runtime override store, read by the ACTIVE version); appended setup tables to {}\n Provision writes service-scoped non-default store-name mappings below. Config stores still select their logical id as the default key.\n To point PRODUCTION at a different config key, and only then:\n fastly config-store-entry update --store-id= --key={production_selector_key} --value= --upsert\n Do NOT set a `_staging` key here: staged config is isolated by a per-service `{RUNTIME_ENV_STAGING_STORE_PREFIX}_` store, which a staged deploy creates and links automatically.", + fastly_path.display() + ); + if let Some(note) = post_create_note { + line.push('\n'); + line.push_str(¬e); } + Ok(Some(runtime_env_entry(ProvisionAction::Created, line))) } /// A provision entry about `EdgeZero`'s own runtime-override config store @@ -5511,8 +5516,9 @@ fn version_active_verdict( /// `healthcheck --adapter fastly ...`: probe the domain /// (production) or the version's staging IP (`--staging`), retrying up -/// to `--retry` times. Emits `status-code` / `healthy` and returns -/// `Err` (non-zero exit) when unhealthy after retries. +/// to `--retry` times. An unhealthy probe is still `Ok`, with `failure` +/// set; the CLI emits `status-code` / `healthy` and turns that into a +/// non-zero exit. /// /// `--domain`, `--service-id` and `--version` are REQUIRED and validated /// on BOTH the production and the staging path. GitHub Actions' `required: @@ -5609,14 +5615,16 @@ fn healthcheck(args: &[String]) -> Result { ); // The CLI emits `status-code=` / `healthy=` from this outcome, and // turns an unhealthy one into a non-zero exit. - let (healthy, status_code, failure) = match outcome { + // `version_verified` means BOTH checks ran, so it is true only on the + // healthy arm, the one that runs the "after probing" check. + let (version_verified, status_code, failure) = match outcome { Ok(code) => { // Confirm `version` is STILL active, so a deploy that activated a newer // version during the probe+retries is not reported as a healthy `version`. if let Some(token) = production_token.as_deref() { verify_version_active(&service_id, version, token, "after probing")?; } - (true, Some(code), None) + (production_token.is_some(), Some(code), None) } Err((last_code, msg)) => ( false, @@ -5631,14 +5639,13 @@ fn healthcheck(args: &[String]) -> Result { attempts, domain: domain.to_owned(), failure, - healthy, path: path.to_owned(), service_id, staging: is_staging, staging_ip, status_code, version, - version_verified: production_token.is_some(), + version_verified, }) } @@ -8381,7 +8388,6 @@ exit 1 attempts: 1, domain: "app.example.com".to_owned(), failure: None, - healthy: true, path: "/".to_owned(), service_id: "SVC1".to_owned(), staging: false, @@ -8397,7 +8403,7 @@ exit 1 let _path = PathPrepend::new(unhealthy_curl.path()); let unhealthy = healthcheck(&args("2")).expect("an unhealthy probe is an outcome, not an error"); - assert!(!unhealthy.healthy); + assert!(!unhealthy.healthy()); assert_eq!(unhealthy.attempts, 2); assert_eq!(unhealthy.status_code, Some(503)); let failure = unhealthy @@ -8409,6 +8415,72 @@ exit 1 ); } + /// `version_verified` claims the version was ACTIVE before AND after the + /// probe, so it is true only when both API checks actually ran: an + /// unhealthy probe skips the "after" check and must report `false`. + #[cfg(unix)] + #[test] + fn healthcheck_version_verified_only_when_both_checks_ran() { + use std::os::unix::fs::PermissionsExt as _; + let _lock = path_mutation_guard().lock().expect("guard"); + let _token = EnvOverride::set(FASTLY_API_TOKEN_ENV, "test-token"); + let args: Vec = [ + "--domain", + "app.example.com", + "--service-id", + "SVC1", + "--version", + "7", + "--retry", + "1", + "--retry-delay", + "0", + ] + .into_iter() + .map(str::to_owned) + .collect(); + // A fake `curl` serving the versions API (`--config -`, version 7 + // active) and answering health probes with `code`; each API call is + // logged so the test can count the checks. + let fake_curl_with_api = |code: u16| { + let dir = tempdir().expect("tempdir"); + let log = dir.path().join("api.log"); + let script = format!( + "#!/bin/sh + case \" $* \" in *\" --config \"*) cat >/dev/null; echo get >> '{log}'; \ + printf '%s\\n200' '[{{\"number\":7,\"active\":true}}]'; exit 0;; esac + echo {code} +", + log = log.display() + ); + let script_path = dir.path().join("curl"); + fs::write(&script_path, script).expect("write script"); + let mut perms = fs::metadata(&script_path).expect("meta").permissions(); + perms.set_mode(0o755); + fs::set_permissions(&script_path, perms).expect("chmod +x"); + (dir, log) + }; + let api_calls = |log: &Path| fs::read_to_string(log).unwrap_or_default().lines().count(); + + let (unhealthy_curl, unhealthy_log) = fake_curl_with_api(503); + let unhealthy_path = PathPrepend::new(unhealthy_curl.path()); + let unhealthy = healthcheck(&args).expect("an unhealthy probe is an outcome"); + assert!(!unhealthy.healthy()); + assert!( + !unhealthy.version_verified, + "the after-probe check never ran on an unhealthy probe" + ); + assert_eq!(api_calls(&unhealthy_log), 1, "only the before-probe check"); + drop(unhealthy_path); + + let (healthy_curl, healthy_log) = fake_curl_with_api(200); + let _path = PathPrepend::new(healthy_curl.path()); + let healthy = healthcheck(&args).expect("probe runs"); + assert!(healthy.healthy()); + assert!(healthy.version_verified, "both checks ran and passed"); + assert_eq!(api_calls(&healthy_log), 2, "before and after the probe"); + } + #[cfg(unix)] #[test] fn read_remote_returns_present_on_success() { @@ -10089,9 +10161,8 @@ echo 'unexpected' >&2; exit 1 // ---------- config gc (operator-invoked reclamation) ---------- #[cfg(unix)] - fn run_gc(dir: &Path, older_than_secs: u64, dry_run: bool) -> Result, String> { - // Mirror the CLI: a report carrying a failure diagnostic is an error. - let report = FastlyCliAdapter.gc_config_entries( + fn gc_report(dir: &Path, older_than_secs: u64, dry_run: bool) -> Result { + FastlyCliAdapter.gc_config_entries( dir, None, None, @@ -10099,9 +10170,15 @@ echo 'unexpected' >&2; exit 1 &AdapterPushContext::new(), older_than_secs, dry_run, - )?; - match report.failure_diagnostic { - Some(diagnostic) => Err(diagnostic), + ) + } + + #[cfg(unix)] + fn run_gc(dir: &Path, older_than_secs: u64, dry_run: bool) -> Result, String> { + // Mirror the CLI: a report carrying a failure is an error. + let report = gc_report(dir, older_than_secs, dry_run)?; + match report.failure { + Some(failure) => Err(failure.diagnostic), None => Ok(report.text_lines), } } @@ -10307,6 +10384,73 @@ echo 'unexpected' >&2; exit 1 ); } + /// `deleted` is `None` only on a dry run: a real run that finds nothing to + /// reclaim deleted zero entries, and says so. + #[cfg(unix)] + #[test] + fn gc_report_deleted_is_zero_for_a_real_run_with_nothing_to_reclaim() { + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + let oplog = dir.path().join("ops.log"); + let live = gen_envelope("live"); + let mut listing = vec![listed_root(TEST_CONFIG_ID, &live, 172_800)]; + listing.extend(listed_generation(TEST_CONFIG_ID, &live, 172_800)); + let fake = fake_fastly_gc(TEST_CONFIG_ID, &[], &listing, None, false, &oplog); + let _path = PathPrepend::new(fake.path()); + + let real = gc_report(dir.path(), 86_400, false).expect("gc runs"); + assert_eq!(real.deleted, Some(0)); + assert_eq!(real.failure, None); + assert!(real.planned.is_empty()); + + let preview = gc_report(dir.path(), 86_400, true).expect("gc runs"); + assert_eq!(preview.deleted, None, "a dry run deletes nothing at all"); + } + + /// A failed delete is still a report: `failure` names the failed key, and + /// the keys whose outcome is unknown, alongside the recovery text. + #[cfg(unix)] + #[test] + fn gc_report_carries_a_failed_delete() { + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + let oplog = dir.path().join("ops.log"); + let live = gen_envelope("live"); + let dead = gen_envelope("dead"); + let dead_chunks = chunk_keys_of(TEST_CONFIG_ID, &dead); + let mut listing = vec![listed_root(TEST_CONFIG_ID, &live, 172_800)]; + listing.extend(listed_generation(TEST_CONFIG_ID, &live, 172_800)); + listing.extend(listed_generation(TEST_CONFIG_ID, &dead, 604_800)); + let fake = fake_fastly_gc( + TEST_CONFIG_ID, + &[], + &listing, + Some(&dead_chunks[0]), + false, + &oplog, + ); + let _path = PathPrepend::new(fake.path()); + + let report = gc_report(dir.path(), 86_400, false).expect("a failed delete is a report"); + assert_eq!(report.deleted, Some(0)); + assert_eq!(report.planned.len(), dead_chunks.len()); + let failure = report.failure.expect("the failed delete is reported"); + assert_eq!(failure.failed, vec![dead_chunks[0].clone()]); + assert!( + failure.stranded.is_empty(), + "nothing was deleted before the failure" + ); + assert!( + !failure.uncertain.is_empty(), + "the failed first delete has an unknown outcome" + ); + assert!( + failure.diagnostic.contains("unknown outcome"), + "{}", + failure.diagnostic + ); + } + /// a delete that fails on a generation's FIRST key has /// an UNKNOWN outcome -- Fastly may have committed it before returning an /// error. called this "whole and retryable", which is unsound: if the diff --git a/crates/edgezero-adapter-spin/src/cli.rs b/crates/edgezero-adapter-spin/src/cli.rs index 3cf745a8f..e851ce03d 100644 --- a/crates/edgezero-adapter-spin/src/cli.rs +++ b/crates/edgezero-adapter-spin/src/cli.rs @@ -246,7 +246,7 @@ impl Adapter for SpinCliAdapter { } let added = ensure_kv_label_in_component(&spin_path, &component_id, label)?; if added { - entries.push(ProvisionEntry::for_store(ProvisionAction::Updated, store_kind, store, format!( + entries.push(ProvisionEntry::for_store(ProvisionAction::Created, store_kind, store, format!( "added {kind} label `{label}` (logical id `{logical}`) to [component.{component_id}].key_value_stores in {}", spin_path.display() ))); @@ -1738,10 +1738,12 @@ mod tests { kv: &kv_ids, secrets: &[], }; - let out = SpinCliAdapter + let report = SpinCliAdapter .provision(dir.path(), Some("spin.toml"), None, &stores, false) - .expect("real run succeeds") - .into_messages(); + .expect("real run succeeds"); + // Pairs with the dry run's `WouldCreate`. + assert_eq!(report.entries[0].action, ProvisionAction::Created); + let out = report.into_messages(); assert_eq!(out.len(), 1); assert!(out[0].contains("added KV label `sessions`"), "got: {out:?}"); let after = fs::read_to_string(dir.path().join("spin.toml")).expect("read back"); diff --git a/crates/edgezero-adapter/src/cli_support.rs b/crates/edgezero-adapter/src/cli_support.rs index 4837aa85d..d82007104 100644 --- a/crates/edgezero-adapter/src/cli_support.rs +++ b/crates/edgezero-adapter/src/cli_support.rs @@ -83,9 +83,9 @@ pub fn path_distance(left: &Path, right: &Path) -> usize { /// the child fails to spawn, or it exits non-zero. #[inline] pub fn run_native_cli(program: &str, args: &[&str], install_hint: &str) -> Result<(), String> { - match native_auth_status(program, args, install_hint)?.failure { - None => Ok(()), - Some(failure) => Err(failure), + match native_auth_status(program, args, install_hint)?.state { + AuthState::Unauthenticated { reason } => Err(reason), + AuthState::Authenticated | AuthState::NotApplicable => Ok(()), } } @@ -111,16 +111,13 @@ pub fn native_auth_status( })?; Ok(if status.success() { AuthStatusOutcome { - failure: None, state: AuthState::Authenticated, } } else { AuthStatusOutcome { - failure: Some(format!( - "`{program} {}` exited with status {status}", - args.join(" ") - )), - state: AuthState::Unauthenticated, + state: AuthState::Unauthenticated { + reason: format!("`{program} {}` exited with status {status}", args.join(" ")), + }, } }) } @@ -275,12 +272,12 @@ mod tests { fn native_auth_status_maps_exit_status_to_state() { let ok = native_auth_status("true", &[], "hint").expect("spawns"); assert_eq!(ok.state, AuthState::Authenticated); - assert_eq!(ok.failure, None); let unauthenticated = native_auth_status("false", &[], "hint").expect("spawns"); - assert_eq!(unauthenticated.state, AuthState::Unauthenticated); - let failure = unauthenticated.failure.expect("failure message"); - assert!(failure.contains("exited with status"), "got: {failure}"); + let AuthState::Unauthenticated { reason } = unauthenticated.state else { + panic!("expected Unauthenticated, got {:?}", unauthenticated.state); + }; + assert!(reason.contains("exited with status"), "got: {reason}"); let missing = native_auth_status("edgezero-no-such-program-xyz", &[], "install it") .expect_err("missing program is an error, not a state"); diff --git a/crates/edgezero-adapter/src/registry.rs b/crates/edgezero-adapter/src/registry.rs index 5cfedf134..47d0398ac 100644 --- a/crates/edgezero-adapter/src/registry.rs +++ b/crates/edgezero-adapter/src/registry.rs @@ -73,9 +73,6 @@ pub struct ActiveVersionOutcome { /// Result of [`AdapterAction::AuthStatus`]. #[derive(Clone, Debug, Eq, PartialEq)] pub struct AuthStatusOutcome { - /// Why the session is not authenticated (`Some` exactly when `state` is - /// [`AuthState::Unauthenticated`]). - pub failure: Option, pub state: AuthState, } @@ -84,12 +81,15 @@ pub struct AuthStatusOutcome { /// Deliberately exhaustive (like [`ProvisionAction`] and [`StoreKind`]): the /// CLI maps each variant onto its versioned JSON schema, so a new variant must /// fail to compile there until the schema covers it. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Eq, PartialEq)] pub enum AuthState { Authenticated, /// The adapter has no remote auth surface (axum). NotApplicable, - Unauthenticated, + Unauthenticated { + /// Why the session is not authenticated. + reason: String, + }, } /// Result of [`AdapterAction::Build`]. @@ -102,6 +102,8 @@ pub struct BuildOutcome { /// Result of [`AdapterAction::Deploy`] / [`AdapterAction::DeployStaging`]. #[derive(Clone, Debug, Eq, PartialEq)] pub struct DeployOutcome { + /// The service the deploy targeted, when the adapter resolved one. + pub service_id: Option, /// The staged (or activated) platform version, when known. pub version: Option, } @@ -112,9 +114,8 @@ pub struct HealthcheckOutcome { /// Probes actually made. pub attempts: u32, pub domain: String, - /// Why the probe is unhealthy (`Some` exactly when `healthy` is false). + /// `None` when healthy; why the probe is unhealthy otherwise. pub failure: Option, - pub healthy: bool, pub path: String, pub service_id: String, pub staging: bool, @@ -126,6 +127,15 @@ pub struct HealthcheckOutcome { pub version_verified: bool, } +impl HealthcheckOutcome { + /// Whether the probe came back healthy. + #[inline] + #[must_use] + pub const fn healthy(&self) -> bool { + self.failure.is_none() + } +} + /// Result of [`AdapterAction::Rollback`]. #[derive(Clone, Debug, Eq, PartialEq)] pub struct RollbackOutcome { @@ -304,18 +314,16 @@ pub enum StoreKind { /// What [`Adapter::gc_config_entries`] found and did. /// -/// A run whose deletes partly failed is still `Ok`: `failed` is non-empty and -/// `failure_diagnostic` carries the operator-facing recovery text, which the -/// CLI turns into a non-zero exit. +/// A run whose deletes partly failed is still `Ok`: `failure` describes what +/// failed, and the CLI turns it into a non-zero exit. #[derive(Clone, Debug, Default, Eq, PartialEq)] pub struct GcReport { /// Entries deleted; `None` on a dry run. pub deleted: Option, /// Entries in the store. pub entries: usize, - pub failed: Vec, - /// `Some` exactly when `failed` is non-empty. - pub failure_diagnostic: Option, + /// `None` unless a delete failed. + pub failure: Option, pub generations_planned: usize, pub kept_roots: Vec, /// Orphan chunks planned for deletion, with their ages. @@ -326,15 +334,26 @@ pub struct GcReport { pub roots: usize, /// The platform's id for the swept store, when resolved. pub store_id: Option, - pub stranded: Vec, /// The human-readable report, rendered only by the text output. pub text_lines: Vec, - pub uncertain: Vec, /// Chunk-shaped entries left untouched because they could not be proved. pub unprovable: usize, pub warnings: Vec, } +/// The deletes a `config gc` run could not complete. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct GcFailure { + /// The operator-facing report and recovery text. + pub diagnostic: String, + /// Keys whose delete failed (never empty). + pub failed: Vec, + /// Keys left in a generation that lost a sibling to a confirmed delete. + pub stranded: Vec, + /// Keys whose generation's only failure has an unknown outcome. + pub uncertain: Vec, +} + /// One orphan chunk `config gc` would delete. #[derive(Clone, Debug, Eq, PartialEq)] pub struct GcCandidate { diff --git a/crates/edgezero-cli/src/adapter.rs b/crates/edgezero-cli/src/adapter.rs index 8af39c9a5..4221e5c09 100644 --- a/crates/edgezero-cli/src/adapter.rs +++ b/crates/edgezero-cli/src/adapter.rs @@ -341,12 +341,9 @@ fn run_shell( .then(|| format!("{action} command `{command}` exited with status {status}")); match (action, exit_failure) { (Action::AuthStatus, failure) => Ok(ActionOutcome::AuthStatus(AuthStatusOutcome { - state: if failure.is_some() { - AuthState::Unauthenticated - } else { - AuthState::Authenticated - }, - failure, + state: failure.map_or(AuthState::Authenticated, |reason| { + AuthState::Unauthenticated { reason } + }), })), (_, Some(message)) => Err(message), (_, None) => Ok(ActionOutcome::Empty), @@ -428,7 +425,12 @@ fn run_shell_tee( let captured_stdout = if process::child_stdout_to_stderr() { tee_stream(child_stdout, io::stderr()) } else { - tee_stream(child_stdout, io::stdout()) + #[expect( + clippy::disallowed_methods, + reason = "text mode echoes the child's stdout to stdout, exactly as before `--format`" + )] + let stdout = io::stdout(); + tee_stream(child_stdout, stdout) }; let captured_stderr = stderr_worker.join().unwrap_or_default(); diff --git a/crates/edgezero-cli/src/args.rs b/crates/edgezero-cli/src/args.rs index 4365a059c..895663b89 100644 --- a/crates/edgezero-cli/src/args.rs +++ b/crates/edgezero-cli/src/args.rs @@ -229,6 +229,7 @@ pub enum AuthSub { }, /// Show the current session (`wrangler whoami` / `fastly profile /// list` / `spin cloud info`). + #[non_exhaustive] Status { #[arg(long)] adapter: String, diff --git a/crates/edgezero-cli/src/auth.rs b/crates/edgezero-cli/src/auth.rs index 2c840b9f5..30d689f5a 100644 --- a/crates/edgezero-cli/src/auth.rs +++ b/crates/edgezero-cli/src/auth.rs @@ -48,13 +48,10 @@ fn auth_status(adapter_name: &str) -> Outcome { let ActionOutcome::AuthStatus(status) = outcome else { return Err(format!("adapter `{adapter_name}` returned no auth status result").into()); }; - let result = AuthStatusResult::new(adapter_name, status.state); - match (status.state, status.failure) { - (AuthState::Unauthenticated, failure) => Err(Failure::with_result( - failure.unwrap_or_else(|| format!("adapter `{adapter_name}` is not authenticated")), - result, - )), - (AuthState::Authenticated | AuthState::NotApplicable, _) => Ok(result), + let result = AuthStatusResult::new(adapter_name, &status.state); + match status.state { + AuthState::Unauthenticated { reason } => Err(Failure::with_result(reason, result)), + AuthState::Authenticated | AuthState::NotApplicable => Ok(result), } } diff --git a/crates/edgezero-cli/src/config.rs b/crates/edgezero-cli/src/config.rs index 18b59ec12..7dd5d450c 100644 --- a/crates/edgezero-cli/src/config.rs +++ b/crates/edgezero-cli/src/config.rs @@ -24,7 +24,9 @@ use crate::args::{ }; use crate::diff::{collect_changes, render_json, render_structured}; use crate::ensure_adapter_defined; -use crate::output::{self, CommandName, Failure, GcResult, Outcome, OutputScope, ValidateResult}; +use crate::output::{ + self, CommandName, Failure, GcResult, Outcome, OutputScope, ValidateMode, ValidateResult, +}; use edgezero_adapter::registry::{ self as adapter_registry, ReadConfigEntry, ResolvedStoreId, TypedSecretEntry, }; @@ -232,9 +234,10 @@ fn validate_raw(args: &ConfigValidateArgs) -> Outcome { ); Ok(ValidateResult::new( &args.manifest, - None, + &ctx.app_config_path, &ctx.app_name, args.strict, + ValidateMode::Raw, )) } @@ -288,9 +291,10 @@ where ); Ok(ValidateResult::new( &args.manifest, - Some(&ctx.app_config_path), + &ctx.app_config_path, &ctx.app_name, args.strict, + ValidateMode::Typed, )) } @@ -464,8 +468,8 @@ fn config_gc(args: &ConfigGcArgs) -> Outcome { // A run whose deletes failed reports through its error (the full report // plus recovery commands), exactly as before; its lines are not logged // separately. - if let Some(diagnostic) = report.failure_diagnostic { - return Err(Failure::with_result(diagnostic, result)); + if let Some(failure) = report.failure { + return Err(Failure::with_result(failure.diagnostic, result)); } for line in &report.text_lines { log::info!("[edgezero] {line}"); @@ -1367,6 +1371,10 @@ pub(crate) fn print_unified_diff_inline( local_sha: &str, ) { use std::io::stdout; + #[expect( + clippy::disallowed_methods, + reason = "`config push` has no `--format`; its inline diff is text-only output" + )] let mut stdout = stdout().lock(); // Silently ignore write errors — stdout may be a closed pipe (e.g. // `edgezero config push | head`). The operator already saw the diff diff --git a/crates/edgezero-cli/src/lib.rs b/crates/edgezero-cli/src/lib.rs index ec12c3a1e..3893404ea 100644 --- a/crates/edgezero-cli/src/lib.rs +++ b/crates/edgezero-cli/src/lib.rs @@ -72,7 +72,7 @@ use output::{ #[cfg(feature = "cli")] use std::env; #[cfg(feature = "cli")] -use std::io::ErrorKind; +use std::io::{self, ErrorKind, Write as _}; #[cfg(feature = "cli")] use std::path::PathBuf; #[cfg(feature = "cli")] @@ -115,24 +115,16 @@ impl log::Log for CliLogger { if !self.enabled(record.metadata()) { return; } + // `writeln!` rather than `eprintln!`: a closed stderr must not panic + // the command before `--format json` writes its envelope. match record.level() { + // warn/error always go to stderr; `--format json` sends info there + // too, keeping stdout for the JSON envelope. log::Level::Error | log::Level::Warn => { - #[expect( - clippy::print_stderr, - reason = "CLI UX output goes to stderr for warn/error" - )] - { - eprintln!("{}", record.args()); - } + let _ignored = writeln!(io::stderr(), "{}", record.args()); } log::Level::Info if INFO_TO_STDERR.load(Ordering::SeqCst) => { - #[expect( - clippy::print_stderr, - reason = "`--format json` keeps stdout for the JSON envelope" - )] - { - eprintln!("{}", record.args()); - } + let _ignored = writeln!(io::stderr(), "{}", record.args()); } log::Level::Info => { #[expect(clippy::print_stdout, reason = "CLI UX output goes to stdout for info")] @@ -178,6 +170,20 @@ pub fn run_build(args: &BuildArgs) -> Result<(), String> { #[cfg(feature = "cli")] fn build(args: &BuildArgs) -> Outcome { + // `trailing_var_arg` captures everything after the first passthrough token, + // so a late `--format json` would be forwarded to the build command and the + // run would silently print text. Fail closed instead. + if let Some(flag) = args + .adapter_args + .iter() + .find(|arg| *arg == "--format" || arg.starts_with("--format=")) + { + return Err(format!( + "`{flag}` after a passthrough argument is forwarded to the build command, not read \ + as EdgeZero's `--format`. Put `--format` before the first passthrough argument." + ) + .into()); + } let manifest = load_manifest_optional()?; ensure_adapter_defined(&args.adapter, manifest.as_ref())?; if let Some(loader) = &manifest { @@ -213,10 +219,11 @@ pub fn run_deploy(args: &DeployArgs) -> Result<(), String> { #[cfg(feature = "cli")] fn deploy(args: &DeployArgs) -> Outcome { - let result = |version: Option| { + // `service_id`: the id the adapter resolved, else `--service-id` as passed. + let result = |service_id: Option, version: Option| { DeployResult::new( &args.adapter, - args.service_id.clone(), + service_id.or_else(|| args.service_id.clone()), args.staging, version, ) @@ -308,11 +315,11 @@ fn deploy(args: &DeployArgs) -> Outcome { manifest.as_ref(), &passthrough, )?; - let version = deployed_version(&outcome); + let (service_id, version) = deployed(outcome); if let Some(staged) = version { log::info!("version={staged}"); } - return Ok(result(version)); + return Ok(result(service_id, version)); } // Production deploy also emits the activated version @@ -330,11 +337,16 @@ fn deploy(args: &DeployArgs) -> Outcome { // 2. Only when the output yields nothing: the Fastly API lookup // (`EmitVersion`), which needs a live API + a real token. // 3. If BOTH fail: a clear `Err`. We never silently emit an empty - // version — that was the original bug. + // version — that was the original bug. The deploy DID go live, so + // the failure still carries a result (with a `null` version). if args.service_id.is_some() && args.adapter.eq_ignore_ascii_case("fastly") { - let version = fastly_production_deploy(&args.adapter, manifest.as_ref(), &passthrough)?; - log::info!("version={version}"); - return Ok(result(Some(version))); + return match fastly_production_deploy(&args.adapter, manifest.as_ref(), &passthrough)? { + Ok(version) => { + log::info!("version={version}"); + Ok(result(None, Some(version))) + } + Err(unresolved) => Err(Failure::with_result(unresolved, result(None, None))), + }; } let outcome = adapter::execute( @@ -343,21 +355,25 @@ fn deploy(args: &DeployArgs) -> Outcome { manifest.as_ref(), &passthrough, )?; - Ok(result(deployed_version(&outcome))) + let (service_id, version) = deployed(outcome); + Ok(result(service_id, version)) } /// Run a production Fastly deploy for a known service and resolve the version /// it activated (see `deploy` for the resolution precedence). +/// +/// The outer `Err` means the deploy itself failed. The inner `Err` means the +/// deploy went live but the activated version could not be resolved. #[cfg(feature = "cli")] fn fastly_production_deploy( adapter_name: &str, manifest: Option<&ManifestLoader>, passthrough: &[String], -) -> Result { +) -> Result, String> { let captured = adapter::execute_capture(adapter_name, adapter::Action::Deploy, manifest, passthrough)?; if let Some(version) = captured.as_deref().and_then(parse_deploy_version) { - return Ok(version); + return Ok(Ok(version)); } // Fallback: resolve the version the deploy just activated via the Fastly // API. `--require-active` makes EmitVersion FAIL (not emit an empty @@ -373,24 +389,29 @@ fn fastly_production_deploy( failed: {err}" ) }; - let outcome = adapter::execute( - adapter_name, - adapter::Action::EmitVersion, - manifest, - &emit_args, - ) - .map_err(|err| unresolved(&err))?; // `--require-active` makes EmitVersion fail rather than report none. - active_version(&outcome).ok_or_else(|| unresolved("no active version was reported")) + Ok( + match adapter::execute( + adapter_name, + adapter::Action::EmitVersion, + manifest, + &emit_args, + ) { + Ok(outcome) => { + active_version(&outcome).ok_or_else(|| unresolved("no active version was reported")) + } + Err(err) => Err(unresolved(&err)), + }, + ) } -/// The version a deploy outcome reports, if any. +/// The service id and version a deploy outcome reports, if any. #[cfg(feature = "cli")] -const fn deployed_version(outcome: &ActionOutcome) -> Option { +fn deployed(outcome: ActionOutcome) -> (Option, Option) { if let ActionOutcome::Deploy(deployed) = outcome { - deployed.version + (deployed.service_id, deployed.version) } else { - None + (None, None) } } @@ -600,7 +621,7 @@ fn healthcheck(args: &HealthcheckArgs) -> Outcome { if let Some(code) = outcome.status_code { log::info!("status-code={code}"); } - log::info!("healthy={}", outcome.healthy); + log::info!("healthy={}", outcome.healthy()); let result = HealthcheckResult::new(&args.adapter, &outcome); match outcome.failure { Some(failure) => Err(Failure::with_result(failure, result)), diff --git a/crates/edgezero-cli/src/output.rs b/crates/edgezero-cli/src/output.rs index 951f01e32..de7b2e8ad 100644 --- a/crates/edgezero-cli/src/output.rs +++ b/crates/edgezero-cli/src/output.rs @@ -72,12 +72,13 @@ pub(crate) struct OutputScope { impl OutputScope { pub(crate) fn enter(format: OutputFormat) -> Self { - let previous = (format == OutputFormat::Json).then(|| { - ( + let previous = match format { + OutputFormat::Text => None, + OutputFormat::Json => Some(( process::set_child_stdout_to_stderr(true), crate::set_info_to_stderr(true), - ) - }); + )), + }; Self { previous } } } @@ -164,13 +165,13 @@ pub(crate) struct AuthStatusResult { } impl AuthStatusResult { - pub(crate) fn new(adapter: &str, state: AuthState) -> Self { + pub(crate) fn new(adapter: &str, state: &AuthState) -> Self { Self { adapter: adapter.to_owned(), state: match state { AuthState::Authenticated => WireAuthState::Authenticated, AuthState::NotApplicable => WireAuthState::NotApplicable, - AuthState::Unauthenticated => WireAuthState::Unauthenticated, + AuthState::Unauthenticated { .. } => WireAuthState::Unauthenticated, }, } } @@ -247,7 +248,7 @@ impl HealthcheckResult { adapter: adapter.to_owned(), attempts: outcome.attempts, domain: outcome.domain.clone(), - healthy: outcome.healthy, + healthy: outcome.healthy(), path: outcome.path.clone(), service_id: outcome.service_id.clone(), staging: outcome.staging, @@ -394,11 +395,12 @@ impl GcResult { older_than_secs: Option, report: &GcReport, ) -> Self { + let failure = report.failure.clone().unwrap_or_default(); Self { adapter: adapter.to_owned(), deleted: report.deleted, dry_run, - failed: report.failed.clone(), + failed: failure.failed, kept_roots: report.kept_roots.clone(), older_than_secs, planned_deletions: report @@ -414,7 +416,7 @@ impl GcResult { logical: store.logical.clone(), platform: store.platform.clone(), }, - stranded: report.stranded.clone(), + stranded: failure.stranded, summary: GcSummaryResult { entries: report.entries, generations_planned: report.generations_planned, @@ -424,7 +426,7 @@ impl GcResult { roots: report.roots, unprovable: report.unprovable, }, - uncertain: report.uncertain.clone(), + uncertain: failure.uncertain, warnings: report.warnings.clone(), } } @@ -457,7 +459,7 @@ struct GcSummaryResult { /// `config validate` result. Present only when validation passed. #[derive(Debug, Serialize)] pub(crate) struct ValidateResult { - app_config: Option, + app_config: String, app_name: String, manifest: String, mode: ValidateMode, @@ -465,30 +467,27 @@ pub(crate) struct ValidateResult { } impl ValidateResult { - /// `app_config` is `None` for the raw (untyped) flow. pub(crate) fn new( manifest: &Path, - app_config: Option<&Path>, + app_config: &Path, app_name: &str, strict: bool, + mode: ValidateMode, ) -> Self { Self { - app_config: app_config.map(path_string), + app_config: path_string(app_config), app_name: app_name.to_owned(), manifest: path_string(manifest), - mode: if app_config.is_some() { - ValidateMode::Typed - } else { - ValidateMode::Raw - }, + mode, strict, } } } +/// Which `config validate` flow ran. #[derive(Debug, Serialize)] #[serde(rename_all = "snake_case")] -enum ValidateMode { +pub(crate) enum ValidateMode { Raw, Typed, } @@ -532,12 +531,19 @@ pub(crate) fn finish( format: OutputFormat, outcome: Outcome, ) -> Result<(), String> { - if format == OutputFormat::Json { - let document = render_envelope(command, &outcome)?; - let mut stdout = io::stdout().lock(); - writeln!(stdout, "{document}") - .and_then(|()| stdout.flush()) - .map_err(|err| format!("failed to write the JSON result to stdout: {err}"))?; + match format { + OutputFormat::Text => {} + OutputFormat::Json => { + let document = render_envelope(command, &outcome)?; + #[expect( + clippy::disallowed_methods, + reason = "the JSON envelope is the one thing `--format json` writes to stdout" + )] + let mut stdout = io::stdout().lock(); + writeln!(stdout, "{document}") + .and_then(|()| stdout.flush()) + .map_err(|err| format!("failed to write the JSON result to stdout: {err}"))?; + } } outcome.map(|_| ()).map_err(|failure| failure.message) } @@ -554,7 +560,7 @@ fn path_string(path: &Path) -> String { mod tests { use super::*; use crate::test_support::manifest_guard; - use edgezero_adapter::registry::{GcCandidate, ProvisionEntry}; + use edgezero_adapter::registry::{GcCandidate, GcFailure, ProvisionEntry}; use serde_json::{Value, json}; fn envelope_of(command: CommandName, outcome: &Outcome) -> Value { @@ -614,7 +620,6 @@ mod tests { attempts: 3, domain: "app.example.com".to_owned(), failure: Some("healthcheck failed".to_owned()), - healthy: false, path: "/".to_owned(), service_id: "SVC1".to_owned(), staging: true, @@ -674,7 +679,7 @@ mod tests { #[test] fn auth_build_deploy_rollback_results_serialize_every_key() { - let auth = serde_json::to_value(AuthStatusResult::new("axum", AuthState::NotApplicable)) + let auth = serde_json::to_value(AuthStatusResult::new("axum", &AuthState::NotApplicable)) .expect("serialize"); assert_eq!(auth, json!({"adapter": "axum", "state": "not_applicable"})); @@ -772,23 +777,59 @@ mod tests { } #[test] - fn validate_result_reports_mode_from_the_app_config() { + fn gc_failure_fills_the_partial_result() { + let report = GcReport { + deleted: Some(1), + failure: Some(GcFailure { + diagnostic: "config gc: 1 of 3 deletes FAILED".to_owned(), + failed: vec!["c.2".to_owned()], + stranded: vec!["c.3".to_owned()], + uncertain: vec!["d.1".to_owned()], + }), + store_id: Some("7Ab".to_owned()), + ..GcReport::default() + }; + let store = ResolvedStoreId::from_logical("app_config"); + let result = GcResult::new("fastly", &store, false, Some(3_600), &report); + let failed: Outcome = Err(Failure::with_result( + report.failure.expect("failure").diagnostic, + result, + )); + let envelope = envelope_of(CommandName::ConfigGc, &failed); + assert_envelope_invariants(&envelope); + assert_eq!(envelope["ok"], json!(false)); + assert_eq!( + envelope["error"], + json!({"message": "config gc: 1 of 3 deletes FAILED"}) + ); + let gc = &envelope["result"]; + assert_eq!(gc["deleted"], json!(1)); + assert_eq!(gc["failed"], json!(["c.2"])); + assert_eq!(gc["stranded"], json!(["c.3"])); + assert_eq!(gc["uncertain"], json!(["d.1"])); + assert_eq!(gc["older_than_secs"], json!(3_600)); + } + + #[test] + fn validate_result_reports_the_app_config_in_both_modes() { let raw = serde_json::to_value(ValidateResult::new( Path::new("edgezero.toml"), - None, + Path::new("demo.toml"), "demo", true, + ValidateMode::Raw, )) .expect("serialize"); assert_eq!( raw, - json!({"app_config": null, "app_name": "demo", "manifest": "edgezero.toml", "mode": "raw", "strict": true}) + json!({"app_config": "demo.toml", "app_name": "demo", "manifest": "edgezero.toml", "mode": "raw", "strict": true}) ); let typed = serde_json::to_value(ValidateResult::new( Path::new("edgezero.toml"), - Some(Path::new("demo.toml")), + Path::new("demo.toml"), "demo", false, + ValidateMode::Typed, )) .expect("serialize"); assert_eq!(typed["mode"], json!("typed")); diff --git a/crates/edgezero-cli/src/templates/root/clippy.toml.hbs b/crates/edgezero-cli/src/templates/root/clippy.toml.hbs index 36e6164cb..11b7d49f8 100644 --- a/crates/edgezero-cli/src/templates/root/clippy.toml.hbs +++ b/crates/edgezero-cli/src/templates/root/clippy.toml.hbs @@ -8,3 +8,13 @@ allow-expect-in-tests = true allow-indexing-slicing-in-tests = true allow-panic-in-tests = true allow-unwrap-in-tests = true + +# The built-in commands' `--format json` reserves stdout for one JSON envelope. +# Mirror the EdgeZero workspace guard so your own subcommands keep stdout clean +# too: route output through the logger (`edgezero_cli::init_cli_logger`), and +# `#[expect]` any deliberate stdout write or child spawn with a reason. +disallowed-methods = [ + { path = "std::process::Command::status", reason = "the child inherits stdout; pipe or redirect it, or `#[expect]` with a reason" }, + { path = "std::process::Command::spawn", reason = "the child inherits stdout by default; pipe or redirect it, or `#[expect]` with a reason" }, + { path = "std::io::stdout", reason = "`--format json` reserves stdout for the JSON envelope; write through the logger, or `#[expect]` with a reason" }, +] diff --git a/crates/edgezero-cli/tests/format_json.rs b/crates/edgezero-cli/tests/format_json.rs index d4c1517dc..e55ae35b3 100644 --- a/crates/edgezero-cli/tests/format_json.rs +++ b/crates/edgezero-cli/tests/format_json.rs @@ -5,8 +5,9 @@ //! its inherited stdout. That output, and every log line, must land on stderr. //! `--format text` (the default) must keep today's bytes on stdout. //! -//! Hermetic: manifest commands are `echo` / `false`, and the healthcheck probes -//! a fake `curl` on `PATH`. No network, no platform credentials. +//! Hermetic: manifest commands are `echo` / `false`, and the Fastly commands +//! talk to a fake `curl` on `PATH` (health probes and the versions API). No +//! network, no platform credentials. #![cfg(unix)] @@ -36,6 +37,22 @@ auth-status = "echo child-wrote-to-stdout; false" ids = ["sessions"] "#; + /// A Fastly app whose production deploy is a manifest command, so `deploy` + /// runs it through the capturing (tee) path. + const FASTLY_MANIFEST: &str = r#" +[app] +name = "demo-app" + +[adapters.fastly.adapter] +crate = "crates/demo-fastly" + +[adapters.fastly.commands] +deploy = "echo 'SUCCESS: Deployed package (service SVC1, version 12)'" +"#; + + /// The versions API answer: 6 inactive, 7 active. + const VERSIONS: &str = r#"[{"number":6,"active":false},{"number":7,"active":true}]"#; + /// `healthcheck` against a fake `curl`: one attempt, no delay. const HEALTHCHECK: [&str; 13] = [ "healthcheck", @@ -53,21 +70,62 @@ ids = ["sessions"] "0", ]; + const ACTIVE_VERSION: [&str; 5] = [ + "active-version", + "--adapter", + "fastly", + "--service-id", + "SVC1", + ]; + + const ROLLBACK: [&str; 9] = [ + "rollback", + "--adapter", + "fastly", + "--service-id", + "SVC1", + "--version", + "7", + "--rollback-to", + "6", + ]; + + const DEPLOY: [&str; 5] = ["deploy", "--adapter", "fastly", "--service-id", "SVC1"]; + /// A temp project holding [`MANIFEST`] as `edgezero.toml`. fn project() -> TempDir { + project_with(MANIFEST) + } + + /// A temp project holding `manifest` as `edgezero.toml`. + fn project_with(manifest: &str) -> TempDir { let dir = TempDir::new().expect("temp dir"); - fs::write(dir.path().join("edgezero.toml"), MANIFEST).expect("write manifest"); + fs::write(dir.path().join("edgezero.toml"), manifest).expect("write manifest"); dir } /// Run `edgezero ` in `dir`, optionally with `bin_dir` first on `PATH`. fn edgezero(dir: &Path, args: &[&str], bin_dir: Option<&Path>) -> Output { + edgezero_with_token(dir, args, bin_dir, None) + } + + /// [`edgezero`], with `FASTLY_API_TOKEN` set to `token` (removed when `None`). + fn edgezero_with_token( + dir: &Path, + args: &[&str], + bin_dir: Option<&Path>, + token: Option<&str>, + ) -> Output { let mut command = Command::new(env!("CARGO_BIN_EXE_edgezero")); command .args(args) .current_dir(dir) .env("EDGEZERO_MANIFEST", dir.join("edgezero.toml")) - .env_remove("FASTLY_API_TOKEN"); + .env_remove("FASTLY_API_TOKEN") + .env_remove("FASTLY_SERVICE_ID"); + if let Some(value) = token { + command.env("FASTLY_API_TOKEN", value); + } if let Some(bin) = bin_dir { let path = env::var_os("PATH").unwrap_or_default(); let mut paths = vec![bin.to_path_buf()]; @@ -102,15 +160,43 @@ ids = ["sessions"] /// A fake `curl` answering every probe with HTTP `code`. fn fake_curl(code: u16) -> TempDir { + fake_curl_script(&format!("echo {code}")) + } + + /// A fake `curl` that also serves the Fastly API: a request read from + /// `--config -` gets [`VERSIONS`] (GET) or a bare 200 (PUT), and is logged + /// to `api.log` as `GET` / `PUT`. A health probe gets HTTP `code`. + fn fake_curl_api(code: u16) -> TempDir { + fake_curl_script(&format!( + "case \" $* \" in *\" --config \"*)\n\ + \x20 cfg=$(cat)\n\ + \x20 case \"$cfg\" in *'request = \"PUT\"'*) echo PUT >> \"$(dirname \"$0\")/api.log\"; printf 200;;\n\ + \x20 *) echo GET >> \"$(dirname \"$0\")/api.log\"; printf '%s\\n200' '{VERSIONS}';; esac\n\ + \x20 exit 0;;\n\ + esac\n\ + echo {code}" + )) + } + + fn fake_curl_script(body: &str) -> TempDir { let dir = TempDir::new().expect("temp dir"); let script = dir.path().join("curl"); - fs::write(&script, format!("#!/bin/sh\necho {code}\n")).expect("write curl"); + fs::write(&script, format!("#!/bin/sh\n{body}\n")).expect("write curl"); let mut perms = fs::metadata(&script).expect("meta").permissions(); perms.set_mode(0o755); fs::set_permissions(&script, perms).expect("chmod +x"); dir } + /// The Fastly API calls a [`fake_curl_api`] served, in order. + fn api_calls(curl: &TempDir) -> Vec { + fs::read_to_string(curl.path().join("api.log")) + .unwrap_or_default() + .lines() + .map(str::to_owned) + .collect() + } + #[test] fn json_build_keeps_child_stdout_off_the_envelope_stream() { let dir = project(); @@ -220,7 +306,7 @@ ids = ["sessions"] assert_eq!( envelope["result"], json!({ - "app_config": null, "app_name": "demo-app", "manifest": "edgezero.toml", + "app_config": "demo-app.toml", "app_name": "demo-app", "manifest": "edgezero.toml", "mode": "raw", "strict": false }) ); @@ -295,4 +381,216 @@ ids = ["sessions"] assert_eq!(output.status.code(), Some(2_i32)); assert!(output.stdout.is_empty(), "stdout: {:?}", stdout_of(&output)); } + + #[test] + fn json_build_rejects_a_format_after_passthrough_args() { + let dir = project(); + let output = edgezero( + dir.path(), + &[ + "build", + "--adapter", + "axum", + "--release", + "--format", + "json", + ], + None, + ); + assert_eq!(output.status.code(), Some(1_i32)); + // The late `--format` was never read, so this is a text-mode failure: + // nothing on stdout, and the build command never ran. + assert!(output.stdout.is_empty(), "stdout: {:?}", stdout_of(&output)); + let stderr = stderr_of(&output); + assert!( + stderr.contains("`--format` after a passthrough argument"), + "stderr: {stderr}" + ); + assert!( + !stderr.contains("child-wrote-to-stdout"), + "stderr: {stderr}" + ); + } + + #[test] + fn json_config_validate_failure_has_no_result() { + // No `demo-app.toml`: the raw validator requires it. + let dir = project(); + let output = edgezero( + dir.path(), + &["config", "validate", "--format", "json"], + None, + ); + assert_eq!(output.status.code(), Some(1_i32)); + let envelope = sole_json_document(&output); + assert_eq!(envelope["ok"], json!(false)); + assert!(envelope["result"].is_null(), "{envelope}"); + let message = envelope["error"]["message"] + .as_str() + .expect("error message"); + assert!(message.contains("demo-app.toml"), "{message}"); + } + + #[test] + fn json_healthcheck_with_a_token_verifies_the_version() { + let dir = project(); + let curl = fake_curl_api(200); + let mut args = HEALTHCHECK.to_vec(); + args.extend(["--format", "json"]); + let output = edgezero_with_token(dir.path(), &args, Some(curl.path()), Some("tok")); + assert!(output.status.success(), "stderr: {}", stderr_of(&output)); + let envelope = sole_json_document(&output); + assert_eq!(envelope["result"]["healthy"], json!(true)); + assert_eq!(envelope["result"]["version_verified"], json!(true)); + assert_eq!( + api_calls(&curl), + ["GET", "GET"], + "before and after the probe" + ); + } + + #[test] + fn json_unhealthy_healthcheck_with_a_token_is_not_verified() { + let dir = project(); + let curl = fake_curl_api(503); + let mut args = HEALTHCHECK.to_vec(); + args.extend(["--format", "json"]); + let output = edgezero_with_token(dir.path(), &args, Some(curl.path()), Some("tok")); + assert_eq!(output.status.code(), Some(1_i32)); + let envelope = sole_json_document(&output); + assert_eq!(envelope["result"]["healthy"], json!(false)); + assert_eq!( + envelope["result"]["version_verified"], + json!(false), + "the after-probe check never ran" + ); + assert_eq!(api_calls(&curl), ["GET"]); + } + + #[test] + fn active_version_text_and_json() { + let dir = project(); + let curl = fake_curl_api(200); + let text = edgezero_with_token(dir.path(), &ACTIVE_VERSION, Some(curl.path()), Some("t")); + assert!(text.status.success(), "stderr: {}", stderr_of(&text)); + assert_eq!(stdout_of(&text), "version=7\n"); + + let mut args = ACTIVE_VERSION.to_vec(); + args.extend(["--format", "json"]); + let output = edgezero_with_token(dir.path(), &args, Some(curl.path()), Some("t")); + assert!(output.status.success(), "stderr: {}", stderr_of(&output)); + assert_eq!( + sole_json_document(&output)["result"], + json!({"adapter": "fastly", "service_id": "SVC1", "version": 7_i32}) + ); + assert!(stderr_of(&output).contains("version=7\n")); + } + + #[test] + fn active_version_with_none_active_reports_null() { + let dir = project(); + // Overwrite the API answer: no version is active yet. + let curl = fake_curl_script( + "case \" $* \" in *\" --config \"*) cat >/dev/null; \ + printf '%s\\n200' '[{\"number\":1,\"active\":false}]'; exit 0;; esac", + ); + let text = edgezero_with_token(dir.path(), &ACTIVE_VERSION, Some(curl.path()), Some("t")); + assert!(text.status.success(), "stderr: {}", stderr_of(&text)); + assert_eq!( + stdout_of(&text), + "version=\nservice SVC1 has no active version yet; emitting an empty rollback target\n" + ); + + let mut args = ACTIVE_VERSION.to_vec(); + args.extend(["--format", "json"]); + let output = edgezero_with_token(dir.path(), &args, Some(curl.path()), Some("t")); + assert!(output.status.success(), "stderr: {}", stderr_of(&output)); + assert_eq!( + sole_json_document(&output)["result"]["version"], + json!(null) + ); + } + + #[test] + fn rollback_text_and_json() { + let dir = project(); + let curl = fake_curl_api(200); + let text = edgezero_with_token(dir.path(), &ROLLBACK, Some(curl.path()), Some("t")); + assert!(text.status.success(), "stderr: {}", stderr_of(&text)); + assert!( + stdout_of(&text).ends_with("rolled-back-to=6\n"), + "stdout: {:?}", + stdout_of(&text) + ); + + let mut args = ROLLBACK.to_vec(); + args.extend(["--format", "json"]); + let output = edgezero_with_token(dir.path(), &args, Some(curl.path()), Some("t")); + assert!(output.status.success(), "stderr: {}", stderr_of(&output)); + assert_eq!( + sole_json_document(&output)["result"], + json!({ + "adapter": "fastly", "rolled_back_to": 6_i32, "service_id": "SVC1", + "staging": false, "version": 7_i32 + }) + ); + assert!(stderr_of(&output).contains("rolled-back-to=6\n")); + } + + #[test] + fn deploy_reads_the_version_from_the_captured_output() { + let dir = project_with(FASTLY_MANIFEST); + let text = edgezero(dir.path(), &DEPLOY, None); + assert!(text.status.success(), "stderr: {}", stderr_of(&text)); + let stdout = stdout_of(&text); + // The manifest command receives `--service-id SVC1` as passthrough. + assert!( + stdout.ends_with( + "SUCCESS: Deployed package (service SVC1, version 12) --service-id SVC1\n\ + version=12\n" + ), + "the tee echoes the child live, then the version line: {stdout:?}" + ); + + let mut args = DEPLOY.to_vec(); + args.extend(["--format", "json"]); + let output = edgezero(dir.path(), &args, None); + assert!(output.status.success(), "stderr: {}", stderr_of(&output)); + assert_eq!( + sole_json_document(&output)["result"], + json!({"adapter": "fastly", "service_id": "SVC1", "staging": false, "version": 12_i32}) + ); + // The tee sends the captured child's stdout to stderr under JSON. + let stderr = stderr_of(&output); + assert!( + stderr.contains("SUCCESS: Deployed package (service SVC1, version 12) --service-id"), + "stderr: {stderr}" + ); + assert!(stderr.contains("version=12\n"), "stderr: {stderr}"); + } + + #[test] + fn deploy_that_went_live_without_a_version_keeps_its_result() { + let manifest = FASTLY_MANIFEST.replace( + "echo 'SUCCESS: Deployed package (service SVC1, version 12)'", + "echo deployed", + ); + let dir = project_with(&manifest); + let mut args = DEPLOY.to_vec(); + args.extend(["--format", "json"]); + // No token: the API fallback cannot resolve the version either. + let output = edgezero(dir.path(), &args, None); + assert_eq!(output.status.code(), Some(1_i32)); + let envelope = sole_json_document(&output); + assert_eq!(envelope["ok"], json!(false)); + assert_eq!( + envelope["result"], + json!({"adapter": "fastly", "service_id": "SVC1", "staging": false, "version": null}), + "the deploy went live, so the result is present" + ); + let message = envelope["error"]["message"] + .as_str() + .expect("error message"); + assert!(message.starts_with("deploy succeeded but"), "{message}"); + } } diff --git a/docs/guide/cli-reference.md b/docs/guide/cli-reference.md index 67d8f0d05..400aaef25 100644 --- a/docs/guide/cli-reference.md +++ b/docs/guide/cli-reference.md @@ -659,6 +659,14 @@ happens on command-line usage errors (clap reports them before `--format` is read), on the bundled binary's `config push` / `config diff` stubs, and on a crash. Treat it as a failure. +`--help` and `--version` are handled by clap before `--format` is read: they +print clap's text to stdout and exit `0`, with no envelope. + +The routing relies on the EdgeZero logger (`edgezero_cli::init_cli_logger()`). +A downstream CLI that installs a different logger, such as `simple_logger`, +which writes every level to stdout, will put log lines on stdout and break the +one-document rule. + ### The envelope ```json @@ -689,7 +697,9 @@ order is not part of the contract. **Exit codes** are the same in both formats: `0` on success, and non-zero on failure (`1` for the bundled `edgezero` binary, `2` for a CLI generated from the -template). Branch on `ok` rather than on the specific non-zero code. +template). Branch on `ok` rather than on the specific non-zero code. In a +generated CLI, `2` also means a usage error or an unsupported `config diff`; +only the envelope tells them apart, and an empty stdout means there is none. ### Compatibility @@ -706,60 +716,128 @@ Consumers should ignore unknown keys, handle unknown enum values, and check ### Results -**`active-version`:** `{ adapter, service_id, version }`. `version` is `null` -when the service has no active version yet. - -**`auth status`:** `{ adapter, state }`, where `state` is `authenticated`, -`unauthenticated`, or `not_applicable` (axum). `unauthenticated` gives -`ok: false` with the result present. A missing native CLI gives `result: null`. - -**`build`:** `{ adapter, artifact }`. `artifact` is the built file's path, or -`null` when a manifest `commands.build` override ran or the adapter does not -track it (axum). - -**`deploy`:** `{ adapter, service_id, staging, version }`. `version` is the -staged version for `--staging`, the activated version for a Fastly production -deploy with a service id, and `null` otherwise. - -**`healthcheck`:** `{ adapter, service_id, domain, path, version, staging, -staging_ip, healthy, status_code, attempts, version_verified }`. `attempts` is -the number of probes actually made. `version_verified` is `true` when the -version was confirmed active before and after the probe (a production probe with -`FASTLY_API_TOKEN` set). An unhealthy probe gives `ok: false` with the result -present. - -**`rollback`:** `{ adapter, service_id, staging, version, rolled_back_to }`. -`version` is the version rolled back from, or the staged version that was -deactivated. `rolled_back_to` is `null` for `--staging`. - -**`provision`:** `{ adapter, dry_run, entries }`. Each entry is -`{ action, store, message }`: - -- `action` is one of `created`, `already_present`, `would_create`, `updated`, - `would_update`, `not_applicable`, or `note`. -- `store` is `{ kind, logical, platform }` (`kind` is `config`, `kv`, or - `secrets`), or `null` for an adapter-level note. `logical` is `null` for a - store EdgeZero owns itself, such as Fastly's `edgezero_runtime_env`. -- `message` is the line `text` mode prints. It may span several lines and is - meant for people, not parsing. - -**`config gc`:** `{ adapter, store, dry_run, older_than_secs, summary, -kept_roots, planned_deletions, deleted, failed, stranded, uncertain, warnings }`. - -- `store` is `{ logical, platform, id }`. -- `summary` holds the counts `entries`, `roots`, `referenced_chunks`, - `orphans_planned`, `generations_planned`, `orphans_too_recent`, and - `unprovable`. -- `planned_deletions` is a list of `{ key, age_secs }`. -- `older_than_secs` is `null` without `--older-than`, and `deleted` is `null` on - a dry run. -- If any delete failed, the command gives `ok: false` with the result present, - and `error.message` carries the recovery commands. - -**`config validate`:** `{ mode, manifest, app_config, app_name, strict }`. -`mode` is `raw` for the bundled binary (where `app_config` is `null`) and -`typed` for a generated CLI. A validation failure gives `ok: false` and -`result: null`, with the first failing check in `error.message`. +Each table lists every key of a command's `result`. **Nullable** says whether +the key can be `null` under schema version `1`; a key marked "no" never is. +Every result except `config validate`'s also has a non-null `adapter` string. + +**`active-version`** + +| Key | Type | Nullable | Meaning | +| ------------ | ------- | -------- | --------------------------------------------------- | +| `service_id` | string | no | The service queried. | +| `version` | integer | yes | The active version; `null` when none is active yet. | + +**`auth status`** + +| Key | Type | Nullable | Meaning | +| ------- | ------ | -------- | --------------------------------------------------------------- | +| `state` | string | no | `authenticated`, `unauthenticated`, or `not_applicable` (axum). | + +`unauthenticated` gives `ok: false` with the result present. A missing native +CLI gives `result: null`. + +**`build`** + +| Key | Type | Nullable | Meaning | +| ---------- | ------ | -------- | -------------------------------------------------------------------------------------------------------------------- | +| `artifact` | string | yes | The built file's path; `null` when a manifest `commands.build` override ran or the adapter does not track it (axum). | + +`--format` must come before the first passthrough argument. A `--format` after +one is rejected rather than forwarded to the build command. + +**`deploy`** + +| Key | Type | Nullable | Meaning | +| ------------ | ------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| `service_id` | string | yes | The service the adapter resolved (a Fastly `--staging` deploy falls back to `FASTLY_SERVICE_ID`), else `--service-id` as passed, else `null`. | +| `staging` | boolean | no | Whether `--staging` was passed. | +| `version` | integer | yes | The staged version for `--staging`, the activated version for a Fastly production deploy with `--service-id`, and `null` otherwise. | + +A Fastly production deploy that went live but whose version could not be +resolved gives `ok: false` with the result present and `version: null`. Check +`result`, not only `ok`, before assuming nothing was deployed. + +**`healthcheck`** + +| Key | Type | Nullable | Meaning | +| ------------------ | ------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| `service_id` | string | no | The service probed. | +| `domain` | string | no | The probed domain. | +| `path` | string | no | The probed path. | +| `version` | integer | no | The version under test. | +| `staging` | boolean | no | Whether the staging IP was probed. | +| `staging_ip` | string | yes | The resolved staging IP; `null` for a production probe. | +| `healthy` | boolean | no | Whether the probe passed. | +| `status_code` | integer | yes | The last HTTP status; `null` when no probe got a response. | +| `attempts` | integer | no | The number of probes actually made. | +| `version_verified` | boolean | no | `true` when the version was confirmed active before and after the probe: a healthy production probe with `FASTLY_API_TOKEN` set. Otherwise `false`. | + +An unhealthy probe gives `ok: false` with the result present. + +**`rollback`** + +| Key | Type | Nullable | Meaning | +| ---------------- | ------- | -------- | ---------------------------------------------------------------- | +| `service_id` | string | no | The service rolled back. | +| `staging` | boolean | no | Whether a staged version was deactivated. | +| `version` | integer | no | The version rolled back from, or the staged version deactivated. | +| `rolled_back_to` | integer | yes | The re-activated version; `null` for `--staging`. | + +**`provision`** + +| Key | Type | Nullable | Meaning | +| --------- | ------- | -------- | ------------------------------- | +| `dry_run` | boolean | no | Whether `--dry-run` was passed. | +| `entries` | array | no | One entry per step, in order. | + +Each entry: + +| Key | Type | Nullable | Meaning | +| ---------------- | ------ | -------- | ----------------------------------------------------------------------------------------------------- | +| `action` | string | no | `created`, `already_present`, `would_create`, `updated`, `would_update`, `not_applicable`, or `note`. | +| `store` | object | yes | `{ kind, logical, platform }`; `null` for an adapter-level note. | +| `store.kind` | string | no | `config`, `kv`, or `secrets`. | +| `store.logical` | string | yes | The manifest id; `null` for a store EdgeZero owns itself, such as Fastly's `edgezero_runtime_env`. | +| `store.platform` | string | no | The platform name the runtime resolves. | +| `message` | string | no | The line `text` mode prints. It may span several lines and is meant for people, not parsing. | + +A dry run's `would_create` pairs with a real run's `created` or +`already_present`, and `would_update` with `updated`. The Fastly and Spin dry +runs report the plan without checking the current state, so a store that +already exists still shows `would_create`. Fastly's real run also omits the +`edgezero_runtime_env` entry when `fastly.toml` already declares it. + +**`config gc`** + +| Key | Type | Nullable | Meaning | +| ------------------- | ------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| `store` | object | no | `{ logical, platform, id }`. `id`, the platform's store id, is nullable. | +| `dry_run` | boolean | no | `true` unless `--yes` was passed. | +| `older_than_secs` | integer | yes | The `--older-than` window; `null` without `--older-than`. | +| `summary` | object | no | The counts `entries`, `roots`, `referenced_chunks`, `orphans_planned`, `generations_planned`, `orphans_too_recent`, and `unprovable`. | +| `kept_roots` | array | no | The retained root keys. | +| `planned_deletions` | array | no | `{ key, age_secs }` for each planned delete. | +| `deleted` | integer | yes | Entries deleted; `null` on a dry run, and `0` for a real run with nothing to reclaim. | +| `failed` | array | no | Keys whose delete failed. | +| `stranded` | array | no | Keys left in a generation that lost a sibling to a confirmed delete. Remove these by hand. | +| `uncertain` | array | no | Keys whose delete outcome is unknown; a re-run may reclaim them. | +| `warnings` | array | no | Warnings about the store's contents. | + +If any delete failed, the command gives `ok: false` with the result present, +and `error.message` carries the recovery commands. + +**`config validate`** + +| Key | Type | Nullable | Meaning | +| ------------ | ------- | -------- | ---------------------------------------------------------- | +| `mode` | string | no | `raw` for the bundled binary, `typed` for a generated CLI. | +| `manifest` | string | no | The validated manifest. | +| `app_config` | string | no | The app-config file both modes read and require. | +| `app_name` | string | no | The app name from the manifest. | +| `strict` | boolean | no | Whether `--strict` was passed. | + +A validation failure gives `ok: false` and `result: null`, with the first +failing check in `error.message`. ### Schema changelog From e6a0b12b009f990547523fa491b0e218bc211f99 Mon Sep 17 00:00:00 2001 From: dhruv8sh Date: Thu, 8 Oct 2026 00:54:50 +0530 Subject: [PATCH 6/6] Address the second PR #388 review Mark the spec as proposed and awaiting maintainer approval, and update its type listings, testing section and revision notes to match the code. Remove the retroactive plan document. Keep the command's own error when writing the JSON envelope fails. Report the staged Fastly deploy's service id from the deploy itself, return Empty for the production deploy, and replace the nested Result with a ProductionDeploy enum. Treat curl's 000 as no response, not status 0. Document that build never forwards --format, that auth status reports the probe's exit status, and that only one command may run per process. Cover config gc, auth status, staged deploy and staged rollback end to end, pin the text output of all nine commands, and clear EDGEZERO__ overlays in the test harness. Disallow CommandExt::exec, and give the lifecycle fixture its own clippy.toml so the CLI stdout guard does not apply to it. --- clippy.toml | 1 + crates/edgezero-adapter-fastly/src/cli.rs | 56 +- crates/edgezero-adapter/src/cli_support.rs | 1 + crates/edgezero-adapter/src/process.rs | 33 +- crates/edgezero-adapter/src/registry.rs | 2 +- crates/edgezero-cli/src/args.rs | 4 +- crates/edgezero-cli/src/config.rs | 2 +- crates/edgezero-cli/src/generator.rs | 21 +- crates/edgezero-cli/src/lib.rs | 106 +- crates/edgezero-cli/src/output.rs | 35 +- .../src/templates/root/clippy.toml.hbs | 1 + crates/edgezero-cli/tests/format_json.rs | 472 +++++- docs/guide/cli-reference.md | 25 +- .../plans/2026-10-05-pr-388-review-fixes.md | 1507 ----------------- .../2026-09-25-cli-format-json-design.md | 146 +- tests/fixtures/reusable-app/clippy.toml | 9 + 16 files changed, 747 insertions(+), 1674 deletions(-) delete mode 100644 docs/superpowers/plans/2026-10-05-pr-388-review-fixes.md create mode 100644 tests/fixtures/reusable-app/clippy.toml diff --git a/clippy.toml b/clippy.toml index 1c25038fc..18d81d9dd 100644 --- a/clippy.toml +++ b/clippy.toml @@ -17,4 +17,5 @@ disallowed-methods = [ { path = "std::process::Command::status", reason = "use `edgezero_adapter::process::status` so the `--format json` stdout policy applies" }, { path = "std::process::Command::spawn", reason = "inherits stdout by default; pipe stdout and `#[expect]` with a reason, or use `edgezero_adapter::process::status`" }, { path = "std::io::stdout", reason = "`--format json` reserves stdout for the JSON envelope; write through the logger, or `#[expect]` with a reason" }, + { path = "std::os::unix::process::CommandExt::exec", reason = "replaces this process, so the new program writes straight to stdout; use `edgezero_adapter::process::status`", allow-invalid = true }, ] diff --git a/crates/edgezero-adapter-fastly/src/cli.rs b/crates/edgezero-adapter-fastly/src/cli.rs index d94b493c4..8d8bfa3ef 100644 --- a/crates/edgezero-adapter-fastly/src/cli.rs +++ b/crates/edgezero-adapter-fastly/src/cli.rs @@ -418,23 +418,11 @@ impl Adapter for FastlyCliAdapter { })) } // The CLI resolves the activated version (deploy output, then the - // Fastly API), so the built-in deploy reports none. - AdapterAction::Deploy => deploy(args).map(|()| { - ActionOutcome::Deploy(DeployOutcome { - service_id: None, - version: None, - }) - }), + // Fastly API), so the built-in deploy reports nothing. + AdapterAction::Deploy => deploy(args).map(|()| ActionOutcome::Empty), AdapterAction::Serve => serve(args).map(|()| ActionOutcome::Empty), // Fastly staging lifecycle. - AdapterAction::DeployStaging => deploy_staging(args).map(|version| { - ActionOutcome::Deploy(DeployOutcome { - // `deploy_staging` already resolved this (flag, then - // `FASTLY_SERVICE_ID`), so it cannot fail here. - service_id: resolve_service_id(args).ok(), - version: Some(version), - }) - }), + AdapterAction::DeployStaging => deploy_staging(args).map(ActionOutcome::Deploy), AdapterAction::EmitVersion => { emit_active_version(args).map(ActionOutcome::ActiveVersion) } @@ -5194,7 +5182,7 @@ fn resolve_manifest_dir(args: &[String]) -> Result { /// `deploy --adapter fastly --service-id --staging`: /// build, upload to a new draft version (no activation), stage it, and /// emit `version=`. -fn deploy_staging(args: &[String]) -> Result { +fn deploy_staging(args: &[String]) -> Result { let service_id = resolve_service_id(args)?; validate_service_id(&service_id)?; // The Fastly CLI reads FASTLY_API_TOKEN from the env; fail fast @@ -5313,8 +5301,11 @@ fn deploy_staging(args: &[String]) -> Result { manifest_dir, )?; - // 6. Report the staged version; the CLI emits it (`version=`). - Ok(version) + // 6. Report the service and staged version; the CLI emits `version=`. + Ok(DeployOutcome { + service_id: Some(service_id), + version: Some(version), + }) } /// Point a staged draft's `edgezero_runtime_env` link at the STAGING selector @@ -5668,12 +5659,16 @@ fn curl_status(args: &[String]) -> Result { )); } let stdout = String::from_utf8_lossy(&output.stdout); - stdout.trim().parse::().map_err(|err| { - format!( + match stdout.trim().parse::() { + // curl writes `000` when no HTTP response arrived; that is a transport + // failure, not a status code. + Ok(0) => Err("curl got no HTTP response (status 000)".to_owned()), + Ok(code) => Ok(code), + Err(err) => Err(format!( "could not parse HTTP status from curl output {:?}: {err}", stdout.trim() - ) - }) + )), + } } /// `rollback --adapter fastly ...`: production activates the explicit @@ -8342,13 +8337,14 @@ exit 1 GUARD.get_or_init(|| Mutex::new(())) } - /// A fake `curl` that answers every health probe with HTTP `code`. + /// A fake `curl` that answers every health probe with HTTP `code`, + /// written as curl's three-digit `%{http_code}` (`0` prints `000`). #[cfg(unix)] fn fake_curl(code: u16) -> tempfile::TempDir { use std::os::unix::fs::PermissionsExt as _; let dir = tempdir().expect("tempdir"); let script_path = dir.path().join("curl"); - fs::write(&script_path, format!("#!/bin/sh\necho {code}\n")).expect("write script"); + fs::write(&script_path, format!("#!/bin/sh\necho {code:03}\n")).expect("write script"); let mut perms = fs::metadata(&script_path).expect("meta").permissions(); perms.set_mode(0o755); fs::set_permissions(&script_path, perms).expect("chmod +x"); @@ -8400,7 +8396,7 @@ exit 1 drop(healthy_path); let unhealthy_curl = fake_curl(503); - let _path = PathPrepend::new(unhealthy_curl.path()); + let unhealthy_path = PathPrepend::new(unhealthy_curl.path()); let unhealthy = healthcheck(&args("2")).expect("an unhealthy probe is an outcome, not an error"); assert!(!unhealthy.healthy()); @@ -8413,6 +8409,16 @@ exit 1 failure.contains("failed after 2 attempt(s)"), "keeps the pre-existing message: {failure}" ); + drop(unhealthy_path); + + // curl's `000` means no HTTP response arrived: no status code. + let silent_curl = fake_curl(0); + let _silent_path = PathPrepend::new(silent_curl.path()); + let silent = healthcheck(&args("1")).expect("a silent probe is an outcome"); + assert!(!silent.healthy()); + assert_eq!(silent.status_code, None, "`000` is not a status code"); + let silent_failure = silent.failure.expect("carries its reason"); + assert!(silent_failure.contains("status 000"), "{silent_failure}"); } /// `version_verified` claims the version was ACTIVE before AND after the diff --git a/crates/edgezero-adapter/src/cli_support.rs b/crates/edgezero-adapter/src/cli_support.rs index d82007104..023c3337f 100644 --- a/crates/edgezero-adapter/src/cli_support.rs +++ b/crates/edgezero-adapter/src/cli_support.rs @@ -268,6 +268,7 @@ mod tests { ); } + #[cfg(unix)] #[test] fn native_auth_status_maps_exit_status_to_state() { let ok = native_auth_status("true", &[], "hint").expect("spawns"); diff --git a/crates/edgezero-adapter/src/process.rs b/crates/edgezero-adapter/src/process.rs index 15a26ec1d..da6503569 100644 --- a/crates/edgezero-adapter/src/process.rs +++ b/crates/edgezero-adapter/src/process.rs @@ -52,29 +52,48 @@ pub fn status(command: &mut Command) -> io::Result { #[cfg(test)] mod tests { use super::*; - use std::sync::{LazyLock, Mutex}; + use std::sync::{LazyLock, Mutex, MutexGuard, PoisonError}; /// Serialises tests that flip the process-wide policy. static POLICY_LOCK: LazyLock> = LazyLock::new(|| Mutex::new(())); + /// Sets the policy, and restores the previous one on drop (even if the + /// test panics, so later tests start from the original policy). + struct PolicyOverride(bool); + + impl PolicyOverride { + fn set(to_stderr: bool) -> Self { + Self(set_child_stdout_to_stderr(to_stderr)) + } + } + + impl Drop for PolicyOverride { + fn drop(&mut self) { + set_child_stdout_to_stderr(self.0); + } + } + + /// Takes [`POLICY_LOCK`], recovering it if an earlier test panicked. + fn policy_lock() -> MutexGuard<'static, ()> { + POLICY_LOCK.lock().unwrap_or_else(PoisonError::into_inner) + } + #[test] fn set_returns_previous_and_get_reflects_it() { - let _guard = POLICY_LOCK.lock().expect("lock"); - let original = set_child_stdout_to_stderr(true); + let _guard = policy_lock(); + let _policy = PolicyOverride::set(true); assert!(child_stdout_to_stderr()); assert!(set_child_stdout_to_stderr(false)); assert!(!child_stdout_to_stderr()); - set_child_stdout_to_stderr(original); } #[cfg(unix)] #[test] fn status_reports_child_exit() { - let _guard = POLICY_LOCK.lock().expect("lock"); - let original = set_child_stdout_to_stderr(true); + let _guard = policy_lock(); + let _policy = PolicyOverride::set(true); let ok = status(Command::new("sh").args(["-c", "echo routed; exit 0"])).expect("spawn"); let failed = status(Command::new("sh").args(["-c", "exit 3"])).expect("spawn"); - set_child_stdout_to_stderr(original); assert!(ok.success()); assert_eq!(failed.code(), Some(3_i32)); } diff --git a/crates/edgezero-adapter/src/registry.rs b/crates/edgezero-adapter/src/registry.rs index 47d0398ac..1da7bc4b7 100644 --- a/crates/edgezero-adapter/src/registry.rs +++ b/crates/edgezero-adapter/src/registry.rs @@ -342,7 +342,7 @@ pub struct GcReport { } /// The deletes a `config gc` run could not complete. -#[derive(Clone, Debug, Default, Eq, PartialEq)] +#[derive(Clone, Debug, Eq, PartialEq)] pub struct GcFailure { /// The operator-facing report and recovery text. pub diagnostic: String, diff --git a/crates/edgezero-cli/src/args.rs b/crates/edgezero-cli/src/args.rs index 895663b89..e37772430 100644 --- a/crates/edgezero-cli/src/args.rs +++ b/crates/edgezero-cli/src/args.rs @@ -233,7 +233,9 @@ pub enum AuthSub { Status { #[arg(long)] adapter: String, - /// Output format: `text` (default) or `json`. + /// Output format: `text` (default, the human-readable output) or + /// `json` (a single versioned JSON envelope on stdout; everything else + /// goes to stderr). #[arg(long, value_enum, default_value_t = OutputFormat::Text)] format: OutputFormat, }, diff --git a/crates/edgezero-cli/src/config.rs b/crates/edgezero-cli/src/config.rs index 39d00eafe..3e90ef6fb 100644 --- a/crates/edgezero-cli/src/config.rs +++ b/crates/edgezero-cli/src/config.rs @@ -1373,7 +1373,7 @@ pub(crate) fn print_unified_diff_inline( use std::io::stdout; #[expect( clippy::disallowed_methods, - reason = "`config push` has no `--format`; its inline diff is text-only output" + reason = "`config push` and `config diff` have no `--format json` envelope; this inline diff is text-only output" )] let mut stdout = stdout().lock(); // Silently ignore write errors — stdout may be a closed pipe (e.g. diff --git a/crates/edgezero-cli/src/generator.rs b/crates/edgezero-cli/src/generator.rs index 155f456cf..e48f8028e 100644 --- a/crates/edgezero-cli/src/generator.rs +++ b/crates/edgezero-cli/src/generator.rs @@ -1125,6 +1125,24 @@ mod tests { ); } + /// The generated `clippy.toml` exempts tests, and carries the `--format + /// json` stdout guard. + fn assert_clippy_toml(project_dir: &Path) { + let clippy = fs::read_to_string(project_dir.join("clippy.toml")).expect("read clippy.toml"); + assert!(clippy.contains("allow-expect-in-tests = true")); + for path in [ + "std::process::Command::status", + "std::process::Command::spawn", + "std::io::stdout", + "std::os::unix::process::CommandExt::exec", + ] { + assert!( + clippy.contains(&format!("{{ path = \"{path}\"")), + "generated clippy.toml disallows `{path}`" + ); + } + } + fn assert_scaffold_workspace(project_dir: &Path) { let cargo_toml = fs::read_to_string(project_dir.join("Cargo.toml")).expect("read Cargo.toml"); @@ -1167,8 +1185,7 @@ mod tests { let gitignore = fs::read_to_string(project_dir.join(".gitignore")).expect("read .gitignore"); assert!(gitignore.contains("target/")); - let clippy = fs::read_to_string(project_dir.join("clippy.toml")).expect("read clippy.toml"); - assert!(clippy.contains("allow-expect-in-tests = true")); + assert_clippy_toml(project_dir); // The generated manifests must keep the package-metadata contract: the // root declares the five shared fields once, and EVERY member inherits diff --git a/crates/edgezero-cli/src/lib.rs b/crates/edgezero-cli/src/lib.rs index 3893404ea..c7e24cb82 100644 --- a/crates/edgezero-cli/src/lib.rs +++ b/crates/edgezero-cli/src/lib.rs @@ -6,6 +6,11 @@ //! reuses any subset of edgezero's built-in commands. The default //! `edgezero` binary (`main.rs`) is a thin wrapper over this library. //! +//! Run one command per process. Under `--format json` a `run_*` handler +//! reroutes process-wide output (the logger and inheriting children) for the +//! duration of the call, so overlapping runs on separate threads are not +//! supported. +//! //! `run_demo` is an additional contributor-only handler, available only //! under the `demo-example` feature — it runs the in-repo `app-demo` //! example and is not meant for downstream CLIs. @@ -137,6 +142,16 @@ impl log::Log for CliLogger { } } +/// How a production Fastly deploy that went live ended. +#[cfg(feature = "cli")] +enum ProductionDeploy { + /// The version the deploy activated. + Live(u64), + /// The deploy went live, but its version could not be resolved (the + /// message says why). + LiveVersionUnknown(String), +} + /// Route [`CliLogger`]'s `info` output to stderr (`true`) or stdout (`false`). /// Returns the previous setting so a scope guard can restore it. #[cfg(feature = "cli")] @@ -293,32 +308,7 @@ fn deploy(args: &DeployArgs) -> Outcome { passthrough.extend_from_slice(&args.adapter_args); if args.staging { - // Thread the app's declared config-store logical ids so the staged - // relink knows which selectors to redirect to `_staging`. The - // adapter reads config usage from THIS list, never a remote probe — - // avoiding a lookup that fails open. One inline token per store; the - // adapter strips them before `fastly compute update`. - if let Some(loader) = manifest.as_ref() - && let Some(config) = loader.manifest().stores.config.as_ref() - { - for id in &config.ids { - passthrough.push(format!("--edgezero-staging-config={id}")); - } - } - // Staged deploy: clone the active version, upload the built - // package to a new draft, mark it staged, and emit the staged - // version. Never runs the manifest `deploy` - // command, which would activate production. - let outcome = adapter::execute( - &args.adapter, - adapter::Action::DeployStaging, - manifest.as_ref(), - &passthrough, - )?; - let (service_id, version) = deployed(outcome); - if let Some(staged) = version { - log::info!("version={staged}"); - } + let (service_id, version) = deploy_staging(&args.adapter, manifest.as_ref(), passthrough)?; return Ok(result(service_id, version)); } @@ -341,11 +331,13 @@ fn deploy(args: &DeployArgs) -> Outcome { // the failure still carries a result (with a `null` version). if args.service_id.is_some() && args.adapter.eq_ignore_ascii_case("fastly") { return match fastly_production_deploy(&args.adapter, manifest.as_ref(), &passthrough)? { - Ok(version) => { + ProductionDeploy::Live(version) => { log::info!("version={version}"); Ok(result(None, Some(version))) } - Err(unresolved) => Err(Failure::with_result(unresolved, result(None, None))), + ProductionDeploy::LiveVersionUnknown(unresolved) => { + Err(Failure::with_result(unresolved, result(None, None))) + } }; } @@ -359,21 +351,58 @@ fn deploy(args: &DeployArgs) -> Outcome { Ok(result(service_id, version)) } +/// Run a staged deploy: clone the active version, upload the built package to +/// a new draft, mark it staged, and log the staged version. Never runs the +/// manifest `deploy` command, which would activate production. +/// +/// Returns the service id and staged version the adapter reported. +#[cfg(feature = "cli")] +fn deploy_staging( + adapter_name: &str, + manifest: Option<&ManifestLoader>, + mut passthrough: Vec, +) -> Result<(Option, Option), String> { + // Thread the app's declared config-store logical ids so the staged + // relink knows which selectors to redirect to `_staging`. The + // adapter reads config usage from THIS list, never a remote probe — + // avoiding a lookup that fails open. One inline token per store; the + // adapter strips them before `fastly compute update`. + if let Some(loader) = manifest + && let Some(config) = loader.manifest().stores.config.as_ref() + { + for id in &config.ids { + passthrough.push(format!("--edgezero-staging-config={id}")); + } + } + let outcome = adapter::execute( + adapter_name, + adapter::Action::DeployStaging, + manifest, + &passthrough, + )?; + let (service_id, version) = deployed(outcome); + if let Some(staged) = version { + log::info!("version={staged}"); + } + Ok((service_id, version)) +} + /// Run a production Fastly deploy for a known service and resolve the version /// it activated (see `deploy` for the resolution precedence). /// -/// The outer `Err` means the deploy itself failed. The inner `Err` means the -/// deploy went live but the activated version could not be resolved. +/// # Errors +/// +/// Returns an error if the deploy itself failed. #[cfg(feature = "cli")] fn fastly_production_deploy( adapter_name: &str, manifest: Option<&ManifestLoader>, passthrough: &[String], -) -> Result, String> { +) -> Result { let captured = adapter::execute_capture(adapter_name, adapter::Action::Deploy, manifest, passthrough)?; if let Some(version) = captured.as_deref().and_then(parse_deploy_version) { - return Ok(Ok(version)); + return Ok(ProductionDeploy::Live(version)); } // Fallback: resolve the version the deploy just activated via the Fastly // API. `--require-active` makes EmitVersion FAIL (not emit an empty @@ -397,10 +426,15 @@ fn fastly_production_deploy( manifest, &emit_args, ) { - Ok(outcome) => { - active_version(&outcome).ok_or_else(|| unresolved("no active version was reported")) - } - Err(err) => Err(unresolved(&err)), + Ok(outcome) => active_version(&outcome).map_or_else( + || { + ProductionDeploy::LiveVersionUnknown(unresolved( + "no active version was reported", + )) + }, + ProductionDeploy::Live, + ), + Err(err) => ProductionDeploy::LiveVersionUnknown(unresolved(&err)), }, ) } diff --git a/crates/edgezero-cli/src/output.rs b/crates/edgezero-cli/src/output.rs index de7b2e8ad..bbd0c18fd 100644 --- a/crates/edgezero-cli/src/output.rs +++ b/crates/edgezero-cli/src/output.rs @@ -19,8 +19,8 @@ use std::path::Path; use edgezero_adapter::process; use edgezero_adapter::registry::{ - AuthState, GcReport, HealthcheckOutcome, ProvisionAction, ProvisionReport, ProvisionStoreRef, - ResolvedStoreId, RollbackOutcome, StoreKind, + AuthState, GcFailure, GcReport, HealthcheckOutcome, ProvisionAction, ProvisionReport, + ProvisionStoreRef, ResolvedStoreId, RollbackOutcome, StoreKind, }; use serde::Serialize; @@ -64,6 +64,10 @@ impl CommandName { /// Routes stdout for one command run: under `--format json`, logger `info` /// output and inheriting children's stdout go to stderr, and the previous /// routing is restored on drop. A `text` scope changes nothing. +/// +/// The routing is process-wide. Nested scopes restore correctly, but +/// overlapping scopes on separate threads do not: the first to drop restores +/// routing while the other is still running. One command per process. #[must_use = "the routing lasts only while the scope is alive"] pub(crate) struct OutputScope { /// The routing to restore (`(child_stdout, info)`); `None` for `text`. @@ -395,12 +399,20 @@ impl GcResult { older_than_secs: Option, report: &GcReport, ) -> Self { - let failure = report.failure.clone().unwrap_or_default(); + let (failed, stranded, uncertain) = match &report.failure { + Some(GcFailure { + failed, + stranded, + uncertain, + .. + }) => (failed.clone(), stranded.clone(), uncertain.clone()), + None => (Vec::new(), Vec::new(), Vec::new()), + }; Self { adapter: adapter.to_owned(), deleted: report.deleted, dry_run, - failed: failure.failed, + failed, kept_roots: report.kept_roots.clone(), older_than_secs, planned_deletions: report @@ -416,7 +428,7 @@ impl GcResult { logical: store.logical.clone(), platform: store.platform.clone(), }, - stranded: failure.stranded, + stranded, summary: GcSummaryResult { entries: report.entries, generations_planned: report.generations_planned, @@ -426,7 +438,7 @@ impl GcResult { roots: report.roots, unprovable: report.unprovable, }, - uncertain: failure.uncertain, + uncertain, warnings: report.warnings.clone(), } } @@ -540,9 +552,14 @@ pub(crate) fn finish( reason = "the JSON envelope is the one thing `--format json` writes to stdout" )] let mut stdout = io::stdout().lock(); - writeln!(stdout, "{document}") - .and_then(|()| stdout.flush()) - .map_err(|err| format!("failed to write the JSON result to stdout: {err}"))?; + // A failed write must not hide the command's own failure message. + if let Err(err) = writeln!(stdout, "{document}").and_then(|()| stdout.flush()) { + let message = format!("failed to write the JSON result to stdout: {err}"); + if outcome.is_ok() { + return Err(message); + } + log::error!("[edgezero] {message}"); + } } } outcome.map(|_| ()).map_err(|failure| failure.message) diff --git a/crates/edgezero-cli/src/templates/root/clippy.toml.hbs b/crates/edgezero-cli/src/templates/root/clippy.toml.hbs index 11b7d49f8..754629345 100644 --- a/crates/edgezero-cli/src/templates/root/clippy.toml.hbs +++ b/crates/edgezero-cli/src/templates/root/clippy.toml.hbs @@ -17,4 +17,5 @@ disallowed-methods = [ { path = "std::process::Command::status", reason = "the child inherits stdout; pipe or redirect it, or `#[expect]` with a reason" }, { path = "std::process::Command::spawn", reason = "the child inherits stdout by default; pipe or redirect it, or `#[expect]` with a reason" }, { path = "std::io::stdout", reason = "`--format json` reserves stdout for the JSON envelope; write through the logger, or `#[expect]` with a reason" }, + { path = "std::os::unix::process::CommandExt::exec", reason = "replaces this process, so the new program writes straight to stdout; spawn a child instead, or `#[expect]` with a reason", allow-invalid = true }, ] diff --git a/crates/edgezero-cli/tests/format_json.rs b/crates/edgezero-cli/tests/format_json.rs index e55ae35b3..876314022 100644 --- a/crates/edgezero-cli/tests/format_json.rs +++ b/crates/edgezero-cli/tests/format_json.rs @@ -48,6 +48,46 @@ crate = "crates/demo-fastly" [adapters.fastly.commands] deploy = "echo 'SUCCESS: Deployed package (service SVC1, version 12)'" +"#; + + /// A Fastly app with no config stores, so a staged deploy needs only + /// `fastly compute update` to answer (no runtime-env relink). + const FASTLY_STAGING_MANIFEST: &str = r#" +[app] +name = "demo-app" + +[adapters.fastly.adapter] +crate = "crates/demo-fastly" +manifest = "fastly.toml" +"#; + + /// A Fastly app with one config store, for `config gc`. + const FASTLY_GC_MANIFEST: &str = r#" +[app] +name = "demo-app" + +[adapters.fastly.adapter] +crate = "crates/demo-fastly" +manifest = "fastly.toml" + +[stores.config] +ids = ["app_config"] +"#; + + /// An app whose axum session probe succeeds, and a cloudflare adapter + /// with no override (its probe is the native `wrangler whoami`). + const AUTH_MANIFEST: &str = r#" +[app] +name = "demo-app" + +[adapters.axum.adapter] +crate = "crates/demo-axum" + +[adapters.axum.commands] +auth-status = "echo logged-in" + +[adapters.cloudflare.adapter] +crate = "crates/demo-cloudflare" "#; /// The versions API answer: 6 inactive, 7 active. @@ -104,6 +144,35 @@ deploy = "echo 'SUCCESS: Deployed package (service SVC1, version 12)'" dir } + /// `edgezero ` in `dir`, with every variable that could change a + /// result removed: the Fastly credentials, and the `EDGEZERO__*` and + /// `DEMO_APP__*` overlays that `provision`, `config validate` and + /// `config gc` read. + fn command(dir: &Path, args: &[&str]) -> Command { + let mut command = Command::new(env!("CARGO_BIN_EXE_edgezero")); + command + .args(args) + .current_dir(dir) + .env("EDGEZERO_MANIFEST", dir.join("edgezero.toml")) + .env_remove("FASTLY_API_TOKEN") + .env_remove("FASTLY_SERVICE_ID"); + for (key, _) in env::vars_os() { + let name = key.to_string_lossy(); + if name.starts_with("EDGEZERO__") || name.starts_with("DEMO_APP__") { + command.env_remove(&key); + } + } + command + } + + /// Put `bin_dir` first on the command's `PATH`. + fn prepend_path(command: &mut Command, bin_dir: &Path) { + let path = env::var_os("PATH").unwrap_or_default(); + let mut paths = vec![bin_dir.to_path_buf()]; + paths.extend(env::split_paths(&path)); + command.env("PATH", env::join_paths(paths).expect("join PATH")); + } + /// Run `edgezero ` in `dir`, optionally with `bin_dir` first on `PATH`. fn edgezero(dir: &Path, args: &[&str], bin_dir: Option<&Path>) -> Output { edgezero_with_token(dir, args, bin_dir, None) @@ -116,21 +185,12 @@ deploy = "echo 'SUCCESS: Deployed package (service SVC1, version 12)'" bin_dir: Option<&Path>, token: Option<&str>, ) -> Output { - let mut command = Command::new(env!("CARGO_BIN_EXE_edgezero")); - command - .args(args) - .current_dir(dir) - .env("EDGEZERO_MANIFEST", dir.join("edgezero.toml")) - .env_remove("FASTLY_API_TOKEN") - .env_remove("FASTLY_SERVICE_ID"); + let mut command = command(dir, args); if let Some(value) = token { command.env("FASTLY_API_TOKEN", value); } if let Some(bin) = bin_dir { - let path = env::var_os("PATH").unwrap_or_default(); - let mut paths = vec![bin.to_path_buf()]; - paths.extend(env::split_paths(&path)); - command.env("PATH", env::join_paths(paths).expect("join PATH")); + prepend_path(&mut command, bin); } command.output().expect("run edgezero") } @@ -179,15 +239,59 @@ deploy = "echo 'SUCCESS: Deployed package (service SVC1, version 12)'" } fn fake_curl_script(body: &str) -> TempDir { + fake_binary("curl", body) + } + + /// A temp dir holding an executable `name` that runs the shell `body`. + fn fake_binary(name: &str, body: &str) -> TempDir { let dir = TempDir::new().expect("temp dir"); - let script = dir.path().join("curl"); - fs::write(&script, format!("#!/bin/sh\n{body}\n")).expect("write curl"); + let script = dir.path().join(name); + fs::write(&script, format!("#!/bin/sh\n{body}\n")).expect("write fake binary"); let mut perms = fs::metadata(&script).expect("meta").permissions(); perms.set_mode(0o755); fs::set_permissions(&script, perms).expect("chmod +x"); dir } + /// A fake `fastly` for `config gc`: one config store (`app_config`, id + /// `store-1`) holding only a plain `greeting` entry, so there is nothing to + /// reclaim. Any `delete` is logged to `deletes.log` next to the script. + fn fake_fastly_gc() -> TempDir { + fake_binary( + "fastly", + "case \"$1 $2\" in\n\ + \x20 'config-store list') echo '[{\"name\":\"app_config\",\"id\":\"store-1\"}]';;\n\ + \x20 'config-store-entry list') echo '[{\"item_key\":\"greeting\",\"item_value\":\"hello\",\"created_at\":\"2026-07-01T00:00:00Z\"}]';;\n\ + \x20 'config-store-entry delete') echo \"$*\" >> \"$(dirname \"$0\")/deletes.log\";;\n\ + esac", + ) + } + + /// A fake `fastly` whose `compute update` makes draft version 8. + fn fake_fastly_staging() -> TempDir { + fake_binary( + "fastly", + "if [ \"$1 $2\" = 'compute update' ]; then \ + echo 'SUCCESS: Updated package (service SVC1, version 8)'; fi", + ) + } + + /// A project holding `manifest` and an empty `fastly.toml`. + fn fastly_project(manifest: &str) -> TempDir { + let dir = project_with(manifest); + fs::write(dir.path().join("fastly.toml"), "name = \"app\"\n").expect("fastly.toml"); + dir + } + + /// The text stdout of a manifest command: the `[edgezero] executing …` + /// line, then what the command printed (`child_output`). + fn executed(command: &str, adapter: &str, dir: &Path, child_output: &str) -> String { + format!( + "[edgezero] executing `{command}` for adapter `{adapter}` in {}\n{child_output}", + dir.display() + ) + } + /// The Fastly API calls a [`fake_curl_api`] served, in order. fn api_calls(curl: &TempDir) -> Vec { fs::read_to_string(curl.path().join("api.log")) @@ -227,17 +331,16 @@ deploy = "echo 'SUCCESS: Deployed package (service SVC1, version 12)'" let dir = project(); let output = edgezero(dir.path(), &["build", "--adapter", "axum"], None); assert!(output.status.success(), "stderr: {}", stderr_of(&output)); - let stdout = stdout_of(&output); - assert!( - stdout.starts_with( - "[edgezero] executing `echo child-wrote-to-stdout` for adapter `axum` in " - ), - "stdout: {stdout:?}" - ); - assert!( - stdout.ends_with("\nchild-wrote-to-stdout\n"), - "stdout: {stdout:?}" + assert_eq!( + stdout_of(&output), + executed( + "echo child-wrote-to-stdout", + "axum", + dir.path(), + "child-wrote-to-stdout\n" + ) ); + assert!(output.stderr.is_empty(), "stderr: {}", stderr_of(&output)); } #[test] @@ -412,6 +515,30 @@ deploy = "echo 'SUCCESS: Deployed package (service SVC1, version 12)'" ); } + #[test] + fn build_rejects_every_passthrough_format_spelling() { + let dir = project(); + for args in [ + &["build", "--adapter", "axum", "--release", "--format=json"][..], + // clap drops a leading `--`, so this cannot be told apart from a + // misplaced flag: `build` never forwards `--format`. + &["build", "--adapter", "axum", "--", "--format", "json"][..], + ] { + let output = edgezero(dir.path(), args, None); + assert_eq!(output.status.code(), Some(1_i32), "{args:?}"); + assert!( + output.stdout.is_empty(), + "{args:?}: {:?}", + stdout_of(&output) + ); + assert!( + stderr_of(&output).contains("after a passthrough argument"), + "{args:?}: {}", + stderr_of(&output) + ); + } + } + #[test] fn json_config_validate_failure_has_no_result() { // No `demo-app.toml`: the raw validator requires it. @@ -517,11 +644,7 @@ deploy = "echo 'SUCCESS: Deployed package (service SVC1, version 12)'" let curl = fake_curl_api(200); let text = edgezero_with_token(dir.path(), &ROLLBACK, Some(curl.path()), Some("t")); assert!(text.status.success(), "stderr: {}", stderr_of(&text)); - assert!( - stdout_of(&text).ends_with("rolled-back-to=6\n"), - "stdout: {:?}", - stdout_of(&text) - ); + assert_eq!(stdout_of(&text), "rolled-back-to=6\n"); let mut args = ROLLBACK.to_vec(); args.extend(["--format", "json"]); @@ -542,14 +665,17 @@ deploy = "echo 'SUCCESS: Deployed package (service SVC1, version 12)'" let dir = project_with(FASTLY_MANIFEST); let text = edgezero(dir.path(), &DEPLOY, None); assert!(text.status.success(), "stderr: {}", stderr_of(&text)); - let stdout = stdout_of(&text); - // The manifest command receives `--service-id SVC1` as passthrough. - assert!( - stdout.ends_with( + // The manifest command receives `--service-id SVC1` as passthrough. The + // tee echoes the child live, then the CLI prints the version line. + assert_eq!( + stdout_of(&text), + executed( + "echo 'SUCCESS: Deployed package (service SVC1, version 12)'", + "fastly", + dir.path(), "SUCCESS: Deployed package (service SVC1, version 12) --service-id SVC1\n\ version=12\n" - ), - "the tee echoes the child live, then the version line: {stdout:?}" + ) ); let mut args = DEPLOY.to_vec(); @@ -593,4 +719,282 @@ deploy = "echo 'SUCCESS: Deployed package (service SVC1, version 12)'" .expect("error message"); assert!(message.starts_with("deploy succeeded but"), "{message}"); } + + #[test] + fn auth_status_text_failure_is_unchanged() { + let dir = project(); + let output = edgezero(dir.path(), &["auth", "status", "--adapter", "axum"], None); + assert_eq!(output.status.code(), Some(1_i32)); + assert_eq!( + stdout_of(&output), + executed( + "echo child-wrote-to-stdout; false", + "axum", + dir.path(), + "child-wrote-to-stdout\n" + ) + ); + assert_eq!( + stderr_of(&output), + "[edgezero] auth status command `echo child-wrote-to-stdout; false` exited with \ + status exit status: 1\n" + ); + } + + #[test] + fn auth_status_text_and_json_when_authenticated() { + let dir = project_with(AUTH_MANIFEST); + let text = edgezero(dir.path(), &["auth", "status", "--adapter", "axum"], None); + assert!(text.status.success(), "stderr: {}", stderr_of(&text)); + assert_eq!( + stdout_of(&text), + executed("echo logged-in", "axum", dir.path(), "logged-in\n") + ); + + let output = edgezero( + dir.path(), + &["auth", "status", "--adapter", "axum", "--format", "json"], + None, + ); + assert!(output.status.success(), "stderr: {}", stderr_of(&output)); + assert_eq!( + sole_json_document(&output), + json!({ + "command": "auth status", + "error": null, + "ok": true, + "result": {"adapter": "axum", "state": "authenticated"}, + "schema_version": 1_i32 + }) + ); + assert!(stderr_of(&output).contains("logged-in\n")); + } + + #[test] + fn auth_status_without_the_native_cli_has_no_result() { + let dir = project_with(AUTH_MANIFEST); + let empty_path = TempDir::new().expect("temp dir"); + let output = command( + dir.path(), + &[ + "auth", + "status", + "--adapter", + "cloudflare", + "--format", + "json", + ], + ) + .env("PATH", empty_path.path()) + .output() + .expect("run edgezero"); + assert_eq!(output.status.code(), Some(1_i32)); + let envelope = sole_json_document(&output); + assert_eq!(envelope["ok"], json!(false)); + assert!(envelope["result"].is_null(), "{envelope}"); + let message = envelope["error"]["message"] + .as_str() + .expect("error message"); + assert!( + message.contains("`wrangler` not found on PATH"), + "{message}" + ); + } + + #[test] + fn provision_text_is_unchanged() { + let dir = project(); + let output = edgezero(dir.path(), &["provision", "--adapter", "axum"], None); + assert!(output.status.success(), "stderr: {}", stderr_of(&output)); + assert_eq!( + stdout_of(&output), + "axum KV store `sessions` is in-memory; nothing to provision\n" + ); + assert!(output.stderr.is_empty(), "stderr: {}", stderr_of(&output)); + } + + #[test] + fn config_validate_text_is_unchanged() { + let dir = project(); + // No `demo-app.toml` yet: the failure goes to stderr only. + let failed = edgezero(dir.path(), &["config", "validate"], None); + assert_eq!(failed.status.code(), Some(1_i32)); + assert!(failed.stdout.is_empty(), "stdout: {:?}", stdout_of(&failed)); + assert_eq!( + stderr_of(&failed), + "[edgezero] failed to read demo-app.toml: No such file or directory (os error 2)\n" + ); + + fs::write(dir.path().join("demo-app.toml"), "").expect("write app config"); + let output = edgezero(dir.path(), &["config", "validate"], None); + assert!(output.status.success(), "stderr: {}", stderr_of(&output)); + assert_eq!( + stdout_of(&output), + "[edgezero] config validate (raw): edgezero.toml OK\n" + ); + } + + /// The `config gc` report lines for the [`fake_fastly_gc`] store, with the + /// `older_than` window `secs`. + fn gc_report_lines(secs: u64) -> String { + format!( + "[edgezero] fastly config-store `app_config` (id=store-1): 1 entries, 1 root(s), 0 \ + referenced chunk(s), 0 orphan(s) in 0 generation(s) older than {secs}s, 0 orphan(s) \ + too recent\n\ + [edgezero] keeping 1 retained root(s) (0 referenced chunk(s) held by them):\n\ + [edgezero] keeping `greeting`\n\ + [edgezero] nothing to reclaim\n" + ) + } + + #[test] + fn config_gc_dry_run_text_and_json() { + let dir = fastly_project(FASTLY_GC_MANIFEST); + let fastly = fake_fastly_gc(); + let text = edgezero( + dir.path(), + &["config", "gc", "--adapter", "fastly"], + Some(fastly.path()), + ); + assert!(text.status.success(), "stderr: {}", stderr_of(&text)); + let stdout = stdout_of(&text); + assert!( + stdout.starts_with(&gc_report_lines(0)), + "the report, then the dry-run advisory: {stdout:?}" + ); + let advisory = stdout.strip_prefix(&gc_report_lines(0)).unwrap_or_default(); + assert!( + advisory.starts_with("[edgezero] dry-run: previewing ALL orphans") + && advisory.ends_with("(a bare `--yes` is rejected).\n") + && advisory.lines().count() == 1, + "one advisory line: {advisory:?}" + ); + + // No `--yes`: a dry run by default, with no `--older-than` window. + let output = edgezero( + dir.path(), + &["config", "gc", "--adapter", "fastly", "--format", "json"], + Some(fastly.path()), + ); + assert!(output.status.success(), "stderr: {}", stderr_of(&output)); + assert_eq!( + sole_json_document(&output)["result"], + json!({ + "adapter": "fastly", "deleted": null, "dry_run": true, "failed": [], + "kept_roots": ["greeting"], "older_than_secs": null, "planned_deletions": [], + "store": {"id": "store-1", "logical": "app_config", "platform": "app_config"}, + "stranded": [], + "summary": { + "entries": 1_i32, "generations_planned": 0_i32, "orphans_planned": 0_i32, + "orphans_too_recent": 0_i32, "referenced_chunks": 0_i32, "roots": 1_i32, + "unprovable": 0_i32 + }, + "uncertain": [], "warnings": [] + }) + ); + assert!(stderr_of(&output).contains(&gc_report_lines(0))); + assert!( + !fastly.path().join("deletes.log").exists(), + "nothing deleted" + ); + } + + #[test] + fn config_gc_real_run_with_nothing_to_reclaim_reports_zero_deleted() { + let dir = fastly_project(FASTLY_GC_MANIFEST); + let fastly = fake_fastly_gc(); + let args = [ + "config", + "gc", + "--adapter", + "fastly", + "--yes", + "--older-than", + "1h", + ]; + let text = edgezero(dir.path(), &args, Some(fastly.path())); + assert!(text.status.success(), "stderr: {}", stderr_of(&text)); + assert_eq!(stdout_of(&text), gc_report_lines(3600)); + + let mut json_args = args.to_vec(); + json_args.extend(["--format", "json"]); + let output = edgezero(dir.path(), &json_args, Some(fastly.path())); + assert!(output.status.success(), "stderr: {}", stderr_of(&output)); + let result = &sole_json_document(&output)["result"]; + assert_eq!(result["dry_run"], json!(false)); + assert_eq!(result["deleted"], json!(0_i32), "a real run, not a dry run"); + assert_eq!(result["older_than_secs"], json!(3600_i32)); + assert_eq!(stderr_of(&output), gc_report_lines(3600)); + assert!( + !fastly.path().join("deletes.log").exists(), + "nothing deleted" + ); + } + + #[test] + fn staged_deploy_text_and_json_resolve_the_service_from_the_env() { + let dir = fastly_project(FASTLY_STAGING_MANIFEST); + let fastly = fake_fastly_staging(); + // No `--service-id`: the adapter falls back to `FASTLY_SERVICE_ID`. + let run = |format: &[&str]| { + let mut args = vec!["deploy", "--adapter", "fastly", "--staging"]; + args.extend(format); + let mut command = command(dir.path(), &args); + prepend_path(&mut command, fastly.path()); + command + .env("FASTLY_API_TOKEN", "t") + .env("FASTLY_SERVICE_ID", "SVC1") + .output() + .expect("run edgezero") + }; + let lines = "app declares no config stores, so staged version 8 has no config selector \ + to isolate; keeping the inherited runtime-env link\n\ + version=8\n"; + + let text = run(&[]); + assert!(text.status.success(), "stderr: {}", stderr_of(&text)); + assert_eq!(stdout_of(&text), lines); + + let output = run(&["--format", "json"]); + assert!(output.status.success(), "stderr: {}", stderr_of(&output)); + assert_eq!( + sole_json_document(&output)["result"], + json!({"adapter": "fastly", "service_id": "SVC1", "staging": true, "version": 8_i32}) + ); + assert_eq!(stderr_of(&output), lines); + } + + #[test] + fn staged_rollback_text_and_json() { + let dir = project(); + let curl = fake_curl_api(200); + let args = [ + "rollback", + "--adapter", + "fastly", + "--service-id", + "SVC1", + "--version", + "8", + "--staging", + ]; + let line = "[edgezero] deactivated staged version 8 on Fastly service SVC1\n"; + let text = edgezero_with_token(dir.path(), &args, Some(curl.path()), Some("t")); + assert!(text.status.success(), "stderr: {}", stderr_of(&text)); + assert_eq!(stdout_of(&text), line); + assert_eq!(api_calls(&curl), ["PUT"]); + + let mut json_args = args.to_vec(); + json_args.extend(["--format", "json"]); + let output = edgezero_with_token(dir.path(), &json_args, Some(curl.path()), Some("t")); + assert!(output.status.success(), "stderr: {}", stderr_of(&output)); + assert_eq!( + sole_json_document(&output)["result"], + json!({ + "adapter": "fastly", "rolled_back_to": null, "service_id": "SVC1", + "staging": true, "version": 8_i32 + }) + ); + assert_eq!(stderr_of(&output), line); + } } diff --git a/docs/guide/cli-reference.md b/docs/guide/cli-reference.md index 1e60a1d29..ff8a151d9 100644 --- a/docs/guide/cli-reference.md +++ b/docs/guide/cli-reference.md @@ -671,11 +671,12 @@ With `--format json`: An **empty stdout** with a non-zero exit means no envelope was produced. That happens on command-line usage errors (clap reports them before `--format` is -read), on the bundled binary's `config push` / `config diff` stubs, and on a -crash. Treat it as a failure. +read), on the bundled binary's `config push` / `config diff` stubs, on a +`build` that rejects a `--format` among its passthrough arguments (exit `1`, +see [`build`](#results)), and on a crash. Treat it as a failure. -`--help` and `--version` are handled by clap before `--format` is read: they -print clap's text to stdout and exit `0`, with no envelope. +`--help` is handled by clap before `--format` is read: it prints clap's text to +stdout and exits `0`, with no envelope. The routing relies on the EdgeZero logger (`edgezero_cli::init_cli_logger()`). A downstream CLI that installs a different logger, such as `simple_logger`, @@ -715,6 +716,8 @@ failure (`1` for the bundled `edgezero` binary, `2` for a CLI generated from the template). Branch on `ok` rather than on the specific non-zero code. In a generated CLI, `2` also means a usage error or an unsupported `config diff`; only the envelope tells them apart, and an empty stdout means there is none. +In the bundled binary, a `1` with an empty stdout usually means a `build` that +rejected a late `--format`. ### Compatibility @@ -748,6 +751,10 @@ Every result except `config validate`'s also has a non-null `adapter` string. | ------- | ------ | -------- | --------------------------------------------------------------- | | `state` | string | no | `authenticated`, `unauthenticated`, or `not_applicable` (axum). | +`state` reflects the probe command's exit status only (the probes are listed +under [Adapter built-ins](#edgezero-auth)): `authenticated` means the probe +exited `0`, not that a token was checked. Some `wrangler whoami` versions exit +`0` when logged out, and `fastly profile list` only lists saved profiles. `unauthenticated` gives `ok: false` with the result present. A missing native CLI gives `result: null`. @@ -757,8 +764,11 @@ CLI gives `result: null`. | ---------- | ------ | -------- | -------------------------------------------------------------------------------------------------------------------- | | `artifact` | string | yes | The built file's path; `null` when a manifest `commands.build` override ran or the adapter does not track it (axum). | -`--format` must come before the first passthrough argument. A `--format` after -one is rejected rather than forwarded to the build command. +`--format` must come before the first passthrough argument. A `--format` (or +`--format=…`) among the passthrough arguments is rejected with exit `1` rather +than forwarded to the build command; stdout is empty unless `--format json` +also came before the passthrough arguments. This includes +`build --adapter -- --format json`: `build` cannot forward a `--format`. **`deploy`** @@ -768,6 +778,9 @@ one is rejected rather than forwarded to the build command. | `staging` | boolean | no | Whether `--staging` was passed. | | `version` | integer | yes | The staged version for `--staging`, the activated version for a Fastly production deploy with `--service-id`, and `null` otherwise. | +A production deploy reads only `--service-id`, so a Fastly production deploy +that relies on `FASTLY_SERVICE_ID` reports `service_id: null`. + A Fastly production deploy that went live but whose version could not be resolved gives `ok: false` with the result present and `version: null`. Check `result`, not only `ok`, before assuming nothing was deployed. diff --git a/docs/superpowers/plans/2026-10-05-pr-388-review-fixes.md b/docs/superpowers/plans/2026-10-05-pr-388-review-fixes.md deleted file mode 100644 index 75b5e12d6..000000000 --- a/docs/superpowers/plans/2026-10-05-pr-388-review-fixes.md +++ /dev/null @@ -1,1507 +0,0 @@ -# PR #388 Review Fixes Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -> **Status: retroactive record.** This plan records changes that were already -> applied to the working tree of `spec/383-cli-format-json` on 2026-10-05, in -> response to aram356's "changes requested" review of -> [#388](https://github.com/stackpop/edgezero/pull/388). Every step is checked -> because it has been done and verified. The commit steps describe how to split -> the working tree into reviewable commits; none has been made yet. - -**Goal:** Fix the `--format json` results that contradicted the documented -schema before schema version 1 ships, and close the review's lint, docs and test -gaps. - -**Architecture:** Adapters return typed outcomes (`edgezero-adapter/src/registry.rs`), -the CLI maps them onto wire structs (`edgezero-cli/src/output.rs`), and -`output::finish` writes the envelope. The fixes correct the outcome producers -(Fastly, Spin adapters), the CLI mapping (`lib.rs`, `config.rs`), and the -outcome types themselves, so that each fact is stored once. - -**Tech Stack:** Rust 1.95.0 (edition 2024), clap, serde/serde_json, clippy -`disallowed-methods`, VitePress docs with Prettier. - -**Spec:** `docs/superpowers/specs/2026-09-25-cli-format-json-design.md` -(§6 output contract, §8 per-command schemas, §13 "Revision 3" lists these -changes). - -## Global Constraints - -- Run every cargo command with `cargo +1.95.0` (the `.tool-versions` pin). The - default toolchain on this machine reports false clippy failures. -- `--format text` output must stay byte-identical, except that `build` now - rejects a `--format` after a passthrough argument. -- No `http` crate imports, no Tokio in core or adapter crates. -- Tests must not need a network or platform credentials. Fake `curl` / `fastly` - binaries go on `PATH`. -- No `Co-Authored-By` trailers or AI bylines in commits. -- CI gates: `cargo fmt --all -- --check`; - `cargo clippy --workspace --all-targets --all-features -- -D warnings`; - `cargo test --workspace --all-targets`; - `cargo test -p edgezero-adapter-fastly --all-targets --features cli`; - `cargo check --workspace --all-targets --features "fastly cloudflare spin"`; - `cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin`; - the wasm clippy matrix in `.github/workflows/format.yml`; - `cargo test -p edgezero-cli --test generated_project_builds -- --ignored`; - `npm run lint` and `npm run format` in `docs/`. - -## Review item → task map - -| Review comment | Severity | Task | -| ------------------------------------------------------ | -------- | ---- | -| `version_verified: true` on an unhealthy probe | blocking | 2 | -| `build` swallows a trailing `--format json` | blocking | 4 | -| `config gc --yes` with nothing to reclaim → `null` | blocking | 3 | -| Spec approval link | question | (author answers on the PR; no code) | -| Raw `config validate` reports `app_config: null` | 🤔 | 5 | -| Provision dry-run / real-run actions don't pair | 🤔 | 7, 11 | -| Deploy went live, version unknown → `result: null` | 🤔 | 6 | -| `deploy.service_id` reflects only the flag | 🤔 | 6 | -| Outcome types can hold contradictory values | ♻️ | 1 | -| `AuthSub::Status`, `OutputFormat ==`, bare block, spec `--require-active` | ⛏ | 8, 11 | -| Docs: nullability, exit code 2, `--help`, logger | 🤔 | 11 | -| Test gaps | 🤔 | 2, 3, 4, 5, 6, 10 | -| Lint gaps (`io::stdout`, template `clippy.toml`) | 🌱 | 9 | -| Closed stderr loses the envelope | 🌱 | 9 | -| `wrangler whoami` exit code | out of scope | not addressed | - -## File map - -| File | Responsibility | Tasks | -| ---- | -------------- | ----- | -| `crates/edgezero-adapter/src/registry.rs` | Typed outcomes adapters return | 1, 6 | -| `crates/edgezero-adapter/src/cli_support.rs` | `native_auth_status`, `run_native_cli` | 1 | -| `crates/edgezero-adapter-axum/src/cli.rs` | axum `AuthStatus` outcome | 1 | -| `crates/edgezero-adapter-fastly/src/cli.rs` | Fastly healthcheck, gc, deploy, provision | 1, 2, 3, 6, 8 | -| `crates/edgezero-adapter-spin/src/cli.rs` | Spin provision | 7 | -| `crates/edgezero-cli/src/adapter.rs` | Manifest-command dispatch, tee | 1, 9 | -| `crates/edgezero-cli/src/auth.rs` | `auth status` mapping | 1 | -| `crates/edgezero-cli/src/output.rs` | Wire schema, `OutputScope`, `finish` | 1, 3, 5, 8, 9 | -| `crates/edgezero-cli/src/lib.rs` | `build`, `deploy`, healthcheck, logger | 1, 4, 6, 9 | -| `crates/edgezero-cli/src/config.rs` | `config validate`, `config gc` | 1, 5, 9 | -| `crates/edgezero-cli/src/args.rs` | clap argument types | 8 | -| `crates/edgezero-cli/tests/format_json.rs` | End-to-end stream tests | 4, 5, 6, 10 | -| `clippy.toml`, `crates/edgezero-cli/src/templates/root/clippy.toml.hbs` | stdout lint guard | 9 | -| `docs/guide/cli-reference.md`, the spec | Public contract | 11 | - ---- - -### Task 1: Store each outcome fact once - -The review found `HealthcheckOutcome { failure, healthy }`, -`AuthStatusOutcome { failure, state }` and -`GcReport { failed, failure_diagnostic, stranded, uncertain }` each record one -fact twice, kept in sync only by doc comments. This task makes the invalid -combinations unrepresentable. It is a pure refactor: JSON and text output do -not change, so the existing tests are the safety net. It goes first because -Tasks 2 and 3 build on the new shapes. - -**Files:** -- Modify: `crates/edgezero-adapter/src/registry.rs` (`AuthStatusOutcome`, `AuthState`, `HealthcheckOutcome`, `GcReport`, new `GcFailure`) -- Modify: `crates/edgezero-adapter/src/cli_support.rs` (`run_native_cli`, `native_auth_status`, its test) -- Modify: `crates/edgezero-adapter-axum/src/cli.rs` (`AuthStatus` arm) -- Modify: `crates/edgezero-adapter-fastly/src/cli.rs` (`healthcheck`, `gc_report_for`, `gc_fastly_config_store`, tests) -- Modify: `crates/edgezero-cli/src/adapter.rs` (`AuthStatus` arm of the manifest dispatch) -- Modify: `crates/edgezero-cli/src/auth.rs` (`auth_status`) -- Modify: `crates/edgezero-cli/src/output.rs` (`AuthStatusResult::new`, `HealthcheckResult::new`, `GcResult::new`, tests) -- Modify: `crates/edgezero-cli/src/config.rs` (`config_gc` failure mapping) -- Modify: `crates/edgezero-cli/src/lib.rs` (`healthy=` line) - -**Interfaces:** -- Produces: - - `pub struct AuthStatusOutcome { pub state: AuthState }` - - `pub enum AuthState { Authenticated, NotApplicable, Unauthenticated { reason: String } }` (no longer `Copy`) - - `HealthcheckOutcome` loses `healthy: bool`; gains `pub const fn healthy(&self) -> bool` - - `GcReport { ..., failure: Option, ... }` replaces `failed`, `failure_diagnostic`, `stranded`, `uncertain` - - `pub struct GcFailure { pub diagnostic: String, pub failed: Vec, pub stranded: Vec, pub uncertain: Vec }` (`Clone, Debug, Default, Eq, PartialEq`) - - `AuthStatusResult::new(adapter: &str, state: &AuthState) -> Self` - -- [x] **Step 1: Change the types in `registry.rs`** - -```rust -/// Result of [`AdapterAction::AuthStatus`]. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct AuthStatusOutcome { - pub state: AuthState, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub enum AuthState { - Authenticated, - /// The adapter has no remote auth surface (axum). - NotApplicable, - Unauthenticated { - /// Why the session is not authenticated. - reason: String, - }, -} -``` - -In `HealthcheckOutcome`, delete `pub healthy: bool`, reword the `failure` doc -to `/// \`None\` when healthy; why the probe is unhealthy otherwise.`, and add: - -```rust -impl HealthcheckOutcome { - /// Whether the probe came back healthy. - #[inline] - #[must_use] - pub const fn healthy(&self) -> bool { - self.failure.is_none() - } -} -``` - -In `GcReport`, replace `failed`, `failure_diagnostic`, `stranded` and -`uncertain` with `/// \`None\` unless a delete failed.\npub failure: Option,` -and add: - -```rust -/// The deletes a `config gc` run could not complete. -#[derive(Clone, Debug, Default, Eq, PartialEq)] -pub struct GcFailure { - /// The operator-facing report and recovery text. - pub diagnostic: String, - /// Keys whose delete failed (never empty). - pub failed: Vec, - /// Keys left in a generation that lost a sibling to a confirmed delete. - pub stranded: Vec, - /// Keys whose generation's only failure has an unknown outcome. - pub uncertain: Vec, -} -``` - -- [x] **Step 2: Update the auth producers** - -`cli_support.rs`: - -```rust -pub fn run_native_cli(program: &str, args: &[&str], install_hint: &str) -> Result<(), String> { - match native_auth_status(program, args, install_hint)?.state { - AuthState::Unauthenticated { reason } => Err(reason), - AuthState::Authenticated | AuthState::NotApplicable => Ok(()), - } -} -// in native_auth_status: - Ok(if status.success() { - AuthStatusOutcome { - state: AuthState::Authenticated, - } - } else { - AuthStatusOutcome { - state: AuthState::Unauthenticated { - reason: format!("`{program} {}` exited with status {status}", args.join(" ")), - }, - } - }) -``` - -axum: drop `failure: None,` from its `AuthStatusOutcome`. `edgezero-cli/src/adapter.rs`: - -```rust - (Action::AuthStatus, failure) => Ok(ActionOutcome::AuthStatus(AuthStatusOutcome { - state: failure.map_or(AuthState::Authenticated, |reason| { - AuthState::Unauthenticated { reason } - }), - })), -``` - -- [x] **Step 3: Update the auth consumers** - -`auth.rs`: - -```rust - let result = AuthStatusResult::new(adapter_name, &status.state); - match status.state { - AuthState::Unauthenticated { reason } => Err(Failure::with_result(reason, result)), - AuthState::Authenticated | AuthState::NotApplicable => Ok(result), - } -``` - -`output.rs`: `AuthStatusResult::new` takes `state: &AuthState` and matches -`AuthState::Unauthenticated { .. } => WireAuthState::Unauthenticated`. - -- [x] **Step 4: Update the healthcheck and gc consumers** - -- `output.rs` `HealthcheckResult::new`: `healthy: outcome.healthy(),` -- `lib.rs` healthcheck: `log::info!("healthy={}", outcome.healthy());` -- `output.rs` `GcResult::new`: start with - `let failure = report.failure.clone().unwrap_or_default();` and fill - `failed: failure.failed`, `stranded: failure.stranded`, - `uncertain: failure.uncertain`. -- `config.rs` `config_gc`: - -```rust - if let Some(failure) = report.failure { - return Err(Failure::with_result(failure.diagnostic, result)); - } -``` - -- Fastly `gc_fastly_config_store` failure branch: - -```rust - report.failure = Some(GcFailure { - diagnostic, - failed, - stranded, - uncertain, - }); - report.text_lines = out; - Ok(report) -``` - - `gc_report_for` drops the four old fields and sets `failure: None`. Add - `GcFailure` to the Fastly `use edgezero_adapter::registry::{…}` list. - -- [x] **Step 5: Update the tests that named the old fields** - -- `cli_support.rs` `native_auth_status_maps_exit_status_to_state`: - -```rust - let unauthenticated = native_auth_status("false", &[], "hint").expect("spawns"); - let AuthState::Unauthenticated { reason } = unauthenticated.state else { - panic!("expected Unauthenticated, got {:?}", unauthenticated.state); - }; - assert!(reason.contains("exited with status"), "got: {reason}"); -``` - -- Fastly `healthcheck_reports_a_structured_outcome`: drop `healthy: true,` from - the expected struct; `assert!(!unhealthy.healthy());`. -- Fastly test helper `run_gc`: `match report.failure { Some(failure) => Err(failure.diagnostic), None => Ok(report.text_lines) }`. -- `output.rs`: drop `healthy: false,` from `failure_envelope_keeps_the_partial_result`; - `AuthStatusResult::new("axum", &AuthState::NotApplicable)`. - -- [x] **Step 6: Verify** - -Run: `cargo +1.95.0 test --workspace --all-targets && cargo +1.95.0 test -p edgezero-adapter-fastly --features cli` -Expected: all pass, with no expectation changes beyond the field renames above. - -- [ ] **Step 7: Commit** - -```bash -git add crates/edgezero-adapter/src/registry.rs crates/edgezero-adapter/src/cli_support.rs \ - crates/edgezero-adapter-axum/src/cli.rs crates/edgezero-cli/src/adapter.rs \ - crates/edgezero-cli/src/auth.rs crates/edgezero-cli/src/output.rs \ - crates/edgezero-cli/src/config.rs crates/edgezero-cli/src/lib.rs -git add -p crates/edgezero-adapter-fastly/src/cli.rs # only the Task 1 hunks -git commit -m "Store each adapter outcome fact once" -``` - ---- - -### Task 2: `version_verified` only when both checks ran (blocking) - -The "after probing" `verify_version_active` call runs only in the healthy arm, -but the field was `production_token.is_some()`. An unhealthy production probe -with `FASTLY_API_TOKEN` set reported `version_verified: true`. - -**Files:** -- Modify: `crates/edgezero-adapter-fastly/src/cli.rs` (`healthcheck`, its doc comment, a new test after `healthcheck_reports_a_structured_outcome`) -- Test: `crates/edgezero-cli/tests/format_json.rs` (Task 10 adds the end-to-end pair) - -**Interfaces:** -- Consumes: `HealthcheckOutcome::healthy()` (Task 1) -- Produces: `HealthcheckOutcome::version_verified` is `true` only for a healthy production probe with a token. - -- [x] **Step 1: Write the failing test** - -```rust - /// `version_verified` claims the version was ACTIVE before AND after the - /// probe, so it is true only when both API checks actually ran: an - /// unhealthy probe skips the "after" check and must report `false`. - #[cfg(unix)] - #[test] - fn healthcheck_version_verified_only_when_both_checks_ran() { - use std::os::unix::fs::PermissionsExt as _; - let _lock = path_mutation_guard().lock().expect("guard"); - let _token = EnvOverride::set(FASTLY_API_TOKEN_ENV, "test-token"); - let args: Vec = [ - "--domain", "app.example.com", "--service-id", "SVC1", "--version", "7", - "--retry", "1", "--retry-delay", "0", - ] - .into_iter() - .map(str::to_owned) - .collect(); - // A fake `curl` serving the versions API (`--config -`, version 7 - // active) and answering health probes with `code`; each API call is - // logged so the test can count the checks. - let fake_curl_with_api = |code: u16| { - let dir = tempdir().expect("tempdir"); - let log = dir.path().join("api.log"); - let script = format!( - "#!/bin/sh\n\ - case \" $* \" in *\" --config \"*) cat >/dev/null; echo get >> '{log}'; \ - printf '%s\\n200' '[{{\"number\":7,\"active\":true}}]'; exit 0;; esac\n\ - echo {code}\n", - log = log.display() - ); - let script_path = dir.path().join("curl"); - fs::write(&script_path, script).expect("write script"); - let mut perms = fs::metadata(&script_path).expect("meta").permissions(); - perms.set_mode(0o755); - fs::set_permissions(&script_path, perms).expect("chmod +x"); - (dir, log) - }; - let api_calls = - |log: &Path| fs::read_to_string(log).unwrap_or_default().lines().count(); - - let (unhealthy_curl, unhealthy_log) = fake_curl_with_api(503); - let unhealthy_path = PathPrepend::new(unhealthy_curl.path()); - let unhealthy = healthcheck(&args).expect("an unhealthy probe is an outcome"); - assert!(!unhealthy.healthy()); - assert!( - !unhealthy.version_verified, - "the after-probe check never ran on an unhealthy probe" - ); - assert_eq!(api_calls(&unhealthy_log), 1, "only the before-probe check"); - drop(unhealthy_path); - - let (healthy_curl, healthy_log) = fake_curl_with_api(200); - let _path = PathPrepend::new(healthy_curl.path()); - let healthy = healthcheck(&args).expect("probe runs"); - assert!(healthy.healthy()); - assert!(healthy.version_verified, "both checks ran and passed"); - assert_eq!(api_calls(&healthy_log), 2, "before and after the probe"); - } -``` - -- [x] **Step 2: Run it to verify it fails** - -Run: `cargo +1.95.0 test -p edgezero-adapter-fastly --features cli -- healthcheck_version_verified` -Expected: FAIL at `!unhealthy.version_verified` (verified against the old code). - -- [x] **Step 3: Implement** - -```rust - // `version_verified` means BOTH checks ran, so it is true only on the - // healthy arm, the one that runs the "after probing" check. - let (version_verified, status_code, failure) = match outcome { - Ok(code) => { - if let Some(token) = production_token.as_deref() { - verify_version_active(&service_id, version, token, "after probing")?; - } - (production_token.is_some(), Some(code), None) - } - Err((last_code, msg)) => ( - false, - last_code, - Some(format!( - "healthcheck for {domain} failed after {} attempt(s): {msg}", - retry.max(1) - )), - ), - }; -``` - -and `version_verified,` in the returned struct. Fix the stale function doc: - -```rust -/// to `--retry` times. An unhealthy probe is still `Ok`, with `failure` -/// set; the CLI emits `status-code` / `healthy` and turns that into a -/// non-zero exit. -``` - -- [x] **Step 4: Run it to verify it passes** - -Run: `cargo +1.95.0 test -p edgezero-adapter-fastly --features cli -- healthcheck` -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add -p crates/edgezero-adapter-fastly/src/cli.rs # Task 2 hunks -git commit -m "Report version_verified only when both active checks ran" -``` - ---- - -### Task 3: `config gc --yes` with nothing to reclaim reports `deleted: 0` (blocking) - -`gc_report_for` started every report at `deleted: None`, and the -`doomed_count == 0` early return came before `report.deleted = Some(deleted)`. -A real run against a store without orphans reported `deleted: null`, which the -docs define as a dry run. - -**Files:** -- Modify: `crates/edgezero-adapter-fastly/src/cli.rs` (`gc_report_for`, its call site, gc test helpers, two new tests) -- Modify: `crates/edgezero-cli/src/output.rs` (new `gc_failure_fills_the_partial_result` test) - -**Interfaces:** -- Consumes: `GcReport::failure`, `GcFailure` (Task 1) -- Produces: `fn gc_report_for(plan: &GcPlan, entries: usize, store_id: &str, dry_run: bool) -> GcReport`; test helper `fn gc_report(dir: &Path, older_than_secs: u64, dry_run: bool) -> Result` - -- [x] **Step 1: Split the test helper** - -```rust - #[cfg(unix)] - fn gc_report(dir: &Path, older_than_secs: u64, dry_run: bool) -> Result { - FastlyCliAdapter.gc_config_entries( - dir, - None, - None, - &ResolvedStoreId::from_logical(TEST_CONFIG_ID), - &AdapterPushContext::new(), - older_than_secs, - dry_run, - ) - } - - #[cfg(unix)] - fn run_gc(dir: &Path, older_than_secs: u64, dry_run: bool) -> Result, String> { - // Mirror the CLI: a report carrying a failure is an error. - let report = gc_report(dir, older_than_secs, dry_run)?; - match report.failure { - Some(failure) => Err(failure.diagnostic), - None => Ok(report.text_lines), - } - } -``` - -- [x] **Step 2: Write the failing tests** - -```rust - /// `deleted` is `None` only on a dry run: a real run that finds nothing to - /// reclaim deleted zero entries, and says so. - #[cfg(unix)] - #[test] - fn gc_report_deleted_is_zero_for_a_real_run_with_nothing_to_reclaim() { - let _lock = path_mutation_guard().lock().expect("guard"); - let dir = tempdir().expect("tempdir"); - let oplog = dir.path().join("ops.log"); - let live = gen_envelope("live"); - let mut listing = vec![listed_root(TEST_CONFIG_ID, &live, 172_800)]; - listing.extend(listed_generation(TEST_CONFIG_ID, &live, 172_800)); - let fake = fake_fastly_gc(TEST_CONFIG_ID, &[], &listing, None, false, &oplog); - let _path = PathPrepend::new(fake.path()); - - let real = gc_report(dir.path(), 86_400, false).expect("gc runs"); - assert_eq!(real.deleted, Some(0)); - assert_eq!(real.failure, None); - assert!(real.planned.is_empty()); - - let preview = gc_report(dir.path(), 86_400, true).expect("gc runs"); - assert_eq!(preview.deleted, None, "a dry run deletes nothing at all"); - } - - /// A failed delete is still a report: `failure` names the failed key, and - /// the keys whose outcome is unknown, alongside the recovery text. - #[cfg(unix)] - #[test] - fn gc_report_carries_a_failed_delete() { - let _lock = path_mutation_guard().lock().expect("guard"); - let dir = tempdir().expect("tempdir"); - let oplog = dir.path().join("ops.log"); - let live = gen_envelope("live"); - let dead = gen_envelope("dead"); - let dead_chunks = chunk_keys_of(TEST_CONFIG_ID, &dead); - let mut listing = vec![listed_root(TEST_CONFIG_ID, &live, 172_800)]; - listing.extend(listed_generation(TEST_CONFIG_ID, &live, 172_800)); - listing.extend(listed_generation(TEST_CONFIG_ID, &dead, 604_800)); - let fake = fake_fastly_gc( - TEST_CONFIG_ID, &[], &listing, Some(&dead_chunks[0]), false, &oplog, - ); - let _path = PathPrepend::new(fake.path()); - - let report = gc_report(dir.path(), 86_400, false).expect("a failed delete is a report"); - assert_eq!(report.deleted, Some(0)); - assert_eq!(report.planned.len(), dead_chunks.len()); - let failure = report.failure.expect("the failed delete is reported"); - assert_eq!(failure.failed, vec![dead_chunks[0].clone()]); - assert!(failure.stranded.is_empty(), "nothing was deleted before the failure"); - assert!(!failure.uncertain.is_empty(), "the failed first delete has an unknown outcome"); - assert!(failure.diagnostic.contains("unknown outcome"), "{}", failure.diagnostic); - } -``` - -In `output.rs` tests (add `GcFailure` to the test module's registry import): - -```rust - #[test] - fn gc_failure_fills_the_partial_result() { - let report = GcReport { - deleted: Some(1), - failure: Some(GcFailure { - diagnostic: "config gc: 1 of 3 deletes FAILED".to_owned(), - failed: vec!["c.2".to_owned()], - stranded: vec!["c.3".to_owned()], - uncertain: vec!["d.1".to_owned()], - }), - store_id: Some("7Ab".to_owned()), - ..GcReport::default() - }; - let store = ResolvedStoreId::from_logical("app_config"); - let result = GcResult::new("fastly", &store, false, Some(3_600), &report); - let failed: Outcome = Err(Failure::with_result( - report.failure.expect("failure").diagnostic, - result, - )); - let envelope = envelope_of(CommandName::ConfigGc, &failed); - assert_envelope_invariants(&envelope); - assert_eq!(envelope["ok"], json!(false)); - assert_eq!(envelope["error"], json!({"message": "config gc: 1 of 3 deletes FAILED"})); - let gc = &envelope["result"]; - assert_eq!(gc["deleted"], json!(1)); - assert_eq!(gc["failed"], json!(["c.2"])); - assert_eq!(gc["stranded"], json!(["c.3"])); - assert_eq!(gc["uncertain"], json!(["d.1"])); - assert_eq!(gc["older_than_secs"], json!(3_600)); - } -``` - -- [x] **Step 3: Run them to verify the first fails** - -Run: `cargo +1.95.0 test -p edgezero-adapter-fastly --features cli -- gc_report_` -Expected: `gc_report_deleted_is_zero_for_a_real_run_with_nothing_to_reclaim` FAILS (`left: None, right: Some(0)`); `gc_report_carries_a_failed_delete` passes, since it pins existing behavior. - -- [x] **Step 4: Implement** - -```rust -/// The typed `config gc` report for `plan`, before any delete runs. -fn gc_report_for(plan: &GcPlan, entries: usize, store_id: &str, dry_run: bool) -> GcReport { - GcReport { - // A real run that finds nothing to reclaim deleted zero entries; only a - // dry run reports `None`. - deleted: (!dry_run).then_some(0), - entries, - failure: None, - // ... remaining fields unchanged - } -} -``` - -Call site: `let mut report = gc_report_for(&plan, items.len(), &resolved_id, dry_run);` - -- [x] **Step 5: Run to verify they pass** - -Run: `cargo +1.95.0 test -p edgezero-adapter-fastly --features cli -- gc_ && cargo +1.95.0 test -p edgezero-cli --lib output::` -Expected: PASS. - -- [ ] **Step 6: Commit** - -```bash -git add -p crates/edgezero-adapter-fastly/src/cli.rs crates/edgezero-cli/src/output.rs -git commit -m "Report deleted: 0 for a real config gc run with nothing to reclaim" -``` - ---- - -### Task 4: `build` rejects a `--format` after passthrough args (blocking) - -`BuildArgs::adapter_args` uses `trailing_var_arg = true`, so -`edgezero build --adapter axum --release --format json` forwarded -`--format json` to the build command, printed text, and exited 0. - -**Files:** -- Modify: `crates/edgezero-cli/src/lib.rs` (`build`) -- Test: `crates/edgezero-cli/tests/format_json.rs` - -**Interfaces:** -- Consumes: none -- Produces: `build` returns `Err` (exit 1, empty stdout) when `adapter_args` contains `--format` or `--format=*`. - -- [x] **Step 1: Write the failing test** - -```rust - #[test] - fn json_build_rejects_a_format_after_passthrough_args() { - let dir = project(); - let output = edgezero( - dir.path(), - &["build", "--adapter", "axum", "--release", "--format", "json"], - None, - ); - assert_eq!(output.status.code(), Some(1_i32)); - // The late `--format` was never read, so this is a text-mode failure: - // nothing on stdout, and the build command never ran. - assert!(output.stdout.is_empty(), "stdout: {:?}", stdout_of(&output)); - let stderr = stderr_of(&output); - assert!( - stderr.contains("`--format` after a passthrough argument"), - "stderr: {stderr}" - ); - assert!(!stderr.contains("child-wrote-to-stdout"), "stderr: {stderr}"); - } -``` - -- [x] **Step 2: Run it to verify it fails** - -Run: `cargo +1.95.0 test -p edgezero-cli --test format_json json_build_rejects` -Expected: FAIL, exit code `Some(0)`. - -- [x] **Step 3: Implement** (first statement of `fn build`, before the manifest loads, so the check is hermetic) - -```rust - // `trailing_var_arg` captures everything after the first passthrough token, - // so a late `--format json` would be forwarded to the build command and the - // run would silently print text. Fail closed instead. - if let Some(flag) = args - .adapter_args - .iter() - .find(|arg| *arg == "--format" || arg.starts_with("--format=")) - { - return Err(format!( - "`{flag}` after a passthrough argument is forwarded to the build command, not read \ - as EdgeZero's `--format`. Put `--format` before the first passthrough argument." - ) - .into()); - } -``` - -`deploy -- --format=json` is deliberately left alone: the explicit `--` makes -the intent clear. - -- [x] **Step 4: Run it to verify it passes** - -Run: `cargo +1.95.0 test -p edgezero-cli --test format_json` -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add crates/edgezero-cli/src/lib.rs crates/edgezero-cli/tests/format_json.rs -git commit -m "Reject a build --format that follows passthrough args" -``` - ---- - -### Task 5: `config validate` always reports `app_config` - -Both flows load and require `.toml`, but raw mode reported -`app_config: null` and the mode was inferred from that null. - -**Files:** -- Modify: `crates/edgezero-cli/src/output.rs` (`ValidateResult`, `ValidateMode`, unit test) -- Modify: `crates/edgezero-cli/src/config.rs` (`validate_raw`, `validate_typed`, import) -- Test: `crates/edgezero-cli/tests/format_json.rs` - -**Interfaces:** -- Produces: `ValidateResult::new(manifest: &Path, app_config: &Path, app_name: &str, strict: bool, mode: ValidateMode) -> Self`; `pub(crate) enum ValidateMode { Raw, Typed }`; wire key `app_config: String` (non-null; compatible under spec §6.5). - -- [x] **Step 1: Update the tests first** - -`format_json.rs` `json_config_validate_reports_the_validated_inputs`: expect -`"app_config": "demo-app.toml"`. Add: - -```rust - #[test] - fn json_config_validate_failure_has_no_result() { - // No `demo-app.toml`: the raw validator requires it. - let dir = project(); - let output = edgezero(dir.path(), &["config", "validate", "--format", "json"], None); - assert_eq!(output.status.code(), Some(1_i32)); - let envelope = sole_json_document(&output); - assert_eq!(envelope["ok"], json!(false)); - assert!(envelope["result"].is_null(), "{envelope}"); - let message = envelope["error"]["message"].as_str().expect("error message"); - assert!(message.contains("demo-app.toml"), "{message}"); - } -``` - -`output.rs`: rename the unit test to -`validate_result_reports_the_app_config_in_both_modes`, pass -`Path::new("demo.toml")` and `ValidateMode::Raw` / `ValidateMode::Typed`, and -expect `"app_config": "demo.toml"` for raw. - -- [x] **Step 2: Run to verify the e2e test fails** - -Run: `cargo +1.95.0 test -p edgezero-cli --test format_json json_config_validate` -Expected: FAIL, `left: … "app_config": String("demo-app.toml") … right: … Null` -(after Step 3 compiles), or a compile error in `output.rs` before it. - -- [x] **Step 3: Implement** - -```rust -pub(crate) struct ValidateResult { - app_config: String, - app_name: String, - manifest: String, - mode: ValidateMode, - strict: bool, -} - -impl ValidateResult { - pub(crate) fn new( - manifest: &Path, - app_config: &Path, - app_name: &str, - strict: bool, - mode: ValidateMode, - ) -> Self { - Self { - app_config: path_string(app_config), - app_name: app_name.to_owned(), - manifest: path_string(manifest), - mode, - strict, - } - } -} - -/// Which `config validate` flow ran. -#[derive(Debug, Serialize)] -#[serde(rename_all = "snake_case")] -pub(crate) enum ValidateMode { - Raw, - Typed, -} -``` - -`config.rs`: both flows call -`ValidateResult::new(&args.manifest, &ctx.app_config_path, &ctx.app_name, args.strict, ValidateMode::Raw)` -(`ValidateMode::Typed` in `validate_typed`), and the `use crate::output::{…}` -list gains `ValidateMode`. - -- [x] **Step 4: Run to verify it passes** - -Run: `cargo +1.95.0 test -p edgezero-cli` -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add crates/edgezero-cli/src/output.rs crates/edgezero-cli/src/config.rs crates/edgezero-cli/tests/format_json.rs -git commit -m "Always report app_config from config validate" -``` - ---- - -### Task 6: `deploy` keeps its result when it went live, and reports the resolved service id - -Two review items in one function: (a) when a Fastly production deploy succeeded -but neither the captured output nor the API fallback resolved the version, `?` -turned it into `result: null`, the same shape as "never ran"; (b) -`service_id` echoed `--service-id`, so a staged deploy using -`FASTLY_SERVICE_ID` reported `null`. - -**Files:** -- Modify: `crates/edgezero-adapter/src/registry.rs` (`DeployOutcome`) -- Modify: `crates/edgezero-adapter-fastly/src/cli.rs` (`Deploy` / `DeployStaging` arms) -- Modify: `crates/edgezero-cli/src/lib.rs` (`deploy`, `fastly_production_deploy`, `deployed_version` → `deployed`) -- Test: `crates/edgezero-cli/tests/format_json.rs` - -**Interfaces:** -- Consumes: `Failure::with_result` (`output.rs`) -- Produces: - - `DeployOutcome { pub service_id: Option, pub version: Option }` - - `fn fastly_production_deploy(..) -> Result, String>`: outer `Err` = deploy failed; inner `Err` = deployed, version unresolved - - `fn deployed(outcome: ActionOutcome) -> (Option, Option)` - -- [x] **Step 1: Write the failing tests** (uses `FASTLY_MANIFEST`, `DEPLOY` and `project_with` from Task 10 Step 1) - -```rust - #[test] - fn deploy_reads_the_version_from_the_captured_output() { - let dir = project_with(FASTLY_MANIFEST); - let text = edgezero(dir.path(), &DEPLOY, None); - assert!(text.status.success(), "stderr: {}", stderr_of(&text)); - let stdout = stdout_of(&text); - // The manifest command receives `--service-id SVC1` as passthrough. - assert!( - stdout.ends_with( - "SUCCESS: Deployed package (service SVC1, version 12) --service-id SVC1\n\ - version=12\n" - ), - "the tee echoes the child live, then the version line: {stdout:?}" - ); - - let mut args = DEPLOY.to_vec(); - args.extend(["--format", "json"]); - let output = edgezero(dir.path(), &args, None); - assert!(output.status.success(), "stderr: {}", stderr_of(&output)); - assert_eq!( - sole_json_document(&output)["result"], - json!({"adapter": "fastly", "service_id": "SVC1", "staging": false, "version": 12_i32}) - ); - // The tee sends the captured child's stdout to stderr under JSON. - let stderr = stderr_of(&output); - assert!( - stderr.contains("SUCCESS: Deployed package (service SVC1, version 12) --service-id"), - "stderr: {stderr}" - ); - assert!(stderr.contains("version=12\n"), "stderr: {stderr}"); - } - - #[test] - fn deploy_that_went_live_without_a_version_keeps_its_result() { - let manifest = FASTLY_MANIFEST.replace( - "echo 'SUCCESS: Deployed package (service SVC1, version 12)'", - "echo deployed", - ); - let dir = project_with(&manifest); - let mut args = DEPLOY.to_vec(); - args.extend(["--format", "json"]); - // No token: the API fallback cannot resolve the version either. - let output = edgezero(dir.path(), &args, None); - assert_eq!(output.status.code(), Some(1_i32)); - let envelope = sole_json_document(&output); - assert_eq!(envelope["ok"], json!(false)); - assert_eq!( - envelope["result"], - json!({"adapter": "fastly", "service_id": "SVC1", "staging": false, "version": null}), - "the deploy went live, so the result is present" - ); - let message = envelope["error"]["message"].as_str().expect("error message"); - assert!(message.starts_with("deploy succeeded but"), "{message}"); - } -``` - -- [x] **Step 2: Run to verify the second fails** - -Run: `cargo +1.95.0 test -p edgezero-cli --test format_json deploy_` -Expected: `deploy_that_went_live_without_a_version_keeps_its_result` FAILS (`result` is `null`); the first passes and pins the tee path. - -- [x] **Step 3: Add `service_id` to `DeployOutcome`** - -```rust -pub struct DeployOutcome { - /// The service the deploy targeted, when the adapter resolved one. - pub service_id: Option, - /// The staged (or activated) platform version, when known. - pub version: Option, -} -``` - -Fastly arms: - -```rust - AdapterAction::Deploy => deploy(args).map(|()| { - ActionOutcome::Deploy(DeployOutcome { - service_id: None, - version: None, - }) - }), - // ... - AdapterAction::DeployStaging => deploy_staging(args).map(|version| { - ActionOutcome::Deploy(DeployOutcome { - // `deploy_staging` already resolved this (flag, then - // `FASTLY_SERVICE_ID`), so it cannot fail here. - service_id: resolve_service_id(args).ok(), - version: Some(version), - }) - }), -``` - -- [x] **Step 4: Rework `deploy` in `lib.rs`** - -```rust - // `service_id`: the id the adapter resolved, else `--service-id` as passed. - let result = |service_id: Option, version: Option| { - DeployResult::new( - &args.adapter, - service_id.or_else(|| args.service_id.clone()), - args.staging, - version, - ) - }; -``` - -Staged branch: `let (service_id, version) = deployed(outcome); … return Ok(result(service_id, version));` - -Production Fastly branch: - -```rust - if args.service_id.is_some() && args.adapter.eq_ignore_ascii_case("fastly") { - return match fastly_production_deploy(&args.adapter, manifest.as_ref(), &passthrough)? { - Ok(version) => { - log::info!("version={version}"); - Ok(result(None, Some(version))) - } - Err(unresolved) => Err(Failure::with_result(unresolved, result(None, None))), - }; - } -``` - -Fallthrough: `let (service_id, version) = deployed(outcome); Ok(result(service_id, version))`. - -`fastly_production_deploy` (doc: "The outer `Err` means the deploy itself -failed. The inner `Err` means the deploy went live but the activated version -could not be resolved."): `return Ok(Ok(version));` on a parsed version, and end -with: - -```rust - // `--require-active` makes EmitVersion fail rather than report none. - Ok( - match adapter::execute( - adapter_name, - adapter::Action::EmitVersion, - manifest, - &emit_args, - ) { - Ok(outcome) => active_version(&outcome) - .ok_or_else(|| unresolved("no active version was reported")), - Err(err) => Err(unresolved(&err)), - }, - ) -``` - -Replace `deployed_version`: - -```rust -/// The service id and version a deploy outcome reports, if any. -#[cfg(feature = "cli")] -fn deployed(outcome: ActionOutcome) -> (Option, Option) { - if let ActionOutcome::Deploy(deployed) = outcome { - (deployed.service_id, deployed.version) - } else { - (None, None) - } -} -``` - -The text-mode error message is unchanged: `Failure`'s message is the same -`unresolved(..)` string. - -- [x] **Step 5: Run to verify** - -Run: `cargo +1.95.0 test -p edgezero-cli && cargo +1.95.0 clippy -p edgezero-cli --all-targets --all-features -- -D warnings` -Expected: PASS. (A `match outcome { Ok(outcome) => … }` form trips -`clippy::shadow_reuse`; the inline `match adapter::execute(..)` avoids it.) - -- [ ] **Step 6: Commit** - -```bash -git add crates/edgezero-adapter/src/registry.rs crates/edgezero-cli/src/lib.rs -git add -p crates/edgezero-adapter-fastly/src/cli.rs crates/edgezero-cli/tests/format_json.rs -git commit -m "Keep the deploy result when the version is unresolved; report the resolved service id" -``` - ---- - -### Task 7: Spin reports `created` for an added label - -The Spin dry run reports `would_create`, but adding the label reported -`updated`, so a consumer couldn't pair a plan with its outcome. The text line is -unchanged. - -**Files:** -- Modify: `crates/edgezero-adapter-spin/src/cli.rs` (`provision` add branch; `provision_writes_kv_labels_into_resolved_component` test) - -**Interfaces:** -- Produces: Spin real run → `ProvisionAction::Created` (added) or `AlreadyPresent`. - -- [x] **Step 1: Write the failing assertion** - -```rust - let report = SpinCliAdapter - .provision(dir.path(), Some("spin.toml"), None, &stores, false) - .expect("real run succeeds"); - // Pairs with the dry run's `WouldCreate`. - assert_eq!(report.entries[0].action, ProvisionAction::Created); - let out = report.into_messages(); - assert_eq!(out.len(), 1); - assert!(out[0].contains("added KV label `sessions`"), "got: {out:?}"); -``` - -- [x] **Step 2: Run to verify it fails** - -Run: `cargo +1.95.0 test -p edgezero-adapter-spin --features cli provision_writes_kv_labels` -Expected: FAIL, `left: Updated, right: Created`. - -- [x] **Step 3: Implement** - -In the `if added {` branch, change `ProvisionAction::Updated` to `ProvisionAction::Created`. - -- [x] **Step 4: Run to verify it passes** - -Same command. Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add crates/edgezero-adapter-spin/src/cli.rs -git commit -m "Report created for a Spin label provision adds" -``` - -(The Fastly dry run also reports `would_create` before checking -`setup_block_present`. Making it check would change text output, so the review -agreed to document it instead; see Task 11.) - ---- - -### Task 8: Review nits - -**Files:** -- Modify: `crates/edgezero-cli/src/args.rs` (`AuthSub::Status`) -- Modify: `crates/edgezero-cli/src/output.rs` (`OutputScope::enter`, `finish`) -- Modify: `crates/edgezero-adapter-fastly/src/cli.rs` (`provision_runtime_env_store`) - -**Interfaces:** no signature changes. - -- [x] **Step 1: `#[non_exhaustive]` on the variant that gained `format`** - -```rust - /// Show the current session (`wrangler whoami` / `fastly profile - /// list` / `spin cloud info`). - #[non_exhaustive] - Status { -``` - -- [x] **Step 2: Exhaustive `match` on `OutputFormat`** (it is `#[non_exhaustive]`, but an in-crate `match` is still checked, so a future `Ndjson` fails to compile here instead of silently acting like `text`) - -```rust - let previous = match format { - OutputFormat::Text => None, - OutputFormat::Json => Some(( - process::set_child_stdout_to_stderr(true), - crate::set_info_to_stderr(true), - )), - }; -``` - -and in `finish`: `match format { OutputFormat::Text => {} OutputFormat::Json => { … } }` -around the existing envelope write. - -- [x] **Step 3: Flatten the leftover bare block** - -In `provision_runtime_env_store`, remove the `{` … `}` that wraps everything -after the `setup_block_present` early return, and dedent its body by one level. -No logic changes. - -- [x] **Step 4: Verify** - -Run: `cargo +1.95.0 fmt --all && cargo +1.95.0 clippy --workspace --all-targets --all-features -- -D warnings && cargo +1.95.0 test -p edgezero-cli` -Expected: clean, PASS. - -- [ ] **Step 5: Commit** - -```bash -git add crates/edgezero-cli/src/args.rs -git add -p crates/edgezero-cli/src/output.rs crates/edgezero-adapter-fastly/src/cli.rs -git commit -m "Address review nits in the --format plumbing" -``` - ---- - -### Task 9: Guard every stdout writer, and survive a closed stderr - -`disallowed-methods` caught `status` / `spawn`, but not -`writeln!(io::stdout(), …)` (`print_stdout` only covers the macros). And the -logger's `eprintln!` panicked on a closed stderr, which under JSON is the busy -stream, so the command could die (exit 101) before writing its envelope. - -**Files:** -- Modify: `clippy.toml` -- Modify: `crates/edgezero-cli/src/templates/root/clippy.toml.hbs` -- Modify: `crates/edgezero-cli/src/output.rs` (`finish`), `crates/edgezero-cli/src/adapter.rs` (`run_shell_tee`), `crates/edgezero-cli/src/config.rs` (`print_unified_diff_inline`) -- Modify: `crates/edgezero-cli/src/lib.rs` (`CliLogger::log`, imports) - -**Interfaces:** none. - -- [x] **Step 1: Add the lint entry** - -`clippy.toml` (and extend the comment above it with "as does every direct -`std::io::stdout()` writer (`print_stdout` covers only the macros)"): - -```toml - { path = "std::io::stdout", reason = "`--format json` reserves stdout for the JSON envelope; write through the logger, or `#[expect]` with a reason" }, -``` - -- [x] **Step 2: Run clippy to see it fire** - -Run: `cargo +1.95.0 clippy --workspace --all-targets --all-features -- -D warnings` -Expected: three `disallowed_methods` errors: `output.rs` (`finish`), -`adapter.rs` (`run_shell_tee`), `config.rs` (`print_unified_diff_inline`). - -- [x] **Step 3: `#[expect]` the three deliberate writers** - -```rust - // output.rs, finish: - #[expect( - clippy::disallowed_methods, - reason = "the JSON envelope is the one thing `--format json` writes to stdout" - )] - let mut stdout = io::stdout().lock(); - - // adapter.rs, run_shell_tee: - } else { - #[expect( - clippy::disallowed_methods, - reason = "text mode echoes the child's stdout to stdout, exactly as before `--format`" - )] - let stdout = io::stdout(); - tee_stream(child_stdout, stdout) - }; - - // config.rs, print_unified_diff_inline: - #[expect( - clippy::disallowed_methods, - reason = "`config push` has no `--format`; its inline diff is text-only output" - )] - let mut stdout = stdout().lock(); -``` - -- [x] **Step 4: Carry the guard into generated projects** - -Append to `templates/root/clippy.toml.hbs`: - -```toml - -# The built-in commands' `--format json` reserves stdout for one JSON envelope. -# Mirror the EdgeZero workspace guard so your own subcommands keep stdout clean -# too: route output through the logger (`edgezero_cli::init_cli_logger`), and -# `#[expect]` any deliberate stdout write or child spawn with a reason. -disallowed-methods = [ - { path = "std::process::Command::status", reason = "the child inherits stdout; pipe or redirect it, or `#[expect]` with a reason" }, - { path = "std::process::Command::spawn", reason = "the child inherits stdout by default; pipe or redirect it, or `#[expect]` with a reason" }, - { path = "std::io::stdout", reason = "`--format json` reserves stdout for the JSON envelope; write through the logger, or `#[expect]` with a reason" }, -] -``` - -The reasons don't point at `edgezero_adapter::process`: a generated CLI depends -only on `edgezero-cli`. - -- [x] **Step 5: Make the logger's stderr writes infallible** - -`lib.rs` imports: `use std::io::{self, ErrorKind, Write as _};`. In `CliLogger::log`: - -```rust - // `writeln!` rather than `eprintln!`: a closed stderr must not panic - // the command before `--format json` writes its envelope. - match record.level() { - // warn/error always go to stderr; `--format json` sends info there - // too, keeping stdout for the JSON envelope. - log::Level::Error | log::Level::Warn => { - let _ignored = writeln!(io::stderr(), "{}", record.args()); - } - log::Level::Info if INFO_TO_STDERR.load(Ordering::SeqCst) => { - let _ignored = writeln!(io::stderr(), "{}", record.args()); - } - // the stdout `info` arm is unchanged -``` - -- [x] **Step 6: Verify** - -Run: `cargo +1.95.0 clippy --workspace --all-targets --all-features -- -D warnings && cargo +1.95.0 test -p edgezero-cli --test generated_project_builds -- --ignored` -Expected: clippy clean (the three `#[expect]`s are fulfilled, so none is -reported as unfulfilled); the generated workspace compiles (about 2 minutes). - -- [ ] **Step 7: Commit** - -```bash -git add clippy.toml crates/edgezero-cli/src/templates/root/clippy.toml.hbs crates/edgezero-cli/src/adapter.rs -git add -p crates/edgezero-cli/src/output.rs crates/edgezero-cli/src/config.rs crates/edgezero-cli/src/lib.rs -git commit -m "Lint direct stdout writers and never panic on a closed stderr" -``` - ---- - -### Task 10: End-to-end coverage for active-version, rollback and the token healthcheck - -The review listed commands whose result mapping, and the -`version=` / `rolled-back-to=` / "no active version yet" lines that moved into -the CLI, had no tests. These tests also commit a reduced form of the text-mode -byte comparison: each checks the exact text bytes as well as the JSON. - -**Files:** -- Test: `crates/edgezero-cli/tests/format_json.rs` - -**Interfaces:** -- Produces (test helpers, used by Tasks 4–6 tests too): `const FASTLY_MANIFEST: &str`, `const VERSIONS: &str`, `const ACTIVE_VERSION: [&str; 5]`, `const ROLLBACK: [&str; 9]`, `const DEPLOY: [&str; 5]`, `fn project_with(manifest: &str) -> TempDir`, `fn edgezero_with_token(dir: &Path, args: &[&str], bin_dir: Option<&Path>, token: Option<&str>) -> Output`, `fn fake_curl_api(code: u16) -> TempDir`, `fn fake_curl_script(body: &str) -> TempDir`, `fn api_calls(curl: &TempDir) -> Vec` - -- [x] **Step 1: Add the helpers** - -```rust - /// A Fastly app whose production deploy is a manifest command, so `deploy` - /// runs it through the capturing (tee) path. - const FASTLY_MANIFEST: &str = r#" -[app] -name = "demo-app" - -[adapters.fastly.adapter] -crate = "crates/demo-fastly" - -[adapters.fastly.commands] -deploy = "echo 'SUCCESS: Deployed package (service SVC1, version 12)'" -"#; - - /// The versions API answer: 6 inactive, 7 active. - const VERSIONS: &str = r#"[{"number":6,"active":false},{"number":7,"active":true}]"#; - - const ACTIVE_VERSION: [&str; 5] = - ["active-version", "--adapter", "fastly", "--service-id", "SVC1"]; - - const ROLLBACK: [&str; 9] = [ - "rollback", "--adapter", "fastly", "--service-id", "SVC1", - "--version", "7", "--rollback-to", "6", - ]; - - const DEPLOY: [&str; 5] = ["deploy", "--adapter", "fastly", "--service-id", "SVC1"]; -``` - -These consts sit with `HEALTHCHECK`, before the functions -(`clippy::arbitrary_source_item_ordering` rejects consts between tests). - -```rust - /// A temp project holding `manifest` as `edgezero.toml`. - fn project_with(manifest: &str) -> TempDir { - let dir = TempDir::new().expect("temp dir"); - fs::write(dir.path().join("edgezero.toml"), manifest).expect("write manifest"); - dir - } -``` - -`project()` becomes `project_with(MANIFEST)`. `edgezero(dir, args, bin_dir)` -becomes `edgezero_with_token(dir, args, bin_dir, None)`, and -`edgezero_with_token` is the old body plus `.env_remove("FASTLY_SERVICE_ID")` -and: - -```rust - if let Some(value) = token { - command.env("FASTLY_API_TOKEN", value); - } -``` - -(`value`, not `token`: clippy rejects shadowing the parameter.) - -```rust - /// A fake `curl` answering every probe with HTTP `code`. - fn fake_curl(code: u16) -> TempDir { - fake_curl_script(&format!("echo {code}")) - } - - /// A fake `curl` that also serves the Fastly API: a request read from - /// `--config -` gets [`VERSIONS`] (GET) or a bare 200 (PUT), and is logged - /// to `api.log` as `GET` / `PUT`. A health probe gets HTTP `code`. - fn fake_curl_api(code: u16) -> TempDir { - fake_curl_script(&format!( - "case \" $* \" in *\" --config \"*)\n\ - \x20 cfg=$(cat)\n\ - \x20 case \"$cfg\" in *'request = \"PUT\"'*) echo PUT >> \"$(dirname \"$0\")/api.log\"; printf 200;;\n\ - \x20 *) echo GET >> \"$(dirname \"$0\")/api.log\"; printf '%s\\n200' '{VERSIONS}';; esac\n\ - \x20 exit 0;;\n\ - esac\n\ - echo {code}" - )) - } - - fn fake_curl_script(body: &str) -> TempDir { - let dir = TempDir::new().expect("temp dir"); - let script = dir.path().join("curl"); - fs::write(&script, format!("#!/bin/sh\n{body}\n")).expect("write curl"); - let mut perms = fs::metadata(&script).expect("meta").permissions(); - perms.set_mode(0o755); - fs::set_permissions(&script, perms).expect("chmod +x"); - dir - } - - /// The Fastly API calls a [`fake_curl_api`] served, in order. - fn api_calls(curl: &TempDir) -> Vec { - fs::read_to_string(curl.path().join("api.log")) - .unwrap_or_default() - .lines() - .map(str::to_owned) - .collect() - } -``` - -- [x] **Step 2: Add the tests** - -```rust - #[test] - fn json_healthcheck_with_a_token_verifies_the_version() { - let dir = project(); - let curl = fake_curl_api(200); - let mut args = HEALTHCHECK.to_vec(); - args.extend(["--format", "json"]); - let output = edgezero_with_token(dir.path(), &args, Some(curl.path()), Some("tok")); - assert!(output.status.success(), "stderr: {}", stderr_of(&output)); - let envelope = sole_json_document(&output); - assert_eq!(envelope["result"]["healthy"], json!(true)); - assert_eq!(envelope["result"]["version_verified"], json!(true)); - assert_eq!(api_calls(&curl), ["GET", "GET"], "before and after the probe"); - } - - #[test] - fn json_unhealthy_healthcheck_with_a_token_is_not_verified() { - let dir = project(); - let curl = fake_curl_api(503); - let mut args = HEALTHCHECK.to_vec(); - args.extend(["--format", "json"]); - let output = edgezero_with_token(dir.path(), &args, Some(curl.path()), Some("tok")); - assert_eq!(output.status.code(), Some(1_i32)); - let envelope = sole_json_document(&output); - assert_eq!(envelope["result"]["healthy"], json!(false)); - assert_eq!( - envelope["result"]["version_verified"], - json!(false), - "the after-probe check never ran" - ); - assert_eq!(api_calls(&curl), ["GET"]); - } - - #[test] - fn active_version_text_and_json() { - let dir = project(); - let curl = fake_curl_api(200); - let text = edgezero_with_token(dir.path(), &ACTIVE_VERSION, Some(curl.path()), Some("t")); - assert!(text.status.success(), "stderr: {}", stderr_of(&text)); - assert_eq!(stdout_of(&text), "version=7\n"); - - let mut args = ACTIVE_VERSION.to_vec(); - args.extend(["--format", "json"]); - let output = edgezero_with_token(dir.path(), &args, Some(curl.path()), Some("t")); - assert!(output.status.success(), "stderr: {}", stderr_of(&output)); - assert_eq!( - sole_json_document(&output)["result"], - json!({"adapter": "fastly", "service_id": "SVC1", "version": 7_i32}) - ); - assert!(stderr_of(&output).contains("version=7\n")); - } - - #[test] - fn active_version_with_none_active_reports_null() { - let dir = project(); - // Overwrite the API answer: no version is active yet. - let curl = fake_curl_script( - "case \" $* \" in *\" --config \"*) cat >/dev/null; \ - printf '%s\\n200' '[{\"number\":1,\"active\":false}]'; exit 0;; esac", - ); - let text = edgezero_with_token(dir.path(), &ACTIVE_VERSION, Some(curl.path()), Some("t")); - assert!(text.status.success(), "stderr: {}", stderr_of(&text)); - assert_eq!( - stdout_of(&text), - "version=\nservice SVC1 has no active version yet; emitting an empty rollback target\n" - ); - - let mut args = ACTIVE_VERSION.to_vec(); - args.extend(["--format", "json"]); - let output = edgezero_with_token(dir.path(), &args, Some(curl.path()), Some("t")); - assert!(output.status.success(), "stderr: {}", stderr_of(&output)); - assert_eq!(sole_json_document(&output)["result"]["version"], json!(null)); - } - - #[test] - fn rollback_text_and_json() { - let dir = project(); - let curl = fake_curl_api(200); - let text = edgezero_with_token(dir.path(), &ROLLBACK, Some(curl.path()), Some("t")); - assert!(text.status.success(), "stderr: {}", stderr_of(&text)); - assert!( - stdout_of(&text).ends_with("rolled-back-to=6\n"), - "stdout: {:?}", - stdout_of(&text) - ); - - let mut args = ROLLBACK.to_vec(); - args.extend(["--format", "json"]); - let output = edgezero_with_token(dir.path(), &args, Some(curl.path()), Some("t")); - assert!(output.status.success(), "stderr: {}", stderr_of(&output)); - assert_eq!( - sole_json_document(&output)["result"], - json!({ - "adapter": "fastly", "rolled_back_to": 6_i32, "service_id": "SVC1", - "staging": false, "version": 7_i32 - }) - ); - assert!(stderr_of(&output).contains("rolled-back-to=6\n")); - } -``` - -- [x] **Step 3: Run** - -Run: `cargo +1.95.0 test -p edgezero-cli --test format_json` -Expected: 18 passed. `json_unhealthy_healthcheck_with_a_token_is_not_verified` -fails without Task 2; the others pin existing behavior. - -- [ ] **Step 4: Commit** (Tasks 4–6 commit their own tests; if they land first, this commit holds only the helpers and the five tests above) - -```bash -git add crates/edgezero-cli/tests/format_json.rs -git commit -m "Cover active-version, rollback and the token healthcheck end to end" -``` - ---- - -### Task 11: Docs and spec - -**Files:** -- Modify: `docs/guide/cli-reference.md` ("Machine-readable output") -- Modify: `docs/superpowers/specs/2026-09-25-cli-format-json-design.md` (§6.1, §6.4, §8, §13) - -**Interfaces:** none (documents Tasks 2–9). - -- [x] **Step 1: CLI reference, Streams** - -After the "empty stdout" paragraph, add: - -```markdown -`--help` and `--version` are handled by clap before `--format` is read: they -print clap's text to stdout and exit `0`, with no envelope. - -The routing relies on the EdgeZero logger (`edgezero_cli::init_cli_logger()`). -A downstream CLI that installs a different logger, such as `simple_logger`, -which writes every level to stdout, will put log lines on stdout and break the -one-document rule. -``` - -Extend the exit-code paragraph with: "In a generated CLI, `2` also means a -usage error or an unsupported `config diff`; only the envelope tells them apart, -and an empty stdout means there is none." - -- [x] **Step 2: CLI reference, Results** - -Replace the prose list with one table per command with columns -`Key | Type | Nullable | Meaning`, covering every key. The nullable keys under -schema 1 are exactly: `active-version.version`, `build.artifact`, -`deploy.service_id`, `deploy.version`, `healthcheck.staging_ip`, -`healthcheck.status_code`, `rollback.rolled_back_to`, `provision.entries[].store`, -`provision.entries[].store.logical`, `config gc` `older_than_secs`, `deleted` and -`store.id`. `config validate.app_config` is now non-null. Notes under the -tables: - -- `build`: a `--format` after a passthrough argument is rejected. -- `deploy`: `service_id` resolution order; a deploy that went live without a - resolvable version gives `ok: false` with the result present and - `version: null`. -- `healthcheck.version_verified`: true only for a healthy production probe with - `FASTLY_API_TOKEN` set. -- `provision`: `would_create` → `created` or `already_present`; `would_update` → - `updated`. The Fastly and Spin dry runs report the plan without checking - current state; Fastly's real run omits the `edgezero_runtime_env` entry when - it is already declared. -- `config gc.deleted`: `null` on a dry run, `0` for a real run with nothing to - reclaim. - -- [x] **Step 3: Spec** - -- §6.1 `result` row: replace "`active-version --require-active` with nothing - active" with "a deploy that went live without a resolvable version". -- §6.4: add a `--help` / `--version` row (exit 0, clap's text) and the exit-2 - paragraph. -- §8: drop the `--require-active` bullet from `active-version`; update - `deploy`, `healthcheck`, `provision`, `config gc` and `config validate` to - match Step 2. -- §13: reword the `AuthStatus` bullet to "`AuthState::Unauthenticated` carries - its reason", and append a "Revision 3" list naming each change in Tasks 1–9. - -- [x] **Step 4: Format and lint the docs** - -Run: `cd docs && npx prettier --write guide/cli-reference.md superpowers/specs/2026-09-25-cli-format-json-design.md && npm run lint && npm run format` -Expected: "All matched files use Prettier code style!" and no ESLint errors. - -- [ ] **Step 5: Commit** - -```bash -git add docs/guide/cli-reference.md docs/superpowers/specs/2026-09-25-cli-format-json-design.md -git commit -m "Document result nullability and the review's contract fixes" -``` - ---- - -### Task 12: Final verification, push, reply - -- [x] **Step 1: Run every CI gate** (see Global Constraints) - -Results on 2026-10-05: fmt clean; workspace clippy clean; workspace tests pass -(edgezero-cli lib 217, `format_json` 18); Fastly `--features cli` 291 pass; the -feature check and Spin wasm32 check pass; the wasm clippy matrix (cloudflare, -fastly, fastly+cli, spin) is clean; app-demo fmt, clippy and tests pass; -`generated_project_builds` passes (116 s); docs ESLint and Prettier are clean. - -- [ ] **Step 2: Push** `spec/383-cli-format-json` (with the author's go-ahead). - -- [ ] **Step 3: Reply in each review thread** - -Use `gh api repos/stackpop/edgezero/pulls/388/comments/{id}/replies`, one reply -per inline comment, naming the fix and its commit. The spec-approval question -and the out-of-scope `wrangler whoami` note are the author's to answer. diff --git a/docs/superpowers/specs/2026-09-25-cli-format-json-design.md b/docs/superpowers/specs/2026-09-25-cli-format-json-design.md index 9bafd8eb8..dac909656 100644 --- a/docs/superpowers/specs/2026-09-25-cli-format-json-design.md +++ b/docs/superpowers/specs/2026-09-25-cli-format-json-design.md @@ -1,9 +1,11 @@ # Machine-readable CLI output (`--format json`) — Design - **Issue:** [#383](https://github.com/stackpop/edgezero/issues/383) -- **Status:** Approved; implemented (revision 2 records what implementation changed, see §13) +- **Status:** Proposed, awaiting maintainer approval; implemented in + [#388](https://github.com/stackpop/edgezero/pull/388) (§13 records what + implementation and review changed) - **Date:** 2026-09-25 -- **Delivery:** a single implementation PR, opened only after this spec is approved +- **Delivery:** a single implementation PR ([#388](https://github.com/stackpop/edgezero/pull/388)) ## 1. Summary @@ -22,7 +24,8 @@ lines. The CLI then owns both renderings. 1. Scripts can gate on structured results without parsing log lines. 2. Every existing invocation behaves exactly as before: same bytes, same - streams, same exit codes. + streams, same exit codes. The one exception is `build` with a `--format` + token among its passthrough arguments, which is now rejected (§5.2). 3. Under `--format json`, no byte on stdout is anything other than the envelope. This holds by construction and is enforced by lint and tests, not by convention. @@ -108,9 +111,12 @@ it cannot honour. - `--format=json` and `--format json` are both accepted, as clap handles either. - `build` collects passthrough arguments with `trailing_var_arg`, so - `--format` has to come before the first passthrough token. After that point - it is forwarded to the adapter. This is documented and covered by a parse - test. + `--format` has to come before the first passthrough token. A `--format` (or + `--format=…`) token among the passthrough arguments is rejected with exit 1 + and an empty stdout, instead of being forwarded and silently printing text. + clap drops a leading `--`, so `build --adapter x -- --format json` is + rejected too: `build` cannot forward a `--format` to its build command. No + built-in build command takes one. - `deploy` passes arguments through only after `--`, so `--format` can go anywhere before `--`. @@ -179,8 +185,8 @@ Rules: - The envelope is written only by `edgezero_cli::output`, and only once per run. - An empty stdout with a non-zero exit means no envelope was produced. That - happens on usage errors, stub commands and panics, and consumers must treat - it as failure. + happens on usage errors, stub commands, a `build` that rejects a late + `--format` (§5.2), and panics, and consumers must treat it as failure. ### 6.3 Where the envelope is written @@ -205,7 +211,8 @@ Exit codes do not change. | Command failure | 1 (bundled binary) / 2 (template-generated CLIs) | envelope, `ok: false` | | Usage error (reported by clap) | 2 | empty. clap parses before the format is known, and its message goes to stderr as today | | Bundled stub (`config push` / `config diff`) | 2 | empty | -| `--help` / `--version` (clap) | 0 | clap's text, not an envelope | +| `build` with `--format` among its passthrough arguments (§5.2) | 1 | empty | +| `--help` (clap) | 0 | clap's text, not an envelope | In a generated CLI, exit code 2 covers a command failure, a usage error and an unsupported `config diff`. Only the envelope tells them apart: an empty stdout @@ -280,16 +287,19 @@ changing the trait breaks no external implementor. #[non_exhaustive] pub enum ActionOutcome { ActiveVersion(ActiveVersionOutcome), // { service_id, version: Option } - AuthStatus(AuthStatusOutcome), // { failure: Option, state: AuthState } + AuthStatus(AuthStatusOutcome), // { state: AuthState } Build(BuildOutcome), // { artifact: Option } - Deploy(DeployOutcome), // { version: Option } - Empty, // login, logout, serve, manifest command overrides - Healthcheck(HealthcheckOutcome), // { attempts, domain, failure, healthy, path, service_id, - // staging, staging_ip, status_code, version, version_verified } + Deploy(DeployOutcome), // { service_id: Option, version: Option } + Empty, // login, logout, serve, production deploy, + // manifest command overrides + Healthcheck(HealthcheckOutcome), // { attempts, domain, failure: Option, path, + // service_id, staging, staging_ip, status_code, + // version, version_verified }; healthy() derives + // from `failure` Rollback(RollbackOutcome), // { rolled_back_to, service_id, staging, version } } -pub enum AuthState { Authenticated, NotApplicable, Unauthenticated } +pub enum AuthState { Authenticated, NotApplicable, Unauthenticated { reason: String } } fn execute(&self, action: AdapterAction, args: &[String]) -> Result; fn provision(/* unchanged params */) -> Result; @@ -304,11 +314,12 @@ adding a variant fails to compile until the schema covers it. **Outcomes versus errors.** A negative result that the command still finished measuring is an `Ok` outcome carrying a `failure` message, not an `Err`: -- an unhealthy healthcheck is `healthy: false`; -- a non-zero exit from `auth status` is `Unauthenticated` +- an unhealthy healthcheck has `failure: Some(message)`, so `healthy()` is + `false`; +- a non-zero exit from `auth status` is `Unauthenticated { reason }` (`cli_support::native_auth_status`, or a manifest `auth-status` command); -- a gc run with failed deletes has a non-empty `failed` list and a - `failure_diagnostic` holding exactly the error text it returned before. +- a gc run with failed deletes has `failure: Some(GcFailure)`, whose + `diagnostic` holds exactly the error text it returned before. The CLI turns these into a non-zero exit, using the same message as before. `Err(String)` is kept for "could not determine a result": bad arguments, API @@ -327,8 +338,7 @@ pub struct ProvisionEntry { pub struct GcReport { pub deleted: Option, // None on a dry run pub entries: usize, - pub failed: Vec, - pub failure_diagnostic: Option, + pub failure: Option, // None unless a delete failed pub generations_planned: usize, pub kept_roots: Vec, pub planned: Vec, // { age_secs, key } @@ -336,12 +346,17 @@ pub struct GcReport { pub retained_recent: usize, pub roots: usize, pub store_id: Option, - pub stranded: Vec, pub text_lines: Vec, // text-mode lines only; not on the wire - pub uncertain: Vec, pub unprovable: usize, pub warnings: Vec, } + +pub struct GcFailure { + pub diagnostic: String, // the operator-facing report and recovery text + pub failed: Vec, // never empty + pub stranded: Vec, + pub uncertain: Vec, +} ``` **Data lines move from the Fastly adapter to the CLI.** The adapter used to @@ -436,8 +451,11 @@ Every command's `result` includes `adapter` (string), except today's non-zero exit. - A native CLI that is missing or fails to spawn gives `ok: false` with `result: null`. -- `state` comes from the native CLI's exit status, or from a manifest - `commands.auth_status` override. Account details are not parsed. +- `state` comes from the probe command's exit status, or from a manifest + `commands.auth_status` override. Output and account details are not parsed, + so `authenticated` means "the probe exited 0", not "a token works": some + `wrangler whoami` versions exit 0 when logged out, and `fastly profile list` + only lists saved profiles. ### `build` @@ -460,7 +478,9 @@ adapter cannot know the path. output, then falling back to the API). - Otherwise `version` is `null`. - `service_id` is the id the adapter resolved (a Fastly staged deploy falls - back to `FASTLY_SERVICE_ID`), else `--service-id` as passed, else `null`. + back to `FASTLY_SERVICE_ID`), else `--service-id` as passed, else `null`. A + production deploy reads only `--service-id`, so one that relies on + `FASTLY_SERVICE_ID` reports `null`. - A production deploy that went live but whose version could not be resolved gives `ok: false` with `result` present and `version: null`. @@ -565,25 +585,36 @@ never on string matches (#383), and text tests assert exact bytes. survives a failure, command names match their spelling, and `OutputScope` routes only while it is alive. 2. **Text is byte-identical to `main`.** Because text output is not - re-rendered (§7.1), this is verified by running the branch and `main` - binaries through the same 21 hermetic scenarios and diffing stdout, stderr - and exit code. The scenarios cover success and failure paths for build, - auth, provision, validate, deploy, healthcheck (fake `curl`), active-version, - rollback, gc, a usage error, and the stub. One case is pinned permanently as - a test: `healthcheck_text_bytes_match_the_line_contract` asserts the exact - healthcheck bytes the CI action parses. + re-rendered (§7.1), this was first verified by running the branch and + `main` binaries through the same 21 hermetic scenarios and diffing stdout, + stderr and exit code. The text output of all nine commands is now pinned as + exact bytes in the end-to-end tests below, so the contract cannot drift. The + one intended change is `build` rejecting a passthrough `--format` (§5.2). 3. **End-to-end stream tests** (`edgezero-cli/tests/format_json.rs`). These run - `env!("CARGO_BIN_EXE_edgezero")` in a temp project and need no network or - credentials. Each asserts that stdout parses as exactly one JSON value: - - `build --format json`, where a manifest command writes to stdout: its - output appears only on stderr; - - `auth status --format json` with a failing manifest probe: exit 1, and the - envelope carries the `unauthenticated` partial result; - - `provision` and `config validate`, with the parsed structure asserted; - - `healthcheck --format json` against a fake `curl` answering 503: exit 1, - the full partial result is present, and the line contract is on stderr; - - text-mode `build` output is unchanged, a usage error leaves stdout empty, - and the bundled stub still absorbs `--format json`. + `env!("CARGO_BIN_EXE_edgezero")` in a temp project, with fake `curl` / + `fastly` binaries on `PATH`, and need no network or credentials. The + harness clears `FASTLY_API_TOKEN`, `FASTLY_SERVICE_ID`, and every + `EDGEZERO__*` and `DEMO_APP__*` variable. Each JSON test asserts that stdout + parses as exactly one JSON value. There are 28 tests: + - **build:** child stdout reaches only stderr under JSON; exact text bytes; + a usage error leaves stdout empty; a passthrough `--format`, `--format=json` + or `-- --format json` is rejected with exit 1 and an empty stdout. + - **auth status:** a failing probe gives the `unauthenticated` partial + result; a passing probe gives `authenticated`; a missing native CLI gives + `result: null`; exact text bytes on success and failure. + - **provision** and **config validate:** the parsed structure, exact text + bytes, and a validate failure with `result: null`. + - **config gc:** a default dry run (`dry_run: true`, `deleted: null`, + `older_than_secs: null`) and a real run with nothing to reclaim + (`deleted: 0`, `older_than_secs: 3600`), both with exact text bytes. + - **healthcheck:** the exact line-contract bytes; an unhealthy probe's full + partial result; `version_verified` only when both API checks ran. + - **deploy:** the version read from captured output; a deploy that went live + without a version keeps its result; a staged deploy resolving its service + from `FASTLY_SERVICE_ID`; exact text bytes. + - **rollback** and **active-version:** production and staged rollback, and + an active version present or absent, each in text and JSON. + - the bundled `config diff` stub still absorbs `--format json`. 4. **Argument parsing** (`args.rs`). Every in-scope command defaults to `Text` and accepts `--format json` and `--format=text`. `unified` and `structured` are rejected. `build --adapter fastly --format json --release` parses the @@ -635,7 +666,7 @@ In `docs/guide/cli-reference.md`: | Risk | Mitigation | | --- | --- | -| Text output drifts, breaking CI actions | Text output is not re-rendered (§7.1). A 21-scenario byte diff against `main`, plus a permanent healthcheck byte test (§9.2). | +| Text output drifts, breaking CI actions | Text output is not re-rendered (§7.1). A 21-scenario byte diff against `main`, and exact-byte text tests for all nine commands (§9.2, §9.3). | | A missed stdout writer corrupts JSON | The `print_stdout` deny, the `disallowed-methods` spawn lint, and the end-to-end test (§9.3). | | JSON numbers above 2^53 lose precision in JavaScript consumers | Fastly service versions are small integers. u64 values are documented as JSON numbers, and none of today's fields comes close. | | A native CLI reads from or checks stdout as a TTY and behaves differently when stdout is redirected to stderr | Only under `--format json`, which is new and opt-in. Interactive `auth login` is out of scope. | @@ -683,3 +714,28 @@ Revision 3 records the changes from the review of the implementation PR - `active-version` has no `--require-active` flag (only the CLI's internal deploy fallback passes it), so §6.1 and §8 no longer promise a partial result for it. + +Revision 4 records the changes from the second review of #388. None changes a +result shape. + +- **Status** is "Proposed, awaiting maintainer approval" until a maintainer + approves this spec on #383 or #388. +- **§7.2** now shows the outcome types as implemented: `failure` on + `HealthcheckOutcome` with a derived `healthy()`, `Unauthenticated { reason }`, + `DeployOutcome { service_id, version }`, and `GcReport.failure: Option`. +- **`build`'s passthrough `--format` rejection** is the one exception to + Goal 2, including `build -- --format json` (§5.2), and appears in the + empty-stdout cases (§6.2, §6.4). +- **`auth status` `authenticated`** is documented as "the probe exited 0" + (§8). +- **`healthcheck`** treats curl's `000` as a probe that got no response, not + as status `0`: `status_code` keeps the last real HTTP status, or `null` if + no probe got one. +- **The Fastly production deploy** returns `ActionOutcome::Empty`, like the + other adapters, and the staged deploy reports the service id it used. +- **A failed write of the envelope** no longer hides the command's own error: + the write error is logged and the command's outcome is returned. +- **`std::os::unix::process::CommandExt::exec`** is a disallowed method, in the + workspace and the generated project. +- **§9** lists the 28 end-to-end tests, including exact text bytes for all nine + commands. diff --git a/tests/fixtures/reusable-app/clippy.toml b/tests/fixtures/reusable-app/clippy.toml new file mode 100644 index 000000000..eae882f98 --- /dev/null +++ b/tests/fixtures/reusable-app/clippy.toml @@ -0,0 +1,9 @@ +# Clippy configuration for the lifecycle fixture workspace. Without this file +# clippy would use the EdgeZero root `clippy.toml`, whose `disallowed-methods` +# guard keeps the EdgeZero CLI's stdout clean for `--format json`. The fixture +# harness is a test driver that spawns and waits on processes directly, so it +# keeps only the test exemptions. +allow-expect-in-tests = true +allow-indexing-slicing-in-tests = true +allow-panic-in-tests = true +allow-unwrap-in-tests = true