From db72fdc937b02afb3aa2c9528a7290147db4fab2 Mon Sep 17 00:00:00 2001 From: Sean Klein Date: Thu, 10 Mar 2022 18:04:31 -0500 Subject: [PATCH 1/5] [nexus] Add stubs for images api --- common/src/api/external/mod.rs | 14 + common/src/sql/dbinit.sql | 20 + nexus/src/db/model.rs | 33 +- nexus/src/db/schema.rs | 14 + nexus/src/external_api/http_entrypoints.rs | 256 +++++++++++ nexus/src/external_api/params.rs | 13 + nexus/src/nexus.rs | 72 +++ nexus/tests/output/nexus_tags.txt | 11 + openapi/nexus.json | 502 ++++++++++++++++++++- 9 files changed, 927 insertions(+), 8 deletions(-) diff --git a/common/src/api/external/mod.rs b/common/src/api/external/mod.rs index a890106e10..491ec6f306 100644 --- a/common/src/api/external/mod.rs +++ b/common/src/api/external/mod.rs @@ -935,6 +935,20 @@ impl DiskState { } } +/* + * IMAGES + */ + +/// Client view of Images +#[derive(ObjectIdentity, Clone, Debug, Deserialize, Serialize, JsonSchema)] +pub struct Image { + #[serde(flatten)] + pub identity: IdentityMetadata, + + pub project_id: Option, + pub size: ByteCount, +} + /* * Sagas * diff --git a/common/src/sql/dbinit.sql b/common/src/sql/dbinit.sql index 531f37f26f..a2417ef74e 100644 --- a/common/src/sql/dbinit.sql +++ b/common/src/sql/dbinit.sql @@ -370,6 +370,26 @@ CREATE INDEX ON omicron.public.disk ( ) WHERE time_deleted IS NULL AND attach_instance_id IS NOT NULL; +CREATE TABLE omicron.public.image ( + /* Identity metadata (resource) */ + id UUID PRIMARY KEY, + name STRING(63) NOT NULL, + description STRING(512) NOT NULL, + time_created TIMESTAMPTZ NOT NULL, + time_modified TIMESTAMPTZ NOT NULL, + /* Indicates that the object has been deleted */ + time_deleted TIMESTAMPTZ, + + project_id UUID, + volume_id UUID NOT NULL, + size_bytes INT NOT NULL +); + +CREATE UNIQUE INDEX on omicron.public.image ( + project_id, + name +) WHERE + time_deleted is NULL; /* * Oximeter collector servers. diff --git a/nexus/src/db/model.rs b/nexus/src/db/model.rs index 8870ff0b6f..257d51d4c6 100644 --- a/nexus/src/db/model.rs +++ b/nexus/src/db/model.rs @@ -7,7 +7,7 @@ use crate::db::collection_insert::DatastoreCollection; use crate::db::identity::{Asset, Resource}; use crate::db::schema::{ - console_session, dataset, disk, instance, metric_producer, + console_session, dataset, disk, image, instance, metric_producer, network_interface, organization, oximeter, project, rack, region, role_assignment_builtin, role_builtin, router_route, sled, update_available_artifact, user_builtin, volume, vpc, vpc_firewall_rule, @@ -1231,6 +1231,37 @@ impl Into for DiskState { } } +#[derive( + Queryable, + Insertable, + Selectable, + Clone, + Debug, + Resource, + Serialize, + Deserialize, +)] +#[table_name = "image"] +pub struct Image { + #[diesel(embed)] + identity: ImageIdentity, + + project_id: Option, + volume_id: Uuid, + #[column_name = "size_bytes"] + size: ByteCount, +} + +impl From for external::Image { + fn from(image: Image) -> Self { + Self { + identity: image.identity(), + project_id: image.project_id, + size: image.size.into(), + } + } +} + /// Information announced by a metric server, used so that clients can contact it and collect /// available metric data from it. #[derive(Queryable, Insertable, Debug, Clone, Selectable, Asset)] diff --git a/nexus/src/db/schema.rs b/nexus/src/db/schema.rs index ba665e533f..4d21526487 100644 --- a/nexus/src/db/schema.rs +++ b/nexus/src/db/schema.rs @@ -26,6 +26,20 @@ table! { } } +table! { + image (id) { + id -> Uuid, + name -> Text, + description -> Text, + time_created -> Timestamptz, + time_modified -> Timestamptz, + time_deleted -> Nullable, + project_id -> Nullable, + volume_id -> Uuid, + size_bytes -> Int8, + } +} + table! { instance (id) { id -> Uuid, diff --git a/nexus/src/external_api/http_entrypoints.rs b/nexus/src/external_api/http_entrypoints.rs index b4b481f860..54e642b781 100644 --- a/nexus/src/external_api/http_entrypoints.rs +++ b/nexus/src/external_api/http_entrypoints.rs @@ -45,6 +45,7 @@ use omicron_common::api::external::to_list; use omicron_common::api::external::DataPageParams; use omicron_common::api::external::Disk; use omicron_common::api::external::Error; +use omicron_common::api::external::Image; use omicron_common::api::external::Instance; use omicron_common::api::external::NetworkInterface; use omicron_common::api::external::RouterRoute; @@ -96,6 +97,18 @@ pub fn external_api() -> NexusApiDescription { api.register(project_instances_instance_start)?; api.register(project_instances_instance_stop)?; + // Globally-scoped Images API + api.register(images_get)?; + api.register(images_post)?; + api.register(images_get_image)?; + api.register(images_delete_image)?; + + // Project-scoped images API + api.register(project_images_get)?; + api.register(project_images_post)?; + api.register(project_images_get_image)?; + api.register(project_images_delete_image)?; + api.register(instance_disks_get)?; api.register(instance_disks_attach)?; api.register(instance_disks_detach)?; @@ -1106,6 +1119,249 @@ async fn instance_disks_detach( apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await } +/* + * Images + */ + +/// List global images. +#[endpoint { + method = GET, + path = "/images", + tags = ["images"], +}] +async fn images_get( + rqctx: Arc>>, + query_params: Query, +) -> Result>, HttpError> { + let apictx = rqctx.context(); + let nexus = &apictx.nexus; + let query = query_params.into_inner(); + let handler = async { + let opctx = OpContext::for_external_api(&rqctx).await?; + let images = nexus + .list_images( + &opctx, + &data_page_params_for(&rqctx, &query)? + .map_name(|n| Name::ref_cast(n)), + ) + .await? + .into_iter() + .map(|d| d.into()) + .collect(); + Ok(HttpResponseOk(ScanByName::results_page(&query, images)?)) + }; + apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await +} + +/// Create a global image. +#[endpoint { + method = POST, + path = "/images", + tags = ["images"] +}] +async fn images_post( + rqctx: Arc>>, + new_image: TypedBody, +) -> Result, HttpError> { + let apictx = rqctx.context(); + let nexus = &apictx.nexus; + let new_image_params = &new_image.into_inner(); + let handler = async { + let opctx = OpContext::for_external_api(&rqctx).await?; + let image = nexus.create_image(&opctx, &new_image_params).await?; + Ok(HttpResponseCreated(image.into())) + }; + apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await +} + +/// Path parameters for Image requests +#[derive(Deserialize, JsonSchema)] +struct GlobalImagePathParam { + image_name: Name, +} + +/// Get a global image. +#[endpoint { + method = GET, + path = "/images/{image_name}", + tags = ["images"], +}] +async fn images_get_image( + rqctx: Arc>>, + path_params: Path, +) -> Result, HttpError> { + let apictx = rqctx.context(); + let nexus = &apictx.nexus; + let path = path_params.into_inner(); + let image_name = &path.image_name; + let handler = async { + let opctx = OpContext::for_external_api(&rqctx).await?; + let image = nexus.image_fetch(&opctx, &image_name).await?; + Ok(HttpResponseOk(image.into())) + }; + apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await +} + +/// Delete a global image. +#[endpoint { + method = DELETE, + path = "/images/{image_name}", + tags = ["images"], +}] +async fn images_delete_image( + rqctx: Arc>>, + path_params: Path, +) -> Result { + let apictx = rqctx.context(); + let nexus = &apictx.nexus; + let path = path_params.into_inner(); + let image_name = &path.image_name; + let handler = async { + let opctx = OpContext::for_external_api(&rqctx).await?; + nexus.delete_image(&opctx, &image_name).await?; + Ok(HttpResponseDeleted()) + }; + apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await +} + +/// List images in a project. +#[endpoint { + method = GET, + path = "/organizations/{organization_name}/projects/{project_name}/images", + tags = ["images"], +}] +async fn project_images_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 organization_name = &path.organization_name; + let project_name = &path.project_name; + let handler = async { + let opctx = OpContext::for_external_api(&rqctx).await?; + let images = nexus + .project_list_images( + &opctx, + organization_name, + project_name, + &data_page_params_for(&rqctx, &query)? + .map_name(|n| Name::ref_cast(n)), + ) + .await? + .into_iter() + .map(|d| d.into()) + .collect(); + Ok(HttpResponseOk(ScanByName::results_page(&query, images)?)) + }; + apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await +} + +/// Create an instance in a project +#[endpoint { + method = POST, + path = "/organizations/{organization_name}/projects/{project_name}/images", + tags = ["images"] +}] +async fn project_images_post( + rqctx: Arc>>, + path_params: Path, + new_image: 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_image_params = &new_image.into_inner(); + let handler = async { + let opctx = OpContext::for_external_api(&rqctx).await?; + let image = nexus + .project_create_image( + &opctx, + &organization_name, + &project_name, + &new_image_params, + ) + .await?; + Ok(HttpResponseCreated(image.into())) + }; + apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await +} + +/// Path parameters for Image requests +#[derive(Deserialize, JsonSchema)] +struct ImagePathParam { + organization_name: Name, + project_name: Name, + image_name: Name, +} + +/// Get an image in a project. +#[endpoint { + method = GET, + path = "/organizations/{organization_name}/projects/{project_name}/images/{image_name}", + tags = ["images"], +}] +async fn project_images_get_image( + rqctx: Arc>>, + 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 image_name = &path.image_name; + let handler = async { + let opctx = OpContext::for_external_api(&rqctx).await?; + let image = nexus + .project_image_fetch( + &opctx, + &organization_name, + &project_name, + &image_name, + ) + .await?; + Ok(HttpResponseOk(image.into())) + }; + apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await +} + +/// Delete an image from a project. +#[endpoint { + method = DELETE, + path = "/organizations/{organization_name}/projects/{project_name}/images/{image_name}", + tags = ["images"], +}] +async fn project_images_delete_image( + rqctx: Arc>>, + 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 image_name = &path.image_name; + let handler = async { + let opctx = OpContext::for_external_api(&rqctx).await?; + nexus + .project_delete_image( + &opctx, + &organization_name, + &project_name, + &image_name, + ) + .await?; + Ok(HttpResponseDeleted()) + }; + apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await +} + /* * VPCs */ diff --git a/nexus/src/external_api/params.rs b/nexus/src/external_api/params.rs index 40f37ec63e..68bf9eb10e 100644 --- a/nexus/src/external_api/params.rs +++ b/nexus/src/external_api/params.rs @@ -229,6 +229,19 @@ pub struct DiskIdentifier { pub disk: Name, } +/* + * IMAGES + */ + +/// Create-time parameters for an +/// [`Image`](omicron_common::api::external::Image) +#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] +pub struct ImageCreate { + /// common identifying metadata + #[serde(flatten)] + pub identity: IdentityMetadataCreateParams, +} + /* * BUILT-IN USERS * diff --git a/nexus/src/nexus.rs b/nexus/src/nexus.rs index 4adb092b7d..1943e48572 100644 --- a/nexus/src/nexus.rs +++ b/nexus/src/nexus.rs @@ -791,6 +791,78 @@ impl Nexus { Ok(()) } + pub async fn list_images( + &self, + _opctx: &OpContext, + _pagparams: &DataPageParams<'_, Name>, + ) -> ListResultVec { + unimplemented!(); + } + + pub async fn create_image( + self: &Arc, + _opctx: &OpContext, + _params: ¶ms::ImageCreate, + ) -> CreateResult { + unimplemented!(); + } + + pub async fn image_fetch( + &self, + _opctx: &OpContext, + _image_name: &Name, + ) -> LookupResult { + unimplemented!(); + } + + pub async fn delete_image( + self: &Arc, + _opctx: &OpContext, + _image_name: &Name, + ) -> DeleteResult { + unimplemented!(); + } + + pub async fn project_list_images( + &self, + _opctx: &OpContext, + _organization_name: &Name, + _project_name: &Name, + _pagparams: &DataPageParams<'_, Name>, + ) -> ListResultVec { + unimplemented!(); + } + + pub async fn project_create_image( + self: &Arc, + _opctx: &OpContext, + _organization_name: &Name, + _project_name: &Name, + _params: ¶ms::ImageCreate, + ) -> CreateResult { + unimplemented!(); + } + + pub async fn project_image_fetch( + &self, + _opctx: &OpContext, + _organization_name: &Name, + _project_name: &Name, + _image_name: &Name, + ) -> LookupResult { + unimplemented!(); + } + + pub async fn project_delete_image( + self: &Arc, + _opctx: &OpContext, + _organization_name: &Name, + _project_name: &Name, + _image_name: &Name, + ) -> DeleteResult { + unimplemented!(); + } + /* * Instances */ diff --git a/nexus/tests/output/nexus_tags.txt b/nexus/tests/output/nexus_tags.txt index e7f4fa0fe1..059c00297a 100644 --- a/nexus/tests/output/nexus_tags.txt +++ b/nexus/tests/output/nexus_tags.txt @@ -16,6 +16,17 @@ logout /logout session_me /session/me spoof_login /login +API operations found with tag "images" +OPERATION ID URL PATH +images_delete_image /images/{image_name} +images_get /images +images_get_image /images/{image_name} +images_post /images +project_images_delete_image /organizations/{organization_name}/projects/{project_name}/images/{image_name} +project_images_get /organizations/{organization_name}/projects/{project_name}/images +project_images_get_image /organizations/{organization_name}/projects/{project_name}/images/{image_name} +project_images_post /organizations/{organization_name}/projects/{project_name}/images + API operations found with tag "instances" OPERATION ID URL PATH instance_disks_attach /organizations/{organization_name}/projects/{project_name}/instances/{instance_name}/disks/attach diff --git a/openapi/nexus.json b/openapi/nexus.json index a88e86dd09..ff15ad71fa 100644 --- a/openapi/nexus.json +++ b/openapi/nexus.json @@ -210,6 +210,168 @@ } } }, + "/images": { + "get": { + "tags": [ + "images" + ], + "summary": "List global images.", + "operationId": "images_get", + "parameters": [ + { + "in": "query", + "name": "limit", + "description": "Maximum number of items returned by a single call", + "schema": { + "nullable": true, + "type": "integer", + "format": "uint32", + "minimum": 1 + }, + "style": "form" + }, + { + "in": "query", + "name": "page_token", + "description": "Token returned by previous call to retreive the subsequent page", + "schema": { + "nullable": true, + "type": "string" + }, + "style": "form" + }, + { + "in": "query", + "name": "sort_by", + "schema": { + "$ref": "#/components/schemas/NameSortMode" + }, + "style": "form" + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImageResultsPage" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + }, + "x-dropshot-pagination": true + }, + "post": { + "tags": [ + "images" + ], + "summary": "Create a global image.", + "operationId": "images_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImageCreate" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "successful creation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Image" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/images/{image_name}": { + "get": { + "tags": [ + "images" + ], + "summary": "Get a global image.", + "operationId": "images_get_image", + "parameters": [ + { + "in": "path", + "name": "image_name", + "required": true, + "schema": { + "$ref": "#/components/schemas/Name" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Image" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "delete": { + "tags": [ + "images" + ], + "summary": "Delete a global image.", + "operationId": "images_delete_image", + "parameters": [ + { + "in": "path", + "name": "image_name", + "required": true, + "schema": { + "$ref": "#/components/schemas/Name" + }, + "style": "simple" + } + ], + "responses": { + "204": { + "description": "successful deletion" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, "/login": { "post": { "tags": [ @@ -960,6 +1122,246 @@ } } }, + "/organizations/{organization_name}/projects/{project_name}/images": { + "get": { + "tags": [ + "images" + ], + "summary": "List images in a project.", + "operationId": "project_images_get", + "parameters": [ + { + "in": "query", + "name": "limit", + "description": "Maximum number of items returned by a single call", + "schema": { + "nullable": true, + "type": "integer", + "format": "uint32", + "minimum": 1 + }, + "style": "form" + }, + { + "in": "query", + "name": "page_token", + "description": "Token returned by previous call to retreive the subsequent page", + "schema": { + "nullable": true, + "type": "string" + }, + "style": "form" + }, + { + "in": "query", + "name": "sort_by", + "schema": { + "$ref": "#/components/schemas/NameSortMode" + }, + "style": "form" + }, + { + "in": "path", + "name": "organization_name", + "description": "The organization's unique name.", + "required": true, + "schema": { + "$ref": "#/components/schemas/Name" + }, + "style": "simple" + }, + { + "in": "path", + "name": "project_name", + "description": "The project's unique name within the organization.", + "required": true, + "schema": { + "$ref": "#/components/schemas/Name" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImageResultsPage" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + }, + "x-dropshot-pagination": true + }, + "post": { + "tags": [ + "images" + ], + "summary": "Create an instance in a project", + "operationId": "project_images_post", + "parameters": [ + { + "in": "path", + "name": "organization_name", + "description": "The organization's unique name.", + "required": true, + "schema": { + "$ref": "#/components/schemas/Name" + }, + "style": "simple" + }, + { + "in": "path", + "name": "project_name", + "description": "The project's unique name within the organization.", + "required": true, + "schema": { + "$ref": "#/components/schemas/Name" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImageCreate" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "successful creation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Image" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/organizations/{organization_name}/projects/{project_name}/images/{image_name}": { + "get": { + "tags": [ + "images" + ], + "summary": "Get an image in a project.", + "operationId": "project_images_get_image", + "parameters": [ + { + "in": "path", + "name": "image_name", + "required": true, + "schema": { + "$ref": "#/components/schemas/Name" + }, + "style": "simple" + }, + { + "in": "path", + "name": "organization_name", + "required": true, + "schema": { + "$ref": "#/components/schemas/Name" + }, + "style": "simple" + }, + { + "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/Image" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "delete": { + "tags": [ + "images" + ], + "summary": "Delete an image from a project.", + "operationId": "project_images_delete_image", + "parameters": [ + { + "in": "path", + "name": "image_name", + "required": true, + "schema": { + "$ref": "#/components/schemas/Name" + }, + "style": "simple" + }, + { + "in": "path", + "name": "organization_name", + "required": true, + "schema": { + "$ref": "#/components/schemas/Name" + }, + "style": "simple" + }, + { + "in": "path", + "name": "project_name", + "required": true, + "schema": { + "$ref": "#/components/schemas/Name" + }, + "style": "simple" + } + ], + "responses": { + "204": { + "description": "successful deletion" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, "/organizations/{organization_name}/projects/{project_name}/instances": { "get": { "tags": [ @@ -3956,6 +4358,92 @@ "Bool" ] }, + "Image": { + "description": "Client view of Images", + "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": { + "description": "unique, mutable, user-controlled identifier for each resource", + "allOf": [ + { + "$ref": "#/components/schemas/Name" + } + ] + }, + "project_id": { + "nullable": true, + "type": "string", + "format": "uuid" + }, + "size": { + "$ref": "#/components/schemas/ByteCount" + }, + "time_created": { + "description": "timestamp when this resource was created", + "type": "string", + "format": "date-time" + }, + "time_modified": { + "description": "timestamp when this resource was last modified", + "type": "string", + "format": "date-time" + } + }, + "required": [ + "description", + "id", + "name", + "size", + "time_created", + "time_modified" + ] + }, + "ImageCreate": { + "description": "Create-time parameters for an [`Image`](omicron_common::api::external::Image)", + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "name": { + "$ref": "#/components/schemas/Name" + } + }, + "required": [ + "description", + "name" + ] + }, + "ImageResultsPage": { + "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/Image" + } + }, + "next_page": { + "nullable": true, + "description": "token used to fetch the next page of results (if any)", + "type": "string" + } + }, + "required": [ + "items" + ] + }, "Instance": { "description": "Client view of an [`Instance`]", "type": "object", @@ -6068,6 +6556,13 @@ "id-ascending" ] }, + "NameSortMode": { + "description": "Supported set of sort modes for scanning by name only\n\nCurrently, we only support scanning in ascending order.", + "type": "string", + "enum": [ + "name-ascending" + ] + }, "NameOrIdSortMode": { "description": "Supported set of sort modes for scanning by name or id", "type": "string", @@ -6076,13 +6571,6 @@ "name-descending", "id-ascending" ] - }, - "NameSortMode": { - "description": "Supported set of sort modes for scanning by name only\n\nCurrently, we only support scanning in ascending order.", - "type": "string", - "enum": [ - "name-ascending" - ] } } } From c324b120053c05db0d31a4a022aed3800c20127b Mon Sep 17 00:00:00 2001 From: Sean Klein Date: Thu, 10 Mar 2022 18:35:47 -0500 Subject: [PATCH 2/5] Move to views --- common/src/api/external/mod.rs | 14 -------------- nexus/src/db/model.rs | 3 ++- nexus/src/external_api/http_entrypoints.rs | 5 +++-- nexus/src/external_api/views.rs | 17 ++++++++++++++++- 4 files changed, 21 insertions(+), 18 deletions(-) diff --git a/common/src/api/external/mod.rs b/common/src/api/external/mod.rs index 491ec6f306..a890106e10 100644 --- a/common/src/api/external/mod.rs +++ b/common/src/api/external/mod.rs @@ -935,20 +935,6 @@ impl DiskState { } } -/* - * IMAGES - */ - -/// Client view of Images -#[derive(ObjectIdentity, Clone, Debug, Deserialize, Serialize, JsonSchema)] -pub struct Image { - #[serde(flatten)] - pub identity: IdentityMetadata, - - pub project_id: Option, - pub size: ByteCount, -} - /* * Sagas * diff --git a/nexus/src/db/model.rs b/nexus/src/db/model.rs index 257d51d4c6..9faa8c88f6 100644 --- a/nexus/src/db/model.rs +++ b/nexus/src/db/model.rs @@ -15,6 +15,7 @@ use crate::db::schema::{ }; use crate::defaults; use crate::external_api::params; +use crate::external_api::views; use crate::internal_api; use chrono::{DateTime, Utc}; use db_macros::{Asset, Resource}; @@ -1252,7 +1253,7 @@ pub struct Image { size: ByteCount, } -impl From for external::Image { +impl From for views::Image { fn from(image: Image) -> Self { Self { identity: image.identity(), diff --git a/nexus/src/external_api/http_entrypoints.rs b/nexus/src/external_api/http_entrypoints.rs index 54e642b781..3e904c8e12 100644 --- a/nexus/src/external_api/http_entrypoints.rs +++ b/nexus/src/external_api/http_entrypoints.rs @@ -12,7 +12,9 @@ use crate::ServerContext; use super::{ console_api, params, - views::{Organization, Project, Rack, Role, Sled, User, Vpc, VpcSubnet}, + views::{ + Image, Organization, Project, Rack, Role, Sled, User, Vpc, VpcSubnet, + }, }; use crate::context::OpContext; use dropshot::ApiDescription; @@ -45,7 +47,6 @@ use omicron_common::api::external::to_list; use omicron_common::api::external::DataPageParams; use omicron_common::api::external::Disk; use omicron_common::api::external::Error; -use omicron_common::api::external::Image; use omicron_common::api::external::Instance; use omicron_common::api::external::NetworkInterface; use omicron_common::api::external::RouterRoute; diff --git a/nexus/src/external_api/views.rs b/nexus/src/external_api/views.rs index 3958a82508..c794b73e7f 100644 --- a/nexus/src/external_api/views.rs +++ b/nexus/src/external_api/views.rs @@ -11,7 +11,8 @@ use crate::db::identity::{Asset, Resource}; use crate::db::model; use api_identity::ObjectIdentity; use omicron_common::api::external::{ - IdentityMetadata, Ipv4Net, Ipv6Net, Name, ObjectIdentity, RoleName, + ByteCount, IdentityMetadata, Ipv4Net, Ipv6Net, Name, ObjectIdentity, + RoleName, }; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -64,6 +65,20 @@ impl Into for model::Project { } } +/* + * IMAGES + */ + +/// Client view of Images +#[derive(ObjectIdentity, Clone, Debug, Deserialize, Serialize, JsonSchema)] +pub struct Image { + #[serde(flatten)] + pub identity: IdentityMetadata, + + pub project_id: Option, + pub size: ByteCount, +} + /* * VPCs */ From 2b98d634fe2f04a78c6b7a54908300957b09a0cd Mon Sep 17 00:00:00 2001 From: Sean Klein Date: Mon, 28 Mar 2022 21:45:39 -0400 Subject: [PATCH 3/5] better docs, add images tag --- nexus/src/external_api/http_entrypoints.rs | 31 +++++++++++++++++++--- nexus/src/external_api/tag-config.json | 6 +++++ 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/nexus/src/external_api/http_entrypoints.rs b/nexus/src/external_api/http_entrypoints.rs index 981bd6541c..53b4175782 100644 --- a/nexus/src/external_api/http_entrypoints.rs +++ b/nexus/src/external_api/http_entrypoints.rs @@ -1183,6 +1183,9 @@ async fn instance_disks_detach( // Images /// List global images. +/// +/// Returns a list of all the global images. Global images are returned sorted +/// by creation date, with the most recent images appearing first. #[endpoint { method = GET, path = "/images", @@ -1213,6 +1216,9 @@ async fn images_get( } /// Create a global image. +/// +/// Create a new global image. This image can then be used by any user as a base +/// for instances. #[endpoint { method = POST, path = "/images", @@ -1240,6 +1246,8 @@ struct GlobalImagePathParam { } /// Get a global image. +/// +/// Returns the details of a specific global image. #[endpoint { method = GET, path = "/images/{image_name}", @@ -1262,6 +1270,10 @@ async fn images_get_image( } /// Delete a global image. +/// +/// Permanently delete a global image. This operation cannot be undone. Any +/// instances using the global image will continue to run, however new instances +/// can not be created with this image. #[endpoint { method = DELETE, path = "/images/{image_name}", @@ -1283,7 +1295,10 @@ async fn images_delete_image( apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await } -/// List images in a project. +/// List images +/// +/// List images in a project. The images are returned sorted by creation date, +/// with the most recent images appearing first. #[endpoint { method = GET, path = "/organizations/{organization_name}/projects/{project_name}/images", @@ -1319,7 +1334,9 @@ async fn project_images_get( apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await } -/// Create an instance in a project +/// Create an image +/// +/// Create a new image in a project. #[endpoint { method = POST, path = "/organizations/{organization_name}/projects/{project_name}/images", @@ -1359,7 +1376,9 @@ struct ImagePathParam { image_name: Name, } -/// Get an image in a project. +/// Get an image +/// +/// Get the details of a specific image in a project. #[endpoint { method = GET, path = "/organizations/{organization_name}/projects/{project_name}/images/{image_name}", @@ -1390,7 +1409,11 @@ async fn project_images_get_image( apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await } -/// Delete an image from a project. +/// Delete an image +/// +/// Permanently delete an image from a project. This operation cannot be undone. +/// Any instances in the project using the image will continue to run, however +/// new instances can not be created with this image. #[endpoint { method = DELETE, path = "/organizations/{organization_name}/projects/{project_name}/images/{image_name}", diff --git a/nexus/src/external_api/tag-config.json b/nexus/src/external_api/tag-config.json index c02b6314a1..bfd8b24ede 100644 --- a/nexus/src/external_api/tag-config.json +++ b/nexus/src/external_api/tag-config.json @@ -20,6 +20,12 @@ "url": "http://oxide.computer/docs/#xxx" } }, + "images": { + "description": "Images are read-only Virtual Disks that may be used to boot Virtual Machines", + "external_docs": { + "url": "http://oxide.computer/docs/#xxx" + } + }, "instances": { "description": "Virtual machine instances are the basic unit of computation. These operations are used for provisioning, controlling, and destroying instances.", "external_docs": { From ab273bd8e5052ced5f32e24072f4467d66f70772 Mon Sep 17 00:00:00 2001 From: Sean Klein Date: Mon, 28 Mar 2022 22:34:16 -0400 Subject: [PATCH 4/5] expectorate more stuff --- .../output/uncovered-authz-endpoints.txt | 8 +++++++ openapi/nexus.json | 23 +++++++++++++++---- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/nexus/tests/output/uncovered-authz-endpoints.txt b/nexus/tests/output/uncovered-authz-endpoints.txt index 38d2cb5302..aebceb8c5d 100644 --- a/nexus/tests/output/uncovered-authz-endpoints.txt +++ b/nexus/tests/output/uncovered-authz-endpoints.txt @@ -1,7 +1,13 @@ API endpoints with no coverage in authz tests: +images_delete_image (delete "/images/{image_name}") +project_images_delete_image (delete "/organizations/{organization_name}/projects/{project_name}/images/{image_name}") instance_network_interfaces_delete_interface (delete "/organizations/{organization_name}/projects/{project_name}/instances/{instance_name}/network-interfaces/{interface_name}") project_snapshots_delete_snapshot (delete "/organizations/{organization_name}/projects/{project_name}/snapshots/{snapshot_name}") silos_delete_silo (delete "/silos/{silo_name}") +images_get (get "/images") +images_get_image (get "/images/{image_name}") +project_images_get (get "/organizations/{organization_name}/projects/{project_name}/images") +project_images_get_image (get "/organizations/{organization_name}/projects/{project_name}/images/{image_name}") instance_disks_get (get "/organizations/{organization_name}/projects/{project_name}/instances/{instance_name}/disks") instance_network_interfaces_get (get "/organizations/{organization_name}/projects/{project_name}/instances/{instance_name}/network-interfaces") instance_network_interfaces_get_interface (get "/organizations/{organization_name}/projects/{project_name}/instances/{instance_name}/network-interfaces/{interface_name}") @@ -11,8 +17,10 @@ subnet_network_interfaces_get (get "/organizations/{organization_n session_me (get "/session/me") silos_get (get "/silos") silos_get_silo (get "/silos/{silo_name}") +images_post (post "/images") spoof_login (post "/login") logout (post "/logout") +project_images_post (post "/organizations/{organization_name}/projects/{project_name}/images") instance_network_interfaces_post (post "/organizations/{organization_name}/projects/{project_name}/instances/{instance_name}/network-interfaces") project_snapshots_post (post "/organizations/{organization_name}/projects/{project_name}/snapshots") silos_post (post "/silos") diff --git a/openapi/nexus.json b/openapi/nexus.json index d0ba27ded2..557aeb147d 100644 --- a/openapi/nexus.json +++ b/openapi/nexus.json @@ -216,6 +216,7 @@ "images" ], "summary": "List global images.", + "description": "Returns a list of all the global images. Global images are returned sorted by creation date, with the most recent images appearing first.", "operationId": "images_get", "parameters": [ { @@ -274,6 +275,7 @@ "images" ], "summary": "Create a global image.", + "description": "Create a new global image. This image can then be used by any user as a base for instances.", "operationId": "images_post", "requestBody": { "content": { @@ -311,6 +313,7 @@ "images" ], "summary": "Get a global image.", + "description": "Returns the details of a specific global image.", "operationId": "images_get_image", "parameters": [ { @@ -347,6 +350,7 @@ "images" ], "summary": "Delete a global image.", + "description": "Permanently delete a global image. This operation cannot be undone. Any instances using the global image will continue to run, however new instances can not be created with this image.", "operationId": "images_delete_image", "parameters": [ { @@ -1137,7 +1141,8 @@ "tags": [ "images" ], - "summary": "List images in a project.", + "summary": "List images", + "description": "List images in a project. The images are returned sorted by creation date, with the most recent images appearing first.", "operationId": "project_images_get", "parameters": [ { @@ -1215,7 +1220,8 @@ "tags": [ "images" ], - "summary": "Create an instance in a project", + "summary": "Create an image", + "description": "Create a new image in a project.", "operationId": "project_images_post", "parameters": [ { @@ -1274,7 +1280,8 @@ "tags": [ "images" ], - "summary": "Get an image in a project.", + "summary": "Get an image", + "description": "Get the details of a specific image in a project.", "operationId": "project_images_get_image", "parameters": [ { @@ -1328,7 +1335,8 @@ "tags": [ "images" ], - "summary": "Delete an image from a project.", + "summary": "Delete an image", + "description": "Permanently delete an image from a project. This operation cannot be undone. Any instances in the project using the image will continue to run, however new instances can not be created with this image.", "operationId": "project_images_delete_image", "parameters": [ { @@ -7788,6 +7796,13 @@ "url": "http://oxide.computer/docs/#xxx" } }, + { + "name": "images", + "description": "Images are read-only Virtual Disks that may be used to boot Virtual Machines", + "externalDocs": { + "url": "http://oxide.computer/docs/#xxx" + } + }, { "name": "instances", "description": "Virtual machine instances are the basic unit of computation. These operations are used for provisioning, controlling, and destroying instances.", From 52c18f535f7dceb21db65f527299b608b087694b Mon Sep 17 00:00:00 2001 From: Sean Klein Date: Mon, 4 Apr 2022 11:21:23 -0400 Subject: [PATCH 5/5] Add image source: Either URL or snapshot UUIID --- common/src/sql/dbinit.sql | 6 ++++- nexus/src/db/model.rs | 4 ++- nexus/src/db/schema.rs | 1 + nexus/src/external_api/params.rs | 10 +++++++ nexus/src/external_api/views.rs | 1 + openapi/nexus.json | 45 +++++++++++++++++++++++++++++++- 6 files changed, 64 insertions(+), 3 deletions(-) diff --git a/common/src/sql/dbinit.sql b/common/src/sql/dbinit.sql index 2e40af47e2..b67212eb2d 100644 --- a/common/src/sql/dbinit.sql +++ b/common/src/sql/dbinit.sql @@ -424,8 +424,12 @@ CREATE TABLE omicron.public.image ( /* Indicates that the object has been deleted */ time_deleted TIMESTAMPTZ, + /* Optional project UUID: Images may or may not be global */ project_id UUID, - volume_id UUID NOT NULL, + /* Optional volume ID: Images may exist without backing volumes */ + volume_id UUID, + /* Optional URL: Images may be backed by either a URL or a volume */ + url STRING(8192), size_bytes INT NOT NULL ); diff --git a/nexus/src/db/model.rs b/nexus/src/db/model.rs index 562f9f9de6..4d65040ad0 100644 --- a/nexus/src/db/model.rs +++ b/nexus/src/db/model.rs @@ -1521,7 +1521,8 @@ pub struct Image { identity: ImageIdentity, project_id: Option, - volume_id: Uuid, + volume_id: Option, + url: Option, #[column_name = "size_bytes"] size: ByteCount, } @@ -1531,6 +1532,7 @@ impl From for views::Image { Self { identity: image.identity(), project_id: image.project_id, + url: image.url, size: image.size.into(), } } diff --git a/nexus/src/db/schema.rs b/nexus/src/db/schema.rs index 062a5c3052..a102de7c29 100644 --- a/nexus/src/db/schema.rs +++ b/nexus/src/db/schema.rs @@ -36,6 +36,7 @@ table! { time_deleted -> Nullable, project_id -> Nullable, volume_id -> Uuid, + url -> Text, size_bytes -> Int8, } } diff --git a/nexus/src/external_api/params.rs b/nexus/src/external_api/params.rs index c39691ca05..6b2bc08b7c 100644 --- a/nexus/src/external_api/params.rs +++ b/nexus/src/external_api/params.rs @@ -283,6 +283,13 @@ pub struct NetworkInterfaceIdentifier { // IMAGES +/// The source of the underlying image. +#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] +pub enum ImageSource { + Url(String), + Snapshot(Uuid), +} + /// Create-time parameters for an /// [`Image`](omicron_common::api::external::Image) #[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] @@ -290,6 +297,9 @@ pub struct ImageCreate { /// common identifying metadata #[serde(flatten)] pub identity: IdentityMetadataCreateParams, + + /// The source of the image's contents. + pub source: ImageSource, } // SNAPSHOTS diff --git a/nexus/src/external_api/views.rs b/nexus/src/external_api/views.rs index 99b6b8f233..fdceddacc1 100644 --- a/nexus/src/external_api/views.rs +++ b/nexus/src/external_api/views.rs @@ -82,6 +82,7 @@ pub struct Image { pub identity: IdentityMetadata, pub project_id: Option, + pub url: Option, pub size: ByteCount, } diff --git a/openapi/nexus.json b/openapi/nexus.json index e5de70596e..ff75aab512 100644 --- a/openapi/nexus.json +++ b/openapi/nexus.json @@ -5117,6 +5117,10 @@ "description": "timestamp when this resource was last modified", "type": "string", "format": "date-time" + }, + "url": { + "nullable": true, + "type": "string" } }, "required": [ @@ -5137,11 +5141,20 @@ }, "name": { "$ref": "#/components/schemas/Name" + }, + "source": { + "description": "The source of the image's contents.", + "allOf": [ + { + "$ref": "#/components/schemas/ImageSource" + } + ] } }, "required": [ "description", - "name" + "name", + "source" ] }, "ImageResultsPage": { @@ -5165,6 +5178,36 @@ "items" ] }, + "ImageSource": { + "description": "The source of the underlying image.", + "oneOf": [ + { + "type": "object", + "properties": { + "Url": { + "type": "string" + } + }, + "required": [ + "Url" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "Snapshot": { + "type": "string", + "format": "uuid" + } + }, + "required": [ + "Snapshot" + ], + "additionalProperties": false + } + ] + }, "Instance": { "description": "Client view of an [`Instance`]", "type": "object",