Skip to content

Commit 76d51d8

Browse files
authored
[nexus] Refuse to boot until Nexus finds a matching DB schema (#3531)
- Nexus now reads the `db_metadata` table, ensuring that the schema "in-the-DB" matches the schema "in Nexus". At the moment, we're being picky, and we require an exact match. - Adds tests validating that we refuse to boot if the schema does not match - Add tests validating that we **eventually** boot Nexus if the schema is corrected
1 parent 9862da8 commit 76d51d8

File tree

14 files changed

+293
-18
lines changed

14 files changed

+293
-18
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: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2510,7 +2510,7 @@ CREATE TABLE omicron.public.switch_port_settings_address_config (
25102510
*/
25112511

25122512
CREATE TABLE omicron.public.db_metadata (
2513-
name STRING(63) NOT NULL,
2513+
name STRING(63) NOT NULL PRIMARY KEY,
25142514
value STRING(1023) NOT NULL
25152515
);
25162516

nexus/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ reqwest = { workspace = true, features = ["json"] }
6060
ring.workspace = true
6161
samael.workspace = true
6262
schemars = { workspace = true, features = ["chrono", "uuid1"] }
63+
semver.workspace = true
6364
serde.workspace = true
6465
serde_json.workspace = true
6566
serde_urlencoded.workspace = true

nexus/db-model/src/db_metadata.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// This Source Code Form is subject to the terms of the Mozilla Public
2+
// License, v. 2.0. If a copy of the MPL was not distributed with this
3+
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4+
5+
use crate::schema::db_metadata;
6+
7+
/// Internal database metadata
8+
#[derive(Queryable, Insertable, Debug, Clone, Selectable)]
9+
#[diesel(table_name = db_metadata)]
10+
pub struct DbMetadata {
11+
name: String,
12+
value: String,
13+
}
14+
15+
impl DbMetadata {
16+
pub fn new(name: String, value: String) -> Self {
17+
Self { name, value }
18+
}
19+
20+
pub fn name(&self) -> &str {
21+
&self.name
22+
}
23+
24+
pub fn value(&self) -> &str {
25+
&self.value
26+
}
27+
}

nexus/db-model/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ mod collection;
1818
mod console_session;
1919
mod dataset;
2020
mod dataset_kind;
21+
mod db_metadata;
2122
mod device_auth;
2223
mod digest;
2324
mod disk;
@@ -104,6 +105,7 @@ pub use collection::*;
104105
pub use console_session::*;
105106
pub use dataset::*;
106107
pub use dataset_kind::*;
108+
pub use db_metadata::*;
107109
pub use device_auth::*;
108110
pub use digest::*;
109111
pub use disk::*;

nexus/db-model/src/schema.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
//!
77
//! NOTE: Should be kept up-to-date with dbinit.sql.
88
9+
use omicron_common::api::external::SemverVersion;
10+
911
table! {
1012
disk (id) {
1113
id -> Uuid,
@@ -1109,6 +1111,17 @@ table! {
11091111
}
11101112
}
11111113

1114+
table! {
1115+
db_metadata (name) {
1116+
name -> Text,
1117+
value -> Text,
1118+
}
1119+
}
1120+
1121+
/// The version of the database schema this particular version of Nexus was
1122+
/// built against.
1123+
pub const SCHEMA_VERSION: SemverVersion = SemverVersion::new(1, 0, 0);
1124+
11121125
allow_tables_to_appear_in_same_query!(
11131126
system_update,
11141127
component_update,
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// This Source Code Form is subject to the terms of the Mozilla Public
2+
// License, v. 2.0. If a copy of the MPL was not distributed with this
3+
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4+
5+
//! [`DataStore`] methods on Database Metadata.
6+
7+
use super::DataStore;
8+
use crate::db;
9+
use crate::db::error::public_error_from_diesel_pool;
10+
use crate::db::error::ErrorHandler;
11+
use async_bb8_diesel::AsyncRunQueryDsl;
12+
use diesel::prelude::*;
13+
use omicron_common::api::external::Error;
14+
use omicron_common::api::external::SemverVersion;
15+
use std::str::FromStr;
16+
17+
impl DataStore {
18+
pub async fn database_schema_version(
19+
&self,
20+
) -> Result<SemverVersion, Error> {
21+
use db::schema::db_metadata::dsl;
22+
23+
let version: String = dsl::db_metadata
24+
.filter(dsl::name.eq("schema_version"))
25+
.select(dsl::value)
26+
.get_result_async(self.pool())
27+
.await
28+
.map_err(|e| {
29+
public_error_from_diesel_pool(e, ErrorHandler::Server)
30+
})?;
31+
32+
SemverVersion::from_str(&version).map_err(|e| {
33+
Error::internal_error(&format!("Invalid schema version: {e}"))
34+
})
35+
}
36+
}

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

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,11 @@ use omicron_common::api::external::Error;
3737
use omicron_common::api::external::IdentityMetadataCreateParams;
3838
use omicron_common::api::external::LookupType;
3939
use omicron_common::api::external::ResourceType;
40+
use omicron_common::api::external::SemverVersion;
41+
use omicron_common::backoff::{
42+
retry_notify, retry_policy_internal_service, BackoffError,
43+
};
44+
use slog::Logger;
4045
use std::net::Ipv6Addr;
4146
use std::sync::Arc;
4247
use uuid::Uuid;
@@ -45,6 +50,7 @@ mod address_lot;
4550
mod certificate;
4651
mod console_session;
4752
mod dataset;
53+
mod db_metadata;
4854
mod device_auth;
4955
mod disk;
5056
mod dns;
@@ -131,12 +137,40 @@ pub struct DataStore {
131137
// to compilation times; changing a query only requires incremental
132138
// recompilation of that query's module instead of all queries on `DataStore`.
133139
impl DataStore {
134-
pub fn new(pool: Arc<Pool>) -> Self {
135-
DataStore {
140+
/// Constructs a new Datastore object.
141+
///
142+
/// Only returns if the database schema is compatible with Nexus's known
143+
/// schema version.
144+
pub async fn new(log: &Logger, pool: Arc<Pool>) -> Result<Self, String> {
145+
let datastore = DataStore {
136146
pool,
137147
virtual_provisioning_collection_producer:
138148
crate::provisioning::Producer::new(),
139-
}
149+
};
150+
151+
// Keep looping until we find that the schema matches our expectation.
152+
const EXPECTED_VERSION: SemverVersion = SemverVersion::new(1, 0, 0);
153+
retry_notify(
154+
retry_policy_internal_service(),
155+
|| async {
156+
match datastore.database_schema_version().await {
157+
Ok(version) => {
158+
if version == nexus_db_model::schema::SCHEMA_VERSION {
159+
return Ok(());
160+
}
161+
let observed = version.0;
162+
warn!(log, "Incompatible database schema: Saw {observed}, expected {EXPECTED_VERSION}");
163+
}
164+
Err(e) => {
165+
warn!(log, "Cannot read database schema version: {e}");
166+
}
167+
};
168+
return Err(BackoffError::transient(()));
169+
},
170+
|_, _| {},
171+
).await.map_err(|_| "Failed to read valid DB schema".to_string())?;
172+
173+
Ok(datastore)
140174
}
141175

142176
pub fn register_producers(&self, registry: &ProducerRegistry) {
@@ -248,7 +282,7 @@ pub async fn datastore_test(
248282

249283
let cfg = db::Config { url: db.pg_config().clone() };
250284
let pool = Arc::new(db::Pool::new(&logctx.log, &cfg));
251-
let datastore = Arc::new(DataStore::new(pool));
285+
let datastore = Arc::new(DataStore::new(&logctx.log, pool).await.unwrap());
252286

253287
// Create an OpContext with the credentials of "db-init" just for the
254288
// purpose of loading the built-in users, roles, and assignments.
@@ -907,7 +941,8 @@ mod test {
907941
let mut db = test_setup_database(&logctx.log).await;
908942
let cfg = db::Config { url: db.pg_config().clone() };
909943
let pool = db::Pool::new(&logctx.log, &cfg);
910-
let datastore = DataStore::new(Arc::new(pool));
944+
let datastore =
945+
DataStore::new(&logctx.log, Arc::new(pool)).await.unwrap();
911946

912947
let explanation = DataStore::get_allocated_regions_query(Uuid::nil())
913948
.explain_async(datastore.pool())
@@ -956,7 +991,8 @@ mod test {
956991
let mut db = test_setup_database(&logctx.log).await;
957992
let cfg = db::Config { url: db.pg_config().clone() };
958993
let pool = Arc::new(db::Pool::new(&logctx.log, &cfg));
959-
let datastore = Arc::new(DataStore::new(Arc::clone(&pool)));
994+
let datastore =
995+
Arc::new(DataStore::new(&logctx.log, pool).await.unwrap());
960996
let opctx =
961997
OpContext::for_tests(logctx.log.new(o!()), datastore.clone());
962998

nexus/db-queries/src/db/queries/external_ip.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -851,8 +851,11 @@ mod tests {
851851
crate::db::datastore::datastore_test(&logctx, &db).await;
852852
let cfg = crate::db::Config { url: db.pg_config().clone() };
853853
let pool = Arc::new(crate::db::Pool::new(&logctx.log, &cfg));
854-
let db_datastore =
855-
Arc::new(crate::db::DataStore::new(Arc::clone(&pool)));
854+
let db_datastore = Arc::new(
855+
crate::db::DataStore::new(&logctx.log, Arc::clone(&pool))
856+
.await
857+
.unwrap(),
858+
);
856859
let opctx =
857860
OpContext::for_tests(log.new(o!()), db_datastore.clone());
858861
Self { logctx, opctx, db, db_datastore }

nexus/db-queries/src/db/queries/vpc_subnet.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -444,8 +444,9 @@ mod test {
444444
let mut db = test_setup_database(&log).await;
445445
let cfg = crate::db::Config { url: db.pg_config().clone() };
446446
let pool = Arc::new(crate::db::Pool::new(&logctx.log, &cfg));
447-
let db_datastore =
448-
Arc::new(crate::db::DataStore::new(Arc::clone(&pool)));
447+
let db_datastore = Arc::new(
448+
crate::db::DataStore::new(&log, Arc::clone(&pool)).await.unwrap(),
449+
);
449450

450451
// We should be able to insert anything into an empty table.
451452
assert!(

0 commit comments

Comments
 (0)