From 6a99c4ac733ccdf03937570513a041a91f8469fd Mon Sep 17 00:00:00 2001 From: s3rj1k Date: Thu, 11 Jun 2026 11:00:10 +0000 Subject: [PATCH] feat: Add `rune`-scripted libredfish vendor Signed-off-by: s3rj1k --- Cargo.toml | 3 + src/lib.rs | 2 + src/model/service_root.rs | 22 + src/network.rs | 31 +- src/rune_vendor.rs | 1118 +++++++++++++++++++++++++++++++++++++ src/standard.rs | 42 ++ src/vendor_override.rs | 139 +++++ tests/rune/README.md | 263 +++++++++ tests/rune/http_stub.rn | 37 ++ 9 files changed, 1648 insertions(+), 9 deletions(-) create mode 100644 src/rune_vendor.rs create mode 100644 src/vendor_override.rs create mode 100644 tests/rune/README.md create mode 100644 tests/rune/http_stub.rn diff --git a/Cargo.toml b/Cargo.toml index 3bd7c46bf..7b9df486c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,6 +33,9 @@ regex = "1.10" mac_address = "1.1" chrono = { version = "0.4.34", features = ["serde"] } urlencoding = "2.1.3" +rune = "0.14" +sha2 = "0.10" +base64 = "0.22" [dev-dependencies] anyhow = { version = "1" } diff --git a/src/lib.rs b/src/lib.rs index 183459b0b..1868e0411 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -58,7 +58,9 @@ mod nvidia_gbswitch; mod nvidia_gbx00; mod nvidia_gh200; mod nvidia_viking; +mod rune_vendor; mod supermicro; +mod vendor_override; pub use network::{Endpoint, RedfishClientPool, RedfishClientPoolBuilder, REDFISH_ENDPOINT}; pub mod standard; pub use error::RedfishError; diff --git a/src/model/service_root.rs b/src/model/service_root.rs index 814772a46..5eec58ce6 100644 --- a/src/model/service_root.rs +++ b/src/model/service_root.rs @@ -38,6 +38,10 @@ pub struct ServiceRoot { pub product: Option, pub redfish_version: String, pub vendor: Option, + /// Vendor forced by the override file; set in `get_service_root` and returned by + /// `vendor()` ahead of auto-detection. Not part of the Redfish schema. + #[serde(skip)] + pub override_vendor: Option, #[serde(rename = "UUID")] pub uuid: Option, pub oem: Option>, @@ -72,6 +76,7 @@ pub enum RedfishVendor { P3809, // dummy for P3809, needs to be set to NvidiaGH200 or NvidiaGBSwitch based on chassis LiteOnPowerShelf, DeltaPowerShelf, + Rune, Unknown, } @@ -93,6 +98,10 @@ impl ServiceRoot { } pub fn vendor(&self) -> Option { + // A forced override vendor wins over auto-detection. + if self.override_vendor.is_some() { + return self.override_vendor; + } let v = self.vendor_string().unwrap_or("Unknown".to_string()); Some(match v.to_lowercase().as_str() { "ami" => RedfishVendor::AMI, @@ -114,6 +123,7 @@ impl ServiceRoot { "supermicro" => RedfishVendor::Supermicro, "lite-on technology corp." => RedfishVendor::LiteOnPowerShelf, "delta" => RedfishVendor::DeltaPowerShelf, + "rune" => RedfishVendor::Rune, _ => RedfishVendor::Unknown, }) } @@ -157,4 +167,16 @@ mod test { }; assert_eq!(result.vendor().unwrap(), RedfishVendor::NvidiaDpu); } + + #[test] + fn override_vendor_wins_over_detection() { + // A pinned override vendor (stamped by get_service_root) takes precedence + // over whatever the BMC reports, so detection/auto-detect honor it. + let result = ServiceRoot { + vendor: Some("dell".to_string()), + override_vendor: Some(RedfishVendor::Rune), + ..Default::default() + }; + assert_eq!(result.vendor().unwrap(), RedfishVendor::Rune); + } } diff --git a/src/network.rs b/src/network.rs index b8b663c78..c924f1663 100644 --- a/src/network.rs +++ b/src/network.rs @@ -196,23 +196,36 @@ impl RedfishClientPool { vendor: Option, custom_headers: Vec<(HeaderName, String)>, ) -> Result, RedfishError> { + // Host is the vendor-override key; capture it before `endpoint` moves. + let host = endpoint.host.clone(); let client = RedfishHttpClient::new(self.http_client.clone(), endpoint, custom_headers); let mut s = RedfishStandard::new(client); let service_root = s.get_service_root().await?; - // Resolve the vendor up-front (explicit override, else from the service - // root, which get_service_root backfills from the chassis manufacturer - // for vendorless power shelves). Knowing the vendor here lets us skip - // resource lookups for platforms that don't expose them. - let vendor = match vendor { - Some(v) => v, - None => service_root.vendor().ok_or(RedfishError::MissingVendor)?, - }; - + // Manager id is needed both as the vendor-override key and for set_manager_id. let managers = s.get_managers().await?; let manager_id = managers.first().ok_or_else(|| RedfishError::GenericError { error: "No managers found in service root".to_string(), })?; + + // Resolve the vendor: override file wins, then the caller's vendor, then + // auto-detection. Knowing it now lets us skip lookups some platforms 404 on. + let ov = crate::vendor_override::resolve(&host, manager_id)?; + let vendor = ov + .as_ref() + .map(|o| o.vendor) + .or(vendor) + .or_else(|| service_root.vendor()) + .ok_or(RedfishError::MissingVendor)?; + // Variant and script come only from the override file; they ride the + // `RedfishStandard` clone into `set_vendor`. + let (ov_variant, ov_script) = match ov { + Some(o) => (o.variant, o.script), + None => (None, None), + }; + s.set_vendor_variant(ov_variant); + s.set_vendor_script(ov_script); + let chassis = s.get_chassis_all().await?; // Delta power shelves expose no `/Systems` resource (a real query 404s) diff --git a/src/rune_vendor.rs b/src/rune_vendor.rs new file mode 100644 index 000000000..194784bfc --- /dev/null +++ b/src/rune_vendor.rs @@ -0,0 +1,1118 @@ +//! `rune` vendor — a scriptable BMC backed by a [Rune](https://rune-rs.github.io) script. +//! +//! Selected via the vendor-override file (`vendor` "Rune" plus a `script` path). Each +//! `Redfish` method dispatches to a same-named script function and falls back to +//! [`RedfishStandard`] when the script doesn't define one (methods with complex or +//! non-`Deserialize` arguments always delegate). A script reaches the BMC through a +//! [`RedfishCtx`] handle (`ctx`) plus a set of free helper functions called directly. +//! +//! On `ctx`: the HTTP verbs `get`/`patch`/`post`/`delete` and the accessors +//! `system_id()`/`manager_id()`/`variant()`/`bmc_address()`. Called directly, no `ctx`: +//! `sha256`/`sha512`, `b64_encode`/`b64_decode`, `json_encode`/`json_decode`, the host helpers +//! `read_file`/`read_env`, and the clock `unix_time`. The HTTP verbs return +//! `Ok(#{ status, headers, body })` and the fallible helpers return `Ok(..)`/`Err(msg)`, so a +//! script can branch with `match` or use `?` to fail the method (an `Err` surfaces to the +//! caller as a `RedfishError`). +//! +//! `read_file`/`read_env` reach the host filesystem and environment, and scripts run with the +//! process's privileges under no sandbox or resource limits — load trusted scripts only. +//! +//! The full script-facing surface — host API, the methods a script may override, and the +//! available language/std features — is catalogued in `tests/rune/README.md`. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::{Duration, SystemTime}; + +use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; +use reqwest::header::HeaderMap; +use reqwest::{Method, StatusCode}; +use rune::runtime::{Args, Ref, RuntimeContext, Unit, Value, VmError, VmResult}; +use rune::{ + Any, Context, ContextError, Diagnostics, FromValue, Module, Source, Sources, ToValue, Vm, +}; +use serde::de::DeserializeOwned; +use sha2::{Digest, Sha256, Sha512}; + +use crate::model::account_service::ManagerAccount; +use crate::model::certificate::Certificate; +use crate::model::component_integrity::{CaCertificate, ComponentIntegrities, Evidence}; +use crate::model::oem::nvidia_dpu::{HostPrivilegeLevel, NicMode}; +use crate::model::power::Power; +use crate::model::secure_boot::SecureBoot; +use crate::model::sel::LogEntry; +use crate::model::sensor::GPUSensors; +use crate::model::service_root::ServiceRoot; +use crate::model::software_inventory::SoftwareInventory; +use crate::model::storage::Drives; +use crate::model::task::Task; +use crate::model::thermal::Thermal; +use crate::model::update_service::{ComponentType, TransferProtocolType, UpdateService}; +use crate::model::{BootOption, ComputerSystem, Manager, ODataId}; +use crate::network::RedfishHttpClient; +use crate::standard::RedfishStandard; +use crate::{ + Assembly, BiosProfileType, BiosProfileVendor, Boot, BootOptions, BootOverride, Chassis, + Collection, EnabledDisabled, EthernetInterface, JobState, MachineSetupStatus, NetworkAdapter, + NetworkDeviceFunction, NetworkPort, PCIeDevice, PowerState, Redfish, RedfishError, + RedfishFuture, Resource, RoleId, Status, SystemPowerControl, +}; + +// Host API exposed to scripts. + +/// Context handed to a Rune script. Holds the BMC HTTP client plus resolved ids and variant. +#[derive(Any, Clone)] +pub(crate) struct RedfishCtx { + client: RedfishHttpClient, + system_id: String, + manager_id: String, + variant: Option, +} + +fn vm_err(msg: String) -> VmResult { + VmResult::err(VmError::panic(msg)) +} + +/// `GET {path}` → `Ok(#{ status, headers, body })` or `Err(message)`. +#[rune::function(instance)] +async fn get(ctx: Ref, path: String) -> VmResult { + http_call(&ctx, Method::GET, &path, None).await +} + +/// `PATCH {path}` with JSON `body` → `Ok(#{ status, headers, body })` or `Err(message)`. +#[rune::function(instance)] +async fn patch(ctx: Ref, path: String, body: Value) -> VmResult { + http_call(&ctx, Method::PATCH, &path, Some(body)).await +} + +/// `POST {path}` with JSON `body` → `Ok(#{ status, headers, body })` or `Err(message)`. +#[rune::function(instance)] +async fn post(ctx: Ref, path: String, body: Value) -> VmResult { + http_call(&ctx, Method::POST, &path, Some(body)).await +} + +/// `DELETE {path}` → `Ok(#{ status, headers, body })` or `Err(message)`. +#[rune::function(instance)] +async fn delete(ctx: Ref, path: String) -> VmResult { + http_call(&ctx, Method::DELETE, &path, None).await +} + +/// Run an HTTP request and hand the script a `Result` value (built via `ToValue`) so +/// scripts can `match` or `?` it instead of the VM unwinding. +async fn http_call( + ctx: &RedfishCtx, + method: Method, + path: &str, + body: Option, +) -> VmResult { + result_to_value(do_http(ctx, method, path, body).await, "http") +} + +/// The request itself. Returns `Ok(#{status, headers, body})` on a completed HTTP +/// exchange and `Err(message)` on a transport or encode failure. +async fn do_http( + ctx: &RedfishCtx, + method: Method, + path: &str, + body: Option, +) -> Result { + let json_body: Option = match body { + Some(v) => Some( + serde_json::to_value(&v).map_err(|e| format!("{method} {path}: encode body: {e}"))?, + ), + None => None, + }; + match ctx + .client + .req::( + method.clone(), + path, + json_body, + None, + None, + Vec::new(), + ) + .await + { + Ok((status, body_opt, headers_opt)) => { + let resp = response_json(status, headers_opt, body_opt); + serde_json::from_value::(resp) + .map_err(|e| format!("{method} {path}: decode response: {e}")) + } + Err(e) => Err(format!("{method} {path}: {e}")), + } +} + +/// Build the `#{ status, headers, body }` response object scripts receive. +fn response_json( + status: StatusCode, + headers: Option, + body: Option, +) -> serde_json::Value { + let mut hdrs = serde_json::Map::new(); + if let Some(h) = headers { + for (name, value) in h.iter() { + hdrs.insert( + name.as_str().to_string(), + serde_json::Value::String(value.to_str().unwrap_or_default().to_string()), + ); + } + } + serde_json::json!({ + "status": status.as_u16(), + "headers": serde_json::Value::Object(hdrs), + "body": body.unwrap_or(serde_json::Value::Null), + }) +} + +/// Bridge a rune `Value` to `T` via serde_json. +fn bridge(value: &Value, name: &str) -> Result { + let json = serde_json::to_value(value).map_err(|e| RedfishError::GenericError { + error: format!("rune {name}: result encode: {e}"), + })?; + serde_json::from_value::(json).map_err(|e| RedfishError::GenericError { + error: format!("rune {name}: result -> {}: {e}", std::any::type_name::()), + }) +} + +/// Stringify the payload `Value` of a script `Err`. +fn value_to_string(v: &Value) -> String { + match serde_json::to_value(v) { + Ok(serde_json::Value::String(s)) => s, + Ok(other) => other.to_string(), + Err(_) => "".to_string(), + } +} + +/// Interpret a script's return value. A top level `Err(..)`, from `?` on a failed HTTP +/// call or an explicit `return Err(..)`, becomes a [`RedfishError`]. An `Ok(v)` is +/// unwrapped. Any other value is bridged directly. This is the script error channel. +fn interpret(value: Value, name: &str) -> Result { + match >::from_value(value.clone()) { + Ok(Ok(inner)) => bridge(&inner, name), + Ok(Err(e)) => Err(RedfishError::GenericError { + error: format!("rune {name}: script error: {}", value_to_string(&e)), + }), + Err(_) => bridge(&value, name), + } +} + +#[rune::function(instance)] +fn system_id(ctx: &RedfishCtx) -> String { + ctx.system_id.clone() +} + +#[rune::function(instance)] +fn manager_id(ctx: &RedfishCtx) -> String { + ctx.manager_id.clone() +} + +#[rune::function(instance)] +fn variant(ctx: &RedfishCtx) -> Option { + ctx.variant.clone() +} + +/// `ctx.bmc_address()` → the BMC host this client targets: hostname or IP, no scheme, +/// port, or path. This is the same address the vendor-override file matched on (its +/// `addr` key), so a script can use it to key per-host behavior. +#[rune::function(instance)] +fn bmc_address(ctx: &RedfishCtx) -> String { + ctx.client.host().to_string() +} + +/// `sha256(data)` → lowercase-hex SHA-256 of `data`'s UTF-8 bytes. Free function. +#[rune::function] +fn sha256(data: String) -> String { + hex_lower(Sha256::digest(data.as_bytes())) +} + +/// `sha512(data)` → lowercase-hex SHA-512 of `data`'s UTF-8 bytes. Free function. +#[rune::function] +fn sha512(data: String) -> String { + hex_lower(Sha512::digest(data.as_bytes())) +} + +/// Lowercase-hex encode bytes (backs the `sha256`/`sha512` script helpers). +fn hex_lower(bytes: impl AsRef<[u8]>) -> String { + use std::fmt::Write as _; + let bytes = bytes.as_ref(); + let mut s = String::with_capacity(bytes.len() * 2); + for b in bytes { + let _ = write!(s, "{b:02x}"); + } + s +} + +/// Hand a `Result` to a script as a matchable rune `Ok(..)`/`Err(..)` value (the +/// same convention the HTTP verbs use), or raise a VM error if the value can't be encoded. +fn result_to_value(result: Result, name: &str) -> VmResult { + match result.to_value() { + Ok(v) => VmResult::Ok(v), + Err(e) => vm_err(format!("rune {name}: encode result: {e}")), + } +} + +/// `b64_encode(data)` → standard-alphabet, padded base64 of `data`'s UTF-8 bytes. Free function. +#[rune::function] +fn b64_encode(data: String) -> String { + BASE64.encode(data.as_bytes()) +} + +/// `b64_decode(data)` → `Ok(text)` when `data` is valid standard base64 that decodes to UTF-8, +/// else `Err(message)`. Match it or `?` it like the HTTP verbs. Free function. +#[rune::function] +fn b64_decode(data: String) -> VmResult { + result_to_value(do_b64_decode(&data), "b64_decode") +} + +fn do_b64_decode(data: &str) -> Result { + let bytes = BASE64 + .decode(data.as_bytes()) + .map_err(|e| format!("b64_decode: {e}"))?; + String::from_utf8(bytes).map_err(|e| format!("b64_decode: invalid utf-8: {e}")) +} + +/// `json_encode(value)` → `Ok(json_text)` for any serializable value, else `Err(message)`. +/// Free function. +#[rune::function] +fn json_encode(value: Value) -> VmResult { + let encoded = serde_json::to_string(&value).map_err(|e| format!("json_encode: {e}")); + result_to_value(encoded, "json_encode") +} + +/// `json_decode(text)` → `Ok(value)` for valid JSON (object/array/scalar), else `Err(message)`. +/// Free function. +#[rune::function] +fn json_decode(data: String) -> VmResult { + let decoded = serde_json::from_str::(&data).map_err(|e| format!("json_decode: {e}")); + result_to_value(decoded, "json_decode") +} + +/// `read_file(path)` → `Ok(contents)` reading `path` as UTF-8 text, else `Err(message)`. +/// Reaches the host filesystem with the process's privileges; for trusted scripts only. +/// Free function. +#[rune::function] +fn read_file(path: String) -> VmResult { + let read = std::fs::read_to_string(&path).map_err(|e| format!("read_file {path}: {e}")); + result_to_value(read, "read_file") +} + +/// `read_env(name)` → the value of environment variable `name`, or `None` if it is unset or +/// not valid UTF-8. Reads the process environment; for trusted scripts only. Free function. +#[rune::function] +fn read_env(name: String) -> Option { + std::env::var(name).ok() +} + +/// `unix_time()` → current wall-clock Unix time in whole seconds since the epoch (0 if the +/// clock is set before 1970). Free function. +#[rune::function] +fn unix_time() -> i64 { + SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +/// The libredfish host module registered into the Rune context. +fn module() -> Result { + let mut m = Module::new(); + m.ty::()?; + m.function_meta(get)?; + m.function_meta(patch)?; + m.function_meta(post)?; + m.function_meta(delete)?; + m.function_meta(system_id)?; + m.function_meta(manager_id)?; + m.function_meta(variant)?; + m.function_meta(bmc_address)?; + m.function_meta(sha256)?; + m.function_meta(sha512)?; + m.function_meta(b64_encode)?; + m.function_meta(b64_decode)?; + m.function_meta(json_encode)?; + m.function_meta(json_decode)?; + m.function_meta(read_file)?; + m.function_meta(read_env)?; + m.function_meta(unix_time)?; + Ok(m) +} + +// Compilation / runtime. + +fn ctx_err(e: impl std::fmt::Display) -> RedfishError { + RedfishError::GenericError { + error: format!("rune context: {e}"), + } +} + +/// Build a compile and runtime context from the default std modules plus our host module. +fn build_context() -> Result { + let mut context = Context::with_default_modules().map_err(ctx_err)?; + context + .install(module().map_err(ctx_err)?) + .map_err(ctx_err)?; + Ok(context) +} + +/// Shared runtime context, built once from the same module set the units compile against. +fn shared_runtime() -> Result, RedfishError> { + static RT: OnceLock> = OnceLock::new(); + if let Some(rt) = RT.get() { + return Ok(rt.clone()); + } + let context = build_context()?; + let rt = Arc::new(context.runtime().map_err(ctx_err)?); + let _ = RT.set(rt.clone()); + Ok(rt) +} + +/// Cache of compiled units, keyed by script path (invalidated by mtime). +type UnitCache = HashMap)>; + +/// Compile a script to a `Unit`, cached by path + mtime (recompiled on change). +fn compile(path: &str) -> Result, RedfishError> { + static CACHE: OnceLock> = OnceLock::new(); + let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new())); + let p = PathBuf::from(path); + + let mtime = std::fs::metadata(&p) + .and_then(|m| m.modified()) + .map_err(|e| RedfishError::FileError(format!("rune script {path}: {e}")))?; + if let Some((t, u)) = cache.lock().unwrap().get(&p) { + if *t == mtime { + return Ok(u.clone()); + } + } + + let src = std::fs::read_to_string(&p) + .map_err(|e| RedfishError::FileError(format!("rune script {path}: {e}")))?; + let context = build_context()?; + let mut sources = Sources::new(); + sources + .insert( + Source::new(path, src) + .map_err(|e| RedfishError::FileError(format!("rune source {path}: {e}")))?, + ) + .map_err(|e| RedfishError::FileError(format!("rune sources {path}: {e}")))?; + let mut diagnostics = Diagnostics::new(); + let unit = rune::prepare(&mut sources) + .with_context(&context) + .with_diagnostics(&mut diagnostics) + .build() + .map_err(|e| RedfishError::FileError(format!("rune compile {path}: {e}")))?; + + let unit = Arc::new(unit); + cache.lock().unwrap().insert(p, (mtime, unit.clone())); + Ok(unit) +} + +// The vendor. + +pub(crate) struct Bmc { + s: RedfishStandard, + unit: Arc, + runtime: Arc, +} + +impl Bmc { + pub(crate) fn new(s: RedfishStandard) -> Result { + let path = s.vendor_script().ok_or_else(|| { + RedfishError::FileError( + "Rune vendor selected but no script set (override entry needs a \"script\" path)" + .to_string(), + ) + })?; + let unit = compile(path)?; + let runtime = shared_runtime()?; + Ok(Bmc { s, unit, runtime }) + } + + /// Build a fresh script context. Cheap, just clones the client handle. + fn ctx(&self) -> RedfishCtx { + RedfishCtx { + client: self.s.client.clone(), + system_id: self.s.system_id().to_string(), + manager_id: self.s.manager_id().to_string(), + variant: self.s.vendor_variant().map(str::to_string), + } + } + + /// True if the script defines a top-level function `name`. + fn has(&self, name: &str) -> bool { + Vm::new(self.runtime.clone(), self.unit.clone()) + .lookup_function([name]) + .is_ok() + } + + /// Call script function `name` with `args` (Send native values), deserialize the result. + async fn call(&self, name: &str, args: A) -> Result + where + A: Args + Send, + T: DeserializeOwned, + { + let execution = Vm::new(self.runtime.clone(), self.unit.clone()) + .send_execute([name], args) + .map_err(|e| RedfishError::GenericError { + error: format!("rune {name}: {e}"), + })?; + let value = execution + .async_complete() + .await + .into_result() + .map_err(|e| RedfishError::GenericError { + error: format!("rune {name}: {e}"), + })?; + interpret::(value, name) + } +} + +// Per-method dispatch generators (script if defined, else `self.s`). The macros own +// the `'a` lifetime. Entries are separated by `;` so return types can contain commas. +macro_rules! dispatch_noarg { + ($($name:ident -> $ret:ty);* $(;)?) => {$( + fn $name<'a>(&'a self) -> RedfishFuture<'a, Result<$ret, RedfishError>> { + if self.has(stringify!($name)) { + Box::pin(async move { + self.call::<_, $ret>(stringify!($name), (self.ctx(),)).await + }) + } else { + self.s.$name() + } + } + )*}; +} + +macro_rules! dispatch_noarg_boxed { + ($($name:ident -> $ret:ty);* $(;)?) => {$( + fn $name<'a>(&'a self) -> RedfishFuture<'a, Result<$ret, RedfishError>> { + if self.has(stringify!($name)) { + Box::pin(async move { + self.call::<_, $ret>(stringify!($name), (self.ctx(),)).await + }) + } else { + Box::pin(self.s.$name()) + } + } + )*}; +} + +macro_rules! dispatch_str { + ($($name:ident ( $($arg:ident),+ ) -> $ret:ty);* $(;)?) => {$( + fn $name<'a>(&'a self $(, $arg: &'a str)+) -> RedfishFuture<'a, Result<$ret, RedfishError>> { + if self.has(stringify!($name)) { + Box::pin(async move { + self.call::<_, $ret>(stringify!($name), (self.ctx(), $($arg.to_string()),+)).await + }) + } else { + self.s.$name($($arg),+) + } + } + )*}; +} + +/// The Redfish `BootSourceOverrideTarget` string for a `Boot` value. +fn boot_target_str(target: Boot) -> &'static str { + match target { + Boot::Pxe => "Pxe", + Boot::HardDisk => "Hdd", + Boot::UefiHttp => "UefiHttp", + } +} + +impl Redfish for Bmc { + dispatch_noarg! { + get_accounts -> Vec; + get_software_inventories -> Vec; + get_tasks -> Vec; + get_power_state -> PowerState; + get_service_root -> ServiceRoot; + get_systems -> Vec; + get_system -> ComputerSystem; + get_managers -> Vec; + get_manager -> Manager; + get_secure_boot -> SecureBoot; + disable_secure_boot -> (); + enable_secure_boot -> (); + bmc_reset -> (); + bmc_reset_to_defaults -> (); + get_system_event_log -> Vec; + set_machine_password_policy -> (); + setup_serial_console -> (); + clear_tpm -> (); + pcie_devices -> Vec; + bios -> HashMap; + reset_bios -> (); + pending -> HashMap; + clear_pending -> (); + get_chassis_all -> Vec; + get_manager_ethernet_interfaces -> Vec; + get_system_ethernet_interfaces -> Vec; + get_update_service -> UpdateService; + get_base_mac_address -> Option; + is_ipmi_over_lan_enabled -> bool; + enable_rshim_bmc -> (); + clear_nvram -> (); + get_nic_mode -> Option; + enable_infinite_boot -> (); + is_infinite_boot_enabled -> Option; + get_host_rshim -> Option; + get_boss_controller -> Option; + get_component_integrities -> ComponentIntegrities; + set_utc_timezone -> (); + } + + dispatch_noarg_boxed! { + get_power_metrics -> Power; + get_thermal_metrics -> Thermal; + get_drives_metrics -> Vec; + get_boot_options -> BootOptions; + } + + dispatch_str! { + delete_user(username) -> (); + get_firmware(id) -> SoftwareInventory; + get_task(id) -> Task; + get_secure_boot_certificate(database_id, certificate_id) -> Certificate; + get_secure_boot_certificates(database_id) -> Vec; + add_secure_boot_certificate(pem_cert, database_id) -> Task; + get_boot_option(option_id) -> BootOption; + get_network_device_functions(chassis_id) -> Vec; + get_chassis(id) -> Chassis; + get_chassis_assembly(chassis_id) -> Assembly; + get_chassis_network_adapters(chassis_id) -> Vec; + get_chassis_network_adapter(chassis_id, id) -> NetworkAdapter; + get_base_network_adapters(system_id) -> Vec; + get_base_network_adapter(system_id, id) -> NetworkAdapter; + get_ports(chassis_id, network_adapter) -> Vec; + get_port(chassis_id, network_adapter, id) -> NetworkPort; + get_manager_ethernet_interface(id) -> EthernetInterface; + get_system_ethernet_interface(id) -> EthernetInterface; + change_username(old_name, new_name) -> (); + change_password(username, new_pass) -> (); + change_password_by_id(account_id, new_pass) -> (); + change_uefi_password(current_uefi_password, new_uefi_password) -> Option; + clear_uefi_password(current_uefi_password) -> Option; + get_job_state(job_id) -> JobState; + get_firmware_for_component(component_integrity_id) -> SoftwareInventory; + get_component_ca_certificate(url) -> CaCertificate; + trigger_evidence_collection(url, nonce) -> Task; + get_evidence(url) -> Evidence; + decommission_storage_controller(controller_id) -> Option; + create_storage_volume(controller_id, volume_name) -> Option; + } + + // dispatched (enum arg marshaled as a string) + fn power<'a>( + &'a self, + action: SystemPowerControl, + ) -> RedfishFuture<'a, Result<(), RedfishError>> { + if self.has("power") { + Box::pin(async move { + self.call::<_, ()>("power", (self.ctx(), action.to_string())) + .await + }) + } else { + self.s.power(action) + } + } + + // return types without `Deserialize` can't use the script return bridge, so delegate + fn get_gpu_sensors<'a>(&'a self) -> RedfishFuture<'a, Result, RedfishError>> { + self.s.get_gpu_sensors() + } + + fn lockdown_status<'a>(&'a self) -> RedfishFuture<'a, Result> { + self.s.lockdown_status() + } + + fn serial_console_status<'a>(&'a self) -> RedfishFuture<'a, Result> { + self.s.serial_console_status() + } + + // Complex or vendor args delegate to standard (scriptable later). + fn create_user<'a>( + &'a self, + username: &'a str, + password: &'a str, + role_id: RoleId, + ) -> RedfishFuture<'a, Result<(), RedfishError>> { + self.s.create_user(username, password, role_id) + } + + fn chassis_reset<'a>( + &'a self, + chassis_id: &'a str, + reset_type: SystemPowerControl, + ) -> RedfishFuture<'a, Result<(), RedfishError>> { + self.s.chassis_reset(chassis_id, reset_type) + } + + fn get_bmc_event_log<'a>( + &'a self, + from: Option>, + ) -> RedfishFuture<'a, Result, RedfishError>> { + self.s.get_bmc_event_log(from) + } + + fn machine_setup<'a>( + &'a self, + boot_interface: Option>, + bios_profiles: &'a BiosProfileVendor, + selected_profile: BiosProfileType, + oem_manager_profiles: &'a BiosProfileVendor, + ) -> RedfishFuture<'a, Result, RedfishError>> { + if self.has("machine_setup") { + Box::pin(async move { + self.call::<_, Option>("machine_setup", (self.ctx(),)) + .await + }) + } else { + self.s.machine_setup( + boot_interface, + bios_profiles, + selected_profile, + oem_manager_profiles, + ) + } + } + + fn machine_setup_status<'a>( + &'a self, + boot_interface: Option>, + ) -> RedfishFuture<'a, Result> { + self.s.machine_setup_status(boot_interface) + } + + fn is_bios_setup<'a>( + &'a self, + boot_interface: Option>, + ) -> RedfishFuture<'a, Result> { + if self.has("is_bios_setup") { + Box::pin(async move { self.call::<_, bool>("is_bios_setup", (self.ctx(),)).await }) + } else { + self.s.is_bios_setup(boot_interface) + } + } + + fn lockdown<'a>( + &'a self, + target: EnabledDisabled, + ) -> RedfishFuture<'a, Result<(), RedfishError>> { + self.s.lockdown(target) + } + + fn boot_once<'a>(&'a self, target: Boot) -> RedfishFuture<'a, Result<(), RedfishError>> { + if self.has("boot_once") { + let t = boot_target_str(target).to_string(); + Box::pin(async move { self.call::<_, ()>("boot_once", (self.ctx(), t)).await }) + } else { + self.s.boot_once(target) + } + } + + fn boot_first<'a>(&'a self, target: Boot) -> RedfishFuture<'a, Result<(), RedfishError>> { + if self.has("boot_first") { + let t = boot_target_str(target).to_string(); + Box::pin(async move { self.call::<_, ()>("boot_first", (self.ctx(), t)).await }) + } else { + self.s.boot_first(target) + } + } + + fn set_boot_override<'a>( + &'a self, + settings: BootOverride, + ) -> RedfishFuture<'a, Result, RedfishError>> { + if self.has("set_boot_override") { + let target = settings.target.to_string(); + let enabled = settings.enabled.to_string(); + let mode = settings.mode.as_ref().map(|m| m.to_string()); + let uri = settings.http_boot_uri.clone(); + Box::pin(async move { + self.call::<_, Option>( + "set_boot_override", + (self.ctx(), target, enabled, mode, uri), + ) + .await + }) + } else { + self.s.set_boot_override(settings) + } + } + + fn change_boot_order<'a>( + &'a self, + boot_array: Vec, + ) -> RedfishFuture<'a, Result<(), RedfishError>> { + self.s.change_boot_order(boot_array) + } + + fn set_ntp_servers<'a>( + &'a self, + servers: &'a [String], + ) -> RedfishFuture<'a, Result<(), RedfishError>> { + self.s.set_ntp_servers(servers) + } + + fn update_firmware<'a>( + &'a self, + filename: tokio::fs::File, + ) -> RedfishFuture<'a, Result> { + self.s.update_firmware(filename) + } + + fn update_firmware_multipart<'a>( + &'a self, + firmware: &'a Path, + reboot: bool, + timeout: Duration, + component_type: ComponentType, + ) -> RedfishFuture<'a, Result> { + self.s + .update_firmware_multipart(firmware, reboot, timeout, component_type) + } + + fn update_firmware_simple_update<'a>( + &'a self, + image_uri: &'a str, + targets: Vec, + transfer_protocol: TransferProtocolType, + ) -> RedfishFuture<'a, Result> { + self.s + .update_firmware_simple_update(image_uri, targets, transfer_protocol) + } + + fn set_bios<'a>( + &'a self, + values: HashMap, + ) -> RedfishFuture<'a, Result<(), RedfishError>> { + self.s.set_bios(values) + } + + fn get_network_device_function<'a>( + &'a self, + chassis_id: &'a str, + id: &'a str, + port: Option<&'a str>, + ) -> RedfishFuture<'a, Result> { + self.s.get_network_device_function(chassis_id, id, port) + } + + fn get_resource<'a>( + &'a self, + id: ODataId, + ) -> RedfishFuture<'a, Result> { + self.s.get_resource(id) + } + + fn get_collection<'a>( + &'a self, + id: ODataId, + ) -> RedfishFuture<'a, Result> { + self.s.get_collection(id) + } + + fn set_boot_order_dpu_first<'a>( + &'a self, + boot_interface: crate::BootInterfaceRef<'a>, + ) -> RedfishFuture<'a, Result, RedfishError>> { + if self.has("set_boot_order_dpu_first") { + Box::pin(async move { + self.call::<_, Option>("set_boot_order_dpu_first", (self.ctx(),)) + .await + }) + } else { + self.s.set_boot_order_dpu_first(boot_interface) + } + } + + fn lockdown_bmc<'a>( + &'a self, + target: EnabledDisabled, + ) -> RedfishFuture<'a, Result<(), RedfishError>> { + self.s.lockdown_bmc(target) + } + + fn enable_ipmi_over_lan<'a>( + &'a self, + target: EnabledDisabled, + ) -> RedfishFuture<'a, Result<(), RedfishError>> { + self.s.enable_ipmi_over_lan(target) + } + + fn set_nic_mode<'a>(&'a self, mode: NicMode) -> RedfishFuture<'a, Result<(), RedfishError>> { + self.s.set_nic_mode(mode) + } + + fn set_host_rshim<'a>( + &'a self, + enabled: EnabledDisabled, + ) -> RedfishFuture<'a, Result<(), RedfishError>> { + self.s.set_host_rshim(enabled) + } + + fn set_idrac_lockdown<'a>( + &'a self, + enabled: EnabledDisabled, + ) -> RedfishFuture<'a, Result<(), RedfishError>> { + self.s.set_idrac_lockdown(enabled) + } + + fn is_boot_order_setup<'a>( + &'a self, + boot_interface: crate::BootInterfaceRef<'a>, + ) -> RedfishFuture<'a, Result> { + if self.has("is_boot_order_setup") { + Box::pin(async move { + self.call::<_, bool>("is_boot_order_setup", (self.ctx(),)) + .await + }) + } else { + self.s.is_boot_order_setup(boot_interface) + } + } + + fn set_host_privilege_level<'a>( + &'a self, + level: HostPrivilegeLevel, + ) -> RedfishFuture<'a, Result<(), RedfishError>> { + self.s.set_host_privilege_level(level) + } + + fn ac_powercycle_supported_by_power(&self) -> bool { + self.s.ac_powercycle_supported_by_power() + } +} + +#[cfg(test)] +mod test { + use super::{ + compile, do_b64_decode, interpret, response_json, shared_runtime, RedfishCtx, + RedfishHttpClient, + }; + use reqwest::header::HeaderMap; + use reqwest::StatusCode; + use rune::runtime::Value; + use rune::Vm; + + const STUB: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/rune/http_stub.rn"); + + // Run a no-arg async fn from the committed stub and return its raw rune Value. + async fn run(name: &str) -> Value { + let unit = compile(STUB).unwrap(); + Vm::new(shared_runtime().unwrap(), unit) + .send_execute([name], ()) + .unwrap() + .async_complete() + .await + .into_result() + .unwrap() + } + + // Compile a script, run an async fn, and bridge the result back through serde_json. + #[tokio::test] + async fn script_runs_and_result_bridges() { + let path = std::env::temp_dir().join("libredfish_rune_ok.rn"); + std::fs::write(&path, "pub async fn answer() { 42 }").unwrap(); + let unit = compile(path.to_str().unwrap()).unwrap(); + let runtime = shared_runtime().unwrap(); + let value = Vm::new(runtime, unit) + .send_execute(["answer"], ()) + .unwrap() + .async_complete() + .await + .into_result() + .unwrap(); + let n: i64 = serde_json::from_value(serde_json::to_value(&value).unwrap()).unwrap(); + assert_eq!(n, 42); + } + + #[test] + fn compile_error_is_file_error() { + let path = std::env::temp_dir().join("libredfish_rune_bad.rn"); + std::fs::write(&path, "pub async fn x( {").unwrap(); + let err = compile(path.to_str().unwrap()).unwrap_err(); + assert!( + matches!(err, crate::RedfishError::FileError(_)), + "expected FileError, got {err:?}" + ); + } + + #[test] + fn json_value_bridge_roundtrips() { + let j = serde_json::json!({"PowerState":"On","n":3,"list":[1,2],"nil":null}); + let v: Value = serde_json::from_value(j.clone()).unwrap(); + let back = serde_json::to_value(&v).unwrap(); + assert_eq!(j, back); + } + + // Unit returns (from every dispatched method that yields `()`) must bridge. + #[tokio::test] + async fn unit_return_bridges() { + let path = std::env::temp_dir().join("libredfish_rune_unit.rn"); + std::fs::write(&path, "pub async fn nothing() { () }").unwrap(); + let unit = compile(path.to_str().unwrap()).unwrap(); + let value = Vm::new(shared_runtime().unwrap(), unit) + .send_execute(["nothing"], ()) + .unwrap() + .async_complete() + .await + .into_result() + .unwrap(); + let _: () = serde_json::from_value(serde_json::to_value(&value).unwrap()).unwrap(); + } + + // A script `Err(..)` (say from `?` on a failed call) becomes a RedfishError. + #[tokio::test] + async fn script_err_becomes_redfish_error() { + let v = run("returns_err").await; + let r = interpret::<()>(v, "returns_err"); + assert!( + matches!(r, Err(crate::RedfishError::GenericError { .. })), + "expected GenericError, got {r:?}" + ); + } + + // A top level `Ok(v)` is unwrapped before bridging. + #[tokio::test] + async fn script_ok_is_unwrapped() { + let v = run("returns_ok").await; + let n: i64 = interpret::(v, "returns_ok").unwrap(); + assert_eq!(n, 42); + } + + // A bare return that isn't a Result bridges directly (backward compatible). + #[tokio::test] + async fn bare_value_passes_through() { + let v = run("returns_bare").await; + let n: i64 = interpret::(v, "returns_bare").unwrap(); + assert_eq!(n, 42); + } + + // The committed stub's HTTP functions must compile against the host module. + #[test] + fn stub_http_functions_compile() { + let unit = compile(STUB).unwrap(); + let rt = shared_runtime().unwrap(); + assert!(Vm::new(rt.clone(), unit.clone()) + .lookup_function(["power_state"]) + .is_ok()); + assert!(Vm::new(rt, unit) + .lookup_function(["reset_and_wait"]) + .is_ok()); + } + + // The response object handed to scripts has status, headers, and body. + #[test] + fn response_json_shape() { + let mut h = HeaderMap::new(); + h.insert( + "location", + "/redfish/v1/TaskService/Tasks/3".parse().unwrap(), + ); + let body = Some(serde_json::json!({ "PowerState": "On" })); + let j = response_json(StatusCode::ACCEPTED, Some(h), body); + assert_eq!(j["status"], 202); + assert_eq!(j["headers"]["location"], "/redfish/v1/TaskService/Tasks/3"); + assert_eq!(j["body"]["PowerState"], "On"); + } + + // The free helpers (sha/b64/json/read_*/unix_time) register and resolve when called bare, + // and `bmc_address` rides on `ctx`. Fully offline: sha2/base64/json are pure; read_file + // hits a temp file and read_env a uniquely-named var set here. + #[tokio::test] + async fn host_helpers_register_and_run() { + std::env::set_var("LIBREDFISH_RUNE_TEST_ENVVAR", "present"); + let file_path = std::env::temp_dir().join("libredfish_rune_readfile.txt"); + std::fs::write(&file_path, "hello-from-file").unwrap(); + + let endpoint = crate::Endpoint { + host: "bmc.example".to_string(), + port: None, + user: None, + password: None, + }; + let client = RedfishHttpClient::new(reqwest::Client::new(), endpoint, Vec::new()); + let ctx = RedfishCtx { + client, + system_id: "1".to_string(), + manager_id: "1".to_string(), + variant: None, + }; + + let script = r#"pub async fn probe(ctx, file_path, env_present, env_missing) { + let decoded_b64 = match b64_decode("YWJj") { Ok(t) => t, Err(e) => e }; + let obj = match json_decode("{\"PowerState\":\"On\",\"n\":3}") { Ok(v) => v, Err(_) => #{} }; + let file = match read_file(file_path) { Ok(t) => t, Err(e) => e }; + let encoded = match json_encode(#{ "a": 1 }) { Ok(s) => s, Err(e) => e }; + #{ + "addr": ctx.bmc_address(), + "sha256_abc": sha256("abc"), + "sha512_abc": sha512("abc"), + "b64_abc": b64_encode("abc"), + "b64_roundtrip": decoded_b64, + "json_power": obj["PowerState"], + "json_n": obj["n"], + "json_encoded": encoded, + "file": file, + "env_present": read_env(env_present), + "env_missing": read_env(env_missing), + "unix_time": unix_time() + } +}"#; + let path = std::env::temp_dir().join("libredfish_rune_host_helpers.rn"); + std::fs::write(&path, script).unwrap(); + + let unit = compile(path.to_str().unwrap()).unwrap(); + let value = Vm::new(shared_runtime().unwrap(), unit) + .send_execute( + ["probe"], + ( + ctx, + file_path.to_string_lossy().to_string(), + "LIBREDFISH_RUNE_TEST_ENVVAR".to_string(), + "LIBREDFISH_RUNE_DEFINITELY_UNSET_9f3b".to_string(), + ), + ) + .unwrap() + .async_complete() + .await + .into_result() + .unwrap(); + let out: serde_json::Value = serde_json::to_value(&value).unwrap(); + + assert_eq!(out["addr"], "bmc.example"); + // NIST SHA-256/512("abc") vectors — proves the digest, not just registration. + assert_eq!( + out["sha256_abc"], + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + assert_eq!( + out["sha512_abc"], + "ddaf35a193617abacc417349ae20413112e6fa4e89a97ea20a9eeee64b55d39a2192992a274fc1a836ba3c23a3feebbd454d4423643ce80e2a9ac94fa54ca49f" + ); + assert_eq!(out["b64_abc"], "YWJj"); + assert_eq!(out["b64_roundtrip"], "abc"); + assert_eq!(out["json_power"], "On"); + assert_eq!(out["json_n"], 3); + assert_eq!(out["json_encoded"], "{\"a\":1}"); + assert_eq!(out["file"], "hello-from-file"); + assert_eq!(out["env_present"], "present"); + assert!(out["env_missing"].is_null()); + assert!( + out["unix_time"].as_i64().unwrap() > 1_700_000_000, + "unix_time should be a recent epoch second, got {:?}", + out["unix_time"] + ); + } + + // `b64_decode` round-trips valid input and reports an error on non-base64 (the Err the + // script can `match`/`?`). + #[test] + fn b64_decode_roundtrips_and_rejects_invalid() { + assert_eq!(do_b64_decode("YWJj").unwrap(), "abc"); + assert!(do_b64_decode("*** not base64 ***").is_err()); + } +} diff --git a/src/standard.rs b/src/standard.rs index 2bdc53a60..4c2c33ac9 100644 --- a/src/standard.rs +++ b/src/standard.rs @@ -67,6 +67,12 @@ pub struct RedfishStandard { manager_id: String, system_id: String, service_root: ServiceRoot, + /// Optional free-form variant from the vendor-override file, handed to the + /// vendor implementation so it can branch on a host-specific type. + vendor_variant: Option, + /// Optional Rune script path (from the vendor-override file) implementing + /// the vendor; consumed by the `rune` vendor. + vendor_script: Option, } impl Redfish for RedfishStandard { fn create_user<'a>( @@ -810,6 +816,17 @@ impl Redfish for RedfishStandard { ) -> crate::RedfishFuture<'a, Result> { Box::pin(async move { let (_status_code, mut body): (StatusCode, ServiceRoot) = self.client.get("").await?; + // Honor a vendor-override file: stamp the forced vendor onto the service + // root so auto-detection and any reader of `vendor` pick it up. + // Fail-closed on a bad file, matching client creation. + if let Some(ov) = + crate::vendor_override::resolve(self.client.host(), self.manager_id())? + { + body.override_vendor = Some(ov.vendor); + if body.vendor.is_none() { + body.vendor = Some(ov.vendor.to_string()); + } + } if body.vendor.is_none() && !self.client.is_anonymous() { // Power shelves don't advertise a vendor in the service root, // so fall back to the Manufacturer of the first chassis that @@ -1329,6 +1346,7 @@ impl RedfishStandard { RedfishVendor::DeltaPowerShelf => { Ok(Box::new(crate::delta_powershelf::Bmc::new(self.clone())?)) } + RedfishVendor::Rune => Ok(Box::new(crate::rune_vendor::Bmc::new(self.clone())?)), _ => Ok(Box::new(self.clone())), } } @@ -1359,6 +1377,8 @@ impl RedfishStandard { system_id: "".to_string(), vendor: None, service_root: default::Default::default(), + vendor_variant: None, + vendor_script: None, } } @@ -1370,6 +1390,28 @@ impl RedfishStandard { &self.manager_id } + /// Set the optional vendor variant (from the vendor-override file). Call + /// before `set_vendor` so the value is carried into the vendor client. + pub fn set_vendor_variant(&mut self, variant: Option) { + self.vendor_variant = variant; + } + + /// The optional vendor variant supplied via the vendor-override file, if any. + pub fn vendor_variant(&self) -> Option<&str> { + self.vendor_variant.as_deref() + } + + /// Set the optional Rune script path (from the vendor-override file). Call + /// before `set_vendor` so it is carried into the vendor client. + pub fn set_vendor_script(&mut self, script: Option) { + self.vendor_script = script; + } + + /// The optional Rune script path supplied via the vendor-override file, if any. + pub fn vendor_script(&self) -> Option<&str> { + self.vendor_script.as_deref() + } + /// Gets the location of the update service from the saved service root pub fn update_service(&self) -> String { self.service_root diff --git a/src/vendor_override.rs b/src/vendor_override.rs new file mode 100644 index 000000000..6f07432ca --- /dev/null +++ b/src/vendor_override.rs @@ -0,0 +1,139 @@ +//! Optional vendor override from a local JSON file. +//! +//! When `LIBREDFISH_VENDOR_OVERRIDE_FILE` is set, it points to a JSON file pinning +//! the Redfish vendor (and optional `variant`/`script`) per endpoint, keyed by +//! remote address and optionally manager id. It is consulted in [`crate::network`] +//! before auto-detection, so it wins over both an explicit and a detected vendor. +//! +//! Inert when unset; if set but the file is missing/unreadable/malformed, client +//! creation fails with [`RedfishError::FileError`] (fail-closed). +//! +//! File format (`vendor` is a [`RedfishVendor`] variant; `manager`/`variant`/`script` +//! optional): +//! +//! ```json +//! [ +//! { "addr": "10.42.0.5", "vendor": "Rune", "script": "/etc/bmc.rn", "variant": "model-x" }, +//! { "addr": "10.42.0.6", "manager": "1", "vendor": "Dell" } +//! ] +//! ``` + +use serde::Deserialize; + +use crate::model::service_root::RedfishVendor; +use crate::RedfishError; + +/// Environment variable holding the path to the vendor-override JSON file. +pub(crate) const ENV: &str = "LIBREDFISH_VENDOR_OVERRIDE_FILE"; + +#[derive(Debug, Deserialize)] +struct Entry { + /// BMC remote address; matched against `Endpoint::host`. + addr: String, + /// Optional manager id; when present, the entry only matches that manager. + #[serde(default)] + manager: Option, + vendor: RedfishVendor, + /// Optional free-form discriminator handed to the vendor implementation. + #[serde(default)] + variant: Option, + /// Optional path to a Rune script implementing the vendor (used by `Rune`). + #[serde(default)] + script: Option, +} + +/// A matched vendor override: the forced vendor plus an optional variant. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct VendorOverride { + pub vendor: RedfishVendor, + pub variant: Option, + pub script: Option, +} + +fn parse(contents: &str) -> Result, serde_json::Error> { + serde_json::from_str(contents) +} + +/// Pick the override for `host`/`manager_id`. An entry naming both the address +/// and the manager wins over an address-only entry (which acts as the +/// any-manager default for that address). +fn select(entries: &[Entry], host: &str, manager_id: &str) -> Option { + entries + .iter() + .find(|e| e.addr == host && e.manager.as_deref() == Some(manager_id)) + .or_else(|| { + entries + .iter() + .find(|e| e.addr == host && e.manager.is_none()) + }) + .map(|e| VendorOverride { + vendor: e.vendor, + variant: e.variant.clone(), + script: e.script.clone(), + }) +} + +/// Resolve a vendor override for the given endpoint. +/// +/// Returns `Ok(None)` when the env var is unset or no entry matches; returns +/// `Err(FileError)` when the env var is set but the file cannot be read or +/// parsed (fail-closed). +pub(crate) fn resolve( + host: &str, + manager_id: &str, +) -> Result, RedfishError> { + let Ok(path) = std::env::var(ENV) else { + return Ok(None); + }; + let contents = std::fs::read_to_string(&path) + .map_err(|e| RedfishError::FileError(format!("vendor override {path}: {e}")))?; + let entries = parse(&contents) + .map_err(|e| RedfishError::FileError(format!("vendor override {path}: {e}")))?; + Ok(select(&entries, host, manager_id)) +} + +#[cfg(test)] +mod test { + use super::*; + + const JSON: &str = r#"[ + { "addr": "10.42.0.5", "vendor": "Rune", "variant": "model-x" }, + { "addr": "10.42.0.6", "manager": "1", "vendor": "Dell" }, + { "addr": "10.42.0.6", "vendor": "NvidiaDpu" } + ]"#; + + fn entries() -> Vec { + parse(JSON).unwrap() + } + + #[test] + fn addr_only_match_returns_vendor_and_variant() { + let m = select(&entries(), "10.42.0.5", "1").unwrap(); + assert_eq!(m.vendor, RedfishVendor::Rune); + assert_eq!(m.variant.as_deref(), Some("model-x")); + } + + #[test] + fn addr_plus_manager_beats_addr_only() { + // 10.42.0.6 has a manager-"1" entry (Dell) and an addr-only entry (NvidiaDpu). + let with_mgr = select(&entries(), "10.42.0.6", "1").unwrap(); + assert_eq!(with_mgr.vendor, RedfishVendor::Dell); + assert_eq!(with_mgr.variant, None); + + // A different manager falls back to the addr-only default. + let other_mgr = select(&entries(), "10.42.0.6", "2").unwrap(); + assert_eq!(other_mgr.vendor, RedfishVendor::NvidiaDpu); + } + + #[test] + fn no_match_returns_none() { + assert!(select(&entries(), "10.0.0.99", "1").is_none()); + } + + #[test] + fn malformed_or_unknown_vendor_errors() { + assert!(parse("{ not json").is_err()); + // An unknown RedfishVendor variant name also fails to deserialize. + assert!(parse(r#"[{"addr":"x","vendor":"Nope"}]"#).is_err()); + } +} diff --git a/tests/rune/README.md b/tests/rune/README.md new file mode 100644 index 000000000..de69fa878 --- /dev/null +++ b/tests/rune/README.md @@ -0,0 +1,263 @@ +# Rune vendor — what a script can use + +The `rune` vendor (`src/rune_vendor.rs`) runs a [Rune](https://rune-rs.github.io) +script as a BMC backend. This is the reference for everything a script can call, +override, and rely on. + +> **Trust:** scripts run with the host process's privileges, no sandbox, and no +> resource limits. `read_file`/`read_env` reach the real filesystem and +> environment. Only load scripts you trust. + +## Selecting a script + +Set `LIBREDFISH_VENDOR_OVERRIDE_FILE` to a JSON file that pins the `Rune` vendor +and points at the script, keyed by BMC address (and optionally manager id): + +```json +[ + { "addr": "10.42.0.5", "vendor": "Rune", "script": "/etc/bmc.rn", "variant": "model-x" } +] +``` + +`variant` is optional free-form text the script reads via `ctx.variant()`. + +## How a script hooks in + +For each `Redfish` trait method, the vendor looks for a **top-level function with +the same name**. If the script defines it, it is called; otherwise the call falls +back to the standard Redfish implementation. So a script only implements what it +needs to change. + +```rune +// Overrides get_power_state; everything else uses the standard behavior. +pub async fn get_power_state(ctx) { + match ctx.get(`Systems/${ctx.system_id()}`).await { + Ok(resp) => resp["body"]["PowerState"], + Err(_) => "Unknown", + } +} +``` + +Override functions should be `pub async fn` and take `ctx` first, followed by any +string arguments the method passes (see the tables below). + +### Return & error conventions + +A function's return value is bridged into the method's Rust return type via JSON, +so return shapes that match the type (a string for `PowerState`, an object for +`bios`, `()` for actions, `None`/a value for `Option`, etc.). + +- `return Ok(v)` / a bare `v` → the method succeeds with `v`. +- `return Err(msg)`, or `?` on a failed call → the method fails with a + `RedfishError` carrying `msg`. + +## The `ctx` handle + +`ctx` is the BMC handle passed to every override. Its methods need BMC state, so +they live on `ctx`. + +| Call | Returns | Notes | +|------|---------|-------| +| `ctx.get(path).await` | `Ok(#{status, headers, body})` / `Err(msg)` | `path` is relative to `redfish/v1/`, no leading `/` | +| `ctx.post(path, body).await` | `Ok(#{...})` / `Err(msg)` | `body` is any value (encoded as JSON) | +| `ctx.patch(path, body).await` | `Ok(#{...})` / `Err(msg)` | | +| `ctx.delete(path).await` | `Ok(#{...})` / `Err(msg)` | | +| `ctx.system_id()` | `String` | first system id resolved at client creation | +| `ctx.manager_id()` | `String` | first manager id | +| `ctx.variant()` | `Option` | the override file's `variant`, if any | +| `ctx.bmc_address()` | `String` | BMC host/IP (the override file's `addr` key) | + +The response object is `#{ status: , headers: #{..}, body: }`. +Header names are lowercased. + +## Free functions (called directly, no `ctx`) + +These are pure/host helpers, so they are plain functions — call them by name. + +| Call | Returns | Notes | +|------|---------|-------| +| `sha256(text)` | `String` | lowercase-hex SHA-256 of the UTF-8 bytes | +| `sha512(text)` | `String` | lowercase-hex SHA-512 | +| `b64_encode(text)` | `String` | standard base64, padded | +| `b64_decode(text)` | `Ok(text)` / `Err(msg)` | errors on bad base64 or non-UTF-8 | +| `json_encode(value)` | `Ok(text)` / `Err(msg)` | serialize any value to JSON text | +| `json_decode(text)` | `Ok(value)` / `Err(msg)` | parse JSON text to a value | +| `read_file(path)` | `Ok(text)` / `Err(msg)` | read a file as UTF-8 (host privileges) | +| `read_env(name)` | `Option` | env var value, or `None` if unset | +| `unix_time()` | `i64` | wall-clock seconds since the Unix epoch | + +The ones returning `Ok/Err` can be matched or `?`-ed exactly like the HTTP verbs. + +## Methods a script may override + +Define a function with one of these names to take over that method. Arguments +shown are what the script receives (always `ctx` first). + +### No arguments — `pub async fn name(ctx)` + +``` +get_accounts get_software_inventories get_tasks +get_power_state get_service_root get_systems +get_system get_managers get_manager +get_secure_boot disable_secure_boot enable_secure_boot +bmc_reset bmc_reset_to_defaults get_system_event_log +set_machine_password_policy setup_serial_console clear_tpm +pcie_devices bios reset_bios +pending clear_pending get_chassis_all +get_manager_ethernet_interfaces get_system_ethernet_interfaces +get_update_service get_base_mac_address is_ipmi_over_lan_enabled +enable_rshim_bmc clear_nvram get_nic_mode +enable_infinite_boot is_infinite_boot_enabled get_host_rshim +get_boss_controller get_component_integrities set_utc_timezone +get_power_metrics get_thermal_metrics get_drives_metrics +get_boot_options +``` + +### String arguments — `pub async fn name(ctx, arg1, ...)` + +| Function | Args | +|----------|------| +| `delete_user` | `username` | +| `get_firmware` | `id` | +| `get_task` | `id` | +| `get_secure_boot_certificate` | `database_id, certificate_id` | +| `get_secure_boot_certificates` | `database_id` | +| `add_secure_boot_certificate` | `pem_cert, database_id` | +| `get_boot_option` | `option_id` | +| `get_network_device_functions` | `chassis_id` | +| `get_chassis` | `id` | +| `get_chassis_assembly` | `chassis_id` | +| `get_chassis_network_adapters` | `chassis_id` | +| `get_chassis_network_adapter` | `chassis_id, id` | +| `get_base_network_adapters` | `system_id` | +| `get_base_network_adapter` | `system_id, id` | +| `get_ports` | `chassis_id, network_adapter` | +| `get_port` | `chassis_id, network_adapter, id` | +| `get_manager_ethernet_interface` | `id` | +| `get_system_ethernet_interface` | `id` | +| `change_username` | `old_name, new_name` | +| `change_password` | `username, new_pass` | +| `change_password_by_id` | `account_id, new_pass` | +| `change_uefi_password` | `current_uefi_password, new_uefi_password` | +| `clear_uefi_password` | `current_uefi_password` | +| `get_job_state` | `job_id` | +| `get_firmware_for_component` | `component_integrity_id` | +| `get_component_ca_certificate` | `url` | +| `trigger_evidence_collection` | `url, nonce` | +| `get_evidence` | `url` | +| `decommission_storage_controller` | `controller_id` | +| `create_storage_volume` | `controller_id, volume_name` | + +### Enum/struct arguments marshaled as strings + +| Function | Args (script side) | +|----------|--------------------| +| `power` | `action` — e.g. `"On"`, `"ForceOff"`, `"GracefulRestart"` | +| `boot_once` | `target` — `"Pxe"`, `"Hdd"`, or `"UefiHttp"` | +| `boot_first` | `target` — same set | +| `set_boot_override` | `target, enabled, mode, uri` (`mode`/`uri` may be `None`) | + +### Extra Rust-only arguments are dropped (script gets just `ctx`) + +`machine_setup`, `is_bios_setup`, `set_boot_order_dpu_first`, `is_boot_order_setup`. + +### Always delegate — cannot be overridden from a script + +These take non-`Deserialize`/complex arguments or return non-`Deserialize` types, +so they always run the standard implementation: + +``` +get_gpu_sensors lockdown_status serial_console_status +create_user chassis_reset get_bmc_event_log +machine_setup_status lockdown change_boot_order +update_firmware update_firmware_multipart update_firmware_simple_update +set_bios get_network_device_function get_resource +get_collection lockdown_bmc enable_ipmi_over_lan +set_nic_mode set_host_rshim set_idrac_lockdown +set_host_privilege_level ac_powercycle_supported_by_power set_ntp_servers +``` + +## Language & standard library + +Scripts are ordinary Rune (0.14). Language: `let`, `if`/`else`, `match`, `for`, +`while`, `loop`, closures (`|x| ...`), `async`/`await`, the `?` operator, ranges +(`a..b`), template strings (`` `text ${expr}` ``), object literals +(`#{ key: value }`), vectors (`[1, 2]`), and tuples. + +The tables below are the script-callable standard library from +`Context::with_default_modules()`. Instance methods are `value.method(...)`; free +functions/constructors are bare (`min(a, b)`, `String::new()`); macros end in `!`. + +**Globals & macros** + +- Free fns: `min(a,b)`, `max(a,b)`, `clone(x)`, `drop(x)`, `print(x)`, `println(x)`, + `panic(msg)`, `range(a,b)` +- Macros: `format!`, `println!`, `print!`, `panic!`, `assert!`, `assert_eq!`, + `stringify!`, `file!`, `line!` + +**Iterators** (chain off `.iter()`, a range, or any iterable) + +`map`, `filter`, `filter_map`, `flat_map`, `enumerate`, `chain`, `skip`, `take`, +`peekable`, `rev`, `fold`, `reduce`, `find`, `any`, `all`, `count`, `sum`, +`product`, `nth`, `next`, `collect::()` / `collect::()` + +**String** — `len`, `is_empty`, `capacity`, `char_at`, `chars`, `bytes`, `lines`, +`get`, `contains`, `starts_with`, `ends_with`, `find`, `split`, `split_once`, +`split_str`, `trim`, `trim_end`, `replace`, `to_lowercase`, `to_uppercase`, +`push`, `push_str`, `clear`, `reserve`, `as_bytes`, `into_bytes`, +`is_char_boundary`, `parse::()`/`parse::()`/`parse::()`; ctors +`String::new`/`with_capacity`/`from`/`from_utf8`. + +**Vec / `[...]`** — `len`, `is_empty`, `capacity`, `push`, `pop`, `insert`, +`remove`, `get`, `clear`, `extend`, `resize`, `sort`, `sort_by`, `iter`; ctors +`Vec::new`/`with_capacity`; iterate `for x in v`. + +**Object / `#{...}`** — `get`, `contains_key`, `remove`; index `obj["key"]`; +iterate `for (k, v) in obj`. + +**Option** — `is_some`, `is_none`, `unwrap`, `unwrap_or`, `unwrap_or_else`, +`expect`, `map`, `and_then`, `ok_or`, `ok_or_else`, `take`, `transpose`, `iter`. + +**Result** — `is_ok`, `is_err`, `ok`, `unwrap`, `unwrap_or`, `unwrap_or_else`, +`expect`, `map`, `and_then`; plus `?`. + +**Numbers** + +- int (i64/u64): `abs`, `signum`, `pow`, `min`, `max`, `to_float`, `to_string`, + `parse`, `is_positive`, `is_negative`, `checked_add/sub/mul/div/rem`, + `saturating_*`, `wrapping_*` +- f64: `abs`, `ceil`, `floor`, `round`, `sqrt`, `powi`, `powf`, `is_nan`, + `is_finite`, `is_infinite`, `is_normal`, `is_subnormal`, `to::()`, `parse` +- operators `+ - * / %` and comparisons work via protocols + +**char** — `is_alphabetic`, `is_alphanumeric`, `is_numeric`, `is_whitespace`, +`is_control`, `is_uppercase`, `is_lowercase`, `to_digit`, `to_i64`; ctor +`char::from_i64`. + +**Tuple** — `len`, `is_empty`, `get`, `iter`. + +**Bytes** — `len`, `is_empty`, `push`, `pop`, `insert`, `remove`, `first`, `last`, +`extend`, `extend_str`, `as_vec`, `into_vec`, `clear`, `capacity`, `reserve`; +ctors `Bytes::new`/`with_capacity`/`from_vec`. + +**Collections** (under `std::collections` — need a `use` or full path) + +- `HashMap`: `new`, `with_capacity`, `from_iter`, `insert`, `get`, `remove`, + `contains_key`, `keys`, `values`, `iter`, `len`, `is_empty`, `clear`, + `capacity`, `extend` +- `HashSet`: `new`, `with_capacity`, `from_iter`, `insert`, `remove`, `contains`, + `union`, `intersection`, `difference`, `iter`, `len`, `is_empty`, `clear`, + `capacity`, `extend` +- `VecDeque`: `new`, `with_capacity`, `from_iter`, `from::`, `push_back`, + `push_front`, `pop_back`, `pop_front`, `front`, `back`, `insert`, `remove`, + `rotate_left`, `rotate_right`, `iter`, `len`, `reserve`, `extend` + +**Async & misc** — `future::join(..)` to await several futures; `cmp::Ordering` +(`Less`/`Equal`/`Greater`); `mem::drop`. + +**Boundary:** that is the whole of "pure computation." Rune's std has **no** +filesystem, network, process, clock, or environment access — a script reaches the +outside world only through the `ctx` methods and the free functions above, all +registered by libredfish. + +See `http_stub.rn` in this directory for runnable HTTP examples. diff --git a/tests/rune/http_stub.rn b/tests/rune/http_stub.rn new file mode 100644 index 000000000..a4a38711a --- /dev/null +++ b/tests/rune/http_stub.rn @@ -0,0 +1,37 @@ +// Test fixture for the Rune vendor host API (src/rune_vendor.rs). +// `returns_*` drive `interpret`'s result and error channel. `power_state` and +// `reset_and_wait` exercise the script HTTP API (get/post, matching on the +// response, `?`, indexing into `#{ status, headers, body }`) and must compile +// against the registered host module. + +pub async fn returns_err() { + Err("boom") +} + +pub async fn returns_ok() { + Ok(42) +} + +pub async fn returns_bare() { + 42 +} + +// Branch on a response without failing the method. +pub async fn power_state(ctx) { + match ctx.get(`Systems/${ctx.system_id()}`).await { + Ok(resp) => resp["body"]["PowerState"], + Err(_) => "Unknown", + } +} + +// Multiple calls driven by the response. POST an action, then poll the returned +// Task via the Location header. `?` fails the method (returns a RedfishError) on any error. +pub async fn reset_and_wait(ctx) { + let r = ctx.post(`Systems/${ctx.system_id()}/Actions/ComputerSystem.Reset`, + #{ "ResetType": "On" }).await?; + if r["status"] == 202 { + let task = r["headers"]["location"]; + ctx.get(task).await?; + } + None +}