Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
edb7f0c
Add EdgeZero-backed ts CLI
ChristianPavilonis Jun 17, 2026
51aaecc
Update CLI EdgeZero revision
ChristianPavilonis Jun 22, 2026
9b4d6fc
Push Trusted Server config as a blob
ChristianPavilonis Jun 23, 2026
f4411cb
Use configured Fastly config store name for EdgeZero bootstrap
ChristianPavilonis Jun 23, 2026
6e44a98
Refactor trusted-server CLI around typed EdgeZero blob config
ChristianPavilonis Jun 23, 2026
ffc2ba3
Fix host CLI clippy and integration lock
ChristianPavilonis Jun 23, 2026
76040ca
Fix integration dependency parity check
ChristianPavilonis Jun 23, 2026
36d5605
Seed integration app config blob
ChristianPavilonis Jun 23, 2026
621e740
Update EdgeZero integration canary
ChristianPavilonis Jun 23, 2026
57ace6a
Read EdgeZero rollout flag as raw Fastly config
ChristianPavilonis Jun 23, 2026
65188b5
Make EdgeZero integration probe non-fatal
ChristianPavilonis Jun 23, 2026
3831cf2
Generate integration Viceroy configs
ChristianPavilonis Jun 23, 2026
2ca44fe
support config diff
ChristianPavilonis Jun 25, 2026
bdb9284
Add Trusted Server audit command
ChristianPavilonis Jun 17, 2026
14eb507
Add ConfigStoreUnavailable error variant mapping to 503
prk-Jr Jun 27, 2026
4d6509f
Classify config-store read failures as ConfigStoreUnavailable (503)
prk-Jr Jun 27, 2026
61fea54
Lock adapter 503 response for ConfigStoreUnavailable
prk-Jr Jun 27, 2026
854edef
Add HTTP-layer config-store 503 design doc
prk-Jr Jun 27, 2026
bca5e22
Merge main into feature/edgezero-269-http
prk-Jr Jul 2, 2026
ca2e77a
Address PR review: accurate chunk-read hint and retryable 503 body
prk-Jr Jul 2, 2026
3105e6c
Backtick EdgeZero in ec probe doc comments for clippy doc_markdown
prk-Jr Jul 2, 2026
44c2dff
Address automated review: EnvConfig store-name fallback and JA4 503 p…
prk-Jr Jul 2, 2026
b075078
Remove duplicated CI steps and docs introduced by main merge
prk-Jr Jul 2, 2026
d7e47eb
Format doc
prk-Jr Jul 2, 2026
7e22561
Merge branch 'main' into feature/edgezero-269-http
prk-Jr Jul 3, 2026
27b3ec6
Align edgezero.toml store ids with runtime and drop incidental drift
prk-Jr Jul 3, 2026
8a36c06
Load Spin startup config from a key-value store
prk-Jr Jul 3, 2026
03fc21c
Make Spin startup errors visible and status-accurate
prk-Jr Jul 3, 2026
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
4 changes: 4 additions & 0 deletions .github/workflows/integration-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,10 @@ jobs:
WASM_BINARY_PATH: ${{ env.WASM_ARTIFACT_PATH }}
INTEGRATION_ORIGIN_PORT: ${{ env.ORIGIN_PORT }}
VICEROY_CONFIG_PATH: ${{ env.ARTIFACTS_DIR }}/configs/viceroy-edgezero.toml
# Opt into the EdgeZero entry-point probe in test_ec_lifecycle_fastly.
# Only set here, so the legacy integration-tests job runs the same
# scenarios through legacy_main without the EdgeZero diagnostic probe.
EXPECT_EDGEZERO_ENTRY_POINT: "true"
RUST_LOG: info

browser-tests:
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@
/spin
/spin.sig

# Spin runtime state (local KV/SQLite created by `spin up`)
.spin/

# EdgeZero local KV store (created by edgezero-adapter-axum framework)
.edgezero/

Expand Down
8 changes: 8 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@ members = [
"crates/trusted-server-openrtb",
]

# The OpenRTB codegen crate is a standalone build tool run on demand via
# `crates/trusted-server-openrtb/generate.sh`; keep it out of the workspace so
# `cargo run --manifest-path .../trusted-server-openrtb-codegen/Cargo.toml` does
# not fail with "current package believes it's in a workspace when it's not".
exclude = [
"crates/trusted-server-openrtb-codegen",
]

# Viceroy (cargo test-fastly runner) calls `cargo run --bin trusted-server-adapter-fastly`
# against the default-run packages. It must be the sole default member so Cargo can
# locate the binary. Use aliases to test each adapter with the correct target:
Expand Down
23 changes: 23 additions & 0 deletions crates/trusted-server-adapter-fastly/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,26 @@ pub fn to_error_response(report: &Report<TrustedServerError>) -> Response {
Response::from_status(root_error.status_code())
.with_body_text_plain(&format!("{}\n", root_error.user_message()))
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn config_store_unavailable_renders_503() {
// Locks the end-to-end mapping: a config-store read failure reaches the
// client as 503 via `status_code()` — not bypassed by the adapter.
let report = Report::new(TrustedServerError::ConfigStoreUnavailable {
store_name: "app_config".to_string(),
message: "unavailable or not seeded".to_string(),
});

let response = to_error_response(&report);

assert_eq!(
response.get_status(),
fastly::http::StatusCode::SERVICE_UNAVAILABLE,
"config-store read failure should render as 503 to the client"
);
}
}
4 changes: 1 addition & 3 deletions crates/trusted-server-adapter-fastly/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -400,9 +400,7 @@ fn edgezero_main(mut req: FastlyRequest, config_store: ConfigStoreHandle) {
}
Err(e) => {
log::warn!("EdgeZero JA4 endpoint: failed to load settings: {e:?}");
FastlyResponse::from_status(fastly::http::StatusCode::INTERNAL_SERVER_ERROR)
.with_body_text_plain("Internal Server Error")
.send_to_client();
to_error_response(&e).send_to_client();
}
}
return;
Expand Down
15 changes: 15 additions & 0 deletions crates/trusted-server-adapter-spin/runtime-config.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Spin runtime configuration for the Trusted Server adapter.
#
# Declares the `app_config` key-value store that holds the Trusted Server
# app-config blob loaded at startup and seeded by `ts config push --adapter
# spin`. Spin auto-provides only the `default` label; any other label (here
# `app_config`) must be declared here or `spin up` fails with
# `unknown key_value_stores label app_config`.
#
# `type = "spin"` uses Spin's built-in SQLite key-value backend (a local
# `.spin/sqlite_key_value.db` file), matching what `ts config push --adapter
# spin --local` writes to. Point it at redis/azure/etc. for a shared backend.
#
# Load it explicitly: `spin up --runtime-config-file runtime-config.toml`.
[key_value_store.app_config]
type = "spin"
6 changes: 5 additions & 1 deletion crates/trusted-server-adapter-spin/spin.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,11 @@ source = "../../target/wasm32-wasip1/release/trusted_server_adapter_spin.wasm"
# origins are still served over plaintext http. Follow-up: scope this to the
# configured origins once they can be enumerated from settings.
allowed_outbound_hosts = ["https://*:*", "http://*:*"]
key_value_stores = ["default"]
# `app_config` holds the Trusted Server app-config blob loaded at startup and
# seeded by `ts config push --adapter spin`. Spin auto-provides only `default`;
# `app_config` must be granted here and backed by a `[key_value_store.app_config]`
# stanza in runtime-config.toml (passed via `spin up --runtime-config-file`).
key_value_stores = ["default", "app_config"]

[component.trusted-server.variables]
v_current_x2dkid = "{{ v_current_x2dkid }}"
Expand Down
114 changes: 90 additions & 24 deletions crates/trusted-server-adapter-spin/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ use trusted_server_core::request_signing::{
handle_verify_signature,
};
use trusted_server_core::settings::Settings;
#[cfg(all(feature = "spin", target_arch = "wasm32"))]
use trusted_server_core::settings_data::{
default_config_key, default_config_store_name, get_settings_from_config_store,
};

use crate::middleware::{AuthMiddleware, FinalizeResponseMiddleware, NormalizeMiddleware};
use crate::platform::build_runtime_services;
Expand All @@ -48,8 +52,34 @@ pub struct AppState {
/// Returns an error when settings, the auction orchestrator, or the integration
/// registry fail to initialise.
fn build_state() -> Result<Arc<AppState>, Report<TrustedServerError>> {
let settings = Settings::from_toml(include_str!("../../../trusted-server.example.toml"))?;
build_state_with_settings(settings)
build_state_with_settings(load_startup_settings()?)
}

/// Loads startup [`Settings`] on the Spin runtime from the app-config blob in
/// the Spin key-value store seeded by `ts config push --adapter spin` (store id
/// and blob key both resolve to `app_config`).
///
/// # Errors
///
/// Returns [`TrustedServerError::ConfigStoreUnavailable`] (HTTP 503) when the
/// store is unseeded or unreadable, and [`TrustedServerError::Configuration`]
/// (HTTP 500) when the blob fails envelope/settings verification.
#[cfg(all(feature = "spin", target_arch = "wasm32"))]
fn load_startup_settings() -> Result<Settings, Report<TrustedServerError>> {
let store_name = default_config_store_name();
let config_key = default_config_key();
get_settings_from_config_store(
&crate::platform::SpinKvConfigStore,
&store_name,
&config_key,
)
}

/// Loads startup [`Settings`] from the embedded example config on non-Spin
/// (native test) builds, where Spin host key-value functions are unavailable.
#[cfg(not(all(feature = "spin", target_arch = "wasm32")))]
fn load_startup_settings() -> Result<Settings, Report<TrustedServerError>> {
Settings::from_toml(include_str!("../../../trusted-server.example.toml"))
}

/// Build the application state from explicit settings.
Expand Down Expand Up @@ -348,27 +378,34 @@ fn legacy_admin_alias_denied() -> Response {
// Startup error fallback
// ---------------------------------------------------------------------------

/// Returns a [`RouterService`] that responds to every route with a generic
/// 503 Service Unavailable. The startup error is logged but not echoed in the
/// response body so that deployment state is not leaked to anonymous callers.
/// Returns a [`RouterService`] that answers every route with the startup
/// error's mapped HTTP status: an unseeded/unreadable config store yields 503,
/// while a blob that reads but fails envelope/settings verification yields 500.
/// The body is the generic user-facing message so deployment state is not
/// leaked to anonymous callers.
fn startup_error_router(e: &Report<TrustedServerError>) -> RouterService {
log::error!("startup failed, serving error fallback: {:?}", e);

let handler = |_ctx: RequestContext| {
let body = edgezero_core::body::Body::from("Service Unavailable\n");
let mut resp = Response::new(body);
*resp.status_mut() = StatusCode::SERVICE_UNAVAILABLE;
resp.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static("text/plain; charset=utf-8"),
);
async move { Ok::<Response, EdgeError>(resp) }
log::error!("startup failed, serving error fallback: {e:?}");

let status = e.current_context().status_code();
let message = Arc::new(format!("{}\n", e.current_context().user_message()));

let make = move |msg: Arc<String>| {
move |_ctx: RequestContext| {
let body = edgezero_core::body::Body::from((*msg).clone());
let mut resp = Response::new(body);
*resp.status_mut() = status;
resp.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static("text/plain; charset=utf-8"),
);
async move { Ok::<Response, EdgeError>(resp) }
}
};

// Cover the full publisher fallback method set (GET, POST, HEAD, OPTIONS,
// PUT, PATCH, DELETE) so degraded behaviour stays consistent with the
// healthy router: every method on `/` and `/{*rest}` returns the generic
// 503 instead of a router-level 405 for HEAD/OPTIONS/PATCH.
// healthy router: every method on `/` and `/{*rest}` returns the mapped
// error status instead of a router-level 405 for HEAD/OPTIONS/PATCH.
let mut builder = RouterService::builder().middleware(FinalizeResponseMiddleware::new(
Arc::new(Settings::default()),
));
Expand All @@ -378,8 +415,8 @@ fn startup_error_router(e: &Report<TrustedServerError>) -> RouterService {
Ok::<Response, EdgeError>(health_response())
});
for method in publisher_fallback_methods() {
builder = builder.route("/", method.clone(), handler);
builder = builder.route("/{*rest}", method, handler);
builder = builder.route("/", method.clone(), make(Arc::clone(&message)));
builder = builder.route("/{*rest}", method, make(Arc::clone(&message)));
}
builder.build()
}
Expand Down Expand Up @@ -953,8 +990,9 @@ mod tests {
// (including HEAD/OPTIONS/PATCH) on both "/" and nested paths with the
// generic 503, never a router-level 405, so startup-failure behaviour
// stays consistent with the healthy router.
let report = Report::new(TrustedServerError::BadRequest {
message: "startup failure".to_string(),
let report = Report::new(TrustedServerError::ConfigStoreUnavailable {
store_name: "app_config".to_string(),
message: "unseeded".to_string(),
});
let router = startup_error_router(&report);

Expand All @@ -979,12 +1017,40 @@ mod tests {
}
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn startup_error_router_maps_verify_failure_to_500() {
// A blob that reads but fails envelope/settings verification is a
// Configuration error (500), not a config-store read failure (503). The
// startup router must surface that distinction, not flatten to 503.
let report = Report::new(TrustedServerError::Configuration {
message: "blob failed integrity verification".to_string(),
});
let router = startup_error_router(&report);

let req = edgezero_core::http::request_builder()
.method("GET")
.uri("/")
.body(edgezero_core::body::Body::empty())
.expect("should build request");
let status = router
.oneshot(req)
.await
.expect("should route startup-error request")
.status()
.as_u16();
assert_eq!(
status, 500,
"a verify-failure startup error must map to 500, not a flattened 503"
);
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn startup_error_router_answers_health_with_200() {
// The liveness probe must keep returning 200 even while application state
// construction is failing, matching the Fastly/Axum health behaviour.
let report = Report::new(TrustedServerError::BadRequest {
message: "startup failure".to_string(),
let report = Report::new(TrustedServerError::ConfigStoreUnavailable {
store_name: "app_config".to_string(),
message: "unseeded".to_string(),
});
let router = startup_error_router(&report);

Expand Down
53 changes: 53 additions & 0 deletions crates/trusted-server-adapter-spin/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,58 @@ use spin_sdk::http_service;
#[http_service]
// FORCED: edgezero_adapter_spin::run_app returns anyhow::Result — EdgeZero SDK constraint, not a project choice.
async fn handle(req: Request) -> anyhow::Result<impl IntoResponse> {
// Install a real `log` backend before dispatch. `edgezero_adapter_spin`
// only calls a no-op `init_logger`, so without this every `log::error!`
// (including the startup-error diagnostics) is silently dropped on Spin.
logging::init();
edgezero_adapter_spin::run_app::<app::TrustedServerApp>(req).await
}

/// Minimal stderr [`log`] backend for the Spin component.
///
/// Spin captures component stderr to `.spin/logs/<component>_stderr.txt`
/// (local) and to the Fermyon Cloud log stream, so routing `log` records to
/// stderr makes startup and request diagnostics visible.
#[cfg(all(feature = "spin", target_arch = "wasm32"))]
mod logging {
use std::io::Write as _;
use std::sync::Once;

struct StderrLogger;

impl log::Log for StderrLogger {
fn enabled(&self, _metadata: &log::Metadata<'_>) -> bool {
true
}

fn log(&self, record: &log::Record<'_>) {
// Write to the stderr handle directly rather than via `eprintln!`
// so the component's `print_stderr` lint stays enforced elsewhere.
let _ = writeln!(
std::io::stderr(),
"[{}] {}: {}",
record.level(),
record.target(),
record.args()
);
}

fn flush(&self) {}
}

static LOGGER: StderrLogger = StderrLogger;
static INIT: Once = Once::new();

/// Installs [`StderrLogger`] as the global `log` backend exactly once.
///
/// Runs before `run_app`, so this wins the global slot and the adapter's
/// own no-op `init_logger` becomes the harmless second `set_logger` (whose
/// `Err` it already ignores).
pub(crate) fn init() {
INIT.call_once(|| {
if log::set_logger(&LOGGER).is_ok() {
log::set_max_level(log::LevelFilter::Info);
}
});
}
}
51 changes: 51 additions & 0 deletions crates/trusted-server-adapter-spin/src/platform.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,57 @@ impl PlatformConfigStore for NoopConfigStore {
}
}

/// Spin key-value-backed [`PlatformConfigStore`] used to load the app-config
/// blob at startup.
///
/// `ts config push --adapter spin` writes the app-config blob into a Spin
/// key-value store (label = the config store id, key = the blob key), so
/// startup opens that store by name and reads the blob. This is deliberately
/// distinct from [`ConfigStoreHandleAdapter`], which reads per-request
/// request-signing config from Spin component *variables*: a multi-kilobyte
/// blob does not fit Spin's flat variable namespace.
#[cfg(all(feature = "spin", target_arch = "wasm32"))]
pub(crate) struct SpinKvConfigStore;

#[cfg(all(feature = "spin", target_arch = "wasm32"))]
impl PlatformConfigStore for SpinKvConfigStore {
fn get(&self, store_name: &StoreName, key: &str) -> Result<String, Report<PlatformError>> {
let label = store_name.as_ref();
let store =
futures::executor::block_on(spin_sdk::key_value::Store::open(label)).map_err(|e| {
Report::new(PlatformError::ConfigStore).attach(format!(
"failed to open Spin key-value store `{label}`: {e}"
))
})?;
let bytes = futures::executor::block_on(store.get(key))
.map_err(|e| {
Report::new(PlatformError::ConfigStore).attach(format!(
"Spin key-value lookup for `{key}` in `{label}` failed: {e}"
))
})?
.ok_or_else(|| {
Report::new(PlatformError::ConfigStore).attach(format!(
"key `{key}` not found in Spin key-value store `{label}`"
))
})?;
String::from_utf8(bytes).map_err(|e| {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review: Present-but-corrupt Spin app-config bytes are reported as retryable store unavailability.

This String::from_utf8 failure means the app_config key was successfully opened and read, but the stored value is corrupt/not a valid text config blob. Because the error is returned as PlatformError::ConfigStore, read_config_entry() wraps it in TrustedServerError::ConfigStoreUnavailable, so Spin returns the new retryable 503 path instead of the intended 500-class "read succeeded but reconstruct/verify failed" path. In practice, an operator who accidentally seeds binary/corrupt bytes gets misleading retryable behavior and clients may keep retrying a terminal bad config.

Suggested fix: keep open/get/missing-key failures on the 503 path, but make a present value that cannot be decoded feed the verification/configuration path (for example, have this adapter convert invalid UTF-8 into a TrustedServerError::Configuration, or decode lossily/otherwise pass bytes forward so settings_from_config_blob fails as a 500), and add a Spin-specific regression test for non-UTF-8 bytes.

Report::new(PlatformError::ConfigStore).attach(format!(
"Spin key-value value for `{key}` is not valid UTF-8: {e}"
))
})
}

fn put(&self, _: &StoreId, _: &str, _: &str) -> Result<(), Report<PlatformError>> {
Err(Report::new(PlatformError::ConfigStore)
.attach("config store writes are not supported on Spin"))
}

fn delete(&self, _: &StoreId, _: &str) -> Result<(), Report<PlatformError>> {
Err(Report::new(PlatformError::ConfigStore)
.attach("config store writes are not supported on Spin"))
}
}

#[cfg(not(all(feature = "spin", target_arch = "wasm32")))]
struct NoopSecretStore;

Expand Down
2 changes: 1 addition & 1 deletion crates/trusted-server-adapter-spin/tests/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ fn edgezero_manifest_loads_and_resolves_spin_stores() {
.as_ref()
.expect("should declare a KV store")
.default_id(),
"trusted_server_kv",
"ec_identity_store",
"Spin KV declaration must expose its default logical store id"
);
assert!(
Expand Down
Loading
Loading