diff --git a/common/src/api/external/mod.rs b/common/src/api/external/mod.rs index 18343ac44a..b7f193758a 100644 --- a/common/src/api/external/mod.rs +++ b/common/src/api/external/mod.rs @@ -1156,7 +1156,9 @@ impl InstanceState { } /// The number of CPUs in an Instance -#[derive(Copy, Clone, Debug, Deserialize, Serialize, JsonSchema)] +#[derive( + Copy, Clone, Debug, Deserialize, Serialize, JsonSchema, PartialEq, Eq, +)] pub struct InstanceCpuCount(pub u16); impl TryFrom for InstanceCpuCount { diff --git a/nexus/db-model/src/instance.rs b/nexus/db-model/src/instance.rs index b9d08191aa..19886c5f67 100644 --- a/nexus/db-model/src/instance.rs +++ b/nexus/db-model/src/instance.rs @@ -544,4 +544,8 @@ pub struct InstanceUpdate { /// set the instance's auto-restart policy to `NULL`. #[diesel(column_name = auto_restart_policy)] pub auto_restart_policy: Option, + + pub ncpus: InstanceCpuCount, + + pub memory: ByteCount, } diff --git a/nexus/db-model/src/instance_cpu_count.rs b/nexus/db-model/src/instance_cpu_count.rs index 6c7debc6a5..dfa524a452 100644 --- a/nexus/db-model/src/instance_cpu_count.rs +++ b/nexus/db-model/src/instance_cpu_count.rs @@ -13,7 +13,15 @@ use serde::Serialize; use std::convert::TryFrom; #[derive( - Copy, Clone, Debug, AsExpression, FromSqlRow, Serialize, Deserialize, + Copy, + Clone, + Debug, + AsExpression, + FromSqlRow, + Serialize, + Deserialize, + PartialEq, + Eq, )] #[diesel(sql_type = sql_types::BigInt)] pub struct InstanceCpuCount(pub external::InstanceCpuCount); diff --git a/nexus/db-model/src/instance_state.rs b/nexus/db-model/src/instance_state.rs index 5c8f496832..a3bc9d7270 100644 --- a/nexus/db-model/src/instance_state.rs +++ b/nexus/db-model/src/instance_state.rs @@ -36,6 +36,11 @@ impl_enum_type!( ); impl InstanceState { + /// Instance states where there is not currently a VMM incarnating this + /// instance, but might be in the future. + pub const NOT_INCARNATED_STATES: &'static [InstanceState] = + &[InstanceState::NoVmm, InstanceState::Creating, InstanceState::Failed]; + pub fn state(&self) -> external::InstanceState { external::InstanceState::from(*self) } diff --git a/nexus/db-queries/src/db/datastore/instance.rs b/nexus/db-queries/src/db/datastore/instance.rs index 4ff97e0e9a..6f395099a1 100644 --- a/nexus/db-queries/src/db/datastore/instance.rs +++ b/nexus/db-queries/src/db/datastore/instance.rs @@ -18,10 +18,12 @@ use crate::db::error::public_error_from_diesel; use crate::db::error::ErrorHandler; use crate::db::identity::Resource; use crate::db::lookup::LookupPath; +use crate::db::model::ByteCount; use crate::db::model::Generation; use crate::db::model::Instance; use crate::db::model::InstanceAutoRestart; use crate::db::model::InstanceAutoRestartPolicy; +use crate::db::model::InstanceCpuCount; use crate::db::model::InstanceRuntimeState; use crate::db::model::InstanceState; use crate::db::model::InstanceUpdate; @@ -1038,8 +1040,12 @@ impl DataStore { .transaction_retry_wrapper("reconfigure_instance") .transaction(&conn, |conn| { let err = err.clone(); - let InstanceUpdate { boot_disk_id, auto_restart_policy } = - update.clone(); + let InstanceUpdate { + boot_disk_id, + auto_restart_policy, + ncpus, + memory, + } = update.clone(); async move { // Set the auto-restart policy. diesel::update(instance_dsl::instance) @@ -1051,11 +1057,21 @@ impl DataStore { .execute_async(&conn) .await?; + // Set vCPUs and memory size. + self.instance_set_size_on_conn( + &conn, + &err, + &authz_instance, + ncpus, + memory, + ) + .await?; + // Next, set the boot disk if needed. self.instance_set_boot_disk_on_conn( &conn, &err, - authz_instance, + &authz_instance, boot_disk_id, ) .await?; @@ -1077,14 +1093,16 @@ impl DataStore { Ok(instance_and_vmm) } + /// Set the boot disk on an instance, bypassing the rest of an instance + /// update. You probably don't need this; it's only used at the end of + /// instance creation, since the boot disk can't be set until the new + /// instance's disks are all attached. pub async fn instance_set_boot_disk( &self, opctx: &OpContext, authz_instance: &authz::Instance, boot_disk_id: Option, ) -> Result<(), Error> { - opctx.authorize(authz::Action::Modify, authz_instance).await?; - let err = OptionalError::new(); let conn = self.pool_connection_authorized(opctx).await?; self.transaction_retry_wrapper("instance_set_boot_disk") @@ -1128,48 +1146,9 @@ impl DataStore { use db::schema::disk::dsl as disk_dsl; use db::schema::instance::dsl as instance_dsl; - // * Allow setting the boot disk in NoVmm because there is no VMM to - // contend with. - // * Allow setting the boot disk in Failed to allow changing the boot - // disk of a failed instance and free its boot disk for detach. - // * Allow setting the boot disk in Creating because one of the last - // steps of instance creation, while the instance is still in - // Creating, is to reconfigure the instance to the desired boot disk. - const OK_TO_SET_BOOT_DISK_STATES: &'static [InstanceState] = &[ - InstanceState::NoVmm, - InstanceState::Failed, - InstanceState::Creating, - ]; - - let maybe_instance = instance_dsl::instance - .filter(instance_dsl::id.eq(authz_instance.id())) - .filter(instance_dsl::time_deleted.is_null()) - .select(Instance::as_select()) - .first_async::(conn) - .await; - let instance = match maybe_instance { - Ok(i) => i, - Err(diesel::NotFound) => { - // If the instance simply doesn't exist, we - // shouldn't retry. Bail with a useful error. - return Err(err.bail(Error::not_found_by_id( - ResourceType::Instance, - &authz_instance.id(), - ))); - } - Err(e) => return Err(e), - }; - - // If the desired boot disk is already set, we're good here, and can - // elide the check that the instance is in an acceptable state to change - // the boot disk. - if instance.boot_disk_id == boot_disk_id { - return Ok(()); - } - if let Some(disk_id) = boot_disk_id { - // Ensure the disk is currently attached before updating - // the database. + // Ensure the disk is currently attached before updating the + // database. let expected_state = api::external::DiskState::Attached(authz_instance.id()); @@ -1188,28 +1167,108 @@ impl DataStore { ); } } - // - // NOTE: from this point forward it is OK if we update the - // instance's `boot_disk_id` column with the updated value - // again. It will have already been assigned with constraint - // checking performed above, so updates will just be - // repetitive, not harmful. - let r = diesel::update(instance_dsl::instance) + let query = diesel::update(instance_dsl::instance) + .into_boxed() .filter(instance_dsl::id.eq(authz_instance.id())) - .filter(instance_dsl::state.eq_any(OK_TO_SET_BOOT_DISK_STATES)) + .filter( + instance_dsl::state + .eq_any(InstanceState::NOT_INCARNATED_STATES), + ); + let query = if boot_disk_id.is_some() { + query.filter( + instance_dsl::boot_disk_id + .ne(boot_disk_id) + .or(instance_dsl::boot_disk_id.is_null()), + ) + } else { + query.filter(instance_dsl::boot_disk_id.is_not_null()) + }; + + let r = query .set(instance_dsl::boot_disk_id.eq(boot_disk_id)) .check_if_exists::(authz_instance.id()) .execute_and_check(&conn) .await?; match r.status { UpdateStatus::NotUpdatedButExists => { - // This should be the only reason the query would fail... - debug_assert!(!OK_TO_SET_BOOT_DISK_STATES - .contains(&r.found.runtime().nexus_state)); - Err(err.bail(Error::conflict( - "instance must be stopped to set boot disk", - ))) + if r.found.boot_disk_id == boot_disk_id { + // Not updated, because the update is no change.. + return Ok(()); + } + + if !InstanceState::NOT_INCARNATED_STATES + .contains(&r.found.runtime().nexus_state) + { + return Err(err.bail(Error::conflict( + "instance must be stopped to set boot disk", + ))); + } + + // There should be no other reason the update fails on an + // existing instance. + return Err(err.bail(Error::internal_error( + "unable to reconfigure instance boot disk", + ))); + } + UpdateStatus::Updated => Ok(()), + } + } + + /// Set an instance's CPU count and memory size to the provided values, + /// within an existing transaction. + /// + /// Does not allow setting sizes of running instances to ensure that if an + /// instance is running, its resource reservation matches what we record in + /// the database. + async fn instance_set_size_on_conn( + &self, + conn: &async_bb8_diesel::Connection, + err: &OptionalError, + authz_instance: &authz::Instance, + ncpus: InstanceCpuCount, + memory: ByteCount, + ) -> Result<(), diesel::result::Error> { + use db::schema::instance::dsl as instance_dsl; + + let r = diesel::update(instance_dsl::instance) + .filter(instance_dsl::id.eq(authz_instance.id())) + .filter( + instance_dsl::state + .eq_any(InstanceState::NOT_INCARNATED_STATES), + ) + .filter( + instance_dsl::ncpus + .ne(ncpus) + .or(instance_dsl::memory.ne(memory)), + ) + .set(( + instance_dsl::ncpus.eq(ncpus), + instance_dsl::memory.eq(memory), + )) + .check_if_exists::(authz_instance.id()) + .execute_and_check(&conn) + .await?; + match r.status { + UpdateStatus::NotUpdatedButExists => { + if (r.found.ncpus, r.found.memory) == (ncpus, memory) { + // Not updated, because the update is no change.. + return Ok(()); + } + + if !InstanceState::NOT_INCARNATED_STATES + .contains(&r.found.runtime().nexus_state) + { + return Err(err.bail(Error::conflict( + "instance must be stopped to be resized", + ))); + } + + // There should be no other reason the update fails on an + // existing instance. + return Err(err.bail(Error::internal_error( + "unable to reconfigure instance size", + ))); } UpdateStatus::Updated => Ok(()), } diff --git a/nexus/src/app/instance.rs b/nexus/src/app/instance.rs index 97e7b3ffe8..e916974332 100644 --- a/nexus/src/app/instance.rs +++ b/nexus/src/app/instance.rs @@ -41,6 +41,7 @@ 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::InstanceCpuCount; use omicron_common::api::external::InstanceState; use omicron_common::api::external::InternalContext; use omicron_common::api::external::ListResultVec; @@ -311,6 +312,8 @@ impl super::Nexus { let (.., authz_project, authz_instance) = instance_lookup.lookup_for(authz::Action::Modify).await?; + check_instance_cpu_memory_sizes(params.ncpus, params.memory)?; + let boot_disk_id = match params.boot_disk.clone() { Some(disk) => { let selector = params::DiskSelector { @@ -331,8 +334,11 @@ impl super::Nexus { }; let auto_restart_policy = params.auto_restart_policy.map(Into::into); + let ncpus = params.ncpus.into(); + let memory = params.memory.into(); - let update = InstanceUpdate { boot_disk_id, auto_restart_policy }; + let update = + InstanceUpdate { boot_disk_id, auto_restart_policy, ncpus, memory }; self.datastore() .instance_reconfigure(opctx, &authz_instance, update) .await @@ -347,6 +353,8 @@ impl super::Nexus { let (.., authz_project) = project_lookup.lookup_for(authz::Action::CreateChild).await?; + check_instance_cpu_memory_sizes(params.ncpus, params.memory)?; + let all_disks: Vec<¶ms::InstanceDiskAttachment> = params.boot_disk.iter().chain(params.disks.iter()).collect(); @@ -363,12 +371,6 @@ impl super::Nexus { .await?; } } - if params.ncpus.0 > MAX_VCPU_PER_INSTANCE { - return Err(Error::invalid_request(&format!( - "cannot have more than {} vCPUs per instance", - MAX_VCPU_PER_INSTANCE - ))); - } if params.external_ips.len() > MAX_EXTERNAL_IPS_PER_INSTANCE { return Err(Error::invalid_request(&format!( "An instance may not have more than {} external IP addresses", @@ -413,43 +415,6 @@ impl super::Nexus { } } - // Reject instances where the memory is not at least - // MIN_MEMORY_BYTES_PER_INSTANCE - if params.memory.to_bytes() < u64::from(MIN_MEMORY_BYTES_PER_INSTANCE) { - return Err(Error::invalid_value( - "size", - format!( - "memory must be at least {}", - ByteCount::from(MIN_MEMORY_BYTES_PER_INSTANCE) - ), - )); - } - - // Reject instances where the memory is not divisible by - // MIN_MEMORY_BYTES_PER_INSTANCE - if (params.memory.to_bytes() % u64::from(MIN_MEMORY_BYTES_PER_INSTANCE)) - != 0 - { - return Err(Error::invalid_value( - "size", - format!( - "memory must be divisible by {}", - ByteCount::from(MIN_MEMORY_BYTES_PER_INSTANCE) - ), - )); - } - - // Reject instances where the memory is greater than the limit - if params.memory.to_bytes() > MAX_MEMORY_BYTES_PER_INSTANCE { - return Err(Error::invalid_value( - "size", - format!( - "memory must be less than or equal to {}", - ByteCount::try_from(MAX_MEMORY_BYTES_PER_INSTANCE).unwrap() - ), - )); - } - let actor = opctx.authn.actor_required().internal_context( "loading current user's ssh keys for new Instance", )?; @@ -2201,6 +2166,57 @@ pub(crate) async fn process_vmm_update( } } +/// Determines whether the supplied instance sizes (CPU count and memory size) +/// are acceptable. +fn check_instance_cpu_memory_sizes( + ncpus: InstanceCpuCount, + memory: ByteCount, +) -> Result<(), Error> { + if ncpus.0 > MAX_VCPU_PER_INSTANCE { + return Err(Error::invalid_request(&format!( + "cannot have more than {} vCPUs per instance", + MAX_VCPU_PER_INSTANCE + ))); + } + + // Reject instances where the memory is not at least + // MIN_MEMORY_BYTES_PER_INSTANCE + if memory.to_bytes() < u64::from(MIN_MEMORY_BYTES_PER_INSTANCE) { + return Err(Error::invalid_value( + "size", + format!( + "memory must be at least {}", + ByteCount::from(MIN_MEMORY_BYTES_PER_INSTANCE) + ), + )); + } + + // Reject instances where the memory is not divisible by + // MIN_MEMORY_BYTES_PER_INSTANCE + if (memory.to_bytes() % u64::from(MIN_MEMORY_BYTES_PER_INSTANCE)) != 0 { + return Err(Error::invalid_value( + "size", + format!( + "memory must be divisible by {}", + ByteCount::from(MIN_MEMORY_BYTES_PER_INSTANCE) + ), + )); + } + + // Reject instances where the memory is greater than the limit + if memory.to_bytes() > MAX_MEMORY_BYTES_PER_INSTANCE { + return Err(Error::invalid_value( + "size", + format!( + "memory must be less than or equal to {}", + ByteCount::try_from(MAX_MEMORY_BYTES_PER_INSTANCE).unwrap() + ), + )); + } + + Ok(()) +} + /// Determines the disposition of a request to start an instance given its state /// (and its current VMM's state, if it has one) in the database. fn instance_start_allowed( diff --git a/nexus/tests/integration_tests/endpoints.rs b/nexus/tests/integration_tests/endpoints.rs index d6f83063a0..59f595df46 100644 --- a/nexus/tests/integration_tests/endpoints.rs +++ b/nexus/tests/integration_tests/endpoints.rs @@ -512,6 +512,8 @@ pub static DEMO_INSTANCE_UPDATE: Lazy = Lazy::new(|| params::InstanceUpdate { boot_disk: None, auto_restart_policy: None, + ncpus: InstanceCpuCount(1), + memory: ByteCount::from_gibibytes_u32(16), }); // The instance needs a network interface, too. diff --git a/nexus/tests/integration_tests/instances.rs b/nexus/tests/integration_tests/instances.rs index 9866c761ce..be124e14b8 100644 --- a/nexus/tests/integration_tests/instances.rs +++ b/nexus/tests/integration_tests/instances.rs @@ -4160,7 +4160,12 @@ async fn test_cannot_detach_boot_disk(cptestctx: &ControlPlaneTestContext) { let instance = expect_instance_reconfigure_ok( &client, &instance.identity.id, - params::InstanceUpdate { boot_disk: None, auto_restart_policy: None }, + params::InstanceUpdate { + boot_disk: None, + auto_restart_policy: None, + ncpus: InstanceCpuCount::try_from(2).unwrap(), + memory: ByteCount::from_gibibytes_u32(4), + }, ) .await; assert_eq!(instance.boot_disk_id, None); @@ -4260,6 +4265,8 @@ async fn test_updating_running_instance_boot_disk_is_conflict( params::InstanceUpdate { boot_disk: Some(alsodata.clone().into()), auto_restart_policy: None, + ncpus: InstanceCpuCount::try_from(2).unwrap(), + memory: ByteCount::from_gibibytes_u32(4), }, http::StatusCode::CONFLICT, ) @@ -4276,6 +4283,8 @@ async fn test_updating_running_instance_boot_disk_is_conflict( // was created. boot_disk: Some(probablydata.clone().into()), auto_restart_policy: Some(InstanceAutoRestartPolicy::BestEffort), + ncpus: InstanceCpuCount::try_from(2).unwrap(), + memory: ByteCount::from_gibibytes_u32(4), }, ) .await; @@ -4293,7 +4302,12 @@ async fn test_updating_missing_instance_is_not_found( let error = expect_instance_reconfigure_err( &client, &UUID_THAT_DOESNT_EXIST, - params::InstanceUpdate { boot_disk: None, auto_restart_policy: None }, + params::InstanceUpdate { + boot_disk: None, + auto_restart_policy: None, + ncpus: InstanceCpuCount::try_from(0).unwrap(), + memory: ByteCount::from_gibibytes_u32(0), + }, http::StatusCode::NOT_FOUND, ) .await; @@ -4355,6 +4369,214 @@ async fn expect_instance_reconfigure_err( .parsed_body::() .expect("error response should parse successfully") } + +// Test reconfiguring an instance's size in CPUs and memory. +#[nexus_test] +async fn test_size_can_be_changed(cptestctx: &ControlPlaneTestContext) { + let client = &cptestctx.external_client; + let instance_name = "downloading-more-ram"; + + create_project_and_pool(&client).await; + + let initial_ncpus = InstanceCpuCount::try_from(2).unwrap(); + let initial_memory = ByteCount::from_gibibytes_u32(4); + + let instance_params = params::InstanceCreate { + identity: IdentityMetadataCreateParams { + name: instance_name.parse().unwrap(), + description: String::from("stuff"), + }, + ncpus: initial_ncpus, + memory: initial_memory, + hostname: instance_name.parse().unwrap(), + user_data: vec![], + ssh_public_keys: None, + network_interfaces: params::InstanceNetworkInterfaceAttachment::Default, + external_ips: vec![], + boot_disk: None, + disks: Vec::new(), + start: true, + // Start out with None + auto_restart_policy: None, + }; + + let builder = + RequestBuilder::new(client, http::Method::POST, &get_instances_url()) + .body(Some(&instance_params)) + .expect_status(Some(http::StatusCode::CREATED)); + let response = NexusRequest::new(builder) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .expect("Expected instance creation to work!"); + + let instance = response.parsed_body::().unwrap(); + let boot_disk_nameorid: Option = + instance.boot_disk_id.map(|x| x.into()); + let auto_restart_policy = instance.auto_restart_status.policy; + + let new_ncpus = InstanceCpuCount::try_from(4).unwrap(); + let new_memory = ByteCount::from_gibibytes_u32(8); + + // Resizing the instance immediately will error; the instance is running. + let err = expect_instance_reconfigure_err( + client, + &instance.identity.id, + params::InstanceUpdate { + auto_restart_policy, + boot_disk: boot_disk_nameorid.clone(), + ncpus: new_ncpus, + memory: new_memory, + }, + StatusCode::CONFLICT, + ) + .await; + + assert_eq!(err.message, "instance must be stopped to be resized"); + + instance_post(&client, instance_name, InstanceOp::Stop).await; + let nexus = &cptestctx.server.server_context().nexus; + let instance_id = InstanceUuid::from_untyped_uuid(instance.identity.id); + instance_simulate(nexus, &instance_id).await; + instance_wait_for_state(client, instance_id, InstanceState::Stopped).await; + + // Now that the instance is stopped, we can resize it.. + let instance = expect_instance_reconfigure_ok( + client, + &instance.identity.id, + params::InstanceUpdate { + auto_restart_policy, + boot_disk: boot_disk_nameorid.clone(), + ncpus: new_ncpus, + memory: new_memory, + }, + ) + .await; + assert_eq!(instance.ncpus.0, new_ncpus.0); + assert_eq!(instance.memory, new_memory); + + // No harm in reverting to the original size one field at a time though: + let instance = expect_instance_reconfigure_ok( + client, + &instance.identity.id, + params::InstanceUpdate { + auto_restart_policy, + boot_disk: boot_disk_nameorid.clone(), + ncpus: initial_ncpus, + memory: new_memory, + }, + ) + .await; + assert_eq!(instance.ncpus.0, initial_ncpus.0); + assert_eq!(instance.memory, new_memory); + + let instance = expect_instance_reconfigure_ok( + client, + &instance.identity.id, + params::InstanceUpdate { + auto_restart_policy, + boot_disk: boot_disk_nameorid.clone(), + ncpus: initial_ncpus, + memory: initial_memory, + }, + ) + .await; + assert_eq!(instance.ncpus.0, initial_ncpus.0); + assert_eq!(instance.memory, initial_memory); + + // Now try a few invalid sizes. These all should fail for slightly different + // reasons. + + // Too many CPUs. + let err = expect_instance_reconfigure_err( + client, + &instance.identity.id, + params::InstanceUpdate { + auto_restart_policy, + boot_disk: boot_disk_nameorid.clone(), + ncpus: InstanceCpuCount(MAX_VCPU_PER_INSTANCE + 1), + memory: instance.memory, + }, + StatusCode::BAD_REQUEST, + ) + .await; + assert_eq!( + err.message, + format!( + "cannot have more than {} vCPUs per instance", + MAX_VCPU_PER_INSTANCE + ) + ); + + // Too little memory. + let err = expect_instance_reconfigure_err( + client, + &instance.identity.id, + params::InstanceUpdate { + auto_restart_policy, + boot_disk: boot_disk_nameorid.clone(), + ncpus: instance.ncpus, + memory: ByteCount::from_mebibytes_u32(0), + }, + StatusCode::BAD_REQUEST, + ) + .await; + assert!(err.message.contains("memory must be at least")); + + // Enough memory, but not an amount we want to accept. + let err = expect_instance_reconfigure_err( + client, + &instance.identity.id, + params::InstanceUpdate { + auto_restart_policy, + boot_disk: boot_disk_nameorid.clone(), + ncpus: instance.ncpus, + memory: ByteCount::try_from(MAX_MEMORY_BYTES_PER_INSTANCE - 1) + .unwrap(), + }, + StatusCode::BAD_REQUEST, + ) + .await; + assert!(err.message.contains("memory must be divisible by")); + + // Too much memory, but an otherwise-acceptable amount. + let max_mib = MAX_MEMORY_BYTES_PER_INSTANCE / (1024 * 1024); + let err = expect_instance_reconfigure_err( + client, + &instance.identity.id, + params::InstanceUpdate { + auto_restart_policy, + boot_disk: boot_disk_nameorid.clone(), + ncpus: instance.ncpus, + memory: ByteCount::from_mebibytes_u32( + (max_mib + 1024).try_into().unwrap(), + ), + }, + StatusCode::BAD_REQUEST, + ) + .await; + assert!(err.message.contains("must be less than or equal")); + + // Now delete the instance; we should not be able to resize (or really + // interact at all!) with a deleted instance.. + expect_instance_delete_ok(client, instance_name).await; + + // Attempt to send a previously-valid update again. The only reason this + // will not work is that we just deleted the instance we'd be updating. + expect_instance_reconfigure_err( + client, + &instance.identity.id, + params::InstanceUpdate { + auto_restart_policy, + boot_disk: boot_disk_nameorid.clone(), + ncpus: new_ncpus, + memory: new_memory, + }, + StatusCode::NOT_FOUND, + ) + .await; +} + // Test reconfiguring an instance's auto-restart policy. #[nexus_test] async fn test_auto_restart_policy_can_be_changed( @@ -4406,6 +4628,8 @@ async fn test_auto_restart_policy_can_be_changed( dbg!(params::InstanceUpdate { auto_restart_policy, boot_disk: None, + ncpus: InstanceCpuCount::try_from(2).unwrap(), + memory: ByteCount::from_gibibytes_u32(4), }), ) .await; @@ -4499,6 +4723,8 @@ async fn test_boot_disk_can_be_changed(cptestctx: &ControlPlaneTestContext) { params::InstanceUpdate { boot_disk: Some(disks[1].identity.id.into()), auto_restart_policy: None, + ncpus: InstanceCpuCount::try_from(2).unwrap(), + memory: ByteCount::from_gibibytes_u32(4), }, ) .await; @@ -4563,6 +4789,8 @@ async fn test_boot_disk_must_be_attached(cptestctx: &ControlPlaneTestContext) { params::InstanceUpdate { boot_disk: Some(disks[0].identity.id.into()), auto_restart_policy: None, + ncpus: InstanceCpuCount::try_from(2).unwrap(), + memory: ByteCount::from_gibibytes_u32(4), }, http::StatusCode::CONFLICT, ) @@ -4594,6 +4822,8 @@ async fn test_boot_disk_must_be_attached(cptestctx: &ControlPlaneTestContext) { params::InstanceUpdate { boot_disk: Some(disks[0].identity.id.into()), auto_restart_policy: None, + ncpus: InstanceCpuCount::try_from(2).unwrap(), + memory: ByteCount::from_gibibytes_u32(4), }, ) .await; diff --git a/nexus/types/src/external_api/params.rs b/nexus/types/src/external_api/params.rs index 9af9d3be1e..60da28b57e 100644 --- a/nexus/types/src/external_api/params.rs +++ b/nexus/types/src/external_api/params.rs @@ -1156,6 +1156,12 @@ pub struct InstanceCreate { /// Parameters of an `Instance` that can be reconfigured after creation. #[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] pub struct InstanceUpdate { + /// The number of CPUs to assign to this instance. + pub ncpus: InstanceCpuCount, + + /// The amount of memory to assign to this instance. + pub memory: ByteCount, + /// Name or ID of the disk the instance should be instructed to boot from. /// /// If not provided, unset the instance's boot disk. diff --git a/openapi/nexus.json b/openapi/nexus.json index f12ff4730c..a4ae570dcc 100644 --- a/openapi/nexus.json +++ b/openapi/nexus.json @@ -16491,8 +16491,28 @@ "$ref": "#/components/schemas/NameOrId" } ] + }, + "memory": { + "description": "The amount of memory to assign to this instance.", + "allOf": [ + { + "$ref": "#/components/schemas/ByteCount" + } + ] + }, + "ncpus": { + "description": "The number of CPUs to assign to this instance.", + "allOf": [ + { + "$ref": "#/components/schemas/InstanceCpuCount" + } + ] } - } + }, + "required": [ + "memory", + "ncpus" + ] }, "InternetGateway": { "description": "An internet gateway provides a path between VPC networks and external networks.",