Skip to content

Commit 5538dbd

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

File tree

8 files changed

+86
-38
lines changed

8 files changed

+86
-38
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/src/app/sagas/instance_ip_attach.rs

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -95,17 +95,15 @@ async fn siia_begin_attach_ip(
9595
match &params.create_params {
9696
// Allocate a new IP address from the target, possibly default, pool
9797
ExternalIpAttach::Ephemeral { pool, ip_version } => {
98-
let pool = if let Some(name_or_id) = pool {
99-
Some(
100-
osagactx
101-
.nexus()
102-
.ip_pool_lookup(&opctx, name_or_id)
103-
.map_err(ActionError::action_failed)?
104-
.lookup_for(authz::Action::CreateChild)
105-
.await
106-
.map_err(ActionError::action_failed)?
107-
.0,
108-
)
98+
let authz_pool = if let Some(name_or_id) = pool {
99+
let (authz_pool, _db_pool) = osagactx
100+
.nexus()
101+
.ip_pool_lookup(&opctx, name_or_id)
102+
.map_err(ActionError::action_failed)?
103+
.fetch_for(authz::Action::CreateChild)
104+
.await
105+
.map_err(ActionError::action_failed)?;
106+
Some(authz_pool)
109107
} else {
110108
None
111109
};
@@ -115,7 +113,7 @@ async fn siia_begin_attach_ip(
115113
&opctx,
116114
Uuid::new_v4(),
117115
instance_id,
118-
pool,
116+
authz_pool,
119117
ip_version.map(Into::into),
120118
false,
121119
)

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)