diff --git a/clippy.toml b/clippy.toml index 0b4d3d8cc..18d81d9dd 100644 --- a/clippy.toml +++ b/clippy.toml @@ -7,3 +7,15 @@ 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]`, as does every +# direct `std::io::stdout()` writer (`print_stdout` covers only the macros). +disallowed-methods = [ + { path = "std::process::Command::status", reason = "use `edgezero_adapter::process::status` so the `--format json` stdout policy applies" }, + { path = "std::process::Command::spawn", reason = "inherits stdout by default; pipe stdout and `#[expect]` with a reason, or use `edgezero_adapter::process::status`" }, + { path = "std::io::stdout", reason = "`--format json` reserves stdout for the JSON envelope; write through the logger, or `#[expect]` with a reason" }, + { path = "std::os::unix::process::CommandExt::exec", reason = "replaces this process, so the new program writes straight to stdout; use `edgezero_adapter::process::status`", allow-invalid = true }, +] diff --git a/crates/edgezero-adapter-axum/src/cli.rs b/crates/edgezero-adapter-axum/src/cli.rs index af669cce9..6083b1215 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, - TypedSecretEntry, register_adapter, + ActionOutcome, Adapter, AdapterAction, AdapterPushContext, AuthState, AuthStatusOutcome, + BuildOutcome, ProvisionAction, ProvisionEntry, ProvisionReport, ProvisionStores, + ReadConfigEntry, ResolvedStoreId, StoreKind, TypedSecretEntry, register_adapter, }; use edgezero_adapter::scaffold::{ AdapterBlueprint, AdapterFileSpec, CommandTemplates, DependencySpec, LoggingDefaults, @@ -135,7 +137,7 @@ struct EdgezeroAxumConfig { reason = "axum has no validate_app_config_keys / validate_adapter_manifest requirements; those two trait defaults are intentionally inherited. `validate_typed_secrets` IS overridden below (each `#[secret]` value must map to one `EDGEZERO__SECRETS__` env var). `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. @@ -144,11 +146,21 @@ 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 { + 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 @@ -171,12 +183,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() @@ -185,8 +197,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 { @@ -195,20 +210,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( @@ -442,8 +467,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 efbcccbee..e8568de85 100644 --- a/crates/edgezero-adapter-fastly/src/cli.rs +++ b/crates/edgezero-adapter-fastly/src/cli.rs @@ -26,11 +26,16 @@ use crate::chunked_config::{ }; 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, AdapterDeployContext, AdapterPushContext, DeployOwnership, - DeployStoreIds, ProvisionStores, ReadConfigEntry, ResolvedStoreId, register_adapter, + ActionOutcome, ActiveVersionOutcome, Adapter, AdapterAction, AdapterDeployContext, + AdapterPushContext, BuildOutcome, DeployOutcome, DeployOwnership, DeployStoreIds, GcCandidate, + GcFailure, GcReport, HealthcheckOutcome, ProvisionAction, ProvisionEntry, ProvisionReport, + ProvisionStores, ReadConfigEntry, ResolvedStoreId, RollbackOutcome, StoreKind, + register_adapter, }; use edgezero_adapter::release::{VerifiedApplicationRelease, verify_application_release}; use edgezero_adapter::scaffold::{ @@ -896,19 +901,24 @@ fn build_managed_deploy_plan( fn deploy_managed_with_context( context: &AdapterDeployContext, args: &[String], -) -> Result<(), String> { +) -> Result { let plan = build_managed_deploy_plan(context, args)?; - execute_managed_deploy_plan(&plan) + let version = execute_managed_deploy_plan(&plan)?; + Ok(DeployOutcome { + service_id: Some(plan.service_id), + version: Some(version), + }) } -fn execute_managed_deploy_plan(plan: &ManagedDeployPlan) -> Result<(), String> { +/// Returns the staged or activated version. +fn execute_managed_deploy_plan(plan: &ManagedDeployPlan) -> Result { execute_managed_deploy_plan_with_emit(plan, &mut |line| log::info!("{line}")) } fn execute_managed_deploy_plan_with_emit( plan: &ManagedDeployPlan, emit: &mut dyn FnMut(&str), -) -> Result<(), String> { +) -> Result { let cwd = plan .release .adapter_manifest() @@ -1002,7 +1012,8 @@ fn execute_managed_deploy_plan_with_emit( plan.token.as_str(), ) .map(|_status| ()), - } + }?; + Ok(version) } struct CapturedFastlyCommand { @@ -2034,43 +2045,57 @@ fn owns_managed_deploy(context: &AdapterDeployContext) -> bool { 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 deploy(&self, context: &AdapterDeployContext, args: &[String]) -> Result<(), String> { + fn deploy( + &self, + context: &AdapterDeployContext, + args: &[String], + ) -> Result { if owns_managed_deploy(context) { - deploy_managed_with_context(context, args) + deploy_managed_with_context(context, args).map(ActionOutcome::Deploy) } else { validate_effective_deploy_service_id(context)?; scan_reserved_deploy_args(args)?; - deploy_with_context(context, args) + // `finalize_deploy` resolves the activated version. + deploy_with_context(context, args).map(|()| ActionOutcome::Empty) } } - 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), + })) } - AdapterAction::Deploy => deploy(args), - AdapterAction::Serve => serve(args), + // Production: the CLI's `finalize_deploy` resolves the activated + // version (deploy output, then the Fastly API). + AdapterAction::Deploy => deploy(args).map(|()| ActionOutcome::Empty), + AdapterAction::Serve => serve(args).map(|()| ActionOutcome::Empty), AdapterAction::DeployStaging => Err( "Fastly staging requires typed deploy context and --application-release".to_owned(), ), - AdapterAction::EmitVersion => emit_active_version(args), - AdapterAction::Healthcheck => healthcheck(args), - AdapterAction::Rollback => rollback(args), + 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:?}")), } } @@ -2079,15 +2104,22 @@ impl Adapter for FastlyCliAdapter { &self, context: &AdapterDeployContext, command_output: Option<&str>, - ) -> Result<(), String> { + ) -> Result { if context.staging { - return Ok(()); + return Ok(ActionOutcome::Empty); } let Some(service_id) = context.service_id.as_deref() else { - return Ok(()); + return Ok(ActionOutcome::Empty); }; validate_service_id(service_id)?; + let deployed = |version| { + log::info!("version={version}"); + ActionOutcome::Deploy(DeployOutcome { + service_id: Some(service_id.to_owned()), + version: Some(version), + }) + }; if let Some(version) = command_output.and_then(parse_fastly_version) { let token = require_token()?; verify_version_active( @@ -2096,14 +2128,15 @@ impl Adapter for FastlyCliAdapter { &token, "after manifest-command deployment", )?; - log::info!("version={version}"); - return Ok(()); + return Ok(deployed(version)); } - emit_active_version_for(service_id, true).map_err(|error| { + // `require_active`: a version was just activated, so "none" is an error. + let active = emit_active_version_for(service_id, true).map_err(|error| { format!( "deploy succeeded but the activated version could not be resolved from the deploy output or Fastly API: {error}" ) - }) + })?; + Ok(active.version.map_or(ActionOutcome::Empty, deployed)) } fn gc_config_entries( @@ -2115,7 +2148,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) } @@ -2178,7 +2211,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 selected physical resource // is created through the Fastly CLI. A default logical/physical mapping // can be represented by `[setup]`; a distinct physical name requires an @@ -2208,35 +2241,41 @@ impl Adapter for FastlyCliAdapter { } } } - 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 { let logical = store.logical.as_str(); let name = store.platform.as_str(); let setup_can_represent_mapping = logical == name; if dry_run { - if setup_can_represent_mapping { - out.push(format!( + let message = if setup_can_represent_mapping { + format!( "would run `fastly {kind}-store create --name={name}` and append [setup.{kind}_stores.{logical}] to {}", fastly_path.display() - )); + ) } else { - out.push(format!( + format!( "would run `fastly {kind}-store create --name={name}` for logical id `{logical}`; the selected service requires an explicit resource link under alias `{logical}`" - )); - } + ) + }; + entries.push(ProvisionEntry::for_store( + ProvisionAction::WouldCreate, + store_kind, + store, + message, + )); continue; } if setup_can_represent_mapping && setup_block_present(&fastly_path, kind, logical)? { - out.push(format!( + entries.push(ProvisionEntry::for_store(ProvisionAction::AlreadyPresent, store_kind, store, format!( "fastly {kind}-store `{name}` already declared in {}; skipping. To force a fresh remote: delete the [setup.{kind}_stores.{logical}] 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)?; @@ -2285,13 +2324,20 @@ impl Adapter for FastlyCliAdapter { line.push('\n'); line.push_str(¬e); } - out.push(line); + entries.push(ProvisionEntry::for_store( + ProvisionAction::Created, + store_kind, + store, + line, + )); } } - 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( @@ -4166,6 +4212,35 @@ 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, dry_run: bool) -> GcReport { + GcReport { + // A real run that finds nothing to reclaim deleted zero entries; only a + // dry run reports `None`. + deleted: (!dry_run).then_some(0), + entries, + failure: None, + 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()), + text_lines: 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`. /// @@ -4180,7 +4255,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 @@ -4199,6 +4274,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, dry_run); let GcPlan { doomed, kept_roots, @@ -4227,7 +4303,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 @@ -4242,7 +4319,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 @@ -4262,9 +4340,41 @@ 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` into a non-zero exit. + let diagnostic = gc_failure_diagnostic( + &out, + doomed_count, + &failed, + &uncertain, + &stranded, + &resolved_id, + )?; + report.failure = Some(GcFailure { + diagnostic, + failed, + stranded, + uncertain, + }); + report.text_lines = out; + 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 ({})", @@ -4282,7 +4392,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}"))?; } @@ -4297,11 +4407,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 @@ -5013,6 +5123,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()) @@ -5296,20 +5410,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}")); } @@ -5378,11 +5493,12 @@ fn deploy_with_context( forwarded.extend(["--service-id".to_owned(), service_id.to_owned()]); } - 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}")); } @@ -5485,12 +5601,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}")); } @@ -6184,10 +6301,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}") @@ -6219,6 +6333,10 @@ fn run_fastly_status(fastly_args: &[String], cwd: &Path) -> Result<(), String> { fn curl_config_capture(config: &str) -> 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", @@ -6477,27 +6595,26 @@ fn resolve_deploy_manifest_path(context: &AdapterDeployContext) -> Result Result<(), String> { +fn emit_active_version(args: &[String]) -> Result { let service_id = resolve_service_id(args)?; validate_service_id(&service_id)?; emit_active_version_for(&service_id, arg_flag(args, "--require-active")) } -fn emit_active_version_for(service_id: &str, require_active: bool) -> Result<(), String> { +fn emit_active_version_for( + service_id: &str, + require_active: bool, +) -> Result { let token = require_token()?; let json = fastly_api_get(&format!("/service/{service_id}/version"), &token)?; - if let Some(version) = active_version_or_require(&json, 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, require_active, service_id)?; + Ok(ActiveVersionOutcome { + service_id: service_id.to_owned(), + version, + }) } /// Resolve the active version and apply the `--require-active` policy. @@ -6561,8 +6678,9 @@ fn version_active_verdict( /// `healthcheck --adapter fastly ...`: probe the domain /// (production) or the version's staging IP (`--staging`), retrying up -/// to `--retry` times. Emits `status-code` / `healthy` and returns -/// `Err` (non-zero exit) when unhealthy after retries. +/// to `--retry` times. An unhealthy probe is still `Ok`, with `failure` +/// set; the CLI emits `status-code` / `healthy` and turns that into a +/// non-zero exit. /// /// `--domain`, `--service-id` and `--version` are REQUIRED and validated /// on BOTH the production and the staging path. GitHub Actions' `required: @@ -6574,7 +6692,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)?; @@ -6645,29 +6763,49 @@ 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. + // `version_verified` means BOTH checks ran, so it is true only on the + // healthy arm, the one that runs the "after probing" check. + let (version_verified, status_code, failure) = match outcome { Ok(code) => { // Confirm `version` is STILL active, so a deploy that activated a newer // version during the probe+retries is not reported as a healthy `version`. if let Some(token) = production_token.as_deref() { verify_version_active(&service_id, version, token, "after probing")?; } - log::info!("status-code={code}"); - log::info!("healthy=true"); - Ok(()) + (production_token.is_some(), 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, + path: path.to_owned(), + service_id, + staging: is_staging, + staging_ip, + status_code, + version, + version_verified, + }) } /// Run a single `curl` health probe, returning the HTTP status. A @@ -6689,18 +6827,22 @@ fn curl_status(args: &[String]) -> Result { )); } let stdout = String::from_utf8_lossy(&output.stdout); - stdout.trim().parse::().map_err(|err| { - format!( + match stdout.trim().parse::() { + // curl writes `000` when no HTTP response arrived; that is a transport + // failure, not a status code. + Ok(0) => Err("curl got no HTTP response (status 000)".to_owned()), + Ok(code) => Ok(code), + Err(err) => Err(format!( "could not parse HTTP status from curl output {:?}: {err}", stdout.trim() - ) - }) + )), + } } /// `rollback --adapter fastly ...`: production activates the explicit /// `--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 = @@ -6728,6 +6870,12 @@ fn rollback(args: &[String]) -> Result<(), String> { "[edgezero] Fastly version {version} is an unpublished draft; staging rollback has nothing to deactivate" ), } + 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 @@ -6756,9 +6904,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)] @@ -7115,9 +7268,17 @@ mod tests { ..AdapterDeployContext::default() }; - FastlyCliAdapter + let outcome = FastlyCliAdapter .finalize_deploy(&context, Some("version=8")) .expect("active version finalization"); + assert_eq!( + outcome, + ActionOutcome::Deploy(DeployOutcome { + service_id: Some("SVC1".to_owned()), + version: Some(8), + }), + "finalization reports the verified service version" + ); assert!( marker.exists(), "finalization must consult the exact service" @@ -9377,7 +9538,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_eq!(out.len(), 3, "dry-run rows: {out:?}"); assert!(out[0].contains("would run `fastly kv-store create --name=sessions`")); assert!(out[1].contains("would run `fastly config-store create --name=app_config`")); @@ -9494,7 +9656,8 @@ build = \"cargo build --release\" let lines = FastlyCliAdapter .provision(dir.path(), Some("fastly.toml"), None, &stores, false) - .expect("selected service can use a distinct physical store"); + .expect("selected service can use a distinct physical store") + .into_messages(); let log = fs::read_to_string(&oplog).expect("provider log"); assert!( @@ -9545,7 +9708,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"]); } @@ -9574,7 +9738,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:?}"); assert!( @@ -10311,6 +10476,156 @@ exit 1 GUARD.get_or_init(|| Mutex::new(())) } + /// A fake `curl` that answers every health probe with HTTP `code`, + /// written as curl's three-digit `%{http_code}` (`0` prints `000`). + #[cfg(unix)] + fn fake_curl(code: u16) -> tempfile::TempDir { + use std::os::unix::fs::PermissionsExt as _; + let dir = tempdir().expect("tempdir"); + let script_path = dir.path().join("curl"); + fs::write(&script_path, format!("#!/bin/sh\necho {code:03}\n")).expect("write script"); + let mut perms = fs::metadata(&script_path).expect("meta").permissions(); + perms.set_mode(0o755); + fs::set_permissions(&script_path, perms).expect("chmod +x"); + 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, + 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 unhealthy_path = PathPrepend::new(unhealthy_curl.path()); + let unhealthy = + healthcheck(&args("2")).expect("an unhealthy probe is an outcome, not an error"); + assert!(!unhealthy.healthy()); + 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}" + ); + drop(unhealthy_path); + + // curl's `000` means no HTTP response arrived: no status code. + let silent_curl = fake_curl(0); + let _silent_path = PathPrepend::new(silent_curl.path()); + let silent = healthcheck(&args("1")).expect("a silent probe is an outcome"); + assert!(!silent.healthy()); + assert_eq!(silent.status_code, None, "`000` is not a status code"); + let silent_failure = silent.failure.expect("carries its reason"); + assert!(silent_failure.contains("status 000"), "{silent_failure}"); + } + + /// `version_verified` claims the version was ACTIVE before AND after the + /// probe, so it is true only when both API checks actually ran: an + /// unhealthy probe skips the "after" check and must report `false`. + #[cfg(unix)] + #[test] + fn healthcheck_version_verified_only_when_both_checks_ran() { + use std::os::unix::fs::PermissionsExt as _; + let _lock = path_mutation_guard().lock().expect("guard"); + let _token = EnvOverride::set(FASTLY_API_TOKEN_ENV, "test-token"); + let args: Vec = [ + "--domain", + "app.example.com", + "--service-id", + "SVC1", + "--version", + "7", + "--retry", + "1", + "--retry-delay", + "0", + ] + .into_iter() + .map(str::to_owned) + .collect(); + // A fake `curl` serving the versions API (`--config -`, version 7 + // active) and answering health probes with `code`; each API call is + // logged so the test can count the checks. + let fake_curl_with_api = |code: u16| { + let dir = tempdir().expect("tempdir"); + let log = dir.path().join("api.log"); + let script = format!( + "#!/bin/sh + case \" $* \" in *\" --config \"*) cat >/dev/null; echo get >> '{log}'; \ + printf '%s\\n200' '[{{\"number\":7,\"active\":true,\"locked\":true,\"staging\":false,\"deployed\":true,\"environments\":[]}}]'; exit 0;; esac + echo {code} +", + log = log.display() + ); + let script_path = dir.path().join("curl"); + fs::write(&script_path, script).expect("write script"); + let mut perms = fs::metadata(&script_path).expect("meta").permissions(); + perms.set_mode(0o755); + fs::set_permissions(&script_path, perms).expect("chmod +x"); + (dir, log) + }; + let api_calls = |log: &Path| fs::read_to_string(log).unwrap_or_default().lines().count(); + + let (unhealthy_curl, unhealthy_log) = fake_curl_with_api(503); + let unhealthy_path = PathPrepend::new(unhealthy_curl.path()); + let unhealthy = healthcheck(&args).expect("an unhealthy probe is an outcome"); + assert!(!unhealthy.healthy()); + assert!( + !unhealthy.version_verified, + "the after-probe check never ran on an unhealthy probe" + ); + assert_eq!(api_calls(&unhealthy_log), 1, "only the before-probe check"); + drop(unhealthy_path); + + let (healthy_curl, healthy_log) = fake_curl_with_api(200); + let _path = PathPrepend::new(healthy_curl.path()); + let healthy = healthcheck(&args).expect("probe runs"); + assert!(healthy.healthy()); + assert!(healthy.version_verified, "both checks ran and passed"); + assert_eq!(api_calls(&healthy_log), 2, "before and after the probe"); + } + #[cfg(unix)] #[test] fn read_remote_returns_present_on_success() { @@ -11845,7 +12160,7 @@ echo 'unexpected' >&2; exit 1 // ---------- config gc (operator-invoked reclamation) ---------- #[cfg(unix)] - fn run_gc(dir: &Path, older_than_secs: u64, dry_run: bool) -> Result, String> { + fn gc_report(dir: &Path, older_than_secs: u64, dry_run: bool) -> Result { FastlyCliAdapter.gc_config_entries( dir, None, @@ -11857,6 +12172,16 @@ echo 'unexpected' >&2; exit 1 ) } + #[cfg(unix)] + fn run_gc(dir: &Path, older_than_secs: u64, dry_run: bool) -> Result, String> { + // Mirror the CLI: a report carrying a failure is an error. + let report = gc_report(dir, older_than_secs, dry_run)?; + match report.failure { + Some(failure) => Err(failure.diagnostic), + None => Ok(report.text_lines), + } + } + /// gc never deletes a chunk the LIVE root pointer references, however old. #[cfg(unix)] #[test] @@ -12058,6 +12383,73 @@ echo 'unexpected' >&2; exit 1 ); } + /// `deleted` is `None` only on a dry run: a real run that finds nothing to + /// reclaim deleted zero entries, and says so. + #[cfg(unix)] + #[test] + fn gc_report_deleted_is_zero_for_a_real_run_with_nothing_to_reclaim() { + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + let oplog = dir.path().join("ops.log"); + let live = gen_envelope("live"); + let mut listing = vec![listed_root(TEST_CONFIG_ID, &live, 172_800)]; + listing.extend(listed_generation(TEST_CONFIG_ID, &live, 172_800)); + let fake = fake_fastly_gc(TEST_CONFIG_ID, &[], &listing, None, false, &oplog); + let _path = PathPrepend::new(fake.path()); + + let real = gc_report(dir.path(), 86_400, false).expect("gc runs"); + assert_eq!(real.deleted, Some(0)); + assert_eq!(real.failure, None); + assert!(real.planned.is_empty()); + + let preview = gc_report(dir.path(), 86_400, true).expect("gc runs"); + assert_eq!(preview.deleted, None, "a dry run deletes nothing at all"); + } + + /// A failed delete is still a report: `failure` names the failed key, and + /// the keys whose outcome is unknown, alongside the recovery text. + #[cfg(unix)] + #[test] + fn gc_report_carries_a_failed_delete() { + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + let oplog = dir.path().join("ops.log"); + let live = gen_envelope("live"); + let dead = gen_envelope("dead"); + let dead_chunks = chunk_keys_of(TEST_CONFIG_ID, &dead); + let mut listing = vec![listed_root(TEST_CONFIG_ID, &live, 172_800)]; + listing.extend(listed_generation(TEST_CONFIG_ID, &live, 172_800)); + listing.extend(listed_generation(TEST_CONFIG_ID, &dead, 604_800)); + let fake = fake_fastly_gc( + TEST_CONFIG_ID, + &[], + &listing, + Some(&dead_chunks[0]), + false, + &oplog, + ); + let _path = PathPrepend::new(fake.path()); + + let report = gc_report(dir.path(), 86_400, false).expect("a failed delete is a report"); + assert_eq!(report.deleted, Some(0)); + assert_eq!(report.planned.len(), dead_chunks.len()); + let failure = report.failure.expect("the failed delete is reported"); + assert_eq!(failure.failed, vec![dead_chunks[0].clone()]); + assert!( + failure.stranded.is_empty(), + "nothing was deleted before the failure" + ); + assert!( + !failure.uncertain.is_empty(), + "the failed first delete has an unknown outcome" + ); + assert!( + failure.diagnostic.contains("unknown outcome"), + "{}", + failure.diagnostic + ); + } + /// a delete that fails on a generation's FIRST key has /// an UNKNOWN outcome -- Fastly may have committed it before returning an /// error. called this "whole and retryable", which is unsound: if the diff --git a/crates/edgezero-adapter-spin/src/cli.rs b/crates/edgezero-adapter-spin/src/cli.rs index d68dd7dba..519560464 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::Created, 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( @@ -1059,20 +1074,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}")); } @@ -1099,12 +1115,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}")); } @@ -1206,12 +1223,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}")); } @@ -1409,8 +1427,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" @@ -1680,7 +1702,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`")); @@ -1711,7 +1734,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:?}" @@ -1741,9 +1765,12 @@ mod tests { kv: &kv_ids, secrets: &[], }; - let out = SpinCliAdapter + let report = SpinCliAdapter .provision(dir.path(), Some("spin.toml"), None, &stores, false) .expect("real run succeeds"); + // Pairs with the dry run's `WouldCreate`. + assert_eq!(report.entries[0].action, ProvisionAction::Created); + let out = report.into_messages(); assert_eq!(out.len(), 1); assert!(out[0].contains("added KV label `sessions`"), "got: {out:?}"); let after = fs::read_to_string(dir.path().join("spin.toml")).expect("read back"); @@ -1791,7 +1818,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"), @@ -1823,7 +1851,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..023c3337f 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,43 @@ 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)?.state { + AuthState::Unauthenticated { reason } => Err(reason), + AuthState::Authenticated | AuthState::NotApplicable => Ok(()), + } +} + +/// [`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 { + state: AuthState::Authenticated, + } } else { - Err(format!( - "`{program} {}` exited with status {status}", - args.join(" ") - )) - } + AuthStatusOutcome { + state: AuthState::Unauthenticated { + reason: format!("`{program} {}` exited with status {status}", args.join(" ")), + }, + } + }) } /// Reads the crate name from a `Cargo.toml`, supporting both the inline and `[package]` forms. @@ -242,4 +267,21 @@ mod tests { "expected the exit-status branch, got: {err}" ); } + + #[cfg(unix)] + #[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); + + let unauthenticated = native_auth_status("false", &[], "hint").expect("spawns"); + let AuthState::Unauthenticated { reason } = unauthenticated.state else { + panic!("expected Unauthenticated, got {:?}", unauthenticated.state); + }; + assert!(reason.contains("exited with status"), "got: {reason}"); + + 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 441f85160..00e28e35f 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..da6503569 --- /dev/null +++ b/crates/edgezero-adapter/src/process.rs @@ -0,0 +1,100 @@ +//! 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, MutexGuard, PoisonError}; + + /// Serialises tests that flip the process-wide policy. + static POLICY_LOCK: LazyLock> = LazyLock::new(|| Mutex::new(())); + + /// Sets the policy, and restores the previous one on drop (even if the + /// test panics, so later tests start from the original policy). + struct PolicyOverride(bool); + + impl PolicyOverride { + fn set(to_stderr: bool) -> Self { + Self(set_child_stdout_to_stderr(to_stderr)) + } + } + + impl Drop for PolicyOverride { + fn drop(&mut self) { + set_child_stdout_to_stderr(self.0); + } + } + + /// Takes [`POLICY_LOCK`], recovering it if an earlier test panicked. + fn policy_lock() -> MutexGuard<'static, ()> { + POLICY_LOCK.lock().unwrap_or_else(PoisonError::into_inner) + } + + #[test] + fn set_returns_previous_and_get_reflects_it() { + let _guard = policy_lock(); + let _policy = PolicyOverride::set(true); + assert!(child_stdout_to_stderr()); + assert!(set_child_stdout_to_stderr(false)); + assert!(!child_stdout_to_stderr()); + } + + #[cfg(unix)] + #[test] + fn status_reports_child_exit() { + let _guard = policy_lock(); + let _policy = PolicyOverride::set(true); + let ok = status(Command::new("sh").args(["-c", "echo routed; exit 0"])).expect("spawn"); + let failed = status(Command::new("sh").args(["-c", "exit 3"])).expect("spawn"); + 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 1aafaa44b..65eed490c 100644 --- a/crates/edgezero-adapter/src/registry.rs +++ b/crates/edgezero-adapter/src/registry.rs @@ -41,6 +41,112 @@ 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 { + 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, Debug, Eq, PartialEq)] +pub enum AuthState { + Authenticated, + /// The adapter has no remote auth surface (axum). + NotApplicable, + Unauthenticated { + /// Why the session is not authenticated. + reason: String, + }, +} + +/// 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 service the deploy targeted, when the adapter resolved one. + pub service_id: Option, + /// 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, + /// `None` when healthy; why the probe is unhealthy otherwise. + pub failure: Option, + 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, +} + +impl HealthcheckOutcome { + /// Whether the probe came back healthy. + #[inline] + #[must_use] + pub const fn healthy(&self) -> bool { + self.failure.is_none() + } +} + +/// Result of [`AdapterAction::Rollback`]. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RollbackOutcome { + /// 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, +} + /// Logical store ids declared by the application manifest for a deploy. /// /// This stays platform-neutral: each adapter decides whether and how its @@ -153,6 +259,147 @@ 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`: `failure` describes what +/// failed, and the CLI turns it into a non-zero exit. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct GcReport { + /// Entries deleted; `None` on a dry run. + pub deleted: Option, + /// Entries in the store. + pub entries: usize, + /// `None` unless a delete failed. + pub failure: 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, + /// The human-readable report, rendered only by the text output. + pub text_lines: Vec, + /// Chunk-shaped entries left untouched because they could not be proved. + pub unprovable: usize, + pub warnings: Vec, +} + +/// The deletes a `config gc` run could not complete. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GcFailure { + /// The operator-facing report and recovery text. + pub diagnostic: String, + /// Keys whose delete failed (never empty). + pub failed: Vec, + /// Keys left in a generation that lost a sibling to a confirmed delete. + pub stranded: Vec, + /// Keys whose generation's only failure has an unknown outcome. + pub uncertain: Vec, +} + +/// One orphan chunk `config gc` would delete. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GcCandidate { + 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. @@ -334,12 +581,17 @@ pub trait Adapter: Sync + Send { /// provider-native passthrough in `args`. /// /// Adapters that do not need structured deploy data can use the default - /// dispatch to their existing `execute` implementation. + /// dispatch to their existing `execute` implementation. Report what the + /// deploy produced (an [`ActionOutcome::Deploy`]) or [`ActionOutcome::Empty`]. /// /// # Errors /// Returns an error string when the adapter deploy fails. #[inline] - fn deploy(&self, context: &AdapterDeployContext, args: &[String]) -> Result<(), String> { + fn deploy( + &self, + context: &AdapterDeployContext, + args: &[String], + ) -> Result { let action = if context.staging { AdapterAction::DeployStaging } else { @@ -360,15 +612,20 @@ 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; /// Finish a successful deploy. /// /// `command_output` is present when a manifest-defined deploy command ran /// instead of the adapter's built-in deploy. Adapters can reconcile - /// provider state or emit deployment metadata from either path. + /// provider state or emit deployment metadata from either path. A + /// resolved deployment is reported as an [`ActionOutcome::Deploy`], which + /// takes precedence over the outcome `deploy` returned. /// /// # Errors /// Returns an error string when provider state cannot be finalized. @@ -377,8 +634,8 @@ pub trait Adapter: Sync + Send { &self, _context: &AdapterDeployContext, _command_output: Option<&str>, - ) -> Result<(), String> { - Ok(()) + ) -> Result { + Ok(ActionOutcome::Empty) } /// Reclaim chunk entries that no LIVE config pointer references. @@ -419,7 +676,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() @@ -490,9 +747,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 @@ -501,7 +759,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 @@ -516,8 +774,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 @@ -785,9 +1043,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 db6575961..7e8cde796 100644 --- a/crates/edgezero-cli/src/adapter.rs +++ b/crates/edgezero-cli/src/adapter.rs @@ -1,5 +1,7 @@ +use edgezero_adapter::process; use edgezero_adapter::registry::{ - self as adapter_registry, AdapterAction, AdapterDeployContext, DeployOwnership, + self as adapter_registry, ActionOutcome, AdapterAction, AdapterDeployContext, AuthState, + AuthStatusOutcome, DeployOwnership, }; use edgezero_core::manifest::{Manifest, ManifestLoader, ResolvedEnvironment}; @@ -67,6 +69,22 @@ impl From for AdapterAction { } } +/// Why [`deploy`] failed. +#[derive(Debug)] +pub enum DeployFailure { + /// The deploy itself failed; nothing is known to be live. + Deploy(String), + /// The deploy went live, but finalizing it (e.g. resolving the activated + /// version) failed. + Finalize(String), +} + +impl From for DeployFailure { + fn from(message: String) -> Self { + Self::Deploy(message) + } +} + fn apply_environment( adapter_name: &str, environment: &ResolvedEnvironment, @@ -114,7 +132,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) { @@ -164,13 +182,17 @@ fn require_adapter( /// Select deployment ownership through the registered adapter's preflight. /// Manifest-owned and fallback deployments retain the legacy finalizer, while /// adapter-managed deployments own the complete lifecycle in `deploy`. +/// +/// Returns what the deploy produced: the finalizer's outcome when it reports +/// one, else the adapter deploy's (always [`ActionOutcome::Empty`] for a +/// manifest command). pub fn deploy( adapter_name: &str, context: &AdapterDeployContext, adapter_manifest_path_error: Option<&str>, manifest_loader: Option<&ManifestLoader>, adapter_args: &[String], -) -> Result<(), String> { +) -> Result { let registered_adapter = adapter_registry::get_adapter(adapter_name); let ownership = registered_adapter .map_or(Ok(DeployOwnership::ManifestCommand), |registered| { @@ -180,19 +202,21 @@ pub fn deploy( if ownership != DeployOwnership::AdapterManaged && context.application_release_root.is_some() { return Err(format!( "adapter `{adapter_name}` does not support immutable application releases; omit --application-release" - )); + ) + .into()); } if ownership == DeployOwnership::AdapterManaged { if let Some(err) = adapter_manifest_path_error { - return Err(err.to_owned()); + return Err(err.to_owned().into()); } let Some(managed_adapter) = registered_adapter else { return Err(format!( "adapter `{adapter_name}` selected managed deployment without being registered" - )); + ) + .into()); }; - return managed_adapter.deploy(context, adapter_args); + return Ok(managed_adapter.deploy(context, adapter_args)?); } if !context.staging @@ -203,7 +227,7 @@ pub fn deploy( && !context.stores.is_empty() && let Some(err) = adapter_manifest_path_error { - return Err(err.to_owned()); + return Err(err.to_owned().into()); } let root = loader.manifest().root().unwrap_or_else(|| Path::new(".")); let env = loader.manifest().environment_for(adapter_name); @@ -222,18 +246,26 @@ pub fn deploy( adapter_bind, &command_args, )?; - if let Some(finalizer) = registered_adapter { - finalizer.finalize_deploy(context, Some(&output))?; - } - return Ok(()); + return registered_adapter.map_or(Ok(ActionOutcome::Empty), |finalizer| { + finalizer + .finalize_deploy(context, Some(&output)) + .map_err(DeployFailure::Finalize) + }); } if let Some(err) = adapter_manifest_path_error { - return Err(err.to_owned()); + return Err(err.to_owned().into()); } let fallback_adapter = require_adapter(adapter_name, manifest_loader.is_some())?; - fallback_adapter.deploy(context, adapter_args)?; - fallback_adapter.finalize_deploy(context, None) + let deployed = fallback_adapter.deploy(context, adapter_args)?; + let finalized = fallback_adapter + .finalize_deploy(context, None) + .map_err(DeployFailure::Finalize)?; + Ok(if finalized == ActionOutcome::Empty { + deployed + } else { + finalized + }) } fn manifest_command<'manifest>( @@ -336,6 +368,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, @@ -344,7 +382,7 @@ fn run_shell( environment: Option, adapter_bind: (Option, Option), adapter_args: &[String], -) -> Result<(), String> { +) -> Result { let mut cmd = build_shell_command( command, cwd, @@ -354,16 +392,19 @@ 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: failure.map_or(AuthState::Authenticated, |reason| { + AuthState::Unauthenticated { reason } + }), + })), + (_, Some(message)) => Err(message), + (_, None) => Ok(ActionOutcome::Empty), } } @@ -397,8 +438,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( @@ -420,6 +461,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}"))?; @@ -435,7 +480,16 @@ 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 { + #[expect( + clippy::disallowed_methods, + reason = "text mode echoes the child's stdout to stdout, exactly as before `--format`" + )] + let stdout = io::stdout(); + tee_stream(child_stdout, stdout) + }; let captured_stderr = stderr_worker.join().unwrap_or_default(); let status = child @@ -473,7 +527,9 @@ fn shell_join(args: &[String]) -> String { #[cfg(test)] mod tests { - use super::{AdapterDeployContext, ResolvedEnvironment, apply_environment, deploy}; + use super::{ + AdapterDeployContext, DeployFailure, ResolvedEnvironment, apply_environment, deploy, + }; use crate::test_support::manifest_guard; use edgezero_core::manifest::ResolvedEnvironmentBinding; use edgezero_core::test_env::EnvOverride; @@ -489,9 +545,13 @@ mod tests { let error = deploy("unregistered", &context, None, None, &[]) .expect_err("a manifest-owned adapter must not ignore the release contract"); - assert_eq!( - error, - "adapter `unregistered` does not support immutable application releases; omit --application-release" + assert!( + matches!( + &error, + DeployFailure::Deploy(message) + if message == "adapter `unregistered` does not support immutable application releases; omit --application-release" + ), + "unexpected failure: {error:?}" ); } diff --git a/crates/edgezero-cli/src/args.rs b/crates/edgezero-cli/src/args.rs index 2e69a2833..e56cc66f8 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, @@ -223,9 +229,15 @@ pub enum AuthSub { }, /// Show the current session (`wrangler whoami` / `fastly profile /// list` / `spin cloud info`). + #[non_exhaustive] Status { #[arg(long)] 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)] + format: OutputFormat, }, } @@ -236,9 +248,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. @@ -260,6 +278,11 @@ pub struct DeployArgs { /// the selected adapter validates its own release metadata. #[arg(long)] pub application_release: 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, /// 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 @@ -298,6 +321,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, @@ -317,6 +345,7 @@ impl Default for ProvisionArgs { Self { adapter: String::new(), dry_run: false, + format: OutputFormat::Text, manifest: default_manifest_path(), } } @@ -346,6 +375,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 '/'. @@ -383,6 +417,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 @@ -410,11 +449,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 { @@ -611,6 +672,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, @@ -631,6 +697,7 @@ impl Default for ConfigValidateArgs { fn default() -> Self { Self { app_config: None, + format: OutputFormat::Text, manifest: default_manifest_path(), no_env: false, strict: false, @@ -689,6 +756,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 @@ -794,6 +901,7 @@ mod tests { let Command::Build(BuildArgs { adapter, adapter_args, + .. }) = args.cmd else { panic!("expected Command::Build"); @@ -952,7 +1060,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)"); @@ -1482,4 +1590,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..30d689f5a 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,32 @@ 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 { + AuthState::Unauthenticated { reason } => Err(Failure::with_result(reason, result)), + AuthState::Authenticated | AuthState::NotApplicable => Ok(result), + } } #[cfg(test)] @@ -66,6 +89,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 60a86799a..66a33c256 100644 --- a/crates/edgezero-cli/src/config.rs +++ b/crates/edgezero-cli/src/config.rs @@ -19,10 +19,13 @@ //! 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::output::{ + self, CommandName, Failure, GcResult, Outcome, OutputScope, ValidateMode, ValidateResult, +}; use crate::{ensure_adapter_defined, manifest_variable_defaults}; use edgezero_adapter::registry::{ self as adapter_registry, ReadConfigEntry, ResolvedStoreId, TypedSecretEntry, @@ -227,6 +230,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, AdapterValidationScope::All)?; log::info!( @@ -234,7 +242,13 @@ pub fn run_config_validate(args: &ConfigValidateArgs) -> Result<(), String> { args.manifest.display(), if args.strict { " (strict)" } else { "" }, ); - Ok(()) + Ok(ValidateResult::new( + &args.manifest, + &ctx.app_config_path, + &ctx.app_name, + args.strict, + ValidateMode::Raw, + )) } /// Typed flow — adds the checks that need the user's `C` struct. @@ -243,6 +257,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, { @@ -273,7 +299,13 @@ where ctx.app_config_path.display(), if args.strict { " (strict)" } else { "" }, ); - Ok(()) + Ok(ValidateResult::new( + &args.manifest, + &ctx.app_config_path, + &ctx.app_name, + args.strict, + ValidateMode::Typed, + )) } // ------------------------------------------------------------------- @@ -321,31 +353,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 @@ -360,12 +387,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 @@ -415,7 +459,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(), @@ -424,7 +468,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(failure) = report.failure { + return Err(Failure::with_result(failure.diagnostic, result)); + } + for line in &report.text_lines { log::info!("[edgezero] {line}"); } if dry_run { @@ -437,7 +494,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 @@ -615,6 +672,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, @@ -1327,6 +1385,10 @@ pub(crate) fn print_unified_diff_inline( local_sha: &str, ) { use std::io::stdout; + #[expect( + clippy::disallowed_methods, + reason = "`config push` and `config diff` have no `--format json` envelope; this inline diff is text-only output" + )] let mut stdout = stdout().lock(); // Silently ignore write errors — stdout may be a closed pipe (e.g. // `edgezero config push | head`). The operator already saw the diff @@ -1351,6 +1413,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, @@ -2103,8 +2166,8 @@ mod tests { &self, _action: adapter_registry::AdapterAction, _args: &[String], - ) -> Result<(), String> { - Ok(()) + ) -> Result { + Ok(adapter_registry::ActionOutcome::Empty) } fn name(&self) -> &'static str { @@ -2670,6 +2733,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 c40f6c15f..51a4eb64d 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/", @@ -1135,6 +1136,24 @@ mod tests { ); } + /// The generated `clippy.toml` exempts tests, and carries the `--format + /// json` stdout guard. + fn assert_clippy_toml(project_dir: &Path) { + let clippy = fs::read_to_string(project_dir.join("clippy.toml")).expect("read clippy.toml"); + assert!(clippy.contains("allow-expect-in-tests = true")); + for path in [ + "std::process::Command::status", + "std::process::Command::spawn", + "std::io::stdout", + "std::os::unix::process::CommandExt::exec", + ] { + assert!( + clippy.contains(&format!("{{ path = \"{path}\"")), + "generated clippy.toml disallows `{path}`" + ); + } + } + fn assert_scaffold_workspace(project_dir: &Path) { let cargo_toml = fs::read_to_string(project_dir.join("Cargo.toml")).expect("read Cargo.toml"); @@ -1177,8 +1196,7 @@ mod tests { let gitignore = fs::read_to_string(project_dir.join(".gitignore")).expect("read .gitignore"); assert!(gitignore.contains("target/")); - let clippy = fs::read_to_string(project_dir.join("clippy.toml")).expect("read clippy.toml"); - assert!(clippy.contains("allow-expect-in-tests = true")); + assert_clippy_toml(project_dir); // The generated manifests must keep the package-metadata contract: the // root declares the five shared fields once, and EVERY member inherits diff --git a/crates/edgezero-cli/src/lib.rs b/crates/edgezero-cli/src/lib.rs index 37232db75..c7ad73f4f 100644 --- a/crates/edgezero-cli/src/lib.rs +++ b/crates/edgezero-cli/src/lib.rs @@ -6,6 +6,11 @@ //! reuses any subset of edgezero's built-in commands. The default //! `edgezero` binary (`main.rs`) is a thin wrapper over this library. //! +//! Run one command per process. Under `--format json` a `run_*` handler +//! reroutes process-wide output (the logger and inheriting children) for the +//! duration of the call, so overlapping runs on separate threads are not +//! supported. +//! //! `run_demo` is an additional contributor-only handler, available only //! under the `demo-example` feature — it runs the in-repo `app-demo` //! example and is not meant for downstream CLIs. @@ -32,6 +37,8 @@ mod diff; #[cfg(feature = "cli")] mod generator; #[cfg(feature = "cli")] +mod output; +#[cfg(feature = "cli")] mod provision; #[cfg(feature = "cli")] mod scaffold; @@ -59,22 +66,36 @@ use args::{ ActiveVersionArgs, BuildArgs, DeployArgs, HealthcheckArgs, NewArgs, RollbackArgs, ServeArgs, }; #[cfg(feature = "cli")] -use edgezero_adapter::registry::{AdapterDeployContext, DeployStoreIds}; +use edgezero_adapter::registry::{ActionOutcome, AdapterDeployContext, DeployStoreIds}; #[cfg(feature = "cli")] use edgezero_core::manifest::{Manifest, ManifestLoader, StoreDeclaration}; #[cfg(feature = "cli")] +use output::{ + ActiveVersionResult, BuildResult, CommandName, DeployResult, Failure, HealthcheckResult, + Outcome, OutputScope, RollbackResult, +}; +#[cfg(feature = "cli")] use std::collections::BTreeMap; #[cfg(feature = "cli")] use std::env; #[cfg(feature = "cli")] -use std::io::ErrorKind; +use std::io::{self, ErrorKind, Write as _}; #[cfg(feature = "cli")] use std::path::{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. @@ -101,15 +122,16 @@ impl log::Log for CliLogger { if !self.enabled(record.metadata()) { return; } + // `writeln!` rather than `eprintln!`: a closed stderr must not panic + // the command before `--format json` writes its envelope. match record.level() { + // warn/error always go to stderr; `--format json` sends info there + // too, keeping stdout for the JSON envelope. log::Level::Error | log::Level::Warn => { - #[expect( - clippy::print_stderr, - reason = "CLI UX output goes to stderr for warn/error" - )] - { - eprintln!("{}", record.args()); - } + let _ignored = writeln!(io::stderr(), "{}", record.args()); + } + log::Level::Info if INFO_TO_STDERR.load(Ordering::SeqCst) => { + let _ignored = writeln!(io::stderr(), "{}", record.args()); } log::Level::Info => { #[expect(clippy::print_stdout, reason = "CLI UX output goes to stdout for info")] @@ -122,6 +144,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. @@ -142,17 +171,44 @@ 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 { + // `trailing_var_arg` captures everything after the first passthrough token, + // so a late `--format json` would be forwarded to the build command and the + // run would silently print text. Fail closed instead. + if let Some(flag) = args + .adapter_args + .iter() + .find(|arg| *arg == "--format" || arg.starts_with("--format=")) + { + return Err(format!( + "`{flag}` after a passthrough argument is forwarded to the build command, not read \ + as EdgeZero's `--format`. Put `--format` before the first passthrough argument." + ) + .into()); + } let manifest = load_manifest_optional()?; ensure_adapter_defined(&args.adapter, manifest.as_ref())?; if let Some(loader) = &manifest { 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. @@ -164,6 +220,21 @@ 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 { + // `service_id`: the id the adapter resolved, else `--service-id` as passed. + let result = |service_id: Option, version: Option| { + DeployResult::new( + &args.adapter, + service_id.or_else(|| args.service_id.clone()), + args.staging, + version, + ) + }; // 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 @@ -182,7 +253,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()?; @@ -222,13 +294,40 @@ pub fn run_deploy(args: &DeployArgs) -> Result<(), String> { variable_defaults, }; - adapter::deploy( + match adapter::deploy( &args.adapter, &context, adapter_manifest_path_error.as_deref(), manifest.as_ref(), &args.adapter_args, - ) + ) { + Ok(outcome) => { + let (service_id, version) = deployed(outcome); + Ok(result(service_id, version)) + } + Err(adapter::DeployFailure::Deploy(message)) => Err(message.into()), + // The deploy went live; only finalizing it (e.g. resolving the + // activated version) failed, so the failure still carries a result. + Err(adapter::DeployFailure::Finalize(message)) => { + Err(Failure::with_result(message, result(None, None))) + } + } +} + +/// The service id and version a deploy outcome reports, if any. +#[cfg(feature = "cli")] +fn deployed(outcome: ActionOutcome) -> (Option, Option) { + if let ActionOutcome::Deploy(deployed) = outcome { + (deployed.service_id, deployed.version) + } else { + (None, None) + } +} + +/// The 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") } #[cfg(feature = "cli")] @@ -376,6 +475,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). @@ -400,12 +505,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): @@ -420,6 +538,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![ @@ -444,10 +568,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=`. @@ -463,18 +596,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. @@ -494,6 +656,7 @@ pub fn run_serve(args: &ServeArgs) -> Result<(), String> { manifest.as_ref(), &[], ) + .map(|_| ()) } /// Create a new `EdgeZero` app skeleton. @@ -603,6 +766,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_adapter::registry::{ self as adapter_registry, Adapter, AdapterAction, DeployOwnership, @@ -639,16 +803,24 @@ mod tests { reason = "the recording adapter exercises only deploy preflight, deploy dispatch, and finalization" )] impl Adapter for RecordingDeployAdapter { - fn deploy(&self, context: &AdapterDeployContext, _args: &[String]) -> Result<(), String> { + fn deploy( + &self, + context: &AdapterDeployContext, + _args: &[String], + ) -> Result { *DEPLOY_CONTEXT .lock() .map_err(|err| format!("deploy context lock poisoned: {err}"))? = Some(context.clone()); DEPLOY_CALLS.fetch_add(1, Ordering::SeqCst); - Ok(()) + Ok(ActionOutcome::Empty) } - fn execute(&self, action: AdapterAction, _args: &[String]) -> Result<(), String> { + fn execute( + &self, + action: AdapterAction, + _args: &[String], + ) -> Result { if action == AdapterAction::Deploy { return Err("managed deployment must call Adapter::deploy".to_owned()); } @@ -659,9 +831,9 @@ mod tests { &self, _context: &AdapterDeployContext, _command_output: Option<&str>, - ) -> Result<(), String> { + ) -> Result { DEPLOY_FINALIZE_CALLS.fetch_add(1, Ordering::SeqCst); - Ok(()) + Ok(ActionOutcome::Empty) } fn name(&self) -> &'static str { @@ -794,6 +966,7 @@ mod tests { let _env = EnvOverride::set("EDGEZERO_MANIFEST", &manifest_str); let args = DeployArgs { + format: OutputFormat::Text, adapter: "fastly".to_owned(), application_release: None, adapter_args: vec!["--non-interactive".to_owned()], @@ -825,6 +998,7 @@ mod tests { let _env = EnvOverride::set("EDGEZERO_MANIFEST", &manifest_str); let err = run_deploy(&DeployArgs { + format: OutputFormat::Text, adapter: "fastly".to_owned(), application_release: None, adapter_args: Vec::new(), @@ -864,6 +1038,7 @@ mod tests { let _env = EnvOverride::set("EDGEZERO_MANIFEST", &manifest_str); run_deploy(&DeployArgs { + format: OutputFormat::Text, adapter: "unregistered_test".to_owned(), application_release: None, adapter_args: Vec::new(), @@ -933,6 +1108,7 @@ deploy = "touch '{}'" let _env = EnvOverride::set("EDGEZERO_MANIFEST", &manifest_str); run_deploy(&DeployArgs { + format: OutputFormat::Text, adapter: "recording_deploy_test".to_owned(), application_release: None, adapter_args: Vec::new(), @@ -1069,6 +1245,7 @@ deploy = "touch '{}'" let _env = EnvOverride::set("EDGEZERO_MANIFEST", &manifest_str); let err = run_deploy(&DeployArgs { + format: OutputFormat::Text, adapter: "recording_deploy_test".to_owned(), application_release: None, adapter_args: Vec::new(), @@ -1113,6 +1290,7 @@ deploy = "touch '{}'" let _env = EnvOverride::set("EDGEZERO_MANIFEST", &manifest_str); run_deploy(&DeployArgs { + format: OutputFormat::Text, adapter: "recording_deploy_test".to_owned(), application_release: None, adapter_args: Vec::new(), @@ -1152,6 +1330,7 @@ deploy = "touch '{}'" let _env = EnvOverride::set("EDGEZERO_MANIFEST", &manifest_str); let err = run_deploy(&DeployArgs { + format: OutputFormat::Text, adapter: "recording_deploy_test".to_owned(), application_release: None, adapter_args: Vec::new(), @@ -1192,6 +1371,7 @@ deploy = "touch '{}'" let _env = EnvOverride::set("EDGEZERO_MANIFEST", &manifest_str); let err = run_deploy(&DeployArgs { + format: OutputFormat::Text, adapter: "recording_deploy_test".to_owned(), application_release: None, adapter_args: Vec::new(), @@ -1248,6 +1428,7 @@ deploy = "touch '{}'" adapter: "fastly".to_owned(), application_release: None, adapter_args: vec!["--non-interactive".to_owned()], + format: OutputFormat::Text, service_id: Some("SVC1".to_owned()), staging: false, }) @@ -1272,6 +1453,7 @@ deploy = "touch '{}'" adapter: "fastly".to_owned(), application_release: None, adapter_args: vec![flag.to_owned()], + format: OutputFormat::Text, service_id: Some("SVC1".to_owned()), staging: false, }; @@ -1315,6 +1497,7 @@ deploy = "touch '{}'" let args = BuildArgs { adapter: "fastly".to_owned(), adapter_args: Vec::new(), + format: OutputFormat::Text, }; run_build(&args).expect("build command runs"); } @@ -1335,6 +1518,7 @@ deploy = "touch '{}'" // 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..bbd0c18fd --- /dev/null +++ b/crates/edgezero-cli/src/output.rs @@ -0,0 +1,867 @@ +//! `--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, GcFailure, 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. +/// +/// The routing is process-wide. Nested scopes restore correctly, but +/// overlapping scopes on separate threads do not: the first to drop restores +/// routing while the other is still running. One command per process. +#[must_use = "the routing lasts only while the scope is alive"] +pub(crate) struct OutputScope { + /// The routing to restore (`(child_stdout, info)`); `None` for `text`. + previous: Option<(bool, bool)>, +} + +impl OutputScope { + pub(crate) fn enter(format: OutputFormat) -> Self { + let previous = match format { + OutputFormat::Text => None, + OutputFormat::Json => Some(( + process::set_child_stdout_to_stderr(true), + crate::set_info_to_stderr(true), + )), + }; + Self { previous } + } +} + +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 { + let (failed, stranded, uncertain) = match &report.failure { + Some(GcFailure { + failed, + stranded, + uncertain, + .. + }) => (failed.clone(), stranded.clone(), uncertain.clone()), + None => (Vec::new(), Vec::new(), Vec::new()), + }; + Self { + adapter: adapter.to_owned(), + deleted: report.deleted, + dry_run, + failed, + 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, + 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, + 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: String, + app_name: String, + manifest: String, + mode: ValidateMode, + strict: bool, +} + +impl ValidateResult { + pub(crate) fn new( + manifest: &Path, + app_config: &Path, + app_name: &str, + strict: bool, + mode: ValidateMode, + ) -> Self { + Self { + app_config: path_string(app_config), + app_name: app_name.to_owned(), + manifest: path_string(manifest), + mode, + strict, + } + } +} + +/// Which `config validate` flow ran. +#[derive(Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ValidateMode { + Raw, + Typed, +} + +// ---------------------------------------------------------------- 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> { + match format { + OutputFormat::Text => {} + OutputFormat::Json => { + let document = render_envelope(command, &outcome)?; + #[expect( + clippy::disallowed_methods, + reason = "the JSON envelope is the one thing `--format json` writes to stdout" + )] + let mut stdout = io::stdout().lock(); + // A failed write must not hide the command's own failure message. + if let Err(err) = writeln!(stdout, "{document}").and_then(|()| stdout.flush()) { + let message = format!("failed to write the JSON result to stdout: {err}"); + if outcome.is_ok() { + return Err(message); + } + log::error!("[edgezero] {message}"); + } + } + } + outcome.map(|_| ()).map_err(|failure| failure.message) +} + +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, GcFailure, 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()), + 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 gc_failure_fills_the_partial_result() { + let report = GcReport { + deleted: Some(1), + failure: Some(GcFailure { + diagnostic: "config gc: 1 of 3 deletes FAILED".to_owned(), + failed: vec!["c.2".to_owned()], + stranded: vec!["c.3".to_owned()], + uncertain: vec!["d.1".to_owned()], + }), + store_id: Some("7Ab".to_owned()), + ..GcReport::default() + }; + let store = ResolvedStoreId::from_logical("app_config"); + let result = GcResult::new("fastly", &store, false, Some(3_600), &report); + let failed: Outcome = Err(Failure::with_result( + report.failure.expect("failure").diagnostic, + result, + )); + let envelope = envelope_of(CommandName::ConfigGc, &failed); + assert_envelope_invariants(&envelope); + assert_eq!(envelope["ok"], json!(false)); + assert_eq!( + envelope["error"], + json!({"message": "config gc: 1 of 3 deletes FAILED"}) + ); + let gc = &envelope["result"]; + assert_eq!(gc["deleted"], json!(1)); + assert_eq!(gc["failed"], json!(["c.2"])); + assert_eq!(gc["stranded"], json!(["c.3"])); + assert_eq!(gc["uncertain"], json!(["d.1"])); + assert_eq!(gc["older_than_secs"], json!(3_600)); + } + + #[test] + fn validate_result_reports_the_app_config_in_both_modes() { + let raw = serde_json::to_value(ValidateResult::new( + Path::new("edgezero.toml"), + Path::new("demo.toml"), + "demo", + true, + ValidateMode::Raw, + )) + .expect("serialize"); + assert_eq!( + raw, + json!({"app_config": "demo.toml", "app_name": "demo", "manifest": "edgezero.toml", "mode": "raw", "strict": true}) + ); + let typed = serde_json::to_value(ValidateResult::new( + Path::new("edgezero.toml"), + Path::new("demo.toml"), + "demo", + false, + ValidateMode::Typed, + )) + .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 37f86d4ff..ca14751a5 100644 --- a/crates/edgezero-cli/src/provision.rs +++ b/crates/edgezero-cli/src/provision.rs @@ -16,6 +16,7 @@ use crate::config::{ 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}; @@ -28,6 +29,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(); @@ -119,7 +125,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(), @@ -130,10 +136,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 @@ -162,7 +168,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; @@ -213,6 +219,7 @@ ids = ["app_config"] 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)"); @@ -230,6 +237,7 @@ ids = ["app_config"] run_provision(&ProvisionArgs { adapter: "axum".to_owned(), dry_run: true, + format: OutputFormat::Text, manifest: manifest_path.clone(), }) .expect("axum dry-run also exits 0"); @@ -247,6 +255,7 @@ ids = ["app_config"] 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"); @@ -276,6 +285,7 @@ ids = ["app_config"] run_provision(&ProvisionArgs { adapter: "spin".to_owned(), dry_run: true, + format: OutputFormat::Text, manifest: manifest_path.clone(), }) .expect("spin dry-run dispatches cleanly"); @@ -314,6 +324,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"); @@ -344,6 +355,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"); @@ -395,6 +407,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"); @@ -444,6 +457,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"); @@ -497,6 +511,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"); @@ -522,6 +537,7 @@ ids = ["default"] fs::write(&spin_path, SPIN_MANIFEST).expect("write spin manifest"); run_provision(&ProvisionArgs { + format: OutputFormat::Text, adapter: "spin".to_owned(), dry_run: false, manifest: manifest_path, @@ -557,6 +573,7 @@ ids = ["default"] fs::write(&spin_path, SPIN_MANIFEST).expect("write spin manifest"); run_provision(&ProvisionArgs { + format: OutputFormat::Text, adapter: "spin".to_owned(), dry_run: false, manifest: manifest_path, @@ -586,6 +603,7 @@ ids = ["default"] let before = fs::read_to_string(&spin_path).expect("read before"); let error = run_provision(&ProvisionArgs { + format: OutputFormat::Text, adapter: "spin".to_owned(), dry_run: false, manifest: manifest_path, @@ -621,6 +639,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"); @@ -642,6 +661,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"); @@ -662,6 +682,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/src/templates/root/clippy.toml.hbs b/crates/edgezero-cli/src/templates/root/clippy.toml.hbs index 36e6164cb..754629345 100644 --- a/crates/edgezero-cli/src/templates/root/clippy.toml.hbs +++ b/crates/edgezero-cli/src/templates/root/clippy.toml.hbs @@ -8,3 +8,14 @@ allow-expect-in-tests = true allow-indexing-slicing-in-tests = true allow-panic-in-tests = true allow-unwrap-in-tests = true + +# The built-in commands' `--format json` reserves stdout for one JSON envelope. +# Mirror the EdgeZero workspace guard so your own subcommands keep stdout clean +# too: route output through the logger (`edgezero_cli::init_cli_logger`), and +# `#[expect]` any deliberate stdout write or child spawn with a reason. +disallowed-methods = [ + { path = "std::process::Command::status", reason = "the child inherits stdout; pipe or redirect it, or `#[expect]` with a reason" }, + { path = "std::process::Command::spawn", reason = "the child inherits stdout by default; pipe or redirect it, or `#[expect]` with a reason" }, + { path = "std::io::stdout", reason = "`--format json` reserves stdout for the JSON envelope; write through the logger, or `#[expect]` with a reason" }, + { path = "std::os::unix::process::CommandExt::exec", reason = "replaces this process, so the new program writes straight to stdout; spawn a child instead, or `#[expect]` with a reason", allow-invalid = true }, +] diff --git a/crates/edgezero-cli/tests/format_json.rs b/crates/edgezero-cli/tests/format_json.rs new file mode 100644 index 000000000..7fec1e852 --- /dev/null +++ b/crates/edgezero-cli/tests/format_json.rs @@ -0,0 +1,1007 @@ +//! 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 Fastly commands +//! talk to a fake `curl` on `PATH` (health probes and the versions API). 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"] +"#; + + /// A Fastly app whose production deploy is a manifest command, so `deploy` + /// runs it through the capturing (tee) path. + const FASTLY_MANIFEST: &str = r#" +[app] +name = "demo-app" + +[adapters.fastly.adapter] +crate = "crates/demo-fastly" + +[adapters.fastly.commands] +deploy = "echo 'SUCCESS: Deployed package (service SVC1, version 12)'" +"#; + + /// A Fastly app with no config stores, so a staged deploy needs only + /// `fastly compute update` to answer (no runtime-env relink). + const FASTLY_STAGING_MANIFEST: &str = r#" +[app] +name = "demo-app" + +[adapters.fastly.adapter] +crate = "crates/demo-fastly" +manifest = "fastly.toml" +"#; + + /// A Fastly app with one config store, for `config gc`. + const FASTLY_GC_MANIFEST: &str = r#" +[app] +name = "demo-app" + +[adapters.fastly.adapter] +crate = "crates/demo-fastly" +manifest = "fastly.toml" + +[stores.config] +ids = ["app_config"] +"#; + + /// An app whose axum session probe succeeds, and a cloudflare adapter + /// with no override (its probe is the native `wrangler whoami`). + const AUTH_MANIFEST: &str = r#" +[app] +name = "demo-app" + +[adapters.axum.adapter] +crate = "crates/demo-axum" + +[adapters.axum.commands] +auth-status = "echo logged-in" + +[adapters.cloudflare.adapter] +crate = "crates/demo-cloudflare" +"#; + + /// The versions API answer: 6 inactive, 7 active, 8 staged. + const VERSIONS: &str = r#"[{"number":6,"active":false,"locked":true,"staging":false,"deployed":true,"environments":[]},{"number":7,"active":true,"locked":true,"staging":false,"deployed":true,"environments":[]},{"number":8,"active":false,"locked":true,"staging":false,"deployed":false,"environments":[{"active_version":8,"name":"staging","service_id":"SVC1"}]}]"#; + + /// `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", + ]; + + const ACTIVE_VERSION: [&str; 5] = [ + "active-version", + "--adapter", + "fastly", + "--service-id", + "SVC1", + ]; + + const ROLLBACK: [&str; 9] = [ + "rollback", + "--adapter", + "fastly", + "--service-id", + "SVC1", + "--version", + "7", + "--rollback-to", + "6", + ]; + + const DEPLOY: [&str; 5] = ["deploy", "--adapter", "fastly", "--service-id", "SVC1"]; + + /// A temp project holding [`MANIFEST`] as `edgezero.toml`. + fn project() -> TempDir { + project_with(MANIFEST) + } + + /// A temp project holding `manifest` as `edgezero.toml`. + fn project_with(manifest: &str) -> TempDir { + let dir = TempDir::new().expect("temp dir"); + fs::write(dir.path().join("edgezero.toml"), manifest).expect("write manifest"); + dir + } + + /// `edgezero ` in `dir`, with every variable that could change a + /// result removed: the Fastly credentials, and the `EDGEZERO__*` and + /// `DEMO_APP__*` overlays that `provision`, `config validate` and + /// `config gc` read. + fn command(dir: &Path, args: &[&str]) -> Command { + let mut command = Command::new(env!("CARGO_BIN_EXE_edgezero")); + command + .args(args) + .current_dir(dir) + .env("EDGEZERO_MANIFEST", dir.join("edgezero.toml")) + .env_remove("FASTLY_API_TOKEN") + .env_remove("FASTLY_SERVICE_ID"); + for (key, _) in env::vars_os() { + let name = key.to_string_lossy(); + if name.starts_with("EDGEZERO__") || name.starts_with("DEMO_APP__") { + command.env_remove(&key); + } + } + command + } + + /// Put `bin_dir` first on the command's `PATH`. + fn prepend_path(command: &mut Command, bin_dir: &Path) { + let path = env::var_os("PATH").unwrap_or_default(); + let mut paths = vec![bin_dir.to_path_buf()]; + paths.extend(env::split_paths(&path)); + command.env("PATH", env::join_paths(paths).expect("join PATH")); + } + + /// Run `edgezero ` in `dir`, optionally with `bin_dir` first on `PATH`. + fn edgezero(dir: &Path, args: &[&str], bin_dir: Option<&Path>) -> Output { + edgezero_with_token(dir, args, bin_dir, None) + } + + /// [`edgezero`], with `FASTLY_API_TOKEN` set to `token` (removed when `None`). + fn edgezero_with_token( + dir: &Path, + args: &[&str], + bin_dir: Option<&Path>, + token: Option<&str>, + ) -> Output { + let mut command = command(dir, args); + if let Some(value) = token { + command.env("FASTLY_API_TOKEN", value); + } + if let Some(bin) = bin_dir { + prepend_path(&mut command, bin); + } + 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 { + fake_curl_script(&format!("echo {code}")) + } + + /// A fake `curl` that also serves the Fastly API: a request read from + /// `--config -` gets [`VERSIONS`] (GET) or a bare 200 (PUT), and is logged + /// to `api.log` as `GET` / `PUT`. A health probe gets HTTP `code`. + fn fake_curl_api(code: u16) -> TempDir { + fake_curl_script(&format!( + "case \" $* \" in *\" --config \"*)\n\ + \x20 cfg=$(cat)\n\ + \x20 case \"$cfg\" in *'request = \"PUT\"'*) echo PUT >> \"$(dirname \"$0\")/api.log\"; printf 200;;\n\ + \x20 *) echo GET >> \"$(dirname \"$0\")/api.log\"; printf '%s\\n200' '{VERSIONS}';; esac\n\ + \x20 exit 0;;\n\ + esac\n\ + echo {code}" + )) + } + + fn fake_curl_script(body: &str) -> TempDir { + fake_binary("curl", body) + } + + /// A temp dir holding an executable `name` that runs the shell `body`. + fn fake_binary(name: &str, body: &str) -> TempDir { + let dir = TempDir::new().expect("temp dir"); + let script = dir.path().join(name); + fs::write(&script, format!("#!/bin/sh\n{body}\n")).expect("write fake binary"); + let mut perms = fs::metadata(&script).expect("meta").permissions(); + perms.set_mode(0o755); + fs::set_permissions(&script, perms).expect("chmod +x"); + dir + } + + /// A fake `fastly` for `config gc`: one config store (`app_config`, id + /// `store-1`) holding only a plain `greeting` entry, so there is nothing to + /// reclaim. Any `delete` is logged to `deletes.log` next to the script. + fn fake_fastly_gc() -> TempDir { + fake_binary( + "fastly", + "case \"$1 $2\" in\n\ + \x20 'config-store list') echo '[{\"name\":\"app_config\",\"id\":\"store-1\"}]';;\n\ + \x20 'config-store-entry list') echo '[{\"item_key\":\"greeting\",\"item_value\":\"hello\",\"created_at\":\"2026-07-01T00:00:00Z\"}]';;\n\ + \x20 'config-store-entry delete') echo \"$*\" >> \"$(dirname \"$0\")/deletes.log\";;\n\ + esac", + ) + } + + /// A fake `fastly` whose `compute update` makes draft version 8. + fn fake_fastly_staging() -> TempDir { + fake_binary( + "fastly", + "if [ \"$1 $2\" = 'compute update' ]; then \ + echo 'SUCCESS: Updated package (service SVC1, version 8)'; fi", + ) + } + + /// A project holding `manifest` and an empty `fastly.toml`. + fn fastly_project(manifest: &str) -> TempDir { + let dir = project_with(manifest); + fs::write(dir.path().join("fastly.toml"), "name = \"app\"\n").expect("fastly.toml"); + dir + } + + /// The text stdout of a manifest command: the `[edgezero] executing …` + /// line, then what the command printed (`child_output`). + fn executed(command: &str, adapter: &str, dir: &Path, child_output: &str) -> String { + format!( + "[edgezero] executing `{command}` for adapter `{adapter}` in {}\n{child_output}", + dir.display() + ) + } + + /// The Fastly API calls a [`fake_curl_api`] served, in order. + fn api_calls(curl: &TempDir) -> Vec { + fs::read_to_string(curl.path().join("api.log")) + .unwrap_or_default() + .lines() + .map(str::to_owned) + .collect() + } + + #[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)); + assert_eq!( + stdout_of(&output), + executed( + "echo child-wrote-to-stdout", + "axum", + dir.path(), + "child-wrote-to-stdout\n" + ) + ); + assert!(output.stderr.is_empty(), "stderr: {}", stderr_of(&output)); + } + + #[test] + 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": "demo-app.toml", "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)); + } + + #[test] + fn json_build_rejects_a_format_after_passthrough_args() { + let dir = project(); + let output = edgezero( + dir.path(), + &[ + "build", + "--adapter", + "axum", + "--release", + "--format", + "json", + ], + None, + ); + assert_eq!(output.status.code(), Some(1_i32)); + // The late `--format` was never read, so this is a text-mode failure: + // nothing on stdout, and the build command never ran. + assert!(output.stdout.is_empty(), "stdout: {:?}", stdout_of(&output)); + let stderr = stderr_of(&output); + assert!( + stderr.contains("`--format` after a passthrough argument"), + "stderr: {stderr}" + ); + assert!( + !stderr.contains("child-wrote-to-stdout"), + "stderr: {stderr}" + ); + } + + #[test] + fn build_rejects_every_passthrough_format_spelling() { + let dir = project(); + for args in [ + &["build", "--adapter", "axum", "--release", "--format=json"][..], + // clap drops a leading `--`, so this cannot be told apart from a + // misplaced flag: `build` never forwards `--format`. + &["build", "--adapter", "axum", "--", "--format", "json"][..], + ] { + let output = edgezero(dir.path(), args, None); + assert_eq!(output.status.code(), Some(1_i32), "{args:?}"); + assert!( + output.stdout.is_empty(), + "{args:?}: {:?}", + stdout_of(&output) + ); + assert!( + stderr_of(&output).contains("after a passthrough argument"), + "{args:?}: {}", + stderr_of(&output) + ); + } + } + + #[test] + fn json_config_validate_failure_has_no_result() { + // No `demo-app.toml`: the raw validator requires it. + let dir = project(); + let output = edgezero( + dir.path(), + &["config", "validate", "--format", "json"], + None, + ); + assert_eq!(output.status.code(), Some(1_i32)); + let envelope = sole_json_document(&output); + assert_eq!(envelope["ok"], json!(false)); + assert!(envelope["result"].is_null(), "{envelope}"); + let message = envelope["error"]["message"] + .as_str() + .expect("error message"); + assert!(message.contains("demo-app.toml"), "{message}"); + } + + #[test] + fn json_healthcheck_with_a_token_verifies_the_version() { + let dir = project(); + let curl = fake_curl_api(200); + let mut args = HEALTHCHECK.to_vec(); + args.extend(["--format", "json"]); + let output = edgezero_with_token(dir.path(), &args, Some(curl.path()), Some("tok")); + assert!(output.status.success(), "stderr: {}", stderr_of(&output)); + let envelope = sole_json_document(&output); + assert_eq!(envelope["result"]["healthy"], json!(true)); + assert_eq!(envelope["result"]["version_verified"], json!(true)); + assert_eq!( + api_calls(&curl), + ["GET", "GET"], + "before and after the probe" + ); + } + + #[test] + fn json_unhealthy_healthcheck_with_a_token_is_not_verified() { + let dir = project(); + let curl = fake_curl_api(503); + let mut args = HEALTHCHECK.to_vec(); + args.extend(["--format", "json"]); + let output = edgezero_with_token(dir.path(), &args, Some(curl.path()), Some("tok")); + assert_eq!(output.status.code(), Some(1_i32)); + let envelope = sole_json_document(&output); + assert_eq!(envelope["result"]["healthy"], json!(false)); + assert_eq!( + envelope["result"]["version_verified"], + json!(false), + "the after-probe check never ran" + ); + assert_eq!(api_calls(&curl), ["GET"]); + } + + #[test] + fn active_version_text_and_json() { + let dir = project(); + let curl = fake_curl_api(200); + let text = edgezero_with_token(dir.path(), &ACTIVE_VERSION, Some(curl.path()), Some("t")); + assert!(text.status.success(), "stderr: {}", stderr_of(&text)); + assert_eq!(stdout_of(&text), "version=7\n"); + + let mut args = ACTIVE_VERSION.to_vec(); + args.extend(["--format", "json"]); + let output = edgezero_with_token(dir.path(), &args, Some(curl.path()), Some("t")); + assert!(output.status.success(), "stderr: {}", stderr_of(&output)); + assert_eq!( + sole_json_document(&output)["result"], + json!({"adapter": "fastly", "service_id": "SVC1", "version": 7_i32}) + ); + assert!(stderr_of(&output).contains("version=7\n")); + } + + #[test] + fn active_version_with_none_active_reports_null() { + let dir = project(); + // Overwrite the API answer: no version is active yet. + let curl = fake_curl_script( + "case \" $* \" in *\" --config \"*) cat >/dev/null; \ + printf '%s\\n200' '[{\"number\":1,\"active\":false,\"locked\":false,\"staging\":false,\"deployed\":false,\"environments\":[]}]'; exit 0;; esac", + ); + let text = edgezero_with_token(dir.path(), &ACTIVE_VERSION, Some(curl.path()), Some("t")); + assert!(text.status.success(), "stderr: {}", stderr_of(&text)); + assert_eq!( + stdout_of(&text), + "version=\nservice SVC1 has no active version yet; emitting an empty rollback target\n" + ); + + let mut args = ACTIVE_VERSION.to_vec(); + args.extend(["--format", "json"]); + let output = edgezero_with_token(dir.path(), &args, Some(curl.path()), Some("t")); + assert!(output.status.success(), "stderr: {}", stderr_of(&output)); + assert_eq!( + sole_json_document(&output)["result"]["version"], + json!(null) + ); + } + + #[test] + fn rollback_text_and_json() { + let dir = project(); + let curl = fake_curl_api(200); + let text = edgezero_with_token(dir.path(), &ROLLBACK, Some(curl.path()), Some("t")); + assert!(text.status.success(), "stderr: {}", stderr_of(&text)); + assert_eq!(stdout_of(&text), "rolled-back-to=6\n"); + + let mut args = ROLLBACK.to_vec(); + args.extend(["--format", "json"]); + let output = edgezero_with_token(dir.path(), &args, Some(curl.path()), Some("t")); + assert!(output.status.success(), "stderr: {}", stderr_of(&output)); + assert_eq!( + sole_json_document(&output)["result"], + json!({ + "adapter": "fastly", "rolled_back_to": 6_i32, "service_id": "SVC1", + "staging": false, "version": 7_i32 + }) + ); + assert!(stderr_of(&output).contains("rolled-back-to=6\n")); + } + + #[test] + fn deploy_reads_the_version_from_the_captured_output() { + let dir = project_with(FASTLY_MANIFEST); + // The version parsed from the output is verified active via the API. + let curl = fake_curl_script( + "case \" $* \" in *\" --config \"*) cat >/dev/null; \ + printf '%s\\n200' '[{\"number\":12,\"active\":true,\"locked\":true,\"staging\":false,\"deployed\":true,\"environments\":[]}]'; exit 0;; esac", + ); + let text = edgezero_with_token(dir.path(), &DEPLOY, Some(curl.path()), Some("t")); + assert!(text.status.success(), "stderr: {}", stderr_of(&text)); + // The manifest command receives `--service-id SVC1` as passthrough. The + // tee echoes the child live, then the CLI prints the version line. + assert_eq!( + stdout_of(&text), + executed( + "echo 'SUCCESS: Deployed package (service SVC1, version 12)'", + "fastly", + dir.path(), + "SUCCESS: Deployed package (service SVC1, version 12) --service-id SVC1\n\ + version=12\n" + ) + ); + + let mut args = DEPLOY.to_vec(); + args.extend(["--format", "json"]); + let output = edgezero_with_token(dir.path(), &args, Some(curl.path()), Some("t")); + assert!(output.status.success(), "stderr: {}", stderr_of(&output)); + assert_eq!( + sole_json_document(&output)["result"], + json!({"adapter": "fastly", "service_id": "SVC1", "staging": false, "version": 12_i32}) + ); + // The tee sends the captured child's stdout to stderr under JSON. + let stderr = stderr_of(&output); + assert!( + stderr.contains("SUCCESS: Deployed package (service SVC1, version 12) --service-id"), + "stderr: {stderr}" + ); + assert!(stderr.contains("version=12\n"), "stderr: {stderr}"); + } + + #[test] + fn deploy_that_went_live_without_a_version_keeps_its_result() { + let manifest = FASTLY_MANIFEST.replace( + "echo 'SUCCESS: Deployed package (service SVC1, version 12)'", + "echo deployed", + ); + let dir = project_with(&manifest); + let mut args = DEPLOY.to_vec(); + args.extend(["--format", "json"]); + // No token: the API fallback cannot resolve the version either. + let output = edgezero(dir.path(), &args, None); + assert_eq!(output.status.code(), Some(1_i32)); + let envelope = sole_json_document(&output); + assert_eq!(envelope["ok"], json!(false)); + assert_eq!( + envelope["result"], + json!({"adapter": "fastly", "service_id": "SVC1", "staging": false, "version": null}), + "the deploy went live, so the result is present" + ); + let message = envelope["error"]["message"] + .as_str() + .expect("error message"); + assert!(message.starts_with("deploy succeeded but"), "{message}"); + } + + #[test] + fn auth_status_text_failure_is_unchanged() { + let dir = project(); + let output = edgezero(dir.path(), &["auth", "status", "--adapter", "axum"], None); + assert_eq!(output.status.code(), Some(1_i32)); + assert_eq!( + stdout_of(&output), + executed( + "echo child-wrote-to-stdout; false", + "axum", + dir.path(), + "child-wrote-to-stdout\n" + ) + ); + assert_eq!( + stderr_of(&output), + "[edgezero] auth status command `echo child-wrote-to-stdout; false` exited with \ + status exit status: 1\n" + ); + } + + #[test] + fn auth_status_text_and_json_when_authenticated() { + let dir = project_with(AUTH_MANIFEST); + let text = edgezero(dir.path(), &["auth", "status", "--adapter", "axum"], None); + assert!(text.status.success(), "stderr: {}", stderr_of(&text)); + assert_eq!( + stdout_of(&text), + executed("echo logged-in", "axum", dir.path(), "logged-in\n") + ); + + let output = edgezero( + dir.path(), + &["auth", "status", "--adapter", "axum", "--format", "json"], + None, + ); + assert!(output.status.success(), "stderr: {}", stderr_of(&output)); + assert_eq!( + sole_json_document(&output), + json!({ + "command": "auth status", + "error": null, + "ok": true, + "result": {"adapter": "axum", "state": "authenticated"}, + "schema_version": 1_i32 + }) + ); + assert!(stderr_of(&output).contains("logged-in\n")); + } + + #[test] + fn auth_status_without_the_native_cli_has_no_result() { + let dir = project_with(AUTH_MANIFEST); + let empty_path = TempDir::new().expect("temp dir"); + let output = command( + dir.path(), + &[ + "auth", + "status", + "--adapter", + "cloudflare", + "--format", + "json", + ], + ) + .env("PATH", empty_path.path()) + .output() + .expect("run edgezero"); + assert_eq!(output.status.code(), Some(1_i32)); + let envelope = sole_json_document(&output); + assert_eq!(envelope["ok"], json!(false)); + assert!(envelope["result"].is_null(), "{envelope}"); + let message = envelope["error"]["message"] + .as_str() + .expect("error message"); + assert!( + message.contains("`wrangler` not found on PATH"), + "{message}" + ); + } + + #[test] + fn provision_text_is_unchanged() { + let dir = project(); + let output = edgezero(dir.path(), &["provision", "--adapter", "axum"], None); + assert!(output.status.success(), "stderr: {}", stderr_of(&output)); + assert_eq!( + stdout_of(&output), + "axum KV store `sessions` is in-memory; nothing to provision\n" + ); + assert!(output.stderr.is_empty(), "stderr: {}", stderr_of(&output)); + } + + #[test] + fn config_validate_text_is_unchanged() { + let dir = project(); + // No `demo-app.toml` yet: the failure goes to stderr only. + let failed = edgezero(dir.path(), &["config", "validate"], None); + assert_eq!(failed.status.code(), Some(1_i32)); + assert!(failed.stdout.is_empty(), "stdout: {:?}", stdout_of(&failed)); + assert_eq!( + stderr_of(&failed), + "[edgezero] failed to read demo-app.toml: No such file or directory (os error 2)\n" + ); + + fs::write(dir.path().join("demo-app.toml"), "").expect("write app config"); + let output = edgezero(dir.path(), &["config", "validate"], None); + assert!(output.status.success(), "stderr: {}", stderr_of(&output)); + assert_eq!( + stdout_of(&output), + "[edgezero] config validate (raw): edgezero.toml OK\n" + ); + } + + /// The `config gc` report lines for the [`fake_fastly_gc`] store, with the + /// `older_than` window `secs`. + fn gc_report_lines(secs: u64) -> String { + format!( + "[edgezero] fastly config-store `app_config` (id=store-1): 1 entries, 1 root(s), 0 \ + referenced chunk(s), 0 orphan(s) in 0 generation(s) older than {secs}s, 0 orphan(s) \ + too recent\n\ + [edgezero] keeping 1 retained root(s) (0 referenced chunk(s) held by them):\n\ + [edgezero] keeping `greeting`\n\ + [edgezero] nothing to reclaim\n" + ) + } + + #[test] + fn config_gc_dry_run_text_and_json() { + let dir = fastly_project(FASTLY_GC_MANIFEST); + let fastly = fake_fastly_gc(); + let text = edgezero( + dir.path(), + &["config", "gc", "--adapter", "fastly"], + Some(fastly.path()), + ); + assert!(text.status.success(), "stderr: {}", stderr_of(&text)); + let stdout = stdout_of(&text); + assert!( + stdout.starts_with(&gc_report_lines(0)), + "the report, then the dry-run advisory: {stdout:?}" + ); + let advisory = stdout.strip_prefix(&gc_report_lines(0)).unwrap_or_default(); + assert!( + advisory.starts_with("[edgezero] dry-run: previewing ALL orphans") + && advisory.ends_with("(a bare `--yes` is rejected).\n") + && advisory.lines().count() == 1, + "one advisory line: {advisory:?}" + ); + + // No `--yes`: a dry run by default, with no `--older-than` window. + let output = edgezero( + dir.path(), + &["config", "gc", "--adapter", "fastly", "--format", "json"], + Some(fastly.path()), + ); + assert!(output.status.success(), "stderr: {}", stderr_of(&output)); + assert_eq!( + sole_json_document(&output)["result"], + json!({ + "adapter": "fastly", "deleted": null, "dry_run": true, "failed": [], + "kept_roots": ["greeting"], "older_than_secs": null, "planned_deletions": [], + "store": {"id": "store-1", "logical": "app_config", "platform": "app_config"}, + "stranded": [], + "summary": { + "entries": 1_i32, "generations_planned": 0_i32, "orphans_planned": 0_i32, + "orphans_too_recent": 0_i32, "referenced_chunks": 0_i32, "roots": 1_i32, + "unprovable": 0_i32 + }, + "uncertain": [], "warnings": [] + }) + ); + assert!(stderr_of(&output).contains(&gc_report_lines(0))); + assert!( + !fastly.path().join("deletes.log").exists(), + "nothing deleted" + ); + } + + #[test] + fn config_gc_real_run_with_nothing_to_reclaim_reports_zero_deleted() { + let dir = fastly_project(FASTLY_GC_MANIFEST); + let fastly = fake_fastly_gc(); + let args = [ + "config", + "gc", + "--adapter", + "fastly", + "--yes", + "--older-than", + "1h", + ]; + let text = edgezero(dir.path(), &args, Some(fastly.path())); + assert!(text.status.success(), "stderr: {}", stderr_of(&text)); + assert_eq!(stdout_of(&text), gc_report_lines(3600)); + + let mut json_args = args.to_vec(); + json_args.extend(["--format", "json"]); + let output = edgezero(dir.path(), &json_args, Some(fastly.path())); + assert!(output.status.success(), "stderr: {}", stderr_of(&output)); + let result = &sole_json_document(&output)["result"]; + assert_eq!(result["dry_run"], json!(false)); + assert_eq!(result["deleted"], json!(0_i32), "a real run, not a dry run"); + assert_eq!(result["older_than_secs"], json!(3600_i32)); + assert_eq!(stderr_of(&output), gc_report_lines(3600)); + assert!( + !fastly.path().join("deletes.log").exists(), + "nothing deleted" + ); + } + + #[test] + fn staged_deploy_without_a_release_fails_with_a_json_envelope() { + let dir = fastly_project(FASTLY_STAGING_MANIFEST); + let fastly = fake_fastly_staging(); + // A staged deploy is adapter-managed and needs an immutable release; + // without one it fails before any provider call, so no result exists. + let mut command = command( + dir.path(), + &[ + "deploy", + "--adapter", + "fastly", + "--staging", + "--format", + "json", + ], + ); + prepend_path(&mut command, fastly.path()); + let output = command + .env("FASTLY_API_TOKEN", "t") + .env("FASTLY_SERVICE_ID", "SVC1") + .output() + .expect("run edgezero"); + assert_eq!(output.status.code(), Some(1_i32)); + let envelope = sole_json_document(&output); + assert_eq!(envelope["command"], json!("deploy")); + assert_eq!(envelope["ok"], json!(false)); + assert_eq!(envelope["result"], json!(null)); + let message = envelope["error"]["message"] + .as_str() + .expect("error message"); + assert!(message.contains("--application-release"), "{message}"); + } + + #[test] + fn staged_rollback_text_and_json() { + let dir = project(); + let curl = fake_curl_api(200); + let args = [ + "rollback", + "--adapter", + "fastly", + "--service-id", + "SVC1", + "--version", + "8", + "--staging", + ]; + let line = "[edgezero] deactivated staged version 8 on Fastly service SVC1\n"; + let text = edgezero_with_token(dir.path(), &args, Some(curl.path()), Some("t")); + assert!(text.status.success(), "stderr: {}", stderr_of(&text)); + assert_eq!(stdout_of(&text), line); + // The staged version is confirmed from the version list before deactivating. + assert_eq!(api_calls(&curl), ["GET", "PUT"]); + + let mut json_args = args.to_vec(); + json_args.extend(["--format", "json"]); + let output = edgezero_with_token(dir.path(), &json_args, Some(curl.path()), Some("t")); + assert!(output.status.success(), "stderr: {}", stderr_of(&output)); + assert_eq!( + sole_json_document(&output)["result"], + json!({ + "adapter": "fastly", "rolled_back_to": null, "service_id": "SVC1", + "staging": true, "version": 8_i32 + }) + ); + assert_eq!(stderr_of(&output), line); + } +} diff --git a/crates/edgezero-cli/tests/generated_project_builds.rs b/crates/edgezero-cli/tests/generated_project_builds.rs index be2808852..fde062dda 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::env; use std::path::Path; 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/guide/cli-reference.md b/docs/guide/cli-reference.md index 3cdd6dc95..9937537c3 100644 --- a/docs/guide/cli-reference.md +++ b/docs/guide/cli-reference.md @@ -85,6 +85,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:** @@ -159,6 +160,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. - `--application-release ` - Extracted application release root. The @@ -254,13 +256,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 @@ -275,7 +278,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:** @@ -289,6 +292,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 curls the domain; with the token present it also verifies that @@ -304,7 +308,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:** @@ -314,6 +318,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 @@ -329,7 +334,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:** @@ -338,6 +343,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:** @@ -500,7 +506,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:** @@ -512,6 +518,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 @@ -578,9 +585,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 | @@ -630,9 +644,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, @@ -666,6 +684,238 @@ server reads secrets from process env vars (`EDGEZERO__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, on a +`build` that rejects a `--format` among its passthrough arguments (exit `1`, +see [`build`](#results)), and on a crash. Treat it as a failure. + +`--help` is handled by clap before `--format` is read: it prints clap's text to +stdout and exits `0`, with no envelope. + +The routing relies on the EdgeZero logger (`edgezero_cli::init_cli_logger()`). +A downstream CLI that installs a different logger, such as `simple_logger`, +which writes every level to stdout, will put log lines on stdout and break the +one-document rule. + +### 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. In a +generated CLI, `2` also means a usage error or an unsupported `config diff`; +only the envelope tells them apart, and an empty stdout means there is none. +In the bundled binary, a `1` with an empty stdout usually means a `build` that +rejected a late `--format`. + +### Compatibility + +`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 + +Each table lists every key of a command's `result`. **Nullable** says whether +the key can be `null` under schema version `1`; a key marked "no" never is. +Every result except `config validate`'s also has a non-null `adapter` string. + +**`active-version`** + +| Key | Type | Nullable | Meaning | +| ------------ | ------- | -------- | --------------------------------------------------- | +| `service_id` | string | no | The service queried. | +| `version` | integer | yes | The active version; `null` when none is active yet. | + +**`auth status`** + +| Key | Type | Nullable | Meaning | +| ------- | ------ | -------- | --------------------------------------------------------------- | +| `state` | string | no | `authenticated`, `unauthenticated`, or `not_applicable` (axum). | + +`state` reflects the probe command's exit status only (the probes are listed +under [Adapter built-ins](#edgezero-auth)): `authenticated` means the probe +exited `0`, not that a token was checked. Some `wrangler whoami` versions exit +`0` when logged out, and `fastly profile list` only lists saved profiles. +`unauthenticated` gives `ok: false` with the result present. A missing native +CLI gives `result: null`. + +**`build`** + +| Key | Type | Nullable | Meaning | +| ---------- | ------ | -------- | -------------------------------------------------------------------------------------------------------------------- | +| `artifact` | string | yes | The built file's path; `null` when a manifest `commands.build` override ran or the adapter does not track it (axum). | + +`--format` must come before the first passthrough argument. A `--format` (or +`--format=…`) among the passthrough arguments is rejected with exit `1` rather +than forwarded to the build command; stdout is empty unless `--format json` +also came before the passthrough arguments. This includes +`build --adapter -- --format json`: `build` cannot forward a `--format`. + +**`deploy`** + +| Key | Type | Nullable | Meaning | +| ------------ | ------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| `service_id` | string | yes | The service the adapter resolved (a Fastly `--staging` deploy falls back to `FASTLY_SERVICE_ID`), else `--service-id` as passed, else `null`. | +| `staging` | boolean | no | Whether `--staging` was passed. | +| `version` | integer | yes | The staged version for `--staging`, the activated version for a Fastly production deploy with `--service-id`, and `null` otherwise. | + +A production deploy reads only `--service-id`, so a Fastly production deploy +that relies on `FASTLY_SERVICE_ID` reports `service_id: null`. + +A Fastly production deploy that went live but whose version could not be +resolved gives `ok: false` with the result present and `version: null`. Check +`result`, not only `ok`, before assuming nothing was deployed. + +**`healthcheck`** + +| Key | Type | Nullable | Meaning | +| ------------------ | ------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| `service_id` | string | no | The service probed. | +| `domain` | string | no | The probed domain. | +| `path` | string | no | The probed path. | +| `version` | integer | no | The version under test. | +| `staging` | boolean | no | Whether the staging IP was probed. | +| `staging_ip` | string | yes | The resolved staging IP; `null` for a production probe. | +| `healthy` | boolean | no | Whether the probe passed. | +| `status_code` | integer | yes | The last HTTP status; `null` when no probe got a response. | +| `attempts` | integer | no | The number of probes actually made. | +| `version_verified` | boolean | no | `true` when the version was confirmed active before and after the probe: a healthy production probe with `FASTLY_API_TOKEN` set. Otherwise `false`. | + +An unhealthy probe gives `ok: false` with the result present. + +**`rollback`** + +| Key | Type | Nullable | Meaning | +| ---------------- | ------- | -------- | ---------------------------------------------------------------- | +| `service_id` | string | no | The service rolled back. | +| `staging` | boolean | no | Whether a staged version was deactivated. | +| `version` | integer | no | The version rolled back from, or the staged version deactivated. | +| `rolled_back_to` | integer | yes | The re-activated version; `null` for `--staging`. | + +**`provision`** + +| Key | Type | Nullable | Meaning | +| --------- | ------- | -------- | ------------------------------- | +| `dry_run` | boolean | no | Whether `--dry-run` was passed. | +| `entries` | array | no | One entry per step, in order. | + +Each entry: + +| Key | Type | Nullable | Meaning | +| ---------------- | ------ | -------- | ----------------------------------------------------------------------------------------------------- | +| `action` | string | no | `created`, `already_present`, `would_create`, `updated`, `would_update`, `not_applicable`, or `note`. | +| `store` | object | yes | `{ kind, logical, platform }`; `null` for an adapter-level note. | +| `store.kind` | string | no | `config`, `kv`, or `secrets`. | +| `store.logical` | string | yes | The manifest id; `null` for a store EdgeZero owns itself, such as Fastly's `edgezero_runtime_env`. | +| `store.platform` | string | no | The platform name the runtime resolves. | +| `message` | string | no | The line `text` mode prints. It may span several lines and is meant for people, not parsing. | + +A dry run's `would_create` pairs with a real run's `created` or +`already_present`, and `would_update` with `updated`. The Fastly and Spin dry +runs report the plan without checking the current state, so a store that +already exists still shows `would_create`. Fastly's real run also omits the +`edgezero_runtime_env` entry when `fastly.toml` already declares it. + +**`config gc`** + +| Key | Type | Nullable | Meaning | +| ------------------- | ------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| `store` | object | no | `{ logical, platform, id }`. `id`, the platform's store id, is nullable. | +| `dry_run` | boolean | no | `true` unless `--yes` was passed. | +| `older_than_secs` | integer | yes | The `--older-than` window; `null` without `--older-than`. | +| `summary` | object | no | The counts `entries`, `roots`, `referenced_chunks`, `orphans_planned`, `generations_planned`, `orphans_too_recent`, and `unprovable`. | +| `kept_roots` | array | no | The retained root keys. | +| `planned_deletions` | array | no | `{ key, age_secs }` for each planned delete. | +| `deleted` | integer | yes | Entries deleted; `null` on a dry run, and `0` for a real run with nothing to reclaim. | +| `failed` | array | no | Keys whose delete failed. | +| `stranded` | array | no | Keys left in a generation that lost a sibling to a confirmed delete. Remove these by hand. | +| `uncertain` | array | no | Keys whose delete outcome is unknown; a re-run may reclaim them. | +| `warnings` | array | no | Warnings about the store's contents. | + +If any delete failed, the command gives `ok: false` with the result present, +and `error.message` carries the recovery commands. + +**`config validate`** + +| Key | Type | Nullable | Meaning | +| ------------ | ------- | -------- | ---------------------------------------------------------- | +| `mode` | string | no | `raw` for the bundled binary, `typed` for a generated CLI. | +| `manifest` | string | no | The validated manifest. | +| `app_config` | string | no | The app-config file both modes read and require. | +| `app_name` | string | no | The app name from the manifest. | +| `strict` | boolean | no | Whether `--strict` was passed. | + +A validation failure gives `ok: false` and `result: null`, with the first +failing check in `error.message`. + +### Schema changelog + +- **1**: initial version. + ## Environment Variables The CLI respects these environment variables: 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..968bd1442 --- /dev/null +++ b/docs/superpowers/specs/2026-09-25-cli-format-json-design.md @@ -0,0 +1,759 @@ +# Machine-readable CLI output (`--format json`) — Design + +- **Issue:** [#383](https://github.com/stackpop/edgezero/issues/383) +- **Status:** Proposed, awaiting maintainer approval; implemented in + [#388](https://github.com/stackpop/edgezero/pull/388) (§13 records what + implementation and review changed) +- **Date:** 2026-09-25 +- **Delivery:** a single implementation PR ([#388](https://github.com/stackpop/edgezero/pull/388)) + +## 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. The one exception is `build` with a `--format` + token among its passthrough arguments, which is now rejected (§5.2). +3. Under `--format json`, no byte on stdout is anything other than the + envelope. This holds by construction and is enforced by lint and tests, + not by convention. +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. A `--format` (or + `--format=…`) token among the passthrough arguments is rejected with exit 1 + and an empty stdout, instead of being forwarded and silently printing text. + clap drops a leading `--`, so `build --adapter x -- --format json` is + rejected too: `build` cannot forward a `--format` to its build command. No + built-in build command takes one. +- `deploy` passes arguments through only after `--`, so `--format` can go + anywhere before `--`. + +### 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`, a deploy that went live without a resolvable version), and is `null` otherwise. | +| `error` | object or `null` | `null` exactly when `ok` is `true`. Otherwise `{ "message": string }`. | + +**Field conventions**, applied to the envelope and every result: + +- 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, a `build` that rejects a late + `--format` (§5.2), and panics, and consumers must treat it as failure. + +### 6.3 Where the envelope is written + +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 | +| `build` with `--format` among its passthrough arguments (§5.2) | 1 | empty | +| `--help` (clap) | 0 | clap's text, not an envelope | + +In a generated CLI, exit code 2 covers a command failure, a usage error and an +unsupported `config diff`. Only the envelope tells them apart: an empty stdout +means no envelope was produced. + +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), // { state: AuthState } + Build(BuildOutcome), // { artifact: Option } + Deploy(DeployOutcome), // { service_id: Option, version: Option } + Empty, // login, logout, serve, production deploy, + // manifest command overrides + Healthcheck(HealthcheckOutcome), // { attempts, domain, failure: Option, path, + // service_id, staging, staging_ip, status_code, + // version, version_verified }; healthy() derives + // from `failure` + Rollback(RollbackOutcome), // { rolled_back_to, service_id, staging, version } +} + +pub enum AuthState { Authenticated, NotApplicable, Unauthenticated { reason: String } } + +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 has `failure: Some(message)`, so `healthy()` is + `false`; +- a non-zero exit from `auth status` is `Unauthenticated { reason }` + (`cli_support::native_auth_status`, or a manifest `auth-status` command); +- a gc run with failed deletes has `failure: Some(GcFailure)`, whose + `diagnostic` holds exactly the error text it returned before. + +The CLI turns these into a non-zero exit, using the same message as before. +`Err(String)` is kept for "could not determine a result": bad arguments, API +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 failure: Option, // None unless a delete failed + 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 text_lines: Vec, // text-mode lines only; not on the wire + pub unprovable: usize, + pub warnings: Vec, +} + +pub struct GcFailure { + pub diagnostic: String, // the operator-facing report and recovery text + pub failed: Vec, // never empty + pub stranded: Vec, + pub uncertain: Vec, +} +``` + +**Data lines move from the Fastly adapter to the CLI.** The adapter used to +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. + +### `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 probe command's exit status, or from a manifest + `commands.auth_status` override. Output and account details are not parsed, + so `authenticated` means "the probe exited 0", not "a token works": some + `wrangler whoami` versions exit 0 when logged out, and `fastly profile list` + only lists saved profiles. + +### `build` + +```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 text mode (captured + `version=` output verified active through the API, else the API's active + version). +- Otherwise `version` is `null`. +- `service_id` is the id the adapter resolved (a Fastly staged deploy falls + back to `FASTLY_SERVICE_ID`), else `--service-id` as passed, else `null`. A + production deploy reads only `--service-id`, so one that relies on + `FASTLY_SERVICE_ID` reports `null`. +- A production deploy that went live but whose version could not be resolved + gives `ok: false` with `result` present and `version: null`. + +### `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 (a healthy production probe with an API + token). An unhealthy probe skips the after-probe check, so it is `false`. +- Unhealthy means `ok: false` with `result` present, `healthy: false`, and + `error.message` set to today's failure message. +- A failure before the probe (invalid arguments, version not active, staging + 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`. A dry run's `would_create` pairs + with a real run's `created` or `already_present`, and `would_update` with + `updated`. The Fastly and Spin dry runs report the plan without checking the + current state. +- `store` is `null` for adapter-level notes. `store.logical` is `null` for + stores EdgeZero owns rather than the manifest, such as Fastly's + `edgezero_runtime_env`. +- `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, and `0` for a real run with nothing to + reclaim. +- If any delete failed: `ok: false`, `result` present, and `error.message` set + to today's diagnostic, including the recovery commands. +- The dry-run advisory text goes to stderr and is not part of `result`. + +### `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 the app-config file both modes read and require. +- A validation failure gives `ok: false` and `result: null`, with the first + failing check in `error.message`. Validation stops at the first failure + today. +- 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 was first verified by running the branch and + `main` binaries through the same 21 hermetic scenarios and diffing stdout, + stderr and exit code. The text output of all nine commands is now pinned as + exact bytes in the end-to-end tests below, so the contract cannot drift. The + one intended change is `build` rejecting a passthrough `--format` (§5.2). +3. **End-to-end stream tests** (`edgezero-cli/tests/format_json.rs`). These run + `env!("CARGO_BIN_EXE_edgezero")` in a temp project, with fake `curl` / + `fastly` binaries on `PATH`, and need no network or credentials. The + harness clears `FASTLY_API_TOKEN`, `FASTLY_SERVICE_ID`, and every + `EDGEZERO__*` and `DEMO_APP__*` variable. Each JSON test asserts that stdout + parses as exactly one JSON value. There are 28 tests: + - **build:** child stdout reaches only stderr under JSON; exact text bytes; + a usage error leaves stdout empty; a passthrough `--format`, `--format=json` + or `-- --format json` is rejected with exit 1 and an empty stdout. + - **auth status:** a failing probe gives the `unauthenticated` partial + result; a passing probe gives `authenticated`; a missing native CLI gives + `result: null`; exact text bytes on success and failure. + - **provision** and **config validate:** the parsed structure, exact text + bytes, and a validate failure with `result: null`. + - **config gc:** a default dry run (`dry_run: true`, `deleted: null`, + `older_than_secs: null`) and a real run with nothing to reclaim + (`deleted: 0`, `older_than_secs: 3600`), both with exact text bytes. + - **healthcheck:** the exact line-contract bytes; an unhealthy probe's full + partial result; `version_verified` only when both API checks ran. + - **deploy:** the version read from captured output; a deploy that went live + without a version keeps its result; a staged deploy without + `--application-release` failing with `result: null`; exact text bytes. + - **rollback** and **active-version:** production and staged rollback, and + an active version present or absent, each in text and JSON. + - the bundled `config diff` stub still absorbs `--format json`. +4. **Argument parsing** (`args.rs`). Every in-scope command defaults to `Text` + and accepts `--format json` and `--format=text`. `unified` and `structured` + are rejected. `build --adapter fastly --format json --release` parses the + 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`, and exact-byte text tests for all nine commands (§9.2, §9.3). | +| A missed stdout writer corrupts JSON | The `print_stdout` deny, the `disallowed-methods` spawn lint, and the end-to-end test (§9.3). | +| JSON numbers above 2^53 lose precision in JavaScript consumers | Fastly service versions are small integers. u64 values are documented as JSON numbers, and none of today's fields comes close. | +| A native CLI reads from or checks stdout as a TTY and behaves differently when stdout is redirected to stderr | Only under `--format json`, which is new and opt-in. Interactive `auth login` is out of scope. | +| 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. +- **`AuthState::Unauthenticated` carries its reason**, so an unauthenticated + status keeps its exact error text. +- **The enums mapped to the wire are exhaustive**, not `#[non_exhaustive]` + (§7.2). +- **Wire types live in a single `output.rs`**, not an `output::wire` submodule. + +Revision 3 records the changes from the review of the implementation PR +(#388). They fix results that contradicted §8 before schema version 1 shipped. + +- **`healthcheck.version_verified`** is `true` only when both active-version + checks ran. It was `true` on an unhealthy probe with a token. +- **`config gc` `deleted`** is `0`, not `null`, for a real run with nothing to + reclaim. +- **`build`** rejects a `--format` that follows a passthrough argument instead + of forwarding it to the build command. +- **`config validate.app_config`** is always set, in both modes. +- **`deploy`** reports the adapter-resolved `service_id`, and keeps its result + when the deploy went live but the version could not be resolved. +- **Spin `provision`** reports `created` for an added label, pairing with the + dry run's `would_create`. +- **Outcome types hold each fact once**: `HealthcheckOutcome::healthy()` is + derived from `failure`, `AuthState::Unauthenticated` carries its reason, and + a failed gc is one `GcFailure`. +- **`std::io::stdout` is a disallowed method**, with an `#[expect]` at each of + the three deliberate writers. The generated project's `clippy.toml` carries + the same guard. +- `active-version` has no `--require-active` flag (only the CLI's internal + deploy fallback passes it), so §6.1 and §8 no longer promise a partial result + for it. + +Revision 4 records the changes from the second review of #388. None changes a +result shape. + +- **Status** is "Proposed, awaiting maintainer approval" until a maintainer + approves this spec on #383 or #388. +- **§7.2** now shows the outcome types as implemented: `failure` on + `HealthcheckOutcome` with a derived `healthy()`, `Unauthenticated { reason }`, + `DeployOutcome { service_id, version }`, and `GcReport.failure: Option`. +- **`build`'s passthrough `--format` rejection** is the one exception to + Goal 2, including `build -- --format json` (§5.2), and appears in the + empty-stdout cases (§6.2, §6.4). +- **`auth status` `authenticated`** is documented as "the probe exited 0" + (§8). +- **`healthcheck`** treats curl's `000` as a probe that got no response, not + as status `0`: `status_code` keeps the last real HTTP status, or `null` if + no probe got one. +- **The Fastly production deploy** returns `ActionOutcome::Empty`, like the + other adapters, and the staged deploy reports the service id it used. +- **A failed write of the envelope** no longer hides the command's own error: + the write error is logged and the command's outcome is returned. +- **`std::os::unix::process::CommandExt::exec`** is a disallowed method, in the + workspace and the generated project. +- **§9** lists the 28 end-to-end tests, including exact text bytes for all nine + commands. + +Revision 5 records the merge with #381, which moved deploy into the +`Adapter::preflight_deploy` / `deploy` / `finalize_deploy` hooks. No result +shape changes. + +- **`Adapter::deploy` and `Adapter::finalize_deploy`** return `ActionOutcome`, + like `execute`. A finalizer's `Deploy` outcome takes precedence over the + deploy's. +- **The CLI's `adapter::deploy`** returns a `DeployFailure` that separates a + failed deploy from a live deploy whose finalization failed, so the second + keeps its result with `version: null`. +- **A Fastly staged deploy** is adapter-managed and requires + `--application-release`; it reports the service and the staged version + from its deploy plan. Without a release it fails before any provider call. +- **A Fastly production deploy's version** is resolved by `finalize_deploy`: + a captured `version=` is verified active through the API, else the API's + active version is used. diff --git a/tests/fixtures/reusable-app/clippy.toml b/tests/fixtures/reusable-app/clippy.toml new file mode 100644 index 000000000..eae882f98 --- /dev/null +++ b/tests/fixtures/reusable-app/clippy.toml @@ -0,0 +1,9 @@ +# Clippy configuration for the lifecycle fixture workspace. Without this file +# clippy would use the EdgeZero root `clippy.toml`, whose `disallowed-methods` +# guard keeps the EdgeZero CLI's stdout clean for `--format json`. The fixture +# harness is a test driver that spawns and waits on processes directly, so it +# keeps only the test exemptions. +allow-expect-in-tests = true +allow-indexing-slicing-in-tests = true +allow-panic-in-tests = true +allow-unwrap-in-tests = true