Skip to content

Commit 9f49f97

Browse files
committed
Initial schema updates disallowing default IP Pool for the internal silo
- Add database constraints that ensure that we can't set a default pool for the Oxide internal silo. Add tests for this specifically. - This is part of #8948, but does not resolve it.
1 parent e4022bd commit 9f49f97

File tree

8 files changed

+167
-38
lines changed

8 files changed

+167
-38
lines changed

nexus/db-model/src/ip_pool.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,16 @@ impl_enum_type!(
153153
Silo => b"silo"
154154
);
155155

156-
#[derive(Queryable, Insertable, Selectable, Clone, Debug)]
156+
#[derive(
157+
AsChangeset,
158+
Queryable,
159+
Insertable,
160+
Selectable,
161+
Clone,
162+
Copy,
163+
Debug,
164+
PartialEq,
165+
)]
157166
#[diesel(table_name = ip_pool_resource)]
158167
pub struct IpPoolResource {
159168
pub ip_pool_id: Uuid,

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(186, 0, 0);
19+
pub const SCHEMA_VERSION: Version = Version::new(187, 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(187, "no-default-pool-for-internal-silo"),
3132
KnownVersion::new(186, "nexus-generation"),
3233
KnownVersion::new(185, "populate-db-metadata-nexus"),
3334
KnownVersion::new(184, "store-silo-admin-group-name"),

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

Lines changed: 108 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ use crate::db::queries::ip_pool::FilterOverlappingIpRanges;
2727
use async_bb8_diesel::AsyncRunQueryDsl;
2828
use chrono::Utc;
2929
use diesel::prelude::*;
30+
use diesel::result::DatabaseErrorKind;
3031
use diesel::result::Error as DieselError;
3132
use ipnetwork::IpNetwork;
3233
use nexus_db_errors::ErrorHandler;
@@ -587,23 +588,40 @@ impl DataStore {
587588

588589
let conn = self.pool_connection_authorized(opctx).await?;
589590

591+
// We always insert and update the record on conflicts.
592+
//
593+
// This lets us use the database constraints for a few checks, such as
594+
// assigning more than one default pool for a silo, and ensuring that
595+
// there is no default at all for the internal silo.
590596
let result = diesel::insert_into(dsl::ip_pool_resource)
591-
.values(ip_pool_resource.clone())
597+
.values(ip_pool_resource)
598+
.on_conflict((dsl::ip_pool_id, dsl::resource_id, dsl::resource_type))
599+
.do_update()
600+
.set(ip_pool_resource)
592601
.get_result_async(&*conn)
593602
.await
594603
.map_err(|e| {
595-
public_error_from_diesel(
596-
e,
597-
ErrorHandler::Conflict(
598-
ResourceType::IpPoolResource,
599-
&format!(
600-
"ip_pool_id: {:?}, resource_id: {:?}, resource_type: {:?}",
601-
ip_pool_resource.ip_pool_id,
602-
ip_pool_resource.resource_id,
603-
ip_pool_resource.resource_type,
604+
match e {
605+
// Specifically catch conflicts on the unique index which
606+
// ensures at most one default IP Pool per silo.
607+
DieselError::DatabaseError(DatabaseErrorKind::UniqueViolation, ref info)
608+
if info.constraint_name() == Some("one_default_ip_pool_per_resource") =>
609+
{
610+
public_error_from_diesel(
611+
e,
612+
ErrorHandler::Conflict(
613+
ResourceType::IpPoolResource,
614+
&format!(
615+
"ip_pool_id: {}, resource_id: {}, resource_type: {:?}",
616+
ip_pool_resource.ip_pool_id,
617+
ip_pool_resource.resource_id,
618+
ip_pool_resource.resource_type,
619+
),
620+
)
604621
)
605-
),
606-
)
622+
}
623+
_ => public_error_from_diesel(e, ErrorHandler::Server),
624+
}
607625
})?;
608626

609627
if ip_pool_resource.is_default {
@@ -1360,7 +1378,7 @@ mod test {
13601378
is_default: false,
13611379
};
13621380
datastore
1363-
.ip_pool_link_silo(&opctx, link_body.clone())
1381+
.ip_pool_link_silo(&opctx, link_body)
13641382
.await
13651383
.expect("Failed to associate IP pool with silo");
13661384

@@ -1378,12 +1396,12 @@ mod test {
13781396
assert_eq!(silo_pools[0].0.id(), pool1_for_silo.id());
13791397
assert_eq!(silo_pools[0].1.is_default, false);
13801398

1381-
// linking an already linked silo errors due to PK conflict
1382-
let err = datastore
1399+
// linking an already linked silo is fine
1400+
let new = datastore
13831401
.ip_pool_link_silo(&opctx, link_body)
13841402
.await
1385-
.expect_err("Creating the same link again should conflict");
1386-
assert_matches!(err, Error::ObjectAlreadyExists { .. });
1403+
.expect("Creating the same link again should not conflict");
1404+
assert_eq!(new, link_body);
13871405

13881406
// now make it default
13891407
datastore
@@ -1489,9 +1507,6 @@ mod test {
14891507
assert_eq!(is_internal, Ok(false));
14901508

14911509
// now link it to the current silo, and it is still not internal.
1492-
//
1493-
// We're only making the IPv4 pool the default right now. See
1494-
// https://github.com/oxidecomputer/omicron/issues/8884 for more.
14951510
let silo_id = opctx.authn.silo_required().unwrap().id();
14961511
let is_default = matches!(version, IpVersion::V4);
14971512
let link = IpPoolResource {
@@ -1514,6 +1529,79 @@ mod test {
15141529
logctx.cleanup_successful();
15151530
}
15161531

1532+
#[tokio::test]
1533+
async fn cannot_set_default_ip_pool_for_internal_silo() {
1534+
let logctx =
1535+
dev::test_setup_log("cannot_set_default_ip_pool_for_internal_silo");
1536+
let db = TestDatabase::new_with_datastore(&logctx.log).await;
1537+
let (opctx, datastore) = (db.opctx(), db.datastore());
1538+
1539+
for version in [IpVersion::V4, IpVersion::V6] {
1540+
// confirm internal pools appear as internal
1541+
let (authz_pool, pool) = datastore
1542+
.ip_pools_service_lookup(&opctx, version)
1543+
.await
1544+
.unwrap();
1545+
assert_eq!(pool.ip_version, version);
1546+
1547+
let is_internal =
1548+
datastore.ip_pool_is_internal(&opctx, &authz_pool).await;
1549+
assert_eq!(is_internal, Ok(true));
1550+
1551+
// Try to link it as the default.
1552+
let (authz_silo, ..) =
1553+
nexus_db_lookup::LookupPath::new(&opctx, datastore)
1554+
.silo_id(nexus_types::silo::INTERNAL_SILO_ID)
1555+
.lookup_for(authz::Action::Read)
1556+
.await
1557+
.expect("Should be able to lookup internal silo");
1558+
let link = IpPoolResource {
1559+
ip_pool_id: authz_pool.id(),
1560+
resource_type: IpPoolResourceType::Silo,
1561+
resource_id: authz_silo.id(),
1562+
is_default: true,
1563+
};
1564+
let Err(e) = datastore.ip_pool_link_silo(opctx, link).await else {
1565+
panic!(
1566+
"should have failed to link IP Pool to internal silo as a default"
1567+
);
1568+
};
1569+
let Error::InternalError { internal_message } = &e else {
1570+
panic!("should have received an internal error");
1571+
};
1572+
assert!(
1573+
internal_message.contains("failed to satisfy CHECK constraint"),
1574+
"Expected a CHECK constraint violation, found: {}",
1575+
internal_message,
1576+
);
1577+
1578+
// We can link it if it's not the default.
1579+
let link = IpPoolResource { is_default: false, ..link };
1580+
datastore.ip_pool_link_silo(opctx, link).await.expect(
1581+
"Should be able to link non-default pool to internal silo",
1582+
);
1583+
1584+
// Try to set it to the default, and ensure that this also fails.
1585+
let Err(e) = datastore
1586+
.ip_pool_set_default(opctx, &authz_pool, &authz_silo, true)
1587+
.await
1588+
else {
1589+
panic!("should have failed to set internal pool to default");
1590+
};
1591+
let Error::InternalError { internal_message } = &e else {
1592+
panic!("should have received an internal error");
1593+
};
1594+
assert!(
1595+
internal_message.contains("failed to satisfy CHECK constraint"),
1596+
"Expected a CHECK constraint violation, found: {}",
1597+
internal_message,
1598+
);
1599+
}
1600+
1601+
db.terminate().await;
1602+
logctx.cleanup_successful();
1603+
}
1604+
15171605
// We're breaking out the utilization tests for IPv4 and IPv6 pools, since
15181606
// pools only contain one version now.
15191607
//

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

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -991,9 +991,9 @@ impl DataStore {
991991
},
992992
version,
993993
);
994-
994+
// Create the pool, and link it to the internal silo if needed. But
995+
// we cannot set a default.
995996
let internal_pool_id = internal_pool.id();
996-
997997
let internal_created = self
998998
.ip_pool_create(opctx, internal_pool)
999999
.await
@@ -1002,25 +1002,14 @@ impl DataStore {
10021002
Error::ObjectAlreadyExists { .. } => Ok(false),
10031003
_ => Err(e),
10041004
})?;
1005-
1006-
// make default for the internal silo. only need to do this if
1007-
// the create went through, i.e., if it wasn't already there
1008-
//
1009-
// TODO-completeness: We're linking both IP pools here, but only the
1010-
// IPv4 pool is set as a default. We need a way for the operator to
1011-
// control this, either at RSS or through the API. An alternative is
1012-
// to not set a default at all, even though both are linked.
1013-
//
1014-
// See https://github.com/oxidecomputer/omicron/issues/8884
10151005
if internal_created {
1016-
let is_default = matches!(version, IpVersion::V4);
10171006
self.ip_pool_link_silo(
10181007
opctx,
10191008
db::model::IpPoolResource {
10201009
ip_pool_id: internal_pool_id,
10211010
resource_type: db::model::IpPoolResourceType::Silo,
10221011
resource_id: INTERNAL_SILO_ID,
1023-
is_default,
1012+
is_default: false,
10241013
},
10251014
)
10261015
.await?;

nexus/src/app/silo.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ impl super::Nexus {
5050
let silo = self.silo_lookup(opctx, NameOrId::Id(silo.id()))?;
5151
Ok(silo)
5252
}
53+
5354
pub fn silo_lookup<'a>(
5455
&'a self,
5556
opctx: &'a OpContext,

schema/crdb/dbinit.sql

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2110,7 +2110,20 @@ CREATE TABLE IF NOT EXISTS omicron.public.ip_pool_resource (
21102110

21112111
-- resource_type is redundant because resource IDs are globally unique, but
21122112
-- logically it belongs here
2113-
PRIMARY KEY (ip_pool_id, resource_type, resource_id)
2113+
PRIMARY KEY (ip_pool_id, resource_type, resource_id),
2114+
2115+
-- Check that there are no default pools for the internal silo
2116+
CONSTRAINT internal_silo_has_no_default_pool CHECK (
2117+
-- A = linked to internal, B = (not default)
2118+
-- A -> B iff ¬A v B
2119+
-- ¬(linked to internal silo) OR (not default)
2120+
NOT (
2121+
resource_type = 'silo' AND
2122+
resource_id = '001de000-5110-4000-8000-000000000001'
2123+
)
2124+
OR NOT is_default
2125+
)
2126+
21142127
);
21152128

21162129
-- a given resource can only have one default ip pool
@@ -2123,6 +2136,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS one_default_ip_pool_per_resource ON omicron.pu
21232136
CREATE INDEX IF NOT EXISTS ip_pool_resource_id ON omicron.public.ip_pool_resource (
21242137
resource_id
21252138
);
2139+
21262140
CREATE INDEX IF NOT EXISTS ip_pool_resource_ip_pool_id ON omicron.public.ip_pool_resource (
21272141
ip_pool_id
21282142
);
@@ -6602,7 +6616,7 @@ INSERT INTO omicron.public.db_metadata (
66026616
version,
66036617
target_version
66046618
) VALUES
6605-
(TRUE, NOW(), NOW(), '186.0.0', NULL)
6619+
(TRUE, NOW(), NOW(), '187.0.0', NULL)
66066620
ON CONFLICT DO NOTHING;
66076621

66086622
COMMIT;
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/*
2+
* Ensure we have no default pool for the internal silo.
3+
*/
4+
SET LOCAL disallow_full_table_scans = 'off';
5+
UPDATE omicron.public.ip_pool_resource
6+
SET is_default = FALSE
7+
WHERE
8+
resource_type = 'silo' AND
9+
resource_id = '001de000-5110-4000-8000-000000000001';
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/*
2+
* Add check constraint ensuring that, if the pool is linked
3+
* to the internal Oxide services silo, it's not marked as
4+
* a default pool.
5+
*/
6+
ALTER TABLE IF EXISTS
7+
omicron.public.ip_pool_resource
8+
ADD CONSTRAINT IF NOT EXISTS
9+
internal_silo_has_no_default_pool CHECK (
10+
-- A = linked to internal, B = (not default)
11+
-- A -> B iff ¬A v B
12+
-- ¬(linked to internal silo) OR (not default)
13+
NOT (
14+
resource_type = 'silo' AND
15+
resource_id = '001de000-5110-4000-8000-000000000001'
16+
)
17+
OR NOT is_default
18+
);

0 commit comments

Comments
 (0)