Skip to content

Commit c797644

Browse files
authored
Initial schema updates disallowing default IP Pool for the internal silo (#8964)
- 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 8cde6ef commit c797644

File tree

8 files changed

+201
-46
lines changed

8 files changed

+201
-46
lines changed

nexus/db-model/src/ip_pool.rs

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

156-
#[derive(Queryable, Insertable, Selectable, Clone, Debug)]
156+
#[derive(Queryable, Insertable, Selectable, Clone, Copy, Debug, PartialEq)]
157157
#[diesel(table_name = ip_pool_resource)]
158158
pub struct IpPoolResource {
159159
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: 157 additions & 28 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;
@@ -86,6 +87,16 @@ impl ServiceIpPools {
8687
}
8788
}
8889

90+
// Constraint used to ensure we don't set a default IP Pool for the internal
91+
// silo.
92+
const INTERNAL_SILO_DEFAULT_CONSTRAINT: &'static str =
93+
"internal_silo_has_no_default_pool";
94+
95+
// Error message emitted when we attempt to set a default IP Pool for the
96+
// internal silo.
97+
const INTERNAL_SILO_DEFAULT_ERROR: &'static str =
98+
"The internal Silo cannot have a default IP Pool";
99+
89100
impl DataStore {
90101
/// List IP Pools
91102
pub async fn ip_pools_list(
@@ -588,22 +599,34 @@ impl DataStore {
588599
let conn = self.pool_connection_authorized(opctx).await?;
589600

590601
let result = diesel::insert_into(dsl::ip_pool_resource)
591-
.values(ip_pool_resource.clone())
602+
.values(ip_pool_resource)
592603
.get_result_async(&*conn)
593604
.await
594605
.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,
606+
match e {
607+
// Catch the check constraint ensuring the internal silo has
608+
// no default pool
609+
DieselError::DatabaseError(DatabaseErrorKind::CheckViolation, ref info)
610+
if info.constraint_name() == Some(INTERNAL_SILO_DEFAULT_CONSTRAINT) =>
611+
{
612+
Error::invalid_request(INTERNAL_SILO_DEFAULT_ERROR)
613+
}
614+
DieselError::DatabaseError(DatabaseErrorKind::UniqueViolation, _) => {
615+
public_error_from_diesel(
616+
e,
617+
ErrorHandler::Conflict(
618+
ResourceType::IpPoolResource,
619+
&format!(
620+
"ip_pool_id: {}, resource_id: {}, resource_type: {:?}",
621+
ip_pool_resource.ip_pool_id,
622+
ip_pool_resource.resource_id,
623+
ip_pool_resource.resource_type,
624+
),
625+
)
604626
)
605-
),
606-
)
627+
}
628+
_ => public_error_from_diesel(e, ErrorHandler::Server),
629+
}
607630
})?;
608631

609632
if ip_pool_resource.is_default {
@@ -885,21 +908,47 @@ impl DataStore {
885908
IpPoolResourceUpdateError::FailedToUnsetDefault(err),
886909
)) => public_error_from_diesel(err, ErrorHandler::Server),
887910
Some(TxnError::Database(err)) => {
888-
public_error_from_diesel(err, ErrorHandler::Server)
911+
match err {
912+
// Catch the check constraint ensuring the internal silo has
913+
// no default pool
914+
DieselError::DatabaseError(
915+
DatabaseErrorKind::CheckViolation,
916+
ref info,
917+
) if info.constraint_name()
918+
== Some(INTERNAL_SILO_DEFAULT_CONSTRAINT) =>
919+
{
920+
Error::invalid_request(INTERNAL_SILO_DEFAULT_ERROR)
921+
}
922+
_ => {
923+
public_error_from_diesel(err, ErrorHandler::Server)
924+
}
925+
}
889926
}
890927
None => {
891-
public_error_from_diesel(
892-
e,
893-
ErrorHandler::NotFoundByLookup(
894-
ResourceType::IpPoolResource,
895-
// TODO: would be nice to put the actual names and/or ids in
896-
// here but LookupType on each of the two silos doesn't have
897-
// a nice to_string yet or a way of composing them
898-
LookupType::ByCompositeId(
899-
"(pool, silo)".to_string(),
928+
match e {
929+
// Catch the check constraint ensuring the internal silo has
930+
// no default pool
931+
DieselError::DatabaseError(
932+
DatabaseErrorKind::CheckViolation,
933+
ref info,
934+
) if info.constraint_name()
935+
== Some(INTERNAL_SILO_DEFAULT_CONSTRAINT) =>
936+
{
937+
Error::invalid_request(INTERNAL_SILO_DEFAULT_ERROR)
938+
}
939+
_ => public_error_from_diesel(
940+
e,
941+
ErrorHandler::NotFoundByLookup(
942+
ResourceType::IpPoolResource,
943+
// TODO: would be nice to put the actual names and/or ids in
944+
// here but LookupType on each of the two silos doesn't have
945+
// a nice to_string yet or a way of composing them
946+
LookupType::ByCompositeId(
947+
"(pool, silo)".to_string(),
948+
),
900949
),
901950
),
902-
)
951+
}
903952
}
904953
})
905954
}
@@ -1269,12 +1318,13 @@ mod test {
12691318
use std::num::NonZeroU32;
12701319

12711320
use crate::authz;
1321+
use crate::db::datastore::ip_pool::INTERNAL_SILO_DEFAULT_ERROR;
12721322
use crate::db::model::{
12731323
IpPool, IpPoolResource, IpPoolResourceType, Project,
12741324
};
12751325
use crate::db::pub_test_utils::TestDatabase;
12761326
use assert_matches::assert_matches;
1277-
use nexus_db_model::IpVersion;
1327+
use nexus_db_model::{IpPoolIdentity, IpVersion};
12781328
use nexus_types::external_api::params;
12791329
use nexus_types::identity::Resource;
12801330
use omicron_common::address::{IpRange, Ipv4Range, Ipv6Range};
@@ -1283,6 +1333,7 @@ mod test {
12831333
DataPageParams, Error, IdentityMetadataCreateParams, LookupType,
12841334
};
12851335
use omicron_test_utils::dev;
1336+
use uuid::Uuid;
12861337

12871338
#[tokio::test]
12881339
async fn test_default_ip_pools() {
@@ -1360,7 +1411,7 @@ mod test {
13601411
is_default: false,
13611412
};
13621413
datastore
1363-
.ip_pool_link_silo(&opctx, link_body.clone())
1414+
.ip_pool_link_silo(&opctx, link_body)
13641415
.await
13651416
.expect("Failed to associate IP pool with silo");
13661417

@@ -1489,9 +1540,6 @@ mod test {
14891540
assert_eq!(is_internal, Ok(false));
14901541

14911542
// 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.
14951543
let silo_id = opctx.authn.silo_required().unwrap().id();
14961544
let is_default = matches!(version, IpVersion::V4);
14971545
let link = IpPoolResource {
@@ -1514,6 +1562,87 @@ mod test {
15141562
logctx.cleanup_successful();
15151563
}
15161564

1565+
#[tokio::test]
1566+
async fn cannot_set_default_ip_pool_for_internal_silo() {
1567+
let logctx =
1568+
dev::test_setup_log("cannot_set_default_ip_pool_for_internal_silo");
1569+
let db = TestDatabase::new_with_datastore(&logctx.log).await;
1570+
let (opctx, datastore) = (db.opctx(), db.datastore());
1571+
1572+
for ip_version in [IpVersion::V4, IpVersion::V6] {
1573+
// Make some new pool.
1574+
let params = IpPool {
1575+
identity: IpPoolIdentity::new(
1576+
Uuid::new_v4(),
1577+
IdentityMetadataCreateParams {
1578+
name: format!("test-pool-{}", ip_version)
1579+
.parse()
1580+
.unwrap(),
1581+
description: String::new(),
1582+
},
1583+
),
1584+
ip_version,
1585+
rcgen: 0,
1586+
};
1587+
let pool = datastore
1588+
.ip_pool_create(&opctx, params)
1589+
.await
1590+
.expect("Should be able to create pool");
1591+
assert_eq!(pool.ip_version, ip_version);
1592+
let authz_pool =
1593+
nexus_db_lookup::LookupPath::new(&opctx, datastore)
1594+
.ip_pool_id(pool.id())
1595+
.lookup_for(authz::Action::Read)
1596+
.await
1597+
.expect("Should be able to lookup new IP Pool")
1598+
.0;
1599+
1600+
// Try to link it as the default.
1601+
let (authz_silo, ..) =
1602+
nexus_db_lookup::LookupPath::new(&opctx, datastore)
1603+
.silo_id(nexus_types::silo::INTERNAL_SILO_ID)
1604+
.lookup_for(authz::Action::Read)
1605+
.await
1606+
.expect("Should be able to lookup internal silo");
1607+
let link = IpPoolResource {
1608+
ip_pool_id: authz_pool.id(),
1609+
resource_type: IpPoolResourceType::Silo,
1610+
resource_id: authz_silo.id(),
1611+
is_default: true,
1612+
};
1613+
let Err(e) = datastore.ip_pool_link_silo(opctx, link).await else {
1614+
panic!(
1615+
"should have failed to link IP Pool to internal silo as a default"
1616+
);
1617+
};
1618+
let Error::InvalidRequest { message } = &e else {
1619+
panic!("should have received an invalid request, got: {:?}", e);
1620+
};
1621+
assert_eq!(message.external_message(), INTERNAL_SILO_DEFAULT_ERROR);
1622+
1623+
// We can link it if it's not the default.
1624+
let link = IpPoolResource { is_default: false, ..link };
1625+
datastore.ip_pool_link_silo(opctx, link).await.expect(
1626+
"Should be able to link non-default pool to internal silo",
1627+
);
1628+
1629+
// Try to set it to the default, and ensure that this also fails.
1630+
let Err(e) = datastore
1631+
.ip_pool_set_default(opctx, &authz_pool, &authz_silo, true)
1632+
.await
1633+
else {
1634+
panic!("should have failed to set internal pool to default");
1635+
};
1636+
let Error::InvalidRequest { message } = &e else {
1637+
panic!("should have received an invalid request, got: {:?}", e);
1638+
};
1639+
assert_eq!(message.external_message(), INTERNAL_SILO_DEFAULT_ERROR);
1640+
}
1641+
1642+
db.terminate().await;
1643+
logctx.cleanup_successful();
1644+
}
1645+
15171646
// We're breaking out the utilization tests for IPv4 and IPv6 pools, since
15181647
// pools only contain one version now.
15191648
//

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: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2110,7 +2110,17 @@ 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+
NOT (
2118+
resource_type = 'silo' AND
2119+
resource_id = '001de000-5110-4000-8000-000000000001' AND
2120+
is_default
2121+
)
2122+
)
2123+
21142124
);
21152125

21162126
-- a given resource can only have one default ip pool
@@ -2123,6 +2133,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS one_default_ip_pool_per_resource ON omicron.pu
21232133
CREATE INDEX IF NOT EXISTS ip_pool_resource_id ON omicron.public.ip_pool_resource (
21242134
resource_id
21252135
);
2136+
21262137
CREATE INDEX IF NOT EXISTS ip_pool_resource_ip_pool_id ON omicron.public.ip_pool_resource (
21272138
ip_pool_id
21282139
);
@@ -6602,7 +6613,7 @@ INSERT INTO omicron.public.db_metadata (
66026613
version,
66036614
target_version
66046615
) VALUES
6605-
(TRUE, NOW(), NOW(), '186.0.0', NULL)
6616+
(TRUE, NOW(), NOW(), '187.0.0', NULL)
66066617
ON CONFLICT DO NOTHING;
66076618

66086619
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: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
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
7+
omicron.public.ip_pool_resource
8+
ADD CONSTRAINT IF NOT EXISTS
9+
internal_silo_has_no_default_pool CHECK (
10+
NOT (
11+
resource_type = 'silo' AND
12+
resource_id = '001de000-5110-4000-8000-000000000001' AND
13+
is_default
14+
)
15+
);

0 commit comments

Comments
 (0)