Skip to content

Commit d6dfbb0

Browse files
authored
Respect IP Pool when allocating ephemeral addresses for guests (#1496)
* Respect IP Pool when allocating ephemeral addresses for guests - Use IP Pool name provided by clients in the actual allocation query and calls using it - Add test verifying restriction of IPs to the requested pool, when an address from another pool would normally be preferred. * Review feedback - Restrict external IP query to projects, even if pool ID is specified - Lookup IP Pool ID by name, restricting to those which are valid for the project (unreserved, or reserved for the project). - Add test ensuring we don't start allocating from a different pool, if the one we request an address from has been exhausted.
1 parent fb0db35 commit d6dfbb0

File tree

2 files changed

+198
-13
lines changed

2 files changed

+198
-13
lines changed

nexus/src/db/datastore/instance_external_ip.rs

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ use crate::db::error::ErrorHandler;
1212
use crate::db::model::IncompleteInstanceExternalIp;
1313
use crate::db::model::InstanceExternalIp;
1414
use crate::db::model::IpKind;
15+
use crate::db::model::IpPool;
1516
use crate::db::model::Name;
1617
use crate::db::queries::external_ip::NextExternalIp;
1718
use crate::db::update_and_check::UpdateAndCheck;
@@ -22,6 +23,8 @@ use diesel::prelude::*;
2223
use omicron_common::api::external::CreateResult;
2324
use omicron_common::api::external::Error;
2425
use omicron_common::api::external::LookupResult;
26+
use omicron_common::api::external::LookupType;
27+
use omicron_common::api::external::ResourceType;
2528
use uuid::Uuid;
2629

2730
impl DataStore {
@@ -50,13 +53,50 @@ impl DataStore {
5053
ip_id: Uuid,
5154
project_id: Uuid,
5255
instance_id: Uuid,
53-
_pool_name: Option<Name>,
56+
pool_name: Option<Name>,
5457
) -> CreateResult<InstanceExternalIp> {
58+
let pool_id = if let Some(ref name) = pool_name {
59+
// We'd like to add authz checks here, and use the `LookupPath`
60+
// methods on the project-scoped view of this resource. It's not
61+
// entirely clear how that'll work in the API, so see RFD 288 and
62+
// https://github.com/oxidecomputer/omicron/issues/1470 for more
63+
// details.
64+
//
65+
// For now, we just ensure that the pool is either unreserved, or
66+
// reserved for the instance's project.
67+
use db::schema::ip_pool::dsl;
68+
Some(
69+
dsl::ip_pool
70+
.filter(dsl::name.eq(name.clone()))
71+
.filter(dsl::time_deleted.is_null())
72+
.filter(
73+
dsl::project_id
74+
.is_null()
75+
.or(dsl::project_id.eq(Some(project_id))),
76+
)
77+
.select(IpPool::as_select())
78+
.first_async::<IpPool>(self.pool_authorized(opctx).await?)
79+
.await
80+
.map_err(|e| {
81+
public_error_from_diesel_pool(
82+
e,
83+
ErrorHandler::NotFoundByLookup(
84+
ResourceType::IpPool,
85+
LookupType::ByName(name.to_string()),
86+
),
87+
)
88+
})?
89+
.identity
90+
.id,
91+
)
92+
} else {
93+
None
94+
};
5595
let data = IncompleteInstanceExternalIp::for_ephemeral(
5696
ip_id,
5797
project_id,
5898
instance_id,
59-
/* pool_id = */ None,
99+
pool_id,
60100
);
61101
self.allocate_instance_external_ip(opctx, data).await
62102
}

nexus/src/db/queries/external_ip.rs

Lines changed: 156 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -98,8 +98,7 @@ const MAX_PORT: i32 = u16::MAX as _;
9898
/// FROM
9999
/// ip_pool_range
100100
/// WHERE
101-
/// project_id = <project_id> OR
102-
/// project_id IS NULL AND
101+
/// <pool restriction clause> AND
103102
/// time_deleted IS NULL
104103
/// )
105104
/// CROSS JOIN
@@ -183,9 +182,36 @@ const MAX_PORT: i32 = u16::MAX as _;
183182
/// (even though we send the start/stop to the database as `i32`s). We need to
184183
/// cast it on the way out of the database, so that Diesel can correctly
185184
/// deserialize it into an `i32`.
185+
///
186+
/// Pool restriction
187+
/// ----------------
188+
///
189+
/// Clients may optionally request an external address from a specific IP Pool.
190+
/// If they don't provide a pool, the query is restricted to all IP Pools
191+
/// available to their project (not reserved for a different project). In that
192+
/// case, the pool restriction looks like:
193+
///
194+
/// ```sql
195+
/// (project_id = <project_id> OR project_id IS NULL)
196+
/// ```
197+
///
198+
/// That is, we search for all pools that are unreserved for reserved for _this
199+
/// instance's_ project.
200+
///
201+
/// If the client does provide a pool, an additional filtering clause is added,
202+
/// so that the whole filter looks like:
203+
///
204+
/// ```sql
205+
/// pool_id = <pool_id> AND
206+
/// (project_id = <project_id> OR project_id IS NULL)
207+
/// ```
208+
///
209+
/// That filter on `project_id` is a bit redundant, as the caller should be
210+
/// looking up the pool's ID by name, among those available to the instance's
211+
/// project. However, it helps provides safety in the case that the caller
212+
/// violates that expectation.
186213
#[derive(Debug, Clone)]
187214
pub struct NextExternalIp {
188-
// TODO-completeness: Add `ip_pool_id`, and restrict search to it.
189215
ip: IncompleteInstanceExternalIp,
190216
// Number of ports reserved per IP address. Only applicable if the IP kind
191217
// is snat.
@@ -390,7 +416,7 @@ impl NextExternalIp {
390416
// FROM
391417
// ip_pool_range
392418
// WHERE
393-
// (project_id = <project_id> OR project_id IS NULL) AND
419+
// <pool_restriction> AND
394420
// time_deleted IS NULL
395421
// ```
396422
fn push_address_sequence_subquery<'a>(
@@ -410,13 +436,21 @@ impl NextExternalIp {
410436
out.push_identifier(dsl::first_address::NAME)?;
411437
out.push_sql(") AS candidate_ip FROM ");
412438
IP_POOL_RANGE_FROM_CLAUSE.walk_ast(out.reborrow())?;
413-
out.push_sql(" WHERE (");
414-
out.push_identifier(dsl::project_id::NAME)?;
415-
out.push_sql(" = ");
416-
out.push_bind_param::<sql_types::Uuid, Uuid>(self.ip.project_id())?;
417-
out.push_sql(" OR ");
418-
out.push_identifier(dsl::project_id::NAME)?;
419-
out.push_sql(" IS NULL) AND ");
439+
out.push_sql(" WHERE ");
440+
if let Some(ref pool_id) = self.ip.pool_id() {
441+
out.push_identifier(dsl::ip_pool_id::NAME)?;
442+
out.push_sql(" = ");
443+
out.push_bind_param::<sql_types::Uuid, Uuid>(pool_id)?;
444+
} else {
445+
out.push_sql("(");
446+
out.push_identifier(dsl::project_id::NAME)?;
447+
out.push_sql(" = ");
448+
out.push_bind_param::<sql_types::Uuid, Uuid>(self.ip.project_id())?;
449+
out.push_sql(" OR ");
450+
out.push_identifier(dsl::project_id::NAME)?;
451+
out.push_sql(" IS NULL)");
452+
}
453+
out.push_sql(" AND ");
420454
out.push_identifier(dsl::time_deleted::NAME)?;
421455
out.push_sql(" IS NULL");
422456
Ok(())
@@ -581,6 +615,7 @@ mod tests {
581615
use crate::db::model::IpKind;
582616
use crate::db::model::IpPool;
583617
use crate::db::model::IpPoolRange;
618+
use crate::db::model::Name;
584619
use crate::external_api::shared::IpRange;
585620
use async_bb8_diesel::AsyncRunQueryDsl;
586621
use dropshot::test_util::LogContext;
@@ -589,6 +624,7 @@ mod tests {
589624
use omicron_common::api::external::IdentityMetadataCreateParams;
590625
use omicron_test_utils::dev;
591626
use omicron_test_utils::dev::db::CockroachInstance;
627+
use std::net::IpAddr;
592628
use std::net::Ipv4Addr;
593629
use std::sync::Arc;
594630
use uuid::Uuid;
@@ -999,4 +1035,113 @@ mod tests {
9991035

10001036
context.success().await;
10011037
}
1038+
1039+
#[tokio::test]
1040+
async fn test_next_external_ip_is_restricted_to_pools() {
1041+
let context =
1042+
TestContext::new("test_next_external_ip_is_restricted_to_pools")
1043+
.await;
1044+
1045+
// Create two pools, neither project-restricted.
1046+
let first_range = IpRange::try_from((
1047+
Ipv4Addr::new(10, 0, 0, 1),
1048+
Ipv4Addr::new(10, 0, 0, 3),
1049+
))
1050+
.unwrap();
1051+
context.create_ip_pool("p0", first_range, None).await;
1052+
let second_range = IpRange::try_from((
1053+
Ipv4Addr::new(10, 0, 0, 4),
1054+
Ipv4Addr::new(10, 0, 0, 6),
1055+
))
1056+
.unwrap();
1057+
context.create_ip_pool("p1", second_range, None).await;
1058+
1059+
// Allocating an address on an instance in the second pool should be
1060+
// respected, even though there are IPs available in the first.
1061+
let instance_id = Uuid::new_v4();
1062+
let project_id = Uuid::new_v4();
1063+
let id = Uuid::new_v4();
1064+
let pool_name = Some(Name("p1".parse().unwrap()));
1065+
1066+
let ip = context
1067+
.db_datastore
1068+
.allocate_instance_ephemeral_ip(
1069+
&context.opctx,
1070+
id,
1071+
project_id,
1072+
instance_id,
1073+
pool_name,
1074+
)
1075+
.await
1076+
.expect("Failed to allocate instance ephemeral IP address");
1077+
assert_eq!(ip.kind, IpKind::Ephemeral);
1078+
assert_eq!(ip.ip.ip(), second_range.first_address());
1079+
assert_eq!(ip.first_port.0, 0);
1080+
assert_eq!(ip.last_port.0, u16::MAX);
1081+
assert_eq!(ip.project_id, project_id);
1082+
1083+
context.success().await;
1084+
}
1085+
1086+
#[tokio::test]
1087+
async fn test_ensure_pool_exhaustion_does_not_use_other_pool() {
1088+
let context = TestContext::new(
1089+
"test_ensure_pool_exhaustion_does_not_use_other_pool",
1090+
)
1091+
.await;
1092+
1093+
// Create two pools, neither project-restricted.
1094+
let first_range = IpRange::try_from((
1095+
Ipv4Addr::new(10, 0, 0, 1),
1096+
Ipv4Addr::new(10, 0, 0, 3),
1097+
))
1098+
.unwrap();
1099+
context.create_ip_pool("p0", first_range, None).await;
1100+
let first_address = Ipv4Addr::new(10, 0, 0, 4);
1101+
let last_address = Ipv4Addr::new(10, 0, 0, 6);
1102+
let second_range =
1103+
IpRange::try_from((first_address, last_address)).unwrap();
1104+
context.create_ip_pool("p1", second_range, None).await;
1105+
1106+
// Allocate all available addresses in the second pool.
1107+
let instance_id = Uuid::new_v4();
1108+
let project_id = Uuid::new_v4();
1109+
let pool_name = Some(Name("p1".parse().unwrap()));
1110+
let first_octet = first_address.octets()[3];
1111+
let last_octet = last_address.octets()[3];
1112+
for octet in first_octet..=last_octet {
1113+
let ip = context
1114+
.db_datastore
1115+
.allocate_instance_ephemeral_ip(
1116+
&context.opctx,
1117+
Uuid::new_v4(),
1118+
project_id,
1119+
instance_id,
1120+
pool_name.clone(),
1121+
)
1122+
.await
1123+
.expect("Failed to allocate instance ephemeral IP address");
1124+
println!("{ip:#?}");
1125+
if let IpAddr::V4(addr) = ip.ip.ip() {
1126+
assert_eq!(addr.octets()[3], octet);
1127+
} else {
1128+
panic!("Expected an IPv4 address");
1129+
}
1130+
}
1131+
1132+
// Allocating another address should _fail_, and not use the first pool.
1133+
context
1134+
.db_datastore
1135+
.allocate_instance_ephemeral_ip(
1136+
&context.opctx,
1137+
Uuid::new_v4(),
1138+
project_id,
1139+
instance_id,
1140+
pool_name,
1141+
)
1142+
.await
1143+
.expect_err("Should not use IP addresses from a different pool");
1144+
1145+
context.success().await;
1146+
}
10021147
}

0 commit comments

Comments
 (0)