diff --git a/Cargo.lock b/Cargo.lock index e29380b77..ce9768dc8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5341,6 +5341,7 @@ dependencies = [ "async-trait", "base64", "bytes", + "derive_more", "edgezero-adapter-cloudflare", "edgezero-core", "error-stack", diff --git a/crates/trusted-server-adapter-axum/src/platform.rs b/crates/trusted-server-adapter-axum/src/platform.rs index a511daab2..52c1823a5 100644 --- a/crates/trusted-server-adapter-axum/src/platform.rs +++ b/crates/trusted-server-adapter-axum/src/platform.rs @@ -25,7 +25,9 @@ fn normalize_env_segment(s: &str) -> String { s.to_uppercase().replace(['-', '.', ' '], "_") } -fn config_env_var(store_name: &str, key: &str) -> String { +/// Returns the environment-variable name for a config store entry. +#[must_use] +pub fn config_env_var(store_name: &str, key: &str) -> String { format!( "TRUSTED_SERVER_CONFIG_{}_{}", normalize_env_segment(store_name), @@ -601,6 +603,15 @@ mod tests { use std::time::Duration; use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; + #[test] + fn config_env_var_normalizes_store_and_key() { + assert_eq!( + config_env_var("my-store.name", "my key"), + "TRUSTED_SERVER_CONFIG_MY_STORE_NAME_MY_KEY", + "should normalize environment-variable segments" + ); + } + #[test] fn config_store_reads_from_env_var() { temp_env::with_var( diff --git a/crates/trusted-server-adapter-cloudflare/Cargo.toml b/crates/trusted-server-adapter-cloudflare/Cargo.toml index 097844012..2dfd9773d 100644 --- a/crates/trusted-server-adapter-cloudflare/Cargo.toml +++ b/crates/trusted-server-adapter-cloudflare/Cargo.toml @@ -23,6 +23,7 @@ cloudflare = ["edgezero-adapter-cloudflare/cloudflare", "dep:worker"] [dependencies] async-trait = { workspace = true } bytes = { workspace = true } +derive_more = { workspace = true } edgezero-adapter-cloudflare = { workspace = true } edgezero-core = { workspace = true } error-stack = { workspace = true } diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index 86ac86987..780fd3c0c 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -11,6 +11,8 @@ use error_stack::Report; use trusted_server_core::auction::endpoints::handle_auction; use trusted_server_core::auction::{AuctionOrchestrator, build_orchestrator}; use trusted_server_core::cache_policy::EdgeCacheHeader; +#[cfg(any(test, target_arch = "wasm32"))] +use trusted_server_core::config_payload::CONFIG_BLOB_KEY; #[cfg(target_arch = "wasm32")] use trusted_server_core::config_payload::settings_from_config_blob; use trusted_server_core::ec::EcContext; @@ -79,13 +81,39 @@ fn load_startup_settings() -> Result> { Settings::from_toml(include_str!("../../../trusted-server.example.toml")) } +/// Older Cloudflare bindings used this JSON property before config stores adopted +/// the manifest-derived default. +/// +/// Remove this fallback only when support for those bindings is deliberately retired. +#[cfg(any(test, target_arch = "wasm32"))] +const LEGACY_CONFIG_BLOB_KEY: &str = "app_config"; + +#[cfg(any(test, target_arch = "wasm32"))] +#[derive(Debug, Eq, PartialEq, derive_more::Display)] +enum CloudflareConfigEnvelopeError { + #[display( + "Cloudflare TRUSTED_SERVER_CONFIG missing string values at `{primary_key}` and legacy `{legacy_key}`" + )] + Missing { + primary_key: &'static str, + legacy_key: &'static str, + }, + #[display("Cloudflare TRUSTED_SERVER_CONFIG value at `{key}` must be a string")] + NonString { key: &'static str }, +} + +#[cfg(any(test, target_arch = "wasm32"))] +impl core::error::Error for CloudflareConfigEnvelopeError {} + #[cfg(target_arch = "wasm32")] fn settings_from_cloudflare_config_json() -> Result> { let raw_config = CLOUDFLARE_CONFIG_JSON.get().ok_or_else(|| { Report::new(TrustedServerError::Configuration { message: "Cloudflare TRUSTED_SERVER_CONFIG is required".to_string(), }) - .attach("set TRUSTED_SERVER_CONFIG to JSON containing the app_config blob envelope") + .attach(format!( + "set TRUSTED_SERVER_CONFIG to JSON containing the `{CONFIG_BLOB_KEY}` blob envelope" + )) })?; let value: serde_json::Value = serde_json::from_str(raw_config).map_err(|error| { Report::new(TrustedServerError::Configuration { @@ -93,17 +121,38 @@ fn settings_from_cloudflare_config_json() -> Result Result<&str, CloudflareConfigEnvelopeError> { + match value.get(CONFIG_BLOB_KEY) { + Some(envelope) => envelope + .as_str() + .ok_or(CloudflareConfigEnvelopeError::NonString { + key: CONFIG_BLOB_KEY, + }), + None => match value.get(LEGACY_CONFIG_BLOB_KEY) { + Some(envelope) => envelope + .as_str() + .ok_or(CloudflareConfigEnvelopeError::NonString { + key: LEGACY_CONFIG_BLOB_KEY, + }), + None => Err(CloudflareConfigEnvelopeError::Missing { + primary_key: CONFIG_BLOB_KEY, + legacy_key: LEGACY_CONFIG_BLOB_KEY, + }), + }, + } +} + /// Build the application state from explicit settings. /// /// # Errors @@ -620,3 +669,83 @@ fn build_router(state: &Arc) -> RouterService { router.build() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cloudflare_config_prefers_manifest_default_key() { + let value = serde_json::json!({ + LEGACY_CONFIG_BLOB_KEY: "legacy-envelope", + CONFIG_BLOB_KEY: "manifest-envelope", + }); + + assert_eq!( + cloudflare_config_envelope(&value), + Ok("manifest-envelope"), + "manifest-derived key should take precedence" + ); + } + + #[test] + fn cloudflare_config_accepts_legacy_app_config_key() { + let value = serde_json::json!({ LEGACY_CONFIG_BLOB_KEY: "legacy-envelope" }); + + assert_eq!( + cloudflare_config_envelope(&value), + Ok("legacy-envelope"), + "legacy app_config key should remain compatible" + ); + } + + #[test] + fn cloudflare_config_reports_missing_keys() { + let value = serde_json::json!({}); + + assert_eq!( + cloudflare_config_envelope(&value), + Err(CloudflareConfigEnvelopeError::Missing { + primary_key: CONFIG_BLOB_KEY, + legacy_key: LEGACY_CONFIG_BLOB_KEY, + }), + "missing config should name both accepted keys" + ); + } + + #[test] + fn cloudflare_config_does_not_mask_malformed_manifest_value() { + let value = serde_json::json!({ + LEGACY_CONFIG_BLOB_KEY: "legacy-envelope", + CONFIG_BLOB_KEY: true, + }); + + assert_eq!( + cloudflare_config_envelope(&value), + Err(CloudflareConfigEnvelopeError::NonString { + key: CONFIG_BLOB_KEY, + }), + "malformed manifest-derived value should not fall back" + ); + } + + #[test] + fn cloudflare_config_reports_malformed_legacy_value() { + let value = serde_json::json!({ LEGACY_CONFIG_BLOB_KEY: false }); + let error = cloudflare_config_envelope(&value) + .expect_err("should reject a malformed legacy config value"); + + assert_eq!( + error, + CloudflareConfigEnvelopeError::NonString { + key: LEGACY_CONFIG_BLOB_KEY, + }, + "malformed legacy value should name the legacy key" + ); + assert_eq!( + error.to_string(), + "Cloudflare TRUSTED_SERVER_CONFIG value at `app_config` must be a string", + "configuration error should name the malformed legacy key" + ); + } +} diff --git a/crates/trusted-server-adapter-cloudflare/wrangler.toml b/crates/trusted-server-adapter-cloudflare/wrangler.toml index 7c91173fc..ffb331529 100644 --- a/crates/trusted-server-adapter-cloudflare/wrangler.toml +++ b/crates/trusted-server-adapter-cloudflare/wrangler.toml @@ -23,6 +23,6 @@ id = "REPLACE_WITH_YOUR_KV_NAMESPACE_ID" [vars] # TRUSTED_SERVER_CONFIG is required at startup. Replace this intentionally -# invalid placeholder with JSON containing an `app_config` blob envelope before -# deploying or running `wrangler dev` against real traffic. -TRUSTED_SERVER_CONFIG = '{"app_config":""}' +# invalid placeholder with JSON containing the manifest-default app-config blob +# envelope before deploying or running `wrangler dev` against real traffic. +TRUSTED_SERVER_CONFIG = '{"trusted_server_config":""}' diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index a19d0485d..93aba5f5e 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -26,6 +26,7 @@ use trusted_server_core::platform::RuntimeServices; use trusted_server_core::proxy::{AssetProxyCachePolicy, stream_asset_body}; use trusted_server_core::response_privacy::TerminalPrivateResponse; use trusted_server_core::settings::Settings; +use trusted_server_core::settings_data::default_config_store_name; mod app; mod backend; @@ -46,18 +47,15 @@ use crate::middleware::{HEADER_X_TS_FINALIZED, apply_finalize_headers, resolve_g use crate::platform::{FastlyPlatformGeo, client_info_from_request}; use crate::rate_limiter::{FastlyRateLimiter, RATE_COUNTER_NAME}; -const TRUSTED_SERVER_CONFIG_STORE: &str = "trusted_server_config"; - -/// Opens the Fastly Config Store used by the `EdgeZero` dispatcher. +/// Opens the manifest-default Fastly Config Store used by the `EdgeZero` dispatcher. /// /// # Errors /// /// Returns [`fastly::Error`] if the config store cannot be opened. fn open_trusted_server_config_store() -> Result { - let store = EdgeZeroFastlyConfigStore::try_open(TRUSTED_SERVER_CONFIG_STORE).map_err(|e| { - fastly::Error::msg(format!( - "failed to open config store `{TRUSTED_SERVER_CONFIG_STORE}`: {e}" - )) + let store_name = default_config_store_name(); + let store = EdgeZeroFastlyConfigStore::try_open(store_name.as_ref()).map_err(|e| { + fastly::Error::msg(format!("failed to open config store `{store_name}`: {e}")) })?; Ok(ConfigStoreHandle::new(Arc::new(store))) } diff --git a/crates/trusted-server-core/Cargo.toml b/crates/trusted-server-core/Cargo.toml index e44d46f77..994a7c366 100644 --- a/crates/trusted-server-core/Cargo.toml +++ b/crates/trusted-server-core/Cargo.toml @@ -58,6 +58,9 @@ web-time = { workspace = true } getrandom = { workspace = true, features = ["js"] } uuid = { workspace = true, features = ["js"] } +[build-dependencies] +edgezero-core = { workspace = true } + [features] default = [] # Exposes test-only constructors (e.g. `IntegrationRegistry::from_request_filters`) diff --git a/crates/trusted-server-core/build.rs b/crates/trusted-server-core/build.rs index c2bce4fe2..df790cb7f 100644 --- a/crates/trusted-server-core/build.rs +++ b/crates/trusted-server-core/build.rs @@ -1,3 +1,36 @@ +use std::env; +use std::path::PathBuf; + +use edgezero_core::manifest::ManifestLoader; + fn main() { println!("cargo:rerun-if-changed=build.rs"); + + // Keep every adapter's compiled default synchronized with the repository manifest. + let manifest_path = PathBuf::from( + env::var("CARGO_MANIFEST_DIR").expect("should receive CARGO_MANIFEST_DIR from Cargo"), + ) + .join("../..") + .join("edgezero.toml"); + println!("cargo:rerun-if-changed={}", manifest_path.display()); + + let manifest = match ManifestLoader::from_path(&manifest_path) { + Ok(manifest) => manifest, + Err(error) => { + println!( + "cargo::error=should load EdgeZero manifest at {}: {error}", + manifest_path.display() + ); + std::process::exit(1); + } + }; + let Some(config_store) = manifest.manifest().stores.config.as_ref() else { + println!( + "cargo::error=should declare [stores.config] in EdgeZero manifest at {}", + manifest_path.display() + ); + std::process::exit(1); + }; + let default_store_id = config_store.default_id(); + println!("cargo:rustc-env=TRUSTED_SERVER_DEFAULT_CONFIG_STORE_ID={default_store_id}"); } diff --git a/crates/trusted-server-core/src/config_payload.rs b/crates/trusted-server-core/src/config_payload.rs index 6ede36e9c..e17c91675 100644 --- a/crates/trusted-server-core/src/config_payload.rs +++ b/crates/trusted-server-core/src/config_payload.rs @@ -11,8 +11,13 @@ use error_stack::Report; use crate::error::TrustedServerError; use crate::settings::Settings; +/// Default logical config-store id, from `[stores.config].default` in `edgezero.toml`. +/// +/// Derived at build time so every adapter uses the repository manifest's default. +pub const DEFAULT_CONFIG_STORE_ID: &str = env!("TRUSTED_SERVER_DEFAULT_CONFIG_STORE_ID"); + /// Default config-store key containing the Trusted Server app-config blob. -pub const CONFIG_BLOB_KEY: &str = "trusted_server_config"; +pub const CONFIG_BLOB_KEY: &str = DEFAULT_CONFIG_STORE_ID; /// Reconstruct validated [`Settings`] from a serialized config blob envelope. /// diff --git a/crates/trusted-server-core/src/settings_data.rs b/crates/trusted-server-core/src/settings_data.rs index 06ea548fc..91ea2d0ca 100644 --- a/crates/trusted-server-core/src/settings_data.rs +++ b/crates/trusted-server-core/src/settings_data.rs @@ -3,12 +3,11 @@ use error_stack::{Report, ResultExt}; use serde::Deserialize; use sha2::{Digest as _, Sha256}; -use crate::config_payload::settings_from_config_blob; +use crate::config_payload::{DEFAULT_CONFIG_STORE_ID, settings_from_config_blob}; use crate::error::TrustedServerError; use crate::platform::{PlatformConfigStore, StoreName}; use crate::settings::Settings; -const DEFAULT_CONFIG_STORE_ID: &str = "trusted_server_config"; const FASTLY_CHUNK_POINTER_KIND: &str = "fastly_config_chunks"; const FASTLY_CONFIG_ENTRY_LIMIT: usize = 8_000; @@ -29,15 +28,32 @@ struct FastlyChunkRef { } /// Returns the default `EdgeZero` app-config store name. +/// +/// Process-environment overrides apply to native adapters such as Axum. Fastly +/// has no process environment, so it uses the manifest default as the logical +/// name and resolves the physical store through a resource link. #[must_use] pub fn default_config_store_name() -> StoreName { - StoreName::from(EnvConfig::from_env().store_name("config", DEFAULT_CONFIG_STORE_ID)) + default_config_store_name_from_env(&EnvConfig::from_env()) +} + +fn default_config_store_name_from_env(env_config: &EnvConfig) -> StoreName { + StoreName::from(env_config.store_name("config", DEFAULT_CONFIG_STORE_ID)) } /// Returns the default config-store key containing the app-config blob. +/// +/// Process-environment overrides apply to native adapters such as Axum. When +/// using a key override, pass the same value to `ts config push --key`; the CLI +/// otherwise writes at the logical store ID. Fastly has no process environment, +/// so its custom entry point uses the manifest default key. #[must_use] pub fn default_config_key() -> String { - EnvConfig::from_env().store_key("config", DEFAULT_CONFIG_STORE_ID) + default_config_key_from_env(&EnvConfig::from_env()) +} + +fn default_config_key_from_env(env_config: &EnvConfig) -> String { + env_config.store_key("config", DEFAULT_CONFIG_STORE_ID) } /// Loads [`Settings`] from a platform config store and key. @@ -176,7 +192,7 @@ fn configuration_error(message: String) -> Result Result fn generated_config_store_blocks(envelope_json: &str) -> String { format!( r#" # Generated by generate-viceroy-config. Do not edit generated output. - [local_server.config_stores.trusted_server_config] + [local_server.config_stores.{DEFAULT_CONFIG_STORE_ID}] format = "inline-toml" - [local_server.config_stores.trusted_server_config.contents] - trusted_server_config = '''{envelope_json}'''"# + [local_server.config_stores.{DEFAULT_CONFIG_STORE_ID}.contents] + {CONFIG_BLOB_KEY} = '''{envelope_json}'''"# ) } @@ -216,8 +220,10 @@ mod tests { .expect("should inject generated stores"); assert!( - generated.contains("[local_server.config_stores.trusted_server_config]"), - "should include app config store" + generated.contains(&format!( + "[local_server.config_stores.{DEFAULT_CONFIG_STORE_ID}]" + )), + "should include manifest-default app config store" ); assert!( !generated.contains("edgezero_enabled"), @@ -241,11 +247,11 @@ mod tests { let parsed: toml::Value = toml::from_str(&generated).expect("should parse as TOML"); assert_eq!( - parsed["local_server"]["config_stores"]["trusted_server_config"]["contents"] - ["trusted_server_config"] + parsed["local_server"]["config_stores"][DEFAULT_CONFIG_STORE_ID]["contents"] + [CONFIG_BLOB_KEY] .as_str(), Some(envelope.as_str()), - "trusted_server_config should contain the app-config blob" + "manifest-default config store should contain the app-config blob" ); } diff --git a/crates/trusted-server-integration-tests/tests/common/config.rs b/crates/trusted-server-integration-tests/tests/common/config.rs index 4dc971d0e..caedf9799 100644 --- a/crates/trusted-server-integration-tests/tests/common/config.rs +++ b/crates/trusted-server-integration-tests/tests/common/config.rs @@ -1,6 +1,7 @@ use edgezero_core::blob_envelope::BlobEnvelope; use error_stack::Report; use trusted_server_core::config::validate_settings_for_deploy; +use trusted_server_core::config_payload::CONFIG_BLOB_KEY; use trusted_server_core::settings::Settings; use crate::common::runtime::{TestError, TestResult}; @@ -35,7 +36,7 @@ pub fn integration_app_config_envelope(origin_port: u16) -> TestResult { pub fn cloudflare_config_json(origin_port: u16) -> TestResult { let envelope = integration_app_config_envelope(origin_port)?; - serde_json::to_string(&serde_json::json!({ "app_config": envelope })).map_err(|error| { + serde_json::to_string(&serde_json::json!({ CONFIG_BLOB_KEY: envelope })).map_err(|error| { Report::new(TestError::ConfigGeneration).attach(format!( "failed to serialize Cloudflare config binding: {error}" )) diff --git a/crates/trusted-server-integration-tests/tests/environments/axum.rs b/crates/trusted-server-integration-tests/tests/environments/axum.rs index 235af413f..f7f766245 100644 --- a/crates/trusted-server-integration-tests/tests/environments/axum.rs +++ b/crates/trusted-server-integration-tests/tests/environments/axum.rs @@ -6,6 +6,8 @@ use error_stack::ResultExt as _; use std::io::{BufRead as _, BufReader}; use std::path::Path; use std::process::{Child, Command, Stdio}; +use trusted_server_adapter_axum::platform::config_env_var; +use trusted_server_core::settings_data::{default_config_key, default_config_store_name}; /// Default port the Axum dev server binds to when no `PORT` env var is supplied. const AXUM_DEFAULT_PORT: u16 = 8787; @@ -33,13 +35,13 @@ impl RuntimeEnvironment for AxumDevServer { let port = super::find_available_port().unwrap_or(AXUM_DEFAULT_PORT); let app_config = integration_app_config_envelope(origin_port())?; + let store_name = default_config_store_name(); + let config_key = default_config_key(); + let config_variable = config_env_var(store_name.as_ref(), &config_key); let mut child = Command::new(&binary) .env("PORT", port.to_string()) - .env( - "TRUSTED_SERVER_CONFIG_TRUSTED_SERVER_CONFIG_TRUSTED_SERVER_CONFIG", - app_config, - ) + .env(config_variable, app_config) .stdout(Stdio::null()) .stderr(Stdio::piped()) .spawn() diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 1240dce20..41f24bb57 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -1733,25 +1733,54 @@ After the EdgeZero cutover, the Fastly adapter always dispatches through the EdgeZero entry point. The former `edgezero_enabled` and `edgezero_rollout_pct` canary keys are no longer read. -The Fastly service must still provide a `trusted_server_config` config store -because the entry point opens it before dispatch and passes the handle to -EdgeZero-backed platform services. The store may be empty unless another feature -adds keys to it. +The Fastly service opens the logical config store selected by +`[stores.config].default` in `edgezero.toml` and reads the app-config blob at +its default key. A Fastly resource link maps that logical store name to the +physical config store for the service at runtime. -**Local development** (`fastly.toml`): +Fastly store names are account-level. For a first deployment, the default setup +is safe only when no other service in the account uses the +`trusted_server_config` physical store: -```toml -[local_server.config_stores] - [local_server.config_stores.trusted_server_config] - format = "inline-toml" - [local_server.config_stores.trusted_server_config.contents] +```bash +ts provision --adapter fastly +ts config push --adapter fastly --dry-run +ts config push --adapter fastly +``` + +The pinned EdgeZero provisioner cannot create a service-specific physical store +with a different logical resource-link name during first deployment. Its +`__NAME` override becomes both the physical store name and the generated Fastly +setup-table key, so the deployed service would link that physical name while the +Trusted Server entry point opens `trusted_server_config`. Do not use +`ts provision` with a `__NAME` override for this case. + +For a service in a shared account, create the service and a service version +first. Then create and link the service-specific physical store explicitly, +seed it, and activate the linked version before publishing traffic: + +```bash +fastly config-store create --name +fastly resource-link create --service-id --version latest --autoclone \ + --resource-id --name trusted_server_config +EDGEZERO__STORES__CONFIG__TRUSTED_SERVER_CONFIG__NAME= \ + ts config push --adapter fastly --dry-run +EDGEZERO__STORES__CONFIG__TRUSTED_SERVER_CONFIG__NAME= \ + ts config push --adapter fastly +fastly service-version activate --service-id --version latest ``` -**Production setup** (Fastly CLI): +Confirm that each dry run names the intended physical store before writing. In +a shared account, it must be the service-specific store rather than the +account-level default. The resource-link name must match the logical store ID, +and the physical store must contain a valid Trusted Server app-config blob +envelope at its default key. An absent or empty entry makes application startup +fail closed. + +**Local development** (writes the entry used by Viceroy in `fastly.toml`): ```bash -# Create the store once and attach it to the service. -fastly config-store create --name trusted_server_config +ts config push --adapter fastly --local ``` Rollback to the legacy entry point is no longer controlled by runtime config diff --git a/edgezero.toml b/edgezero.toml index 2120ca5c9..b40b20281 100644 --- a/edgezero.toml +++ b/edgezero.toml @@ -16,9 +16,9 @@ version = "0.1.0" # -- Stores ------------------------------------------------------------------ # Logical store ids only. These are the portable Trusted Server names; the # physical store each adapter binds is overridable out of band (Fastly binds -# `ec_identity_store`/`app_config`/secret stores in `fastly.toml`, Spin via its -# runtime config, Cloudflare via bindings), so the ids here do not have to match -# any one platform's names. `default` is the primary logical id. +# `ec_identity_store`/`trusted_server_config`/secret stores in `fastly.toml`, +# Spin via its runtime config, Cloudflare via bindings), so the ids here do not +# have to match any one platform's names. `default` is the primary logical id. [stores.kv] ids = ["trusted_server_kv"]