diff --git a/Cargo.lock b/Cargo.lock index cf61eef7a..7051f7dab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5365,6 +5365,7 @@ dependencies = [ "async-trait", "base64", "bytes", + "derive_more", "edgezero-adapter-cloudflare", "edgezero-core", "error-stack", @@ -5373,6 +5374,7 @@ dependencies = [ "log", "serde_json", "tokio", + "toml", "trusted-server-core", "trusted-server-js", "worker", diff --git a/crates/trusted-server-adapter-axum/src/platform.rs b/crates/trusted-server-adapter-axum/src/platform.rs index 7dcdd53d8..d13588115 100644 --- a/crates/trusted-server-adapter-axum/src/platform.rs +++ b/crates/trusted-server-adapter-axum/src/platform.rs @@ -26,7 +26,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), @@ -608,6 +610,15 @@ mod tests { ); } + #[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 e1b438bf5..2ce969ef7 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 } @@ -42,4 +43,5 @@ worker = { workspace = true } trusted-server-core = { workspace = true, features = ["test-utils"] } base64 = { workspace = true } edgezero-core = { workspace = true } +toml = { workspace = true } tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index 9fd316d9f..1a9911b0c 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -13,6 +13,8 @@ use trusted_server_core::auction::{ AuctionOrchestrator, build_orchestrator_with_plan, compile_auction_plan, }; 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::{DEFAULT_SECRET_STORE_ID, settings_from_config_blob}; use trusted_server_core::ec::EcContext; @@ -99,6 +101,30 @@ fn load_startup_settings() -> Result> { .attach("use TrustedServerApp::routes_with_settings for host tests")) } +/// 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 has no `{primary_key}` or legacy `{legacy_key}` property" + )] + 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.with(|slot| slot.get().cloned()); @@ -106,7 +132,9 @@ fn settings_from_cloudflare_config_json() -> Result Result Result Result<&str, CloudflareConfigEnvelopeError> { + match value.get(CONFIG_BLOB_KEY).filter(|envelope| { + // Treat a blank placeholder as absent so a populated legacy property + // remains usable during migration. + envelope.as_str() != Some("") + }) { + 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 @@ -745,6 +798,20 @@ mod tests { ); } + #[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 disabled_startup_accepts_dormant_multi_provider_auction_plan() { let mut settings = Settings::from_toml( @@ -838,4 +905,89 @@ mod tests { "should identify unsupported fanout: {error:?}" ); } + + #[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_treats_blank_primary_as_absent() { + let value = serde_json::json!({ + CONFIG_BLOB_KEY: "", + LEGACY_CONFIG_BLOB_KEY: "legacy-envelope", + }); + + assert_eq!( + cloudflare_config_envelope(&value), + Ok("legacy-envelope"), + "blank primary should not shadow a populated legacy property" + ); + } + + #[test] + fn cloudflare_config_reports_missing_keys() { + let value = serde_json::json!({}); + + let error = cloudflare_config_envelope(&value) + .expect_err("should reject config without either accepted property"); + + assert_eq!( + error, + CloudflareConfigEnvelopeError::Missing { + primary_key: CONFIG_BLOB_KEY, + legacy_key: LEGACY_CONFIG_BLOB_KEY, + }, + "missing config should name both accepted keys" + ); + assert_eq!( + error.to_string(), + format!( + "Cloudflare TRUSTED_SERVER_CONFIG has no `{CONFIG_BLOB_KEY}` or legacy `{LEGACY_CONFIG_BLOB_KEY}` property" + ), + "missing config should report absent properties, not invalid types" + ); + } + + #[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/tests/config_defaults.rs b/crates/trusted-server-adapter-cloudflare/tests/config_defaults.rs new file mode 100644 index 000000000..e573c9747 --- /dev/null +++ b/crates/trusted-server-adapter-cloudflare/tests/config_defaults.rs @@ -0,0 +1,38 @@ +//! Keep the checked-in Cloudflare configuration aligned with the runtime defaults. + +use trusted_server_core::config_payload::{CONFIG_BLOB_KEY, DEFAULT_CONFIG_STORE_ID}; + +#[test] +fn cloudflare_manifest_uses_the_runtime_config_store_id() { + let manifest: toml::Value = toml::from_str(include_str!("../cloudflare.toml")) + .expect("should parse the Cloudflare manifest"); + + assert_eq!( + manifest["stores"]["config"]["name"].as_str(), + Some(DEFAULT_CONFIG_STORE_ID), + "Cloudflare config store should match the manifest-derived runtime default" + ); +} + +#[test] +fn wrangler_placeholder_uses_the_runtime_blob_key() { + let manifest: toml::Value = toml::from_str(include_str!("../wrangler.toml")) + .expect("should parse the Wrangler manifest"); + let raw_config = manifest["vars"]["TRUSTED_SERVER_CONFIG"] + .as_str() + .expect("should declare the config JSON binding"); + let config: serde_json::Value = + serde_json::from_str(raw_config).expect("should parse the config JSON placeholder"); + let entries = config + .as_object() + .expect("should contain config properties"); + + assert_eq!(entries.len(), 1, "should declare only the current blob key"); + assert_eq!( + entries + .get(CONFIG_BLOB_KEY) + .and_then(serde_json::Value::as_str), + Some(""), + "placeholder should use the runtime key and remain invalid until seeded" + ); +} diff --git a/crates/trusted-server-adapter-cloudflare/wrangler.toml b/crates/trusted-server-adapter-cloudflare/wrangler.toml index 48eb2db8d..f3b5ef6aa 100644 --- a/crates/trusted-server-adapter-cloudflare/wrangler.toml +++ b/crates/trusted-server-adapter-cloudflare/wrangler.toml @@ -23,9 +23,9 @@ 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":""}' # App-config secret values are provisioned as Worker secrets with # `wrangler secret put `. The pushed blob contains only those key diff --git a/crates/trusted-server-cli/tests/config_store_defaults.rs b/crates/trusted-server-cli/tests/config_store_defaults.rs new file mode 100644 index 000000000..23c5f86e9 --- /dev/null +++ b/crates/trusted-server-cli/tests/config_store_defaults.rs @@ -0,0 +1,151 @@ +//! Exercise the CLI's manifest resolution against the compiled runtime defaults. + +use std::collections::BTreeMap; +use std::fs; +use std::process::{Command, Output}; + +use tempfile::TempDir; +use trusted_server_core::config_payload::{CONFIG_BLOB_KEY, DEFAULT_CONFIG_STORE_ID}; + +fn project() -> TempDir { + let directory = tempfile::tempdir().expect("should create a temporary project"); + let mut manifest: toml::Value = toml::from_str(include_str!("../../../edgezero.toml")) + .expect("should parse the repository manifest"); + // Keep the real store declarations without loading unrelated adapter files. + manifest["adapters"] + .as_table_mut() + .expect("should declare adapters") + .retain(|name, _| name == "axum"); + fs::write( + directory.path().join("edgezero.toml"), + toml::to_string(&manifest).expect("should serialize the Axum test manifest"), + ) + .expect("should write the test manifest"); + fs::write( + directory.path().join("trusted-server.toml"), + include_str!("../../../trusted-server.example.toml"), + ) + .expect("should copy the example app config"); + directory +} + +fn push(project: &TempDir, args: &[&str], overrides: &[(&str, &str)]) -> Output { + let output = Command::new(env!("CARGO_BIN_EXE_ts")) + .args([ + "config", + "push", + "--adapter", + "axum", + "--local", + "--no-diff", + ]) + .args(args) + .current_dir(project.path()) + // Do not inherit operator configuration or credentials from the test runner. + .env_clear() + .env("RUST_LOG", "info") + .env("TRUSTED_SERVER__PUBLISHER__DOMAIN", "publisher.example.com") + .env( + "TRUSTED_SERVER__PUBLISHER__COOKIE_DOMAIN", + ".publisher.example.com", + ) + .env( + "TRUSTED_SERVER__PUBLISHER__ORIGIN_URL", + "https://upstream.example.com", + ) + .envs(overrides.iter().copied()) + .output() + .expect("should run a local config push"); + assert!( + output.status.success(), + "local push should succeed: {}", + String::from_utf8_lossy(&output.stderr) + ); + output +} + +fn stored_entries(project: &TempDir) -> BTreeMap { + // Axum's local file is keyed by logical ID even with a physical-name override. + let path = project.path().join(format!( + ".edgezero/local-config-{DEFAULT_CONFIG_STORE_ID}.json" + )); + let raw = fs::read_to_string(path).expect("should write the manifest-default local store"); + serde_json::from_str(&raw).expect("should parse the stored config entries") +} + +#[test] +fn config_push_default_store_and_key_match_the_compiled_runtime() { + let project = project(); + push(&project, &["--yes"], &[]); + + let entries = stored_entries(&project); + assert_eq!(entries.len(), 1, "should write only the default blob key"); + let envelope: serde_json::Value = serde_json::from_str( + entries + .get(CONFIG_BLOB_KEY) + .expect("should write at the compiled runtime's default key"), + ) + .expect("should write a JSON blob envelope"); + assert_eq!( + envelope["data"]["publisher"]["domain"], + "publisher.example.com" + ); +} + +#[test] +fn config_push_resolves_the_physical_name_and_explicit_key() { + let project = project(); + let name_var = format!( + "EDGEZERO__STORES__CONFIG__{}__NAME", + DEFAULT_CONFIG_STORE_ID.to_uppercase() + ); + let key_var = format!( + "EDGEZERO__STORES__CONFIG__{}__KEY", + DEFAULT_CONFIG_STORE_ID.to_uppercase() + ); + let overrides = [ + (name_var.as_str(), "example_config"), + (key_var.as_str(), "active_config"), + ]; + let preview = push( + &project, + &["--dry-run", "--key", "active_config"], + &overrides, + ); + let stdout = String::from_utf8_lossy(&preview.stdout); + assert!( + stdout.contains(&format!( + "store `{DEFAULT_CONFIG_STORE_ID}` (platform name `example_config`)" + )), + "preview should resolve the logical and physical store names: {stdout}" + ); + assert!( + !project.path().join(".edgezero").exists(), + "preview should not write local config" + ); + + push(&project, &["--yes", "--key", "active_config"], &overrides); + let entries = stored_entries(&project); + assert_eq!(entries.len(), 1); + assert!( + entries.contains_key("active_config"), + "explicit push key should match the runtime override" + ); +} + +#[test] +fn config_push_does_not_use_the_runtime_key_override_without_the_key_flag() { + let project = project(); + let key_var = format!( + "EDGEZERO__STORES__CONFIG__{}__KEY", + DEFAULT_CONFIG_STORE_ID.to_uppercase() + ); + push(&project, &["--yes"], &[(&key_var, "active_config")]); + + let entries = stored_entries(&project); + assert_eq!(entries.len(), 1); + assert!( + entries.contains_key(CONFIG_BLOB_KEY), + "runtime-only key override should not move the CLI's write destination" + ); +} diff --git a/crates/trusted-server-core/Cargo.toml b/crates/trusted-server-core/Cargo.toml index 01780dd39..3dbf41df3 100644 --- a/crates/trusted-server-core/Cargo.toml +++ b/crates/trusted-server-core/Cargo.toml @@ -59,6 +59,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..47f5d5740 100644 --- a/crates/trusted-server-core/build.rs +++ b/crates/trusted-server-core/build.rs @@ -1,3 +1,39 @@ +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 crate_dir = PathBuf::from( + env::var("CARGO_MANIFEST_DIR").expect("should receive CARGO_MANIFEST_DIR from Cargo"), + ); + let manifest_path = crate_dir + .ancestors() + .nth(2) + .expect("should resolve the workspace root from CARGO_MANIFEST_DIR") + .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 497d48b3e..c9690b01f 100644 --- a/crates/trusted-server-core/src/config_payload.rs +++ b/crates/trusted-server-core/src/config_payload.rs @@ -17,8 +17,19 @@ use crate::settings::Settings; /// Canonical logical secret store used by Trusted Server app-config secrets. pub const DEFAULT_SECRET_STORE_ID: &str = "trusted_server_secrets"; +/// 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"; +/// +/// Intentionally matches the logical store ID: an ordinary `ts config push` +/// writes there unless `--key` selects another key. This constant does not apply +/// runtime overrides; use [`crate::settings_data::config_key`] with the adapter's +/// runtime configuration, or [`crate::settings_data::default_config_key`] for +/// process-environment overrides. +pub const CONFIG_BLOB_KEY: &str = DEFAULT_CONFIG_STORE_ID; /// Reconstruct runtime [`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 103ac819c..07108fac6 100644 --- a/crates/trusted-server-core/src/settings_data.rs +++ b/crates/trusted-server-core/src/settings_data.rs @@ -3,14 +3,12 @@ use error_stack::{Report, ResultExt}; use serde::Deserialize; use sha2::{Digest as _, Sha256}; -use crate::config_payload::DEFAULT_SECRET_STORE_ID; -use crate::config_payload::settings_from_config_blob; +pub use crate::config_payload::DEFAULT_CONFIG_STORE_ID; +use crate::config_payload::{DEFAULT_SECRET_STORE_ID, settings_from_config_blob}; use crate::error::TrustedServerError; use crate::platform::{PlatformConfigStore, PlatformSecretStore, StoreName}; use crate::settings::Settings; -/// Canonical logical config store used by Trusted Server app config. -pub 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; @@ -42,13 +40,23 @@ pub fn config_key(env: &EnvConfig) -> String { env.store_key("config", DEFAULT_CONFIG_STORE_ID) } -/// Returns the default `EdgeZero` app-config store name. +/// Resolves the `EdgeZero` app-config store name from the process environment. +/// +/// Native adapters such as Axum use this wrapper. Fastly instead supplies an +/// `EnvConfig` populated from service-scoped entries in `edgezero_runtime_env` +/// and resolves the store name from that configuration. Without an override, +/// both paths use the manifest default logical store ID. #[must_use] pub fn default_config_store_name() -> StoreName { config_store_name(&EnvConfig::from_env()) } -/// Returns the default config-store key containing the app-config blob. +/// Resolves the app-config blob key from the process environment. +/// +/// Native adapters such as Axum use this wrapper. Fastly resolves the key from +/// service-scoped entries in `edgezero_runtime_env` instead. A runtime `__KEY` +/// override must match `ts config push --key`; an ordinary push without that +/// flag writes at the logical store ID, regardless of the `__KEY` override. #[must_use] pub fn default_config_key() -> String { config_key(&EnvConfig::from_env()) @@ -198,7 +206,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}'''"# ) } @@ -277,8 +278,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"), @@ -302,11 +305,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 d1fddcb95..3bcbfe53e 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::TrustedServerAppConfig; +use trusted_server_core::config_payload::CONFIG_BLOB_KEY; 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 3623d8491..6e1388cea 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; @@ -57,13 +59,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) .envs(INTEGRATION_SECRET_ENV.iter().copied()) .stdout(Stdio::null()) .stderr(Stdio::piped()) diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 0d1612fd5..3dc9c2483 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -2617,27 +2617,129 @@ 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. +`[stores.config].default` in `edgezero.toml` supplies the logical config store +ID and default blob key, currently `trusted_server_config`. Fastly has no +process environment. Its entry point reads service-scoped overrides from the +`edgezero_runtime_env` Config Store before opening the app-config store: -**Local development** (`fastly.toml`): +```mermaid +flowchart TD + A[Manifest default store ID] --> B[Resolve store name and blob key] + C[Service-scoped entries in edgezero_runtime_env] --> B + B --> D[Open the resolved resource-link name] + D --> E[Read the selected blob key from the linked 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] +For this logical ID, the runtime selectors are: + +```text +EDGEZERO__SERVICES____STORES__CONFIG__TRUSTED_SERVER_CONFIG__NAME +EDGEZERO__SERVICES____STORES__CONFIG__TRUSTED_SERVER_CONFIG__KEY +``` + +The runtime ignores unscoped entries. Missing or blank selectors fall back to +the logical ID. A resource link must exist under the resolved name, not always +under `trusted_server_config`. + +### Initial setup with a service-specific store + +Fastly store names are account-level. Choose a physical name that is not used +by another service. The default physical name is safe only if the service owns +that store exclusively. + +Create the Fastly service and an editable service version before provisioning +non-default mappings. Select its ID through top-level `service_id` in +`fastly.toml` or `FASTLY_SERVICE_ID`. If both are set, they must agree. Do not +reuse the checked-in service ID for your deployment. Without a service ID, +provisioning rejects non-default mappings before creating resources. + +The following example is for initial setup before the service receives traffic. +Replace the service ID and choose your own physical store name: + +```bash +export FASTLY_SERVICE_ID="" +export EDGEZERO__STORES__CONFIG__TRUSTED_SERVER_CONFIG__NAME=example_config + +ts provision --adapter fastly --dry-run +ts provision --adapter fastly ``` -**Production setup** (Fastly CLI): +Provisioning creates the stores and persists the selected name in the +service-scoped `edgezero_runtime_env` entry. Keep all intended store-name +overrides set when provisioning, including any [secret-store mapping](/guide/fastly#secret-stores). +Provisioning reconciles mappings for all declared stores, so omitting a previous +override can remove it. + +For an existing service, Fastly does not reapply `[setup]` entries. Follow the +provisioner's resource-link instructions. Both the app-config store and the +runtime-env store must be linked to the same editable version. For this example: + +```bash +fastly resource-link create --service-id "$FASTLY_SERVICE_ID" --version latest --autoclone \ + --resource-id --name example_config +fastly resource-link create --service-id "$FASTLY_SERVICE_ID" --version latest --autoclone \ + --resource-id --name edgezero_runtime_env + +ts config push --adapter fastly --dry-run +ts config push --adapter fastly +fastly compute publish --service-id "$FASTLY_SERVICE_ID" --version latest +``` + +Look up each store ID by its name before linking. Confirm the push dry run names +`example_config`, not the account-level default. Publish the application to the +linked version only after seeding its config store; a missing or invalid blob +makes application startup fail closed. Do not activate a new service's empty +version before uploading the application. If your deployment separates upload +from activation, activate the prepared version with +`fastly service-version activate --service-id "$FASTLY_SERVICE_ID" --version ` +only after both the code and config are ready. + +Keep the `__NAME` override in your deployment environment for **every subsequent +push**. The CLI reads its process environment, not the service's persisted +runtime mapping. Omitting the override can write to the wrong physical store. +Reject empty values in deployment scripts rather than relying on the fallback. + +For a live service, changing entries in its active `edgezero_runtime_env` store +changes runtime selection immediately, independently of service-version +activation. Do not use the initial-setup sequence to migrate a live mapping. +Prepare and seed the destination and make its resource link available to the +active version before switching the selector, or use an isolated staged runtime +configuration. + +An existing deployment may instead link a service-specific physical store under +the logical name `trusted_server_config`, with no runtime `__NAME` override. +That alias works, but the CLI still needs the physical-name override on every +push. Do not add a runtime override unless a link under the newly selected name +also exists. + +### Selecting another blob key + +A normal push writes at the logical store ID. A runtime `__KEY` override does +not change that write destination. To select another production key, first push +with `ts config push --adapter fastly --key `, then set the matching +service-scoped `__KEY` entry in `edgezero_runtime_env`. Changing that entry affects +the active service immediately. Do not point the production selector at a +staging key; staged deployments need their own runtime-env store. + +### Local development + +The repository's Viceroy configuration uses the default logical app-config name +and key. Clear production overrides for the local push: ```bash -# Create the store once and attach it to the service. -fastly config-store create --name trusted_server_config +env -u EDGEZERO__STORES__CONFIG__TRUSTED_SERVER_CONFIG__NAME \ + -u EDGEZERO__STORES__CONFIG__TRUSTED_SERVER_CONFIG__KEY \ + ts config push --adapter fastly --local ``` +`--local` writes under `[local_server.config_stores.]` in the +tracked `fastly.toml`. If you customize Viceroy's service-scoped runtime selectors, +keep that local name and the pushed key aligned with them. Review the generated +diff and do not commit deployment-specific app-config entries. Credentials belong +in secret stores; the app-config blob contains their key references. + +### Rollback + Rollback to the legacy entry point is no longer controlled by runtime config keys. Use the normal deployment rollback path to restore a pre-cleanup service version if that is required. diff --git a/docs/guide/fastly.md b/docs/guide/fastly.md index bc1174033..f4dfc8caf 100644 --- a/docs/guide/fastly.md +++ b/docs/guide/fastly.md @@ -278,7 +278,10 @@ Trusted Server keeps static app-config credentials under logical store ID as `ts_secrets`. Request-signing private keys remain in their separate, runtime-managed store. -Set the physical mapping before provisioning: +Create or select the service before provisioning a non-default mapping. Set its +ID in `fastly.toml` or `FASTLY_SERVICE_ID`; if both are set, they must agree. +Provisioning rejects non-default mappings without a service ID. Set the physical +mapping before provisioning, alongside any config-store or KV-store overrides: ```bash export EDGEZERO__STORES__SECRETS__TRUSTED_SERVER_SECRETS__NAME=ts_secrets @@ -300,6 +303,12 @@ app config, so every startup and reload resolves static credentials from `ts_secrets` while the portable manifest continues to declare `trusted_server_secrets`. +The same runtime mapping mechanism applies to app-config stores. See +[Fastly runtime config stores](/guide/configuration#fastly-runtime-config-store) +for the initial provisioning and linking sequence, subsequent CLI pushes, and +precautions when changing a live mapping. A process-environment override alone +does not configure the Fastly runtime. + Create the separate request-signing store when that feature is enabled: ```bash diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index fb7d1c84c..83b8ef105 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -80,6 +80,10 @@ the variables into your shell before starting the server. cp trusted-server.example.toml trusted-server.toml set -a && source .env.dev && set +a +# Use the repository's default app-config store name and key for this quickstart. +unset EDGEZERO__STORES__CONFIG__TRUSTED_SERVER_CONFIG__NAME +unset EDGEZERO__STORES__CONFIG__TRUSTED_SERVER_CONFIG__KEY + # Create the local blob-backed config-store entry. ts config push --adapter axum --local --yes export TRUSTED_SERVER_CONFIG_TRUSTED_SERVER_CONFIG_TRUSTED_SERVER_CONFIG="$( @@ -106,11 +110,24 @@ The server will be available at `http://localhost:8787`. Set `PORT=` befor | Config store value | `TRUSTED_SERVER_CONFIG_{STORE}_{KEY}` | `TRUSTED_SERVER_CONFIG_TRUSTED_SERVER_CONFIG_TRUSTED_SERVER_CONFIG=…` | | Secret store value | `TRUSTED_SERVER_SECRET_{STORE}_{KEY}` | `TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_PROXY_KEY=…` | +The repeated `TRUSTED_SERVER_CONFIG` segments in the example are intentional: +`TRUSTED_SERVER_CONFIG_` is the adapter prefix, followed by the resolved store +name and blob key. Both default to `[stores.config].default` in `edgezero.toml`, +currently `trusted_server_config`. The commands above assume that repository +default and clear any name/key overrides left in the shell. + +If you customize the defaults or use `EDGEZERO__STORES__CONFIG____NAME` or +`__KEY`, adjust the exported variable's store/key segments and the `jq` key to +match. Pass a matching `--key` to `ts config push` when overriding the runtime +key. The local JSON filename still uses the logical store ID, even with a +physical-name override. + The config-store value is the verified app-config blob. Secret-store values are looked up by the key names in that blob. Store names and key names are uppercased -with hyphens and dots replaced by underscores. The quick-start exports ephemeral -secret-store values only into the current shell; do not put secret values in the -TOML config, config-store blob, or a source-controlled environment file. +with hyphens, dots, and spaces replaced by underscores. The quick-start exports +ephemeral secret-store values only into the current shell; do not put secret +values in the TOML config, config-store blob, or a source-controlled environment +file. > **Dev server limitations:** The Axum adapter does not support KV store, > geo lookup, config/secret-store writes, or admin key-management routes. 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"]