diff --git a/crates/nexum-runtime/src/builder.rs b/crates/nexum-runtime/src/builder.rs index 22d59b4..cc4ffb0 100644 --- a/crates/nexum-runtime/src/builder.rs +++ b/crates/nexum-runtime/src/builder.rs @@ -19,7 +19,7 @@ use tracing::{error, info, warn}; use wasmtime::Engine; use crate::addons::{AddOnHandle, AddOns, AddOnsContext, RuntimeAddOn}; -use crate::engine_config::EngineConfig; +use crate::engine_config::{EngineConfig, ModuleEntry}; use crate::host::component::{ BuilderContext, ComponentBuilder, Components, ComponentsBuilder, RuntimeTypes, }; @@ -183,15 +183,16 @@ impl AssembledRuntime<'_, T> { "ignoring engine.toml [[modules]] because a module source override was given" ); } + let entry = ModuleEntry { + path: wasm.to_path_buf(), + manifest: manifest.map(Path::to_path_buf), + }; Supervisor::boot_single( &engine, &linker, - wasm, - manifest, + &entry, &components, - &engine_cfg.limits, - &supervisor::ConfiguredChains::from_config(engine_cfg), - engine_cfg.engine.require_component_digest, + &supervisor::BootEnv::from_config(engine_cfg), &extensions, clocks, ) diff --git a/crates/nexum-runtime/src/supervisor/mod.rs b/crates/nexum-runtime/src/supervisor/mod.rs index 06fb9f4..032d5d3 100644 --- a/crates/nexum-runtime/src/supervisor/mod.rs +++ b/crates/nexum-runtime/src/supervisor/mod.rs @@ -15,7 +15,6 @@ pub use prepass::ConfiguredChains; pub use store::{WasiClockOverride, build_linker, build_provider_linker}; pub use subscriptions::ChainLogSub; -use std::path::Path; use std::sync::Arc; use anyhow::{Context, Result}; @@ -46,6 +45,23 @@ pub struct Supervisor { chain_log_cursors: ChainLogCursors, } +/// Boot inputs derived from [`EngineConfig`], bundled once at the call site. +pub struct BootEnv<'a> { + pub limits: &'a ModuleLimits, + pub configured_chains: ConfiguredChains, + pub require_component_digest: bool, +} + +impl<'a> BootEnv<'a> { + pub fn from_config(cfg: &'a EngineConfig) -> Self { + Self { + limits: &cfg.limits, + configured_chains: ConfiguredChains::from_config(cfg), + require_component_digest: cfg.engine.require_component_digest, + } + } +} + /// Cached at boot so restarts rebuild an identical store and linker. pub(super) struct Shared { pub(super) engine: Engine, @@ -68,7 +84,7 @@ impl Supervisor { extensions: &[Arc>], clocks: Option, ) -> Result { - let shared = wire_extensions(engine, components, extensions, clocks)?; + let shared = wire_extensions(engine, components, extensions, clocks, true)?; let registry = capability_registry(&shared.extensions); let prepass = prepass::run(engine_cfg, ®istry)?; // Providers boot first, so every module store built after already @@ -92,49 +108,32 @@ impl Supervisor { } /// Single-component boot for `just run` without an `engine.toml`. - #[allow(clippy::too_many_arguments)] pub async fn boot_single( engine: &Engine, linker: &Linker>, - wasm: &Path, - manifest: Option<&Path>, + entry: &ModuleEntry, components: &Components, - limits: &ModuleLimits, - configured_chains: &ConfiguredChains, - require_component_digest: bool, + env: &BootEnv<'_>, extensions: &[Arc>], clocks: Option, ) -> Result { - enforce_extension_uniqueness(extensions)?; - let services = HostServices::from_extensions(extensions)?; - // Providers come only from `engine.toml`, so no kinds register here. - let shared = Shared { - engine: engine.clone(), - components: components.clone(), - extensions: extensions.to_vec(), - services, - kinds: ProviderKinds::new(), - clocks, - }; + // Provider kinds come only from `engine.toml`, so none register here. + let shared = wire_extensions(engine, components, extensions, clocks, false)?; let registry = capability_registry(&shared.extensions); - let entry = ModuleEntry { - path: wasm.to_path_buf(), - manifest: manifest.map(Path::to_path_buf), - }; let loaded_manifest = load_required_manifest(&entry.path, entry.manifest.as_deref(), ®istry, "module")?; enforce_configured_chains( &manifest_namespace(&loaded_manifest, MODULE_FALLBACK_NAME), &loaded_manifest, - configured_chains, + &env.configured_chains, )?; let loaded = load::module( &shared, linker, - &entry, + entry, loaded_manifest, - limits, - require_component_digest, + env.limits, + env.require_component_digest, &[], ) .await?; @@ -142,7 +141,7 @@ impl Supervisor { shared, modules: vec![loaded], providers: Vec::new(), - policy: limits.poison(), + policy: env.limits.poison(), chain_log_cursors: ChainLogCursors::default(), }) } @@ -186,15 +185,21 @@ impl Supervisor { } /// The resulting [`Shared`] is the one wiring every later phase reads. +/// `with_provider_kinds: false` skips [`provider_kinds`], which refuses a serviceless kind. fn wire_extensions( engine: &Engine, components: &Components, extensions: &[Arc>], clocks: Option, + with_provider_kinds: bool, ) -> Result> { enforce_extension_uniqueness(extensions)?; let services = HostServices::from_extensions(extensions)?; - let kinds = provider_kinds(extensions, &services)?; + let kinds = if with_provider_kinds { + provider_kinds(extensions, &services)? + } else { + ProviderKinds::new() + }; Ok(Shared { engine: engine.clone(), components: components.clone(), diff --git a/crates/nexum-runtime/src/supervisor/tests/boot_refusals.rs b/crates/nexum-runtime/src/supervisor/tests/boot_refusals.rs index 0da5440..fec2e2f 100644 --- a/crates/nexum-runtime/src/supervisor/tests/boot_refusals.rs +++ b/crates/nexum-runtime/src/supervisor/tests/boot_refusals.rs @@ -46,6 +46,52 @@ async fn boot_admits_a_registered_provider_kind_past_the_kind_gate() { .lacks("requires a module.toml"); } +/// The multi-entry path wires provider kinds, so a serviceless kind refuses +/// the boot before any entry loads. +#[tokio::test] +async fn boot_refuses_a_provider_kind_without_a_host_service() { + BootScenario::over(mock_components()) + .extensions(serviceless_acme_extensions()) + .expect_refusal() + .await + .names("extension acme registers provider kind acme-adapter without a host service"); +} + +/// Provider kinds come only from `engine.toml`, so single boot skips the +/// service gate and the first refusal is the missing manifest. +#[tokio::test] +async fn boot_single_skips_the_provider_kind_service_gate() { + let extensions = serviceless_acme_extensions(); + let engine = test_wasmtime_engine(); + let linker = crate::supervisor::build_linker(&engine, &extensions).expect("build_linker"); + let dir = tempfile::tempdir().expect("tempdir"); + let entry = ModuleEntry { + path: dir.path().join("missing.wasm"), + manifest: None, + }; + let limits = ModuleLimits::default(); + let env = BootEnv { + limits: &limits, + configured_chains: test_chains(), + require_component_digest: false, + }; + let err = Supervisor::boot_single( + &engine, + &linker, + &entry, + &mock_components(), + &env, + &extensions, + None, + ) + .await + .err() + .expect("a missing manifest must refuse the boot"); + Refusal::from(err) + .names("no module.toml") + .lacks("without a host service"); +} + /// `[capabilities]` is declared so the failing gate is the subscription kind. #[tokio::test] async fn boot_refuses_an_undeclared_extension_subscription_kind() { diff --git a/crates/nexum-runtime/src/supervisor/tests/chain_gate.rs b/crates/nexum-runtime/src/supervisor/tests/chain_gate.rs index 289559b..31e8163 100644 --- a/crates/nexum-runtime/src/supervisor/tests/chain_gate.rs +++ b/crates/nexum-runtime/src/supervisor/tests/chain_gate.rs @@ -36,6 +36,27 @@ async fn boot_refuses_a_subscription_on_an_unconfigured_chain() { } } +/// The single-boot path reads the same configured-chains gate. +#[tokio::test] +async fn boot_single_refuses_a_subscription_on_an_unconfigured_chain() { + let dir = tempfile::tempdir().expect("tempdir"); + let manifest = TestManifest::new("gated") + .cap("logging") + .block_sub(424_242) + .write_to(dir.path()); + let wasm = dir.path().join("missing.wasm"); + + let (_store, result) = try_boot_single(&wasm, Some(&manifest), false, None).await; + Refusal::from( + result + .err() + .expect("an unconfigured chain must refuse the boot"), + ) + .names("module gated subscribes to chain 424242") + .names("configured chains: 1, 100, 11155111") + .lacks("compile"); +} + #[tokio::test] async fn boot_admits_a_block_subscription_on_a_configured_chain_past_the_chain_gate() { BootScenario::new() diff --git a/crates/nexum-runtime/src/supervisor/tests/mod.rs b/crates/nexum-runtime/src/supervisor/tests/mod.rs index 6567ba3..fe36f76 100644 --- a/crates/nexum-runtime/src/supervisor/tests/mod.rs +++ b/crates/nexum-runtime/src/supervisor/tests/mod.rs @@ -79,15 +79,22 @@ async fn try_boot_single( let engine = test_wasmtime_engine(); let linker = make_linker(&engine); let (dir, store) = temp_local_store(); + let entry = ModuleEntry { + path: wasm.to_path_buf(), + manifest: manifest.map(Path::to_path_buf), + }; + let limits = ModuleLimits::default(); + let env = BootEnv { + limits: &limits, + configured_chains: test_chains(), + require_component_digest: require_digest, + }; let result = Supervisor::boot_single( &engine, &linker, - wasm, - manifest, + &entry, &test_components(store), - &ModuleLimits::default(), - &test_chains(), - require_digest, + &env, &core_extensions(), clocks, ) @@ -157,3 +164,34 @@ impl Extension for AcmeExtension { fn acme_extensions() -> Vec>> { vec![Arc::new(AcmeExtension)] } + +/// [`AcmeExtension`] minus its service, for the serviceless kind gate tests. +struct ServicelessAcmeExtension; + +impl Extension for ServicelessAcmeExtension { + fn namespace(&self) -> &'static str { + "acme" + } + + fn capabilities(&self) -> manifest::NamespaceCaps { + manifest::NamespaceCaps { + prefix: "test:acme/", + ifaces: &[], + } + } + + fn link( + &self, + _linker: &mut Linker>, + ) -> anyhow::Result<()> { + Ok(()) + } + + fn provider(&self) -> Option>> { + Some(Box::new(AcmeKind)) + } +} + +fn serviceless_acme_extensions() -> Vec>> { + vec![Arc::new(ServicelessAcmeExtension)] +}