diff --git a/bin/propolis-cli/Cargo.toml b/bin/propolis-cli/Cargo.toml index 79f18c93f..7153a920a 100644 --- a/bin/propolis-cli/Cargo.toml +++ b/bin/propolis-cli/Cargo.toml @@ -11,7 +11,7 @@ anyhow = "1.0" clap = { version = "3.2", features = ["derive"] } futures = "0.3" libc = "0.2" -propolis-client = { path = "../../lib/propolis-client" } +propolis-client = { path = "../../lib/propolis-client", features = ["generated"] } slog = "2.7" slog-async = "2.7" slog-term = "2.8" diff --git a/bin/propolis-cli/src/main.rs b/bin/propolis-cli/src/main.rs index b62b3f899..af963874b 100644 --- a/bin/propolis-cli/src/main.rs +++ b/bin/propolis-cli/src/main.rs @@ -10,7 +10,7 @@ use std::{ use anyhow::{anyhow, Context}; use clap::{Parser, Subcommand}; use futures::{future, SinkExt, StreamExt}; -use propolis_client::{ +use propolis_client::handmade::{ api::{ DiskRequest, InstanceEnsureRequest, InstanceMigrateInitiateRequest, InstanceProperties, InstanceStateRequested, MigrationState, @@ -19,7 +19,9 @@ use propolis_client::{ }; use slog::{o, Drain, Level, Logger}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio_tungstenite::tungstenite::protocol::Role; use tokio_tungstenite::tungstenite::Message; +use tokio_tungstenite::WebSocketStream; use uuid::Uuid; #[derive(Debug, Parser)] @@ -317,10 +319,14 @@ async fn test_stdin_to_websockets_task() { } async fn serial(addr: SocketAddr) -> anyhow::Result<()> { - let path = format!("ws://{}/instance/serial", addr); - let (mut ws, _) = tokio_tungstenite::connect_async(path) + let upgraded = propolis_client::Client::new(&format!("http://{}", addr)) + .instance_serial() + .send() .await - .with_context(|| anyhow!("failed to create serial websocket stream"))?; + .map_err(|e| anyhow!("Failed to upgrade connection: {}", e))? + .into_inner(); + let mut ws = + WebSocketStream::from_raw_socket(upgraded, Role::Client, None).await; let _raw_guard = RawTermiosGuard::stdio_guard() .with_context(|| anyhow!("failed to set raw mode"))?; diff --git a/bin/propolis-server/Cargo.toml b/bin/propolis-server/Cargo.toml index 19637fbff..ee4d58637 100644 --- a/bin/propolis-server/Cargo.toml +++ b/bin/propolis-server/Cargo.toml @@ -45,7 +45,7 @@ serde_derive = "1.0" serde_json = "1.0" slog = "2.7" propolis = { path = "../../lib/propolis", features = ["crucible-full", "oximeter"], default-features = false } -propolis-client = { path = "../../lib/propolis-client" } +propolis-client = { path = "../../lib/propolis-client", features = ["generated"] } propolis-server-config = { path = "../../crates/propolis-server-config" } rfb = { git = "https://github.com/oxidecomputer/rfb" } uuid = "1.0.0" @@ -57,9 +57,10 @@ features = [ "chrono", "uuid1" ] [dev-dependencies] hex = "0.4.3" -reqwest = { version = "0.11", default-features = false, features = ["rustls-tls"] } +reqwest = { version = "0.11.12", default-features = false, features = ["rustls-tls"] } ring = "0.16" slog = { version = "2.5", features = [ "max_level_trace", "release_max_level_debug" ] } +expectorate = "1.0.5" [features] default = ["dtrace-probes"] diff --git a/bin/propolis-server/src/lib/migrate/mod.rs b/bin/propolis-server/src/lib/migrate/mod.rs index ce86d0491..0bdc4b711 100644 --- a/bin/propolis-server/src/lib/migrate/mod.rs +++ b/bin/propolis-server/src/lib/migrate/mod.rs @@ -4,7 +4,7 @@ use bit_field::BitField; use dropshot::{HttpError, RequestContext}; use hyper::{header, Body, Method, Response, StatusCode}; use propolis::migrate::MigrateStateError; -use propolis_client::api::{self, MigrationState}; +use propolis_client::handmade::api::{self, MigrationState}; use serde::{Deserialize, Serialize}; use slog::{error, info, o}; use thiserror::Error; diff --git a/bin/propolis-server/src/lib/server.rs b/bin/propolis-server/src/lib/server.rs index d5da93298..210abfe35 100644 --- a/bin/propolis-server/src/lib/server.rs +++ b/bin/propolis-server/src/lib/server.rs @@ -9,20 +9,21 @@ use std::sync::Arc; use std::{collections::BTreeMap, net::SocketAddr}; use dropshot::{ - endpoint, ApiDescription, HttpError, HttpResponseCreated, HttpResponseOk, - HttpResponseUpdatedNoContent, Path, RequestContext, TypedBody, + channel, endpoint, ApiDescription, HttpError, HttpResponseCreated, + HttpResponseOk, HttpResponseUpdatedNoContent, Path, RequestContext, + TypedBody, WebsocketConnection, }; -use hyper::StatusCode; -use hyper::{http::header, upgrade, Body, Response}; +use hyper::{Body, Response}; use oximeter::types::ProducerRegistry; -use propolis_client::instance_spec; -use propolis_client::{api, instance_spec::InstanceSpec}; +use propolis_client::{ + handmade::api, + instance_spec::{self, InstanceSpec}, +}; use propolis_server_config::Config as VmTomlConfig; use rfb::server::VncServer; use slog::{error, o, Logger}; use thiserror::Error; use tokio::sync::{mpsc, oneshot, MappedMutexGuard, Mutex, MutexGuard}; -use tokio_tungstenite::tungstenite::handshake; use tokio_tungstenite::tungstenite::protocol::{Role, WebSocketConfig}; use tokio_tungstenite::WebSocketStream; @@ -112,9 +113,9 @@ impl VmControllerState { /// `VmControllerState::Destroyed`. pub fn take_controller(&mut self) -> Option> { if let VmControllerState::Created(vm) = self { - let last_instance = propolis_client::api::Instance { + let last_instance = api::Instance { properties: vm.properties().clone(), - state: propolis_client::api::InstanceState::Destroyed, + state: api::InstanceState::Destroyed, disks: vec![], nics: vec![], }; @@ -245,7 +246,7 @@ enum SpecCreationError { /// Creates an instance spec from an ensure request. (Both types are foreign to /// this crate, so implementing TryFrom for them is not allowed.) fn instance_spec_from_request( - request: &propolis_client::api::InstanceEnsureRequest, + request: &api::InstanceEnsureRequest, toml_config: &VmTomlConfig, ) -> Result<(InstanceSpec, BTreeMap>), SpecCreationError> { let mut in_memory_disk_contents: BTreeMap> = @@ -314,11 +315,8 @@ async fn register_oximeter( }] async fn instance_ensure( rqctx: Arc>, - request: TypedBody, -) -> Result< - HttpResponseCreated, - HttpError, -> { + request: TypedBody, +) -> Result, HttpError> { let server_context = rqctx.context(); let request = request.into_inner(); @@ -478,9 +476,7 @@ async fn instance_ensure( None }; - Ok(HttpResponseCreated(propolis_client::api::InstanceEnsureResponse { - migrate, - })) + Ok(HttpResponseCreated(api::InstanceEnsureResponse { migrate })) } #[endpoint { @@ -489,8 +485,7 @@ async fn instance_ensure( }] async fn instance_get( rqctx: Arc>, -) -> Result, HttpError> -{ +) -> Result, HttpError> { let ctx = rqctx.context(); let instance_info = match &*ctx.services.vm.lock().await { VmControllerState::NotCreated => { @@ -499,7 +494,7 @@ async fn instance_get( )); } VmControllerState::Created(vm) => { - propolis_client::api::Instance { + api::Instance { properties: vm.properties().clone(), state: vm.external_instance_state(), disks: vec![], @@ -518,9 +513,7 @@ async fn instance_get( } }; - Ok(HttpResponseOk(propolis_client::api::InstanceGetResponse { - instance: instance_info, - })) + Ok(HttpResponseOk(api::InstanceGetResponse { instance: instance_info })) } #[endpoint { @@ -604,73 +597,21 @@ async fn instance_state_put( result } -#[endpoint { - method = GET, +#[channel { + protocol = WEBSOCKETS, path = "/instance/serial", }] async fn instance_serial( rqctx: Arc>, -) -> Result, HttpError> { + websock: WebsocketConnection, +) -> dropshot::WebsocketChannelResult { let ctx = rqctx.context(); let vm = ctx.vm().await?; let serial = vm.com1().clone(); - let request = &mut *rqctx.request.lock().await; - - if !request - .headers() - .get(header::CONNECTION) - .and_then(|hv| hv.to_str().ok()) - .map(|hv| { - hv.split(|c| c == ',' || c == ' ') - .any(|vs| vs.eq_ignore_ascii_case("upgrade")) - }) - .unwrap_or(false) - { - return Err(HttpError::for_bad_request( - None, - "expected connection upgrade".to_string(), - )); - } - if !request - .headers() - .get(header::UPGRADE) - .and_then(|v| v.to_str().ok()) - .map(|v| { - v.split(|c| c == ',' || c == ' ') - .any(|v| v.eq_ignore_ascii_case("websocket")) - }) - .unwrap_or(false) - { - return Err(HttpError::for_bad_request( - None, - "unexpected protocol for upgrade".to_string(), - )); - } - if request - .headers() - .get(header::SEC_WEBSOCKET_VERSION) - .map(|v| v.as_bytes()) - != Some(b"13") - { - return Err(HttpError::for_bad_request( - None, - "missing or invalid websocket version".to_string(), - )); - } - let accept_key = request - .headers() - .get(header::SEC_WEBSOCKET_KEY) - .map(|hv| hv.as_bytes()) - .map(handshake::derive_accept_key) - .ok_or_else(|| { - HttpError::for_bad_request( - None, - "missing websocket key".to_string(), - ) - })?; - let ws_log = rqctx.log.new(o!()); - let err_log = ws_log.clone(); + let err_log = rqctx.log.new(o!()); + + // Create or get active serial task handle and channels let mut serial_task = ctx.services.serial_task.lock().await; let serial_task = serial_task.get_or_insert_with(move || { let (websocks_ch, websocks_recv) = mpsc::channel(1); @@ -681,11 +622,11 @@ async fn instance_serial( websocks_recv, close_recv, serial, - ws_log.clone(), + err_log.clone(), ) .await { - error!(ws_log, "Failed to spawn instance serial task: {}", e); + error!(err_log, "Failed to spawn instance serial task: {}", e); } }); @@ -696,37 +637,21 @@ async fn instance_serial( } }); - let upgrade_fut = upgrade::on(request); let config = WebSocketConfig { max_send_queue: Some(4096), ..Default::default() }; let websocks_send = serial_task.websocks_ch.clone(); - tokio::spawn(async move { - let upgraded = match upgrade_fut.await { - Ok(u) => u, - Err(e) => { - error!(err_log, "Serial socket upgrade failed: {}", e); - return; - } - }; - let ws_stream = WebSocketStream::from_raw_socket( - upgraded, - Role::Server, - Some(config), - ) - .await; + let ws_stream = WebSocketStream::from_raw_socket( + websock.into_inner(), + Role::Server, + Some(config), + ) + .await; - if let Err(e) = websocks_send.send(ws_stream).await { - error!(err_log, "Serial socket hand-off failed: {}", e); - } - }); - - Ok(Response::builder() - .status(StatusCode::SWITCHING_PROTOCOLS) - .header(header::CONNECTION, "Upgrade") - .header(header::UPGRADE, "websocket") - .header(header::SEC_WEBSOCKET_ACCEPT, accept_key) - .body(Body::empty())?) + websocks_send + .send(ws_stream.into()) + .await + .map_err(|e| format!("Serial socket hand-off failed: {}", e).into()) } // This endpoint is meant to only be called during a migration from the destination @@ -799,3 +724,25 @@ pub fn api() -> ApiDescription { api } + +#[cfg(test)] +mod tests { + #[test] + fn test_propolis_server_openapi() { + let mut buf: Vec = vec![]; + super::api() + .openapi("Oxide Propolis Server API", "0.0.1") + .description( + "API for interacting with the Propolis hypervisor frontend.", + ) + .contact_url("https://oxide.computer") + .contact_email("api@oxide.computer") + .write(&mut buf) + .unwrap(); + let output = String::from_utf8(buf).unwrap(); + expectorate::assert_contents( + "../../openapi/propolis-server.json", + &output, + ); + } +} diff --git a/bin/propolis-server/src/lib/spec.rs b/bin/propolis-server/src/lib/spec.rs index 3aaa4f32d..5aedfd3fc 100644 --- a/bin/propolis-server/src/lib/spec.rs +++ b/bin/propolis-server/src/lib/spec.rs @@ -4,7 +4,7 @@ use std::collections::BTreeSet; use std::convert::TryInto; use std::str::FromStr; -use propolis_client::api::{ +use propolis_client::handmade::api::{ self, DiskRequest, InstanceProperties, NetworkInterfaceRequest, }; use propolis_client::instance_spec::*; @@ -567,7 +567,7 @@ impl SpecBuilder { mod test { use std::{collections::BTreeMap, path::PathBuf}; - use propolis_client::api::Slot; + use propolis_client::handmade::api::Slot; use uuid::Uuid; use crate::config::{self, Config}; diff --git a/bin/propolis-server/src/lib/vm.rs b/bin/propolis-server/src/lib/vm.rs index 9c0edfd38..8f4030fd7 100644 --- a/bin/propolis-server/src/lib/vm.rs +++ b/bin/propolis-server/src/lib/vm.rs @@ -36,12 +36,13 @@ use propolis::{ hw::{ps2::ctrl::PS2Ctrl, qemu::ramfb::RamFb, uart::LpcUart}, Instance, }; -use propolis_client::{ +use propolis_client::handmade::{ api::InstanceProperties, api::InstanceState as ApiInstanceState, api::InstanceStateMonitorResponse as ApiMonitoredState, api::InstanceStateRequested as ApiInstanceStateRequested, - api::MigrationState as ApiMigrationState, instance_spec::InstanceSpec, + api::MigrationState as ApiMigrationState, }; +use propolis_client::instance_spec::InstanceSpec; use slog::{error, info, Logger}; use thiserror::Error; use tokio::task::JoinHandle as TaskJoinHandle; diff --git a/lib/propolis-client/Cargo.toml b/lib/propolis-client/Cargo.toml index 135f45c8e..2c8b940f5 100644 --- a/lib/propolis-client/Cargo.toml +++ b/lib/propolis-client/Cargo.toml @@ -8,7 +8,9 @@ edition = "2018" [dependencies] propolis_types = { path = "../../crates/propolis-types" } -reqwest = { version = "0.11", default-features = false, features = ["json", "rustls-tls"] } +reqwest = { version = "0.11.12", default-features = false, features = ["json", "rustls-tls"] } +base64 = "0.13" +rand = "0.8" ring = "0.16" schemars = { version = "0.8.10", features = [ "uuid1" ] } serde = "1.0" @@ -16,7 +18,14 @@ serde_json = "1.0" slog = { version = "2.5", features = [ "max_level_trace", "release_max_level_debug" ] } thiserror = "1.0" uuid = { version = "1.0.0", features = [ "serde", "v4" ] } +progenitor = { git = "https://github.com/oxidecomputer/progenitor", branch = "main", optional = true } +tokio = { version = "1.0", features = [ "net" ], optional = true } [dependencies.crucible-client-types] git = "https://github.com/oxidecomputer/crucible" rev = "144d8dafa41715e00b08a5929cc62140ff0eb561" + +[features] +default = [] +generated = ["progenitor", "tokio"] +generated-migration = ["generated"] diff --git a/lib/propolis-client/src/generated.rs b/lib/propolis-client/src/generated.rs new file mode 100644 index 000000000..8975d98a2 --- /dev/null +++ b/lib/propolis-client/src/generated.rs @@ -0,0 +1,8 @@ +// Copyright 2022 Oxide Computer Company +//! Experimental progenitor-generated propolis-server API client. + +progenitor::generate_api!( + spec = "../../openapi/propolis-server.json", + interface = Builder, + tags = Separate, +); diff --git a/lib/propolis-client/src/api.rs b/lib/propolis-client/src/handmade/api.rs similarity index 100% rename from lib/propolis-client/src/api.rs rename to lib/propolis-client/src/handmade/api.rs diff --git a/lib/propolis-client/src/handmade/mod.rs b/lib/propolis-client/src/handmade/mod.rs new file mode 100644 index 000000000..f9baf3ab5 --- /dev/null +++ b/lib/propolis-client/src/handmade/mod.rs @@ -0,0 +1,193 @@ +//! Interface for making API requests to propolis. +//! This should be replaced with a client generated from the OpenAPI spec. + +use reqwest::Body; +use reqwest::IntoUrl; +use serde::de::DeserializeOwned; +use slog::{info, o, Logger}; +use std::net::SocketAddr; +use thiserror::Error; +use uuid::Uuid; + +pub mod api; + +/// Errors which may be returend from the Propolis Client. +#[derive(Debug, Error)] +pub enum Error { + #[error("Request failed: {0}")] + Reqwest(#[from] reqwest::Error), + + #[error("Bad Status: {0}")] + Status(u16), +} + +/// Client-side connection to propolis. +pub struct Client { + client: reqwest::Client, + log: Logger, + address: SocketAddr, +} + +// Sends "request", awaits "response", and returns an error on any +// non-success status code. +// +// TODO: Do we want to handle re-directs? +async fn send_and_check_ok( + request: reqwest::RequestBuilder, +) -> Result { + let response = request.send().await.map_err(Error::from)?; + + if !response.status().is_success() { + return Err(Error::Status(response.status().as_u16())); + } + + Ok(response) +} + +// Sends a "request", awaits "response", and parses the body +// into a deserializable type. +async fn send_and_parse_response( + request: reqwest::RequestBuilder, +) -> Result { + send_and_check_ok(request).await?.json().await.map_err(|e| e.into()) +} + +impl Client { + pub fn new(address: SocketAddr, log: Logger) -> Client { + Client { + client: reqwest::Client::new(), + log: log.new(o!("propolis_client address" => address.to_string())), + address, + } + } + + async fn get( + &self, + path: U, + body: Option, + ) -> Result { + info!(self.log, "GET request to {}", path); + let mut request = self.client.get(path); + if let Some(body) = body { + request = request.body(body); + } + + send_and_parse_response(request).await + } + + async fn put( + &self, + path: U, + body: Option, + ) -> Result { + info!(self.log, "PUT request to {}", path); + let mut request = self.client.put(path); + if let Some(body) = body { + request = request.body(body); + } + + send_and_parse_response(request).await + } + + async fn post( + &self, + path: U, + body: Option, + ) -> Result { + info!(self.log, "POST request to {}", path); + let mut request = self.client.post(path); + if let Some(body) = body { + request = request.body(body); + } + + send_and_parse_response(request).await + } + + async fn put_no_response( + &self, + path: U, + body: Option, + ) -> Result<(), Error> { + info!(self.log, "PUT request to {}", path); + let mut request = self.client.put(path); + if let Some(body) = body { + request = request.body(body); + } + + send_and_check_ok(request).await?; + Ok(()) + } + + /// Ensures that an instance with the specified properties exists. + pub async fn instance_ensure( + &self, + request: &api::InstanceEnsureRequest, + ) -> Result { + let path = format!("http://{}/instance", self.address,); + let body = Body::from(serde_json::to_string(&request).unwrap()); + self.put(path, Some(body)).await + } + + /// Returns information about an instance, by UUID. + pub async fn instance_get( + &self, + ) -> Result { + let path = format!("http://{}/instance", self.address); + self.get(path, None).await + } + + /// Long-poll for state changes. + pub async fn instance_state_monitor( + &self, + gen: u64, + ) -> Result { + let path = format!("http://{}/instance/state-monitor", self.address); + let body = Body::from( + serde_json::to_string(&api::InstanceStateMonitorRequest { gen }) + .unwrap(), + ); + self.get(path, Some(body)).await + } + + /// Puts an instance into a new state. + pub async fn instance_state_put( + &self, + state: api::InstanceStateRequested, + ) -> Result<(), Error> { + let path = format!("http://{}/instance/state", self.address); + let body = Body::from(serde_json::to_string(&state).unwrap()); + self.put_no_response(path, Some(body)).await + } + + /// Get the status of an ongoing migration + pub async fn instance_migrate_status( + &self, + migration_id: Uuid, + ) -> Result { + let path = format!("http://{}/instance/migrate/status", self.address); + let body = Body::from( + serde_json::to_string(&api::InstanceMigrateStatusRequest { + migration_id, + }) + .unwrap(), + ); + self.get(path, Some(body)).await + } + + /// Returns the WebSocket URI to an instance's serial console stream. + pub fn instance_serial_console_ws_uri(&self) -> String { + format!("ws://{}/instance/serial", self.address) + } + + pub async fn instance_issue_crucible_snapshot_request( + &self, + disk_id: Uuid, + snapshot_id: Uuid, + ) -> Result<(), Error> { + let path = format!( + "http://{}/instance/disk/{}/snapshot/{}", + self.address, disk_id, snapshot_id, + ); + self.post(path, None).await + } +} diff --git a/lib/propolis-client/src/lib.rs b/lib/propolis-client/src/lib.rs index 0965be17e..54a1572b1 100644 --- a/lib/propolis-client/src/lib.rs +++ b/lib/propolis-client/src/lib.rs @@ -1,194 +1,131 @@ -//! Interface for making API requests to propolis. -//! This should be replaced with a client generated from the OpenAPI spec. +// Copyright 2022 Oxide Computer Company +//! A client for the Propolis hypervisor frontend's server API. +//! +//! It is being experimentally migrated to `progenitor` for auto-generation, +//! which is opt-in at present with crate feature `generated`, and additional +//! compatibility impls and re-exports to approximate the former handmade +//! bindings' module layout with crate feature `generated-migration`. +//! +//! Presently, when built with the `generated` flag, the legacy handmade +//! bindings are available in the `handmade` submodule. + +#![cfg_attr( + feature = "generated", + doc = "This documentation was built with the `generated` feature **on**." +)] +#![cfg_attr( + not(feature = "generated"), + doc = "This documentation was built with the `generated` feature **off**." +)] -use reqwest::Body; -use reqwest::IntoUrl; -use serde::de::DeserializeOwned; -use slog::{info, o, Logger}; -use std::net::SocketAddr; -use thiserror::Error; -use uuid::Uuid; - -pub mod api; pub mod instance_spec; -/// Errors which may be returend from the Propolis Client. -#[derive(Debug, Error)] -pub enum Error { - #[error("Request failed: {0}")] - Reqwest(#[from] reqwest::Error), - - #[error("Bad Status: {0}")] - Status(u16), -} - -/// Client-side connection to propolis. -pub struct Client { - client: reqwest::Client, - log: Logger, - address: SocketAddr, -} - -// Sends "request", awaits "response", and returns an error on any -// non-success status code. -// -// TODO: Do we want to handle re-directs? -async fn send_and_check_ok( - request: reqwest::RequestBuilder, -) -> Result { - let response = request.send().await.map_err(Error::from)?; - - if !response.status().is_success() { - return Err(Error::Status(response.status().as_u16())); - } - - Ok(response) -} - -// Sends a "request", awaits "response", and parses the body -// into a deserializable type. -async fn send_and_parse_response( - request: reqwest::RequestBuilder, -) -> Result { - send_and_check_ok(request).await?.json().await.map_err(|e| e.into()) -} - -impl Client { - pub fn new(address: SocketAddr, log: Logger) -> Client { - Client { - client: reqwest::Client::new(), - log: log.new(o!("propolis_client address" => address.to_string())), - address, +#[cfg(feature = "generated")] +mod generated; +#[cfg(feature = "generated")] +pub use generated::*; + +#[cfg(feature = "generated")] +pub mod handmade; +#[cfg(not(feature = "generated"))] +mod handmade; +#[cfg(not(feature = "generated"))] +pub use handmade::*; + +#[cfg(feature = "generated-migration")] +pub use types as api; +#[cfg(feature = "generated-migration")] +mod _compat_impls { + use super::{generated, handmade}; + use crucible_client_types::VolumeConstructionRequest as CrucibleVCR; + use generated::types::VolumeConstructionRequest as GenVCR; + + impl Into for handmade::api::DiskRequest { + fn into(self) -> generated::types::DiskRequest { + let Self { + name, + slot, + read_only, + device, + gen, + volume_construction_request, + } = self; + generated::types::DiskRequest { + name, + slot: slot.into(), + read_only, + device, + gen, + volume_construction_request: volume_construction_request.into(), + } } } - async fn get( - &self, - path: U, - body: Option, - ) -> Result { - info!(self.log, "GET request to {}", path); - let mut request = self.client.get(path); - if let Some(body) = body { - request = request.body(body); - } - - send_and_parse_response(request).await - } - - async fn put( - &self, - path: U, - body: Option, - ) -> Result { - info!(self.log, "PUT request to {}", path); - let mut request = self.client.put(path); - if let Some(body) = body { - request = request.body(body); + impl Into for handmade::api::Slot { + fn into(self) -> generated::types::Slot { + generated::types::Slot(self.0) } - - send_and_parse_response(request).await } - async fn post( - &self, - path: U, - body: Option, - ) -> Result { - info!(self.log, "POST request to {}", path); - let mut request = self.client.post(path); - if let Some(body) = body { - request = request.body(body); + impl From for GenVCR { + fn from(vcr: CrucibleVCR) -> Self { + match vcr { + CrucibleVCR::Volume { + id, + block_size, + sub_volumes, + read_only_parent, + } => GenVCR::Volume { + id, + block_size, + sub_volumes: sub_volumes + .into_iter() + .map(Into::into) + .collect(), + read_only_parent: read_only_parent + .map(|rop| Box::new((*rop).into())), + }, + CrucibleVCR::Url { id, block_size, url } => { + GenVCR::Url { id, block_size, url } + } + CrucibleVCR::Region { block_size, opts, gen } => { + GenVCR::Region { block_size, opts: opts.into(), gen } + } + CrucibleVCR::File { id, block_size, path } => { + GenVCR::File { id, block_size, path } + } + } } - - send_and_parse_response(request).await } - async fn put_no_response( - &self, - path: U, - body: Option, - ) -> Result<(), Error> { - info!(self.log, "PUT request to {}", path); - let mut request = self.client.put(path); - if let Some(body) = body { - request = request.body(body); + impl From + for generated::types::CrucibleOpts + { + fn from(opts: crucible_client_types::CrucibleOpts) -> Self { + let crucible_client_types::CrucibleOpts { + id, + target, + lossy, + flush_timeout, + key, + cert_pem, + key_pem, + root_cert_pem, + control, + read_only, + } = opts; + generated::types::CrucibleOpts { + id, + target: target.into_iter().map(|t| t.to_string()).collect(), + lossy, + flush_timeout, + key, + cert_pem, + key_pem, + root_cert_pem, + control: control.map(|c| c.to_string()), + read_only, + } } - - send_and_check_ok(request).await?; - Ok(()) - } - - /// Ensures that an instance with the specified properties exists. - pub async fn instance_ensure( - &self, - request: &api::InstanceEnsureRequest, - ) -> Result { - let path = format!("http://{}/instance", self.address,); - let body = Body::from(serde_json::to_string(&request).unwrap()); - self.put(path, Some(body)).await - } - - /// Returns information about an instance, by UUID. - pub async fn instance_get( - &self, - ) -> Result { - let path = format!("http://{}/instance", self.address); - self.get(path, None).await - } - - /// Long-poll for state changes. - pub async fn instance_state_monitor( - &self, - gen: u64, - ) -> Result { - let path = format!("http://{}/instance/state-monitor", self.address); - let body = Body::from( - serde_json::to_string(&api::InstanceStateMonitorRequest { gen }) - .unwrap(), - ); - self.get(path, Some(body)).await - } - - /// Puts an instance into a new state. - pub async fn instance_state_put( - &self, - state: api::InstanceStateRequested, - ) -> Result<(), Error> { - let path = format!("http://{}/instance/state", self.address); - let body = Body::from(serde_json::to_string(&state).unwrap()); - self.put_no_response(path, Some(body)).await - } - - /// Get the status of an ongoing migration - pub async fn instance_migrate_status( - &self, - migration_id: Uuid, - ) -> Result { - let path = format!("http://{}/instance/migrate/status", self.address); - let body = Body::from( - serde_json::to_string(&api::InstanceMigrateStatusRequest { - migration_id, - }) - .unwrap(), - ); - self.get(path, Some(body)).await - } - - /// Returns the WebSocket URI to an instance's serial console stream. - pub fn instance_serial_console_ws_uri(&self) -> String { - format!("ws://{}/instance/serial", self.address) - } - - pub async fn instance_issue_crucible_snapshot_request( - &self, - disk_id: Uuid, - snapshot_id: Uuid, - ) -> Result<(), Error> { - let path = format!( - "http://{}/instance/disk/{}/snapshot/{}", - self.address, disk_id, snapshot_id, - ); - self.post(path, None).await } } diff --git a/openapi/propolis-server.json b/openapi/propolis-server.json new file mode 100644 index 000000000..002ff67c8 --- /dev/null +++ b/openapi/propolis-server.json @@ -0,0 +1,850 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Oxide Propolis Server API", + "description": "API for interacting with the Propolis hypervisor frontend.", + "contact": { + "url": "https://oxide.computer", + "email": "api@oxide.computer" + }, + "version": "0.0.1" + }, + "paths": { + "/instance": { + "get": { + "operationId": "instance_get", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InstanceGetResponse" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "put": { + "operationId": "instance_ensure", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InstanceEnsureRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "successful creation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InstanceEnsureResponse" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/instance/disk/{id}/snapshot/{snapshot_id}": { + "post": { + "summary": "Issues a snapshot request to a crucible backend.", + "operationId": "instance_issue_crucible_snapshot_request", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + }, + "style": "simple" + }, + { + "in": "path", + "name": "snapshot_id", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "title": "Null", + "type": "string", + "enum": [ + null + ] + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/instance/migrate/status": { + "get": { + "operationId": "instance_migrate_status", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InstanceMigrateStatusRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InstanceMigrateStatusResponse" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/instance/serial": { + "get": { + "operationId": "instance_serial", + "responses": { + "default": { + "description": "", + "content": { + "*/*": { + "schema": {} + } + } + } + }, + "x-dropshot-websocket": {} + } + }, + "/instance/state": { + "put": { + "operationId": "instance_state_put", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InstanceStateRequested" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/instance/state-monitor": { + "get": { + "operationId": "instance_state_monitor", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InstanceStateMonitorRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InstanceStateMonitorResponse" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + } + }, + "components": { + "responses": { + "Error": { + "description": "Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "schemas": { + "CrucibleOpts": { + "type": "object", + "properties": { + "cert_pem": { + "nullable": true, + "type": "string" + }, + "control": { + "nullable": true, + "type": "string" + }, + "flush_timeout": { + "nullable": true, + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "id": { + "type": "string", + "format": "uuid" + }, + "key": { + "nullable": true, + "type": "string" + }, + "key_pem": { + "nullable": true, + "type": "string" + }, + "lossy": { + "type": "boolean" + }, + "read_only": { + "type": "boolean" + }, + "root_cert_pem": { + "nullable": true, + "type": "string" + }, + "target": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "id", + "lossy", + "read_only", + "target" + ] + }, + "DiskAttachment": { + "type": "object", + "properties": { + "disk_id": { + "type": "string", + "format": "uuid" + }, + "generation_id": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "state": { + "$ref": "#/components/schemas/DiskAttachmentState" + } + }, + "required": [ + "disk_id", + "generation_id", + "state" + ] + }, + "DiskAttachmentState": { + "oneOf": [ + { + "type": "string", + "enum": [ + "Detached", + "Destroyed", + "Faulted" + ] + }, + { + "type": "object", + "properties": { + "Attached": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "Attached" + ], + "additionalProperties": false + } + ] + }, + "DiskRequest": { + "type": "object", + "properties": { + "device": { + "type": "string" + }, + "gen": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "name": { + "type": "string" + }, + "read_only": { + "type": "boolean" + }, + "slot": { + "$ref": "#/components/schemas/Slot" + }, + "volume_construction_request": { + "$ref": "#/components/schemas/VolumeConstructionRequest" + } + }, + "required": [ + "device", + "gen", + "name", + "read_only", + "slot", + "volume_construction_request" + ] + }, + "Error": { + "description": "Error information from a response.", + "type": "object", + "properties": { + "error_code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "request_id": { + "type": "string" + } + }, + "required": [ + "message", + "request_id" + ] + }, + "Instance": { + "type": "object", + "properties": { + "disks": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DiskAttachment" + } + }, + "nics": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NetworkInterface" + } + }, + "properties": { + "$ref": "#/components/schemas/InstanceProperties" + }, + "state": { + "$ref": "#/components/schemas/InstanceState" + } + }, + "required": [ + "disks", + "nics", + "properties", + "state" + ] + }, + "InstanceEnsureRequest": { + "type": "object", + "properties": { + "cloud_init_bytes": { + "nullable": true, + "type": "string" + }, + "disks": { + "default": [], + "type": "array", + "items": { + "$ref": "#/components/schemas/DiskRequest" + } + }, + "migrate": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/InstanceMigrateInitiateRequest" + } + ] + }, + "nics": { + "default": [], + "type": "array", + "items": { + "$ref": "#/components/schemas/NetworkInterfaceRequest" + } + }, + "properties": { + "$ref": "#/components/schemas/InstanceProperties" + } + }, + "required": [ + "properties" + ] + }, + "InstanceEnsureResponse": { + "type": "object", + "properties": { + "migrate": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/InstanceMigrateInitiateResponse" + } + ] + } + } + }, + "InstanceGetResponse": { + "type": "object", + "properties": { + "instance": { + "$ref": "#/components/schemas/Instance" + } + }, + "required": [ + "instance" + ] + }, + "InstanceMigrateInitiateRequest": { + "type": "object", + "properties": { + "migration_id": { + "type": "string", + "format": "uuid" + }, + "src_addr": { + "type": "string" + }, + "src_uuid": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "migration_id", + "src_addr", + "src_uuid" + ] + }, + "InstanceMigrateInitiateResponse": { + "type": "object", + "properties": { + "migration_id": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "migration_id" + ] + }, + "InstanceMigrateStatusRequest": { + "type": "object", + "properties": { + "migration_id": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "migration_id" + ] + }, + "InstanceMigrateStatusResponse": { + "type": "object", + "properties": { + "state": { + "$ref": "#/components/schemas/MigrationState" + } + }, + "required": [ + "state" + ] + }, + "InstanceProperties": { + "type": "object", + "properties": { + "bootrom_id": { + "description": "ID of the bootrom used to initialize this Instance.", + "type": "string", + "format": "uuid" + }, + "description": { + "description": "Free-form text description of an Instance.", + "type": "string" + }, + "id": { + "description": "Unique identifier for this Instance.", + "type": "string", + "format": "uuid" + }, + "image_id": { + "description": "ID of the image used to initialize this Instance.", + "type": "string", + "format": "uuid" + }, + "memory": { + "description": "Size of memory allocated to the Instance, in MiB.", + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "name": { + "description": "Human-readable name of the Instance.", + "type": "string" + }, + "vcpus": { + "description": "Number of vCPUs to be allocated to the Instance.", + "type": "integer", + "format": "uint8", + "minimum": 0 + } + }, + "required": [ + "bootrom_id", + "description", + "id", + "image_id", + "memory", + "name", + "vcpus" + ] + }, + "InstanceState": { + "description": "Current state of an Instance.", + "type": "string", + "enum": [ + "Creating", + "Starting", + "Running", + "Stopping", + "Stopped", + "Rebooting", + "Migrating", + "Repairing", + "Failed", + "Destroyed" + ] + }, + "InstanceStateMonitorRequest": { + "type": "object", + "properties": { + "gen": { + "type": "integer", + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "gen" + ] + }, + "InstanceStateMonitorResponse": { + "type": "object", + "properties": { + "gen": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "state": { + "$ref": "#/components/schemas/InstanceState" + } + }, + "required": [ + "gen", + "state" + ] + }, + "InstanceStateRequested": { + "type": "string", + "enum": [ + "Run", + "Stop", + "Reboot", + "MigrateStart" + ] + }, + "MigrationState": { + "type": "string", + "enum": [ + "Sync", + "RamPush", + "Pause", + "RamPushDirty", + "Device", + "Arch", + "Resume", + "RamPull", + "Finish", + "Error" + ] + }, + "NetworkInterface": { + "type": "object", + "properties": { + "attachment": { + "$ref": "#/components/schemas/NetworkInterfaceAttachmentState" + }, + "name": { + "type": "string" + } + }, + "required": [ + "attachment", + "name" + ] + }, + "NetworkInterfaceAttachmentState": { + "oneOf": [ + { + "type": "string", + "enum": [ + "Detached", + "Faulted" + ] + }, + { + "type": "object", + "properties": { + "Attached": { + "$ref": "#/components/schemas/Slot" + } + }, + "required": [ + "Attached" + ], + "additionalProperties": false + } + ] + }, + "NetworkInterfaceRequest": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "slot": { + "$ref": "#/components/schemas/Slot" + } + }, + "required": [ + "name", + "slot" + ] + }, + "Slot": { + "description": "A stable index which is translated by Propolis into a PCI BDF, visible to the guest.", + "type": "integer", + "format": "uint8", + "minimum": 0 + }, + "VolumeConstructionRequest": { + "oneOf": [ + { + "type": "object", + "properties": { + "block_size": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "id": { + "type": "string", + "format": "uuid" + }, + "read_only_parent": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/VolumeConstructionRequest" + } + ] + }, + "sub_volumes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/VolumeConstructionRequest" + } + }, + "type": { + "type": "string", + "enum": [ + "volume" + ] + } + }, + "required": [ + "block_size", + "id", + "sub_volumes", + "type" + ] + }, + { + "type": "object", + "properties": { + "block_size": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "id": { + "type": "string", + "format": "uuid" + }, + "type": { + "type": "string", + "enum": [ + "url" + ] + }, + "url": { + "type": "string" + } + }, + "required": [ + "block_size", + "id", + "type", + "url" + ] + }, + { + "type": "object", + "properties": { + "block_size": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "gen": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "opts": { + "$ref": "#/components/schemas/CrucibleOpts" + }, + "type": { + "type": "string", + "enum": [ + "region" + ] + } + }, + "required": [ + "block_size", + "gen", + "opts", + "type" + ] + }, + { + "type": "object", + "properties": { + "block_size": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "id": { + "type": "string", + "format": "uuid" + }, + "path": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "file" + ] + } + }, + "required": [ + "block_size", + "id", + "path", + "type" + ] + } + ] + } + } + } +} \ No newline at end of file diff --git a/phd-tests/framework/Cargo.toml b/phd-tests/framework/Cargo.toml index 82d2dd160..6dca7ab3f 100644 --- a/phd-tests/framework/Cargo.toml +++ b/phd-tests/framework/Cargo.toml @@ -18,7 +18,7 @@ libc = "0.2.129" propolis-client = { path = "../../lib/propolis-client" } propolis-server-config = { path = "../../crates/propolis-server-config" } propolis_types = { path = "../../crates/propolis-types" } -reqwest = { version = "0.11.11", features = ["blocking"] } +reqwest = { version = "0.11.12", features = ["blocking"] } ring = "0.16.20" serde = { version = "1.0.139", features = ["derive"] } serde_derive = "1.0.139"