Skip to content

Commit 0fa862d

Browse files
[review] check ip_version against pool + sql migration split
1 parent 93460f3 commit 0fa862d

File tree

7 files changed

+76
-26
lines changed

7 files changed

+76
-26
lines changed

nexus/src/app/instance.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2193,6 +2193,27 @@ impl super::Nexus {
21932193
pool: Option<NameOrId>,
21942194
ip_version: Option<IpVersion>,
21952195
) -> UpdateResult<views::ExternalIp> {
2196+
// Validate pool/ip_version compatibility upfront for clear error
2197+
// communication.
2198+
//
2199+
// The saga will look up the pool again, but this ensures the user gets
2200+
// a direct error before any saga machinery starts, and without changing
2201+
// the `Ephemeral` type fields.
2202+
if let (Some(pool_id), Some(requested_version)) = (&pool, ip_version) {
2203+
let (_, db_pool) = self
2204+
.ip_pool_lookup(opctx, pool_id)?
2205+
.fetch_for(authz::Action::CreateChild)
2206+
.await?;
2207+
let db_version: IpVersion = db_pool.ip_version.into();
2208+
if db_version != requested_version {
2209+
return Err(Error::invalid_request(format!(
2210+
"requested IP version ({requested_version}) does not match \
2211+
pool '{}' version ({db_version})",
2212+
db_pool.name().as_str(),
2213+
)));
2214+
}
2215+
}
2216+
21962217
let (.., authz_project, authz_instance) =
21972218
instance_lookup.lookup_for(authz::Action::Modify).await?;
21982219

nexus/tests/integration_tests/external_ips.rs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1548,6 +1548,37 @@ async fn test_ephemeral_ip_ip_version_conflict(
15481548
);
15491549
}
15501550

1551+
/// Test that specifying both `pool` and IP version where the pool's version
1552+
/// doesn't match the requested version returns an error.
1553+
#[nexus_test]
1554+
async fn test_ephemeral_ip_pool_version_mismatch(
1555+
cptestctx: &ControlPlaneTestContext,
1556+
) {
1557+
let client = &cptestctx.external_client;
1558+
1559+
// Create default IPv4 pool
1560+
create_default_ip_pool(&client).await;
1561+
create_project(&client, PROJECT_NAME).await;
1562+
instance_for_external_ips(client, INSTANCE_NAMES[0], false, false, &[])
1563+
.await;
1564+
1565+
let url = instance_ephemeral_ip_url(INSTANCE_NAMES[0], PROJECT_NAME);
1566+
1567+
// Request IPv6 from the IPv4 pool -> should fail
1568+
NexusRequest::new(
1569+
RequestBuilder::new(client, Method::POST, &url)
1570+
.body(Some(&params::EphemeralIpCreate {
1571+
pool: Some(NameOrId::Name("default".parse().unwrap())),
1572+
ip_version: Some(views::IpVersion::V6),
1573+
}))
1574+
.expect_status(Some(StatusCode::BAD_REQUEST)),
1575+
)
1576+
.authn_as(AuthnMode::PrivilegedUser)
1577+
.execute()
1578+
.await
1579+
.unwrap();
1580+
}
1581+
15511582
#[nexus_test]
15521583
async fn can_list_instance_snat_ip(cptestctx: &ControlPlaneTestContext) {
15531584
let client = &cptestctx.external_client;

schema/crdb/multiple-default-ip-pools-per-silo/up01.sql

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,3 @@
99

1010
-- Drop the old single-default constraint
1111
DROP INDEX IF EXISTS omicron.public.one_default_ip_pool_per_resource;
12-
13-
-- Add denormalized columns (nullable initially for backfill)
14-
ALTER TABLE omicron.public.ip_pool_resource
15-
ADD COLUMN IF NOT EXISTS pool_type omicron.public.ip_pool_type,
16-
ADD COLUMN IF NOT EXISTS ip_version omicron.public.ip_version;
Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,4 @@
1-
-- Backfill pool_type and ip_version from ip_pool table
2-
SET LOCAL disallow_full_table_scans = off;
3-
UPDATE omicron.public.ip_pool_resource AS resource
4-
SET
5-
pool_type = pool.pool_type,
6-
ip_version = pool.ip_version
7-
FROM omicron.public.ip_pool AS pool
8-
WHERE resource.ip_pool_id = pool.id
9-
AND (resource.pool_type IS NULL OR resource.ip_version IS NULL);
1+
-- Add denormalized columns (nullable initially for backfill)
2+
ALTER TABLE omicron.public.ip_pool_resource
3+
ADD COLUMN IF NOT EXISTS pool_type omicron.public.ip_pool_type,
4+
ADD COLUMN IF NOT EXISTS ip_version omicron.public.ip_version;
Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,9 @@
1-
-- Make columns NOT NULL and create new unique index
2-
ALTER TABLE omicron.public.ip_pool_resource
3-
ALTER COLUMN pool_type SET NOT NULL,
4-
ALTER COLUMN ip_version SET NOT NULL;
5-
6-
-- One default pool per (resource, pool_type, ip_version) combination
7-
CREATE UNIQUE INDEX IF NOT EXISTS one_default_ip_pool_per_resource_type_version
8-
ON omicron.public.ip_pool_resource (
9-
resource_id,
10-
pool_type,
11-
ip_version
12-
) WHERE is_default = true;
1+
-- Backfill pool_type and ip_version from ip_pool table
2+
SET LOCAL disallow_full_table_scans = off;
3+
UPDATE omicron.public.ip_pool_resource AS resource
4+
SET
5+
pool_type = pool.pool_type,
6+
ip_version = pool.ip_version
7+
FROM omicron.public.ip_pool AS pool
8+
WHERE resource.ip_pool_id = pool.id
9+
AND (resource.pool_type IS NULL OR resource.ip_version IS NULL);
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
-- Make columns NOT NULL after backfill
2+
ALTER TABLE omicron.public.ip_pool_resource
3+
ALTER COLUMN pool_type SET NOT NULL,
4+
ALTER COLUMN ip_version SET NOT NULL;
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
-- One default pool per (resource, pool_type, ip_version) combination
2+
CREATE UNIQUE INDEX IF NOT EXISTS one_default_ip_pool_per_resource_type_version
3+
ON omicron.public.ip_pool_resource (
4+
resource_id,
5+
pool_type,
6+
ip_version
7+
) WHERE is_default = true;

0 commit comments

Comments
 (0)