Skip to content

Commit 884d26e

Browse files
authored
[ls-apis] check that only omicron-sled-agent depends on nexus-lockstep-client (#9413)
Sled Agent depends on nexus-lockstep-client via RSS, and we now ensure that nothing else that's part of one of the deployment units depends on it. (We don't treat omdb as part of a deployment unit because it doesn't participate in upgrade in the typical sense.) Also add an expectorate test for the output of `ls-apis check`, similar to the existing one for `ls-apis apis`. Closes #9057.
1 parent 05b2d5a commit 884d26e

File tree

11 files changed

+612
-47
lines changed

11 files changed

+612
-47
lines changed

Cargo.lock

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

dev-tools/ls-apis/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ anyhow.workspace = true
1212
camino.workspace = true
1313
cargo_metadata.workspace = true
1414
clap.workspace = true
15+
iddqd.workspace = true
16+
indent_write.workspace = true
1517
newtype_derive.workspace = true
1618
parse-display.workspace = true
1719
petgraph.workspace = true

dev-tools/ls-apis/api-manifest.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,12 @@ server_package_name = "nexus-lockstep-api"
347347
# dependency with sled-agent-client, which is server-versioned.
348348
versioned_how = "client"
349349
versioned_how_reason = "Circular dependencies between Nexus and other services"
350+
# Exactly these consumers (Rust packages specified in one or more of the
351+
# `packages` arrays in the deployment_units table above) are allowed to use this
352+
# API. ls-apis check will error out if the list of consumers is incorrect.
353+
restricted_to_consumers = [
354+
{ name = "omicron-sled-agent", reason = "Only RSS uses this API and it is not part of upgrade" },
355+
]
350356

351357
[[apis]]
352358
client_package_name = "oxide-client"

dev-tools/ls-apis/src/api_metadata.rs

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ use crate::ServerPackageName;
1111
use crate::cargo::DepPath;
1212
use crate::workspaces::Workspaces;
1313
use anyhow::{Result, bail};
14+
use iddqd::IdOrdItem;
15+
use iddqd::IdOrdMap;
16+
use iddqd::id_upcast;
1417
use serde::Deserialize;
1518
use std::borrow::Borrow;
1619
use std::collections::BTreeMap;
@@ -228,6 +231,13 @@ pub struct ApiMetadata {
228231
pub label: String,
229232
/// package name of the server that provides the corresponding API
230233
pub server_package_name: ServerPackageName,
234+
/// expected consumers (Rust packages) that use this API
235+
///
236+
/// By default, we don't make any assertions about expected consumers. But
237+
/// some APIs must have a fixed list of consumers, and we assert on that
238+
/// via this array.
239+
#[serde(default)]
240+
pub restricted_to_consumers: ApiExpectedConsumers,
231241
/// human-readable notes about this API
232242
pub notes: Option<String>,
233243
/// describes how we've decided this API will be versioned
@@ -248,6 +258,121 @@ impl ApiMetadata {
248258
}
249259
}
250260

261+
/// Expected consumers (Rust packages) for an API.
262+
#[derive(Debug, Default)]
263+
pub enum ApiExpectedConsumers {
264+
/// This API has no configured restrictions on which consumers can use it.
265+
#[default]
266+
Unrestricted,
267+
/// This API is restricted to exactly these consumers.
268+
Restricted(IdOrdMap<ApiExpectedConsumer>),
269+
}
270+
271+
impl ApiExpectedConsumers {
272+
pub fn status(
273+
&self,
274+
server_pkgname: &ServerComponentName,
275+
) -> ApiConsumerStatus {
276+
match self {
277+
ApiExpectedConsumers::Unrestricted => {
278+
ApiConsumerStatus::NoAssertion
279+
}
280+
ApiExpectedConsumers::Restricted(consumers) => {
281+
if let Some(consumer) =
282+
consumers.iter().find(|c| c.name == *server_pkgname)
283+
{
284+
ApiConsumerStatus::Expected {
285+
reason: consumer.reason.clone(),
286+
}
287+
} else {
288+
ApiConsumerStatus::Unexpected
289+
}
290+
}
291+
}
292+
}
293+
}
294+
295+
impl<'de> Deserialize<'de> for ApiExpectedConsumers {
296+
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
297+
where
298+
D: serde::Deserializer<'de>,
299+
{
300+
use serde::de::Error;
301+
302+
struct ApiExpectedConsumersVisitor;
303+
304+
impl<'de> serde::de::Visitor<'de> for ApiExpectedConsumersVisitor {
305+
type Value = ApiExpectedConsumers;
306+
307+
fn expecting(
308+
&self,
309+
formatter: &mut std::fmt::Formatter,
310+
) -> std::fmt::Result {
311+
formatter.write_str(
312+
"null (for no assertions) or a list of Rust package names",
313+
)
314+
}
315+
316+
fn visit_unit<E>(self) -> Result<Self::Value, E>
317+
where
318+
E: Error,
319+
{
320+
Ok(ApiExpectedConsumers::Unrestricted)
321+
}
322+
323+
fn visit_none<E>(self) -> Result<Self::Value, E>
324+
where
325+
E: Error,
326+
{
327+
Ok(ApiExpectedConsumers::Unrestricted)
328+
}
329+
330+
fn visit_seq<A>(self, seq: A) -> Result<Self::Value, A::Error>
331+
where
332+
A: serde::de::SeqAccess<'de>,
333+
{
334+
// Note IdOrdMap deserializes as a sequence by default.
335+
let consumers = IdOrdMap::<ApiExpectedConsumer>::deserialize(
336+
serde::de::value::SeqAccessDeserializer::new(seq),
337+
)?;
338+
Ok(ApiExpectedConsumers::Restricted(consumers))
339+
}
340+
}
341+
342+
deserializer.deserialize_any(ApiExpectedConsumersVisitor)
343+
}
344+
}
345+
346+
/// Describes a single allowed consumer for an API.
347+
#[derive(Clone, Debug, Deserialize)]
348+
#[serde(deny_unknown_fields)]
349+
pub struct ApiExpectedConsumer {
350+
/// The name of the Rust package.
351+
pub name: ServerComponentName,
352+
/// The reason this consumer is allowed.
353+
pub reason: String,
354+
}
355+
356+
impl IdOrdItem for ApiExpectedConsumer {
357+
type Key<'a> = &'a ServerComponentName;
358+
fn key(&self) -> Self::Key<'_> {
359+
&self.name
360+
}
361+
id_upcast!();
362+
}
363+
364+
/// The status of an API consumer that was discovered by walking the Cargo
365+
/// metadata graph.
366+
#[derive(Clone, Debug, PartialEq, Eq)]
367+
pub enum ApiConsumerStatus {
368+
/// No assertions were made about this API consumer.
369+
NoAssertion,
370+
/// The API consumer is expected to be used.
371+
Expected { reason: String },
372+
/// The API consumer was not expected. This is an error case.
373+
Unexpected,
374+
}
375+
251376
/// Describes a unit that combines one or more servers that get deployed
252377
/// together
253378
#[derive(Deserialize)]

dev-tools/ls-apis/src/bin/ls-apis.rs

Lines changed: 100 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@
77
use anyhow::{Context, Result, bail};
88
use camino::Utf8PathBuf;
99
use clap::{Args, Parser, Subcommand};
10+
use indent_write::indentable::Indentable;
1011
use omicron_ls_apis::{
11-
AllApiMetadata, ApiDependencyFilter, ApiMetadata, LoadArgs,
12-
ServerComponentName, SystemApis, VersionedHow,
12+
AllApiMetadata, ApiConsumerStatus, ApiDependencyFilter, ApiMetadata,
13+
FailedConsumerCheck, LoadArgs, ServerComponentName, SystemApis,
14+
VersionedHow, plural,
1315
};
1416
use parse_display::{Display, FromStr};
1517

@@ -119,11 +121,11 @@ fn run_adoc(apis: &SystemApis) -> Result<()> {
119121
println!("|{}", apis.adoc_label(&api.client_package_name)?);
120122

121123
println!("|");
122-
for (c, _) in apis.api_consumers(
124+
for consumer in apis.api_consumers(
123125
&api.client_package_name,
124126
ApiDependencyFilter::default(),
125127
)? {
126-
println!("* {}", apis.adoc_label(c)?);
128+
println!("* {}", apis.adoc_label(&consumer.server_pkgname)?);
127129
}
128130

129131
match &api.versioned_how {
@@ -145,24 +147,25 @@ fn run_adoc(apis: &SystemApis) -> Result<()> {
145147
}
146148

147149
fn run_apis(apis: &SystemApis, args: ShowDepsArgs) -> Result<()> {
150+
let mut error_count = 0;
151+
148152
let metadata = apis.api_metadata();
149153
for api in metadata.apis() {
150154
println!("{} (client: {})", api.label, api.client_package_name);
151-
for (s, dep_paths) in
152-
apis.api_consumers(&api.client_package_name, args.filter)?
153-
{
154-
let (repo_name, package_path) = apis.package_label(s)?;
155+
for c in apis.api_consumers(&api.client_package_name, args.filter)? {
156+
let (repo_name, package_path) =
157+
apis.package_label(c.server_pkgname)?;
155158
println!(
156159
" consumed by: {} ({}/{}) via {} path{}",
157-
s,
160+
c.server_pkgname,
158161
repo_name,
159162
package_path,
160-
dep_paths.len(),
161-
if dep_paths.len() == 1 { "" } else { "s" },
163+
c.dep_paths.len(),
164+
if c.dep_paths.len() == 1 { "" } else { "s" },
162165
);
163166
if args.show_deps {
164-
for (i, dep_path) in dep_paths.iter().enumerate() {
165-
let label = if dep_paths.len() > 1 {
167+
for (i, dep_path) in c.dep_paths.iter().enumerate() {
168+
let label = if c.dep_paths.len() > 1 {
166169
format!(" path {}", i + 1)
167170
} else {
168171
String::new()
@@ -173,8 +176,33 @@ fn run_apis(apis: &SystemApis, args: ShowDepsArgs) -> Result<()> {
173176
}
174177
}
175178
}
179+
match c.status {
180+
ApiConsumerStatus::NoAssertion => {
181+
// We don't know whether it's okay for this API to be
182+
// present.
183+
}
184+
ApiConsumerStatus::Expected { reason } => {
185+
println!(" status: expected, reason: {reason}");
186+
}
187+
ApiConsumerStatus::Unexpected => {
188+
println!(" error: unexpected dependency");
189+
error_count += 1;
190+
}
191+
}
176192
}
177-
println!("");
193+
if let Some(missing) =
194+
apis.missing_expected_consumers(&api.client_package_name)
195+
{
196+
println!("{}", missing.display(apis).indented(" "));
197+
error_count += missing.error_count();
198+
}
199+
println!();
200+
}
201+
if error_count > 0 {
202+
bail!(
203+
"{error_count} {} reported (see above)",
204+
plural::errors_str(error_count)
205+
);
178206
}
179207
Ok(())
180208
}
@@ -311,6 +339,32 @@ fn run_check(apis: &SystemApis) -> Result<()> {
311339
println!(")");
312340
}
313341

342+
fn print_failed_consumer_check(
343+
api: &ApiMetadata,
344+
check: &FailedConsumerCheck,
345+
apis: &SystemApis,
346+
) {
347+
println!(" {} ({}):", api.label, api.client_package_name);
348+
for consumer in &check.unexpected {
349+
let deployment_unit =
350+
apis.server_component_unit(consumer).unwrap_or_else(|| {
351+
panic!(
352+
"consumer {consumer} doesn't have an associated \
353+
deployment unit (this is checked at load time, so \
354+
if you're seeing this message, there's a bug in that \
355+
check)"
356+
);
357+
});
358+
println!(
359+
" error: unexpected dependency on {consumer} (part of {deployment_unit})"
360+
);
361+
}
362+
363+
if let Some(missing) = check.missing {
364+
println!("{}", missing.display(apis).indented(" "));
365+
}
366+
}
367+
314368
println!("\n");
315369
println!("Server-managed APIs:\n");
316370
for api in apis
@@ -330,6 +384,8 @@ fn run_check(apis: &SystemApis) -> Result<()> {
330384
}
331385
}
332386

387+
let mut error_count = 0;
388+
333389
println!("\n");
334390
print!("APIs with unknown version management:");
335391
let unknown: Vec<_> = apis
@@ -343,8 +399,37 @@ fn run_check(apis: &SystemApis) -> Result<()> {
343399
println!("\n");
344400
for api in unknown {
345401
print_api_and_producers(api, apis);
402+
error_count += 1;
346403
}
347-
bail!("at least one API has unknown version strategy (see above)");
404+
}
405+
406+
println!("\n");
407+
print!("APIs that failed consumer checks:");
408+
if dag_check.failed_consumers().is_empty() {
409+
println!(" none");
410+
} else {
411+
println!("\n");
412+
for c in dag_check.failed_consumers() {
413+
let api = apis
414+
.api_metadata()
415+
.client_pkgname_lookup(c.client_pkgname)
416+
.unwrap_or_else(|| {
417+
panic!(
418+
"client package name {} not found in API metadata",
419+
c.client_pkgname
420+
)
421+
});
422+
print_failed_consumer_check(api, c, apis);
423+
error_count += c.error_count();
424+
}
425+
}
426+
427+
if error_count > 0 {
428+
println!("\n");
429+
bail!(
430+
"{error_count} {} reported (see above)",
431+
plural::errors_str(error_count)
432+
);
348433
}
349434

350435
Ok(())

dev-tools/ls-apis/src/lib.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,17 @@
66
77
mod api_metadata;
88
mod cargo;
9+
pub mod plural;
910
mod system_apis;
1011
mod workspaces;
1112

1213
pub use api_metadata::AllApiMetadata;
14+
pub use api_metadata::ApiConsumerStatus;
15+
pub use api_metadata::ApiExpectedConsumer;
1316
pub use api_metadata::ApiMetadata;
1417
pub use api_metadata::VersionedHow;
1518
pub use system_apis::ApiDependencyFilter;
19+
pub use system_apis::FailedConsumerCheck;
1620
pub use system_apis::SystemApis;
1721

1822
use anyhow::{Context, Result};
@@ -25,7 +29,7 @@ use std::borrow::Borrow;
2529
#[macro_use]
2630
extern crate newtype_derive;
2731

28-
#[derive(Clone, Deserialize, Ord, PartialOrd, Eq, PartialEq)]
32+
#[derive(Clone, Deserialize, Hash, Ord, PartialOrd, Eq, PartialEq)]
2933
#[serde(transparent)]
3034
pub struct ClientPackageName(String);
3135
NewtypeDebug! { () pub struct ClientPackageName(String); }
@@ -62,7 +66,7 @@ impl Borrow<str> for ServerPackageName {
6266
}
6367
}
6468

65-
#[derive(Clone, Deserialize, Ord, PartialOrd, Eq, PartialEq)]
69+
#[derive(Clone, Deserialize, Hash, Ord, PartialOrd, Eq, PartialEq)]
6670
#[serde(transparent)]
6771
pub struct ServerComponentName(String);
6872
NewtypeDebug! { () pub struct ServerComponentName(String); }

0 commit comments

Comments
 (0)