use std::collections::BTreeMap;
use std::io::{Read, Write};
use std::net::{SocketAddr, TcpListener, TcpStream};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use serde_json::{json, Value};
use crate::kvm::KvmBoot;
use crate::redfish::wire::{self, prop};
use crate::{
BmcEndpoint, Boot, BootOrder, BootSpec, BootTarget, Error, Lifecycle, Machine, PowerState,
Result, Seen,
};
pub const BASE_REGISTRY: &str = "Base.1.19.0";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Message {
pub id: &'static str,
pub template: &'static str,
pub severity: &'static str,
pub resolution: &'static str,
pub nargs: usize,
pub status: u16,
}
impl Message {
pub fn message_id(&self) -> String {
format!("{BASE_REGISTRY}.{}", self.id)
}
pub fn render(&self, args: &[&str]) -> String {
debug_assert_eq!(
args.len(),
self.nargs,
"{} takes {} args, got {}",
self.id,
self.nargs,
args.len()
);
let mut out = self.template.to_string();
for (i, a) in args.iter().enumerate().rev() {
out = out.replace(&format!("%{}", i + 1), a);
}
out
}
}
pub const ACTION_PARAMETER_VALUE_NOT_IN_LIST: Message = Message {
id: "ActionParameterValueNotInList",
template: "The value '%1' for the parameter %2 in the action %3 is not in the list of acceptable values.",
severity: "Warning",
resolution: "Choose a value from the enumeration list that the implementation can support and resubmit the request if the operation failed.",
nargs: 3,
status: 400,
};
pub const ACTION_PARAMETER_MISSING: Message = Message {
id: "ActionParameterMissing",
template: "The action %1 requires the parameter %2 to be present in the request body.",
severity: "Critical",
resolution: "Supply the action with the required parameter in the request body when the request is resubmitted.",
nargs: 2,
status: 400,
};
pub const PROPERTY_VALUE_NOT_IN_LIST: Message = Message {
id: "PropertyValueNotInList",
template: "The value '%1' for the property %2 is not in the list of acceptable values.",
severity: "Warning",
resolution: "Choose a value from the enumeration list that the implementation can support and resubmit the request if the operation failed.",
nargs: 2,
status: 400,
};
pub const RESOURCE_MISSING_AT_URI: Message = Message {
id: "ResourceMissingAtURI",
template: "The resource at the URI '%1' was not found.",
severity: "Critical",
resolution: "Place a valid resource at the URI or correct the URI and resubmit the request.",
nargs: 1,
status: 400,
};
pub const RESOURCE_NOT_FOUND: Message = Message {
id: "ResourceNotFound",
template: "The requested resource of type %1 named '%2' was not found.",
severity: "Critical",
resolution: "Provide a valid resource identifier and resubmit the request.",
nargs: 2,
status: 404,
};
pub const NO_VALID_SESSION: Message = Message {
id: "NoValidSession",
template: "There is no valid session established with the implementation.",
severity: "Critical",
resolution: "Establish a session before attempting any operations.",
nargs: 0,
status: 401,
};
pub const ACTION_NOT_SUPPORTED: Message = Message {
id: "ActionNotSupported",
template: "The action %1 is not supported by the resource.",
severity: "Critical",
resolution: "Check the Actions property in the resource for the supported actions.",
nargs: 1,
status: 400,
};
pub const MALFORMED_JSON: Message = Message {
id: "MalformedJSON",
template: "The request body submitted was malformed JSON and could not be parsed by the receiving service.",
severity: "Critical",
resolution: "Ensure that the request body is valid JSON and resubmit the request.",
nargs: 0,
status: 400,
};
pub const GENERAL_ERROR: Message = Message {
id: "GeneralError",
template: "A general error has occurred. See Resolution for information on how to resolve the error, or @Message.ExtendedInfo if Resolution is not provided.",
severity: "Critical",
resolution: "None.",
nargs: 0,
status: 500,
};
pub const ALL_MESSAGES: &[Message] = &[
ACTION_PARAMETER_VALUE_NOT_IN_LIST,
ACTION_PARAMETER_MISSING,
PROPERTY_VALUE_NOT_IN_LIST,
RESOURCE_MISSING_AT_URI,
RESOURCE_NOT_FOUND,
NO_VALID_SESSION,
ACTION_NOT_SUPPORTED,
MALFORMED_JSON,
GENERAL_ERROR,
];
mod odata_type {
pub const SERVICE_ROOT: &str = "#ServiceRoot.v1_16_1.ServiceRoot";
pub const SYSTEM_COLLECTION: &str = "#ComputerSystemCollection.ComputerSystemCollection";
pub const SYSTEM: &str = "#ComputerSystem.v1_22_0.ComputerSystem";
pub const VIRTUAL_MEDIA_COLLECTION: &str = "#VirtualMediaCollection.VirtualMediaCollection";
pub const VIRTUAL_MEDIA: &str = "#VirtualMedia.v1_6_3.VirtualMedia";
pub const SESSION_COLLECTION: &str = "#SessionCollection.SessionCollection";
}
const REDFISH_VERSION: &str = "1.15.0";
#[derive(Debug, Clone)]
pub struct NodeConfig {
pub system_id: String,
pub media_slot: String,
pub username: String,
pub password: String,
pub bind: SocketAddr,
pub local_disk: Option<String>,
pub mem_mb: u32,
pub cores: u32,
pub system_name: String,
pub manufacturer: String,
pub model: String,
pub uuid: String,
pub extra_sans: Vec<String>,
}
impl NodeConfig {
pub fn new(system_id: impl Into<String>) -> Self {
Self {
system_id: system_id.into(),
media_slot: crate::redfish::DEFAULT_MEDIA_ID.to_string(),
username: "admin".into(),
password: String::new(),
bind: SocketAddr::from(([127, 0, 0, 1], 0)),
local_disk: None,
mem_mb: 1024,
cores: 2,
system_name: "draupnir-node".into(),
manufacturer: "nordisk".into(),
model: "draupnir Redfish/KVM".into(),
uuid: "1f0d3a26-4c5b-4e77-9a2c-6b1e0d7a55f1".into(),
extra_sans: Vec::new(),
}
}
pub fn credentials(mut self, username: impl Into<String>, password: impl Into<String>) -> Self {
self.username = username.into();
self.password = password.into();
self
}
pub fn bind(mut self, bind: SocketAddr) -> Self {
self.bind = bind;
self
}
pub fn media_slot(mut self, slot: impl Into<String>) -> Self {
self.media_slot = slot.into();
self
}
pub fn local_disk(mut self, disk: impl Into<String>) -> Self {
self.local_disk = Some(disk.into());
self
}
pub fn sized(mut self, mem_mb: u32, cores: u32) -> Self {
self.mem_mb = mem_mb;
self.cores = cores;
self
}
pub fn extra_sans<I, S>(mut self, sans: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.extra_sans.extend(sans.into_iter().map(Into::into));
self
}
}
#[derive(Debug, Default)]
struct NodeState {
image_uri: Option<String>,
image_path: Option<String>,
inserted: bool,
write_protected: bool,
override_enabled: String,
override_target: Option<BootTarget>,
machine: Option<Machine>,
last_spec: Option<BootSpec>,
last_reset: Option<String>,
}
impl NodeState {
fn fresh() -> Self {
Self {
override_enabled: wire::OVERRIDE_DISABLED.to_string(),
..Default::default()
}
}
}
pub trait NodeBackend: Boot + Lifecycle + Send + Sync {}
impl<T: Boot + Lifecycle + Send + Sync> NodeBackend for T {}
pub struct RedfishKvmServer {
cfg: NodeConfig,
addr: SocketAddr,
cert_pem: String,
state: Arc<Mutex<NodeState>>,
stop: Arc<AtomicBool>,
job: Option<gatling::background::Job<()>>,
kvm: Option<Arc<KvmBoot>>,
}
impl std::fmt::Debug for RedfishKvmServer {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RedfishKvmServer")
.field("addr", &self.addr)
.field("system_id", &self.cfg.system_id)
.field("media_slot", &self.cfg.media_slot)
.field("cert_pem", &format_args!("<{} bytes>", self.cert_pem.len()))
.finish_non_exhaustive()
}
}
impl RedfishKvmServer {
pub fn start(cfg: NodeConfig) -> Result<Self> {
let kvm = Arc::new(KvmBoot::new());
let backend: Arc<dyn NodeBackend> = kvm.clone();
let mut me = Self::start_with(cfg, backend)?;
me.kvm = Some(kvm);
Ok(me)
}
pub fn start_with(cfg: NodeConfig, backend: Arc<dyn NodeBackend>) -> Result<Self> {
let (tls_config, cert_pem) = tls_identity(&cfg)?;
let listener = TcpListener::bind(cfg.bind)
.map_err(|e| Error::Backend(format!("redfish server bind {}: {e}", cfg.bind)))?;
let addr = listener
.local_addr()
.map_err(|e| Error::Backend(format!("redfish server local_addr: {e}")))?;
listener
.set_nonblocking(true)
.map_err(|e| Error::Backend(format!("redfish server set_nonblocking: {e}")))?;
let state = Arc::new(Mutex::new(NodeState::fresh()));
let stop = Arc::new(AtomicBool::new(false));
let service = Service {
routes: Routes::for_node(&cfg),
cfg: cfg.clone(),
state: state.clone(),
backend,
};
let loop_stop = stop.clone();
let job = gatling::background::Job::spawn(move || {
accept_loop(listener, tls_config, service, loop_stop)
});
crate::functional_status(
"draupnir/redfish-server",
"start",
true,
&format!(
"BMC for system `{}` listening on https://{addr} (slot `{}`)",
cfg.system_id, cfg.media_slot
),
);
Ok(Self {
cfg,
addr,
cert_pem,
state,
stop,
job: Some(job),
kvm: None,
})
}
pub fn addr(&self) -> SocketAddr {
self.addr
}
pub fn base_url(&self) -> String {
format!("https://{}", self.addr)
}
pub fn cert_pem(&self) -> &str {
&self.cert_pem
}
pub fn bmc_endpoint(&self) -> BmcEndpoint {
BmcEndpoint {
host: self.base_url(),
username: self.cfg.username.clone(),
system_id: self.cfg.system_id.clone(),
}
}
pub fn machine(&self) -> Option<Machine> {
self.state.lock().unwrap().machine.clone()
}
pub fn last_boot_spec(&self) -> Option<BootSpec> {
self.state.lock().unwrap().last_spec.clone()
}
pub fn inserted_image(&self) -> Option<String> {
let s = self.state.lock().unwrap();
s.inserted.then(|| s.image_uri.clone()).flatten()
}
pub fn serial_log(&self) -> Option<String> {
let kvm = self.kvm.as_ref()?;
let machine = self.machine()?;
kvm.serial_log(&machine)
}
pub fn await_serial_marker(
&self,
marker: &str,
budget: Duration,
poll: Duration,
) -> Result<Seen> {
let kvm = self.kvm.as_ref().ok_or_else(|| {
Error::Backend(
"this Redfish server does not front a KVM backend (started with start_with)".into(),
)
})?;
let machine = self
.machine()
.ok_or_else(|| Error::Backend("no machine has been powered on yet".into()))?;
kvm.await_serial_marker(&machine, marker, budget, poll)
}
pub fn shutdown(&mut self) {
self.stop.store(true, Ordering::SeqCst);
if let Some(job) = self.job.take() {
let _ = job.join();
}
}
}
impl Drop for RedfishKvmServer {
fn drop(&mut self) {
self.shutdown();
if let (Some(kvm), Some(machine)) = (self.kvm.as_ref(), self.machine()) {
let _ = kvm.power_off(&machine);
}
}
}
#[allow(clippy::type_complexity)]
fn tls_identity(cfg: &NodeConfig) -> Result<(Arc<rustls::ServerConfig>, String)> {
let mut sans: Vec<String> = vec![
"localhost".into(),
"127.0.0.1".into(),
"::1".into(),
cfg.bind.ip().to_string(),
];
sans.extend(cfg.extra_sans.iter().cloned());
sans.sort();
sans.dedup();
let mut params = rcgen::CertificateParams::new(sans)
.map_err(|e| Error::Backend(format!("redfish server cert params: {e}")))?;
params.distinguished_name = {
let mut dn = rcgen::DistinguishedName::new();
dn.push(
rcgen::DnType::CommonName,
format!("draupnir BMC {}", cfg.system_id),
);
dn.push(rcgen::DnType::OrganizationName, "nordisk");
dn
};
let key = rcgen::KeyPair::generate()
.map_err(|e| Error::Backend(format!("redfish server keypair: {e}")))?;
let cert = params
.self_signed(&key)
.map_err(|e| Error::Backend(format!("redfish server self-sign: {e}")))?;
let cert_pem = cert.pem();
let cert_der = rustls::pki_types::CertificateDer::from(cert.der().to_vec());
let key_der = rustls::pki_types::PrivateKeyDer::try_from(key.serialize_der())
.map_err(|e| Error::Backend(format!("redfish server key DER: {e}")))?;
let provider = Arc::new(rustls::crypto::ring::default_provider());
let tls = rustls::ServerConfig::builder_with_provider(provider)
.with_safe_default_protocol_versions()
.map_err(|e| Error::Backend(format!("redfish server tls versions: {e}")))?
.with_no_client_auth()
.with_single_cert(vec![cert_der], key_der)
.map_err(|e| Error::Backend(format!("redfish server tls cert: {e}")))?;
Ok((Arc::new(tls), cert_pem))
}
const CONNECTION_TIMEOUT: Duration = Duration::from_secs(30);
const ACCEPT_POLL: Duration = Duration::from_millis(5);
fn accept_loop(
listener: TcpListener,
tls: Arc<rustls::ServerConfig>,
service: Service,
stop: Arc<AtomicBool>,
) {
while !stop.load(Ordering::SeqCst) {
match listener.accept() {
Ok((sock, _peer)) => {
if sock.set_nonblocking(false).is_err() {
continue;
}
let _ = sock.set_read_timeout(Some(CONNECTION_TIMEOUT));
let _ = sock.set_write_timeout(Some(CONNECTION_TIMEOUT));
serve_connection(sock, &tls, &service);
}
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
std::thread::sleep(ACCEPT_POLL);
}
Err(_) => break,
}
}
}
fn serve_connection(sock: TcpStream, tls: &Arc<rustls::ServerConfig>, service: &Service) {
let Ok(conn) = rustls::ServerConnection::new(tls.clone()) else {
return;
};
let mut stream = rustls::StreamOwned::new(conn, sock);
let mut buf: Vec<u8> = Vec::with_capacity(2048);
match read_request(&mut stream, &mut buf) {
Ok(None) => {}
Ok(Some(request)) => {
let response = service
.dispatch(&request)
.with_header("Connection", "close");
let _ = write_response(&mut stream, &response);
}
Err(resp) => {
let _ = write_response(&mut stream, &resp.with_header("Connection", "close"));
}
}
graceful_close(stream);
}
fn graceful_close(mut stream: rustls::StreamOwned<rustls::ServerConnection, TcpStream>) {
stream.conn.send_close_notify();
let _ = stream.flush();
let _ = stream.sock.shutdown(std::net::Shutdown::Write);
let _ = stream
.sock
.set_read_timeout(Some(Duration::from_millis(250)));
let mut sink = [0u8; 1024];
for _ in 0..16 {
match stream.sock.read(&mut sink) {
Ok(0) | Err(_) => break,
Ok(_) => continue,
}
}
}
const MAX_HEAD: usize = 16 * 1024;
const MAX_BODY: usize = 1024 * 1024;
struct Request {
method: String,
path: String,
headers: BTreeMap<String, String>,
body: Vec<u8>,
}
impl Request {
fn header(&self, name: &str) -> Option<&str> {
self.headers.get(&name.to_ascii_lowercase()).map(String::as_str)
}
}
struct Response {
status: u16,
headers: Vec<(String, String)>,
body: Vec<u8>,
}
impl Response {
fn new(status: u16) -> Self {
Self {
status,
headers: Vec::new(),
body: Vec::new(),
}
}
fn with_header(mut self, name: &str, value: impl Into<String>) -> Self {
self.headers.push((name.to_string(), value.into()));
self
}
fn json(status: u16, value: &Value) -> Self {
let mut r = Self::new(status);
r.body = serde_json::to_vec(value).unwrap_or_else(|_| b"{}".to_vec());
r.headers.push((
"Content-Type".into(),
"application/json;charset=utf-8".into(),
));
r
}
fn no_content() -> Self {
Self::new(204)
}
fn error(msg: Message, args: &[&str], detail: Option<&str>) -> Self {
let rendered = msg.render(args);
let resolution = match detail {
Some(d) => format!("{} {d}", msg.resolution),
None => msg.resolution.to_string(),
};
let mut info = json!({
"@odata.type": "#Message.v1_1_2.Message",
"MessageId": msg.message_id(),
"Message": rendered,
"MessageSeverity": msg.severity,
"Resolution": resolution,
});
if !args.is_empty() {
info["MessageArgs"] = json!(args);
}
Response::json(
msg.status,
&json!({
"error": {
"code": msg.message_id(),
"message": rendered,
"@Message.ExtendedInfo": [info],
}
}),
)
}
fn method_not_allowed(allow: &str) -> Self {
Response::error(
ACTION_NOT_SUPPORTED,
&["the requested method"],
Some(&format!("Allowed methods: {allow}.")),
)
.with_status(405)
.with_header("Allow", allow)
}
fn with_status(mut self, status: u16) -> Self {
self.status = status;
self
}
}
fn reason(status: u16) -> &'static str {
match status {
200 => "OK",
201 => "Created",
204 => "No Content",
400 => "Bad Request",
401 => "Unauthorized",
404 => "Not Found",
405 => "Method Not Allowed",
413 => "Payload Too Large",
500 => "Internal Server Error",
501 => "Not Implemented",
_ => "Status",
}
}
fn read_request<S: Read>(stream: &mut S, buf: &mut Vec<u8>) -> std::result::Result<Option<Request>, Response> {
let head_end = loop {
if let Some(p) = find_subsequence(buf, b"\r\n\r\n") {
break p;
}
if buf.len() > MAX_HEAD {
return Err(Response::error(
MALFORMED_JSON,
&[],
Some("The request head exceeded this service's limit."),
)
.with_status(413));
}
let mut chunk = [0u8; 1024];
match stream.read(&mut chunk) {
Ok(0) => return Ok(None),
Ok(n) => buf.extend_from_slice(&chunk[..n]),
Err(_) => return Ok(None),
}
};
let head = String::from_utf8_lossy(&buf[..head_end]).into_owned();
let mut lines = head.split("\r\n");
let request_line = lines.next().unwrap_or_default();
let mut parts = request_line.split_whitespace();
let method = parts.next().unwrap_or_default().to_string();
let raw_target = parts.next().unwrap_or_default();
if method.is_empty() || raw_target.is_empty() {
return Ok(None);
}
let path_part = raw_target.split('?').next().unwrap_or(raw_target);
let path = percent_decode(path_part);
let mut headers = BTreeMap::new();
for line in lines {
if let Some((k, v)) = line.split_once(':') {
headers.insert(k.trim().to_ascii_lowercase(), v.trim().to_string());
}
}
let len: usize = headers
.get("content-length")
.and_then(|v| v.parse().ok())
.unwrap_or(0);
if len > MAX_BODY {
return Err(Response::error(
MALFORMED_JSON,
&[],
Some("The request body exceeded this service's limit."),
)
.with_status(413));
}
let body_start = head_end + 4;
while buf.len() < body_start + len {
let mut chunk = [0u8; 4096];
match stream.read(&mut chunk) {
Ok(0) => return Ok(None),
Ok(n) => buf.extend_from_slice(&chunk[..n]),
Err(_) => return Ok(None),
}
}
let body = buf[body_start..body_start + len].to_vec();
buf.drain(..body_start + len);
Ok(Some(Request {
method,
path,
headers,
body,
}))
}
fn write_response<S: Write>(stream: &mut S, resp: &Response) -> std::io::Result<()> {
let mut out = Vec::with_capacity(256 + resp.body.len());
out.extend_from_slice(
format!("HTTP/1.1 {} {}\r\n", resp.status, reason(resp.status)).as_bytes(),
);
out.extend_from_slice(b"OData-Version: 4.0\r\n");
out.extend_from_slice(b"Cache-Control: no-cache\r\n");
for (k, v) in &resp.headers {
out.extend_from_slice(format!("{k}: {v}\r\n").as_bytes());
}
out.extend_from_slice(format!("Content-Length: {}\r\n\r\n", resp.body.len()).as_bytes());
out.extend_from_slice(&resp.body);
stream.write_all(&out)?;
stream.flush()
}
fn find_subsequence(haystack: &[u8], needle: &[u8]) -> Option<usize> {
haystack
.windows(needle.len())
.position(|w| w == needle)
}
fn percent_decode(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).ok();
if let Some(b) = hex.and_then(|h| u8::from_str_radix(h, 16).ok()) {
out.push(b);
i += 3;
continue;
}
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
#[derive(Debug, Clone)]
struct Routes {
system: String,
reset: String,
vm_collection: String,
vm: String,
vm_insert: String,
vm_eject: String,
}
impl Routes {
fn for_node(cfg: &NodeConfig) -> Self {
let id = &cfg.system_id;
let slot = &cfg.media_slot;
Self {
system: wire::system_path(id),
reset: wire::reset_path(id),
vm_collection: wire::virtual_media_collection_path(id),
vm: wire::virtual_media_path(id, slot),
vm_insert: wire::virtual_media_action_path(id, slot, wire::INSERT_MEDIA),
vm_eject: wire::virtual_media_action_path(id, slot, wire::EJECT_MEDIA),
}
}
}
struct Service {
routes: Routes,
cfg: NodeConfig,
state: Arc<Mutex<NodeState>>,
backend: Arc<dyn NodeBackend>,
}
impl Service {
fn dispatch(&self, req: &Request) -> Response {
let path = req.path.as_str();
let m = req.method.as_str();
if path == wire::PROTOCOL_VERSION_PATH {
return match m {
"GET" | "HEAD" => Response::json(200, &json!({ "v1": wire::SERVICE_ROOT_PATH })),
_ => Response::method_not_allowed("GET"),
};
}
if path == wire::SERVICE_ROOT_PATH || path == wire::SERVICE_ROOT_PATH_BARE {
return match m {
"GET" | "HEAD" => Response::json(200, &self.service_root()),
_ => Response::method_not_allowed("GET"),
};
}
if let Some(unauthorized) = self.check_auth(req) {
return unauthorized;
}
let r = &self.routes;
if path == wire::SYSTEMS_PATH {
return match m {
"GET" | "HEAD" => Response::json(200, &self.system_collection()),
_ => Response::method_not_allowed("GET"),
};
}
if path == wire::SESSIONS_PATH {
return match m {
"GET" | "HEAD" => Response::json(200, &self.session_collection()),
_ => Response::method_not_allowed("GET"),
};
}
if path == r.system {
return match m {
"GET" | "HEAD" => Response::json(200, &self.computer_system()),
"PATCH" => self.patch_system(req),
_ => Response::method_not_allowed("GET, PATCH"),
};
}
if path == r.reset {
return match m {
"POST" => self.post_reset(req),
_ => Response::method_not_allowed("POST"),
};
}
if path == r.vm_collection {
return match m {
"GET" | "HEAD" => Response::json(200, &self.virtual_media_collection()),
_ => Response::method_not_allowed("GET"),
};
}
if path == r.vm {
return match m {
"GET" | "HEAD" => Response::json(200, &self.virtual_media()),
_ => Response::method_not_allowed("GET"),
};
}
if path == r.vm_insert {
return match m {
"POST" => self.post_insert_media(req),
_ => Response::method_not_allowed("POST"),
};
}
if path == r.vm_eject {
return match m {
"POST" => self.post_eject_media(),
_ => Response::method_not_allowed("POST"),
};
}
Response::error(RESOURCE_NOT_FOUND, &["Resource", path], None)
}
fn check_auth(&self, req: &Request) -> Option<Response> {
let ok = req
.header("authorization")
.and_then(wire::parse_basic_auth)
.is_some_and(|(u, p)| u == self.cfg.username && p == self.cfg.password);
if ok {
return None;
}
Some(
Response::error(NO_VALID_SESSION, &[], None)
.with_header("WWW-Authenticate", "Basic realm=\"RedfishService\""),
)
}
fn service_root(&self) -> Value {
json!({
"@odata.id": wire::SERVICE_ROOT_PATH,
"@odata.type": odata_type::SERVICE_ROOT,
"Id": "RootService",
"Name": "Root Service",
"RedfishVersion": REDFISH_VERSION,
"UUID": self.cfg.uuid,
"Product": "draupnir",
"Vendor": "nordisk",
"Systems": { "@odata.id": wire::SYSTEMS_PATH },
"Links": { "Sessions": { "@odata.id": wire::SESSIONS_PATH } },
})
}
fn system_collection(&self) -> Value {
json!({
"@odata.id": wire::SYSTEMS_PATH,
"@odata.type": odata_type::SYSTEM_COLLECTION,
"Name": "Computer System Collection",
"Members": [ { "@odata.id": self.routes.system } ],
"Members@odata.count": 1,
})
}
fn session_collection(&self) -> Value {
json!({
"@odata.id": wire::SESSIONS_PATH,
"@odata.type": odata_type::SESSION_COLLECTION,
"Name": "Session Collection",
"Members": [],
"Members@odata.count": 0,
})
}
fn computer_system(&self) -> Value {
let s = self.state.lock().unwrap();
let power = self.observe_power(&s);
let target = s
.override_target
.map_or(wire::BOOT_TARGET_NONE, wire::target_str);
json!({
"@odata.id": self.routes.system,
"@odata.type": odata_type::SYSTEM,
"Id": self.cfg.system_id,
"Name": self.cfg.system_name,
"SystemType": "Physical",
"Manufacturer": self.cfg.manufacturer,
"Model": self.cfg.model,
"UUID": self.cfg.uuid,
"PowerState": power_state_str(power),
"Status": { "State": "Enabled", "Health": "OK" },
"Boot": {
prop::BOOT_SOURCE_OVERRIDE_ENABLED: s.override_enabled,
prop::BOOT_SOURCE_OVERRIDE_TARGET: target,
prop::BOOT_SOURCE_OVERRIDE_MODE: "UEFI",
wire::allowable_values_key(prop::BOOT_SOURCE_OVERRIDE_TARGET):
wire::BOOT_TARGET_ALLOWABLE,
},
"MemorySummary": { "TotalSystemMemoryGiB": self.cfg.mem_mb as f64 / 1024.0 },
"ProcessorSummary": { "Count": self.cfg.cores },
"VirtualMedia": { "@odata.id": self.routes.vm_collection },
"Actions": {
wire::ACTION_RESET: {
prop::TARGET: self.routes.reset,
wire::allowable_values_key(prop::RESET_TYPE): wire::RESET_TYPE_ALLOWABLE,
}
},
})
}
fn virtual_media_collection(&self) -> Value {
json!({
"@odata.id": self.routes.vm_collection,
"@odata.type": odata_type::VIRTUAL_MEDIA_COLLECTION,
"Name": "Virtual Media Services",
"Members": [ { "@odata.id": self.routes.vm } ],
"Members@odata.count": 1,
})
}
fn virtual_media(&self) -> Value {
let s = self.state.lock().unwrap();
json!({
"@odata.id": self.routes.vm,
"@odata.type": odata_type::VIRTUAL_MEDIA,
"Id": self.cfg.media_slot,
"Name": "Virtual CD",
"MediaTypes": ["CD", "DVD"],
"ConnectedVia": if s.inserted { "URI" } else { "NotConnected" },
prop::IMAGE: s.image_uri,
prop::IMAGE_NAME: s.image_uri.as_deref().and_then(basename),
prop::INSERTED: s.inserted,
prop::WRITE_PROTECTED: s.write_protected,
"Actions": {
wire::ACTION_INSERT_MEDIA: { prop::TARGET: self.routes.vm_insert },
wire::ACTION_EJECT_MEDIA: { prop::TARGET: self.routes.vm_eject },
},
})
}
fn body_json(&self, req: &Request) -> std::result::Result<Value, Response> {
if req.body.is_empty() {
return Ok(json!({}));
}
serde_json::from_slice(&req.body)
.map_err(|e| Response::error(MALFORMED_JSON, &[], Some(&format!("Parser said: {e}."))))
}
fn post_insert_media(&self, req: &Request) -> Response {
let body = match self.body_json(req) {
Ok(b) => b,
Err(r) => return r,
};
let insert = match wire::read_insert_media_body(&body) {
Ok(i) => i,
Err(missing) => {
return Response::error(
ACTION_PARAMETER_MISSING,
&[wire::ACTION_INSERT_MEDIA, missing],
None,
)
}
};
let path = match resolve_media(&insert.image) {
Ok(p) => p,
Err(why) => {
return Response::error(
RESOURCE_MISSING_AT_URI,
&[&insert.image],
Some(&why),
)
}
};
let mut s = self.state.lock().unwrap();
s.image_uri = Some(insert.image.clone());
s.image_path = Some(path.clone());
s.inserted = insert.inserted;
s.write_protected = insert.write_protected;
drop(s);
crate::functional_status(
"draupnir/redfish-server",
"InsertMedia",
true,
&format!("`{}` mounted from `{path}`", insert.image),
);
Response::no_content()
}
fn post_eject_media(&self) -> Response {
let mut s = self.state.lock().unwrap();
s.image_uri = None;
s.image_path = None;
s.inserted = false;
s.write_protected = false;
Response::no_content()
}
fn patch_system(&self, req: &Request) -> Response {
let body = match self.body_json(req) {
Ok(b) => b,
Err(r) => return r,
};
let Some(ovr) = wire::read_boot_override_body(&body) else {
return Response::no_content();
};
let mut s = self.state.lock().unwrap();
if let Some(enabled) = &ovr.enabled {
if !wire::OVERRIDE_ENABLED_ALLOWABLE.contains(&enabled.as_str()) {
return Response::error(
PROPERTY_VALUE_NOT_IN_LIST,
&[enabled, prop::BOOT_SOURCE_OVERRIDE_ENABLED],
Some(&format!(
"Acceptable values: {}.",
wire::OVERRIDE_ENABLED_ALLOWABLE.join(", ")
)),
);
}
s.override_enabled = enabled.clone();
}
if let Some(raw) = &ovr.target {
if raw == wire::BOOT_TARGET_NONE {
s.override_target = None;
} else {
match wire::target_from_str(raw)
.and_then(|t| BootOrder::from_redfish_target(Some(t)).map(|_| t))
{
Some(t) => s.override_target = Some(t),
None => {
return Response::error(
PROPERTY_VALUE_NOT_IN_LIST,
&[raw, prop::BOOT_SOURCE_OVERRIDE_TARGET],
Some(&format!(
"This node honours: {}.",
boot_targets_this_node_honours().join(", ")
)),
)
}
}
}
}
let applied = format!(
"{}/{}",
s.override_enabled,
s.override_target.map_or(wire::BOOT_TARGET_NONE, wire::target_str)
);
drop(s);
crate::functional_status(
"draupnir/redfish-server",
"BootOverride",
true,
&format!("boot override set to {applied}"),
);
Response::no_content()
}
fn post_reset(&self, req: &Request) -> Response {
let body = match self.body_json(req) {
Ok(b) => b,
Err(r) => return r,
};
let reset_type = match wire::read_reset_body(&body) {
Ok(t) => t,
Err(missing) => {
return Response::error(
ACTION_PARAMETER_MISSING,
&[wire::ACTION_RESET, missing],
None,
)
}
};
if !wire::is_allowable_reset_type(&reset_type) {
return Response::error(
ACTION_PARAMETER_VALUE_NOT_IN_LIST,
&[&reset_type, prop::RESET_TYPE, wire::ACTION_RESET],
Some(&format!(
"Acceptable values: {}.",
wire::RESET_TYPE_ALLOWABLE.join(", ")
)),
);
}
match reset_type.as_str() {
wire::RESET_ON | wire::RESET_FORCE_ON => self.power_up(&reset_type),
wire::RESET_FORCE_OFF | wire::RESET_GRACEFUL_SHUTDOWN => self.power_down(&reset_type),
wire::RESET_FORCE_RESTART | wire::RESET_GRACEFUL_RESTART => {
self.power_down(&reset_type);
self.power_up(&reset_type)
}
other => Response::error(
ACTION_PARAMETER_VALUE_NOT_IN_LIST,
&[other, prop::RESET_TYPE, wire::ACTION_RESET],
Some("This service advertises the value but does not implement it."),
),
}
}
fn power_up(&self, reset_type: &str) -> Response {
let spec = {
let s = self.state.lock().unwrap();
match self.spec_for(&s) {
Ok(spec) => spec,
Err(why) => {
crate::functional_status(
"draupnir/redfish-server",
"Reset",
false,
&format!("{reset_type} refused: {why}"),
);
return Response::error(GENERAL_ERROR, &[], Some(&why));
}
}
};
match self.backend.boot(&spec) {
Ok(machine) => {
let mut s = self.state.lock().unwrap();
s.machine = Some(machine);
s.last_spec = Some(spec.clone());
s.last_reset = Some(reset_type.to_string());
if s.override_enabled == wire::OVERRIDE_ONCE {
s.override_enabled = wire::OVERRIDE_DISABLED.to_string();
s.override_target = None;
}
drop(s);
crate::functional_status(
"draupnir/redfish-server",
"Reset",
true,
&format!(
"{reset_type}: booted `{}` (order {:?}, medium {:?})",
spec.name,
spec.boot_order,
spec.medium_path()
),
);
Response::no_content()
}
Err(e) => {
crate::functional_status(
"draupnir/redfish-server",
"Reset",
false,
&format!("{reset_type} failed: {e}"),
);
Response::error(GENERAL_ERROR, &[], Some(&format!("The node's backend refused to start: {e}")))
}
}
}
fn power_down(&self, reset_type: &str) -> Response {
let machine = self.state.lock().unwrap().machine.clone();
let Some(machine) = machine else {
return Response::no_content();
};
match self.backend.power_off(&machine) {
Ok(()) => {
let mut s = self.state.lock().unwrap();
s.machine = None;
s.last_reset = Some(reset_type.to_string());
Response::no_content()
}
Err(e) => Response::error(
GENERAL_ERROR,
&[],
Some(&format!("The node's backend refused to power off: {e}")),
),
}
}
fn spec_for(&self, s: &NodeState) -> std::result::Result<BootSpec, String> {
let effective_target = if s.override_enabled == wire::OVERRIDE_DISABLED {
None
} else {
s.override_target
};
let order = BootOrder::from_redfish_target(effective_target).ok_or_else(|| {
format!(
"boot override target {:?} is not a boot order this node can apply",
effective_target
)
})?;
let name = format!("redfish-{}", self.cfg.system_id);
let medium = s.inserted.then(|| s.image_path.clone()).flatten();
let mut spec = match order {
BootOrder::Medium => {
let iso = medium.ok_or_else(|| {
format!(
"the boot override asks for {} but no virtual media is inserted in slot `{}`",
wire::target_str(BootTarget::Cd),
self.cfg.media_slot
)
})?;
BootSpec::iso_boot(name, iso)
}
BootOrder::Disk => {
let disk = self.cfg.local_disk.clone().ok_or_else(|| {
format!(
"the boot override asks for {} but this node has no local disk configured \
(NodeConfig::local_disk)",
wire::target_str(BootTarget::Hdd)
)
})?;
BootSpec::kvm_boot_installed_disk(name, disk)
}
BootOrder::Auto => match (medium, self.cfg.local_disk.clone()) {
(Some(iso), _) => BootSpec::iso_boot(name, iso),
(None, Some(disk)) => BootSpec::kvm_boot_installed_disk(name, disk),
(None, None) => {
return Err(
"this node has neither virtual media inserted nor a local disk to boot"
.into(),
)
}
},
};
spec.mem_mb = self.cfg.mem_mb;
spec.cores = self.cfg.cores;
spec.validate().map_err(|e| e.to_string())?;
Ok(spec)
}
fn observe_power(&self, s: &NodeState) -> PowerState {
match &s.machine {
None => PowerState::Off,
Some(m) => self.backend.status(m).unwrap_or(PowerState::Unknown),
}
}
}
fn boot_targets_this_node_honours() -> Vec<&'static str> {
wire::BOOT_TARGET_ALLOWABLE
.iter()
.copied()
.filter(|t| {
wire::target_from_str(t)
.and_then(|bt| BootOrder::from_redfish_target(Some(bt)))
.is_some()
})
.chain(std::iter::once(wire::BOOT_TARGET_NONE))
.collect()
}
fn power_state_str(p: PowerState) -> &'static str {
match p {
PowerState::On => "On",
PowerState::Off => "Off",
PowerState::Unknown => "Paused",
}
}
fn basename(uri: &str) -> Option<String> {
uri.rsplit(['/', '\\']).next().map(str::to_string).filter(|s| !s.is_empty())
}
fn resolve_media(uri: &str) -> std::result::Result<String, String> {
let path = if let Some(rest) = uri.strip_prefix("file://") {
let rest = rest.strip_prefix("localhost").unwrap_or(rest);
if !rest.starts_with('/') {
return Err(format!(
"This service resolves `file://` URIs with an absolute path; `{uri}` has none."
));
}
percent_decode(rest)
} else if uri.starts_with('/') {
uri.to_string()
} else {
let scheme = uri.split_once("://").map(|(s, _)| s).unwrap_or("<none>");
return Err(format!(
"This BMC fronts local KVM and mounts only `file://` URIs or absolute paths; \
it does not fetch `{scheme}` media. A real BMC would."
));
};
if std::path::Path::new(&path).is_file() {
Ok(path)
} else {
Err(format!(
"No file exists at `{path}` on the host running this service."
))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn registry_messages_render_their_arguments() {
assert_eq!(
ACTION_PARAMETER_VALUE_NOT_IN_LIST.render(&[
"Reboot",
prop::RESET_TYPE,
wire::ACTION_RESET
]),
"The value 'Reboot' for the parameter ResetType in the action \
#ComputerSystem.Reset is not in the list of acceptable values."
);
assert_eq!(
RESOURCE_MISSING_AT_URI.render(&["file:///nope.iso"]),
"The resource at the URI 'file:///nope.iso' was not found."
);
assert_eq!(NO_VALID_SESSION.render(&[]), NO_VALID_SESSION.template);
for m in ALL_MESSAGES {
let args: Vec<&str> = (0..m.nargs).map(|_| "X").collect();
let out = m.render(&args);
assert!(!out.contains('%'), "{} left a placeholder: {out}", m.id);
}
}
#[test]
fn message_ids_are_registry_qualified() {
assert_eq!(
RESOURCE_MISSING_AT_URI.message_id(),
"Base.1.19.0.ResourceMissingAtURI"
);
for m in ALL_MESSAGES {
assert!(
m.message_id().starts_with("Base.1.19.0."),
"{} is unqualified",
m.id
);
}
}
#[test]
fn every_route_is_the_path_the_client_would_ask_for() {
let cfg = NodeConfig::new("System.Embedded.1").media_slot("CD");
let r = Routes::for_node(&cfg);
assert_eq!(r.system, "/redfish/v1/Systems/System.Embedded.1");
assert_eq!(
r.reset,
"/redfish/v1/Systems/System.Embedded.1/Actions/ComputerSystem.Reset"
);
assert_eq!(
r.vm_insert,
"/redfish/v1/Systems/System.Embedded.1/VirtualMedia/CD/Actions/VirtualMedia.InsertMedia"
);
assert_eq!(
r.vm_eject,
"/redfish/v1/Systems/System.Embedded.1/VirtualMedia/CD/Actions/VirtualMedia.EjectMedia"
);
assert_eq!(r.vm, "/redfish/v1/Systems/System.Embedded.1/VirtualMedia/CD");
assert_eq!(
r.vm_collection,
"/redfish/v1/Systems/System.Embedded.1/VirtualMedia"
);
}
#[test]
fn media_resolution_refuses_by_name() {
let e = resolve_media("https://depot/x.iso").unwrap_err();
assert!(e.contains("https"), "names the scheme: {e}");
let e = resolve_media("/nonexistent/never-built.iso").unwrap_err();
assert!(e.contains("/nonexistent/never-built.iso"), "{e}");
assert!(resolve_media("/tmp").is_err());
let real = std::env::current_exe().unwrap();
let real = real.to_string_lossy().into_owned();
assert_eq!(resolve_media(&real).unwrap(), real);
assert_eq!(resolve_media(&format!("file://{real}")).unwrap(), real);
let odd = std::env::temp_dir().join(format!("draupnir rf-server å-{}.iso", std::process::id()));
std::fs::write(&odd, b"medium").unwrap();
let encoded = odd
.to_string_lossy()
.replace(' ', "%20")
.replace('å', "%C3%A5");
assert_eq!(
resolve_media(&format!("file://{encoded}")).unwrap(),
odd.to_string_lossy()
);
let _ = std::fs::remove_file(&odd);
}
#[test]
fn percent_decode_handles_utf8_and_leaves_junk_alone() {
assert_eq!(percent_decode("/H%C3%A4mtningar"), "/Hämtningar");
assert_eq!(percent_decode("/a%zz"), "/a%zz", "invalid escape survives");
assert_eq!(percent_decode("/plain"), "/plain");
}
#[test]
fn basename_is_the_last_segment() {
assert_eq!(basename("file:///a/b/c.iso").as_deref(), Some("c.iso"));
assert_eq!(basename("x.iso").as_deref(), Some("x.iso"));
assert_eq!(basename("/a/b/").as_deref(), None);
}
#[test]
fn the_node_honours_only_the_targets_it_can_actually_apply() {
let honoured = boot_targets_this_node_honours();
assert!(honoured.contains(&"Cd"));
assert!(honoured.contains(&"Hdd"));
assert!(honoured.contains(&"None"));
assert!(!honoured.contains(&"Pxe"), "no PXE on a KVM front end");
assert!(!honoured.contains(&"BiosSetup"));
}
#[derive(Default)]
struct Recorder {
specs: Mutex<Vec<BootSpec>>,
}
impl Boot for Recorder {
fn boot(&self, spec: &BootSpec) -> Result<Machine> {
self.specs.lock().unwrap().push(spec.clone());
Ok(Machine::started(format!("rec-{}", spec.name), spec))
}
}
impl Lifecycle for Recorder {
fn power_on(&self, _m: &Machine) -> Result<()> {
Ok(())
}
fn power_off(&self, _m: &Machine) -> Result<()> {
Ok(())
}
fn status(&self, _m: &Machine) -> Result<PowerState> {
Ok(PowerState::On)
}
}
fn service_with(cfg: NodeConfig) -> Service {
Service {
routes: Routes::for_node(&cfg),
cfg,
state: Arc::new(Mutex::new(NodeState::fresh())),
backend: Arc::new(Recorder::default()),
}
}
#[test]
fn the_boot_override_selects_which_spec_the_node_boots() {
let iso = std::env::current_exe().unwrap().to_string_lossy().into_owned();
let svc = service_with(
NodeConfig::new("s1")
.local_disk("/var/lib/node.qcow2")
.sized(2048, 4),
);
{
let mut s = svc.state.lock().unwrap();
s.image_uri = Some(iso.clone());
s.image_path = Some(iso.clone());
s.inserted = true;
s.override_enabled = wire::OVERRIDE_ONCE.into();
s.override_target = Some(BootTarget::Cd);
}
let spec = svc.spec_for(&svc.state.lock().unwrap()).unwrap();
assert_eq!(spec.boot_order, BootOrder::Medium);
assert_eq!(spec.medium_path(), Some(iso.as_str()));
assert_eq!(spec.mem_mb, 2048);
assert_eq!(spec.cores, 4);
svc.state.lock().unwrap().override_target = Some(BootTarget::Hdd);
let spec = svc.spec_for(&svc.state.lock().unwrap()).unwrap();
assert_eq!(spec.boot_order, BootOrder::Disk);
assert_eq!(
spec.medium_path(),
None,
"an Hdd override must not leave the installer medium attached — that is \
how a boot override gets accepted without ever being applied"
);
}
#[test]
fn an_override_the_node_cannot_apply_is_refused_by_name_not_coerced() {
let svc = service_with(NodeConfig::new("s1"));
let req = Request {
method: "PATCH".into(),
path: wire::system_path("s1"),
headers: BTreeMap::new(),
body: serde_json::to_vec(&json!({
"Boot": { "BootSourceOverrideTarget": "Pxe" }
}))
.unwrap(),
};
let resp = svc.patch_system(&req);
assert_eq!(resp.status, 400);
let v: Value = serde_json::from_slice(&resp.body).unwrap();
assert_eq!(v["error"]["code"], "Base.1.19.0.PropertyValueNotInList");
assert!(
v["error"]["message"].as_str().unwrap().contains("Pxe"),
"the refusal quotes the value: {v}"
);
assert_eq!(svc.state.lock().unwrap().override_target, None);
}
#[test]
fn a_cd_override_with_an_empty_tray_refuses_instead_of_booting_something_else() {
let svc = service_with(NodeConfig::new("s1").local_disk("/var/lib/node.qcow2"));
{
let mut s = svc.state.lock().unwrap();
s.override_enabled = wire::OVERRIDE_ONCE.into();
s.override_target = Some(BootTarget::Cd);
}
let err = svc.spec_for(&svc.state.lock().unwrap()).unwrap_err();
assert!(err.contains("no virtual media is inserted"), "{err}");
assert!(!err.contains("qcow2"), "{err}");
}
#[test]
fn a_hdd_override_on_a_diskless_node_refuses_instead_of_falling_back_to_the_medium() {
let iso = std::env::current_exe().unwrap().to_string_lossy().into_owned();
let svc = service_with(NodeConfig::new("s1")); {
let mut s = svc.state.lock().unwrap();
s.image_path = Some(iso);
s.inserted = true;
s.override_enabled = wire::OVERRIDE_ONCE.into();
s.override_target = Some(BootTarget::Hdd);
}
let err = svc.spec_for(&svc.state.lock().unwrap()).unwrap_err();
assert!(err.contains("no local disk"), "{err}");
}
#[test]
fn a_once_override_does_not_steer_the_next_boot_too() {
let iso = std::env::current_exe().unwrap().to_string_lossy().into_owned();
let svc = service_with(NodeConfig::new("s1"));
{
let mut s = svc.state.lock().unwrap();
s.image_uri = Some(iso.clone());
s.image_path = Some(iso);
s.inserted = true;
s.override_enabled = wire::OVERRIDE_ONCE.into();
s.override_target = Some(BootTarget::Cd);
}
assert_eq!(svc.power_up(wire::RESET_ON).status, 204);
let s = svc.state.lock().unwrap();
assert_eq!(s.override_enabled, wire::OVERRIDE_DISABLED);
assert_eq!(s.override_target, None);
assert_eq!(s.last_spec.as_ref().unwrap().boot_order, BootOrder::Medium);
}
#[test]
fn a_bad_reset_type_is_refused_and_nothing_boots() {
let svc = service_with(NodeConfig::new("s1"));
let req = Request {
method: "POST".into(),
path: wire::reset_path("s1"),
headers: BTreeMap::new(),
body: serde_json::to_vec(&json!({ "ResetType": "Reboot" })).unwrap(),
};
let resp = svc.post_reset(&req);
assert_eq!(resp.status, 400);
let v: Value = serde_json::from_slice(&resp.body).unwrap();
assert_eq!(
v["error"]["code"],
"Base.1.19.0.ActionParameterValueNotInList"
);
assert_eq!(v["error"]["@Message.ExtendedInfo"][0]["MessageArgs"][0], "Reboot");
assert!(svc.state.lock().unwrap().machine.is_none(), "nothing booted");
}
#[test]
fn a_reset_with_no_reset_type_names_the_missing_parameter() {
let svc = service_with(NodeConfig::new("s1"));
let req = Request {
method: "POST".into(),
path: wire::reset_path("s1"),
headers: BTreeMap::new(),
body: b"{}".to_vec(),
};
let resp = svc.post_reset(&req);
assert_eq!(resp.status, 400);
let v: Value = serde_json::from_slice(&resp.body).unwrap();
assert_eq!(v["error"]["code"], "Base.1.19.0.ActionParameterMissing");
assert!(v["error"]["message"].as_str().unwrap().contains("ResetType"));
}
#[test]
fn insert_media_pointing_at_nothing_is_refused_by_name() {
let svc = service_with(NodeConfig::new("s1"));
let req = Request {
method: "POST".into(),
path: svc.routes.vm_insert.clone(),
headers: BTreeMap::new(),
body: serde_json::to_vec(&wire::insert_media_body("/nonexistent/never-built.iso"))
.unwrap(),
};
let resp = svc.post_insert_media(&req);
assert_eq!(resp.status, 400);
let v: Value = serde_json::from_slice(&resp.body).unwrap();
assert_eq!(v["error"]["code"], "Base.1.19.0.ResourceMissingAtURI");
assert!(v["error"]["message"]
.as_str()
.unwrap()
.contains("/nonexistent/never-built.iso"));
assert!(!svc.state.lock().unwrap().inserted);
}
#[test]
fn a_method_the_resource_does_not_take_answers_405_with_allow() {
let svc = service_with(NodeConfig::new("s1").credentials("admin", "pw"));
let auth = wire::basic_auth_header("admin", "pw");
let mut headers = BTreeMap::new();
headers.insert("authorization".to_string(), auth);
let req = Request {
method: "GET".into(),
path: wire::reset_path("s1"),
headers: headers.clone(),
body: Vec::new(),
};
let resp = svc.dispatch(&req);
assert_eq!(resp.status, 405);
assert!(resp
.headers
.iter()
.any(|(k, v)| k == "Allow" && v == "POST"));
let req = Request {
method: "POST".into(),
path: wire::system_path("s1"),
headers,
body: Vec::new(),
};
let resp = svc.dispatch(&req);
assert_eq!(resp.status, 405);
assert!(resp
.headers
.iter()
.any(|(k, v)| k == "Allow" && v == "GET, PATCH"));
}
#[test]
fn everything_below_the_service_root_needs_a_credential() {
let svc = service_with(NodeConfig::new("s1").credentials("admin", "pw"));
let unauth = |path: &str| Request {
method: "GET".into(),
path: path.into(),
headers: BTreeMap::new(),
body: Vec::new(),
};
assert_eq!(svc.dispatch(&unauth(wire::PROTOCOL_VERSION_PATH)).status, 200);
assert_eq!(svc.dispatch(&unauth(wire::SERVICE_ROOT_PATH)).status, 200);
for p in [
wire::SYSTEMS_PATH,
&wire::system_path("s1"),
&wire::virtual_media_path("s1", "CD"),
] {
let r = svc.dispatch(&unauth(p));
assert_eq!(r.status, 401, "{p}");
assert!(r.headers.iter().any(|(k, _)| k == "WWW-Authenticate"), "{p}");
}
let mut headers = BTreeMap::new();
headers.insert(
"authorization".into(),
wire::basic_auth_header("admin", "wrong"),
);
let req = Request {
method: "GET".into(),
path: wire::system_path("s1"),
headers,
body: Vec::new(),
};
assert_eq!(svc.dispatch(&req).status, 401);
}
#[test]
fn an_unknown_uri_is_a_dmtf_shaped_404_naming_the_uri() {
let svc = service_with(NodeConfig::new("s1").credentials("admin", ""));
let mut headers = BTreeMap::new();
headers.insert("authorization".into(), wire::basic_auth_header("admin", ""));
let req = Request {
method: "GET".into(),
path: "/redfish/v1/Systems/other".into(),
headers,
body: Vec::new(),
};
let resp = svc.dispatch(&req);
assert_eq!(resp.status, 404);
let v: Value = serde_json::from_slice(&resp.body).unwrap();
assert_eq!(v["error"]["code"], "Base.1.19.0.ResourceNotFound");
assert!(v["error"]["message"]
.as_str()
.unwrap()
.contains("/redfish/v1/Systems/other"));
}
#[test]
fn malformed_json_is_reported_as_malformed_json() {
let svc = service_with(NodeConfig::new("s1"));
let req = Request {
method: "POST".into(),
path: wire::reset_path("s1"),
headers: BTreeMap::new(),
body: b"{not json".to_vec(),
};
let resp = svc.post_reset(&req);
assert_eq!(resp.status, 400);
let v: Value = serde_json::from_slice(&resp.body).unwrap();
assert_eq!(v["error"]["code"], "Base.1.19.0.MalformedJSON");
}
#[test]
fn a_freshly_started_node_reports_itself_off_with_an_empty_tray() {
let svc = service_with(NodeConfig::new("s1"));
let sys = svc.computer_system();
assert_eq!(sys["PowerState"], "Off");
assert_eq!(sys["Boot"]["BootSourceOverrideEnabled"], "Disabled");
assert_eq!(sys["Boot"]["BootSourceOverrideTarget"], "None");
let vm = svc.virtual_media();
assert_eq!(vm["Inserted"], false);
assert!(vm["Image"].is_null());
}
}