Skip to content

Commit f65b0e4

Browse files
authored
[nexus] make the external API versioned (#9430)
Part of #9425. Use a YYYYMMDDNN pattern to allow for multiple API version changes in a day while keeping date-based versioning in place. Also pull in dropshot and dropshot-api-manager updates, required to make the api-version header optional and support extra validation with versioned APIs, respectively.
1 parent a0a2683 commit f65b0e4

File tree

12 files changed

+392
-263
lines changed

12 files changed

+392
-263
lines changed

Cargo.lock

Lines changed: 201 additions & 185 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -441,9 +441,9 @@ dns-server = { path = "dns-server" }
441441
dns-server-api = { path = "dns-server-api" }
442442
dns-service-client = { path = "clients/dns-service-client" }
443443
dpd-client = { git = "https://github.com/oxidecomputer/dendrite", rev = "cc8e02a0800034c431c8cf96b889ea638da3d194" }
444-
dropshot = { version = "0.16.3", features = [ "usdt-probes" ] }
445-
dropshot-api-manager = "0.2.3"
446-
dropshot-api-manager-types = "0.2.3"
444+
dropshot = { version = "0.16.5", features = [ "usdt-probes" ] }
445+
dropshot-api-manager = "0.2.4"
446+
dropshot-api-manager-types = "0.2.4"
447447
dyn-clone = "1.0.20"
448448
either = "1.15.0"
449449
ereport-types = { path = "ereport/types" }

clients/oxide-client/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ use std::sync::Arc;
1717
use thiserror::Error;
1818

1919
progenitor::generate_api!(
20-
spec = "../../openapi/nexus.json",
20+
spec = "../../openapi/nexus/nexus-latest.json",
2121
interface = Builder,
2222
tags = Separate,
2323
);

dev-tools/dropshot-apis/src/main.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -192,9 +192,9 @@ fn all_apis() -> anyhow::Result<ManagedApis> {
192192
},
193193
ManagedApiConfig {
194194
title: "Oxide Region API",
195-
versions: Versions::new_lockstep(semver::Version::new(
196-
20251208, 0, 0,
197-
)),
195+
versions: Versions::new_versioned(
196+
nexus_external_api::supported_versions(),
197+
),
198198
metadata: ManagedApiMetadata {
199199
description: Some(
200200
"API for interacting with the Oxide control plane",

nexus/external-api/src/lib.rs

Lines changed: 108 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ use dropshot::{
1414
Query, RequestContext, ResultsPage, StreamingBody, TypedBody,
1515
WebsocketChannelResult, WebsocketConnection,
1616
};
17-
use dropshot_api_manager_types::ValidationContext;
17+
use dropshot_api_manager_types::{ValidationContext, api_versions};
1818
use http::Response;
1919
use ipnetwork::IpNetwork;
2020
use nexus_types::{
@@ -33,7 +33,48 @@ use omicron_common::api::external::{
3333
};
3434
use openapiv3::OpenAPI;
3535

36-
pub const API_VERSION: &str = "20251208.0.0";
36+
api_versions!([
37+
// API versions are in the format YYYYMMDDNN.0.0, defined below as
38+
// YYYYMMDDNN. Here, NN is a two-digit number starting at 00 for a
39+
// particular date.
40+
//
41+
// WHEN CHANGING THE API (part 1 of 2):
42+
//
43+
// +- First, determine the next API version number to use.
44+
// |
45+
// | * On the main branch: Take today's date in YYYYMMDD format, e.g. 20251112.
46+
// | Find the smallest NN that isn't already defined in the list below. In
47+
// | most cases, that is 00, but if 00 is already taken, use 01, 02, etc.
48+
// |
49+
// | * On a release branch, don't alter the date. Instead, always bump the NN.
50+
// |
51+
// | Duplicate this line, uncomment the *second* copy, update that copy for
52+
// | your new API version, and leave the first copy commented out as an
53+
// | example for the next person.
54+
// |
55+
// | If there's a merge conflict, update the version number to the current
56+
// | date. Otherwise, it is okay to leave the version number unchanged even
57+
// | if you land your change on a different day from the one you make it on.
58+
// |
59+
// | Ensure that version numbers are sorted in descending order. (This macro
60+
// | will panic at runtime if they're not in descending order.) The newest
61+
// | date-based version should be at the top of the list.
62+
// v
63+
// (next_yyyymmddnn, IDENT),
64+
(2025112000, INITIAL),
65+
]);
66+
67+
// WHEN CHANGING THE API (part 2 of 2):
68+
//
69+
// The call to `api_versions!` above defines constants of type
70+
// `semver::Version` that you can use in your Dropshot API definition to specify
71+
// the version when a particular endpoint was added or removed. For example, if
72+
// you used:
73+
//
74+
// (2025120100, ADD_FOOBAR)
75+
//
76+
// Then you could use `VERSION_ADD_FOOBAR` as the version in which endpoints
77+
// were added or removed.
3778

3879
const MIB: usize = 1024 * 1024;
3980
const GIB: usize = 1024 * MIB;
@@ -4364,8 +4405,37 @@ pub trait NexusExternalApi {
43644405
) -> Result<HttpResponseDeleted, HttpError>;
43654406
}
43664407

4367-
/// Perform extra validations on the OpenAPI spec.
4408+
/// Perform extra validations on the OpenAPI document, and generate the
4409+
/// nexus_tags.txt file.
43684410
pub fn validate_api(spec: &OpenAPI, mut cx: ValidationContext<'_>) {
4411+
let blessed = cx
4412+
.is_blessed()
4413+
.expect("this is a versioned API so is_blessed should always be Some");
4414+
4415+
// There are two parts to this function:
4416+
//
4417+
// 1. Perform validation on the OpenAPI document.
4418+
// 2. Generate the nexus_tags.txt file.
4419+
//
4420+
// Step 1 should only be performed on non-blessed versions. That's because
4421+
// blessed versions are immutable, and if new checks are added in the
4422+
// future, we don't want old API versions to be affected.
4423+
//
4424+
// nexus_tags.txt is unversioned, so step 2 should only be performed on the
4425+
// latest version, whether or not it's blessed.
4426+
4427+
if !blessed {
4428+
validate_api_doc(spec, &mut cx);
4429+
}
4430+
4431+
// nexus_tags.txt is unversioned, so only write it out for the latest
4432+
// version (whether it's blessed or not).
4433+
if cx.is_latest() {
4434+
generate_tags_file(spec, &mut cx);
4435+
}
4436+
}
4437+
4438+
fn validate_api_doc(spec: &OpenAPI, cx: &mut ValidationContext<'_>) {
43694439
if spec.openapi != "3.0.3" {
43704440
cx.report_error(anyhow!(
43714441
"Expected OpenAPI version to be 3.0.3, found {}",
@@ -4378,13 +4448,6 @@ pub fn validate_api(spec: &OpenAPI, mut cx: ValidationContext<'_>) {
43784448
spec.info.title,
43794449
));
43804450
}
4381-
if spec.info.version != API_VERSION {
4382-
cx.report_error(anyhow!(
4383-
"Expected OpenAPI version to be '{}', found '{}'",
4384-
API_VERSION,
4385-
spec.info.version,
4386-
));
4387-
}
43884451

43894452
// Spot check a couple of items.
43904453
if spec.paths.paths.is_empty() {
@@ -4394,13 +4457,7 @@ pub fn validate_api(spec: &OpenAPI, mut cx: ValidationContext<'_>) {
43944457
cx.report_error(anyhow!("Expected a path for /v1/projects"));
43954458
}
43964459

4397-
// Construct a string that helps us identify the organization of tags and
4398-
// operations.
4399-
let mut ops_by_tag =
4400-
BTreeMap::<String, Vec<(String, String, String)>>::new();
4401-
4402-
let mut ops_by_tag_valid = true;
4403-
for (path, method, op) in spec.operations() {
4460+
for (_path, _method, op) in spec.operations() {
44044461
// Make sure each operation has exactly one tag. Note, we intentionally
44054462
// do this before validating the OpenAPI output as fixing an error here
44064463
// would necessitate refreshing the spec file again.
@@ -4410,8 +4467,6 @@ pub fn validate_api(spec: &OpenAPI, mut cx: ValidationContext<'_>) {
44104467
op.operation_id.as_ref().unwrap(),
44114468
op.tags.len()
44124469
));
4413-
ops_by_tag_valid = false;
4414-
continue;
44154470
}
44164471

44174472
// Every non-hidden endpoint must have a summary
@@ -4421,8 +4476,21 @@ pub fn validate_api(spec: &OpenAPI, mut cx: ValidationContext<'_>) {
44214476
"operation '{}' is missing a summary doc comment",
44224477
op.operation_id.as_ref().unwrap()
44234478
));
4424-
// This error does not prevent `ops_by_tag` from being populated
4425-
// correctly, so we can continue.
4479+
}
4480+
}
4481+
}
4482+
4483+
fn generate_tags_file(spec: &OpenAPI, cx: &mut ValidationContext<'_>) {
4484+
// Construct a string that helps us identify the organization of tags and
4485+
// operations.
4486+
let mut ops_by_tag =
4487+
BTreeMap::<String, Vec<(String, String, String)>>::new();
4488+
4489+
for (path, method, op) in spec.operations() {
4490+
// If an operation doesn't have exactly one tag, skip generating the
4491+
// tags file entirely. (Validation above catches this case).
4492+
if op.tags.len() != 1 {
4493+
return;
44264494
}
44274495

44284496
ops_by_tag
@@ -4435,34 +4503,29 @@ pub fn validate_api(spec: &OpenAPI, mut cx: ValidationContext<'_>) {
44354503
));
44364504
}
44374505

4438-
if ops_by_tag_valid {
4439-
let mut tags = String::new();
4440-
for (tag, mut ops) in ops_by_tag {
4441-
ops.sort();
4442-
tags.push_str(&format!(
4443-
r#"API operations found with tag "{}""#,
4444-
tag
4445-
));
4506+
let mut tags = String::new();
4507+
for (tag, mut ops) in ops_by_tag {
4508+
ops.sort();
4509+
tags.push_str(&format!(r#"API operations found with tag "{}""#, tag));
4510+
tags.push_str(&format!(
4511+
"\n{:40} {:8} {}\n",
4512+
"OPERATION ID", "METHOD", "URL PATH"
4513+
));
4514+
for (operation_id, method, path) in ops {
44464515
tags.push_str(&format!(
4447-
"\n{:40} {:8} {}\n",
4448-
"OPERATION ID", "METHOD", "URL PATH"
4516+
"{:40} {:8} {}\n",
4517+
operation_id, method, path
44494518
));
4450-
for (operation_id, method, path) in ops {
4451-
tags.push_str(&format!(
4452-
"{:40} {:8} {}\n",
4453-
operation_id, method, path
4454-
));
4455-
}
4456-
tags.push('\n');
44574519
}
4458-
4459-
// When this fails, verify that operations on which you're adding,
4460-
// renaming, or changing the tags are what you intend.
4461-
cx.record_file_contents(
4462-
"nexus/external-api/output/nexus_tags.txt",
4463-
tags.into_bytes(),
4464-
);
4520+
tags.push('\n');
44654521
}
4522+
4523+
// When this fails, verify that operations on which you're adding,
4524+
// renaming, or changing the tags are what you intend.
4525+
cx.record_file_contents(
4526+
"nexus/external-api/output/nexus_tags.txt",
4527+
tags.into_bytes(),
4528+
);
44664529
}
44674530

44684531
pub type IpPoolRangePaginationParams =

nexus/src/lib.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,18 @@ impl Server {
225225
log.new(o!("component" => "dropshot_external")),
226226
)
227227
.config(config.deployment.dropshot_external.dropshot.clone())
228+
.version_policy(dropshot::VersionPolicy::Dynamic(Box::new(
229+
dropshot::ClientSpecifiesVersionInHeader::new(
230+
omicron_common::api::VERSION_HEADER,
231+
nexus_external_api::latest_version(),
232+
)
233+
// Since we don't have control over all clients to the external
234+
// API, we allow the api-version header to not be specified
235+
// (picking the latest version in that case). However, all
236+
// clients that *are* under our control should specify the
237+
// api-version header.
238+
.on_missing(nexus_external_api::latest_version()),
239+
)))
228240
.tls(tls_config.clone().map(dropshot::ConfigTls::Dynamic))
229241
.start()
230242
.map_err(|error| {

nexus/test-utils/src/http_testing.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,11 @@ impl<'a> RequestBuilder<'a> {
124124
self
125125
}
126126

127+
/// Return the current header map.
128+
pub fn headers(&self) -> &http::HeaderMap {
129+
&self.headers
130+
}
131+
127132
/// Set the outgoing request body
128133
///
129134
/// If `body` is `None`, the request body will be empty.

nexus/tests/integration_tests/unauthorized_coverage.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ use std::collections::BTreeMap;
1515
#[test]
1616
fn test_unauthorized_coverage() {
1717
// Load the OpenAPI schema for Nexus's public API.
18-
let schema_path = "../openapi/nexus.json";
18+
let schema_path = "../openapi/nexus/nexus-latest.json";
1919
let schema_contents = std::fs::read_to_string(&schema_path)
2020
.expect("failed to read Nexus OpenAPI spec");
2121
let spec: OpenAPI = serde_json::from_str(&schema_contents)

nexus/tests/integration_tests/updates.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@ use camino::Utf8Path;
77
use camino_tempfile::{Builder, Utf8TempPath};
88
use chrono::{DateTime, Duration, Timelike, Utc};
99
use dropshot::ResultsPage;
10+
use dropshot::test_util::ClientTestContext;
1011
use http::{Method, StatusCode};
1112
use nexus_db_model::SemverVersion;
1213
use nexus_db_queries::context::OpContext;
1314
use nexus_db_queries::db::pub_test_utils::helpers::insert_test_tuf_repo;
15+
use nexus_test_interface::NexusServer;
1416
use nexus_test_utils::background::activate_background_task;
1517
use nexus_test_utils::background::run_tuf_artifact_replication_step;
1618
use nexus_test_utils::background::wait_tuf_artifact_replication_step;
@@ -1039,3 +1041,31 @@ async fn test_repo_list() -> Result<()> {
10391041
cptestctx.teardown().await;
10401042
Ok(())
10411043
}
1044+
1045+
/// Test that a request without an API version header still succeeds.
1046+
///
1047+
/// Unlike internal APIs, we don't control all clients for our external API, so
1048+
/// we make the api-version header optional. This test ensures that a request
1049+
/// without the header succeeds.
1050+
#[nexus_test]
1051+
async fn test_request_without_api_version(cptestctx: &ControlPlaneTestContext) {
1052+
// We can't use cptestctx.external_client directly since it always sets the
1053+
// header. Instead, construct a NexusRequest by hand.
1054+
let server_addr = cptestctx.server.get_http_server_external_address();
1055+
let test_cx =
1056+
ClientTestContext::new(server_addr, cptestctx.logctx.log.clone());
1057+
let req_builder = RequestBuilder::new(
1058+
&test_cx,
1059+
http::Method::GET,
1060+
"/v1/system/update/status",
1061+
);
1062+
assert_eq!(
1063+
req_builder.headers().get(&omicron_common::api::VERSION_HEADER),
1064+
None,
1065+
"api-version header is not set"
1066+
);
1067+
let req =
1068+
NexusRequest::new(req_builder).authn_as(AuthnMode::PrivilegedUser);
1069+
let status: views::UpdateStatus = req.execute_and_parse_unwrap().await;
1070+
assert_eq!(status.target_release.0, None);
1071+
}

openapi/nexus.json renamed to openapi/nexus/nexus-2025112000.0.0-53f3c8.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"url": "https://oxide.computer",
88
"email": "api@oxide.computer"
99
},
10-
"version": "20251208.0.0"
10+
"version": "2025112000.0.0"
1111
},
1212
"paths": {
1313
"/device/auth": {

0 commit comments

Comments
 (0)