diff --git a/omicron-common/src/api/external/mod.rs b/omicron-common/src/api/external/mod.rs index 56bbc6a373c..5376f88b4f0 100644 --- a/omicron-common/src/api/external/mod.rs +++ b/omicron-common/src/api/external/mod.rs @@ -435,6 +435,7 @@ pub enum ResourceType { Rack, Sled, SagaDbg, + Vpc, } impl Display for ResourceType { @@ -450,6 +451,7 @@ impl Display for ResourceType { ResourceType::Rack => "rack", ResourceType::Sled => "sled", ResourceType::SagaDbg => "saga_dbg", + ResourceType::Vpc => "vpc", } ) } @@ -1063,15 +1065,26 @@ impl From for SagaStateView { } } -/// A Virtual Private Cloud (VPC) object. -#[derive(Clone, Debug)] -pub struct VPC { - /** common identifying metadata */ +#[derive(ObjectIdentity, Clone, Debug, Deserialize, Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct Vpc { + #[serde(flatten)] pub identity: IdentityMetadata, - /** id for the project containing this Instance */ + + /** id for the project containing this VPC */ pub project_id: Uuid, } +/** + * Create-time parameters for a [`Vpc`] + */ +#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct VpcCreateParams { + #[serde(flatten)] + pub identity: IdentityMetadataCreateParams, +} + /// An `Ipv4Net` represents a IPv4 subnetwork, including the address and network mask. #[derive(Clone, Copy, Debug, Deserialize, PartialEq, Serialize)] pub struct Ipv4Net(pub ipnet::Ipv4Net); @@ -1183,7 +1196,7 @@ impl JsonSchema for Ipv6Net { /// A VPC subnet represents a logical grouping for instances that allows network traffic between /// them, within a IPv4 subnetwork or optionall an IPv6 subnetwork. #[derive(Clone, Debug)] -pub struct VPCSubnet { +pub struct VpcSubnet { /** common identifying metadata */ pub identity: IdentityMetadata, diff --git a/omicron-common/src/model_db.rs b/omicron-common/src/model_db.rs index 398f63b317f..8f1efef1791 100644 --- a/omicron-common/src/model_db.rs +++ b/omicron-common/src/model_db.rs @@ -64,8 +64,8 @@ use crate::api::external::InstanceState; use crate::api::external::MacAddr; use crate::api::external::Name; use crate::api::external::NetworkInterface; -use crate::api::external::VPCSubnet; -use crate::api::external::VPC; +use crate::api::external::Vpc; +use crate::api::external::VpcSubnet; use crate::api::external::{Ipv4Net, Ipv6Net}; use crate::api::internal::nexus::Disk; use crate::api::internal::nexus::DiskRuntimeState; @@ -434,8 +434,8 @@ impl TryFrom<&tokio_postgres::Row> for OximeterAssignment { } } -/// Load an [`VPC`] from a row in the `VPC` table. -impl TryFrom<&tokio_postgres::Row> for VPC { +/// Load an [`Vpc`] from a row in the `Vpc` table. +impl TryFrom<&tokio_postgres::Row> for Vpc { type Error = Error; fn try_from(value: &tokio_postgres::Row) -> Result { @@ -446,8 +446,8 @@ impl TryFrom<&tokio_postgres::Row> for VPC { } } -/// Load an [`VPCSubnet`] from a row in the `VPCSubnet` table. -impl TryFrom<&tokio_postgres::Row> for VPCSubnet { +/// Load a [`VpcSubnet`] from a row in the `VpcSubnet` table. +impl TryFrom<&tokio_postgres::Row> for VpcSubnet { type Error = Error; fn try_from(value: &tokio_postgres::Row) -> Result { diff --git a/omicron-common/src/sql/dbinit.sql b/omicron-common/src/sql/dbinit.sql index 12f842fd603..d305ef13dab 100644 --- a/omicron-common/src/sql/dbinit.sql +++ b/omicron-common/src/sql/dbinit.sql @@ -249,7 +249,7 @@ CREATE TABLE omicron.public.OximeterAssignment ( * VPCs and networking primitives */ -CREATE TABLE omicron.public.VPC ( +CREATE TABLE omicron.public.Vpc ( /* Identity metadata */ id UUID PRIMARY KEY, name STRING(63) NOT NULL, @@ -261,13 +261,13 @@ CREATE TABLE omicron.public.VPC ( project_id UUID NOT NULL ); --- TODO: add project_id to index -CREATE UNIQUE INDEX ON omicron.public.VPC ( - name +CREATE UNIQUE INDEX ON omicron.public.Vpc ( + name, + project_id ) WHERE time_deleted IS NULL; -CREATE TABLE omicron.public.VPCSubnet ( +CREATE TABLE omicron.public.VpcSubnet ( /* Identity metadata */ id UUID PRIMARY KEY, name STRING(63) NOT NULL, @@ -281,9 +281,10 @@ CREATE TABLE omicron.public.VPCSubnet ( ipv6_block INET ); --- TODO: add project_id to index -CREATE UNIQUE INDEX ON omicron.public.VPCSubnet ( - name +/* Subnet and network interface names are unique per VPC, not project */ +CREATE UNIQUE INDEX ON omicron.public.VpcSubnet ( + name, + vpc_id ) WHERE time_deleted IS NULL; @@ -312,9 +313,9 @@ CREATE TABLE omicron.public.NetworkInterface ( * as moving IPs between NICs on different instances, etc. */ --- TODO: add project_id to index CREATE UNIQUE INDEX ON omicron.public.NetworkInterface ( - name + name, + vpc_id ) WHERE time_deleted IS NULL; diff --git a/omicron-nexus/src/db/conversions.rs b/omicron-nexus/src/db/conversions.rs index b49bca1c40a..66ff075a367 100644 --- a/omicron-nexus/src/db/conversions.rs +++ b/omicron-nexus/src/db/conversions.rs @@ -12,6 +12,7 @@ use omicron_common::api::external::IdentityMetadataCreateParams; use omicron_common::api::external::InstanceCreateParams; use omicron_common::api::external::InstanceState; use omicron_common::api::external::ProjectCreateParams; +use omicron_common::api::external::VpcCreateParams; use omicron_common::api::internal::nexus::DiskRuntimeState; use omicron_common::api::internal::nexus::InstanceRuntimeState; use omicron_common::api::internal::nexus::OximeterAssignment; @@ -83,6 +84,12 @@ impl SqlSerialize for DiskState { } } +impl SqlSerialize for VpcCreateParams { + fn sql_serialize(&self, output: &mut SqlValueSet) { + self.identity.sql_serialize(output); + } +} + impl SqlSerialize for OximeterInfo { fn sql_serialize(&self, output: &mut SqlValueSet) { output.set("id", &self.collector_id); diff --git a/omicron-nexus/src/db/datastore.rs b/omicron-nexus/src/db/datastore.rs index e57e5719f54..ac835de1cf2 100644 --- a/omicron-nexus/src/db/datastore.rs +++ b/omicron-nexus/src/db/datastore.rs @@ -51,6 +51,7 @@ use super::schema::LookupByUniqueId; use super::schema::LookupByUniqueName; use super::schema::LookupByUniqueNameInProject; use super::schema::Project; +use super::schema::Vpc; use super::sql::SqlSerialize; use super::sql::SqlString; use super::sql::SqlValueSet; @@ -731,4 +732,75 @@ impl DataStore { ); Ok(()) } + + pub async fn project_list_vpcs( + &self, + project_id: &Uuid, + pagparams: &DataPageParams<'_, Name>, + ) -> ListResult { + let client = self.pool.acquire().await?; + sql_fetch_page_by::< + LookupByUniqueNameInProject, + Vpc, + ::ModelType, + >(&client, (project_id,), pagparams, Vpc::ALL_COLUMNS) + .await + } + + pub async fn project_create_vpc( + &self, + vpc_id: &Uuid, + project_id: &Uuid, + params: &api::external::VpcCreateParams, + ) -> Result { + let client = self.pool.acquire().await?; + let now = Utc::now(); + let mut values = SqlValueSet::new(); + values.set("id", vpc_id); + values.set("time_created", &now); + values.set("time_modified", &now); + values.set("project_id", project_id); + params.sql_serialize(&mut values); + + sql_insert_unique_idempotent_and_fetch::( + &client, + &values, + params.identity.name.as_str(), + "id", + (), + &vpc_id, + ) + .await + } + + pub async fn vpc_fetch_by_name( + &self, + project_id: &Uuid, + vpc_name: &Name, + ) -> LookupResult { + let client = self.pool.acquire().await?; + sql_fetch_row_by::( + &client, + (project_id,), + vpc_name, + ) + .await + } + + pub async fn project_delete_vpc(&self, vpc_id: &Uuid) -> DeleteResult { + let client = self.pool.acquire().await?; + let now = Utc::now(); + sql_execute_maybe_one( + &client, + format!( + "UPDATE {} SET time_deleted = $1 WHERE \ + time_deleted IS NULL AND id = $2", + Vpc::TABLE_NAME, + ) + .as_str(), + &[&now, &vpc_id], + || Error::not_found_by_id(ResourceType::Vpc, vpc_id), + ) + .await + } } diff --git a/omicron-nexus/src/db/schema.rs b/omicron-nexus/src/db/schema.rs index ab86dbcb8e7..63818b25184 100644 --- a/omicron-nexus/src/db/schema.rs +++ b/omicron-nexus/src/db/schema.rs @@ -151,11 +151,11 @@ impl Table for OximeterAssignment { &["oximeter_id", "producer_id", "time_created"]; } -/** Describes the "VPC" table */ -pub struct VPC; -impl Table for VPC { - type ModelType = api::external::VPC; - const TABLE_NAME: &'static str = "VPC"; +/** Describes the "Vpc" table */ +pub struct Vpc; +impl Table for Vpc { + type ModelType = api::external::Vpc; + const TABLE_NAME: &'static str = "Vpc"; const ALL_COLUMNS: &'static [&'static str] = &[ "id", "name", @@ -167,11 +167,15 @@ impl Table for VPC { ]; } -/** Describes the "VPCSubnet" table */ -pub struct VPCSubnet; -impl Table for VPCSubnet { - type ModelType = api::external::VPCSubnet; - const TABLE_NAME: &'static str = "VPCSubnet"; +impl ResourceTable for Vpc { + const RESOURCE_TYPE: ResourceType = ResourceType::Vpc; +} + +/** Describes the "VpcSubnet" table */ +pub struct VpcSubnet; +impl Table for VpcSubnet { + type ModelType = api::external::VpcSubnet; + const TABLE_NAME: &'static str = "VpcSubnet"; const ALL_COLUMNS: &'static [&'static str] = &[ "id", "name", @@ -213,7 +217,7 @@ mod test { use super::SagaNodeEvent; use super::Table; use super::{MetricProducer, Oximeter, OximeterAssignment}; - use super::{NetworkInterface, VPCSubnet, VPC}; + use super::{NetworkInterface, Vpc, VpcSubnet}; use omicron_common::dev; use std::collections::BTreeSet; use tokio_postgres::types::ToSql; @@ -242,8 +246,8 @@ mod test { check_table_schema::(&client).await; check_table_schema::(&client).await; check_table_schema::(&client).await; - check_table_schema::(&client).await; - check_table_schema::(&client).await; + check_table_schema::(&client).await; + check_table_schema::(&client).await; check_table_schema::(&client).await; database.cleanup().await.expect("failed to clean up database"); diff --git a/omicron-nexus/src/http_entrypoints_external.rs b/omicron-nexus/src/http_entrypoints_external.rs index 83e232bc441..54f92bc852b 100644 --- a/omicron-nexus/src/http_entrypoints_external.rs +++ b/omicron-nexus/src/http_entrypoints_external.rs @@ -44,6 +44,8 @@ use omicron_common::api::external::ProjectView; use omicron_common::api::external::RackView; use omicron_common::api::external::SagaView; use omicron_common::api::external::SledView; +use omicron_common::api::external::Vpc; +use omicron_common::api::external::VpcCreateParams; use schemars::JsonSchema; use serde::Deserialize; use std::num::NonZeroU32; @@ -81,6 +83,11 @@ pub fn external_api() -> NexusApiDescription { api.register(instance_disks_put_disk)?; api.register(instance_disks_delete_disk)?; + api.register(project_vpcs_get)?; + api.register(project_vpcs_post)?; + api.register(project_vpcs_get_vpc)?; + api.register(project_vpcs_delete_vpc)?; + api.register(hardware_racks_get)?; api.register(hardware_racks_get_rack)?; api.register(hardware_sleds_get)?; @@ -654,6 +661,107 @@ async fn instance_disks_delete_disk( Ok(HttpResponseDeleted()) } +/* + * VPCs + */ + +/** + * List VPCs in a project. + */ +#[endpoint { + method = GET, + path = "/projects/{project_name}/vpcs", + }] +async fn project_vpcs_get( + rqctx: Arc>>, + query_params: Query, + 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 project_name = &path.project_name; + let vpc_stream = nexus + .project_list_vpcs( + &project_name, + &data_page_params_for(&rqctx, &query)?, + ) + .await?; + let view_list = to_list(vpc_stream).await; + Ok(HttpResponseOk(ScanByName::results_page(&query, view_list)?)) +} + +/** + * Path parameters for VPC requests + */ +#[derive(Deserialize, JsonSchema)] +struct VpcPathParam { + project_name: Name, + vpc_name: Name, +} + +/** + * Get a VPC in a project. + */ +#[endpoint { + method = GET, + path = "/projects/{project_name}/vpcs/{vpc_name}", + }] +async fn project_vpcs_get_vpc( + rqctx: Arc>>, + path_params: Path, +) -> Result, HttpError> { + let apictx = rqctx.context(); + let nexus = &apictx.nexus; + let path = path_params.into_inner(); + let project_name = &path.project_name; + let vpc_name = &path.vpc_name; + let vpc = nexus.project_lookup_vpc(&project_name, &vpc_name).await?; + Ok(HttpResponseOk(vpc)) +} + +/** + * Create a VPC in a project. + */ +#[endpoint { + method = POST, + path = "/projects/{project_name}/vpcs", + }] +async fn project_vpcs_post( + rqctx: Arc>>, + path_params: Path, + new_vpc: TypedBody, +) -> Result, HttpError> { + let apictx = rqctx.context(); + let nexus = &apictx.nexus; + let path = path_params.into_inner(); + let project_name = &path.project_name; + let new_vpc_params = &new_vpc.into_inner(); + let vpc = nexus.project_create_vpc(&project_name, &new_vpc_params).await?; + Ok(HttpResponseCreated(vpc)) +} + +/** + * Delete a vpc from a project. + */ +#[endpoint { + method = DELETE, + path = "/projects/{project_name}/vpcs/{vpc_name}", + }] +async fn project_vpcs_delete_vpc( + rqctx: Arc>>, + path_params: Path, +) -> Result { + let apictx = rqctx.context(); + let nexus = &apictx.nexus; + let path = path_params.into_inner(); + let project_name = &path.project_name; + let vpc_name = &path.vpc_name; + nexus.project_delete_vpc(&project_name, &vpc_name).await?; + Ok(HttpResponseDeleted()) +} + /* * Racks */ diff --git a/omicron-nexus/src/nexus.rs b/omicron-nexus/src/nexus.rs index c6bb6c37ca9..c8b08e52332 100644 --- a/omicron-nexus/src/nexus.rs +++ b/omicron-nexus/src/nexus.rs @@ -30,6 +30,8 @@ use omicron_common::api::external::ProjectUpdateParams; use omicron_common::api::external::ResourceType; use omicron_common::api::external::SagaView; use omicron_common::api::external::UpdateResult; +use omicron_common::api::external::Vpc; +use omicron_common::api::external::VpcCreateParams; use omicron_common::api::internal::nexus::Disk; use omicron_common::api::internal::nexus::DiskRuntimeState; use omicron_common::api::internal::nexus::Instance; @@ -1000,6 +1002,50 @@ impl Nexus { .map(|_| ()) } + pub async fn project_list_vpcs( + &self, + project_name: &Name, + pagparams: &DataPageParams<'_, Name>, + ) -> ListResult { + let project_id = + self.db_datastore.project_lookup_id_by_name(project_name).await?; + self.db_datastore.project_list_vpcs(&project_id, pagparams).await + } + + pub async fn project_create_vpc( + &self, + project_name: &Name, + params: &VpcCreateParams, + ) -> CreateResult { + let project_id = + self.db_datastore.project_lookup_id_by_name(project_name).await?; + let id = Uuid::new_v4(); + let vpc = self + .db_datastore + .project_create_vpc(&id, &project_id, params) + .await?; + Ok(vpc) + } + + pub async fn project_lookup_vpc( + &self, + project_name: &Name, + vpc_name: &Name, + ) -> LookupResult { + let project_id = + self.db_datastore.project_lookup_id_by_name(project_name).await?; + self.db_datastore.vpc_fetch_by_name(&project_id, vpc_name).await + } + + pub async fn project_delete_vpc( + &self, + project_name: &Name, + vpc_name: &Name, + ) -> DeleteResult { + let vpc = self.project_lookup_vpc(project_name, vpc_name).await?; + self.db_datastore.project_delete_vpc(&vpc.identity.id).await + } + /* * Racks. We simulate just one for now. */ diff --git a/omicron-nexus/tests/output/nexus-openapi.json b/omicron-nexus/tests/output/nexus-openapi.json index 4846a2ae14d..98b04113763 100644 --- a/omicron-nexus/tests/output/nexus-openapi.json +++ b/omicron-nexus/tests/output/nexus-openapi.json @@ -941,6 +941,168 @@ } } }, + "/projects/{project_name}/vpcs": { + "get": { + "description": "List VPCs in a project.", + "operationId": "project_vpcs_get", + "parameters": [ + { + "in": "query", + "name": "limit", + "schema": { + "description": "Maximum number of items returned by a single call", + "type": "integer", + "format": "uint32", + "minimum": 1 + }, + "style": "form" + }, + { + "in": "query", + "name": "page_token", + "schema": { + "description": "Token returned by previous call to retreive the subsequent page", + "type": "string" + }, + "style": "form" + }, + { + "in": "query", + "name": "sort_by", + "schema": { + "$ref": "#/components/schemas/NameSortMode" + }, + "style": "form" + }, + { + "in": "path", + "name": "project_name", + "required": true, + "schema": { + "$ref": "#/components/schemas/Name" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VpcResultsPage" + } + } + } + } + }, + "x-dropshot-pagination": true + }, + "post": { + "description": "Create a VPC in a project.", + "operationId": "project_vpcs_post", + "parameters": [ + { + "in": "path", + "name": "project_name", + "required": true, + "schema": { + "$ref": "#/components/schemas/Name" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VpcCreateParams" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "successful creation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Vpc" + } + } + } + } + } + } + }, + "/projects/{project_name}/vpcs/{vpc_name}": { + "get": { + "description": "Get a VPC in a project.", + "operationId": "project_vpcs_get_vpc", + "parameters": [ + { + "in": "path", + "name": "project_name", + "required": true, + "schema": { + "$ref": "#/components/schemas/Name" + }, + "style": "simple" + }, + { + "in": "path", + "name": "vpc_name", + "required": true, + "schema": { + "$ref": "#/components/schemas/Name" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Vpc" + } + } + } + } + } + }, + "delete": { + "description": "Delete a vpc from a project.", + "operationId": "project_vpcs_delete_vpc", + "parameters": [ + { + "in": "path", + "name": "project_name", + "required": true, + "schema": { + "$ref": "#/components/schemas/Name" + }, + "style": "simple" + }, + { + "in": "path", + "name": "vpc_name", + "required": true, + "schema": { + "$ref": "#/components/schemas/Name" + }, + "style": "simple" + } + ], + "responses": { + "204": { + "description": "successful deletion" + } + } + } + }, "/sagas": { "get": { "description": "List all sagas (for debugging)", @@ -1725,6 +1887,83 @@ "items" ] }, + "Vpc": { + "description": "Identity-related metadata that's included in nearly all public API objects", + "type": "object", + "properties": { + "description": { + "description": "human-readable free-form text about a resource", + "type": "string" + }, + "id": { + "description": "unique, immutable, system-controlled identifier for each resource", + "type": "string", + "format": "uuid" + }, + "name": { + "$ref": "#/components/schemas/Name" + }, + "projectId": { + "description": "id for the project containing this VPC", + "type": "string", + "format": "uuid" + }, + "timeCreated": { + "description": "timestamp when this resource was created", + "type": "string", + "format": "date-time" + }, + "timeModified": { + "description": "timestamp when this resource was last modified", + "type": "string", + "format": "date-time" + } + }, + "required": [ + "description", + "id", + "name", + "projectId", + "timeCreated", + "timeModified" + ] + }, + "VpcCreateParams": { + "description": "Create-time parameters for a [`Vpc`]", + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "name": { + "$ref": "#/components/schemas/Name" + } + }, + "required": [ + "description", + "name" + ] + }, + "VpcResultsPage": { + "description": "A single page of results", + "type": "object", + "properties": { + "items": { + "description": "list of items on this page of results", + "type": "array", + "items": { + "$ref": "#/components/schemas/Vpc" + } + }, + "next_page": { + "description": "token used to fetch the next page of results (if any)", + "type": "string" + } + }, + "required": [ + "items" + ] + }, "IdSortMode": { "description": "Supported set of sort modes for scanning by id only.\n\nCurrently, we only support scanning in ascending order.", "type": "string", diff --git a/omicron-nexus/tests/test_vpcs.rs b/omicron-nexus/tests/test_vpcs.rs new file mode 100644 index 00000000000..29bbbed4179 --- /dev/null +++ b/omicron-nexus/tests/test_vpcs.rs @@ -0,0 +1,138 @@ +use http::method::Method; +use http::StatusCode; +use omicron_common::api::external::IdentityMetadataCreateParams; +use omicron_common::api::external::Name; +use omicron_common::api::external::ProjectCreateParams; +use omicron_common::api::external::ProjectView; +use omicron_common::api::external::Vpc; +use omicron_common::api::external::VpcCreateParams; +use std::convert::TryFrom; + +use dropshot::test_util::object_get; +use dropshot::test_util::objects_list_page; +use dropshot::test_util::objects_post; +use dropshot::test_util::ClientTestContext; + +pub mod common; +use common::identity_eq; +use common::test_setup; + +#[macro_use] +extern crate slog; + +#[tokio::test] +async fn test_vpcs() { + let cptestctx = test_setup("test_vpcs").await; + let client = &cptestctx.external_client; + + /* Create a project that we'll use for testing. */ + let project_name = "springfield-squidport"; + let vpcs_url = format!("/projects/{}/vpcs", project_name); + let _ = create_project(&client, &project_name).await; + + let project_name2 = "pokemon"; + let vpcs_url2 = format!("/projects/{}/vpcs", project_name2); + let _ = create_project(&client, &project_name2).await; + + /* List vpcs. There aren't any yet. */ + let vpcs = vpcs_list(&client, &vpcs_url).await; + assert_eq!(vpcs.len(), 0); + + /* Make sure we get a 404 if we fetch one. */ + let vpc_url = format!("{}/just-rainsticks", vpcs_url); + + let error = client + .make_request_error(Method::GET, &vpc_url, StatusCode::NOT_FOUND) + .await; + assert_eq!(error.message, "not found: vpc with name \"just-rainsticks\""); + + /* Ditto if we try to delete one. */ + let error = client + .make_request_error(Method::DELETE, &vpc_url, StatusCode::NOT_FOUND) + .await; + assert_eq!(error.message, "not found: vpc with name \"just-rainsticks\""); + + /* Create a VPC. */ + let new_vpc = VpcCreateParams { + identity: IdentityMetadataCreateParams { + name: Name::try_from("just-rainsticks").unwrap(), + description: String::from("sells rainsticks"), + }, + }; + let vpc: Vpc = objects_post(&client, &vpcs_url, new_vpc.clone()).await; + assert_eq!(vpc.identity.name, "just-rainsticks"); + assert_eq!(vpc.identity.description, "sells rainsticks"); + + /* Attempt to create a second VPC with a conflicting name. */ + let error = client + .make_request_error_body( + Method::POST, + &vpcs_url, + new_vpc.clone(), + StatusCode::BAD_REQUEST, + ) + .await; + assert_eq!(error.message, "already exists: vpc \"just-rainsticks\""); + + /* creating a VPC with the same name in another project works, though */ + let vpc2: Vpc = objects_post(&client, &vpcs_url2, new_vpc.clone()).await; + assert_eq!(vpc2.identity.name, "just-rainsticks"); + assert_eq!(vpc2.identity.description, "sells rainsticks"); + + /* List VPCs again and expect to find the one we just created. */ + let vpcs = vpcs_list(&client, &vpcs_url).await; + assert_eq!(vpcs.len(), 1); + vpcs_eq(&vpcs[0], &vpc); + + /* Fetch the VPC and expect it to match. */ + let vpc = vpc_get(&client, &vpc_url).await; + vpcs_eq(&vpcs[0], &vpc); + + /* Delete the VPC. */ + client + .make_request_no_body(Method::DELETE, &vpc_url, StatusCode::NO_CONTENT) + .await + .unwrap(); + + /* Now we expect a 404 on fetch */ + let error = client + .make_request_error(Method::GET, &vpc_url, StatusCode::NOT_FOUND) + .await; + assert_eq!(error.message, "not found: vpc with name \"just-rainsticks\""); + + /* And the list should be empty again */ + let vpcs = vpcs_list(&client, &vpcs_url).await; + assert_eq!(vpcs.len(), 0); + + cptestctx.teardown().await; +} + +async fn vpcs_list(client: &ClientTestContext, vpcs_url: &str) -> Vec { + objects_list_page::(client, vpcs_url).await.items +} + +async fn vpc_get(client: &ClientTestContext, vpc_url: &str) -> Vpc { + object_get::(client, vpc_url).await +} + +fn vpcs_eq(vpc1: &Vpc, vpc2: &Vpc) { + identity_eq(&vpc1.identity, &vpc2.identity); + assert_eq!(vpc1.project_id, vpc2.project_id); +} + +async fn create_project( + client: &ClientTestContext, + project_name: &str, +) -> ProjectView { + objects_post( + &client, + "/projects", + ProjectCreateParams { + identity: IdentityMetadataCreateParams { + name: Name::try_from(project_name).unwrap(), + description: "a pier".to_string(), + }, + }, + ) + .await +} diff --git a/tools/oxapi_demo b/tools/oxapi_demo index 46a03005e5b..581b222d48a 100755 --- a/tools/oxapi_demo +++ b/tools/oxapi_demo @@ -157,6 +157,12 @@ function cmd_project_list_disks do_curl "/projects/$1/disks" } +function cmd_project_list_vpcs +{ + [[ $# != 1 ]] && usage "expected PROJECT_NAME" + do_curl "/projects/$1/vpcs" +} + function cmd_instance_create_demo { # memory is 1024 * 1024 * 256 @@ -239,6 +245,25 @@ function cmd_disk_delete do_curl "/projects/$1/disks/$2" -X DELETE } +function cmd_vpc_create_demo +{ + [[ $# != 2 ]] && usage "expected PROJECT_NAME VPC_NAME" + mkjson name="$2" description="a disk called $2" | + do_curl "/projects/$1/vpcs" -X POST -T - +} + +function cmd_vpc_get +{ + [[ $# != 2 ]] && usage "expected PROJECT_NAME VPC_NAME" + do_curl "/projects/$1/vpcs/$2" +} + +function cmd_vpc_delete +{ + [[ $# != 2 ]] && usage "expected PROJECT_NAME VPC_NAME" + do_curl "/projects/$1/vpcs/$2" -X DELETE +} + function cmd_racks_list { [[ $# != 0 ]] && usage "expected no arguments"