diff --git a/nexus/src/app/sagas/mod.rs b/nexus/src/app/sagas/mod.rs index b73cf62c677..3fdba98c73a 100644 --- a/nexus/src/app/sagas/mod.rs +++ b/nexus/src/app/sagas/mod.rs @@ -25,6 +25,7 @@ pub mod instance_create; pub mod instance_migrate; pub mod snapshot_create; pub mod volume_delete; +pub mod volume_remove_rop; pub mod common_storage; @@ -103,6 +104,9 @@ fn make_action_registry() -> ActionRegistry { ::register_actions( &mut registry, ); + ::register_actions( + &mut registry, + ); registry } diff --git a/nexus/src/app/sagas/volume_remove_rop.rs b/nexus/src/app/sagas/volume_remove_rop.rs new file mode 100644 index 00000000000..caaaa302da0 --- /dev/null +++ b/nexus/src/app/sagas/volume_remove_rop.rs @@ -0,0 +1,199 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +use super::{ActionRegistry, NexusActionContext, NexusSaga, SagaInitError}; +use crate::app::sagas; +use crate::app::sagas::NexusAction; +use crate::db; +use lazy_static::lazy_static; +use omicron_common::api::external::Error; +use serde::Deserialize; +use serde::Serialize; +use sled_agent_client::types::VolumeConstructionRequest; +use std::sync::Arc; +use steno::{new_action_noop_undo, ActionError, ActionFunc, Node}; +use uuid::Uuid; + +// Volume remove read only parent saga: input parameters + +#[derive(Debug, Deserialize, Serialize)] +pub struct Params { + pub volume_id: Uuid, +} + +// Volume remove_read_only_parent saga: actions + +lazy_static! { + // A read-only parent is a structure in a volume that indicates that the + // volume is logically created from this parent. The initial data for the + // volume (implicitly) comes from the parent volume. In the background, + // we'll copy data from the parent into the physical storage allocated + // for this volume and, when that copy has completed, it will no longer + // be necessary to maintain this link to the read-only parent. At that + // point, we execute this saga. + // If this volume was the only one referencing that parent, then it's time + // to free the underlying storage resources of the parent as well. We can + // do this with the volume-delete saga, which takes care of correctly + // identifying whether other volumes are still referencing this parent. + // But we don't actually want to delete this volume. Instead, we create a + // temporary volume, move the read-only parent information from this volume + // to the temporary volume (so that this volume is now independent of the + // parent, and the temporary volume appears to depend on the parent), and + // then delete that temporary volume. + + // Create the temporary volume + static ref CREATE_TEMP_VOLUME: NexusAction = ActionFunc::new_action( + "volume-remove-rop.create-temp-volume", + svr_create_temp_volume, + svr_create_temp_volume_undo + ); + + // remove the read_only_parent, attach it to the temp volume. + static ref REMOVE_READ_ONLY_PARENT: NexusAction = new_action_noop_undo( + "volume-remove-rop.remove-read-only-parent", + svr_remove_read_only_parent + ); +} + +// volume remove read only parent saga: definition + +#[derive(Debug)] +pub struct SagaVolumeRemoveROP; +impl NexusSaga for SagaVolumeRemoveROP { + const NAME: &'static str = "volume-remove-read-only-parent"; + type Params = Params; + + fn register_actions(registry: &mut ActionRegistry) { + registry.register(Arc::clone(&*CREATE_TEMP_VOLUME)); + registry.register(Arc::clone(&*REMOVE_READ_ONLY_PARENT)); + } + + fn make_saga_dag( + _params: &Self::Params, + mut builder: steno::DagBuilder, + ) -> Result { + // Generate the temp volume ID this saga will use. + let temp_volume_id = Uuid::new_v4(); + // Generate the params for the subsaga called at the end. + let subsaga_params = + sagas::volume_delete::Params { volume_id: temp_volume_id }; + let subsaga_dag = { + let subsaga_builder = steno::DagBuilder::new(steno::SagaName::new( + sagas::volume_delete::SagaVolumeDelete::NAME, + )); + sagas::volume_delete::SagaVolumeDelete::make_saga_dag( + &subsaga_params, + subsaga_builder, + )? + }; + + // Add the temp_volume_id to the saga. + builder.append(Node::constant( + "temp_volume_id", + serde_json::to_value(&temp_volume_id).map_err(|e| { + SagaInitError::SerializeError(String::from("temp_volume_id"), e) + })?, + )); + + // Create the temporary volume + builder.append(Node::action( + "temp_volume", + "CreateTempVolume", + CREATE_TEMP_VOLUME.as_ref(), + )); + + // Remove the read only parent, attach to temp volume + builder.append(Node::action( + "no_result_1", + "RemoveReadOnlyParent", + REMOVE_READ_ONLY_PARENT.as_ref(), + )); + + // Build the params for the subsaga to delete the temp volume + builder.append(Node::constant( + "params_for_delete_subsaga", + serde_json::to_value(&subsaga_params).map_err(|e| { + SagaInitError::SerializeError( + String::from("params_for_delete_subsaga"), + e, + ) + })?, + )); + + // Call the subsaga to delete the temp volume + builder.append(Node::subsaga( + "final_no_result", + subsaga_dag, + "params_for_delete_subsaga", + )); + + Ok(builder.build()?) + } +} + +// volume remove read only parent saga: action implementations + +async fn svr_create_temp_volume( + sagactx: NexusActionContext, +) -> Result { + let osagactx = sagactx.user_data(); + + let temp_volume_id = sagactx.lookup::("temp_volume_id")?; + + // Create the crucible VolumeConstructionRequest which we use + // for the temporary volume. + let volume_construction_request = VolumeConstructionRequest::Volume { + id: temp_volume_id, + block_size: 512, + sub_volumes: vec![], + read_only_parent: None, + }; + let temp_volume_data = serde_json::to_string(&volume_construction_request) + .map_err(|e| { + ActionError::action_failed(Error::internal_error(&format!( + "failed to deserialize volume data: {}", + e, + ))) + })?; + + let volume = db::model::Volume::new(temp_volume_id, temp_volume_data); + let volume_created = osagactx + .datastore() + .volume_create(volume) + .await + .map_err(ActionError::action_failed)?; + + Ok(volume_created) +} + +async fn svr_create_temp_volume_undo( + sagactx: NexusActionContext, +) -> Result<(), anyhow::Error> { + let osagactx = sagactx.user_data(); + + let temp_volume_id = sagactx.lookup::("temp_volume_id")?; + + osagactx + .datastore() + .volume_hard_delete(temp_volume_id) + .await + .map_err(ActionError::action_failed)?; + Ok(()) +} + +async fn svr_remove_read_only_parent( + sagactx: NexusActionContext, +) -> Result<(), ActionError> { + let osagactx = sagactx.user_data(); + let params = sagactx.saga_params::()?; + + let temp_volume_id = sagactx.lookup::("temp_volume_id")?; + + osagactx + .datastore() + .volume_remove_rop(params.volume_id, temp_volume_id) + .await + .map_err(ActionError::action_failed)?; + Ok(()) +} diff --git a/nexus/src/app/volume.rs b/nexus/src/app/volume.rs index e8bfe362920..86c01b19c2a 100644 --- a/nexus/src/app/volume.rs +++ b/nexus/src/app/volume.rs @@ -40,4 +40,19 @@ impl super::Nexus { Ok(volume_deleted) } + + /// Start a saga to remove a read only parent from a volume. + pub async fn volume_remove_read_only_parent( + self: &Arc, + volume_id: Uuid, + ) -> DeleteResult { + let saga_params = sagas::volume_remove_rop::Params { volume_id }; + + self.execute_saga::( + saga_params, + ) + .await?; + + Ok(()) + } } diff --git a/nexus/src/db/datastore/volume.rs b/nexus/src/db/datastore/volume.rs index 8be451592c0..a4882ab5ef3 100644 --- a/nexus/src/db/datastore/volume.rs +++ b/nexus/src/db/datastore/volume.rs @@ -447,6 +447,197 @@ impl DataStore { } }) } + + // Here we remove the read only parent from volume_id, and attach it + // to temp_volume_id. + // + // As this is part of a saga, it will be able to handle being replayed + // If we call this twice, any work done the first time through should + // not happen again, or be undone. + pub async fn volume_remove_rop( + &self, + volume_id: Uuid, + temp_volume_id: Uuid, + ) -> Result { + #[derive(Debug, thiserror::Error)] + enum RemoveReadOnlyParentError { + #[error("Error removing read only parent: {0}")] + DieselError(#[from] diesel::result::Error), + + #[error("Serde error removing read only parent: {0}")] + SerdeError(#[from] serde_json::Error), + + #[error("Updated {0} database rows, expected {1}")] + UnexpectedDatabaseUpdate(usize, usize), + } + type TxnError = TransactionError; + + // In this single transaction: + // - Get the given volume from the volume_id from the database + // - Extract the volume.data into a VolumeConstructionRequest (VCR) + // - Create a new VCR, copying over anything from the original VCR, + // but, replacing the read_only_parent with None. + // - Put the new VCR into volume.data, then update the volume in the + // database. + // - Get the given volume from temp_volume_id from the database + // - Extract the temp volume.data into a VCR + // - Create a new VCR, copying over anything from the original VCR, + // but, replacing the read_only_parent with the read_only_parent + // data from original volume_id. + // - Put the new temp VCR into the temp volume.data, update the + // temp_volume in the database. + self.pool() + .transaction(move |conn| { + // Grab the volume in question. If the volume record was already + // deleted then we can just return. + let volume = { + use db::schema::volume::dsl; + + let volume = dsl::volume + .filter(dsl::id.eq(volume_id)) + .select(Volume::as_select()) + .get_result(conn) + .optional()?; + + let volume = if let Some(v) = volume { + v + } else { + // the volume does not exist, nothing to do. + return Ok(false); + }; + + if volume.time_deleted.is_some() { + // this volume is deleted, so let whatever is deleting + // it clean it up. + return Ok(false); + } else { + // A volume record exists, and was not deleted, we + // can attempt to remove its read_only_parent. + volume + } + }; + + // If a read_only_parent exists, remove it from volume_id, and + // attach it to temp_volume_id. + let vcr: VolumeConstructionRequest = + serde_json::from_str( + volume.data() + ) + .map_err(|e| { + TxnError::CustomError( + RemoveReadOnlyParentError::SerdeError( + e, + ), + ) + })?; + + match vcr { + VolumeConstructionRequest::Volume { + id, + block_size, + sub_volumes, + read_only_parent, + } => { + if read_only_parent.is_none() { + // This volume has no read_only_parent + Ok(false) + } else { + // Create a new VCR and fill in the contents + // from what the original volume had. + let new_vcr = VolumeConstructionRequest::Volume { + id, + block_size, + sub_volumes, + read_only_parent: None, + }; + + let new_volume_data = + serde_json::to_string( + &new_vcr + ) + .map_err(|e| { + TxnError::CustomError( + RemoveReadOnlyParentError::SerdeError( + e, + ), + ) + })?; + + // Update the original volume_id with the new + // volume.data. + use db::schema::volume::dsl as volume_dsl; + let num_updated = diesel::update(volume_dsl::volume) + .filter(volume_dsl::id.eq(volume_id)) + .set(volume_dsl::data.eq(new_volume_data)) + .execute(conn)?; + + // This should update just one row. If it does + // not, then something is terribly wrong in the + // database. + if num_updated != 1 { + return Err(TxnError::CustomError( + RemoveReadOnlyParentError::UnexpectedDatabaseUpdate(num_updated, 1), + )); + } + + // Make a new VCR, with the information from + // our temp_volume_id, but the read_only_parent + // from the original volume. + let rop_vcr = VolumeConstructionRequest::Volume { + id: temp_volume_id, + block_size, + sub_volumes: vec![], + read_only_parent, + }; + let rop_volume_data = + serde_json::to_string( + &rop_vcr + ) + .map_err(|e| { + TxnError::CustomError( + RemoveReadOnlyParentError::SerdeError( + e, + ), + ) + })?; + // Update the temp_volume_id with the volume + // data that contains the read_only_parent. + let num_updated = + diesel::update(volume_dsl::volume) + .filter(volume_dsl::id.eq(temp_volume_id)) + .filter(volume_dsl::time_deleted.is_null()) + .set(volume_dsl::data.eq(rop_volume_data)) + .execute(conn)?; + if num_updated != 1 { + return Err(TxnError::CustomError( + RemoveReadOnlyParentError::UnexpectedDatabaseUpdate(num_updated, 1), + )); + } + Ok(true) + } + } + VolumeConstructionRequest::File { id: _, block_size: _, path: _ } + | VolumeConstructionRequest::Region { block_size: _, opts: _, gen: _ } + | VolumeConstructionRequest::Url { id: _, block_size: _, url: _ } => { + // Volume has a format that does not contain ROPs + Ok(false) + } + } + }) + .await + .map_err(|e| match e { + TxnError::CustomError( + RemoveReadOnlyParentError::DieselError(e), + ) => public_error_from_diesel_pool( + e.into(), + ErrorHandler::Server, + ), + + _ => { + Error::internal_error(&format!("Transaction error: {}", e)) + } + }) + } } #[derive(Default)] diff --git a/nexus/src/internal_api/http_entrypoints.rs b/nexus/src/internal_api/http_entrypoints.rs index b75caddda13..692dd82b9b6 100644 --- a/nexus/src/internal_api/http_entrypoints.rs +++ b/nexus/src/internal_api/http_entrypoints.rs @@ -42,6 +42,7 @@ pub fn internal_api() -> NexusApiDescription { api.register(dataset_put)?; api.register(cpapi_instances_put)?; api.register(cpapi_disks_put)?; + api.register(cpapi_volume_remove_read_only_parent)?; api.register(cpapi_producers_post)?; api.register(cpapi_collectors_post)?; api.register(cpapi_metrics_collect)?; @@ -224,6 +225,39 @@ async fn cpapi_disks_put( apictx.internal_latencies.instrument_dropshot_handler(&rqctx, handler).await } +/// Path parameters for Volume requests (internal API) +#[derive(Deserialize, JsonSchema)] +struct VolumePathParam { + volume_id: Uuid, +} + +/// Request removal of a read_only_parent from a volume +/// A volume can be created with the source data for that volume being another +/// volume that attached as a "read_only_parent". In the background there +/// exists a scrubber that will copy the data from the read_only_parent +/// into the volume. When that scrubber has completed copying the data, this +/// endpoint can be called to update the database that the read_only_parent +/// is no longer needed for a volume and future attachments of this volume +/// should not include that read_only_parent. +#[endpoint { + method = POST, + path = "/volume/{volume_id}/remove-read-only-parent", + }] +async fn cpapi_volume_remove_read_only_parent( + rqctx: Arc>>, + path_params: Path, +) -> Result { + let apictx = rqctx.context(); + let nexus = &apictx.nexus; + let path = path_params.into_inner(); + + let handler = async { + nexus.volume_remove_read_only_parent(path.volume_id).await?; + Ok(HttpResponseUpdatedNoContent()) + }; + apictx.internal_latencies.instrument_dropshot_handler(&rqctx, handler).await +} + /// Accept a registration from a new metric producer #[endpoint { method = POST, diff --git a/nexus/tests/integration_tests/volume_management.rs b/nexus/tests/integration_tests/volume_management.rs index 1943ec3e6c8..469d35ffb2a 100644 --- a/nexus/tests/integration_tests/volume_management.rs +++ b/nexus/tests/integration_tests/volume_management.rs @@ -22,11 +22,13 @@ use omicron_common::api::external::ByteCount; use omicron_common::api::external::Disk; use omicron_common::api::external::IdentityMetadataCreateParams; use omicron_common::api::external::Name; +use omicron_nexus::db::DataStore; use omicron_nexus::external_api::params; use omicron_nexus::external_api::views; use rand::prelude::SliceRandom; use rand::{rngs::StdRng, SeedableRng}; use sled_agent_client::types::VolumeConstructionRequest; +use std::sync::Arc; use uuid::Uuid; use httptest::{matchers::*, responders::*, Expectation, ServerBuilder}; @@ -48,21 +50,9 @@ async fn create_org_and_project(client: &ClientTestContext) -> Uuid { project.identity.id } -#[nexus_test] -async fn test_snapshot_then_delete_disk(cptestctx: &ControlPlaneTestContext) { - // Test that Nexus does not delete a region if there's a snapshot of that - // region: - // - // 1. Create a disk - // 2. Create a snapshot of that disk (creating running snapshots) - // 3. Delete the disk - // 4. Delete the snapshot - - let client = &cptestctx.external_client; - let disk_test = DiskTest::new(&cptestctx).await; +async fn create_global_image(client: &ClientTestContext) -> views::GlobalImage { create_ip_pool(&client, "p0", None, None).await; create_org_and_project(client).await; - let disks_url = get_disks_url(); // Define a global image let server = ServerBuilder::new().run().unwrap(); @@ -94,21 +84,22 @@ async fn test_snapshot_then_delete_disk(cptestctx: &ControlPlaneTestContext) { block_size: params::BlockSize::try_from(512).unwrap(), }; - let global_image: views::GlobalImage = NexusRequest::objects_post( - client, - "/system/images", - &image_create_params, - ) - .authn_as(AuthnMode::PrivilegedUser) - .execute() - .await - .unwrap() - .parsed_body() - .unwrap(); + NexusRequest::objects_post(client, "/system/images", &image_create_params) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .unwrap() + .parsed_body() + .unwrap() +} - // Create a disk from this image +async fn create_base_disk( + client: &ClientTestContext, + global_image: &views::GlobalImage, + disks_url: &String, + base_disk_name: &Name, +) -> Disk { let disk_size = ByteCount::from_gibibytes_u32(2); - let base_disk_name: Name = "base-disk".parse().unwrap(); let base_disk = params::DiskCreate { identity: IdentityMetadataCreateParams { name: base_disk_name.clone(), @@ -120,7 +111,7 @@ async fn test_snapshot_then_delete_disk(cptestctx: &ControlPlaneTestContext) { size: disk_size, }; - let base_disk: Disk = NexusRequest::new( + NexusRequest::new( RequestBuilder::new(client, Method::POST, &disks_url) .body(Some(&base_disk)) .expect_status(Some(StatusCode::CREATED)), @@ -130,7 +121,29 @@ async fn test_snapshot_then_delete_disk(cptestctx: &ControlPlaneTestContext) { .await .unwrap() .parsed_body() - .unwrap(); + .unwrap() +} + +#[nexus_test] +async fn test_snapshot_then_delete_disk(cptestctx: &ControlPlaneTestContext) { + // Test that Nexus does not delete a region if there's a snapshot of that + // region: + // + // 1. Create a disk + // 2. Create a snapshot of that disk (creating running snapshots) + // 3. Delete the disk + // 4. Delete the snapshot + + let client = &cptestctx.external_client; + let disk_test = DiskTest::new(&cptestctx).await; + let disks_url = get_disks_url(); + let base_disk_name: Name = "base-disk".parse().unwrap(); + + let global_image = create_global_image(&client).await; + // Create a disk from this image + let base_disk = + create_base_disk(&client, &global_image, &disks_url, &base_disk_name) + .await; // Issue snapshot request let snapshots_url = format!( @@ -192,77 +205,15 @@ async fn test_delete_snapshot_then_disk(cptestctx: &ControlPlaneTestContext) { let client = &cptestctx.external_client; let disk_test = DiskTest::new(&cptestctx).await; - create_ip_pool(&client, "p0", None, None).await; - create_org_and_project(client).await; let disks_url = get_disks_url(); + let base_disk_name: Name = "base-disk".parse().unwrap(); // Define a global image - let server = ServerBuilder::new().run().unwrap(); - server.expect( - Expectation::matching(request::method_path("HEAD", "/image.raw")) - .times(1..) - .respond_with( - status_code(200).append_header( - "Content-Length", - format!("{}", 4096 * 1000), - ), - ), - ); - - let image_create_params = params::GlobalImageCreate { - identity: IdentityMetadataCreateParams { - name: "alpine-edge".parse().unwrap(), - description: String::from( - "you can boot any image, as long as it's alpine", - ), - }, - source: params::ImageSource::Url { - url: server.url("/image.raw").to_string(), - }, - distribution: params::Distribution { - name: "alpine".parse().unwrap(), - version: "edge".into(), - }, - block_size: params::BlockSize::try_from(512).unwrap(), - }; - - let global_image: views::GlobalImage = NexusRequest::objects_post( - client, - "/system/images", - &image_create_params, - ) - .authn_as(AuthnMode::PrivilegedUser) - .execute() - .await - .unwrap() - .parsed_body() - .unwrap(); - + let global_image = create_global_image(&client).await; // Create a disk from this image - let disk_size = ByteCount::from_gibibytes_u32(2); - let base_disk_name: Name = "base-disk".parse().unwrap(); - let base_disk = params::DiskCreate { - identity: IdentityMetadataCreateParams { - name: base_disk_name.clone(), - description: String::from("sells rainsticks"), - }, - disk_source: params::DiskSource::GlobalImage { - image_id: global_image.identity.id, - }, - size: disk_size, - }; - - let base_disk: Disk = NexusRequest::new( - RequestBuilder::new(client, Method::POST, &disks_url) - .body(Some(&base_disk)) - .expect_status(Some(StatusCode::CREATED)), - ) - .authn_as(AuthnMode::PrivilegedUser) - .execute() - .await - .unwrap() - .parsed_body() - .unwrap(); + let base_disk = + create_base_disk(&client, &global_image, &disks_url, &base_disk_name) + .await; // Issue snapshot request let snapshots_url = format!( @@ -324,77 +275,14 @@ async fn test_multiple_snapshots(cptestctx: &ControlPlaneTestContext) { let client = &cptestctx.external_client; let disk_test = DiskTest::new(&cptestctx).await; - create_ip_pool(&client, "p0", None, None).await; - create_org_and_project(client).await; let disks_url = get_disks_url(); - - // Define a global image - let server = ServerBuilder::new().run().unwrap(); - server.expect( - Expectation::matching(request::method_path("HEAD", "/image.raw")) - .times(1..) - .respond_with( - status_code(200).append_header( - "Content-Length", - format!("{}", 4096 * 1000), - ), - ), - ); - - let image_create_params = params::GlobalImageCreate { - identity: IdentityMetadataCreateParams { - name: "alpine-edge".parse().unwrap(), - description: String::from( - "you can boot any image, as long as it's alpine", - ), - }, - source: params::ImageSource::Url { - url: server.url("/image.raw").to_string(), - }, - distribution: params::Distribution { - name: "alpine".parse().unwrap(), - version: "edge".into(), - }, - block_size: params::BlockSize::try_from(512).unwrap(), - }; - - let global_image: views::GlobalImage = NexusRequest::objects_post( - client, - "/system/images", - &image_create_params, - ) - .authn_as(AuthnMode::PrivilegedUser) - .execute() - .await - .unwrap() - .parsed_body() - .unwrap(); - - // Create a disk from this image - let disk_size = ByteCount::from_gibibytes_u32(1); let base_disk_name: Name = "base-disk".parse().unwrap(); - let base_disk = params::DiskCreate { - identity: IdentityMetadataCreateParams { - name: base_disk_name.clone(), - description: String::from("sells rainsticks"), - }, - disk_source: params::DiskSource::GlobalImage { - image_id: global_image.identity.id, - }, - size: disk_size, - }; - let base_disk: Disk = NexusRequest::new( - RequestBuilder::new(client, Method::POST, &disks_url) - .body(Some(&base_disk)) - .expect_status(Some(StatusCode::CREATED)), - ) - .authn_as(AuthnMode::PrivilegedUser) - .execute() - .await - .unwrap() - .parsed_body() - .unwrap(); + let global_image = create_global_image(&client).await; + // Create a disk from this image + let base_disk = + create_base_disk(&client, &global_image, &disks_url, &base_disk_name) + .await; // Issue snapshot requests let snapshots_url = format!( @@ -455,79 +343,17 @@ async fn test_snapshot_prevents_other_disk( ) { // Test that region remains if there is a snapshot, preventing further // allocation. + let client = &cptestctx.external_client; let disk_test = DiskTest::new(&cptestctx).await; - create_ip_pool(&client, "p0", None, None).await; - create_org_and_project(client).await; let disks_url = get_disks_url(); - - // Define a global image - let server = ServerBuilder::new().run().unwrap(); - server.expect( - Expectation::matching(request::method_path("HEAD", "/image.raw")) - .times(1..) - .respond_with( - status_code(200).append_header( - "Content-Length", - format!("{}", 4096 * 1000), - ), - ), - ); - - let image_create_params = params::GlobalImageCreate { - identity: IdentityMetadataCreateParams { - name: "alpine-edge".parse().unwrap(), - description: String::from( - "you can boot any image, as long as it's alpine", - ), - }, - source: params::ImageSource::Url { - url: server.url("/image.raw").to_string(), - }, - distribution: params::Distribution { - name: "alpine".parse().unwrap(), - version: "edge".into(), - }, - block_size: params::BlockSize::try_from(512).unwrap(), - }; - - let global_image: views::GlobalImage = NexusRequest::objects_post( - client, - "/system/images", - &image_create_params, - ) - .authn_as(AuthnMode::PrivilegedUser) - .execute() - .await - .unwrap() - .parsed_body() - .unwrap(); - - // Create a disk from this image - let disk_size = ByteCount::from_gibibytes_u32(2); let base_disk_name: Name = "base-disk".parse().unwrap(); - let base_disk = params::DiskCreate { - identity: IdentityMetadataCreateParams { - name: base_disk_name.clone(), - description: String::from("sells rainsticks"), - }, - disk_source: params::DiskSource::GlobalImage { - image_id: global_image.identity.id, - }, - size: disk_size, - }; - let base_disk: Disk = NexusRequest::new( - RequestBuilder::new(client, Method::POST, &disks_url) - .body(Some(&base_disk)) - .expect_status(Some(StatusCode::CREATED)), - ) - .authn_as(AuthnMode::PrivilegedUser) - .execute() - .await - .unwrap() - .parsed_body() - .unwrap(); + let global_image = create_global_image(&client).await; + // Create a disk from this image + let base_disk = + create_base_disk(&client, &global_image, &disks_url, &base_disk_name) + .await; // Issue snapshot request let snapshots_url = format!( @@ -1208,6 +1034,481 @@ async fn test_multiple_layers_of_snapshots_random_delete_order( assert!(disk_test.crucible_resources_deleted().await); } +// A test function to create a volume with the provided read only parent. +async fn create_volume( + datastore: &Arc, + volume_id: Uuid, + rop_option: Option, +) { + let block_size = 512; + + // Make the SubVolume + let sub_volume = VolumeConstructionRequest::File { + id: volume_id, + block_size, + path: "/lol".to_string(), + }; + let sub_volumes = vec![sub_volume]; + + let rop = match rop_option { + Some(x) => Some(Box::new(x)), + None => None, + }; + + // Create the volume from the parts above and insert into the database. + datastore + .volume_create(nexus_db_model::Volume::new( + volume_id, + serde_json::to_string(&VolumeConstructionRequest::Volume { + id: volume_id, + block_size, + sub_volumes, + read_only_parent: rop, + }) + .unwrap(), + )) + .await + .unwrap(); +} + +#[nexus_test] +async fn test_volume_remove_read_only_parent_base( + cptestctx: &ControlPlaneTestContext, +) { + // Test the removal of a volume with a read only parent. + // The ROP should end up on the t_vid volume. + let nexus = &cptestctx.server.apictx.nexus; + let datastore = nexus.datastore(); + + let volume_id = Uuid::new_v4(); + let t_vid = Uuid::new_v4(); + let block_size = 512; + + // Make our read_only_parent + let rop = VolumeConstructionRequest::Url { + id: Uuid::new_v4(), + block_size, + url: "http://oxide.computer/rop".to_string(), + }; + + // Create the Volume with a read_only_parent, and the temp volume that + // the saga would create for us. + create_volume(&datastore, volume_id, Some(rop)).await; + create_volume(&datastore, t_vid, None).await; + + // We should get Ok(true) back after removal of the ROP. + let res = datastore.volume_remove_rop(volume_id, t_vid).await.unwrap(); + assert!(res); + + // Go and get the volume from the database, verify it no longer + // has a read only parent. + let new_vol = datastore.volume_get(volume_id).await.unwrap(); + let vcr: VolumeConstructionRequest = + serde_json::from_str(new_vol.data()).unwrap(); + + match vcr { + VolumeConstructionRequest::Volume { + id: _, + block_size: _, + sub_volumes: _, + read_only_parent, + } => { + assert!(read_only_parent.is_none()); + } + x => { + panic!("Unexpected volume type returned: {:?}", x); + } + } + + // Verify the t_vid now has a ROP. + let new_vol = datastore.volume_get(t_vid).await.unwrap(); + let vcr: VolumeConstructionRequest = + serde_json::from_str(new_vol.data()).unwrap(); + + match vcr { + VolumeConstructionRequest::Volume { + id: _, + block_size: _, + sub_volumes: _, + read_only_parent, + } => { + assert!(read_only_parent.is_some()); + } + x => { + panic!("Unexpected volume type returned: {:?}", x); + } + } + + // Try to remove the read only parent a 2nd time, it should + // return Ok(false) as there is now no volume to remove. + let res = datastore.volume_remove_rop(volume_id, t_vid).await.unwrap(); + assert!(!res); + + // Verify the t_vid still has the read_only_parent. + // We want to verify we can call volume_remove_rop twice and the second + // time through it won't change what it did the first time. This is + // critical to supporting replay of the saga, should it be needed. + let new_vol = datastore.volume_get(t_vid).await.unwrap(); + let vcr: VolumeConstructionRequest = + serde_json::from_str(new_vol.data()).unwrap(); + + match vcr { + VolumeConstructionRequest::Volume { + id: _, + block_size: _, + sub_volumes: _, + read_only_parent, + } => { + assert!(read_only_parent.is_some()); + } + x => { + panic!("Unexpected volume type returned: {:?}", x); + } + } +} + +#[nexus_test] +async fn test_volume_remove_read_only_parent_no_parent( + cptestctx: &ControlPlaneTestContext, +) { + // Test the removal of a read only parent from a volume + // without a read only parent. + let nexus = &cptestctx.server.apictx.nexus; + let datastore = nexus.datastore(); + + let volume_id = Uuid::new_v4(); + let t_vid = Uuid::new_v4(); + create_volume(&datastore, volume_id, None).await; + + // We will get Ok(false) back from this operation. + let res = datastore.volume_remove_rop(volume_id, t_vid).await.unwrap(); + assert!(!res); +} + +#[nexus_test] +async fn test_volume_remove_read_only_parent_volume_not_volume( + cptestctx: &ControlPlaneTestContext, +) { + // test removal of a read only volume for a volume that is not + // of a type to have a read only parent. + let nexus = &cptestctx.server.apictx.nexus; + let datastore = nexus.datastore(); + + let volume_id = Uuid::new_v4(); + let t_vid = Uuid::new_v4(); + + datastore + .volume_create(nexus_db_model::Volume::new( + volume_id, + serde_json::to_string(&VolumeConstructionRequest::File { + id: volume_id, + block_size: 512, + path: "/lol".to_string(), + }) + .unwrap(), + )) + .await + .unwrap(); + + let removed = datastore.volume_remove_rop(volume_id, t_vid).await.unwrap(); + assert!(!removed); +} + +#[nexus_test] +async fn test_volume_remove_read_only_parent_bad_volume( + cptestctx: &ControlPlaneTestContext, +) { + // Test the removal of a read only parent from a volume + // that does not exist + let nexus = &cptestctx.server.apictx.nexus; + let datastore = nexus.datastore(); + + let volume_id = Uuid::new_v4(); + let t_vid = Uuid::new_v4(); + + // Nothing should be removed, but we also don't return error. + let removed = datastore.volume_remove_rop(volume_id, t_vid).await.unwrap(); + assert!(!removed); +} + +#[nexus_test] +async fn test_volume_remove_read_only_parent_volume_deleted( + cptestctx: &ControlPlaneTestContext, +) { + // Test the removal of a read_only_parent from a deleted volume. + let nexus = &cptestctx.server.apictx.nexus; + let datastore = nexus.datastore(); + let volume_id = Uuid::new_v4(); + let block_size = 512; + + // Make our read_only_parent + let rop = VolumeConstructionRequest::Url { + id: Uuid::new_v4(), + block_size, + url: "http://oxide.computer/rop".to_string(), + }; + // Make the volume + create_volume(&datastore, volume_id, Some(rop)).await; + + // Soft delete the volume + let _cr = datastore + .decrease_crucible_resource_count_and_soft_delete_volume(volume_id) + .await + .unwrap(); + + let t_vid = Uuid::new_v4(); + // Nothing should be removed, but we also don't return error. + let removed = datastore.volume_remove_rop(volume_id, t_vid).await.unwrap(); + assert!(!removed); +} + +#[nexus_test] +async fn test_volume_remove_rop_saga(cptestctx: &ControlPlaneTestContext) { + // Test the saga for removal of a volume with a read only parent. + // We create a volume with a read only parent, then call the saga on it. + let nexus = &cptestctx.server.apictx.nexus; + let datastore = nexus.datastore(); + + let volume_id = Uuid::new_v4(); + let block_size = 512; + + // Make our read_only_parent + let rop = VolumeConstructionRequest::Url { + id: Uuid::new_v4(), + block_size, + url: "http://oxide.computer/rop".to_string(), + }; + + create_volume(&datastore, volume_id, Some(rop)).await; + + println!("Created this volume: {:?}", volume_id); + // disk to volume id, to then remove ROP? + let int_client = &cptestctx.internal_client; + let rop_url = format!("/volume/{}/remove-read-only-parent", volume_id); + + // Call the internal API endpoint for removal of the read only parent + int_client + .make_request( + Method::POST, + &rop_url, + None as Option<&serde_json::Value>, + StatusCode::NO_CONTENT, + ) + .await + .unwrap(); + + let new_vol = datastore.volume_get(volume_id).await.unwrap(); + let vcr: VolumeConstructionRequest = + serde_json::from_str(new_vol.data()).unwrap(); + + match vcr { + VolumeConstructionRequest::Volume { + id: _, + block_size: _, + sub_volumes: _, + read_only_parent, + } => { + assert!(read_only_parent.is_none()); + } + x => { + panic!("Unexpected volume type returned: {:?}", x); + } + } +} + +#[nexus_test] +async fn test_volume_remove_rop_saga_twice( + cptestctx: &ControlPlaneTestContext, +) { + // Test calling the saga for removal of a volume with a read only parent + // two times, the first will remove the read_only_parent, the second will + // do nothing. + let nexus = &cptestctx.server.apictx.nexus; + let datastore = nexus.datastore(); + + let volume_id = Uuid::new_v4(); + let block_size = 512; + + // Make our read_only_parent + let rop = VolumeConstructionRequest::Url { + id: Uuid::new_v4(), + block_size, + url: "http://oxide.computer/rop".to_string(), + }; + + create_volume(&datastore, volume_id, Some(rop)).await; + + println!("Created this volume: {:?}", volume_id); + // disk to volume id, to then remove ROP? + let int_client = &cptestctx.internal_client; + let rop_url = format!("/volume/{}/remove-read-only-parent", volume_id); + + // Call the internal API endpoint for removal of the read only parent + let res = int_client + .make_request( + Method::POST, + &rop_url, + None as Option<&serde_json::Value>, + StatusCode::NO_CONTENT, + ) + .await + .unwrap(); + + println!("first returns {:?}", res); + let new_vol = datastore.volume_get(volume_id).await.unwrap(); + let vcr: VolumeConstructionRequest = + serde_json::from_str(new_vol.data()).unwrap(); + + match vcr { + VolumeConstructionRequest::Volume { + id: _, + block_size: _, + sub_volumes: _, + read_only_parent, + } => { + assert!(read_only_parent.is_none()); + } + x => { + panic!("Unexpected volume type returned: {:?}", x); + } + } + + // Call the internal API endpoint a second time. Should be okay. + let res = int_client + .make_request( + Method::POST, + &rop_url, + None as Option<&serde_json::Value>, + StatusCode::NO_CONTENT, + ) + .await + .unwrap(); + + println!("twice returns {:?}", res); +} + +#[nexus_test] +async fn test_volume_remove_rop_saga_no_volume( + cptestctx: &ControlPlaneTestContext, +) { + // Test calling the saga on a volume that does not exist. + let volume_id = Uuid::new_v4(); + + println!("Non-existant volume: {:?}", volume_id); + let int_client = &cptestctx.internal_client; + let rop_url = format!("/volume/{}/remove-read-only-parent", volume_id); + + // Call the internal API endpoint for removal of the read only parent + int_client + .make_request( + Method::POST, + &rop_url, + None as Option<&serde_json::Value>, + StatusCode::NO_CONTENT, + ) + .await + .unwrap(); +} + +#[nexus_test] +async fn test_volume_remove_rop_saga_volume_not_volume( + cptestctx: &ControlPlaneTestContext, +) { + // Test saga removal of a read only volume for a volume that is not + // of a type to have a read only parent. + let nexus = &cptestctx.server.apictx.nexus; + let volume_id = Uuid::new_v4(); + let datastore = nexus.datastore(); + + datastore + .volume_create(nexus_db_model::Volume::new( + volume_id, + serde_json::to_string(&VolumeConstructionRequest::File { + id: volume_id, + block_size: 512, + path: "/lol".to_string(), + }) + .unwrap(), + )) + .await + .unwrap(); + + let int_client = &cptestctx.internal_client; + // Call the saga on this volume + let rop_url = format!("/volume/{}/remove-read-only-parent", volume_id); + + // Call the internal API endpoint for removal of the read only parent + int_client + .make_request( + Method::POST, + &rop_url, + None as Option<&serde_json::Value>, + StatusCode::NO_CONTENT, + ) + .await + .unwrap(); +} + +#[nexus_test] +async fn test_volume_remove_rop_saga_deleted_volume( + cptestctx: &ControlPlaneTestContext, +) { + // Test that a saga removal of a read_only_parent from a deleted volume + // takes no action on that deleted volume. + let nexus = &cptestctx.server.apictx.nexus; + let datastore = nexus.datastore(); + let volume_id = Uuid::new_v4(); + let block_size = 512; + + // Make our read_only_parent + let rop = VolumeConstructionRequest::Url { + id: Uuid::new_v4(), + block_size, + url: "http://oxide.computer/rop".to_string(), + }; + // Make the volume + create_volume(&datastore, volume_id, Some(rop)).await; + + // Soft delete the volume + let _cr = datastore + .decrease_crucible_resource_count_and_soft_delete_volume(volume_id) + .await + .unwrap(); + + let int_client = &cptestctx.internal_client; + let rop_url = format!("/volume/{}/remove-read-only-parent", volume_id); + + // Call the internal API endpoint for removal of the read only parent + int_client + .make_request( + Method::POST, + &rop_url, + None as Option<&serde_json::Value>, + StatusCode::NO_CONTENT, + ) + .await + .unwrap(); + + let new_vol = datastore.volume_get(volume_id).await.unwrap(); + let vcr: VolumeConstructionRequest = + serde_json::from_str(new_vol.data()).unwrap(); + + // Volume should still have read only parent + match vcr { + VolumeConstructionRequest::Volume { + id: _, + block_size: _, + sub_volumes: _, + read_only_parent, + } => { + assert!(read_only_parent.is_some()); + } + x => { + panic!("Unexpected volume type returned: {:?}", x); + } + } +} + // volume_delete saga node idempotency tests #[nexus_test] diff --git a/openapi/nexus-internal.json b/openapi/nexus-internal.json index 9cfb220e9e8..c125ed92d89 100644 --- a/openapi/nexus-internal.json +++ b/openapi/nexus-internal.json @@ -373,6 +373,36 @@ } } }, + "/volume/{volume_id}/remove-read-only-parent": { + "post": { + "summary": "Request removal of a read_only_parent from a volume", + "description": "A volume can be created with the source data for that volume being another volume that attached as a \"read_only_parent\". In the background there exists a scrubber that will copy the data from the read_only_parent into the volume. When that scrubber has completed copying the data, this endpoint can be called to update the database that the read_only_parent is no longer needed for a volume and future attachments of this volume should not include that read_only_parent.", + "operationId": "cpapi_volume_remove_read_only_parent", + "parameters": [ + { + "in": "path", + "name": "volume_id", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + }, + "style": "simple" + } + ], + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, "/zpools/{zpool_id}/dataset/{dataset_id}": { "put": { "summary": "Report that a dataset within a pool has come online.",