Skip to content

Commit 0eca494

Browse files
author
lif
committed
Generate an API client with Progenitor
- Validate/generate an OpenAPI schema in a test, as done in omicron - Provide an (optional, for now) codegen'd client from the OpenAPI
1 parent f414d0d commit 0eca494

File tree

12 files changed

+1218
-193
lines changed

12 files changed

+1218
-193
lines changed

bin/propolis-cli/Cargo.toml

+1-1
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ anyhow = "1.0"
1111
clap = { version = "3.2", features = ["derive"] }
1212
futures = "0.3"
1313
libc = "0.2"
14-
propolis-client = { path = "../../lib/propolis-client" }
14+
propolis-client = { path = "../../lib/propolis-client", features = ["generated"] }
1515
slog = "2.7"
1616
slog-async = "2.7"
1717
slog-term = "2.8"

bin/propolis-cli/src/main.rs

+11-4
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ use std::{
1010
use anyhow::{anyhow, Context};
1111
use clap::{Parser, Subcommand};
1212
use futures::{future, SinkExt, StreamExt};
13-
use propolis_client::{
13+
use propolis_client::handmade::{
1414
api::{
1515
DiskRequest, InstanceEnsureRequest, InstanceMigrateInitiateRequest,
1616
InstanceProperties, InstanceStateRequested, MigrationState,
@@ -19,7 +19,9 @@ use propolis_client::{
1919
};
2020
use slog::{o, Drain, Level, Logger};
2121
use tokio::io::{AsyncReadExt, AsyncWriteExt};
22+
use tokio_tungstenite::tungstenite::protocol::Role;
2223
use tokio_tungstenite::tungstenite::Message;
24+
use tokio_tungstenite::WebSocketStream;
2325
use uuid::Uuid;
2426

2527
#[derive(Debug, Parser)]
@@ -317,10 +319,15 @@ async fn test_stdin_to_websockets_task() {
317319
}
318320

319321
async fn serial(addr: SocketAddr) -> anyhow::Result<()> {
320-
let path = format!("ws://{}/instance/serial", addr);
321-
let (mut ws, _) = tokio_tungstenite::connect_async(path)
322+
let upgraded = propolis_client::Client::new(&format!("http://{}", addr))
323+
.instance_serial()
324+
.send()
322325
.await
323-
.with_context(|| anyhow!("failed to create serial websocket stream"))?;
326+
.expect("Failed to upgrade connection")
327+
//.map_err(|_| anyhow!("Failed to upgrade connection"))?
328+
.into_inner();
329+
let mut ws =
330+
WebSocketStream::from_raw_socket(upgraded, Role::Client, None).await;
324331

325332
let _raw_guard = RawTermiosGuard::stdio_guard()
326333
.with_context(|| anyhow!("failed to set raw mode"))?;

bin/propolis-server/Cargo.toml

+2-1
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ serde_derive = "1.0"
4545
serde_json = "1.0"
4646
slog = "2.7"
4747
propolis = { path = "../../lib/propolis", features = ["crucible-full", "oximeter"], default-features = false }
48-
propolis-client = { path = "../../lib/propolis-client" }
48+
propolis-client = { path = "../../lib/propolis-client", features = ["generated"] }
4949
propolis-server-config = { path = "../../crates/propolis-server-config" }
5050
rfb = { git = "https://github.com/oxidecomputer/rfb", branch = "main" }
5151
uuid = "1.0.0"
@@ -60,6 +60,7 @@ hex = "0.4.3"
6060
reqwest = { version = "0.11", default-features = false, features = ["rustls-tls"] }
6161
ring = "0.16"
6262
slog = { version = "2.5", features = [ "max_level_trace", "release_max_level_debug" ] }
63+
expectorate = "1.0.5"
6364

6465
[features]
6566
default = ["dtrace-probes"]

bin/propolis-server/src/lib/migrate/mod.rs

+2-4
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,8 @@ use propolis::{
88
instance::{Instance, MigratePhase, MigrateRole, State, TransitionError},
99
migrate::MigrateStateError,
1010
};
11-
use propolis_client::{
12-
api::{self, MigrationState},
13-
instance_spec::InstanceSpec,
14-
};
11+
use propolis_client::handmade::api::{self, MigrationState};
12+
use propolis_client::instance_spec::InstanceSpec;
1513
use serde::{Deserialize, Serialize};
1614
use slog::{error, info, o};
1715
use thiserror::Error;

bin/propolis-server/src/lib/server.rs

+24-1
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ use tokio_tungstenite::WebSocketStream;
2828
use propolis::dispatch::AsyncCtx;
2929
use propolis::hw::uart::LpcUart;
3030
use propolis::instance::Instance;
31-
use propolis_client::{api, instance_spec};
31+
use propolis_client::handmade::api;
32+
use propolis_client::instance_spec;
3233

3334
use crate::config::Config as VmConfig;
3435
use crate::initializer::{build_instance, MachineInitializer};
@@ -882,3 +883,25 @@ pub fn api() -> ApiDescription<DropshotEndpointContext> {
882883

883884
api
884885
}
886+
887+
#[cfg(test)]
888+
mod tests {
889+
#[test]
890+
fn test_propolis_server_openapi() {
891+
let mut buf: Vec<u8> = vec![];
892+
super::api()
893+
.openapi("Oxide Propolis Server API", "0.0.1")
894+
.description(
895+
"API for interacting with the Propolis hypervisor frontend.",
896+
)
897+
.contact_url("https://oxide.computer")
898+
.contact_email("api@oxide.computer")
899+
.write(&mut buf)
900+
.unwrap();
901+
let output = String::from_utf8(buf).unwrap();
902+
expectorate::assert_contents(
903+
"../../openapi/propolis-server.json",
904+
&output,
905+
);
906+
}
907+
}

bin/propolis-server/src/lib/spec.rs

+2-2
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use std::collections::BTreeSet;
44
use std::convert::TryInto;
55
use std::str::FromStr;
66

7-
use propolis_client::api::{
7+
use propolis_client::handmade::api::{
88
self, DiskRequest, InstanceProperties, NetworkInterfaceRequest,
99
};
1010
use propolis_client::instance_spec::*;
@@ -552,7 +552,7 @@ impl SpecBuilder {
552552
mod test {
553553
use std::{collections::BTreeMap, path::PathBuf};
554554

555-
use propolis_client::api::Slot;
555+
use propolis_client::handmade::api::Slot;
556556
use uuid::Uuid;
557557

558558
use crate::config::{self, Config};

lib/propolis-client/Cargo.toml

+9-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ edition = "2018"
88

99
[dependencies]
1010
propolis_types = { path = "../../crates/propolis-types" }
11-
reqwest = { version = "0.11", default-features = false, features = ["json", "rustls-tls"] }
11+
reqwest = { git = "https://github.com/seanmonstar/reqwest", rev = "6ceb23958c8b6d7f1d8ee093f0ad73184d133d40" }
12+
#reqwest = { version = "0.11", default-features = false, features = ["json", "rustls-tls"] }
1213
ring = "0.16"
1314
schemars = { version = "0.8.10", features = [ "uuid1" ] }
1415
serde = "1.0"
@@ -17,3 +18,10 @@ slog = { version = "2.5", features = [ "max_level_trace", "release_max_level_deb
1718
thiserror = "1.0"
1819
uuid = { version = "1.0.0", features = [ "serde", "v4" ] }
1920
crucible-client-types = { git = "https://github.com/oxidecomputer/crucible", rev = "bacffd142fc38a01fe255407b0c8d5d0aacfe778" }
21+
progenitor = { git = "https://github.com/oxidecomputer/progenitor", branch = "main", optional = true }
22+
tokio = { version = "1.0", features = [ "net" ], optional = true }
23+
24+
[features]
25+
default = []
26+
generated = ["progenitor", "tokio"]
27+
generated-migration = ["generated"]

lib/propolis-client/src/generated.rs

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
// Copyright 2022 Oxide Computer Company
2+
//! Experimental progenitor-generated propolis-server API client.
3+
4+
progenitor::generate_api!(
5+
spec = "../../openapi/propolis-server.json",
6+
interface = Builder,
7+
tags = Separate,
8+
);
File renamed without changes.
+193
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
//! Interface for making API requests to propolis.
2+
//! This should be replaced with a client generated from the OpenAPI spec.
3+
4+
use reqwest::Body;
5+
use reqwest::IntoUrl;
6+
use serde::de::DeserializeOwned;
7+
use slog::{info, o, Logger};
8+
use std::net::SocketAddr;
9+
use thiserror::Error;
10+
use uuid::Uuid;
11+
12+
pub mod api;
13+
14+
/// Errors which may be returend from the Propolis Client.
15+
#[derive(Debug, Error)]
16+
pub enum Error {
17+
#[error("Request failed: {0}")]
18+
Reqwest(#[from] reqwest::Error),
19+
20+
#[error("Bad Status: {0}")]
21+
Status(u16),
22+
}
23+
24+
/// Client-side connection to propolis.
25+
pub struct Client {
26+
client: reqwest::Client,
27+
log: Logger,
28+
address: SocketAddr,
29+
}
30+
31+
// Sends "request", awaits "response", and returns an error on any
32+
// non-success status code.
33+
//
34+
// TODO: Do we want to handle re-directs?
35+
async fn send_and_check_ok(
36+
request: reqwest::RequestBuilder,
37+
) -> Result<reqwest::Response, Error> {
38+
let response = request.send().await.map_err(Error::from)?;
39+
40+
if !response.status().is_success() {
41+
return Err(Error::Status(response.status().as_u16()));
42+
}
43+
44+
Ok(response)
45+
}
46+
47+
// Sends a "request", awaits "response", and parses the body
48+
// into a deserializable type.
49+
async fn send_and_parse_response<T: DeserializeOwned>(
50+
request: reqwest::RequestBuilder,
51+
) -> Result<T, Error> {
52+
send_and_check_ok(request).await?.json().await.map_err(|e| e.into())
53+
}
54+
55+
impl Client {
56+
pub fn new(address: SocketAddr, log: Logger) -> Client {
57+
Client {
58+
client: reqwest::Client::new(),
59+
log: log.new(o!("propolis_client address" => address.to_string())),
60+
address,
61+
}
62+
}
63+
64+
async fn get<T: DeserializeOwned, U: IntoUrl + std::fmt::Display>(
65+
&self,
66+
path: U,
67+
body: Option<Body>,
68+
) -> Result<T, Error> {
69+
info!(self.log, "GET request to {}", path);
70+
let mut request = self.client.get(path);
71+
if let Some(body) = body {
72+
request = request.body(body);
73+
}
74+
75+
send_and_parse_response(request).await
76+
}
77+
78+
async fn put<T: DeserializeOwned, U: IntoUrl + std::fmt::Display>(
79+
&self,
80+
path: U,
81+
body: Option<Body>,
82+
) -> Result<T, Error> {
83+
info!(self.log, "PUT request to {}", path);
84+
let mut request = self.client.put(path);
85+
if let Some(body) = body {
86+
request = request.body(body);
87+
}
88+
89+
send_and_parse_response(request).await
90+
}
91+
92+
async fn post<T: DeserializeOwned, U: IntoUrl + std::fmt::Display>(
93+
&self,
94+
path: U,
95+
body: Option<Body>,
96+
) -> Result<T, Error> {
97+
info!(self.log, "POST request to {}", path);
98+
let mut request = self.client.post(path);
99+
if let Some(body) = body {
100+
request = request.body(body);
101+
}
102+
103+
send_and_parse_response(request).await
104+
}
105+
106+
async fn put_no_response<U: IntoUrl + std::fmt::Display>(
107+
&self,
108+
path: U,
109+
body: Option<Body>,
110+
) -> Result<(), Error> {
111+
info!(self.log, "PUT request to {}", path);
112+
let mut request = self.client.put(path);
113+
if let Some(body) = body {
114+
request = request.body(body);
115+
}
116+
117+
send_and_check_ok(request).await?;
118+
Ok(())
119+
}
120+
121+
/// Ensures that an instance with the specified properties exists.
122+
pub async fn instance_ensure(
123+
&self,
124+
request: &api::InstanceEnsureRequest,
125+
) -> Result<api::InstanceEnsureResponse, Error> {
126+
let path = format!("http://{}/instance", self.address,);
127+
let body = Body::from(serde_json::to_string(&request).unwrap());
128+
self.put(path, Some(body)).await
129+
}
130+
131+
/// Returns information about an instance, by UUID.
132+
pub async fn instance_get(
133+
&self,
134+
) -> Result<api::InstanceGetResponse, Error> {
135+
let path = format!("http://{}/instance", self.address);
136+
self.get(path, None).await
137+
}
138+
139+
/// Long-poll for state changes.
140+
pub async fn instance_state_monitor(
141+
&self,
142+
gen: u64,
143+
) -> Result<api::InstanceStateMonitorResponse, Error> {
144+
let path = format!("http://{}/instance/state-monitor", self.address);
145+
let body = Body::from(
146+
serde_json::to_string(&api::InstanceStateMonitorRequest { gen })
147+
.unwrap(),
148+
);
149+
self.get(path, Some(body)).await
150+
}
151+
152+
/// Puts an instance into a new state.
153+
pub async fn instance_state_put(
154+
&self,
155+
state: api::InstanceStateRequested,
156+
) -> Result<(), Error> {
157+
let path = format!("http://{}/instance/state", self.address);
158+
let body = Body::from(serde_json::to_string(&state).unwrap());
159+
self.put_no_response(path, Some(body)).await
160+
}
161+
162+
/// Get the status of an ongoing migration
163+
pub async fn instance_migrate_status(
164+
&self,
165+
migration_id: Uuid,
166+
) -> Result<api::InstanceMigrateStatusResponse, Error> {
167+
let path = format!("http://{}/instance/migrate/status", self.address);
168+
let body = Body::from(
169+
serde_json::to_string(&api::InstanceMigrateStatusRequest {
170+
migration_id,
171+
})
172+
.unwrap(),
173+
);
174+
self.get(path, Some(body)).await
175+
}
176+
177+
/// Returns the WebSocket URI to an instance's serial console stream.
178+
pub fn instance_serial_console_ws_uri(&self) -> String {
179+
format!("ws://{}/instance/serial", self.address)
180+
}
181+
182+
pub async fn instance_issue_crucible_snapshot_request(
183+
&self,
184+
disk_id: Uuid,
185+
snapshot_id: Uuid,
186+
) -> Result<(), Error> {
187+
let path = format!(
188+
"http://{}/instance/disk/{}/snapshot/{}",
189+
self.address, disk_id, snapshot_id,
190+
);
191+
self.post(path, None).await
192+
}
193+
}

0 commit comments

Comments
 (0)