Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 19 additions & 6 deletions omicron-common/src/api/external/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -435,6 +435,7 @@ pub enum ResourceType {
Rack,
Sled,
SagaDbg,
Vpc,
}

impl Display for ResourceType {
Expand All @@ -450,6 +451,7 @@ impl Display for ResourceType {
ResourceType::Rack => "rack",
ResourceType::Sled => "sled",
ResourceType::SagaDbg => "saga_dbg",
ResourceType::Vpc => "vpc",
}
)
}
Expand Down Expand Up @@ -1063,15 +1065,26 @@ impl From<steno::SagaStateView> 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);
Expand Down Expand Up @@ -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,

Expand Down
12 changes: 6 additions & 6 deletions omicron-common/src/model_db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<Self, Self::Error> {
Expand All @@ -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<Self, Self::Error> {
Expand Down
21 changes: 11 additions & 10 deletions omicron-common/src/sql/dbinit.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Copy link
Collaborator

@davepacheco davepacheco Jul 30, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't quite right. It needs to be (project_id, name) rather than (name, project_id). This is critical to be able to use this index to list the VPCs in a Project. (Think of it this way: we want the database to be able to quickly find the first the VPC in the Project. If the index is sorted by project_id first, that's easy. Once it finds that record, the next N values in the index will be the N VPCs in that project, sorted by name -- perfect. If the index is sorted by (name, project_id), this doesn't work, and there's no efficient way to find the first VPC (or any other VPC) in a Project.)

Edit: I really wish there were some way to automatically test this. This is probably working in your change because CockroachDB is doing a table scan rather than using this index. I kind of want CockroachDB to refuse to do queries that always necessitate table scans. I'm not really sure how best to do this.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it. Didn't realize the index works like a nested dictionary.

) 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,
Expand All @@ -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,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to above, this needs to be (vpc_id, name), not (name, vpc_id).

vpc_id
) WHERE
time_deleted IS NULL;

Expand Down Expand Up @@ -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,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above.

vpc_id
) WHERE
time_deleted IS NULL;

Expand Down
7 changes: 7 additions & 0 deletions omicron-nexus/src/db/conversions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
72 changes: 72 additions & 0 deletions omicron-nexus/src/db/datastore.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -731,4 +732,75 @@ impl DataStore {
);
Ok(())
}

pub async fn project_list_vpcs(
&self,
project_id: &Uuid,
pagparams: &DataPageParams<'_, Name>,
) -> ListResult<api::external::Vpc> {
let client = self.pool.acquire().await?;
sql_fetch_page_by::<
LookupByUniqueNameInProject,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're looking up VPCs by name here, but the name isn't explicitly a unique identifier for VPCs, is it?

CREATE TABLE omicron.public.VPC (
/* Identity metadata */
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 NOT NULL
);

The primary key is the UUID - shouldn't we be looking up VPCs by ID, not name?

I get that the implementation of project_create_vpc should be preventing the names from overlapping, but it seems like it would be nice to rely on the structure of the DB for the uniqueness, rather than an implicit constraint.

Caveat: It's totally possible I'm misinterpreting how sql_fetch_page_by is working here...

/**
* Implementation of [`LookupKey`] for looking up objects within a project by
* the project_id and the object's name
*/
pub struct LookupByUniqueNameInProject;
impl<'a, R: ResourceTable> LookupKey<'a, R> for LookupByUniqueNameInProject {
type ScopeKey = (&'a Uuid,);
const SCOPE_KEY_COLUMN_NAMES: &'static [&'static str] = &["project_id"];
type ItemKey = Name;
const ITEM_KEY_COLUMN_NAME: &'static str = "name";
fn where_select_error(
_scope_key: Self::ScopeKey,
item_key: &Self::ItemKey,
) -> Error {
Error::not_found_by_name(R::RESOURCE_TYPE, item_key)
}
}

But wouldn't we want a LookupByUniqueIDInProject that uses ItemKey = ID?

(FYI @davepacheco - I see LookupByUniqueNameInProject is also used for project_list_instances; I think my same question applies there too)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up: Is this relying on the unique index in dbinit.sql?

CREATE UNIQUE INDEX ON omicron.public.VPC (
name
) WHERE
time_deleted IS NULL;

Copy link
Contributor Author

@david-crespo david-crespo Jul 29, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good news, I already wrote LookupByUniqueIDInProject and got rid of it in 67eccbc89, so we can easily put it back. I don't think the DB is constraining uniqueness. To be honest I also am not sure where the constraint is coming from (though the constraint is there, the test attempting to create a second VPC with the same name proves it).

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I saw the test, but I'm still trying to wrap my head around this - the unique_value parameter passed to sql_insert_unique_idempotent is only used in the error message!

pub async fn sql_insert_unique_idempotent<'a, R>(
client: &'a tokio_postgres::Client,
values: &'a SqlValueSet,
unique_value: &'a str,
ignore_conflicts_on: &'static str,
) -> Result<(), Error>
where
R: ResourceTable,
{
let mut sql = SqlString::new();
let param_names = values
.values()
.iter()
.map(|value| sql.next_param(*value))
.collect::<Vec<String>>();
let column_names = values.names().iter().cloned().collect::<Vec<&str>>();
sql.push_str(
format!(
"INSERT INTO {} ({}) VALUES ({}) ON CONFLICT ({}) DO NOTHING",
R::TABLE_NAME,
column_names.join(", "),
param_names.join(", "),
ignore_conflicts_on,
)
.as_str(),
);
sql_execute(client, sql.sql_fragment(), sql.sql_params())
.await
.map_err(|e| sql_error_on_create(R::RESOURCE_TYPE, unique_value, e))
.map(|_| ())
}

So I'm still kinda uncertain what provides the "names must be unique" property, if not the unique DB index.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Having some conversations in chat, but figured I'd post the TL;DR here too:

If the CREATE UNIQUE INDEX statement on omicron.public.VPC is what's actually enforcing this uniqueness, wouldn't that imply that names are globally unique? (example: two different subnets both want to create a VPC named "Foo" - one of 'em would get a collision).

Seems the approach taken by instances is creating a unique index by the tuple (project_id, name). Seems like we'll need to update dbinit.sql's indices for VPC objects to guarantee similar scoping.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed in the docs that the index is the official way to enforce uniqueness for a pair of columns:

Indexes can also be used to enforce uniqueness of a column's value, or the uniqueness of the combined values of more than one column.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think y'all figured most of this out already! I'll try to explain a little more why things are the way they are.


In terms of desired behavior, RFD 4 says this about the "name" property of something that has identity metadata:

Names of resources must be unique under the scope of the parent resource. For example, Instance names are required to be unique to a project and project names are required to be unique to an Organization.

RFD 21 explains that VPCs are scoped to projects. So yes, their name should be unique within the project, and not necessarily globally unique.


In terms of implementing this: yes, we use CockroachDB unique indexes to guarantee uniqueness. That's the natural place to do it, since the database has such a facility and the behavior needs to be strongly consistent.

As @smklein points out, the unique index for Instances is on the (project_id, name) tuple, not just name. You want something similar for VPCs here. (Since we're organizing everything around Projects, nearly everything should probably look like this. Projects themselves should be scoped to an Organization, and Organization names I believe will wind up being globally unique, but we haven't implemented Organizations yet so Project names are currently globally unique.)

There's a close relationship between the fields in the unique indexes on a table, the Lookup* structs supported by the table, and the ScopeKey/ItemKey in those lookups.


@smklein wrote:

Yeah, I saw the test, but I'm still trying to wrap my head around this - the unique_value parameter passed to sql_insert_unique_idempotent is only used in the error message!

Yeah, so when the block comment on sql_insert_unique_idempotent talks about "conflict", it's referring not to some vague notion of some constraint being violated (that the function itself might be expected to check) but a specific condition that databases like CockroachDB and PostgreSQL call "conflict" and handle with an ON CONFLICT clause on the INSERT statement. In retrospect maybe that's not obvious if you haven't dug into the implementation of the function or dealt with UPSERTs before. The comment currently says:

/// Idempotently insert a database record.  This is intended for cases where the
/// record may conflict with an existing record with the same id, which should
/// be ignored, or with an existing record with the same name, which should
/// produce an [`Error::ObjectAlreadyExists`] error.

It might be more precise to say:

///
/// Idempotently insert a database record.  More precisely, this is intended
/// for cases where:
///
/// * the caller wants to insert a database row for some particular object
/// * the object is identified by an immutable, unique id
/// * the caller wants the operation to succeed if a row already exists for this
///   object (i.e., a row exists with the same unique id, which is by definition
///   a row for the same object)
/// * the caller wants to fail if inserting the record would violate some other
///   constraint, usually a name conflict (which is necessarily a record
///   for a _different_ object, since if it were the same object, it would have
///   the same id, and the operation would succeed without inserting anything)
///
/// That might sound incredibly specific or even contrived, but it's the common
/// case for sagas that want to create database records for API resources.  For
/// example, when provisioning an Instance, the provision saga will generate a
/// unique id and use this function to insert an initial Instance object into
/// the database.  Since the id is generated in the saga, the only way to have a
/// conflict on the object's id is if the saga action is being rerun.  Since
/// actions are supposed to be idempotent, the correct behavior is to succeed
/// despite the conflict.  On the other hand, if the new Instance's name
/// conflicts with another one in the same project, that will raise a different
/// constraint violation that we do want to package up and bubble up to the
/// caller.  This condition means somebody tried to create an Instance with the
/// same name as one that's already running, so we package this up as an
/// [`Error::ObjectAlreadyExists`] error.
///

This is a long way of explaining why the unique value is not used in the function aside from the error message. (Why bother putting it in the error message? The ObjectAlreadyExists error requires you to say what the conflicting value was, and that's because I've found specific messages like that to be a huge improvement in user experience.)


@smklein wrote:

But wouldn't we want a LookupByUniqueIDInProject that uses ItemKey = ID?

(FYI @davepacheco - I see LookupByUniqueNameInProject is also used for project_list_instances; I think my same question applies there too)

I think you got this answered but I'm not sure? I wasn't quite sure why you expected to use a lookup by id. Is it just because that's the primary key in the database? Anyway, I expect API operations to do lookups by name rather than id. (RFD 4 is a little confusing in that it uses identifiers like "projectId" in all the URLs. But it also says "the endpoints that reference an individual resource work on either the uuid or the name." If I remember right, we only implement name lookups in Nexus for most resources today.) Given this, for any resource that's scoped to a project, I'd expect us to use LookupByUniqueNameInProject and not LookupByUniqueIdInProject.

The general ideas here are:

  • The uuid of an object is immutable and should uniquely identify it across all time, all types of objects, and all Oxide systems. (Like many systems, we assume this by the astronomical probability of UUIDv4 collision.)
  • The name of an object is mutable and uniquely identifies the object within its parent scope and while it is still live. We will (do already?) use soft deletes for many objects, and there may be multiple objects with the same name in the same scope as long as no more than one is alive. This is why the unique indexes include a WHERE time_deleted IS NULL clause.
  • From the user's perspective, since they can only see live objects, both identifiers are equally good. I expect most people to want to use "name" most of the time. Software that wants to be robust against other clients renaming things that they're working on might prefer to use ids instead.
  • Internally, we usually want to resolve the name to an id when we receive a request and do all of our internal work using these ids. Otherwise, we can wind up with nasty concurrency issues if somebody renames a resource while we're working on it. This can even lead to security vulnerabilities, if we were to do an access check of some resource and then somebody renames it and we wind up operating on a different resource than the one we checked.

I'd been assuming all this mostly based on past experience, but assuming it makes sense, we should probably document it.

Does all that make sense?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That all makes sense. Most of it we were able to infer from the code, but it is helpful to see it spelled out. Especially:

Internally, we usually want to resolve the name to an id when we receive a request and do all of our internal work using these ids.

I was mostly able to get there by copying the patterns I saw used for instances.

Vpc,
<Vpc as Table>::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<api::external::Vpc, Error> {
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::<Vpc, LookupByUniqueId>(
&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<api::external::Vpc> {
let client = self.pool.acquire().await?;
sql_fetch_row_by::<LookupByUniqueNameInProject, Vpc>(
&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
}
}
30 changes: 17 additions & 13 deletions omicron-nexus/src/db/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -242,8 +246,8 @@ mod test {
check_table_schema::<Oximeter>(&client).await;
check_table_schema::<MetricProducer>(&client).await;
check_table_schema::<OximeterAssignment>(&client).await;
check_table_schema::<VPC>(&client).await;
check_table_schema::<VPCSubnet>(&client).await;
check_table_schema::<Vpc>(&client).await;
check_table_schema::<VpcSubnet>(&client).await;
check_table_schema::<NetworkInterface>(&client).await;

database.cleanup().await.expect("failed to clean up database");
Expand Down
Loading