diff --git a/common/src/sql/dbinit.sql b/common/src/sql/dbinit.sql index d332f60b03..bb4028ec2f 100644 --- a/common/src/sql/dbinit.sql +++ b/common/src/sql/dbinit.sql @@ -371,6 +371,35 @@ CREATE INDEX ON omicron.public.disk ( time_deleted IS NULL AND attach_instance_id IS NOT NULL; +CREATE TABLE omicron.public.snapshot ( + /* 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, + + /* Every Snapshot is in exactly one Project at a time. */ + project_id UUID NOT NULL, + + /* Every Snapshot originated from a single disk */ + disk_id UUID NOT NULL, + + /* Every Snapshot consists of a root volume */ + volume_id UUID NOT NULL, + + /* Disk configuration (from the time the snapshot was taken) */ + size_bytes INT NOT NULL +); + +CREATE UNIQUE INDEX ON omicron.public.snapshot ( + 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 e9615092fd..afea062982 100644 --- a/nexus/src/db/model.rs +++ b/nexus/src/db/model.rs @@ -9,12 +9,13 @@ use crate::db::identity::{Asset, Resource}; use crate::db::schema::{ console_session, dataset, disk, instance, metric_producer, network_interface, organization, oximeter, project, rack, region, - role_assignment_builtin, role_builtin, router_route, sled, + role_assignment_builtin, role_builtin, router_route, sled, snapshot, update_available_artifact, user_builtin, volume, vpc, vpc_firewall_rule, vpc_router, vpc_subnet, zpool, }; 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}; @@ -1260,6 +1261,40 @@ impl Into for DiskState { } } +#[derive( + Queryable, + Insertable, + Selectable, + Clone, + Debug, + Resource, + Serialize, + Deserialize, +)] +#[table_name = "snapshot"] +pub struct Snapshot { + #[diesel(embed)] + identity: SnapshotIdentity, + + project_id: Uuid, + disk_id: Uuid, + volume_id: Uuid, + + #[column_name = "size_bytes"] + pub size: ByteCount, +} + +impl From for views::Snapshot { + fn from(snapshot: Snapshot) -> Self { + Self { + identity: snapshot.identity(), + project_id: snapshot.project_id, + disk_id: snapshot.disk_id, + size: snapshot.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..1cab17dd90 100644 --- a/nexus/src/db/schema.rs +++ b/nexus/src/db/schema.rs @@ -26,6 +26,22 @@ table! { } } +table! { + snapshot (id) { + id -> Uuid, + name -> Text, + description -> Text, + time_created -> Timestamptz, + time_modified -> Timestamptz, + time_deleted -> Nullable, + + project_id -> Uuid, + disk_id -> Uuid, + 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 834df6eceb..70448a4990 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::{ + Organization, Project, Rack, Role, Sled, Snapshot, User, Vpc, VpcSubnet, + }, }; use crate::context::OpContext; use dropshot::endpoint; @@ -103,6 +105,11 @@ pub fn external_api() -> NexusApiDescription { api.register(instance_disks_attach)?; api.register(instance_disks_detach)?; + api.register(project_snapshots_get)?; + api.register(project_snapshots_post)?; + api.register(project_snapshots_get_snapshot)?; + api.register(project_snapshots_delete_snapshot)?; + api.register(project_vpcs_get)?; api.register(project_vpcs_post)?; api.register(project_vpcs_get_vpc)?; @@ -1261,6 +1268,148 @@ async fn instance_network_interfaces_get_interface( apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await } +/* + * Snapshots + */ + +/// List snapshots in a project. +#[endpoint { + method = GET, + path = "/organizations/{organization_name}/projects/{project_name}/snapshots", + tags = ["snapshots"], +}] +async fn project_snapshots_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 snapshots = nexus + .project_list_snapshots( + &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, snapshots)?)) + }; + apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await +} + +/// Create a snapshot of a disk. +#[endpoint { + method = POST, + path = "/organizations/{organization_name}/projects/{project_name}/snapshots", + tags = ["snapshots"], +}] +async fn project_snapshots_post( + rqctx: Arc>>, + path_params: Path, + new_snapshot: TypedBody, +) -> Result, HttpError> { + let apictx = rqctx.context(); + let nexus = &apictx.nexus; + let path = path_params.into_inner(); + let organization_name = &path.organization_name; + let project_name = &path.project_name; + let new_snapshot_params = &new_snapshot.into_inner(); + let handler = async { + let opctx = OpContext::for_external_api(&rqctx).await?; + let snapshot = nexus + .project_create_snapshot( + &opctx, + &organization_name, + &project_name, + &new_snapshot_params, + ) + .await?; + Ok(HttpResponseCreated(snapshot.into())) + }; + apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await +} + +/// Path parameters for Snapshot requests +#[derive(Deserialize, JsonSchema)] +struct SnapshotPathParam { + organization_name: Name, + project_name: Name, + snapshot_name: Name, +} + +/// Get a snapshot in a project. +#[endpoint { + method = GET, + path = "/organizations/{organization_name}/projects/{project_name}/snapshots/{snapshot_name}", + tags = ["snapshots"], +}] +async fn project_snapshots_get_snapshot( + 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 snapshot_name = &path.snapshot_name; + let handler = async { + let opctx = OpContext::for_external_api(&rqctx).await?; + let snapshot = nexus + .snapshot_fetch( + &opctx, + &organization_name, + &project_name, + &snapshot_name, + ) + .await?; + Ok(HttpResponseOk(snapshot.into())) + }; + apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await +} + +/// Delete a snapshot from a project. +#[endpoint { + method = DELETE, + path = "/organizations/{organization_name}/projects/{project_name}/snapshots/{snapshot_name}", + tags = ["snapshots"], +}] +async fn project_snapshots_delete_snapshot( + 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 snapshot_name = &path.snapshot_name; + let handler = async { + let opctx = OpContext::for_external_api(&rqctx).await?; + nexus + .project_delete_snapshot( + &opctx, + &organization_name, + &project_name, + &snapshot_name, + ) + .await?; + 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 6e9b48a727..3029623a80 100644 --- a/nexus/src/external_api/params.rs +++ b/nexus/src/external_api/params.rs @@ -291,6 +291,21 @@ pub struct NetworkInterfaceIdentifier { pub interface_name: Name, } +/* + * SNAPSHOTS + */ + +/// Create-time parameters for a [`Snapshot`](omicron_common::api::external::Snapshot) +#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] +pub struct SnapshotCreate { + /// common identifying metadata + #[serde(flatten)] + pub identity: IdentityMetadataCreateParams, + + /// The name of the disk to be snapshotted + pub disk: Name, +} + /* * BUILT-IN USERS * diff --git a/nexus/src/external_api/tag-config.json b/nexus/src/external_api/tag-config.json index a84bd660e4..0a9cea9c55 100644 --- a/nexus/src/external_api/tag-config.json +++ b/nexus/src/external_api/tag-config.json @@ -80,6 +80,12 @@ "url": "http://oxide.computer/docs/#xxx" } }, + "snapshots": { + "description": "Snapshots of Virtual Disks at a particular point in time.", + "external_docs": { + "url": "http://oxide.computer/docs/#xxx" + } + }, "subnets": { "description": "This tag should be moved into a generic network tag", "external_docs": { @@ -105,4 +111,4 @@ } } } -} \ No newline at end of file +} diff --git a/nexus/src/external_api/views.rs b/nexus/src/external_api/views.rs index 3958a82508..7d4b6b1b92 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,21 @@ impl Into for model::Project { } } +/* + * SNAPSHOTS + */ + +/// Client view of a Snapshot +#[derive(ObjectIdentity, Clone, Debug, Deserialize, Serialize, JsonSchema)] +pub struct Snapshot { + #[serde(flatten)] + pub identity: IdentityMetadata, + + pub project_id: Uuid, + pub disk_id: Uuid, + pub size: ByteCount, +} + /* * VPCs */ diff --git a/nexus/src/nexus.rs b/nexus/src/nexus.rs index 232b64bccb..276b8b8434 100644 --- a/nexus/src/nexus.rs +++ b/nexus/src/nexus.rs @@ -794,6 +794,46 @@ impl Nexus { Ok(()) } + pub async fn project_create_snapshot( + self: &Arc, + _opctx: &OpContext, + _organization_name: &Name, + _project_name: &Name, + _params: ¶ms::SnapshotCreate, + ) -> CreateResult { + unimplemented!(); + } + + pub async fn project_list_snapshots( + &self, + _opctx: &OpContext, + _organization_name: &Name, + _project_name: &Name, + _pagparams: &DataPageParams<'_, Name>, + ) -> ListResultVec { + unimplemented!(); + } + + pub async fn snapshot_fetch( + &self, + _opctx: &OpContext, + _organization_name: &Name, + _project_name: &Name, + _snapshot_name: &Name, + ) -> LookupResult { + unimplemented!(); + } + + pub async fn project_delete_snapshot( + self: &Arc, + _opctx: &OpContext, + _organization_name: &Name, + _project_name: &Name, + _snapshot_name: &Name, + ) -> DeleteResult { + unimplemented!(); + } + /* * Instances */ diff --git a/nexus/tests/output/nexus_tags.txt b/nexus/tests/output/nexus_tags.txt index 2eae202df1..2ddef25a4c 100644 --- a/nexus/tests/output/nexus_tags.txt +++ b/nexus/tests/output/nexus_tags.txt @@ -90,6 +90,13 @@ OPERATION ID URL PATH hardware_sleds_get /hardware/sleds hardware_sleds_get_sled /hardware/sleds/{sled_id} +API operations found with tag "snapshots" +OPERATION ID URL PATH +project_snapshots_delete_snapshot /organizations/{organization_name}/projects/{project_name}/snapshots/{snapshot_name} +project_snapshots_get /organizations/{organization_name}/projects/{project_name}/snapshots +project_snapshots_get_snapshot /organizations/{organization_name}/projects/{project_name}/snapshots/{snapshot_name} +project_snapshots_post /organizations/{organization_name}/projects/{project_name}/snapshots + API operations found with tag "subnets" OPERATION ID URL PATH subnet_network_interfaces_get /organizations/{organization_name}/projects/{project_name}/vpcs/{vpc_name}/subnets/{subnet_name}/network-interfaces diff --git a/openapi/nexus.json b/openapi/nexus.json index 638c98fc7a..35f9c5f5f5 100644 --- a/openapi/nexus.json +++ b/openapi/nexus.json @@ -1923,6 +1923,246 @@ } } }, + "/organizations/{organization_name}/projects/{project_name}/snapshots": { + "get": { + "tags": [ + "snapshots" + ], + "summary": "List snapshots in a project.", + "operationId": "project_snapshots_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/SnapshotResultsPage" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + }, + "x-dropshot-pagination": true + }, + "post": { + "tags": [ + "snapshots" + ], + "summary": "Create a snapshot of a disk.", + "operationId": "project_snapshots_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/SnapshotCreate" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "successful creation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Snapshot" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/organizations/{organization_name}/projects/{project_name}/snapshots/{snapshot_name}": { + "get": { + "tags": [ + "snapshots" + ], + "summary": "Get a snapshot in a project.", + "operationId": "project_snapshots_get_snapshot", + "parameters": [ + { + "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" + }, + { + "in": "path", + "name": "snapshot_name", + "required": true, + "schema": { + "$ref": "#/components/schemas/Name" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Snapshot" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "delete": { + "tags": [ + "snapshots" + ], + "summary": "Delete a snapshot from a project.", + "operationId": "project_snapshots_delete_snapshot", + "parameters": [ + { + "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" + }, + { + "in": "path", + "name": "snapshot_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}/vpcs": { "get": { "tags": [ @@ -5577,6 +5817,106 @@ "items" ] }, + "Snapshot": { + "description": "Client view of a Snapshot", + "type": "object", + "properties": { + "description": { + "description": "human-readable free-form text about a resource", + "type": "string" + }, + "disk_id": { + "type": "string", + "format": "uuid" + }, + "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": { + "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", + "disk_id", + "id", + "name", + "project_id", + "size", + "time_created", + "time_modified" + ] + }, + "SnapshotCreate": { + "description": "Create-time parameters for a [`Snapshot`](omicron_common::api::external::Snapshot)", + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "disk": { + "description": "The name of the disk to be snapshotted", + "allOf": [ + { + "$ref": "#/components/schemas/Name" + } + ] + }, + "name": { + "$ref": "#/components/schemas/Name" + } + }, + "required": [ + "description", + "disk", + "name" + ] + }, + "SnapshotResultsPage": { + "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/Snapshot" + } + }, + "next_page": { + "nullable": true, + "description": "token used to fetch the next page of results (if any)", + "type": "string" + } + }, + "required": [ + "items" + ] + }, "TimeseriesName": { "title": "The name of a timeseries", "description": "Names are constructed by concatenating the target and metric names with ':'. Target and metric names must be lowercase alphanumeric characters with '_' separating words.", @@ -6683,6 +7023,13 @@ "url": "http://oxide.computer/docs/#xxx" } }, + { + "name": "snapshots", + "description": "Snapshots of Virtual Disks at a particular point in time.", + "externalDocs": { + "url": "http://oxide.computer/docs/#xxx" + } + }, { "name": "subnets", "description": "This tag should be moved into a generic network tag",