diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index ba64fec1e..b53165878 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -158,6 +158,17 @@ jobs: - name: Load integration test Docker images run: docker load --input "$DOCKER_ARTIFACT_PATH" + - name: Seed app_config config store for Viceroy + run: >- + cargo run --quiet + --manifest-path crates/integration-tests/Cargo.toml + --target x86_64-unknown-linux-gnu + --bin seed-viceroy-config -- + --template crates/integration-tests/fixtures/configs/viceroy-template.toml + --fixture crates/integration-tests/fixtures/configs/trusted-server-integration.toml + --port ${{ env.ORIGIN_PORT }} + --out ${{ env.ARTIFACTS_DIR }}/viceroy-seeded.toml + - name: Set up Node.js uses: actions/setup-node@v4 with: @@ -176,7 +187,7 @@ jobs: env: WASM_BINARY_PATH: ${{ env.WASM_ARTIFACT_PATH }} INTEGRATION_ORIGIN_PORT: ${{ env.ORIGIN_PORT }} - VICEROY_CONFIG_PATH: ${{ github.workspace }}/crates/integration-tests/fixtures/configs/viceroy-template.toml + VICEROY_CONFIG_PATH: ${{ env.ARTIFACTS_DIR }}/viceroy-seeded.toml TEST_FRAMEWORK: nextjs PLAYWRIGHT_HTML_REPORT: playwright-report-nextjs run: npx playwright test @@ -195,7 +206,7 @@ jobs: env: WASM_BINARY_PATH: ${{ env.WASM_ARTIFACT_PATH }} INTEGRATION_ORIGIN_PORT: ${{ env.ORIGIN_PORT }} - VICEROY_CONFIG_PATH: ${{ github.workspace }}/crates/integration-tests/fixtures/configs/viceroy-template.toml + VICEROY_CONFIG_PATH: ${{ env.ARTIFACTS_DIR }}/viceroy-seeded.toml TEST_FRAMEWORK: wordpress PLAYWRIGHT_HTML_REPORT: playwright-report-wordpress run: npx playwright test diff --git a/crates/integration-tests/Cargo.toml b/crates/integration-tests/Cargo.toml index fe393d3b9..01fa25fc4 100644 --- a/crates/integration-tests/Cargo.toml +++ b/crates/integration-tests/Cargo.toml @@ -9,13 +9,17 @@ name = "integration" path = "tests/integration.rs" harness = true -[dev-dependencies] +# Library + `seed-viceroy-config` binary share the `app_config` seeding logic +# with the test harness, so both need these at build time (not just tests). +[dependencies] trusted-server-core = { path = "../trusted-server-core" } +serde_json = "1.0.149" + +[dev-dependencies] testcontainers = { version = "0.25", features = ["blocking"] } reqwest = { version = "0.12", features = ["blocking", "cookies", "json"] } scraper = "0.21" log = "0.4.29" -serde_json = "1.0.149" error-stack = "0.6" derive_more = { version = "2.0", features = ["display"] } env_logger = "0.11" diff --git a/crates/integration-tests/fixtures/configs/trusted-server-integration.toml b/crates/integration-tests/fixtures/configs/trusted-server-integration.toml new file mode 100644 index 000000000..73c50cd86 --- /dev/null +++ b/crates/integration-tests/fixtures/configs/trusted-server-integration.toml @@ -0,0 +1,134 @@ +# Trusted Server application config for integration tests. +# +# Mirrors `trusted-server.example.toml` with the integration-environment +# overrides that the WASM build and CI previously injected as +# `TRUSTED_SERVER__*` env vars (origin URL, proxy secret, EC passphrase, EC +# partners, certificate check). The harness loads this file, builds the +# `app_config` config-store payload via `build_config_payload`, and seeds it +# into Viceroy so the runtime can reconstruct Settings at request time. +# +# `__ORIGIN_PORT__` is substituted by the harness with the fixed origin port +# that Docker test containers are mapped to. + +[[handlers]] +path = "^/_ts/admin" +username = "admin" +password = "integration-test-admin-password-32b" + +[publisher] +domain = "example.com" +cookie_domain = ".example.com" +origin_url = "http://127.0.0.1:__ORIGIN_PORT__" +proxy_secret = "integration-test-proxy-secret" + +[ec] +passphrase = "integration-test-ec-secret-padded-32" +ec_store = "ec_identity_store" +pull_sync_concurrency = 3 + +[[ec.partners]] +name = "Integration Test Partner" +source_domain = "inttest.example.com" +bidstream_enabled = true +api_token = "integration-test-token-alpha-32-bytes-ok" + +[[ec.partners]] +name = "Integration Test Partner 2" +source_domain = "inttest2.example.com" +bidstream_enabled = true +api_token = "integration-test-token-bravo-32-bytes-ok" + +[request_signing] +enabled = false +config_store_id = "app_config" +secret_store_id = "secrets" + +[integrations.prebid] +enabled = false +server_url = "https://prebid.example.com/openrtb2/auction" +timeout_ms = 1000 +bidders = [] +debug = false +client_side_bidders = [] + +[integrations.nextjs] +enabled = false +rewrite_attributes = ["href", "link", "siteBaseUrl", "siteProductionDomain", "url"] +max_combined_payload_bytes = 10485760 + +[integrations.testlight] +enabled = false +endpoint = "https://testlight.example.com/openrtb2/auction" +timeout_ms = 1200 +rewrite_scripts = true + +[integrations.didomi] +enabled = false +sdk_origin = "https://sdk.example.com" +api_origin = "https://api.example.com" + +[integrations.sourcepoint] +enabled = false +rewrite_sdk = true +cdn_origin = "https://cdn.example.com" +cache_ttl_seconds = 3600 + +[integrations.permutive] +enabled = false +organization_id = "" +workspace_id = "" +project_id = "" +api_endpoint = "https://api.example.com" +secure_signals_endpoint = "https://secure-signals.example.com" + +[integrations.lockr] +enabled = false +app_id = "" +api_endpoint = "https://identity.example.com" +sdk_url = "https://identity.example.com/trusted-server.js" +cache_ttl_seconds = 3600 +rewrite_sdk = true + +[integrations.datadome] +enabled = false +sdk_origin = "https://sdk.example.com" +api_origin = "https://api.example.com" +cache_ttl_seconds = 3600 +rewrite_sdk = true + +[integrations.gpt] +enabled = false +script_url = "https://ads.example.com/gpt.js" +cache_ttl_seconds = 3600 +rewrite_script = true + +[proxy] +certificate_check = false + +[auction] +enabled = false +providers = [] +timeout_ms = 2000 +allowed_context_keys = [] + +[integrations.aps] +enabled = false +pub_id = "your-aps-publisher-id" +endpoint = "https://aps.example.com/e/dtb/bid" +timeout_ms = 1000 + +[integrations.google_tag_manager] +enabled = false +container_id = "GTM-EXAMPLE" +upstream_url = "https://tags.example.com" + +[integrations.adserver_mock] +enabled = false +endpoint = "https://adserver.example.com/mediate" +timeout_ms = 1000 + +[integrations.adserver_mock.context_query_params] +example_segments = "segments" + +[debug] +ja4_endpoint_enabled = false diff --git a/crates/integration-tests/src/bin/seed-viceroy-config.rs b/crates/integration-tests/src/bin/seed-viceroy-config.rs new file mode 100644 index 000000000..2cfc0c649 --- /dev/null +++ b/crates/integration-tests/src/bin/seed-viceroy-config.rs @@ -0,0 +1,64 @@ +//! Generates a Viceroy config with the `app_config` config store seeded from +//! the integration application config. +//! +//! Used by the Playwright browser runner (and CI) to produce the config the +//! WASM runtime loads Settings from. The Rust `cargo test` runtime renders the +//! same config in-process via +//! [`integration_tests::render_seeded_viceroy_config`]. +//! +//! Usage: +//! seed-viceroy-config --template --fixture --port --out + +use std::process::ExitCode; + +use integration_tests::render_seeded_viceroy_config; + +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(err) => { + eprintln!("seed-viceroy-config: {err}"); + ExitCode::FAILURE + } + } +} + +fn run() -> Result<(), String> { + let mut template = None; + let mut fixture = None; + let mut port = None; + let mut out = None; + + let mut args = std::env::args().skip(1); + while let Some(flag) = args.next() { + match flag.as_str() { + "--template" => template = Some(next_value(&mut args, "--template")?), + "--fixture" => fixture = Some(next_value(&mut args, "--fixture")?), + "--port" => port = Some(next_value(&mut args, "--port")?), + "--out" => out = Some(next_value(&mut args, "--out")?), + other => return Err(format!("unknown argument `{other}`")), + } + } + + let template = template.ok_or("missing --template")?; + let fixture = fixture.ok_or("missing --fixture")?; + let port: u16 = port + .ok_or("missing --port")? + .parse() + .map_err(|err| format!("invalid --port: {err}"))?; + let out = out.ok_or("missing --out")?; + + let template = std::fs::read_to_string(&template) + .map_err(|err| format!("failed to read template `{template}`: {err}"))?; + let fixture = std::fs::read_to_string(&fixture) + .map_err(|err| format!("failed to read fixture `{fixture}`: {err}"))?; + + let config = render_seeded_viceroy_config(&template, &fixture, port)?; + std::fs::write(&out, config).map_err(|err| format!("failed to write `{out}`: {err}"))?; + Ok(()) +} + +fn next_value(args: &mut impl Iterator, flag: &str) -> Result { + args.next() + .ok_or_else(|| format!("missing value for {flag}")) +} diff --git a/crates/integration-tests/src/lib.rs b/crates/integration-tests/src/lib.rs new file mode 100644 index 000000000..5b4454fae --- /dev/null +++ b/crates/integration-tests/src/lib.rs @@ -0,0 +1,67 @@ +//! Shared helpers for the Trusted Server integration tests. +//! +//! The runtime reconstructs `Settings` from the `app_config` config store at +//! request time, so every harness (the Rust `cargo test` runtime and the +//! Playwright browser runner) must seed that store into the Viceroy config +//! before booting the WASM. This module renders that seeded config from a +//! single source — the integration application config fixture — so the Rust +//! harness and the `seed-viceroy-config` binary stay in lockstep. + +use std::collections::BTreeMap; + +use trusted_server_core::config_payload::build_config_payload; +use trusted_server_core::settings::Settings; + +/// Placeholder in the integration config fixture, replaced with the live origin +/// port before the settings are parsed. +pub const ORIGIN_PORT_PLACEHOLDER: &str = "__ORIGIN_PORT__"; + +/// Renders a complete Viceroy config: the static `[local_server]` template plus +/// an inline `app_config` config store seeded from the integration application +/// config. +/// +/// `template` is the Viceroy `[local_server]` config (backends, KV/secret +/// stores); `fixture` is the integration `trusted-server.toml` with an +/// [`ORIGIN_PORT_PLACEHOLDER`] for the origin port. The runtime opens +/// `app_config` and reconstructs Settings from its flattened entries +/// ([`build_config_payload`]), so without this seeding every settings-dependent +/// request returns 503. +/// +/// # Errors +/// +/// Returns a human-readable message if the fixture cannot be parsed into +/// [`Settings`] or the config-store payload cannot be built. +pub fn render_seeded_viceroy_config( + template: &str, + fixture: &str, + origin_port: u16, +) -> Result { + let fixture = fixture.replace(ORIGIN_PORT_PLACEHOLDER, &origin_port.to_string()); + let settings = Settings::from_toml(&fixture) + .map_err(|err| format!("failed to parse integration config fixture: {err:?}"))?; + let payload = build_config_payload(&settings) + .map_err(|err| format!("failed to build app_config config-store payload: {err:?}"))?; + + let mut config = String::from(template); + config.push_str(&render_app_config_store(&payload.entries)); + Ok(config) +} + +/// Renders the seeded `app_config` config store as an inline-TOML section. +/// +/// Keys (flattened dotted settings paths) and values (including JSON-encoded +/// metadata) are emitted as TOML basic strings via `serde_json`, whose string +/// escaping is a subset of TOML's, so dots and quotes round-trip safely. +fn render_app_config_store(entries: &BTreeMap) -> String { + let mut section = String::from( + "\n[local_server.config_stores.app_config]\nformat = \"inline-toml\"\n\n\ + [local_server.config_stores.app_config.contents]\n", + ); + for (key, value) in entries { + let key = serde_json::to_string(key).expect("should encode config key as JSON string"); + let value = + serde_json::to_string(value).expect("should encode config value as JSON string"); + section.push_str(&format!("{key} = {value}\n")); + } + section +} diff --git a/crates/integration-tests/tests/common/runtime.rs b/crates/integration-tests/tests/common/runtime.rs index 3ab916d56..b2b5c4cea 100644 --- a/crates/integration-tests/tests/common/runtime.rs +++ b/crates/integration-tests/tests/common/runtime.rs @@ -119,8 +119,9 @@ pub fn wasm_binary_path() -> PathBuf { /// Get the fixed origin port used for Docker container port mapping. /// -/// This must match the port baked into the WASM binary via -/// `TRUSTED_SERVER__PUBLISHER__ORIGIN_URL` at build time. +/// This must match the `publisher.origin_url` port in the integration config +/// fixture, which the Fastly harness substitutes for `__ORIGIN_PORT__` before +/// seeding it into the `app_config` config store the runtime loads Settings from. pub fn origin_port() -> u16 { match std::env::var("INTEGRATION_ORIGIN_PORT") { Ok(value) => value diff --git a/crates/integration-tests/tests/environments/fastly.rs b/crates/integration-tests/tests/environments/fastly.rs index 34a49d283..a5597803e 100644 --- a/crates/integration-tests/tests/environments/fastly.rs +++ b/crates/integration-tests/tests/environments/fastly.rs @@ -1,17 +1,19 @@ use crate::common::runtime::{ - RuntimeEnvironment, RuntimeProcess, RuntimeProcessHandle, TestError, TestResult, + RuntimeEnvironment, RuntimeProcess, RuntimeProcessHandle, TestError, TestResult, origin_port, }; -use error_stack::ResultExt as _; +use error_stack::{Report, ResultExt as _}; +use integration_tests::render_seeded_viceroy_config; use std::io::{BufRead as _, BufReader}; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; /// Fastly Compute runtime using Viceroy local simulator. /// -/// Spawns a `viceroy` child process with the WASM binary and the -/// Viceroy-specific `fastly.toml` config (KV stores, secrets). -/// The application config (origin URL, integrations) is baked into -/// the WASM binary at build time. +/// Spawns a `viceroy` child process with the WASM binary and a generated +/// Viceroy config: the static `[local_server]` template (backends, KV stores, +/// secret stores) plus a seeded `app_config` config store. The runtime +/// reconstructs Settings from `app_config` at request time, so the store must +/// carry the integration application config (see `trusted-server-integration.toml`). pub struct FastlyViceroy; impl RuntimeEnvironment for FastlyViceroy { @@ -22,7 +24,7 @@ impl RuntimeEnvironment for FastlyViceroy { fn spawn(&self, wasm_path: &Path) -> TestResult { let port = super::find_available_port()?; - let viceroy_config = self.viceroy_config_path(); + let viceroy_config = self.generate_viceroy_config(port)?; let mut child = Command::new("viceroy") .arg(wasm_path) @@ -47,8 +49,12 @@ impl RuntimeEnvironment for FastlyViceroy { }); } - // Wrap immediately so Drop::drop kills the process if readiness check fails - let handle = ViceroyHandle { child }; + // Wrap immediately so Drop::drop kills the process and removes the + // generated config if the readiness check fails. + let handle = ViceroyHandle { + child, + config_path: viceroy_config, + }; let base_url = format!("http://127.0.0.1:{port}"); // Fastly exposes a dedicated `/health` route, so root fallback only @@ -69,19 +75,53 @@ impl FastlyViceroy { /// secret stores) that Viceroy needs, separate from the application config. /// /// Honors the `VICEROY_CONFIG_PATH` environment variable so a CI job can - /// point the same WASM binary at an alternative config store — e.g. the + /// point the same WASM binary at an alternative template — e.g. the /// EdgeZero fixture that sets `trusted_server_config.edgezero_enabled = /// "true"` to exercise the EdgeZero entry point. Mirrors the browser /// harness's `global-setup.ts`, which reads the same variable. Falls back to /// the default legacy template when unset. - fn viceroy_config_path(&self) -> std::path::PathBuf { + fn viceroy_template_path(&self) -> PathBuf { if let Ok(path) = std::env::var("VICEROY_CONFIG_PATH") { if !path.is_empty() { - return std::path::PathBuf::from(path); + return PathBuf::from(path); } } - std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("fixtures/configs/viceroy-template.toml") + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("fixtures/configs/viceroy-template.toml") + } + + /// Path to the integration application config seeded into the `app_config` + /// config store. + fn app_config_fixture_path(&self) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("fixtures/configs/trusted-server-integration.toml") + } + + /// Renders a complete Viceroy config for this run: the static + /// `[local_server]` template plus an inline `app_config` config store + /// seeded from the integration application config. + /// + /// The runtime opens `app_config` and reconstructs Settings from its + /// flattened entries, so without this seeding every settings-dependent + /// request returns 503. Written to a per-port temp file the + /// [`ViceroyHandle`] removes on drop. Shares + /// [`render_seeded_viceroy_config`] with the `seed-viceroy-config` binary + /// the browser runner uses. + fn generate_viceroy_config(&self, port: u16) -> TestResult { + let template = std::fs::read_to_string(self.viceroy_template_path()) + .change_context(TestError::RuntimeSpawn) + .attach("Failed to read viceroy template")?; + let fixture = std::fs::read_to_string(self.app_config_fixture_path()) + .change_context(TestError::RuntimeSpawn) + .attach("Failed to read integration config fixture")?; + + let config = render_seeded_viceroy_config(&template, &fixture, origin_port()) + .map_err(|message| Report::new(TestError::RuntimeSpawn).attach(message))?; + + let path = std::env::temp_dir().join(format!("ts-viceroy-{port}.toml")); + std::fs::write(&path, config) + .change_context(TestError::RuntimeSpawn) + .attach("Failed to write generated viceroy config")?; + Ok(path) } } @@ -91,6 +131,7 @@ impl FastlyViceroy { /// preventing orphaned Viceroy processes. struct ViceroyHandle { child: Child, + config_path: PathBuf, } impl RuntimeProcessHandle for ViceroyHandle {} @@ -99,5 +140,6 @@ impl Drop for ViceroyHandle { fn drop(&mut self) { let _ = self.child.kill(); let _ = self.child.wait(); + let _ = std::fs::remove_file(&self.config_path); } } diff --git a/crates/trusted-server-adapter-fastly/src/error.rs b/crates/trusted-server-adapter-fastly/src/error.rs index 560a2e181..12215f719 100644 --- a/crates/trusted-server-adapter-fastly/src/error.rs +++ b/crates/trusted-server-adapter-fastly/src/error.rs @@ -17,3 +17,22 @@ pub fn to_error_response(report: &Report) -> 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() { + let report = Report::new(TrustedServerError::ConfigStoreUnavailable { + store_name: "app_config".to_string(), + message: "not seeded".to_string(), + }); + let resp = to_error_response(&report); + assert_eq!( + resp.get_status(), + fastly::http::StatusCode::SERVICE_UNAVAILABLE, + "should render 503 for ConfigStoreUnavailable" + ); + } +} diff --git a/crates/trusted-server-core/src/error.rs b/crates/trusted-server-core/src/error.rs index 2804afeca..9178b0968 100644 --- a/crates/trusted-server-core/src/error.rs +++ b/crates/trusted-server-core/src/error.rs @@ -26,6 +26,11 @@ pub enum TrustedServerError { #[display("Configuration error: {message}")] Configuration { message: String }, + /// Config store could not be read (unseeded, transient backend, or a listed + /// key missing) — Settings cannot be loaded. Retryable / fix by seeding. + #[display("Config store unavailable: {store_name} - {message}")] + ConfigStoreUnavailable { store_name: String, message: String }, + /// Auction orchestration error. #[display("Auction error: {message}")] Auction { message: String }, @@ -123,6 +128,7 @@ impl IntoHttpResponse for TrustedServerError { Self::InvalidHeaderValue { .. } => StatusCode::BAD_REQUEST, Self::InvalidUtf8 { .. } => StatusCode::INTERNAL_SERVER_ERROR, Self::KvStore { .. } => StatusCode::SERVICE_UNAVAILABLE, + Self::ConfigStoreUnavailable { .. } => StatusCode::SERVICE_UNAVAILABLE, Self::Prebid { .. } => StatusCode::BAD_GATEWAY, Self::Integration { .. } => StatusCode::BAD_GATEWAY, Self::Proxy { .. } => StatusCode::BAD_GATEWAY, @@ -266,6 +272,13 @@ mod tests { }, StatusCode::INTERNAL_SERVER_ERROR, ), + ( + TrustedServerError::ConfigStoreUnavailable { + store_name: String::from("app_config"), + message: String::from("store unavailable"), + }, + StatusCode::SERVICE_UNAVAILABLE, + ), ]; // `mapped_status` is an exhaustive match with no `_` arm, so adding a @@ -294,6 +307,9 @@ mod tests { TrustedServerError::EdgeCookie { .. } => StatusCode::INTERNAL_SERVER_ERROR, TrustedServerError::PartnerNotFound { .. } => StatusCode::NOT_FOUND, TrustedServerError::InsecureDefault { .. } => StatusCode::INTERNAL_SERVER_ERROR, + TrustedServerError::ConfigStoreUnavailable { .. } => { + StatusCode::SERVICE_UNAVAILABLE + } } } @@ -400,6 +416,15 @@ mod tests { assert_eq!(error.user_message(), "Invalid header value"); } + #[test] + fn config_store_unavailable_maps_to_503() { + let err = TrustedServerError::ConfigStoreUnavailable { + store_name: "app_config".to_string(), + message: "not seeded".to_string(), + }; + assert_eq!(err.status_code(), StatusCode::SERVICE_UNAVAILABLE); + } + #[test] fn status_code_maps_each_error_variant_to_expected_http_response() { // Compile-time guard: adding a TrustedServerError variant without @@ -422,6 +447,7 @@ mod tests { | TrustedServerError::EdgeCookie { .. } | TrustedServerError::PartnerNotFound { .. } | TrustedServerError::RequestTooLarge { .. } + | TrustedServerError::ConfigStoreUnavailable { .. } | TrustedServerError::InsecureDefault { .. } => (), }; @@ -499,6 +525,13 @@ mod tests { }, StatusCode::SERVICE_UNAVAILABLE, ), + ( + TrustedServerError::ConfigStoreUnavailable { + store_name: "app_config".to_string(), + message: "config store unavailable".to_string(), + }, + StatusCode::SERVICE_UNAVAILABLE, + ), ( TrustedServerError::Auction { message: "auction failed".to_string(), diff --git a/crates/trusted-server-core/src/settings_data.rs b/crates/trusted-server-core/src/settings_data.rs index 130efb927..1992d702e 100644 --- a/crates/trusted-server-core/src/settings_data.rs +++ b/crates/trusted-server-core/src/settings_data.rs @@ -16,9 +16,12 @@ const DEFAULT_CONFIG_STORE_ID: &str = "app_config"; /// /// # Errors /// -/// Returns [`TrustedServerError::Configuration`] when metadata or any flattened -/// config entry is missing, cannot be read, fails hash verification, or fails -/// Trusted Server settings validation. +/// Returns [`TrustedServerError::ConfigStoreUnavailable`] (HTTP 503) when a +/// config-store entry cannot be read (store unseeded, transient backend, or a +/// listed key missing), and [`TrustedServerError::Configuration`] / +/// [`TrustedServerError::Settings`] (HTTP 500) when the read succeeds but +/// reconstruction fails (metadata unparseable, hash verification, or settings +/// validation). pub fn get_settings_from_services( services: &RuntimeServices, ) -> Result> { @@ -39,9 +42,12 @@ pub fn default_config_store_name() -> StoreName { /// /// # Errors /// -/// Returns [`TrustedServerError::Configuration`] when metadata or any flattened -/// config entry is missing, cannot be read, fails hash verification, or fails -/// Trusted Server settings validation. +/// Returns [`TrustedServerError::ConfigStoreUnavailable`] (HTTP 503) when a +/// config-store entry cannot be read (store unseeded, transient backend, or a +/// listed key missing), and [`TrustedServerError::Configuration`] / +/// [`TrustedServerError::Settings`] (HTTP 500) when the read succeeds but +/// reconstruction fails (metadata unparseable, hash verification, or settings +/// validation). pub fn get_settings_from_config_store( config_store: &dyn PlatformConfigStore, store_name: &StoreName, @@ -73,10 +79,11 @@ fn read_config_entry( ) -> Result> { config_store .get(store_name, key) - .change_context(TrustedServerError::Configuration { + .change_context(TrustedServerError::ConfigStoreUnavailable { + store_name: store_name.to_string(), message: format!( - "failed to read Trusted Server app config key `{key}` from config store `{store_name}`" - ), + "unavailable or not seeded (failed to read `{key}`) — run `ts config push`" + ), }) } @@ -84,6 +91,7 @@ fn read_config_entry( mod tests { use super::*; use crate::config_payload::build_config_payload; + use crate::error::IntoHttpResponse; use crate::platform::PlatformError; use crate::settings::Settings; use crate::test_support::tests::crate_test_settings_str; @@ -149,4 +157,68 @@ mod tests { "error should mention missing keys metadata" ); } + + #[test] + fn unseeded_store_is_config_store_unavailable_503() { + let store = MemoryConfigStore { + entries: BTreeMap::new(), + }; + let err = get_settings_from_config_store(&store, &StoreName::from("app_config")) + .expect_err("unseeded store must error"); + assert_eq!( + err.current_context().status_code(), + http::StatusCode::SERVICE_UNAVAILABLE, + "unseeded store read failure should map to 503" + ); + // The actionable hint must ride the error chain so it reaches the + // server log (the operator's channel); the public 503 body stays + // generic by design. + assert!( + format!("{err:?}").contains("ts config push"), + "error chain should carry the actionable `ts config push` hint for logs" + ); + } + + #[test] + fn malformed_hash_stays_500() { + let mut entries = build_config_payload( + &Settings::from_toml(&crate_test_settings_str()).expect("should parse"), + ) + .expect("should build payload") + .entries; + entries.insert(CONFIG_HASH_KEY.to_string(), "sha256:deadbeef".to_string()); + let store = MemoryConfigStore { entries }; + let err = get_settings_from_config_store(&store, &StoreName::from("app_config")) + .expect_err("hash mismatch must error"); + assert_eq!( + err.current_context().status_code(), + http::StatusCode::INTERNAL_SERVER_ERROR, + "reconstruct/verify failure should stay 500" + ); + } + + #[test] + fn missing_listed_key_is_503() { + // Metadata (`ts-config-keys` / `ts-config-hash`) reads succeed, but a key + // the metadata lists is absent — still a read failure → 503. + let mut entries = build_config_payload( + &Settings::from_toml(&crate_test_settings_str()).expect("should parse"), + ) + .expect("should build payload") + .entries; + let victim = entries + .keys() + .find(|key| !key.starts_with("ts-config-")) + .cloned() + .expect("payload should have at least one settings key"); + entries.remove(&victim); + let store = MemoryConfigStore { entries }; + let err = get_settings_from_config_store(&store, &StoreName::from("app_config")) + .expect_err("missing listed key must error"); + assert_eq!( + err.current_context().status_code(), + http::StatusCode::SERVICE_UNAVAILABLE, + "a listed key missing is a config-store read failure → 503" + ); + } } diff --git a/docs/superpowers/plans/2026-06-18-edgezero-269-http-layer-runtime.md b/docs/superpowers/plans/2026-06-18-edgezero-269-http-layer-runtime.md new file mode 100644 index 000000000..a17abba1a --- /dev/null +++ b/docs/superpowers/plans/2026-06-18-edgezero-269-http-layer-runtime.md @@ -0,0 +1,312 @@ +# EdgeZero #269 HTTP-Layer Runtime Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Harden the runtime config-store load so an unseeded/unavailable `app_config` store returns an actionable **503** (not an opaque 500), confirm the non-Fastly adapters still build, and record the secret-write boundary — on top of the inherited #269 base. + +**Architecture:** trusted-server boots by rebuilding `Settings` from the `app_config` config store (`get_settings_from_services` → `get_settings_from_config_store` → `read_config_entry` per key → `settings_from_config_entries`). We classify failures by **call site**: a **config-store read failure** (unseeded, transient, or a missing listed key — all `PlatformConfigStore::get → Err`) maps to a new `TrustedServerError::ConfigStoreUnavailable` → **503**; a **reconstruct/verify failure** (`settings_from_config_entries`: hash mismatch, unparseable) stays `Configuration` → **500**. One new error variant, no platform-layer change (spec option Y). + +**Tech Stack:** Rust 2024, `error-stack` (`Report`), `derive_more::Display`, cargo, `wasm32-wasip1`. + +**Source spec:** [2026-06-18-edgezero-269-http-layer-runtime-design.md](../specs/2026-06-18-edgezero-269-http-layer-runtime-design.md) — §3.3 behavior matrix, §4.1 mechanism, §4.4 the `get→Option` follow-up (out of scope here). + +**Branch:** `feature/edgezero-269-http` (off `ts-cli-next` `14a91cc1`, edgezero `2eeccc9`), inherited base verified green. + +--- + +## Scope & non-goals + +**In scope:** the new `ConfigStoreUnavailable` 503 variant + read-failure classification (§4.1); core + adapter tests; malformed-hash stays-500 test; non-Fastly build check (§4.2); secret-write boundary note (§4.3). + +**Out of scope:** `PlatformConfigStore::get → Result>` convergence (spec §4.4 — pre-existing trait, store-convergence follow-up); the PR14→PR20 stack; CLI changes; edgezero extractor/`run_app` adoption. + +--- + +## File structure + +| File | Change | +| -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `crates/trusted-server-core/src/error.rs` | Add `ConfigStoreUnavailable { message: String }` variant + `status_code()` arm → `SERVICE_UNAVAILABLE` | +| `crates/trusted-server-core/src/settings_data.rs` | `read_config_entry` `change_context` → `ConfigStoreUnavailable` (actionable message); `get_settings_from_config_store` metadata reads inherit it; **reconstruct path unchanged**; add tests | +| `crates/trusted-server-adapter-fastly/src/` (test) | Adapter test: read-failure error → **503** to client via `to_error_response` | + +`settings_from_config_entries` and `config_payload.rs` are **not** touched (reconstruct/verify stays 500; shared seam read-only). + +--- + +## Task 1: Add the `ConfigStoreUnavailable` error variant (→ 503) + +**Files:** Modify `crates/trusted-server-core/src/error.rs` + +- [ ] **Step 1: Write the failing test** (in `error.rs` `#[cfg(test)]`) + +```rust +#[test] +fn config_store_unavailable_maps_to_503() { + let err = TrustedServerError::ConfigStoreUnavailable { + message: "config store unavailable or not seeded".to_string(), + }; + assert_eq!(err.status_code(), StatusCode::SERVICE_UNAVAILABLE); +} +``` + +- [ ] **Step 2: Run it — verify it fails** + +Run: `cargo test -p trusted-server-core config_store_unavailable_maps_to_503` +Expected: FAIL to compile — variant doesn't exist yet. + +- [ ] **Step 3: Add the variant + mapping** + +In the `TrustedServerError` enum, beside `Configuration { message: String }` (mirror its `#[display(...)]` style): + +```rust +/// Config store could not be read (unseeded, transient backend, or a +/// listed key missing) — the service cannot load Settings. Retryable / fix +/// by seeding the store. +#[display("Config store unavailable: {message}")] +ConfigStoreUnavailable { message: String }, +``` + +In `status_code()`, beside the `KvStore` 503 arm (`error.rs:125`): + +```rust +Self::ConfigStoreUnavailable { .. } => StatusCode::SERVICE_UNAVAILABLE, +``` + +**Also (required — or Step 4 won't compile):** `error.rs` has an existing +exhaustiveness-guard test `status_code_maps_each_error_variant_to_expected_http_response` +(~lines 246-390) whose `let _guard: fn(&TrustedServerError) = |error| match error { … }` +lists **every** variant with no wildcard. Add the new variant to that guard arm +(and a case to its `cases` array) in this step, e.g.: + +```rust +TrustedServerError::ConfigStoreUnavailable { .. } => {} +``` + +Skipping this turns Step 4 into a non-exhaustive-match **compile error**, not a pass. + +- [ ] **Step 4: Run it — verify it passes** + +Run: `cargo test -p trusted-server-core config_store_unavailable_maps_to_503` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add crates/trusted-server-core/src/error.rs +git commit -m "Add ConfigStoreUnavailable error variant mapping to 503" +``` + +--- + +## Task 2: Classify config-store read failures as `ConfigStoreUnavailable` + +**Files:** Modify `crates/trusted-server-core/src/settings_data.rs` + +Background (confirmed): `read_config_entry` wraps `config_store.get(...)` with +`change_context(TrustedServerError::Configuration { … })` today (→ 500). The +in-memory test fake is `MemoryConfigStore { entries: BTreeMap }` +(**struct literal, no `::new`**); its `get` returns `Err(PlatformError::ConfigStore)` +for a missing key — so an empty map models the unseeded store. + +- [ ] **Step 1: Write the failing tests** (in `settings_data.rs` `#[cfg(test)]`, reuse `MemoryConfigStore`) + +**Imports (required — `status_code()` is a trait method):** the test module is +`use super::*`, which does **not** bring in the status trait. Add to the test +module: `use crate::error::IntoHttpResponse;` (precedent: `proxy.rs` and +`request_signing/endpoints.rs` test modules do the same). `http::StatusCode` works +as a bare path (`http` is a direct dep) — no `use` needed for it. + +```rust +#[test] +fn unseeded_store_is_config_store_unavailable_503() { + let store = MemoryConfigStore { entries: BTreeMap::new() }; // no ts-config-keys + let err = get_settings_from_config_store(&store, &StoreName::from("app_config")) + .expect_err("unseeded store must error"); + assert_eq!( + err.current_context().status_code(), + http::StatusCode::SERVICE_UNAVAILABLE, + "unseeded/unavailable config store should be 503" + ); +} + +#[test] +fn malformed_hash_stays_500() { + // Build a valid payload, then corrupt the hash entry so reconstruct fails. + let mut payload = build_config_payload( + &Settings::from_toml(&crate_test_settings_str()).expect("should parse"), + ) + .expect("should build payload") + .entries; + payload.insert(CONFIG_HASH_KEY.to_string(), "sha256:deadbeef".to_string()); + let store = MemoryConfigStore { entries: payload }; + let err = get_settings_from_config_store(&store, &StoreName::from("app_config")) + .expect_err("hash mismatch must error"); + assert_eq!( + err.current_context().status_code(), + http::StatusCode::INTERNAL_SERVER_ERROR, + "corrupt (loaded-but-invalid) config should stay 500" + ); +} +``` + +- [ ] **Step 2: Run — verify the unseeded test fails, malformed passes** + +Run: `cargo test -p trusted-server-core -- unseeded_store_is_config_store_unavailable malformed_hash_stays_500` +Expected: `unseeded_…` FAILS (currently 500); `malformed_hash_stays_500` PASSES already (reconstruct path unchanged). If `malformed` fails, the corruption isn't reaching `settings_from_config_entries` — re-check the fixture before touching code. + +- [ ] **Step 3: Implement — flip only the read path** + +In `read_config_entry`, change the `change_context` target from +`TrustedServerError::Configuration` to the new variant with an actionable message: + +```rust +fn read_config_entry( + config_store: &dyn PlatformConfigStore, + store_name: &StoreName, + key: &str, +) -> Result> { + config_store + .get(store_name, key) + .change_context(TrustedServerError::ConfigStoreUnavailable { + message: format!( + "config store `{store_name}` unavailable or not seeded \ + (failed to read `{key}`) — run `ts config push`" + ), + }) +} +``` + +Do **not** change the `serde_json::from_str(&keys_raw)` parse (`Configuration`/500 +— that's reconstruct of metadata, genuine corruption) or `settings_from_config_entries`. + +- [ ] **Step 4: Run — both tests pass + full core suite** + +Run: `cargo test -p trusted-server-core` +Expected: the two new tests PASS; the existing `settings_data` round-trip/load tests still PASS (seeded path unchanged). + +- [ ] **Step 5: Commit** + +```bash +git add crates/trusted-server-core/src/settings_data.rs +git commit -m "Classify config-store read failures as ConfigStoreUnavailable (503)" +``` + +--- + +## Task 3: Adapter end-to-end — read failure reaches the client as 503 + +**Files:** Add a test in `crates/trusted-server-adapter-fastly/src/` (follow the existing test module style — see `route_tests.rs` for how adapter tests assert status; `to_error_response` lives in `crate::error`). + +- [ ] **Step 1: Write the failing test** + +Assert that the adapter's error→response path maps a `ConfigStoreUnavailable` +error to a 503 response. Minimal, no live store: + +```rust +#[test] +fn config_store_unavailable_error_renders_503() { + use trusted_server_core::error::TrustedServerError; + let err = error_stack::Report::new(TrustedServerError::ConfigStoreUnavailable { + message: "unseeded".to_string(), + }); + let resp = crate::error::to_error_response(&err); + assert_eq!(resp.get_status(), fastly::http::StatusCode::SERVICE_UNAVAILABLE); +} +``` + +(Confirm `to_error_response`'s exact signature/return type — adjust the call and +the status accessor to match `route_tests.rs` conventions. If `to_error_response` +takes the error by value or a different ref, follow the existing call sites.) + +- [ ] **Step 2: Run — verify it fails** (compile or assertion), then make it pass + +Run: `cargo test -p trusted-server-adapter-fastly config_store_unavailable_error_renders_503` +If it fails only because `to_error_response` already maps via `status_code()` (Task 1), it should pass once the call signature is correct — this test **locks** that the variant→503 mapping isn't bypassed by the adapter. + +- [ ] **Step 3: Commit** + +```bash +git add crates/trusted-server-adapter-fastly/src +git commit -m "Lock adapter 503 response for ConfigStoreUnavailable" +``` + +--- + +## Task 4: Confirm non-Fastly adapters still build + +**Files:** none (verification) + +- [ ] **Step 1: Determine what "builds" means per stub** + +`trusted-server-adapter-{cloudflare,spin}` are stubs (cloudflare/axum absent from +the dependency graph). Run host checks: +`cargo check -p trusted-server-adapter-cloudflare` and `-p trusted-server-adapter-spin`. +Only attempt `--target wasm32-unknown-unknown` if the crate has a real worker +entry (install the target first). + +- [ ] **Step 2: If anything breaks under #269**, apply the §4.1 / finding fix shapes (likely none — our change is additive to core). Expected: green with no edits. + +- [ ] **Step 3: Commit any fixups** (likely none). + +--- + +## Task 5: Record the secret-write boundary (§4.3) + +**Files:** Modify a doc comment near the signing/secret store wiring (no behavior change) + +- [ ] **Step 1:** Add a brief doc comment where signing secrets are read (or at + `management_api.rs`'s secret-write entry) noting: runtime key-rotation writes go + through `FastlyPlatformSecretStore` CRUD (pre-existing); CLI-driven secret push + is deferred (spec §4.3). One comment so the split is discoverable in code, not + only the spec. + +- [ ] **Step 2: Commit** + +```bash +git add crates/trusted-server-adapter-fastly/src +git commit -m "Document runtime-vs-CLI secret-write boundary" || echo "nothing to commit" +``` + +> Skip this task if the team prefers the boundary lives only in the spec. + +--- + +## Task 6: Full gate + +**Files:** none (verification) + +- [ ] **Step 1: Run the gate** + +```bash +cargo build --workspace --all-targets +cargo build -p trusted-server-adapter-fastly --release --target wasm32-wasip1 +cargo test --workspace +cargo clippy --workspace --all-targets --all-features -- -D warnings +cargo fmt --all -- --check +``` + +Expected: all green (the §6 baseline + the new tests). + +- [ ] **Step 2: integration-tests lockfile** (separate workspace, path-deps core) + +Run: `( cd crates/integration-tests && cargo build --workspace )`. Only on +shared-dep mismatch: `cargo update -p --precise ` (never +blanket). + +- [ ] **Step 3: Commit any fmt fixups** + +```bash +git add crates Cargo.toml Cargo.lock && git commit -m "Gate fixups" || echo "nothing to commit" +``` + +--- + +## Risks & notes + +| Risk / note | Handling | +| --------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | +| `to_error_response` signature differs from the Task 3 sketch | Confirm against `route_tests.rs` call sites; the test is illustrative | +| The unseeded message also fires on transient backend errors | By design (spec §3.3/§3.4 option Y) — 503 covers both; message names both paths | +| `get→Option` would let us split unseeded vs transient precisely | **Out of scope** (spec §4.4) — tracked store-convergence follow-up; this plan does not block on it | +| `MemoryConfigStore` constructor | Struct literal `MemoryConfigStore { entries }` — **no `::new`** (confirmed) | diff --git a/docs/superpowers/specs/2026-06-18-edgezero-269-http-layer-runtime-design.md b/docs/superpowers/specs/2026-06-18-edgezero-269-http-layer-runtime-design.md new file mode 100644 index 000000000..eca7b329a --- /dev/null +++ b/docs/superpowers/specs/2026-06-18-edgezero-269-http-layer-runtime-design.md @@ -0,0 +1,307 @@ +# Design: EdgeZero #269 HTTP-Layer Runtime + +- **Date:** 2026-06-18 +- **Author:** Prakash (HTTP-layer / runtime). CLI side: Christian (`feature/ts-cli-next`). +- **Status:** design — base build verified green (see §6). +- **Base:** edgezero `stackpop/edgezero#269` (`feature/extensible-cli`, HEAD `2eeccc9`), + adopted on `main` via `feature/ts-cli-next`. Our work branches off it + (`feature/edgezero-269-http`). +- **Companion docs:** references + [2026-06-16-edgezero-269-repin-breaking-api-finding.md](./2026-06-16-edgezero-269-repin-breaking-api-finding.md) + for the breaking-API / `Body`-sink detail (not duplicated here). Subsumes the + plan's Phase-4 "runtime-config-store spec" — this is that, widened to all + runtime surfaces. + +--- + +## 1. Scope & base assumptions + +This spec governs the **runtime (HTTP-layer) half** of running trusted-server on +edgezero #269. It is the source of truth for how the Fastly adapter boots, loads +configuration, and serves requests under #269. + +**Convergence model (decided):** `main` is the #269 convergence point. +`ts-cli-next` (off `main`) lands first, establishing #269 on `main`; our +HTTP-layer work branches off it and also targets `main`. **The PR14→PR20 +migration stack is explicitly out of scope** — it reconciles to #269 on its own +schedule and is not a dependency of, or dependent on, this work. + +**Inherited from `ts-cli-next`** (already done, verified green — §6): + +- the #269 dependency repin (`2eeccc9`); +- the `Body::into_bytes() → Option` fixes across `trusted-server-core`; +- the **Fastly adapter migration** to the #269 API (the dual-path entry point was + removed — §2.2); +- runtime `Settings`-from-config-store load (`get_settings_from_services`). + +**What we own (add on top):** runtime hardening of the config-store load path +(§4), the non-Fastly adapter build state (§4.2), the secret-write boundary +decision (§4.3), and this spec. + +**Architecture invariant:** trusted-server keeps its **bespoke `platform/` +layer** (`RuntimeServices` + `PlatformConfigStore`/`PlatformSecretStore`/ +`PlatformKvStore`). We do **not** adopt edgezero's first-class +`ConfigStore`/`StoreRegistry`/extractor/`RequestContext`. The #269 convergence is +of the config **source** (TOML → config store), not the abstraction. + +--- + +## 2. Surface inventory + +Every surface #269 touches, with current state and owner. "Inherited" = done on +`ts-cli-next`; "ours" = HTTP-layer work in this spec. + +| Surface | State under #269 | Owner | +| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- | +| `Body::into_bytes()` → `Option` (`trusted-server-core`) | Fixed: `request_body_bytes` helper + graceful `ok_or_else` (prod) / `unwrap_or_default` (test); `body_as_reader` returns `Result`. 18 sinks (finding §2). | inherited | +| **Adapter entry flow** | Dual-path (`into_core_request` + router `oneshot` + middleware) **removed**; converts via `compat::from_fastly_request` → `route_request` (§2.2). | inherited | +| **Config-store load** | `Settings` rebuilt at boot from the `app_config` config store via the `config_payload` flatten/hash contract (§3). `trusted-server.toml` deleted. | inherited; **hardening = ours** (§4.1) | +| `secrets` store | Read via `PlatformSecretStore`; write CRUD (key rotation) via pre-existing `adapter-fastly/src/management_api.rs`. | inherited; **boundary doc = ours** (§4.3) | +| `ec_identity_store` KV | Adapter boots `UnavailableKvStore`; EC routes lazily bind the configured store at dispatch. | inherited; **regression test = ours** | +| **`fastly` 0.11 / 0.12 coexistence** | edgezero #269 pulls `fastly 0.12.1`; trusted-server core+adapter stay on `fastly 0.11.13`. Bridged at `compat::from_fastly_request` (core, 0.11). Both versions resolve in the tree (§2.3). | inherited (constraint) | +| integration-tests lockfile | Separate workspace, path-deps core; shared deps must match root. | verify (§4) | +| CI gates | fmt / clippy `-D warnings` / `cargo test --workspace` / wasm32-wasip1 — all green on base (§6). | verify per change | +| JS (`crates/js`) | Untouched by #269. | n/a | + +### 2.1 Store ids (`edgezero.toml`) + +| Kind | id | Runtime use | +| ------- | ------------------- | ----------------------------- | +| config | `app_config` | `Settings` source (§3) | +| secrets | `secrets` | signing keys; rotation writes | +| kv | `ec_identity_store` | EC identity graph | + +### 2.2 Adapter entry flow (why the dual-path is gone) + +PR14 introduced a dual-path entry: `edgezero_adapter_fastly::into_core_request` +plus an edgezero router `oneshot` and a middleware chain keyed on +`FastlyRequestContext`. Under #269 those symbols are **fastly-0.12-bound**, but +the adapter builds the request with **fastly-0.11** — an unbridgeable version +mismatch. The migrated adapter (inherited) abandons that path: it converts the +Fastly request via `trusted_server_core::compat::from_fastly_request` +(fastly-0.11, in core) and routes through `route_request`, deleting `app.rs` and +`middleware.rs`. **This spec does not revive the dual-path.** + +### 2.3 The `fastly` version split (load-bearing constraint) + +The 0.11/0.12 coexistence is **deliberate and required**, not a smell: +edgezero #269 internally uses `fastly 0.12`; trusted-server stays on `fastly +0.11`. The only safe bridge is `compat::from_fastly_request` (a core, 0.11 +function that produces the platform-neutral `http` request). **Do not** call +edgezero APIs that take/return a fastly-0.12 `Request`/`Response` directly from +adapter code built on 0.11 — that reintroduces the PR14 dead end. All +adapter↔core hand-off goes through `compat` and the bespoke `platform/` types. + +--- + +## 3. Runtime config-store load (core design) + +### 3.1 Load sequence + +At adapter boot (`crates/trusted-server-adapter-fastly/src/main.rs`): + +``` +build_runtime_services(&req, kv_store) // config store available first + → get_settings_from_services(&services) // settings_data.rs + → get_settings_from_config_store(services.config_store(), &store_name) + → read_config_entry(CONFIG_KEYS_KEY) // "ts-config-keys" + → read_config_entry(CONFIG_HASH_KEY) // "ts-config-hash" + → read_config_entry() + → settings_from_config_entries(entries) // hash verify + reconstruct +``` + +`store_name` resolves via `EnvConfig::store_name("config", DEFAULT_CONFIG_STORE_ID)` +where `DEFAULT_CONFIG_STORE_ID = "app_config"`, overridable by +`EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME`. + +**Store dependency ordering (resolves the apparent §2 tension):** the **config +store is required at boot** — if `app_config` is unseeded, settings load fails and +_no_ route serves (§3.3). The **`ec_identity_store` KV is optional/lazy** — the +adapter boots `UnavailableKvStore` and EC routes bind it at dispatch, so EC-KV +being unavailable degrades only EC routes while everything else serves. The §2 +"non-EC routes still serve" resilience therefore holds **only after** config load +has succeeded. + +### 3.2 The `config_payload` contract (shared seam — reference only) + +`trusted-server-core/src/config_payload.rs` is the **single bidirectional +contract**: the CLI flattens `Settings` → config-store entries; the runtime +reconstructs from the same module. Do **not** fork it. Properties (per the CLI +design): escaped dotted keys (`\` → `\\`, `.` → `\.`); leaf values as canonical +JSON; `ts-config-keys` (sorted key array) + `ts-config-hash` (`sha256` over +settings-only entries) metadata; `ts-config-*` reserved. + +### 3.3 Behavior matrix (the contract this spec locks) + +**The boundary is "couldn't load the config" vs "loaded it but it's corrupt"** — +because `PlatformConfigStore::get` collapses key-absent and transport failure into +the same `PlatformError::ConfigStore` (`platform.rs:50-66`), the runtime cannot +cheaply tell "unseeded" from "transient backend" today (see §4.4 for the long-term +fix). So we classify by **where** the failure occurs, not by trying to subdivide a +read error: + +| Situation | Current (inherited) | Target (ours) | +| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **Config-store read failure** — `read_config_entry` returns `Err` for any reason: store unseeded (`ts-config-keys` absent), transient backend hiccup, or a listed key missing | every case → generic **500**; indistinguishable from a real bug | **503** (`SERVICE_UNAVAILABLE`) via one new `TrustedServerError` variant, actionable message `"config store unavailable or not seeded — run \`ts config push\`"`. 503 is correct: unseeded → seed it; transient → retry. | +| **Reconstruct / verify failure** — config read OK but `settings_from_config_entries` fails (hash mismatch, unparseable value) | `Configuration`/`Settings` → 500 | **500** (genuine corruption / bug) — unchanged | +| Seeded + valid | `Settings` loads | unchanged | + +The 503/500 split is exactly the read-vs-reconstruct boundary in +`get_settings_from_config_store` → `read_config_entry` (read; → 503) vs +`settings_from_config_entries` (reconstruct; → 500). No `PlatformError` change +needed. + +### 3.4 Seed-before-serve (operational contract) + +Because `Settings` lives only in the store (no `trusted-server.toml` fallback), +**the service cannot serve real routes until `ts config push` seeds +`app_config`.** The runtime makes the unseeded state **observable** (503 status + +an actionable message in the **server logs** — §3.3), but observability is not +availability — the store **must** be seeded for the service to function. + +**This bites existing production, not just fresh installs.** `trusted-server.toml` +is deleted, so the moment the #269 wasm goes live on a service whose `app_config` +store is empty, **every route 503s** — an instant outage, not an edge case. + +**Cutover migration (one-time, ordered):** + +1. Export the currently-live `Settings` to a config payload (the CLI's + `config push` flattens `Settings` via `config_payload` — §3.2). +2. **Seed `app_config` first** (`ts config push --adapter fastly`), verify the + store holds `ts-config-keys`/`ts-config-hash` + entries. +3. **Then** deploy the #269 wasm. + +Never deploy the wasm before the store is seeded. This ordering is the contract; +§5 carries it as the top risk + the rollback. + +**503 covers two cases; the actionable text goes to LOGS, not the client body.** +Under option Y (§3.3) a 503 means "couldn't load config": either **unseeded** +(operator must `ts config push`) or a **transient backend** error (genuinely +retryable). The actionable message — `config store \`{store}\` unavailable or not +seeded (failed to read \`{key}\`) — run \`ts config push\`` — is carried on the +error (`ConfigStoreUnavailable`'s `Display`) and surfaced in the **server log** +(`main.rs` logs the full error chain). The **client 503 body stays generic** +(`user_message()`'s catch-all, "An internal error occurred") **by design** — per +the security rule, don't leak internal tooling/paths to public clients; detail +lives server-side. So: operator-actionable in logs, safe-generic to the client. + +--- + +## 4. HTTP-layer work we add + +### 4.1 Config-store load hardening (the core deliverable) + +Implement §3.3's target column, TDD-first, in `settings_data.rs` + `error.rs`. +Reuse the existing `MemoryConfigStore` test fake. Test at **both layers** — +neither alone proves the contract: + +- **core** (`settings_data.rs` / `error.rs`): a config-store **read failure** → + the new variant, and the variant's `status_code()` == **503**; a + malformed-hash (reconstruct failure) → stays **500**. +- **adapter** (`trusted-server-adapter-fastly`): the read-failure error actually + reaches the client as **503** via `to_error_response` (end-to-end check that + the variant→status mapping isn't bypassed). + +**Mechanism — one new variant, no platform-layer change (option Y).** A +`change_context`/`attach` does **not** alter `status_code()`, and +`PlatformConfigStore::get` cannot distinguish key-absent from transport error +(both `PlatformError::ConfigStore` — §4.4). So classify by **call site**: + +1. Add `TrustedServerError::ConfigStoreUnavailable` (or similar), mapped to + `StatusCode::SERVICE_UNAVAILABLE` in `error.rs` — precedent: the `KvStore` arm + already maps to 503 (`error.rs:125`). +2. In `get_settings_from_config_store` / `read_config_entry`, `change_context` + **read failures** (the `config_store.get(...)` path — `ts-config-keys`, + `ts-config-hash`, each entry) to `ConfigStoreUnavailable` (→ 503) with the + actionable message. +3. Leave `settings_from_config_entries` failures (hash mismatch, unparseable) + as `Configuration`/`Settings` (→ 500). That is the only change — the + read-vs-reconstruct boundary does the classification. + +No new `PlatformError` variant, no change to `PlatformConfigStore` or any of its +impls. (Detailed steps live in the implementation plan, not here.) + +**Do NOT add a `user_message()` arm for this variant.** The actionable text must +reach **logs only**; the public client body stays generic via `user_message()`'s +catch-all (§3.4, security). The `ConfigStoreUnavailable` `Display` (carrying the +"run `ts config push`" message) flows to the log via `main.rs`'s error-chain dump +— that is the operator's channel. Adding a `user_message()` arm would leak +internal tooling into public 503 bodies. + +### 4.4 Long-term: `PlatformConfigStore::get → Result>` (tracked follow-up — NOT this work) + +The reason §4.1 can't cleanly separate "unseeded" from "transient backend" is a +**pre-existing trait-shape smell**: `PlatformConfigStore::get` (defined in PR2 / +#545, on `main` — not `ts-cli-next`) returns `Result` and folds +key-absent into `Err(PlatformError::ConfigStore)`, same as a transport failure. + +The durable fix is to make absence a **value**, not an error — +`get(...) -> Result, Report>` (`Ok(None)` = absent, +`Err` = real failure). This is **exactly edgezero #269's own `ConfigStore::get` +shape** (`Result, ConfigStoreError>`), so it is also the +**store-convergence** direction (finding §6 B). With it, unseeded (`Ok(None)` on +`ts-config-keys`) becomes distinguishable from transient (`Err`) for free, and +§4.1's option Y sharpens into the precise option X **without rework**. + +**Out of scope here** (it's a pre-existing trait `ts-cli-next` only consumes, and +touches every `PlatformConfigStore` impl). Track it on the store-convergence +follow-up; optionally surface it as a non-blocking comment on the `ts-cli-next` +PR (its config-load path is the consumer that exposes the limitation). + +### 4.2 Non-Fastly adapters + +`trusted-server-adapter-{cloudflare,spin}` are stubs (cloudflare/axum are absent +from the dependency graph). Goal: they **compile** under #269 — not feature +parity. Confirm what "builds" means per stub (`cargo check -p …` on host vs a +real wasm target) before acting. + +### 4.3 Secret-write boundary (decision to record) + +Runtime key-rotation secret **writes** already work via the pre-existing +`crates/trusted-server-adapter-fastly/src/management_api.rs` +(`FastlyPlatformSecretStore` CRUD). The CLI's `config push` +**does not** push secrets (deferred by the CLI design until edgezero exposes +secret-store write primitives). Net: **runtime rotation stays; CLI-driven secret +push is out of scope.** Recorded so the split is intentional. + +--- + +## 5. Risks + +| Risk | Mitigation | +| ----------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Cutover outage on existing prod** — `trusted-server.toml` deleted; deploying the #269 wasm before `app_config` is seeded 503s every route instantly | §3.4 cutover migration: **seed the store first, then deploy the wasm** (ordered); §3.3 makes the unseeded state observable | +| **No rollback path once `trusted-server.toml` is gone** | Rollback = **redeploy the pre-#269 wasm** (still reads `trusted-server.toml`); keep that build artifact available through cutover; the repin is also a single-commit revert | +| Unseeded / fresh install can't serve | §3.3 actionable 503 + §3.4 seed-before-serve | +| **Two `fastly` versions in the wasm binary** (0.11 + 0.12 coexist, §2.3) | Builds green today (§6); watch binary size / duplicate-symbol bloat; the 0.12 bump is deferred (§7), not a fix | +| **`fastly` 0.11/0.12 coexistence** misused — calling 0.12 edgezero APIs from 0.11 adapter code | §2.3: all hand-off via `compat` + `platform/`; never revive the dual-path | +| **`ts-cli-next` is unmerged WIP** (force-pushable, off `main`) | record its SHA when branching; re-base from new SHA + coordinate if it moves; keep our additions as discrete commits | +| Whole-`Settings`-in-store enlarges blast radius of a bad push | `ts-config-hash` verification + malformed-store test (§3.3) | +| `config_payload` forked / drifts | treat as read-only shared seam (§3.2); both sides import one module | +| integration-tests lockfile drift after repin reaches main | targeted `cargo update -p --precise`; never blanket | + +--- + +## 6. Verification (base, this branch) + +`feature/edgezero-269-http` off `ts-cli-next` (`14a91cc1`), pinned `2eeccc9`, +verified **green** on 2026-06-18: + +- `cargo build --workspace --all-targets` — 0 errors +- `cargo build -p trusted-server-adapter-fastly --release --target wasm32-wasip1` — ok +- `cargo test --workspace` — 1372 + 38 + 21 + 2 pass, 0 fail +- `cargo clippy --workspace --all-targets --all-features -- -D warnings` — exit 0 +- `cargo fmt --all -- --check` — clean + +The hardening (§4.1) must keep all of the above green and add its own tests. + +--- + +## 7. Out of scope + +- The PR14→PR20 migration stack and its reconciliation to #269 (separate effort). +- edgezero `run_app`/`app!`/extractor/`RequestContext` adoption (we keep the + bespoke `platform/` layer). +- CLI-driven secret push; the CLI crate itself (`ts config`/`audit`). +- Bumping trusted-server to `fastly 0.12` (the 0.11/0.12 bridge via `compat` is + the chosen design). diff --git a/scripts/integration-tests-browser.sh b/scripts/integration-tests-browser.sh index 9db45f34a..2d3bb8187 100755 --- a/scripts/integration-tests-browser.sh +++ b/scripts/integration-tests-browser.sh @@ -54,10 +54,28 @@ cd "$REPO_ROOT/$BROWSER_DIR" npm ci npx playwright install chromium +# --- Seed the app_config config store into the Viceroy config --- +# The runtime reconstructs Settings from the `app_config` config store at +# request time; without it every settings-dependent route returns 503 and +# Viceroy never becomes ready. Generate a seeded config from the integration +# application config (shares the logic with the Rust test harness). +echo "==> Seeding app_config config store for Viceroy..." +HOST_TARGET="$(rustc -vV | sed -n 's/^host: //p')" +SEEDED_VICEROY_CONFIG="$REPO_ROOT/target/integration/viceroy-seeded.toml" +mkdir -p "$(dirname "$SEEDED_VICEROY_CONFIG")" +cargo run --quiet \ + --manifest-path "$REPO_ROOT/crates/integration-tests/Cargo.toml" \ + --target "$HOST_TARGET" \ + --bin seed-viceroy-config -- \ + --template "$REPO_ROOT/crates/integration-tests/fixtures/configs/viceroy-template.toml" \ + --fixture "$REPO_ROOT/crates/integration-tests/fixtures/configs/trusted-server-integration.toml" \ + --port "$ORIGIN_PORT" \ + --out "$SEEDED_VICEROY_CONFIG" + # --- Export env vars for global-setup.ts --- export WASM_BINARY_PATH="$REPO_ROOT/target/wasm32-wasip1/release/trusted-server-adapter-fastly.wasm" export INTEGRATION_ORIGIN_PORT="$ORIGIN_PORT" -export VICEROY_CONFIG_PATH="$REPO_ROOT/crates/integration-tests/fixtures/configs/viceroy-template.toml" +export VICEROY_CONFIG_PATH="$SEEDED_VICEROY_CONFIG" # Cleanup trap: stop any leftover containers on failure stop_matching_containers() {