Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions crates/nexum-runtime/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ thiserror.workspace = true
async-trait.workspace = true
# Newtype boilerplate (`Display`, `AsRef`, `From`) for identity wrappers.
derive_more.workspace = true
# `strum::IntoStaticStr` on `LogSource`: the snake_case variant name is
# the tracing `source` field.
# `strum::IntoStaticStr`: the snake_case variant name is the tracing
# `source` field (`LogSource`) and the boot-refusal `error_kind` label.
strum.workspace = true
tokio.workspace = true
# Task lifecycle and graceful shutdown; the sole crate that raw-spawns
Expand Down
37 changes: 29 additions & 8 deletions crates/nexum-runtime/src/addons.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,29 @@
//! installs a facility from the resolved config and returns a handle the
//! launcher keeps alive for the run.

use metrics_exporter_prometheus::BuildError;
use tracing::info;

use crate::engine_config::MetricsSection;

/// The foreign cause renders inline, so the operator sees one line.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum PrometheusError {
#[error("invalid [engine.metrics].bind_addr `{addr}`: {cause}")]
BindAddr {
addr: String,
cause: std::net::AddrParseError,
},
#[error("install Prometheus exporter on {addr}: {cause}")]
Exporter {
addr: std::net::SocketAddr,
cause: BuildError,
},
#[error("install Prometheus recorder: {cause}")]
Recorder { cause: BuildError },
}

/// Inputs an add-on reads at install time.
pub struct AddOnsContext<'a> {
/// Resolved `[engine.metrics]` config.
Expand Down Expand Up @@ -43,23 +62,25 @@ pub struct PrometheusAddOn;
impl RuntimeAddOn for PrometheusAddOn {
fn install(&self, ctx: &AddOnsContext<'_>) -> anyhow::Result<AddOnHandle> {
if ctx.metrics.enabled {
let addr: std::net::SocketAddr = ctx.metrics.bind_addr.parse().map_err(|e| {
anyhow::anyhow!(
"invalid [engine.metrics].bind_addr `{}`: {e}",
ctx.metrics.bind_addr
)
})?;
let addr: std::net::SocketAddr =
ctx.metrics
.bind_addr
.parse()
.map_err(|cause| PrometheusError::BindAddr {
addr: ctx.metrics.bind_addr.clone(),
cause,
})?;
metrics_exporter_prometheus::PrometheusBuilder::new()
.with_http_listener(addr)
.install()
.map_err(|e| anyhow::anyhow!("install Prometheus exporter on {addr}: {e}"))?;
.map_err(|cause| PrometheusError::Exporter { addr, cause })?;
info!(addr = %addr, "metrics exporter listening at /metrics");
} else {
// Recorder installed globally so metrics call sites stay live;
// no HTTP port is opened. It accumulates samples in memory, unread.
metrics_exporter_prometheus::PrometheusBuilder::new()
.install_recorder()
.map_err(|e| anyhow::anyhow!("install Prometheus recorder: {e}"))?;
.map_err(|cause| PrometheusError::Recorder { cause })?;
}
Ok(AddOnHandle::named("prometheus"))
}
Expand Down
59 changes: 37 additions & 22 deletions crates/nexum-runtime/src/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,35 @@ use crate::runtime::event_loop;
pub use crate::supervisor::WasiClockOverride;
use crate::supervisor::{self, Supervisor, Viability};

/// Launch refusals around the supervisor boot; the wording is operator-pinned.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum LaunchRefusal {
#[error("event loop task terminated abnormally")]
EventLoopGone,
#[error(
"no modules to run - set a module source or declare [[modules]] or \
[[adapters]] entries in engine.toml"
)]
NothingToRun,
#[error(
"all {modules} module(s) failed initialisation - check the logs above for \
per-module errors and fix the wasm binary passed as an override"
)]
AllDeadOverride { modules: usize },
#[error(
"all {modules} module(s) failed initialisation - check the logs above for \
per-module errors and fix or remove the failing module from engine.toml"
)]
AllDeadConfigured { modules: usize },
#[error(
"every declared [[subscription]] belongs to an init-failed module - \
the engine would idle with nothing to run; fix or remove the \
failing module(s)"
)]
DeadHoldSubs,
}

/// Ambient inputs the launcher reads.
pub struct LaunchContext<'a> {
/// Owns task spawning and graceful shutdown for the run.
Expand Down Expand Up @@ -109,7 +138,7 @@ impl RuntimeHandle {
fn finish_wait(joined: Option<TaskExit>) -> anyhow::Result<()> {
match joined {
Some(_) => Ok(()),
None => anyhow::bail!("event loop task terminated abnormally"),
None => Err(LaunchRefusal::EventLoopGone.into()),
}
}

Expand Down Expand Up @@ -208,10 +237,7 @@ impl<T: RuntimeTypes> AssembledRuntime<T> {
)
.await?
} else {
anyhow::bail!(
"no modules to run - set a module source or declare [[modules]] or \
[[adapters]] entries in engine.toml"
);
return Err(LaunchRefusal::NothingToRun.into());
};

let alive = supervisor.alive_count();
Expand All @@ -224,19 +250,12 @@ impl<T: RuntimeTypes> AssembledRuntime<T> {
"supervisor ready"
);
if alive == 0 {
if wasm_override {
anyhow::bail!(
"all {} module(s) failed initialisation - check the logs above for \
per-module errors and fix the wasm binary passed as an override",
supervisor.module_count(),
);
let modules = supervisor.module_count();
return Err(if wasm_override {
LaunchRefusal::AllDeadOverride { modules }.into()
} else {
anyhow::bail!(
"all {} module(s) failed initialisation - check the logs above for \
per-module errors and fix or remove the failing module from engine.toml",
supervisor.module_count(),
);
}
LaunchRefusal::AllDeadConfigured { modules }.into()
});
}

// The OS signal listener: SIGINT/SIGTERM ends it, and its end (or
Expand Down Expand Up @@ -282,11 +301,7 @@ impl<T: RuntimeTypes> AssembledRuntime<T> {
}

match plan.viability(extension_streams.len()) {
Viability::DeadHoldSubs => anyhow::bail!(
"every declared [[subscription]] belongs to an init-failed module - \
the engine would idle with nothing to run; fix or remove the \
failing module(s)"
),
Viability::DeadHoldSubs => return Err(LaunchRefusal::DeadHoldSubs.into()),
Viability::Nothing => {
// Nothing to drive: return a handle whose event loop is
// already complete so `wait` resolves immediately.
Expand Down
15 changes: 11 additions & 4 deletions crates/nexum-runtime/src/host/extension.rs
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,13 @@ pub fn downcast_service<S: HostService>(service: &Arc<dyn HostService>) -> Optio
erased.downcast().ok()
}

/// Two wired extensions claim one service namespace.
#[derive(Debug, thiserror::Error)]
#[error("duplicate extension service namespace {namespace}")]
pub struct DuplicateServiceNamespace {
pub namespace: &'static str,
}

/// Immutable per-namespace service map, built once at boot.
#[derive(Clone, Default)]
pub struct HostServices(Arc<BTreeMap<&'static str, Arc<dyn HostService>>>);
Expand All @@ -242,15 +249,15 @@ impl HostServices {
/// duplicate.
pub fn from_extensions<T: RuntimeTypes>(
extensions: &[Arc<dyn Extension<T>>],
) -> anyhow::Result<Self> {
) -> Result<Self, DuplicateServiceNamespace> {
let mut map = BTreeMap::new();
for ext in extensions {
let Some(service) = ext.service() else {
continue;
};
let namespace = ext.namespace();
if map.insert(namespace, service).is_some() {
anyhow::bail!("duplicate extension service namespace {namespace}");
return Err(DuplicateServiceNamespace { namespace });
}
}
Ok(Self(Arc::new(map)))
Expand All @@ -272,10 +279,10 @@ impl HostServices {
self,
namespace: &'static str,
service: Arc<dyn HostService>,
) -> anyhow::Result<Self> {
) -> Result<Self, DuplicateServiceNamespace> {
let mut map = Arc::unwrap_or_clone(self.0);
if map.insert(namespace, service).is_some() {
anyhow::bail!("duplicate extension service namespace {namespace}");
return Err(DuplicateServiceNamespace { namespace });
}
Ok(Self(Arc::new(map)))
}
Expand Down
4 changes: 3 additions & 1 deletion crates/nexum-runtime/src/manifest/error.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
//! Error types for manifest parsing and capability enforcement.

use strum::IntoStaticStr;
use thiserror::Error;

/// Errors from loading or validating a manifest.
Expand Down Expand Up @@ -56,7 +57,8 @@ pub struct CapabilityViolation {
}

/// A component's WIT imports exceed its declared capabilities.
#[derive(Debug, Error)]
#[derive(Debug, Error, IntoStaticStr)]
#[strum(serialize_all = "snake_case")]
#[non_exhaustive]
pub enum CapabilityError {
/// A gated import was not declared in `[capabilities]`.
Expand Down
3 changes: 2 additions & 1 deletion crates/nexum-runtime/src/manifest/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,11 @@ mod types;

pub(crate) use capabilities::enforce_capabilities;
pub use capabilities::{CapabilityRegistry, NamespaceCaps};
pub(crate) use error::{CapabilityError, ParseError};
pub(crate) use load::{host_allowed, load};
pub use types::ExtensionSections;
pub(crate) use types::{ComponentKind, LoadedManifest, ResourceSection, Subscription};
// CapabilityViolation, ParseError, and the *Section structs are
// CapabilityViolation and the *Section structs are
// reachable through these functions' return / argument types;
// consumers that need to name them directly do so via
// `crate::manifest::error::*` or `::types::*`.
53 changes: 27 additions & 26 deletions crates/nexum-runtime/src/supervisor/admission.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@
use std::collections::{BTreeMap, BTreeSet};
use std::sync::Arc;

use anyhow::{Result, anyhow};

use super::load::LoadRefusal;
use crate::host::component::RuntimeTypes;
use crate::host::extension::{Extension, HostService, HostServices, ProviderKind};
use crate::manifest::{self, CapabilityRegistry};
Expand All @@ -20,19 +19,20 @@ pub(super) type ProviderKinds<T> = BTreeMap<&'static str, ProviderRow<T>>;
pub(super) fn provider_kinds<T: RuntimeTypes>(
extensions: &[Arc<dyn Extension<T>>],
services: &HostServices,
) -> Result<ProviderKinds<T>> {
) -> Result<ProviderKinds<T>, LoadRefusal> {
let mut kinds = ProviderKinds::new();
for ext in extensions {
let Some(provider) = ext.provider() else {
continue;
};
let service = services.raw(ext.namespace()).cloned().ok_or_else(|| {
anyhow!(
"extension {} registers provider kind {} without a host service",
ext.namespace(),
provider.kind(),
)
})?;
let service =
services
.raw(ext.namespace())
.cloned()
.ok_or_else(|| LoadRefusal::ServicelessKind {
namespace: ext.namespace(),
kind: provider.kind(),
})?;
register_kind(&mut kinds, provider, service)?;
}
Ok(kinds)
Expand All @@ -43,16 +43,16 @@ fn register_kind<T: RuntimeTypes>(
kinds: &mut ProviderKinds<T>,
provider: Box<dyn ProviderKind<T>>,
service: Arc<dyn HostService>,
) -> Result<()> {
) -> Result<(), LoadRefusal> {
let kind = provider.kind();
if kinds.insert(kind, (provider, service)).is_some() {
return Err(anyhow!("provider kind {kind} is registered twice"));
return Err(LoadRefusal::KindRegisteredTwice { kind });
}
Ok(())
}

pub(super) fn registered_kinds<T: RuntimeTypes>(kinds: &ProviderKinds<T>) -> String {
kinds.keys().copied().collect::<Vec<_>>().join(", ")
pub(super) fn registered_kinds<T: RuntimeTypes>(kinds: &ProviderKinds<T>) -> Vec<&'static str> {
kinds.keys().copied().collect()
}

pub(super) fn extension_subscription_vocabulary<T: RuntimeTypes>(
Expand All @@ -69,15 +69,16 @@ pub(super) fn enforce_extension_sections<T: RuntimeTypes>(
owner: &str,
sections: &manifest::ExtensionSections,
extensions: &[Arc<dyn Extension<T>>],
) -> Result<()> {
) -> Result<(), LoadRefusal> {
for key in sections.keys() {
let claimed = extensions
.iter()
.any(|ext| ext.manifest_sections().contains(&key.as_str()));
if !claimed {
return Err(anyhow!(
"{owner} declares manifest section [{key}]; no wired extension claims it"
));
return Err(LoadRefusal::SectionUnclaimed {
owner: owner.to_owned(),
section: key.clone(),
});
}
}
Ok(())
Expand All @@ -87,23 +88,23 @@ pub(super) fn enforce_extension_sections<T: RuntimeTypes>(
/// subscription kind, or manifest section.
pub(super) fn enforce_extension_uniqueness<T: RuntimeTypes>(
extensions: &[Arc<dyn Extension<T>>],
) -> Result<()> {
) -> Result<(), LoadRefusal> {
let mut namespaces = BTreeSet::new();
let mut kinds = BTreeSet::new();
let mut sections = BTreeSet::new();
for ext in extensions {
let namespace = ext.namespace();
if !namespaces.insert(namespace) {
return Err(anyhow!("extension namespace {namespace} is claimed twice"));
return Err(LoadRefusal::ExtensionNamespaceClaimed { namespace });
}
for kind in ext.subscriptions() {
if !kinds.insert(*kind) {
return Err(anyhow!("subscription kind {kind} is claimed twice"));
for &kind in ext.subscriptions() {
if !kinds.insert(kind) {
return Err(LoadRefusal::SubscriptionKindClaimed { kind });
}
}
for section in ext.manifest_sections() {
if !sections.insert(*section) {
return Err(anyhow!("manifest section [{section}] is claimed twice"));
for &section in ext.manifest_sections() {
if !sections.insert(section) {
return Err(LoadRefusal::SectionClaimed { section });
}
}
}
Expand Down
15 changes: 9 additions & 6 deletions crates/nexum-runtime/src/supervisor/artifact.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@

use std::path::Path;

use anyhow::{Context, Error, Result, bail};
use anyhow::{Context, Error, Result};
use tracing::{debug, warn};
use wasmtime::component::Component;
use wasmtime::{CodeBuilder, Engine};

use super::load::LoadRefusal;
use crate::digest::{ContentDigest, DigestMismatch};

/// The only production compile path; the verified bytes are the compiled bytes.
Expand All @@ -23,6 +24,7 @@ pub(super) fn read_verified_component(
std::fs::read(path).with_context(|| format!("read component {}", path.display()))?;
let actual = ContentDigest::of_bytes(&bytes);
match declared {
// A mismatch stays its own anyhow root: callers downcast to `DigestMismatch`.
Some(declared) => {
if actual != *declared {
return Err(DigestMismatch {
Expand All @@ -34,11 +36,12 @@ pub(super) fn read_verified_component(
}
debug!(component = %path.display(), digest = %actual, "component digest verified");
}
None if require_digest => bail!(
"no [module].component digest for {} and [engine] require_component_digest is set; \
pin the artifact's sha256 in its module.toml",
path.display(),
),
None if require_digest => {
return Err(LoadRefusal::DigestUnpinned {
path: path.to_owned(),
}
.into());
}
None => warn!(
component = %path.display(),
digest = %actual,
Expand Down
Loading
Loading