Skip to content

Commit 3be1d57

Browse files
authored
Report IP Pool utilization as capacity and remaining (#8928)
- Remove IP version-specific utilization types. All pools are only of one version, so we can use the same types for both. - Report IP Pool utilization through the API as a floating-point capacity and count of _remaining_ addresses, rather than count of allocated. This avoids dealing with enormous bit-width numbers like a u128. The cost is reduced precision when either the capacity or remaining is > 2**53, but in that case, the caller almost certainly doesn't care about that. As the remaining number of addresses is smaller, they get perfect precision. - Fixes #8888 - Fixes #5347
1 parent f7147e5 commit 3be1d57

File tree

9 files changed

+229
-358
lines changed

9 files changed

+229
-358
lines changed

nexus/db-model/src/utilization.rs

Lines changed: 3 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -57,39 +57,15 @@ impl From<SiloUtilization> for views::Utilization {
5757
}
5858
}
5959

60-
#[derive(Debug, Clone, Serialize, Deserialize)]
61-
pub struct Ipv4Utilization {
62-
pub allocated: u32,
63-
pub capacity: u32,
64-
}
65-
66-
#[derive(Debug, Clone, Serialize, Deserialize)]
67-
pub struct Ipv6Utilization {
68-
pub allocated: u128,
69-
pub capacity: u128,
70-
}
71-
7260
// Not really a DB model, just the result of a datastore function
7361
#[derive(Debug, Clone, Serialize, Deserialize)]
7462
pub struct IpPoolUtilization {
75-
pub ipv4: Ipv4Utilization,
76-
pub ipv6: Ipv6Utilization,
77-
}
78-
79-
impl From<Ipv4Utilization> for views::Ipv4Utilization {
80-
fn from(util: Ipv4Utilization) -> Self {
81-
Self { allocated: util.allocated, capacity: util.capacity }
82-
}
83-
}
84-
85-
impl From<Ipv6Utilization> for views::Ipv6Utilization {
86-
fn from(util: Ipv6Utilization) -> Self {
87-
Self { allocated: util.allocated, capacity: util.capacity }
88-
}
63+
pub remaining: f64,
64+
pub capacity: f64,
8965
}
9066

9167
impl From<IpPoolUtilization> for views::IpPoolUtilization {
9268
fn from(util: IpPoolUtilization) -> Self {
93-
Self { ipv4: util.ipv4.into(), ipv6: util.ipv6.into() }
69+
Self { remaining: util.remaining, capacity: util.capacity }
9470
}
9571
}

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

Lines changed: 99 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -57,16 +57,6 @@ use omicron_common::api::external::http_pagination::PaginatedBy;
5757
use ref_cast::RefCast;
5858
use uuid::Uuid;
5959

60-
pub struct IpsAllocated {
61-
pub ipv4: i64,
62-
pub ipv6: i64,
63-
}
64-
65-
pub struct IpsCapacity {
66-
pub ipv4: u32,
67-
pub ipv6: u128,
68-
}
69-
7060
/// Helper type with both an authz IP Pool and the actual DB record.
7161
#[derive(Debug, Clone)]
7262
pub struct ServiceIpPool {
@@ -401,53 +391,100 @@ impl DataStore {
401391
})
402392
}
403393

404-
pub async fn ip_pool_allocated_count(
394+
/// Return the number of IPs allocated from and the capacity of the provided
395+
/// IP Pool.
396+
pub async fn ip_pool_utilization(
405397
&self,
406398
opctx: &OpContext,
407399
authz_pool: &authz::IpPool,
408-
) -> Result<IpsAllocated, Error> {
400+
) -> Result<(i64, u128), Error> {
409401
opctx.authorize(authz::Action::Read, authz_pool).await?;
402+
opctx.authorize(authz::Action::ListChildren, authz_pool).await?;
403+
let conn = self.pool_connection_authorized(opctx).await?;
404+
let (allocated, ranges) = self
405+
.transaction_retry_wrapper("ip_pool_utilization")
406+
.transaction(&conn, |conn| async move {
407+
let allocated = self
408+
.ip_pool_allocated_count_on_connection(&conn, authz_pool)
409+
.await?;
410+
let ranges = self
411+
.ip_pool_list_ranges_batched_on_connection(
412+
&conn, authz_pool,
413+
)
414+
.await?;
415+
Ok((allocated, ranges))
416+
})
417+
.await
418+
.map_err(|e| match &e {
419+
DieselError::NotFound => public_error_from_diesel(
420+
e,
421+
ErrorHandler::NotFoundByResource(authz_pool),
422+
),
423+
_ => public_error_from_diesel(e, ErrorHandler::Server),
424+
})?;
425+
let capacity = Self::accumulate_ip_range_sizes(ranges)?;
426+
Ok((allocated, capacity))
427+
}
410428

411-
use diesel::dsl::sql;
412-
use diesel::sql_types::BigInt;
413-
use nexus_db_schema::schema::external_ip;
429+
/// Return the total number of IPs allocated from the provided pool.
430+
#[cfg(test)]
431+
async fn ip_pool_allocated_count(
432+
&self,
433+
opctx: &OpContext,
434+
authz_pool: &authz::IpPool,
435+
) -> Result<i64, Error> {
436+
opctx.authorize(authz::Action::Read, authz_pool).await?;
437+
let conn = self.pool_connection_authorized(opctx).await?;
438+
self.ip_pool_allocated_count_on_connection(&conn, authz_pool)
439+
.await
440+
.map_err(|e| public_error_from_diesel(e, ErrorHandler::Server))
441+
}
414442

415-
let (ipv4, ipv6) = external_ip::table
443+
async fn ip_pool_allocated_count_on_connection(
444+
&self,
445+
conn: &async_bb8_diesel::Connection<DbConnection>,
446+
authz_pool: &authz::IpPool,
447+
) -> Result<i64, DieselError> {
448+
use nexus_db_schema::schema::external_ip;
449+
external_ip::table
416450
.filter(external_ip::ip_pool_id.eq(authz_pool.id()))
417451
.filter(external_ip::time_deleted.is_null())
418-
// need to do distinct IP because SNAT IPs are shared between
419-
// multiple instances, and each gets its own row in the table
420-
.select((
421-
sql::<BigInt>(
422-
"count(distinct ip) FILTER (WHERE family(ip) = 4)",
423-
),
424-
sql::<BigInt>(
425-
"count(distinct ip) FILTER (WHERE family(ip) = 6)",
426-
),
427-
))
428-
.first_async::<(i64, i64)>(
429-
&*self.pool_connection_authorized(opctx).await?,
430-
)
452+
.select(diesel::dsl::count_distinct(external_ip::ip))
453+
.first_async::<i64>(conn)
431454
.await
432-
.map_err(|e| public_error_from_diesel(e, ErrorHandler::Server))?;
433-
434-
Ok(IpsAllocated { ipv4, ipv6 })
435455
}
436456

437-
pub async fn ip_pool_total_capacity(
457+
/// Return the total capacity of the provided pool.
458+
#[cfg(test)]
459+
async fn ip_pool_total_capacity(
438460
&self,
439461
opctx: &OpContext,
440462
authz_pool: &authz::IpPool,
441-
) -> Result<IpsCapacity, Error> {
463+
) -> Result<u128, Error> {
442464
opctx.authorize(authz::Action::Read, authz_pool).await?;
443465
opctx.authorize(authz::Action::ListChildren, authz_pool).await?;
466+
let conn = self.pool_connection_authorized(opctx).await?;
467+
self.ip_pool_list_ranges_batched_on_connection(&conn, authz_pool)
468+
.await
469+
.map_err(|e| {
470+
public_error_from_diesel(
471+
e,
472+
ErrorHandler::NotFoundByResource(authz_pool),
473+
)
474+
})
475+
.and_then(Self::accumulate_ip_range_sizes)
476+
}
444477

478+
async fn ip_pool_list_ranges_batched_on_connection(
479+
&self,
480+
conn: &async_bb8_diesel::Connection<DbConnection>,
481+
authz_pool: &authz::IpPool,
482+
) -> Result<Vec<(IpNetwork, IpNetwork)>, DieselError> {
445483
use nexus_db_schema::schema::ip_pool_range;
446-
447-
let ranges = ip_pool_range::table
484+
ip_pool_range::table
448485
.filter(ip_pool_range::ip_pool_id.eq(authz_pool.id()))
449486
.filter(ip_pool_range::time_deleted.is_null())
450-
.select(IpPoolRange::as_select())
487+
.select((ip_pool_range::first_address, ip_pool_range::last_address))
451488
// This is a rare unpaginated DB query, which means we are
452489
// vulnerable to a resource exhaustion attack in which someone
453490
// creates a very large number of ranges in order to make this
@@ -457,28 +494,25 @@ impl DataStore {
457494
// than 10,000 ranges in a pool, we will undercount, but I have a
458495
// hard time seeing that as a practical problem.
459496
.limit(10000)
460-
.get_results_async::<IpPoolRange>(
461-
&*self.pool_connection_authorized(opctx).await?,
462-
)
497+
.get_results_async::<(IpNetwork, IpNetwork)>(conn)
463498
.await
464-
.map_err(|e| {
465-
public_error_from_diesel(
466-
e,
467-
ErrorHandler::NotFoundByResource(authz_pool),
468-
)
469-
})?;
470-
471-
let mut ipv4: u32 = 0;
472-
let mut ipv6: u128 = 0;
499+
}
473500

474-
for range in &ranges {
475-
let r = IpRange::from(range);
501+
fn accumulate_ip_range_sizes(
502+
ranges: Vec<(IpNetwork, IpNetwork)>,
503+
) -> Result<u128, Error> {
504+
let mut count: u128 = 0;
505+
for range in ranges.into_iter() {
506+
let first = range.0.ip();
507+
let last = range.1.ip();
508+
let r = IpRange::try_from((first, last))
509+
.map_err(|e| Error::internal_error(e.as_str()))?;
476510
match r {
477-
IpRange::V4(r) => ipv4 += r.len(),
478-
IpRange::V6(r) => ipv6 += r.len(),
511+
IpRange::V4(r) => count += u128::from(r.len()),
512+
IpRange::V6(r) => count += r.len(),
479513
}
480514
}
481-
Ok(IpsCapacity { ipv4, ipv6 })
515+
Ok(count)
482516
}
483517

484518
pub async fn ip_pool_silo_list(
@@ -1523,8 +1557,7 @@ mod test {
15231557
.ip_pool_total_capacity(&opctx, &authz_pool)
15241558
.await
15251559
.unwrap();
1526-
assert_eq!(max_ips.ipv4, 0);
1527-
assert_eq!(max_ips.ipv6, 0);
1560+
assert_eq!(max_ips, 0);
15281561

15291562
let range = IpRange::V4(
15301563
Ipv4Range::new(
@@ -1543,8 +1576,7 @@ mod test {
15431576
.ip_pool_total_capacity(&opctx, &authz_pool)
15441577
.await
15451578
.unwrap();
1546-
assert_eq!(max_ips.ipv4, 5);
1547-
assert_eq!(max_ips.ipv6, 0);
1579+
assert_eq!(max_ips, 5);
15481580

15491581
let link = IpPoolResource {
15501582
ip_pool_id: pool.id(),
@@ -1561,8 +1593,7 @@ mod test {
15611593
.ip_pool_allocated_count(&opctx, &authz_pool)
15621594
.await
15631595
.unwrap();
1564-
assert_eq!(ip_count.ipv4, 0);
1565-
assert_eq!(ip_count.ipv6, 0);
1596+
assert_eq!(ip_count, 0);
15661597

15671598
let identity = IdentityMetadataCreateParams {
15681599
name: "my-ip".parse().unwrap(),
@@ -1578,16 +1609,14 @@ mod test {
15781609
.ip_pool_allocated_count(&opctx, &authz_pool)
15791610
.await
15801611
.unwrap();
1581-
assert_eq!(ip_count.ipv4, 1);
1582-
assert_eq!(ip_count.ipv6, 0);
1612+
assert_eq!(ip_count, 1);
15831613

15841614
// allocating one has nothing to do with total capacity
15851615
let max_ips = datastore
15861616
.ip_pool_total_capacity(&opctx, &authz_pool)
15871617
.await
15881618
.unwrap();
1589-
assert_eq!(max_ips.ipv4, 5);
1590-
assert_eq!(max_ips.ipv6, 0);
1619+
assert_eq!(max_ips, 5);
15911620

15921621
db.terminate().await;
15931622
logctx.cleanup_successful();
@@ -1642,8 +1671,7 @@ mod test {
16421671
.ip_pool_total_capacity(&opctx, &authz_pool)
16431672
.await
16441673
.unwrap();
1645-
assert_eq!(max_ips.ipv4, 0);
1646-
assert_eq!(max_ips.ipv6, 0);
1674+
assert_eq!(max_ips, 0);
16471675

16481676
// Add an IPv6 range
16491677
let ipv6_range = IpRange::V6(
@@ -1661,15 +1689,13 @@ mod test {
16611689
.ip_pool_total_capacity(&opctx, &authz_pool)
16621690
.await
16631691
.unwrap();
1664-
assert_eq!(max_ips.ipv4, 0);
1665-
assert_eq!(max_ips.ipv6, 11 + 65536);
1692+
assert_eq!(max_ips, 11 + 65536);
16661693

16671694
let ip_count = datastore
16681695
.ip_pool_allocated_count(&opctx, &authz_pool)
16691696
.await
16701697
.unwrap();
1671-
assert_eq!(ip_count.ipv4, 0);
1672-
assert_eq!(ip_count.ipv6, 0);
1698+
assert_eq!(ip_count, 0);
16731699

16741700
let identity = IdentityMetadataCreateParams {
16751701
name: "my-ip".parse().unwrap(),
@@ -1685,16 +1711,14 @@ mod test {
16851711
.ip_pool_allocated_count(&opctx, &authz_pool)
16861712
.await
16871713
.unwrap();
1688-
assert_eq!(ip_count.ipv4, 0);
1689-
assert_eq!(ip_count.ipv6, 1);
1714+
assert_eq!(ip_count, 1);
16901715

16911716
// allocating one has nothing to do with total capacity
16921717
let max_ips = datastore
16931718
.ip_pool_total_capacity(&opctx, &authz_pool)
16941719
.await
16951720
.unwrap();
1696-
assert_eq!(max_ips.ipv4, 0);
1697-
assert_eq!(max_ips.ipv6, 11 + 65536);
1721+
assert_eq!(max_ips, 11 + 65536);
16981722

16991723
// add a giant range for fun
17001724
let ipv6_range = IpRange::V6(
@@ -1715,8 +1739,7 @@ mod test {
17151739
.ip_pool_total_capacity(&opctx, &authz_pool)
17161740
.await
17171741
.unwrap();
1718-
assert_eq!(max_ips.ipv4, 0);
1719-
assert_eq!(max_ips.ipv6, 1208925819614629174706166);
1742+
assert_eq!(max_ips, 1208925819614629174706166);
17201743

17211744
db.terminate().await;
17221745
logctx.cleanup_successful();

0 commit comments

Comments
 (0)