From 3129534026975d022d25f47eeb04c548b459f6d8 Mon Sep 17 00:00:00 2001 From: Christian Date: Mon, 24 Aug 2026 15:21:24 -0500 Subject: [PATCH 1/6] Support optional secret paths and persisted Fastly mappings --- crates/edgezero-adapter-fastly/src/cli.rs | 234 +++++++++++++++++++++- crates/edgezero-cli/src/config.rs | 106 +++++++--- crates/edgezero-core/src/app_config.rs | 17 +- crates/edgezero-core/src/extractor.rs | 52 ++++- 4 files changed, 358 insertions(+), 51 deletions(-) diff --git a/crates/edgezero-adapter-fastly/src/cli.rs b/crates/edgezero-adapter-fastly/src/cli.rs index 51ee8325..5f64b0f6 100644 --- a/crates/edgezero-adapter-fastly/src/cli.rs +++ b/crates/edgezero-adapter-fastly/src/cli.rs @@ -578,7 +578,7 @@ impl Adapter for FastlyCliAdapter { // selector via `edgezero_runtime_env_staging`, wired automatically by // a staged deploy; nothing here should be edited to stage config. let mut line = format!( - "created fastly {runtime_env_kind}-store `{runtime_env_name}` (EdgeZero runtime override store, read by the ACTIVE version); appended setup tables to {}\n It already selects each store's default key, so no edit is needed for a normal setup.\n To point PRODUCTION at a different key (e.g. a renamed store), and only then:\n fastly config-store-entry update --store-id= --key=EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY --value= --upsert\n Do NOT set a `_staging` key here: staged config is isolated by a per-service `{RUNTIME_ENV_STAGING_STORE_PREFIX}_` store, which a staged deploy creates and links automatically.", + "created fastly {runtime_env_kind}-store `{runtime_env_name}` (EdgeZero runtime override store, read by the ACTIVE version); appended setup tables to {}\n Provision writes non-default store-name mappings below. Config stores still select their logical id as the default key.\n To point PRODUCTION at a different config key, and only then:\n fastly config-store-entry update --store-id= --key=EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY --value= --upsert\n Do NOT set a `_staging` key here: staged config is isolated by a per-service `{RUNTIME_ENV_STAGING_STORE_PREFIX}_` store, which a staged deploy creates and links automatically.", fastly_path.display() ); if let Some(note) = post_create_note { @@ -590,6 +590,8 @@ impl Adapter for FastlyCliAdapter { // Already declared; nothing to do. } + out.extend(persist_runtime_env_store_name_entries(stores, dry_run)?); + // The STAGING twin of the runtime-override store is created and // populated entirely by a staged deploy (see // `relink_runtime_env_for_staging` → `mirror_production_to_staging`), so @@ -3455,6 +3457,62 @@ fn staging_entries_from_production( out } +fn is_runtime_store_name_key(key: &str) -> bool { + let mut segments = key.split("__"); + matches!( + ( + segments.next(), + segments.next(), + segments.next(), + segments.next(), + segments.next(), + segments.next(), + ), + ( + Some("EDGEZERO"), + Some("STORES"), + Some("CONFIG" | "KV" | "SECRETS"), + Some(id), + Some("NAME"), + None, + ) if !id.is_empty() + ) +} + +fn runtime_store_name_entries_from_vars( + vars: impl IntoIterator, +) -> Result, String> { + let mut entries = Vec::new(); + for (key, value) in vars { + if !is_runtime_store_name_key(&key) { + continue; + } + if value.is_empty() || value.trim() != value { + return Err(format!( + "runtime store-name override `{key}` must be non-empty and contain no surrounding whitespace" + )); + } + entries.push((key, value)); + } + entries.sort_by(|left, right| left.0.cmp(&right.0)); + Ok(entries) +} + +fn overlay_runtime_store_name_entries( + base: &[(String, String)], + overrides: &[(String, String)], +) -> Vec<(String, String)> { + let mut entries = base.to_vec(); + for (key, value) in overrides { + if let Some((_, current)) = entries.iter_mut().find(|(candidate, _)| candidate == key) { + current.clone_from(value); + } else { + entries.push((key.clone(), value.clone())); + } + } + entries +} + /// Resolve the staging twin store, creating it on demand. A staged deploy owns /// this store end to end (it is never linked on the ACTIVE version), so it does /// not depend on `provision` having created it first. Fails closed on a lookup @@ -3520,7 +3578,9 @@ fn mirror_production_to_staging( config_logical_ids: &[String], cwd: &Path, ) -> Result<(), String> { - let desired = staging_entries_from_production(production, config_logical_ids); + let process_overrides = runtime_store_name_entries_from_vars(env::vars())?; + let effective_production = overlay_runtime_store_name_entries(production, &process_overrides); + let desired = staging_entries_from_production(&effective_production, config_logical_ids); for (key, value) in &desired { create_config_store_entry(staging_id, key, value)?; @@ -3534,6 +3594,61 @@ fn mirror_production_to_staging( Ok(()) } +/// Return the runtime entries required when logical store ids map to different +/// Fastly resource names. +fn runtime_env_store_name_entries(stores: &ProvisionStores<'_>) -> Vec<(String, String)> { + let mut entries = Vec::new(); + for (kind, ids) in [ + ("CONFIG", stores.config), + ("KV", stores.kv), + ("SECRETS", stores.secrets), + ] { + for store in ids { + if store.logical == store.platform { + continue; + } + entries.push(( + format!( + "EDGEZERO__STORES__{kind}__{}__NAME", + store.logical.to_ascii_uppercase() + ), + store.platform.clone(), + )); + } + } + entries +} + +fn persist_runtime_env_store_name_entries( + stores: &ProvisionStores<'_>, + dry_run: bool, +) -> Result, String> { + let entries = runtime_env_store_name_entries(stores); + if dry_run { + return Ok(entries + .iter() + .map(|(key, value)| { + format!( + "would upsert `{key}={value}` into fastly config-store `{RUNTIME_ENV_STORE}`" + ) + }) + .collect()); + } + if entries.is_empty() { + return Ok(Vec::new()); + } + + let runtime_env_store_id = resolve_remote_config_store_id(RUNTIME_ENV_STORE)? + .ok_or_else(|| no_matching_store_error(RUNTIME_ENV_STORE))?; + push_entries_with_committer(&entries, |key, value| { + create_config_store_entry(&runtime_env_store_id, key, value) + })?; + Ok(vec![format!( + "persisted {} non-default store-name mapping(s) in fastly config-store `{RUNTIME_ENV_STORE}`", + entries.len() + )]) +} + /// The runtime-override entry naming the config-store KEY for logical store /// `id` — `EDGEZERO__STORES__CONFIG____KEY`. /// @@ -6630,6 +6745,27 @@ build = \"cargo build --release\" assert_eq!(after, "name = \"demo\"\n", "dry-run mutated fastly.toml"); } + #[test] + fn provision_dry_run_reports_non_default_store_name_mapping() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write(&path, "name = \"demo\"\n").expect("write"); + let secret_ids = vec![ResolvedStoreId::new("default", "production_secrets")]; + let stores = ProvisionStores { + config: &[], + kv: &[], + secrets: &secret_ids, + }; + + let out = FastlyCliAdapter + .provision(dir.path(), Some("fastly.toml"), None, &stores, true) + .expect("dry-run succeeds"); + + assert!(out.iter().any(|line| { + line.contains("EDGEZERO__STORES__SECRETS__DEFAULT__NAME=production_secrets") + })); + } + #[test] fn provision_errors_when_adapter_manifest_path_missing() { let dir = tempdir().expect("tempdir"); @@ -10208,6 +10344,40 @@ echo 'unexpected' >&2; exit 1 } } + #[test] + fn runtime_env_store_name_entries_include_only_non_default_mappings() { + use edgezero_core::env_config::EnvConfig; + + let config = vec![ResolvedStoreId::from_logical("app_config")]; + let kv = vec![ResolvedStoreId::new("sessions", "production_sessions")]; + let secrets = vec![ResolvedStoreId::new("default", "production_secrets")]; + let stores = ProvisionStores { + config: &config, + kv: &kv, + secrets: &secrets, + }; + + let entries = runtime_env_store_name_entries(&stores); + assert_eq!( + entries, + vec![ + ( + "EDGEZERO__STORES__KV__SESSIONS__NAME".to_owned(), + "production_sessions".to_owned(), + ), + ( + "EDGEZERO__STORES__SECRETS__DEFAULT__NAME".to_owned(), + "production_secrets".to_owned(), + ), + ] + ); + + let env = EnvConfig::from_vars(entries); + assert_eq!(env.store_name("config", "app_config"), "app_config"); + assert_eq!(env.store_name("kv", "sessions"), "production_sessions"); + assert_eq!(env.store_name("secrets", "default"), "production_secrets"); + } + #[test] fn runtime_env_key_matches_what_the_runtime_reads() { use edgezero_core::env_config::EnvConfig; @@ -10289,6 +10459,66 @@ echo 'unexpected' >&2; exit 1 ); } + #[test] + fn runtime_store_name_entries_from_vars_filters_and_validates() { + let entries = runtime_store_name_entries_from_vars([ + ( + "EDGEZERO__STORES__SECRETS__DEFAULT__NAME".to_owned(), + "physical_secrets".to_owned(), + ), + ( + "EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY".to_owned(), + "ignored_selector".to_owned(), + ), + ("UNRELATED".to_owned(), "ignored".to_owned()), + ]) + .expect("valid store-name override"); + + assert_eq!( + entries, + vec![( + "EDGEZERO__STORES__SECRETS__DEFAULT__NAME".to_owned(), + "physical_secrets".to_owned(), + )] + ); + assert!( + runtime_store_name_entries_from_vars([( + "EDGEZERO__STORES__SECRETS__DEFAULT__NAME".to_owned(), + String::new(), + )]) + .is_err(), + "an empty mapped resource name must fail closed" + ); + } + + #[test] + fn process_store_name_overrides_win_before_staging_mirror() { + let production = vec![ + ( + "EDGEZERO__STORES__SECRETS__DEFAULT__NAME".to_owned(), + "old_secrets".to_owned(), + ), + ("EDGEZERO__LOGGING__LEVEL".to_owned(), "info".to_owned()), + ]; + let overrides = vec![( + "EDGEZERO__STORES__SECRETS__DEFAULT__NAME".to_owned(), + "new_secrets".to_owned(), + )]; + + let effective = overlay_runtime_store_name_entries(&production, &overrides); + let staging = staging_entries_from_production(&effective, &["app_config".to_owned()]); + + assert!(staging.contains(&( + "EDGEZERO__STORES__SECRETS__DEFAULT__NAME".to_owned(), + "new_secrets".to_owned(), + ))); + assert!(!staging.iter().any(|(_, value)| value == "old_secrets")); + assert!(staging.contains(&( + "EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY".to_owned(), + "app_config_staging".to_owned(), + ))); + } + #[test] fn find_resource_link_id_matches_on_link_name_not_resource_name() { // The link's `name` is an alias defaulting to the resource's name. The diff --git a/crates/edgezero-cli/src/config.rs b/crates/edgezero-cli/src/config.rs index 079069d7..bdf05561 100644 --- a/crates/edgezero-cli/src/config.rs +++ b/crates/edgezero-cli/src/config.rs @@ -1633,6 +1633,44 @@ pub(crate) fn reject_merged_id_collisions( Ok(()) } +fn collect_secret_leaf<'raw>( + node: &'raw Value, + field: &SecretField, + name: &str, + rendered: &str, + optional_segment: bool, + out: &mut Vec>, +) -> Result<(), String> { + let parent = node + .as_table() + .ok_or_else(|| format!("expected a table containing `{name}` at `{rendered}`"))?; + let leaf_label = if rendered.is_empty() { + name.to_owned() + } else { + format!("{rendered}.{name}") + }; + match parent.get(name).and_then(Value::as_str) { + Some(value) => { + let store_ref_value = match field.kind { + SecretKind::KeyInNamedStore { store_ref_field } => { + parent.get(store_ref_field).and_then(Value::as_str) + } + SecretKind::KeyInDefault | SecretKind::StoreRef => None, + }; + out.push(ResolvedTomlLeaf { + label: leaf_label, + store_ref_value, + value, + }); + Ok(()) + } + None if (field.optional || optional_segment) && parent.get(name).is_none() => Ok(()), + None => Err(format!( + "`#[secret]` field `{leaf_label}` is missing or not a string" + )), + } +} + /// Collect every concrete secret leaf a `SecretField` resolves to in the /// raw app-config TOML, navigating `Field` (table descent) and `ArrayEach` /// (per-element) segments. `label` uses concrete `[n]` indices and, for a @@ -1652,36 +1690,26 @@ fn collect_secret_leaves<'raw>( ) -> Result<(), String> { match remaining.split_first() { Some((SecretPathSegment::Field(name), [])) => { - let parent = node.as_table().ok_or_else(|| { - format!("expected a table containing `{name}` at `{rendered}`") - })?; - let leaf_label = if rendered.is_empty() { + collect_secret_leaf(node, field, name, rendered, false, out) + } + Some((SecretPathSegment::OptionalField(name), [])) => { + collect_secret_leaf(node, field, name, rendered, true, out) + } + Some((SecretPathSegment::Field(name), rest)) => { + let table = node + .as_table() + .ok_or_else(|| format!("expected a table at `{rendered}`"))?; + let next_rendered = if rendered.is_empty() { name.to_string() } else { format!("{rendered}.{name}") }; - match parent.get(name.as_ref()).and_then(Value::as_str) { - Some(value) => { - let store_ref_value = match field.kind { - SecretKind::KeyInNamedStore { store_ref_field } => { - parent.get(store_ref_field).and_then(Value::as_str) - } - SecretKind::KeyInDefault | SecretKind::StoreRef => None, - }; - out.push(ResolvedTomlLeaf { - label: leaf_label, - store_ref_value, - value, - }); - Ok(()) - } - None if field.optional && parent.get(name.as_ref()).is_none() => Ok(()), - None => Err(format!( - "`#[secret]` field `{leaf_label}` is missing or not a string" - )), + match table.get(name.as_ref()) { + Some(child) => walk(child, field, rest, &next_rendered, out), + None => Err(format!("missing `{next_rendered}`")), } } - Some((SecretPathSegment::Field(name), rest)) => { + Some((SecretPathSegment::OptionalField(name), rest)) => { let table = node .as_table() .ok_or_else(|| format!("expected a table at `{rendered}`"))?; @@ -1690,13 +1718,9 @@ fn collect_secret_leaves<'raw>( } else { format!("{rendered}.{name}") }; - // Intermediates are always required — `field.optional` reflects - // only the leaf, and the derive never nests through `Option`. This - // matches the runtime walk (`resolve_secret_field`) so `config - // validate` catches exactly what the runtime would reject. match table.get(name.as_ref()) { Some(child) => walk(child, field, rest, &next_rendered, out), - None => Err(format!("missing `{next_rendered}`")), + None => Ok(()), } } Some((SecretPathSegment::ArrayEach, rest)) => { @@ -2828,12 +2852,28 @@ other = "x" assert!(leaves.is_empty(), "absent optional leaf yields nothing"); } + #[test] + fn collect_secret_leaves_skips_absent_optional_intermediate() { + let raw: Value = toml::from_str("[integrations]\n").expect("toml"); + let field = SecretField { + kind: SecretKind::KeyInDefault, + path: vec![ + SecretPathSegment::Field(Cow::Borrowed("integrations")), + SecretPathSegment::OptionalField(Cow::Borrowed("datadome")), + SecretPathSegment::Field(Cow::Borrowed("webhook_key")), + ], + optional: true, + }; + let leaves = collect_secret_leaves(&raw, &field) + .expect("absent optional intermediate should be skipped"); + assert!( + leaves.is_empty(), + "absent optional intermediate yields nothing" + ); + } + #[test] fn collect_secret_leaves_errors_on_missing_required_intermediate() { - // A missing INTERMEDIATE (the `integrations` table) is an error even - // when the leaf is optional — `optional` reflects only the leaf, and - // intermediates are structurally required. Locks alignment with the - // runtime walk (`resolve_secret_field`). let raw: Value = toml::from_str("other = \"x\"\n").expect("toml"); let field = SecretField { kind: SecretKind::KeyInDefault, diff --git a/crates/edgezero-core/src/app_config.rs b/crates/edgezero-core/src/app_config.rs index 509e5684..ac759024 100644 --- a/crates/edgezero-core/src/app_config.rs +++ b/crates/edgezero-core/src/app_config.rs @@ -37,6 +37,8 @@ pub enum SecretPathSegment { ArrayEach, /// An object key — a Rust field name, verbatim (no `serde(rename)`). Field(Cow<'static, str>), + /// An optional field that skips the rest of this secret path when absent or null. + OptionalField(Cow<'static, str>), } /// One field's worth of secret-annotation metadata. @@ -64,7 +66,7 @@ impl SecretField { let mut out = String::new(); for segment in &self.path { match segment { - SecretPathSegment::Field(name) => { + SecretPathSegment::Field(name) | SecretPathSegment::OptionalField(name) => { if !out.is_empty() { out.push('.'); } @@ -324,10 +326,13 @@ fn prune_secret_leaf(errors: &mut ValidationErrors, path: &[SecretPathSegment]) let Some((head, rest)) = path.split_first() else { return; }; - let SecretPathSegment::Field(name) = head else { - // `ArrayEach` only appears immediately after a `Field` (the root is - // always a struct), so it is consumed by the peek below, never a head. - return; + let name = match head { + SecretPathSegment::Field(name) | SecretPathSegment::OptionalField(name) => name, + SecretPathSegment::ArrayEach => { + // `ArrayEach` only appears immediately after a field (the root is + // always a struct), so it is consumed by the peek below, never a head. + return; + } }; // Leaf reached: drop the validator error keyed by this field name. @@ -1554,7 +1559,7 @@ greeting = "hello" kind: SecretKind::KeyInDefault, path: vec![ Field(Cow::Borrowed("integrations")), - Field(Cow::Borrowed("datadome")), + OptionalField(Cow::Borrowed("datadome")), Field(Cow::Borrowed("server_side_key")), ], optional: false, diff --git a/crates/edgezero-core/src/extractor.rs b/crates/edgezero-core/src/extractor.rs index 7cbb4a85..22205682 100644 --- a/crates/edgezero-core/src/extractor.rs +++ b/crates/edgezero-core/src/extractor.rs @@ -999,15 +999,22 @@ fn resolve_secret_field<'walk>( ) -> Pin> + 'walk>> { Box::pin(async move { match remaining.split_first() { - // Leaf reached: `node` is the PARENT object; the last Field is the key. + // Leaf reached: `node` is the PARENT object; the last field is the key. Some((SecretPathSegment::Field(name), [])) => { resolve_leaf(ctx, node, field, name.as_ref(), &rendered).await } - // Descend into an object key. Intermediates are ALWAYS required — - // `field.optional` reflects only the LEAF (`Option`), and the - // derive never nests through `Option`/`Box`, so a missing/null parent - // is a stale blob. (Skipping it here would let the whole subtree pass - // silently and only fail later with a vaguer serde error.) + Some((SecretPathSegment::OptionalField(name), [])) => { + if matches!( + node.get(name.as_ref()), + None | Some(serde_json::Value::Null) + ) { + return Ok(()); + } + resolve_leaf(ctx, node, field, name.as_ref(), &rendered).await + } + // Required intermediates still reject stale blobs. Optional + // intermediates are represented explicitly below rather than by the + // leaf's `field.optional` flag. Some((SecretPathSegment::Field(name), rest)) => { let next_rendered = join_field(&rendered, name.as_ref()); match node.get_mut(name.as_ref()) { @@ -1020,8 +1027,17 @@ fn resolve_secret_field<'walk>( } } } + Some((SecretPathSegment::OptionalField(name), rest)) => { + let next_rendered = join_field(&rendered, name.as_ref()); + match node.get_mut(name.as_ref()) { + None | Some(serde_json::Value::Null) => Ok(()), + Some(child) => { + resolve_secret_field(ctx, child, field, rest, next_rendered).await + } + } + } // Iterate every array element. The array itself is a required - // intermediate (see above), so a non-array is always an error. + // intermediate unless its containing field was optional above. Some((SecretPathSegment::ArrayEach, rest)) => { let Some(items) = node.as_array_mut() else { return Err(EdgeError::config_out_of_date( @@ -1394,7 +1410,8 @@ mod tests { } } - // Optional leaf behind required intermediates: integrations.datadome.webhook_key + // Optional leaf behind one required and one optional intermediate: + // integrations.datadome.webhook_key struct OptionalNestedCfg; impl AppConfigMeta for OptionalNestedCfg { fn secret_fields() -> Vec { @@ -1402,7 +1419,7 @@ mod tests { kind: SecretKind::KeyInDefault, path: vec![ SecretPathSegment::Field(Cow::Borrowed("integrations")), - SecretPathSegment::Field(Cow::Borrowed("datadome")), + SecretPathSegment::OptionalField(Cow::Borrowed("datadome")), SecretPathSegment::Field(Cow::Borrowed("webhook_key")), ], optional: true, @@ -2861,9 +2878,24 @@ mod tests { ); } + #[test] + fn secret_walk_skips_absent_optional_intermediate() { + let ctx = ctx_with_default_secret_store("unused", "unused"); + let mut data = serde_json::json!({ "integrations": {} }); + block_on(secret_walk::(&ctx, &mut data)) + .expect("absent optional intermediate is fine"); + } + + #[test] + fn secret_walk_skips_null_optional_intermediate() { + let ctx = ctx_with_default_secret_store("unused", "unused"); + let mut data = serde_json::json!({ "integrations": { "datadome": null } }); + block_on(secret_walk::(&ctx, &mut data)) + .expect("null optional intermediate is fine"); + } + #[test] fn secret_walk_present_intermediate_absent_optional_leaf_is_ok() { - // The mirror case: intermediates present, optional leaf absent -> skip. let ctx = ctx_with_default_secret_store("unused", "unused"); let mut data = serde_json::json!({ "integrations": { "datadome": {} } }); block_on(secret_walk::(&ctx, &mut data)) From ebe5022a588792a1041b538802ff28b8380473f6 Mon Sep 17 00:00:00 2001 From: Christian Date: Wed, 26 Aug 2026 12:01:51 -0500 Subject: [PATCH 2/6] Address typed static secret path review feedback --- crates/edgezero-adapter-fastly/src/cli.rs | 464 ++++++++++++++++++---- crates/edgezero-cli/src/config.rs | 4 + crates/edgezero-core/src/app_config.rs | 4 + docs/guide/cli-reference.md | 18 +- 4 files changed, 404 insertions(+), 86 deletions(-) diff --git a/crates/edgezero-adapter-fastly/src/cli.rs b/crates/edgezero-adapter-fastly/src/cli.rs index 5f64b0f6..9408e95d 100644 --- a/crates/edgezero-adapter-fastly/src/cli.rs +++ b/crates/edgezero-adapter-fastly/src/cli.rs @@ -349,6 +349,11 @@ struct TempFileGuard { path: Option, } +struct RuntimeStoreNameReconciliation { + deletes: Vec, + upserts: Vec<(String, String)>, +} + // The three `validate_*` trait methods exist on `Adapter` because // spin requires them (variable-name regex, `[component.*]` // discovery, flat-namespace collision). The trait surface is typed @@ -464,6 +469,7 @@ impl Adapter for FastlyCliAdapter { ); }; let fastly_path = manifest_root.join(rel); + let manifest_dir = fastly_path.parent().unwrap_or(manifest_root); let mut out = Vec::new(); for (kind, ids) in [ @@ -494,7 +500,7 @@ impl Adapter for FastlyCliAdapter { )); continue; } - create_fastly_store(kind, name)?; + create_fastly_store_in(kind, name, manifest_dir)?; // If the platform store was created but the // writeback fails, remote state and the local // manifest are out of sync. Re-running `provision` @@ -555,7 +561,7 @@ impl Adapter for FastlyCliAdapter { fastly_path.display() )); } else if !setup_block_present(&fastly_path, runtime_env_kind, runtime_env_name)? { - create_fastly_store(runtime_env_kind, runtime_env_name)?; + create_fastly_store_in(runtime_env_kind, runtime_env_name, manifest_dir)?; append_fastly_setup(&fastly_path, runtime_env_kind, runtime_env_name).map_err( |err| { format!( @@ -590,7 +596,11 @@ impl Adapter for FastlyCliAdapter { // Already declared; nothing to do. } - out.extend(persist_runtime_env_store_name_entries(stores, dry_run)?); + out.extend(persist_runtime_env_store_name_entries( + stores, + dry_run, + manifest_dir, + )?); // The STAGING twin of the runtime-override store is created and // populated entirely by a staged deploy (see @@ -1462,19 +1472,20 @@ fn classify_resolved_read( /// # Errors /// Returns an error if `fastly` isn't on `PATH`, the child fails to /// spawn, or the exit status is non-zero. -fn create_fastly_store(kind: &str, name: &str) -> Result<(), String> { +fn create_fastly_store_in(kind: &str, name: &str, cwd: &Path) -> Result<(), String> { let subcommand = format!("{kind}-store"); let name_arg = format!("--name={name}"); - let output = Command::new("fastly") + let mut command = Command::new("fastly"); + command .args([subcommand.as_str(), "create", name_arg.as_str()]) - .output() - .map_err(|err| { - if err.kind() == ErrorKind::NotFound { - format!("`fastly` not found on PATH; {FASTLY_INSTALL_HINT}") - } else { - format!("failed to spawn `fastly`: {err}") - } - })?; + .current_dir(cwd); + let output = command.output().map_err(|err| { + if err.kind() == ErrorKind::NotFound { + format!("`fastly` not found on PATH; {FASTLY_INSTALL_HINT}") + } else { + format!("failed to spawn `fastly`: {err}") + } + })?; if output.status.success() { return Ok(()); } @@ -3240,17 +3251,39 @@ where /// bytes out of argv and lifts the size cap to whatever the OS /// pipe buffer + the CLI's read accept (megabytes in practice). fn create_config_store_entry(store_id: &str, key: &str, value: &str) -> Result<(), String> { + create_config_store_entry_with_cwd(store_id, key, value, None) +} + +fn create_config_store_entry_in( + store_id: &str, + key: &str, + value: &str, + cwd: &Path, +) -> Result<(), String> { + create_config_store_entry_with_cwd(store_id, key, value, Some(cwd)) +} + +fn create_config_store_entry_with_cwd( + store_id: &str, + key: &str, + value: &str, + cwd: Option<&Path>, +) -> Result<(), String> { let store_arg = format!("--store-id={store_id}"); let key_arg = format!("--key={key}"); - let mut child = Command::new("fastly") - .args([ - "config-store-entry", - "update", - store_arg.as_str(), - key_arg.as_str(), - "--upsert", - "--stdin", - ]) + let mut command = Command::new("fastly"); + command.args([ + "config-store-entry", + "update", + store_arg.as_str(), + key_arg.as_str(), + "--upsert", + "--stdin", + ]); + if let Some(command_cwd) = cwd { + command.current_dir(command_cwd); + } + let mut child = command .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -3409,9 +3442,9 @@ fn parse_config_store_entries(stdout: &str) -> Result, Str /// `fastly config-store-entry delete --store-id= --key=`, run in the /// app manifest directory. Distinct from the `config gc` `delete_config_store_entry` -/// (which runs in the process cwd with redacted diagnostics); staging reconciliation -/// must run `fastly` in `cwd` so it resolves the right service context. -fn delete_staging_config_store_entry(store_id: &str, key: &str, cwd: &Path) -> Result<(), String> { +/// (which runs in the process cwd with redacted diagnostics); runtime-env +/// reconciliation must run `fastly` in `cwd` so it resolves the right service context. +fn delete_config_store_entry_in(store_id: &str, key: &str, cwd: &Path) -> Result<(), String> { run_fastly_status( &[ "config-store-entry".to_owned(), @@ -3487,9 +3520,9 @@ fn runtime_store_name_entries_from_vars( if !is_runtime_store_name_key(&key) { continue; } - if value.is_empty() || value.trim() != value { + if value.is_empty() || value.trim() != value || value.chars().any(char::is_control) { return Err(format!( - "runtime store-name override `{key}` must be non-empty and contain no surrounding whitespace" + "runtime store-name override `{key}` must be non-empty and contain no surrounding whitespace or control characters" )); } entries.push((key, value)); @@ -3524,15 +3557,15 @@ fn staging_selector_store_name(service_id: &str) -> String { format!("{RUNTIME_ENV_STAGING_STORE_PREFIX}_{service_id}") } -fn ensure_staging_selector_store(store_name: &str) -> Result { - match classify_remote_config_store(store_name)? { +fn ensure_staging_selector_store(store_name: &str, cwd: &Path) -> Result { + match classify_remote_config_store_in(store_name, cwd)? { ConfigStoreLookup::Found(id) => Ok(id), ConfigStoreLookup::NotFound => { - create_fastly_store("config", store_name)?; + create_fastly_store_in("config", store_name, cwd)?; // resolve_remote_config_store_id now yields a typed absence; we just // created the store, so a None here is fail-closed (the listing did // not reflect our own create), not a genuine absence. - resolve_remote_config_store_id(store_name) + resolve_remote_config_store_id_in(store_name, cwd) .map_err(|err| { format!( "created fastly config-store `{store_name}` but could not resolve its id: {err}" @@ -3583,17 +3616,24 @@ fn mirror_production_to_staging( let desired = staging_entries_from_production(&effective_production, config_logical_ids); for (key, value) in &desired { - create_config_store_entry(staging_id, key, value)?; + create_config_store_entry_in(staging_id, key, value, cwd)?; } let current = read_config_store_entries(staging_id, cwd)?; for (key, _) in ¤t { if !desired.iter().any(|(dk, _)| dk == key) { - delete_staging_config_store_entry(staging_id, key, cwd)?; + delete_config_store_entry_in(staging_id, key, cwd)?; } } Ok(()) } +fn runtime_store_name_key(kind: &str, logical: &str) -> String { + format!( + "EDGEZERO__STORES__{kind}__{}__NAME", + logical.to_ascii_uppercase() + ) +} + /// Return the runtime entries required when logical store ids map to different /// Fastly resource names. fn runtime_env_store_name_entries(stores: &ProvisionStores<'_>) -> Vec<(String, String)> { @@ -3608,10 +3648,7 @@ fn runtime_env_store_name_entries(stores: &ProvisionStores<'_>) -> Vec<(String, continue; } entries.push(( - format!( - "EDGEZERO__STORES__{kind}__{}__NAME", - store.logical.to_ascii_uppercase() - ), + runtime_store_name_key(kind, &store.logical), store.platform.clone(), )); } @@ -3619,33 +3656,106 @@ fn runtime_env_store_name_entries(stores: &ProvisionStores<'_>) -> Vec<(String, entries } +fn runtime_env_store_name_keys(stores: &ProvisionStores<'_>) -> Vec { + let mut keys = Vec::new(); + for (kind, ids) in [ + ("CONFIG", stores.config), + ("KV", stores.kv), + ("SECRETS", stores.secrets), + ] { + keys.extend( + ids.iter() + .map(|store| runtime_store_name_key(kind, &store.logical)), + ); + } + keys +} + +/// Compute the minimal changes needed for store-name mappings owned by the +/// logical ids this app currently declares. Entries for undeclared ids and +/// unrelated runtime settings are preserved because the production runtime-env +/// store can be linked by more than one service in the same Fastly account. +fn runtime_store_name_reconciliation( + stores: &ProvisionStores<'_>, + current: &[(String, String)], +) -> RuntimeStoreNameReconciliation { + let desired = runtime_env_store_name_entries(stores); + let declared = runtime_env_store_name_keys(stores); + + let mut upserts = desired + .iter() + .filter(|(key, value)| { + current + .iter() + .find(|(current_key, _)| current_key == key) + .is_none_or(|(_, current_value)| current_value != value) + }) + .cloned() + .collect::>(); + let mut deletes = current + .iter() + .filter(|(key, _)| { + declared.iter().any(|declared_key| declared_key == key) + && !desired.iter().any(|(desired_key, _)| desired_key == key) + }) + .map(|(key, _)| key.clone()) + .collect::>(); + upserts.sort_by(|left, right| left.0.cmp(&right.0)); + deletes.sort(); + + RuntimeStoreNameReconciliation { deletes, upserts } +} + fn persist_runtime_env_store_name_entries( stores: &ProvisionStores<'_>, dry_run: bool, + cwd: &Path, ) -> Result, String> { let entries = runtime_env_store_name_entries(stores); + let declared = runtime_env_store_name_keys(stores); + if declared.is_empty() { + return Ok(Vec::new()); + } if dry_run { - return Ok(entries + let mut out = entries .iter() .map(|(key, value)| { format!( "would upsert `{key}={value}` into fastly config-store `{RUNTIME_ENV_STORE}`" ) }) - .collect()); + .collect::>(); + out.extend( + declared + .iter() + .filter(|key| !entries.iter().any(|(entry_key, _)| entry_key == *key)) + .map(|key| { + format!( + "would remove `{key}` from fastly config-store `{RUNTIME_ENV_STORE}` if a stale mapping is present" + ) + }), + ); + return Ok(out); } - if entries.is_empty() { + + let runtime_env_store_id = resolve_remote_config_store_id_in(RUNTIME_ENV_STORE, cwd)? + .ok_or_else(|| no_matching_store_error(RUNTIME_ENV_STORE))?; + let current = read_config_store_entries(&runtime_env_store_id, cwd)?; + let reconciliation = runtime_store_name_reconciliation(stores, ¤t); + if reconciliation.upserts.is_empty() && reconciliation.deletes.is_empty() { return Ok(Vec::new()); } - let runtime_env_store_id = resolve_remote_config_store_id(RUNTIME_ENV_STORE)? - .ok_or_else(|| no_matching_store_error(RUNTIME_ENV_STORE))?; - push_entries_with_committer(&entries, |key, value| { - create_config_store_entry(&runtime_env_store_id, key, value) + push_entries_with_committer(&reconciliation.upserts, |key, value| { + create_config_store_entry_in(&runtime_env_store_id, key, value, cwd) })?; + for key in &reconciliation.deletes { + delete_config_store_entry_in(&runtime_env_store_id, key, cwd)?; + } Ok(vec![format!( - "persisted {} non-default store-name mapping(s) in fastly config-store `{RUNTIME_ENV_STORE}`", - entries.len() + "reconciled store-name mappings in fastly config-store `{RUNTIME_ENV_STORE}`: upserted {}, removed {} stale mapping(s)", + reconciliation.upserts.len(), + reconciliation.deletes.len() )]) } @@ -3816,7 +3926,23 @@ fn shape_summary(value: &serde_json::Value) -> &'static str { /// spawn/list failure, schema drift) stays `Err` -- callers that read for a diff /// must not treat an operational failure as "store absent" and overwrite. fn resolve_remote_config_store_id(name: &str) -> Result, String> { - match classify_remote_config_store(name)? { + resolve_remote_config_store_id_with_cwd(name, None) +} + +fn resolve_remote_config_store_id_in(name: &str, cwd: &Path) -> Result, String> { + resolve_remote_config_store_id_with_cwd(name, Some(cwd)) +} + +fn resolve_remote_config_store_id_with_cwd( + name: &str, + cwd: Option<&Path>, +) -> Result, String> { + let lookup = if let Some(command_cwd) = cwd { + classify_remote_config_store_in(name, command_cwd)? + } else { + classify_remote_config_store(name)? + }; + match lookup { ConfigStoreLookup::Found(id) => Ok(Some(id)), ConfigStoreLookup::NotFound => Ok(None), ConfigStoreLookup::SchemaDrift(detail) => Err(format!( @@ -3834,16 +3960,29 @@ fn resolve_remote_config_store_id(name: &str) -> Result, String> /// `Err` is only for a failure to OBTAIN an answer; a successful listing that /// simply doesn't contain `name` is `Ok(ConfigStoreLookup::NotFound)`. fn classify_remote_config_store(name: &str) -> Result { - let output = Command::new("fastly") - .args(["config-store", "list", "--json"]) - .output() - .map_err(|err| { - if err.kind() == ErrorKind::NotFound { - format!("`fastly` not found on PATH; {FASTLY_INSTALL_HINT}") - } else { - format!("failed to spawn `fastly`: {err}") - } - })?; + classify_remote_config_store_with_cwd(name, None) +} + +fn classify_remote_config_store_in(name: &str, cwd: &Path) -> Result { + classify_remote_config_store_with_cwd(name, Some(cwd)) +} + +fn classify_remote_config_store_with_cwd( + name: &str, + cwd: Option<&Path>, +) -> Result { + let mut command = Command::new("fastly"); + command.args(["config-store", "list", "--json"]); + if let Some(command_cwd) = cwd { + command.current_dir(command_cwd); + } + let output = command.output().map_err(|err| { + if err.kind() == ErrorKind::NotFound { + format!("`fastly` not found on PATH; {FASTLY_INSTALL_HINT}") + } else { + format!("failed to spawn `fastly`: {err}") + } + })?; if !output.status.success() { return Err(format!( "`fastly config-store list --json` exited with status {}\nstderr: {}", @@ -4962,7 +5101,8 @@ 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_NAME)? { + let production = + match classify_remote_config_store_in(RUNTIME_ENV_STORE_NAME, manifest_dir)? { ConfigStoreLookup::Found(id) => read_config_store_entries(&id, manifest_dir)?, ConfigStoreLookup::NotFound => Vec::new(), ConfigStoreLookup::SchemaDrift(detail) => { @@ -4977,7 +5117,7 @@ fn relink_runtime_env_for_staging( // THIS draft at the twin. Create the twin on demand so a staged deploy never // depends on a prior provision having created it. let staging_store_name = staging_selector_store_name(service_id); - let staging_store_id = ensure_staging_selector_store(&staging_store_name)?; + let staging_store_id = ensure_staging_selector_store(&staging_store_name, manifest_dir)?; mirror_production_to_staging( &production, &staging_store_id, @@ -6724,10 +6864,10 @@ build = \"cargo build --release\" let out = FastlyCliAdapter .provision(dir.path(), Some("fastly.toml"), None, &stores, true) .expect("dry-run succeeds"); - // 1 KV + 1 config + 1 secret + runtime-env = 4 status lines. The staging - // twin is created and populated by a staged deploy, NOT by provision, so - // it does not appear here. - assert_eq!(out.len(), 4, "dry-run rows: {out:?}"); + // 1 KV + 1 config + 1 secret + runtime-env + 3 possible stale-mapping + // removals = 7 status lines. The staging twin is created and populated by + // a staged deploy, NOT by provision, so it does not appear here. + assert_eq!(out.len(), 7, "dry-run rows: {out:?}"); assert!(out[0].contains("would run `fastly kv-store create --name=sessions`")); assert!(out[1].contains("would run `fastly config-store create --name=app_config`")); assert!(out[2].contains("would run `fastly secret-store create --name=default`")); @@ -6735,6 +6875,11 @@ build = \"cargo build --release\" out[3].contains("would run `fastly config-store create --name=edgezero_runtime_env`"), "runtime-env store row: {out:?}", ); + assert!( + out.iter() + .any(|row| row.contains("EDGEZERO__STORES__KV__SESSIONS__NAME")), + "dry-run reports possible stale mapping cleanup: {out:?}", + ); assert!( !out.iter() .any(|row| row.contains("edgezero_runtime_env_staging")), @@ -6766,6 +6911,84 @@ build = \"cargo build --release\" })); } + #[cfg(unix)] + #[test] + fn provision_reconciles_runtime_store_name_mappings() { + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write( + &path, + "[setup.kv_stores.production_sessions]\n\ + [setup.secret_stores.default]\n", + ) + .expect("write"); + let kv = vec![ResolvedStoreId::new("sessions", "production_sessions")]; + let secrets = vec![ResolvedStoreId::from_logical("default")]; + let stores = ProvisionStores { + config: &[], + kv: &kv, + secrets: &secrets, + }; + let current = vec![ + ( + "EDGEZERO__STORES__KV__SESSIONS__NAME".to_owned(), + "old_sessions".to_owned(), + ), + ( + "EDGEZERO__STORES__SECRETS__DEFAULT__NAME".to_owned(), + "old_secrets".to_owned(), + ), + ( + "EDGEZERO__STORES__KV__OTHER__NAME".to_owned(), + "other_service".to_owned(), + ), + ("EDGEZERO__LOGGING__LEVEL".to_owned(), "debug".to_owned()), + ]; + let oplog = dir.path().join("oplog.txt"); + let fake = fake_fastly_runtime_mapping(¤t, &oplog); + let _path = PathPrepend::new(fake.path()); + + let out = FastlyCliAdapter + .provision(dir.path(), Some("fastly.toml"), None, &stores, false) + .expect("mapping reconciliation succeeds"); + let log = fs::read_to_string(&oplog).expect("oplog"); + let manifest_dir = fs::canonicalize(dir.path()).expect("canonical manifest dir"); + + assert!( + log.contains(&format!("store-create cwd={}", manifest_dir.display())), + "runtime-env store creation runs in the manifest directory: {log}" + ); + assert!( + log.contains(&format!("store-list cwd={}", manifest_dir.display())), + "runtime-env store lookup runs in the manifest directory: {log}" + ); + assert!( + log.contains(&format!( + "update EDGEZERO__STORES__KV__SESSIONS__NAME=production_sessions cwd={}", + manifest_dir.display() + )), + "changed non-default mapping is upserted in the manifest directory: {log}" + ); + assert!( + log.contains(&format!( + "delete EDGEZERO__STORES__SECRETS__DEFAULT__NAME cwd={}", + manifest_dir.display() + )), + "stale mapping is removed in the manifest directory: {log}" + ); + assert!( + !log.contains("delete EDGEZERO__STORES__KV__OTHER__NAME") + && !log.contains("EDGEZERO__LOGGING__LEVEL="), + "unrelated runtime entries are preserved: {log}" + ); + assert!( + out.iter() + .any(|line| line.contains("upserted 1, removed 1")), + "status reports both mutations: {out:?}" + ); + } + #[test] fn provision_errors_when_adapter_manifest_path_missing() { let dir = tempdir().expect("tempdir"); @@ -6807,13 +7030,12 @@ build = \"cargo build --release\" assert_eq!(out, vec!["fastly has no declared stores to provision"]); } + #[cfg(unix)] #[test] - fn provision_skips_id_when_setup_block_already_present() { - // setup_block_present's role in the flow: re-running - // provision after the user already declared a store in - // fastly.toml must be a no-op (no shell-out to fastly). - // We can verify this in a real (non-dry-run) call because - // the skip path bypasses create_fastly_store entirely. + fn provision_skips_store_creation_when_setup_block_already_present() { + // Re-running provision skips resource creation but still reads the + // runtime-env store to reconcile a mapping that may have been removed. + let _lock = path_mutation_guard().lock().expect("guard"); let dir = tempdir().expect("tempdir"); let path = dir.path().join("fastly.toml"); fs::write( @@ -6828,11 +7050,21 @@ build = \"cargo build --release\" kv: &kv_ids, secrets: &[], }; + let oplog = dir.path().join("oplog.txt"); + let fake = fake_fastly_runtime_mapping(&[], &oplog); + let _path = PathPrepend::new(fake.path()); + let out = FastlyCliAdapter .provision(dir.path(), Some("fastly.toml"), None, &stores, false) - .expect("skip path succeeds without invoking fastly"); + .expect("skip path succeeds"); assert_eq!(out.len(), 1); assert!(out[0].contains("already declared"), "got: {out:?}"); + let manifest_dir = fs::canonicalize(dir.path()).expect("canonical manifest dir"); + assert_eq!( + fs::read_to_string(oplog).expect("oplog"), + format!("store-list cwd={0}\nlist cwd={0}\n", manifest_dir.display()), + "runtime mapping is inspected in the manifest directory without mutation" + ); } /// When `fastly.toml` declares `service_id`, the next @@ -7344,6 +7576,64 @@ build = \"cargo build --release\" // ---------- read_config_entry (fake fastly, remote shell-out) ---------- + /// Build a fake `fastly` for live runtime store-name reconciliation. + /// The current runtime-env entries are listed verbatim and every update or + /// delete is recorded in `oplog`. + #[cfg(unix)] + fn fake_fastly_runtime_mapping( + current: &[(String, String)], + oplog: &Path, + ) -> tempfile::TempDir { + use std::os::unix::fs::PermissionsExt as _; + + let dir = tempdir().expect("tempdir"); + let store_list = dir.path().join("stores.json"); + let entry_list = dir.path().join("entries.json"); + fs::write( + &store_list, + format!(r#"[{{"name":"{RUNTIME_ENV_STORE}","id":"runtime-env-123"}}]"#), + ) + .expect("store list"); + let entries = current + .iter() + .map(|(key, value)| { + serde_json::json!({ + "item_key": key, + "item_value": value, + }) + }) + .collect::>(); + fs::write( + &entry_list, + serde_json::to_string(&entries).expect("entry list json"), + ) + .expect("entry list"); + + let script = format!( + r#"#!/bin/sh +if [ "$1" = "config-store" ] && [ "$2" = "create" ]; then printf 'store-create cwd=%s\n' "$PWD" >> '{oplog}'; exit 0; fi +if [ "$1" = "config-store" ]; then printf 'store-list cwd=%s\n' "$PWD" >> '{oplog}'; cat '{stores}'; exit 0; fi +sub="$2" +key="" +for arg in "$@"; do case "$arg" in --key=*) key="${{arg#--key=}}";; esac; done +if [ "$sub" = "list" ]; then printf 'list cwd=%s\n' "$PWD" >> '{oplog}'; cat '{entries}'; exit 0; fi +if [ "$sub" = "update" ]; then value=$(cat); printf 'update %s=%s cwd=%s\n' "$key" "$value" "$PWD" >> '{oplog}'; exit 0; fi +if [ "$sub" = "delete" ]; then printf 'delete %s cwd=%s\n' "$key" "$PWD" >> '{oplog}'; exit 0; fi +echo 'unexpected fastly invocation' >&2 +exit 1 +"#, + stores = store_list.display(), + entries = entry_list.display(), + oplog = oplog.display(), + ); + let script_path = dir.path().join("fastly"); + fs::write(&script_path, script).expect("script"); + let mut perms = fs::metadata(&script_path).expect("meta").permissions(); + perms.set_mode(0o755); + fs::set_permissions(&script_path, perms).expect("chmod"); + dir + } + /// Build a tempdir containing a `fastly` shim script that: /// - Responds to `config-store list --json` with a store-list JSON containing /// `TEST_CONFIG_ID` mapped to `store-abc123`. @@ -10470,6 +10760,18 @@ echo 'unexpected' >&2; exit 1 "EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY".to_owned(), "ignored_selector".to_owned(), ), + ( + "EDGEZERO__STORES__KV__A__B__NAME".to_owned(), + "ignored_nested_id".to_owned(), + ), + ( + "EDGEZERO__STORES__KV__A__NAME__EXTRA".to_owned(), + "ignored_extra_segment".to_owned(), + ), + ( + "EDGEZERO__STORES__kv__A__NAME".to_owned(), + "ignored_lowercase_kind".to_owned(), + ), ("UNRELATED".to_owned(), "ignored".to_owned()), ]) .expect("valid store-name override"); @@ -10481,14 +10783,20 @@ echo 'unexpected' >&2; exit 1 "physical_secrets".to_owned(), )] ); - assert!( - runtime_store_name_entries_from_vars([( - "EDGEZERO__STORES__SECRETS__DEFAULT__NAME".to_owned(), - String::new(), - )]) - .is_err(), - "an empty mapped resource name must fail closed" - ); + for invalid in [ + String::new(), + "prod\nsecrets".to_owned(), + "prod\0secrets".to_owned(), + ] { + assert!( + runtime_store_name_entries_from_vars([( + "EDGEZERO__STORES__SECRETS__DEFAULT__NAME".to_owned(), + invalid, + )]) + .is_err(), + "an invalid mapped resource name must fail closed" + ); + } } #[test] diff --git a/crates/edgezero-cli/src/config.rs b/crates/edgezero-cli/src/config.rs index bdf05561..e1cc0a06 100644 --- a/crates/edgezero-cli/src/config.rs +++ b/crates/edgezero-cli/src/config.rs @@ -1733,6 +1733,10 @@ fn collect_secret_leaves<'raw>( } Ok(()) } + Some((_unsupported, _)) => Err(format!( + "unsupported secret path segment in `{}`", + field.dotted_path() + )), None => Ok(()), } } diff --git a/crates/edgezero-core/src/app_config.rs b/crates/edgezero-core/src/app_config.rs index ac759024..6cf96235 100644 --- a/crates/edgezero-core/src/app_config.rs +++ b/crates/edgezero-core/src/app_config.rs @@ -32,12 +32,16 @@ use validator::{Validate, ValidationErrors}; /// One segment of a [`SecretField`] path. #[derive(Clone, Debug, Eq, PartialEq)] +#[non_exhaustive] pub enum SecretPathSegment { /// Every element of an array/`Vec` at this position. ArrayEach, /// An object key — a Rust field name, verbatim (no `serde(rename)`). Field(Cow<'static, str>), /// An optional field that skips the rest of this secret path when absent or null. + /// + /// Available to hand-written [`AppConfigMeta`] implementations. The + /// `AppConfig` derive does not currently emit optional intermediate fields. OptionalField(Cow<'static, str>), } diff --git a/docs/guide/cli-reference.md b/docs/guide/cli-reference.md index 65aaef4c..1f6b48e7 100644 --- a/docs/guide/cli-reference.md +++ b/docs/guide/cli-reference.md @@ -510,12 +510,12 @@ edgezero provision --adapter [--manifest ] [--dry-run] **Per-adapter behaviour:** -| `--adapter` | Behaviour | -| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `axum` | Local-only — prints one note per declared store id and exits 0 (KV in-memory; config in `.edgezero/local-config-.json`). | -| `cloudflare` | For each KV id + config id: shells out to `wrangler kv namespace create ` (where `` resolves from `EDGEZERO__STORES______NAME` or falls back to the logical ``), parses the namespace id from stdout, appends `[[kv_namespaces]] binding = "", id = ""` to `wrangler.toml` (idempotent on the binding name; preserves existing entries and comments). Secrets are runtime-managed via `wrangler secret put` — no-op. | -| `fastly` | For each KV / config / secret id: shells out to `fastly -store create --name=` (using the same `` resolution), then appends the `[setup._stores.]` table to `fastly.toml`. Provision writes ONLY `[setup.*]` (the remote/deploy half); the `[local_server.*]` seeding is written by `config push --local` (config stores only). Idempotent: if the setup table is already present the id is skipped (no shell-out, no edit). Store IDs are not persisted — `config push` resolves them on demand. | -| `spin` | Pure `spin.toml` editing — no shell-out (Spin KV stores are runtime-resolved). For each declared KV id AND each declared `[stores.config]` id (both KV-backed at runtime), appends the platform-resolved label to the resolved `[component.].key_value_stores = [...]` array (idempotent on the label). Secret variables are still manual: `[stores.secrets]` ids get a `nothing to do here` status line and the operator declares `[variables]. = { secret = true }` + the per-component binding by hand. | +| `--adapter` | Behaviour | +| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `axum` | Local-only — prints one note per declared store id and exits 0 (KV in-memory; config in `.edgezero/local-config-.json`). | +| `cloudflare` | For each KV id + config id: shells out to `wrangler kv namespace create ` (where `` resolves from `EDGEZERO__STORES______NAME` or falls back to the logical ``), parses the namespace id from stdout, appends `[[kv_namespaces]] binding = "", id = ""` to `wrangler.toml` (idempotent on the binding name; preserves existing entries and comments). Secrets are runtime-managed via `wrangler secret put` — no-op. | +| `fastly` | For each KV / config / secret id: shells out to `fastly -store create --name=` (using the same `` resolution), then appends the `[setup._stores.]` table to `fastly.toml`. Provision writes ONLY `[setup.*]` (the remote/deploy half); the `[local_server.*]` seeding is written by `config push --local` (config stores only). If the setup table is already present, resource creation and manifest editing are skipped. A live run still reconciles declared logical-to-physical name mappings in `edgezero_runtime_env`, removing stale mappings when an override returns to its logical default. Store IDs are not persisted — `config push` resolves them on demand. | +| `spin` | Pure `spin.toml` editing — no shell-out (Spin KV stores are runtime-resolved). For each declared KV id AND each declared `[stores.config]` id (both KV-backed at runtime), appends the platform-resolved label to the resolved `[component.].key_value_stores = [...]` array (idempotent on the label). Secret variables are still manual: `[stores.secrets]` ids get a `nothing to do here` status line and the operator declares `[variables]. = { secret = true }` + the per-component binding by hand. | **`--dry-run`** prints what each adapter _would_ do without performing it. For `axum` the output is identical to a real run @@ -530,8 +530,10 @@ existing `binding`s are detected and skipped. The `fastly` flow requires `fastly` on `PATH` and `[adapters.fastly.adapter].manifest` pointing at the project's -`fastly.toml`. Re-running is safe: provision skips any id whose -`[setup._stores.]` block already exists in the manifest. +`fastly.toml`. Re-running is safe: provision skips resource creation for any id +whose `[setup._stores.]` block already exists, then reads +`edgezero_runtime_env` and reconciles mappings for the app's declared logical +ids. It does not delete mappings for undeclared ids or unrelated runtime entries. The `spin` flow needs no native CLI but does require `[adapters.spin.adapter].manifest` pointing at the project's From 6f48d664c84408c75f5c1e2f8ee3e6040a403f38 Mon Sep 17 00:00:00 2001 From: Christian Date: Mon, 31 Aug 2026 16:43:29 -0500 Subject: [PATCH 3/6] Scope Fastly runtime mappings by service --- crates/edgezero-adapter-fastly/src/cli.rs | 945 +++++++++++++------- crates/edgezero-adapter-fastly/src/lib.rs | 98 +- crates/edgezero-cli/src/config.rs | 21 + crates/edgezero-core/src/extractor.rs | 32 +- docs/guide/adapters/fastly.md | 18 + docs/guide/blob-app-config-migration.md | 11 +- docs/guide/cli-reference.md | 25 +- docs/guide/deploy-github-actions.md | 9 +- docs/specs/edgezero-deploy-github-action.md | 20 +- 9 files changed, 802 insertions(+), 377 deletions(-) diff --git a/crates/edgezero-adapter-fastly/src/cli.rs b/crates/edgezero-adapter-fastly/src/cli.rs index 9408e95d..34f949ec 100644 --- a/crates/edgezero-adapter-fastly/src/cli.rs +++ b/crates/edgezero-adapter-fastly/src/cli.rs @@ -20,6 +20,7 @@ use crate::chunked_config::{ prior_chunk_keys, resolve_fastly_config_value_typed, sha256_hex, value_announces_our_kind, value_is_future_format, value_is_inert_foreign, verify_writer_split_layout, }; +use crate::service_scoped_runtime_env_key; use ctor::ctor; use edgezero_adapter::cli_support::{ find_manifest_upwards, find_workspace_root, path_distance, read_package_name, run_native_cli, @@ -349,6 +350,14 @@ struct TempFileGuard { path: Option, } +struct EntryCommitFailure { + committed: Vec, + error: String, + failed_key: String, + not_attempted: Vec, + total: usize, +} + struct RuntimeStoreNameReconciliation { deletes: Vec, upserts: Vec<(String, String)>, @@ -470,6 +479,8 @@ impl Adapter for FastlyCliAdapter { }; let fastly_path = manifest_root.join(rel); let manifest_dir = fastly_path.parent().unwrap_or(manifest_root); + let runtime_env_service_id = + provision_runtime_env_service_id_for_stores(&fastly_path, stores)?; let mut out = Vec::new(); for (kind, ids) in [ @@ -583,8 +594,12 @@ impl Adapter for FastlyCliAdapter { // make production serve staged config. Staged versions get their own // selector via `edgezero_runtime_env_staging`, wired automatically by // a staged deploy; nothing here should be edited to stage config. + let production_selector_key = runtime_env_key_for( + runtime_env_service_id.as_deref().unwrap_or(""), + "app_config", + ); let mut line = format!( - "created fastly {runtime_env_kind}-store `{runtime_env_name}` (EdgeZero runtime override store, read by the ACTIVE version); appended setup tables to {}\n Provision writes non-default store-name mappings below. Config stores still select their logical id as the default key.\n To point PRODUCTION at a different config key, and only then:\n fastly config-store-entry update --store-id= --key=EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY --value= --upsert\n Do NOT set a `_staging` key here: staged config is isolated by a per-service `{RUNTIME_ENV_STAGING_STORE_PREFIX}_` store, which a staged deploy creates and links automatically.", + "created fastly {runtime_env_kind}-store `{runtime_env_name}` (EdgeZero runtime override store, read by the ACTIVE version); appended setup tables to {}\n Provision writes service-scoped non-default store-name mappings below. Config stores still select their logical id as the default key.\n To point PRODUCTION at a different config key, and only then:\n fastly config-store-entry update --store-id= --key={production_selector_key} --value= --upsert\n Do NOT set a `_staging` key here: staged config is isolated by a per-service `{RUNTIME_ENV_STAGING_STORE_PREFIX}_` store, which a staged deploy creates and links automatically.", fastly_path.display() ); if let Some(note) = post_create_note { @@ -598,6 +613,7 @@ impl Adapter for FastlyCliAdapter { out.extend(persist_runtime_env_store_name_entries( stores, + runtime_env_service_id.as_deref(), dry_run, manifest_dir, )?); @@ -1562,18 +1578,60 @@ fn read_fastly_service_id(path: &Path) -> Result, String> { Ok(svc) } -/// If fastly.toml declares `service_id`, the next -/// `fastly compute deploy` skips `[setup]` entirely (it only runs on -/// the FIRST deploy of a service). Any store created by provision -/// after that needs a separate `fastly resource-link create` to link -/// the platform store to the service version. This helper returns the -/// remediation note to surface in the provision output, or `None` -/// when the service hasn't been deployed yet (so the next -/// `compute deploy` will pick up the `[setup]` row automatically). +/// Resolve the service namespace provision uses for account-wide runtime-env +/// entries. A manifest id and environment id must agree so Fastly CLI project +/// context cannot write mappings owned by a different service. +fn provision_runtime_env_service_id(path: &Path) -> Result, String> { + let manifest_id = read_fastly_service_id(path)?; + let env_id = match env::var_os(FASTLY_SERVICE_ID_ENV) { + None => None, + Some(value) => Some( + value + .into_string() + .map_err(|_value| format!("{FASTLY_SERVICE_ID_ENV} must contain valid UTF-8"))?, + ), + }; + + if let Some(service_id) = manifest_id.as_deref() { + validate_service_id(service_id)?; + } + if let Some(service_id) = env_id.as_deref() { + validate_service_id(service_id)?; + } + match (manifest_id, env_id) { + (Some(manifest), Some(environment)) if manifest != environment => Err(format!( + "Fastly service id mismatch: {} declares `{manifest}` but {FASTLY_SERVICE_ID_ENV} is `{environment}`; refusing to write runtime mappings across service namespaces", + path.display() + )), + (Some(manifest), _) => Ok(Some(manifest)), + (None, Some(environment)) => Ok(Some(environment)), + (None, None) => Ok(None), + } +} + +fn provision_runtime_env_service_id_for_stores( + path: &Path, + stores: &ProvisionStores<'_>, +) -> Result, String> { + let service_id = provision_runtime_env_service_id(path)?; + if has_non_default_store_name_mappings(stores) && service_id.is_none() { + return Err(format!( + "cannot persist non-default Fastly store-name mappings without a service namespace: set top-level `service_id` in {} or set {FASTLY_SERVICE_ID_ENV}", + path.display() + )); + } + Ok(service_id) +} + +/// If fastly.toml declares `service_id` or `FASTLY_SERVICE_ID` selects one, +/// the next `fastly compute deploy` targets an existing service and skips +/// `[setup]`. Any store created by provision then needs a separate resource +/// link. This helper returns that remediation or `None` before a service has +/// been selected. fn resource_link_note(path: &Path, kind: &str, name: &str) -> Result, String> { - let note = read_fastly_service_id(path)?.map(|svc_id| { + let note = provision_runtime_env_service_id(path)?.map(|svc_id| { format!( - " fastly.toml declares `service_id = \"{svc_id}\"`, so this service is already deployed -- `[setup]` will NOT be re-run on the next `fastly compute deploy`. The store exists in the account but is NOT yet linked to the service. To finish provisioning, look up the store id with `fastly {kind}-store list --json` (match by name=`{name}`), then run:\n fastly resource-link create --service-id={svc_id} --resource-id= --version=latest --autoclone --name={name}\n (the link clones the active version so existing traffic is not affected until you `fastly service-version activate`)." + " Fastly service id resolves to `{svc_id}`, so `[setup]` will NOT be re-run on the next `fastly compute deploy`. The store exists in the account but is NOT yet linked to the service. To finish provisioning, look up the store id with `fastly {kind}-store list --json` (match by name=`{name}`), then run:\n fastly resource-link create --service-id={svc_id} --resource-id= --version=latest --autoclone --name={name}\n (the link clones the active version so existing traffic is not affected until you `fastly service-version activate`)." ) }); Ok(note) @@ -3182,48 +3240,90 @@ fn chunk_key_generation_any(key: &str) -> Option { chunk_key_generation(root, key) } -/// Drive a sequential per-entry commit loop and produce the -/// partial-failure diagnostic when the committer fails mid-way. -/// Pure (no I/O) so the diagnostic shape is unit-testable without -/// the fastly CLI on PATH; production calls it with a closure that -/// shells out via `create_config_store_entry`. On success returns -/// the count of committed entries; on failure returns an error -/// string. The FAILED entry's outcome is UNKNOWN — Fastly may have -/// committed it before returning the error — so the message does not -/// claim a clean boundary; it directs the operator to re-run the whole -/// idempotent push rather than hand-resume from a supposed cut point. -fn push_entries_with_committer( +/// Drive the common sequential commit mechanics while leaving recovery policy +/// to the operation that owns the writes. +fn commit_entries_with_committer( entries: &[(String, String)], mut committer: F, -) -> Result +) -> Result where F: FnMut(&str, &str) -> Result<(), String>, { - let mut pushed: Vec = Vec::with_capacity(entries.len()); - for (key, value) in entries { - if let Err(err) = committer(key, value) { - let remaining: Vec<&str> = entries - .iter() - .skip(pushed.len().saturating_add(1)) - .map(|(remaining_key, _)| remaining_key.as_str()) - .collect(); - return Err(format!( - "fastly push failed at entry `{key}` while committing {committed} of {total} entries.\n \ - The failed entry's outcome is UNKNOWN: Fastly may have committed it before the error \ - (a timeout can arrive after the write lands), including when it is the root pointer.\n \ - Recovery: re-run the SAME `config push`. It is idempotent -- chunk keys are content-addressed \ - and writes use `--upsert` -- so entries already written are rewritten harmlessly and any \ - missing ones are filled. Do NOT hand-delete the failed key.\n \ - Already written (a retry rewrites them): {pushed:?}\n \ - Failed: `{key}` (outcome unknown) -- {err}\n \ - Not attempted: {remaining:?}", - committed = pushed.len(), - total = entries.len(), - )); + let mut written_keys = Vec::with_capacity(entries.len()); + for (index, (key, value)) in entries.iter().enumerate() { + if let Err(error) = committer(key, value) { + return Err(EntryCommitFailure { + committed: written_keys, + error, + failed_key: key.clone(), + not_attempted: entries + .iter() + .skip(index.saturating_add(1)) + .map(|(remaining_key, _)| remaining_key.clone()) + .collect(), + total: entries.len(), + }); } - pushed.push(key.clone()); + written_keys.push(key.clone()); } - Ok(pushed.len()) + Ok(written_keys.len()) +} + +/// Commit config-push entries and retain its chunk-aware retry guidance. +fn push_entries_with_committer( + entries: &[(String, String)], + committer: F, +) -> Result +where + F: FnMut(&str, &str) -> Result<(), String>, +{ + commit_entries_with_committer(entries, committer).map_err(|failure| { + format!( + "fastly push failed at entry `{failed_key}` while committing {committed} of {total} entries.\n \ + The failed entry's outcome is UNKNOWN: Fastly may have committed it before the error \ + (a timeout can arrive after the write lands), including when it is the root pointer.\n \ + Recovery: re-run the SAME `config push`. It is idempotent -- chunk keys are content-addressed \ + and writes use `--upsert` -- so entries already written are rewritten harmlessly and any \ + missing ones are filled. Do NOT hand-delete the failed key.\n \ + Already written (a retry rewrites them): {already_written:?}\n \ + Failed: `{failed_key}` (outcome unknown) -- {error}\n \ + Not attempted: {not_attempted:?}", + failed_key = failure.failed_key, + committed = failure.committed.len(), + total = failure.total, + already_written = failure.committed, + error = failure.error, + not_attempted = failure.not_attempted, + ) + }) +} + +/// Commit runtime store-name mappings with provision-specific recovery advice. +fn push_runtime_store_name_entries_with_committer( + entries: &[(String, String)], + committer: F, +) -> Result +where + F: FnMut(&str, &str) -> Result<(), String>, +{ + commit_entries_with_committer(entries, committer).map_err(|failure| { + format!( + "fastly provision failed while writing runtime store-name mapping `{failed_key}` after committing {committed} of {total} mappings.\n \ + The failed mapping's outcome is UNKNOWN: Fastly may have committed it before the error.\n \ + Recovery: re-run the SAME `edgezero provision --adapter fastly` command with the same \ + `EDGEZERO__STORES__*__NAME` environment. Mapping writes use `--upsert`, so mappings \ + already written are rewritten harmlessly and missing ones are filled.\n \ + Already written (a retry rewrites them): {already_written:?}\n \ + Failed: `{failed_key}` (outcome unknown) -- {error}\n \ + Not attempted: {not_attempted:?}", + failed_key = failure.failed_key, + committed = failure.committed.len(), + total = failure.total, + already_written = failure.committed, + error = failure.error, + not_attempted = failure.not_attempted, + ) + }) } /// Shell `fastly config-store-entry update --upsert --stdin` with @@ -3459,10 +3559,10 @@ fn delete_config_store_entry_in(store_id: &str, key: &str, cwd: &Path) -> Result /// Compute the staging selector store's entries from production's, given the /// declared config-store logical ids. /// -/// The twin is a faithful MIRROR of production's runtime overrides — adapter -/// host, logging level, `__NAME` redirects — with exactly one transform: every -/// declared config store's selector key (`EDGEZERO__STORES__CONFIG____KEY`) -/// points at `_staging`, the key `config push --staging` writes. A +/// The twin is a faithful mirror of this service's production runtime +/// overrides, with exactly one transform: every declared config store's +/// service-scoped selector points at +/// `_staging`, the key `config push --staging` writes. A /// declared store gets that selector even when production has no explicit entry /// for it (production relies on the runtime's default = the logical id; staging /// must NOT inherit that default, or it would read production's key). @@ -3470,82 +3570,30 @@ fn delete_config_store_entry_in(store_id: &str, key: &str, cwd: &Path) -> Result /// Pure so the transform is unit-testable without the fastly CLI. fn staging_entries_from_production( production: &[(String, String)], + service_id: &str, config_logical_ids: &[String], ) -> Vec<(String, String)> { - // selector key -> staging value, one per declared config store. + let service_prefix = service_scoped_runtime_env_key(service_id, "EDGEZERO__"); + // Scoped selector key -> staging value, one per declared config store. let selectors: Vec<(String, String)> = config_logical_ids .iter() - .map(|id| (runtime_env_key_for(id), format!("{id}_staging"))) + .map(|id| (runtime_env_key_for(service_id, id), format!("{id}_staging"))) .collect(); - let is_selector = |key: &str| selectors.iter().any(|(sel, _)| sel == key); + let is_selector = |key: &str| selectors.iter().any(|(selector, _)| selector == key); - // Copy every non-selector production override verbatim; selectors are - // supplied from `selectors` below (whether or not production carried one). + // Copy only current-service production overrides. Legacy unscoped entries + // have no safe owner, and another service's namespace does not belong in + // this per-service staging twin. Selectors are supplied below whether or + // not production carried one. let mut out: Vec<(String, String)> = production .iter() - .filter(|(key, _)| !is_selector(key)) + .filter(|(key, _)| key.starts_with(&service_prefix) && !is_selector(key)) .cloned() .collect(); out.extend(selectors); out } -fn is_runtime_store_name_key(key: &str) -> bool { - let mut segments = key.split("__"); - matches!( - ( - segments.next(), - segments.next(), - segments.next(), - segments.next(), - segments.next(), - segments.next(), - ), - ( - Some("EDGEZERO"), - Some("STORES"), - Some("CONFIG" | "KV" | "SECRETS"), - Some(id), - Some("NAME"), - None, - ) if !id.is_empty() - ) -} - -fn runtime_store_name_entries_from_vars( - vars: impl IntoIterator, -) -> Result, String> { - let mut entries = Vec::new(); - for (key, value) in vars { - if !is_runtime_store_name_key(&key) { - continue; - } - if value.is_empty() || value.trim() != value || value.chars().any(char::is_control) { - return Err(format!( - "runtime store-name override `{key}` must be non-empty and contain no surrounding whitespace or control characters" - )); - } - entries.push((key, value)); - } - entries.sort_by(|left, right| left.0.cmp(&right.0)); - Ok(entries) -} - -fn overlay_runtime_store_name_entries( - base: &[(String, String)], - overrides: &[(String, String)], -) -> Vec<(String, String)> { - let mut entries = base.to_vec(); - for (key, value) in overrides { - if let Some((_, current)) = entries.iter_mut().find(|(candidate, _)| candidate == key) { - current.clone_from(value); - } else { - entries.push((key.clone(), value.clone())); - } - } - entries -} - /// Resolve the staging twin store, creating it on demand. A staged deploy owns /// this store end to end (it is never linked on the ACTIVE version), so it does /// not depend on `provision` having created it first. Fails closed on a lookup @@ -3583,9 +3631,8 @@ fn ensure_staging_selector_store(store_name: &str, cwd: &Path) -> Result_staging`. +/// Reconcile the staging twin so it mirrors the current service's production +/// overrides, with only its config selectors redirected to `_staging`. /// /// Upserts the full desired set FIRST, then deletes twin entries production no /// longer has (so a removed override does not linger and diverge staging from @@ -3608,12 +3655,11 @@ fn ensure_staging_selector_store(store_name: &str, cwd: &Path) -> Result Result<(), String> { - let process_overrides = runtime_store_name_entries_from_vars(env::vars())?; - let effective_production = overlay_runtime_store_name_entries(production, &process_overrides); - let desired = staging_entries_from_production(&effective_production, config_logical_ids); + let desired = staging_entries_from_production(production, service_id, config_logical_ids); for (key, value) in &desired { create_config_store_entry_in(staging_id, key, value, cwd)?; @@ -3627,16 +3673,34 @@ fn mirror_production_to_staging( Ok(()) } -fn runtime_store_name_key(kind: &str, logical: &str) -> String { +fn canonical_runtime_store_name_key(kind: &str, logical: &str) -> String { format!( "EDGEZERO__STORES__{kind}__{}__NAME", logical.to_ascii_uppercase() ) } -/// Return the runtime entries required when logical store ids map to different -/// Fastly resource names. -fn runtime_env_store_name_entries(stores: &ProvisionStores<'_>) -> Vec<(String, String)> { +fn runtime_store_name_key(service_id: &str, kind: &str, logical: &str) -> String { + service_scoped_runtime_env_key(service_id, &canonical_runtime_store_name_key(kind, logical)) +} + +fn has_declared_stores(stores: &ProvisionStores<'_>) -> bool { + !stores.config.is_empty() || !stores.kv.is_empty() || !stores.secrets.is_empty() +} + +fn has_non_default_store_name_mappings(stores: &ProvisionStores<'_>) -> bool { + [stores.config, stores.kv, stores.secrets] + .into_iter() + .flatten() + .any(|store| store.logical != store.platform) +} + +/// Return the service-scoped runtime entries required when logical store ids +/// map to different Fastly resource names. +fn runtime_env_store_name_entries( + stores: &ProvisionStores<'_>, + service_id: &str, +) -> Vec<(String, String)> { let mut entries = Vec::new(); for (kind, ids) in [ ("CONFIG", stores.config), @@ -3648,7 +3712,7 @@ fn runtime_env_store_name_entries(stores: &ProvisionStores<'_>) -> Vec<(String, continue; } entries.push(( - runtime_store_name_key(kind, &store.logical), + runtime_store_name_key(service_id, kind, &store.logical), store.platform.clone(), )); } @@ -3656,7 +3720,7 @@ fn runtime_env_store_name_entries(stores: &ProvisionStores<'_>) -> Vec<(String, entries } -fn runtime_env_store_name_keys(stores: &ProvisionStores<'_>) -> Vec { +fn runtime_env_store_name_keys(stores: &ProvisionStores<'_>, service_id: &str) -> Vec { let mut keys = Vec::new(); for (kind, ids) in [ ("CONFIG", stores.config), @@ -3665,22 +3729,23 @@ fn runtime_env_store_name_keys(stores: &ProvisionStores<'_>) -> Vec { ] { keys.extend( ids.iter() - .map(|store| runtime_store_name_key(kind, &store.logical)), + .map(|store| runtime_store_name_key(service_id, kind, &store.logical)), ); } keys } -/// Compute the minimal changes needed for store-name mappings owned by the -/// logical ids this app currently declares. Entries for undeclared ids and -/// unrelated runtime settings are preserved because the production runtime-env -/// store can be linked by more than one service in the same Fastly account. +/// Compute the minimal changes needed for store-name mappings owned by this +/// Fastly service and the logical ids the app currently declares. Legacy +/// unscoped entries, other service namespaces, undeclared ids, and unrelated +/// runtime settings are preserved. fn runtime_store_name_reconciliation( stores: &ProvisionStores<'_>, + service_id: &str, current: &[(String, String)], ) -> RuntimeStoreNameReconciliation { - let desired = runtime_env_store_name_entries(stores); - let declared = runtime_env_store_name_keys(stores); + let desired = runtime_env_store_name_entries(stores, service_id); + let declared = runtime_env_store_name_keys(stores, service_id); let mut upserts = desired .iter() @@ -3708,14 +3773,26 @@ fn runtime_store_name_reconciliation( fn persist_runtime_env_store_name_entries( stores: &ProvisionStores<'_>, + service_id_hint: Option<&str>, dry_run: bool, cwd: &Path, ) -> Result, String> { - let entries = runtime_env_store_name_entries(stores); - let declared = runtime_env_store_name_keys(stores); - if declared.is_empty() { + if !has_declared_stores(stores) { return Ok(Vec::new()); } + let Some(service_id) = service_id_hint else { + if has_non_default_store_name_mappings(stores) { + return Err(format!( + "cannot persist non-default Fastly store-name mappings without top-level `service_id` or {FASTLY_SERVICE_ID_ENV}" + )); + } + return Ok(vec![ + "no Fastly service id and no non-default store-name mappings; skipping runtime-env reconciliation" + .to_owned(), + ]); + }; + let entries = runtime_env_store_name_entries(stores, service_id); + let declared = runtime_env_store_name_keys(stores, service_id); if dry_run { let mut out = entries .iter() @@ -3738,42 +3815,50 @@ fn persist_runtime_env_store_name_entries( return Ok(out); } - let runtime_env_store_id = resolve_remote_config_store_id_in(RUNTIME_ENV_STORE, cwd)? - .ok_or_else(|| no_matching_store_error(RUNTIME_ENV_STORE))?; + let Some(runtime_env_store_id) = resolve_remote_config_store_id_in(RUNTIME_ENV_STORE, cwd)? + else { + if entries.is_empty() { + return Ok(vec![format!( + "fastly config-store `{RUNTIME_ENV_STORE}` not found; no non-default store-name mappings to write for service `{service_id}`, skipping reconciliation" + )]); + } + return Err(format!( + "cannot write non-default store-name mappings for service `{service_id}`: fastly config-store `{RUNTIME_ENV_STORE}` does not exist remotely even though its setup block is declared. Create it with `fastly config-store create --name={RUNTIME_ENV_STORE}` (and link it to an existing service when needed), then re-run provision" + )); + }; let current = read_config_store_entries(&runtime_env_store_id, cwd)?; - let reconciliation = runtime_store_name_reconciliation(stores, ¤t); + let reconciliation = runtime_store_name_reconciliation(stores, service_id, ¤t); if reconciliation.upserts.is_empty() && reconciliation.deletes.is_empty() { return Ok(Vec::new()); } - push_entries_with_committer(&reconciliation.upserts, |key, value| { + push_runtime_store_name_entries_with_committer(&reconciliation.upserts, |key, value| { create_config_store_entry_in(&runtime_env_store_id, key, value, cwd) })?; for key in &reconciliation.deletes { delete_config_store_entry_in(&runtime_env_store_id, key, cwd)?; } Ok(vec![format!( - "reconciled store-name mappings in fastly config-store `{RUNTIME_ENV_STORE}`: upserted {}, removed {} stale mapping(s)", + "reconciled store-name mappings for service `{service_id}` in fastly config-store `{RUNTIME_ENV_STORE}`: upserted {}, removed {} stale mapping(s)", reconciliation.upserts.len(), reconciliation.deletes.len() )]) } -/// The runtime-override entry naming the config-store KEY for logical store -/// `id` — `EDGEZERO__STORES__CONFIG____KEY`. -/// -/// Must match what the runtime reads: `EnvConfig::from_vars` strips the -/// `EDGEZERO__` prefix, splits on `__`, and lowercases each segment, and -/// `store_key("config", id)` looks up `["stores", "config", id, "key"]`. So the -/// entry name is the id uppercased. A near-miss here is silent — the runtime -/// would just fall back to the id and read production config. -fn runtime_env_key_for(logical_id: &str) -> String { +fn canonical_runtime_env_key_for(logical_id: &str) -> String { format!( "EDGEZERO__STORES__CONFIG__{}__KEY", logical_id.to_ascii_uppercase() ) } +/// The service-scoped runtime-override entry naming the config-store key for a +/// logical store. The runtime converts this stored key back to canonical +/// `EDGEZERO__STORES__CONFIG____KEY` before building `EnvConfig`. +fn runtime_env_key_for(service_id: &str, logical_id: &str) -> String { + service_scoped_runtime_env_key(service_id, &canonical_runtime_env_key_for(logical_id)) +} + /// Find the id of the resource link published under `link_name` in /// `fastly resource-link list --json` output. /// @@ -4798,12 +4883,13 @@ fn curl_quote(value: &str) -> String { } /// Validate an operator-supplied Fastly service id before it is -/// interpolated into an API URL. Fastly service ids are opaque -/// alphanumeric handles; constrain to `^[A-Za-z0-9_-]+$` so a value -/// carrying a quote / newline / space (which could inject curl options -/// via the `--config` file) is rejected with a clear error. +/// interpolated into an API URL or runtime-env key. Fastly service ids are +/// opaque alphanumeric handles; constrain to `^[A-Za-z0-9_-]+$` and reserve +/// `__` as the runtime-env namespace delimiter. Values carrying a quote, +/// newline, or space could inject curl options via the `--config` file. fn validate_service_id(id: &str) -> Result<(), String> { if !id.is_empty() + && !id.contains("__") && id .chars() .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-') @@ -4811,7 +4897,7 @@ fn validate_service_id(id: &str) -> Result<(), String> { Ok(()) } else { Err(format!( - "invalid service id {id:?}: expected only ASCII letters, digits, `_`, or `-`" + "invalid service id {id:?}: expected only ASCII letters, digits, `_`, or `-`, with no `__` namespace delimiter" )) } } @@ -5101,8 +5187,7 @@ 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_in(RUNTIME_ENV_STORE_NAME, manifest_dir)? { + let production = match classify_remote_config_store_in(RUNTIME_ENV_STORE_NAME, manifest_dir)? { ConfigStoreLookup::Found(id) => read_config_store_entries(&id, manifest_dir)?, ConfigStoreLookup::NotFound => Vec::new(), ConfigStoreLookup::SchemaDrift(detail) => { @@ -5121,6 +5206,7 @@ fn relink_runtime_env_for_staging( mirror_production_to_staging( &production, &staging_store_id, + service_id, config_logical_ids, manifest_dir, )?; @@ -5464,9 +5550,11 @@ fn rollback(args: &[String]) -> Result<(), String> { mod tests { use super::*; use edgezero_adapter::cli_support::read_package_name; + use edgezero_core::app::{StoreMetadata, StoresMetadata}; + use edgezero_core::env_config::EnvConfig; #[cfg(unix)] use edgezero_core::test_env::{EnvOverride, PathPrepend}; - use std::collections::HashSet; + use std::collections::{BTreeMap, HashSet}; #[cfg(unix)] use std::sync::Mutex; @@ -5788,6 +5876,16 @@ mod tests { validate_service_id("abc_DEF-123").expect("underscore + dash handle"); } + #[test] + fn validate_service_id_rejects_runtime_env_namespace_delimiter() { + let err = validate_service_id("SVC__OTHER") + .expect_err("the runtime-env namespace delimiter must be unambiguous"); + assert!( + err.contains("namespace delimiter"), + "error explains the reserved delimiter: {err}" + ); + } + #[test] fn validate_service_id_rejects_injection_and_empty() { // The canonical attack: a service id that closes the url value @@ -6852,7 +6950,7 @@ build = \"cargo build --release\" fn provision_dry_run_does_not_invoke_fastly() { let dir = tempdir().expect("tempdir"); let path = dir.path().join("fastly.toml"); - fs::write(&path, "name = \"demo\"\n").expect("write"); + fs::write(&path, "name = \"demo\"\nservice_id = \"SVC1\"\n").expect("write"); let kv_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_KV_ID]); let config_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_CONFIG_ID]); let secret_ids: Vec = ResolvedStoreId::from_logicals(&[TEST_SECRET_ID]); @@ -6877,7 +6975,7 @@ build = \"cargo build --release\" ); assert!( out.iter() - .any(|row| row.contains("EDGEZERO__STORES__KV__SESSIONS__NAME")), + .any(|row| row.contains("EDGEZERO__SERVICES__SVC1__STORES__KV__SESSIONS__NAME")), "dry-run reports possible stale mapping cleanup: {out:?}", ); assert!( @@ -6887,14 +6985,17 @@ build = \"cargo build --release\" ); // Manifest untouched. let after = fs::read_to_string(&path).expect("read"); - assert_eq!(after, "name = \"demo\"\n", "dry-run mutated fastly.toml"); + assert_eq!( + after, "name = \"demo\"\nservice_id = \"SVC1\"\n", + "dry-run mutated fastly.toml" + ); } #[test] fn provision_dry_run_reports_non_default_store_name_mapping() { let dir = tempdir().expect("tempdir"); let path = dir.path().join("fastly.toml"); - fs::write(&path, "name = \"demo\"\n").expect("write"); + fs::write(&path, "name = \"demo\"\nservice_id = \"SVC1\"\n").expect("write"); let secret_ids = vec![ResolvedStoreId::new("default", "production_secrets")]; let stores = ProvisionStores { config: &[], @@ -6907,10 +7008,160 @@ build = \"cargo build --release\" .expect("dry-run succeeds"); assert!(out.iter().any(|line| { - line.contains("EDGEZERO__STORES__SECRETS__DEFAULT__NAME=production_secrets") + line.contains( + "EDGEZERO__SERVICES__SVC1__STORES__SECRETS__DEFAULT__NAME=production_secrets", + ) })); } + #[cfg(unix)] + #[test] + fn provision_non_default_mapping_requires_service_id_before_fastly_mutation() { + let _lock = path_mutation_guard().lock().expect("guard"); + let _service_id = EnvOverride::remove(FASTLY_SERVICE_ID_ENV); + let dir = tempdir().expect("tempdir"); + fs::write(dir.path().join("fastly.toml"), "name = \"demo\"\n").expect("write"); + let kv = vec![ResolvedStoreId::new("sessions", "production_sessions")]; + let stores = ProvisionStores { + config: &[], + kv: &kv, + secrets: &[], + }; + + let err = FastlyCliAdapter + .provision(dir.path(), Some("fastly.toml"), None, &stores, true) + .expect_err("a non-default mapping needs an unambiguous service namespace"); + + assert!( + err.contains("service_id"), + "error names the missing identity: {err}" + ); + assert!( + err.contains(FASTLY_SERVICE_ID_ENV), + "error gives the environment fallback: {err}" + ); + } + + #[cfg(unix)] + #[test] + fn provision_default_mappings_skip_an_absent_runtime_env_store() { + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write( + &path, + "[setup.kv_stores.sessions]\n\ + [setup.config_stores.edgezero_runtime_env]\n", + ) + .expect("write"); + let kv = vec![ResolvedStoreId::from_logical("sessions")]; + let stores = ProvisionStores { + config: &[], + kv: &kv, + secrets: &[], + }; + // This fake lists only `app_config`, so `edgezero_runtime_env` is + // genuinely absent remotely even though its setup block is committed. + let fake = fake_fastly_returning("", "", 0); + let _path = PathPrepend::new(fake.path()); + + let out = FastlyCliAdapter + .provision(dir.path(), Some("fastly.toml"), None, &stores, false) + .expect("default mappings need no remote runtime-env store"); + + assert!( + out.iter() + .any(|line| line.contains("no non-default store-name mappings")), + "provision explains why reconciliation was skipped: {out:?}" + ); + } + + #[cfg(unix)] + #[test] + fn provision_non_default_mapping_requires_a_runtime_env_store() { + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write( + &path, + "service_id = \"SVC1\"\n\ + [setup.kv_stores.production_sessions]\n\ + [setup.config_stores.edgezero_runtime_env]\n", + ) + .expect("write"); + let kv = vec![ResolvedStoreId::new("sessions", "production_sessions")]; + let stores = ProvisionStores { + config: &[], + kv: &kv, + secrets: &[], + }; + let fake = fake_fastly_returning("", "", 0); + let _path = PathPrepend::new(fake.path()); + + let err = FastlyCliAdapter + .provision(dir.path(), Some("fastly.toml"), None, &stores, false) + .expect_err("a required mapping cannot be written without the runtime-env store"); + + assert!( + err.contains("edgezero_runtime_env"), + "missing store is named: {err}" + ); + assert!( + !err.contains("did you run `edgezero provision"), + "provision must not recommend the command already running: {err}" + ); + assert!( + err.contains("fastly config-store create --name=edgezero_runtime_env"), + "missing-store recovery gives an actionable create command: {err}" + ); + } + + #[cfg(unix)] + #[test] + fn provision_creates_declared_store_in_fastly_manifest_directory() { + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + let adapter_dir = dir.path().join("adapters/fastly"); + fs::create_dir_all(&adapter_dir).expect("adapter dir"); + let path = adapter_dir.join("fastly.toml"); + fs::write(&path, "[setup.config_stores.edgezero_runtime_env]\n").expect("write"); + let kv = vec![ResolvedStoreId::from_logical("sessions")]; + let stores = ProvisionStores { + config: &[], + kv: &kv, + secrets: &[], + }; + let oplog = dir.path().join("oplog.txt"); + let fake = fake_fastly_runtime_mapping(&[], &oplog); + let _path = PathPrepend::new(fake.path()); + + FastlyCliAdapter + .provision( + dir.path(), + Some("adapters/fastly/fastly.toml"), + None, + &stores, + false, + ) + .expect("provision succeeds"); + + let log = fs::read_to_string(&oplog).expect("oplog"); + let manifest_dir = fs::canonicalize(&adapter_dir).expect("canonical manifest dir"); + assert!( + log.contains(&format!( + "kv-store-create name=--name=sessions cwd={}", + manifest_dir.display() + )), + "declared store creation uses the Fastly manifest directory: {log}" + ); + assert!( + fs::read_to_string(path) + .expect("manifest") + .contains("[setup.kv_stores.sessions]"), + "declared store setup block is written" + ); + } + #[cfg(unix)] #[test] fn provision_reconciles_runtime_store_name_mappings() { @@ -6919,7 +7170,8 @@ build = \"cargo build --release\" let path = dir.path().join("fastly.toml"); fs::write( &path, - "[setup.kv_stores.production_sessions]\n\ + "service_id = \"SVC_A\"\n\ + [setup.kv_stores.production_sessions]\n\ [setup.secret_stores.default]\n", ) .expect("write"); @@ -6932,16 +7184,20 @@ build = \"cargo build --release\" }; let current = vec![ ( - "EDGEZERO__STORES__KV__SESSIONS__NAME".to_owned(), + "EDGEZERO__SERVICES__SVC_A__STORES__KV__SESSIONS__NAME".to_owned(), "old_sessions".to_owned(), ), ( - "EDGEZERO__STORES__SECRETS__DEFAULT__NAME".to_owned(), + "EDGEZERO__SERVICES__SVC_A__STORES__SECRETS__DEFAULT__NAME".to_owned(), "old_secrets".to_owned(), ), ( - "EDGEZERO__STORES__KV__OTHER__NAME".to_owned(), - "other_service".to_owned(), + "EDGEZERO__SERVICES__SVC_B__STORES__KV__SESSIONS__NAME".to_owned(), + "service_b_sessions".to_owned(), + ), + ( + "EDGEZERO__STORES__KV__SESSIONS__NAME".to_owned(), + "legacy_sessions".to_owned(), ), ("EDGEZERO__LOGGING__LEVEL".to_owned(), "debug".to_owned()), ]; @@ -6965,22 +7221,23 @@ build = \"cargo build --release\" ); assert!( log.contains(&format!( - "update EDGEZERO__STORES__KV__SESSIONS__NAME=production_sessions cwd={}", + "update EDGEZERO__SERVICES__SVC_A__STORES__KV__SESSIONS__NAME=production_sessions cwd={}", manifest_dir.display() )), "changed non-default mapping is upserted in the manifest directory: {log}" ); assert!( log.contains(&format!( - "delete EDGEZERO__STORES__SECRETS__DEFAULT__NAME cwd={}", + "delete EDGEZERO__SERVICES__SVC_A__STORES__SECRETS__DEFAULT__NAME cwd={}", manifest_dir.display() )), "stale mapping is removed in the manifest directory: {log}" ); assert!( - !log.contains("delete EDGEZERO__STORES__KV__OTHER__NAME") + !log.contains("delete EDGEZERO__SERVICES__SVC_B__STORES__KV__SESSIONS__NAME") + && !log.contains("delete EDGEZERO__STORES__KV__SESSIONS__NAME") && !log.contains("EDGEZERO__LOGGING__LEVEL="), - "unrelated runtime entries are preserved: {log}" + "other services, legacy mappings, and unrelated runtime entries are preserved: {log}" ); assert!( out.iter() @@ -6989,6 +7246,51 @@ build = \"cargo build --release\" ); } + #[cfg(unix)] + #[test] + fn provision_mapping_failure_recommends_provision_recovery() { + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write( + &path, + "service_id = \"SVC1\"\n\ + [setup.kv_stores.production_sessions]\n\ + [setup.config_stores.edgezero_runtime_env]\n", + ) + .expect("write"); + let kv = vec![ResolvedStoreId::new("sessions", "production_sessions")]; + let stores = ProvisionStores { + config: &[], + kv: &kv, + secrets: &[], + }; + let oplog = dir.path().join("oplog.txt"); + let fake = fake_fastly_runtime_mapping_with_update_exit(&[], &oplog, 1); + let _path = PathPrepend::new(fake.path()); + + let err = FastlyCliAdapter + .provision(dir.path(), Some("fastly.toml"), None, &stores, false) + .expect_err("mapping update fails"); + + assert!( + err.contains("UNKNOWN"), + "failed write outcome is explicit: {err}" + ); + assert!( + err.contains("edgezero provision --adapter fastly"), + "recovery names the command to retry: {err}" + ); + assert!( + !err.contains("config push"), + "wrong command is not recommended: {err}" + ); + assert!( + !err.contains("chunk") && !err.contains("root pointer"), + "mapping recovery contains no blob-specific guidance: {err}" + ); + } + #[test] fn provision_errors_when_adapter_manifest_path_missing() { let dir = tempdir().expect("tempdir"); @@ -7040,7 +7342,8 @@ build = \"cargo build --release\" let path = dir.path().join("fastly.toml"); fs::write( &path, - "[setup.kv_stores.sessions]\n[local_server.kv_stores.sessions]\n\ + "service_id = \"SVC1\"\n\ + [setup.kv_stores.sessions]\n[local_server.kv_stores.sessions]\n\ [setup.config_stores.edgezero_runtime_env]\n", ) .expect("write"); @@ -7067,6 +7370,31 @@ build = \"cargo build --release\" ); } + #[cfg(unix)] + #[test] + fn provision_service_namespace_uses_env_and_rejects_manifest_mismatch() { + let _lock = path_mutation_guard().lock().expect("guard"); + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("fastly.toml"); + fs::write(&path, "name = \"demo\"\n").expect("write"); + let _service_id = EnvOverride::set(FASTLY_SERVICE_ID_ENV, "SVC_ENV"); + + assert_eq!( + provision_runtime_env_service_id(&path).expect("env fallback"), + Some("SVC_ENV".to_owned()) + ); + + fs::write(&path, "name = \"demo\"\nservice_id = \"SVC_MANIFEST\"\n") + .expect("write manifest service id"); + let err = provision_runtime_env_service_id(&path) + .expect_err("two target service ids must not select different namespaces"); + assert!(err.contains("mismatch"), "mismatch is explicit: {err}"); + assert!( + err.contains("SVC_MANIFEST") && err.contains("SVC_ENV"), + "both conflicting ids are named: {err}" + ); + } + /// When `fastly.toml` declares `service_id`, the next /// `fastly compute deploy` skips `[setup]` entirely. provision /// must emit the `fastly resource-link create` remediation for @@ -7090,7 +7418,7 @@ build = \"cargo build --release\" .expect("read service_id") .expect("note present when service_id set"); assert!( - note.contains("service_id = \"abc123svc\""), + note.contains("service id resolves to `abc123svc`"), "note quotes the service id: {note}" ); assert!( @@ -7583,6 +7911,15 @@ build = \"cargo build --release\" fn fake_fastly_runtime_mapping( current: &[(String, String)], oplog: &Path, + ) -> tempfile::TempDir { + fake_fastly_runtime_mapping_with_update_exit(current, oplog, 0) + } + + #[cfg(unix)] + fn fake_fastly_runtime_mapping_with_update_exit( + current: &[(String, String)], + oplog: &Path, + update_exit: i32, ) -> tempfile::TempDir { use std::os::unix::fs::PermissionsExt as _; @@ -7612,12 +7949,13 @@ build = \"cargo build --release\" let script = format!( r#"#!/bin/sh if [ "$1" = "config-store" ] && [ "$2" = "create" ]; then printf 'store-create cwd=%s\n' "$PWD" >> '{oplog}'; exit 0; fi +if [ "$1" = "kv-store" ] && [ "$2" = "create" ]; then printf 'kv-store-create name=%s cwd=%s\n' "$3" "$PWD" >> '{oplog}'; exit 0; fi if [ "$1" = "config-store" ]; then printf 'store-list cwd=%s\n' "$PWD" >> '{oplog}'; cat '{stores}'; exit 0; fi sub="$2" key="" for arg in "$@"; do case "$arg" in --key=*) key="${{arg#--key=}}";; esac; done if [ "$sub" = "list" ]; then printf 'list cwd=%s\n' "$PWD" >> '{oplog}'; cat '{entries}'; exit 0; fi -if [ "$sub" = "update" ]; then value=$(cat); printf 'update %s=%s cwd=%s\n' "$key" "$value" "$PWD" >> '{oplog}'; exit 0; fi +if [ "$sub" = "update" ]; then value=$(cat); printf 'update %s=%s cwd=%s\n' "$key" "$value" "$PWD" >> '{oplog}'; exit {update_exit}; fi if [ "$sub" = "delete" ]; then printf 'delete %s cwd=%s\n' "$key" "$PWD" >> '{oplog}'; exit 0; fi echo 'unexpected fastly invocation' >&2 exit 1 @@ -9305,7 +9643,7 @@ echo 'unexpected' >&2; exit 1 printf '%s\\n' '[{{\"id\":\"ENVSEL1\",\"name\":\"edgezero_runtime_env\"}},{{\"id\":\"STAGEID1\",\"name\":\"edgezero_runtime_env_staging_SVC1\"}}]'\n\ elif [ \"$1\" = \"config-store-entry\" ] && [ \"$2\" = \"list\" ]; then\n \ case \"$*\" in\n \ - *--store-id=ENVSEL1*) printf '%s\\n' '[{{\"item_key\":\"EDGEZERO__ADAPTER__FASTLY__LOG_LEVEL\",\"item_value\":\"debug\"}}]' ;;\n \ + *--store-id=ENVSEL1*) printf '%s\\n' '[{{\"item_key\":\"EDGEZERO__SERVICES__SVC1__LOGGING__LEVEL\",\"item_value\":\"debug\"}}]' ;;\n \ *) printf '%s\\n' '[]' ;;\n \ esac\n\ elif [ \"$1\" = \"resource-link\" ] && [ \"$2\" = \"list\" ]; then\n \ @@ -9327,6 +9665,15 @@ echo 'unexpected' >&2; exit 1 fn run_deploy_staged_with_fake( update_stdout: &str, extra: &[&str], + ) -> (Result<(), String>, Vec) { + run_deploy_staged_with_fake_and_env(update_stdout, extra, None) + } + + #[cfg(unix)] + fn run_deploy_staged_with_fake_and_env( + update_stdout: &str, + extra: &[&str], + store_name_override: Option<(&str, &str)>, ) -> (Result<(), String>, Vec) { let _lock = path_mutation_guard().lock().expect("guard"); let (fake, record) = fake_fastly_recorder(update_stdout); @@ -9335,10 +9682,11 @@ echo 'unexpected' >&2; exit 1 let manifest = app.path().join("fastly.toml"); fs::write(&manifest, "name = \"app\"\n").expect("write fastly.toml"); - // RAII: set the token for the call, restore it on drop. Uses the shared - // guard (edition-2024 wraps the env mutation's `unsafe` and holds the - // lock we already took above). + // RAII: set the variables for the call, then restore them on drop. The + // shared guard serializes every process-environment mutation in tests. let _token = EnvOverride::set(FASTLY_API_TOKEN_ENV, "test-token"); + let _store_name_override = + store_name_override.map(|(key, value)| EnvOverride::set(key, value)); let mut args = vec![ "--service-id".to_owned(), "SVC1".to_owned(), @@ -10635,9 +10983,7 @@ echo 'unexpected' >&2; exit 1 } #[test] - fn runtime_env_store_name_entries_include_only_non_default_mappings() { - use edgezero_core::env_config::EnvConfig; - + fn runtime_env_store_name_entries_include_only_non_default_scoped_mappings() { let config = vec![ResolvedStoreId::from_logical("app_config")]; let kv = vec![ResolvedStoreId::new("sessions", "production_sessions")]; let secrets = vec![ResolvedStoreId::new("default", "production_secrets")]; @@ -10647,186 +10993,153 @@ echo 'unexpected' >&2; exit 1 secrets: &secrets, }; - let entries = runtime_env_store_name_entries(&stores); + let entries = runtime_env_store_name_entries(&stores, "SVC_A"); assert_eq!( entries, vec![ ( - "EDGEZERO__STORES__KV__SESSIONS__NAME".to_owned(), + "EDGEZERO__SERVICES__SVC_A__STORES__KV__SESSIONS__NAME".to_owned(), "production_sessions".to_owned(), ), ( - "EDGEZERO__STORES__SECRETS__DEFAULT__NAME".to_owned(), + "EDGEZERO__SERVICES__SVC_A__STORES__SECRETS__DEFAULT__NAME".to_owned(), "production_secrets".to_owned(), ), ] ); + } - let env = EnvConfig::from_vars(entries); + #[test] + fn runtime_dictionary_uses_only_the_current_service_namespace() { + let stores = StoresMetadata { + config: Some(StoreMetadata { + default: "app_config", + ids: &["app_config"], + }), + kv: Some(StoreMetadata { + default: "sessions", + ids: &["sessions"], + }), + secrets: None, + }; + let scoped_sessions = + service_scoped_runtime_env_key("SVC_A", "EDGEZERO__STORES__KV__SESSIONS__NAME"); + let values = BTreeMap::from([ + (scoped_sessions.clone(), "service_a_sessions".to_owned()), + ( + service_scoped_runtime_env_key("SVC_B", "EDGEZERO__STORES__KV__SESSIONS__NAME"), + "service_b_sessions".to_owned(), + ), + ( + "EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME".to_owned(), + "legacy_config".to_owned(), + ), + ( + "EDGEZERO__STORES__KV__SESSIONS__NAME".to_owned(), + "legacy_sessions".to_owned(), + ), + ]); + + assert_eq!( + scoped_sessions, + "EDGEZERO__SERVICES__SVC_A__STORES__KV__SESSIONS__NAME" + ); + let vars = + crate::runtime_env_vars_for_service(stores, "SVC_A", |key| values.get(key).cloned()); + let env = EnvConfig::from_vars(vars); + + assert_eq!(env.store_name("kv", "sessions"), "service_a_sessions"); assert_eq!(env.store_name("config", "app_config"), "app_config"); - assert_eq!(env.store_name("kv", "sessions"), "production_sessions"); - assert_eq!(env.store_name("secrets", "default"), "production_secrets"); + assert_ne!(env.store_name("kv", "sessions"), "service_b_sessions"); + + let default_service_vars = + crate::runtime_env_vars_for_service(stores, "SVC_DEFAULT", |key| { + values.get(key).cloned() + }); + let default_service_env = EnvConfig::from_vars(default_service_vars); + assert_eq!(default_service_env.store_name("kv", "sessions"), "sessions"); + assert_eq!( + default_service_env.store_name("config", "app_config"), + "app_config" + ); } #[test] - fn runtime_env_key_matches_what_the_runtime_reads() { - use edgezero_core::env_config::EnvConfig; - - // EnvConfig::from_vars strips `EDGEZERO__`, splits on `__`, lowercases; - // store_key("config", id) looks up ["stores","config",id,"key"]. So the - // entry name is the id uppercased. A near-miss is SILENT: the runtime - // would fall back to the id and read production config. + fn runtime_env_key_is_scoped_for_the_runtime_reader() { assert_eq!( - runtime_env_key_for("app_config"), + canonical_runtime_env_key_for("app_config"), "EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY" ); - - // Prove it against the real reader rather than restating the format. - let cfg = EnvConfig::from_vars([( - runtime_env_key_for("app_config"), - "app_config_staging".to_owned(), - )]); assert_eq!( - cfg.store_key("config", "app_config"), - "app_config_staging", - "the entry provision writes must be the one the runtime reads" + runtime_env_key_for("SVC_A", "app_config"), + "EDGEZERO__SERVICES__SVC_A__STORES__CONFIG__APP_CONFIG__KEY" ); } #[test] - fn staging_entries_from_production_mirrors_and_overrides() { - // Production carries a non-config override, an explicit config selector, - // and a __NAME redirect. The twin must copy the non-config entries - // verbatim and redirect EVERY declared config store to `_staging` - // — including one production has no explicit entry for (it relies on the - // runtime default; the twin must NOT inherit that default). + fn staging_entries_from_production_mirrors_only_current_service_entries() { + // Production carries an unscoped legacy override, this service's + // explicit selector and name mapping, and another service's mapping. + // The per-service twin keeps only current-service values, replacing + // every declared selector with its scoped staging value. let production = vec![ ( "EDGEZERO__ADAPTER__FASTLY__LOG_LEVEL".to_owned(), "debug".to_owned(), ), ( - "EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY".to_owned(), + "EDGEZERO__SERVICES__SVC1__STORES__CONFIG__APP_CONFIG__KEY".to_owned(), "custom_prod_key".to_owned(), ), ( - "EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME".to_owned(), + "EDGEZERO__SERVICES__SVC1__STORES__CONFIG__APP_CONFIG__NAME".to_owned(), "app_config".to_owned(), ), + ( + "EDGEZERO__SERVICES__SVC2__STORES__SECRETS__DEFAULT__NAME".to_owned(), + "other_service_secrets".to_owned(), + ), ]; let out = staging_entries_from_production( &production, + "SVC1", &["app_config".to_owned(), "feature_flags".to_owned()], ); - // Non-selector overrides copied verbatim. - assert!(out.contains(&( - "EDGEZERO__ADAPTER__FASTLY__LOG_LEVEL".to_owned(), - "debug".to_owned() - ))); + assert!( + !out.iter() + .any(|(key, _)| key == "EDGEZERO__ADAPTER__FASTLY__LOG_LEVEL"), + "legacy unscoped entries are not part of a service-owned twin: {out:?}" + ); assert!(out.contains(&( - "EDGEZERO__STORES__CONFIG__APP_CONFIG__NAME".to_owned(), + "EDGEZERO__SERVICES__SVC1__STORES__CONFIG__APP_CONFIG__NAME".to_owned(), "app_config".to_owned() ))); - // The selector production HAD is overridden to `_staging`, NOT - // production's custom value. assert!(out.contains(&( - "EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY".to_owned(), + "EDGEZERO__SERVICES__SVC1__STORES__CONFIG__APP_CONFIG__KEY".to_owned(), "app_config_staging".to_owned() ))); assert!(!out.iter().any(|(_, value)| value == "custom_prod_key")); - // The declared store production LACKED a selector for still gets one. assert!(out.contains(&( - "EDGEZERO__STORES__CONFIG__FEATURE_FLAGS__KEY".to_owned(), + "EDGEZERO__SERVICES__SVC1__STORES__CONFIG__FEATURE_FLAGS__KEY".to_owned(), "feature_flags_staging".to_owned() ))); - // Exactly one entry per selector key (no duplicate from the copy path). + assert!( + !out.iter().any(|(key, value)| { + key.contains("__SVC2__") || value == "other_service_secrets" + }), + "another service's scoped entries must not enter this twin: {out:?}" + ); assert_eq!( out.iter() - .filter(|(key, _)| key == "EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY") + .filter(|(key, _)| { + key == "EDGEZERO__SERVICES__SVC1__STORES__CONFIG__APP_CONFIG__KEY" + }) .count(), 1 ); } - #[test] - fn runtime_store_name_entries_from_vars_filters_and_validates() { - let entries = runtime_store_name_entries_from_vars([ - ( - "EDGEZERO__STORES__SECRETS__DEFAULT__NAME".to_owned(), - "physical_secrets".to_owned(), - ), - ( - "EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY".to_owned(), - "ignored_selector".to_owned(), - ), - ( - "EDGEZERO__STORES__KV__A__B__NAME".to_owned(), - "ignored_nested_id".to_owned(), - ), - ( - "EDGEZERO__STORES__KV__A__NAME__EXTRA".to_owned(), - "ignored_extra_segment".to_owned(), - ), - ( - "EDGEZERO__STORES__kv__A__NAME".to_owned(), - "ignored_lowercase_kind".to_owned(), - ), - ("UNRELATED".to_owned(), "ignored".to_owned()), - ]) - .expect("valid store-name override"); - - assert_eq!( - entries, - vec![( - "EDGEZERO__STORES__SECRETS__DEFAULT__NAME".to_owned(), - "physical_secrets".to_owned(), - )] - ); - for invalid in [ - String::new(), - "prod\nsecrets".to_owned(), - "prod\0secrets".to_owned(), - ] { - assert!( - runtime_store_name_entries_from_vars([( - "EDGEZERO__STORES__SECRETS__DEFAULT__NAME".to_owned(), - invalid, - )]) - .is_err(), - "an invalid mapped resource name must fail closed" - ); - } - } - - #[test] - fn process_store_name_overrides_win_before_staging_mirror() { - let production = vec![ - ( - "EDGEZERO__STORES__SECRETS__DEFAULT__NAME".to_owned(), - "old_secrets".to_owned(), - ), - ("EDGEZERO__LOGGING__LEVEL".to_owned(), "info".to_owned()), - ]; - let overrides = vec![( - "EDGEZERO__STORES__SECRETS__DEFAULT__NAME".to_owned(), - "new_secrets".to_owned(), - )]; - - let effective = overlay_runtime_store_name_entries(&production, &overrides); - let staging = staging_entries_from_production(&effective, &["app_config".to_owned()]); - - assert!(staging.contains(&( - "EDGEZERO__STORES__SECRETS__DEFAULT__NAME".to_owned(), - "new_secrets".to_owned(), - ))); - assert!(!staging.iter().any(|(_, value)| value == "old_secrets")); - assert!(staging.contains(&( - "EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY".to_owned(), - "app_config_staging".to_owned(), - ))); - } - #[test] fn find_resource_link_id_matches_on_link_name_not_resource_name() { // The link's `name` is an alias defaulting to the resource's name. The @@ -10851,6 +11164,28 @@ echo 'unexpected' >&2; exit 1 assert_eq!(find_resource_link_id("not json", "x"), None); } + #[cfg(unix)] + #[test] + fn deploy_staged_ignores_ambient_store_name_overrides() { + let (result, argv) = run_deploy_staged_with_fake_and_env( + "SUCCESS: Updated package (service SVC1, version 7)", + &["--edgezero-staging-config=app_config"], + Some(( + "EDGEZERO__STORES__SECRETS__DEFAULT__NAME", + "ambient_secrets", + )), + ); + result.expect("staged deploy succeeds"); + + assert!( + !argv.iter().any(|line| { + line.contains("EDGEZERO__STORES__SECRETS__DEFAULT__NAME") + || line.contains("ambient_secrets") + }), + "staging must mirror persisted production mappings, not ambient process env: {argv:?}" + ); + } + #[cfg(unix)] #[test] fn deploy_staged_points_the_draft_at_the_staging_selector_store() { @@ -10870,13 +11205,13 @@ echo 'unexpected' >&2; exit 1 // `app_config_staging` via stdin) into the staging store. assert!( argv.iter().any(|line| line.starts_with( - "config-store-entry update --store-id=STAGEID1 --key=EDGEZERO__ADAPTER__FASTLY__LOG_LEVEL" + "config-store-entry update --store-id=STAGEID1 --key=EDGEZERO__SERVICES__SVC1__LOGGING__LEVEL" )), "production's non-config override must be mirrored into the twin: {argv:?}" ); assert!( argv.iter().any(|line| line.starts_with( - "config-store-entry update --store-id=STAGEID1 --key=EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY" + "config-store-entry update --store-id=STAGEID1 --key=EDGEZERO__SERVICES__SVC1__STORES__CONFIG__APP_CONFIG__KEY" )), "the config selector must be written into the twin: {argv:?}" ); @@ -11085,7 +11420,7 @@ echo 'unexpected' >&2; exit 1 let argv = fs::read_to_string(&record).unwrap_or_default(); assert!( argv.lines().any(|line| line.starts_with( - "config-store-entry update --store-id=STAGEID1 --key=EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY" + "config-store-entry update --store-id=STAGEID1 --key=EDGEZERO__SERVICES__SVC1__STORES__CONFIG__APP_CONFIG__KEY" )), "the staging selector must be written even with no production store: {argv}" ); diff --git a/crates/edgezero-adapter-fastly/src/lib.rs b/crates/edgezero-adapter-fastly/src/lib.rs index daec2ca0..36161a35 100644 --- a/crates/edgezero-adapter-fastly/src/lib.rs +++ b/crates/edgezero-adapter-fastly/src/lib.rs @@ -34,6 +34,11 @@ use edgezero_core::env_config::EnvConfig; use edgezero_core::http::Extensions; #[cfg(any(feature = "fastly", test))] use edgezero_core::manifest::ResolvedLoggingConfig; +#[cfg(feature = "fastly")] +use fastly::compute_runtime::service_id; + +#[cfg(any(feature = "cli", feature = "fastly", test))] +const RUNTIME_ENV_PREFIX: &str = "EDGEZERO__"; /// Name of the Fastly Config Store the runtime opens for `EDGEZERO__*` /// overrides. @@ -99,6 +104,19 @@ impl From<&EnvConfig> for FastlyLogging { } } +/// Prefix a canonical `EDGEZERO__*` key with its owning Fastly service. +/// +/// The shared `edgezero_runtime_env` Config Store is account-wide. Service +/// scoping prevents two linked services that declare the same logical store id +/// from overwriting one another's runtime mappings. +#[cfg(any(feature = "cli", feature = "fastly", test))] +fn service_scoped_runtime_env_key(service_id: &str, canonical_key: &str) -> String { + let suffix = canonical_key + .strip_prefix(RUNTIME_ENV_PREFIX) + .unwrap_or(canonical_key); + format!("{RUNTIME_ENV_PREFIX}SERVICES__{service_id}__{suffix}") +} + /// # Errors /// Returns [`logger::InitLoggerError::Build`] if the underlying logger /// builder rejects its inputs (e.g. an empty endpoint), or @@ -173,49 +191,26 @@ where /// Fastly Config Store. /// /// 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`. +/// come from the Config Store. The function reads a fixed allowlist: adapter +/// host and port, logging settings, `__NAME` entries for declared stores, and +/// `__KEY` entries for declared config stores. /// -/// If the store is missing or empty, returns an empty `EnvConfig` and the rest -/// of the runtime uses its baked-in defaults. +/// Each lookup uses the current Fastly service's +/// `EDGEZERO__SERVICES____*` key. Legacy unscoped entries are not +/// read because they have no safe owner when this Config Store is linked to more +/// than one service. The returned [`EnvConfig`] contains canonical unscoped keys. /// /// [`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. +/// [`FastlyService`](request::FastlyService). A custom entry point on either path +/// must call this explicitly. /// /// 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::{FastlyLogging, init_logger, runtime_env_config}; -/// use edgezero_adapter_fastly::request::dispatch_with_registries; -/// use edgezero_core::app::{StoreMetadata, StoresMetadata}; +/// [`Hooks`] impl inherits the empty [`StoresMetadata::default`] and must +/// override `stores()` or pass explicit metadata here. /// -/// let stores = StoresMetadata { -/// config: Some(StoreMetadata { -/// default: "app_config", -/// ids: &["app_config"], -/// }), -/// ..StoresMetadata::default() -/// }; -/// let env = runtime_env_config(stores); -/// let logging = FastlyLogging::from(&env); -/// if logging.use_fastly_logger { -/// let endpoint = logging.endpoint.as_deref().unwrap_or("stdout"); -/// init_logger(endpoint, logging.level, logging.echo_stdout)?; -/// } -/// let app = MyApp::build_app(); -/// let _response = -/// dispatch_with_registries(&app, req, stores, &env, |_req, _extensions| {})?; -/// ``` +/// If the store cannot be opened, the function logs a warning and returns an +/// empty [`EnvConfig`]. Callers then use their baked-in adapter and store defaults. #[cfg(feature = "fastly")] #[must_use] #[inline] @@ -239,22 +234,33 @@ pub fn runtime_env_config(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))); + let current_service_id = service_id(); + let vars = runtime_env_vars_for_service(stores, current_service_id, |key| dict.get(key)); EnvConfig::from_vars(vars) } +#[cfg(any(feature = "fastly", test))] +fn runtime_env_vars_for_service( + stores: StoresMetadata, + service_id: &str, + mut get: F, +) -> Vec<(String, String)> +where + F: FnMut(&str) -> Option, +{ + runtime_env_keys(stores) + .into_iter() + .filter_map(|canonical_key| { + let scoped_key = service_scoped_runtime_env_key(service_id, &canonical_key); + get(&scoped_key).map(|value| (canonical_key, value)) + }) + .collect() +} + /// 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`. +// The `test` arm keeps the key derivation tests in default workspace tests. #[cfg(any(feature = "fastly", test))] fn runtime_env_keys(stores: StoresMetadata) -> Vec { let mut keys: Vec = vec![ diff --git a/crates/edgezero-cli/src/config.rs b/crates/edgezero-cli/src/config.rs index e1cc0a06..97a16f7c 100644 --- a/crates/edgezero-cli/src/config.rs +++ b/crates/edgezero-cli/src/config.rs @@ -2876,6 +2876,27 @@ other = "x" ); } + #[test] + fn collect_secret_leaves_rejects_scalar_parent_of_optional_intermediate() { + let raw: Value = toml::from_str("integrations = \"not-a-table\"\n").expect("toml"); + let field = SecretField { + kind: SecretKind::KeyInDefault, + path: vec![ + SecretPathSegment::Field(Cow::Borrowed("integrations")), + SecretPathSegment::OptionalField(Cow::Borrowed("datadome")), + SecretPathSegment::Field(Cow::Borrowed("webhook_key")), + ], + optional: true, + }; + + let err = collect_secret_leaves(&raw, &field) + .expect_err("a present optional intermediate must have a table parent"); + assert!( + err.contains("integrations"), + "collector error names the malformed parent: {err}" + ); + } + #[test] fn collect_secret_leaves_errors_on_missing_required_intermediate() { let raw: Value = toml::from_str("other = \"x\"\n").expect("toml"); diff --git a/crates/edgezero-core/src/extractor.rs b/crates/edgezero-core/src/extractor.rs index 22205682..e3fa8385 100644 --- a/crates/edgezero-core/src/extractor.rs +++ b/crates/edgezero-core/src/extractor.rs @@ -1028,8 +1028,14 @@ fn resolve_secret_field<'walk>( } } Some((SecretPathSegment::OptionalField(name), rest)) => { + let Some(parent) = node.as_object_mut() else { + return Err(EdgeError::config_out_of_date( + format!("expected an object at `{rendered}`"), + rendered, + )); + }; let next_rendered = join_field(&rendered, name.as_ref()); - match node.get_mut(name.as_ref()) { + match parent.get_mut(name.as_ref()) { None | Some(serde_json::Value::Null) => Ok(()), Some(child) => { resolve_secret_field(ctx, child, field, rest, next_rendered).await @@ -1075,9 +1081,9 @@ async fn resolve_leaf( rendered_parent: &str, ) -> Result<(), EdgeError> { // `StoreRef` is filtered out in `secret_walk` before any descent, so it - // never reaches here. The leaf's parent is a required intermediate, so a - // non-object parent is always an error — only the leaf key below honors - // `field.optional`. + // never reaches here. Traversal handles optional intermediates; once a leaf + // is reached, its present parent must be an object and only the leaf key + // below honors `field.optional`. let leaf_path = join_field(rendered_parent, key); let Some(parent_obj) = parent.as_object_mut() else { @@ -2894,6 +2900,24 @@ mod tests { .expect("null optional intermediate is fine"); } + #[test] + fn secret_walk_rejects_scalar_parent_of_optional_intermediate() { + let ctx = ctx_with_default_secret_store("unused", "unused"); + let mut data = serde_json::json!({ "integrations": "not-an-object" }); + let err = block_on(secret_walk::(&ctx, &mut data)) + .expect_err("a present optional intermediate must have an object parent"); + + assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE); + assert!( + err.to_string().contains("integrations"), + "error names the malformed parent: {err}" + ); + let EdgeError::ConfigOutOfDate { field_path, .. } = &err else { + panic!("malformed optional parent must be ConfigOutOfDate: {err:?}"); + }; + assert_eq!(field_path, "integrations"); + } + #[test] fn secret_walk_present_intermediate_absent_optional_leaf_is_ok() { let ctx = ctx_with_default_secret_store("unused", "unused"); diff --git a/docs/guide/adapters/fastly.md b/docs/guide/adapters/fastly.md index f8d25739..75d98291 100644 --- a/docs/guide/adapters/fastly.md +++ b/docs/guide/adapters/fastly.md @@ -190,6 +190,24 @@ Fastly uses a native Config Store resource link for runtime configuration. Decla ids in `edgezero.toml`; each id opens its own platform store via `EDGEZERO__STORES__CONFIG____NAME` (default = the logical id): +Because `edgezero_runtime_env` is an account-wide Fastly resource, its stored +keys are scoped by the current service ID: + +```text +EDGEZERO__SERVICES____STORES__CONFIG____NAME +EDGEZERO__SERVICES____STORES__CONFIG____KEY +``` + +The runtime obtains `` from Fastly and translates these entries back +to the portable `EDGEZERO__STORES__*` form. Legacy unscoped entries are ignored +because they have no safe owner when the Config Store is linked to multiple +services. Re-run `edgezero provision --adapter fastly` to write scoped `__NAME` +entries, and rewrite any manually managed adapter, logging, or `__KEY` entries +under the service prefix. Provision writes only the selected service's +namespace; a non-default store-name mapping therefore requires top-level +`service_id` in `fastly.toml` or `FASTLY_SERVICE_ID`. If both are set, they must +match. + ```toml [stores.config] ids = ["app_config"] diff --git a/docs/guide/blob-app-config-migration.md b/docs/guide/blob-app-config-migration.md index f83af3ae..73b835ec 100644 --- a/docs/guide/blob-app-config-migration.md +++ b/docs/guide/blob-app-config-migration.md @@ -246,14 +246,21 @@ provisioning: # Look up the platform store id (matches by name). fastly config-store list --json | jq -r '.[] | select(.name=="edgezero_runtime_env") | .id' -# Set the override. +# Set the override for one service. Config Store keys are case-sensitive. fastly config-store-entry update \ --store-id= \ - --key=EDGEZERO__STORES__CONFIG__APP_CONFIG__KEY \ + --key=EDGEZERO__SERVICES____STORES__CONFIG__APP_CONFIG__KEY \ --value=app_config_staging \ --upsert ``` +Fastly runtime overrides are service-scoped because the Config Store can be +linked to multiple services. Legacy unscoped `EDGEZERO__STORES__...` entries are +not read; migrate manually managed entries by rewriting them under the service +prefix shown above. Provisioning a non-default store-name mapping requires +`service_id` in `fastly.toml` or `FASTLY_SERVICE_ID` so the command cannot write +into an ambiguous namespace. If both are set, they must match. + Locally (Viceroy), the store lives in fastly.toml's `[local_server.config_stores.edgezero_runtime_env]` block. If the store is missing at runtime, EdgeZero logs a one-line warning to diff --git a/docs/guide/cli-reference.md b/docs/guide/cli-reference.md index 1f6b48e7..779c38e7 100644 --- a/docs/guide/cli-reference.md +++ b/docs/guide/cli-reference.md @@ -323,8 +323,9 @@ flags and exits `2` with a pointer to the typed CLI — it cannot push (see key is _derived_ from the store's logical id and is mutually exclusive with `--key` (an explicit staging key would be written where no staged version reads, so the combination is refused). A staged deploy points the staged version's - `edgezero_runtime_env` link at this key via `EDGEZERO__STORES__CONFIG____KEY` - in its staging selector store (see [the blob migration guide](./blob-app-config-migration.md#per-environment-key-override)). + `edgezero_runtime_env` link at this key via the service-scoped + `EDGEZERO__SERVICES____STORES__CONFIG____KEY` entry in its + staging selector store (see [the blob migration guide](./blob-app-config-migration.md#per-environment-key-override)). - `--no-env` — skip the `__…__` env-var overlay when loading the app config. By default the loader reads the overlay so the push sends the same values the runtime would. - `--local` — push into the adapter's local-emulator state instead of the live platform. Fastly edits `[local_server.config_stores]` in `fastly.toml` (Viceroy reads it on startup); Cloudflare runs `wrangler kv bulk put --local` so writes land in `.wrangler/state`; Spin forces SQLite-direct against `/.spin/sqlite_key_value.db` even when the manifest's deploy command targets Fermyon Cloud (the runtime-config `[key_value_store.