Skip to content

Commit 6cf311b

Browse files
authored
[nexus] Safer project deletion (#2024)
## Previously Project deletion was very problematic - even though projects contain many resources, they could be deleted while their children objects still exist. This would effectively "orphan" those resources, making them inaccessible. For example: Create an instance in a project, delete the project. Now the instance exists, but cannot be deleted. ## This PR Adds `rcgen` to projects, and ensures that they can no longer be deleted while containing child resources. - Implements `DatastoreCollectionConfig<ChildResource>` for `Project`, for many child resources - Uses `Project::insert_resource` to bump `rcgen` while creating child resources - Checks for child resources existing during project deletion - Adds tests Fixes #1482
1 parent 3294f8f commit 6cf311b

File tree

23 files changed

+882
-133
lines changed

23 files changed

+882
-133
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

common/src/sql/dbinit.sql

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -537,6 +537,9 @@ CREATE TABLE omicron.public.project (
537537
/* Indicates that the object has been deleted */
538538
time_deleted TIMESTAMPTZ,
539539

540+
/* child resource generation number, per RFD 192 */
541+
rcgen INT NOT NULL,
542+
540543
/* Which organization this project belongs to */
541544
organization_id UUID NOT NULL /* foreign key into "Organization" table */
542545
);

end-to-end-tests/src/helpers/ctx.rs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ use crate::helpers::generate_name;
22
use anyhow::{Context as _, Result};
33
use omicron_sled_agent::rack_setup::config::SetupServiceConfig;
44
use oxide_client::types::{Name, OrganizationCreate, ProjectCreate};
5-
use oxide_client::{Client, ClientOrganizationsExt, ClientProjectsExt};
5+
use oxide_client::{
6+
Client, ClientOrganizationsExt, ClientProjectsExt, ClientVpcsExt,
7+
};
68
use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION};
79
use reqwest::Url;
810
use std::net::SocketAddr;
@@ -49,6 +51,21 @@ impl Context {
4951
}
5052

5153
pub async fn cleanup(self) -> Result<()> {
54+
self.client
55+
.vpc_subnet_delete()
56+
.organization_name(self.org_name.clone())
57+
.project_name(self.project_name.clone())
58+
.vpc_name("default")
59+
.subnet_name("default")
60+
.send()
61+
.await?;
62+
self.client
63+
.vpc_delete()
64+
.organization_name(self.org_name.clone())
65+
.project_name(self.project_name.clone())
66+
.vpc_name("default")
67+
.send()
68+
.await?;
5269
self.client
5370
.project_delete()
5471
.organization_name(self.org_name.clone())

end-to-end-tests/src/instance_launch.rs

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,13 +61,14 @@ async fn instance_launch() -> Result<()> {
6161
.id;
6262

6363
eprintln!("create disk");
64+
let disk_name = generate_name("disk")?;
6465
let disk_name = ctx
6566
.client
6667
.disk_create()
6768
.organization_name(ctx.org_name.clone())
6869
.project_name(ctx.project_name.clone())
6970
.body(DiskCreate {
70-
name: generate_name("disk")?,
71+
name: disk_name.clone(),
7172
description: String::new(),
7273
disk_source: DiskSource::GlobalImage { image_id },
7374
size: ByteCount(2048 * 1024 * 1024),
@@ -89,7 +90,9 @@ async fn instance_launch() -> Result<()> {
8990
hostname: "localshark".into(), // 🦈
9091
memory: ByteCount(1024 * 1024 * 1024),
9192
ncpus: InstanceCpuCount(2),
92-
disks: vec![InstanceDiskAttachment::Attach { name: disk_name }],
93+
disks: vec![InstanceDiskAttachment::Attach {
94+
name: disk_name.clone(),
95+
}],
9396
network_interfaces: InstanceNetworkInterfaceAttachment::Default,
9497
external_ips: vec![ExternalIpCreate::Ephemeral { pool_name: None }],
9598
user_data: String::new(),
@@ -245,6 +248,23 @@ async fn instance_launch() -> Result<()> {
245248
)
246249
.await?;
247250

251+
eprintln!("deleting disk");
252+
wait_for_condition(
253+
|| async {
254+
ctx.client
255+
.disk_delete()
256+
.organization_name(ctx.org_name.clone())
257+
.project_name(ctx.project_name.clone())
258+
.disk_name(disk_name.clone())
259+
.send()
260+
.await
261+
.map_err(|_| CondCheckError::<oxide_client::Error>::NotYet)
262+
},
263+
&Duration::from_secs(1),
264+
&Duration::from_secs(60),
265+
)
266+
.await?;
267+
248268
ctx.cleanup().await
249269
}
250270

nexus/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ oso = "0.26"
4545
oximeter-client = { path = "../oximeter-client" }
4646
oximeter-db = { path = "../oximeter/db/" }
4747
parse-display = "0.7.0"
48+
paste = "1.0"
4849
# See omicron-rpaths for more about the "pq-sys" dependency.
4950
pq-sys = "*"
5051
propolis-client = { git = "https://github.com/oxidecomputer/propolis", rev = "b596e72b6b93bc412d675358f782ae7d53f8bf7a", features = [ "generated" ] }

nexus/db-model/src/project.rs

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,13 @@
22
// License, v. 2.0. If a copy of the MPL was not distributed with this
33
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
44

5-
use super::Name;
6-
use crate::schema::project;
5+
use super::{
6+
Disk, ExternalIp, Generation, Image, Instance, IpPool, Name, Snapshot, Vpc,
7+
};
8+
use crate::collection::DatastoreCollectionConfig;
9+
use crate::schema::{
10+
disk, external_ip, image, instance, ip_pool, project, snapshot, vpc,
11+
};
712
use chrono::{DateTime, Utc};
813
use db_macros::Resource;
914
use nexus_types::external_api::params;
@@ -18,6 +23,8 @@ pub struct Project {
1823
#[diesel(embed)]
1924
identity: ProjectIdentity,
2025

26+
/// child resource generation number, per RFD 192
27+
pub rcgen: Generation,
2128
pub organization_id: Uuid,
2229
}
2330

@@ -26,6 +33,7 @@ impl Project {
2633
pub fn new(organization_id: Uuid, params: params::ProjectCreate) -> Self {
2734
Self {
2835
identity: ProjectIdentity::new(Uuid::new_v4(), params.identity),
36+
rcgen: Generation::new(),
2937
organization_id,
3038
}
3139
}
@@ -40,6 +48,61 @@ impl From<Project> for views::Project {
4048
}
4149
}
4250

51+
impl DatastoreCollectionConfig<Instance> for Project {
52+
type CollectionId = Uuid;
53+
type GenerationNumberColumn = project::dsl::rcgen;
54+
type CollectionTimeDeletedColumn = project::dsl::time_deleted;
55+
type CollectionIdColumn = instance::dsl::project_id;
56+
}
57+
58+
impl DatastoreCollectionConfig<Disk> for Project {
59+
type CollectionId = Uuid;
60+
type GenerationNumberColumn = project::dsl::rcgen;
61+
type CollectionTimeDeletedColumn = project::dsl::time_deleted;
62+
type CollectionIdColumn = disk::dsl::project_id;
63+
}
64+
65+
impl DatastoreCollectionConfig<Image> for Project {
66+
type CollectionId = Uuid;
67+
type GenerationNumberColumn = project::dsl::rcgen;
68+
type CollectionTimeDeletedColumn = project::dsl::time_deleted;
69+
type CollectionIdColumn = image::dsl::project_id;
70+
}
71+
72+
impl DatastoreCollectionConfig<Snapshot> for Project {
73+
type CollectionId = Uuid;
74+
type GenerationNumberColumn = project::dsl::rcgen;
75+
type CollectionTimeDeletedColumn = project::dsl::time_deleted;
76+
type CollectionIdColumn = snapshot::dsl::project_id;
77+
}
78+
79+
impl DatastoreCollectionConfig<Vpc> for Project {
80+
type CollectionId = Uuid;
81+
type GenerationNumberColumn = project::dsl::rcgen;
82+
type CollectionTimeDeletedColumn = project::dsl::time_deleted;
83+
type CollectionIdColumn = vpc::dsl::project_id;
84+
}
85+
86+
// NOTE: "IpPoolRange" also contains a reference to "project_id", but
87+
// ranges should only exist within IP Pools.
88+
impl DatastoreCollectionConfig<IpPool> for Project {
89+
type CollectionId = Uuid;
90+
type GenerationNumberColumn = project::dsl::rcgen;
91+
type CollectionTimeDeletedColumn = project::dsl::time_deleted;
92+
type CollectionIdColumn = ip_pool::dsl::project_id;
93+
}
94+
95+
// TODO(https://github.com/oxidecomputer/omicron/issues/1482): Not yet utilized,
96+
// but needed for project deletion safety.
97+
// TODO(https://github.com/oxidecomputer/omicron/issues/1334): Cannot be
98+
// utilized until floating IPs are implemented.
99+
impl DatastoreCollectionConfig<ExternalIp> for Project {
100+
type CollectionId = Uuid;
101+
type GenerationNumberColumn = project::dsl::rcgen;
102+
type CollectionTimeDeletedColumn = project::dsl::time_deleted;
103+
type CollectionIdColumn = external_ip::dsl::project_id;
104+
}
105+
43106
/// Describes a set of updates for the [`Project`] model.
44107
#[derive(AsChangeset)]
45108
#[diesel(table_name = project)]

nexus/db-model/src/schema.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,7 @@ table! {
330330
time_created -> Timestamptz,
331331
time_modified -> Timestamptz,
332332
time_deleted -> Nullable<Timestamptz>,
333+
rcgen -> Int8,
333334
organization_id -> Uuid,
334335
}
335336
}

nexus/src/app/image.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ impl super::Nexus {
4040
.project_name(project_name)
4141
.lookup_for(authz::Action::CreateChild)
4242
.await?;
43+
44+
// TODO(https://github.com/oxidecomputer/omicron/issues/1482): When
45+
// we implement this, remember to insert the image within a Project
46+
// using "insert_resource".
4347
Err(self.unimplemented_todo(opctx, Unimpl::Public).await)
4448
}
4549

nexus/src/app/project.rs

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -197,12 +197,15 @@ impl super::Nexus {
197197
organization_name: &Name,
198198
project_name: &Name,
199199
) -> DeleteResult {
200-
let (.., authz_project) = LookupPath::new(opctx, &self.db_datastore)
201-
.organization_name(organization_name)
202-
.project_name(project_name)
203-
.lookup_for(authz::Action::Delete)
204-
.await?;
205-
self.db_datastore.project_delete(opctx, &authz_project).await
200+
let (.., authz_project, db_project) =
201+
LookupPath::new(opctx, &self.db_datastore)
202+
.organization_name(organization_name)
203+
.project_name(project_name)
204+
.fetch_for(authz::Action::Delete)
205+
.await?;
206+
self.db_datastore
207+
.project_delete(opctx, &authz_project, &db_project)
208+
.await
206209
}
207210

208211
// Role assignments

nexus/src/app/sagas/disk_create.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,9 +200,15 @@ async fn sdc_create_disk_record(
200200
ActionError::action_failed(Error::invalid_request(&e.to_string()))
201201
})?;
202202

203+
let (.., authz_project) = LookupPath::new(&opctx, &osagactx.datastore())
204+
.project_id(params.project_id)
205+
.lookup_for(authz::Action::CreateChild)
206+
.await
207+
.map_err(ActionError::action_failed)?;
208+
203209
let disk_created = osagactx
204210
.datastore()
205-
.project_create_disk(disk)
211+
.project_create_disk(&opctx, &authz_project, disk)
206212
.await
207213
.map_err(ActionError::action_failed)?;
208214

0 commit comments

Comments
 (0)