Skip to content

Commit 032c556

Browse files
authored
Ensure quotas are non-negative (#8990)
- Adds constraints that all the `INT8` quota components cannot be negative - Adds a schema migration to ensure this is the case - Adds tests validating that data migration, creation, and update cannot create negative silo values Fixes #8973
1 parent 94f0731 commit 032c556

File tree

10 files changed

+239
-14
lines changed

10 files changed

+239
-14
lines changed

nexus/db-model/src/quota.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,9 +80,9 @@ impl From<SiloQuotas> for views::SiloQuotas {
8080
pub struct SiloQuotasUpdate {
8181
pub cpus: Option<i64>,
8282
#[diesel(column_name = memory_bytes)]
83-
pub memory: Option<i64>,
83+
pub memory: Option<ByteCount>,
8484
#[diesel(column_name = storage_bytes)]
85-
pub storage: Option<i64>,
85+
pub storage: Option<ByteCount>,
8686
pub time_modified: DateTime<Utc>,
8787
}
8888

nexus/db-model/src/schema_versions.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ use std::{collections::BTreeMap, sync::LazyLock};
1616
///
1717
/// This must be updated when you change the database schema. Refer to
1818
/// schema/crdb/README.adoc in the root of this repository for details.
19-
pub const SCHEMA_VERSION: Version = Version::new(187, 0, 0);
19+
pub const SCHEMA_VERSION: Version = Version::new(188, 0, 0);
2020

2121
/// List of all past database schema versions, in *reverse* order
2222
///
@@ -28,6 +28,7 @@ static KNOWN_VERSIONS: LazyLock<Vec<KnownVersion>> = LazyLock::new(|| {
2828
// | leaving the first copy as an example for the next person.
2929
// v
3030
// KnownVersion::new(next_int, "unique-dirname-with-the-sql-files"),
31+
KnownVersion::new(188, "positive-quotas"),
3132
KnownVersion::new(187, "no-default-pool-for-internal-silo"),
3233
KnownVersion::new(186, "nexus-generation"),
3334
KnownVersion::new(185, "populate-db-metadata-nexus"),

nexus/db-queries/src/db/datastore/quota.rs

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ use crate::context::OpContext;
44
use crate::db::pagination::paginated;
55
use async_bb8_diesel::AsyncRunQueryDsl;
66
use diesel::prelude::*;
7+
use diesel::result::DatabaseErrorKind;
8+
use diesel::result::Error as DieselError;
79
use nexus_db_errors::ErrorHandler;
810
use nexus_db_errors::public_error_from_diesel;
911
use nexus_db_lookup::DbConnection;
@@ -17,6 +19,15 @@ use omicron_common::api::external::ResourceType;
1719
use omicron_common::api::external::UpdateResult;
1820
use uuid::Uuid;
1921

22+
fn constraint_to_error(constraint: &str) -> &str {
23+
match constraint {
24+
"cpus_not_negative" => "CPU quota must not be negative",
25+
"memory_not_negative" => "Memory quota must not be negative",
26+
"storage_not_negative" => "Storage quota must not be negative",
27+
_ => "Unknown constraint",
28+
}
29+
}
30+
2031
impl DataStore {
2132
/// Creates new quotas for a silo. This is grouped with silo creation
2233
/// and shouldn't be called outside of that flow.
@@ -38,14 +49,26 @@ impl DataStore {
3849
.values(quotas)
3950
.execute_async(conn)
4051
.await
41-
.map_err(|e| {
42-
public_error_from_diesel(
52+
.map_err(|e| match e {
53+
DieselError::DatabaseError(
54+
DatabaseErrorKind::CheckViolation,
55+
ref info,
56+
) => {
57+
let msg = match info.constraint_name() {
58+
Some(constraint) => constraint_to_error(constraint),
59+
None => "Missing constraint name for Check Violation",
60+
};
61+
Error::invalid_request(&format!(
62+
"Cannot create silo quota: {msg}"
63+
))
64+
}
65+
_ => public_error_from_diesel(
4366
e,
4467
ErrorHandler::Conflict(
4568
ResourceType::SiloQuotas,
4669
&silo_id.to_string(),
4770
),
48-
)
71+
),
4972
})
5073
.map(|_| ())
5174
}
@@ -85,14 +108,26 @@ impl DataStore {
85108
.returning(SiloQuotas::as_returning())
86109
.get_result_async(&*self.pool_connection_authorized(opctx).await?)
87110
.await
88-
.map_err(|e| {
89-
public_error_from_diesel(
111+
.map_err(|e| match e {
112+
DieselError::DatabaseError(
113+
DatabaseErrorKind::CheckViolation,
114+
ref info,
115+
) => {
116+
let msg = match info.constraint_name() {
117+
Some(constraint) => constraint_to_error(constraint),
118+
None => "Missing constraint name for Check Violation",
119+
};
120+
Error::invalid_request(&format!(
121+
"Cannot update silo quota: {msg}"
122+
))
123+
}
124+
_ => public_error_from_diesel(
90125
e,
91126
ErrorHandler::Conflict(
92127
ResourceType::SiloQuotas,
93128
&silo_id.to_string(),
94129
),
95-
)
130+
),
96131
})
97132
}
98133

nexus/db-queries/src/db/datastore/virtual_provisioning_collection.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -407,8 +407,8 @@ mod test {
407407

408408
let quotas_update = SiloQuotasUpdate {
409409
cpus: Some(24),
410-
memory: Some(1 << 40),
411-
storage: Some(1 << 50),
410+
memory: Some((1 << 40).try_into().unwrap()),
411+
storage: Some((1 << 50).try_into().unwrap()),
412412
time_modified: chrono::Utc::now(),
413413
};
414414
let authz_silo = LookupPath::new(&opctx, datastore)

nexus/test-utils/src/resource_helpers.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ where
166166
.authn_as(AuthnMode::PrivilegedUser)
167167
.execute()
168168
.await
169-
.unwrap()
169+
.unwrap_or_else(|err| panic!("Error creating object with {path}: {err}"))
170170
.parsed_body::<HttpErrorResponseBody>()
171171
.unwrap()
172172
}

nexus/tests/integration_tests/quotas.rs

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ use nexus_test_utils::resource_helpers::create_local_user;
1212
use nexus_test_utils::resource_helpers::grant_iam;
1313
use nexus_test_utils::resource_helpers::link_ip_pool;
1414
use nexus_test_utils::resource_helpers::object_create;
15+
use nexus_test_utils::resource_helpers::object_create_error;
1516
use nexus_test_utils::resource_helpers::test_params;
1617
use nexus_test_utils_macros::nexus_test;
1718
use nexus_types::external_api::params;
@@ -50,6 +51,27 @@ impl ResourceAllocator {
5051
.await
5152
}
5253

54+
async fn set_quotas_expect_error(
55+
&self,
56+
client: &ClientTestContext,
57+
quotas: params::SiloQuotasUpdate,
58+
code: http::StatusCode,
59+
) -> HttpErrorResponseBody {
60+
NexusRequest::expect_failure_with_body(
61+
client,
62+
code,
63+
http::Method::PUT,
64+
"/v1/system/silos/quota-test-silo/quotas",
65+
&Some(&quotas),
66+
)
67+
.authn_as(self.auth.clone())
68+
.execute()
69+
.await
70+
.expect("Expected failure updating quotas")
71+
.parsed_body::<HttpErrorResponseBody>()
72+
.expect("Failed to read response after setting quotas")
73+
}
74+
5375
async fn get_quotas(&self, client: &ClientTestContext) -> SiloQuotas {
5476
NexusRequest::object_get(
5577
client,
@@ -386,3 +408,68 @@ async fn test_quota_limits(cptestctx: &ControlPlaneTestContext) {
386408
assert_eq!(quotas.limits.storage, quota_limit.storage.unwrap());
387409
}
388410
}
411+
412+
#[nexus_test]
413+
async fn test_negative_quota(cptestctx: &ControlPlaneTestContext) {
414+
let client = &cptestctx.external_client;
415+
416+
// Can't make a silo with a negative quota
417+
let mut quotas = params::SiloQuotasCreate::empty();
418+
quotas.cpus = -1;
419+
let response = object_create_error(
420+
client,
421+
"/v1/system/silos",
422+
&params::SiloCreate {
423+
identity: IdentityMetadataCreateParams {
424+
name: "negative-cpus-not-allowed".parse().unwrap(),
425+
description: "".into(),
426+
},
427+
quotas,
428+
discoverable: true,
429+
identity_mode: shared::SiloIdentityMode::LocalOnly,
430+
admin_group_name: None,
431+
tls_certificates: vec![],
432+
mapped_fleet_roles: Default::default(),
433+
},
434+
http::StatusCode::BAD_REQUEST,
435+
)
436+
.await;
437+
438+
assert!(
439+
response.message.contains(
440+
"Cannot create silo quota: CPU quota must not be negative"
441+
),
442+
"Unexpected response: {}",
443+
response.message
444+
);
445+
446+
// Make the silo with an empty quota
447+
let system = setup_silo_with_quota(
448+
&client,
449+
"quota-test-silo",
450+
params::SiloQuotasCreate::empty(),
451+
)
452+
.await;
453+
454+
// Can't update a silo with a negative quota
455+
let quota_limit = params::SiloQuotasUpdate {
456+
cpus: Some(-1),
457+
memory: Some(0_u64.try_into().unwrap()),
458+
storage: Some(0_u64.try_into().unwrap()),
459+
};
460+
let response = system
461+
.set_quotas_expect_error(
462+
client,
463+
quota_limit.clone(),
464+
http::StatusCode::BAD_REQUEST,
465+
)
466+
.await;
467+
468+
assert!(
469+
response.message.contains(
470+
"Cannot update silo quota: CPU quota must not be negative"
471+
),
472+
"Unexpected response: {}",
473+
response.message
474+
);
475+
}

nexus/tests/integration_tests/schema.rs

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3040,6 +3040,88 @@ fn after_185_0_0<'a>(ctx: &'a MigrationContext<'a>) -> BoxFuture<'a, ()> {
30403040
})
30413041
}
30423042

3043+
const NEGATIVE_QUOTA: Uuid =
3044+
Uuid::from_u128(0x00006001_5c3d_4647_83b0_8f351dda7ce1);
3045+
const POSITIVE_QUOTA: Uuid =
3046+
Uuid::from_u128(0x00006001_5c3d_4647_83b0_8f351dda7ce2);
3047+
const MIXED_QUOTA: Uuid =
3048+
Uuid::from_u128(0x00006001_5c3d_4647_83b0_8f351dda7ce3);
3049+
3050+
fn before_188_0_0<'a>(ctx: &'a MigrationContext<'a>) -> BoxFuture<'a, ()> {
3051+
Box::pin(async move {
3052+
ctx.client
3053+
.execute(
3054+
&format!(
3055+
"INSERT INTO omicron.public.silo_quotas (
3056+
silo_id,
3057+
time_created,
3058+
time_modified,
3059+
cpus,
3060+
memory_bytes,
3061+
storage_bytes
3062+
)
3063+
VALUES
3064+
('{NEGATIVE_QUOTA}', now(), now(), -1, -2, -3),
3065+
('{POSITIVE_QUOTA}', now(), now(), 1, 2, 3),
3066+
('{MIXED_QUOTA}', now(), now(), -1, 0, 3);",
3067+
),
3068+
&[],
3069+
)
3070+
.await
3071+
.expect("inserted silo_quotas");
3072+
})
3073+
}
3074+
3075+
fn after_188_0_0<'a>(ctx: &'a MigrationContext<'a>) -> BoxFuture<'a, ()> {
3076+
Box::pin(async move {
3077+
let rows = ctx
3078+
.client
3079+
.query(
3080+
"SELECT
3081+
silo_id,
3082+
cpus,
3083+
memory_bytes,
3084+
storage_bytes
3085+
FROM omicron.public.silo_quotas ORDER BY silo_id ASC;",
3086+
&[],
3087+
)
3088+
.await
3089+
.expect("queried post-migration positive-quotas");
3090+
3091+
fn check_quota(
3092+
row: &Row,
3093+
id: Uuid,
3094+
cpus: i64,
3095+
memory: i64,
3096+
storage: i64,
3097+
) {
3098+
assert_eq!(
3099+
row.values[0].expect("silo_id").unwrap(),
3100+
&AnySqlType::Uuid(id)
3101+
);
3102+
assert_eq!(
3103+
row.values[1].expect("cpus").unwrap(),
3104+
&AnySqlType::Int8(cpus)
3105+
);
3106+
assert_eq!(
3107+
row.values[2].expect("memory_bytes").unwrap(),
3108+
&AnySqlType::Int8(memory)
3109+
);
3110+
assert_eq!(
3111+
row.values[3].expect("storage_bytes").unwrap(),
3112+
&AnySqlType::Int8(storage)
3113+
);
3114+
}
3115+
3116+
let rows = process_rows(&rows);
3117+
assert_eq!(rows.len(), 3);
3118+
3119+
check_quota(&rows[0], NEGATIVE_QUOTA, 0, 0, 0);
3120+
check_quota(&rows[1], POSITIVE_QUOTA, 1, 2, 3);
3121+
check_quota(&rows[2], MIXED_QUOTA, 0, 0, 3);
3122+
})
3123+
}
3124+
30433125
// Lazily initializes all migration checks. The combination of Rust function
30443126
// pointers and async makes defining a static table fairly painful, so we're
30453127
// using lazy initialization instead.
@@ -3142,6 +3224,10 @@ fn get_migration_checks() -> BTreeMap<Version, DataMigrationFns> {
31423224
Version::new(185, 0, 0),
31433225
DataMigrationFns::new().before(before_185_0_0).after(after_185_0_0),
31443226
);
3227+
map.insert(
3228+
Version::new(188, 0, 0),
3229+
DataMigrationFns::new().before(before_188_0_0).after(after_188_0_0),
3230+
);
31453231
map
31463232
}
31473233

schema/crdb/dbinit.sql

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1073,7 +1073,11 @@ CREATE TABLE IF NOT EXISTS omicron.public.silo_quotas (
10731073
time_modified TIMESTAMPTZ NOT NULL,
10741074
cpus INT8 NOT NULL,
10751075
memory_bytes INT8 NOT NULL,
1076-
storage_bytes INT8 NOT NULL
1076+
storage_bytes INT8 NOT NULL,
1077+
1078+
CONSTRAINT cpus_not_negative CHECK (cpus >= 0),
1079+
CONSTRAINT memory_not_negative CHECK (memory_bytes >= 0),
1080+
CONSTRAINT storage_not_negative CHECK (storage_bytes >= 0)
10771081
);
10781082

10791083
/**
@@ -6613,7 +6617,7 @@ INSERT INTO omicron.public.db_metadata (
66136617
version,
66146618
target_version
66156619
) VALUES
6616-
(TRUE, NOW(), NOW(), '187.0.0', NULL)
6620+
(TRUE, NOW(), NOW(), '188.0.0', NULL)
66176621
ON CONFLICT DO NOTHING;
66186622

66196623
COMMIT;
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
SET LOCAL disallow_full_table_scans = off;
2+
3+
UPDATE omicron.public.silo_quotas
4+
SET
5+
cpus = GREATEST(cpus, 0),
6+
memory_bytes = GREATEST(memory_bytes, 0),
7+
storage_bytes = GREATEST(storage_bytes, 0)
8+
WHERE cpus < 0 OR memory_bytes < 0 OR storage_bytes < 0;
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
ALTER TABLE omicron.public.silo_quotas
2+
ADD CONSTRAINT IF NOT EXISTS cpus_not_negative CHECK (cpus >= 0),
3+
ADD CONSTRAINT IF NOT EXISTS memory_not_negative CHECK (memory_bytes >= 0),
4+
ADD CONSTRAINT IF NOT EXISTS storage_not_negative CHECK (storage_bytes >= 0);

0 commit comments

Comments
 (0)