diff --git a/crates/edgezero-adapter-fastly/src/cli.rs b/crates/edgezero-adapter-fastly/src/cli.rs index 2c0d9bc1..51ee8325 100644 --- a/crates/edgezero-adapter-fastly/src/cli.rs +++ b/crates/edgezero-adapter-fastly/src/cli.rs @@ -13,6 +13,7 @@ use std::process::id as process_id; use std::thread; use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use crate::RUNTIME_ENV_STORE_NAME; use crate::chunked_config::{ CHUNK_KEY_INFIX, GcPointer, GcRootValue, ResolveFailure, chunk_key_generation, chunk_key_index, chunk_lengths, gc_classify_root, gc_verify_generation, prepare_fastly_config_entries, @@ -130,12 +131,7 @@ static FASTLY_TEMPLATE_REGISTRATIONS: &[TemplateRegistration] = &[ const FASTLY_INSTALL_HINT: &str = "install the Fastly CLI (https://www.fastly.com/documentation/reference/tools/cli/) and try again"; -/// The config store the runtime opens for `EDGEZERO__*` overrides. Compute@Edge -/// has no process env, so the runtime reads its config-store KEY selector from -/// here (see `env_config_from_runtime_dictionary` in lib.rs). -const RUNTIME_ENV_STORE: &str = "edgezero_runtime_env"; - -/// Base name of the staging twin of [`RUNTIME_ENV_STORE`]. The actual store is +/// Base name of the staging twin of [`RUNTIME_ENV_STORE_NAME`]. The actual store is /// named PER SERVICE — [`staging_selector_store_name`] appends the service id — /// because Fastly config stores are account-wide, versionless resources: a /// single shared twin would let a staged deploy of service B destructively @@ -546,14 +542,13 @@ impl Adapter for FastlyCliAdapter { // Store named `edgezero_runtime_env`. Compute@Edge has no // process env, so `EDGEZERO__STORES__CONFIG____KEY` and // similar overrides have to come from a platform Config Store - // the runtime opens by name (see - // `env_config_from_runtime_dictionary` in lib.rs). Provision - // owns the store creation alongside the operator's declared - // stores so the runtime override path is wired correctly out - // of the box; if the store already appears in - // `[setup.config_stores.edgezero_runtime_env]`, skip. + // the runtime opens by name (see `runtime_env_config` in + // lib.rs). Provision owns the store creation alongside the + // operator's declared stores so the runtime override path is + // wired correctly out of the box; if the store already appears + // in `[setup.config_stores.edgezero_runtime_env]`, skip. let runtime_env_kind = "config"; - let runtime_env_name = "edgezero_runtime_env"; + let runtime_env_name = RUNTIME_ENV_STORE_NAME; if dry_run { out.push(format!( "would run `fastly {runtime_env_kind}-store create --name={runtime_env_name}` and append [setup.{runtime_env_kind}_stores.{runtime_env_name}] to {} (EdgeZero runtime override store)", @@ -4852,12 +4847,12 @@ fn relink_runtime_env_for_staging( // isolated. There is simply nothing to mirror — the twin gets only the // derived `_staging` selectors, and the staged draft is relinked to // it so it reads staged config while production keeps its default key. - let production = match classify_remote_config_store(RUNTIME_ENV_STORE)? { + let production = match classify_remote_config_store(RUNTIME_ENV_STORE_NAME)? { ConfigStoreLookup::Found(id) => read_config_store_entries(&id, manifest_dir)?, ConfigStoreLookup::NotFound => Vec::new(), ConfigStoreLookup::SchemaDrift(detail) => { return Err(format!( - "could not parse `fastly config-store list --json` while resolving `{RUNTIME_ENV_STORE}` for a staged deploy: {detail}.\n Refusing to stage rather than risk serving PRODUCTION config. Pin a known-compatible fastly CLI version and retry." + "could not parse `fastly config-store list --json` while resolving `{RUNTIME_ENV_STORE_NAME}` for a staged deploy: {detail}.\n Refusing to stage rather than risk serving PRODUCTION config. Pin a known-compatible fastly CLI version and retry." )); } }; @@ -4887,7 +4882,7 @@ fn relink_runtime_env_for_staging( ], manifest_dir, )?; - if let Some(link_id) = find_resource_link_id(&existing, RUNTIME_ENV_STORE) { + if let Some(link_id) = find_resource_link_id(&existing, RUNTIME_ENV_STORE_NAME) { run_fastly_status( &[ "resource-link".to_owned(), @@ -4910,7 +4905,7 @@ fn relink_runtime_env_for_staging( format!("--service-id={service_id}"), format!("--version={version}"), format!("--resource-id={staging_store_id}"), - format!("--name={RUNTIME_ENV_STORE}"), + format!("--name={RUNTIME_ENV_STORE_NAME}"), ], manifest_dir, )?; diff --git a/crates/edgezero-adapter-fastly/src/lib.rs b/crates/edgezero-adapter-fastly/src/lib.rs index 4df9ef7d..daec2ca0 100644 --- a/crates/edgezero-adapter-fastly/src/lib.rs +++ b/crates/edgezero-adapter-fastly/src/lib.rs @@ -25,14 +25,25 @@ pub mod response; pub mod secret_store; #[cfg(feature = "fastly")] -use edgezero_core::app::{Hooks, StoresMetadata}; -#[cfg(feature = "fastly")] +use edgezero_core::app::Hooks; +#[cfg(any(feature = "fastly", test))] +use edgezero_core::app::StoresMetadata; +#[cfg(any(feature = "fastly", test))] use edgezero_core::env_config::EnvConfig; #[cfg(feature = "fastly")] use edgezero_core::http::Extensions; -#[cfg(feature = "fastly")] +#[cfg(any(feature = "fastly", test))] use edgezero_core::manifest::ResolvedLoggingConfig; -#[cfg(feature = "fastly")] + +/// Name of the Fastly Config Store the runtime opens for `EDGEZERO__*` +/// overrides. +/// +/// The fixed name is load-bearing: a staged deploy creates a per-service +/// staging twin and links it into the staged version under THIS name, which is +/// how the runtime resolves staged selectors without knowing the twin exists. +pub const RUNTIME_ENV_STORE_NAME: &str = "edgezero_runtime_env"; + +#[cfg(any(feature = "fastly", test))] #[derive(Debug, Clone)] pub struct FastlyLogging { pub echo_stdout: bool, @@ -41,7 +52,7 @@ pub struct FastlyLogging { pub use_fastly_logger: bool, } -#[cfg(feature = "fastly")] +#[cfg(any(feature = "fastly", test))] impl From for FastlyLogging { #[inline] fn from(config: ResolvedLoggingConfig) -> Self { @@ -54,6 +65,40 @@ impl From for FastlyLogging { } } +/// Resolve [`FastlyLogging`] from the `EDGEZERO__LOGGING__*` overlay. +/// +/// Three rules live here rather than in the caller. An unset or unparseable +/// `EDGEZERO__LOGGING__LEVEL` falls back to [`log::LevelFilter::Info`], and +/// `use_fastly_logger` is DERIVED from `endpoint.is_some()` so a Viceroy run +/// with no endpoint is never handed the reserved `stdout` name. `echo_stdout` +/// is always `true` on this path: `EDGEZERO__LOGGING__ECHO_STDOUT` is resolved +/// into the [`EnvConfig`] for downstream readers but is not applied here. +#[cfg(any(feature = "fastly", test))] +impl From<&EnvConfig> for FastlyLogging { + #[inline] + fn from(env: &EnvConfig) -> Self { + use std::str::FromStr as _; + + let level = env + .logging_level() + .and_then(|raw| log::LevelFilter::from_str(raw).ok()) + .unwrap_or(log::LevelFilter::Info); + // Only attach Fastly's named-endpoint logger when `EDGEZERO__LOGGING__ENDPOINT` + // is set. Production deployments set it to a real `[log_endpoints]` entry from + // `fastly.toml`; local Viceroy runs leave it unset and avoid the + // "endpoint not found, or is reserved" error that fires when the adapter + // would otherwise fall back to a reserved name like `stdout`. + let endpoint = env.logging_endpoint().map(str::to_owned); + let use_fastly_logger = endpoint.is_some(); + Self { + echo_stdout: true, + endpoint, + level, + use_fastly_logger, + } + } +} + /// # Errors /// Returns [`logger::InitLoggerError::Build`] if the underlying logger /// builder rejects its inputs (e.g. an empty endpoint), or @@ -81,31 +126,6 @@ pub fn init_logger( Ok(()) } -/// Resolve [`FastlyLogging`] from `EDGEZERO__LOGGING__LEVEL`, falling back to -/// the adapter default when the variable is unset or unparseable. -#[cfg(feature = "fastly")] -fn logging_from_env(env: &EnvConfig) -> FastlyLogging { - use std::str::FromStr as _; - - let level = env - .logging_level() - .and_then(|raw| log::LevelFilter::from_str(raw).ok()) - .unwrap_or(log::LevelFilter::Info); - // Only attach Fastly's named-endpoint logger when `EDGEZERO__LOGGING__ENDPOINT` - // is set. Production deployments set it to a real `[log_endpoints]` entry from - // `fastly.toml`; local Viceroy runs leave it unset and avoid the - // "endpoint not found, or is reserved" error that fires when the adapter - // would otherwise fall back to a reserved name like `stdout`. - let endpoint = env.logging_endpoint().map(str::to_owned); - let use_fastly_logger = endpoint.is_some(); - FastlyLogging { - echo_stdout: true, - endpoint, - level, - use_fastly_logger, - } -} - /// Entry point for a Fastly Compute application. /// /// Portable store config is baked into `A` by the `app!` macro; adapter-specific @@ -121,7 +141,7 @@ pub fn run_app(req: fastly::Request) -> Result EnvConfig { +#[must_use] +#[inline] +pub fn runtime_env_config(stores: StoresMetadata) -> EnvConfig { use fastly::ConfigStore; use std::iter::empty; - let Ok(dict) = ConfigStore::try_open("edgezero_runtime_env") else { + let Ok(dict) = ConfigStore::try_open(RUNTIME_ENV_STORE_NAME) else { // The store is optional -- a clean cutover deploy with all // baked-in defaults works without it. But the absence means // EDGEZERO__* runtime overrides (spec 5.4 __KEY, spec 5.2 @@ -194,6 +239,24 @@ fn env_config_from_runtime_dictionary(stores: StoresMetadata) -> EnvConfig { ); return EnvConfig::from_vars(empty::<(String, String)>()); }; + let vars = runtime_env_keys(stores) + .into_iter() + .filter_map(|key| dict.get(&key).map(|value| (key, value))); + EnvConfig::from_vars(vars) +} + +/// The `EDGEZERO__*` keys resolved from the store into the [`EnvConfig`]: the +/// fixed adapter and logging settings, plus a `__NAME` selector for every +/// declared store id and a `__KEY` selector for config-store ids only. +/// +/// The Fastly runtime itself consumes only the logging level / endpoint and the +/// per-store selectors; the rest are resolved so downstream readers can fetch +/// them from the returned `EnvConfig`. +// The `test` arm is load-bearing: the crate's default features exclude +// `fastly`, so gating on the feature alone would keep this helper and the test +// pinning its key-derivation rules out of a plain `cargo test --workspace`. +#[cfg(any(feature = "fastly", test))] +fn runtime_env_keys(stores: StoresMetadata) -> Vec { let mut keys: Vec = vec![ "EDGEZERO__ADAPTER__HOST".to_owned(), "EDGEZERO__ADAPTER__PORT".to_owned(), @@ -217,16 +280,17 @@ fn env_config_from_runtime_dictionary(stores: StoresMetadata) -> EnvConfig { } } } - let vars = keys - .into_iter() - .filter_map(|key| dict.get(&key).map(|value| (key, value))); - EnvConfig::from_vars(vars) + keys } -/// Dispatch with a config store wired explicitly. Use `run_app` for -/// the manifest-driven flow that resolves stores automatically. KV -/// is NOT auto-injected on this path; chain `.with_kv(name)` on a -/// `FastlyService` builder if you need KV alongside the config store. +/// Dispatch with a config store wired explicitly. This path does NOT apply the +/// [`EnvConfig`] overlay: the store name comes directly from +/// `config_store_name`, and its default key is always `"default"`, so staged or +/// overridden `__NAME` / `__KEY` selectors are ignored. Use +/// [`runtime_env_config`] with [`request::dispatch_with_registries`] for the +/// same selector resolution as [`run_app`]. KV is not auto-injected on this +/// path; chain `.with_kv(name)` on a [`request::FastlyService`] builder if you +/// need KV alongside the config store. /// /// # Errors /// Returns an error if logger setup fails or the underlying handler returns an error. @@ -250,8 +314,7 @@ pub fn run_app_with_config( } #[cfg(test)] -#[cfg(feature = "fastly")] -mod tests { +mod fastly_logging_tests { use super::*; use edgezero_core::manifest::LogLevel; @@ -269,4 +332,97 @@ mod tests { assert!(!logging.echo_stdout); assert!(logging.use_fastly_logger); } + + #[test] + fn fastly_logging_from_env_falls_back_without_an_endpoint() { + let env = EnvConfig::from_vars([ + ("EDGEZERO__LOGGING__LEVEL", "not-a-level"), + ("EDGEZERO__LOGGING__ECHO_STDOUT", "false"), + ]); + + let logging = FastlyLogging::from(&env); + + assert_eq!(logging.level, log::LevelFilter::Info); + assert_eq!(logging.endpoint, None); + assert!(!logging.use_fastly_logger); + assert!(logging.echo_stdout); + } + + #[test] + fn fastly_logging_from_env_enables_the_named_endpoint_logger() { + let env = EnvConfig::from_vars([ + ("EDGEZERO__LOGGING__LEVEL", "debug"), + ("EDGEZERO__LOGGING__ENDPOINT", "edgezero-logs"), + ]); + + let logging = FastlyLogging::from(&env); + + assert_eq!(logging.level, log::LevelFilter::Debug); + assert_eq!(logging.endpoint.as_deref(), Some("edgezero-logs")); + assert!(logging.use_fastly_logger); + assert!(logging.echo_stdout); + } +} + +#[cfg(test)] +mod runtime_env_key_tests { + use super::runtime_env_keys; + use edgezero_core::app::{StoreMetadata, StoresMetadata}; + + #[test] + fn runtime_env_keys_name_every_store_and_key_only_config_stores() { + let stores = StoresMetadata { + config: Some(StoreMetadata { + default: "main", + ids: &["main", "edge"], + }), + kv: Some(StoreMetadata { + default: "cache", + ids: &["cache"], + }), + secrets: Some(StoreMetadata { + default: "vault", + ids: &["vault"], + }), + }; + + let mut keys = runtime_env_keys(stores); + keys.sort(); + + assert_eq!( + keys, + vec![ + "EDGEZERO__ADAPTER__HOST", + "EDGEZERO__ADAPTER__PORT", + "EDGEZERO__LOGGING__ECHO_STDOUT", + "EDGEZERO__LOGGING__ENDPOINT", + "EDGEZERO__LOGGING__LEVEL", + "EDGEZERO__LOGGING__USE_FASTLY_LOGGER", + "EDGEZERO__STORES__CONFIG__EDGE__KEY", + "EDGEZERO__STORES__CONFIG__EDGE__NAME", + "EDGEZERO__STORES__CONFIG__MAIN__KEY", + "EDGEZERO__STORES__CONFIG__MAIN__NAME", + "EDGEZERO__STORES__KV__CACHE__NAME", + "EDGEZERO__STORES__SECRETS__VAULT__NAME", + ] + ); + } + + #[test] + fn runtime_env_keys_without_declared_stores_are_the_fixed_keys_only() { + let mut keys = runtime_env_keys(StoresMetadata::default()); + keys.sort(); + + assert_eq!( + keys, + vec![ + "EDGEZERO__ADAPTER__HOST", + "EDGEZERO__ADAPTER__PORT", + "EDGEZERO__LOGGING__ECHO_STDOUT", + "EDGEZERO__LOGGING__ENDPOINT", + "EDGEZERO__LOGGING__LEVEL", + "EDGEZERO__LOGGING__USE_FASTLY_LOGGER", + ] + ); + } } diff --git a/crates/edgezero-adapter-fastly/src/request.rs b/crates/edgezero-adapter-fastly/src/request.rs index d9a94df4..ea1a0077 100644 --- a/crates/edgezero-adapter-fastly/src/request.rs +++ b/crates/edgezero-adapter-fastly/src/request.rs @@ -3,7 +3,7 @@ use std::fmt::Display; use std::io::Read as _; use std::sync::{Arc, Mutex, OnceLock, PoisonError}; -use edgezero_core::app::{App, StoreMetadata}; +use edgezero_core::app::{App, StoreMetadata, StoresMetadata}; use edgezero_core::body::Body; use edgezero_core::config_store::ConfigStoreHandle; use edgezero_core::env_config::EnvConfig; @@ -196,6 +196,12 @@ impl<'app> FastlyService<'app> { /// handle into request extensions. If the store is unavailable /// at request time, the dispatcher logs the warning once and /// proceeds without it. + /// + /// Env-overlay limitation: this bare-handle path does not resolve + /// `EDGEZERO__STORES__CONFIG__*` selectors and binds the config registry's + /// default key to `"default"`. Use [`runtime_env_config`](crate::runtime_env_config) + /// with [`dispatch_with_registries`] when a custom entry point needs the + /// same `__NAME` / `__KEY` resolution as [`run_app`](crate::run_app). #[must_use] #[inline] pub fn with_config>(mut self, name: S) -> Self { @@ -206,6 +212,10 @@ impl<'app> FastlyService<'app> { /// Inject a pre-built `ConfigStoreHandle`. Use this when the /// caller has already opened (or mocked) the backend. Mutually /// exclusive with `with_config(name)` -- the last call wins. + /// Like [`Self::with_config`], this binds the config registry's default key + /// to `"default"` and does not apply the [`EnvConfig`] overlay. Use + /// [`runtime_env_config`](crate::runtime_env_config) with + /// [`dispatch_with_registries`] for manifest-driven selector resolution. #[must_use] #[inline] pub fn with_config_handle(mut self, handle: ConfigStoreHandle) -> Self { @@ -305,28 +315,38 @@ where dispatch_core_request(app, core_request, stores) } -/// Dispatch with per-id store registries built from baked metadata. +/// Dispatch with per-id store registries built from baked metadata — the same +/// store wiring [`run_app`](crate::run_app) uses. /// /// Fastly is `Multi` for all three kinds, so each declared id resolves to -/// its own platform store via `EDGEZERO__STORES______NAME` (or the -/// id default). KV failures escalate via [`resolve_kv_handle`]'s -/// `kv_required=true` path; missing config / secret stores degrade silently -/// with a one-time warning. -pub(crate) fn dispatch_with_registries( +/// its own platform store through the [`EnvConfig`] overlay: the +/// `EDGEZERO__STORES__CONFIG____NAME` selector (and its KV / secrets +/// counterparts) picks the platform store, and the config-only `__KEY` +/// selector picks that store's [`ConfigStoreBinding::default_key`]. Pair this +/// with [`runtime_env_config`](crate::runtime_env_config) in a custom entry +/// point for full parity with `run_app`. Contrast [`FastlyService`], whose +/// bare-handle path binds `default_key: "default"` and ignores those selectors. +/// +/// KV failures escalate via `resolve_kv_handle`'s `kv_required=true` path; +/// missing config / secret stores degrade silently with a one-time warning. +/// +/// # Errors +/// Returns an error if a declared KV store cannot be opened, or if the +/// underlying handler returns an error. +#[inline] +pub fn dispatch_with_registries( app: &App, req: FastlyRequest, - config_meta: Option, - kv_meta: Option, - secret_meta: Option, + stores: StoresMetadata, env: &EnvConfig, extend: F, ) -> Result where F: FnOnce(&FastlyRequest, &mut Extensions), { - let kv_registry = build_kv_registry(kv_meta, env)?; - let config_registry = build_config_registry(config_meta, env); - let secret_registry = build_secret_registry(secret_meta, env); + let kv_registry = build_kv_registry(stores.kv, env)?; + let config_registry = build_config_registry(stores.config, env); + let secret_registry = build_secret_registry(stores.secrets, env); dispatch_with_handles( app, req, diff --git a/scripts/smoke_test_config_key_override.sh b/scripts/smoke_test_config_key_override.sh index 244e82b2..f9663d75 100755 --- a/scripts/smoke_test_config_key_override.sh +++ b/scripts/smoke_test_config_key_override.sh @@ -151,7 +151,7 @@ upper() { # Seed the Fastly local config store `edgezero_runtime_env` with the # runtime override env vars. The Fastly Compute@Edge runtime has no # process env, so EDGEZERO__* overrides are read from this dedicated -# Config Store (see env_config_from_runtime_dictionary in +# Config Store (see runtime_env_config in # crates/edgezero-adapter-fastly/src/lib.rs). $1 is the fastly.toml # path; $2 is the per-row __KEY override value (empty -> no override). seed_fastly_runtime_env() {