Skip to content

Commit 5257d79

Browse files
apollo_node_config,apollo_deployments: assert cross-component equality for formerly-pointer-resolved fields
CONFIG_POINTERS copies one source value into N component fields at load. Before that resolution is deleted, add a present-only validator::Validate check (in cross_member_validations) asserting those fields are equal across PRESENT components, so hand-edited/test/non-jsonnet configs can't silently carry divergent values. 10 equality groups derived from CONFIG_POINTERS (chain_id; eth/strk fee tokens incl. the *_fee_token_address vs *_fee_contract_address name asymmetry; recorder_url; native_classes_whitelist; validate_resource_bounds; max_cpu_time; behavior_mode; versioned_constants_overrides; revert_config), plus validation_only: a single-target pointer whose target (the batcher's copy, which drives batcher behavior) is checked against the always-present top-level source field. Skips starknet_url (String vs Url) and validator_id (lone target with no independent source). New all_present_equal helper compares present Option<&T>. Enforce the invariant in CI: build_*_deserializes_into_node_config now calls validate_node_config() on the build() output for every layout (not just deserializes), so a jsonnet change that breaks a pointer group fails CI instead of only at prod boot. Component urls are deploy-time placeholders (in-cluster DNS that won't resolve), so the test rewrites them to localhost before validating, reaching the cross-component checks. Verified the build() native config passes the check for all layouts (no regression); negative tests prove the guard fires (incl. validation_only). Raw SequencerNodeConfig::default() is cross-target inconsistent (only pointer resolution reconciled it), so tests reconcile via a normalize_pointer_groups helper. apollo_node_config 35/35, apollo_deployments 8/8 green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a4eefa9 commit 5257d79

3 files changed

Lines changed: 438 additions & 4 deletions

File tree

crates/apollo_deployments/src/jsonnet.rs

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -93,15 +93,48 @@ where
9393
assert!(!services.is_empty(), "build({layout}) produced no services");
9494

9595
for (service_name, config) in services {
96-
serde_json::from_value::<SequencerNodeConfig>(config.clone()).unwrap_or_else(|error| {
96+
let mut config = config.clone();
97+
fill_placeholder_component_urls(&mut config);
98+
let node_config =
99+
serde_json::from_value::<SequencerNodeConfig>(config).unwrap_or_else(|error| {
100+
panic!(
101+
"service {service_name} of layout {layout} does not deserialize into \
102+
SequencerNodeConfig: {error}"
103+
)
104+
});
105+
// The build output must also satisfy the cross-component invariants (chain_id and the other
106+
// formerly-pointer-resolved values agreeing across components, etc.). Without this, a
107+
// jsonnet change that broke a pointer group would pass CI and only fail at prod boot.
108+
node_config.validate_node_config().unwrap_or_else(|error| {
97109
panic!(
98-
"service {service_name} of layout {layout} does not deserialize into \
99-
SequencerNodeConfig: {error}"
110+
"service {service_name} of layout {layout} deserializes but fails \
111+
validate_node_config: {error}"
100112
)
101113
});
102114
}
103115
}
104116

117+
/// Rewrites every reactive component's `url` in a built config to a resolvable value.
118+
///
119+
/// A component's `url` is a deploy-time placeholder: the build emits the in-cluster service DNS
120+
/// name (e.g. `sequencer-core-service`) for remote components, which does not resolve outside the
121+
/// cluster. `validate_node_config` runs the per-component url validator, which actually resolves
122+
/// the address, so rewrite every present `url` to `localhost` (always resolvable, no network
123+
/// needed) to let validation reach the cross-component invariants. url/port are placeholders anyway
124+
/// — the infra-parity check (`without_url_port`) excludes them for the same reason. Active
125+
/// components have no `url` key and are left untouched.
126+
fn fill_placeholder_component_urls(config: &mut Value) {
127+
let Some(components) = config.get_mut("components").and_then(Value::as_object_mut) else {
128+
return;
129+
};
130+
for component in components.values_mut() {
131+
let Some(component) = component.as_object_mut() else { continue };
132+
if component.contains_key("url") {
133+
component.insert("url".to_owned(), Value::String("localhost".to_owned()));
134+
}
135+
}
136+
}
137+
105138
/// Clones a `components` map with `url` and `port` removed from each component object — the two
106139
/// fields the Rust config leaves as deploy-time placeholders, so they can't be compared against the
107140
/// jsonnet's baked-in real values.

crates/apollo_node_config/src/config_test.rs

Lines changed: 182 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
11
use std::collections::BTreeSet;
22

3+
use apollo_config::behavior_mode::BehaviorMode;
34
use apollo_config::dumping::SerializeConfig;
45
use apollo_config::ParamPath;
56
use apollo_infra::component_client::RemoteClientConfig;
67
use apollo_infra::component_server::{LocalServerConfig, RemoteServerConfig};
78
use apollo_infra_utils::dumping::serialize_to_file_test;
9+
use apollo_reverts::RevertConfig;
810
use apollo_state_sync_config::config::{StateSyncConfig, StateSyncStaticConfig};
911
use apollo_storage::{StorageConfig, StorageScope};
12+
use blockifier::blockifier::config::NativeClassesWhitelist;
1013
use rstest::rstest;
14+
use starknet_api::contract_address;
15+
use starknet_api::core::ChainId;
1116
use validator::Validate;
1217

1318
use crate::component_config::ComponentConfig;
@@ -290,7 +295,7 @@ fn config_manager_local_with_remote_enabled_is_rejected() {
290295

291296
#[test]
292297
fn validation_only_with_tx_ingestion_disabled_succeeds() {
293-
let config = SequencerNodeConfig {
298+
let mut config = SequencerNodeConfig {
294299
validation_only: true,
295300
components: ComponentConfig {
296301
gateway: ReactiveComponentExecutionConfig::disabled(),
@@ -306,5 +311,181 @@ fn validation_only_with_tx_ingestion_disabled_succeeds() {
306311
state_sync_config: Some(state_sync_config_with_full_archive()),
307312
..Default::default()
308313
};
314+
// `SequencerNodeConfig::default()` does not have internally consistent pointer-group values
315+
// (those are only reconciled by pointer resolution at load time), so normalize them before
316+
// exercising the validation_only logic this test targets.
317+
normalize_pointer_groups(&mut config);
309318
assert!(config.validate_node_config().is_ok());
310319
}
320+
321+
/// Overwrites every present target of each multi-target `CONFIG_POINTERS` group with a single,
322+
/// consistent value, mirroring what pointer resolution does at load time. Lets a config assembled
323+
/// directly from `SequencerNodeConfig::default()` satisfy the cross-component equality invariant.
324+
fn normalize_pointer_groups(config: &mut SequencerNodeConfig) {
325+
let chain_id = ChainId::Mainnet;
326+
let eth_fee_token_address = contract_address!("0x1");
327+
let strk_fee_token_address = contract_address!("0x2");
328+
let max_cpu_time: u64 = 600;
329+
330+
config.validation_only = false;
331+
if let Some(sierra_compiler) = config.sierra_compiler_config.as_mut() {
332+
sierra_compiler.max_cpu_time = max_cpu_time;
333+
}
334+
if let Some(batcher) = config.batcher_config.as_mut() {
335+
let static_config = &mut batcher.static_config;
336+
static_config.block_builder_config.chain_info.chain_id = chain_id.clone();
337+
static_config.storage.db_config.chain_id = chain_id.clone();
338+
let fee_token_addresses =
339+
&mut static_config.block_builder_config.chain_info.fee_token_addresses;
340+
fee_token_addresses.eth_fee_token_address = eth_fee_token_address;
341+
fee_token_addresses.strk_fee_token_address = strk_fee_token_address;
342+
static_config.contract_class_manager_config.native_compiler_config.max_cpu_time =
343+
max_cpu_time;
344+
static_config.pre_confirmed_cende_config.recorder_url =
345+
"https://recorder_url".parse().unwrap();
346+
static_config.block_builder_config.versioned_constants_overrides = None;
347+
static_config.validation_only = false;
348+
batcher.dynamic_config.native_classes_whitelist = NativeClassesWhitelist::All;
349+
}
350+
if let Some(class_manager) = config.class_manager_config.as_mut() {
351+
class_manager
352+
.static_config
353+
.class_storage_config
354+
.class_hash_storage_config
355+
.db_config
356+
.chain_id = chain_id.clone();
357+
}
358+
if let Some(consensus_manager) = config.consensus_manager_config.as_mut() {
359+
consensus_manager
360+
.consensus_manager_config
361+
.static_config
362+
.storage_config
363+
.db_config
364+
.chain_id = chain_id.clone();
365+
consensus_manager.context_config.static_config.chain_id = chain_id.clone();
366+
consensus_manager.network_config.chain_id = chain_id.clone();
367+
consensus_manager.context_config.static_config.behavior_mode = BehaviorMode::Starknet;
368+
consensus_manager.cende_config.recorder_url = "https://recorder_url".parse().unwrap();
369+
consensus_manager.revert_config = RevertConfig::default();
370+
}
371+
if let Some(gateway) = config.gateway_config.as_mut() {
372+
gateway.static_config.chain_info.chain_id = chain_id.clone();
373+
let fee_token_addresses = &mut gateway.static_config.chain_info.fee_token_addresses;
374+
fee_token_addresses.eth_fee_token_address = eth_fee_token_address;
375+
fee_token_addresses.strk_fee_token_address = strk_fee_token_address;
376+
gateway.static_config.contract_class_manager_config.native_compiler_config.max_cpu_time =
377+
max_cpu_time;
378+
gateway.static_config.stateful_tx_validator_config.validate_resource_bounds = true;
379+
gateway.static_config.stateless_tx_validator_config.validate_resource_bounds = true;
380+
gateway.static_config.stateful_tx_validator_config.versioned_constants_overrides = None;
381+
gateway.dynamic_config.native_classes_whitelist = NativeClassesWhitelist::All;
382+
}
383+
if let Some(l1_events_scraper) = config.l1_events_scraper_config.as_mut() {
384+
l1_events_scraper.chain_id = chain_id.clone();
385+
}
386+
if let Some(l1_gas_price_scraper) = config.l1_gas_price_scraper_config.as_mut() {
387+
l1_gas_price_scraper.chain_id = chain_id.clone();
388+
}
389+
if let Some(mempool) = config.mempool_config.as_mut() {
390+
mempool.static_config.recorder_url = "https://recorder_url".parse().unwrap();
391+
mempool.static_config.validate_resource_bounds = true;
392+
mempool.static_config.behavior_mode = BehaviorMode::Starknet;
393+
}
394+
if let Some(mempool_p2p) = config.mempool_p2p_config.as_mut() {
395+
mempool_p2p.network_config.chain_id = chain_id.clone();
396+
}
397+
if let Some(state_sync) = config.state_sync_config.as_mut() {
398+
let static_config = &mut state_sync.static_config;
399+
static_config.storage_config.db_config.chain_id = chain_id.clone();
400+
if let Some(network_config) = static_config.network_config.as_mut() {
401+
network_config.chain_id = chain_id.clone();
402+
}
403+
static_config.rpc_config.chain_id = chain_id.clone();
404+
static_config.rpc_config.execution_config.eth_fee_contract_address = eth_fee_token_address;
405+
static_config.rpc_config.execution_config.strk_fee_contract_address =
406+
strk_fee_token_address;
407+
static_config.revert_config = RevertConfig::default();
408+
}
409+
}
410+
411+
/// A config assembled directly from `SequencerNodeConfig::default()` is not internally consistent
412+
/// on pointer-group values, so after normalizing those groups it validates `Ok`. This is the
413+
/// "full" positive case: every component is present and every group agrees.
414+
#[test]
415+
fn pointer_groups_consistent_full_config_validates() {
416+
let mut config = SequencerNodeConfig::default();
417+
normalize_pointer_groups(&mut config);
418+
assert!(
419+
config.validate_node_config().is_ok(),
420+
"normalized full config should validate: {:?}",
421+
config.validate_node_config()
422+
);
423+
}
424+
425+
/// Present-only guard: when only one owner of a pointer group is present (a partial/distributed
426+
/// deployment), the equality check has nothing to compare against and validates `Ok`.
427+
#[test]
428+
fn pointer_groups_single_present_owner_validates() {
429+
// Only `gateway_config` owns `native_classes_whitelist`/`validate_resource_bounds`; with the
430+
// batcher and mempool absent there is a single present value, so the group is trivially equal.
431+
let mut config = SequencerNodeConfig {
432+
batcher_config: None,
433+
mempool_config: None,
434+
..SequencerNodeConfig::default()
435+
};
436+
// Disable the now-absent components so the per-component "set iff running locally" check
437+
// passes.
438+
config.components.batcher = ReactiveComponentExecutionConfig::disabled();
439+
config.components.mempool = ReactiveComponentExecutionConfig::disabled();
440+
normalize_pointer_groups(&mut config);
441+
assert!(
442+
config.validate_node_config().is_ok(),
443+
"single-owner config should validate: {:?}",
444+
config.validate_node_config()
445+
);
446+
}
447+
448+
/// Negative: a uniform shared field (`chain_id`) diverging between two present owners fails.
449+
#[test]
450+
fn pointer_group_chain_id_mismatch_fails() {
451+
let mut config = SequencerNodeConfig::default();
452+
normalize_pointer_groups(&mut config);
453+
// Diverge the gateway's chain_id from everyone else's.
454+
config.gateway_config.as_mut().unwrap().static_config.chain_info.chain_id = ChainId::Sepolia;
455+
let err = config.validate_node_config().unwrap_err();
456+
assert!(format!("{err:?}").contains("chain_id"), "Unexpected error: {err:?}");
457+
}
458+
459+
/// Negative covering the fee-token name asymmetry: the batcher/gateway `eth_fee_token_address` and
460+
/// the state_sync `eth_fee_contract_address` are the same logical value; diverging them fails.
461+
#[test]
462+
fn pointer_group_eth_fee_token_name_asymmetry_mismatch_fails() {
463+
let mut config = SequencerNodeConfig::default();
464+
normalize_pointer_groups(&mut config);
465+
// state_sync stores it under `eth_fee_contract_address`; diverge it from the gateway/batcher.
466+
config
467+
.state_sync_config
468+
.as_mut()
469+
.unwrap()
470+
.static_config
471+
.rpc_config
472+
.execution_config
473+
.eth_fee_contract_address = contract_address!("0xdead");
474+
let err = config.validate_node_config().unwrap_err();
475+
assert!(format!("{err:?}").contains("eth_fee_token_address"), "Unexpected error: {err:?}");
476+
}
477+
478+
/// Negative: the node-level `validation_only` source disagreeing with the batcher's copy (its lone
479+
/// pointer target, which actually drives batcher behavior) fails. Guards the source-vs-target
480+
/// group.
481+
#[test]
482+
fn pointer_group_validation_only_mismatch_fails() {
483+
let mut config = SequencerNodeConfig::default();
484+
normalize_pointer_groups(&mut config);
485+
// Top-level `validation_only` is false (set by `normalize_pointer_groups`); diverge the
486+
// batcher's copy. The top-level flag stays false, so `validate_validation_only_config` is a
487+
// no-op and the equality group is what must catch this.
488+
config.batcher_config.as_mut().unwrap().static_config.validation_only = true;
489+
let err = config.validate_node_config().unwrap_err();
490+
assert!(format!("{err:?}").contains("validation_only"), "Unexpected error: {err:?}");
491+
}

0 commit comments

Comments
 (0)