Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Instructions for dropshot-api-manager

## General instructions

* Always use `cargo nextest run` to run tests. Never use `cargo test`.
* Wrap comments to 80 characters.
* Always end comments with a period.
182 changes: 111 additions & 71 deletions crates/dropshot-api-manager/src/resolved.rs
Original file line number Diff line number Diff line change
Expand Up @@ -672,7 +672,19 @@ fn resolve_api<'a>(
let latest_generated = api_generated.latest_link().expect(
"\"generated\" source should always have a \"latest\" link",
);
let symlink = match api_local.and_then(|l| l.latest_link()) {
let generated_version =
latest_generated.version().expect("versioned APIs have a version");
let resolution =
by_version.get(generated_version).unwrap_or_else(|| {
panic!(
"by_version map should have a version \
corresponding to latest_generated ({})",
latest_generated
)
});

let latest_local = api_local.and_then(|l| l.latest_link());
let symlink = match latest_local {
Some(latest_local) => {
if latest_local == latest_generated {
None
Expand All @@ -689,95 +701,123 @@ fn resolve_api<'a>(
// 1. latest_local is blessed, latest_generated has the same
// version as latest_local, and it has wire-compatible
// changes. In that case, don't update the symlink.
//
// 2. latest_local is blessed, latest_generated has the same
// version as latest_local, and latest_generated has
// wire-*incompatible* changes. In that case, we'd have
// returned errors in the by_version map above, and we
// wouldn't want to update the symlink in any case.
//
// 3. latest_local is blessed, and latest_generated is
// blessed but a *different* version. This means that
// the latest version was retired. In this case,
// we want to update the symlink to the blessed hash
// corresponding to the latest generated version.
//
// 4. latest_local is not blessed. In that case, we do
// want to update the symlink.
let generated_version = latest_generated
.version()
.expect("versioned APIs have a version");
let local_version = latest_local
.version()
.expect("versioned APIs have a version");
if let Some(resolution) = by_version.get(generated_version)
{
match resolution.kind() {
ResolutionKind::Lockstep => {
unreachable!("this is a versioned API");
}
// Case 1 and 2 above.
ResolutionKind::Blessed
if generated_version == local_version =>
{
// latest_generated is blessed and the same
// version as latest_local, so don't update the
// symlink.
None
}
ResolutionKind::Blessed => {
// latest_generated is blessed, and has a
// different version from latest_local. In this
// case, we want to update the symlink to the
// blessed version matching latest_generated
// (not latest_generated, in case it's different
// from the blessed version in a wire-compatible
// way!)
let api_blessed =
api_blessed.unwrap_or_else(|| {
panic!(
"for {}, Blessed means \
api_blessed exists",
api.ident()
)
});
let blessed = api_blessed
.versions()
.get(generated_version)
.unwrap_or_else(|| {
panic!(
"for {} v{}, Blessed means \
generated_version exists",
api.ident(),
generated_version
);
});
Some(Problem::LatestLinkStale {
api_ident: api.ident().clone(),
link: blessed.spec_file_name(),
found: latest_local,
})
}
ResolutionKind::NewLocally => {
// latest_generated is not blessed, so update
// the symlink.
Some(Problem::LatestLinkStale {
api_ident: api.ident().clone(),
link: latest_generated,
found: latest_local,
})
}
match resolution.kind() {
ResolutionKind::Lockstep => {
unreachable!("this is a versioned API");
}
// Case 1 and 2 above.
ResolutionKind::Blessed
if generated_version == local_version =>
{
// latest_generated is blessed and the same
// version as latest_local, so don't update the
// symlink.
None
}
ResolutionKind::Blessed => {
// latest_generated is blessed, and has a
// different version from latest_local. In this
// case, we want to update the symlink to the
// blessed version matching latest_generated
// (not latest_generated, in case it's different
// from the blessed version in a wire-compatible
// way!)
let api_blessed =
api_blessed.unwrap_or_else(|| {
panic!(
"for {}, Blessed means \
api_blessed exists",
api.ident()
)
});
let blessed = api_blessed
.versions()
.get(generated_version)
.unwrap_or_else(|| {
panic!(
"for {} v{}, Blessed means \
generated_version exists",
api.ident(),
generated_version
);
});
Some(Problem::LatestLinkStale {
api_ident: api.ident().clone(),
link: blessed.spec_file_name(),
found: latest_local,
})
}
ResolutionKind::NewLocally => {
// latest_generated is not blessed, so update
// the symlink.
Some(Problem::LatestLinkStale {
api_ident: api.ident().clone(),
link: latest_generated,
found: latest_local,
})
}
} else {
unreachable!(
"by_version map should have a version \
corresponding to latest_generated ({})",
latest_generated
)
}
}
}
None => Some(Problem::LatestLinkMissing {
api_ident: api.ident().clone(),
link: latest_generated,
}),
None => {
// As in case 3 above, if the resolution is blessed, we want to
// update the symlink to the *blessed() hash corresponding to
// the latest generated version.
match resolution.kind() {
ResolutionKind::Lockstep => {
unreachable!("this is a versioned API");
}
ResolutionKind::Blessed => {
let api_blessed = api_blessed.unwrap_or_else(|| {
panic!(
"for {}, Blessed means api_blessed exists",
api.ident()
)
});
let blessed = api_blessed
.versions()
.get(generated_version)
.unwrap_or_else(|| {
panic!(
"for {} v{}, Blessed means \
generated_version exists",
api.ident(),
generated_version
);
});
Some(Problem::LatestLinkMissing {
api_ident: api.ident().clone(),
link: blessed.spec_file_name(),
})
}
ResolutionKind::NewLocally => {
// latest_generated is not blessed, so update
// the symlink to the generated version.
Some(Problem::LatestLinkMissing {
api_ident: api.ident().clone(),
link: latest_generated,
})
}
}
}
};

(by_version, symlink)
Expand Down
134 changes: 130 additions & 4 deletions crates/integration-tests/src/common/fixtures.rs
Original file line number Diff line number Diff line change
Expand Up @@ -163,9 +163,7 @@ pub mod versioned_health {
use super::*;
use dropshot_api_manager_types::api_versions;

api_versions!(
[(3, WITH_METRICS), (2, WITH_DETAILED_STATUS), (1, INITIAL),]
);
api_versions!([(3, WITH_METRICS), (2, WITH_DETAILED_STATUS), (1, INITIAL)]);

#[dropshot::api_description]
pub trait VersionedHealthApi {
Expand Down Expand Up @@ -457,7 +455,7 @@ pub mod versioned_health_reduced {
use super::*;
use dropshot_api_manager_types::api_versions;

api_versions!([(2, WITH_DETAILED_STATUS), (1, INITIAL),]);
api_versions!([(2, WITH_DETAILED_STATUS), (1, INITIAL)]);

#[dropshot::api_description]
pub trait VersionedHealthApi {
Expand Down Expand Up @@ -491,3 +489,131 @@ pub mod versioned_health_reduced {
DependencyStatus, DetailedHealthStatus, HealthStatusV1,
};
}

/// Versioned health API fixture that skips the middle version (2.0.0). This has
/// versions 3.0.0 and 1.0.0 only, simulating retirement of an older blessed
/// version.
pub mod versioned_health_skip_middle {
use super::*;
use dropshot_api_manager_types::api_versions;

api_versions!([(3, WITH_METRICS), (1, INITIAL)]);

#[dropshot::api_description]
pub trait VersionedHealthApi {
type Context;

/// Check if the service is healthy (all versions).
#[endpoint {
method = GET,
path = "/health",
operation_id = "health_check",
versions = "1.0.0"..
}]
async fn health_check(
rqctx: RequestContext<Self::Context>,
) -> Result<HttpResponseOk<HealthStatusV1>, HttpError>;

/// Get detailed health status (v2+, but only available in v3 since we skip v2).
#[endpoint {
method = GET,
path = "/health/detailed",
operation_id = "detailed_health_check",
versions = "3.0.0"..
}]
async fn detailed_health_check(
rqctx: RequestContext<Self::Context>,
) -> Result<HttpResponseOk<DetailedHealthStatus>, HttpError>;

/// Get service metrics (v3+).
#[endpoint {
method = GET,
path = "/metrics",
operation_id = "get_metrics",
versions = "3.0.0"..
}]
async fn get_metrics(
rqctx: RequestContext<Self::Context>,
) -> Result<HttpResponseOk<ServiceMetrics>, HttpError>;
}

// Reuse the same response types from the main versioned_health module.
pub use super::versioned_health::{
DependencyStatus, DetailedHealthStatus, HealthStatusV1, ServiceMetrics,
};
}

/// Versioned health API with incompatible changes - this breaks backward
/// compatibility by changing the response schema of an existing endpoint.
pub mod versioned_health_incompatible {
use super::*;
use dropshot_api_manager_types::api_versions;

api_versions!([(3, WITH_METRICS), (2, WITH_DETAILED_STATUS), (1, INITIAL)]);

#[dropshot::api_description]
pub trait VersionedHealthApi {
type Context;

/// Check if the service is healthy (all versions).
#[endpoint {
method = GET,
path = "/health",
operation_id = "health_check",
versions = "1.0.0"..
}]
async fn health_check(
rqctx: RequestContext<Self::Context>,
) -> Result<HttpResponseOk<HealthStatusV1>, HttpError>;

/// Get detailed health status (v2+).
#[endpoint {
method = GET,
path = "/health/detailed",
operation_id = "detailed_health_check",
versions = "2.0.0"..
}]
async fn detailed_health_check(
rqctx: RequestContext<Self::Context>,
) -> Result<HttpResponseOk<DetailedHealthStatus>, HttpError>;

/// Get service metrics (v3+).
#[endpoint {
method = GET,
path = "/metrics",
operation_id = "get_metrics",
versions = "3.0.0"..
}]
async fn get_metrics(
rqctx: RequestContext<Self::Context>,
) -> Result<HttpResponseOk<ServiceMetrics>, HttpError>;

/// Get system info (v3+): new endpoint added to existing version.
///
/// This breaks backward compatibility by adding a new endpoint to
/// v3.0.0.
#[endpoint {
method = GET,
path = "/system/info",
operation_id = "get_system_info",
versions = "3.0.0"..
}]
async fn get_system_info(
rqctx: RequestContext<Self::Context>,
) -> Result<HttpResponseOk<SystemInfo>, HttpError>;
}

/// System information response for the new endpoint.
#[derive(JsonSchema, Serialize)]
pub struct SystemInfo {
pub version: String,
pub build_time: DateTime<Utc>,
pub environment: String,
}

// Reuse response types from the main versioned_health module for other
// endpoints.
pub use super::versioned_health::{
DependencyStatus, DetailedHealthStatus, HealthStatusV1, ServiceMetrics,
};
}
Loading