From f0bad611f2355581eaffd8fd33f733692208c27b Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Tue, 16 Jun 2026 11:10:20 -0700 Subject: [PATCH 1/2] feat: add dynamic plugin control-plane foundation Signed-off-by: Alex Fournier --- crates/adaptive/src/error.rs | 1 + crates/core/src/plugin.rs | 8 + crates/core/src/plugin/dynamic.rs | 412 +++++++ crates/core/src/plugin/dynamic/manifest.rs | 436 +++++++ crates/core/src/plugin/dynamic/registry.rs | 253 ++++ .../core/tests/unit/plugin_dynamic_tests.rs | 1050 +++++++++++++++++ crates/ffi/src/error.rs | 6 +- 7 files changed, 2163 insertions(+), 3 deletions(-) create mode 100644 crates/core/src/plugin/dynamic.rs create mode 100644 crates/core/src/plugin/dynamic/manifest.rs create mode 100644 crates/core/src/plugin/dynamic/registry.rs create mode 100644 crates/core/tests/unit/plugin_dynamic_tests.rs diff --git a/crates/adaptive/src/error.rs b/crates/adaptive/src/error.rs index 7d7f2287..816121af 100644 --- a/crates/adaptive/src/error.rs +++ b/crates/adaptive/src/error.rs @@ -53,6 +53,7 @@ impl From for AdaptiveError { fn from(value: PluginError) -> Self { match value { PluginError::InvalidConfig(message) => Self::InvalidConfig(message), + PluginError::Conflict(message) => Self::InvalidConfig(message), PluginError::NotFound(message) => Self::NotFound(message), PluginError::Serialization(err) => Self::Serialization(err), PluginError::Internal(message) => Self::Internal(message), diff --git a/crates/core/src/plugin.rs b/crates/core/src/plugin.rs index 234625d2..cf406b56 100644 --- a/crates/core/src/plugin.rs +++ b/crates/core/src/plugin.rs @@ -39,6 +39,9 @@ use crate::api::runtime::{ }; use crate::api::subscriber::{deregister_subscriber, register_subscriber}; +pub mod dynamic; +pub use dynamic::*; + type PluginMap = HashMap>; static PLUGIN_HANDLERS: LazyLock> = LazyLock::new(|| RwLock::new(HashMap::new())); @@ -53,6 +56,10 @@ pub enum PluginError { #[error("invalid config: {0}")] InvalidConfig(String), + /// The requested mutation conflicts with current plugin state. + #[error("conflict: {0}")] + Conflict(String), + /// The requested plugin resource was not found. #[error("not found: {0}")] NotFound(String), @@ -776,6 +783,7 @@ pub fn ensure_builtin_plugins_registered() -> Result<()> { fn clone_cached_plugin_error(err: &PluginError) -> PluginError { match err { PluginError::InvalidConfig(message) => PluginError::InvalidConfig(message.clone()), + PluginError::Conflict(message) => PluginError::Conflict(message.clone()), PluginError::NotFound(message) => PluginError::NotFound(message.clone()), PluginError::Serialization(err) => PluginError::Internal(err.to_string()), PluginError::Internal(message) => PluginError::Internal(message.clone()), diff --git a/crates/core/src/plugin/dynamic.rs b/crates/core/src/plugin/dynamic.rs new file mode 100644 index 00000000..8e77c9a4 --- /dev/null +++ b/crates/core/src/plugin/dynamic.rs @@ -0,0 +1,412 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Dynamic plugin control-plane and registry model. +//! +//! This module owns the durable control-plane record shape for dynamic plugins. +//! Authored manifest parsing/validation and in-memory registry mutation logic +//! live in dedicated submodules so those responsibilities do not accumulate in +//! one file as the feature grows. + +use chrono::Utc; +use serde::{Deserialize, Serialize}; + +/// Canonical identifier for one dynamic plugin record. +pub type DynamicPluginId = String; + +/// Canonical filename for authored Relay plugin manifests. +pub const DYNAMIC_PLUGIN_MANIFEST_FILENAME: &str = "relay-plugin.toml"; + +mod manifest; +mod registry; + +pub use manifest::*; +pub use registry::*; + +/// Plugin execution lane. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "snake_case")] +pub enum DynamicPluginKind { + /// Trusted in-process native plugin. + RustDynamic, + /// Isolated worker-based plugin runtime. + Worker, +} + +/// Managed runtime identity for worker-based plugins. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "snake_case")] +pub enum WorkerRuntime { + /// Python worker runtime. + Python, +} + +/// Relay-enforced capability declared by a dynamic plugin. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "snake_case")] +pub enum DynamicPluginCapability { + /// Trusted in-process native extension capability. + PluginNative, + /// Isolated worker-based extension capability. + PluginWorker, + /// Guardrail-style middleware registration capability. + MiddlewareGuardrail, + /// Interceptor-style middleware registration capability. + MiddlewareInterceptor, + /// Telemetry exporter registration capability. + TelemetryExporter, + /// Typed configuration schema contribution capability. + ConfigSchema, +} + +/// Host policy startup classification for a plugin. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "snake_case")] +pub enum DynamicPluginStartupClass { + /// Failure is tolerated and the host may start in degraded mode. + Optional, + /// Failure is startup-fatal under current host policy. + Required, +} + +/// Host attestation policy mode. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "snake_case")] +pub enum DynamicPluginAttestationMode { + /// Integrity verification only. + IntegrityOnly, + /// Verify signatures when present but do not require them. + SignatureIfPresent, + /// Require trusted signature verification. + SignatureRequired, +} + +/// High-level verification state for one validation axis. +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "snake_case")] +pub enum DynamicPluginCheckState { + /// No verification result is currently known. + #[default] + Unknown, + /// Verification passed. + Valid, + /// Verification failed. + Invalid, +} + +/// Observed runtime state for a dynamic plugin. +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "snake_case")] +pub enum DynamicPluginRuntimeState { + /// Not currently active. + #[default] + Stopped, + /// Activation is in progress. + Starting, + /// Currently active. + Running, + /// Activation failed or the active runtime crashed. + Failed, +} + +/// Recent failure phase for diagnostics and operator UX. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "snake_case")] +pub enum DynamicPluginFailurePhase { + /// Failure occurred during validation. + Validation, + /// Failure occurred during activation or reconciliation. + Activation, + /// Failure occurred after activation while running. + Runtime, + /// Failure occurred because policy no longer permits realization. + Policy, +} + +/// Stable metadata for one durable plugin record. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +pub struct DynamicPluginMetadata { + /// Canonical plugin identifier. + pub id: DynamicPluginId, + /// Optional human-friendly display label. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + /// Optional plugin version mirrored from packaging metadata when desired. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub version: Option, + /// Execution lane used by the plugin. + pub kind: DynamicPluginKind, + /// Monotonic desired-state generation. + #[serde(default)] + pub generation: u64, + /// Creation timestamp in RFC 3339 form. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub created_at: Option, + /// Last durable record update time in RFC 3339 form. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub updated_at: Option, +} + +/// Source and resolved artifact facts for a plugin. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +pub struct DynamicPluginSource { + /// Canonical manifest location or reference. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub manifest_ref: Option, + /// Resolved runtime artifact location. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub artifact_ref: Option, + /// Resolved environment location for worker-based plugins. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub environment_ref: Option, + /// Pinned artifact digest. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub artifact_digest: Option, +} + +/// Desired-state fields owned by user-facing operations. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +pub struct DynamicPluginSpec { + /// Whether the plugin should be present in desired state. + #[serde(default = "default_present")] + pub present: bool, + /// Whether the plugin should be enabled in desired state. + #[serde(default)] + pub enabled: bool, + /// Optional config reference controlled by higher-level config surfaces. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub config_ref: Option, +} + +pub(crate) fn default_present() -> bool { + true +} + +impl Default for DynamicPluginSpec { + fn default() -> Self { + Self { + present: true, + enabled: false, + config_ref: None, + } + } +} + +/// Compatibility declarations and resolved compatibility facts. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +pub struct DynamicPluginCompatibility { + /// Compatible NeMo Relay version or version range. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub relay: Option, + /// Native host API/ABI contract version for `rust_dynamic`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub native_api: Option, + /// Worker protocol version for `worker`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub worker_protocol: Option, +} + +/// Runtime entry contract for the resolved plugin. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +pub struct DynamicPluginLoadContract { + /// Managed worker runtime when `kind = worker`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub worker_runtime: Option, + /// Worker entrypoint or registration target. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub entrypoint: Option, + /// Native dynamic library path. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub library: Option, + /// Native exported registration symbol. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub symbol: Option, +} + +/// One structured recent failure summary. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +pub struct DynamicPluginFailure { + /// Failure phase. + pub phase: DynamicPluginFailurePhase, + /// Machine-readable failure code. + pub code: String, + /// Human-readable summary. + pub message: String, +} + +/// Decomposed validation results for one plugin record. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +pub struct DynamicPluginValidationStatus { + /// Manifest schema/result state. + #[serde(default)] + pub manifest: DynamicPluginCheckState, + /// Relay/native/worker compatibility state. + #[serde(default)] + pub compatibility: DynamicPluginCheckState, + /// Artifact integrity state. + #[serde(default)] + pub integrity: DynamicPluginCheckState, + /// Environment/runtime readiness state. + #[serde(default)] + pub environment: DynamicPluginCheckState, + /// Signature/authenticity state. + #[serde(default)] + pub authenticity: DynamicPluginCheckState, + /// Whether the current host policy is satisfied. + #[serde(default)] + pub policy_satisfied: DynamicPluginCheckState, + /// Most recent validation time in RFC 3339 form. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub checked_at: Option, + /// Concise operator-facing validation summary. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub message: Option, +} + +impl Default for DynamicPluginValidationStatus { + fn default() -> Self { + Self { + manifest: DynamicPluginCheckState::Unknown, + compatibility: DynamicPluginCheckState::Unknown, + integrity: DynamicPluginCheckState::Unknown, + environment: DynamicPluginCheckState::Unknown, + authenticity: DynamicPluginCheckState::Unknown, + policy_satisfied: DynamicPluginCheckState::Unknown, + checked_at: None, + message: None, + } + } +} + +/// Observed runtime state for one plugin record. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +pub struct DynamicPluginRuntimeStatus { + /// Current observed runtime state. + #[serde(default)] + pub state: DynamicPluginRuntimeState, + /// Desired-state generation this runtime status reflects. + #[serde(default)] + pub observed_generation: u64, + /// Most recent successful start/activation time. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub started_at: Option, + /// Most recent runtime-status refresh time. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub updated_at: Option, + /// Concise operator-facing runtime summary. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub message: Option, +} + +impl Default for DynamicPluginRuntimeStatus { + fn default() -> Self { + Self { + state: DynamicPluginRuntimeState::Stopped, + observed_generation: 0, + started_at: None, + updated_at: None, + message: None, + } + } +} + +/// Durable observed state for a plugin record. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +pub struct DynamicPluginStatus { + /// Validation and policy status. + #[serde(default)] + pub validation: DynamicPluginValidationStatus, + /// Runtime state observed by the control plane. + #[serde(default)] + pub runtime: DynamicPluginRuntimeStatus, + /// Host policy startup classification. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub startup_class: Option, + /// Effective attestation mode for this plugin under host policy. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub attestation_mode: Option, + /// Most recent meaningful failure summary. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_error: Option, +} + +/// Durable control-plane record for a dynamic plugin. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +pub struct DynamicPluginRecord { + /// Stable plugin metadata. + pub metadata: DynamicPluginMetadata, + /// Declared capability set. + #[serde(default)] + pub capabilities: Vec, + /// Source and artifact facts. + #[serde(default)] + pub source: DynamicPluginSource, + /// Desired state. + #[serde(default)] + pub spec: DynamicPluginSpec, + /// Compatibility declarations and resolved compatibility facts. + #[serde(default)] + pub compatibility: DynamicPluginCompatibility, + /// Resolved runtime entry contract. + #[serde(default)] + pub load: DynamicPluginLoadContract, + /// Observed state. + #[serde(default)] + pub status: DynamicPluginStatus, +} + +impl DynamicPluginRecord { + /// Returns `true` when the runtime has observed the current desired-state generation. + pub fn is_reconciled(&self) -> bool { + self.status.runtime.observed_generation == self.metadata.generation + } + + /// Returns `true` when the record is tombstoned. + pub fn is_tombstoned(&self) -> bool { + !self.spec.present + } +} + +pub(crate) fn current_timestamp() -> String { + Utc::now().to_rfc3339() +} + +pub(crate) fn stamp_creation_metadata(metadata: &mut DynamicPluginMetadata) { + if metadata.created_at.is_none() { + metadata.created_at = Some(current_timestamp()); + } + if metadata.updated_at.is_none() { + metadata.updated_at = metadata.created_at.clone(); + } +} + +pub(crate) fn touch_metadata(metadata: &mut DynamicPluginMetadata) { + metadata.updated_at = Some(current_timestamp()); +} + +pub(crate) fn bump_generation(record: &mut DynamicPluginRecord) { + record.metadata.generation = record.metadata.generation.saturating_add(1); + touch_metadata(&mut record.metadata); +} + +#[cfg(test)] +#[path = "../../tests/unit/plugin_dynamic_tests.rs"] +mod tests; diff --git a/crates/core/src/plugin/dynamic/manifest.rs b/crates/core/src/plugin/dynamic/manifest.rs new file mode 100644 index 00000000..38948bf0 --- /dev/null +++ b/crates/core/src/plugin/dynamic/manifest.rs @@ -0,0 +1,436 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::fs; +use std::path::Path; + +use serde::{Deserialize, Serialize}; + +use super::{ + DYNAMIC_PLUGIN_MANIFEST_FILENAME, DynamicPluginCapability, DynamicPluginCheckState, + DynamicPluginCompatibility, DynamicPluginId, DynamicPluginKind, DynamicPluginLoadContract, + DynamicPluginMetadata, DynamicPluginRecord, DynamicPluginSource, DynamicPluginSpec, + DynamicPluginStatus, DynamicPluginValidationStatus, WorkerRuntime, current_timestamp, +}; +use crate::plugin::{PluginError, Result}; + +/// Authored `relay-plugin.toml` manifest. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +pub struct DynamicPluginManifest { + /// Relay plugin manifest schema version. + pub manifest_version: u32, + /// Plugin identity and lane declaration. + pub plugin: DynamicPluginManifestPlugin, + /// Relay compatibility declarations. + pub compat: DynamicPluginManifestCompat, + /// Default desired-state settings. + pub defaults: DynamicPluginManifestDefaults, + /// Required capability declarations. + pub capabilities: DynamicPluginManifestCapabilities, + /// Runtime load contract. + pub load: DynamicPluginManifestLoad, + /// Optional source-oriented author metadata. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source: Option, + /// Optional integrity/authenticity evidence references. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub integrity: Option, + /// Optional human-oriented description. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, +} + +/// Plugin identity block for `relay-plugin.toml`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +pub struct DynamicPluginManifestPlugin { + /// Stable plugin identifier. + pub id: DynamicPluginId, + /// Optional display name. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + /// Optional plugin version. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub version: Option, + /// Execution lane. + pub kind: DynamicPluginKind, +} + +/// Compatibility block for `relay-plugin.toml`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +pub struct DynamicPluginManifestCompat { + /// Supported Relay version or version range. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub relay: Option, + /// Native plugin contract version. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub native_api: Option, + /// Worker protocol version. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub worker_protocol: Option, +} + +/// Defaults block for authored manifests. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +pub struct DynamicPluginManifestDefaults { + /// Explicit default desired enabled state. + #[serde(default)] + pub enabled: bool, +} + +/// Capability declarations for authored manifests. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +pub struct DynamicPluginManifestCapabilities { + /// Required capability set. + pub items: Vec, +} + +/// Load block for authored manifests. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +pub struct DynamicPluginManifestLoad { + /// Worker runtime when `kind = "worker"`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub runtime: Option, + /// Worker entrypoint. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub entrypoint: Option, + /// Native library path. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub library: Option, + /// Native registration symbol. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub symbol: Option, +} + +/// Source block for authored manifests. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +pub struct DynamicPluginManifestSource { + /// Author-facing manifest root or package root. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub manifest_root: Option, + /// Author-facing artifact hint. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub artifact: Option, +} + +/// Integrity/authenticity evidence block for authored manifests. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +pub struct DynamicPluginManifestIntegrity { + /// Expected artifact digest. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sha256: Option, + /// Optional signature reference. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub signature: Option, +} + +impl DynamicPluginManifest { + /// Parses a `relay-plugin.toml` manifest from TOML text. + pub fn parse_toml(toml_source: &str) -> Result { + let manifest = toml::from_str::(toml_source).map_err(|err| { + PluginError::InvalidConfig(format!("invalid relay-plugin.toml: {err}")) + })?; + manifest.validate()?; + Ok(manifest) + } + + /// Loads and validates a `relay-plugin.toml` manifest from a file path or directory. + pub fn load_from_path(path: impl AsRef) -> Result<(Self, String)> { + let path = path.as_ref(); + let manifest_path = if path.is_dir() { + path.join(DYNAMIC_PLUGIN_MANIFEST_FILENAME) + } else { + path.to_path_buf() + }; + + let display_ref = manifest_path.to_string_lossy().into_owned(); + let exists = manifest_path.try_exists().map_err(|err| { + PluginError::Internal(format!("failed to inspect '{}': {err}", display_ref)) + })?; + if !exists { + return Err(PluginError::NotFound(format!( + "dynamic plugin manifest '{}' does not exist", + display_ref + ))); + } + + let normalized_manifest_path = fs::canonicalize(&manifest_path).map_err(|err| { + PluginError::Internal(format!( + "failed to normalize dynamic plugin manifest '{}': {err}", + display_ref + )) + })?; + let manifest_ref = normalized_manifest_path.to_string_lossy().into_owned(); + + let contents = fs::read_to_string(&normalized_manifest_path).map_err(|err| { + PluginError::Internal(format!( + "failed to read dynamic plugin manifest '{}': {err}", + manifest_ref + )) + })?; + let manifest = Self::parse_toml(&contents)?; + Ok((manifest, manifest_ref)) + } + + /// Validates the authored manifest against the v1 contract. + pub fn validate(&self) -> Result<()> { + if self.manifest_version != 1 { + return Err(PluginError::InvalidConfig(format!( + "unsupported relay-plugin.toml manifest_version {}; expected 1", + self.manifest_version + ))); + } + if self.plugin.id.trim().is_empty() { + return Err(PluginError::InvalidConfig( + "plugin.id must not be empty".into(), + )); + } + ensure_optional_string_non_empty(self.plugin.name.as_deref(), "plugin.name")?; + ensure_optional_string_non_empty(self.plugin.version.as_deref(), "plugin.version")?; + ensure_optional_string_non_empty(self.description.as_deref(), "description")?; + ensure_optional_string_non_empty( + self.source + .as_ref() + .and_then(|source| source.manifest_root.as_deref()), + "source.manifest_root", + )?; + ensure_optional_string_non_empty( + self.source + .as_ref() + .and_then(|source| source.artifact.as_deref()), + "source.artifact", + )?; + ensure_optional_string_non_empty( + self.integrity + .as_ref() + .and_then(|integrity| integrity.sha256.as_deref()), + "integrity.sha256", + )?; + ensure_optional_string_non_empty( + self.integrity + .as_ref() + .and_then(|integrity| integrity.signature.as_deref()), + "integrity.signature", + )?; + + required_trimmed_string(self.compat.relay.as_deref(), "compat.relay")?; + if self.capabilities.items.is_empty() { + return Err(PluginError::InvalidConfig( + "capabilities.items must declare at least one capability".into(), + )); + } + reject_duplicate_capabilities(&self.capabilities.items)?; + + if self.defaults.enabled { + return Err(PluginError::InvalidConfig( + "defaults.enabled=true is not supported for dynamic plugins; plugins are added disabled and require explicit enablement".into(), + )); + } + + validate_capability_shape(self.plugin.kind, &self.capabilities.items)?; + validate_load_shape(self.plugin.kind, &self.load)?; + validate_compat_shape(self.plugin.kind, &self.compat)?; + Ok(()) + } + + /// Converts the authored manifest into a durable control-plane record. + pub fn into_record(self, manifest_ref: Option) -> Result { + self.validate()?; + let validation = self.validation_status(); + + Ok(DynamicPluginRecord { + metadata: DynamicPluginMetadata { + id: self.plugin.id, + name: self.plugin.name, + version: self.plugin.version, + kind: self.plugin.kind, + generation: 0, + created_at: None, + updated_at: None, + }, + capabilities: self.capabilities.items, + source: DynamicPluginSource { + manifest_ref, + artifact_ref: self + .source + .as_ref() + .and_then(|source| source.artifact.clone()), + environment_ref: None, + artifact_digest: self + .integrity + .as_ref() + .and_then(|integrity| integrity.sha256.clone()), + }, + spec: DynamicPluginSpec { + present: true, + enabled: false, + config_ref: None, + }, + compatibility: DynamicPluginCompatibility { + relay: self.compat.relay, + native_api: self.compat.native_api, + worker_protocol: self.compat.worker_protocol, + }, + load: DynamicPluginLoadContract { + worker_runtime: self.load.runtime, + entrypoint: self.load.entrypoint, + library: self.load.library, + symbol: self.load.symbol, + }, + status: DynamicPluginStatus { + validation, + ..DynamicPluginStatus::default() + }, + }) + } + + /// Produces the initial validation status for a successfully validated manifest. + pub fn validation_status(&self) -> DynamicPluginValidationStatus { + DynamicPluginValidationStatus { + manifest: DynamicPluginCheckState::Valid, + compatibility: DynamicPluginCheckState::Unknown, + integrity: DynamicPluginCheckState::Unknown, + environment: DynamicPluginCheckState::Unknown, + authenticity: DynamicPluginCheckState::Unknown, + policy_satisfied: DynamicPluginCheckState::Unknown, + checked_at: Some(current_timestamp()), + message: Some("manifest validated".into()), + } + } +} + +fn required_string<'a>(value: Option<&'a str>, field: &str) -> Result<&'a str> { + value.ok_or_else(|| PluginError::InvalidConfig(format!("{field} is required"))) +} + +fn required_trimmed_string<'a>(value: Option<&'a str>, field: &str) -> Result<&'a str> { + let value = required_string(value, field)?; + if value.trim().is_empty() { + return Err(PluginError::InvalidConfig(format!( + "{field} must not be empty" + ))); + } + Ok(value) +} + +fn ensure_optional_string_non_empty(value: Option<&str>, field: &str) -> Result<()> { + if value.is_some_and(|value| value.trim().is_empty()) { + return Err(PluginError::InvalidConfig(format!( + "{field} must not be empty when provided" + ))); + } + Ok(()) +} + +fn reject_duplicate_capabilities(capabilities: &[DynamicPluginCapability]) -> Result<()> { + let mut seen = Vec::with_capacity(capabilities.len()); + for capability in capabilities { + if seen.contains(capability) { + return Err(PluginError::InvalidConfig(format!( + "capabilities.items contains duplicate capability '{capability:?}'" + ))); + } + seen.push(*capability); + } + Ok(()) +} + +fn validate_capability_shape( + kind: DynamicPluginKind, + capabilities: &[DynamicPluginCapability], +) -> Result<()> { + let has_native = capabilities.contains(&DynamicPluginCapability::PluginNative); + let has_worker = capabilities.contains(&DynamicPluginCapability::PluginWorker); + match kind { + DynamicPluginKind::RustDynamic => { + if !has_native { + return Err(PluginError::InvalidConfig( + "rust_dynamic plugins must declare capabilities.items containing plugin_native" + .into(), + )); + } + if has_worker { + return Err(PluginError::InvalidConfig( + "rust_dynamic plugins must not declare plugin_worker".into(), + )); + } + } + DynamicPluginKind::Worker => { + if !has_worker { + return Err(PluginError::InvalidConfig( + "worker plugins must declare capabilities.items containing plugin_worker" + .into(), + )); + } + if has_native { + return Err(PluginError::InvalidConfig( + "worker plugins must not declare plugin_native".into(), + )); + } + } + } + Ok(()) +} + +fn validate_load_shape(kind: DynamicPluginKind, load: &DynamicPluginManifestLoad) -> Result<()> { + match kind { + DynamicPluginKind::RustDynamic => { + required_trimmed_string(load.library.as_deref(), "load.library")?; + required_trimmed_string(load.symbol.as_deref(), "load.symbol")?; + if load.runtime.is_some() || load.entrypoint.is_some() { + return Err(PluginError::InvalidConfig( + "rust_dynamic plugins must not declare load.runtime or load.entrypoint".into(), + )); + } + } + DynamicPluginKind::Worker => { + required_trimmed_string(load.entrypoint.as_deref(), "load.entrypoint")?; + match load.runtime { + Some(WorkerRuntime::Python) => {} + None => { + return Err(PluginError::InvalidConfig( + "worker plugins must declare load.runtime".into(), + )); + } + } + if load.library.is_some() || load.symbol.is_some() { + return Err(PluginError::InvalidConfig( + "worker plugins must not declare load.library or load.symbol".into(), + )); + } + } + } + Ok(()) +} + +fn validate_compat_shape( + kind: DynamicPluginKind, + compat: &DynamicPluginManifestCompat, +) -> Result<()> { + match kind { + DynamicPluginKind::RustDynamic => { + required_trimmed_string(compat.native_api.as_deref(), "compat.native_api")?; + if compat.worker_protocol.is_some() { + return Err(PluginError::InvalidConfig( + "rust_dynamic plugins must not declare compat.worker_protocol".into(), + )); + } + } + DynamicPluginKind::Worker => { + required_trimmed_string(compat.worker_protocol.as_deref(), "compat.worker_protocol")?; + if compat.native_api.is_some() { + return Err(PluginError::InvalidConfig( + "worker plugins must not declare compat.native_api".into(), + )); + } + } + } + Ok(()) +} diff --git a/crates/core/src/plugin/dynamic/registry.rs b/crates/core/src/plugin/dynamic/registry.rs new file mode 100644 index 00000000..1e439c2d --- /dev/null +++ b/crates/core/src/plugin/dynamic/registry.rs @@ -0,0 +1,253 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::collections::BTreeMap; + +use super::{ + DynamicPluginFailure, DynamicPluginId, DynamicPluginManifest, DynamicPluginMetadata, + DynamicPluginRecord, DynamicPluginRuntimeStatus, DynamicPluginValidationStatus, + bump_generation, stamp_creation_metadata, touch_metadata, +}; +use crate::plugin::{PluginError, Result}; + +/// In-memory dynamic plugin registry used by the control plane. +#[derive(Debug, Default)] +pub struct DynamicPluginRegistry { + records: BTreeMap, +} + +impl DynamicPluginRegistry { + /// Creates an empty dynamic plugin registry. + pub fn new() -> Self { + Self::default() + } + + /// Returns the registered record for `plugin_id`, if present. + pub fn get(&self, plugin_id: &str) -> Option<&DynamicPluginRecord> { + self.records.get(plugin_id) + } + + /// Lists records, hiding tombstones unless requested. + pub fn list(&self, include_tombstoned: bool) -> Vec<&DynamicPluginRecord> { + self.records + .values() + .filter(|record| include_tombstoned || !record.is_tombstoned()) + .collect() + } + + /// Adds a new dynamic plugin record. + /// + /// This is a trusted internal control-plane API. Callers that start from an + /// authored `relay-plugin.toml` manifest should prefer [`Self::add_manifest`] + /// so the manifest contract is enforced before record creation. + pub fn add(&mut self, mut record: DynamicPluginRecord) -> Result<&DynamicPluginRecord> { + validate_record_shape(&record)?; + + let plugin_id = record.metadata.id.clone(); + record.spec.present = true; + + if let Some(existing) = self.records.get(&plugin_id) { + if !existing.is_tombstoned() { + return Err(PluginError::Conflict(format!( + "dynamic plugin '{plugin_id}' is already registered" + ))); + } + + inherit_tombstoned_lineage(&mut record.metadata, &existing.metadata); + } + + stamp_creation_metadata(&mut record.metadata); + touch_metadata(&mut record.metadata); + + self.records.insert(plugin_id.clone(), record); + Ok(self + .records + .get(&plugin_id) + .expect("dynamic plugin record must exist immediately after insert")) + } + + /// Validates an authored manifest and registers the resulting dynamic plugin record. + pub fn add_manifest( + &mut self, + manifest: DynamicPluginManifest, + manifest_ref: Option, + ) -> Result<&DynamicPluginRecord> { + let record = manifest.into_record(manifest_ref)?; + self.add(record) + } + + /// Marks the plugin enabled in desired state. + pub fn enable(&mut self, plugin_id: &str) -> Result { + let record = self.lookup_mut(plugin_id)?; + ensure_live_record(record, plugin_id)?; + if record.spec.enabled { + return Ok(false); + } + record.spec.enabled = true; + bump_generation(record); + Ok(true) + } + + /// Marks the plugin disabled in desired state. + pub fn disable(&mut self, plugin_id: &str) -> Result { + let record = self.lookup_mut(plugin_id)?; + ensure_live_record(record, plugin_id)?; + if !record.spec.enabled { + return Ok(false); + } + record.spec.enabled = false; + bump_generation(record); + Ok(true) + } + + /// Tombstones the plugin record and disables desired runtime realization. + pub fn remove(&mut self, plugin_id: &str) -> Result { + let record = self.lookup_mut(plugin_id)?; + if record.is_tombstoned() { + return Ok(false); + } + record.spec.present = false; + record.spec.enabled = false; + bump_generation(record); + Ok(true) + } + + /// Replaces the current validation status without mutating desired state. + pub fn update_validation_status( + &mut self, + plugin_id: &str, + mut validation: DynamicPluginValidationStatus, + ) -> Result<()> { + validation.checked_at = Some(super::current_timestamp()); + let record = self.lookup_mut(plugin_id)?; + record.status.validation = validation; + Ok(()) + } + + /// Replaces the current runtime status without mutating desired state. + pub fn update_runtime_status( + &mut self, + plugin_id: &str, + mut runtime: DynamicPluginRuntimeStatus, + ) -> Result<()> { + runtime.updated_at = Some(super::current_timestamp()); + let record = self.lookup_mut(plugin_id)?; + record.status.runtime = runtime; + Ok(()) + } + + /// Records the most recent dynamic-plugin failure summary. + pub fn update_last_error( + &mut self, + plugin_id: &str, + last_error: Option, + ) -> Result<()> { + let record = self.lookup_mut(plugin_id)?; + record.status.last_error = last_error; + Ok(()) + } + + fn lookup_mut(&mut self, plugin_id: &str) -> Result<&mut DynamicPluginRecord> { + self.records.get_mut(plugin_id).ok_or_else(|| { + PluginError::NotFound(format!("dynamic plugin '{plugin_id}' is not registered")) + }) + } +} + +fn ensure_live_record(record: &DynamicPluginRecord, plugin_id: &str) -> Result<()> { + if record.is_tombstoned() { + return Err(PluginError::Conflict(format!( + "dynamic plugin '{plugin_id}' has been removed" + ))); + } + Ok(()) +} + +fn inherit_tombstoned_lineage( + metadata: &mut DynamicPluginMetadata, + existing: &DynamicPluginMetadata, +) { + let next_generation = existing.generation.saturating_add(1); + if metadata.created_at.is_none() { + metadata.created_at = existing.created_at.clone(); + } + metadata.generation = next_generation; +} + +fn validate_record_shape(record: &DynamicPluginRecord) -> Result<()> { + if record.metadata.id.trim().is_empty() { + return Err(PluginError::InvalidConfig( + "dynamic plugin id must not be empty".into(), + )); + } + + let has_native = record + .capabilities + .contains(&super::DynamicPluginCapability::PluginNative); + let has_worker = record + .capabilities + .contains(&super::DynamicPluginCapability::PluginWorker); + + match record.metadata.kind { + super::DynamicPluginKind::RustDynamic => { + if !has_native || has_worker { + return Err(PluginError::InvalidConfig( + "dynamic rust_dynamic record has invalid capability shape".into(), + )); + } + if record.compatibility.native_api.is_none() + || record.compatibility.worker_protocol.is_some() + { + return Err(PluginError::InvalidConfig( + "dynamic rust_dynamic record has invalid compatibility shape".into(), + )); + } + if record + .load + .library + .as_deref() + .is_none_or(|value| value.trim().is_empty()) + || record + .load + .symbol + .as_deref() + .is_none_or(|value| value.trim().is_empty()) + || record.load.worker_runtime.is_some() + || record.load.entrypoint.is_some() + { + return Err(PluginError::InvalidConfig( + "dynamic rust_dynamic record has invalid load shape".into(), + )); + } + } + super::DynamicPluginKind::Worker => { + if !has_worker || has_native { + return Err(PluginError::InvalidConfig( + "dynamic worker record has invalid capability shape".into(), + )); + } + if record.compatibility.worker_protocol.is_none() + || record.compatibility.native_api.is_some() + { + return Err(PluginError::InvalidConfig( + "dynamic worker record has invalid compatibility shape".into(), + )); + } + if record + .load + .entrypoint + .as_deref() + .is_none_or(|value| value.trim().is_empty()) + || record.load.worker_runtime.is_none() + || record.load.library.is_some() + || record.load.symbol.is_some() + { + return Err(PluginError::InvalidConfig( + "dynamic worker record has invalid load shape".into(), + )); + } + } + } + + Ok(()) +} diff --git a/crates/core/tests/unit/plugin_dynamic_tests.rs b/crates/core/tests/unit/plugin_dynamic_tests.rs new file mode 100644 index 00000000..29855259 --- /dev/null +++ b/crates/core/tests/unit/plugin_dynamic_tests.rs @@ -0,0 +1,1050 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Unit tests for the dynamic plugin control-plane model. + +use super::*; +use crate::plugin::PluginError; +use std::fs; +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +fn temp_dir(prefix: &str) -> PathBuf { + let id = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let path = std::env::temp_dir().join(format!("nemo-relay-{prefix}-{id}")); + fs::create_dir_all(&path).unwrap(); + path +} + +fn sample_record() -> DynamicPluginRecord { + DynamicPluginRecord { + metadata: DynamicPluginMetadata { + id: "acme.guardrails.pii".into(), + name: Some("PII Guardrails".into()), + version: Some("0.1.0".into()), + kind: DynamicPluginKind::Worker, + generation: 3, + created_at: Some("2026-06-16T00:00:00Z".into()), + updated_at: Some("2026-06-16T00:00:00Z".into()), + }, + capabilities: vec![ + DynamicPluginCapability::PluginWorker, + DynamicPluginCapability::MiddlewareGuardrail, + ], + source: DynamicPluginSource { + manifest_ref: Some("/plugins/pii/relay-plugin.toml".into()), + artifact_ref: Some("/plugins/pii/dist/pii.whl".into()), + environment_ref: Some("/plugins/pii/.venv".into()), + artifact_digest: Some("sha256:abc123".into()), + }, + spec: DynamicPluginSpec { + present: true, + enabled: true, + config_ref: Some("plugins.acme.guardrails.pii".into()), + }, + compatibility: DynamicPluginCompatibility { + relay: Some(">=0.1.0,<0.2.0".into()), + native_api: None, + worker_protocol: Some("1".into()), + }, + load: DynamicPluginLoadContract { + worker_runtime: Some(WorkerRuntime::Python), + entrypoint: Some("acme_guardrails.plugin:register".into()), + library: None, + symbol: None, + }, + status: DynamicPluginStatus { + validation: DynamicPluginValidationStatus { + manifest: DynamicPluginCheckState::Valid, + compatibility: DynamicPluginCheckState::Valid, + integrity: DynamicPluginCheckState::Unknown, + environment: DynamicPluginCheckState::Valid, + authenticity: DynamicPluginCheckState::Unknown, + policy_satisfied: DynamicPluginCheckState::Valid, + checked_at: Some("2026-06-16T00:00:01Z".into()), + message: Some("ready".into()), + }, + runtime: DynamicPluginRuntimeStatus { + state: DynamicPluginRuntimeState::Running, + observed_generation: 3, + started_at: Some("2026-06-16T00:00:02Z".into()), + updated_at: Some("2026-06-16T00:00:02Z".into()), + message: Some("running".into()), + }, + startup_class: Some(DynamicPluginStartupClass::Optional), + attestation_mode: Some(DynamicPluginAttestationMode::IntegrityOnly), + last_error: None, + }, + } +} + +#[test] +fn dynamic_plugin_spec_defaults_to_present_but_disabled() { + let spec = DynamicPluginSpec::default(); + assert!(spec.present); + assert!(!spec.enabled); + assert!(spec.config_ref.is_none()); +} + +#[test] +fn dynamic_plugin_status_defaults_to_unknown_validation_and_stopped_runtime() { + let status = DynamicPluginStatus::default(); + assert_eq!(status.validation.manifest, DynamicPluginCheckState::Unknown); + assert_eq!( + status.validation.policy_satisfied, + DynamicPluginCheckState::Unknown + ); + assert_eq!(status.runtime.state, DynamicPluginRuntimeState::Stopped); + assert_eq!(status.runtime.observed_generation, 0); + assert!(status.last_error.is_none()); +} + +#[test] +fn dynamic_plugin_record_reports_reconciliation_from_generation() { + let record = sample_record(); + assert!(record.is_reconciled()); + + let mut stale = record.clone(); + stale.status.runtime.observed_generation = 2; + assert!(!stale.is_reconciled()); +} + +#[test] +fn dynamic_plugin_record_tombstone_tracks_presence_in_spec() { + let record = sample_record(); + assert!(!record.is_tombstoned()); + + let mut removed = record.clone(); + removed.spec.present = false; + assert!(removed.is_tombstoned()); +} + +#[test] +fn dynamic_plugin_record_round_trips_through_json() { + let record = sample_record(); + let json = serde_json::to_value(&record).expect("serialize dynamic plugin record"); + let decoded: DynamicPluginRecord = + serde_json::from_value(json).expect("deserialize dynamic plugin record"); + assert_eq!(decoded, record); +} + +#[test] +fn registry_adds_record_and_lists_live_entries_only_by_default() { + let mut registry = DynamicPluginRegistry::new(); + registry.add(sample_record()).expect("register plugin"); + + let live = registry.list(false); + assert_eq!(live.len(), 1); + assert_eq!(live[0].metadata.id, "acme.guardrails.pii"); + + let all = registry.list(true); + assert_eq!(all.len(), 1); +} + +#[test] +fn registry_rejects_duplicate_live_plugin_ids() { + let mut registry = DynamicPluginRegistry::new(); + registry.add(sample_record()).expect("register plugin"); + + let err = registry + .add(sample_record()) + .expect_err("duplicate id should fail"); + match err { + PluginError::Conflict(message) => { + assert!(message.contains("already registered"), "{message}"); + } + other => panic!("unexpected duplicate error: {other}"), + } +} + +#[test] +fn registry_rejects_invalid_raw_record_shapes() { + let mut registry = DynamicPluginRegistry::new(); + let mut record = sample_record(); + record.capabilities = vec![DynamicPluginCapability::PluginNative]; + + let err = registry + .add(record) + .expect_err("invalid raw record shape should fail"); + match err { + PluginError::InvalidConfig(message) => { + assert!(message.contains("capability shape"), "{message}"); + } + other => panic!("unexpected invalid raw record error: {other}"), + } +} + +#[test] +fn registry_rejects_invalid_raw_record_load_shapes() { + let mut registry = DynamicPluginRegistry::new(); + let mut record = sample_record(); + record.load.entrypoint = None; + + let err = registry + .add(record) + .expect_err("invalid raw worker load shape should fail"); + match err { + PluginError::InvalidConfig(message) => { + assert!(message.contains("load shape"), "{message}"); + } + other => panic!("unexpected invalid raw load error: {other}"), + } +} + +#[test] +fn registry_rejects_invalid_raw_record_compatibility_shapes() { + let mut registry = DynamicPluginRegistry::new(); + let mut record = sample_record(); + record.compatibility.worker_protocol = None; + + let err = registry + .add(record) + .expect_err("invalid raw worker compatibility shape should fail"); + match err { + PluginError::InvalidConfig(message) => { + assert!(message.contains("compatibility shape"), "{message}"); + } + other => panic!("unexpected invalid raw compatibility error: {other}"), + } +} + +#[test] +fn registry_enable_disable_and_remove_are_generation_bumping_spec_mutations() { + let mut registry = DynamicPluginRegistry::new(); + let mut record = sample_record(); + record.metadata.generation = 0; + record.spec.enabled = false; + record.status.runtime.observed_generation = 0; + registry.add(record).expect("register plugin"); + + assert!( + registry + .enable("acme.guardrails.pii") + .expect("enable plugin") + ); + let enabled = registry.get("acme.guardrails.pii").expect("enabled record"); + assert!(enabled.spec.enabled); + assert_eq!(enabled.metadata.generation, 1); + + assert!( + !registry + .enable("acme.guardrails.pii") + .expect("idempotent enable") + ); + let still_enabled = registry.get("acme.guardrails.pii").expect("enabled record"); + assert_eq!(still_enabled.metadata.generation, 1); + + assert!( + registry + .disable("acme.guardrails.pii") + .expect("disable plugin") + ); + let disabled = registry + .get("acme.guardrails.pii") + .expect("disabled record"); + assert!(!disabled.spec.enabled); + assert_eq!(disabled.metadata.generation, 2); + + assert!( + registry + .remove("acme.guardrails.pii") + .expect("remove plugin") + ); + let removed = registry.get("acme.guardrails.pii").expect("removed record"); + assert!(removed.is_tombstoned()); + assert!(!removed.spec.enabled); + assert_eq!(removed.metadata.generation, 3); + + assert_eq!(registry.list(false).len(), 0); + assert_eq!(registry.list(true).len(), 1); +} + +#[test] +fn registry_can_revive_tombstoned_ids_and_preserve_logical_lineage() { + let mut registry = DynamicPluginRegistry::new(); + let mut original = sample_record(); + original.metadata.generation = 4; + original.spec.enabled = false; + original.metadata.created_at = Some("2026-06-01T00:00:00Z".into()); + registry.add(original).expect("register plugin"); + registry + .remove("acme.guardrails.pii") + .expect("tombstone plugin"); + + let mut revived = sample_record(); + revived.metadata.generation = 0; + revived.metadata.created_at = None; + revived.spec.enabled = false; + registry.add(revived).expect("revive plugin"); + + let record = registry.get("acme.guardrails.pii").expect("revived record"); + assert!(!record.is_tombstoned()); + assert_eq!(record.metadata.generation, 6); + assert_eq!( + record.metadata.created_at.as_deref(), + Some("2026-06-01T00:00:00Z") + ); +} + +#[test] +fn registry_status_updates_do_not_change_desired_state_generation() { + let mut registry = DynamicPluginRegistry::new(); + let mut record = sample_record(); + record.metadata.generation = 7; + registry.add(record).expect("register plugin"); + + registry + .update_validation_status( + "acme.guardrails.pii", + DynamicPluginValidationStatus { + manifest: DynamicPluginCheckState::Valid, + compatibility: DynamicPluginCheckState::Invalid, + integrity: DynamicPluginCheckState::Unknown, + environment: DynamicPluginCheckState::Valid, + authenticity: DynamicPluginCheckState::Unknown, + policy_satisfied: DynamicPluginCheckState::Invalid, + checked_at: Some("2026-06-16T01:00:00Z".into()), + message: Some("compatibility failed".into()), + }, + ) + .expect("update validation status"); + let checked_at = registry + .get("acme.guardrails.pii") + .and_then(|record| record.status.validation.checked_at.clone()) + .expect("registry should stamp validation checked_at"); + + registry + .update_runtime_status( + "acme.guardrails.pii", + DynamicPluginRuntimeStatus { + state: DynamicPluginRuntimeState::Failed, + observed_generation: 6, + started_at: None, + updated_at: None, + message: Some("worker crashed".into()), + }, + ) + .expect("update runtime status"); + let runtime_updated_at = registry + .get("acme.guardrails.pii") + .and_then(|record| record.status.runtime.updated_at.clone()) + .expect("registry should stamp runtime updated_at"); + registry + .update_last_error( + "acme.guardrails.pii", + Some(DynamicPluginFailure { + phase: DynamicPluginFailurePhase::Runtime, + code: "worker.crash".into(), + message: "worker crashed".into(), + }), + ) + .expect("update failure"); + + let record = registry.get("acme.guardrails.pii").expect("updated record"); + assert_eq!(record.metadata.generation, 7); + assert_eq!( + record.status.validation.compatibility, + DynamicPluginCheckState::Invalid + ); + assert!(!checked_at.trim().is_empty()); + assert_eq!( + record.status.runtime.state, + DynamicPluginRuntimeState::Failed + ); + assert!(!runtime_updated_at.trim().is_empty()); + assert_eq!( + record + .status + .last_error + .as_ref() + .map(|error| error.code.as_str()), + Some("worker.crash") + ); +} + +fn valid_worker_manifest_toml() -> &'static str { + r#" +manifest_version = 1 +description = "PII guardrail worker" + +[plugin] +id = "acme.guardrails.pii" +name = "PII Guardrails" +version = "0.1.0" +kind = "worker" + +[compat] +relay = ">=0.1.0,<0.2.0" +worker_protocol = "1" + +[defaults] +enabled = false + +[capabilities] +items = ["plugin_worker", "middleware_guardrail", "config_schema"] + +[load] +runtime = "python" +entrypoint = "acme_guardrails.plugin:register" + +[source] +manifest_root = "." +artifact = "dist/acme_guardrails.whl" + +[integrity] +sha256 = "sha256:abc123" +"# +} + +fn valid_rust_manifest_toml() -> &'static str { + r#" +manifest_version = 1 + +[plugin] +id = "acme.native.switchyard" +kind = "rust_dynamic" + +[compat] +relay = ">=0.1.0,<0.2.0" +native_api = "1" + +[defaults] +enabled = false + +[capabilities] +items = ["plugin_native", "middleware_interceptor"] + +[load] +library = "target/release/libswitchyard.dylib" +symbol = "nemo_relay_register_plugin" +"# +} + +#[test] +fn manifest_parse_and_conversion_supports_worker_lane() { + let manifest = + DynamicPluginManifest::parse_toml(valid_worker_manifest_toml()).expect("parse manifest"); + assert_eq!(manifest.plugin.kind, DynamicPluginKind::Worker); + assert_eq!(manifest.load.runtime, Some(WorkerRuntime::Python)); + + let record = manifest + .into_record(Some("/plugins/pii/relay-plugin.toml".into())) + .expect("manifest converts into record"); + assert_eq!(record.metadata.id, "acme.guardrails.pii"); + assert_eq!(record.metadata.version.as_deref(), Some("0.1.0")); + assert!(!record.spec.enabled); + assert_eq!(record.load.worker_runtime, Some(WorkerRuntime::Python)); + assert_eq!( + record.source.manifest_ref.as_deref(), + Some("/plugins/pii/relay-plugin.toml") + ); + assert_eq!( + record.source.artifact_ref.as_deref(), + Some("dist/acme_guardrails.whl") + ); + assert_eq!( + record.source.artifact_digest.as_deref(), + Some("sha256:abc123") + ); + assert_eq!( + record.status.validation.manifest, + DynamicPluginCheckState::Valid + ); + assert_eq!( + record.status.validation.compatibility, + DynamicPluginCheckState::Unknown + ); + assert_eq!( + record.status.validation.message.as_deref(), + Some("manifest validated") + ); +} + +#[test] +fn manifest_parse_and_conversion_supports_rust_dynamic_lane() { + let manifest = + DynamicPluginManifest::parse_toml(valid_rust_manifest_toml()).expect("parse manifest"); + let record = manifest + .into_record(None) + .expect("manifest converts into record"); + assert_eq!(record.metadata.kind, DynamicPluginKind::RustDynamic); + assert_eq!( + record.capabilities, + vec![ + DynamicPluginCapability::PluginNative, + DynamicPluginCapability::MiddlewareInterceptor + ] + ); + assert_eq!( + record.load.library.as_deref(), + Some("target/release/libswitchyard.dylib") + ); + assert_eq!(record.compatibility.native_api.as_deref(), Some("1")); +} + +#[test] +fn manifest_requires_kind_specific_worker_compatibility_and_load_fields() { + let err = DynamicPluginManifest::parse_toml( + r#" +manifest_version = 1 + +[plugin] +id = "acme.guardrails.bad" +kind = "worker" + +[compat] +relay = ">=0.1.0,<0.2.0" + +[defaults] +enabled = false + +[capabilities] +items = ["plugin_worker"] + +[load] +runtime = "python" +"#, + ) + .expect_err("worker manifest without required fields should fail"); + + match err { + PluginError::InvalidConfig(message) => { + assert!( + message.contains("compat.worker_protocol") || message.contains("load.entrypoint"), + "{message}" + ); + } + other => panic!("unexpected worker validation error: {other}"), + } +} + +#[test] +fn manifest_rejects_capability_kind_mismatch() { + let err = DynamicPluginManifest::parse_toml( + r#" +manifest_version = 1 + +[plugin] +id = "acme.native.bad" +kind = "rust_dynamic" + +[compat] +relay = ">=0.1.0,<0.2.0" +native_api = "1" + +[defaults] +enabled = false + +[capabilities] +items = ["plugin_worker"] + +[load] +library = "target/release/libbad.dylib" +symbol = "nemo_relay_register_plugin" +"#, + ) + .expect_err("capability mismatch should fail"); + + match err { + PluginError::InvalidConfig(message) => { + assert!(message.contains("plugin_native"), "{message}"); + } + other => panic!("unexpected capability validation error: {other}"), + } +} + +#[test] +fn manifest_rejects_empty_plugin_id() { + let err = DynamicPluginManifest::parse_toml( + r#" +manifest_version = 1 + +[plugin] +id = " " +kind = "worker" + +[compat] +relay = ">=0.1.0,<0.2.0" +worker_protocol = "1" + +[defaults] +enabled = false + +[capabilities] +items = ["plugin_worker"] + +[load] +runtime = "python" +entrypoint = "acme_guardrails.plugin:register" +"#, + ) + .expect_err("empty plugin id should fail"); + + match err { + PluginError::InvalidConfig(message) => { + assert!(message.contains("plugin.id"), "{message}"); + } + other => panic!("unexpected empty-id validation error: {other}"), + } +} + +#[test] +fn manifest_requires_native_api_for_rust_dynamic_plugins() { + let err = DynamicPluginManifest::parse_toml( + r#" +manifest_version = 1 + +[plugin] +id = "acme.native.missing-api" +kind = "rust_dynamic" + +[compat] +relay = ">=0.1.0,<0.2.0" + +[defaults] +enabled = false + +[capabilities] +items = ["plugin_native"] + +[load] +library = "target/release/libmissing.dylib" +symbol = "nemo_relay_register_plugin" +"#, + ) + .expect_err("native manifest without compat.native_api should fail"); + + match err { + PluginError::InvalidConfig(message) => { + assert!(message.contains("compat.native_api"), "{message}"); + } + other => panic!("unexpected native compatibility validation error: {other}"), + } +} + +#[test] +fn manifest_rejects_worker_fields_for_rust_dynamic_plugins() { + let err = DynamicPluginManifest::parse_toml( + r#" +manifest_version = 1 + +[plugin] +id = "acme.native.bad-load" +kind = "rust_dynamic" + +[compat] +relay = ">=0.1.0,<0.2.0" +native_api = "1" + +[defaults] +enabled = false + +[capabilities] +items = ["plugin_native"] + +[load] +runtime = "python" +entrypoint = "bad.plugin:register" +library = "target/release/libbad.dylib" +symbol = "nemo_relay_register_plugin" +"#, + ) + .expect_err("native manifest with worker load fields should fail"); + + match err { + PluginError::InvalidConfig(message) => { + assert!( + message.contains("load.runtime") || message.contains("load.entrypoint"), + "{message}" + ); + } + other => panic!("unexpected native load validation error: {other}"), + } +} + +#[test] +fn manifest_load_from_file_path_returns_manifest_and_manifest_ref() { + let dir = temp_dir("dynamic-plugin-manifest-file"); + let path = dir.join("custom-plugin.toml"); + fs::write(&path, valid_worker_manifest_toml()).expect("write manifest"); + let canonical = fs::canonicalize(&path).expect("canonicalize manifest path"); + + let (manifest, manifest_ref) = + DynamicPluginManifest::load_from_path(&path).expect("load manifest from file"); + assert_eq!(manifest.plugin.id, "acme.guardrails.pii"); + assert_eq!(manifest_ref, canonical.to_string_lossy()); +} + +#[test] +fn manifest_load_from_directory_resolves_canonical_filename() { + let dir = temp_dir("dynamic-plugin-manifest-dir"); + let path = dir.join(DYNAMIC_PLUGIN_MANIFEST_FILENAME); + fs::write(&path, valid_rust_manifest_toml()).expect("write manifest"); + let canonical = fs::canonicalize(&path).expect("canonicalize manifest path"); + + let (manifest, manifest_ref) = + DynamicPluginManifest::load_from_path(&dir).expect("load manifest from dir"); + assert_eq!(manifest.plugin.id, "acme.native.switchyard"); + assert_eq!(manifest_ref, canonical.to_string_lossy()); +} + +#[test] +fn manifest_load_from_directory_errors_when_manifest_is_missing() { + let dir = temp_dir("dynamic-plugin-manifest-missing"); + let err = DynamicPluginManifest::load_from_path(&dir) + .expect_err("missing relay-plugin.toml should fail"); + + match err { + PluginError::NotFound(message) => { + assert!( + message.contains(DYNAMIC_PLUGIN_MANIFEST_FILENAME), + "{message}" + ); + } + other => panic!("unexpected missing-manifest error: {other}"), + } +} + +#[test] +fn manifest_load_from_missing_file_path_errors_with_not_found() { + let dir = temp_dir("dynamic-plugin-manifest-missing-file"); + let missing = dir.join("relay-plugin.toml"); + let err = DynamicPluginManifest::load_from_path(&missing) + .expect_err("missing explicit manifest file should fail"); + + match err { + PluginError::NotFound(message) => { + assert!(message.contains("does not exist"), "{message}"); + } + other => panic!("unexpected missing-file error: {other}"), + } +} + +#[test] +fn registry_add_manifest_converts_and_registers_validated_record() { + let manifest = + DynamicPluginManifest::parse_toml(valid_worker_manifest_toml()).expect("parse manifest"); + let mut registry = DynamicPluginRegistry::new(); + + let record = registry + .add_manifest(manifest, Some("/plugins/pii/relay-plugin.toml".into())) + .expect("add manifest"); + assert_eq!(record.metadata.id, "acme.guardrails.pii"); + assert_eq!( + record.status.validation.manifest, + DynamicPluginCheckState::Valid + ); + assert_eq!( + record.status.validation.compatibility, + DynamicPluginCheckState::Unknown + ); + assert_eq!( + record.source.manifest_ref.as_deref(), + Some("/plugins/pii/relay-plugin.toml") + ); +} + +#[test] +fn registry_add_manifest_preserves_canonicalized_manifest_path() { + let dir = temp_dir("dynamic-plugin-add-manifest-path"); + let path = dir.join(DYNAMIC_PLUGIN_MANIFEST_FILENAME); + fs::write(&path, valid_worker_manifest_toml()).expect("write manifest"); + let (manifest, manifest_ref) = + DynamicPluginManifest::load_from_path(&dir).expect("load manifest from dir"); + let canonical = fs::canonicalize(&path).expect("canonicalize manifest path"); + + let mut registry = DynamicPluginRegistry::new(); + let record = registry + .add_manifest(manifest, Some(manifest_ref)) + .expect("add manifest"); + + assert_eq!( + record.source.manifest_ref.as_deref(), + Some(canonical.to_string_lossy().as_ref()) + ); +} + +#[test] +fn manifest_rejects_defaults_enabled_true_for_dynamic_plugins() { + let err = DynamicPluginManifest::parse_toml( + r#" +manifest_version = 1 + +[plugin] +id = "acme.guardrails.enabled" +kind = "worker" + +[compat] +relay = ">=0.1.0,<0.2.0" +worker_protocol = "1" + +[defaults] +enabled = true + +[capabilities] +items = ["plugin_worker"] + +[load] +runtime = "python" +entrypoint = "acme_guardrails.plugin:register" +"#, + ) + .expect_err("defaults.enabled=true should fail"); + + match err { + PluginError::InvalidConfig(message) => { + assert!(message.contains("added disabled"), "{message}"); + } + other => panic!("unexpected defaults.enabled error: {other}"), + } +} + +#[test] +fn manifest_rejects_empty_optional_source_and_integrity_strings() { + let err = DynamicPluginManifest::parse_toml( + r#" +manifest_version = 1 + +[plugin] +id = "acme.guardrails.empty-paths" +kind = "worker" + +[compat] +relay = ">=0.1.0,<0.2.0" +worker_protocol = "1" + +[defaults] +enabled = false + +[capabilities] +items = ["plugin_worker"] + +[load] +runtime = "python" +entrypoint = "acme_guardrails.plugin:register" + +[source] +manifest_root = " " +artifact = " " + +[integrity] +sha256 = " " +signature = " " +"#, + ) + .expect_err("empty source/integrity strings should fail"); + + match err { + PluginError::InvalidConfig(message) => { + assert!( + message.contains("source.manifest_root") + || message.contains("source.artifact") + || message.contains("integrity.sha256") + || message.contains("integrity.signature"), + "{message}" + ); + } + other => panic!("unexpected source/integrity validation error: {other}"), + } +} + +#[test] +fn registry_add_manifest_rejects_duplicate_live_ids() { + let manifest = + DynamicPluginManifest::parse_toml(valid_worker_manifest_toml()).expect("parse manifest"); + let manifest_ref = Some("/plugins/pii/relay-plugin.toml".into()); + let mut registry = DynamicPluginRegistry::new(); + registry + .add_manifest(manifest.clone(), manifest_ref.clone()) + .expect("initial add_manifest succeeds"); + + let err = registry + .add_manifest(manifest, manifest_ref) + .expect_err("duplicate manifest add should fail"); + match err { + PluginError::Conflict(message) => { + assert!(message.contains("already registered"), "{message}"); + } + other => panic!("unexpected duplicate manifest error: {other}"), + } +} + +#[test] +fn registry_enable_and_disable_reject_tombstoned_records() { + let mut registry = DynamicPluginRegistry::new(); + registry.add(sample_record()).expect("register plugin"); + registry + .remove("acme.guardrails.pii") + .expect("tombstone plugin"); + + let enable_err = registry + .enable("acme.guardrails.pii") + .expect_err("cannot enable tombstoned plugin"); + match enable_err { + PluginError::Conflict(message) => { + assert!(message.contains("has been removed"), "{message}"); + } + other => panic!("unexpected tombstone enable error: {other}"), + } + + let disable_err = registry + .disable("acme.guardrails.pii") + .expect_err("cannot disable tombstoned plugin"); + match disable_err { + PluginError::Conflict(message) => { + assert!(message.contains("has been removed"), "{message}"); + } + other => panic!("unexpected tombstone disable error: {other}"), + } +} + +#[test] +fn registry_status_updates_require_existing_plugin_ids() { + let mut registry = DynamicPluginRegistry::new(); + + let validation_err = registry + .update_validation_status("missing.plugin", DynamicPluginValidationStatus::default()) + .expect_err("missing id should fail validation update"); + match validation_err { + PluginError::NotFound(message) => { + assert!(message.contains("missing.plugin"), "{message}"); + } + other => panic!("unexpected missing-id validation error: {other}"), + } + + let runtime_err = registry + .update_runtime_status("missing.plugin", DynamicPluginRuntimeStatus::default()) + .expect_err("missing id should fail runtime update"); + match runtime_err { + PluginError::NotFound(message) => { + assert!(message.contains("missing.plugin"), "{message}"); + } + other => panic!("unexpected missing-id runtime error: {other}"), + } + + let error_err = registry + .update_last_error("missing.plugin", None) + .expect_err("missing id should fail last-error update"); + match error_err { + PluginError::NotFound(message) => { + assert!(message.contains("missing.plugin"), "{message}"); + } + other => panic!("unexpected missing-id last-error error: {other}"), + } +} + +#[test] +fn manifest_rejects_unsupported_manifest_version() { + let err = DynamicPluginManifest::parse_toml( + r#" +manifest_version = 2 + +[plugin] +id = "acme.guardrails.future" +kind = "worker" + +[compat] +relay = ">=0.1.0,<0.2.0" +worker_protocol = "1" + +[defaults] +enabled = false + +[capabilities] +items = ["plugin_worker"] + +[load] +runtime = "python" +entrypoint = "future.plugin:register" +"#, + ) + .expect_err("unsupported manifest version should fail"); + + match err { + PluginError::InvalidConfig(message) => { + assert!(message.contains("manifest_version"), "{message}"); + } + other => panic!("unexpected manifest version error: {other}"), + } +} + +#[test] +fn manifest_rejects_duplicate_capabilities() { + let err = DynamicPluginManifest::parse_toml( + r#" +manifest_version = 1 + +[plugin] +id = "acme.guardrails.dupe-cap" +kind = "worker" + +[compat] +relay = ">=0.1.0,<0.2.0" +worker_protocol = "1" + +[defaults] +enabled = false + +[capabilities] +items = ["plugin_worker", "plugin_worker"] + +[load] +runtime = "python" +entrypoint = "acme_guardrails.plugin:register" +"#, + ) + .expect_err("duplicate capabilities should fail"); + + match err { + PluginError::InvalidConfig(message) => { + assert!(message.contains("duplicate capability"), "{message}"); + } + other => panic!("unexpected duplicate capability error: {other}"), + } +} + +#[test] +fn manifest_rejects_empty_optional_strings_when_present() { + let err = DynamicPluginManifest::parse_toml( + r#" +manifest_version = 1 +description = " " + +[plugin] +id = "acme.guardrails.empty-strings" +name = " " +version = " " +kind = "worker" + +[compat] +relay = ">=0.1.0,<0.2.0" +worker_protocol = "1" + +[defaults] +enabled = false + +[capabilities] +items = ["plugin_worker"] + +[load] +runtime = "python" +entrypoint = "acme_guardrails.plugin:register" +"#, + ) + .expect_err("empty optional strings should fail"); + + match err { + PluginError::InvalidConfig(message) => { + assert!( + message.contains("plugin.name") + || message.contains("plugin.version") + || message.contains("description"), + "{message}" + ); + } + other => panic!("unexpected empty optional string error: {other}"), + } +} diff --git a/crates/ffi/src/error.rs b/crates/ffi/src/error.rs index f014c847..78999b1f 100644 --- a/crates/ffi/src/error.rs +++ b/crates/ffi/src/error.rs @@ -138,9 +138,9 @@ pub fn status_from_plugin_error(e: &PluginError) -> NemoRelayStatus { set_last_error(&e.to_string()); match e { PluginError::NotFound(_) => NemoRelayStatus::NotFound, - PluginError::InvalidConfig(_) | PluginError::Serialization(_) => { - NemoRelayStatus::InvalidArg - } + PluginError::Conflict(_) + | PluginError::InvalidConfig(_) + | PluginError::Serialization(_) => NemoRelayStatus::InvalidArg, PluginError::Internal(_) | PluginError::RegistrationFailed(_) => NemoRelayStatus::Internal, } } From f54fedca1f777121d0f57fcb89778b3ea2871934 Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Tue, 16 Jun 2026 12:05:32 -0700 Subject: [PATCH 2/2] fix: preserve conflict semantics in plugin control-plane errors Signed-off-by: Alex Fournier --- crates/adaptive/src/error.rs | 2 +- crates/adaptive/tests/coverage/error_tests.rs | 6 ++++++ crates/core/src/plugin/dynamic/manifest.rs | 6 +++--- crates/core/src/plugin/dynamic/registry.rs | 3 +-- crates/core/tests/unit/plugin_dynamic_tests.rs | 1 + crates/ffi/src/error.rs | 7 ++++--- crates/ffi/tests/coverage/error_tests.rs | 5 +++++ 7 files changed, 21 insertions(+), 9 deletions(-) diff --git a/crates/adaptive/src/error.rs b/crates/adaptive/src/error.rs index 816121af..94579297 100644 --- a/crates/adaptive/src/error.rs +++ b/crates/adaptive/src/error.rs @@ -53,7 +53,7 @@ impl From for AdaptiveError { fn from(value: PluginError) -> Self { match value { PluginError::InvalidConfig(message) => Self::InvalidConfig(message), - PluginError::Conflict(message) => Self::InvalidConfig(message), + PluginError::Conflict(message) => Self::RegistrationFailed(message), PluginError::NotFound(message) => Self::NotFound(message), PluginError::Serialization(err) => Self::Serialization(err), PluginError::Internal(message) => Self::Internal(message), diff --git a/crates/adaptive/tests/coverage/error_tests.rs b/crates/adaptive/tests/coverage/error_tests.rs index ed8489a1..5cb32706 100644 --- a/crates/adaptive/tests/coverage/error_tests.rs +++ b/crates/adaptive/tests/coverage/error_tests.rs @@ -61,6 +61,12 @@ fn test_plugin_error_conversion_maps_all_variants() { let invalid = AdaptiveError::from(PluginError::InvalidConfig("bad config".into())); assert_eq!(format!("{invalid}"), "invalid config: bad config"); + let conflict = AdaptiveError::from(PluginError::Conflict("duplicate plugin".into())); + assert_eq!( + format!("{conflict}"), + "registration failed: duplicate plugin" + ); + let missing = AdaptiveError::from(PluginError::NotFound("missing plugin".into())); assert_eq!(format!("{missing}"), "not found: missing plugin"); diff --git a/crates/core/src/plugin/dynamic/manifest.rs b/crates/core/src/plugin/dynamic/manifest.rs index 38948bf0..e77fc9ca 100644 --- a/crates/core/src/plugin/dynamic/manifest.rs +++ b/crates/core/src/plugin/dynamic/manifest.rs @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +use std::collections::HashSet; use std::fs; use std::path::Path; @@ -330,14 +331,13 @@ fn ensure_optional_string_non_empty(value: Option<&str>, field: &str) -> Result< } fn reject_duplicate_capabilities(capabilities: &[DynamicPluginCapability]) -> Result<()> { - let mut seen = Vec::with_capacity(capabilities.len()); + let mut seen = HashSet::with_capacity(capabilities.len()); for capability in capabilities { - if seen.contains(capability) { + if !seen.insert(*capability) { return Err(PluginError::InvalidConfig(format!( "capabilities.items contains duplicate capability '{capability:?}'" ))); } - seen.push(*capability); } Ok(()) } diff --git a/crates/core/src/plugin/dynamic/registry.rs b/crates/core/src/plugin/dynamic/registry.rs index 1e439c2d..6d3cf573 100644 --- a/crates/core/src/plugin/dynamic/registry.rs +++ b/crates/core/src/plugin/dynamic/registry.rs @@ -6,7 +6,7 @@ use std::collections::BTreeMap; use super::{ DynamicPluginFailure, DynamicPluginId, DynamicPluginManifest, DynamicPluginMetadata, DynamicPluginRecord, DynamicPluginRuntimeStatus, DynamicPluginValidationStatus, - bump_generation, stamp_creation_metadata, touch_metadata, + bump_generation, stamp_creation_metadata, }; use crate::plugin::{PluginError, Result}; @@ -57,7 +57,6 @@ impl DynamicPluginRegistry { } stamp_creation_metadata(&mut record.metadata); - touch_metadata(&mut record.metadata); self.records.insert(plugin_id.clone(), record); Ok(self diff --git a/crates/core/tests/unit/plugin_dynamic_tests.rs b/crates/core/tests/unit/plugin_dynamic_tests.rs index 29855259..e699bac5 100644 --- a/crates/core/tests/unit/plugin_dynamic_tests.rs +++ b/crates/core/tests/unit/plugin_dynamic_tests.rs @@ -139,6 +139,7 @@ fn registry_adds_record_and_lists_live_entries_only_by_default() { let live = registry.list(false); assert_eq!(live.len(), 1); assert_eq!(live[0].metadata.id, "acme.guardrails.pii"); + assert_eq!(live[0].metadata.created_at, live[0].metadata.updated_at); let all = registry.list(true); assert_eq!(all.len(), 1); diff --git a/crates/ffi/src/error.rs b/crates/ffi/src/error.rs index 78999b1f..dc1f195c 100644 --- a/crates/ffi/src/error.rs +++ b/crates/ffi/src/error.rs @@ -138,9 +138,10 @@ pub fn status_from_plugin_error(e: &PluginError) -> NemoRelayStatus { set_last_error(&e.to_string()); match e { PluginError::NotFound(_) => NemoRelayStatus::NotFound, - PluginError::Conflict(_) - | PluginError::InvalidConfig(_) - | PluginError::Serialization(_) => NemoRelayStatus::InvalidArg, + PluginError::Conflict(_) => NemoRelayStatus::AlreadyExists, + PluginError::InvalidConfig(_) | PluginError::Serialization(_) => { + NemoRelayStatus::InvalidArg + } PluginError::Internal(_) | PluginError::RegistrationFailed(_) => NemoRelayStatus::Internal, } } diff --git a/crates/ffi/tests/coverage/error_tests.rs b/crates/ffi/tests/coverage/error_tests.rs index c66fa515..fe279266 100644 --- a/crates/ffi/tests/coverage/error_tests.rs +++ b/crates/ffi/tests/coverage/error_tests.rs @@ -96,6 +96,11 @@ fn test_status_from_plugin_error_maps_variants_and_sets_message() { NemoRelayStatus::InvalidArg, "bad config", ), + ( + PluginError::Conflict("duplicate plugin".into()), + NemoRelayStatus::AlreadyExists, + "duplicate plugin", + ), ( PluginError::Serialization(serialization_error), NemoRelayStatus::InvalidArg,