diff --git a/Cargo.lock b/Cargo.lock index 0246c835..f409bccc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1332,9 +1332,11 @@ dependencies = [ "schemars", "serde", "serde_json", + "tempfile", "thiserror 2.0.18", "tokio", "tokio-stream", + "toml", "tonic", "typed-builder", "uuid", diff --git a/crates/cli/src/config.rs b/crates/cli/src/config.rs index 2307065f..3fd2d5f1 100644 --- a/crates/cli/src/config.rs +++ b/crates/cli/src/config.rs @@ -1,12 +1,12 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -use std::collections::HashSet; use std::net::SocketAddr; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use axum::http::HeaderMap; use clap::{ArgGroup, Args, Parser, Subcommand, ValueEnum}; +use nemo_relay::plugin::{PluginError, load_plugin_config_files}; use serde::Deserialize; use serde_json::Value; @@ -619,18 +619,8 @@ fn implicit_plugin_config_paths( cwd: Option<&std::path::Path>, user_config_dir: Option, ) -> Vec { - // Ordered from lowest to highest precedence. User-level plugin config intentionally loads last - // so an operator can override project-local plugin defaults without editing the checkout. - let mut paths = vec![PathBuf::from("/etc/nemo-relay").join(PLUGINS_TOML)]; - if let Some(cwd) = cwd - && let Some(project) = find_project_plugin_config(cwd) - { - paths.push(project); - } - if let Some(user) = user_config_dir { - paths.push(user.join(PLUGINS_TOML)); - } - paths + // The search-path logic lives in core; the gateway shares it so discovery stays identical. + nemo_relay::plugin::default_plugin_config_paths(cwd, user_config_dir) } // Walks upward from the current directory and returns the nearest project-local gateway config. @@ -645,15 +635,9 @@ fn find_project_config(start: &std::path::Path) -> Option { None } -// Walks upward from the current directory and returns the nearest project-local plugin config. +// The project-walk lives in core; the gateway shares it so discovery stays identical. fn find_project_plugin_config(start: &std::path::Path) -> Option { - for ancestor in start.ancestors() { - let path = ancestor.join(".nemo-relay").join(PLUGINS_TOML); - if path.exists() { - return Some(path); - } - } - None + nemo_relay::plugin::nearest_project_plugin_config(start) } pub(crate) fn user_plugin_config_path() -> Option { @@ -679,15 +663,10 @@ fn user_config_path() -> Option { user_config_dir().map(|dir| dir.join("config.toml")) } -/// Resolves the nemo-relay user config DIRECTORY (without trailing filename) using the same XDG -/// rules as `user_config_path`. Exposed so wizard/doctor code paths that write to or display -/// the global location stay in sync with the loader — without this, hard-coded -/// `$HOME/.config/nemo-relay` references silently ignore `$XDG_CONFIG_HOME`. +/// Resolves the nemo-relay user config DIRECTORY (without trailing filename). Delegates to core's +/// resolver so the gateway, the editor, and the plugin runtime agree on the location. pub(crate) fn user_config_dir() -> Option { - if let Some(base) = std::env::var_os("XDG_CONFIG_HOME") { - return Some(PathBuf::from(base).join("nemo-relay")); - } - home_dir().map(|home| home.join(".config/nemo-relay")) + nemo_relay::plugin::user_config_dir() } // Applies the typed TOML config model to the resolved runtime config. Missing sections and fields @@ -744,31 +723,12 @@ fn load_plugin_toml_config_from_paths(paths: I) -> Result, { - let mut merged = toml::Value::Table(toml::map::Map::new()); - let mut sources = Vec::new(); - for path in paths { - if path.exists() { - let raw = std::fs::read_to_string(&path)?; - let parsed = raw - .parse::() - .map(toml::Value::Table) - .map_err(|error| { - CliError::Config(format!( - "invalid plugin TOML in {}: {error}", - path.display() - )) - })?; - validate_plugin_toml_component_kinds(&path, &parsed)?; - merge_plugin_toml(&mut merged, parsed); - sources.push(path); - } - } - if sources.is_empty() { - return Ok(None); - } - let value = serde_json::to_value(merged) - .map_err(|error| CliError::Config(format!("invalid plugin TOML shape: {error}")))?; - Ok(Some(PluginTomlConfig { value, sources })) + // Delegate read/parse/merge to the shared core primitive (file precedence unchanged). + let resolved = load_plugin_config_files(paths).map_err(|err| match err { + PluginError::InvalidConfig(message) => CliError::Config(message), + other => CliError::Config(other.to_string()), + })?; + Ok(resolved.map(|(value, sources)| PluginTomlConfig { value, sources })) } fn apply_plugin_toml_config( @@ -859,86 +819,6 @@ fn merge_toml(left: &mut toml::Value, right: toml::Value) { } } -// Plugin TOML uses normal recursive TOML merging except for the top-level components array. Each -// component is keyed by `kind`, so project/user plugins.toml files can add distinct plugin kinds or -// override one plugin kind without restating every other component. -fn merge_plugin_toml(left: &mut toml::Value, right: toml::Value) { - match (left, right) { - (toml::Value::Table(left), toml::Value::Table(right)) => { - for (key, value) in right { - match (key.as_str(), left.get_mut(&key)) { - ("components", Some(existing)) => merge_plugin_components(existing, value), - (_, Some(existing)) => merge_toml(existing, value), - _ => { - left.insert(key, value); - } - } - } - } - (left, right) => *left = right, - } -} - -fn merge_plugin_components(left: &mut toml::Value, right: toml::Value) { - let toml::Value::Array(left_components) = left else { - *left = right; - return; - }; - let toml::Value::Array(right_components) = right else { - *left = right; - return; - }; - - for component in right_components { - let Some(kind) = component_kind(&component).map(str::to_owned) else { - left_components.push(component); - continue; - }; - if let Some(existing) = left_components - .iter_mut() - .find(|candidate| component_kind(candidate) == Some(kind.as_str())) - { - merge_toml(existing, component); - } else { - left_components.push(component); - } - } -} - -fn component_kind(component: &toml::Value) -> Option<&str> { - component - .as_table() - .and_then(|table| table.get("kind")) - .and_then(toml::Value::as_str) -} - -fn validate_plugin_toml_component_kinds(path: &Path, value: &toml::Value) -> Result<(), CliError> { - let Some(components) = value.get("components").and_then(toml::Value::as_array) else { - return Ok(()); - }; - let mut seen = HashSet::new(); - let mut duplicates = Vec::new(); - for component in components { - let Some(kind) = component_kind(component) else { - continue; - }; - if !seen.insert(kind.to_string()) { - duplicates.push(kind.to_string()); - } - } - duplicates.sort(); - duplicates.dedup(); - if duplicates.is_empty() { - Ok(()) - } else { - Err(CliError::Config(format!( - "duplicate plugin component kind in {}: {}; declare each kind once per plugins.toml", - path.display(), - duplicates.join(", ") - ))) - } -} - fn has_config_toml_plugin_config(value: &toml::Value) -> bool { value .get("plugins") @@ -979,14 +859,6 @@ fn parse_json_option(name: &str, value: &str) -> Result { .map_err(|error| CliError::Config(format!("invalid {name}: {error}"))) } -// Resolves a cross-platform home directory from environment only. The gateway avoids extra OS -// lookups here so tests can control install/config locations by setting env variables. -fn home_dir() -> Option { - std::env::var_os("HOME") - .or_else(|| std::env::var_os("USERPROFILE")) - .map(PathBuf::from) -} - /// Reads a non-empty UTF-8 header value as an owned string. /// /// Invalid header bytes and empty strings are treated as absent so callers can preserve their diff --git a/crates/cli/src/server.rs b/crates/cli/src/server.rs index fef92e1d..37c9c669 100644 --- a/crates/cli/src/server.rs +++ b/crates/cli/src/server.rs @@ -7,7 +7,7 @@ use axum::extract::State; use axum::http::HeaderMap; use axum::routing::{get, post}; use axum::{Json, Router}; -use nemo_relay::plugin::{PluginConfig, clear_plugin_configuration, initialize_plugins}; +use nemo_relay::plugin::{PluginConfig, clear_plugin_configuration, initialize_plugins_exact}; use nemo_relay_adaptive::plugin_component::register_adaptive_component; use reqwest::Client; use serde_json::Value; @@ -157,9 +157,10 @@ impl PluginActivation { register_adaptive_component().map_err(|error| { CliError::Config(format!("adaptive plugin registration failed: {error}")) })?; + // Gateway already resolved its config; activate exactly (no re-discovery). let plugin_config: PluginConfig = serde_json::from_value(config) .map_err(|error| CliError::Config(format!("invalid plugin config: {error}")))?; - initialize_plugins(plugin_config) + initialize_plugins_exact(plugin_config) .await .map_err(|error| CliError::Config(format!("plugin activation failed: {error}")))?; Ok(Self { active: true }) diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index e205b83c..07d59ba8 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -61,6 +61,7 @@ openinference = [ uuid = { workspace = true, features = ["v7", "serde"] } serde = { version = "1", features = ["derive", "rc"] } serde_json = "1" +toml = { version = "0.9" } schemars = { version = "0.8", optional = true } chrono = { version = "0.4", features = ["serde"] } bitflags = { version = "2", features = ["serde"] } @@ -85,6 +86,7 @@ futures = "0.3" opentelemetry_sdk = { version = "0.31", default-features = false, features = ["trace", "testing"] } serde_json = "1" object_store = { version = "0.13", default-features = false, features = ["aws"] } +tempfile = "3" [[test]] name = "codec_integration" diff --git a/crates/core/src/plugin.rs b/crates/core/src/plugin.rs index f9731a3e..ac5c6ea8 100644 --- a/crates/core/src/plugin.rs +++ b/crates/core/src/plugin.rs @@ -899,6 +899,85 @@ pub fn validate_plugin_config(config: &PluginConfig) -> ConfigReport { report } +/// Layers `right` (higher precedence) onto `left` in place. +/// +/// Objects merge recursively and arrays/scalars are replaced by `right`, except the +/// top-level `components` array, whose entries pair by `kind` in order of appearance so +/// multi-instance kinds are not collapsed. Internal helper shared by plugin +/// initialization and `plugins.toml` discovery. +fn layer_config(left: &mut Json, right: Json) { + match (left, right) { + (Json::Object(left), Json::Object(right)) => { + for (key, value) in right { + match (key.as_str(), left.get_mut(&key)) { + ("components", Some(existing)) => merge_plugin_components(existing, value), + (_, Some(existing)) => merge_json_value(existing, value), + (_, _) => { + left.insert(key, value); + } + } + } + } + (left, right) => *left = right, + } +} + +/// Merges `right` components into `left` by `kind`, pairing repeated kinds positionally. +fn merge_plugin_components(left: &mut Json, right: Json) { + let Json::Array(left_components) = left else { + *left = right; + return; + }; + let Json::Array(right_components) = right else { + *left = right; + return; + }; + let mut base_slots: HashMap> = HashMap::new(); + for (index, component) in left_components.iter().enumerate() { + if let Some(kind) = component_kind(component) { + base_slots.entry(kind.to_string()).or_default().push(index); + } + } + let mut consumed: HashMap = HashMap::new(); + for component in right_components { + let Some(kind) = component_kind(&component).map(str::to_owned) else { + left_components.push(component); + continue; + }; + let nth = consumed.entry(kind.clone()).or_insert(0); + let slot = base_slots + .get(&kind) + .and_then(|slots| slots.get(*nth)) + .copied(); + *nth += 1; + match slot { + Some(index) => merge_json_value(&mut left_components[index], component), + None => left_components.push(component), + } + } +} + +/// Recursively merges `right` into a `left` JSON object; arrays and scalars are replaced. +fn merge_json_value(left: &mut Json, right: Json) { + match (left, right) { + (Json::Object(left), Json::Object(right)) => { + for (key, value) in right { + match left.get_mut(&key) { + Some(existing) => merge_json_value(existing, value), + None => { + left.insert(key, value); + } + } + } + } + (left, right) => *left = right, + } +} + +fn component_kind(component: &Json) -> Option<&str> { + component.get("kind").and_then(Json::as_str) +} + /// Returns the JSON Schema for the canonical plugin configuration document. #[cfg(feature = "schema")] pub fn plugin_config_schema() -> Json { @@ -926,7 +1005,8 @@ pub fn plugin_config_schema() -> Json { /// # Notes /// Initialization is replace-with-rollback: the previous active configuration /// is removed before the new configuration is activated. -pub async fn initialize_plugins(config: PluginConfig) -> Result { +#[doc(hidden)] +pub async fn initialize_plugins_exact(config: PluginConfig) -> Result { let report = validate_plugin_config(&config); if report.has_errors() { return Err(PluginError::InvalidConfig(join_error_messages(&report))); @@ -968,6 +1048,123 @@ pub async fn initialize_plugins(config: PluginConfig) -> Result { } } +/// Validates and activates `config` layered on top of the discovered +/// `plugins.toml` configuration, so a direct integration sees the same file +/// layering as the gateway. `config` wins on conflicts; as a typed document its +/// default `version`/`policy`/`enabled` override the file, while `config` bodies +/// merge field-by-field. Delegates to [`initialize_plugins_exact`]. +pub async fn initialize_plugins(config: PluginConfig) -> Result { + let mut base = resolve_default_file_plugin_config()?; + layer_config(&mut base, serde_json::to_value(config)?); + let config: PluginConfig = serde_json::from_value(base)?; + initialize_plugins_exact(config).await +} + +/// Resolves the default `plugins.toml` layering into one JSON document, or an +/// empty object when no plugin file exists. +fn resolve_default_file_plugin_config() -> Result { + let paths = + default_plugin_config_paths(std::env::current_dir().ok().as_deref(), user_config_dir()); + Ok(load_plugin_config_files(paths)? + .map(|(value, _sources)| value) + .unwrap_or_else(|| Json::Object(Map::new()))) +} + +use std::path::{Path, PathBuf}; + +/// Reads, parses, and merges the `plugins.toml` files at `paths` (lowest +/// precedence first) into one JSON document with its source paths, or `None` +/// when none exist. Internal: `pub` only for cross-crate reuse by the gateway. +#[doc(hidden)] +pub fn load_plugin_config_files(paths: I) -> Result)>> +where + I: IntoIterator, +{ + let mut merged = Json::Object(Map::new()); + let mut sources = Vec::new(); + for path in paths { + if !path.exists() { + continue; + } + let raw = std::fs::read_to_string(&path).map_err(|err| { + PluginError::InvalidConfig(format!("failed to read {}: {err}", path.display())) + })?; + let parsed = raw.parse::().map_err(|err| { + PluginError::InvalidConfig(format!("invalid plugin TOML in {}: {err}", path.display())) + })?; + let document = serde_json::to_value(parsed)?; + validate_unique_component_kinds(&path, &document)?; + layer_config(&mut merged, document); + sources.push(path); + } + Ok((!sources.is_empty()).then_some((merged, sources))) +} + +/// Rejects a single file that declares the same component `kind` more than once. +fn validate_unique_component_kinds(path: &Path, document: &Json) -> Result<()> { + let Some(components) = document.get("components").and_then(Json::as_array) else { + return Ok(()); + }; + let mut seen = HashSet::new(); + let mut duplicates = Vec::new(); + for component in components { + if let Some(kind) = component_kind(component) + && !seen.insert(kind) + { + duplicates.push(kind.to_string()); + } + } + if duplicates.is_empty() { + return Ok(()); + } + duplicates.sort(); + duplicates.dedup(); + Err(PluginError::InvalidConfig(format!( + "duplicate plugin component kind in {}: {}; declare each kind once per plugins.toml", + path.display(), + duplicates.join(", ") + ))) +} + +/// Default `plugins.toml` search path (lowest precedence first): system, nearest +/// project file, then user file — mirroring the gateway's discovery. `pub` only +/// for cross-crate reuse by the gateway. +#[doc(hidden)] +pub fn default_plugin_config_paths(cwd: Option<&Path>, user_dir: Option) -> Vec { + let mut paths = vec![PathBuf::from("/etc/nemo-relay/plugins.toml")]; + if let Some(cwd) = cwd + && let Some(project) = nearest_project_plugin_config(cwd) + { + paths.push(project); + } + if let Some(dir) = user_dir { + paths.push(dir.join("plugins.toml")); + } + paths +} + +/// Walks upward from `start` for the nearest `.nemo-relay/plugins.toml`. `pub` +/// only for cross-crate reuse by the gateway. +#[doc(hidden)] +pub fn nearest_project_plugin_config(start: &Path) -> Option { + start + .ancestors() + .map(|ancestor| ancestor.join(".nemo-relay").join("plugins.toml")) + .find(|path| path.exists()) +} + +/// Resolves the nemo-relay user config directory from `XDG_CONFIG_HOME`, then +/// `HOME`/`USERPROFILE`. `pub` only for cross-crate reuse by the gateway. +#[doc(hidden)] +pub fn user_config_dir() -> Option { + if let Some(base) = std::env::var_os("XDG_CONFIG_HOME") { + return Some(PathBuf::from(base).join("nemo-relay")); + } + std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .map(|home| PathBuf::from(home).join(".config/nemo-relay")) +} + /// Deregisters and clears all configured plugin components. /// /// Registered plugin kinds remain available for future validation and diff --git a/crates/core/tests/unit/plugin_tests.rs b/crates/core/tests/unit/plugin_tests.rs index e5eb7255..b81df4d9 100644 --- a/crates/core/tests/unit/plugin_tests.rs +++ b/crates/core/tests/unit/plugin_tests.rs @@ -314,6 +314,110 @@ fn reset_global() { let _ = deregister_plugin("vanishing.plugin"); } +#[test] +fn test_layer_config_overlay_wins() { + // The overlay is the higher-precedence layer: it overrides shared component fields, deep-merges + // nested config objects, replaces arrays, appends overlay-only kinds, preserves base-only kinds, + // replaces top-level scalars, and recursively merges top-level objects (policy). + let base = json!({ + "version": 1, + "components": [ + { + "kind": "alpha", + "enabled": true, + "config": { "keep": "base", "override": "base", "nested": {"a": 1, "b": 2}, "list": [1, 2, 3] } + }, + { "kind": "base_only", "enabled": true, "config": {} } + ], + "policy": { "unknown_component": "warn", "unknown_field": "warn" } + }); + let overlay = json!({ + "version": 2, + "components": [ + { + "kind": "alpha", + "enabled": false, + "config": { "override": "overlay", "added": true, "nested": {"b": 20, "c": 30}, "list": [9] } + }, + { "kind": "overlay_only", "enabled": true, "config": {} } + ], + "policy": { "unknown_component": "error" } + }); + + let mut merged = base; + layer_config(&mut merged, overlay); + let components = merged["components"].as_array().unwrap(); + + // Ordering: base components first (in base order), then overlay-only components appended. + let kinds: Vec<&str> = components + .iter() + .map(|component| component["kind"].as_str().unwrap()) + .collect(); + assert_eq!(kinds, ["alpha", "base_only", "overlay_only"]); + + let alpha = &components[0]; + assert_eq!(alpha["enabled"], json!(false), "overlay enabled wins"); + assert_eq!( + alpha["config"]["keep"], + json!("base"), + "base-only key preserved" + ); + assert_eq!( + alpha["config"]["override"], + json!("overlay"), + "overlay scalar wins" + ); + assert_eq!(alpha["config"]["added"], json!(true), "overlay key added"); + assert_eq!( + alpha["config"]["nested"], + json!({"a": 1, "b": 20, "c": 30}), + "nested objects merge recursively" + ); + assert_eq!( + alpha["config"]["list"], + json!([9]), + "arrays are replaced, not merged" + ); + + // Base-only component is preserved. + assert_eq!(components[1]["kind"], json!("base_only")); + + // Top-level scalars are replaced by the overlay; objects (policy) merge recursively. + assert_eq!(merged["version"], json!(2)); + assert_eq!(merged["policy"]["unknown_component"], json!("error")); + assert_eq!( + merged["policy"]["unknown_field"], + json!("warn"), + "base-only policy field preserved" + ); +} + +#[test] +fn test_layer_config_preserves_multi_instance_kinds() { + // A kind used more than once (multi-instance plugins) must not collapse into the first slot. + let base = json!({ "components": [ { "kind": "multi", "config": { "n": 0 } } ] }); + let overlay = json!({ + "components": [ + { "kind": "multi", "config": { "n": 1 } }, + { "kind": "multi", "config": { "tag": "second" } } + ] + }); + + let mut merged = base; + layer_config(&mut merged, overlay); + let components = merged["components"].as_array().unwrap(); + + // First overlay instance pairs with the base instance; the second is appended, not dropped. + assert_eq!(components.len(), 2); + assert!( + components + .iter() + .all(|component| component["kind"] == json!("multi")) + ); + assert_eq!(components[0]["config"]["n"], json!(1)); + assert_eq!(components[1]["config"]["tag"], json!("second")); +} + #[test] fn test_config_report_has_errors() { let report = ConfigReport { @@ -1315,3 +1419,115 @@ fn test_initialize_plugins_reports_failed_restore_when_previous_configuration_ca assert!(active_plugin_report().is_none()); reset_global(); } + +#[test] +fn test_load_plugin_config_files_merges_files_by_precedence() { + let dir = tempfile::tempdir().unwrap(); + let lower = dir.path().join("lower.toml"); + let higher = dir.path().join("higher.toml"); + std::fs::write( + &lower, + "version = 1\n\ + [[components]]\n\ + kind = \"observability\"\n\ + enabled = false\n\ + [components.config]\n\ + output_directory = \"/var/log\"\n\ + mode = \"append\"\n", + ) + .unwrap(); + std::fs::write( + &higher, + "[[components]]\n\ + kind = \"observability\"\n\ + [components.config]\n\ + mode = \"overwrite\"\n\ + [[components]]\n\ + kind = \"adaptive\"\n", + ) + .unwrap(); + + let (merged, sources) = load_plugin_config_files([lower.clone(), higher.clone()]) + .unwrap() + .expect("a file exists"); + assert_eq!(sources, vec![lower, higher]); + + let components = merged["components"].as_array().unwrap(); + let observability = &components[0]; + assert_eq!(observability["kind"], json!("observability")); + assert_eq!( + observability["enabled"], + json!(false), + "lower-file enabled is inherited (higher omits it)" + ); + assert_eq!( + observability["config"]["output_directory"], + json!("/var/log"), + "lower-only config key is inherited" + ); + assert_eq!( + observability["config"]["mode"], + json!("overwrite"), + "higher file overrides the shared config key" + ); + assert_eq!( + components[1]["kind"], + json!("adaptive"), + "higher-only component kind is appended" + ); +} + +#[test] +fn test_layer_config_applies_typed_overlay_defaults_over_file_base() { + // The code-vs-file path `initialize_plugins` takes: a typed `PluginConfig` is layered + // over the discovered file base. Its serde defaults (`version`/`policy`/`enabled`) + // override the file, the free-form `config` body merges, and an undeclared component + // kind is inherited from the file. + let file_base = json!({ + "version": 2, + "components": [ + { + "kind": "observability", + "enabled": false, + "config": { "output_directory": "/var/log", "mode": "append" } + }, + { "kind": "adaptive", "config": { "ttl": 60 } } + ], + "policy": { + "unknown_component": "error", + "unknown_field": "warn", + "unsupported_value": "error" + } + }); + let code = PluginConfig { + components: vec![PluginComponentSpec { + config: Map::from_iter([(String::from("mode"), json!("overwrite"))]), + ..PluginComponentSpec::new("observability") + }], + ..PluginConfig::default() + }; + + let mut merged = file_base; + layer_config(&mut merged, serde_json::to_value(code).unwrap()); + let typed: PluginConfig = serde_json::from_value(merged).unwrap(); + + // Typed defaults override the file base. + assert_eq!(typed.version, 1, "typed default version overrides the file"); + assert_eq!( + typed.policy.unknown_component, + UnsupportedBehavior::Warn, + "typed default policy overrides the file" + ); + let observability = &typed.components[0]; + assert_eq!(observability.kind, "observability"); + assert!( + observability.enabled, + "typed default enabled=true overrides the file's false" + ); + // The component config body merges: code's `mode` wins, the file's `output_directory` + // is inherited. + assert_eq!(observability.config["mode"], json!("overwrite")); + assert_eq!(observability.config["output_directory"], json!("/var/log")); + // A kind the code config does not declare is inherited from the file. + assert_eq!(typed.components[1].kind, "adaptive"); +} diff --git a/docs/build-plugins/plugin-configuration-files.mdx b/docs/build-plugins/plugin-configuration-files.mdx index 866d80d9..aa602b4f 100644 --- a/docs/build-plugins/plugin-configuration-files.mdx +++ b/docs/build-plugins/plugin-configuration-files.mdx @@ -70,7 +70,10 @@ The gateway reads only files named `plugins.toml`. ## Discovery -The gateway can receive plugin configuration from three source classes: +The gateway resolves plugin configuration from two kinds of input: file/CLI +sources and an optional code-driven layer. + +File/CLI configuration comes from one of three mutually exclusive source classes: | Source | Use case | |---|---| @@ -78,8 +81,8 @@ The gateway can receive plugin configuration from three source classes: | `[plugins].config` in `config.toml` | Inline gateway config for small or generated setups. | | `--plugin-config ''` | CI, tests, wrappers, or one-off automation. | -Use only one source class for a given gateway run. The gateway fails clearly if -file-based plugin config and `--plugin-config` are both present, or if +Use only one file/CLI source class for a given gateway run. The gateway fails +clearly if file-based plugin config and `--plugin-config` are both present, or if `plugins.toml` and `[plugins].config` are both present. When `--config path/to/config.toml` is supplied, plugin file discovery is scoped @@ -195,6 +198,16 @@ components that reach plugin validation also fail validation. Arrays inside component config are replaced by the higher-precedence value. Tables inside component config merge recursively. +## Configuration Layering + +A direct integration that calls `plugin.initialize(...)` (any binding) resolves the +discovered `plugins.toml` layering as a base, then layers the code-driven config on +top (the code-driven config wins). Precedence differs slightly from the file-vs-file +rule above: a code-driven config is a typed document, so `version`, `policy`, and a +declared component's `enabled` take the code value (their defaults included), while +omitted component kinds and `config` keys are inherited from the files. On targets +without a filesystem (such as wasm), no files are found and the base is empty. + ## Explicit Defaults And Overrides The editor writes explicit defaults for edited Observability and Adaptive