diff --git a/api/src/lib.rs b/api/src/lib.rs index d9403fe..8bf8a38 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -26,6 +26,7 @@ use sush_common::jobs::{ SignedJob, }; use sush_common::keys::{KeyId, SshPublicKey}; +use sush_common::targets::SledVersion; use sush_common::version::VersionInfo; /// Oxide Support Shell API @@ -234,6 +235,15 @@ pub trait SushApi { query: QueryParams, ) -> Result, HttpError>; + /// Get the cubby, baseboard, and build of every known sled. + /// + /// Unauthenticated, like `/target`. + #[endpoint { method = GET, path = "/versions" }] + async fn versions( + ctx: RequestContext, + query: QueryParams, + ) -> Result>, HttpError>; + /// Get the version and git commit of the server's build. /// /// Unauthenticated, like `/target`. diff --git a/client/src/cli.rs b/client/src/cli.rs index 651f4f9..a21c1ec 100644 --- a/client/src/cli.rs +++ b/client/src/cli.rs @@ -27,6 +27,7 @@ use sush_common::jobs::{ SignedJob, job_status_to_json_map, }; use sush_common::keys::{KeyId, Signature, SshPublicKey}; +use sush_common::targets::{MAX_CUBBY, SledVersion}; use sush_common::version::VersionInfo; use crate::AuthzSigner; @@ -119,14 +120,25 @@ impl CommandContext for Cli { // Build provenance - fn versions(&mut self, client: &VersionInfo, server: Option<&VersionInfo>) { + fn versions( + &mut self, + client: &VersionInfo, + server: Option<&VersionInfo>, + sleds: &[SledVersion], + ) { match self.get_output_format() { - OutputFormat::Json => println!("{}", json!({"client": client, "server": server})), + OutputFormat::Json => println!( + "{}", + json!({"client": client, "server": server, "sleds": sleds}) + ), OutputFormat::Text => { println!("Client:\t{client}"); if let Some(server) = server { println!("Server:\t{server}"); } + if !sleds.is_empty() { + print!("{}", draw_rack(sleds)); + } } } } @@ -854,3 +866,114 @@ mod test { )); } } + +/// Cell text width for one sled in the rack drawing. +const CELL: usize = 28; + +/// One sled cell: serial on the left, build on the right. +fn rack_cell(sled: Option<&SledVersion>) -> String { + match sled { + Some(sled) => { + let build = match &sled.version { + Some(v) => { + let dirty = if v.commit.ends_with("-dirty") { + "+" + } else { + "" + }; + format!("{} {:.7}{dirty}", v.version, v.commit) + } + None => String::new(), + }; + format!(" {:<12.12}{:>14.14} ", sled.baseboard.serial_number, build) + } + None => " ".repeat(CELL), + } +} + +/// Draw the rack as wicket does: 16 rows of two cubbies, numbered +/// bottom-to-top and left-to-right per RFD 200, split where the +/// switches and power shelves sit. Sleds known only by build (no +/// cubby) are listed below the rack. +fn draw_rack(sleds: &[SledVersion]) -> String { + let by_cubby: BTreeMap = sleds + .iter() + .filter_map(|sled| sled.cubby.map(|cubby| (cubby, sled))) + .collect(); + let row = |row: u8| { + let (left, right) = (2 * row, 2 * row + 1); + format!( + "{left:>3} │{}│{}│ {right}\n", + rack_cell(by_cubby.get(&left).copied()), + rack_cell(by_cubby.get(&right).copied()), + ) + }; + let bar = "─".repeat(CELL); + let mut out = format!(" ┌{bar}┬{bar}┐\n"); + for r in (8..16).rev() { + out.push_str(&row(r)); + } + out.push_str(&format!(" ├{bar}┼{bar}┤\n")); + for r in (0..8).rev() { + out.push_str(&row(r)); + } + out.push_str(&format!(" └{bar}┴{bar}┘\n")); + for sled in sleds + .iter() + .filter(|sled| sled.cubby.is_none_or(|cubby| cubby > MAX_CUBBY)) + { + out.push_str(&format!(" ?? │{}│\n", rack_cell(Some(sled)))); + } + out +} + +#[cfg(test)] +mod rack { + use std::env; + use std::fs::{read_to_string, write}; + + use super::*; + + fn sled(cubby: u8, serial: &str) -> SledVersion { + SledVersion { + cubby: Some(cubby), + baseboard: BaseboardId { + part_number: "913-0000019".to_string(), + serial_number: serial.to_string(), + }, + version: Some(VersionInfo { + version: "0.1.0".to_string(), + commit: "f078e863b17359031de072222bb631270f2d5157".to_string(), + }), + } + } + + /// Compare the rack drawing against the snapshot in + /// `tests/output/`, or rewrite it under `EXPECTORATE=overwrite`. + #[test] + fn rack_drawing() { + let mut sleds = vec![ + sled(14, "BRM42220030"), + sled(15, "BRM42220036"), + sled(16, "2CN2M459"), + sled(17, "2RGCFG10"), + ]; + sleds.push(SledVersion { + cubby: None, + ..sled(0, "STRAGGLER") + }); + sleds.push(sled(32, "MISCUBBIED")); + sleds[2].version = None; + if let Some(version) = &mut sleds[1].version { + version.commit.push_str("-dirty"); + } + let drawing = draw_rack(&sleds); + let path = "tests/output/rack.txt"; + if env::var("EXPECTORATE").as_deref() == Ok("overwrite") { + write(path, &drawing).unwrap(); + } else { + let expected = read_to_string(path).expect("missing snapshot"); + assert_eq!(drawing, expected, "rack drawing changed:\n{drawing}"); + } + } +} diff --git a/client/src/commands.rs b/client/src/commands.rs index b54d266..904537b 100644 --- a/client/src/commands.rs +++ b/client/src/commands.rs @@ -385,7 +385,7 @@ pub enum ClientCommand { #[clap(alias = "repl")] Shell, - /// Report client and server build versions. + /// Report client and sled build versions. Version, /// Leave the interactive REPL. @@ -669,11 +669,19 @@ impl ClientCommand { } (ClientCommand::Version, client) => { - let server = match client { - Some(client) => Some(client.version().send().await?.into_inner()), - None => None, + let (server, sleds) = match client { + Some(client) => ( + Some(client.version().send().await?.into_inner()), + client + .versions() + .send() + .await + .map(|sleds| sleds.into_inner()) + .unwrap_or_default(), + ), + None => (None, Vec::new()), }; - ctx.versions(&VersionInfo::current(), server.as_ref()); + ctx.versions(&VersionInfo::current(), server.as_ref(), &sleds); Ok(()) } diff --git a/client/src/context.rs b/client/src/context.rs index 9ee1a0f..62a7010 100644 --- a/client/src/context.rs +++ b/client/src/context.rs @@ -18,6 +18,7 @@ use sush_common::jobs::{ Access, JobId, JobOutputStream, JobStatusMap, Session, SessionId, SignedJob, }; use sush_common::keys::{KeyId, SshPublicKey}; +use sush_common::targets::SledVersion; use sush_common::version::VersionInfo; use crate::AuthzSigner; @@ -97,7 +98,12 @@ pub trait CommandContext: Clone + Send + Sync { fn pre_parse_hook(&mut self, _command: &str) {} // Build provenance - fn versions(&mut self, client: &VersionInfo, server: Option<&VersionInfo>); + fn versions( + &mut self, + client: &VersionInfo, + server: Option<&VersionInfo>, + sleds: &[SledVersion], + ); // Session management fn authz_signer(&self) -> AuthzSigner; diff --git a/client/src/lib.rs b/client/src/lib.rs index 2efdb84..025532a 100644 --- a/client/src/lib.rs +++ b/client/src/lib.rs @@ -79,6 +79,7 @@ progenitor::generate_api!( KeyId = sush_common::keys::KeyId, Signature = sush_common::keys::Signature, SignedForJobStartRequest = sush_common::jobs::SignedJob, + SledVersion = sush_common::targets::SledVersion, VersionInfo = sush_common::version::VersionInfo, }, timeout = 600, diff --git a/client/src/repl.rs b/client/src/repl.rs index 92acf2c..29cd7c2 100644 --- a/client/src/repl.rs +++ b/client/src/repl.rs @@ -23,6 +23,7 @@ use sush_common::jobs::{ Access, JobId, JobOutputStream, JobStatusMap, Session, SessionId, SignedJob, }; use sush_common::keys::{KeyId, SshPublicKey}; +use sush_common::targets::SledVersion; use sush_common::version::VersionInfo; use crate::cli::Cli; @@ -202,8 +203,13 @@ impl CommandContext for Repl { // Build provenance - fn versions(&mut self, client: &VersionInfo, server: Option<&VersionInfo>) { - self.cli.versions(client, server) + fn versions( + &mut self, + client: &VersionInfo, + server: Option<&VersionInfo>, + sleds: &[SledVersion], + ) { + self.cli.versions(client, server, sleds) } // Session management diff --git a/client/tests/output/rack.txt b/client/tests/output/rack.txt new file mode 100644 index 0000000..ffe9b9e --- /dev/null +++ b/client/tests/output/rack.txt @@ -0,0 +1,21 @@ + ┌────────────────────────────┬────────────────────────────┐ + 30 │ │ │ 31 + 28 │ │ │ 29 + 26 │ │ │ 27 + 24 │ │ │ 25 + 22 │ │ │ 23 + 20 │ │ │ 21 + 18 │ │ │ 19 + 16 │ 2CN2M459 │ 2RGCFG10 0.1.0 f078e86 │ 17 + ├────────────────────────────┼────────────────────────────┤ + 14 │ BRM42220030 0.1.0 f078e86 │ BRM42220036 0.1.0 f078e86+ │ 15 + 12 │ │ │ 13 + 10 │ │ │ 11 + 8 │ │ │ 9 + 6 │ │ │ 7 + 4 │ │ │ 5 + 2 │ │ │ 3 + 0 │ │ │ 1 + └────────────────────────────┴────────────────────────────┘ + ?? │ STRAGGLER 0.1.0 f078e86 │ + ?? │ MISCUBBIED 0.1.0 f078e86 │ diff --git a/common/src/targets.rs b/common/src/targets.rs index c700346..b4cfde8 100644 --- a/common/src/targets.rs +++ b/common/src/targets.rs @@ -22,12 +22,22 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer}; use sled_hardware_types::BaseboardId; use thiserror::Error; +use crate::version::VersionInfo; + /// Baseboards by cubby number, as much of it as is known. pub type Cubbies = BTreeMap; /// The highest cubby number in a rack. pub const MAX_CUBBY: u8 = 31; +/// One sled's location and build. +#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)] +pub struct SledVersion { + pub cubby: Option, + pub baseboard: BaseboardId, + pub version: Option, +} + /// The sleds a request names. #[derive(Clone, Debug, Default, Eq, PartialEq)] pub enum Target { diff --git a/common/src/version.rs b/common/src/version.rs index c8be560..b9490f6 100644 --- a/common/src/version.rs +++ b/common/src/version.rs @@ -4,10 +4,13 @@ //! The build's provenance. +use std::collections::BTreeMap; use std::fmt; +use borsh::{BorshDeserialize, BorshSerialize}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use sled_hardware_types::BaseboardId; /// The git commit of this build, "-dirty" suffixed when the tree had /// uncommitted changes. @@ -22,7 +25,17 @@ pub const LONG_VERSION: &str = concat!( ); /// One build's version and commit. -#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] +#[derive( + BorshDeserialize, + BorshSerialize, + Clone, + Debug, + Deserialize, + Eq, + JsonSchema, + PartialEq, + Serialize, +)] pub struct VersionInfo { /// The package version. pub version: String, @@ -45,3 +58,5 @@ impl fmt::Display for VersionInfo { write!(f, "{} ({})", self.version, self.commit) } } + +pub type VersionMap = BTreeMap; diff --git a/server/src/manager.rs b/server/src/manager.rs index 3fc0ad4..c60d1f1 100644 --- a/server/src/manager.rs +++ b/server/src/manager.rs @@ -5,6 +5,7 @@ //! Manage authentication, job signature verification, and the session //! state machine. Does not manage jobs directly. +use std::collections::BTreeSet; use std::num::NonZeroUsize; use std::path::Path; use std::sync::Arc; @@ -32,7 +33,8 @@ use sush_common::authn::{ use sush_common::jobs::JobOutputStream; use sush_common::jobs::{Access, JobId, JobStatusMap, Session, SessionId, SignedJob}; use sush_common::keys::{KeyError, KeyId, SshPublicKey}; -use sush_common::targets::Cubbies; +use sush_common::targets::{Cubbies, SledVersion}; +use sush_common::version::LONG_VERSION; use crate::error::JobError; use crate::executor::PathIsolation; @@ -127,6 +129,7 @@ impl JobManager { roots: &[Certificate], shutdown: CancellationToken, ) -> Result { + info!(log, "starting sush"; "version" => LONG_VERSION); let (tx_req, rx_req) = mpsc::channel(16); let requests = ReceiverStream::new(rx_req); let (rx_state, join_state) = StateManager::run( @@ -156,6 +159,26 @@ impl JobManager { &self.own_baseboard } + /// Every sled known by cubby or by build, sorted by cubby first. + pub fn versions(&self) -> Vec { + let state = self.state.borrow(); + let mut sleds: BTreeSet<&BaseboardId> = state.versions().keys().collect(); + sleds.extend(state.cubbies().values()); + let mut rows: Vec = sleds + .into_iter() + .map(|baseboard| SledVersion { + cubby: state + .cubbies() + .iter() + .find_map(|(cubby, b)| (b == baseboard).then_some(*cubby)), + baseboard: baseboard.clone(), + version: state.versions().get(baseboard).cloned(), + }) + .collect(); + rows.sort_by_key(|row| (row.cubby.is_none(), row.cubby)); + rows + } + async fn cert_request(&self, authn: &Identity, request: CertRequest) -> Result<(), JobError> { self.tx_req .send(Request::cert(authn.key_id.clone(), request)) diff --git a/server/src/messages.rs b/server/src/messages.rs index 9ed896a..cfebefe 100644 --- a/server/src/messages.rs +++ b/server/src/messages.rs @@ -23,6 +23,7 @@ use sush_common::borsh::{ use sush_common::jobs::JobOutputState; use sush_common::jobs::{Access, JobId, ProcessError, SessionId, SignedJob}; use sush_common::keys::{KeyId, SshPublicKey}; +use sush_common::version::VersionInfo; #[derive(BorshDeserialize, BorshSerialize, Copy, Clone, Debug, Eq, PartialEq)] pub struct RequestId(pub Uuid); @@ -32,9 +33,7 @@ pub struct RequestId(pub Uuid); /// own module; everything defined there and all of their dependencies /// (e.g., types shared with the HTTP API, etc.) become part of the frozen /// version, whose Borsh encoding must not change. New versions must -/// implement `TryInto` to convert old messages into compatible new ones; -/// old servers must tolerate or ignore new messages they can't decode -/// (TODO: verify and implement that policy). +/// implement `TryInto` to convert old messages into compatible new ones. #[derive(BorshDeserialize, BorshSerialize, Clone, Debug, Eq, PartialEq)] pub enum VersionedMessage { V0(v0::Message), @@ -186,6 +185,7 @@ pub mod v0 { pub enum Event { Job(JobEvent), Error(Error), + Version(VersionInfo), } #[derive(BorshDeserialize, BorshSerialize, Clone, Debug, Eq, PartialEq)] @@ -392,5 +392,21 @@ mod wire_format { assert_wire_format("identity-login-request", msg); } + #[test] + fn version_event() { + let msg: VersionedMessage = Message::Event( + BaseboardId { + part_number: "913-0000019".to_string(), + serial_number: "BRM42220030".to_string(), + }, + Event::Version(VersionInfo { + version: "0.1.0".to_string(), + commit: "f078e863b17359031de072222bb631270f2d5157".to_string(), + }), + ) + .into(); + assert_wire_format("version-event", msg); + } + // TODO: snapshot more messages } diff --git a/server/src/proxy.rs b/server/src/proxy.rs index 10a21cd..cec610d 100644 --- a/server/src/proxy.rs +++ b/server/src/proxy.rs @@ -4,7 +4,8 @@ //! Proxy server for the Oxide Support Shell API. //! -//! Terminates client connections and routes each request to a sled. +//! Terminates client connections and routes each request to a sled, +//! answering only `/version` itself. //! A request that names a target goes to the first sled the target //! resolves to. Anything else goes to a sticky default, because //! identities are cached on the sled that authenticated them. @@ -17,7 +18,8 @@ use std::io; use std::net::SocketAddr; use std::sync::{Arc, Mutex}; -use http::StatusCode; +use http::header::CONTENT_TYPE; +use http::{Method, StatusCode}; use http_body_util::{Either, Full}; use hyper::body::{Bytes, Incoming}; use hyper::client::conn::http1 as client_conn; @@ -40,6 +42,7 @@ use tokio_rustls::TlsAcceptor; use tokio_util::sync::CancellationToken; use sush_common::targets::{Cubbies, SledId, Target}; +use sush_common::version::VersionInfo; /// The sush servers the proxy may route to. #[derive(Clone, Debug, Default)] @@ -196,6 +199,31 @@ fn named_target(request: &Request) -> Option> { } } +/// The proxy answers `/version` for itself, so proxy and sled builds +/// stay distinguishable when a rack updates gradually. A `via` still +/// routes the request to a sled. +fn own_version(log: &Logger, request: &Request) -> Option> { + if request.method() != Method::GET + || request.uri().path() != "/version" + || named_target(request).is_some() + { + return None; + } + let response = serde_json::to_vec(&VersionInfo::current()) + .ok() + .and_then(|body| { + Response::builder() + .status(StatusCode::OK) + .header(CONTENT_TYPE, "application/json") + .body(Either::Right(Full::new(body.into()))) + .ok() + }); + if response.is_none() { + warn!(log, "unable to answer /version; forwarding to a sled"); + } + response +} + async fn listen( log: Logger, listener: TcpListener, @@ -246,9 +274,12 @@ where let log = log.clone(); let router = router.clone(); async move { - Ok::<_, Infallible>(match router.route(&request) { - Ok(addr) => forward(log, addr, request).await, - Err(response) => *response, + Ok::<_, Infallible>(match own_version(&log, &request) { + Some(response) => response, + None => match router.route(&request) { + Ok(addr) => forward(log, addr, request).await, + Err(response) => *response, + }, }) } }); @@ -336,6 +367,24 @@ mod test { s.parse().unwrap() } + /// The proxy answers `GET /version` itself unless `via` routes it. + /// A `*` target means the handling server, which is the proxy. + #[test] + fn version_interception() { + let log = Logger::root(slog::Discard, o!()); + assert!(own_version(&log, &request("/version")).is_some()); + assert!(own_version(&log, &request("/version?via=*")).is_some()); + assert!(own_version(&log, &request("/version?via=14")).is_none()); + assert!(own_version(&log, &request("/version?via=")).is_none()); + assert!(own_version(&log, &request("/versions")).is_none()); + let post = Request::builder() + .method(Method::POST) + .uri("/version") + .body(()) + .unwrap(); + assert!(own_version(&log, &post).is_none()); + } + #[test] fn named_targets() { assert_eq!( diff --git a/server/src/server.rs b/server/src/server.rs index 7463281..05ae3e9 100644 --- a/server/src/server.rs +++ b/server/src/server.rs @@ -31,6 +31,7 @@ use sush_api::{ use sush_common::authn::Identity; use sush_common::jobs::{JsonJobStatusMap, Session, SignedJob, job_status_to_json_map}; use sush_common::keys::{KeyId, SshPublicKey, pem_cert_chain}; +use sush_common::targets::SledVersion; use sush_common::version::VersionInfo; use crate::error::JobError; @@ -416,6 +417,13 @@ impl SushApi for ApiServer { Ok(HttpResponseOk(ctx.context().own_baseboard().to_owned())) } + async fn versions( + ctx: RequestContext, + _query: QueryParams, + ) -> Result>, HttpError> { + Ok(HttpResponseOk(ctx.context().versions())) + } + async fn version( _ctx: RequestContext, _query: QueryParams, diff --git a/server/src/state.rs b/server/src/state.rs index 27da3ed..e4a0299 100644 --- a/server/src/state.rs +++ b/server/src/state.rs @@ -29,6 +29,7 @@ use sush_common::authn::{Identity, Nonce, RequestVerifier, SignedLogin}; use sush_common::jobs::{Access, JobId, JobStatus, JobStatusMap, Session, SessionId, SignedJob}; use sush_common::keys::{KeyError, KeyId, Signature, SshPublicKey}; use sush_common::targets::Cubbies; +use sush_common::version::{VersionInfo, VersionMap}; use crate::executor::{Executor, PathIsolation}; use crate::history::JobHistory; @@ -314,6 +315,8 @@ pub struct State { roots: Box<[KeyId]>, /// Baseboards by cubby number, as much of it as is known. cubbies: Cubbies, + /// Build provenance by sled. + versions: VersionMap, /// Verified logins, rack-wide. identities: LruCache<(KeyId, Nonce), RegisteredIdentity>, /// SSH keys refused at login. @@ -336,6 +339,7 @@ impl State { .collect::, KeyError>>()?; let roots: Box<[KeyId]> = certs.keys().cloned().collect(); let mut new = Self { + versions: [(own_baseboard.clone(), VersionInfo::current())].into(), own_baseboard, running: Default::default(), history: Default::default(), @@ -371,6 +375,14 @@ impl State { self.session.attach_access(key_id) } + pub fn cubbies(&self) -> &Cubbies { + &self.cubbies + } + + pub fn versions(&self) -> &VersionMap { + &self.versions + } + pub fn history(&self) -> &JobHistory { &self.history } @@ -813,6 +825,14 @@ impl State { Event::Error(error) => { error!(log, "session error"; "error" => %error); } + Event::Version(info) => { + // Our own seed is authoritative, and may be newer + // than a replayed announce from a previous boot. + if *baseboard_id != self.own_baseboard { + info!(log, "sled version"; "sled" => %baseboard_id, "version" => %info); + self.versions.insert(baseboard_id.clone(), info.clone()); + } + } }, } @@ -895,6 +915,17 @@ impl StateManager { spawn(async move { info!(log, "managing state"); + // Announce our build. + if let Some((rumors, _)) = &gossip { + rumors.send( + Message::Event( + own_baseboard.clone(), + Event::Version(VersionInfo::current()), + ) + .into(), + ); + } + // These flip both to `true` once our two input streams (local // requests and local events from the executor) terminate or // we're shutting down. At this point, we must drop `gossip` @@ -1007,6 +1038,13 @@ impl StateManager { state.cubbies = cubbies.borrow().clone(); }); *rumors = fresh; + rumors.send( + Message::Event( + own_baseboard.clone(), + Event::Version(VersionInfo::current()), + ) + .into(), + ); } } }), diff --git a/server/tests/distributed.rs b/server/tests/distributed.rs index 9ee6584..904f2db 100644 --- a/server/tests/distributed.rs +++ b/server/tests/distributed.rs @@ -20,6 +20,7 @@ use sush_api::{JobStartParams, JobWait}; use sush_common::jobs::{JobStatus, Session, SessionId}; use sush_common::keys::pem_cert_chain; use sush_common::targets::Cubbies; +use sush_common::version::VersionInfo; use sush_server::executor::PathIsolation; use sush_server::gossip::spawn_gossip; use sush_server::output::JobOutputDir; @@ -114,6 +115,20 @@ async fn jobs_gossip_between_sleds() { }) .await; + // Each sled learns the other's build. + eventually("versions gossip", 60, async || { + [&a, &b].iter().all(|sled| { + let versions = sled.mgr.versions(); + [&a.baseboard, &b.baseboard].iter().all(|baseboard| { + versions.iter().any(|row| { + row.baseboard == **baseboard + && row.version.as_ref() == Some(&VersionInfo::current()) + }) + }) + }) + }) + .await; + // A session started on sled A becomes B's active session too. let authn_a = fake_identity(&mut root).await; let authn_b = fake_identity(&mut root).await; diff --git a/server/tests/output/version-event.bin b/server/tests/output/version-event.bin new file mode 100644 index 0000000..127f436 Binary files /dev/null and b/server/tests/output/version-event.bin differ diff --git a/sush.json b/sush.json index 4db02a0..ab40187 100644 --- a/sush.json +++ b/sush.json @@ -995,6 +995,46 @@ } } } + }, + "/versions": { + "get": { + "summary": "Get the cubby, baseboard, and build of every known sled.", + "description": "Unauthenticated, like `/target`.", + "operationId": "versions", + "parameters": [ + { + "in": "query", + "name": "via", + "description": "Where a proxy should route this request.", + "schema": { + "nullable": true, + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "title": "Array_of_SledVersion", + "type": "array", + "items": { + "$ref": "#/components/schemas/SledVersion" + } + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } } }, "components": { @@ -1559,12 +1599,38 @@ "signature" ] }, + "SledVersion": { + "description": "One sled's location and build.", + "type": "object", + "properties": { + "baseboard": { + "$ref": "#/components/schemas/BaseboardId" + }, + "cubby": { + "nullable": true, + "type": "integer", + "format": "uint8", + "minimum": 0 + }, + "version": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/VersionInfo" + } + ] + } + }, + "required": [ + "baseboard" + ] + }, "VersionInfo": { "description": "One build's version and commit.", "type": "object", "properties": { "commit": { - "description": "The git commit, with a \"-dirty\" suffix for an unclean tree.", + "description": "The git commit, \"-dirty\" suffixed for an unclean tree.", "type": "string" }, "version": {