From 09c7bf5c052d7d3addb256cc00afc9373179178a Mon Sep 17 00:00:00 2001 From: dhruv8sh Date: Fri, 25 Sep 2026 19:54:24 +0530 Subject: [PATCH 1/2] 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/2] 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: