Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions .github/actions/deploy-fastly/tests/assert-json-deploy-version.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
set -euo pipefail

# Runs the fixture release's CLI with `deploy --format json` against the
# stateful fake Fastly provider, and asserts that a deploy failing after it
# cloned version 42 still reports that version and its state.
#
# Args: the expected `version_state` (`draft` or `unknown`).
# Reads (env):
# GITHUB_WORKSPACE, RUNNER_TEMP, FAKE_CALL_LOG (from make-fake-fastly-env.sh)
# Writes: none

SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
# shellcheck source=../../fastly-common/scripts/common.sh
source "$SCRIPT_DIR/../../fastly-common/scripts/common.sh"

main() {
local expected_state="${1:?expected version_state is required}"
local workspace="${GITHUB_WORKSPACE:?GITHUB_WORKSPACE is required}"
local runner_temp="${RUNNER_TEMP:?RUNNER_TEMP is required}"
local root bin cli fastly_archive envelope rc=0
root=$(mktemp -d "$runner_temp/json-deploy-release.XXXXXX")
bin=$(mktemp -d "$runner_temp/json-deploy-bin.XXXXXX")
envelope="$bin/envelope.json"

tar -xzf "$workspace/fixture-release/app-release.tar.gz" -C "$root"
tar -xf "$root/$(jq -er '.app_cli.path' "$root/release.json")" -C "$bin"
cli="$bin/$(jq -er '."app-cli-bin"' "$bin/app-cli-meta.json")"
# make-fake-fastly-env.sh stages the fake `fastly` for install-fastly.sh;
# this script runs no action, so it puts it on PATH itself.
fastly_archive=$(find "$runner_temp/edgezero-action-tools/downloads" -name 'fastly-*-linux-amd64.tar.gz' | head -n 1)
[[ -n "$fastly_archive" ]] || fail "the fake Fastly CLI archive is missing"
tar -xzf "$fastly_archive" -C "$bin" fastly

PATH="$bin:$PATH" FASTLY_API_TOKEN=dummy-token \
EDGEZERO_MANIFEST="$root/$(jq -er '.manifests.edgezero.path' "$root/release.json")" \
"$cli" deploy --adapter fastly --format json --service-id dummyservice \
--application-release "$root" >"$envelope" || rc=$?

[[ "$rc" -ne 0 ]] || fail "the injected provider failure did not fail the deploy"
grep -q '^PUT .*/service/dummyservice/version/40/clone$' "$FAKE_CALL_LOG" || fail "failure occurred before the clone"
jq -e --arg state "$expected_state" \
'.ok == false and .result.service_id == "dummyservice" and .result.version == 42 and .result.version_state == $state' \
"$envelope" >/dev/null || fail "unexpected envelope: $(cat "$envelope")"
notice "a failed JSON deploy reported version 42 as $expected_state"
}
main "$@"
31 changes: 31 additions & 0 deletions .github/workflows/deploy-action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -831,6 +831,37 @@ jobs:
EDGEZERO__TEST__PUSHED_STORE: ${{ steps.production.outputs.store }}
run: .github/actions/deploy-fastly/tests/assert-config-push.sh

json-deploy-smoke:
needs: fixture-release
runs-on: ubuntu-latest
env:
EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME: config-prod
EDGEZERO__STORES__KV__CACHE__NAME: cache-prod
EDGEZERO__STORES__SECRETS__CREDENTIALS__NAME: credentials-prod
FAKE_EXPECTED_PACKAGE_DIGEST: ${{ needs.fixture-release.outputs.package-digest }}
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/download-artifact@v8
with:
name: edgezero-fastly-release
path: fixture-release

- name: Install the stateful fake Fastly provider
run: .github/actions/deploy-fastly/tests/make-fake-fastly-env.sh
- name: A failure after the clone reports the draft
env:
FAKE_FAIL_AFTER_VERSION: "1"
run: .github/actions/deploy-fastly/tests/assert-json-deploy-version.sh draft

- name: Reset the fake Fastly provider
run: .github/actions/deploy-fastly/tests/make-fake-fastly-env.sh
- name: A failed activation response reports an unknown state
env:
FAKE_FAIL_AFTER_ACTIVATION: "1"
run: .github/actions/deploy-fastly/tests/assert-json-deploy-version.sh unknown

recovery-smoke:
needs: fixture-release
runs-on: ubuntu-latest
Expand Down
12 changes: 12 additions & 0 deletions clippy.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Comment thread
dhruv8sh marked this conversation as resolved.
Resolved
{ 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 },
]
64 changes: 44 additions & 20 deletions crates/edgezero-adapter-axum/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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__<KEY>` 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<ActionOutcome, String> {
match action {
// The axum adapter is the in-process native dev server —
// there is no remote auth provider to sign in/out of.
Expand All @@ -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
Expand All @@ -171,12 +183,12 @@ impl Adapter for AxumCliAdapter {
_component_selector: Option<&str>,
stores: &ProvisionStores<'_>,
_dry_run: bool,
) -> Result<Vec<String>, String> {
) -> Result<ProvisionReport, String> {
//: 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()
Expand All @@ -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 {
Expand All @@ -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(
Expand Down Expand Up @@ -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(())
Expand Down
Loading
Loading