From 97299a73eb5f3f8c6833c58c38d32ccb54e5d4f5 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 26 Aug 2026 22:17:17 -0700 Subject: [PATCH 1/3] Expose runtime_env_config for custom Fastly entry points Rename the private env_config_from_runtime_dictionary loader to runtime_env_config and export it from edgezero-adapter-fastly, so custom Fastly entry points that bypass run_app can resolve staged EDGEZERO__* store selectors identically instead of duplicating the store name and key-derivation rules. Split the key derivation into a pure runtime_env_keys helper and pin its rules (a __NAME selector per store id, __KEY for config stores only) with a unit test that runs without a Fastly host. Closes #349 --- crates/edgezero-adapter-fastly/src/cli.rs | 13 ++- crates/edgezero-adapter-fastly/src/lib.rs | 98 ++++++++++++++++++----- scripts/smoke_test_config_key_override.sh | 2 +- 3 files changed, 85 insertions(+), 28 deletions(-) diff --git a/crates/edgezero-adapter-fastly/src/cli.rs b/crates/edgezero-adapter-fastly/src/cli.rs index 2c0d9bc1..c2f766d6 100644 --- a/crates/edgezero-adapter-fastly/src/cli.rs +++ b/crates/edgezero-adapter-fastly/src/cli.rs @@ -132,7 +132,7 @@ const FASTLY_INSTALL_HINT: &str = "install the Fastly CLI (https://www.fastly.co /// 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). +/// here (see `runtime_env_config` 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 @@ -546,12 +546,11 @@ 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"; if dry_run { diff --git a/crates/edgezero-adapter-fastly/src/lib.rs b/crates/edgezero-adapter-fastly/src/lib.rs index 4df9ef7d..20335c80 100644 --- a/crates/edgezero-adapter-fastly/src/lib.rs +++ b/crates/edgezero-adapter-fastly/src/lib.rs @@ -25,7 +25,9 @@ pub mod response; pub mod secret_store; #[cfg(feature = "fastly")] -use edgezero_core::app::{Hooks, StoresMetadata}; +use edgezero_core::app::Hooks; +#[cfg(any(feature = "fastly", test))] +use edgezero_core::app::StoresMetadata; #[cfg(feature = "fastly")] use edgezero_core::env_config::EnvConfig; #[cfg(feature = "fastly")] @@ -139,7 +141,7 @@ where F: FnOnce(&fastly::Request, &mut Extensions), { let stores = A::stores(); - let env = env_config_from_runtime_dictionary(stores); + let env = runtime_env_config(stores); let logging = logging_from_env(&env); if logging.use_fastly_logger && !A::owns_logging() { let endpoint = logging.endpoint.as_deref().unwrap_or("stdout"); @@ -158,23 +160,24 @@ where } /// Build an [`EnvConfig`] from the optional `edgezero_runtime_env` -/// Fastly Config Store. Compute@Edge has no process env -- the -/// `EDGEZERO__*` runtime overrides spec 5.2/5.4 expects must come -/// from a Config Store the operator pre-populates (locally via -/// `fastly.toml`'s `[local_server.config_stores.edgezero_runtime_env]` -/// block; remotely via a `fastly config-store` named `edgezero_runtime_env`). +/// Fastly Config Store. /// -/// The Cloudflare adapter does the same thing through `env.var(...)` -/// (lib.rs:55) -- Workers also have no `std::env`. Mirroring the -/// approach here closes the spec 12.7 gap where `__KEY` runtime -/// overrides silently fell back to the binding's default id. +/// Compute@Edge has no process env, so the `EDGEZERO__*` runtime overrides +/// (logging settings, per-store platform names, the config-store `__KEY` +/// selector) come from a Config Store the operator pre-populates: locally via +/// `fastly.toml`'s `[local_server.config_stores.edgezero_runtime_env]` block, +/// remotely via a `fastly config-store` named `edgezero_runtime_env`. /// -/// If the store is missing or empty, returns an empty `EnvConfig` -- -/// the rest of the runtime then uses the baked-in defaults (which is -/// what the pre-fix code did, just without the env-driven override -/// path the spec promises). +/// If the store is missing or empty, returns an empty `EnvConfig` and the rest +/// of the runtime uses its baked-in defaults. +/// +/// [`run_app`] calls this itself. A custom Fastly entry point that bypasses +/// [`run_app`] should call it with its own `A::stores()` so staged and +/// overridden store selectors resolve identically. #[cfg(feature = "fastly")] -fn env_config_from_runtime_dictionary(stores: StoresMetadata) -> 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 { @@ -194,6 +197,17 @@ 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 the Fastly runtime looks up: the fixed adapter and +/// logging settings, plus a `__NAME` selector for every declared store id and +/// a `__KEY` selector for config-store ids only. +#[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,10 +231,7 @@ 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 @@ -270,3 +281,50 @@ mod tests { assert!(logging.use_fastly_logger); } } + +#[cfg(test)] +mod runtime_env_key_tests { + use super::runtime_env_keys; + use edgezero_core::app::{StoreMetadata, StoresMetadata}; + + fn contains(keys: &[String], key: &str) -> bool { + keys.iter().any(|candidate| candidate.as_str() == key) + } + + #[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 keys = runtime_env_keys(stores); + + assert!(contains(&keys, "EDGEZERO__ADAPTER__HOST")); + assert!(contains(&keys, "EDGEZERO__ADAPTER__PORT")); + assert!(contains(&keys, "EDGEZERO__LOGGING__LEVEL")); + assert!(contains(&keys, "EDGEZERO__LOGGING__ENDPOINT")); + assert!(contains(&keys, "EDGEZERO__LOGGING__USE_FASTLY_LOGGER")); + assert!(contains(&keys, "EDGEZERO__LOGGING__ECHO_STDOUT")); + + assert!(contains(&keys, "EDGEZERO__STORES__CONFIG__MAIN__NAME")); + assert!(contains(&keys, "EDGEZERO__STORES__CONFIG__EDGE__NAME")); + assert!(contains(&keys, "EDGEZERO__STORES__KV__CACHE__NAME")); + assert!(contains(&keys, "EDGEZERO__STORES__SECRETS__VAULT__NAME")); + + assert!(contains(&keys, "EDGEZERO__STORES__CONFIG__MAIN__KEY")); + assert!(contains(&keys, "EDGEZERO__STORES__CONFIG__EDGE__KEY")); + assert!(!contains(&keys, "EDGEZERO__STORES__KV__CACHE__KEY")); + assert!(!contains(&keys, "EDGEZERO__STORES__SECRETS__VAULT__KEY")); + } +} 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() { From 85bfbc0186e9bd44e6329e840d5191ccf1f72507 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:23:08 -0700 Subject: [PATCH 2/3] Address PR 351 review: full custom-entry-point parity Make dispatch_with_registries public, taking StoresMetadata whole, so a custom Fastly entry point pairs it with runtime_env_config for the same store wiring run_app uses, including the config-only __KEY selector that FastlyService's bare-handle path cannot express. Replace the private logging_from_env with From<&EnvConfig> for FastlyLogging so the level-parse fallback and the endpoint-derived use_fastly_logger rule are not reimplemented downstream. Add the ungated RUNTIME_ENV_STORE_NAME const as the single source of the store name; document that the fixed name is what staged relinking relies on. Document the empty-default-stores() trap for handwritten Hooks impls and the entry points that do not resolve the env overlay themselves. Explain the test arm of the runtime_env_keys cfg gate, and pin the key derivation with exact-set assertions plus an empty-metadata case. --- crates/edgezero-adapter-fastly/src/cli.rs | 18 +- crates/edgezero-adapter-fastly/src/lib.rs | 179 ++++++++++++------ crates/edgezero-adapter-fastly/src/request.rs | 36 ++-- 3 files changed, 148 insertions(+), 85 deletions(-) diff --git a/crates/edgezero-adapter-fastly/src/cli.rs b/crates/edgezero-adapter-fastly/src/cli.rs index c2f766d6..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 `runtime_env_config` 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 @@ -552,7 +548,7 @@ impl Adapter for FastlyCliAdapter { // 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)", @@ -4851,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." )); } }; @@ -4886,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(), @@ -4909,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 20335c80..cb7ab1b3 100644 --- a/crates/edgezero-adapter-fastly/src/lib.rs +++ b/crates/edgezero-adapter-fastly/src/lib.rs @@ -34,6 +34,15 @@ use edgezero_core::env_config::EnvConfig; use edgezero_core::http::Extensions; #[cfg(feature = "fastly")] use edgezero_core::manifest::ResolvedLoggingConfig; + +/// 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(feature = "fastly")] #[derive(Debug, Clone)] pub struct FastlyLogging { @@ -56,6 +65,38 @@ impl From for FastlyLogging { } } +/// Resolve [`FastlyLogging`] from the `EDGEZERO__LOGGING__*` overlay. +/// +/// Two 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. +#[cfg(feature = "fastly")] +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 @@ -83,31 +124,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 @@ -142,21 +158,13 @@ where { let stores = A::stores(); let env = runtime_env_config(stores); - let logging = logging_from_env(&env); + let logging = FastlyLogging::from(&env); if logging.use_fastly_logger && !A::owns_logging() { let endpoint = logging.endpoint.as_deref().unwrap_or("stdout"); init_logger(endpoint, logging.level, logging.echo_stdout)?; } let app = A::build_app(); - request::dispatch_with_registries( - &app, - req, - stores.config, - stores.kv, - stores.secrets, - &env, - extend, - ) + request::dispatch_with_registries(&app, req, stores, &env, extend) } /// Build an [`EnvConfig`] from the optional `edgezero_runtime_env` @@ -171,16 +179,42 @@ where /// If the store is missing or empty, returns an empty `EnvConfig` and the rest /// of the runtime uses its baked-in defaults. /// -/// [`run_app`] calls this itself. A custom Fastly entry point that bypasses -/// [`run_app`] should call it with its own `A::stores()` so staged and -/// overridden store selectors resolve identically. +/// [`run_app`] and [`run_app_with_request_extensions`] call this themselves. +/// [`run_app_with_config`] does NOT, and neither does a hand-built +/// [`FastlyService`](request::FastlyService): a custom entry point on either of +/// those paths must call this explicitly, or staged and overridden store +/// selectors silently fall back to baked defaults. +/// +/// The `stores` argument must name the app's logical store ids. A handwritten +/// [`Hooks`] impl inherits the default `stores()`, which is EMPTY +/// ([`StoresMetadata::default`]), and empty metadata derives no +/// `EDGEZERO__STORES__*` keys at all — every selector override silently never +/// resolves. Such an impl must override `stores()`, or pass explicit +/// [`StoresMetadata`] here. +/// +/// ```rust,ignore +/// use edgezero_adapter_fastly::request::dispatch_with_registries; +/// use edgezero_adapter_fastly::runtime_env_config; +/// use edgezero_core::app::{StoreMetadata, StoresMetadata}; +/// +/// let stores = StoresMetadata { +/// config: Some(StoreMetadata { +/// default: "app_config", +/// ids: &["app_config"], +/// }), +/// ..StoresMetadata::default() +/// }; +/// let env = runtime_env_config(stores); +/// let app = MyApp::build_app(); +/// dispatch_with_registries(&app, req, stores, &env, |_req, _extensions| {}) +/// ``` #[cfg(feature = "fastly")] #[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 @@ -203,9 +237,16 @@ pub fn runtime_env_config(stores: StoresMetadata) -> EnvConfig { EnvConfig::from_vars(vars) } -/// The `EDGEZERO__*` keys the Fastly runtime looks up: 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 `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![ @@ -287,10 +328,6 @@ mod runtime_env_key_tests { use super::runtime_env_keys; use edgezero_core::app::{StoreMetadata, StoresMetadata}; - fn contains(keys: &[String], key: &str) -> bool { - keys.iter().any(|candidate| candidate.as_str() == key) - } - #[test] fn runtime_env_keys_name_every_store_and_key_only_config_stores() { let stores = StoresMetadata { @@ -308,23 +345,43 @@ mod runtime_env_key_tests { }), }; - let keys = runtime_env_keys(stores); + let mut keys = runtime_env_keys(stores); + keys.sort(); - assert!(contains(&keys, "EDGEZERO__ADAPTER__HOST")); - assert!(contains(&keys, "EDGEZERO__ADAPTER__PORT")); - assert!(contains(&keys, "EDGEZERO__LOGGING__LEVEL")); - assert!(contains(&keys, "EDGEZERO__LOGGING__ENDPOINT")); - assert!(contains(&keys, "EDGEZERO__LOGGING__USE_FASTLY_LOGGER")); - assert!(contains(&keys, "EDGEZERO__LOGGING__ECHO_STDOUT")); + 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", + ] + ); + } - assert!(contains(&keys, "EDGEZERO__STORES__CONFIG__MAIN__NAME")); - assert!(contains(&keys, "EDGEZERO__STORES__CONFIG__EDGE__NAME")); - assert!(contains(&keys, "EDGEZERO__STORES__KV__CACHE__NAME")); - assert!(contains(&keys, "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!(contains(&keys, "EDGEZERO__STORES__CONFIG__MAIN__KEY")); - assert!(contains(&keys, "EDGEZERO__STORES__CONFIG__EDGE__KEY")); - assert!(!contains(&keys, "EDGEZERO__STORES__KV__CACHE__KEY")); - assert!(!contains(&keys, "EDGEZERO__STORES__SECRETS__VAULT__KEY")); + 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..b905b990 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; @@ -305,28 +305,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, From 6d2a7ae042982612b34780194406efbfa8ad3fd3 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Tue, 1 Sep 2026 09:01:02 -0700 Subject: [PATCH 3/3] Address PR 351 follow-up review feedback --- crates/edgezero-adapter-fastly/src/lib.rs | 73 +++++++++++++++---- crates/edgezero-adapter-fastly/src/request.rs | 12 ++- 2 files changed, 68 insertions(+), 17 deletions(-) diff --git a/crates/edgezero-adapter-fastly/src/lib.rs b/crates/edgezero-adapter-fastly/src/lib.rs index cb7ab1b3..daec2ca0 100644 --- a/crates/edgezero-adapter-fastly/src/lib.rs +++ b/crates/edgezero-adapter-fastly/src/lib.rs @@ -28,11 +28,11 @@ pub mod secret_store; use edgezero_core::app::Hooks; #[cfg(any(feature = "fastly", test))] use edgezero_core::app::StoresMetadata; -#[cfg(feature = "fastly")] +#[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; /// Name of the Fastly Config Store the runtime opens for `EDGEZERO__*` @@ -43,7 +43,7 @@ use edgezero_core::manifest::ResolvedLoggingConfig; /// how the runtime resolves staged selectors without knowing the twin exists. pub const RUNTIME_ENV_STORE_NAME: &str = "edgezero_runtime_env"; -#[cfg(feature = "fastly")] +#[cfg(any(feature = "fastly", test))] #[derive(Debug, Clone)] pub struct FastlyLogging { pub echo_stdout: bool, @@ -52,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 { @@ -67,11 +67,13 @@ impl From for FastlyLogging { /// Resolve [`FastlyLogging`] from the `EDGEZERO__LOGGING__*` overlay. /// -/// Two rules live here rather than in the caller. An unset or unparseable +/// 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. -#[cfg(feature = "fastly")] +/// 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 { @@ -139,7 +141,7 @@ pub fn run_app(req: fastly::Request) -> Result Vec { 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. @@ -302,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; @@ -321,6 +332,36 @@ 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)] diff --git a/crates/edgezero-adapter-fastly/src/request.rs b/crates/edgezero-adapter-fastly/src/request.rs index b905b990..ea1a0077 100644 --- a/crates/edgezero-adapter-fastly/src/request.rs +++ b/crates/edgezero-adapter-fastly/src/request.rs @@ -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 { @@ -317,7 +327,7 @@ where /// 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; +/// KV failures escalate via `resolve_kv_handle`'s `kv_required=true` path; /// missing config / secret stores degrade silently with a one-time warning. /// /// # Errors