From 57658d3f3817b66a3ce318c9f098f3c99e39731b Mon Sep 17 00:00:00 2001 From: Justin Bennett Date: Fri, 10 Feb 2023 16:15:40 -0500 Subject: [PATCH 1/4] Start on snapshot v1 conversion --- nexus/src/app/disk.rs | 170 -------------- nexus/src/app/mod.rs | 1 + nexus/src/app/snapshot.rs | 181 ++++++++++++++ nexus/src/db/datastore/snapshot.rs | 30 ++- nexus/src/external_api/http_entrypoints.rs | 259 +++++++++++++++------ nexus/types/src/external_api/params.rs | 26 +++ 6 files changed, 422 insertions(+), 245 deletions(-) create mode 100644 nexus/src/app/snapshot.rs diff --git a/nexus/src/app/disk.rs b/nexus/src/app/disk.rs index 118439a51a5..7a5ab31970c 100644 --- a/nexus/src/app/disk.rs +++ b/nexus/src/app/disk.rs @@ -16,10 +16,8 @@ use crate::external_api::params; use omicron_common::api::external::http_pagination::PaginatedBy; use omicron_common::api::external::ByteCount; use omicron_common::api::external::CreateResult; -use omicron_common::api::external::DataPageParams; use omicron_common::api::external::DeleteResult; use omicron_common::api::external::Error; -use omicron_common::api::external::InstanceState; use omicron_common::api::external::InternalContext; use omicron_common::api::external::ListResultVec; use omicron_common::api::external::LookupResult; @@ -389,174 +387,6 @@ impl super::Nexus { Ok(()) } - // Snapshots - - pub async fn project_create_snapshot( - self: &Arc, - opctx: &OpContext, - organization_name: &Name, - project_name: &Name, - params: ¶ms::SnapshotCreate, - ) -> CreateResult { - let authz_silo: authz::Silo; - let _authz_org: authz::Organization; - let authz_project: authz::Project; - let authz_disk: authz::Disk; - let db_disk: db::model::Disk; - - (authz_silo, _authz_org, authz_project, authz_disk, db_disk) = - LookupPath::new(opctx, &self.db_datastore) - .organization_name(organization_name) - .project_name(project_name) - .disk_name(&db::model::Name(params.disk.clone())) - .fetch_for(authz::Action::Read) - .await?; - - // If there isn't a running propolis, Nexus needs to use the Crucible - // Pantry to make this snapshot - let use_the_pantry = if let Some(attach_instance_id) = - &db_disk.runtime_state.attach_instance_id - { - let (.., db_instance) = LookupPath::new(opctx, &self.db_datastore) - .instance_id(*attach_instance_id) - .fetch_for(authz::Action::Read) - .await?; - - let instance_state: InstanceState = db_instance.runtime().state.0; - - match instance_state { - // If there's a propolis running, use that - InstanceState::Running | - // Rebooting doesn't deactivate the volume - InstanceState::Rebooting - => false, - - // If there's definitely no propolis running, then use the - // pantry - InstanceState::Stopped | InstanceState::Destroyed => true, - - // If there *may* be a propolis running, then fail: we can't - // know if that propolis has activated the Volume or not, or if - // it's in the process of deactivating. - _ => { - return Err( - Error::invalid_request( - &format!("cannot snapshot attached disk for instance in state {}", instance_state) - ) - ); - } - } - } else { - // This disk is not attached to an instance, use the pantry. - true - }; - - let saga_params = sagas::snapshot_create::Params { - serialized_authn: authn::saga::Serialized::for_opctx(opctx), - silo_id: authz_silo.id(), - project_id: authz_project.id(), - disk_id: authz_disk.id(), - use_the_pantry, - create_params: params.clone(), - }; - - let saga_outputs = self - .execute_saga::( - saga_params, - ) - .await?; - - let snapshot_created = saga_outputs - .lookup_node_output::("finalized_snapshot") - .map_err(|e| Error::InternalError { - internal_message: e.to_string(), - })?; - - Ok(snapshot_created) - } - - pub async fn project_list_snapshots( - &self, - opctx: &OpContext, - organization_name: &Name, - project_name: &Name, - pagparams: &DataPageParams<'_, Name>, - ) -> ListResultVec { - let (.., authz_project) = LookupPath::new(opctx, &self.db_datastore) - .organization_name(organization_name) - .project_name(project_name) - .lookup_for(authz::Action::ListChildren) - .await?; - - self.db_datastore - .project_list_snapshots(opctx, &authz_project, pagparams) - .await - } - - pub async fn snapshot_fetch( - &self, - opctx: &OpContext, - organization_name: &Name, - project_name: &Name, - snapshot_name: &Name, - ) -> LookupResult { - let (.., db_snapshot) = LookupPath::new(opctx, &self.db_datastore) - .organization_name(organization_name) - .project_name(project_name) - .snapshot_name(snapshot_name) - .fetch() - .await?; - - Ok(db_snapshot) - } - - pub async fn snapshot_fetch_by_id( - &self, - opctx: &OpContext, - snapshot_id: &Uuid, - ) -> LookupResult { - let (.., db_snapshot) = LookupPath::new(opctx, &self.db_datastore) - .snapshot_id(*snapshot_id) - .fetch() - .await?; - - Ok(db_snapshot) - } - - pub async fn project_delete_snapshot( - self: &Arc, - opctx: &OpContext, - organization_name: &Name, - project_name: &Name, - snapshot_name: &Name, - ) -> DeleteResult { - // TODO-correctness - // This also requires solving how to clean up the associated resources - // (on-disk snapshots, running read-only downstairs) because disks - // *could* still be using them (if the snapshot has not yet been turned - // into a regular crucible volume). It will involve some sort of - // reference counting for volumes. - - let (.., authz_snapshot, db_snapshot) = - LookupPath::new(opctx, &self.db_datastore) - .organization_name(organization_name) - .project_name(project_name) - .snapshot_name(snapshot_name) - .fetch() - .await?; - - let saga_params = sagas::snapshot_delete::Params { - serialized_authn: authn::saga::Serialized::for_opctx(opctx), - authz_snapshot, - snapshot: db_snapshot, - }; - self.execute_saga::( - saga_params, - ) - .await?; - Ok(()) - } - /// Remove a read only parent from a disk. /// This is just a wrapper around the volume operation of the same /// name, but we provide this interface when all the caller has is diff --git a/nexus/src/app/mod.rs b/nexus/src/app/mod.rs index 1dfd83a68d6..fa814f70e32 100644 --- a/nexus/src/app/mod.rs +++ b/nexus/src/app/mod.rs @@ -43,6 +43,7 @@ pub mod saga; mod session; mod silo; mod sled; +mod snapshot; pub mod test_interfaces; mod update; mod volume; diff --git a/nexus/src/app/snapshot.rs b/nexus/src/app/snapshot.rs new file mode 100644 index 00000000000..e547743f88e --- /dev/null +++ b/nexus/src/app/snapshot.rs @@ -0,0 +1,181 @@ +use std::sync::Arc; + +use crate::authn; +use crate::authz; +use crate::context::OpContext; +use crate::db; +use crate::db::lookup; +use crate::db::lookup::LookupPath; +use db::model::Name; +use nexus_types::external_api::params; +use omicron_common::api::external::http_pagination::PaginatedBy; +use omicron_common::api::external::CreateResult; +use omicron_common::api::external::DeleteResult; +use omicron_common::api::external::Error; +use omicron_common::api::external::InstanceState; +use omicron_common::api::external::ListResultVec; +use omicron_common::api::external::LookupResult; +use omicron_common::api::external::NameOrId; +use ref_cast::RefCast; + +use super::sagas; + +impl super::Nexus { + // Snapshots + + pub fn snapshot_lookup<'a>( + &'a self, + opctx: &'a OpContext, + snapshot_selector: &'a params::SnapshotSelector, + ) -> LookupResult> { + match snapshot_selector { + params::SnapshotSelector { + snapshot: NameOrId::Id(id), + project_selector: None, + } => { + let snapshot = + LookupPath::new(opctx, &self.db_datastore).snapshot_id(*id); + Ok(snapshot) + } + params::SnapshotSelector { + snapshot: NameOrId::Name(name), + project_selector: Some(project_selector), + } => { + let snapshot = self + .project_lookup(opctx, project_selector)? + .snapshot_name(Name::ref_cast(name)); + Ok(snapshot) + } + params::SnapshotSelector { + snapshot: NameOrId::Id(_), + project_selector: Some(_), + } => Err(Error::invalid_request( + "when providing snpashot as an ID, prject should not be specified" + )), + _ => Err(Error::invalid_request( + "snapshot should either be a UUID or project should be specified" + )) + } + } + + pub async fn snapshot_create( + self: &Arc, + opctx: &OpContext, + project_lookup: &lookup::Project<'_>, + params: ¶ms::SnapshotCreate, + ) -> CreateResult { + let authz_silo: authz::Silo; + let _authz_org: authz::Organization; + let authz_project: authz::Project; + let authz_disk: authz::Disk; + let db_disk: db::model::Disk; + + // FIXME: Borrowing error here with project_lookup due some issue with disk_name? + (authz_silo, _authz_org, authz_project, authz_disk, db_disk) = + project_lookup + .disk_name(Name::ref_cast(¶ms.disk.clone())) + .fetch_for(authz::Action::Read) + .await?; + + // If there isn't a running propolis, Nexus needs to use the Crucible + // Pantry to make this snapshot + let use_the_pantry = if let Some(attach_instance_id) = + &db_disk.runtime_state.attach_instance_id + { + let (.., db_instance) = LookupPath::new(opctx, &self.db_datastore) + .instance_id(*attach_instance_id) + .fetch_for(authz::Action::Read) + .await?; + + let instance_state: InstanceState = db_instance.runtime().state.0; + + match instance_state { + // If there's a propolis running, use that + InstanceState::Running | + // Rebooting doesn't deactivate the volume + InstanceState::Rebooting + => false, + + // If there's definitely no propolis running, then use the + // pantry + InstanceState::Stopped | InstanceState::Destroyed => true, + + // If there *may* be a propolis running, then fail: we can't + // know if that propolis has activated the Volume or not, or if + // it's in the process of deactivating. + _ => { + return Err( + Error::invalid_request( + &format!("cannot snapshot attached disk for instance in state {}", instance_state) + ) + ); + } + } + } else { + // This disk is not attached to an instance, use the pantry. + true + }; + + let saga_params = sagas::snapshot_create::Params { + serialized_authn: authn::saga::Serialized::for_opctx(opctx), + silo_id: authz_silo.id(), + project_id: authz_project.id(), + disk_id: authz_disk.id(), + use_the_pantry, + create_params: params.clone(), + }; + + let saga_outputs = self + .execute_saga::( + saga_params, + ) + .await?; + + let snapshot_created = saga_outputs + .lookup_node_output::("finalized_snapshot") + .map_err(|e| Error::InternalError { + internal_message: e.to_string(), + })?; + + Ok(snapshot_created) + } + + pub async fn snapshot_list( + &self, + opctx: &OpContext, + project_lookup: &lookup::Project<'_>, + pagparams: &PaginatedBy<'_>, + ) -> ListResultVec { + let (.., authz_project) = + project_lookup.lookup_for(authz::Action::ListChildren).await?; + + self.db_datastore.snapshot_list(opctx, &authz_project, pagparams).await + } + + pub async fn snapshot_delete( + self: &Arc, + opctx: &OpContext, + snapshot_lookup: &lookup::Snapshot<'_>, + ) -> DeleteResult { + // TODO-correctness + // This also requires solving how to clean up the associated resources + // (on-disk snapshots, running read-only downstairs) because disks + // *could* still be using them (if the snapshot has not yet been turned + // into a regular crucible volume). It will involve some sort of + // reference counting for volumes. + + let (.., authz_snapshot, db_snapshot) = + snapshot_lookup.fetch_for(authz::Action::Delete).await?; + + let saga_params = sagas::snapshot_delete::Params { + serialized_authn: authn::saga::Serialized::for_opctx(opctx), + authz_snapshot, + snapshot: db_snapshot, + }; + self.execute_saga::( + saga_params, + ) + .await?; + Ok(()) + } +} diff --git a/nexus/src/db/datastore/snapshot.rs b/nexus/src/db/datastore/snapshot.rs index f893b9f9791..60f19311a66 100644 --- a/nexus/src/db/datastore/snapshot.rs +++ b/nexus/src/db/datastore/snapshot.rs @@ -22,14 +22,15 @@ use crate::db::update_and_check::UpdateAndCheck; use async_bb8_diesel::AsyncRunQueryDsl; use chrono::Utc; use diesel::prelude::*; +use omicron_common::api::external::http_pagination::PaginatedBy; use omicron_common::api::external::CreateResult; -use omicron_common::api::external::DataPageParams; use omicron_common::api::external::Error; use omicron_common::api::external::ListResultVec; use omicron_common::api::external::LookupType; use omicron_common::api::external::ResourceType; use omicron_common::api::external::UpdateResult; use omicron_common::bail_unless; +use ref_cast::RefCast; use uuid::Uuid; impl DataStore { @@ -108,22 +109,31 @@ impl DataStore { }) } - pub async fn project_list_snapshots( + pub async fn snapshot_list( &self, opctx: &OpContext, authz_project: &authz::Project, - pagparams: &DataPageParams<'_, Name>, + pagparams: &PaginatedBy<'_>, ) -> ListResultVec { opctx.authorize(authz::Action::ListChildren, authz_project).await?; use db::schema::snapshot::dsl; - paginated(dsl::snapshot, dsl::name, &pagparams) - .filter(dsl::time_deleted.is_null()) - .filter(dsl::project_id.eq(authz_project.id())) - .select(Snapshot::as_select()) - .load_async::(self.pool_authorized(opctx).await?) - .await - .map_err(|e| public_error_from_diesel_pool(e, ErrorHandler::Server)) + match pagparams { + PaginatedBy::Id(pagparams) => { + paginated(dsl::snapshot, dsl::id, &pagparams) + } + PaginatedBy::Name(pagparams) => paginated( + dsl::snapshot, + dsl::name, + &pagparams.map_name(|n| Name::ref_cast(n)), + ), + } + .filter(dsl::time_deleted.is_null()) + .filter(dsl::project_id.eq(authz_project.id())) + .select(Snapshot::as_select()) + .load_async::(self.pool_authorized(opctx).await?) + .await + .map_err(|e| public_error_from_diesel_pool(e, ErrorHandler::Server)) } pub async fn project_delete_snapshot( diff --git a/nexus/src/external_api/http_entrypoints.rs b/nexus/src/external_api/http_entrypoints.rs index df2a6fde482..144b8084a5c 100644 --- a/nexus/src/external_api/http_entrypoints.rs +++ b/nexus/src/external_api/http_entrypoints.rs @@ -194,6 +194,11 @@ pub fn external_api() -> NexusApiDescription { api.register(snapshot_view_by_id)?; api.register(snapshot_delete)?; + api.register(snapshot_list_v1)?; + api.register(snapshot_create_v1)?; + api.register(snapshot_view_v1)?; + api.register(snapshot_delete_v1)?; + api.register(vpc_list)?; api.register(vpc_create)?; api.register(vpc_view)?; @@ -2270,12 +2275,12 @@ async fn disk_list_v1( ) -> Result>, HttpError> { let apictx = rqctx.context(); let handler = async { + let opctx = OpContext::for_external_api(&rqctx).await?; let nexus = &apictx.nexus; let query = query_params.into_inner(); let pag_params = data_page_params_for(&rqctx, &query)?; let scan_params = ScanByNameOrId::from_query(&query)?; let paginated_by = name_or_id_pagination(&pag_params, scan_params)?; - let opctx = OpContext::for_external_api(&rqctx).await?; let project_lookup = nexus.project_lookup(&opctx, &scan_params.selector)?; let disks = nexus @@ -2308,6 +2313,7 @@ async fn disk_list( ) -> Result>, HttpError> { let apictx = rqctx.context(); let handler = async { + let opctx = OpContext::for_external_api(&rqctx).await?; let nexus = &apictx.nexus; let query = query_params.into_inner(); let path = path_params.into_inner(); @@ -2315,12 +2321,11 @@ async fn disk_list( Some(path.organization_name.into()), path.project_name.into(), ); - let opctx = OpContext::for_external_api(&rqctx).await?; - let authz_project = nexus.project_lookup(&opctx, &project_selector)?; + let project_lookup = nexus.project_lookup(&opctx, &project_selector)?; let disks = nexus .disk_list( &opctx, - &authz_project, + &project_lookup, &PaginatedBy::Name(data_page_params_for(&rqctx, &query)?), ) .await? @@ -2348,11 +2353,11 @@ async fn disk_create_v1( new_disk: TypedBody, ) -> Result, HttpError> { let apictx = rqctx.context(); - let nexus = &apictx.nexus; - let query = query_params.into_inner(); - let params = new_disk.into_inner(); let handler = async { let opctx = OpContext::for_external_api(&rqctx).await?; + let nexus = &apictx.nexus; + let query = query_params.into_inner(); + let params = new_disk.into_inner(); let project_lookup = nexus.project_lookup(&opctx, &query)?; let disk = nexus.project_create_disk(&opctx, &project_lookup, ¶ms).await?; @@ -2523,15 +2528,15 @@ async fn disk_delete( path_params: Path, ) -> Result { let apictx = rqctx.context(); - let nexus = &apictx.nexus; - let path = path_params.into_inner(); - let disk_selector = params::DiskSelector::new( - Some(path.organization_name.into()), - Some(path.project_name.into()), - path.disk_name.into(), - ); let handler = async { let opctx = OpContext::for_external_api(&rqctx).await?; + let nexus = &apictx.nexus; + let path = path_params.into_inner(); + let disk_selector = params::DiskSelector::new( + Some(path.organization_name.into()), + Some(path.project_name.into()), + path.disk_name.into(), + ); let disk_lookup = nexus.disk_lookup(&opctx, &disk_selector)?; nexus.project_delete_disk(&opctx, &disk_lookup).await?; Ok(HttpResponseDeleted()) @@ -4182,10 +4187,47 @@ async fn instance_external_ip_list( // Snapshots /// List snapshots +#[endpoint { + method = GET, + path = "/v1/snapshots", + tags = ["snapshots"], +}] +async fn snapshot_list_v1( + rqctx: RequestContext>, + query_params: Query>, +) -> Result>, HttpError> { + let apictx = rqctx.context(); + let handler = async { + let opctx = OpContext::for_external_api(&rqctx).await?; + let nexus = &apictx.nexus; + let query = query_params.into_inner(); + let pag_params = data_page_params_for(&rqctx, &query)?; + let scan_params = ScanByNameOrId::from_query(&query)?; + let paginated_by = name_or_id_pagination(&pag_params, scan_params)?; + let project_lookup = + nexus.project_lookup(&opctx, &scan_params.selector)?; + let snapshots = nexus + .snapshot_list(&opctx, &project_lookup, &paginated_by) + .await? + .into_iter() + .map(|d| d.into()) + .collect(); + Ok(HttpResponseOk(ScanByNameOrId::results_page( + &query, + snapshots, + &marker_for_name_or_id, + )?)) + }; + apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await +} + +/// List snapshots +/// Use `GET /v1/snapshots` instead. #[endpoint { method = GET, path = "/organizations/{organization_name}/projects/{project_name}/snapshots", tags = ["snapshots"], + deprecated = true }] async fn snapshot_list( rqctx: RequestContext>, @@ -4193,20 +4235,21 @@ async fn snapshot_list( path_params: Path, ) -> Result>, HttpError> { let apictx = rqctx.context(); - let nexus = &apictx.nexus; - let query = query_params.into_inner(); - let path = path_params.into_inner(); - let organization_name = &path.organization_name; - let project_name = &path.project_name; let handler = async { let opctx = OpContext::for_external_api(&rqctx).await?; + let nexus = &apictx.nexus; + let query = query_params.into_inner(); + let path = path_params.into_inner(); + let project_selector = params::ProjectSelector::new( + Some(path.organization_name.into()), + path.project_name.into(), + ); + let project_lookup = nexus.project_lookup(&opctx, &project_selector)?; let snapshots = nexus - .project_list_snapshots( + .snapshot_list( &opctx, - organization_name, - project_name, - &data_page_params_for(&rqctx, &query)? - .map_name(|n| Name::ref_cast(n)), + &project_lookup, + &PaginatedBy::Name(data_page_params_for(&rqctx, &query)?), ) .await? .into_iter() @@ -4224,10 +4267,38 @@ async fn snapshot_list( /// Create a snapshot /// /// Creates a point-in-time snapshot from a disk. +#[endpoint { + method = POST, + path = "/v1/snapshots", + tags = ["snapshots"], +}] +async fn snapshot_create_v1( + rqctx: RequestContext>, + query_params: Query, + new_snapshot: TypedBody, +) -> Result, HttpError> { + let apictx = rqctx.context(); + let handler = async { + let opctx = OpContext::for_external_api(&rqctx).await?; + let nexus = &apictx.nexus; + let query = query_params.into_inner(); + let new_snapshot_params = &new_snapshot.into_inner(); + let project_lookup = nexus.project_lookup(&opctx, &query)?; + let snapshot = nexus + .snapshot_create(&opctx, &project_lookup, &new_snapshot_params) + .await?; + Ok(HttpResponseCreated(snapshot.into())) + }; + apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await +} + +/// Create a snapshot +/// Use `POST /v1/snapshots` instead. #[endpoint { method = POST, path = "/organizations/{organization_name}/projects/{project_name}/snapshots", tags = ["snapshots"], + deprecated = true, }] async fn snapshot_create( rqctx: RequestContext>, @@ -4235,20 +4306,18 @@ async fn snapshot_create( new_snapshot: TypedBody, ) -> Result, HttpError> { let apictx = rqctx.context(); - let nexus = &apictx.nexus; - let path = path_params.into_inner(); - let organization_name = &path.organization_name; - let project_name = &path.project_name; - let new_snapshot_params = &new_snapshot.into_inner(); let handler = async { let opctx = OpContext::for_external_api(&rqctx).await?; + let nexus = &apictx.nexus; + let path = path_params.into_inner(); + let new_snapshot_params = &new_snapshot.into_inner(); + let project_selector = params::ProjectSelector::new( + Some(path.organization_name.into()), + path.project_name.into(), + ); + let project_lookup = nexus.project_lookup(&opctx, &project_selector)?; let snapshot = nexus - .project_create_snapshot( - &opctx, - &organization_name, - &project_name, - &new_snapshot_params, - ) + .snapshot_create(&opctx, &project_lookup, &new_snapshot_params) .await?; Ok(HttpResponseCreated(snapshot.into())) }; @@ -4264,84 +4333,144 @@ struct SnapshotPathParam { } /// Fetch a snapshot +#[endpoint { + method = GET, + path = "/v1/snapshots/{snapshot}", + tags = ["snapshots"], +}] +async fn snapshot_view_v1( + rqctx: RequestContext>, + path_params: Path, + query_params: Query, +) -> Result, HttpError> { + let apictx = rqctx.context(); + let handler = async { + let opctx = OpContext::for_external_api(&rqctx).await?; + let nexus = &apictx.nexus; + let path = path_params.into_inner(); + let query = query_params.into_inner(); + let snapshot_selector = params::SnapshotSelector { + project_selector: query.project_selector, + snapshot: path.snapshot, + }; + let (.., snapshot) = + nexus.snapshot_lookup(&opctx, &snapshot_selector)?.fetch().await?; + Ok(HttpResponseOk(snapshot.into())) + }; + apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await +} + +/// Fetch a snapshot +/// Use `GET /v1/snapshots/{snapshot}` instead. #[endpoint { method = GET, path = "/organizations/{organization_name}/projects/{project_name}/snapshots/{snapshot_name}", tags = ["snapshots"], + deprecated = true }] async fn snapshot_view( rqctx: RequestContext>, path_params: Path, ) -> Result, HttpError> { let apictx = rqctx.context(); - let nexus = &apictx.nexus; - let path = path_params.into_inner(); - let organization_name = &path.organization_name; - let project_name = &path.project_name; - let snapshot_name = &path.snapshot_name; let handler = async { let opctx = OpContext::for_external_api(&rqctx).await?; - let snapshot = nexus - .snapshot_fetch( - &opctx, - &organization_name, - &project_name, - &snapshot_name, - ) - .await?; + let nexus = &apictx.nexus; + let path = path_params.into_inner(); + let selector = params::SnapshotSelector::new( + Some(path.organization_name.into()), + Some(path.project_name.into()), + path.snapshot_name.into(), + ); + let (.., snapshot) = + nexus.snapshot_lookup(&opctx, &selector)?.fetch().await?; Ok(HttpResponseOk(snapshot.into())) }; apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await } /// Fetch a snapshot by id +/// Use `GET /v1/snapshots/{snapshot}` instead. #[endpoint { method = GET, path = "/by-id/snapshots/{id}", tags = ["snapshots"], + deprecated = true, }] async fn snapshot_view_by_id( rqctx: RequestContext>, path_params: Path, ) -> Result, HttpError> { let apictx = rqctx.context(); - let nexus = &apictx.nexus; - let path = path_params.into_inner(); - let id = &path.id; let handler = async { let opctx = OpContext::for_external_api(&rqctx).await?; - let snapshot = nexus.snapshot_fetch_by_id(&opctx, id).await?; + let nexus = &apictx.nexus; + let path = path_params.into_inner(); + let selector = params::SnapshotSelector { + project_selector: None, + snapshot: path.id.into(), + }; + let (.., snapshot) = + nexus.snapshot_lookup(&opctx, &selector)?.fetch().await?; Ok(HttpResponseOk(snapshot.into())) }; apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await } /// Delete a snapshot +#[endpoint { + method = DELETE, + path = "/v1/snapshots/{snapshot}", + tags = ["snapshots"], +}] +async fn snapshot_delete_v1( + rqctx: RequestContext>, + path_params: Path, + query_params: Query, +) -> Result { + let apictx = rqctx.context(); + let handler = async { + let opctx = OpContext::for_external_api(&rqctx).await?; + let nexus = &apictx.nexus; + let path = path_params.into_inner(); + let query = query_params.into_inner(); + let snapshot_selector = params::SnapshotSelector { + project_selector: query.project_selector, + snapshot: path.snapshot, + }; + let snapshot_lookup = + nexus.snapshot_lookup(&opctx, &snapshot_selector)?; + nexus.snapshot_delete(&opctx, &snapshot_lookup).await?; + Ok(HttpResponseDeleted()) + }; + apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await +} + +/// Delete a snapshot +/// Use `DELETE /v1/snapshots/{snapshot}` instead. #[endpoint { method = DELETE, path = "/organizations/{organization_name}/projects/{project_name}/snapshots/{snapshot_name}", tags = ["snapshots"], + deprecated = true }] async fn snapshot_delete( rqctx: RequestContext>, path_params: Path, ) -> Result { let apictx = rqctx.context(); - let nexus = &apictx.nexus; - let path = path_params.into_inner(); - let organization_name = &path.organization_name; - let project_name = &path.project_name; - let snapshot_name = &path.snapshot_name; let handler = async { let opctx = OpContext::for_external_api(&rqctx).await?; - nexus - .project_delete_snapshot( - &opctx, - &organization_name, - &project_name, - &snapshot_name, - ) - .await?; + let nexus = &apictx.nexus; + let path = path_params.into_inner(); + let snapshot_selector = params::SnapshotSelector::new( + Some(path.organization_name.into()), + Some(path.project_name.into()), + path.snapshot_name.into(), + ); + let snapshot_lookup = + nexus.snapshot_lookup(&opctx, &snapshot_selector)?; + nexus.snapshot_delete(&opctx, &snapshot_lookup).await?; Ok(HttpResponseDeleted()) }; apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await diff --git a/nexus/types/src/external_api/params.rs b/nexus/types/src/external_api/params.rs index 0b1e4fe943a..bfdd7140fab 100644 --- a/nexus/types/src/external_api/params.rs +++ b/nexus/types/src/external_api/params.rs @@ -40,6 +40,11 @@ pub struct DiskPath { pub disk: NameOrId, } +#[derive(Serialize, Deserialize, JsonSchema)] +pub struct SnapshotPath { + pub snapshot: NameOrId, +} + #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)] pub struct OrganizationSelector { pub organization: NameOrId, @@ -102,6 +107,27 @@ impl DiskSelector { } } +#[derive(Deserialize, JsonSchema)] +pub struct SnapshotSelector { + #[serde(flatten)] + pub project_selector: Option, + pub snapshot: NameOrId, +} + +impl SnapshotSelector { + pub fn new( + organization: Option, + project: Option, + snapshot: NameOrId, + ) -> Self { + SnapshotSelector { + project_selector: project + .map(|p| ProjectSelector::new(organization, p)), + snapshot, + } + } +} + #[derive(Deserialize, JsonSchema)] pub struct InstanceSelector { #[serde(flatten)] From a806877cee75d88eb9ffe7e880f159b3d73e2240 Mon Sep 17 00:00:00 2001 From: Justin Bennett Date: Fri, 10 Feb 2023 16:58:34 -0500 Subject: [PATCH 2/4] Fix snapshot create ref failure --- nexus/src/app/snapshot.rs | 6 +++--- nexus/src/external_api/http_entrypoints.rs | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/nexus/src/app/snapshot.rs b/nexus/src/app/snapshot.rs index e547743f88e..76c616fc1a0 100644 --- a/nexus/src/app/snapshot.rs +++ b/nexus/src/app/snapshot.rs @@ -61,7 +61,8 @@ impl super::Nexus { pub async fn snapshot_create( self: &Arc, opctx: &OpContext, - project_lookup: &lookup::Project<'_>, + // Is passed by value due to `disk_name` taking ownership of `self` below + project_lookup: lookup::Project<'_>, params: ¶ms::SnapshotCreate, ) -> CreateResult { let authz_silo: authz::Silo; @@ -70,10 +71,9 @@ impl super::Nexus { let authz_disk: authz::Disk; let db_disk: db::model::Disk; - // FIXME: Borrowing error here with project_lookup due some issue with disk_name? (authz_silo, _authz_org, authz_project, authz_disk, db_disk) = project_lookup - .disk_name(Name::ref_cast(¶ms.disk.clone())) + .disk_name(&db::model::Name(params.disk.clone())) .fetch_for(authz::Action::Read) .await?; diff --git a/nexus/src/external_api/http_entrypoints.rs b/nexus/src/external_api/http_entrypoints.rs index 144b8084a5c..7e638068e56 100644 --- a/nexus/src/external_api/http_entrypoints.rs +++ b/nexus/src/external_api/http_entrypoints.rs @@ -4285,7 +4285,7 @@ async fn snapshot_create_v1( let new_snapshot_params = &new_snapshot.into_inner(); let project_lookup = nexus.project_lookup(&opctx, &query)?; let snapshot = nexus - .snapshot_create(&opctx, &project_lookup, &new_snapshot_params) + .snapshot_create(&opctx, project_lookup, &new_snapshot_params) .await?; Ok(HttpResponseCreated(snapshot.into())) }; @@ -4317,7 +4317,7 @@ async fn snapshot_create( ); let project_lookup = nexus.project_lookup(&opctx, &project_selector)?; let snapshot = nexus - .snapshot_create(&opctx, &project_lookup, &new_snapshot_params) + .snapshot_create(&opctx, project_lookup, &new_snapshot_params) .await?; Ok(HttpResponseCreated(snapshot.into())) }; From 0d5bcb7dae1cac782bf55e84ec9f18ecee94aee2 Mon Sep 17 00:00:00 2001 From: Justin Bennett Date: Fri, 10 Feb 2023 17:11:47 -0500 Subject: [PATCH 3/4] Update nexus api, authz tests --- nexus/tests/integration_tests/endpoints.rs | 13 +- nexus/tests/output/nexus_tags.txt | 4 + .../output/uncovered-authz-endpoints.txt | 5 + openapi/nexus.json | 236 +++++++++++++++++- 4 files changed, 242 insertions(+), 16 deletions(-) diff --git a/nexus/tests/integration_tests/endpoints.rs b/nexus/tests/integration_tests/endpoints.rs index da126b18735..c109fdd7a28 100644 --- a/nexus/tests/integration_tests/endpoints.rs +++ b/nexus/tests/integration_tests/endpoints.rs @@ -119,7 +119,7 @@ lazy_static! { format!("/organizations/{}/projects/{}/images", *DEMO_ORG_NAME, *DEMO_PROJECT_NAME); pub static ref DEMO_PROJECT_URL_INSTANCES: String = format!("/v1/instances?organization={}&project={}", *DEMO_ORG_NAME, *DEMO_PROJECT_NAME); pub static ref DEMO_PROJECT_URL_SNAPSHOTS: String = - format!("/organizations/{}/projects/{}/snapshots", *DEMO_ORG_NAME, *DEMO_PROJECT_NAME); + format!("/v1/snapshots?organization={}&project={}", *DEMO_ORG_NAME, *DEMO_PROJECT_NAME); pub static ref DEMO_PROJECT_URL_VPCS: String = format!("/organizations/{}/projects/{}/vpcs", *DEMO_ORG_NAME, *DEMO_PROJECT_NAME); pub static ref DEMO_PROJECT_CREATE: params::ProjectCreate = @@ -395,7 +395,7 @@ lazy_static! { // Snapshots pub static ref DEMO_SNAPSHOT_NAME: Name = "demo-snapshot".parse().unwrap(); pub static ref DEMO_SNAPSHOT_URL: String = - format!("{}/{}", *DEMO_PROJECT_URL_SNAPSHOTS, *DEMO_SNAPSHOT_NAME); + format!("/v1/snapshots/{}?organization={}&project={}", *DEMO_SNAPSHOT_NAME, *DEMO_ORG_NAME, *DEMO_PROJECT_NAME); pub static ref DEMO_SNAPSHOT_CREATE: params::SnapshotCreate = params::SnapshotCreate { identity: IdentityMetadataCreateParams { @@ -1270,15 +1270,6 @@ lazy_static! { ] }, - VerifyEndpoint { - url: "/by-id/snapshots/{id}", - visibility: Visibility::Protected, - unprivileged_access: UnprivilegedAccess::None, - allowed_methods: vec![ - AllowedMethod::Get, - ], - }, - VerifyEndpoint { url: &DEMO_SNAPSHOT_URL, visibility: Visibility::Protected, diff --git a/nexus/tests/output/nexus_tags.txt b/nexus/tests/output/nexus_tags.txt index d5149314ae5..f8606ebb207 100644 --- a/nexus/tests/output/nexus_tags.txt +++ b/nexus/tests/output/nexus_tags.txt @@ -139,10 +139,14 @@ user_list /users API operations found with tag "snapshots" OPERATION ID URL PATH snapshot_create /organizations/{organization_name}/projects/{project_name}/snapshots +snapshot_create_v1 /v1/snapshots snapshot_delete /organizations/{organization_name}/projects/{project_name}/snapshots/{snapshot_name} +snapshot_delete_v1 /v1/snapshots/{snapshot} snapshot_list /organizations/{organization_name}/projects/{project_name}/snapshots +snapshot_list_v1 /v1/snapshots snapshot_view /organizations/{organization_name}/projects/{project_name}/snapshots/{snapshot_name} snapshot_view_by_id /by-id/snapshots/{id} +snapshot_view_v1 /v1/snapshots/{snapshot} API operations found with tag "system" OPERATION ID URL PATH diff --git a/nexus/tests/output/uncovered-authz-endpoints.txt b/nexus/tests/output/uncovered-authz-endpoints.txt index 653d8046f04..14a5543db4d 100644 --- a/nexus/tests/output/uncovered-authz-endpoints.txt +++ b/nexus/tests/output/uncovered-authz-endpoints.txt @@ -3,10 +3,12 @@ organization_delete (delete "/organizations/{organization_n project_delete (delete "/organizations/{organization_name}/projects/{project_name}") disk_delete (delete "/organizations/{organization_name}/projects/{project_name}/disks/{disk_name}") instance_delete (delete "/organizations/{organization_name}/projects/{project_name}/instances/{instance_name}") +snapshot_delete (delete "/organizations/{organization_name}/projects/{project_name}/snapshots/{snapshot_name}") disk_view_by_id (get "/by-id/disks/{id}") instance_view_by_id (get "/by-id/instances/{id}") organization_view_by_id (get "/by-id/organizations/{id}") project_view_by_id (get "/by-id/projects/{id}") +snapshot_view_by_id (get "/by-id/snapshots/{id}") login_saml_begin (get "/login/{silo_name}/saml/{provider_name}") organization_list (get "/organizations") organization_view (get "/organizations/{organization_name}") @@ -21,6 +23,8 @@ instance_disk_list (get "/organizations/{organization_n instance_serial_console (get "/organizations/{organization_name}/projects/{project_name}/instances/{instance_name}/serial-console") instance_serial_console_stream (get "/organizations/{organization_name}/projects/{project_name}/instances/{instance_name}/serial-console/stream") project_policy_view (get "/organizations/{organization_name}/projects/{project_name}/policy") +snapshot_list (get "/organizations/{organization_name}/projects/{project_name}/snapshots") +snapshot_view (get "/organizations/{organization_name}/projects/{project_name}/snapshots/{snapshot_name}") device_auth_request (post "/device/auth") device_auth_confirm (post "/device/confirm") device_access_token (post "/device/token") @@ -38,6 +42,7 @@ instance_migrate (post "/organizations/{organization_n instance_reboot (post "/organizations/{organization_name}/projects/{project_name}/instances/{instance_name}/reboot") instance_start (post "/organizations/{organization_name}/projects/{project_name}/instances/{instance_name}/start") instance_stop (post "/organizations/{organization_name}/projects/{project_name}/instances/{instance_name}/stop") +snapshot_create (post "/organizations/{organization_name}/projects/{project_name}/snapshots") organization_update (put "/organizations/{organization_name}") organization_policy_update (put "/organizations/{organization_name}/policy") project_update (put "/organizations/{organization_name}/projects/{project_name}") diff --git a/openapi/nexus.json b/openapi/nexus.json index 0640d349b23..883ac9907ea 100644 --- a/openapi/nexus.json +++ b/openapi/nexus.json @@ -250,6 +250,7 @@ "snapshots" ], "summary": "Fetch a snapshot by id", + "description": "Use `GET /v1/snapshots/{snapshot}` instead.", "operationId": "snapshot_view_by_id", "parameters": [ { @@ -279,7 +280,8 @@ "5XX": { "$ref": "#/components/responses/Error" } - } + }, + "deprecated": true } }, "/by-id/vpc-router-routes/{id}": { @@ -3214,6 +3216,7 @@ "snapshots" ], "summary": "List snapshots", + "description": "Use `GET /v1/snapshots` instead.", "operationId": "snapshot_list", "parameters": [ { @@ -3280,6 +3283,7 @@ "$ref": "#/components/responses/Error" } }, + "deprecated": true, "x-dropshot-pagination": true }, "post": { @@ -3287,7 +3291,7 @@ "snapshots" ], "summary": "Create a snapshot", - "description": "Creates a point-in-time snapshot from a disk.", + "description": "Use `POST /v1/snapshots` instead.", "operationId": "snapshot_create", "parameters": [ { @@ -3336,7 +3340,8 @@ "5XX": { "$ref": "#/components/responses/Error" } - } + }, + "deprecated": true } }, "/organizations/{organization_name}/projects/{project_name}/snapshots/{snapshot_name}": { @@ -3345,6 +3350,7 @@ "snapshots" ], "summary": "Fetch a snapshot", + "description": "Use `GET /v1/snapshots/{snapshot}` instead.", "operationId": "snapshot_view", "parameters": [ { @@ -3389,13 +3395,15 @@ "5XX": { "$ref": "#/components/responses/Error" } - } + }, + "deprecated": true }, "delete": { "tags": [ "snapshots" ], "summary": "Delete a snapshot", + "description": "Use `DELETE /v1/snapshots/{snapshot}` instead.", "operationId": "snapshot_delete", "parameters": [ { @@ -3433,7 +3441,8 @@ "5XX": { "$ref": "#/components/responses/Error" } - } + }, + "deprecated": true } }, "/organizations/{organization_name}/projects/{project_name}/vpcs": { @@ -9283,6 +9292,223 @@ } } }, + "/v1/snapshots": { + "get": { + "tags": [ + "snapshots" + ], + "summary": "List snapshots", + "operationId": "snapshot_list_v1", + "parameters": [ + { + "in": "query", + "name": "limit", + "description": "Maximum number of items returned by a single call", + "schema": { + "nullable": true, + "type": "integer", + "format": "uint32", + "minimum": 1 + } + }, + { + "in": "query", + "name": "organization", + "schema": { + "$ref": "#/components/schemas/NameOrId" + } + }, + { + "in": "query", + "name": "page_token", + "description": "Token returned by previous call to retrieve the subsequent page", + "schema": { + "nullable": true, + "type": "string" + } + }, + { + "in": "query", + "name": "project", + "schema": { + "$ref": "#/components/schemas/NameOrId" + } + }, + { + "in": "query", + "name": "sort_by", + "schema": { + "$ref": "#/components/schemas/NameOrIdSortMode" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SnapshotResultsPage" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + }, + "x-dropshot-pagination": true + }, + "post": { + "tags": [ + "snapshots" + ], + "summary": "Create a snapshot", + "description": "Creates a point-in-time snapshot from a disk.", + "operationId": "snapshot_create_v1", + "parameters": [ + { + "in": "query", + "name": "organization", + "schema": { + "$ref": "#/components/schemas/NameOrId" + } + }, + { + "in": "query", + "name": "project", + "required": true, + "schema": { + "$ref": "#/components/schemas/NameOrId" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SnapshotCreate" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "successful creation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Snapshot" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/v1/snapshots/{snapshot}": { + "get": { + "tags": [ + "snapshots" + ], + "summary": "Fetch a snapshot", + "operationId": "snapshot_view_v1", + "parameters": [ + { + "in": "path", + "name": "snapshot", + "required": true, + "schema": { + "$ref": "#/components/schemas/NameOrId" + } + }, + { + "in": "query", + "name": "organization", + "schema": { + "$ref": "#/components/schemas/NameOrId" + } + }, + { + "in": "query", + "name": "project", + "schema": { + "$ref": "#/components/schemas/NameOrId" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Snapshot" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "delete": { + "tags": [ + "snapshots" + ], + "summary": "Delete a snapshot", + "operationId": "snapshot_delete_v1", + "parameters": [ + { + "in": "path", + "name": "snapshot", + "required": true, + "schema": { + "$ref": "#/components/schemas/NameOrId" + } + }, + { + "in": "query", + "name": "organization", + "schema": { + "$ref": "#/components/schemas/NameOrId" + } + }, + { + "in": "query", + "name": "project", + "schema": { + "$ref": "#/components/schemas/NameOrId" + } + } + ], + "responses": { + "204": { + "description": "successful deletion" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, "/v1/system/update/components": { "get": { "tags": [ From b9dc604e035cb2eecc83610d0fac19e828040238 Mon Sep 17 00:00:00 2001 From: Justin Bennett Date: Fri, 10 Feb 2023 17:24:38 -0500 Subject: [PATCH 4/4] Update integration tests --- nexus/tests/integration_tests/snapshots.rs | 40 +++++++++++++--------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/nexus/tests/integration_tests/snapshots.rs b/nexus/tests/integration_tests/snapshots.rs index 259b680fb96..75986c319d8 100644 --- a/nexus/tests/integration_tests/snapshots.rs +++ b/nexus/tests/integration_tests/snapshots.rs @@ -43,12 +43,8 @@ type ControlPlaneTestContext = const ORG_NAME: &str = "test-org"; const PROJECT_NAME: &str = "springfield-squidport-disks"; -fn get_project_url() -> String { - format!("/organizations/{}/projects/{}", ORG_NAME, PROJECT_NAME) -} - fn get_disks_url() -> String { - format!("{}/disks", get_project_url()) + format!("/v1/disks?organization={}&project={}", ORG_NAME, PROJECT_NAME) } async fn create_org_and_project(client: &ClientTestContext) -> Uuid { @@ -135,7 +131,7 @@ async fn test_snapshot_basic(cptestctx: &ControlPlaneTestContext) { // Boot instance with disk let instances_url = format!( - "/organizations/{}/projects/{}/instances", + "/v1/instances?organization={}&project={}", ORG_NAME, PROJECT_NAME, ); let instance_name = "base-instance"; @@ -171,7 +167,7 @@ async fn test_snapshot_basic(cptestctx: &ControlPlaneTestContext) { // Issue snapshot request let snapshots_url = format!( - "/organizations/{}/projects/{}/snapshots", + "/v1/snapshots?organization={}&project={}", ORG_NAME, PROJECT_NAME ); @@ -269,7 +265,10 @@ async fn test_snapshot_without_instance(cptestctx: &ControlPlaneTestContext) { .unwrap(); // Assert disk is detached - let disk_url = format!("{}/{}", disks_url, base_disk_name); + let disk_url = format!( + "/v1/disks/{}?organization={}&project={}", + base_disk_name, ORG_NAME, PROJECT_NAME + ); let disk: Disk = NexusRequest::object_get(client, &disk_url) .authn_as(AuthnMode::PrivilegedUser) .execute() @@ -282,7 +281,7 @@ async fn test_snapshot_without_instance(cptestctx: &ControlPlaneTestContext) { // Issue snapshot request let snapshots_url = format!( - "/organizations/{}/projects/{}/snapshots", + "/v1/snapshots?organization={}&project={}", ORG_NAME, PROJECT_NAME ); @@ -303,7 +302,10 @@ async fn test_snapshot_without_instance(cptestctx: &ControlPlaneTestContext) { assert_eq!(snapshot.size, base_disk.size); // Assert disk is still detached - let disk_url = format!("{}/{}", disks_url, base_disk_name); + let disk_url = format!( + "/v1/disks/{}?organization={}&project={}", + base_disk_name, ORG_NAME, PROJECT_NAME + ); let disk: Disk = NexusRequest::object_get(client, &disk_url) .authn_as(AuthnMode::PrivilegedUser) .execute() @@ -361,7 +363,7 @@ async fn test_delete_snapshot(cptestctx: &ControlPlaneTestContext) { // Issue snapshot request let snapshots_url = format!( - "/organizations/{}/projects/{}/snapshots", + "/v1/snapshots?organization={}&project={}", ORG_NAME, PROJECT_NAME ); @@ -426,8 +428,8 @@ async fn test_delete_snapshot(cptestctx: &ControlPlaneTestContext) { // Delete snapshot let snapshot_url = format!( - "/organizations/{}/projects/{}/snapshots/not-attached", - ORG_NAME, PROJECT_NAME, + "/v1/snapshots/not-attached?organization={}&project={}", + ORG_NAME, PROJECT_NAME ); NexusRequest::new( @@ -449,7 +451,10 @@ async fn test_delete_snapshot(cptestctx: &ControlPlaneTestContext) { ); // Delete the disk using the snapshot - let disk_url = format!("{}/{}", disks_url, snap_disk_name); + let disk_url = format!( + "/v1/disks/{}?organization={}&project={}", + snap_disk_name, ORG_NAME, PROJECT_NAME + ); NexusRequest::object_delete(client, &disk_url) .authn_as(AuthnMode::PrivilegedUser) .execute() @@ -462,7 +467,10 @@ async fn test_delete_snapshot(cptestctx: &ControlPlaneTestContext) { assert_eq!(provision.virtual_disk_bytes_provisioned.0, disk_size); // Delete the original base disk - let disk_url = format!("{}/{}", disks_url, base_disk_name); + let disk_url = format!( + "/v1/disks/{}?organization={}&project={}", + base_disk_name, ORG_NAME, PROJECT_NAME + ); NexusRequest::object_delete(client, &disk_url) .authn_as(AuthnMode::PrivilegedUser) .execute() @@ -764,7 +772,7 @@ async fn test_cannot_snapshot_if_no_space(cptestctx: &ControlPlaneTestContext) { // Issue snapshot request, expect it to fail let snapshots_url = format!( - "/organizations/{}/projects/{}/snapshots", + "/v1/snapshots?organization={}&project={}", ORG_NAME, PROJECT_NAME );