From b3a61c0a2f99ab1ffeda29c9f5064819ccca6b70 Mon Sep 17 00:00:00 2001 From: Rain Date: Thu, 28 Aug 2025 20:24:12 +0000 Subject: [PATCH 1/4] [spr] initial version Created using spr 1.3.6-beta.1 --- Cargo.lock | 23 ++ Cargo.toml | 4 + pantry-api/Cargo.toml | 13 + pantry-api/src/lib.rs | 258 +++++++++++++++++ pantry-types/Cargo.toml | 10 + pantry-types/src/lib.rs | 32 +++ pantry/Cargo.toml | 2 + pantry/src/main.rs | 4 +- pantry/src/pantry.rs | 5 +- pantry/src/server.rs | 606 +++++++++++++--------------------------- 10 files changed, 541 insertions(+), 416 deletions(-) create mode 100644 pantry-api/Cargo.toml create mode 100644 pantry-api/src/lib.rs create mode 100644 pantry-types/Cargo.toml create mode 100644 pantry-types/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 72f21f71e..6c5ab2685 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1219,6 +1219,8 @@ dependencies = [ "clap", "crucible", "crucible-common", + "crucible-pantry-api", + "crucible-pantry-types", "crucible-smf 0.0.0", "crucible-workspace-hack", "dropshot", @@ -1243,6 +1245,18 @@ dependencies = [ "uuid", ] +[[package]] +name = "crucible-pantry-api" +version = "0.1.0" +dependencies = [ + "crucible-client-types", + "crucible-pantry-types", + "crucible-workspace-hack", + "dropshot", + "schemars", + "serde", +] + [[package]] name = "crucible-pantry-client" version = "0.0.1" @@ -1259,6 +1273,15 @@ dependencies = [ "uuid", ] +[[package]] +name = "crucible-pantry-types" +version = "0.1.0" +dependencies = [ + "crucible-workspace-hack", + "schemars", + "serde", +] + [[package]] name = "crucible-protocol" version = "0.0.0" diff --git a/Cargo.toml b/Cargo.toml index 173d54082..b2389c4e3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,9 @@ members = [ "nbd_server", "package", "pantry", + "pantry-api", "pantry-client", + "pantry-types", "protocol", "repair-client", "smf", @@ -133,6 +135,8 @@ crucible-client-types = { path = "./crucible-client-types" } crucible-agent-client = { path = "./agent-client" } crucible-common = { path = "./common" } crucible-control-client = { path = "./control-client" } +crucible-pantry-api = { path = "./pantry-api" } +crucible-pantry-types = { path = "./pantry-types" } # importantly, don't use features = ["zfs_snapshot"] here, this will cause # cleanup issues in the integration tests! crucible-downstairs = { path = "./downstairs" } diff --git a/pantry-api/Cargo.toml b/pantry-api/Cargo.toml new file mode 100644 index 000000000..8e36b38b0 --- /dev/null +++ b/pantry-api/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "crucible-pantry-api" +version = "0.1.0" +edition = "2021" +license = "MPL-2.0" + +[dependencies] +crucible-client-types.workspace = true +crucible-pantry-types.workspace = true +crucible-workspace-hack.workspace = true +dropshot.workspace = true +schemars.workspace = true +serde.workspace = true diff --git a/pantry-api/src/lib.rs b/pantry-api/src/lib.rs new file mode 100644 index 000000000..9e7cad4a0 --- /dev/null +++ b/pantry-api/src/lib.rs @@ -0,0 +1,258 @@ +// Copyright 2025 Oxide Computer Company + +use crucible_client_types::{ReplaceResult, VolumeConstructionRequest}; +use crucible_pantry_types::*; +use dropshot::{ + HttpError, HttpResponseDeleted, HttpResponseOk, + HttpResponseUpdatedNoContent, Path, RequestContext, TypedBody, +}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[dropshot::api_description] +pub trait CruciblePantryApi { + type Context; + + /// Get the Pantry's status + #[endpoint { + method = GET, + path = "/crucible/pantry/0", + }] + async fn pantry_status( + rqctx: RequestContext, + ) -> Result, HttpError>; + + /// Get a current Volume's status + #[endpoint { + method = GET, + path = "/crucible/pantry/0/volume/{id}", + }] + async fn volume_status( + rqctx: RequestContext, + path: Path, + ) -> Result, HttpError>; + + /// Construct a volume from a VolumeConstructionRequest, storing the result in + /// the Pantry. + #[endpoint { + method = POST, + path = "/crucible/pantry/0/volume/{id}", + }] + async fn attach( + rqctx: RequestContext, + path: Path, + body: TypedBody, + ) -> Result, HttpError>; + + /// Construct a volume from a VolumeConstructionRequest, storing the result in + /// the Pantry. Activate in a separate job so as not to block the request. + #[endpoint { + method = POST, + path = "/crucible/pantry/0/volume/{id}/background", + }] + async fn attach_activate_background( + rqctx: RequestContext, + path: Path, + body: TypedBody, + ) -> Result; + + /// Call a volume's target_replace function + #[endpoint { + method = POST, + path = "/crucible/pantry/0/volume/{id}/replace", + }] + async fn replace( + rqctx: RequestContext, + path: Path, + body: TypedBody, + ) -> Result, HttpError>; + + /// Poll to see if a Pantry background job is done + #[endpoint { + method = GET, + path = "/crucible/pantry/0/job/{id}/is-finished", + }] + async fn is_job_finished( + rqctx: RequestContext, + path: Path, + ) -> Result, HttpError>; + + /// Block on returning a Pantry background job result, then return 200 OK if the + /// job executed OK, 500 otherwise. + #[endpoint { + method = GET, + path = "/crucible/pantry/0/job/{id}/ok", + }] + async fn job_result_ok( + rqctx: RequestContext, + path: Path, + ) -> Result, HttpError>; + + /// Import data from a URL into a volume + #[endpoint { + method = POST, + path = "/crucible/pantry/0/volume/{id}/import-from-url", + }] + async fn import_from_url( + rqctx: RequestContext, + path: Path, + body: TypedBody, + ) -> Result, HttpError>; + + /// Take a snapshot of a volume + #[endpoint { + method = POST, + path = "/crucible/pantry/0/volume/{id}/snapshot", + }] + async fn snapshot( + rqctx: RequestContext, + path: Path, + body: TypedBody, + ) -> Result; + + /// Bulk write data into a volume at a specified offset + #[endpoint { + method = POST, + path = "/crucible/pantry/0/volume/{id}/bulk-write", + }] + async fn bulk_write( + rqctx: RequestContext, + path: Path, + body: TypedBody, + ) -> Result; + + /// Bulk read data from a volume at a specified offset + #[endpoint { + method = POST, + path = "/crucible/pantry/0/volume/{id}/bulk-read", + }] + async fn bulk_read( + rqctx: RequestContext, + path: Path, + body: TypedBody, + ) -> Result, HttpError>; + + /// Scrub the volume (copy blocks from read-only parent to subvolumes) + #[endpoint { + method = POST, + path = "/crucible/pantry/0/volume/{id}/scrub", + }] + async fn scrub( + rqctx: RequestContext, + path: Path, + ) -> Result, HttpError>; + + /// Validate the digest of a whole volume + #[endpoint { + method = POST, + path = "/crucible/pantry/0/volume/{id}/validate", + }] + async fn validate( + rqctx: RequestContext, + path: Path, + body: TypedBody, + ) -> Result, HttpError>; + + /// Deactivate a volume, removing it from the Pantry + #[endpoint { + method = DELETE, + path = "/crucible/pantry/0/volume/{id}", + }] + async fn detach( + rqctx: RequestContext, + path: Path, + ) -> Result; +} + +#[derive(Deserialize, JsonSchema)] +pub struct VolumePath { + pub id: String, +} + +#[derive(Deserialize, JsonSchema)] +pub struct AttachRequest { + pub volume_construction_request: VolumeConstructionRequest, +} + +#[derive(Serialize, JsonSchema)] +pub struct AttachResult { + pub id: String, +} + +#[derive(Deserialize, JsonSchema)] +pub struct AttachBackgroundRequest { + pub volume_construction_request: VolumeConstructionRequest, + pub job_id: String, +} + +#[derive(Deserialize, JsonSchema)] +pub struct ReplaceRequest { + pub volume_construction_request: VolumeConstructionRequest, +} + +#[derive(Deserialize, JsonSchema)] +pub struct JobPath { + pub id: String, +} + +#[derive(Serialize, JsonSchema)] +pub struct JobPollResponse { + pub job_is_finished: bool, +} + +#[derive(Serialize, JsonSchema)] +pub struct JobResultOkResponse { + pub job_result_ok: bool, +} + +#[derive(Deserialize, JsonSchema)] +pub struct ImportFromUrlRequest { + pub url: String, + pub expected_digest: Option, +} + +#[derive(Serialize, JsonSchema)] +pub struct ImportFromUrlResponse { + pub job_id: String, +} + +#[derive(Deserialize, JsonSchema)] +pub struct SnapshotRequest { + pub snapshot_id: String, +} + +#[derive(Deserialize, JsonSchema)] +pub struct BulkWriteRequest { + pub offset: u64, + pub base64_encoded_data: String, +} + +#[derive(Deserialize, JsonSchema)] +pub struct BulkReadRequest { + pub offset: u64, + pub size: usize, +} + +#[derive(Serialize, JsonSchema)] +pub struct BulkReadResponse { + pub base64_encoded_data: String, +} + +#[derive(Serialize, JsonSchema)] +pub struct ScrubResponse { + pub job_id: String, +} + +#[derive(Deserialize, JsonSchema)] +pub struct ValidateRequest { + pub expected_digest: ExpectedDigest, + + // Size to validate in bytes, starting from offset 0. If not specified, the + // total volume size is used. + pub size_to_validate: Option, +} + +#[derive(Serialize, JsonSchema)] +pub struct ValidateResponse { + pub job_id: String, +} diff --git a/pantry-types/Cargo.toml b/pantry-types/Cargo.toml new file mode 100644 index 000000000..1345916ce --- /dev/null +++ b/pantry-types/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "crucible-pantry-types" +version = "0.1.0" +edition = "2021" +license = "MPL-2.0" + +[dependencies] +crucible-workspace-hack.workspace = true +schemars.workspace = true +serde.workspace = true diff --git a/pantry-types/src/lib.rs b/pantry-types/src/lib.rs new file mode 100644 index 000000000..6254fb277 --- /dev/null +++ b/pantry-types/src/lib.rs @@ -0,0 +1,32 @@ +// Copyright 2025 Oxide Computer Company + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, JsonSchema)] +pub struct PantryStatus { + /// Which volumes does this Pantry know about? Note this may include volumes + /// that are no longer active, and haven't been garbage collected yet. + pub volumes: Vec, + + /// How many job handles? + pub num_job_handles: usize, +} + +#[derive(Serialize, JsonSchema)] +pub struct VolumeStatus { + /// Is the Volume currently active? + pub active: bool, + + /// Has the Pantry ever seen this Volume active? + pub seen_active: bool, + + /// How many job handles are there for this Volume? + pub num_job_handles: usize, +} + +#[derive(Debug, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ExpectedDigest { + Sha256(String), +} diff --git a/pantry/Cargo.toml b/pantry/Cargo.toml index b37203c4c..fde5aa0cc 100644 --- a/pantry/Cargo.toml +++ b/pantry/Cargo.toml @@ -10,6 +10,8 @@ bytes.workspace = true base64.workspace = true chrono.workspace = true clap.workspace = true +crucible-pantry-api.workspace = true +crucible-pantry-types.workspace = true dropshot.workspace = true futures.workspace = true http.workspace = true diff --git a/pantry/src/main.rs b/pantry/src/main.rs index 2efce87ef..ba9d4da9b 100644 --- a/pantry/src/main.rs +++ b/pantry/src/main.rs @@ -2,6 +2,7 @@ use anyhow::{anyhow, Result}; use clap::Parser; +use crucible_pantry_api::crucible_pantry_api_mod; use semver::Version; use std::io::Write; use std::net::SocketAddr; @@ -46,7 +47,8 @@ async fn main() -> Result<()> { } fn write_openapi(f: &mut W) -> Result<()> { - let api = server::make_api().map_err(|e| anyhow!(e))?; + // TODO: Switch to OpenAPI manager once available. + let api = crucible_pantry_api_mod::stub_api_description()?; api.openapi("Crucible Pantry", Version::new(0, 0, 1)) .write(f)?; Ok(()) diff --git a/pantry/src/pantry.rs b/pantry/src/pantry.rs index 0251e259d..2c1c8838e 100644 --- a/pantry/src/pantry.rs +++ b/pantry/src/pantry.rs @@ -27,10 +27,7 @@ use crucible::Volume; use crucible::VolumeConstructionRequest; use crucible_common::crucible_bail; use crucible_common::CrucibleError; - -use crate::server::ExpectedDigest; -use crate::server::PantryStatus; -use crate::server::VolumeStatus; +use crucible_pantry_types::{ExpectedDigest, PantryStatus, VolumeStatus}; pub enum ActiveObservation { /// This Pantry has never seen this Volume active diff --git a/pantry/src/server.rs b/pantry/src/server.rs index f6a9448a0..6b9ec9e81 100644 --- a/pantry/src/server.rs +++ b/pantry/src/server.rs @@ -1,5 +1,4 @@ -// Copyright 2022 Oxide Computer Company - +// Copyright 2025 Oxide Computer Company use super::pantry::Pantry; use std::net::SocketAddr; @@ -7,454 +6,239 @@ use std::sync::Arc; use anyhow::{anyhow, Result}; use base64::{engine, Engine}; -use dropshot::endpoint; -use dropshot::HandlerTaskMode; -use dropshot::HttpError; -use dropshot::HttpResponseDeleted; -use dropshot::HttpResponseOk; -use dropshot::HttpResponseUpdatedNoContent; -use dropshot::Path as TypedPath; -use dropshot::RequestContext; -use dropshot::TypedBody; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; +use crucible_pantry_api::*; +use crucible_pantry_types::*; +use dropshot::{ + HandlerTaskMode, HttpError, HttpResponseDeleted, HttpResponseOk, + HttpResponseUpdatedNoContent, Path as TypedPath, RequestContext, TypedBody, +}; use slog::{info, o, Logger}; +use std::result::Result as SResult; -use crucible::ReplaceResult; -use crucible::VolumeConstructionRequest; - -#[derive(Serialize, JsonSchema)] -pub struct PantryStatus { - /// Which volumes does this Pantry know about? Note this may include volumes - /// that are no longer active, and haven't been garbage collected yet. - pub volumes: Vec, - - /// How many job handles? - pub num_job_handles: usize, -} +#[derive(Debug)] +pub(crate) struct CruciblePantryImpl; -/// Get the Pantry's status -#[endpoint { - method = GET, - path = "/crucible/pantry/0", -}] -async fn pantry_status( - rc: RequestContext>, -) -> Result, HttpError> { - let pantry = rc.context(); - - let status = pantry.status().await?; - - Ok(HttpResponseOk(status)) -} +impl CruciblePantryApi for CruciblePantryImpl { + type Context = Arc; -#[derive(Deserialize, JsonSchema)] -struct VolumePath { - pub id: String, -} + async fn pantry_status( + rqctx: RequestContext, + ) -> SResult, HttpError> { + let pantry = rqctx.context(); -#[derive(Serialize, JsonSchema)] -pub struct VolumeStatus { - /// Is the Volume currently active? - pub active: bool, + let status = pantry.status().await?; - /// Has the Pantry ever seen this Volume active? - pub seen_active: bool, + Ok(HttpResponseOk(status)) + } - /// How many job handles are there for this Volume? - pub num_job_handles: usize, -} + async fn volume_status( + rqctx: RequestContext, + path: TypedPath, + ) -> SResult, HttpError> { + let path = path.into_inner(); + let pantry = rqctx.context(); -/// Get a current Volume's status -#[endpoint { - method = GET, - path = "/crucible/pantry/0/volume/{id}", -}] -async fn volume_status( - rc: RequestContext>, - path: TypedPath, -) -> Result, HttpError> { - let path = path.into_inner(); - let pantry = rc.context(); - - let status = pantry.volume_status(path.id.clone()).await?; - - Ok(HttpResponseOk(status)) -} + let status = pantry.volume_status(path.id.clone()).await?; -#[derive(Deserialize, JsonSchema)] -struct AttachRequest { - pub volume_construction_request: VolumeConstructionRequest, -} + Ok(HttpResponseOk(status)) + } -#[derive(Serialize, JsonSchema)] -struct AttachResult { - pub id: String, -} + async fn attach( + rqctx: RequestContext, + path: TypedPath, + body: TypedBody, + ) -> SResult, HttpError> { + let path = path.into_inner(); + let body = body.into_inner(); + let pantry = rqctx.context(); -/// Construct a volume from a VolumeConstructionRequest, storing the result in -/// the Pantry. -#[endpoint { - method = POST, - path = "/crucible/pantry/0/volume/{id}", -}] -async fn attach( - rc: RequestContext>, - path: TypedPath, - body: TypedBody, -) -> Result, HttpError> { - let path = path.into_inner(); - let body = body.into_inner(); - let pantry = rc.context(); - - pantry - .attach(path.id.clone(), body.volume_construction_request) - .await?; - - Ok(HttpResponseOk(AttachResult { id: path.id })) -} + pantry + .attach(path.id.clone(), body.volume_construction_request) + .await?; -#[derive(Deserialize, JsonSchema)] -struct AttachBackgroundRequest { - pub volume_construction_request: VolumeConstructionRequest, - pub job_id: String, -} + Ok(HttpResponseOk(AttachResult { id: path.id })) + } -/// Construct a volume from a VolumeConstructionRequest, storing the result in -/// the Pantry. Activate in a separate job so as not to block the request. -#[endpoint { - method = POST, - path = "/crucible/pantry/0/volume/{id}/background", -}] -async fn attach_activate_background( - rc: RequestContext>, - path: TypedPath, - body: TypedBody, -) -> Result { - let path = path.into_inner(); - let body = body.into_inner(); - let pantry = rc.context(); - - pantry - .attach_activate_background( - path.id.clone(), - body.job_id, - body.volume_construction_request, - ) - .await?; - - Ok(HttpResponseUpdatedNoContent()) -} + async fn attach_activate_background( + rqctx: RequestContext, + path: TypedPath, + body: TypedBody, + ) -> SResult { + let path = path.into_inner(); + let body = body.into_inner(); + let pantry = rqctx.context(); + + pantry + .attach_activate_background( + path.id.clone(), + body.job_id, + body.volume_construction_request, + ) + .await?; + + Ok(HttpResponseUpdatedNoContent()) + } -#[derive(Deserialize, JsonSchema)] -struct ReplaceRequest { - pub volume_construction_request: VolumeConstructionRequest, -} + async fn replace( + rqctx: RequestContext, + path: TypedPath, + body: TypedBody, + ) -> SResult, HttpError> { + let path = path.into_inner(); + let body = body.into_inner(); + let pantry = rqctx.context(); -/// Call a volume's target_replace function -#[endpoint { - method = POST, - path = "/crucible/pantry/0/volume/{id}/replace", -}] -async fn replace( - rc: RequestContext>, - path: TypedPath, - body: TypedBody, -) -> Result, HttpError> { - let path = path.into_inner(); - let body = body.into_inner(); - let pantry = rc.context(); - - let result = pantry - .replace(path.id.clone(), body.volume_construction_request) - .await?; - - Ok(HttpResponseOk(result)) -} + let result = pantry + .replace(path.id.clone(), body.volume_construction_request) + .await?; -#[derive(Deserialize, JsonSchema)] -struct JobPath { - pub id: String, -} + Ok(HttpResponseOk(result)) + } -#[derive(Serialize, JsonSchema)] -struct JobPollResponse { - pub job_is_finished: bool, -} + async fn is_job_finished( + rqctx: RequestContext, + path: TypedPath, + ) -> SResult, HttpError> { + let path = path.into_inner(); + let pantry = rqctx.context(); -/// Poll to see if a Pantry background job is done -#[endpoint { - method = GET, - path = "/crucible/pantry/0/job/{id}/is-finished", -}] -async fn is_job_finished( - rc: RequestContext>, - path: TypedPath, -) -> Result, HttpError> { - let path = path.into_inner(); - let pantry = rc.context(); - - let job_is_finished = pantry.is_job_finished(path.id).await?; - - Ok(HttpResponseOk(JobPollResponse { job_is_finished })) -} + let job_is_finished = pantry.is_job_finished(path.id).await?; -#[derive(Serialize, JsonSchema)] -pub struct JobResultOkResponse { - pub job_result_ok: bool, -} + Ok(HttpResponseOk(JobPollResponse { job_is_finished })) + } -/// Block on returning a Pantry background job result, then return 200 OK if the -/// job executed OK, 500 otherwise. -#[endpoint { - method = GET, - path = "/crucible/pantry/0/job/{id}/ok", -}] -async fn job_result_ok( - rc: RequestContext>, - path: TypedPath, -) -> Result, HttpError> { - let path = path.into_inner(); - let pantry = rc.context(); - - match pantry.get_job_result(path.id).await { - Ok(result) => { - // The inner result is from the tokio task itself. - Ok(HttpResponseOk(JobResultOkResponse { - job_result_ok: result.is_ok(), - })) + async fn job_result_ok( + rqctx: RequestContext, + path: TypedPath, + ) -> SResult, HttpError> { + let path = path.into_inner(); + let pantry = rqctx.context(); + + match pantry.get_job_result(path.id).await { + Ok(result) => { + // The inner result is from the tokio task itself. + Ok(HttpResponseOk(JobResultOkResponse { + job_result_ok: result.is_ok(), + })) + } + + // Here is where get_job_result will return 404 if the job id is not + // found, or a 500 if the join_handle.await didn't work. + Err(e) => Err(e), } - - // Here is where get_job_result will return 404 if the job id is not - // found, or a 500 if the join_handle.await didn't work. - Err(e) => Err(e), } -} -#[derive(Debug, Deserialize, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum ExpectedDigest { - Sha256(String), -} + async fn import_from_url( + rqctx: RequestContext, + path: TypedPath, + body: TypedBody, + ) -> SResult, HttpError> { + let path = path.into_inner(); + let body = body.into_inner(); + let pantry = rqctx.context(); -#[derive(Deserialize, JsonSchema)] -struct ImportFromUrlRequest { - pub url: String, - pub expected_digest: Option, -} + let job_id = pantry + .import_from_url(path.id.clone(), body.url, body.expected_digest) + .await?; -#[derive(Serialize, JsonSchema)] -struct ImportFromUrlResponse { - pub job_id: String, -} - -/// Import data from a URL into a volume -#[endpoint { - method = POST, - path = "/crucible/pantry/0/volume/{id}/import-from-url", -}] -async fn import_from_url( - rc: RequestContext>, - path: TypedPath, - body: TypedBody, -) -> Result, HttpError> { - let path = path.into_inner(); - let body = body.into_inner(); - let pantry = rc.context(); - - let job_id = pantry - .import_from_url(path.id.clone(), body.url, body.expected_digest) - .await?; - - Ok(HttpResponseOk(ImportFromUrlResponse { job_id })) -} + Ok(HttpResponseOk(ImportFromUrlResponse { job_id })) + } -#[derive(Deserialize, JsonSchema)] -struct SnapshotRequest { - pub snapshot_id: String, -} + async fn snapshot( + rqctx: RequestContext, + path: TypedPath, + body: TypedBody, + ) -> SResult { + let path = path.into_inner(); + let body = body.into_inner(); + let pantry = rqctx.context(); -/// Take a snapshot of a volume -#[endpoint { - method = POST, - path = "/crucible/pantry/0/volume/{id}/snapshot", -}] -async fn snapshot( - rc: RequestContext>, - path: TypedPath, - body: TypedBody, -) -> Result { - let path = path.into_inner(); - let body = body.into_inner(); - let pantry = rc.context(); - - pantry.snapshot(path.id.clone(), body.snapshot_id).await?; - - Ok(HttpResponseUpdatedNoContent()) -} + pantry.snapshot(path.id.clone(), body.snapshot_id).await?; -#[derive(Deserialize, JsonSchema)] -struct BulkWriteRequest { - pub offset: u64, + Ok(HttpResponseUpdatedNoContent()) + } - pub base64_encoded_data: String, -} + async fn bulk_write( + rqctx: RequestContext, + path: TypedPath, + body: TypedBody, + ) -> SResult { + let path = path.into_inner(); + let body = body.into_inner(); + let pantry = rqctx.context(); -/// Bulk write data into a volume at a specified offset -#[endpoint { - method = POST, - path = "/crucible/pantry/0/volume/{id}/bulk-write", -}] -async fn bulk_write( - rc: RequestContext>, - path: TypedPath, - body: TypedBody, -) -> Result { - let path = path.into_inner(); - let body = body.into_inner(); - let pantry = rc.context(); - - let data = engine::general_purpose::STANDARD - .decode(body.base64_encoded_data) - .map_err(|e| HttpError::for_bad_request(None, e.to_string()))?; - - pantry - .bulk_write(path.id.clone(), body.offset, data) - .await?; - - Ok(HttpResponseUpdatedNoContent()) -} + let data = engine::general_purpose::STANDARD + .decode(body.base64_encoded_data) + .map_err(|e| HttpError::for_bad_request(None, e.to_string()))?; -#[derive(Deserialize, JsonSchema)] -struct BulkReadRequest { - pub offset: u64, - pub size: usize, -} + pantry + .bulk_write(path.id.clone(), body.offset, data) + .await?; -#[derive(Serialize, JsonSchema)] -struct BulkReadResponse { - pub base64_encoded_data: String, -} - -/// Bulk read data from a volume at a specified offset -#[endpoint { - method = POST, - path = "/crucible/pantry/0/volume/{id}/bulk-read", -}] -async fn bulk_read( - rc: RequestContext>, - path: TypedPath, - body: TypedBody, -) -> Result, HttpError> { - let path = path.into_inner(); - let body = body.into_inner(); - let pantry = rc.context(); - - let data = pantry - .bulk_read(path.id.clone(), body.offset, body.size) - .await?; - - Ok(HttpResponseOk(BulkReadResponse { - base64_encoded_data: engine::general_purpose::STANDARD.encode(data), - })) -} + Ok(HttpResponseUpdatedNoContent()) + } -#[derive(Serialize, JsonSchema)] -struct ScrubResponse { - pub job_id: String, -} + async fn bulk_read( + rqctx: RequestContext, + path: TypedPath, + body: TypedBody, + ) -> SResult, HttpError> { + let path = path.into_inner(); + let body = body.into_inner(); + let pantry = rqctx.context(); + + let data = pantry + .bulk_read(path.id.clone(), body.offset, body.size) + .await?; + + Ok(HttpResponseOk(BulkReadResponse { + base64_encoded_data: engine::general_purpose::STANDARD.encode(data), + })) + } -/// Scrub the volume (copy blocks from read-only parent to subvolumes) -#[endpoint { - method = POST, - path = "/crucible/pantry/0/volume/{id}/scrub", -}] -async fn scrub( - rc: RequestContext>, - path: TypedPath, -) -> Result, HttpError> { - let path = path.into_inner(); - let pantry = rc.context(); - - let job_id = pantry.scrub(path.id.clone()).await?; - - Ok(HttpResponseOk(ScrubResponse { job_id })) -} + async fn scrub( + rqctx: RequestContext, + path: TypedPath, + ) -> SResult, HttpError> { + let path = path.into_inner(); + let pantry = rqctx.context(); -#[derive(Deserialize, JsonSchema)] -struct ValidateRequest { - pub expected_digest: ExpectedDigest, + let job_id = pantry.scrub(path.id.clone()).await?; - // Size to validate in bytes, starting from offset 0. If not specified, the - // total volume size is used. - pub size_to_validate: Option, -} + Ok(HttpResponseOk(ScrubResponse { job_id })) + } -#[derive(Serialize, JsonSchema)] -struct ValidateResponse { - pub job_id: String, -} + async fn validate( + rqctx: RequestContext, + path: TypedPath, + body: TypedBody, + ) -> SResult, HttpError> { + let path = path.into_inner(); + let body = body.into_inner(); + let pantry = rqctx.context(); + + let job_id = pantry + .validate( + path.id.clone(), + body.expected_digest, + body.size_to_validate, + ) + .await?; + + Ok(HttpResponseOk(ValidateResponse { job_id })) + } -/// Validate the digest of a whole volume -#[endpoint { - method = POST, - path = "/crucible/pantry/0/volume/{id}/validate", -}] -async fn validate( - rc: RequestContext>, - path: TypedPath, - body: TypedBody, -) -> Result, HttpError> { - let path = path.into_inner(); - let body = body.into_inner(); - let pantry = rc.context(); - - let job_id = pantry - .validate(path.id.clone(), body.expected_digest, body.size_to_validate) - .await?; - - Ok(HttpResponseOk(ValidateResponse { job_id })) -} + async fn detach( + rqctx: RequestContext, + path: TypedPath, + ) -> SResult { + let path = path.into_inner(); + let pantry = rqctx.context(); -/// Deactivate a volume, removing it from the Pantry -#[endpoint { - method = DELETE, - path = "/crucible/pantry/0/volume/{id}", -}] -async fn detach( - rc: RequestContext>, - path: TypedPath, -) -> Result { - let path = path.into_inner(); - let pantry = rc.context(); - - pantry.detach(path.id).await?; - - Ok(HttpResponseDeleted()) -} + pantry.detach(path.id).await?; -pub fn make_api() -> Result< - dropshot::ApiDescription>, - dropshot::ApiDescriptionRegisterError, -> { - let mut api = dropshot::ApiDescription::new(); - - api.register(pantry_status)?; - api.register(volume_status)?; - api.register(attach)?; - api.register(attach_activate_background)?; - api.register(replace)?; - api.register(is_job_finished)?; - api.register(job_result_ok)?; - api.register(import_from_url)?; - api.register(snapshot)?; - api.register(bulk_write)?; - api.register(bulk_read)?; - api.register(scrub)?; - api.register(validate)?; - api.register(detach)?; - - Ok(api) + Ok(HttpResponseDeleted()) + } } pub fn run_server( @@ -462,7 +246,7 @@ pub fn run_server( bind_address: SocketAddr, df: &Arc, ) -> Result<(SocketAddr, tokio::task::JoinHandle>)> { - let api = make_api().map_err(|e| anyhow!(e))?; + let api = crucible_pantry_api_mod::api_description::()?; let server = dropshot::HttpServerStarter::new( &dropshot::ConfigDropshot { From 1f53d7bc7f38a6ce05cf99fd54a1bd102a17e6af Mon Sep 17 00:00:00 2001 From: Rain Date: Thu, 28 Aug 2025 20:25:40 +0000 Subject: [PATCH 2/4] [spr] changes introduced through rebase Created using spr 1.3.6-beta.1 [skip ci] --- Cargo.lock | 25 + Cargo.toml | 6 +- agent-api/Cargo.toml | 12 + agent-api/src/lib.rs | 127 +++++ agent-types/Cargo.toml | 15 + agent-types/src/lib.rs | 3 + .../src/model.rs => agent-types/src/region.rs | 2 +- agent/Cargo.toml | 2 + agent/src/datafile.rs | 2 +- agent/src/main.rs | 21 +- agent/src/server.rs | 486 ++++++++---------- agent/src/snapshot_interface.rs | 2 +- workspace-hack/Cargo.toml | 8 +- 13 files changed, 409 insertions(+), 302 deletions(-) create mode 100644 agent-api/Cargo.toml create mode 100644 agent-api/src/lib.rs create mode 100644 agent-types/Cargo.toml create mode 100644 agent-types/src/lib.rs rename agent/src/model.rs => agent-types/src/region.rs (99%) diff --git a/Cargo.lock b/Cargo.lock index 72f21f71e..7dcf2921f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -966,6 +966,8 @@ dependencies = [ "anyhow", "chrono", "clap", + "crucible-agent-api", + "crucible-agent-types", "crucible-common", "crucible-smf 0.0.0", "crucible-workspace-hack", @@ -989,6 +991,17 @@ dependencies = [ "uuid", ] +[[package]] +name = "crucible-agent-api" +version = "0.1.0" +dependencies = [ + "crucible-agent-types", + "crucible-workspace-hack", + "dropshot", + "schemars", + "serde", +] + [[package]] name = "crucible-agent-client" version = "0.0.1" @@ -1004,6 +1017,18 @@ dependencies = [ "serde_json", ] +[[package]] +name = "crucible-agent-types" +version = "0.1.0" +dependencies = [ + "chrono", + "crucible-smf 0.0.0", + "crucible-workspace-hack", + "schemars", + "serde", + "serde_json", +] + [[package]] name = "crucible-client-types" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 173d54082..90ad24b67 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,9 @@ [workspace] members = [ "agent", + "agent-api", "agent-client", + "agent-types", "crucible-client-types", "common", "control-client", @@ -129,8 +131,10 @@ oximeter-producer = { git = "https://github.com/oxidecomputer/omicron", branch = # local path crucible = { path = "./upstairs" } -crucible-client-types = { path = "./crucible-client-types" } +crucible-agent-api = { path = "./agent-api" } crucible-agent-client = { path = "./agent-client" } +crucible-agent-types = { path = "./agent-types" } +crucible-client-types = { path = "./crucible-client-types" } crucible-common = { path = "./common" } crucible-control-client = { path = "./control-client" } # importantly, don't use features = ["zfs_snapshot"] here, this will cause diff --git a/agent-api/Cargo.toml b/agent-api/Cargo.toml new file mode 100644 index 000000000..4997e45e6 --- /dev/null +++ b/agent-api/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "crucible-agent-api" +version = "0.1.0" +license = "MPL-2.0" +edition = "2024" + +[dependencies] +crucible-agent-types.workspace = true +crucible-workspace-hack.workspace = true +dropshot.workspace = true +schemars.workspace = true +serde.workspace = true diff --git a/agent-api/src/lib.rs b/agent-api/src/lib.rs new file mode 100644 index 000000000..271bb0547 --- /dev/null +++ b/agent-api/src/lib.rs @@ -0,0 +1,127 @@ +// Copyright 2025 Oxide Computer Company + +use std::collections::BTreeMap; + +use crucible_agent_types::region::{ + CreateRegion, Region, RegionId, RunningSnapshot, Snapshot, +}; +use dropshot::{ + HttpError, HttpResponseDeleted, HttpResponseOk, Path, RequestContext, + TypedBody, +}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[dropshot::api_description] +pub trait CrucibleAgentApi { + type Context; + + #[endpoint { + method = GET, + path = "/crucible/0/regions", + }] + async fn region_list( + rqctx: RequestContext, + ) -> Result>, HttpError>; + + #[endpoint { + method = POST, + path = "/crucible/0/regions", + }] + async fn region_create( + rqctx: RequestContext, + body: TypedBody, + ) -> Result, HttpError>; + + #[endpoint { + method = GET, + path = "/crucible/0/regions/{id}", + }] + async fn region_get( + rqctx: RequestContext, + path: Path, + ) -> Result, HttpError>; + + #[endpoint { + method = DELETE, + path = "/crucible/0/regions/{id}", + }] + async fn region_delete( + rqctx: RequestContext, + path: Path, + ) -> Result; + + #[endpoint { + method = GET, + path = "/crucible/0/regions/{id}/snapshots", + }] + async fn region_get_snapshots( + rqctx: RequestContext, + path: Path, + ) -> Result, HttpError>; + + #[endpoint { + method = GET, + path = "/crucible/0/regions/{id}/snapshots/{name}", + }] + async fn region_get_snapshot( + rqctx: RequestContext, + path: Path, + ) -> Result, HttpError>; + + #[endpoint { + method = DELETE, + path = "/crucible/0/regions/{id}/snapshots/{name}", + }] + async fn region_delete_snapshot( + rqctx: RequestContext, + path: Path, + ) -> Result; + + #[endpoint { + method = POST, + path = "/crucible/0/regions/{id}/snapshots/{name}/run", + }] + async fn region_run_snapshot( + rqctx: RequestContext, + path: Path, + ) -> Result, HttpError>; + + #[endpoint { + method = DELETE, + path = "/crucible/0/regions/{id}/snapshots/{name}/run", + }] + async fn region_delete_running_snapshot( + rc: RequestContext, + path: Path, + ) -> Result; +} + +#[derive(Deserialize, JsonSchema)] +pub struct RegionPath { + pub id: RegionId, +} + +#[derive(Serialize, JsonSchema)] +pub struct GetSnapshotResponse { + pub snapshots: Vec, + pub running_snapshots: BTreeMap, +} + +#[derive(Deserialize, JsonSchema)] +pub struct GetSnapshotPath { + pub id: RegionId, + pub name: String, +} + +#[derive(Deserialize, JsonSchema)] +pub struct DeleteSnapshotPath { + pub id: RegionId, + pub name: String, +} + +#[derive(Deserialize, JsonSchema)] +pub struct RunSnapshotPath { + pub id: RegionId, + pub name: String, +} diff --git a/agent-types/Cargo.toml b/agent-types/Cargo.toml new file mode 100644 index 000000000..98be73745 --- /dev/null +++ b/agent-types/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "crucible-agent-types" +version = "0.1.0" +license = "MPL-2.0" +edition = "2024" + +[dependencies] +chrono.workspace = true +crucible-smf.workspace = true +crucible-workspace-hack.workspace = true +serde.workspace = true +schemars.workspace = true + +[dev-dependencies] +serde_json.workspace = true diff --git a/agent-types/src/lib.rs b/agent-types/src/lib.rs new file mode 100644 index 000000000..f488ca656 --- /dev/null +++ b/agent-types/src/lib.rs @@ -0,0 +1,3 @@ +// Copyright 2025 Oxide Computer Company + +pub mod region; diff --git a/agent/src/model.rs b/agent-types/src/region.rs similarity index 99% rename from agent/src/model.rs rename to agent-types/src/region.rs index 614881efe..c65425b25 100644 --- a/agent/src/model.rs +++ b/agent-types/src/region.rs @@ -1,4 +1,4 @@ -// Copyright 2021 Oxide Computer Company +// Copyright 2025 Oxide Computer Company use std::net::SocketAddr; use std::path::Path; diff --git a/agent/Cargo.toml b/agent/Cargo.toml index eb0127acf..bc4f19433 100644 --- a/agent/Cargo.toml +++ b/agent/Cargo.toml @@ -8,6 +8,8 @@ edition = "2021" anyhow.workspace = true chrono.workspace = true clap.workspace = true +crucible-agent-api.workspace = true +crucible-agent-types.workspace = true crucible-common.workspace = true crucible-smf.workspace = true dropshot.workspace = true diff --git a/agent/src/datafile.rs b/agent/src/datafile.rs index 42b54abae..436605330 100644 --- a/agent/src/datafile.rs +++ b/agent/src/datafile.rs @@ -1,7 +1,7 @@ // Copyright 2021 Oxide Computer Company -use super::model::*; use anyhow::{anyhow, bail, Result}; +use crucible_agent_types::region::*; use crucible_common::write_json; use serde::{Deserialize, Serialize}; use slog::{crit, error, info, Logger}; diff --git a/agent/src/main.rs b/agent/src/main.rs index dd69fe237..030fc198b 100644 --- a/agent/src/main.rs +++ b/agent/src/main.rs @@ -40,13 +40,12 @@ const RESERVATION_FACTOR: f64 = 1.25; const QUOTA_FACTOR: u64 = 3; mod datafile; -mod model; mod server; mod smf_interface; mod snapshot_interface; -use model::Resource; -use model::State; +use crucible_agent_types::region::State; +use crucible_agent_types::region::{self, Resource}; use smf_interface::*; #[derive(Debug, Parser)] @@ -343,7 +342,9 @@ async fn main() -> Result<()> { } fn write_openapi(f: &mut W) -> Result<()> { - let api = server::make_api()?; + // TODO: Switch to OpenAPI manager once available + let api = + crucible_agent_api::crucible_agent_api_mod::stub_api_description()?; api.openapi("Crucible Agent", Version::new(0, 0, 1)) .write(f)?; Ok(()) @@ -511,7 +512,7 @@ where // crucible zone that both processes must share and that address is // what will be used by other zones. In the future this could be a // parameter that comes along with the region POST parameters. - properties.push(crate::model::SmfProperty { + properties.push(region::SmfProperty { name: "address", typ: crucible_smf::scf_type_t::SCF_TYPE_ASTRING, val: df.get_listen_addr().ip().to_string(), @@ -520,7 +521,7 @@ where // If the region has a source, then it was created as a clone and // must be started read only. if r.source.is_some() { - properties.push(crate::model::SmfProperty { + properties.push(region::SmfProperty { name: "mode", typ: crucible_smf::scf_type_t::SCF_TYPE_ASTRING, val: "ro".to_string(), @@ -686,7 +687,7 @@ where // is what will be used by other zones. In the future this could // be a parameter that comes along with the region POST // parameters. - properties.push(crate::model::SmfProperty { + properties.push(region::SmfProperty { name: "address", typ: crucible_smf::scf_type_t::SCF_TYPE_ASTRING, val: df.get_listen_addr().ip().to_string(), @@ -823,10 +824,10 @@ where mod test { use super::*; - use crate::model::*; use crate::snapshot_interface::SnapshotInterface; use crate::snapshot_interface::TestSnapshotInterface; + use crucible_agent_types::region::*; use slog::{o, Drain, Logger}; use std::collections::BTreeMap; use tempfile::*; @@ -1867,7 +1868,7 @@ fn worker( fn worker_region_create( log: &Logger, prog: &Path, - region: &model::Region, + region: ®ion::Region, dir: &Path, ) -> Result<()> { let log = log.new(o!("region" => region.id.0.to_string())); @@ -1961,7 +1962,7 @@ fn worker_region_create( fn worker_region_destroy( log: &Logger, - region: &model::Region, + region: ®ion::Region, region_dataset: ZFSDataset, ) -> Result<()> { let log = log.new(o!("region" => region.id.0.to_string())); diff --git a/agent/src/server.rs b/agent/src/server.rs index 5f339d776..8d8c2da8a 100644 --- a/agent/src/server.rs +++ b/agent/src/server.rs @@ -1,345 +1,267 @@ // Copyright 2024 Oxide Computer Company use super::datafile::DataFile; -use super::model; use anyhow::{anyhow, Result}; +use crucible_agent_api::*; +use crucible_agent_types::region; use dropshot::{ - endpoint, HandlerTaskMode, HttpError, HttpResponseDeleted, HttpResponseOk, + HandlerTaskMode, HttpError, HttpResponseDeleted, HttpResponseOk, Path as TypedPath, RequestContext, TypedBody, }; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; use slog::{o, Logger}; -use std::collections::BTreeMap; use std::net::SocketAddr; use std::result::Result as SResult; use std::sync::Arc; -#[endpoint { - method = GET, - path = "/crucible/0/regions", -}] -async fn region_list( - rc: RequestContext>, -) -> SResult>, HttpError> { - Ok(HttpResponseOk(rc.context().regions())) -} +#[derive(Debug)] +pub(crate) struct CrucibleAgentImpl; -#[endpoint { - method = POST, - path = "/crucible/0/regions", -}] -async fn region_create( - rc: RequestContext>, - body: TypedBody, -) -> SResult, HttpError> { - let create = body.into_inner(); - - match rc.context().create_region_request(create) { - Ok(r) => Ok(HttpResponseOk(r)), - Err(e) => Err(HttpError::for_internal_error(format!( - "region create failure: {:?}", - e - ))), - } -} - -#[derive(Deserialize, JsonSchema)] -struct RegionPath { - id: model::RegionId, -} +impl CrucibleAgentApi for CrucibleAgentImpl { + type Context = Arc; -#[endpoint { - method = GET, - path = "/crucible/0/regions/{id}", -}] -async fn region_get( - rc: RequestContext>, - path: TypedPath, -) -> SResult, HttpError> { - let p = path.into_inner(); - - match rc.context().get(&p.id) { - Some(r) => Ok(HttpResponseOk(r)), - None => Err(HttpError::for_not_found( - None, - format!("region {:?} not found", p.id), - )), + async fn region_list( + rqctx: RequestContext, + ) -> SResult>, HttpError> { + Ok(HttpResponseOk(rqctx.context().regions())) } -} -#[endpoint { - method = DELETE, - path = "/crucible/0/regions/{id}", -}] -async fn region_delete( - rc: RequestContext>, - path: TypedPath, -) -> SResult { - let p = path.into_inner(); - - // Cannot delete a region that's backed by a ZFS dataset if there are - // snapshots. - - let snapshots = match rc.context().get_snapshots_for_region(&p.id) { - Ok(results) => results, - Err(e) => { - return Err(HttpError::for_internal_error(e.to_string())); + async fn region_create( + rqctx: RequestContext, + body: TypedBody, + ) -> SResult, HttpError> { + let create = body.into_inner(); + + match rqctx.context().create_region_request(create) { + Ok(r) => Ok(HttpResponseOk(r)), + Err(e) => Err(HttpError::for_internal_error(format!( + "region create failure: {:?}", + e + ))), } - }; - - if !snapshots.is_empty() { - return Err(HttpError::for_bad_request( - None, - "must delete snapshots first!".to_string(), - )); - } - - match rc.context().destroy(&p.id) { - Ok(_) => Ok(HttpResponseDeleted()), - Err(e) => Err(HttpError::for_bad_request(None, e.to_string())), } -} -#[derive(Serialize, JsonSchema)] -pub struct GetSnapshotResponse { - snapshots: Vec, - running_snapshots: BTreeMap, -} + async fn region_get( + rc: RequestContext, + path: TypedPath, + ) -> SResult, HttpError> { + let p = path.into_inner(); -#[endpoint { - method = GET, - path = "/crucible/0/regions/{id}/snapshots", -}] -async fn region_get_snapshots( - rc: RequestContext>, - path: TypedPath, -) -> Result, HttpError> { - let p = path.into_inner(); - - match rc.context().get(&p.id) { - Some(_) => (), - None => { - return Err(HttpError::for_not_found( + match rc.context().get(&p.id) { + Some(r) => Ok(HttpResponseOk(r)), + None => Err(HttpError::for_not_found( None, format!("region {:?} not found", p.id), - )); + )), } } - let snapshots = match rc.context().get_snapshots_for_region(&p.id) { - Ok(results) => results, - Err(e) => { - return Err(HttpError::for_internal_error(e.to_string())); - } - }; - - let running_snapshots = rc - .context() - .running_snapshots() - .get(&p.id) - .cloned() - .unwrap_or_default(); - - Ok(HttpResponseOk(GetSnapshotResponse { - snapshots, - running_snapshots, - })) -} + async fn region_delete( + rc: RequestContext, + path: TypedPath, + ) -> SResult { + let p = path.into_inner(); -#[derive(Deserialize, JsonSchema)] -struct GetSnapshotPath { - id: model::RegionId, - name: String, -} + // Cannot delete a region that's backed by a ZFS dataset if there are + // snapshots. -#[endpoint { - method = GET, - path = "/crucible/0/regions/{id}/snapshots/{name}", -}] -async fn region_get_snapshot( - rc: RequestContext>, - path: TypedPath, -) -> Result, HttpError> { - let p = path.into_inner(); - - match rc.context().get(&p.id) { - Some(_) => (), - None => { - return Err(HttpError::for_not_found( + let snapshots = match rc.context().get_snapshots_for_region(&p.id) { + Ok(results) => results, + Err(e) => { + return Err(HttpError::for_internal_error(e.to_string())); + } + }; + + if !snapshots.is_empty() { + return Err(HttpError::for_bad_request( None, - format!("region {:?} not found", p.id), + "must delete snapshots first!".to_string(), )); } + + match rc.context().destroy(&p.id) { + Ok(_) => Ok(HttpResponseDeleted()), + Err(e) => Err(HttpError::for_bad_request(None, e.to_string())), + } } - let snapshots_for_region = - match rc.context().get_snapshots_for_region(&p.id) { + async fn region_get_snapshots( + rc: RequestContext>, + path: TypedPath, + ) -> Result, HttpError> { + let p = path.into_inner(); + + match rc.context().get(&p.id) { + Some(_) => (), + None => { + return Err(HttpError::for_not_found( + None, + format!("region {:?} not found", p.id), + )); + } + } + + let snapshots = match rc.context().get_snapshots_for_region(&p.id) { Ok(results) => results, Err(e) => { return Err(HttpError::for_internal_error(e.to_string())); } }; - for snapshot in &snapshots_for_region { - if snapshot.name == p.name { - return Ok(HttpResponseOk(snapshot.clone())); - } + let running_snapshots = rc + .context() + .running_snapshots() + .get(&p.id) + .cloned() + .unwrap_or_default(); + + Ok(HttpResponseOk(GetSnapshotResponse { + snapshots, + running_snapshots, + })) } - Err(HttpError::for_not_found( - None, - format!("region {:?} snapshot {:?} not found", p.id, p.name), - )) -} - -#[derive(Deserialize, JsonSchema)] -struct DeleteSnapshotPath { - id: model::RegionId, - name: String, -} - -#[endpoint { - method = DELETE, - path = "/crucible/0/regions/{id}/snapshots/{name}", -}] -async fn region_delete_snapshot( - rc: RequestContext>, - path: TypedPath, -) -> Result { - let p = path.into_inner(); - - match rc.context().get(&p.id) { - Some(_) => (), - None => { - return Err(HttpError::for_not_found( - None, - format!("region {:?} not found", p.id), - )); + async fn region_get_snapshot( + rc: RequestContext, + path: TypedPath, + ) -> Result, HttpError> { + let p = path.into_inner(); + + match rc.context().get(&p.id) { + Some(_) => (), + None => { + return Err(HttpError::for_not_found( + None, + format!("region {:?} not found", p.id), + )); + } } - } - let request = model::DeleteSnapshotRequest { - id: p.id.clone(), - name: p.name, - }; + let snapshots_for_region = + match rc.context().get_snapshots_for_region(&p.id) { + Ok(results) => results, + Err(e) => { + return Err(HttpError::for_internal_error(e.to_string())); + } + }; + + for snapshot in &snapshots_for_region { + if snapshot.name == p.name { + return Ok(HttpResponseOk(snapshot.clone())); + } + } - match rc.context().delete_snapshot(request) { - Ok(_) => Ok(HttpResponseDeleted()), - Err(e) => Err(HttpError::for_internal_error(e.to_string())), + Err(HttpError::for_not_found( + None, + format!("region {:?} snapshot {:?} not found", p.id, p.name), + )) } -} -#[derive(Deserialize, JsonSchema)] -struct RunSnapshotPath { - id: model::RegionId, - name: String, -} + async fn region_delete_snapshot( + rc: RequestContext, + path: TypedPath, + ) -> Result { + let p = path.into_inner(); + + match rc.context().get(&p.id) { + Some(_) => (), + None => { + return Err(HttpError::for_not_found( + None, + format!("region {:?} not found", p.id), + )); + } + } -#[endpoint { - method = POST, - path = "/crucible/0/regions/{id}/snapshots/{name}/run", -}] -async fn region_run_snapshot( - rc: RequestContext>, - path: TypedPath, -) -> Result, HttpError> { - let p = path.into_inner(); - - match rc.context().get(&p.id) { - Some(_) => (), - None => { - return Err(HttpError::for_not_found( - None, - format!("region {:?} not found", p.id), - )); + let request = region::DeleteSnapshotRequest { + id: p.id.clone(), + name: p.name, + }; + + match rc.context().delete_snapshot(request) { + Ok(_) => Ok(HttpResponseDeleted()), + Err(e) => Err(HttpError::for_internal_error(e.to_string())), } } - let snapshots = match rc.context().get_snapshots_for_region(&p.id) { - Ok(results) => results, - Err(e) => { - return Err(HttpError::for_internal_error(e.to_string())); + async fn region_run_snapshot( + rc: RequestContext>, + path: TypedPath, + ) -> Result, HttpError> { + let p = path.into_inner(); + + match rc.context().get(&p.id) { + Some(_) => (), + None => { + return Err(HttpError::for_not_found( + None, + format!("region {:?} not found", p.id), + )); + } } - }; - - let snapshot_names: Vec = - snapshots.iter().map(|s| s.name.clone()).collect(); - if !snapshot_names.contains(&p.name) { - return Err(HttpError::for_not_found( - None, - format!("snapshot {:?} not found", p.name), - )); - } + let snapshots = match rc.context().get_snapshots_for_region(&p.id) { + Ok(results) => results, + Err(e) => { + return Err(HttpError::for_internal_error(e.to_string())); + } + }; - // TODO support running snapshots with their own X509 creds - let create = model::CreateRunningSnapshotRequest { - id: p.id, - name: p.name, - cert_pem: None, - key_pem: None, - root_pem: None, - }; - - match rc.context().create_running_snapshot_request(create) { - Ok(r) => Ok(HttpResponseOk(r)), - Err(e) => Err(HttpError::for_internal_error(format!( - "running snapshot create failure: {:?}", - e - ))), - } -} + let snapshot_names: Vec = + snapshots.iter().map(|s| s.name.clone()).collect(); -#[endpoint { - method = DELETE, - path = "/crucible/0/regions/{id}/snapshots/{name}/run", -}] -async fn region_delete_running_snapshot( - rc: RequestContext>, - path: TypedPath, -) -> Result { - let p = path.into_inner(); - - match rc.context().get(&p.id) { - Some(_) => (), - None => { + if !snapshot_names.contains(&p.name) { return Err(HttpError::for_not_found( None, - format!("region {:?} not found", p.id), + format!("snapshot {:?} not found", p.name), )); } - } - let request = model::DeleteRunningSnapshotRequest { - id: p.id, - name: p.name, - }; - - match rc.context().delete_running_snapshot_request(request) { - Ok(_) => Ok(HttpResponseDeleted()), - Err(e) => Err(HttpError::for_internal_error(format!( - "running snapshot create failure: {:?}", - e - ))), - } -} - -pub fn make_api() -> Result>> { - let mut api = dropshot::ApiDescription::new(); + // TODO support running snapshots with their own X509 creds + let create = region::CreateRunningSnapshotRequest { + id: p.id, + name: p.name, + cert_pem: None, + key_pem: None, + root_pem: None, + }; - api.register(region_list)?; - api.register(region_create)?; - api.register(region_get)?; - api.register(region_delete)?; + match rc.context().create_running_snapshot_request(create) { + Ok(r) => Ok(HttpResponseOk(r)), + Err(e) => Err(HttpError::for_internal_error(format!( + "running snapshot create failure: {:?}", + e + ))), + } + } - api.register(region_get_snapshots)?; - api.register(region_get_snapshot)?; - api.register(region_delete_snapshot)?; + async fn region_delete_running_snapshot( + rc: RequestContext, + path: TypedPath, + ) -> Result { + let p = path.into_inner(); + + match rc.context().get(&p.id) { + Some(_) => (), + None => { + return Err(HttpError::for_not_found( + None, + format!("region {:?} not found", p.id), + )); + } + } - api.register(region_run_snapshot)?; - api.register(region_delete_running_snapshot)?; + let request = region::DeleteRunningSnapshotRequest { + id: p.id, + name: p.name, + }; - Ok(api) + match rc.context().delete_running_snapshot_request(request) { + Ok(_) => Ok(HttpResponseDeleted()), + Err(e) => Err(HttpError::for_internal_error(format!( + "running snapshot create failure: {:?}", + e + ))), + } + } } pub async fn run_server( @@ -347,7 +269,7 @@ pub async fn run_server( bind_address: SocketAddr, df: Arc, ) -> Result<()> { - let api = make_api()?; + let api = crucible_agent_api_mod::api_description::()?; let server = dropshot::HttpServerStarter::new( &dropshot::ConfigDropshot { diff --git a/agent/src/snapshot_interface.rs b/agent/src/snapshot_interface.rs index a17b6f8c3..b343c5a90 100644 --- a/agent/src/snapshot_interface.rs +++ b/agent/src/snapshot_interface.rs @@ -1,7 +1,7 @@ // Copyright 2023 Oxide Computer Company -use super::model::*; use anyhow::{bail, Result}; +use crucible_agent_types::region::Snapshot; use slog::{error, info, Logger}; #[cfg(test)] use std::collections::HashSet; diff --git a/workspace-hack/Cargo.toml b/workspace-hack/Cargo.toml index ba73ea1fa..43aa711de 100644 --- a/workspace-hack/Cargo.toml +++ b/workspace-hack/Cargo.toml @@ -30,6 +30,7 @@ futures-core = { version = "0.3" } futures-executor = { version = "0.3" } futures-sink = { version = "0.3" } futures-util = { version = "0.3", features = ["channel", "io", "sink"] } +getrandom = { version = "0.2", default-features = false, features = ["std"] } hashbrown = { version = "0.15" } hex = { version = "0.4", features = ["serde"] } indexmap = { version = "2", features = ["serde"] } @@ -81,6 +82,7 @@ futures-core = { version = "0.3" } futures-executor = { version = "0.3" } futures-sink = { version = "0.3" } futures-util = { version = "0.3", features = ["channel", "io", "sink"] } +getrandom = { version = "0.2", default-features = false, features = ["std"] } hashbrown = { version = "0.15" } hex = { version = "0.4", features = ["serde"] } indexmap = { version = "2", features = ["serde"] } @@ -120,7 +122,6 @@ uuid = { version = "1", features = ["serde", "v4"] } [target.x86_64-unknown-linux-gnu.dependencies] bitflags = { version = "2", default-features = false, features = ["std"] } dof = { version = "0.3", default-features = false, features = ["des"] } -getrandom = { version = "0.2", default-features = false, features = ["std"] } hyper = { version = "1", features = ["full"] } hyper-rustls = { version = "0.27", default-features = false, features = ["http1", "http2", "ring", "tls12", "webpki-tokio"] } hyper-util = { version = "0.1", features = ["full"] } @@ -134,7 +135,6 @@ tokio-util = { version = "0.7", features = ["codec", "io"] } [target.x86_64-unknown-linux-gnu.build-dependencies] bitflags = { version = "2", default-features = false, features = ["std"] } dof = { version = "0.3", default-features = false, features = ["des"] } -getrandom = { version = "0.2", default-features = false, features = ["std"] } hyper = { version = "1", features = ["full"] } hyper-rustls = { version = "0.27", default-features = false, features = ["http1", "http2", "ring", "tls12", "webpki-tokio"] } hyper-util = { version = "0.1", features = ["full"] } @@ -146,7 +146,6 @@ tokio-util = { version = "0.7", features = ["codec", "io"] } [target.aarch64-apple-darwin.dependencies] bitflags = { version = "2", default-features = false, features = ["std"] } -getrandom = { version = "0.2", default-features = false, features = ["std"] } hyper = { version = "1", features = ["full"] } hyper-rustls = { version = "0.27", default-features = false, features = ["http1", "http2", "ring", "tls12", "webpki-tokio"] } hyper-util = { version = "0.1", features = ["full"] } @@ -158,7 +157,6 @@ tokio-util = { version = "0.7", features = ["codec", "io"] } [target.aarch64-apple-darwin.build-dependencies] bitflags = { version = "2", default-features = false, features = ["std"] } -getrandom = { version = "0.2", default-features = false, features = ["std"] } hyper = { version = "1", features = ["full"] } hyper-rustls = { version = "0.27", default-features = false, features = ["http1", "http2", "ring", "tls12", "webpki-tokio"] } hyper-util = { version = "0.1", features = ["full"] } @@ -170,7 +168,6 @@ tokio-util = { version = "0.7", features = ["codec", "io"] } [target.x86_64-unknown-illumos.dependencies] bitflags = { version = "2", default-features = false, features = ["std"] } dof = { version = "0.3", default-features = false, features = ["des"] } -getrandom = { version = "0.2", default-features = false, features = ["std"] } hyper = { version = "1", features = ["full"] } hyper-rustls = { version = "0.27", default-features = false, features = ["http1", "http2", "ring", "tls12", "webpki-tokio"] } hyper-util = { version = "0.1", features = ["full"] } @@ -184,7 +181,6 @@ tokio-util = { version = "0.7", features = ["codec", "io"] } [target.x86_64-unknown-illumos.build-dependencies] bitflags = { version = "2", default-features = false, features = ["std"] } dof = { version = "0.3", default-features = false, features = ["des"] } -getrandom = { version = "0.2", default-features = false, features = ["std"] } hyper = { version = "1", features = ["full"] } hyper-rustls = { version = "0.27", default-features = false, features = ["http1", "http2", "ring", "tls12", "webpki-tokio"] } hyper-util = { version = "0.1", features = ["full"] } From e94f57873ecff08836faa64930ec5156bbb2481d Mon Sep 17 00:00:00 2001 From: Rain Date: Thu, 28 Aug 2025 23:20:51 +0000 Subject: [PATCH 3/4] rebase on main Created using spr 1.3.6-beta.1 --- Cargo.lock | 25 - Cargo.toml | 6 +- agent-api/Cargo.toml | 12 - agent-api/src/lib.rs | 127 ----- agent-types/Cargo.toml | 15 - agent-types/src/lib.rs | 3 - agent/Cargo.toml | 2 - agent/src/datafile.rs | 2 +- agent/src/main.rs | 21 +- .../src/region.rs => agent/src/model.rs | 2 +- agent/src/server.rs | 486 ++++++++++-------- agent/src/snapshot_interface.rs | 2 +- workspace-hack/Cargo.toml | 8 +- 13 files changed, 302 insertions(+), 409 deletions(-) delete mode 100644 agent-api/Cargo.toml delete mode 100644 agent-api/src/lib.rs delete mode 100644 agent-types/Cargo.toml delete mode 100644 agent-types/src/lib.rs rename agent-types/src/region.rs => agent/src/model.rs (99%) diff --git a/Cargo.lock b/Cargo.lock index 4290ec9c3..6c5ab2685 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -966,8 +966,6 @@ dependencies = [ "anyhow", "chrono", "clap", - "crucible-agent-api", - "crucible-agent-types", "crucible-common", "crucible-smf 0.0.0", "crucible-workspace-hack", @@ -991,17 +989,6 @@ dependencies = [ "uuid", ] -[[package]] -name = "crucible-agent-api" -version = "0.1.0" -dependencies = [ - "crucible-agent-types", - "crucible-workspace-hack", - "dropshot", - "schemars", - "serde", -] - [[package]] name = "crucible-agent-client" version = "0.0.1" @@ -1017,18 +1004,6 @@ dependencies = [ "serde_json", ] -[[package]] -name = "crucible-agent-types" -version = "0.1.0" -dependencies = [ - "chrono", - "crucible-smf 0.0.0", - "crucible-workspace-hack", - "schemars", - "serde", - "serde_json", -] - [[package]] name = "crucible-client-types" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index e67d3e6f4..9c94aa7d8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,9 +1,7 @@ [workspace] members = [ "agent", - "agent-api", "agent-client", - "agent-types", "crucible-client-types", "common", "control-client", @@ -133,10 +131,8 @@ oximeter-producer = { git = "https://github.com/oxidecomputer/omicron", branch = # local path crucible = { path = "./upstairs" } -crucible-agent-api = { path = "./agent-api" } -crucible-agent-client = { path = "./agent-client" } -crucible-agent-types = { path = "./agent-types" } crucible-client-types = { path = "./crucible-client-types" } +crucible-agent-client = { path = "./agent-client" } crucible-common = { path = "./common" } crucible-control-client = { path = "./control-client" } # importantly, don't use features = ["zfs_snapshot"] here, this will cause diff --git a/agent-api/Cargo.toml b/agent-api/Cargo.toml deleted file mode 100644 index 4997e45e6..000000000 --- a/agent-api/Cargo.toml +++ /dev/null @@ -1,12 +0,0 @@ -[package] -name = "crucible-agent-api" -version = "0.1.0" -license = "MPL-2.0" -edition = "2024" - -[dependencies] -crucible-agent-types.workspace = true -crucible-workspace-hack.workspace = true -dropshot.workspace = true -schemars.workspace = true -serde.workspace = true diff --git a/agent-api/src/lib.rs b/agent-api/src/lib.rs deleted file mode 100644 index 271bb0547..000000000 --- a/agent-api/src/lib.rs +++ /dev/null @@ -1,127 +0,0 @@ -// Copyright 2025 Oxide Computer Company - -use std::collections::BTreeMap; - -use crucible_agent_types::region::{ - CreateRegion, Region, RegionId, RunningSnapshot, Snapshot, -}; -use dropshot::{ - HttpError, HttpResponseDeleted, HttpResponseOk, Path, RequestContext, - TypedBody, -}; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; - -#[dropshot::api_description] -pub trait CrucibleAgentApi { - type Context; - - #[endpoint { - method = GET, - path = "/crucible/0/regions", - }] - async fn region_list( - rqctx: RequestContext, - ) -> Result>, HttpError>; - - #[endpoint { - method = POST, - path = "/crucible/0/regions", - }] - async fn region_create( - rqctx: RequestContext, - body: TypedBody, - ) -> Result, HttpError>; - - #[endpoint { - method = GET, - path = "/crucible/0/regions/{id}", - }] - async fn region_get( - rqctx: RequestContext, - path: Path, - ) -> Result, HttpError>; - - #[endpoint { - method = DELETE, - path = "/crucible/0/regions/{id}", - }] - async fn region_delete( - rqctx: RequestContext, - path: Path, - ) -> Result; - - #[endpoint { - method = GET, - path = "/crucible/0/regions/{id}/snapshots", - }] - async fn region_get_snapshots( - rqctx: RequestContext, - path: Path, - ) -> Result, HttpError>; - - #[endpoint { - method = GET, - path = "/crucible/0/regions/{id}/snapshots/{name}", - }] - async fn region_get_snapshot( - rqctx: RequestContext, - path: Path, - ) -> Result, HttpError>; - - #[endpoint { - method = DELETE, - path = "/crucible/0/regions/{id}/snapshots/{name}", - }] - async fn region_delete_snapshot( - rqctx: RequestContext, - path: Path, - ) -> Result; - - #[endpoint { - method = POST, - path = "/crucible/0/regions/{id}/snapshots/{name}/run", - }] - async fn region_run_snapshot( - rqctx: RequestContext, - path: Path, - ) -> Result, HttpError>; - - #[endpoint { - method = DELETE, - path = "/crucible/0/regions/{id}/snapshots/{name}/run", - }] - async fn region_delete_running_snapshot( - rc: RequestContext, - path: Path, - ) -> Result; -} - -#[derive(Deserialize, JsonSchema)] -pub struct RegionPath { - pub id: RegionId, -} - -#[derive(Serialize, JsonSchema)] -pub struct GetSnapshotResponse { - pub snapshots: Vec, - pub running_snapshots: BTreeMap, -} - -#[derive(Deserialize, JsonSchema)] -pub struct GetSnapshotPath { - pub id: RegionId, - pub name: String, -} - -#[derive(Deserialize, JsonSchema)] -pub struct DeleteSnapshotPath { - pub id: RegionId, - pub name: String, -} - -#[derive(Deserialize, JsonSchema)] -pub struct RunSnapshotPath { - pub id: RegionId, - pub name: String, -} diff --git a/agent-types/Cargo.toml b/agent-types/Cargo.toml deleted file mode 100644 index 98be73745..000000000 --- a/agent-types/Cargo.toml +++ /dev/null @@ -1,15 +0,0 @@ -[package] -name = "crucible-agent-types" -version = "0.1.0" -license = "MPL-2.0" -edition = "2024" - -[dependencies] -chrono.workspace = true -crucible-smf.workspace = true -crucible-workspace-hack.workspace = true -serde.workspace = true -schemars.workspace = true - -[dev-dependencies] -serde_json.workspace = true diff --git a/agent-types/src/lib.rs b/agent-types/src/lib.rs deleted file mode 100644 index f488ca656..000000000 --- a/agent-types/src/lib.rs +++ /dev/null @@ -1,3 +0,0 @@ -// Copyright 2025 Oxide Computer Company - -pub mod region; diff --git a/agent/Cargo.toml b/agent/Cargo.toml index bc4f19433..eb0127acf 100644 --- a/agent/Cargo.toml +++ b/agent/Cargo.toml @@ -8,8 +8,6 @@ edition = "2021" anyhow.workspace = true chrono.workspace = true clap.workspace = true -crucible-agent-api.workspace = true -crucible-agent-types.workspace = true crucible-common.workspace = true crucible-smf.workspace = true dropshot.workspace = true diff --git a/agent/src/datafile.rs b/agent/src/datafile.rs index 436605330..42b54abae 100644 --- a/agent/src/datafile.rs +++ b/agent/src/datafile.rs @@ -1,7 +1,7 @@ // Copyright 2021 Oxide Computer Company +use super::model::*; use anyhow::{anyhow, bail, Result}; -use crucible_agent_types::region::*; use crucible_common::write_json; use serde::{Deserialize, Serialize}; use slog::{crit, error, info, Logger}; diff --git a/agent/src/main.rs b/agent/src/main.rs index 030fc198b..dd69fe237 100644 --- a/agent/src/main.rs +++ b/agent/src/main.rs @@ -40,12 +40,13 @@ const RESERVATION_FACTOR: f64 = 1.25; const QUOTA_FACTOR: u64 = 3; mod datafile; +mod model; mod server; mod smf_interface; mod snapshot_interface; -use crucible_agent_types::region::State; -use crucible_agent_types::region::{self, Resource}; +use model::Resource; +use model::State; use smf_interface::*; #[derive(Debug, Parser)] @@ -342,9 +343,7 @@ async fn main() -> Result<()> { } fn write_openapi(f: &mut W) -> Result<()> { - // TODO: Switch to OpenAPI manager once available - let api = - crucible_agent_api::crucible_agent_api_mod::stub_api_description()?; + let api = server::make_api()?; api.openapi("Crucible Agent", Version::new(0, 0, 1)) .write(f)?; Ok(()) @@ -512,7 +511,7 @@ where // crucible zone that both processes must share and that address is // what will be used by other zones. In the future this could be a // parameter that comes along with the region POST parameters. - properties.push(region::SmfProperty { + properties.push(crate::model::SmfProperty { name: "address", typ: crucible_smf::scf_type_t::SCF_TYPE_ASTRING, val: df.get_listen_addr().ip().to_string(), @@ -521,7 +520,7 @@ where // If the region has a source, then it was created as a clone and // must be started read only. if r.source.is_some() { - properties.push(region::SmfProperty { + properties.push(crate::model::SmfProperty { name: "mode", typ: crucible_smf::scf_type_t::SCF_TYPE_ASTRING, val: "ro".to_string(), @@ -687,7 +686,7 @@ where // is what will be used by other zones. In the future this could // be a parameter that comes along with the region POST // parameters. - properties.push(region::SmfProperty { + properties.push(crate::model::SmfProperty { name: "address", typ: crucible_smf::scf_type_t::SCF_TYPE_ASTRING, val: df.get_listen_addr().ip().to_string(), @@ -824,10 +823,10 @@ where mod test { use super::*; + use crate::model::*; use crate::snapshot_interface::SnapshotInterface; use crate::snapshot_interface::TestSnapshotInterface; - use crucible_agent_types::region::*; use slog::{o, Drain, Logger}; use std::collections::BTreeMap; use tempfile::*; @@ -1868,7 +1867,7 @@ fn worker( fn worker_region_create( log: &Logger, prog: &Path, - region: ®ion::Region, + region: &model::Region, dir: &Path, ) -> Result<()> { let log = log.new(o!("region" => region.id.0.to_string())); @@ -1962,7 +1961,7 @@ fn worker_region_create( fn worker_region_destroy( log: &Logger, - region: ®ion::Region, + region: &model::Region, region_dataset: ZFSDataset, ) -> Result<()> { let log = log.new(o!("region" => region.id.0.to_string())); diff --git a/agent-types/src/region.rs b/agent/src/model.rs similarity index 99% rename from agent-types/src/region.rs rename to agent/src/model.rs index c65425b25..614881efe 100644 --- a/agent-types/src/region.rs +++ b/agent/src/model.rs @@ -1,4 +1,4 @@ -// Copyright 2025 Oxide Computer Company +// Copyright 2021 Oxide Computer Company use std::net::SocketAddr; use std::path::Path; diff --git a/agent/src/server.rs b/agent/src/server.rs index 8d8c2da8a..5f339d776 100644 --- a/agent/src/server.rs +++ b/agent/src/server.rs @@ -1,267 +1,345 @@ // Copyright 2024 Oxide Computer Company use super::datafile::DataFile; +use super::model; use anyhow::{anyhow, Result}; -use crucible_agent_api::*; -use crucible_agent_types::region; use dropshot::{ - HandlerTaskMode, HttpError, HttpResponseDeleted, HttpResponseOk, + endpoint, HandlerTaskMode, HttpError, HttpResponseDeleted, HttpResponseOk, Path as TypedPath, RequestContext, TypedBody, }; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; use slog::{o, Logger}; +use std::collections::BTreeMap; use std::net::SocketAddr; use std::result::Result as SResult; use std::sync::Arc; -#[derive(Debug)] -pub(crate) struct CrucibleAgentImpl; +#[endpoint { + method = GET, + path = "/crucible/0/regions", +}] +async fn region_list( + rc: RequestContext>, +) -> SResult>, HttpError> { + Ok(HttpResponseOk(rc.context().regions())) +} -impl CrucibleAgentApi for CrucibleAgentImpl { - type Context = Arc; +#[endpoint { + method = POST, + path = "/crucible/0/regions", +}] +async fn region_create( + rc: RequestContext>, + body: TypedBody, +) -> SResult, HttpError> { + let create = body.into_inner(); + + match rc.context().create_region_request(create) { + Ok(r) => Ok(HttpResponseOk(r)), + Err(e) => Err(HttpError::for_internal_error(format!( + "region create failure: {:?}", + e + ))), + } +} + +#[derive(Deserialize, JsonSchema)] +struct RegionPath { + id: model::RegionId, +} - async fn region_list( - rqctx: RequestContext, - ) -> SResult>, HttpError> { - Ok(HttpResponseOk(rqctx.context().regions())) +#[endpoint { + method = GET, + path = "/crucible/0/regions/{id}", +}] +async fn region_get( + rc: RequestContext>, + path: TypedPath, +) -> SResult, HttpError> { + let p = path.into_inner(); + + match rc.context().get(&p.id) { + Some(r) => Ok(HttpResponseOk(r)), + None => Err(HttpError::for_not_found( + None, + format!("region {:?} not found", p.id), + )), } +} - async fn region_create( - rqctx: RequestContext, - body: TypedBody, - ) -> SResult, HttpError> { - let create = body.into_inner(); - - match rqctx.context().create_region_request(create) { - Ok(r) => Ok(HttpResponseOk(r)), - Err(e) => Err(HttpError::for_internal_error(format!( - "region create failure: {:?}", - e - ))), +#[endpoint { + method = DELETE, + path = "/crucible/0/regions/{id}", +}] +async fn region_delete( + rc: RequestContext>, + path: TypedPath, +) -> SResult { + let p = path.into_inner(); + + // Cannot delete a region that's backed by a ZFS dataset if there are + // snapshots. + + let snapshots = match rc.context().get_snapshots_for_region(&p.id) { + Ok(results) => results, + Err(e) => { + return Err(HttpError::for_internal_error(e.to_string())); } + }; + + if !snapshots.is_empty() { + return Err(HttpError::for_bad_request( + None, + "must delete snapshots first!".to_string(), + )); + } + + match rc.context().destroy(&p.id) { + Ok(_) => Ok(HttpResponseDeleted()), + Err(e) => Err(HttpError::for_bad_request(None, e.to_string())), } +} - async fn region_get( - rc: RequestContext, - path: TypedPath, - ) -> SResult, HttpError> { - let p = path.into_inner(); +#[derive(Serialize, JsonSchema)] +pub struct GetSnapshotResponse { + snapshots: Vec, + running_snapshots: BTreeMap, +} - match rc.context().get(&p.id) { - Some(r) => Ok(HttpResponseOk(r)), - None => Err(HttpError::for_not_found( +#[endpoint { + method = GET, + path = "/crucible/0/regions/{id}/snapshots", +}] +async fn region_get_snapshots( + rc: RequestContext>, + path: TypedPath, +) -> Result, HttpError> { + let p = path.into_inner(); + + match rc.context().get(&p.id) { + Some(_) => (), + None => { + return Err(HttpError::for_not_found( None, format!("region {:?} not found", p.id), - )), + )); } } - async fn region_delete( - rc: RequestContext, - path: TypedPath, - ) -> SResult { - let p = path.into_inner(); - - // Cannot delete a region that's backed by a ZFS dataset if there are - // snapshots. + let snapshots = match rc.context().get_snapshots_for_region(&p.id) { + Ok(results) => results, + Err(e) => { + return Err(HttpError::for_internal_error(e.to_string())); + } + }; + + let running_snapshots = rc + .context() + .running_snapshots() + .get(&p.id) + .cloned() + .unwrap_or_default(); + + Ok(HttpResponseOk(GetSnapshotResponse { + snapshots, + running_snapshots, + })) +} - let snapshots = match rc.context().get_snapshots_for_region(&p.id) { - Ok(results) => results, - Err(e) => { - return Err(HttpError::for_internal_error(e.to_string())); - } - }; +#[derive(Deserialize, JsonSchema)] +struct GetSnapshotPath { + id: model::RegionId, + name: String, +} - if !snapshots.is_empty() { - return Err(HttpError::for_bad_request( +#[endpoint { + method = GET, + path = "/crucible/0/regions/{id}/snapshots/{name}", +}] +async fn region_get_snapshot( + rc: RequestContext>, + path: TypedPath, +) -> Result, HttpError> { + let p = path.into_inner(); + + match rc.context().get(&p.id) { + Some(_) => (), + None => { + return Err(HttpError::for_not_found( None, - "must delete snapshots first!".to_string(), + format!("region {:?} not found", p.id), )); } - - match rc.context().destroy(&p.id) { - Ok(_) => Ok(HttpResponseDeleted()), - Err(e) => Err(HttpError::for_bad_request(None, e.to_string())), - } } - async fn region_get_snapshots( - rc: RequestContext>, - path: TypedPath, - ) -> Result, HttpError> { - let p = path.into_inner(); - - match rc.context().get(&p.id) { - Some(_) => (), - None => { - return Err(HttpError::for_not_found( - None, - format!("region {:?} not found", p.id), - )); - } - } - - let snapshots = match rc.context().get_snapshots_for_region(&p.id) { + let snapshots_for_region = + match rc.context().get_snapshots_for_region(&p.id) { Ok(results) => results, Err(e) => { return Err(HttpError::for_internal_error(e.to_string())); } }; - let running_snapshots = rc - .context() - .running_snapshots() - .get(&p.id) - .cloned() - .unwrap_or_default(); - - Ok(HttpResponseOk(GetSnapshotResponse { - snapshots, - running_snapshots, - })) + for snapshot in &snapshots_for_region { + if snapshot.name == p.name { + return Ok(HttpResponseOk(snapshot.clone())); + } } - async fn region_get_snapshot( - rc: RequestContext, - path: TypedPath, - ) -> Result, HttpError> { - let p = path.into_inner(); - - match rc.context().get(&p.id) { - Some(_) => (), - None => { - return Err(HttpError::for_not_found( - None, - format!("region {:?} not found", p.id), - )); - } - } + Err(HttpError::for_not_found( + None, + format!("region {:?} snapshot {:?} not found", p.id, p.name), + )) +} - let snapshots_for_region = - match rc.context().get_snapshots_for_region(&p.id) { - Ok(results) => results, - Err(e) => { - return Err(HttpError::for_internal_error(e.to_string())); - } - }; - - for snapshot in &snapshots_for_region { - if snapshot.name == p.name { - return Ok(HttpResponseOk(snapshot.clone())); - } - } +#[derive(Deserialize, JsonSchema)] +struct DeleteSnapshotPath { + id: model::RegionId, + name: String, +} - Err(HttpError::for_not_found( - None, - format!("region {:?} snapshot {:?} not found", p.id, p.name), - )) +#[endpoint { + method = DELETE, + path = "/crucible/0/regions/{id}/snapshots/{name}", +}] +async fn region_delete_snapshot( + rc: RequestContext>, + path: TypedPath, +) -> Result { + let p = path.into_inner(); + + match rc.context().get(&p.id) { + Some(_) => (), + None => { + return Err(HttpError::for_not_found( + None, + format!("region {:?} not found", p.id), + )); + } } - async fn region_delete_snapshot( - rc: RequestContext, - path: TypedPath, - ) -> Result { - let p = path.into_inner(); - - match rc.context().get(&p.id) { - Some(_) => (), - None => { - return Err(HttpError::for_not_found( - None, - format!("region {:?} not found", p.id), - )); - } - } + let request = model::DeleteSnapshotRequest { + id: p.id.clone(), + name: p.name, + }; - let request = region::DeleteSnapshotRequest { - id: p.id.clone(), - name: p.name, - }; + match rc.context().delete_snapshot(request) { + Ok(_) => Ok(HttpResponseDeleted()), + Err(e) => Err(HttpError::for_internal_error(e.to_string())), + } +} + +#[derive(Deserialize, JsonSchema)] +struct RunSnapshotPath { + id: model::RegionId, + name: String, +} - match rc.context().delete_snapshot(request) { - Ok(_) => Ok(HttpResponseDeleted()), - Err(e) => Err(HttpError::for_internal_error(e.to_string())), +#[endpoint { + method = POST, + path = "/crucible/0/regions/{id}/snapshots/{name}/run", +}] +async fn region_run_snapshot( + rc: RequestContext>, + path: TypedPath, +) -> Result, HttpError> { + let p = path.into_inner(); + + match rc.context().get(&p.id) { + Some(_) => (), + None => { + return Err(HttpError::for_not_found( + None, + format!("region {:?} not found", p.id), + )); } } - async fn region_run_snapshot( - rc: RequestContext>, - path: TypedPath, - ) -> Result, HttpError> { - let p = path.into_inner(); - - match rc.context().get(&p.id) { - Some(_) => (), - None => { - return Err(HttpError::for_not_found( - None, - format!("region {:?} not found", p.id), - )); - } + let snapshots = match rc.context().get_snapshots_for_region(&p.id) { + Ok(results) => results, + Err(e) => { + return Err(HttpError::for_internal_error(e.to_string())); } + }; - let snapshots = match rc.context().get_snapshots_for_region(&p.id) { - Ok(results) => results, - Err(e) => { - return Err(HttpError::for_internal_error(e.to_string())); - } - }; + let snapshot_names: Vec = + snapshots.iter().map(|s| s.name.clone()).collect(); - let snapshot_names: Vec = - snapshots.iter().map(|s| s.name.clone()).collect(); + if !snapshot_names.contains(&p.name) { + return Err(HttpError::for_not_found( + None, + format!("snapshot {:?} not found", p.name), + )); + } + + // TODO support running snapshots with their own X509 creds + let create = model::CreateRunningSnapshotRequest { + id: p.id, + name: p.name, + cert_pem: None, + key_pem: None, + root_pem: None, + }; + + match rc.context().create_running_snapshot_request(create) { + Ok(r) => Ok(HttpResponseOk(r)), + Err(e) => Err(HttpError::for_internal_error(format!( + "running snapshot create failure: {:?}", + e + ))), + } +} - if !snapshot_names.contains(&p.name) { +#[endpoint { + method = DELETE, + path = "/crucible/0/regions/{id}/snapshots/{name}/run", +}] +async fn region_delete_running_snapshot( + rc: RequestContext>, + path: TypedPath, +) -> Result { + let p = path.into_inner(); + + match rc.context().get(&p.id) { + Some(_) => (), + None => { return Err(HttpError::for_not_found( None, - format!("snapshot {:?} not found", p.name), + format!("region {:?} not found", p.id), )); } + } - // TODO support running snapshots with their own X509 creds - let create = region::CreateRunningSnapshotRequest { - id: p.id, - name: p.name, - cert_pem: None, - key_pem: None, - root_pem: None, - }; - - match rc.context().create_running_snapshot_request(create) { - Ok(r) => Ok(HttpResponseOk(r)), - Err(e) => Err(HttpError::for_internal_error(format!( - "running snapshot create failure: {:?}", - e - ))), - } + let request = model::DeleteRunningSnapshotRequest { + id: p.id, + name: p.name, + }; + + match rc.context().delete_running_snapshot_request(request) { + Ok(_) => Ok(HttpResponseDeleted()), + Err(e) => Err(HttpError::for_internal_error(format!( + "running snapshot create failure: {:?}", + e + ))), } +} - async fn region_delete_running_snapshot( - rc: RequestContext, - path: TypedPath, - ) -> Result { - let p = path.into_inner(); - - match rc.context().get(&p.id) { - Some(_) => (), - None => { - return Err(HttpError::for_not_found( - None, - format!("region {:?} not found", p.id), - )); - } - } +pub fn make_api() -> Result>> { + let mut api = dropshot::ApiDescription::new(); - let request = region::DeleteRunningSnapshotRequest { - id: p.id, - name: p.name, - }; + api.register(region_list)?; + api.register(region_create)?; + api.register(region_get)?; + api.register(region_delete)?; - match rc.context().delete_running_snapshot_request(request) { - Ok(_) => Ok(HttpResponseDeleted()), - Err(e) => Err(HttpError::for_internal_error(format!( - "running snapshot create failure: {:?}", - e - ))), - } - } + api.register(region_get_snapshots)?; + api.register(region_get_snapshot)?; + api.register(region_delete_snapshot)?; + + api.register(region_run_snapshot)?; + api.register(region_delete_running_snapshot)?; + + Ok(api) } pub async fn run_server( @@ -269,7 +347,7 @@ pub async fn run_server( bind_address: SocketAddr, df: Arc, ) -> Result<()> { - let api = crucible_agent_api_mod::api_description::()?; + let api = make_api()?; let server = dropshot::HttpServerStarter::new( &dropshot::ConfigDropshot { diff --git a/agent/src/snapshot_interface.rs b/agent/src/snapshot_interface.rs index b343c5a90..a17b6f8c3 100644 --- a/agent/src/snapshot_interface.rs +++ b/agent/src/snapshot_interface.rs @@ -1,7 +1,7 @@ // Copyright 2023 Oxide Computer Company +use super::model::*; use anyhow::{bail, Result}; -use crucible_agent_types::region::Snapshot; use slog::{error, info, Logger}; #[cfg(test)] use std::collections::HashSet; diff --git a/workspace-hack/Cargo.toml b/workspace-hack/Cargo.toml index 43aa711de..ba73ea1fa 100644 --- a/workspace-hack/Cargo.toml +++ b/workspace-hack/Cargo.toml @@ -30,7 +30,6 @@ futures-core = { version = "0.3" } futures-executor = { version = "0.3" } futures-sink = { version = "0.3" } futures-util = { version = "0.3", features = ["channel", "io", "sink"] } -getrandom = { version = "0.2", default-features = false, features = ["std"] } hashbrown = { version = "0.15" } hex = { version = "0.4", features = ["serde"] } indexmap = { version = "2", features = ["serde"] } @@ -82,7 +81,6 @@ futures-core = { version = "0.3" } futures-executor = { version = "0.3" } futures-sink = { version = "0.3" } futures-util = { version = "0.3", features = ["channel", "io", "sink"] } -getrandom = { version = "0.2", default-features = false, features = ["std"] } hashbrown = { version = "0.15" } hex = { version = "0.4", features = ["serde"] } indexmap = { version = "2", features = ["serde"] } @@ -122,6 +120,7 @@ uuid = { version = "1", features = ["serde", "v4"] } [target.x86_64-unknown-linux-gnu.dependencies] bitflags = { version = "2", default-features = false, features = ["std"] } dof = { version = "0.3", default-features = false, features = ["des"] } +getrandom = { version = "0.2", default-features = false, features = ["std"] } hyper = { version = "1", features = ["full"] } hyper-rustls = { version = "0.27", default-features = false, features = ["http1", "http2", "ring", "tls12", "webpki-tokio"] } hyper-util = { version = "0.1", features = ["full"] } @@ -135,6 +134,7 @@ tokio-util = { version = "0.7", features = ["codec", "io"] } [target.x86_64-unknown-linux-gnu.build-dependencies] bitflags = { version = "2", default-features = false, features = ["std"] } dof = { version = "0.3", default-features = false, features = ["des"] } +getrandom = { version = "0.2", default-features = false, features = ["std"] } hyper = { version = "1", features = ["full"] } hyper-rustls = { version = "0.27", default-features = false, features = ["http1", "http2", "ring", "tls12", "webpki-tokio"] } hyper-util = { version = "0.1", features = ["full"] } @@ -146,6 +146,7 @@ tokio-util = { version = "0.7", features = ["codec", "io"] } [target.aarch64-apple-darwin.dependencies] bitflags = { version = "2", default-features = false, features = ["std"] } +getrandom = { version = "0.2", default-features = false, features = ["std"] } hyper = { version = "1", features = ["full"] } hyper-rustls = { version = "0.27", default-features = false, features = ["http1", "http2", "ring", "tls12", "webpki-tokio"] } hyper-util = { version = "0.1", features = ["full"] } @@ -157,6 +158,7 @@ tokio-util = { version = "0.7", features = ["codec", "io"] } [target.aarch64-apple-darwin.build-dependencies] bitflags = { version = "2", default-features = false, features = ["std"] } +getrandom = { version = "0.2", default-features = false, features = ["std"] } hyper = { version = "1", features = ["full"] } hyper-rustls = { version = "0.27", default-features = false, features = ["http1", "http2", "ring", "tls12", "webpki-tokio"] } hyper-util = { version = "0.1", features = ["full"] } @@ -168,6 +170,7 @@ tokio-util = { version = "0.7", features = ["codec", "io"] } [target.x86_64-unknown-illumos.dependencies] bitflags = { version = "2", default-features = false, features = ["std"] } dof = { version = "0.3", default-features = false, features = ["des"] } +getrandom = { version = "0.2", default-features = false, features = ["std"] } hyper = { version = "1", features = ["full"] } hyper-rustls = { version = "0.27", default-features = false, features = ["http1", "http2", "ring", "tls12", "webpki-tokio"] } hyper-util = { version = "0.1", features = ["full"] } @@ -181,6 +184,7 @@ tokio-util = { version = "0.7", features = ["codec", "io"] } [target.x86_64-unknown-illumos.build-dependencies] bitflags = { version = "2", default-features = false, features = ["std"] } dof = { version = "0.3", default-features = false, features = ["des"] } +getrandom = { version = "0.2", default-features = false, features = ["std"] } hyper = { version = "1", features = ["full"] } hyper-rustls = { version = "0.27", default-features = false, features = ["http1", "http2", "ring", "tls12", "webpki-tokio"] } hyper-util = { version = "0.1", features = ["full"] } From 91a0f6d6a93641006fdf0a4ebafb3314b55f2d4d Mon Sep 17 00:00:00 2001 From: Rain Date: Thu, 28 Aug 2025 23:42:02 +0000 Subject: [PATCH 4/4] hakari Created using spr 1.3.6-beta.1 --- workspace-hack/Cargo.toml | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/workspace-hack/Cargo.toml b/workspace-hack/Cargo.toml index ba73ea1fa..43aa711de 100644 --- a/workspace-hack/Cargo.toml +++ b/workspace-hack/Cargo.toml @@ -30,6 +30,7 @@ futures-core = { version = "0.3" } futures-executor = { version = "0.3" } futures-sink = { version = "0.3" } futures-util = { version = "0.3", features = ["channel", "io", "sink"] } +getrandom = { version = "0.2", default-features = false, features = ["std"] } hashbrown = { version = "0.15" } hex = { version = "0.4", features = ["serde"] } indexmap = { version = "2", features = ["serde"] } @@ -81,6 +82,7 @@ futures-core = { version = "0.3" } futures-executor = { version = "0.3" } futures-sink = { version = "0.3" } futures-util = { version = "0.3", features = ["channel", "io", "sink"] } +getrandom = { version = "0.2", default-features = false, features = ["std"] } hashbrown = { version = "0.15" } hex = { version = "0.4", features = ["serde"] } indexmap = { version = "2", features = ["serde"] } @@ -120,7 +122,6 @@ uuid = { version = "1", features = ["serde", "v4"] } [target.x86_64-unknown-linux-gnu.dependencies] bitflags = { version = "2", default-features = false, features = ["std"] } dof = { version = "0.3", default-features = false, features = ["des"] } -getrandom = { version = "0.2", default-features = false, features = ["std"] } hyper = { version = "1", features = ["full"] } hyper-rustls = { version = "0.27", default-features = false, features = ["http1", "http2", "ring", "tls12", "webpki-tokio"] } hyper-util = { version = "0.1", features = ["full"] } @@ -134,7 +135,6 @@ tokio-util = { version = "0.7", features = ["codec", "io"] } [target.x86_64-unknown-linux-gnu.build-dependencies] bitflags = { version = "2", default-features = false, features = ["std"] } dof = { version = "0.3", default-features = false, features = ["des"] } -getrandom = { version = "0.2", default-features = false, features = ["std"] } hyper = { version = "1", features = ["full"] } hyper-rustls = { version = "0.27", default-features = false, features = ["http1", "http2", "ring", "tls12", "webpki-tokio"] } hyper-util = { version = "0.1", features = ["full"] } @@ -146,7 +146,6 @@ tokio-util = { version = "0.7", features = ["codec", "io"] } [target.aarch64-apple-darwin.dependencies] bitflags = { version = "2", default-features = false, features = ["std"] } -getrandom = { version = "0.2", default-features = false, features = ["std"] } hyper = { version = "1", features = ["full"] } hyper-rustls = { version = "0.27", default-features = false, features = ["http1", "http2", "ring", "tls12", "webpki-tokio"] } hyper-util = { version = "0.1", features = ["full"] } @@ -158,7 +157,6 @@ tokio-util = { version = "0.7", features = ["codec", "io"] } [target.aarch64-apple-darwin.build-dependencies] bitflags = { version = "2", default-features = false, features = ["std"] } -getrandom = { version = "0.2", default-features = false, features = ["std"] } hyper = { version = "1", features = ["full"] } hyper-rustls = { version = "0.27", default-features = false, features = ["http1", "http2", "ring", "tls12", "webpki-tokio"] } hyper-util = { version = "0.1", features = ["full"] } @@ -170,7 +168,6 @@ tokio-util = { version = "0.7", features = ["codec", "io"] } [target.x86_64-unknown-illumos.dependencies] bitflags = { version = "2", default-features = false, features = ["std"] } dof = { version = "0.3", default-features = false, features = ["des"] } -getrandom = { version = "0.2", default-features = false, features = ["std"] } hyper = { version = "1", features = ["full"] } hyper-rustls = { version = "0.27", default-features = false, features = ["http1", "http2", "ring", "tls12", "webpki-tokio"] } hyper-util = { version = "0.1", features = ["full"] } @@ -184,7 +181,6 @@ tokio-util = { version = "0.7", features = ["codec", "io"] } [target.x86_64-unknown-illumos.build-dependencies] bitflags = { version = "2", default-features = false, features = ["std"] } dof = { version = "0.3", default-features = false, features = ["des"] } -getrandom = { version = "0.2", default-features = false, features = ["std"] } hyper = { version = "1", features = ["full"] } hyper-rustls = { version = "0.27", default-features = false, features = ["http1", "http2", "ring", "tls12", "webpki-tokio"] } hyper-util = { version = "0.1", features = ["full"] }